1use crate::api::error::ApiError;
9use leptos::prelude::*;
10
11use super::types::{AssetOpResponse, BatchDeleteAssetsResponse, PurgeOrphansResponse};
12
13#[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#[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 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#[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#[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 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 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 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 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#[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#[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 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#[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#[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#[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#[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 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 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}