feat(auth): P0 登录与会话(迁移 v16、argon2id、HttpOnly Cookie)
- 迁移机制:schema::migrate 支持 15→16,新增 admin/session/setting 表 - 接口:session/setup/login/logout/password;SameSite=Strict + Origin 校验 + 失败限流 - 会话只存 token 的 sha256;改密清空全部会话 - 前端:/login 独立壳(首次=设置密码),页头登录/退出切换 - 测试:37 passed(迁移 + 登录/会话 4 例) - 文档:README 登录说明、docs/ADMIN-PLAN.md 方案与 P0 记录
This commit is contained in:
+309
@@ -0,0 +1,309 @@
|
||||
//! 登录与会话。
|
||||
//!
|
||||
//! - 单管理员:`admin` 表只允许一行,密码用 argon2id 存 PHC 字符串;
|
||||
//! - 会话:随机 32 字节 token 放在 HttpOnly Cookie 里,库里只存它的 sha256;
|
||||
//! - 所有函数都是同步的,调用方负责放到 `spawn_blocking` 里。
|
||||
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use argon2::{
|
||||
Argon2, PasswordHash, PasswordHasher, PasswordVerifier,
|
||||
password_hash::{SaltString, rand_core::OsRng},
|
||||
};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{db::map_error, error::StashError};
|
||||
|
||||
/// 会话有效期:7 天
|
||||
pub const SESSION_TTL_SECS: i64 = 7 * 24 * 60 * 60;
|
||||
/// 密码最短长度
|
||||
pub const MIN_PASSWORD_LEN: usize = 8;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize)]
|
||||
pub struct SessionState {
|
||||
pub authenticated: bool,
|
||||
/// 是否已经设置过管理员密码(前端据此显示「登录」还是「首次设置」)
|
||||
pub has_admin: bool,
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
fn now() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn hash_token(token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
fn random_token() -> String {
|
||||
let bytes: [u8; 32] = rand::random();
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
fn hash_password(password: &str) -> Result<String, StashError> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map(|hash| hash.to_string())
|
||||
.map_err(|error| StashError::Database(format!("密码哈希失败: {error}")))
|
||||
}
|
||||
|
||||
fn verify_hash(password: &str, hash: &str) -> bool {
|
||||
match PasswordHash::new(hash) {
|
||||
Ok(parsed) => Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.is_ok(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_admin(conn: &Connection) -> Result<bool, StashError> {
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM admin", [], |row| row.get(0))
|
||||
.map_err(map_error)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
fn admin_row(conn: &Connection) -> Result<Option<(String, String)>, StashError> {
|
||||
conn.query_row("SELECT username, password_hash FROM admin WHERE id = 1", [], |row| {
|
||||
Ok((row.get(0)?, row.get(1)?))
|
||||
})
|
||||
.optional()
|
||||
.map_err(map_error)
|
||||
}
|
||||
|
||||
fn validate_password(password: &str) -> Result<(), StashError> {
|
||||
if password.chars().count() < MIN_PASSWORD_LEN {
|
||||
return Err(StashError::InvalidDirectory(format!(
|
||||
"密码至少 {MIN_PASSWORD_LEN} 位"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 首次设置管理员密码;已经设置过则拒绝。
|
||||
pub fn create_admin(conn: &Connection, username: &str, password: &str) -> Result<(), StashError> {
|
||||
validate_password(password)?;
|
||||
if has_admin(conn)? {
|
||||
return Err(StashError::InvalidDirectory("管理员已存在,不能重复初始化".into()));
|
||||
}
|
||||
|
||||
let hash = hash_password(password)?;
|
||||
conn.execute(
|
||||
"INSERT INTO admin (id, username, password_hash, updated_at) VALUES (1, ?1, ?2, ?3)",
|
||||
params![username, hash, now()],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 校验密码;成功返回用户名。
|
||||
pub fn verify_login(conn: &Connection, password: &str) -> Result<Option<String>, StashError> {
|
||||
let Some((username, hash)) = admin_row(conn)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(verify_hash(password, &hash).then_some(username))
|
||||
}
|
||||
|
||||
/// 修改密码:必须提供当前密码;成功后清掉其它会话。
|
||||
pub fn change_password(
|
||||
conn: &mut Connection,
|
||||
current: &str,
|
||||
next: &str,
|
||||
) -> Result<bool, StashError> {
|
||||
validate_password(next)?;
|
||||
let Some((_, hash)) = admin_row(conn)? else {
|
||||
return Err(StashError::InvalidDirectory("尚未设置管理员密码".into()));
|
||||
};
|
||||
if !verify_hash(current, &hash) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let new_hash = hash_password(next)?;
|
||||
let tx = conn.transaction().map_err(map_error)?;
|
||||
tx.execute(
|
||||
"UPDATE admin SET password_hash = ?1, updated_at = ?2 WHERE id = 1",
|
||||
params![new_hash, now()],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
tx.execute("DELETE FROM session", []).map_err(map_error)?;
|
||||
tx.commit().map_err(map_error)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 新建会话,返回**原始 token**(只在这里出现一次,库里存哈希)。
|
||||
pub fn issue_session(conn: &Connection) -> Result<String, StashError> {
|
||||
let token = random_token();
|
||||
let issued = now();
|
||||
conn.execute(
|
||||
"INSERT INTO session (token_hash, created_at, expires_at, last_seen_at)
|
||||
VALUES (?1, ?2, ?3, ?2)",
|
||||
params![hash_token(&token), issued, issued + SESSION_TTL_SECS],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// 读取会话状态;token 无效/过期都返回未登录。
|
||||
pub fn state(conn: &Connection, token: Option<&str>) -> Result<SessionState, StashError> {
|
||||
let has = has_admin(conn)?;
|
||||
let Some(token) = token else {
|
||||
return Ok(SessionState {
|
||||
authenticated: false,
|
||||
has_admin: has,
|
||||
username: None,
|
||||
});
|
||||
};
|
||||
|
||||
let token_hash = hash_token(token);
|
||||
let row: Option<(i64,)> = conn
|
||||
.query_row(
|
||||
"SELECT expires_at FROM session WHERE token_hash = ?1",
|
||||
[&token_hash],
|
||||
|row| Ok((row.get(0)?,)),
|
||||
)
|
||||
.optional()
|
||||
.map_err(map_error)?;
|
||||
|
||||
let Some((expires_at,)) = row else {
|
||||
return Ok(SessionState {
|
||||
authenticated: false,
|
||||
has_admin: has,
|
||||
username: None,
|
||||
});
|
||||
};
|
||||
|
||||
if expires_at <= now() {
|
||||
conn.execute("DELETE FROM session WHERE token_hash = ?1", [&token_hash])
|
||||
.map_err(map_error)?;
|
||||
return Ok(SessionState {
|
||||
authenticated: false,
|
||||
has_admin: has,
|
||||
username: None,
|
||||
});
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"UPDATE session SET last_seen_at = ?1 WHERE token_hash = ?2",
|
||||
params![now(), token_hash],
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
Ok(SessionState {
|
||||
authenticated: true,
|
||||
has_admin: has,
|
||||
username: admin_row(conn)?.map(|(username, _)| username),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn revoke(conn: &Connection, token: &str) -> Result<(), StashError> {
|
||||
conn.execute("DELETE FROM session WHERE token_hash = ?1", [hash_token(token)])
|
||||
.map_err(map_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清理过期会话(登录时顺手调用即可)。
|
||||
pub fn purge_expired(conn: &Connection) -> Result<(), StashError> {
|
||||
conn.execute("DELETE FROM session WHERE expires_at <= ?1", [now()])
|
||||
.map_err(map_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn db() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("内存库应能打开");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE admin (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE session (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL
|
||||
);",
|
||||
)
|
||||
.expect("应能建表");
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_then_login_and_session() {
|
||||
let conn = db();
|
||||
assert!(!has_admin(&conn).expect("可查"));
|
||||
create_admin(&conn, "admin", "stash-pass-1").expect("首次设置应成功");
|
||||
|
||||
assert!(has_admin(&conn).expect("可查"));
|
||||
assert!(verify_login(&conn, "wrong-pass").expect("可校验").is_none());
|
||||
assert_eq!(
|
||||
verify_login(&conn, "stash-pass-1").expect("可校验").as_deref(),
|
||||
Some("admin")
|
||||
);
|
||||
|
||||
let token = issue_session(&conn).expect("应能建会话");
|
||||
let session = state(&conn, Some(&token)).expect("应能读状态");
|
||||
assert!(session.authenticated);
|
||||
assert_eq!(session.username.as_deref(), Some("admin"));
|
||||
|
||||
// 原始 token 不落库
|
||||
let stored: String = conn
|
||||
.query_row("SELECT token_hash FROM session", [], |row| row.get(0))
|
||||
.expect("应有会话行");
|
||||
assert_ne!(stored, token);
|
||||
assert_eq!(stored, hash_token(&token));
|
||||
|
||||
revoke(&conn, &token).expect("应能撤销");
|
||||
assert!(!state(&conn, Some(&token)).expect("应能读状态").authenticated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_short_password_and_second_setup() {
|
||||
let conn = db();
|
||||
assert!(create_admin(&conn, "admin", "short").is_err());
|
||||
create_admin(&conn, "admin", "stash-pass-1").expect("首次设置应成功");
|
||||
assert!(create_admin(&conn, "admin", "stash-pass-2").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_password_requires_current_and_clears_sessions() {
|
||||
let mut conn = db();
|
||||
create_admin(&conn, "admin", "stash-pass-1").expect("应能设置");
|
||||
let token = issue_session(&conn).expect("应能建会话");
|
||||
|
||||
assert!(!change_password(&mut conn, "nope", "stash-pass-2").expect("可调用"));
|
||||
assert!(change_password(&mut conn, "stash-pass-1", "stash-pass-2").expect("可调用"));
|
||||
|
||||
assert_eq!(
|
||||
verify_login(&conn, "stash-pass-2").expect("可校验").as_deref(),
|
||||
Some("admin")
|
||||
);
|
||||
assert!(!state(&conn, Some(&token)).expect("可读").authenticated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_session_is_rejected_and_deleted() {
|
||||
let conn = db();
|
||||
create_admin(&conn, "admin", "stash-pass-1").expect("应能设置");
|
||||
let token = issue_session(&conn).expect("应能建会话");
|
||||
conn.execute("UPDATE session SET expires_at = ?1", [now() - 1])
|
||||
.expect("应能改过期时间");
|
||||
|
||||
assert!(!state(&conn, Some(&token)).expect("可读").authenticated);
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM session", [], |row| row.get(0))
|
||||
.expect("可查");
|
||||
assert_eq!(count, 0, "过期会话应被删除");
|
||||
}
|
||||
}
|
||||
+79
-22
@@ -8,6 +8,7 @@
|
||||
//! 3. **开发期不做迁移**:表结构还在变,改结构就删掉 `data/stash.db` 重启,
|
||||
//! 启动时会重新建表并灌入内嵌种子。
|
||||
|
||||
pub mod auth;
|
||||
mod catalog;
|
||||
mod schema;
|
||||
|
||||
@@ -21,6 +22,9 @@ use crate::{config::Config, error::StashError};
|
||||
|
||||
pub use schema::SeedReport;
|
||||
|
||||
/// 登录/会话的对外状态(server 层直接用)。
|
||||
pub use auth::SessionState;
|
||||
|
||||
/// 一次查询得到的完整内容目录。
|
||||
///
|
||||
/// 侧栏已改为由这份分类树派生,不再单独下发导航数据。
|
||||
@@ -57,14 +61,14 @@ impl Db {
|
||||
)
|
||||
.map_err(map_error)?;
|
||||
|
||||
// 先校验结构版本,再动任何东西:旧库直接报错,避免用旧表跑新查询
|
||||
let version = schema::check_version(&conn)?;
|
||||
|
||||
schema::apply(&conn)?;
|
||||
|
||||
// 写入当前结构版本,供下次启动比对
|
||||
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION)
|
||||
.map_err(map_error)?;
|
||||
// 先迁移结构,再动数据:迁移失败会直接报错,不会用半套表跑查询
|
||||
let version = schema::migrate(&conn)?;
|
||||
if version != schema::SCHEMA_VERSION {
|
||||
return Err(StashError::Database(format!(
|
||||
"内容库迁移后版本为 {version},期望 {}",
|
||||
schema::SCHEMA_VERSION
|
||||
)));
|
||||
}
|
||||
|
||||
// 空库才灌种子:用户删掉内置条目后重启不会被重新塞回来
|
||||
let existing: i64 = conn
|
||||
@@ -115,6 +119,39 @@ impl Db {
|
||||
.lock()
|
||||
.map_err(|_| StashError::Database("数据库连接锁已损坏,请重启进程".into()))
|
||||
}
|
||||
|
||||
// ---------- 登录 / 会话 ----------
|
||||
|
||||
pub fn auth_state(&self, token: Option<&str>) -> Result<SessionState, StashError> {
|
||||
let conn = self.lock()?;
|
||||
auth::state(&conn, token)
|
||||
}
|
||||
|
||||
pub fn setup_admin(&self, username: &str, password: &str) -> Result<(), StashError> {
|
||||
let conn = self.lock()?;
|
||||
auth::create_admin(&conn, username, password)
|
||||
}
|
||||
|
||||
pub fn login(&self, password: &str) -> Result<Option<String>, StashError> {
|
||||
let conn = self.lock()?;
|
||||
auth::purge_expired(&conn)?;
|
||||
auth::verify_login(&conn, password)
|
||||
}
|
||||
|
||||
pub fn issue_session(&self) -> Result<String, StashError> {
|
||||
let conn = self.lock()?;
|
||||
auth::issue_session(&conn)
|
||||
}
|
||||
|
||||
pub fn logout(&self, token: &str) -> Result<(), StashError> {
|
||||
let conn = self.lock()?;
|
||||
auth::revoke(&conn, token)
|
||||
}
|
||||
|
||||
pub fn change_password(&self, current: &str, next: &str) -> Result<bool, StashError> {
|
||||
let mut conn = self.lock()?;
|
||||
auth::change_password(&mut conn, current, next)
|
||||
}
|
||||
}
|
||||
|
||||
/// 把 rusqlite 的错误统一收敛成 `StashError::Database`。
|
||||
@@ -446,37 +483,57 @@ mod tests {
|
||||
assert!(db.data_version().expect("应能读取 data_version") >= 0);
|
||||
}
|
||||
|
||||
/// 全新库(user_version = 0)允许直接建表;当前版本也允许。
|
||||
/// 全新库(user_version = 0)建全量结构;当前版本直接返回;
|
||||
/// 15 会迁移到 16;比程序新的版本拒绝。
|
||||
#[test]
|
||||
fn accepts_fresh_or_current_schema_version() {
|
||||
fn migrates_fresh_previous_and_current_schema() {
|
||||
let conn = Connection::open_in_memory().expect("内存库应能打开");
|
||||
|
||||
assert_eq!(
|
||||
schema::check_version(&conn).expect("全新库应放行"),
|
||||
0,
|
||||
"新库的 user_version 应为 0"
|
||||
schema::migrate(&conn).expect("全新库应能建表"),
|
||||
schema::SCHEMA_VERSION,
|
||||
"新库应直接建到当前版本"
|
||||
);
|
||||
|
||||
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION)
|
||||
.expect("应能写入版本");
|
||||
let fresh = Connection::open_in_memory().expect("内存库应能打开");
|
||||
fresh
|
||||
.execute_batch(
|
||||
"CREATE TABLE node (key TEXT PRIMARY KEY);
|
||||
PRAGMA user_version = 15;",
|
||||
)
|
||||
.expect("应能造出 v15 老库");
|
||||
assert_eq!(
|
||||
schema::check_version(&conn).expect("当前版本应放行"),
|
||||
schema::migrate(&fresh).expect("v15 应能迁移"),
|
||||
schema::SCHEMA_VERSION
|
||||
);
|
||||
|
||||
let migrated: i64 = fresh
|
||||
.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||
.expect("应能读出版本");
|
||||
assert_eq!(migrated, schema::SCHEMA_VERSION);
|
||||
|
||||
let table: i64 = fresh
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('admin','session','setting')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("应能查表");
|
||||
assert_eq!(table, 3, "迁移后必须有登录/设置三张表");
|
||||
}
|
||||
|
||||
/// 结构变过之后,旧库必须被拒绝并提示删库,而不是用旧表跑新查询。
|
||||
/// 比程序更新的库必须被拒绝,不能按旧结构乱读。
|
||||
#[test]
|
||||
fn rejects_database_from_older_schema() {
|
||||
fn rejects_database_from_newer_schema() {
|
||||
let conn = Connection::open_in_memory().expect("内存库应能打开");
|
||||
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION - 1)
|
||||
.expect("应能写入旧版本号");
|
||||
conn.pragma_update(None, "user_version", schema::SCHEMA_VERSION + 1)
|
||||
.expect("应能写入新版本号");
|
||||
|
||||
let error = schema::check_version(&conn).expect_err("旧库必须被拒绝");
|
||||
let error = schema::migrate(&conn).expect_err("更新的库必须被拒绝");
|
||||
let message = error.to_string();
|
||||
|
||||
assert!(message.contains("结构版本"), "报错要说明原因: {message}");
|
||||
assert!(message.contains("删除数据库"), "报错要给出处理方式: {message}");
|
||||
assert!(message.contains("升级程序"), "报错要给出处理方式: {message}");
|
||||
}
|
||||
|
||||
/// 回归测试:外部连接提交后,服务端连接读到的 data_version 必须变化。
|
||||
|
||||
+97
-13
@@ -100,6 +100,60 @@ CREATE TABLE IF NOT EXISTS section_block (
|
||||
preview_limit INTEGER, -- 卡片里显示几条;NULL 时用前端默认
|
||||
sort INTEGER NOT NULL DEFAULT 0 -- 卡片顺序
|
||||
);
|
||||
|
||||
-- ========== v16:登录与设置 ==========
|
||||
|
||||
-- 管理员:单用户场景只允许一行
|
||||
CREATE TABLE IF NOT EXISTS admin (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL, -- argon2id PHC 字符串
|
||||
updated_at INTEGER NOT NULL -- unix 秒
|
||||
);
|
||||
|
||||
-- 会话:只存 token 的 sha256,原始 token 只在 Cookie 里
|
||||
CREATE TABLE IF NOT EXISTS session (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS session_expiry_idx ON session(expires_at);
|
||||
|
||||
-- 全局设置:一个 key 一段 JSON,version 用于乐观锁
|
||||
CREATE TABLE IF NOT EXISTS setting (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"#;
|
||||
|
||||
/// v15 → v16 的增量(老库升级用;新库直接走 SCHEMA_SQL)。
|
||||
const V16_SQL: &str = r#"
|
||||
CREATE TABLE IF NOT EXISTS admin (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
username TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS session (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
last_seen_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS session_expiry_idx ON session(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS setting (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"#;
|
||||
|
||||
/// 种子导入结果,仅用于启动日志。
|
||||
@@ -115,28 +169,58 @@ pub struct SeedReport {
|
||||
|
||||
/// 当前表结构版本,写在 SQLite 的 `PRAGMA user_version` 里。
|
||||
///
|
||||
/// 开发阶段不做迁移:版本对不上时直接拒绝启动,让用户删库重建,
|
||||
/// 比悄悄用旧表跑新查询(最终表现为 500)好排查得多。
|
||||
/// 结构调整时把这个数字 +1。
|
||||
pub const SCHEMA_VERSION: i64 = 15;
|
||||
/// 从 v16 起有了第一套迁移:`migrate` 会把老库逐级升上来,
|
||||
/// 只有「比程序更新」或「没有迁移路径」的版本才拒绝启动。
|
||||
pub const SCHEMA_VERSION: i64 = 16;
|
||||
|
||||
/// 校验库结构版本:0 表示全新库,允许;其它非当前版本一律拒绝。
|
||||
pub fn check_version(conn: &Connection) -> Result<i64, StashError> {
|
||||
let version: i64 = conn
|
||||
.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||
.map_err(map_error)?;
|
||||
fn current_version(conn: &Connection) -> Result<i64, StashError> {
|
||||
conn.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||
.map_err(map_error)
|
||||
}
|
||||
|
||||
if version != 0 && version != SCHEMA_VERSION {
|
||||
/// 把库升到 `SCHEMA_VERSION`,返回升级后的版本。
|
||||
///
|
||||
/// - 0:全新库,建全量结构;
|
||||
/// - 15:补 v16 的登录/设置表;
|
||||
/// - 16:已是最新;
|
||||
/// - 其它:拒绝启动(不猜结构)。
|
||||
pub fn migrate(conn: &Connection) -> Result<i64, StashError> {
|
||||
let mut version = current_version(conn)?;
|
||||
|
||||
if version == 0 {
|
||||
conn.execute_batch(SCHEMA_SQL).map_err(map_error)?;
|
||||
conn.pragma_update(None, "user_version", SCHEMA_VERSION)
|
||||
.map_err(map_error)?;
|
||||
return Ok(SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
if version > SCHEMA_VERSION {
|
||||
return Err(StashError::Database(format!(
|
||||
"内容库结构版本为 {version},当前程序需要 {SCHEMA_VERSION}。\
|
||||
开发阶段不做自动迁移,请删除数据库后重启(种子会自动重新导入)。"
|
||||
"内容库结构版本为 {version},比当前程序({SCHEMA_VERSION})更新;\
|
||||
请升级程序,或用旧版本打开。"
|
||||
)));
|
||||
}
|
||||
|
||||
if version < 15 {
|
||||
return Err(StashError::Database(format!(
|
||||
"内容库结构版本为 {version},缺少可用的迁移路径(当前程序支持 15 → {SCHEMA_VERSION})。\
|
||||
请备份后删除数据库重建。"
|
||||
)));
|
||||
}
|
||||
|
||||
if version == 15 {
|
||||
conn.execute_batch(V16_SQL).map_err(map_error)?;
|
||||
version = 16;
|
||||
}
|
||||
|
||||
conn.pragma_update(None, "user_version", version)
|
||||
.map_err(map_error)?;
|
||||
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
/// 建表(幂等):每次启动都跑一遍,缺表就补,已有数据不动。
|
||||
/// 建表(幂等):只在测试里直接用;启动流程走 `migrate`。
|
||||
#[cfg(test)]
|
||||
pub fn apply(conn: &Connection) -> Result<(), StashError> {
|
||||
conn.execute_batch(SCHEMA_SQL).map_err(map_error)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user