Skip to main content

yggdrasil/tasks/
orphan_asset_purge.rs

1//! 孤儿素材定期清理后台任务。
2//!
3//! 仅在 `server` feature 启用时编译,每天运行一次。
4//! 每次执行前读取 settings 表:若自动清理关闭则跳过,否则物理删除
5//! 「无文章引用(asset_refs)且无存活评论引用、超过保留天数」的素材
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    // 孤儿 = 无文章引用(asset_refs)且无存活评论引用(见 COMMENT_REF_CLAUSE)。
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 EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id) \
87                   AND NOT {comment_ref} \
88                   AND a.created_at < NOW() - make_interval(days => $1)",
89                comment_ref = crate::api::assets::COMMENT_REF_CLAUSE
90            ),
91            &[&days],
92        )
93        .await?;
94
95    if rows.is_empty() {
96        return Ok((0, 0));
97    }
98
99    let mut ids: Vec<uuid::Uuid> = Vec::with_capacity(rows.len());
100    let mut freed_bytes: i64 = 0;
101    for row in &rows {
102        let id: uuid::Uuid = row.get("id");
103        let path: String = row.get("path");
104        freed_bytes += row.get::<_, i64>("size_bytes");
105        let file_path = format!("uploads/{}", path);
106        if let Err(e) = tokio::fs::remove_file(&file_path).await {
107            if e.kind() != std::io::ErrorKind::NotFound {
108                tracing::warn!("Orphan purge: remove file failed ({}): {}", file_path, e);
109            }
110        }
111        crate::api::image::invalidate_asset_caches(&path).await;
112        ids.push(id);
113    }
114
115    let n = client
116        .execute("DELETE FROM assets WHERE id = ANY($1)", &[&ids])
117        .await?;
118    Ok((n, freed_bytes))
119}