//! 存儲層:SQLite 連接池的建立、資料庫初始化與各模型的持久化實作。 //! //! 每個模型一個子模組(如 [`account`]),各自封裝對應表的 SQL; //! 共用的錯誤類型 [`StorageError`] 定義於本模組。 pub mod account; pub mod database; use deadpool_sqlite::{Config, Pool, Runtime}; /// 預設資料庫檔案路徑,可透過環境變數 `ALTERDB_DB_PATH` 覆蓋 pub const DEFAULT_DB_PATH: &str = "alterdb.db"; /// 建立 SQLite 連接池 pub fn create_pool(db_path: &str) -> Result> { Ok(Config::new(db_path).create_pool(Runtime::Tokio1)?) } /// 初始化資料庫:啟用 WAL 日誌模式(一次寫入即持久生效)、確保各模型的 /// 表結構存在(冪等,含輕量遷移),並播種默認管理員帳號(僅在不存在時建立)。 pub async fn init_db(pool: &Pool) -> Result<(), Box> { // Argon2 雜湊需耗時數十至數百毫秒,放在互動閉包外先行計算 let admin_hash = crate::models::account::hash_password(account::ADMIN_DEFAULT_PASSWORD)?; pool.get() .await? .interact(move |conn| { conn.query_row("PRAGMA journal_mode=WAL", [], |_| Ok(()))?; account::ensure_table(conn)?; database::ensure_table(conn)?; if account::seed_admin(conn, &admin_hash)? { tracing::info!( "已自動建立默認管理員帳號:{}(初始密碼同帳號,建議儘早變更)", account::ADMIN_USERNAME ); } Ok::<(), rusqlite::Error>(()) }) .await??; Ok(()) } /// 存儲層錯誤:涵蓋連接池、連線互動與 SQLite 執行三類來源。 #[derive(Debug)] pub enum StorageError { /// 無法從連接池取得連線(資料庫可能不可用) Pool(String), /// 與連線的互動失敗(如 SQL 閉包 panic) Interact(String), /// SQLite 執行錯誤(語法、約束等) Sqlite(rusqlite::Error), } impl StorageError { fn pool(err: impl std::fmt::Display) -> Self { StorageError::Pool(err.to_string()) } fn interact(err: impl std::fmt::Display) -> Self { StorageError::Interact(err.to_string()) } /// 是否為唯一性約束衝突(控制器可上報為 HTTP 409) pub fn is_unique_violation(&self) -> bool { matches!( self, Self::Sqlite(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation ) } } impl std::fmt::Display for StorageError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Pool(err) => write!(f, "無法取得資料庫連線:{err}"), Self::Interact(err) => write!(f, "資料庫連線互動失敗:{err}"), Self::Sqlite(err) => write!(f, "SQLite 錯誤:{err}"), } } } impl std::error::Error for StorageError {} /// 測試輔助:以暫存檔建立彼此獨立的資料庫,避免污染工作目錄。 #[cfg(test)] pub(crate) mod testutil { use super::{create_pool, init_db}; use deadpool_sqlite::Pool; use std::path::{Path, PathBuf}; /// 生成測試專用的暫存資料庫路徑(以進程 ID + 奈秒時間戳保證唯一) fn temp_path(label: &str) -> PathBuf { let unique = format!( "{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(), ); std::env::temp_dir().join(format!("alterdb-{label}-{unique}.db")) } /// 建立一個指向暫存檔的連接池(不做任何初始化,供遷移測試自建舊版結構) pub async fn raw_pool(label: &str) -> (Pool, PathBuf) { let path = temp_path(label); let pool = create_pool(path.to_str().expect("暫存路徑應為合法 UTF-8")) .expect("應能建立測試連接池"); (pool, path) } /// 建立並初始化一個指向暫存檔的連接池 pub async fn test_pool(label: &str) -> (Pool, PathBuf) { let (pool, path) = raw_pool(label).await; init_db(&pool).await.expect("測試資料庫初始化應成功"); (pool, path) } /// 盡力清理測試資料庫檔案(含 WAL / SHM 旁車檔) pub fn cleanup(path: &Path) { for suffix in ["", "-wal", "-shm"] { let _ = std::fs::remove_file(format!("{}{suffix}", path.display())); } } }