Skip to main content

yggdrasil/api/
slug.rs

1//! 文章 slug 生成与唯一性校验。
2//!
3//! 将标题转换为小写、仅含字母数字与连字符/下划线的 URL 友好形式,
4//! 并检测数据库中是否已存在,必要时追加数字后缀。
5//! 仅在 `feature = "server"` 时访问数据库。
6
7#![allow(clippy::unused_unit, deprecated)]
8
9#[cfg(feature = "server")]
10use dioxus::prelude::*;
11
12#[cfg(feature = "server")]
13use pinyin::ToPinyin;
14
15#[cfg(feature = "server")]
16/// 将标题转换为 URL 友好的 slug。
17///
18/// 汉字转为无声调拼音(每字独立成词,用 `-` 分隔,如 `你好` → `ni-hao`);
19/// ASCII 字母数字与 `-`/`_` 保留;其余字符替换为 `-`,连续 `-` 合并。
20/// 结果截断至 100 字符;若全部被过滤则回退为当前时间戳。
21///
22/// 多音字取默认读音(不处理歧义),博客 slug 场景足够。
23///
24/// 单遍状态机实现:旧实现 `to_lowercase` 分配一次 String + `split.collect::<Vec>` +
25/// `join` 再分配 + `take.collect` 第三次分配,共 4 次堆分配、3 遍扫描。
26/// 现用 `prev_dash` 状态在单遍内合并连续 `-` 并按需截断,1 次分配、1 遍扫描。
27pub fn slugify(title: &str) -> String {
28    let mut out = String::with_capacity(title.len());
29    // prev_dash = true 表示当前不应再输出 `-`(处于开头,或上一输出字符已是 `-`)。
30    // 等价于旧实现字符流 + split('-').filter(!empty).join('-') 的合并/去首尾效果。
31    let mut prev_dash = true;
32    let mut len = 0usize;
33
34    /// 输出一个字符,截断到 100。返回 false 表示已达上限应停止。
35    fn push_char(out: &mut String, len: &mut usize, c: char) -> bool {
36        if *len >= 100 {
37            return false;
38        }
39        out.push(c);
40        *len += 1;
41        true
42    }
43
44    for c in title.chars() {
45        // 汉字优先转拼音:to_pinyin() 对非汉字(含 ascii)返回 None。
46        if let Some(py) = c.to_pinyin() {
47            // 拼音内部不含 `-`;连续字母直接拼接,与旧实现 push_str 行为一致。
48            for pc in py.plain().chars() {
49                if !push_char(&mut out, &mut len, pc) {
50                    break;
51                }
52            }
53            // 汉字成词,后接 `-`(合并连续、去除尾部由末尾清理负责)。
54            // 拼音后必然接 `-`,所以 prev_dash 在此无条件置 true,中间无读。
55            if !push_char(&mut out, &mut len, '-') {
56                break;
57            }
58            prev_dash = true;
59        } else {
60            // ASCII 小写化避免对整串 to_lowercase 的全量分配。
61            let lc = c.to_ascii_lowercase();
62            if lc.is_ascii_alphanumeric() {
63                if !push_char(&mut out, &mut len, lc) {
64                    break;
65                }
66                prev_dash = false;
67            } else if lc == '_' {
68                // 下划线保留原样(split('-') 不把它当分隔符,行为一致)。
69                if !push_char(&mut out, &mut len, '_') {
70                    break;
71                }
72                prev_dash = false;
73            } else {
74                // `-` 或其他字符(空格/标点):仅当前面非分隔符时输出一个 `-`,
75                // 连续分隔符合并为一个;首尾的 `-` 由 prev_dash 初值和末尾清理负责。
76                if !prev_dash {
77                    if !push_char(&mut out, &mut len, '-') {
78                        break;
79                    }
80                    prev_dash = true;
81                }
82            }
83        }
84    }
85
86    // 去除尾部 `-`(开头已被 prev_dash 初值 true 挡住)。
87    while out.ends_with('-') {
88        out.pop();
89    }
90
91    if out.is_empty() {
92        return chrono::Utc::now().timestamp().to_string();
93    }
94
95    out
96}
97
98#[cfg(feature = "server")]
99/// 校验 slug 是否为空且仅含合法字符、长度不超过 200。
100pub fn is_valid_slug(slug: &str) -> bool {
101    if slug.is_empty() || slug.len() > 200 {
102        return false;
103    }
104    slug.chars()
105        .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
106}
107
108#[cfg(feature = "server")]
109/// 确保生成的 slug 在数据库中唯一。
110///
111/// 若 `exclude_id` 不为空,则排除该文章自身;
112/// 当冲突时依次尝试 `base-2`、`base-3` …… 直到生成唯一值。
113///
114/// 该函数应在事务内调用,确保与后续 INSERT/UPDATE 的 slug 唯一性检查
115/// 在同一个事务中完成,避免并发竞态。
116pub async fn ensure_unique_slug(
117    tx: &deadpool_postgres::Transaction<'_>,
118    base: &str,
119    exclude_id: Option<i32>,
120) -> Result<String, ServerFnError> {
121    use crate::api::error::AppError;
122
123    let mut candidate = base.to_string();
124    let mut suffix = 2;
125
126    loop {
127        // 查询当前候选 slug 是否已存在(排除指定文章 ID)。
128        let exists = if let Some(exclude) = exclude_id {
129            tx.query_opt(
130                "SELECT 1 FROM posts WHERE slug = $1 AND deleted_at IS NULL AND id != $2",
131                &[&candidate, &exclude],
132            )
133            .await
134            .map_err(AppError::query)?
135            .is_some()
136        } else {
137            tx.query_opt(
138                "SELECT 1 FROM posts WHERE slug = $1 AND deleted_at IS NULL",
139                &[&candidate],
140            )
141            .await
142            .map_err(AppError::query)?
143            .is_some()
144        };
145
146        if !exists {
147            return Ok(candidate);
148        }
149
150        candidate = format!("{}-{}", base, suffix);
151        suffix += 1;
152
153        // 防止无限循环:slug 总长度超过 200 时直接报错。
154        if candidate.len() > 200 {
155            return Err(AppError::Internal("无法生成唯一 slug").into());
156        }
157    }
158}
159
160#[cfg(all(test, feature = "server"))]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn slugify_ascii_title() {
166        assert_eq!(slugify("Hello World"), "hello-world");
167    }
168
169    #[test]
170    fn slugify_special_characters() {
171        assert_eq!(slugify("Hello, World! (2024)"), "hello-world-2024");
172    }
173
174    #[test]
175    fn slugify_chinese_characters() {
176        // 汉字逐字转拼音,用 `-` 分隔;混入的 ascii 自然衔接到末尾。
177        assert_eq!(slugify("你好世界"), "ni-hao-shi-jie");
178        assert_eq!(slugify("你好世界 hello"), "ni-hao-shi-jie-hello");
179    }
180
181    #[test]
182    fn slugify_mixed_chinese_ascii() {
183        // Rust 入门指南 → rust + ru-men-zhi-nan
184        assert_eq!(slugify("Rust 入门指南"), "rust-ru-men-zhi-nan");
185    }
186
187    #[test]
188    fn slugify_chinese_with_punctuation() {
189        // 标点替换为 `-`,随后与拼音分隔符合并。
190        assert_eq!(slugify("你好,世界!"), "ni-hao-shi-jie");
191    }
192
193    #[test]
194    fn slugify_collapses_dashes() {
195        assert_eq!(slugify("a---b"), "a-b");
196    }
197
198    #[test]
199    fn slugify_empty_returns_timestamp() {
200        let slug = slugify("");
201        let _: i64 = slug.parse().expect("should be a valid timestamp");
202    }
203
204    #[test]
205    fn slugify_truncates_at_100_chars() {
206        let long_title = "a".repeat(200);
207        assert!(slugify(&long_title).len() <= 100);
208    }
209
210    #[test]
211    fn slugify_preserves_underscores() {
212        assert_eq!(slugify("hello_world"), "hello_world");
213    }
214
215    #[test]
216    fn is_valid_slug_normal() {
217        assert!(is_valid_slug("hello-world_123"));
218    }
219
220    #[test]
221    fn is_valid_slug_rejects_empty() {
222        assert!(!is_valid_slug(""));
223    }
224
225    #[test]
226    fn is_valid_slug_rejects_too_long() {
227        let long_slug = "a".repeat(201);
228        assert!(!is_valid_slug(&long_slug));
229    }
230
231    #[test]
232    fn is_valid_slug_accepts_max_length() {
233        let slug = "a".repeat(200);
234        assert!(is_valid_slug(&slug));
235    }
236
237    #[test]
238    fn is_valid_slug_rejects_special_chars() {
239        assert!(!is_valid_slug("hello world"));
240        assert!(!is_valid_slug("hello.world"));
241        assert!(!is_valid_slug("hello!world"));
242    }
243
244    #[test]
245    fn is_valid_slug_accepts_chinese() {
246        assert!(is_valid_slug("你好-world"));
247    }
248
249    #[test]
250    fn slugify_all_special_characters_returns_timestamp() {
251        let slug = slugify("!@#$%^&*()+=[]{}|\\;:'\",.<>/?`~");
252        let _: i64 = slug.parse().expect("should be a valid timestamp");
253    }
254
255    #[test]
256    fn slugify_only_whitespace_returns_timestamp() {
257        let slug = slugify("   \t\n  ");
258        let _: i64 = slug.parse().expect("should be a valid timestamp");
259    }
260
261    #[test]
262    fn slugify_leading_and_trailing_dashes() {
263        assert_eq!(slugify("-hello-world-"), "hello-world");
264        assert_eq!(slugify("---hello---world---"), "hello-world");
265    }
266
267    #[test]
268    fn is_valid_slug_mixed_chinese_and_digits() {
269        assert!(is_valid_slug("你好123"));
270        assert!(is_valid_slug("123你好456"));
271    }
272
273    #[test]
274    fn is_valid_slug_exact_200_char_boundary() {
275        let slug = "a".repeat(200);
276        assert!(is_valid_slug(&slug));
277        let slug = "a".repeat(201);
278        assert!(!is_valid_slug(&slug));
279    }
280}