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;
60
61        let _admin = get_current_admin_user().await?;
62        let client = get_conn().await.map_err(AppError::db_conn)?;
63
64        let asset_uuid = match uuid::Uuid::parse_str(&id) {
65            Ok(u) => u,
66            Err(_) => return Ok(AssetOpResponse::err("素材 id 非法".to_string())),
67        };
68
69        let row = client
70            .query_opt(
71                "SELECT id AS id, path, filename FROM assets WHERE id = $1",
72                &[&asset_uuid],
73            )
74            .await
75            .map_err(AppError::query)?;
76        let Some(row) = row else {
77            return Ok(AssetOpResponse::err("素材不存在".to_string()));
78        };
79        let path: String = row.get("path");
80
81        // 引用检查:含回收站文章(其 purge 时 refs 级联删,图自然变孤儿)。
82        let ref_rows = client
83            .query(
84                "SELECT p.id, p.title FROM asset_refs r JOIN posts p ON p.id = r.post_id \
85                 WHERE r.asset_id = $1 ORDER BY p.id",
86                &[&asset_uuid],
87            )
88            .await
89            .map_err(AppError::query)?;
90        if !ref_rows.is_empty() {
91            let refs: Vec<AssetRef> = ref_rows
92                .iter()
93                .map(|r| AssetRef {
94                    post_id: r.get(0),
95                    title: r.get(1),
96                })
97                .collect();
98            return Ok(AssetOpResponse {
99                success: false,
100                message: format!("该素材正被 {} 篇文章引用,无法删除", refs.len()),
101                refs,
102            });
103        }
104
105        // 孤儿:先删文件(NotFound 容忍——磁盘与 DB 可能已不一致),再删 DB 行。
106        let file_path = format!("uploads/{}", path);
107        if let Err(e) = tokio::fs::remove_file(&file_path).await {
108            if e.kind() != std::io::ErrorKind::NotFound {
109                tracing::warn!("Remove asset file failed ({}): {}", file_path, e);
110            }
111        }
112        client
113            .execute("DELETE FROM assets WHERE id = $1", &[&asset_uuid])
114            .await
115            .map_err(AppError::query)?;
116        crate::api::image::invalidate_asset_caches(&path).await;
117
118        Ok(AssetOpResponse::ok("已删除".to_string()))
119    }
120    #[cfg(not(feature = "server"))]
121    unreachable!()
122}
123
124/// 批量删除素材。
125///
126/// 逐项做引用检查:被引用的跳过(保护语义与单删一致),无引用的硬删除
127/// (文件 + DB 行 + 派生缓存)。非法 id 计入失败,不存在的 id 静默忽略。
128/// 返回删除/跳过/失败统计,供批量操作条展示。
129#[server(BatchDeleteAssets, "/api")]
130pub async fn batch_delete_assets(
131    ids: Vec<String>,
132) -> Result<BatchDeleteAssetsResponse, ServerFnError> {
133    #[cfg(feature = "server")]
134    {
135        use crate::api::auth::get_current_admin_user;
136        use crate::api::error::AppError;
137        use crate::db::pool::get_conn;
138
139        let _admin = get_current_admin_user().await?;
140        let client = get_conn().await.map_err(AppError::db_conn)?;
141
142        // id 在边界处从 String 解析为 Uuid;非法 id 属业务错误,计入 failures 不走 500。
143        let mut uuids: Vec<uuid::Uuid> = Vec::with_capacity(ids.len());
144        let mut failures: i64 = 0;
145        for id in &ids {
146            match uuid::Uuid::parse_str(id) {
147                Ok(u) => uuids.push(u),
148                Err(_) => failures += 1,
149            }
150        }
151        if uuids.is_empty() {
152            return Ok(BatchDeleteAssetsResponse {
153                success: false,
154                message: "没有可删除的素材".to_string(),
155                deleted_count: 0,
156                skipped_referenced: 0,
157                freed_bytes: 0,
158                failures,
159            });
160        }
161
162        // 一次查询取路径/大小/引用存在性,避免逐 id 往返。
163        let rows = client
164            .query(
165                "SELECT a.id AS id, a.path, a.size_bytes, \
166                        EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id) AS referenced \
167                 FROM assets a WHERE a.id = ANY($1)",
168                &[&uuids],
169            )
170            .await
171            .map_err(AppError::query)?;
172
173        let mut delete_ids: Vec<uuid::Uuid> = Vec::with_capacity(rows.len());
174        let mut freed_bytes: i64 = 0;
175        let mut skipped: i64 = 0;
176        for row in &rows {
177            let id: uuid::Uuid = row.get("id");
178            if row.get::<_, bool>("referenced") {
179                skipped += 1;
180                continue;
181            }
182            let path: String = row.get("path");
183            freed_bytes += row.get::<_, i64>("size_bytes");
184            let file_path = format!("uploads/{}", path);
185            if let Err(e) = tokio::fs::remove_file(&file_path).await {
186                // NotFound 可容忍(文件可能已被手动删);其他错误计入 failures,
187                // DB 行照删——残留文件由重建索引的反向语义兜底。
188                if e.kind() != std::io::ErrorKind::NotFound {
189                    tracing::warn!("Batch delete: remove file failed ({}): {}", file_path, e);
190                    failures += 1;
191                }
192            }
193            crate::api::image::invalidate_asset_caches(&path).await;
194            delete_ids.push(id);
195        }
196
197        let deleted = if delete_ids.is_empty() {
198            0
199        } else {
200            client
201                .execute("DELETE FROM assets WHERE id = ANY($1)", &[&delete_ids])
202                .await
203                .map_err(AppError::query)?
204        };
205
206        let mut message = format!("已删除 {} 张素材", deleted);
207        if skipped > 0 {
208            message.push_str(&format!(",跳过 {} 张被引用", skipped));
209        }
210        Ok(BatchDeleteAssetsResponse {
211            success: true,
212            message,
213            deleted_count: deleted as i64,
214            skipped_referenced: skipped,
215            freed_bytes,
216            failures,
217        })
218    }
219    #[cfg(not(feature = "server"))]
220    unreachable!()
221}
222
223/// 一键清理孤儿素材:无引用且 created_at 早于 7 天保护窗。
224///
225/// 逐项删文件(容忍单项失败),最后批量删 DB 行。
226/// 返回清理数量、释放字节数与文件删除失败数。
227#[server(PurgeOrphanAssets, "/api")]
228pub async fn purge_orphan_assets() -> Result<PurgeOrphansResponse, ServerFnError> {
229    #[cfg(feature = "server")]
230    {
231        use crate::api::auth::get_current_admin_user;
232        use crate::api::error::AppError;
233        use crate::db::pool::get_conn;
234
235        let _admin = get_current_admin_user().await?;
236        let client = get_conn().await.map_err(AppError::db_conn)?;
237
238        let rows = client
239            .query(
240                "SELECT a.id AS id, a.path, a.size_bytes FROM assets a \
241                 WHERE NOT EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id) \
242                   AND a.created_at < NOW() - make_interval(days => $1)",
243                &[&super::list::PURGE_GRACE_DAYS],
244            )
245            .await
246            .map_err(AppError::query)?;
247
248        if rows.is_empty() {
249            return Ok(PurgeOrphansResponse {
250                success: true,
251                message: "没有可清理的未引用素材".to_string(),
252                deleted_count: 0,
253                freed_bytes: 0,
254                failures: 0,
255            });
256        }
257
258        let mut ids: Vec<uuid::Uuid> = Vec::with_capacity(rows.len());
259        let mut freed_bytes: i64 = 0;
260        let mut failures: i64 = 0;
261        for row in &rows {
262            let id: uuid::Uuid = row.get("id");
263            let path: String = row.get("path");
264            freed_bytes += row.get::<_, i64>("size_bytes");
265            let file_path = format!("uploads/{}", path);
266            if let Err(e) = tokio::fs::remove_file(&file_path).await {
267                // NotFound 可容忍(文件可能已被手动删);其他错误计入 failures,
268                // DB 行照删——残留文件由重建索引的反向语义兜底。
269                if e.kind() != std::io::ErrorKind::NotFound {
270                    tracing::warn!("Purge: remove file failed ({}): {}", file_path, e);
271                    failures += 1;
272                }
273            }
274            crate::api::image::invalidate_asset_caches(&path).await;
275            ids.push(id);
276        }
277
278        let deleted = client
279            .execute("DELETE FROM assets WHERE id = ANY($1)", &[&ids])
280            .await
281            .map_err(AppError::query)?;
282
283        Ok(PurgeOrphansResponse {
284            success: true,
285            message: format!("已清理 {} 张未引用素材", deleted),
286            deleted_count: deleted as i64,
287            freed_bytes,
288            failures,
289        })
290    }
291    #[cfg(not(feature = "server"))]
292    unreachable!()
293}