From 508418b0581349b930315b76e713ee890e70bd65 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 3 Sep 2026 07:41:34 +0200 Subject: [PATCH] =?UTF-8?q?F=C3=BCge=20Fehlerberichterstattung=20f=C3=BCr?= =?UTF-8?q?=20Benachrichtigungen=20=C3=BCber=20Charaktertode=20hinzu=20und?= =?UTF-8?q?=20verhindere=20wiederholte=20Meldungen=20innerhalb=20von=20f?= =?UTF-8?q?=C3=BCnf=20Minuten.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/db/connection.rs | 141 +++++++++++++++++++++++++++++++---- src/worker/events.rs | 24 ++++-- src/worker/sql.rs | 37 +++++++-- src/worker/user_character.rs | 24 ++++-- 4 files changed, 189 insertions(+), 37 deletions(-) diff --git a/src/db/connection.rs b/src/db/connection.rs index ecdddaf..e613b74 100755 --- a/src/db/connection.rs +++ b/src/db/connection.rs @@ -3,8 +3,9 @@ use postgres::types::Type; use postgres::Error as PgError; use std::collections::HashMap; use std::fmt; -use std::sync::{Arc, Condvar, Mutex}; -use std::time::Duration; +use std::panic::Location; +use std::sync::{Arc, Condvar, Mutex, OnceLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; pub type Row = HashMap; pub type Rows = Vec; @@ -55,6 +56,30 @@ struct Database { prepared: HashMap, } +/// Verhindert, dass ein fehlerhafter Sekundentick die Admin-Benachrichtigungen +/// flutet. Derselbe Aufrufer/dasselbe Statement wird höchstens alle fünf Minuten +/// gemeldet; der Fehler bleibt unabhängig davon im Daemon-Log sichtbar. +static LAST_ADMIN_ERROR_AT: OnceLock>> = OnceLock::new(); +const ADMIN_ERROR_COOLDOWN_SECS: u64 = 5 * 60; + +fn should_report_to_admins(key: &str) -> bool { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let reports = LAST_ADMIN_ERROR_AT.get_or_init(|| Mutex::new(HashMap::new())); + let Ok(mut reports) = reports.lock() else { + return false; + }; + match reports.get(key) { + Some(last) if now.saturating_sub(*last) < ADMIN_ERROR_COOLDOWN_SECS => false, + _ => { + reports.insert(key.to_string(), now); + true + } + } +} + impl Database { fn connect(conn_str: &str) -> Result { let client = Client::connect(conn_str, NoTls)?; @@ -71,10 +96,88 @@ impl Database { .unwrap_or(false) } - #[allow(dead_code)] - fn query(&mut self, sql: &str) -> Result { - let rows = self.client.query(sql, &[])?; - Ok(rows.into_iter().map(Self::row_to_map).collect()) + fn report_database_error( + &mut self, + statement: &str, + sql: &str, + error: &DbError, + caller: &'static Location<'static>, + ) { + let key = format!("{}:{}:{statement}", caller.file(), caller.line()); + if !should_report_to_admins(&key) { + return; + } + + const MAX_SQL_LEN: usize = 4_000; + let sql_preview = if sql.len() > MAX_SQL_LEN { + format!("{} …", &sql[..MAX_SQL_LEN]) + } else { + sql.to_string() + }; + let payload = serde_json::json!({ + "event": "daemon_database_error", + "module": caller.file(), + "line": caller.line(), + "statement": statement, + "sql": sql_preview, + "error": error.to_string(), + "deduplication_seconds": ADMIN_ERROR_COOLDOWN_SECS, + }) + .to_string(); + + // `falukant_log.notification.user_id` erwartet die Falukant-User-ID; + // Rechte liegen dagegen beim verknüpften Community-User. + let admin_rows = match self.client.query( + r#" + SELECT DISTINCT fu.id + FROM falukant_data.falukant_user fu + JOIN community.user_right ur ON ur.user_id = fu.user_id + JOIN "type".user_right rt ON rt.id = ur.right_type_id + WHERE LOWER(COALESCE(rt.title, '')) LIKE '%admin%' + "#, + &[], + ) { + Ok(rows) => rows, + Err(report_err) => { + eprintln!( + "[Database] Admin-Benachrichtigung konnte keine Admins laden: {report_err}" + ); + return; + } + }; + + for row in admin_rows { + let Ok(user_id) = row.try_get::<_, i32>(0) else { + continue; + }; + if let Err(report_err) = self.client.execute( + r#" + INSERT INTO falukant_log.notification + (user_id, tr, shown, created_at, updated_at) + VALUES ($1, $2, FALSE, NOW(), NOW()) + "#, + &[&user_id, &payload], + ) { + eprintln!( + "[Database] Admin-Benachrichtigung für user_id={user_id} fehlgeschlagen: {report_err}" + ); + } + } + } + + fn query( + &mut self, + sql: &str, + caller: &'static Location<'static>, + ) -> Result { + match self.client.query(sql, &[]) { + Ok(rows) => Ok(rows.into_iter().map(Self::row_to_map).collect()), + Err(err) => { + let error = DbError::from(err); + self.report_database_error("raw_query", sql, &error, caller); + Err(error) + } + } } fn prepare(&mut self, name: &str, sql: &str) -> Result<(), DbError> { @@ -86,13 +189,15 @@ impl Database { &mut self, name: &str, params: &[&(dyn postgres::types::ToSql + Sync)], + caller: &'static Location<'static>, ) -> Result { let sql = self .prepared .get(name) - .ok_or_else(|| DbError::new(format!("Unbekanntes Statement: {name}")))?; + .ok_or_else(|| DbError::new(format!("Unbekanntes Statement: {name}")))? + .clone(); - match self.client.query(sql.as_str(), params) { + match self.client.query(&sql, params) { Ok(rows) => Ok(rows.into_iter().map(Self::row_to_map).collect()), Err(err) => { if let Some(db_err) = err.as_db_error() { @@ -107,14 +212,18 @@ impl Database { sql_preview.truncate(MAX_SQL_PREVIEW); sql_preview.push_str(" …"); } - Err(DbError::new(format!( + let error = DbError::new(format!( "Postgres-Fehler bei Statement '{name}': {} (SQLSTATE: {}, Detail: {}, Hint: {}) | SQL: {}", message, code, detail, hint, sql_preview - ))) + )); + self.report_database_error(name, &sql, &error, caller); + Err(error) } else { - Err(DbError::new(format!( + let error = DbError::new(format!( "Postgres-Fehler (Client) bei Statement '{name}': {err}" - ))) + )); + self.report_database_error(name, &sql, &error, caller); + Err(error) } } } @@ -253,20 +362,22 @@ pub struct DbConnection { impl DbConnection { #[allow(dead_code)] + #[track_caller] pub fn query(&mut self, sql: &str) -> Result { - self.database_mut().query(sql) + self.database_mut().query(sql, Location::caller()) } pub fn prepare(&mut self, name: &str, sql: &str) -> Result<(), DbError> { self.database_mut().prepare(name, sql) } + #[track_caller] pub fn execute( &mut self, name: &str, params: &[&(dyn postgres::types::ToSql + Sync)], ) -> Result { - self.database_mut().execute(name, params) + self.database_mut().execute(name, params, Location::caller()) } fn database_mut(&mut self) -> &mut Database { @@ -283,5 +394,3 @@ impl Drop for DbConnection { } } } - - diff --git a/src/worker/events.rs b/src/worker/events.rs index 3bddafd..d9dc9ee 100755 --- a/src/worker/events.rs +++ b/src/worker/events.rs @@ -1923,12 +1923,14 @@ impl EventsWorker { let tr = death_ctx.as_ref().map(|c| { death_log::wrap_death_notification(c, "director_death", user_id) }); - Self::notify_user( + if let Err(e) = Self::notify_user( pool, broker, user_id, tr.as_deref().unwrap_or("director_death"), - )?; + ) { + eprintln!("[EventsWorker] director_death notification: {e}"); + } } } @@ -1943,12 +1945,14 @@ impl EventsWorker { let tr = death_ctx.as_ref().map(|c| { death_log::wrap_death_notification(c, "relationship_death", related_user_id) }); - Self::notify_user( + if let Err(e) = Self::notify_user( pool, broker, related_user_id, tr.as_deref().unwrap_or("relationship_death"), - )?; + ) { + eprintln!("[EventsWorker] relationship_death notification: {e}"); + } } } @@ -1981,12 +1985,14 @@ impl EventsWorker { let tr = death_ctx.as_ref().map(|c| { death_log::wrap_death_notification(c, "child_death", father_user_id) }); - Self::notify_user( + if let Err(e) = Self::notify_user( pool, broker, father_user_id, tr.as_deref().unwrap_or("child_death"), - )?; + ) { + eprintln!("[EventsWorker] child_death notification (father): {e}"); + } } if let Some(mother_user_id) = row .get("mother_user_id") @@ -1995,12 +2001,14 @@ impl EventsWorker { let tr = death_ctx.as_ref().map(|c| { death_log::wrap_death_notification(c, "child_death", mother_user_id) }); - Self::notify_user( + if let Err(e) = Self::notify_user( pool, broker, mother_user_id, tr.as_deref().unwrap_or("child_death"), - )?; + ) { + eprintln!("[EventsWorker] child_death notification (mother): {e}"); + } } } diff --git a/src/worker/sql.rs b/src/worker/sql.rs index 816c8fa..333b5d5 100755 --- a/src/worker/sql.rs +++ b/src/worker/sql.rs @@ -2150,7 +2150,14 @@ pub const QUERY_GET_MARRIAGE_BIRTH_DELIVERIES: &str = r#" r.id AS relationship_id, CASE WHEN c1.gender = 'male' THEN c1.id ELSE c2.id END AS father_cid, CASE WHEN c1.gender = 'female' THEN c1.id ELSE c2.id END AS mother_cid, - CASE WHEN c1.gender = 'male' THEN c1.title_of_nobility ELSE c2.title_of_nobility END AS title_of_nobility, + -- Kinder eines Spielers erben stets dessen Titel, unabhängig vom Geschlecht. + -- Bei reinen NPC-Paaren bleibt der Titel des Vaters maßgeblich. + CASE + WHEN c1.user_id IS NOT NULL THEN c1.title_of_nobility + WHEN c2.user_id IS NOT NULL THEN c2.title_of_nobility + WHEN c1.gender = 'male' THEN c1.title_of_nobility + ELSE c2.title_of_nobility + END AS title_of_nobility, CASE WHEN c1.gender = 'male' THEN c1.last_name ELSE c2.last_name END AS last_name, CASE WHEN c1.gender = 'male' THEN c1.region_id ELSE c2.region_id END AS region_id, CASE WHEN c1.gender = 'male' THEN fu1.id ELSE fu2.id END AS father_uid, @@ -2204,7 +2211,12 @@ pub const QUERY_TRY_MARRIAGE_CONCEPTION_UPDATE: &str = r#" r.id AS relationship_id, CASE WHEN c1.gender = 'male' THEN c1.id ELSE c2.id END AS father_cid, CASE WHEN c1.gender = 'female' THEN c1.id ELSE c2.id END AS mother_cid, - CASE WHEN c1.gender = 'male' THEN c1.title_of_nobility ELSE c2.title_of_nobility END AS title_of_nobility, + CASE + WHEN c1.user_id IS NOT NULL THEN c1.title_of_nobility + WHEN c2.user_id IS NOT NULL THEN c2.title_of_nobility + WHEN c1.gender = 'male' THEN c1.title_of_nobility + ELSE c2.title_of_nobility + END AS title_of_nobility, CASE WHEN c1.gender = 'male' THEN c1.last_name ELSE c2.last_name END AS last_name, CASE WHEN c1.gender = 'male' THEN c1.region_id ELSE c2.region_id END AS region_id, CASE WHEN c1.gender = 'male' THEN fu1.id ELSE fu2.id END AS father_uid, @@ -2269,7 +2281,12 @@ pub const QUERY_GET_LEGACY_MARRIAGE_INSTANT_PREGNANCY_CANDIDATES: &str = r#" SELECT CASE WHEN c1.gender = 'male' THEN c1.id ELSE c2.id END AS father_cid, CASE WHEN c1.gender = 'female' THEN c1.id ELSE c2.id END AS mother_cid, - CASE WHEN c1.gender = 'male' THEN c1.title_of_nobility ELSE c2.title_of_nobility END AS title_of_nobility, + CASE + WHEN c1.user_id IS NOT NULL THEN c1.title_of_nobility + WHEN c2.user_id IS NOT NULL THEN c2.title_of_nobility + WHEN c1.gender = 'male' THEN c1.title_of_nobility + ELSE c2.title_of_nobility + END AS title_of_nobility, CASE WHEN c1.gender = 'male' THEN c1.last_name ELSE c2.last_name END AS last_name, CASE WHEN c1.gender = 'male' THEN c1.region_id ELSE c2.region_id END AS region_id, CASE WHEN c1.gender = 'male' THEN fu1.id ELSE fu2.id END AS father_uid, @@ -2343,7 +2360,12 @@ pub const QUERY_GET_PLANNED_CHARACTER_BIRTH_DELIVERIES: &str = r#" SELECT c_m.id AS mother_cid, c_f.id AS father_cid, - CASE WHEN c_f.gender = 'male' THEN c_f.title_of_nobility ELSE c_m.title_of_nobility END AS title_of_nobility, + CASE + WHEN c_f.user_id IS NOT NULL THEN c_f.title_of_nobility + WHEN c_m.user_id IS NOT NULL THEN c_m.title_of_nobility + WHEN c_f.gender = 'male' THEN c_f.title_of_nobility + ELSE c_m.title_of_nobility + END AS title_of_nobility, CASE WHEN c_f.gender = 'male' THEN c_f.last_name ELSE c_m.last_name END AS last_name, CASE WHEN c_f.gender = 'male' THEN c_f.region_id ELSE c_m.region_id END AS region_id, fu_f.id AS father_uid, @@ -3899,7 +3921,12 @@ pub const QUERY_GET_LOVER_PREGNANCY_CANDIDATES: &str = r#" SELECT CASE WHEN c1.gender = 'male' THEN c1.id ELSE c2.id END AS father_cid, CASE WHEN c1.gender = 'female' THEN c1.id ELSE c2.id END AS mother_cid, - CASE WHEN c1.gender = 'male' THEN c1.title_of_nobility ELSE c2.title_of_nobility END AS title_of_nobility, + CASE + WHEN c1.user_id IS NOT NULL THEN c1.title_of_nobility + WHEN c2.user_id IS NOT NULL THEN c2.title_of_nobility + WHEN c1.gender = 'male' THEN c1.title_of_nobility + ELSE c2.title_of_nobility + END AS title_of_nobility, CASE WHEN c1.gender = 'male' THEN c1.last_name ELSE c2.last_name END AS last_name, CASE WHEN c1.gender = 'male' THEN c1.region_id ELSE c2.region_id END AS region_id, CASE WHEN c1.gender = 'male' THEN fu1.id ELSE fu2.id END AS father_uid, diff --git a/src/worker/user_character.rs b/src/worker/user_character.rs index c194554..072b2a8 100755 --- a/src/worker/user_character.rs +++ b/src/worker/user_character.rs @@ -864,11 +864,13 @@ impl UserCharacterWorker { let tr = death_ctx.as_ref().map(|c| { death_log::wrap_death_notification(c, "director_death", user_id) }); - self.notify_user_death( + if let Err(e) = self.notify_user_death( &mut conn, user_id, tr.as_deref().unwrap_or("director_death"), - )?; + ) { + eprintln!("[UserCharacterWorker] director_death notification: {e}"); + } } } @@ -881,11 +883,13 @@ impl UserCharacterWorker { let tr = death_ctx.as_ref().map(|c| { death_log::wrap_death_notification(c, "relationship_death", related_user_id) }); - self.notify_user_death( + if let Err(e) = self.notify_user_death( &mut conn, related_user_id, tr.as_deref().unwrap_or("relationship_death"), - )?; + ) { + eprintln!("[UserCharacterWorker] relationship_death notification: {e}"); + } } } @@ -899,11 +903,13 @@ impl UserCharacterWorker { let tr = death_ctx.as_ref().map(|c| { death_log::wrap_death_notification(c, "child_death", father_user_id) }); - self.notify_user_death( + if let Err(e) = self.notify_user_death( &mut conn, father_user_id, tr.as_deref().unwrap_or("child_death"), - )?; + ) { + eprintln!("[UserCharacterWorker] child_death notification (father): {e}"); + } } if let Some(mother_user_id) = row .get("mother_user_id") @@ -912,11 +918,13 @@ impl UserCharacterWorker { let tr = death_ctx.as_ref().map(|c| { death_log::wrap_death_notification(c, "child_death", mother_user_id) }); - self.notify_user_death( + if let Err(e) = self.notify_user_death( &mut conn, mother_user_id, tr.as_deref().unwrap_or("child_death"), - )?; + ) { + eprintln!("[UserCharacterWorker] child_death notification (mother): {e}"); + } } } conn.execute("delete_knowledge", &[&character_id])?;