Skip to main content

yggdrasil/api/assets/
delete.rs

1//! 素材删除与孤儿清理接口。
2//!
3//! 删除保护:被任何文章(含回收站文章)或存活评论引用的素材禁删;
4//! 孤儿素材硬删除(文件 + DB 行 + 派生缓存)。一键清理仅作用于
5//! 无引用且超过 7 天保护窗的素材(保护未保存草稿的引用)。
6//! Dioxus server function,注册在 `/api` 路径下,仅 admin 可用。
7
8use dioxus::prelude::*;
9
10use super::types::{AssetOpResponse, BatchDeleteAssetsResponse, PurgeOrphansResponse};
11
12/// 更新素材 alt(管理性备注,不回写已有文章 HTML)。
13#[server(UpdateAssetAlt, "/api")]
14pub async fn update_asset_alt(id: String, alt: String) -> Result<AssetOpResponse, ServerFnError> {
15    crate::api::auth::get_current_admin_user().await?;
16    update_asset_alt_impl(id, alt).await
17}
18
19/// 共享素材操作;调用方须先完成管理员鉴权。
20#[cfg(feature = "server")]
21pub(crate) async fn update_asset_alt_impl(
22    id: String,
23    alt: String,
24) -> Result<AssetOpResponse, ServerFnError> {
25    use crate::api::error::AppError;
26    use crate::db::pool::get_conn;
27
28    let client = get_conn().await.map_err(AppError::db_conn)?;
29
30    // id 在边界处从 String 解析为 Uuid(非法 id 属业务错误,不走 500)。
31    let asset_uuid = match uuid::Uuid::parse_str(&id) {
32        Ok(u) => u,
33        Err(_) => return Ok(AssetOpResponse::err("素材 id 非法".to_string())),
34    };
35
36    let alt = alt.trim().to_string();
37    let updated = client
38        .execute(
39            "UPDATE assets SET alt = NULLIF($2, ''), updated_at = NOW() WHERE id = $1",
40            &[&asset_uuid, &alt],
41        )
42        .await
43        .map_err(AppError::query)?;
44
45    if updated == 0 {
46        return Ok(AssetOpResponse::err("素材不存在".to_string()));
47    }
48    Ok(AssetOpResponse::ok("alt 已更新".to_string()))
49}
50
51/// 删除单张素材。
52///
53/// 引用中 → `Ok(success:false)` + 引用文章列表(业务拒绝不走 Err);
54/// 孤儿 → 物理删除文件、DB 行(refs 级联)与派生缓存。
55#[server(DeleteAsset, "/api")]
56pub async fn delete_asset(id: String) -> Result<AssetOpResponse, ServerFnError> {
57    crate::api::auth::get_current_admin_user().await?;
58    delete_asset_impl(id).await
59}
60
61/// 共享素材操作;调用方须先完成管理员鉴权。
62#[cfg(feature = "server")]
63pub(crate) async fn delete_asset_impl(id: String) -> Result<AssetOpResponse, ServerFnError> {
64    use crate::api::error::AppError;
65    use crate::db::pool::get_conn;
66    use crate::models::asset::{AssetRef, AssetRefPostStatus};
67
68    let mut client = get_conn().await.map_err(AppError::db_conn)?;
69    // 锁住素材行,令并发文章保存的 asset_refs 外键检查在删除提交前排队,
70    // 避免引用检查后插入新引用再被 ON DELETE CASCADE 静默移除。
71    let tx = client.transaction().await.map_err(AppError::tx)?;
72
73    let asset_uuid = match uuid::Uuid::parse_str(&id) {
74        Ok(u) => u,
75        Err(_) => return Ok(AssetOpResponse::err("素材 id 非法".to_string())),
76    };
77
78    let row = tx
79        .query_opt(
80            "SELECT id AS id, path, filename FROM assets WHERE id = $1 FOR UPDATE",
81            &[&asset_uuid],
82        )
83        .await
84        .map_err(AppError::query)?;
85    let Some(row) = row else {
86        return Ok(AssetOpResponse::err("素材不存在".to_string()));
87    };
88    let path: String = row.get("path");
89
90    let note_reference: bool = tx
91        .query_one(
92            "SELECT EXISTS(SELECT 1 FROM note_asset_refs WHERE asset_id=$1)",
93            &[&asset_uuid],
94        )
95        .await
96        .map_err(AppError::query)?
97        .get(0);
98    if note_reference {
99        return Ok(AssetOpResponse::err(
100            "该素材被笔记或历史版本引用,无法删除".to_string(),
101        ));
102    }
103
104    // 引用检查:含回收站文章(其 purge 时 refs 级联删,图自然变孤儿)。
105    let ref_rows = tx
106        .query(
107            "SELECT p.id, p.title, p.slug, p.status, p.deleted_at \
108             FROM asset_refs r JOIN posts p ON p.id = r.post_id \
109             WHERE r.asset_id = $1 ORDER BY p.id",
110            &[&asset_uuid],
111        )
112        .await
113        .map_err(AppError::query)?;
114    if !ref_rows.is_empty() {
115        let refs: Vec<AssetRef> = ref_rows
116            .iter()
117            .map(|r| {
118                let status = AssetRefPostStatus::resolve(
119                    r.get::<_, &str>(3),
120                    r.get::<_, Option<chrono::DateTime<chrono::Utc>>>(4),
121                );
122                AssetRef::Post {
123                    post_id: r.get(0),
124                    title: r.get(1),
125                    slug: r.get(2),
126                    status,
127                }
128            })
129            .collect();
130        return Ok(AssetOpResponse {
131            success: false,
132            message: format!("该素材正被 {} 篇文章引用,无法删除", refs.len()),
133            refs,
134        });
135    }
136
137    // 评论与头像引用共用一次查询,避免单删路径额外往返数据库。
138    let reference_row = tx
139        .query_one(
140            &format!(
141                "SELECT {comment_ref} AS comment_referenced, \
142                        {avatar_ref} AS avatar_referenced \
143                 FROM assets a WHERE a.id = $1",
144                comment_ref = super::COMMENT_REF_CLAUSE,
145                avatar_ref = super::AVATAR_REF_CLAUSE,
146            ),
147            &[&asset_uuid],
148        )
149        .await
150        .map_err(AppError::query)?;
151    if reference_row.get::<_, bool>("comment_referenced") {
152        return Ok(AssetOpResponse {
153            success: false,
154            message: "该素材正被评论引用,无法删除".to_string(),
155            refs: Vec::new(),
156        });
157    }
158
159    if reference_row.get::<_, bool>("avatar_referenced") {
160        return Ok(AssetOpResponse {
161            success: false,
162            message: "该素材正被头像引用,无法删除".to_string(),
163            refs: Vec::new(),
164        });
165    }
166
167    // 先提交 DB 删除,再清理文件;文件删除失败只会留下可由重建索引清理的孤儿文件,
168    // 不会在事务失败时丢失仍被引用的实体文件。
169    let file_path = format!("uploads/{}", path);
170    tx.execute("DELETE FROM assets WHERE id = $1", &[&asset_uuid])
171        .await
172        .map_err(AppError::tx)?;
173    tx.commit().await.map_err(AppError::tx)?;
174    if let Err(e) = tokio::fs::remove_file(&file_path).await {
175        if e.kind() != std::io::ErrorKind::NotFound {
176            tracing::warn!("Remove asset file failed ({}): {}", file_path, e);
177        }
178    }
179    crate::api::image::invalidate_asset_caches(&path).await;
180
181    Ok(AssetOpResponse::ok("已删除".to_string()))
182}
183
184/// 批量删除素材。
185///
186/// 逐项做引用检查:被引用的跳过(保护语义与单删一致),无引用的硬删除
187/// (文件 + DB 行 + 派生缓存)。非法 id 计入失败,不存在的 id 静默忽略。
188/// 返回删除/跳过/失败统计,供批量操作条展示。
189#[server(BatchDeleteAssets, "/api")]
190pub async fn batch_delete_assets(
191    ids: Vec<String>,
192) -> Result<BatchDeleteAssetsResponse, ServerFnError> {
193    crate::api::auth::get_current_admin_user().await?;
194    batch_delete_assets_impl(ids).await
195}
196
197/// 共享素材操作;调用方须先完成管理员鉴权。
198#[cfg(feature = "server")]
199pub(crate) async fn batch_delete_assets_impl(
200    ids: Vec<String>,
201) -> Result<BatchDeleteAssetsResponse, ServerFnError> {
202    use crate::api::error::AppError;
203    use crate::db::pool::get_conn;
204
205    // id 在边界处从 String 解析为 Uuid;非法 id 属业务错误,计入 failures 不走 500。
206    let mut uuids: Vec<uuid::Uuid> = Vec::with_capacity(ids.len());
207    let mut failures: i64 = 0;
208    for id in &ids {
209        match uuid::Uuid::parse_str(id) {
210            Ok(u) => uuids.push(u),
211            Err(_) => failures += 1,
212        }
213    }
214    if uuids.is_empty() {
215        return Ok(BatchDeleteAssetsResponse {
216            success: false,
217            message: "没有可删除的素材".to_string(),
218            deleted_count: 0,
219            skipped_referenced: 0,
220            freed_bytes: 0,
221            failures,
222        });
223    }
224
225    let mut client = get_conn().await.map_err(AppError::db_conn)?;
226    let tx = client.transaction().await.map_err(AppError::tx)?;
227
228    // 锁住候选素材行,保护引用检查与删除之间的窗口。
229    let rows = tx
230        .query(
231            &format!(
232                "SELECT a.id AS id, a.path, a.size_bytes, \
233                        {asset_ref} AS referenced \
234                 FROM assets a WHERE a.id = ANY($1) FOR UPDATE",
235                asset_ref = super::ASSET_REF_CLAUSE
236            ),
237            &[&uuids],
238        )
239        .await
240        .map_err(AppError::query)?;
241
242    let mut delete_ids: Vec<uuid::Uuid> = Vec::with_capacity(rows.len());
243    let mut files_to_remove: Vec<String> = Vec::with_capacity(rows.len());
244    let mut freed_bytes: i64 = 0;
245    let mut skipped: i64 = 0;
246    for row in &rows {
247        let id: uuid::Uuid = row.get("id");
248        if row.get::<_, bool>("referenced") {
249            skipped += 1;
250            continue;
251        }
252        let path: String = row.get("path");
253        freed_bytes += row.get::<_, i64>("size_bytes");
254        files_to_remove.push(path);
255        delete_ids.push(id);
256    }
257
258    let deleted = if delete_ids.is_empty() {
259        0
260    } else {
261        tx.execute("DELETE FROM assets WHERE id = ANY($1)", &[&delete_ids])
262            .await
263            .map_err(AppError::tx)?
264    };
265    tx.commit().await.map_err(AppError::tx)?;
266
267    // DB 删除成功后再清理文件;失败文件由重建索引兜底。
268    for path in files_to_remove {
269        let file_path = format!("uploads/{}", path);
270        if let Err(e) = tokio::fs::remove_file(&file_path).await {
271            if e.kind() != std::io::ErrorKind::NotFound {
272                tracing::warn!("Batch delete: remove file failed ({}): {}", file_path, e);
273                failures += 1;
274            }
275        }
276        crate::api::image::invalidate_asset_caches(&path).await;
277    }
278
279    let mut message = format!("已删除 {} 张素材", deleted);
280    if skipped > 0 {
281        message.push_str(&format!(",跳过 {} 张被引用", skipped));
282    }
283    Ok(BatchDeleteAssetsResponse {
284        success: true,
285        message,
286        deleted_count: deleted as i64,
287        skipped_referenced: skipped,
288        freed_bytes,
289        failures,
290    })
291}
292
293/// 一键清理孤儿素材:无引用且 created_at 早于 7 天保护窗。
294///
295/// 逐项删文件(容忍单项失败),最后批量删 DB 行。
296/// 返回清理数量、释放字节数与文件删除失败数。
297#[server(PurgeOrphanAssets, "/api")]
298pub async fn purge_orphan_assets() -> Result<PurgeOrphansResponse, ServerFnError> {
299    crate::api::auth::get_current_admin_user().await?;
300    purge_orphan_assets_impl().await
301}
302
303/// 共享素材操作;调用方须先完成管理员鉴权。
304#[cfg(feature = "server")]
305pub(crate) async fn purge_orphan_assets_impl() -> Result<PurgeOrphansResponse, ServerFnError> {
306    use crate::api::error::AppError;
307    use crate::db::pool::get_conn;
308
309    let mut client = get_conn().await.map_err(AppError::db_conn)?;
310    let tx = client.transaction().await.map_err(AppError::tx)?;
311
312    // 锁住候选素材行,令并发文章保存的外键引用在删除提交前排队。
313    let rows = tx
314        .query(
315            &format!(
316                "SELECT a.id AS id, a.path, a.size_bytes FROM assets a \
317                 WHERE NOT {asset_ref} \
318                   AND a.created_at < NOW() - make_interval(days => $1) \
319                 FOR UPDATE",
320                asset_ref = super::ASSET_REF_CLAUSE
321            ),
322            &[&super::list::PURGE_GRACE_DAYS],
323        )
324        .await
325        .map_err(AppError::query)?;
326
327    if rows.is_empty() {
328        tx.commit().await.map_err(AppError::tx)?;
329        return Ok(PurgeOrphansResponse {
330            success: true,
331            message: "没有可清理的未引用素材".to_string(),
332            deleted_count: 0,
333            freed_bytes: 0,
334            failures: 0,
335        });
336    }
337
338    let mut ids: Vec<uuid::Uuid> = Vec::with_capacity(rows.len());
339    let mut paths: Vec<String> = Vec::with_capacity(rows.len());
340    let mut freed_bytes: i64 = 0;
341    for row in &rows {
342        ids.push(row.get("id"));
343        paths.push(row.get("path"));
344        freed_bytes += row.get::<_, i64>("size_bytes");
345    }
346
347    let deleted = tx
348        .execute("DELETE FROM assets WHERE id = ANY($1)", &[&ids])
349        .await
350        .map_err(AppError::tx)?;
351    tx.commit().await.map_err(AppError::tx)?;
352
353    let mut failures: i64 = 0;
354    for path in paths {
355        let file_path = format!("uploads/{}", path);
356        if let Err(e) = tokio::fs::remove_file(&file_path).await {
357            if e.kind() != std::io::ErrorKind::NotFound {
358                tracing::warn!("Purge: remove file failed ({}): {}", file_path, e);
359                failures += 1;
360            }
361        }
362        crate::api::image::invalidate_asset_caches(&path).await;
363    }
364
365    Ok(PurgeOrphansResponse {
366        success: true,
367        message: format!("已清理 {} 张未引用素材", deleted),
368        deleted_count: deleted as i64,
369        freed_bytes,
370        failures,
371    })
372}