feat(db): 内容库迁入 SQLite,分类与侧栏导航改为数据库驱动

This commit is contained in:
2026-10-23 20:22:23 +08:00
parent 386c0f77c7
commit 41fed5e64d
23 changed files with 2867 additions and 319 deletions
+80
View File
@@ -17,6 +17,7 @@ use tower_http::{
use tracing::{info, warn};
use crate::config::Config;
use crate::db::Db;
use crate::error::StashError;
use crate::listing::{AppProject, scan_projects};
use crate::watcher;
@@ -33,6 +34,8 @@ const HTML_CACHE: &str = "no-cache";
pub struct AppState {
pub config: Arc<Config>,
pub project_updates: broadcast::Sender<Vec<AppProject>>,
/// 内容库:分类 / 分组 / 条目的唯一数据源
pub db: Arc<Db>,
}
pub struct ServerHandle {
@@ -138,6 +141,77 @@ async fn list_projects_api(State(state): State<AppState>) -> impl IntoResponse {
}))
}
/// API: 完整内容目录(分类 / 分组 / 条目 + 侧栏导航)
///
/// 数据量很小(几十 KB),一次全量下发,前端搜索仍然在客户端做。
async fn catalog_api(State(state): State<AppState>) -> Response {
let db = state.db.clone();
// rusqlite 是同步的,查询扔进阻塞线程池,别占住 async 执行器
match tokio::task::spawn_blocking(move || db.load_catalog()).await {
Ok(Ok(catalog)) => Json(serde_json::json!({
"categories": catalog.categories,
"nav": catalog.nav,
}))
.into_response(),
Ok(Err(error)) => {
// 典型原因是用户手改坏了 JSON,错误信息里已带条目 id
warn!("读取内容库失败: {error}");
error.into_response()
}
Err(error) => {
warn!("内容库查询任务崩溃: {error}");
StashError::Database(format!("内容库查询任务崩溃: {error}")).into_response()
}
}
}
/// SSE:内容库被外部修改时通知前端重新拉取。
///
/// 这里轮询 `PRAGMA data_version` 而不是监听数据库文件:
/// SQLite 在 journal 模式下会产生临时文件的频繁读写,文件监听会误报,
/// 而 data_version 恰好在「其它连接提交了事务」时才变化,语义正好对上。
async fn catalog_events(State(state): State<AppState>) -> impl IntoResponse {
let db = state.db.clone();
let stream = async_stream::stream! {
let mut last = db.data_version().unwrap_or(0);
let mut ticker = tokio::time::interval(Duration::from_secs(5));
loop {
ticker.tick().await;
let current = match db.data_version() {
Ok(version) => version,
Err(error) => {
warn!("读取内容库版本失败: {error}");
continue;
}
};
// 只在其它连接提交之后才推送。
// 当前服务对内容库是只读的;将来若加入写接口,必须在那里主动广播,
// 因为 data_version 不会因本连接自己的提交而变化。
tracing::debug!(last, current, "内容库版本检查");
if current == last {
continue;
}
last = current;
// 只告诉前端「变了」,具体内容由它重新 GET /api/catalog
yield Ok::<Event, Infallible>(
Event::default().event("catalog").data(r#"{"changed":true}"#),
);
}
};
Sse::new(stream).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(15))
.text("keep-alive"),
)
}
/// SSE:先发送当前快照,再持续推送文件监听产生的项目列表变化。
async fn project_events(State(state): State<AppState>) -> impl IntoResponse {
let root = state.config.root.clone();
@@ -188,9 +262,13 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
let (project_updates, _) = broadcast::channel(32);
watcher::start(config.root.clone(), project_updates.clone())?;
// 内容库在启动时打开:建库 / 建表 / 空库时灌种子都在这里完成
let db = Arc::new(Db::open(&config)?);
let shared_state = AppState {
config: Arc::new(config.clone()),
project_updates,
db,
};
// 托管目录里找不到的路径,交给 404 页面而不是 SPA 回落:
@@ -205,6 +283,8 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
.route("/api/health", get(health_check))
.route("/api/projects", get(list_projects_api))
.route("/api/projects/events", get(project_events))
.route("/api/catalog", get(catalog_api))
.route("/api/catalog/events", get(catalog_events))
.route("/", get(web_index))
.route("/index.html", get(web_index))
.route("/favicon.svg", get(web_root_file))