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