yggdrasil/api/assets/
delete.rs1use 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 #[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 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#[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 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 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#[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 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 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 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#[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 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}