Backend-Arbeitsstand: ERP-Sync, Lieferlebenszyklus, Reports + config.toml

Bringt das Backend vom initialen Skeleton auf den aktuellen Arbeitsstand
(Clean Architecture: domain → application → infrastructure → api).

Wesentliche Bereiche:
- ERP-Anbindung (MSSQL-Pull der Touren, Import-Scheduler, Rückschreiben)
- Lieferlebenszyklus: Scan/Hold/Cancel/Complete, Gutschriften, Notizen,
  Bild-Anhänge, Unterschriften, PDF-Lieferreport → DOCUframe
- Stammdaten: Kunden, Artikel, Lager, Zahlungsarten, Services
- Keycloak-JWT-Gate + Fahrer-Provisionierung via Admin-API
- Admin-API-Key-Gate (X-Admin-Api-Key) für Maschinen-Endpunkte

Jüngste Änderungen dieser Session:
- Belegspezifische Kontaktdaten: alle ERP-Adressen (Beleg-/Liefer-/
  Rechnungsadresse, Ansprechpartner, Kundenstamm) mit Telefon/Mobil/
  E-Mail werden gesynct (Migration 0029, MSSQL-Query, TourDetails)
- Konfiguration von .env (envy/dotenvy) auf config.toml (toml/serde)
  umgestellt; Vorlage config.example.toml, Pfad via HOLZLEITNER_CONFIG

Nicht im Repo (per .gitignore): config.toml (Secrets), data/ (Laufzeit-/
Kundendaten), demo.mp4, .claude/, variocontrol-ai/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-06-01 17:52:58 +02:00
parent 438040acce
commit 6a9b5872e1
137 changed files with 13700 additions and 218 deletions

View File

