Skip to main content

yggdrasil/api/
friends.rs

1//! 友链管理:Dioxus server functions。
2//!
3//! 前台 `/friends` 读取可见友链(`list_friend_links`,走 moka 缓存 + SSR 缓存失效,
4//! 与文章/标签同架构);后台 `/admin/friends` 做完整 CRUD。
5//! 鉴权走 cookie session(`get_current_admin_user`),与其它后台 server-fn 一致。
6//!
7//! 排序用整数 `sort_order`(越小越靠前);删除为物理删除(无回收站/软删除,
8//! 友链无审计需求)。
9
10use dioxus::prelude::*;
11
12use crate::models::friend_link::FriendLink;
13
14#[cfg(feature = "server")]
15use crate::api::error::AppError;
16
17/// 前台友链列表(仅活跃,按 sort_order 升序)。
18///
19/// 缓存命中直返;未命中查询 `friend_links` 活跃行后写缓存。公开接口,无需登录。
20#[server]
21pub async fn list_friend_links() -> Result<Vec<FriendLink>, ServerFnError> {
22    #[cfg(feature = "server")]
23    {
24        use crate::api::error::AppError;
25        use crate::cache;
26        use crate::db::pool::get_conn;
27
28        if let Some(links) = cache::get_friend_links().await {
29            return Ok(links);
30        }
31        let client = get_conn().await.map_err(AppError::db_conn)?;
32        let rows = client
33            .query(
34                "SELECT id, name, url, avatar_url, description, sort_order, is_active, \
35                        created_at, updated_at \
36                 FROM friend_links \
37                 WHERE is_active \
38                 ORDER BY sort_order, id",
39                &[],
40            )
41            .await
42            .map_err(AppError::query)?;
43        let links: Vec<FriendLink> = rows.iter().map(row_to_friend_link).collect();
44        cache::set_friend_links(links.clone()).await;
45        Ok(links)
46    }
47    #[cfg(not(feature = "server"))]
48    unreachable!()
49}
50
51/// 后台友链列表(含停用项,按 sort_order 升序)。仅 admin。
52#[server]
53pub async fn list_all_friend_links() -> Result<Vec<FriendLink>, 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
60        let _admin = get_current_admin_user().await?;
61        let client = get_conn().await.map_err(AppError::db_conn)?;
62        let rows = client
63            .query(
64                "SELECT id, name, url, avatar_url, description, sort_order, is_active, \
65                        created_at, updated_at \
66                 FROM friend_links \
67                 ORDER BY sort_order, id",
68                &[],
69            )
70            .await
71            .map_err(AppError::query)?;
72        Ok(rows.iter().map(row_to_friend_link).collect())
73    }
74    #[cfg(not(feature = "server"))]
75    unreachable!()
76}
77
78/// 新增友链。仅 admin。
79///
80/// 校验通过后 `INSERT ... RETURNING *`,并失效友链 moka 缓存 + `/friends` SSR 缓存
81/// + 递增全局世代号,保证前台下次访问立即看到新卡片。
82#[server]
83pub async fn create_friend_link(
84    name: String,
85    url: String,
86    avatar_url: Option<String>,
87    description: String,
88    sort_order: i32,
89) -> Result<FriendLink, ServerFnError> {
90    #[cfg(feature = "server")]
91    {
92        use crate::api::auth::get_current_admin_user;
93        use crate::api::error::AppError;
94        use crate::db::pool::get_conn;
95
96        let _admin = get_current_admin_user().await?;
97        let avatar_url = validate_link(&name, &url, avatar_url.as_deref(), &description)?;
98        let (name, url, description) = trim_fields(name, url, description);
99
100        let client = get_conn().await.map_err(AppError::db_conn)?;
101        let row = client
102            .query_one(
103                "INSERT INTO friend_links \
104                    (name, url, avatar_url, description, sort_order) \
105                 VALUES ($1, $2, $3, $4, $5) \
106                 RETURNING id, name, url, avatar_url, description, sort_order, is_active, \
107                           created_at, updated_at",
108                &[&name, &url, &avatar_url, &description, &sort_order],
109            )
110            .await
111            .map_err(AppError::query)?;
112        invalidate_friend_links_views();
113        Ok(row_to_friend_link(&row))
114    }
115    #[cfg(not(feature = "server"))]
116    unreachable!()
117}
118
119/// 更新友链(含启用状态)。仅 admin。
120///
121/// 行不存在返回 `AppError::NotFound`;成功后同样失效缓存与 SSR。
122#[server]
123pub async fn update_friend_link(
124    id: i32,
125    name: String,
126    url: String,
127    avatar_url: Option<String>,
128    description: String,
129    sort_order: i32,
130    is_active: bool,
131) -> Result<FriendLink, ServerFnError> {
132    #[cfg(feature = "server")]
133    {
134        use crate::api::auth::get_current_admin_user;
135        use crate::api::error::AppError;
136        use crate::db::pool::get_conn;
137
138        let _admin = get_current_admin_user().await?;
139        let avatar_url = validate_link(&name, &url, avatar_url.as_deref(), &description)?;
140        let (name, url, description) = trim_fields(name, url, description);
141
142        let client = get_conn().await.map_err(AppError::db_conn)?;
143        let row = client
144            .query_opt(
145                "UPDATE friend_links \
146                 SET name = $2, url = $3, avatar_url = $4, description = $5, \
147                     sort_order = $6, is_active = $7, updated_at = NOW() \
148                 WHERE id = $1 \
149                 RETURNING id, name, url, avatar_url, description, sort_order, is_active, \
150                           created_at, updated_at",
151                &[
152                    &id,
153                    &name,
154                    &url,
155                    &avatar_url,
156                    &description,
157                    &sort_order,
158                    &is_active,
159                ],
160            )
161            .await
162            .map_err(AppError::query)?;
163        let Some(row) = row else {
164            return Err(AppError::NotFound("友链不存在").into());
165        };
166        invalidate_friend_links_views();
167        Ok(row_to_friend_link(&row))
168    }
169    #[cfg(not(feature = "server"))]
170    unreachable!()
171}
172
173/// 删除友链(物理删除)。仅 admin。
174///
175/// id 不存在时静默成功(幂等删除),成功后失效缓存与 SSR。
176#[server]
177pub async fn delete_friend_link(id: i32) -> Result<(), ServerFnError> {
178    #[cfg(feature = "server")]
179    {
180        use crate::api::auth::get_current_admin_user;
181        use crate::api::error::AppError;
182        use crate::db::pool::get_conn;
183
184        let _admin = get_current_admin_user().await?;
185        let client = get_conn().await.map_err(AppError::db_conn)?;
186        client
187            .execute("DELETE FROM friend_links WHERE id = $1", &[&id])
188            .await
189            .map_err(AppError::query)?;
190        invalidate_friend_links_views();
191        Ok(())
192    }
193    #[cfg(not(feature = "server"))]
194    unreachable!()
195}
196
197/// 校验友链字段并返回归一化后的头像 URL。
198///
199/// 规则(全部 `AppError::BadRequest`,消息原样透传给后台表单):
200/// - `name` trim 后非空且 ≤ 64 字符;
201/// - `url` trim 后必须 `http://` / `https://` 前缀且 ≤ 512 字符(仅前缀检查,
202///   不引入完整 URL 解析);
203/// - `avatar_url`:`None` 或 trim 后为空 → 归一化为 `None`;否则同 url 规则;
204/// - `description` trim 后 ≤ 200 字符。
205#[cfg(feature = "server")]
206fn validate_link(
207    name: &str,
208    url: &str,
209    avatar_url: Option<&str>,
210    description: &str,
211) -> Result<Option<String>, AppError> {
212    if name.trim().is_empty() {
213        return Err(AppError::BadRequest("友链名称不能为空".to_string()));
214    }
215    if name.trim().chars().count() > 64 {
216        return Err(AppError::BadRequest(
217            "友链名称过长(上限 64 字符)".to_string(),
218        ));
219    }
220    let url = url.trim();
221    if !(url.starts_with("http://") || url.starts_with("https://")) {
222        return Err(AppError::BadRequest(
223            "友链 URL 必须为 http(s) 链接".to_string(),
224        ));
225    }
226    if url.chars().count() > 512 {
227        return Err(AppError::BadRequest(
228            "URL 过长(上限 512 字符)".to_string(),
229        ));
230    }
231    let avatar_url = match avatar_url.map(str::trim) {
232        None | Some("") => None,
233        Some(a) => {
234            if !(a.starts_with("http://") || a.starts_with("https://")) {
235                return Err(AppError::BadRequest(
236                    "头像 URL 必须为 http(s) 链接".to_string(),
237                ));
238            }
239            if a.chars().count() > 512 {
240                return Err(AppError::BadRequest(
241                    "头像 URL 过长(上限 512 字符)".to_string(),
242                ));
243            }
244            Some(a.to_string())
245        }
246    };
247    if description.trim().chars().count() > 200 {
248        return Err(AppError::BadRequest(
249            "描述过长(上限 200 字符)".to_string(),
250        ));
251    }
252    Ok(avatar_url)
253}
254
255/// 归一化 name / url / description 的空白(校验通过后调用)。
256#[cfg(feature = "server")]
257fn trim_fields(name: String, url: String, description: String) -> (String, String, String) {
258    (
259        name.trim().to_string(),
260        url.trim().to_string(),
261        description.trim().to_string(),
262    )
263}
264
265/// 写操作后的统一视图失效:友链 moka 缓存 + `/friends` SSR 缓存 + 全局世代号。
266#[cfg(feature = "server")]
267fn invalidate_friend_links_views() {
268    crate::cache::invalidate_friend_links();
269    crate::ssr_cache::invalidate_ssr_route("/friends");
270    crate::ssr_cache::bump_global_generation();
271}
272
273/// 把 `friend_links` 行解析为 [`FriendLink`]。
274#[cfg(feature = "server")]
275fn row_to_friend_link(row: &tokio_postgres::Row) -> FriendLink {
276    FriendLink {
277        id: row.get("id"),
278        name: row.get("name"),
279        url: row.get("url"),
280        avatar_url: row.get("avatar_url"),
281        description: row.get("description"),
282        sort_order: row.get("sort_order"),
283        is_active: row.get("is_active"),
284        created_at: row.get("created_at"),
285        updated_at: row.get("updated_at"),
286    }
287}
288
289#[cfg(all(test, feature = "server"))]
290mod tests {
291    use super::*;
292
293    /// 断言错误为带指定子串消息的 BadRequest。
294    fn assert_bad_request(err: AppError, needle: &str) {
295        match err {
296            AppError::BadRequest(m) => {
297                assert!(m.contains(needle), "消息应为 {needle:?},实际:{m}")
298            }
299            other => panic!("应为 BadRequest,实际:{other:?}"),
300        }
301    }
302
303    #[test]
304    fn validate_accepts_valid_link() {
305        assert!(validate_link(
306            "示例站",
307            "https://example.com",
308            Some("https://example.com/a.png"),
309            "描述"
310        )
311        .is_ok());
312    }
313
314    #[test]
315    fn validate_rejects_empty_name() {
316        assert_bad_request(
317            validate_link("  ", "https://example.com", None, "").unwrap_err(),
318            "友链名称不能为空",
319        );
320    }
321
322    #[test]
323    fn validate_rejects_overlong_name() {
324        let long = "名".repeat(65);
325        assert_bad_request(
326            validate_link(&long, "https://example.com", None, "").unwrap_err(),
327            "友链名称过长",
328        );
329    }
330
331    #[test]
332    fn validate_rejects_non_http_url() {
333        assert_bad_request(
334            validate_link("示例站", "ftp://example.com", None, "").unwrap_err(),
335            "友链 URL 必须为 http(s) 链接",
336        );
337    }
338
339    #[test]
340    fn validate_normalizes_empty_avatar() {
341        let avatar = validate_link("示例站", "https://example.com", Some("   "), "").unwrap();
342        assert_eq!(avatar, None);
343        let avatar = validate_link("示例站", "https://example.com", None, "").unwrap();
344        assert_eq!(avatar, None);
345    }
346
347    #[test]
348    fn validate_rejects_bad_avatar() {
349        assert_bad_request(
350            validate_link(
351                "示例站",
352                "https://example.com",
353                Some("javascript:alert(1)"),
354                "",
355            )
356            .unwrap_err(),
357            "头像 URL 必须为 http(s) 链接",
358        );
359    }
360
361    #[test]
362    fn validate_rejects_overlong_description() {
363        let long = "描".repeat(201);
364        assert_bad_request(
365            validate_link("示例站", "https://example.com", None, &long).unwrap_err(),
366            "描述过长",
367        );
368    }
369}