1use 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
78const 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 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 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 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"),..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!(""),..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 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!(""),..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}