Skip to main content

yggdrasil/api/assets/
list.rs

1//! 素材分页列表接口。
2//!
3//! 按引用状态(全部/引用中/孤儿)筛选、按文件名/alt 搜索、按时间或大小排序。
4//! 同时返回各筛选维度计数与可清理孤儿统计,供 tabs 与「清理孤儿」按钮展示。
5//! Dioxus server function,注册在 `/api` 路径下,仅 admin 可用。
6
7use dioxus::prelude::*;
8
9use super::types::AssetListResponse;
10use crate::models::asset::{AssetFilter, AssetSort};
11
12/// 每页素材数(网格 6 列 x 10 行)。
13#[cfg(feature = "server")]
14const PER_PAGE: i64 = 60;
15
16/// 孤儿清理保护窗:上传不满 7 天的无引用素材不可一键清理
17/// (保护尚未首次保存的草稿引用)。
18#[cfg(feature = "server")]
19pub(crate) const PURGE_GRACE_DAYS: i32 = 7;
20
21/// 获取素材分页列表。
22#[server(ListAssets, "/api")]
23pub async fn list_assets(
24    filter: AssetFilter,
25    query: String,
26    sort: AssetSort,
27    page: i32,
28) -> Result<AssetListResponse, ServerFnError> {
29    #[cfg(feature = "server")]
30    {
31        use crate::api::auth::get_current_admin_user;
32        use crate::api::error::AppError;
33        use crate::db::pool::get_conn;
34        use crate::models::asset::{Asset, AssetDto, AssetRef};
35
36        let _admin = get_current_admin_user().await?;
37
38        let client = get_conn().await.map_err(AppError::db_conn)?;
39
40        let page = page.max(1);
41        let offset: i64 = (page as i64 - 1) * PER_PAGE;
42        let query = query.trim().to_string();
43
44        // 筛选/搜索条件统一拼进 WHERE;参数按出现顺序编号。
45        // 引用状态用 EXISTS 子查询,搜索用 ILIKE 转义通配符。
46        let mut conditions: Vec<String> = Vec::new();
47        let mut params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = Vec::new();
48        match filter {
49            AssetFilter::Used => {
50                conditions.push(
51                    "EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id)".to_string(),
52                );
53            }
54            AssetFilter::Orphan => {
55                conditions.push(
56                    "NOT EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id)".to_string(),
57                );
58            }
59            AssetFilter::All => {}
60        }
61        if !query.is_empty() {
62            params.push(&query);
63            conditions.push(format!(
64                "(a.filename ILIKE '%' || ${} || '%' OR a.alt ILIKE '%' || ${} || '%')",
65                params.len(),
66                params.len()
67            ));
68        }
69        let where_clause = if conditions.is_empty() {
70            String::new()
71        } else {
72            format!("WHERE {}", conditions.join(" AND "))
73        };
74
75        let order_clause = match sort {
76            AssetSort::CreatedDesc => "a.created_at DESC, a.id",
77            AssetSort::SizeDesc => "a.size_bytes DESC, a.id",
78        };
79
80        // 列表查询:ref_count 用相关子查询一次带出。
81        // const 不能取引用(内联后借临时值会垂悬),绑定到局部变量再进参数列表。
82        let per_page = PER_PAGE;
83        params.push(&per_page);
84        let limit_idx = params.len();
85        params.push(&offset);
86        let offset_idx = params.len();
87        let rows = client
88            .query(
89                &format!(
90                    "SELECT a.id AS id, a.path, a.filename, a.mime, a.size_bytes, \
91                            a.width, a.height, a.alt, a.created_at, \
92                            (SELECT COUNT(*) FROM asset_refs r WHERE r.asset_id = a.id) AS ref_count \
93                     FROM assets a {where_clause} \
94                     ORDER BY {order_clause} LIMIT ${limit_idx} OFFSET ${offset_idx}"
95                ),
96                &params,
97            )
98            .await
99            .map_err(AppError::query)?;
100
101        let total: i64 = client
102            .query_one(
103                &format!("SELECT COUNT(*) FROM assets a {where_clause}"),
104                &params[..params.len() - 2],
105            )
106            .await
107            .map_err(AppError::query)?
108            .get(0);
109
110        // 汇总计数:tabs 与「清理孤儿」按钮徽标。不受筛选/搜索影响,始终全局。
111        let summary = client
112            .query_one(
113                "SELECT \
114                    COUNT(*) FILTER (WHERE EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id)), \
115                    COUNT(*) FILTER (WHERE NOT EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id)), \
116                    COUNT(*) FILTER (WHERE NOT EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id) \
117                        AND a.created_at < NOW() - make_interval(days => $1)), \
118                    COALESCE(SUM(a.size_bytes) FILTER (WHERE NOT EXISTS (SELECT 1 FROM asset_refs r WHERE r.asset_id = a.id) \
119                        AND a.created_at < NOW() - make_interval(days => $1)), 0)::bigint \
120                 FROM assets a",
121                &[&PURGE_GRACE_DAYS],
122            )
123            .await
124            .map_err(AppError::query)?;
125
126        // 本页素材的引用文章(第二查询,避免 JOIN  fan-out 与分页错位)。
127        let ids: Vec<uuid::Uuid> = rows.iter().map(|r| r.get::<_, uuid::Uuid>("id")).collect();
128        let mut refs_map: std::collections::HashMap<String, Vec<AssetRef>> =
129            std::collections::HashMap::new();
130        if !ids.is_empty() {
131            let ref_rows = client
132                .query(
133                    "SELECT r.asset_id AS asset_id, p.id AS post_id, p.title \
134                     FROM asset_refs r JOIN posts p ON p.id = r.post_id \
135                     WHERE r.asset_id = ANY($1) \
136                     ORDER BY p.id",
137                    &[&ids],
138                )
139                .await
140                .map_err(AppError::query)?;
141            for rr in ref_rows {
142                let asset_key: String = rr.get::<_, uuid::Uuid>("asset_id").to_string();
143                refs_map.entry(asset_key).or_default().push(AssetRef {
144                    post_id: rr.get("post_id"),
145                    title: rr.get("title"),
146                });
147            }
148        }
149
150        let assets = rows
151            .into_iter()
152            .map(|row| {
153                let id: String = row.get::<_, uuid::Uuid>("id").to_string();
154                let refs = refs_map.remove(&id).unwrap_or_default();
155                AssetDto {
156                    asset: Asset {
157                        id,
158                        path: row.get("path"),
159                        filename: row.get("filename"),
160                        mime: row.get("mime"),
161                        size_bytes: row.get("size_bytes"),
162                        width: row.get("width"),
163                        height: row.get("height"),
164                        alt: row.get("alt"),
165                        created_at: row.get("created_at"),
166                    },
167                    ref_count: row.get("ref_count"),
168                    refs,
169                }
170            })
171            .collect();
172
173        Ok(AssetListResponse {
174            assets,
175            total,
176            used_count: summary.get(0),
177            orphan_count: summary.get(1),
178            purgeable_count: summary.get(2),
179            purgeable_bytes: summary.get(3),
180        })
181    }
182    #[cfg(not(feature = "server"))]
183    unreachable!()
184}