Skip to main content

yggdrasil/api/notes/
store.rs

1//! 后台和 MCP 共用的笔记规则;所有读取在 SQL 层选择已授权的版本。
2use crate::api::error::AppError;
3use crate::db::pool::get_conn;
4use crate::models::note::*;
5use crate::models::public_note::PublicNote;
6
7#[derive(Clone)]
8pub enum Access {
9    Public,
10    Owner(i32),
11    Knowledge {
12        owner_id: i32,
13        notebook_ids: Option<Vec<i32>>,
14    },
15}
16
17impl Access {
18    fn revision(&self) -> &'static str {
19        match self {
20            Self::Public => "published_version",
21            Self::Owner(_) => "version",
22            Self::Knowledge { .. } => "knowledge_version",
23        }
24    }
25    fn owner(&self) -> Option<i32> {
26        match self {
27            Self::Public => None,
28            Self::Owner(id) => Some(*id),
29            Self::Knowledge { owner_id, .. } => Some(*owner_id),
30        }
31    }
32    fn books(&self) -> Option<Vec<i32>> {
33        match self {
34            Self::Knowledge { notebook_ids, .. } => notebook_ids.clone(),
35            _ => None,
36        }
37    }
38}
39
40fn map_note(row: &tokio_postgres::Row, access: &Access) -> Note {
41    let admin = matches!(access, Access::Owner(_));
42    let toc_html: String = row.get("toc_html");
43    let sections = crate::utils::toc::toc_sections(&toc_html, 0).total;
44    Note {
45        id: row.get("id"),
46        slug: row.get("slug"),
47        version: row.get("revision"),
48        kind: if row.get::<_, String>("kind") == "topic" {
49            NoteKind::Topic
50        } else {
51            NoteKind::Moment
52        },
53        title: row.get("title"),
54        content_md: row.get("content_md"),
55        content_html: row.get("content_html"),
56        toc_html,
57        summary: row.get("summary"),
58        tags: row.get("tags"),
59        created_at: row.get("created_at"),
60        updated_at: row.get("revision_at"),
61        published_at: row.get("published_at"),
62        published_version: if admin {
63            row.get("published_version")
64        } else {
65            None
66        },
67        knowledge_version: if admin {
68            row.get("knowledge_version")
69        } else {
70            None
71        },
72        deleted_at: if admin { row.get("deleted_at") } else { None },
73        notebook_ids: row.get("notebook_ids"),
74        sections: u32::try_from(sections).unwrap_or(u32::MAX),
75    }
76}
77
78const COLUMNS: &str = "n.id, n.slug, n.created_at, n.published_at, n.published_version,
79    n.knowledge_version, n.deleted_at, r.version AS revision, r.kind, r.title,
80    r.content_md, r.content_html, r.toc_html, r.summary, r.tags, r.created_at AS revision_at";
81
82// 笔记本本身的可见性也参与过滤,公开结果不泄露私密笔记本的 ID。
83const BOOK_IDS: &str =
84    "ARRAY(SELECT b.id FROM notebooks b JOIN notebook_notes bn ON bn.notebook_id=b.id
85    WHERE bn.note_id=n.id AND ($1::int IS NOT NULL OR (b.is_public AND b.archived_at IS NULL))
86      AND ($2::int[] IS NULL OR b.id=ANY($2)) ORDER BY b.id) AS notebook_ids";
87
88pub async fn list(access: Access, filter: NoteFilter) -> Result<NotePage, AppError> {
89    if filter.query.chars().count() > 200 {
90        return Err(AppError::BadRequest("搜索内容不能超过 200 字".into()));
91    }
92    let client = get_conn().await.map_err(AppError::db_conn)?;
93    let owner = access.owner();
94    let books = access.books();
95    let trash = matches!(access, Access::Owner(_)) && filter.trash;
96    let (published, knowledge) = if matches!(access, Access::Owner(_)) {
97        (filter.published, filter.knowledge)
98    } else {
99        (None, None)
100    };
101    let query = crate::utils::server::escape_like_pattern(filter.query.trim());
102    let kind = filter.kind.map(|k| k.as_str());
103    let predicate = format!("FROM notes n JOIN note_revisions r ON r.note_id=n.id AND r.version=n.{}
104        WHERE ($1::int IS NULL OR n.owner_id=$1)
105        AND ($2::int[] IS NULL OR EXISTS (SELECT 1 FROM notebook_notes bn WHERE bn.note_id=n.id AND bn.notebook_id=ANY($2)))
106        AND (n.deleted_at IS NOT NULL)=$3
107        AND ($4='' OR r.search_text ILIKE '%' || $4 || '%' ESCAPE '\\' OR EXISTS (SELECT 1 FROM unnest(r.tags) tag WHERE tag ILIKE '%' || $4 || '%' ESCAPE '\\'))
108        AND ($5::text IS NULL OR r.kind=$5)
109        AND ($6::int IS NULL OR EXISTS (SELECT 1 FROM notebook_notes bn JOIN notebooks b ON b.id=bn.notebook_id WHERE bn.note_id=n.id AND b.id=$6 AND ($1::int IS NOT NULL OR (b.is_public AND b.archived_at IS NULL))))
110        AND ($7='' OR $7=ANY(r.tags))
111        AND ($8::bool IS NULL OR (n.published_version IS NOT NULL)=$8)
112        AND ($9::bool IS NULL OR (n.knowledge_version IS NOT NULL)=$9)", access.revision());
113    let params: &[&(dyn tokio_postgres::types::ToSql + Sync)] = &[
114        &owner,
115        &books,
116        &trash,
117        &query,
118        &kind,
119        &filter.notebook_id,
120        &filter.tag,
121        &published,
122        &knowledge,
123    ];
124    let total = client
125        .query_one(&format!("SELECT COUNT(*) {predicate}"), params)
126        .await
127        .map_err(AppError::query)?
128        .get(0);
129    let page = filter.page.clamp(1, 100_000);
130    // 列表仅短随记携带 HTML;长文按需读取,避免每页传输完整知识库。
131    // 主题笔记读出目录只为数出「N 节」,下发前清空。
132    let columns = COLUMNS.replace("r.content_md, r.content_html, r.toc_html", "''::text AS content_md, CASE WHEN r.kind='moment' AND length(r.content_md)<=2000 THEN r.content_html ELSE '' END AS content_html, CASE WHEN r.kind='topic' THEN r.toc_html ELSE '' END AS toc_html");
133    let sql = format!("SELECT {columns}, {BOOK_IDS} {predicate}
134        ORDER BY CASE WHEN $6::int IS NOT NULL THEN (SELECT position FROM notebook_notes WHERE notebook_id=$6 AND note_id=n.id) END,
135        CASE WHEN $4<>'' THEN word_similarity($4, r.search_text) END DESC,
136        r.created_at DESC, n.id DESC LIMIT 20 OFFSET {}", (page-1)*20);
137    let notes = client
138        .query(&sql, params)
139        .await
140        .map_err(AppError::query)?
141        .iter()
142        .map(|r| Note {
143            toc_html: String::new(),
144            ..map_note(r, &access)
145        })
146        .collect();
147    Ok(NotePage { notes, total })
148}
149
150/// 公开笔记的类型计数与常用标签(只看已发布的版本)。
151pub async fn stats(tag_limit: i64) -> Result<NoteStats, AppError> {
152    let client = get_conn().await.map_err(AppError::db_conn)?;
153    let counts = client
154        .query_one(
155            "SELECT COUNT(*) FILTER (WHERE r.kind='moment'), COUNT(*) FILTER (WHERE r.kind='topic')
156             FROM notes n JOIN note_revisions r ON r.note_id=n.id AND r.version=n.published_version
157             WHERE n.deleted_at IS NULL",
158            &[],
159        )
160        .await
161        .map_err(AppError::query)?;
162    let tags = client
163        .query(
164            "SELECT tag, COUNT(*) FROM notes n
165             JOIN note_revisions r ON r.note_id=n.id AND r.version=n.published_version,
166             unnest(r.tags) tag
167             WHERE n.deleted_at IS NULL GROUP BY tag ORDER BY COUNT(*) DESC, tag LIMIT $1",
168            &[&tag_limit],
169        )
170        .await
171        .map_err(AppError::query)?
172        .iter()
173        .map(|r| (r.get(0), r.get(1)))
174        .collect();
175    Ok(NoteStats {
176        moments: counts.get(0),
177        topics: counts.get(1),
178        tags,
179    })
180}
181
182/// 阅读页的笔记本上下文。只考虑公开且未归档的笔记本,同级只列已发布的笔记;
183/// 本篇未发布、不属于任何候选笔记本或无法确定笔记本时返回 `None`。
184pub async fn context(slug: &str, requested: Option<i32>) -> Result<Option<NoteContext>, AppError> {
185    let client = get_conn().await.map_err(AppError::db_conn)?;
186    let candidates: Vec<i32> = client
187        .query(
188            "SELECT b.id FROM notebooks b
189             JOIN notebook_notes bn ON bn.notebook_id=b.id JOIN notes n ON n.id=bn.note_id
190             WHERE n.slug=$1 AND n.deleted_at IS NULL AND n.published_version IS NOT NULL
191               AND b.is_public AND b.archived_at IS NULL
192             ORDER BY b.id",
193            &[&slug],
194        )
195        .await
196        .map_err(AppError::query)?
197        .iter()
198        .map(|r| r.get(0))
199        .collect();
200    let Some(book) = choose_notebook(&candidates, requested) else {
201        return Ok(None);
202    };
203    let siblings = client
204        .query(
205            "SELECT n.slug, r.kind, r.title, r.summary FROM notebook_notes bn
206             JOIN notes n ON n.id=bn.note_id
207             JOIN note_revisions r ON r.note_id=n.id AND r.version=n.published_version
208             WHERE bn.notebook_id=$1 AND n.deleted_at IS NULL
209             ORDER BY bn.position, n.id",
210            &[&book],
211        )
212        .await
213        .map_err(AppError::query)?
214        .iter()
215        .map(|r| {
216            let kind = if r.get::<_, &str>(1) == "topic" {
217                NoteKind::Topic
218            } else {
219                NoteKind::Moment
220            };
221            NoteLink {
222                slug: r.get(0),
223                title: display_title(kind, r.get(2), r.get(3)),
224            }
225        })
226        .collect();
227    let Some(notebook) = notebooks(Access::Public)
228        .await?
229        .into_iter()
230        .find(|b| b.id == book)
231    else {
232        return Ok(None);
233    };
234    Ok(NoteContext::locate(notebook, siblings, slug))
235}
236
237pub async fn get(
238    access: Access,
239    id: Option<i32>,
240    slug: Option<String>,
241    version: Option<i32>,
242) -> Result<Note, AppError> {
243    let client = get_conn().await.map_err(AppError::db_conn)?;
244    let owner = access.owner();
245    let books = access.books();
246    let requested = if matches!(access, Access::Owner(_)) {
247        version
248    } else {
249        None
250    };
251    let sql = format!("SELECT {COLUMNS}, {BOOK_IDS} FROM notes n
252        JOIN note_revisions r ON r.note_id=n.id AND r.version=COALESCE($5, n.{})
253        WHERE ($1::int IS NULL OR n.owner_id=$1)
254        AND ($2::int[] IS NULL OR EXISTS (SELECT 1 FROM notebook_notes bn WHERE bn.note_id=n.id AND bn.notebook_id=ANY($2)))
255        AND (($3::int IS NOT NULL AND n.id=$3) OR ($4::text IS NOT NULL AND n.slug=$4))
256        AND (n.deleted_at IS NULL OR $6)", access.revision());
257    let row = client
258        .query_opt(
259            &sql,
260            &[
261                &owner,
262                &books,
263                &id,
264                &slug,
265                &requested,
266                &matches!(access, Access::Owner(_)),
267            ],
268        )
269        .await
270        .map_err(AppError::query)?
271        .ok_or(AppError::NotFound("笔记不存在或无权访问"))?;
272    Ok(map_note(&row, &access))
273}
274
275/// 公开详情查询:只选择已发布展示所需的列,不读取 `content_md` 与内部版本号
276/// (P4.1)。可见性语义与 `get(Access::Public, ..)` 完全一致——
277/// join `published_version`(未发布时无行)、`deleted_at IS NULL`;
278/// 该查询没有 owner/book 参数,与公开路径的调用形状一一对应。
279/// 注意这里是公开可见性谓词的第二份实现(与 get() 平行):调整任一侧的公开
280/// 可见性语义时必须同步另一侧,并重跑 `notes_database_workflow` 回归
281///(其中的 get_public/get(Access::Public) 平价断言锁定两侧一致)。
282pub async fn get_public(slug: &str) -> Result<PublicNote, AppError> {
283    let client = get_conn().await.map_err(AppError::db_conn)?;
284    let row = client
285        .query_opt(
286            "SELECT n.id, n.slug, r.version AS revision, r.kind, r.title, r.summary,
287                r.tags, r.content_html, r.toc_html,
288                r.created_at AS revision_at, n.created_at
289            FROM notes n
290            JOIN note_revisions r ON r.note_id=n.id AND r.version=n.published_version
291            WHERE n.slug=$1 AND n.deleted_at IS NULL",
292            &[&slug],
293        )
294        .await
295        .map_err(AppError::query)?
296        .ok_or(AppError::NotFound("笔记不存在或未公开"))?;
297    let toc_html: String = row.get("toc_html");
298    let sections = crate::utils::toc::toc_sections(&toc_html, 0).total;
299    Ok(PublicNote {
300        id: row.get("id"),
301        slug: row.get("slug"),
302        version: row.get("revision"),
303        kind: if row.get::<_, String>("kind") == "topic" {
304            NoteKind::Topic
305        } else {
306            NoteKind::Moment
307        },
308        title: row.get("title"),
309        summary: row.get("summary"),
310        content_html: row.get("content_html"),
311        toc_html,
312        tags: row.get("tags"),
313        created_at: row.get("created_at"),
314        updated_at: row.get("revision_at"),
315        sections: u32::try_from(sections).unwrap_or(u32::MAX),
316    })
317}
318
319fn validate(draft: &mut NoteDraft) -> Result<(), AppError> {
320    draft.title = draft.title.trim().to_string();
321    if draft.title.chars().count() > 200 || draft.content_md.len() > 200_000 {
322        return Err(AppError::BadRequest(
323            "标题最多 200 字,正文最多 200 KB".into(),
324        ));
325    }
326    draft.tags = crate::api::posts::helpers::clean_tags(&draft.tags);
327    if draft.tags.len() > 16 || draft.tags.iter().any(|t| t.chars().count() > 40) {
328        return Err(AppError::BadRequest(
329            "最多 16 个标签,每个标签最多 40 字".into(),
330        ));
331    }
332    draft.notebook_ids.sort_unstable();
333    draft.notebook_ids.dedup();
334    if draft.notebook_ids.len() > 30 {
335        return Err(AppError::BadRequest("最多收录到 30 个笔记本".into()));
336    }
337    if draft.id.is_some() && draft.expected_version.is_none() {
338        return Err(AppError::BadRequest("更新笔记必须提供当前版本".into()));
339    }
340    Ok(())
341}
342
343pub async fn save(
344    owner_id: i32,
345    mut draft: NoteDraft,
346    allowed_books: Option<&[i32]>,
347) -> Result<Note, AppError> {
348    validate(&mut draft)?;
349    if let Some(allowed) = allowed_books {
350        if draft.notebook_ids.is_empty() || draft.notebook_ids.iter().any(|b| !allowed.contains(b))
351        {
352            return Err(AppError::Forbidden("笔记必须位于令牌授权的笔记本中"));
353        }
354    }
355    let fields =
356        crate::api::posts::helpers::render_post_fields(&draft.content_md, "draft", None).await?;
357    let mut client = get_conn().await.map_err(AppError::db_conn)?;
358    let tx = client.transaction().await.map_err(AppError::tx)?;
359    let count: i64 = tx
360        .query_one(
361            "SELECT COUNT(*) FROM notebooks WHERE owner_id=$1 AND id=ANY($2)",
362            &[&owner_id, &draft.notebook_ids],
363        )
364        .await
365        .map_err(AppError::query)?
366        .get(0);
367    if count != draft.notebook_ids.len() as i64 {
368        return Err(AppError::Forbidden("笔记本不存在或无权访问"));
369    }
370    let (id, version) = if let Some(id) = draft.id {
371        let row = tx.query_opt("SELECT version FROM notes WHERE id=$1 AND owner_id=$2 AND deleted_at IS NULL FOR UPDATE", &[&id, &owner_id]).await.map_err(AppError::query)?.ok_or(AppError::NotFound("笔记不存在或已移入回收站"))?;
372        let current: i32 = row.get(0);
373        if Some(current) != draft.expected_version {
374            return Err(AppError::BadRequest(
375                "笔记已在别处修改,请重新加载后再保存;当前编辑内容仍保留在页面".into(),
376            ));
377        }
378        if let Some(allowed) = allowed_books {
379            let found: bool = tx.query_one("SELECT EXISTS(SELECT 1 FROM notebook_notes WHERE note_id=$1 AND notebook_id=ANY($2))", &[&id, &allowed]).await.map_err(AppError::query)?.get(0);
380            if !found {
381                return Err(AppError::Forbidden("笔记不在令牌授权范围内"));
382            }
383        }
384        (id, current + 1)
385    } else {
386        let slug = uuid::Uuid::new_v4().simple().to_string();
387        let id = tx
388            .query_one(
389                "INSERT INTO notes(owner_id, slug) VALUES ($1,$2) RETURNING id",
390                &[&owner_id, &slug],
391            )
392            .await
393            .map_err(AppError::tx)?
394            .get(0);
395        (id, 1)
396    };
397    tx.execute("INSERT INTO note_revisions(note_id, version, kind, title, content_md, content_html, toc_html, summary, tags) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)",
398        &[&id, &version, &draft.kind.as_str(), &draft.title, &draft.content_md, &fields.content_html, &fields.toc_html.unwrap_or_default(), &fields.auto_summary, &draft.tags]).await.map_err(AppError::tx)?;
399    tx.execute(
400        "UPDATE notes SET version=$2, updated_at=NOW() WHERE id=$1",
401        &[&id, &version],
402    )
403    .await
404    .map_err(AppError::tx)?;
405    let allowed = allowed_books.map(|b| b.to_vec());
406    tx.execute("DELETE FROM notebook_notes WHERE note_id=$1 AND NOT(notebook_id=ANY($2)) AND ($3::int[] IS NULL OR notebook_id=ANY($3))", &[&id, &draft.notebook_ids, &allowed]).await.map_err(AppError::tx)?;
407    for book in &draft.notebook_ids {
408        tx.execute("INSERT INTO notebook_notes(notebook_id, note_id, position) SELECT $1,$2,COALESCE(MAX(position),-1)+1 FROM notebook_notes WHERE notebook_id=$1 ON CONFLICT DO NOTHING", &[book, &id]).await.map_err(AppError::tx)?;
409    }
410    super::attachments::sync_refs(&tx, owner_id, id, version, &fields.content_html).await?;
411    tx.commit().await.map_err(AppError::tx)?;
412    // 笔记本归属可影响公开目录,即使正文仍是工作稿也应失效目录缓存。
413    invalidate();
414    get(Access::Owner(owner_id), Some(id), None, None).await
415}
416
417pub async fn act(
418    owner_id: i32,
419    id: i32,
420    expected: i32,
421    action: NoteAction,
422    allowed_books: Option<&[i32]>,
423) -> Result<Note, AppError> {
424    let mut client = get_conn().await.map_err(AppError::db_conn)?;
425    let tx = client.transaction().await.map_err(AppError::tx)?;
426    act_in_transaction(&tx, owner_id, id, expected, action, allowed_books).await?;
427    tx.commit().await.map_err(AppError::tx)?;
428    invalidate();
429    get(Access::Owner(owner_id), Some(id), None, None).await
430}
431
432pub async fn batch_act(
433    owner_id: i32,
434    mut targets: Vec<NoteBatchTarget>,
435    action: NoteAction,
436) -> Result<(), AppError> {
437    if !matches!(
438        action,
439        NoteAction::Publish
440            | NoteAction::Unpublish
441            | NoteAction::IncludeKnowledge
442            | NoteAction::ExcludeKnowledge
443    ) {
444        return Err(AppError::BadRequest("不支持此批量操作".into()));
445    }
446    if targets.is_empty() || targets.len() > 100 {
447        return Err(AppError::BadRequest("每次请选择 1 至 100 篇笔记".into()));
448    }
449    targets.sort_unstable_by_key(|target| target.id);
450    if targets.windows(2).any(|pair| pair[0].id == pair[1].id) {
451        return Err(AppError::BadRequest("不能重复选择同一篇笔记".into()));
452    }
453    let mut client = get_conn().await.map_err(AppError::db_conn)?;
454    let tx = client.transaction().await.map_err(AppError::tx)?;
455    // 所有批量请求按 ID 递增锁行;校验或更新失败时由事务整体回滚。
456    for target in targets {
457        act_in_transaction(
458            &tx,
459            owner_id,
460            target.id,
461            target.expected_version,
462            action,
463            None,
464        )
465        .await?;
466    }
467    tx.commit().await.map_err(AppError::tx)?;
468    invalidate();
469    Ok(())
470}
471
472async fn act_in_transaction(
473    tx: &deadpool_postgres::Transaction<'_>,
474    owner_id: i32,
475    id: i32,
476    expected: i32,
477    action: NoteAction,
478    allowed_books: Option<&[i32]>,
479) -> Result<(), AppError> {
480    let row = tx.query_opt("SELECT n.version, n.deleted_at, r.kind, r.title, r.content_md FROM notes n JOIN note_revisions r ON r.note_id=n.id AND r.version=n.version WHERE n.id=$1 AND n.owner_id=$2 FOR UPDATE OF n", &[&id, &owner_id]).await.map_err(AppError::query)?.ok_or(AppError::NotFound("笔记不存在"))?;
481    // 与保存入口一样,先锁住笔记再检查归属,避免并发移动笔记绕过令牌范围。
482    if let Some(allowed) = allowed_books {
483        let found: bool = tx
484            .query_one(
485                "SELECT EXISTS(SELECT 1 FROM notebook_notes WHERE note_id=$1 AND notebook_id=ANY($2))",
486                &[&id, &allowed],
487            )
488            .await
489            .map_err(AppError::query)?
490            .get(0);
491        if !found {
492            return Err(AppError::Forbidden("笔记不在令牌授权范围内"));
493        }
494    }
495    if row.get::<_, i32>("version") != expected {
496        return Err(AppError::BadRequest("笔记版本已变化,请重新加载".into()));
497    }
498    if row
499        .get::<_, Option<chrono::DateTime<chrono::Utc>>>("deleted_at")
500        .is_some()
501        && action != NoteAction::Restore
502    {
503        return Err(AppError::BadRequest("请先从回收站恢复笔记".into()));
504    }
505    if matches!(action, NoteAction::Publish | NoteAction::IncludeKnowledge)
506        && (row.get::<_, String>("content_md").trim().is_empty()
507            || (row.get::<_, String>("kind") == "topic"
508                && row.get::<_, String>("title").trim().is_empty()))
509    {
510        return Err(AppError::BadRequest(
511            "请先填写正文;主题笔记还需要标题".into(),
512        ));
513    }
514    let update = match action {
515        NoteAction::Publish => {
516            "published_version=version, published_at=COALESCE(published_at,NOW())"
517        }
518        NoteAction::Unpublish => "published_version=NULL, published_at=NULL",
519        NoteAction::IncludeKnowledge => "knowledge_version=version",
520        NoteAction::ExcludeKnowledge => "knowledge_version=NULL",
521        NoteAction::Trash => {
522            "deleted_at=NOW(), published_version=NULL, published_at=NULL, knowledge_version=NULL"
523        }
524        NoteAction::Restore => "deleted_at=NULL",
525    };
526    tx.execute(
527        &format!("UPDATE notes SET {update}, updated_at=NOW() WHERE id=$1"),
528        &[&id],
529    )
530    .await
531    .map_err(AppError::tx)?;
532    Ok(())
533}
534
535pub async fn history(owner_id: i32, id: i32) -> Result<Vec<NoteRevision>, AppError> {
536    let client = get_conn().await.map_err(AppError::db_conn)?;
537    let rows = client.query("SELECT r.version,r.title,r.created_at FROM note_revisions r JOIN notes n ON n.id=r.note_id WHERE n.id=$1 AND n.owner_id=$2 ORDER BY r.version DESC LIMIT 100", &[&id, &owner_id]).await.map_err(AppError::query)?;
538    Ok(rows
539        .iter()
540        .map(|r| NoteRevision {
541            version: r.get(0),
542            title: r.get(1),
543            created_at: r.get(2),
544        })
545        .collect())
546}
547
548pub async fn notebooks(access: Access) -> Result<Vec<Notebook>, AppError> {
549    let client = get_conn().await.map_err(AppError::db_conn)?;
550    let owner = access.owner();
551    let books = access.books();
552    let include_archived = matches!(access, Access::Owner(_));
553    let sql = format!("SELECT b.*, (SELECT COUNT(*) FROM notebook_notes bn JOIN notes n ON n.id=bn.note_id WHERE bn.notebook_id=b.id AND n.deleted_at IS NULL AND n.{} IS NOT NULL) AS note_count
554        FROM notebooks b WHERE ($1::int IS NULL AND b.is_public OR b.owner_id=$1)
555        AND ($2::int[] IS NULL OR b.id=ANY($2)) AND ($3 OR b.archived_at IS NULL)
556        ORDER BY b.updated_at DESC,b.id DESC", access.revision());
557    let rows = client
558        .query(&sql, &[&owner, &books, &include_archived])
559        .await
560        .map_err(AppError::query)?;
561    Ok(rows
562        .iter()
563        .map(|r| Notebook {
564            id: r.get("id"),
565            title: r.get("title"),
566            description: r.get("description"),
567            is_public: r.get("is_public"),
568            archived_at: r.get("archived_at"),
569            note_count: r.get("note_count"),
570            updated_at: r.get("updated_at"),
571        })
572        .collect())
573}
574
575pub async fn save_notebook(owner_id: i32, input: NotebookInput) -> Result<(), AppError> {
576    let title = input.title.trim();
577    if title.is_empty() || title.chars().count() > 100 || input.description.chars().count() > 1000 {
578        return Err(AppError::BadRequest(
579            "笔记本标题为 1–100 字,介绍最多 1000 字".into(),
580        ));
581    }
582    let client = get_conn().await.map_err(AppError::db_conn)?;
583    if let Some(id) = input.id {
584        let changed = client.execute("UPDATE notebooks SET title=$3,description=$4,is_public=$5,updated_at=NOW() WHERE id=$1 AND owner_id=$2 AND (archived_at IS NULL OR NOT $5)", &[&id,&owner_id,&title,&input.description,&input.is_public]).await.map_err(AppError::query)?;
585        if changed == 0 {
586            return Err(AppError::BadRequest(
587                "笔记本不存在,或已归档;请先恢复再公开".into(),
588            ));
589        }
590    } else {
591        client
592            .execute(
593                "INSERT INTO notebooks(owner_id,title,description,is_public) VALUES ($1,$2,$3,$4)",
594                &[&owner_id, &title, &input.description, &input.is_public],
595            )
596            .await
597            .map_err(AppError::query)?;
598    }
599    invalidate();
600    Ok(())
601}
602
603pub async fn archive_notebook(owner_id: i32, id: i32, archived: bool) -> Result<(), AppError> {
604    let client = get_conn().await.map_err(AppError::db_conn)?;
605    let changed = client.execute(
606        "UPDATE notebooks SET archived_at=CASE WHEN $3 THEN COALESCE(archived_at,NOW()) ELSE NULL END,
607         is_public=CASE WHEN $3 THEN FALSE ELSE is_public END, updated_at=NOW() WHERE id=$1 AND owner_id=$2",
608        &[&id,&owner_id,&archived],
609    ).await.map_err(AppError::query)?;
610    if changed == 0 {
611        return Err(AppError::NotFound("笔记本不存在"));
612    }
613    invalidate();
614    Ok(())
615}
616
617pub async fn reorder(owner_id: i32, book: i32, ids: Vec<i32>) -> Result<(), AppError> {
618    let mut client = get_conn().await.map_err(AppError::db_conn)?;
619    let tx = client.transaction().await.map_err(AppError::tx)?;
620    tx.query_opt(
621        "SELECT id FROM notebooks WHERE id=$1 AND owner_id=$2 FOR UPDATE",
622        &[&book, &owner_id],
623    )
624    .await
625    .map_err(AppError::query)?
626    .ok_or(AppError::NotFound("笔记本不存在"))?;
627    let existing: Vec<i32> = tx
628        .query(
629            "SELECT note_id FROM notebook_notes WHERE notebook_id=$1 ORDER BY position,note_id",
630            &[&book],
631        )
632        .await
633        .map_err(AppError::query)?
634        .iter()
635        .map(|r| r.get(0))
636        .collect();
637    let mut a = existing;
638    a.sort_unstable();
639    let mut b = ids.clone();
640    b.sort_unstable();
641    if a != b {
642        return Err(AppError::BadRequest(
643            "笔记本内容已变化,请刷新后排序".into(),
644        ));
645    }
646    for (position, id) in ids.iter().enumerate() {
647        tx.execute(
648            "UPDATE notebook_notes SET position=$3 WHERE notebook_id=$1 AND note_id=$2",
649            &[&book, id, &(position as i32)],
650        )
651        .await
652        .map_err(AppError::tx)?;
653    }
654    tx.commit().await.map_err(AppError::tx)?;
655    invalidate();
656    Ok(())
657}
658
659fn invalidate() {
660    crate::ssr_cache::bump_global_generation();
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666
667    #[test]
668    #[ignore = "requires disposable DATABASE_URL database ygg_notes_test"]
669    fn notes_database_batch_actions_are_atomic_and_revision_specific() {
670        crate::db::TEST_DATABASE_RUNTIME.block_on(async {
671            let _guard = crate::db::TEST_DATABASE_LOCK.lock().await;
672            let mut client = get_conn().await.unwrap();
673            let database: String = client
674                .query_one("SELECT current_database()", &[])
675                .await
676                .unwrap()
677                .get(0);
678            assert_eq!(
679                database, "ygg_notes_test",
680                "requires isolated test database"
681            );
682            crate::db::migrate::run_on_conn(&mut client).await.unwrap();
683            client
684                .batch_execute(
685                    "TRUNCATE users CASCADE;
686                     INSERT INTO users(id,username,email,password_hash,role) VALUES
687                     (1,'batch-owner','batch@test.invalid','unused','admin'),
688                     (2,'batch-other','batch-other@test.invalid','unused','blocked');",
689                )
690                .await
691                .unwrap();
692            drop(client);
693            let mut notes = Vec::new();
694            for title in ["批量甲", "批量乙"] {
695                notes.push(
696                    save(
697                        1,
698                        NoteDraft {
699                            title: title.into(),
700                            kind: NoteKind::Topic,
701                            content_md: "初始工作稿".into(),
702                            ..Default::default()
703                        },
704                        None,
705                    )
706                    .await
707                    .unwrap(),
708                );
709            }
710            // 反序传入,存储层仍按 ID 递增锁行。
711            let targets: Vec<_> = notes
712                .iter()
713                .rev()
714                .map(|note| NoteBatchTarget {
715                    id: note.id,
716                    expected_version: note.version,
717                })
718                .collect();
719            batch_act(1, targets.clone(), NoteAction::IncludeKnowledge)
720                .await
721                .unwrap();
722            for note in &notes {
723                let current = get(Access::Owner(1), Some(note.id), None, None)
724                    .await
725                    .unwrap();
726                assert_eq!(current.published_version, None);
727                assert_eq!(current.knowledge_version, Some(1));
728            }
729            // 发布必须指向当前工作稿,且不能顺带推进知识库版本。
730            for note in &mut notes {
731                let mut draft = note.draft();
732                draft.content_md = format!("{} 的最新工作稿", note.title);
733                *note = save(1, draft, None).await.unwrap();
734            }
735            let targets: Vec<_> = notes
736                .iter()
737                .rev()
738                .map(|note| NoteBatchTarget {
739                    id: note.id,
740                    expected_version: note.version,
741                })
742                .collect();
743            batch_act(1, targets.clone(), NoteAction::Publish)
744                .await
745                .unwrap();
746            for note in &notes {
747                let current = get(Access::Owner(1), Some(note.id), None, None)
748                    .await
749                    .unwrap();
750                assert_eq!(current.published_version, Some(2));
751                assert_eq!(current.knowledge_version, Some(1));
752                let public = get(Access::Public, Some(note.id), None, None)
753                    .await
754                    .unwrap();
755                assert_eq!(public.version, 2);
756                assert_eq!(public.content_md, note.content_md);
757            }
758            batch_act(1, targets.clone(), NoteAction::ExcludeKnowledge)
759                .await
760                .unwrap();
761            for note in &notes {
762                let current = get(Access::Owner(1), Some(note.id), None, None)
763                    .await
764                    .unwrap();
765                assert_eq!(current.published_version, Some(2));
766                assert_eq!(current.knowledge_version, None);
767            }
768            batch_act(1, targets.clone(), NoteAction::IncludeKnowledge)
769                .await
770                .unwrap();
771            batch_act(1, targets.clone(), NoteAction::Unpublish)
772                .await
773                .unwrap();
774            for note in &notes {
775                let current = get(Access::Owner(1), Some(note.id), None, None)
776                    .await
777                    .unwrap();
778                assert_eq!(current.published_version, None);
779                assert_eq!(current.published_at, None);
780                assert_eq!(current.knowledge_version, Some(2));
781            }
782
783            // 失败目标 ID 更大,确保此前已更新的第一条也会回滚(含时间戳)。
784            let foreign = save(
785                2,
786                NoteDraft {
787                    content_md: "他人的笔记".into(),
788                    ..Default::default()
789                },
790                None,
791            )
792            .await
793            .unwrap();
794            let trashed = save(
795                1,
796                NoteDraft {
797                    content_md: "回收站笔记".into(),
798                    ..Default::default()
799                },
800                None,
801            )
802            .await
803            .unwrap();
804            act(1, trashed.id, 1, NoteAction::Trash, None)
805                .await
806                .unwrap();
807            let empty = save(1, NoteDraft::default(), None).await.unwrap();
808            let untitled = save(
809                1,
810                NoteDraft {
811                    kind: NoteKind::Topic,
812                    content_md: "没有标题的主题".into(),
813                    ..Default::default()
814                },
815                None,
816            )
817            .await
818            .unwrap();
819            let invalid = [
820                NoteBatchTarget {
821                    id: notes[1].id,
822                    expected_version: 1,
823                },
824                NoteBatchTarget {
825                    id: foreign.id,
826                    expected_version: 1,
827                },
828                NoteBatchTarget {
829                    id: trashed.id,
830                    expected_version: 1,
831                },
832                NoteBatchTarget {
833                    id: empty.id,
834                    expected_version: 1,
835                },
836                NoteBatchTarget {
837                    id: untitled.id,
838                    expected_version: 1,
839                },
840                NoteBatchTarget {
841                    id: i32::MAX,
842                    expected_version: 1,
843                },
844            ];
845            for action in [NoteAction::Publish, NoteAction::IncludeKnowledge] {
846                for target in invalid {
847                    let before = get(Access::Owner(1), Some(notes[0].id), None, None)
848                        .await
849                        .unwrap();
850                    assert!(
851                        batch_act(1, vec![targets[1], target], action)
852                            .await
853                            .is_err(),
854                        "invalid target must abort the entire batch: {target:?}, {action:?}"
855                    );
856                    assert_eq!(
857                        get(Access::Owner(1), Some(notes[0].id), None, None)
858                            .await
859                            .unwrap(),
860                        before,
861                        "no partial state or timestamp writes"
862                    );
863                }
864            }
865            // 撤销公开/收录也必须遵守版本、所有权及回收站约束。
866            for action in [NoteAction::Unpublish, NoteAction::ExcludeKnowledge] {
867                for target in &invalid[..3] {
868                    let before = get(Access::Owner(1), Some(notes[0].id), None, None)
869                        .await
870                        .unwrap();
871                    assert!(batch_act(1, vec![targets[1], *target], action)
872                        .await
873                        .is_err());
874                    assert_eq!(
875                        get(Access::Owner(1), Some(notes[0].id), None, None)
876                            .await
877                            .unwrap(),
878                        before
879                    );
880                }
881            }
882            for action in [NoteAction::Trash, NoteAction::Restore] {
883                assert!(batch_act(1, targets.clone(), action).await.is_err());
884            }
885            assert!(batch_act(1, Vec::new(), NoteAction::Publish).await.is_err());
886            assert!(batch_act(1, vec![targets[0]; 101], NoteAction::Publish)
887                .await
888                .is_err());
889            assert!(batch_act(1, vec![targets[0]; 2], NoteAction::Publish)
890                .await
891                .is_err());
892            for note in &notes {
893                assert!(get(Access::Owner(1), Some(note.id), None, None)
894                    .await
895                    .unwrap()
896                    .deleted_at
897                    .is_none());
898            }
899        });
900    }
901
902    #[test]
903    #[ignore = "requires disposable DATABASE_URL database ygg_notes_test"]
904    fn notes_database_workflow() {
905        crate::db::TEST_DATABASE_RUNTIME.block_on(async {
906            let _guard = crate::db::TEST_DATABASE_LOCK.lock().await;
907            let mut client = get_conn().await.unwrap();
908            let database: String = client
909                .query_one("SELECT current_database()", &[])
910                .await
911                .unwrap()
912                .get(0);
913            assert_eq!(
914                database, "ygg_notes_test",
915                "requires isolated test database"
916            );
917            crate::db::migrate::run_on_conn(&mut client).await.unwrap();
918            client
919                .batch_execute(
920                    "TRUNCATE users CASCADE;
921                INSERT INTO users(id,username,email,password_hash,role) VALUES
922                (1,'note-owner','note@test.invalid','unused','admin'),
923                (2,'note-other','other@test.invalid','unused','blocked');",
924                )
925                .await
926                .unwrap();
927            save_notebook(
928                1,
929                NotebookInput {
930                    title: "公开主题".into(),
931                    is_public: true,
932                    ..Default::default()
933                },
934            )
935            .await
936            .unwrap();
937            save_notebook(
938                1,
939                NotebookInput {
940                    title: "私密主题".into(),
941                    ..Default::default()
942                },
943            )
944            .await
945            .unwrap();
946            let books = notebooks(Access::Owner(1)).await.unwrap();
947            let public = books.iter().find(|b| b.is_public).unwrap().id;
948            let private = books.iter().find(|b| !b.is_public).unwrap().id;
949            let note = save(
950                1,
951                NoteDraft {
952                    title: "所有权".into(),
953                    kind: NoteKind::Topic,
954                    content_md: "第一版 Rust 100%_".into(),
955                    notebook_ids: vec![public, private],
956                    ..Default::default()
957                },
958                None,
959            )
960            .await
961            .unwrap();
962            assert!(get(Access::Public, Some(note.id), None, None)
963                .await
964                .is_err());
965            assert!(get(Access::Owner(2), Some(note.id), None, None)
966                .await
967                .is_err());
968            assert_eq!(
969                list(Access::Public, NoteFilter::default())
970                    .await
971                    .unwrap()
972                    .total,
973                0
974            );
975            assert_eq!(notebooks(Access::Public).await.unwrap()[0].note_count, 0);
976            act(1, note.id, 1, NoteAction::Publish, None).await.unwrap();
977            act(1, note.id, 1, NoteAction::IncludeKnowledge, None)
978                .await
979                .unwrap();
980            let mut draft = note.draft();
981            draft.content_md = "第二版,尚未发布".into();
982            let updated = save(1, draft.clone(), None).await.unwrap();
983            assert_eq!(updated.version, 2);
984            assert!(
985                save(1, draft, None).await.is_err(),
986                "stale writes must fail"
987            );
988            let published = get(Access::Public, Some(note.id), None, Some(2))
989                .await
990                .unwrap();
991            assert_eq!(
992                published.version, 1,
993                "public cannot select a draft revision"
994            );
995            assert_eq!(published.notebook_ids, vec![public]);
996            assert!(published.knowledge_version.is_none());
997            // P4.1:公开详情 DTO 行为与 get(Access::Public) 一致(工作稿 v2 已存在
998            // 时仍固定服务已发布 v1),且响应不携带编辑 Markdown 与内部字段。
999            let public_note = get_public(note.slug.as_str()).await.unwrap();
1000            assert_eq!(public_note.version, 1, "公开详情固定为已发布版本");
1001            assert_eq!(public_note.content_html, published.content_html);
1002            assert_eq!(public_note.updated_at, published.updated_at);
1003            let public_payload = serde_json::to_value(&public_note).unwrap();
1004            assert!(public_payload.get("content_md").is_none());
1005            assert!(public_payload.get("knowledge_version").is_none());
1006            assert!(public_payload.get("notebook_ids").is_none());
1007            assert!(get_public("绝不存在的别名").await.is_err());
1008            let knowledge = Access::Knowledge {
1009                owner_id: 1,
1010                notebook_ids: Some(vec![private]),
1011            };
1012            assert_eq!(
1013                get(knowledge.clone(), Some(note.id), None, None)
1014                    .await
1015                    .unwrap()
1016                    .version,
1017                1
1018            );
1019            assert!(get(
1020                Access::Knowledge {
1021                    owner_id: 1,
1022                    notebook_ids: Some(vec![])
1023                },
1024                Some(note.id),
1025                None,
1026                None
1027            )
1028            .await
1029            .is_err());
1030            assert_eq!(
1031                list(
1032                    Access::Public,
1033                    NoteFilter {
1034                        query: "100%_".into(),
1035                        ..Default::default()
1036                    }
1037                )
1038                .await
1039                .unwrap()
1040                .total,
1041                1
1042            );
1043            assert_eq!(
1044                list(
1045                    Access::Public,
1046                    NoteFilter {
1047                        query: "第二版".into(),
1048                        ..Default::default()
1049                    }
1050                )
1051                .await
1052                .unwrap()
1053                .total,
1054                0
1055            );
1056            assert_eq!(
1057                list(
1058                    Access::Public,
1059                    NoteFilter {
1060                        notebook_id: Some(private),
1061                        ..Default::default()
1062                    }
1063                )
1064                .await
1065                .unwrap()
1066                .total,
1067                0
1068            );
1069            act(1, note.id, 2, NoteAction::Unpublish, None).await.unwrap();
1070            assert!(get(Access::Public, Some(note.id), None, None)
1071                .await
1072                .is_err());
1073            // 撤回后公开详情 DTO 同步不可见。
1074            assert!(get_public(note.slug.as_str()).await.is_err());
1075            assert!(get(knowledge.clone(), Some(note.id), None, None)
1076                .await
1077                .is_ok());
1078            act(1, note.id, 2, NoteAction::Trash, None).await.unwrap();
1079            assert!(get(knowledge, Some(note.id), None, None).await.is_err());
1080            assert_eq!(
1081                list(
1082                    Access::Owner(1),
1083                    NoteFilter {
1084                        trash: true,
1085                        ..Default::default()
1086                    }
1087                )
1088                .await
1089                .unwrap()
1090                .total,
1091                1
1092            );
1093            act(1, note.id, 2, NoteAction::Restore, None).await.unwrap();
1094            assert!(
1095                get(Access::Public, Some(note.id), None, None)
1096                    .await
1097                    .is_err(),
1098                "restore never republishes"
1099            );
1100            assert_eq!(history(1, note.id).await.unwrap().len(), 2);
1101            let mut old = get(Access::Owner(1), Some(note.id), None, Some(1))
1102                .await
1103                .unwrap()
1104                .draft();
1105            old.expected_version = Some(2);
1106            assert_eq!(
1107                save(1, old, None).await.unwrap().version,
1108                3,
1109                "restoration creates a new version"
1110            );
1111            assert!(act(2, note.id, 3, NoteAction::Publish, None).await.is_err());
1112            reorder(1, public, vec![note.id]).await.unwrap();
1113            assert!(reorder(2, public, vec![note.id]).await.is_err());
1114
1115            // 私密图片不能靠猜 URL 读取;发布和撤回立即改变访问权。
1116            let image_id=uuid::Uuid::new_v4();
1117            client.execute("INSERT INTO note_attachments(id,owner_id,mime,data) VALUES($1,1,'image/png',$2)", &[&image_id, &vec![1u8,2,3]]).await.unwrap();
1118            let media_status=|| super::super::attachments::read(axum::http::HeaderMap::new(),axum::extract::Path(image_id.to_string()));
1119            assert_eq!(media_status().await.status(),axum::http::StatusCode::NOT_FOUND);
1120            let reference_only=save(1,NoteDraft {content_md:format!("`/note-media/{image_id}`\n\n![外站](https://example.invalid/note-media/{image_id})"),..Default::default()},None).await.unwrap();
1121            act(1, reference_only.id, 1, NoteAction::Publish, None).await.unwrap();
1122            assert_eq!(media_status().await.status(),axum::http::StatusCode::NOT_FOUND,"code examples and remote URLs cannot publish local media");
1123            let draft=NoteDraft {content_md:format!("![私密图片](/note-media/{image_id})"),..Default::default()};
1124            assert!(save(2,draft.clone(),None).await.is_err(),"cannot reuse someone else's private attachment");
1125            let image_note=save(1,draft,None).await.unwrap();
1126            act(1, image_note.id, 1, NoteAction::IncludeKnowledge, None).await.unwrap();
1127            assert_eq!(media_status().await.status(),axum::http::StatusCode::NOT_FOUND,"knowledge enrollment is not public access");
1128            act(1, image_note.id, 1, NoteAction::Publish, None).await.unwrap();
1129            let response=media_status().await;
1130            assert_eq!(response.status(),axum::http::StatusCode::OK);
1131            assert_eq!(response.headers()["cache-control"],"private, no-store");
1132            let mut draft=image_note.draft();draft.content_md="草稿移除了图片,公开版仍保留".into();
1133            save(1,draft,None).await.unwrap();
1134            assert_eq!(media_status().await.status(),axum::http::StatusCode::OK);
1135            act(1, image_note.id, 2, NoteAction::Unpublish, None).await.unwrap();
1136            assert_eq!(media_status().await.status(),axum::http::StatusCode::NOT_FOUND);
1137
1138            // 公开素材即使只被笔记历史版引用,也不能变成可清理孤儿。
1139            let asset_id=uuid::Uuid::new_v4();
1140            let path=format!("2026/09/20/{asset_id}.webp");
1141            client.execute("INSERT INTO assets(id,path,filename,mime,size_bytes,width,height) VALUES($1,$2,$3,'image/webp',3,1,1)",&[&asset_id,&path,&asset_id.to_string()]).await.unwrap();
1142            let asset_note=save(1,NoteDraft {content_md:format!("![素材](/uploads/{path})"),..Default::default()},None).await.unwrap();
1143            let mut draft=asset_note.draft();draft.content_md="新草稿不再使用图片".into();
1144            save(1,draft,None).await.unwrap();
1145            act(1, asset_note.id, 2, NoteAction::Trash, None).await.unwrap();
1146            let assets=crate::api::assets::list::list_assets_impl(crate::models::asset::AssetFilter::Used,asset_id.to_string(),crate::models::asset::AssetSort::CreatedDesc,1).await.unwrap();
1147            assert_eq!(assets.total,1);
1148            assert_eq!(assets.assets[0].ref_count,1);
1149            assert!(matches!(&assets.assets[0].refs[0],crate::models::asset::AssetRef::Note {note_id,..} if *note_id==asset_note.id));
1150            assert!(!crate::api::assets::delete::delete_asset_impl(asset_id.to_string()).await.unwrap().success);
1151
1152            let filtered=save(1,NoteDraft {title:"状态筛选验收".into(),content_md:"正文".into(),notebook_ids:vec![public],..Default::default()},None).await.unwrap();
1153            let count=|scope,published,knowledge|list(scope,NoteFilter {query:"状态筛选验收".into(),published,knowledge,..Default::default()});
1154            assert_eq!(count(Access::Owner(1),Some(false),Some(false)).await.unwrap().total,1);
1155            assert_eq!(count(Access::Owner(1),Some(true),None).await.unwrap().total,0);
1156            act(1, filtered.id, 1, NoteAction::Publish, None).await.unwrap();
1157            assert_eq!(count(Access::Owner(1),Some(true),Some(false)).await.unwrap().total,1);
1158            act(1, filtered.id, 1, NoteAction::IncludeKnowledge, None).await.unwrap();
1159            assert_eq!(count(Access::Owner(1),Some(true),Some(true)).await.unwrap().total,1);
1160            assert_eq!(count(Access::Owner(1),None,Some(false)).await.unwrap().total,0);
1161            assert_eq!(count(Access::Public,Some(false),Some(false)).await.unwrap().total,1,"public endpoint ignores private state probes");
1162            assert!(archive_notebook(2,public,true).await.is_err());
1163            archive_notebook(1,public,true).await.unwrap();
1164            assert!(notebooks(Access::Public).await.unwrap().iter().all(|b|b.id!=public));
1165            let archived=notebooks(Access::Owner(1)).await.unwrap().into_iter().find(|b|b.id==public).unwrap();
1166            assert!(archived.archived_at.is_some());assert!(!archived.is_public);
1167            assert!(get(Access::Public,Some(filtered.id),None,None).await.is_ok(),"archiving directory does not retract published notes");
1168            let knowledge=Access::Knowledge {owner_id:1,notebook_ids:Some(vec![public])};
1169            assert!(get(knowledge.clone(),Some(filtered.id),None,None).await.is_ok(),"archiving does not silently revoke grants");
1170            assert!(notebooks(knowledge).await.unwrap().is_empty());
1171            assert!(save_notebook(1,NotebookInput {id:Some(public),title:"归档目录".into(),is_public:true,..Default::default()}).await.is_err());
1172            archive_notebook(1,public,false).await.unwrap();
1173            let restored=notebooks(Access::Owner(1)).await.unwrap().into_iter().find(|b|b.id==public).unwrap();
1174            assert!(restored.archived_at.is_none());assert!(!restored.is_public,"restore requires explicit re-publication");
1175            assert_eq!(get(Access::Owner(1),Some(filtered.id),None,None).await.unwrap().notebook_ids,vec![public]);
1176        });
1177    }
1178
1179    #[test]
1180    #[ignore = "requires disposable DATABASE_URL database ygg_notes_test"]
1181    fn notes_database_context_workflow() {
1182        crate::db::TEST_DATABASE_RUNTIME.block_on(async {
1183            let _guard = crate::db::TEST_DATABASE_LOCK.lock().await;
1184            let mut client = get_conn().await.unwrap();
1185            let database: String = client
1186                .query_one("SELECT current_database()", &[])
1187                .await
1188                .unwrap()
1189                .get(0);
1190            assert_eq!(
1191                database, "ygg_notes_test",
1192                "requires isolated test database"
1193            );
1194            crate::db::migrate::run_on_conn(&mut client).await.unwrap();
1195            client
1196                .batch_execute(
1197                    "TRUNCATE users CASCADE;
1198                INSERT INTO users(id,username,email,password_hash,role) VALUES
1199                (1,'note-owner','note@test.invalid','unused','admin');",
1200                )
1201                .await
1202                .unwrap();
1203            for title in ["甲", "乙", "丙"] {
1204                let input = NotebookInput {
1205                    title: title.into(),
1206                    is_public: true,
1207                    ..Default::default()
1208                };
1209                save_notebook(1, input).await.unwrap();
1210            }
1211            let books = notebooks(Access::Owner(1)).await.unwrap();
1212            let book = |title: &str| books.iter().find(|b| b.title == title).unwrap().id;
1213            let (a, b, c) = (book("甲"), book("乙"), book("丙"));
1214            let mut ids = Vec::new();
1215            for (title, notebook_ids, publish) in [
1216                ("第一篇", vec![a, b], true),
1217                ("草稿", vec![a], false),
1218                ("第二篇", vec![a, c], true),
1219                ("第三篇", vec![a], true),
1220            ] {
1221                let draft = NoteDraft {
1222                    title: title.into(),
1223                    kind: NoteKind::Topic,
1224                    content_md: format!("## 小节\n\n{title}"),
1225                    notebook_ids,
1226                    ..Default::default()
1227                };
1228                let note = save(1, draft, None).await.unwrap();
1229                if publish {
1230                    act(1, note.id, 1, NoteAction::Publish, None).await.unwrap();
1231                }
1232                ids.push(note);
1233            }
1234            reorder(1, a, ids.iter().map(|n| n.id).collect())
1235                .await
1236                .unwrap();
1237            archive_notebook(1, c, true).await.unwrap();
1238            let slug = |i: usize| ids[i].slug.clone();
1239
1240            // 属于两个公开笔记本又没指明时不猜;指明后用它。
1241            assert!(context(&slug(0), None).await.unwrap().is_none());
1242            let first = context(&slug(0), Some(a)).await.unwrap().unwrap();
1243            assert_eq!(first.notebook.id, a);
1244            let titles: Vec<_> = first.siblings.iter().map(|s| s.title.as_str()).collect();
1245            assert_eq!(
1246                titles,
1247                ["第一篇", "第二篇", "第三篇"],
1248                "unpublished siblings are excluded"
1249            );
1250            assert_eq!((first.position, first.total), (1, 3));
1251            assert!(first.prev.is_none());
1252            assert_eq!(first.next.as_ref().unwrap().slug, slug(2));
1253            // 已归档的笔记本被忽略,只剩一个公开候选时用它。
1254            let second = context(&slug(2), Some(c)).await.unwrap().unwrap();
1255            assert_eq!(second.notebook.id, a);
1256            let last = context(&slug(3), None).await.unwrap().unwrap();
1257            assert_eq!(last.position, 3);
1258            assert!(last.next.is_none());
1259            assert_eq!(last.prev.as_ref().unwrap().slug, slug(2));
1260            // 未发布的笔记没有上下文。
1261            assert!(context(&slug(1), Some(a)).await.unwrap().is_none());
1262
1263            let listed = list(Access::Public, NoteFilter::default()).await.unwrap();
1264            assert!(listed
1265                .notes
1266                .iter()
1267                .all(|n| n.sections == 1 && n.toc_html.is_empty()));
1268            let stats = stats(8).await.unwrap();
1269            assert_eq!((stats.moments, stats.topics), (0, 3));
1270        });
1271    }
1272}