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