@ -1,8 +1,8 @@
//! Holzleitner-API — HTTP-Layer und Composition Root.
//!
//! Bootstrap-Reihenfolge:
//! 1. Tracing/Logging initialisieren
//! 2. Konfiguration aus Env-Variablen / `.env` laden
//! 1. Konfiguration aus `config.toml` laden (liefert u. a. den Log-Filter)
//! 2. Tracing/Logging initialisieren
//! 3. Postgres-Pool aufbauen und Migrations ausführen
//! 4. Keycloak-AuthService instanziieren
//! 5. Use Cases zusammenstellen und in `AppState` packen
@ -24,36 +24,73 @@ use anyhow::Context;
use axum::Router;
use axum::middleware::from_fn_with_state;
use holzleitner_application::usecases::{
ApplyDeliveryActionUseCase, ApplyScansUseCase, AssignCarToDeliveryUseCase,
CreateDeliveryNoteUseCase, CreateMyCarUseCase, GetAccountUseCase, GetTourUseCase,
ListMyCarsUseCase, ListMyToursTodayUseCase, SetDeliveryOrderUseCase, SyncTourUseCase,
UpdateMyCarUseCase,
ApplyDeliveryActionUseCase, ApplyDeliveryCreditEventUseCase, ApplyScansUseCase,
AssignCarToDeliveryUseCase, CompleteDeliveryUseCase, CreateDeliveryNoteUseCase,
CreateMyCarUseCase, CreatePaymentMethodUseCase, CreateServiceUseCase,
DeleteDeliveryNoteUseCase, DeleteDeliveryServiceUseCase, DeletePaymentMethodUseCase,
DeleteServiceUseCase, DevResyncToursUseCase, GenerateDeliveryReportUseCase, GetAccountUseCase,
GetAttachmentPreviewUseCase, GetTourUseCase,
ImportErpToursUseCase, ListDeliveredBelegnummernUseCase, ListMyCarsUseCase,
ListMyToursTodayUseCase, ListPaymentMethodsUseCase,
ListServicesUseCase, MarkMailSentUseCase, PushCompletionToErpUseCase,
SetDeliveryOrderUseCase, SetDeliveryServiceUseCase, SyncTourUseCase, UpdateDeliveryNoteUseCase,
ProcessDeliveryReportUseCase, UpdateMyCarUseCase, UpdatePaymentMethodUseCase,
UpdateServiceUseCase, UploadDeliveryNoteImageUseCase,
};
use holzleitner_application::ports::DeliveryReportJobRepository;
use holzleitner_infrastructure::auth::{
KeycloakAdapterConfig, KeycloakAdminClient, KeycloakAdminConfig, KeycloakAuthService,
};
use holzleitner_infrastructure::erp::{
MssqlErpConfig, MssqlErpDeliverySource, MssqlErpDeliveryWriteback,
};
use holzleitner_infrastructure::gsd::{GsdConfig, GsdService};
use holzleitner_infrastructure::report::{
LocalReportSink, PdfDeliveryReportRenderer, PgDeliveryReportRepository,
};
use holzleitner_infrastructure::auth::{KeycloakAdapterConfig, KeycloakAuthService};
use holzleitner_infrastructure::persistence::{
PgAccountRepository, PgCarRepository, PgDeliveryNoteRepository, PgDeliveryRepository,
PgScanRepository, PgTourRepository, PoolConfig, connect_and_migrate,
PgAccountRepository, PgAttachmentRepository, PgCarRepository, PgDeliveryCompletionRepository,
PgDeliveryCreditRepository, PgDeliveryNoteRepository, PgDeliveryReportJobRepository,
PgDeliveryRepository, PgDeliveryServiceRepository, PgPaymentMethodRepository, PgScanRepository,
PgServiceRepository, PgTourRepository, PoolConfig, connect_and_migrate,
};
use holzleitner_infrastructure::storage::{LocalAttachmentStorage, LocalSignatureStorage};
use tokio_cron_scheduler::{Job, JobScheduler};
use tower_http::trace::TraceLayer;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
use crate::middleware::jwt_middleware;
use crate::middleware::{admin_api_key_middleware, jwt_middleware};
use crate::openapi::ApiDoc;
use crate::state::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Config ZUERST laden — der Log-Filter steht jetzt in `config.toml`
// (`[logging] filter`), also brauchen wir die Config, bevor der
// Subscriber initialisiert wird. Fehler hier werden von anyhow auch
// ohne aktiven Subscriber sichtbar (stderr).
let cfg = config::load().context("config laden fehlgeschlagen")?;
tracing_subscriber::fmt()
// Auf stderr loggen statt stdout: stderr ist unbuffered und erscheint
// damit sofort — auch wenn die Ausgabe in eine Datei/Pipe/ein
// IDE-Terminal läuft (stdout wäre dort blockgepuffert → Logs würden
// erst verspätet/gar nicht sichtbar).
.with_writer(std::io::stderr)
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
"holzleitner_api=info,holzleitner_infrastructure=info,tower_http=info".into()
}),
// `RUST_LOG`-Env hat Vorrang (Ad-hoc-Debugging ohne Datei-Edit),
// sonst der Filter aus `config.toml` (`[logging] filter`).
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| cfg.logging.filter.clone().into()),
)
.init();
let cfg = config::load().context("config laden fehlgeschlagen")?;
tracing::info!(host = %cfg.server.host, port = cfg.server.port, "starting up");
tracing::info!("starting up");
// Vollständige (secret-maskierte) Konfig-Übersicht beim Start — damit
// Fehlkonfigurationen (z. B. ein aktiver dev.today_override) sofort
// im Log sichtbar sind.
cfg.log_summary();
// --- Persistence ---------------------------------------------------
let pool = connect_and_migrate(&PoolConfig {
@ -69,7 +106,74 @@ async fn main() -> anyhow::Result<()> {
let scan_repository = Arc::new(PgScanRepository::new(pool.clone()));
let delivery_repository = Arc::new(PgDeliveryRepository::new(pool.clone()));
let delivery_note_repository = Arc::new(PgDeliveryNoteRepository::new(pool.clone()));
let car_repository = Arc::new(PgCarRepository::new(pool));
let attachment_repository = Arc::new(PgAttachmentRepository::new(pool.clone()));
let car_repository = Arc::new(PgCarRepository::new(pool.clone()));
let payment_method_repository = Arc::new(PgPaymentMethodRepository::new(pool.clone()));
let delivery_credit_repository = Arc::new(PgDeliveryCreditRepository::new(pool.clone()));
let delivery_completion_repository =
Arc::new(PgDeliveryCompletionRepository::new(pool.clone()));
let service_repository = Arc::new(PgServiceRepository::new(pool.clone()));
let delivery_service_repository = Arc::new(PgDeliveryServiceRepository::new(pool.clone()));
let report_repository = Arc::new(PgDeliveryReportRepository::new(pool.clone()));
let report_job_repository = Arc::new(PgDeliveryReportJobRepository::new(pool.clone()));
// --- Lokaler Unterschriften-Speicher (Dateisystem) -----------------
let signature_storage = Arc::new(
LocalSignatureStorage::new(&cfg.signature.storage_dir)
.context("Signatur-Verzeichnis konnte nicht angelegt werden")?,
);
tracing::info!(dir = %cfg.signature.storage_dir, "signature storage ready");
// --- Lokaler Bild-Speicher (Bild-Notizen, Ordner = Belegnummer) ----
// Ersetzt den DOCUframe-Upload für Bild-Notizen. Der GSD-Service bleibt
// erhalten (s. u.), wird aber nicht mehr für Attachments verdrahtet.
let attachment_storage = Arc::new(
LocalAttachmentStorage::new(&cfg.attachment.storage_dir)
.context("Attachment-Verzeichnis konnte nicht angelegt werden")?,
);
tracing::info!(dir = %cfg.attachment.storage_dir, "attachment storage ready (lokal)");
// --- PDF-Lieferreport (lokaler/temporärer Sink; DOCUframe später) --
let report_sink = Arc::new(
LocalReportSink::new(&cfg.report.storage_dir)
.context("Report-Verzeichnis konnte nicht angelegt werden")?,
);
let generate_delivery_report = Arc::new(GenerateDeliveryReportUseCase::new(
report_repository,
Arc::new(PdfDeliveryReportRenderer),
report_sink.clone(),
signature_storage.clone(),
attachment_storage.clone(),
));
tracing::info!(dir = %cfg.report.storage_dir, "delivery report renderer ready (printpdf, lokal)");
// --- GSD/DOCUframe (Datei-Upload) ----------------------------------
// Bleibt für den Lizenz-Release beim Shutdown konstruiert. Der frühere
// Attachment-Upload/-Download über DOCUframe ist hier bewusst nicht mehr
// verdrahtet (Code in `gsd::GsdService` bleibt für später erhalten).
let gsd_service = Arc::new(GsdService::new(
pool,
GsdConfig {
rest_url: cfg.gsd.rest_url.clone(),
app_key: cfg.gsd.app_key.clone(),
user: cfg.gsd.user.clone(),
password_md5: cfg.gsd.password_md5.clone(),
app_names: cfg.gsd.app_names.clone(),
},
));
// --- Report-Upload-Pipeline (Report → DOCUframe, mit Retry-Job) ----
// Nutzt GsdService als DocuframeReportGateway (Upload + Makro). Räumt
// nach Erfolg lokale Dateien (Report, Unterschriften, Bild-Notizen) auf.
let process_delivery_report = Arc::new(ProcessDeliveryReportUseCase::new(
generate_delivery_report.clone(),
report_job_repository.clone(),
gsd_service.clone(),
attachment_repository.clone(),
attachment_storage.clone(),
signature_storage.clone(),
report_sink.clone(),
));
// --- Auth ----------------------------------------------------------
let auth_service = Arc::new(KeycloakAuthService::new(KeycloakAdapterConfig {
@ -86,8 +190,74 @@ async fn main() -> anyhow::Result<()> {
// --- Use Cases -----------------------------------------------------
let get_account = Arc::new(GetAccountUseCase::new(account_repository));
let get_tour = Arc::new(GetTourUseCase::new(tour_repository.clone()));
let list_my_tours_today = Arc::new(ListMyToursTodayUseCase::new(tour_repository.clone()));
let list_my_tours_today = Arc::new(ListMyToursTodayUseCase::new(
tour_repository.clone(),
cfg.dev.today_override,
));
let sync_tour = Arc::new(SyncTourUseCase::new(tour_repository.clone()));
// ERP-Import (täglicher Pull aus ERPframe-MSSQL) — nutzt denselben
// Sync-Pfad wie der HTTP-Endpoint. Geht NICHT in den AppState (kein
// HTTP-Endpoint), sondern nur in den Scheduler.
let erp_mssql_config = MssqlErpConfig {
host: cfg.erp.host.clone(),
port: cfg.erp.port,
database: cfg.erp.database.clone(),
user: cfg.erp.user.clone(),
password: cfg.erp.password.clone(),
trust_cert: cfg.erp.trust_cert,
};
let erp_source = Arc::new(MssqlErpDeliverySource::new(erp_mssql_config.clone()));
// Optionaler Keycloak-Provisioner: legt beim Sync fehlende Fahrer-Konten
// an (Username = Fahrer-/Account-Nummer, temporäres Passwort). Nur aktiv,
// wenn `KEYCLOAK_PROVISIONING_ENABLED=true`.
let driver_provisioner: Option<
Arc<dyn holzleitner_application::ports::DriverIdentityProvisioner>,
> = if cfg.keycloak.provisioning_enabled {
tracing::info!(
admin_url = %cfg.keycloak.admin_url,
realm = %cfg.keycloak.realm,
role = %cfg.keycloak.driver_role,
"keycloak driver provisioning ENABLED"
);
Some(Arc::new(KeycloakAdminClient::new(KeycloakAdminConfig {
base_url: cfg.keycloak.admin_url.clone(),
realm: cfg.keycloak.realm.clone(),
client_id: cfg.keycloak.provisioner_client_id.clone(),
client_secret: cfg.keycloak.provisioner_client_secret.clone(),
default_password: cfg.keycloak.driver_default_password.clone(),
driver_role: cfg.keycloak.driver_role.clone(),
})))
} else {
tracing::info!("keycloak driver provisioning disabled");
None
};
let import_erp_tours = Arc::new(ImportErpToursUseCase::new(
erp_source,
sync_tour.clone(),
driver_provisioner,
));
// DEV-ONLY: überschreibender Resync (löscht Postgres-Tourdaten + Import).
// Wird immer gebaut, der Endpoint aber nur bei dev.sync_enabled gemountet.
let dev_resync_tours = Arc::new(DevResyncToursUseCase::new(
tour_repository.clone(),
import_erp_tours.clone(),
));
// ERP-Rückschreiben beim Lieferabschluss. Der Push-Use-Case wird IMMER
// gebaut (Admin-Retry-Endpunkt nutzt ihn manuell). Ob der normale
// Abschluss-Pfad automatisch pusht, steuert `ERP_WRITEBACK_ENABLED`.
let erp_writeback = Arc::new(MssqlErpDeliveryWriteback::new(erp_mssql_config));
let push_completion_to_erp = Arc::new(PushCompletionToErpUseCase::new(
delivery_completion_repository.clone(),
erp_writeback,
));
// Admin-Lese-Use-Case: Belegnummern der an einem Tag ausgelieferten
// Lieferungen. `.clone()` VOR dem späteren Move in `complete_delivery`.
let list_delivered_belegnummern = Arc::new(ListDeliveredBelegnummernUseCase::new(
delivery_completion_repository.clone(),
));
let mark_mail_sent = Arc::new(MarkMailSentUseCase::new(
delivery_completion_repository.clone(),
));
let set_delivery_order = Arc::new(SetDeliveryOrderUseCase::new(tour_repository));
let apply_scans = Arc::new(ApplyScansUseCase::new(
scan_repository,
@ -95,10 +265,39 @@ async fn main() -> anyhow::Result<()> {
));
let apply_delivery_action =
Arc::new(ApplyDeliveryActionUseCase::new(delivery_repository.clone()));
let complete_delivery = Arc::new(CompleteDeliveryUseCase::new(
delivery_completion_repository,
signature_storage,
car_repository.clone(),
if cfg.erp.writeback_enabled {
Some(push_completion_to_erp.clone())
} else {
None
},
));
let apply_delivery_credit_event = Arc::new(ApplyDeliveryCreditEventUseCase::new(
delivery_credit_repository,
car_repository.clone(),
));
let create_delivery_note = Arc::new(CreateDeliveryNoteUseCase::new(
delivery_note_repository.clone(),
car_repository.clone(),
));
let update_delivery_note = Arc::new(UpdateDeliveryNoteUseCase::new(
delivery_note_repository.clone(),
));
let delete_delivery_note =
Arc::new(DeleteDeliveryNoteUseCase::new(delivery_note_repository.clone()));
let upload_delivery_note_image = Arc::new(UploadDeliveryNoteImageUseCase::new(
attachment_storage.clone(),
attachment_repository.clone(),
delivery_note_repository,
car_repository.clone(),
));
let get_attachment_preview = Arc::new(GetAttachmentPreviewUseCase::new(
attachment_repository,
attachment_storage.clone(),
));
let list_my_cars = Arc::new(ListMyCarsUseCase::new(car_repository.clone()));
let create_my_car = Arc::new(CreateMyCarUseCase::new(car_repository.clone()));
let update_my_car = Arc::new(UpdateMyCarUseCase::new(car_repository.clone()));
@ -106,6 +305,28 @@ async fn main() -> anyhow::Result<()> {
car_repository,
delivery_repository,
));
let list_payment_methods = Arc::new(ListPaymentMethodsUseCase::new(
payment_method_repository.clone(),
));
let create_payment_method = Arc::new(CreatePaymentMethodUseCase::new(
payment_method_repository.clone(),
));
let update_payment_method = Arc::new(UpdatePaymentMethodUseCase::new(
payment_method_repository.clone(),
));
let delete_payment_method =
Arc::new(DeletePaymentMethodUseCase::new(payment_method_repository));
let list_services = Arc::new(ListServicesUseCase::new(service_repository.clone()));
let create_service = Arc::new(CreateServiceUseCase::new(service_repository.clone()));
let update_service = Arc::new(UpdateServiceUseCase::new(service_repository.clone()));
let delete_service = Arc::new(DeleteServiceUseCase::new(service_repository.clone()));
let set_delivery_service = Arc::new(SetDeliveryServiceUseCase::new(
service_repository,
delivery_service_repository.clone(),
));
let delete_delivery_service = Arc::new(DeleteDeliveryServiceUseCase::new(
delivery_service_repository,
));
let state = AppState {
get_account,
@ -113,14 +334,39 @@ async fn main() -> anyhow::Result<()> {
list_my_tours_today,
sync_tour,
set_delivery_order,
import_erp_tours: import_erp_tours.clone(),
dev_resync_tours,
generate_delivery_report,
process_delivery_report: process_delivery_report.clone(),
report_upload_enabled: cfg.report.upload_enabled,
apply_scans,
apply_delivery_action,
complete_delivery,
push_completion_to_erp,
list_delivered_belegnummern,
mark_mail_sent,
apply_delivery_credit_event,
create_delivery_note,
update_delivery_note,
delete_delivery_note,
upload_delivery_note_image,
get_attachment_preview,
list_my_cars,
create_my_car,
update_my_car,
assign_car_to_delivery,
list_payment_methods,
create_payment_method,
update_payment_method,
delete_payment_method,
list_services,
create_service,
update_service,
delete_service,
set_delivery_service,
delete_delivery_service,
auth_service,
admin_api_key: cfg.admin.api_key.clone().into(),
};
// --- Router --------------------------------------------------------
@ -130,29 +376,156 @@ async fn main() -> anyhow::Result<()> {
// auf dem protected-Subtree. `route_layer` greift nur für die jetzt
// definierten Routen — neue Routen darunter müssten explizit
// nochmal angehängt werden.
let public = Router::new()
let mut public = Router::new()
.merge(routes::health::router())
.merge(SwaggerUi::new("/swagger-ui").url("/openapi.json", ApiDoc::openapi()));
// DEV-ONLY: ungeschützten Resync-Endpoint NUR bei explizit gesetztem Flag
// mounten — in Produktion existiert er nicht.
if cfg.dev.sync_enabled {
public = public.merge(routes::dev::router());
tracing::warn!("DEV: POST /dev/resync gemountet (unauthentifiziert, dev.sync_enabled=true)");
}
let protected = Router::new()
.merge(routes::accounts::router())
.merge(routes::tours::router())
.merge(routes::scans::router())
.merge(routes::deliveries::router())
.merge(routes::attachments::router())
.merge(routes::cars::router())
.merge(routes::payment_methods::router())
.merge(routes::services::router())
.route_layer(from_fn_with_state(state.clone(), jwt_middleware));
// `/admin`-Routen sind Maschinen-Endpunkte (Cron/ERPframe/Ops) und laufen
// bewusst NICHT über die JWT-Middleware, sondern über ein eigenes
// statisches-API-Key-Gate (Header `X-Admin-Api-Key`). Eigener Subtree mit
// eigener `route_layer`.
let admin = routes::admin::router()
.route_layer(from_fn_with_state(state.clone(), admin_api_key_middleware));
let app = Router::new()
.merge(public)
.merge(protected)
.merge(admin)
.layer(TraceLayer::new_for_http())
.with_state(state);
// --- ERP-Import-Scheduler (optional) -------------------------------
// Läuft im selben Prozess. Nur wenn IMPORT_ENABLED=true. Hält den
// JobScheduler am Leben, indem die Variable bis Programmende gebunden
// bleibt (Drop würde die Jobs stoppen).
let _erp_scheduler = if cfg.import.enabled {
let scheduler = JobScheduler::new()
.await
.context("JobScheduler konnte nicht erstellt werden")?;
let import = import_erp_tours.clone();
let offset = cfg.import.date_offset_days;
let job = Job::new_async(cfg.import.cron.as_str(), move |_uuid, _lock| {
let import = import.clone();
Box::pin(async move {
let date = (chrono::Utc::now() + chrono::Duration::days(offset)).date_naive();
match import.execute(date).await {
Ok(summary) => tracing::info!(
date = %summary.date,
total = summary.tours_total,
ok = summary.tours_ok,
failed = summary.tours_failed,
"erp_import.done"
),
Err(e) => tracing::error!(%date, error = %e, "erp_import.failed"),
}
})
})
.context("ERP-Import-Job konnte nicht erstellt werden")?;
scheduler.add(job).await.context("ERP-Import-Job add fehlgeschlagen")?;
scheduler.start().await.context("Scheduler-Start fehlgeschlagen")?;
tracing::info!(
cron = %cfg.import.cron,
offset_days = cfg.import.date_offset_days,
"erp_import scheduler gestartet"
);
Some(scheduler)
} else {
tracing::info!("erp_import deaktiviert (IMPORT_ENABLED!=true)");
None
};
// --- Report-Retry-Scheduler (offene DOCUframe-Übertragungen) -------
// Greift alle offenen `delivery_report_jobs` periodisch erneut auf
// (Upload/Makro, die zuvor fehlschlugen — z. B. DOCUframe nicht erreichbar).
let _report_scheduler = if cfg.report.upload_enabled {
let scheduler = JobScheduler::new()
.await
.context("Report-JobScheduler konnte nicht erstellt werden")?;
let process = process_delivery_report.clone();
let jobs = report_job_repository.clone();
let job = Job::new_async(cfg.report.retry_cron.as_str(), move |_uuid, _lock| {
let process = process.clone();
let jobs = jobs.clone();
Box::pin(async move {
match jobs.list_open().await {
Ok(open) => {
if !open.is_empty() {
tracing::info!(count = open.len(), "report_retry: offene Jobs werden erneut versucht");
}
for j in open {
if let Err(e) = process.execute(j.delivery_id).await {
tracing::warn!(delivery_id = %j.delivery_id, error = %e, "report_retry: Job weiterhin offen");
}
}
}
Err(e) => tracing::error!(error = %e, "report_retry: offene Jobs konnten nicht geladen werden"),
}
})
})
.context("Report-Retry-Job konnte nicht erstellt werden")?;
scheduler.add(job).await.context("Report-Retry-Job add fehlgeschlagen")?;
scheduler.start().await.context("Report-Scheduler-Start fehlgeschlagen")?;
tracing::info!(cron = %cfg.report.retry_cron, "report_retry scheduler gestartet");
Some(scheduler)
} else {
tracing::info!("report_upload deaktiviert (REPORT_UPLOAD_ENABLED!=true) — Reports nur lokal");
None
};
let addr: SocketAddr = format!("{}:{}", cfg.server.host, cfg.server.port)
.parse()
.with_context(|| format!("ungültige Adresse {}:{}", cfg.server.host, cfg.server.port))?;
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!("server läuft auf http://{}", addr);
axum::serve(listener, app).await?;
// Graceful Shutdown: bei Ctrl-C / SIGTERM die GSD-Lizenz aktiv freigeben,
// damit der Seat nicht bis zum Session-Ablauf geblockt bleibt.
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal(gsd_service))
.await?;
Ok(())
}
/// Wartet auf Ctrl-C bzw. SIGTERM und gibt davor die GSD-Lizenz frei.
async fn shutdown_signal(gsd_service: Arc<GsdService>) {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("Ctrl-C-Handler konnte nicht installiert werden");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("SIGTERM-Handler konnte nicht installiert werden")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
tracing::info!("shutdown signal empfangen — gebe GSD-Lizenz frei");
gsd_service.release_license().await;
}