Skip to main content

yggdrasil/api/assets/
delete.rs

1//! 素材删除与孤儿清理接口。
2//!
3//! 删除保护:被任何文章(含回收站文章)、笔记、评论(含回收站评论)或头像引用的素材禁删;
4//! 孤儿素材硬删除(文件 + DB 行 + 派生缓存)。一键清理仅作用于
5//! 无引用且超过 7 天保护窗的素材(保护未保存草稿的引用)。
6//! Leptos server function,注册在 `/api` 路径下,仅 admin 可用。
7
8use crate::api::error::ApiError;
9use leptos::prelude::*;
10
11use super::types::{AssetOpResponse, BatchDeleteAssetsResponse, PurgeOrphansResponse};
12
13/// 更新素材 alt(管理性备注,不回写已有文章 HTML)。
14#[server(UpdateAssetAlt, endpoint = "update_asset_alt", input = leptos::server_fn::codec::Json)]
15pub async fn update_asset_alt(id: String, alt: String) -> Result<AssetOpResponse, ApiError> {
16    crate::api::auth::get_current_admin_user().await?;
17    update_asset_alt_impl(id, alt).await
18}
19
20/// 共享素材操作;调用方须先完成管理员鉴权。
21#[cfg(feature = "server")]
22pub(crate) async fn update_asset_alt_impl(
23    id: String,
24    alt: String,
25) -> Result<AssetOpResponse, ApiError> {
26    use crate::api::error::AppError;
27    use crate::db::pool::get_conn;
28
29    let client = get_conn().await.map_err(AppError::db_conn)?;
30
31    // id 在边界处从 String 解析为 Uuid(非法 id 属业务错误,不走 500)。
32    let asset_uuid = match uuid::Uuid::parse_str(&id) {
33        Ok(u) => u,
34        Err(_) => return Ok(AssetOpResponse::err("素材 id 非法".to_string())),
35    };
36
37    let alt = alt.trim().to_string();
38    let updated = client
39        .execute(
40            "UPDATE assets SET alt = NULLIF($2, ''), updated_at = NOW() WHERE id = $1",
41            &[&asset_uuid, &alt],
42        )
43        .await
44        .map_err(AppError::query)?;
45
46    if updated == 0 {
47        return Ok(AssetOpResponse::err("素材不存在".to_string()));
48    }
49    Ok(AssetOpResponse::ok("alt 已更新".to_string()))
50}
51
52/// 删除单张素材。
53///
54/// 引用中 → `Ok(success:false)` + 引用文章列表(业务拒绝不走 Err);
55/// 孤儿 → 物理删除文件、DB 行(refs 级联)与派生缓存。
56#[server(DeleteAsset, endpoint = "delete_asset", input = leptos::server_fn::codec::Json)]
57pub async fn delete_asset(id: String) -> Result<AssetOpResponse, ApiError> {
58    crate::api::auth::get_current_admin_user().await?;
59    delete_asset_impl(id).await
60}
61
62/// 共享素材操作;调用方须先完成管理员鉴权。
63#[cfg(feature = "server")]
64pub(crate) async fn delete_asset_impl(id: String) -> Result<AssetOpResponse, ApiError> {
65    use crate::api::error::AppError;
66    use crate::db::pool::get_conn;
67    use crate::models::asset::{AssetRef, AssetRefPostStatus};
68
69    let mut client = get_conn().await.map_err(AppError::db_conn)?;
70    // 锁住素材行,令并发文章保存或评论/头像写入在删除提交前排队,
71    // 避免引用检查后插入新引用再被 ON DELETE CASCADE 静默移除。
72    let tx = client.transaction().await.map_err(AppError::tx)?;
73
74    let asset_uuid = match uuid::Uuid::parse_str(&id) {
75        Ok(u) => u,
76        Err(_) => return Ok(AssetOpResponse::err("素材 id 非法".to_string())),
77    };
78
79    let row = tx
80        .query_opt(
81            "SELECT id AS id, path, filename FROM assets WHERE id = $1 FOR UPDATE",
82            &[&asset_uuid],
83        )
84        .await
85        .map_err(AppError::query)?;
86    let Some(row) = row else {
87        return Ok(AssetOpResponse::err("素材不存在".to_string()));
88    };
89    let path: String = row.get("path");
90
91    let note_reference: bool = tx
92        .query_one(
93            "SELECT EXISTS(SELECT 1 FROM note_asset_refs WHERE asset_id=$1)",
94            &[&asset_uuid],
95        )
96        .await
97        .map_err(AppError::query)?
98        .get(0);
99    if note_reference {
100        return Ok(AssetOpResponse::err(
101            "该素材被笔记或历史版本引用,无法删除".to_string(),
102        ));
103    }
104
105    // 引用检查:含回收站文章(其 purge 时 refs 级联删,图自然变孤儿)。
106    let ref_rows = tx
107        .query(
108            "SELECT p.id, p.title, p.slug, p.status, p.deleted_at \
109             FROM asset_refs r JOIN posts p ON p.id = r.post_id \
110             WHERE r.asset_id = $1 ORDER BY p.id",
111            &[&asset_uuid],
112        )
113        .await
114        .map_err(AppError::query)?;
115    if !ref_rows.is_empty() {
116        let refs: Vec<AssetRef> = ref_rows
117            .iter()
118            .map(|r| {
119                let status = AssetRefPostStatus::resolve(
120                    r.get::<_, &str>(3),
121                    r.get::<_, Option<chrono::DateTime<chrono::Utc>>>(4),
122                );
123                AssetRef::Post {
124                    post_id: r.get(0),
125                    title: r.get(1),
126                    slug: r.get(2),
127                    status,
128                }
129            })
130            .collect();
131        return Ok(AssetOpResponse {
132            success: false,
133            message: format!("该素材正被 {} 篇文章引用,无法删除", refs.len()),
134            refs,
135        });
136    }
137
138    // 评论与头像引用共用一次查询,避免单删路径额外往返数据库。
139    let reference_row = tx
140        .query_one(
141            &format!(
142                "SELECT {comment_ref} AS comment_referenced, \
143                        {avatar_ref} AS avatar_referenced \
144                 FROM assets a WHERE a.id = $1",
145                comment_ref = super::COMMENT_REF_CLAUSE,
146                avatar_ref = super::AVATAR_REF_CLAUSE,
147            ),
148            &[&asset_uuid],
149        )
150        .await
151        .map_err(AppError::query)?;
152    if reference_row.get::<_, bool>("comment_referenced") {
153        return Ok(AssetOpResponse {
154            success: false,
155            message: "该素材正被评论引用,无法删除".to_string(),
156            refs: Vec::new(),
157        });
158    }
159
160    if reference_row.get::<_, bool>("avatar_referenced") {
161        return Ok(AssetOpResponse {
162            success: false,
163            message: "该素材正被头像引用,无法删除".to_string(),
164            refs: Vec::new(),
165        });
166    }
167
168    // 先提交 DB 删除,再清理文件;文件删除失败只会留下可由重建索引清理的孤儿文件,
169    // 不会在事务失败时丢失仍被引用的实体文件。
170    let file_path = format!("uploads/{}", path);
171    tx.execute("DELETE FROM assets WHERE id = $1", &[&asset_uuid])
172        .await
173        .map_err(AppError::tx)?;
174    tx.commit().await.map_err(AppError::tx)?;
175    if let Err(e) = tokio::fs::remove_file(&file_path).await {
176        if e.kind() != std::io::ErrorKind::NotFound {
177            tracing::warn!("Remove asset file failed ({}): {}", file_path, e);
178        }
179    }
180    crate::api::image::invalidate_asset_caches(&path).await;
181
182    Ok(AssetOpResponse::ok("已删除".to_string()))
183}
184
185/// 批量删除素材。
186///
187/// 逐项做引用检查:被引用的跳过(保护语义与单删一致),无引用的硬删除
188/// (文件 + DB 行 + 派生缓存)。非法 id 计入失败,不存在的 id 静默忽略。
189/// 返回删除/跳过/失败统计,供批量操作条展示。
190#[server(BatchDeleteAssets, endpoint = "batch_delete_assets", input = leptos::server_fn::codec::Json)]
191pub async fn batch_delete_assets(ids: Vec<String>) -> Result<BatchDeleteAssetsResponse, ApiError> {
192    crate::api::auth::get_current_admin_user().await?;
193    batch_delete_assets_impl(ids).await
194}
195
196/// 共享素材操作;调用方须先完成管理员鉴权。
197#[cfg(feature = "server")]
198pub(crate) async fn batch_delete_assets_impl(
199    ids: Vec<String>,
200) -> Result<BatchDeleteAssetsResponse, ApiError> {
201    use crate::api::error::AppError;
202    use crate::db::pool::get_conn;
203
204    // id 在边界处从 String 解析为 Uuid;非法 id 属业务错误,计入 failures 不走 500。
205    let mut uuids: Vec<uuid::Uuid> = Vec::with_capacity(ids.len());
206    let mut failures: i64 = 0;
207    for id in &ids {
208        match uuid::Uuid::parse_str(id) {
209            Ok(u) => uuids.push(u),
210            Err(_) => failures += 1,
211        }
212    }
213    if uuids.is_empty() {
214        return Ok(BatchDeleteAssetsResponse {
215            success: false,
216            message: "没有可删除的素材".to_string(),
217            deleted_count: 0,
218            skipped_referenced: 0,
219            freed_bytes: 0,
220            failures,
221        });
222    }
223
224    let mut client = get_conn().await.map_err(AppError::db_conn)?;
225    let tx = client.transaction().await.map_err(AppError::tx)?;
226
227    let locked = tx
228        .query(
229            "SELECT id FROM assets WHERE id = ANY($1) ORDER BY id FOR UPDATE",
230            &[&uuids],
231        )
232        .await
233        .map_err(AppError::query)?;
234    let locked: Vec<uuid::Uuid> = locked.iter().map(|r| r.get(0)).collect();
235    let deleted = delete_unreferenced_locked(&tx, &locked).await?;
236    tx.commit().await.map_err(AppError::tx)?;
237
238    let skipped = (locked.len() - deleted.len()) as i64;
239    let freed_bytes = deleted.iter().map(|(_, size)| size).sum();
240    let deleted_count = deleted.len();
241    failures += remove_asset_files(deleted).await;
242
243    let mut message = format!("已删除 {} 张素材", deleted_count);
244    if skipped > 0 {
245        message.push_str(&format!(",跳过 {} 张被引用", skipped));
246    }
247    Ok(BatchDeleteAssetsResponse {
248        success: true,
249        message,
250        deleted_count: deleted_count as i64,
251        skipped_referenced: skipped,
252        freed_bytes,
253        failures,
254    })
255}
256
257/// 删除已被本事务 `FOR UPDATE` 锁住、且此刻仍无引用的素材,返回 (path, size_bytes)。
258///
259/// 必须与加锁分成两条语句:READ COMMITTED 下,若加锁语句等待的是并发文章保存
260/// 插入 asset_refs 时持有的外键锁,拿到锁后 Postgres 不会重新求值 WHERE,
261/// 加锁语句里的 `NOT {ASSET_REF_CLAUSE}` 仍基于旧快照,会把刚提交的引用随
262/// ON DELETE CASCADE 一起删掉。本语句取新快照复核;锁已持有,之后的新引用
263/// 只能在删除提交后因外键失败而报错,不会被静默删除。
264#[cfg(feature = "server")]
265async fn delete_unreferenced_locked(
266    tx: &deadpool_postgres::Transaction<'_>,
267    locked_ids: &[uuid::Uuid],
268) -> Result<Vec<(String, i64)>, crate::api::error::AppError> {
269    use crate::api::error::AppError;
270
271    if locked_ids.is_empty() {
272        return Ok(Vec::new());
273    }
274    let rows = tx
275        .query(
276            &format!(
277                "DELETE FROM assets a WHERE a.id = ANY($1) AND NOT {asset_ref} \
278                 RETURNING a.path, a.size_bytes",
279                asset_ref = super::ASSET_REF_CLAUSE
280            ),
281            &[&locked_ids],
282        )
283        .await
284        .map_err(AppError::tx)?;
285    Ok(rows.iter().map(|r| (r.get(0), r.get(1))).collect())
286}
287
288/// DB 删除提交后清理文件与派生缓存,返回非 NotFound 的文件删除失败数;
289/// 残留文件由重建索引兜底。
290#[cfg(feature = "server")]
291async fn remove_asset_files(deleted: Vec<(String, i64)>) -> i64 {
292    let mut failures = 0;
293    for (path, _) in deleted {
294        let file_path = format!("uploads/{}", path);
295        if let Err(e) = tokio::fs::remove_file(&file_path).await {
296            if e.kind() != std::io::ErrorKind::NotFound {
297                tracing::warn!("Remove asset file failed ({}): {}", file_path, e);
298                failures += 1;
299            }
300        }
301        crate::api::image::invalidate_asset_caches(&path).await;
302    }
303    failures
304}
305
306/// 一键清理孤儿素材:无引用且 created_at 早于 7 天保护窗。
307///
308/// 先提交 DB 删除,再逐项删文件(容忍单项失败)。
309/// 返回清理数量、释放字节数与文件删除失败数。
310#[server(PurgeOrphanAssets, endpoint = "purge_orphan_assets", input = leptos::server_fn::codec::Json)]
311pub async fn purge_orphan_assets() -> Result<PurgeOrphansResponse, ApiError> {
312    crate::api::auth::get_current_admin_user().await?;
313    purge_orphan_assets_impl(super::list::PURGE_GRACE_DAYS).await
314}
315
316/// 删除超过 `retention_days` 的孤儿素材;管理端与定时任务共用。
317/// 调用方须先完成管理员鉴权(定时任务除外)。
318#[cfg(feature = "server")]
319pub(crate) async fn purge_orphan_assets_impl(
320    retention_days: i32,
321) -> Result<PurgeOrphansResponse, ApiError> {
322    use crate::api::error::AppError;
323    use crate::db::pool::get_conn;
324
325    let mut client = get_conn().await.map_err(AppError::db_conn)?;
326    let tx = client.transaction().await.map_err(AppError::tx)?;
327
328    // 锁住候选行,令并发文章保存的外键检查在删除提交前排队;引用在删除语句里复核。
329    let rows = tx
330        .query(
331            &format!(
332                "SELECT a.id FROM assets a \
333                 WHERE NOT {asset_ref} \
334                   AND a.created_at < NOW() - make_interval(days => $1) \
335                 ORDER BY a.id \
336                 FOR UPDATE",
337                asset_ref = super::ASSET_REF_CLAUSE
338            ),
339            &[&retention_days],
340        )
341        .await
342        .map_err(AppError::query)?;
343    let ids: Vec<uuid::Uuid> = rows.iter().map(|r| r.get(0)).collect();
344    let deleted = delete_unreferenced_locked(&tx, &ids).await?;
345    tx.commit().await.map_err(AppError::tx)?;
346
347    if deleted.is_empty() {
348        return Ok(PurgeOrphansResponse {
349            success: true,
350            message: "没有可清理的未引用素材".to_string(),
351            deleted_count: 0,
352            freed_bytes: 0,
353            failures: 0,
354        });
355    }
356
357    let freed_bytes = deleted.iter().map(|(_, size)| size).sum();
358    let deleted_count = deleted.len() as i64;
359    let failures = remove_asset_files(deleted).await;
360
361    Ok(PurgeOrphansResponse {
362        success: true,
363        message: format!("已清理 {} 张未引用素材", deleted_count),
364        deleted_count,
365        freed_bytes,
366        failures,
367    })
368}
369
370#[cfg(all(test, feature = "server"))]
371mod tests {
372    use super::purge_orphan_assets_impl;
373    use crate::db::pool::get_conn;
374
375    #[test]
376    #[ignore = "requires disposable DATABASE_URL database ygg_mcp_test"]
377    fn purge_keeps_reference_committed_while_waiting_for_lock() {
378        crate::db::TEST_DATABASE_RUNTIME.block_on(purge_keeps_reference_impl());
379    }
380
381    /// 文章保存先插入引用(持有外键锁未提交),清理随后锁同一行等待;
382    /// 保存提交后,清理不得把刚提交的引用随素材级联删除。
383    async fn purge_keeps_reference_impl() {
384        let _guard = crate::db::TEST_DATABASE_LOCK.lock().await;
385        let mut client = get_conn().await.unwrap();
386        let database: String = client
387            .query_one("SELECT current_database()", &[])
388            .await
389            .unwrap()
390            .get(0);
391        assert_eq!(database, "ygg_mcp_test", "requires isolated test database");
392        crate::db::migrate::run_on_conn(&mut client).await.unwrap();
393        client
394            .batch_execute(
395                "TRUNCATE users RESTART IDENTITY CASCADE;
396                 TRUNCATE assets CASCADE;
397                 INSERT INTO users(id,username,email,password_hash,role)
398                   VALUES (1,'purge-owner','purge@test.invalid','unused','admin');
399                 INSERT INTO posts(id,author_id,title,slug,content_md,status)
400                   VALUES (1,1,'引用','purge-race','正文','draft');
401                 INSERT INTO assets(id,path,filename,mime,size_bytes,width,height,content_hash,created_at)
402                 VALUES
403                   (gen_random_uuid(),'purge-race-kept.webp','kept.webp','image/webp',3,1,1,'kept',NOW()-INTERVAL '30 days'),
404                   (gen_random_uuid(),'purge-race-orphan.webp','orphan.webp','image/webp',5,1,1,'orphan',NOW()-INTERVAL '30 days');",
405            )
406            .await
407            .unwrap();
408
409        let mut saver = get_conn().await.unwrap();
410        let save = saver.transaction().await.unwrap();
411        save.execute(
412            "INSERT INTO asset_refs(asset_id,post_id) \
413             SELECT id, 1 FROM assets WHERE path = 'purge-race-kept.webp'",
414            &[],
415        )
416        .await
417        .unwrap();
418
419        let purge = tokio::spawn(purge_orphan_assets_impl(7));
420        let mut waited = false;
421        for _ in 0..200 {
422            let blocked: i64 = client
423                .query_one(
424                    "SELECT count(*) FROM pg_stat_activity \
425                     WHERE datname = current_database() AND wait_event_type = 'Lock'",
426                    &[],
427                )
428                .await
429                .unwrap()
430                .get(0);
431            if blocked > 0 {
432                waited = true;
433                break;
434            }
435            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
436        }
437        assert!(waited, "清理应在被引用素材的行锁上等待");
438        save.commit().await.unwrap();
439
440        let res = purge.await.unwrap().unwrap();
441        assert_eq!((res.deleted_count, res.freed_bytes), (1, 5));
442        let remaining: Vec<String> = client
443            .query(
444                "SELECT a.path FROM assets a JOIN asset_refs r ON r.asset_id = a.id",
445                &[],
446            )
447            .await
448            .unwrap()
449            .iter()
450            .map(|r| r.get(0))
451            .collect();
452        assert_eq!(remaining, ["purge-race-kept.webp"]);
453    }
454}