This commit is contained in:
2026-10-02 13:12:49 +08:00
parent 2d4bdd0f85
commit a8ab1dfcc6
38 changed files with 10385 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
//! 存儲層: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<Pool, Box<dyn std::error::Error>> {
Ok(Config::new(db_path).create_pool(Runtime::Tokio1)?)
}
/// 初始化資料庫:啟用 WAL 日誌模式(一次寫入即持久生效)、確保各模型的
/// 表結構存在(冪等,含輕量遷移),並播種默認管理員帳號(僅在不存在時建立)。
pub async fn init_db(pool: &Pool) -> Result<(), Box<dyn std::error::Error>> {
// 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()));
}
}
}