Skip to main content

yggdrasil/tasks/
orphan_asset_purge.rs

1//! 孤儿素材定期清理后台任务。
2//!
3//! 仅在 `server` feature 启用时编译,每天运行一次。
4//! 每次执行前读取 settings 表:若自动清理关闭则跳过,否则物理删除
5//! 「无文章、笔记版本、存活评论或头像引用,且超过保留天数」的素材
6//! (文件 + DB 行 + 派生缓存),语义与 /admin/assets 的「一键清理孤儿」
7//! 一致,只是改为定时自动。
8//!
9//! 评论区允许匿名传图后,未提交/被删评论留下的孤儿图会持续累积,
10//! 此任务是 uploads/ 磁盘的主要回收手段。
11
12use std::time::Duration;
13
14use tokio::time::interval;
15
16use crate::db::pool::get_conn;
17use crate::models::settings::AssetPurgeSettings;
18
19/// 启动孤儿素材清理循环,每天触发一次。
20///
21/// 每次读取最新配置:若 `asset_orphan_purge_enabled` 关闭则 no-op,
22/// 否则删除创建时间早于 `asset_orphan_retention_days` 的孤儿素材。
23/// 任何错误只记录日志,不中断循环。
24pub async fn run_purge() {
25    let mut ticker = interval(Duration::from_secs(86400));
26    loop {
27        match get_conn().await {
28            Ok(client) => match purge_orphans(&client).await {
29                Ok((n, bytes)) => {
30                    if n > 0 {
31                        tracing::info!(
32                            "Orphan asset purge: removed {} assets, freed {} bytes",
33                            n,
34                            bytes
35                        );
36                    }
37                }
38                Err(e) => tracing::error!("Orphan asset purge error: {:?}", e),
39            },
40            Err(e) => tracing::error!(
41                "Failed to get DB connection for orphan asset purge: {:?}",
42                e
43            ),
44        }
45        ticker.tick().await;
46    }
47}
48
49/// 读取配置并删除过期孤儿素材,返回(删除行数, 释放字节数)。
50///
51/// 逐项删文件(容忍单项失败:NotFound 静默,其他错误仅告警,DB 行照删——
52/// 残留文件由重建索引的反向语义兜底),最后批量删 DB 行并失效派生缓存。
53async fn purge_orphans(
54    client: &tokio_postgres::Client,
55) -> Result<(u64, i64), tokio_postgres::Error> {
56    // 读取配置,缺键时回退默认值(默认启用、7 天)。
57    let enabled: bool = client
58        .query_opt(
59            "SELECT value FROM settings WHERE key = 'asset_orphan_purge_enabled'",
60            &[],
61        )
62        .await?
63        .and_then(|r| r.get::<_, String>("value").parse().ok())
64        .unwrap_or(crate::models::settings::DEFAULT_ASSET_ORPHAN_PURGE_ENABLED);
65
66    if !enabled {
67        return Ok((0, 0));
68    }
69
70    let days: i32 = client
71        .query_opt(
72            "SELECT value FROM settings WHERE key = 'asset_orphan_retention_days'",
73            &[],
74        )
75        .await?
76        .and_then(|r| r.get::<_, String>("value").parse().ok())
77        .unwrap_or(crate::models::settings::DEFAULT_ASSET_ORPHAN_RETENTION_DAYS);
78
79    let days = AssetPurgeSettings::clamp_retention(days);
80
81    // 与管理页面共用完整引用判定,包含笔记历史版本和头像。
82    let rows = client
83        .query(
84            &format!(
85                "SELECT a.id AS id, a.path, a.size_bytes FROM assets a \
86                 WHERE NOT {asset_ref} \
87                   AND a.created_at < NOW() - make_interval(days => $1)",
88                asset_ref = crate::api::assets::ASSET_REF_CLAUSE
89            ),
90            &[&days],
91        )
92        .await?;
93
94    if rows.is_empty() {
95        return Ok((0, 0));
96    }
97
98    let mut ids: Vec<uuid::Uuid> = Vec::with_capacity(rows.len());
99    let mut freed_bytes: i64 = 0;
100    for row in &rows {
101        let id: uuid::Uuid = row.get("id");
102        let path: String = row.get("path");
103        freed_bytes += row.get::<_, i64>("size_bytes");
104        let file_path = format!("uploads/{}", path);
105        if let Err(e) = tokio::fs::remove_file(&file_path).await {
106            if e.kind() != std::io::ErrorKind::NotFound {
107                tracing::warn!("Orphan purge: remove file failed ({}): {}", file_path, e);
108            }
109        }
110        crate::api::image::invalidate_asset_caches(&path).await;
111        ids.push(id);
112    }
113
114    let n = client
115        .execute("DELETE FROM assets WHERE id = ANY($1)", &[&ids])
116        .await?;
117    Ok((n, freed_bytes))
118}