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 #[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, AssetRefPostStatus};
60
61 let _admin = get_current_admin_user().await?;
62 let mut client = get_conn().await.map_err(AppError::db_conn)?;
63 let tx = client.transaction().await.map_err(AppError::tx)?;
66
67 let asset_uuid = match uuid::Uuid::parse_str(&id) {
68 Ok(u) => u,
69 Err(_) => return Ok(AssetOpResponse::err("素材 id 非法".to_string())),
70 };
71
72 let row = tx
73 .query_opt(
74 "SELECT id AS id, path, filename FROM assets WHERE id = $1 FOR UPDATE",
75 &[&asset_uuid],
76 )
77 .await
78 .map_err(AppError::query)?;
79 let Some(row) = row else {
80 return Ok(AssetOpResponse::err("素材不存在".to_string()));
81 };
82 let path: String = row.get("path");
83
84 let ref_rows = tx
86 .query(
87 "SELECT p.id, p.title, p.slug, p.status, p.deleted_at \
88 FROM asset_refs r JOIN posts p ON p.id = r.post_id \
89 WHERE r.asset_id = $1 ORDER BY p.id",
90 &[&asset_uuid],
91 )
92 .await
93 .map_err(AppError::query)?;
94 if !ref_rows.is_empty() {
95 let refs: Vec<AssetRef> = ref_rows
96 .iter()
97 .map(|r| {
98 let status = AssetRefPostStatus::resolve(
99 r.get::<_, &str>(3),
100 r.get::<_, Option<chrono::DateTime<chrono::Utc>>>(4),
101 );
102 AssetRef::Post {
103 post_id: r.get(0),
104 title: r.get(1),
105 slug: r.get(2),
106 status,
107 }
108 })
109 .collect();
110 return Ok(AssetOpResponse {
111 success: false,
112 message: format!("该素材正被 {} 篇文章引用,无法删除", refs.len()),
113 refs,
114 });
115 }
116
117 let reference_row = tx
119 .query_one(
120 &format!(
121 "SELECT {comment_ref} AS comment_referenced, \
122 {avatar_ref} AS avatar_referenced \
123 FROM assets a WHERE a.id = $1",
124 comment_ref = super::COMMENT_REF_CLAUSE,
125 avatar_ref = super::AVATAR_REF_CLAUSE,
126 ),
127 &[&asset_uuid],
128 )
129 .await
130 .map_err(AppError::query)?;
131 if reference_row.get::<_, bool>("comment_referenced") {
132 return Ok(AssetOpResponse {
133 success: false,
134 message: "该素材正被评论引用,无法删除".to_string(),
135 refs: Vec::new(),
136 });
137 }
138
139 if reference_row.get::<_, bool>("avatar_referenced") {
140 return Ok(AssetOpResponse {
141 success: false,
142 message: "该素材正被头像引用,无法删除".to_string(),
143 refs: Vec::new(),
144 });
145 }
146
147 let file_path = format!("uploads/{}", path);
150 tx.execute("DELETE FROM assets WHERE id = $1", &[&asset_uuid])
151 .await
152 .map_err(AppError::tx)?;
153 tx.commit().await.map_err(AppError::tx)?;
154 if let Err(e) = tokio::fs::remove_file(&file_path).await {
155 if e.kind() != std::io::ErrorKind::NotFound {
156 tracing::warn!("Remove asset file failed ({}): {}", file_path, e);
157 }
158 }
159 crate::api::image::invalidate_asset_caches(&path).await;
160
161 Ok(AssetOpResponse::ok("已删除".to_string()))
162 }
163 #[cfg(not(feature = "server"))]
164 unreachable!()
165}
166
167#[server(BatchDeleteAssets, "/api")]
173pub async fn batch_delete_assets(
174 ids: Vec<String>,
175) -> Result<BatchDeleteAssetsResponse, ServerFnError> {
176 #[cfg(feature = "server")]
177 {
178 use crate::api::auth::get_current_admin_user;
179 use crate::api::error::AppError;
180 use crate::db::pool::get_conn;
181
182 let _admin = get_current_admin_user().await?;
183
184 let mut uuids: Vec<uuid::Uuid> = Vec::with_capacity(ids.len());
186 let mut failures: i64 = 0;
187 for id in &ids {
188 match uuid::Uuid::parse_str(id) {
189 Ok(u) => uuids.push(u),
190 Err(_) => failures += 1,
191 }
192 }
193 if uuids.is_empty() {
194 return Ok(BatchDeleteAssetsResponse {
195 success: false,
196 message: "没有可删除的素材".to_string(),
197 deleted_count: 0,
198 skipped_referenced: 0,
199 freed_bytes: 0,
200 failures,
201 });
202 }
203
204 let mut client = get_conn().await.map_err(AppError::db_conn)?;
205 let tx = client.transaction().await.map_err(AppError::tx)?;
206
207 let rows = tx
209 .query(
210 &format!(
211 "SELECT a.id AS id, a.path, a.size_bytes, \
212 {asset_ref} AS referenced \
213 FROM assets a WHERE a.id = ANY($1) FOR UPDATE",
214 asset_ref = super::ASSET_REF_CLAUSE
215 ),
216 &[&uuids],
217 )
218 .await
219 .map_err(AppError::query)?;
220
221 let mut delete_ids: Vec<uuid::Uuid> = Vec::with_capacity(rows.len());
222 let mut files_to_remove: Vec<String> = Vec::with_capacity(rows.len());
223 let mut freed_bytes: i64 = 0;
224 let mut skipped: i64 = 0;
225 for row in &rows {
226 let id: uuid::Uuid = row.get("id");
227 if row.get::<_, bool>("referenced") {
228 skipped += 1;
229 continue;
230 }
231 let path: String = row.get("path");
232 freed_bytes += row.get::<_, i64>("size_bytes");
233 files_to_remove.push(path);
234 delete_ids.push(id);
235 }
236
237 let deleted = if delete_ids.is_empty() {
238 0
239 } else {
240 tx.execute("DELETE FROM assets WHERE id = ANY($1)", &[&delete_ids])
241 .await
242 .map_err(AppError::tx)?
243 };
244 tx.commit().await.map_err(AppError::tx)?;
245
246 for path in files_to_remove {
248 let file_path = format!("uploads/{}", path);
249 if let Err(e) = tokio::fs::remove_file(&file_path).await {
250 if e.kind() != std::io::ErrorKind::NotFound {
251 tracing::warn!("Batch delete: remove file failed ({}): {}", file_path, e);
252 failures += 1;
253 }
254 }
255 crate::api::image::invalidate_asset_caches(&path).await;
256 }
257
258 let mut message = format!("已删除 {} 张素材", deleted);
259 if skipped > 0 {
260 message.push_str(&format!(",跳过 {} 张被引用", skipped));
261 }
262 Ok(BatchDeleteAssetsResponse {
263 success: true,
264 message,
265 deleted_count: deleted as i64,
266 skipped_referenced: skipped,
267 freed_bytes,
268 failures,
269 })
270 }
271 #[cfg(not(feature = "server"))]
272 unreachable!()
273}
274
275#[server(PurgeOrphanAssets, "/api")]
280pub async fn purge_orphan_assets() -> Result<PurgeOrphansResponse, ServerFnError> {
281 #[cfg(feature = "server")]
282 {
283 use crate::api::auth::get_current_admin_user;
284 use crate::api::error::AppError;
285 use crate::db::pool::get_conn;
286
287 let _admin = get_current_admin_user().await?;
288 let mut client = get_conn().await.map_err(AppError::db_conn)?;
289 let tx = client.transaction().await.map_err(AppError::tx)?;
290
291 let rows = tx
293 .query(
294 &format!(
295 "SELECT a.id AS id, a.path, a.size_bytes FROM assets a \
296 WHERE NOT {asset_ref} \
297 AND a.created_at < NOW() - make_interval(days => $1) \
298 FOR UPDATE",
299 asset_ref = super::ASSET_REF_CLAUSE
300 ),
301 &[&super::list::PURGE_GRACE_DAYS],
302 )
303 .await
304 .map_err(AppError::query)?;
305
306 if rows.is_empty() {
307 tx.commit().await.map_err(AppError::tx)?;
308 return Ok(PurgeOrphansResponse {
309 success: true,
310 message: "没有可清理的未引用素材".to_string(),
311 deleted_count: 0,
312 freed_bytes: 0,
313 failures: 0,
314 });
315 }
316
317 let mut ids: Vec<uuid::Uuid> = Vec::with_capacity(rows.len());
318 let mut paths: Vec<String> = Vec::with_capacity(rows.len());
319 let mut freed_bytes: i64 = 0;
320 for row in &rows {
321 ids.push(row.get("id"));
322 paths.push(row.get("path"));
323 freed_bytes += row.get::<_, i64>("size_bytes");
324 }
325
326 let deleted = tx
327 .execute("DELETE FROM assets WHERE id = ANY($1)", &[&ids])
328 .await
329 .map_err(AppError::tx)?;
330 tx.commit().await.map_err(AppError::tx)?;
331
332 let mut failures: i64 = 0;
333 for path in paths {
334 let file_path = format!("uploads/{}", path);
335 if let Err(e) = tokio::fs::remove_file(&file_path).await {
336 if e.kind() != std::io::ErrorKind::NotFound {
337 tracing::warn!("Purge: remove file failed ({}): {}", file_path, e);
338 failures += 1;
339 }
340 }
341 crate::api::image::invalidate_asset_caches(&path).await;
342 }
343
344 Ok(PurgeOrphansResponse {
345 success: true,
346 message: format!("已清理 {} 张未引用素材", deleted),
347 deleted_count: deleted as i64,
348 freed_bytes,
349 failures,
350 })
351 }
352 #[cfg(not(feature = "server"))]
353 unreachable!()
354}