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::*;
5
6#[derive(Clone)]
7pub enum Access {
8    Public,
9    Owner(i32),
10    Knowledge {
11        owner_id: i32,
12        notebook_ids: Option<Vec<i32>>,
13    },
14}
15
16impl Access {
17    fn revision(&self) -> &'static str {
18        match self {
19            Self::Public => "published_version",
20            Self::Owner(_) => "version",
21            Self::Knowledge { .. } => "knowledge_version",
22        }
23    }
24    fn owner(&self) -> Option<i32> {
25        match self {
26            Self::Public => None,
27            Self::Owner(id) => Some(*id),
28            Self::Knowledge { owner_id, .. } => Some(*owner_id),
29        }
30    }
31    fn books(&self) -> Option<Vec<i32>> {
32        match self {
33            Self::Knowledge { notebook_ids, .. } => notebook_ids.clone(),
34            _ => None,
35        }
36    }
37}
38
39fn map_note(row: &tokio_postgres::Row, access: &Access) -> Note {
40    let admin = matches!(access, Access::Owner(_));
41    Note {
42        id: row.get("id"),
43        slug: row.get("slug"),
44        version: row.get("revision"),
45        kind: if row.get::<_, String>("kind") == "topic" {
46            NoteKind::Topic
47        } else {
48            NoteKind::Moment
49        },
50        title: row.get("title"),
51        content_md: row.get("content_md"),
52        content_html: row.get("content_html"),
53        toc_html: row.get("toc_html"),
54        summary: row.get("summary"),
55        tags: row.get("tags"),
56        created_at: row.get("created_at"),
57        updated_at: row.get("revision_at"),
58        published_at: row.get("published_at"),
59        published_version: if admin {
60            row.get("published_version")
61        } else {
62            None
63        },
64        knowledge_version: if admin {
65            row.get("knowledge_version")
66        } else {
67            None
68        },
69        deleted_at: if admin { row.get("deleted_at") } else { None },
70        notebook_ids: row.get("notebook_ids"),
71    }
72}
73
74const COLUMNS: &str = "n.id, n.slug, n.created_at, n.published_at, n.published_version,
75    n.knowledge_version, n.deleted_at, r.version AS revision, r.kind, r.title,
76    r.content_md, r.content_html, r.toc_html, r.summary, r.tags, r.created_at AS revision_at";
77
78// 笔记本本身的可见性也参与过滤,公开结果不泄露私密笔记本的 ID。
79const BOOK_IDS: &str =
80    "ARRAY(SELECT b.id FROM notebooks b JOIN notebook_notes bn ON bn.notebook_id=b.id
81    WHERE bn.note_id=n.id AND ($1::int IS NOT NULL OR (b.is_public AND b.archived_at IS NULL))
82      AND ($2::int[] IS NULL OR b.id=ANY($2)) ORDER BY b.id) AS notebook_ids";
83
84pub async fn list(access: Access, filter: NoteFilter) -> Result<NotePage, AppError> {
85    if filter.query.chars().count() > 200 {
86        return Err(AppError::BadRequest("搜索内容不能超过 200 字".into()));
87    }
88    let client = get_conn().await.map_err(AppError::db_conn)?;
89    let owner = access.owner();
90    let books = access.books();
91    let trash = matches!(access, Access::Owner(_)) && filter.trash;
92    let (published, knowledge) = if matches!(access, Access::Owner(_)) {
93        (filter.published, filter.knowledge)
94    } else {
95        (None, None)
96    };
97    let query = crate::utils::server::escape_like_pattern(filter.query.trim());
98    let kind = filter.kind.map(|k| k.as_str());
99    let predicate = format!("FROM notes n JOIN note_revisions r ON r.note_id=n.id AND r.version=n.{}
100        WHERE ($1::int IS NULL OR n.owner_id=$1)
101        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)))
102        AND (n.deleted_at IS NOT NULL)=$3
103        AND ($4='' OR r.search_text ILIKE '%' || $4 || '%' ESCAPE '\\' OR EXISTS (SELECT 1 FROM unnest(r.tags) tag WHERE tag ILIKE '%' || $4 || '%' ESCAPE '\\'))
104        AND ($5::text IS NULL OR r.kind=$5)
105        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))))
106        AND ($7='' OR $7=ANY(r.tags))
107        AND ($8::bool IS NULL OR (n.published_version IS NOT NULL)=$8)
108        AND ($9::bool IS NULL OR (n.knowledge_version IS NOT NULL)=$9)", access.revision());
109    let params: &[&(dyn tokio_postgres::types::ToSql + Sync)] = &[
110        &owner,
111        &books,
112        &trash,
113        &query,
114        &kind,
115        &filter.notebook_id,
116        &filter.tag,
117        &published,
118        &knowledge,
119    ];
120    let total = client
121        .query_one(&format!("SELECT COUNT(*) {predicate}"), params)
122        .await
123        .map_err(AppError::query)?
124        .get(0);
125    let page = filter.page.clamp(1, 100_000);
126    // 列表仅短随记携带 HTML;长文按需读取,避免每页传输完整知识库。
127    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, ''::text AS toc_html");
128    let sql = format!("SELECT {columns}, {BOOK_IDS} {predicate}
129        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,
130        CASE WHEN $4<>'' THEN word_similarity($4, r.search_text) END DESC,
131        r.created_at DESC, n.id DESC LIMIT 20 OFFSET {}", (page-1)*20);
132    let notes = client
133        .query(&sql, params)
134        .await
135        .map_err(AppError::query)?
136        .iter()
137        .map(|r| map_note(r, &access))
138        .collect();
139    Ok(NotePage { notes, total })
140}
141
142pub async fn get(
143    access: Access,
144    id: Option<i32>,
145    slug: Option<String>,
146    version: Option<i32>,
147) -> Result<Note, AppError> {
148    let client = get_conn().await.map_err(AppError::db_conn)?;
149    let owner = access.owner();
150    let books = access.books();
151    let requested = if matches!(access, Access::Owner(_)) {
152        version
153    } else {
154        None
155    };
156    let sql = format!("SELECT {COLUMNS}, {BOOK_IDS} FROM notes n
157        JOIN note_revisions r ON r.note_id=n.id AND r.version=COALESCE($5, n.{})
158        WHERE ($1::int IS NULL OR n.owner_id=$1)
159        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)))
160        AND (($3::int IS NOT NULL AND n.id=$3) OR ($4::text IS NOT NULL AND n.slug=$4))
161        AND (n.deleted_at IS NULL OR $6)", access.revision());
162    let row = client
163        .query_opt(
164            &sql,
165            &[
166                &owner,
167                &books,
168                &id,
169                &slug,
170                &requested,
171                &matches!(access, Access::Owner(_)),
172            ],
173        )
174        .await
175        .map_err(AppError::query)?
176        .ok_or(AppError::NotFound("笔记不存在或无权访问"))?;
177    Ok(map_note(&row, &access))
178}
179
180fn validate(draft: &mut NoteDraft) -> Result<(), AppError> {
181    draft.title = draft.title.trim().to_string();
182    if draft.title.chars().count() > 200 || draft.content_md.len() > 200_000 {
183        return Err(AppError::BadRequest(
184            "标题最多 200 字,正文最多 200 KB".into(),
185        ));
186    }
187    draft.tags = crate::api::posts::helpers::clean_tags(&draft.tags);
188    if draft.tags.len() > 16 || draft.tags.iter().any(|t| t.chars().count() > 40) {
189        return Err(AppError::BadRequest(
190            "最多 16 个标签,每个标签最多 40 字".into(),
191        ));
192    }
193    draft.notebook_ids.sort_unstable();
194    draft.notebook_ids.dedup();
195    if draft.notebook_ids.len() > 30 {
196        return Err(AppError::BadRequest("最多收录到 30 个笔记本".into()));
197    }
198    if draft.id.is_some() && draft.expected_version.is_none() {
199        return Err(AppError::BadRequest("更新笔记必须提供当前版本".into()));
200    }
201    Ok(())
202}
203
204pub async fn save(
205    owner_id: i32,
206    mut draft: NoteDraft,
207    allowed_books: Option<&[i32]>,
208) -> Result<Note, AppError> {
209    validate(&mut draft)?;
210    if let Some(allowed) = allowed_books {
211        if draft.notebook_ids.is_empty() || draft.notebook_ids.iter().any(|b| !allowed.contains(b))
212        {
213            return Err(AppError::Forbidden("笔记必须位于令牌授权的笔记本中"));
214        }
215    }
216    let fields =
217        crate::api::posts::helpers::render_post_fields(&draft.content_md, "draft", None).await?;
218    let mut client = get_conn().await.map_err(AppError::db_conn)?;
219    let tx = client.transaction().await.map_err(AppError::tx)?;
220    let count: i64 = tx
221        .query_one(
222            "SELECT COUNT(*) FROM notebooks WHERE owner_id=$1 AND id=ANY($2)",
223            &[&owner_id, &draft.notebook_ids],
224        )
225        .await
226        .map_err(AppError::query)?
227        .get(0);
228    if count != draft.notebook_ids.len() as i64 {
229        return Err(AppError::Forbidden("笔记本不存在或无权访问"));
230    }
231    let (id, version) = if let Some(id) = draft.id {
232        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("笔记不存在或已移入回收站"))?;
233        let current: i32 = row.get(0);
234        if Some(current) != draft.expected_version {
235            return Err(AppError::BadRequest(
236                "笔记已在别处修改,请重新加载后再保存;当前编辑内容仍保留在页面".into(),
237            ));
238        }
239        if let Some(allowed) = allowed_books {
240            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);
241            if !found {
242                return Err(AppError::Forbidden("笔记不在令牌授权范围内"));
243            }
244        }
245        (id, current + 1)
246    } else {
247        let slug = uuid::Uuid::new_v4().simple().to_string();
248        let id = tx
249            .query_one(
250                "INSERT INTO notes(owner_id, slug) VALUES ($1,$2) RETURNING id",
251                &[&owner_id, &slug],
252            )
253            .await
254            .map_err(AppError::tx)?
255            .get(0);
256        (id, 1)
257    };
258    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)",
259        &[&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)?;
260    tx.execute(
261        "UPDATE notes SET version=$2, updated_at=NOW() WHERE id=$1",
262        &[&id, &version],
263    )
264    .await
265    .map_err(AppError::tx)?;
266    let allowed = allowed_books.map(|b| b.to_vec());
267    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)?;
268    for book in &draft.notebook_ids {
269        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)?;
270    }
271    super::attachments::sync_refs(&tx, owner_id, id, version, &fields.content_html).await?;
272    tx.commit().await.map_err(AppError::tx)?;
273    // 笔记本归属可影响公开目录,即使正文仍是工作稿也应失效目录缓存。
274    invalidate();
275    get(Access::Owner(owner_id), Some(id), None, None).await
276}
277
278pub async fn act(
279    owner_id: i32,
280    id: i32,
281    expected: i32,
282    action: NoteAction,
283) -> Result<Note, AppError> {
284    let mut client = get_conn().await.map_err(AppError::db_conn)?;
285    let tx = client.transaction().await.map_err(AppError::tx)?;
286    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("笔记不存在"))?;
287    if row.get::<_, i32>("version") != expected {
288        return Err(AppError::BadRequest("笔记版本已变化,请重新加载".into()));
289    }
290    if row
291        .get::<_, Option<chrono::DateTime<chrono::Utc>>>("deleted_at")
292        .is_some()
293        && action != NoteAction::Restore
294    {
295        return Err(AppError::BadRequest("请先从回收站恢复笔记".into()));
296    }
297    if matches!(action, NoteAction::Publish | NoteAction::IncludeKnowledge)
298        && (row.get::<_, String>("content_md").trim().is_empty()
299            || (row.get::<_, String>("kind") == "topic"
300                && row.get::<_, String>("title").trim().is_empty()))
301    {
302        return Err(AppError::BadRequest(
303            "请先填写正文;主题笔记还需要标题".into(),
304        ));
305    }
306    let update = match action {
307        NoteAction::Publish => {
308            "published_version=version, published_at=COALESCE(published_at,NOW())"
309        }
310        NoteAction::Unpublish => "published_version=NULL, published_at=NULL",
311        NoteAction::IncludeKnowledge => "knowledge_version=version",
312        NoteAction::ExcludeKnowledge => "knowledge_version=NULL",
313        NoteAction::Trash => {
314            "deleted_at=NOW(), published_version=NULL, published_at=NULL, knowledge_version=NULL"
315        }
316        NoteAction::Restore => "deleted_at=NULL",
317    };
318    tx.execute(
319        &format!("UPDATE notes SET {update}, updated_at=NOW() WHERE id=$1"),
320        &[&id],
321    )
322    .await
323    .map_err(AppError::tx)?;
324    tx.commit().await.map_err(AppError::tx)?;
325    invalidate();
326    get(Access::Owner(owner_id), Some(id), None, None).await
327}
328
329pub async fn history(owner_id: i32, id: i32) -> Result<Vec<NoteRevision>, AppError> {
330    let client = get_conn().await.map_err(AppError::db_conn)?;
331    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)?;
332    Ok(rows
333        .iter()
334        .map(|r| NoteRevision {
335            version: r.get(0),
336            title: r.get(1),
337            created_at: r.get(2),
338        })
339        .collect())
340}
341
342pub async fn notebooks(access: Access) -> Result<Vec<Notebook>, AppError> {
343    let client = get_conn().await.map_err(AppError::db_conn)?;
344    let owner = access.owner();
345    let books = access.books();
346    let include_archived = matches!(access, Access::Owner(_));
347    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
348        FROM notebooks b WHERE ($1::int IS NULL AND b.is_public OR b.owner_id=$1)
349        AND ($2::int[] IS NULL OR b.id=ANY($2)) AND ($3 OR b.archived_at IS NULL)
350        ORDER BY b.updated_at DESC,b.id DESC", access.revision());
351    let rows = client
352        .query(&sql, &[&owner, &books, &include_archived])
353        .await
354        .map_err(AppError::query)?;
355    Ok(rows
356        .iter()
357        .map(|r| Notebook {
358            id: r.get("id"),
359            title: r.get("title"),
360            description: r.get("description"),
361            is_public: r.get("is_public"),
362            archived_at: r.get("archived_at"),
363            note_count: r.get("note_count"),
364            updated_at: r.get("updated_at"),
365        })
366        .collect())
367}
368
369pub async fn save_notebook(owner_id: i32, input: NotebookInput) -> Result<(), AppError> {
370    let title = input.title.trim();
371    if title.is_empty() || title.chars().count() > 100 || input.description.chars().count() > 1000 {
372        return Err(AppError::BadRequest(
373            "笔记本标题为 1–100 字,介绍最多 1000 字".into(),
374        ));
375    }
376    let client = get_conn().await.map_err(AppError::db_conn)?;
377    if let Some(id) = input.id {
378        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)?;
379        if changed == 0 {
380            return Err(AppError::BadRequest(
381                "笔记本不存在,或已归档;请先恢复再公开".into(),
382            ));
383        }
384    } else {
385        client
386            .execute(
387                "INSERT INTO notebooks(owner_id,title,description,is_public) VALUES ($1,$2,$3,$4)",
388                &[&owner_id, &title, &input.description, &input.is_public],
389            )
390            .await
391            .map_err(AppError::query)?;
392    }
393    invalidate();
394    Ok(())
395}
396
397pub async fn archive_notebook(owner_id: i32, id: i32, archived: bool) -> Result<(), AppError> {
398    let client = get_conn().await.map_err(AppError::db_conn)?;
399    let changed = client.execute(
400        "UPDATE notebooks SET archived_at=CASE WHEN $3 THEN COALESCE(archived_at,NOW()) ELSE NULL END,
401         is_public=CASE WHEN $3 THEN FALSE ELSE is_public END, updated_at=NOW() WHERE id=$1 AND owner_id=$2",
402        &[&id,&owner_id,&archived],
403    ).await.map_err(AppError::query)?;
404    if changed == 0 {
405        return Err(AppError::NotFound("笔记本不存在"));
406    }
407    invalidate();
408    Ok(())
409}
410
411pub async fn reorder(owner_id: i32, book: i32, ids: Vec<i32>) -> Result<(), AppError> {
412    let mut client = get_conn().await.map_err(AppError::db_conn)?;
413    let tx = client.transaction().await.map_err(AppError::tx)?;
414    tx.query_opt(
415        "SELECT id FROM notebooks WHERE id=$1 AND owner_id=$2 FOR UPDATE",
416        &[&book, &owner_id],
417    )
418    .await
419    .map_err(AppError::query)?
420    .ok_or(AppError::NotFound("笔记本不存在"))?;
421    let existing: Vec<i32> = tx
422        .query(
423            "SELECT note_id FROM notebook_notes WHERE notebook_id=$1 ORDER BY position,note_id",
424            &[&book],
425        )
426        .await
427        .map_err(AppError::query)?
428        .iter()
429        .map(|r| r.get(0))
430        .collect();
431    let mut a = existing;
432    a.sort_unstable();
433    let mut b = ids.clone();
434    b.sort_unstable();
435    if a != b {
436        return Err(AppError::BadRequest(
437            "笔记本内容已变化,请刷新后排序".into(),
438        ));
439    }
440    for (position, id) in ids.iter().enumerate() {
441        tx.execute(
442            "UPDATE notebook_notes SET position=$3 WHERE notebook_id=$1 AND note_id=$2",
443            &[&book, id, &(position as i32)],
444        )
445        .await
446        .map_err(AppError::tx)?;
447    }
448    tx.commit().await.map_err(AppError::tx)?;
449    invalidate();
450    Ok(())
451}
452
453fn invalidate() {
454    crate::ssr_cache::bump_global_generation();
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    #[test]
462    #[ignore = "requires disposable DATABASE_URL database ygg_notes_test"]
463    fn notes_database_workflow() {
464        crate::db::TEST_DATABASE_RUNTIME.block_on(async {
465            let _guard = crate::db::TEST_DATABASE_LOCK.lock().await;
466            let mut client = get_conn().await.unwrap();
467            let database: String = client
468                .query_one("SELECT current_database()", &[])
469                .await
470                .unwrap()
471                .get(0);
472            assert_eq!(
473                database, "ygg_notes_test",
474                "requires isolated test database"
475            );
476            crate::db::migrate::run_on_conn(&mut client).await.unwrap();
477            client
478                .batch_execute(
479                    "TRUNCATE users CASCADE;
480                INSERT INTO users(id,username,email,password_hash,role) VALUES
481                (1,'note-owner','note@test.invalid','unused','admin'),
482                (2,'note-other','other@test.invalid','unused','blocked');",
483                )
484                .await
485                .unwrap();
486            save_notebook(
487                1,
488                NotebookInput {
489                    title: "公开主题".into(),
490                    is_public: true,
491                    ..Default::default()
492                },
493            )
494            .await
495            .unwrap();
496            save_notebook(
497                1,
498                NotebookInput {
499                    title: "私密主题".into(),
500                    ..Default::default()
501                },
502            )
503            .await
504            .unwrap();
505            let books = notebooks(Access::Owner(1)).await.unwrap();
506            let public = books.iter().find(|b| b.is_public).unwrap().id;
507            let private = books.iter().find(|b| !b.is_public).unwrap().id;
508            let note = save(
509                1,
510                NoteDraft {
511                    title: "所有权".into(),
512                    kind: NoteKind::Topic,
513                    content_md: "第一版 Rust 100%_".into(),
514                    notebook_ids: vec![public, private],
515                    ..Default::default()
516                },
517                None,
518            )
519            .await
520            .unwrap();
521            assert!(get(Access::Public, Some(note.id), None, None)
522                .await
523                .is_err());
524            assert!(get(Access::Owner(2), Some(note.id), None, None)
525                .await
526                .is_err());
527            assert_eq!(
528                list(Access::Public, NoteFilter::default())
529                    .await
530                    .unwrap()
531                    .total,
532                0
533            );
534            assert_eq!(notebooks(Access::Public).await.unwrap()[0].note_count, 0);
535            act(1, note.id, 1, NoteAction::Publish).await.unwrap();
536            act(1, note.id, 1, NoteAction::IncludeKnowledge)
537                .await
538                .unwrap();
539            let mut draft = note.draft();
540            draft.content_md = "第二版,尚未发布".into();
541            let updated = save(1, draft.clone(), None).await.unwrap();
542            assert_eq!(updated.version, 2);
543            assert!(
544                save(1, draft, None).await.is_err(),
545                "stale writes must fail"
546            );
547            let published = get(Access::Public, Some(note.id), None, Some(2))
548                .await
549                .unwrap();
550            assert_eq!(
551                published.version, 1,
552                "public cannot select a draft revision"
553            );
554            assert_eq!(published.notebook_ids, vec![public]);
555            assert!(published.knowledge_version.is_none());
556            let knowledge = Access::Knowledge {
557                owner_id: 1,
558                notebook_ids: Some(vec![private]),
559            };
560            assert_eq!(
561                get(knowledge.clone(), Some(note.id), None, None)
562                    .await
563                    .unwrap()
564                    .version,
565                1
566            );
567            assert!(get(
568                Access::Knowledge {
569                    owner_id: 1,
570                    notebook_ids: Some(vec![])
571                },
572                Some(note.id),
573                None,
574                None
575            )
576            .await
577            .is_err());
578            assert_eq!(
579                list(
580                    Access::Public,
581                    NoteFilter {
582                        query: "100%_".into(),
583                        ..Default::default()
584                    }
585                )
586                .await
587                .unwrap()
588                .total,
589                1
590            );
591            assert_eq!(
592                list(
593                    Access::Public,
594                    NoteFilter {
595                        query: "第二版".into(),
596                        ..Default::default()
597                    }
598                )
599                .await
600                .unwrap()
601                .total,
602                0
603            );
604            assert_eq!(
605                list(
606                    Access::Public,
607                    NoteFilter {
608                        notebook_id: Some(private),
609                        ..Default::default()
610                    }
611                )
612                .await
613                .unwrap()
614                .total,
615                0
616            );
617            act(1, note.id, 2, NoteAction::Unpublish).await.unwrap();
618            assert!(get(Access::Public, Some(note.id), None, None)
619                .await
620                .is_err());
621            assert!(get(knowledge.clone(), Some(note.id), None, None)
622                .await
623                .is_ok());
624            act(1, note.id, 2, NoteAction::Trash).await.unwrap();
625            assert!(get(knowledge, Some(note.id), None, None).await.is_err());
626            assert_eq!(
627                list(
628                    Access::Owner(1),
629                    NoteFilter {
630                        trash: true,
631                        ..Default::default()
632                    }
633                )
634                .await
635                .unwrap()
636                .total,
637                1
638            );
639            act(1, note.id, 2, NoteAction::Restore).await.unwrap();
640            assert!(
641                get(Access::Public, Some(note.id), None, None)
642                    .await
643                    .is_err(),
644                "restore never republishes"
645            );
646            assert_eq!(history(1, note.id).await.unwrap().len(), 2);
647            let mut old = get(Access::Owner(1), Some(note.id), None, Some(1))
648                .await
649                .unwrap()
650                .draft();
651            old.expected_version = Some(2);
652            assert_eq!(
653                save(1, old, None).await.unwrap().version,
654                3,
655                "restoration creates a new version"
656            );
657            assert!(act(2, note.id, 3, NoteAction::Publish).await.is_err());
658            reorder(1, public, vec![note.id]).await.unwrap();
659            assert!(reorder(2, public, vec![note.id]).await.is_err());
660
661            // 私密图片不能靠猜 URL 读取;发布和撤回立即改变访问权。
662            let image_id=uuid::Uuid::new_v4();
663            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();
664            let media_status=|| super::super::attachments::read(axum::http::HeaderMap::new(),axum::extract::Path(image_id.to_string()));
665            assert_eq!(media_status().await.status(),axum::http::StatusCode::NOT_FOUND);
666            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();
667            act(1,reference_only.id,1,NoteAction::Publish).await.unwrap();
668            assert_eq!(media_status().await.status(),axum::http::StatusCode::NOT_FOUND,"code examples and remote URLs cannot publish local media");
669            let draft=NoteDraft {content_md:format!("![私密图片](/note-media/{image_id})"),..Default::default()};
670            assert!(save(2,draft.clone(),None).await.is_err(),"cannot reuse someone else's private attachment");
671            let image_note=save(1,draft,None).await.unwrap();
672            act(1,image_note.id,1,NoteAction::IncludeKnowledge).await.unwrap();
673            assert_eq!(media_status().await.status(),axum::http::StatusCode::NOT_FOUND,"knowledge enrollment is not public access");
674            act(1,image_note.id,1,NoteAction::Publish).await.unwrap();
675            let response=media_status().await;
676            assert_eq!(response.status(),axum::http::StatusCode::OK);
677            assert_eq!(response.headers()["cache-control"],"private, no-store");
678            let mut draft=image_note.draft();draft.content_md="草稿移除了图片,公开版仍保留".into();
679            save(1,draft,None).await.unwrap();
680            assert_eq!(media_status().await.status(),axum::http::StatusCode::OK);
681            act(1,image_note.id,2,NoteAction::Unpublish).await.unwrap();
682            assert_eq!(media_status().await.status(),axum::http::StatusCode::NOT_FOUND);
683
684            // 公开素材即使只被笔记历史版引用,也不能变成可清理孤儿。
685            let asset_id=uuid::Uuid::new_v4();
686            let path=format!("2026/09/20/{asset_id}.webp");
687            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();
688            let asset_note=save(1,NoteDraft {content_md:format!("![素材](/uploads/{path})"),..Default::default()},None).await.unwrap();
689            let mut draft=asset_note.draft();draft.content_md="新草稿不再使用图片".into();
690            save(1,draft,None).await.unwrap();
691            act(1,asset_note.id,2,NoteAction::Trash).await.unwrap();
692            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();
693            assert_eq!(assets.total,1);
694            assert_eq!(assets.assets[0].ref_count,1);
695            assert!(matches!(&assets.assets[0].refs[0],crate::models::asset::AssetRef::Note {note_id,..} if *note_id==asset_note.id));
696            assert!(!crate::api::assets::delete::delete_asset_impl(asset_id.to_string()).await.unwrap().success);
697
698            let filtered=save(1,NoteDraft {title:"状态筛选验收".into(),content_md:"正文".into(),notebook_ids:vec![public],..Default::default()},None).await.unwrap();
699            let count=|scope,published,knowledge|list(scope,NoteFilter {query:"状态筛选验收".into(),published,knowledge,..Default::default()});
700            assert_eq!(count(Access::Owner(1),Some(false),Some(false)).await.unwrap().total,1);
701            assert_eq!(count(Access::Owner(1),Some(true),None).await.unwrap().total,0);
702            act(1,filtered.id,1,NoteAction::Publish).await.unwrap();
703            assert_eq!(count(Access::Owner(1),Some(true),Some(false)).await.unwrap().total,1);
704            act(1,filtered.id,1,NoteAction::IncludeKnowledge).await.unwrap();
705            assert_eq!(count(Access::Owner(1),Some(true),Some(true)).await.unwrap().total,1);
706            assert_eq!(count(Access::Owner(1),None,Some(false)).await.unwrap().total,0);
707            assert_eq!(count(Access::Public,Some(false),Some(false)).await.unwrap().total,1,"public endpoint ignores private state probes");
708            assert!(archive_notebook(2,public,true).await.is_err());
709            archive_notebook(1,public,true).await.unwrap();
710            assert!(notebooks(Access::Public).await.unwrap().iter().all(|b|b.id!=public));
711            let archived=notebooks(Access::Owner(1)).await.unwrap().into_iter().find(|b|b.id==public).unwrap();
712            assert!(archived.archived_at.is_some());assert!(!archived.is_public);
713            assert!(get(Access::Public,Some(filtered.id),None,None).await.is_ok(),"archiving directory does not retract published notes");
714            let knowledge=Access::Knowledge {owner_id:1,notebook_ids:Some(vec![public])};
715            assert!(get(knowledge.clone(),Some(filtered.id),None,None).await.is_ok(),"archiving does not silently revoke grants");
716            assert!(notebooks(knowledge).await.unwrap().is_empty());
717            assert!(save_notebook(1,NotebookInput {id:Some(public),title:"归档目录".into(),is_public:true,..Default::default()}).await.is_err());
718            archive_notebook(1,public,false).await.unwrap();
719            let restored=notebooks(Access::Owner(1)).await.unwrap().into_iter().find(|b|b.id==public).unwrap();
720            assert!(restored.archived_at.is_none());assert!(!restored.is_public,"restore requires explicit re-publication");
721            assert_eq!(get(Access::Owner(1),Some(filtered.id),None,None).await.unwrap().notebook_ids,vec![public]);
722        });
723    }
724}