package store import ( "database/sql" "encoding/json" "errors" "fmt" "strings" ) // Note 笔记实体。DeletedAt 非 nil 表示处于回收站(软删除)。 type Note struct { ID int64 `json:"id"` Slug string `json:"slug"` Title string `json:"title"` Summary string `json:"summary"` Content string `json:"content,omitempty"` Status string `json:"status"` Tags []string `json:"tags"` Pinned bool `json:"pinned"` DeletedAt *int64 `json:"deleted_at,omitempty"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } // ErrNotFound 统一的「不存在」错误。 var ErrNotFound = errors.New("not found") const noteColumns = "id, slug, title, summary, content, status, tags, pinned, deleted_at, created_at, updated_at" func scanNote(scan func(dest ...any) error) (*Note, error) { var n Note var tagsJSON string var pinned int var content string if err := scan(&n.ID, &n.Slug, &n.Title, &n.Summary, &content, &n.Status, &tagsJSON, &pinned, &n.DeletedAt, &n.CreatedAt, &n.UpdatedAt); err != nil { return nil, err } n.Content = content n.Pinned = pinned != 0 if err := json.Unmarshal([]byte(tagsJSON), &n.Tags); err != nil { n.Tags = []string{} } if n.Tags == nil { n.Tags = []string{} } return &n, nil } // CreateNote 新建笔记,返回 id。 func (s *Store) CreateNote(n *Note) (int64, error) { tags, err := marshalTags(n.Tags) if err != nil { return 0, err } res, err := s.db.Exec( `INSERT INTO notes (slug, title, summary, content, status, tags, pinned, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, n.Slug, n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.CreatedAt, n.UpdatedAt) if err != nil { return 0, err } return res.LastInsertId() } // UpdateNote 更新笔记内容字段(不含 slug 冲突裁决,见 httpapi 层)。 func (s *Store) UpdateNote(n *Note) error { tags, err := marshalTags(n.Tags) if err != nil { return err } res, err := s.db.Exec( `UPDATE notes SET title=?, summary=?, content=?, status=?, tags=?, pinned=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.UpdatedAt, n.ID) if err != nil { return err } if rows, _ := res.RowsAffected(); rows == 0 { return ErrNotFound } return nil } // UpdateNoteSlug 修改 slug(仅正常态笔记)。 func (s *Store) UpdateNoteSlug(id int64, slug string, updatedAt int64) error { res, err := s.db.Exec(`UPDATE notes SET slug=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, slug, updatedAt, id) if err != nil { return err } if rows, _ := res.RowsAffected(); rows == 0 { return ErrNotFound } return nil } // SoftDeleteNote 置 deleted_at(回收站)。refs 不动(§6.2)。 func (s *Store) SoftDeleteNote(id int64, at int64) error { res, err := s.db.Exec(`UPDATE notes SET deleted_at=? WHERE id=? AND deleted_at IS NULL`, at, id) if err != nil { return err } if rows, _ := res.RowsAffected(); rows == 0 { return ErrNotFound } return nil } // RestoreNote 清空 deleted_at。 func (s *Store) RestoreNote(id int64) error { res, err := s.db.Exec(`UPDATE notes SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL`, id) if err != nil { return err } if rows, _ := res.RowsAffected(); rows == 0 { return ErrNotFound } return nil } // GetNoteByID 按 id 取(含回收站)。 func (s *Store) GetNoteByID(id int64) (*Note, error) { row := s.db.QueryRow(`SELECT `+noteColumns+` FROM notes WHERE id=?`, id) n, err := scanNote(row.Scan) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return n, err } // GetNoteBySlug 按 slug 取(含回收站;可见性裁决在调用方)。 func (s *Store) GetNoteBySlug(slug string) (*Note, error) { row := s.db.QueryRow(`SELECT `+noteColumns+` FROM notes WHERE slug=?`, slug) n, err := scanNote(row.Scan) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return n, err } // SlugExists slug 是否已被占用(**含回收站**,§6.2:回收期内 slug 仍被占用)。 // excludeID 用于更新场景排除自身;传 0 表示不排除。 func (s *Store) SlugExists(slug string, excludeID int64) (bool, error) { var one int err := s.db.QueryRow(`SELECT 1 FROM notes WHERE slug=? AND id<>? LIMIT 1`, slug, excludeID).Scan(&one) if errors.Is(err, sql.ErrNoRows) { return false, nil } return err == nil, err } // PublicNoteFilter 可见性过滤的统一谓词(§9.1-T10 单一可信点)。 const PublicNoteFilter = "status='public' AND deleted_at IS NULL" // ListPublicNotes 公开笔记分页列表(不含全文)。tag 非空时按标签过滤。 func (s *Store) ListPublicNotes(page, pageSize int, tag string) ([]Note, int, error) { where := PublicNoteFilter args := []any{} if tag != "" { // json_each 参数化过滤,无拼接注入面。 where += ` AND EXISTS (SELECT 1 FROM json_each(n.tags) je WHERE je.value = ?)` args = append(args, tag) } var total int if err := s.db.QueryRow(`SELECT COUNT(*) FROM notes n WHERE `+where, args...).Scan(&total); err != nil { return nil, 0, err } q := `SELECT n.id, n.slug, n.title, n.summary, '' AS content, n.status, n.tags, n.pinned, n.deleted_at, n.created_at, n.updated_at FROM notes n WHERE ` + where + ` ORDER BY n.pinned DESC, n.updated_at DESC, n.id DESC LIMIT ? OFFSET ?` rows, err := s.db.Query(q, append(args, pageSize, (page-1)*pageSize)...) if err != nil { return nil, 0, err } defer rows.Close() var out []Note for rows.Next() { n, err := scanNote(rows.Scan) if err != nil { return nil, 0, err } out = append(out, *n) } return out, total, rows.Err() } // AdjacentPublicNote 返回公开序列中与 n 相邻的上一篇/下一篇 // (按列表序 updated_at DESC, id DESC)。仅返回 slug 与 title。 func (s *Store) AdjacentPublicNote(n *Note, dir string) (slug, title string, err error) { var q string switch dir { case "prev": // 列表中更早的一篇 q = `SELECT slug, title FROM notes WHERE ` + PublicNoteFilter + ` AND (updated_at > ? OR (updated_at = ? AND id < ?)) ORDER BY updated_at ASC, id DESC LIMIT 1` case "next": // 列表中更新的一篇 q = `SELECT slug, title FROM notes WHERE ` + PublicNoteFilter + ` AND (updated_at < ? OR (updated_at = ? AND id > ?)) ORDER BY updated_at DESC, id ASC LIMIT 1` default: return "", "", fmt.Errorf("dir 必须为 prev|next") } row := s.db.QueryRow(q, n.UpdatedAt, n.UpdatedAt, n.ID) if err := row.Scan(&slug, &title); err != nil { if errors.Is(err, sql.ErrNoRows) { return "", "", nil } return "", "", err } return slug, title, nil } // ListPublicNotesFull 公开笔记列表(**含全文**,RSS/渲染用),上限 limit 条。 func (s *Store) ListPublicNotesFull(limit int) ([]Note, error) { q := `SELECT ` + noteColumns + ` FROM notes WHERE ` + PublicNoteFilter + ` ORDER BY updated_at DESC, id DESC LIMIT ?` rows, err := s.db.Query(q, limit) if err != nil { return nil, err } defer rows.Close() out := []Note{} for rows.Next() { n, err := scanNote(rows.Scan) if err != nil { return nil, err } out = append(out, *n) } return out, rows.Err() } // ListAdminNotes 全部正常笔记(含私有,不含回收站),不含全文。 func (s *Store) ListAdminNotes() ([]Note, error) { q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at FROM notes WHERE deleted_at IS NULL ORDER BY pinned DESC, updated_at DESC, id DESC` return s.queryNotes(q) } // ListTrash 回收站列表(软删除中),不含全文。 func (s *Store) ListTrash() ([]Note, error) { q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at FROM notes WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC` return s.queryNotes(q) } // ListExpiredTrash 过期待物理删除的笔记 id(gc 用)。 func (s *Store) ListExpiredTrash(before int64) ([]int64, error) { rows, err := s.db.Query(`SELECT id FROM notes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, before) if err != nil { return nil, err } defer rows.Close() var ids []int64 for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, err } ids = append(ids, id) } return ids, rows.Err() } // DeleteNoteForever 物理删除(refs 级联,需 foreign_keys=1)。 func (s *Store) DeleteNoteForever(id int64) error { res, err := s.db.Exec(`DELETE FROM notes WHERE id=?`, id) if err != nil { return err } if rows, _ := res.RowsAffected(); rows == 0 { return ErrNotFound } return nil } // TagCount 标签聚合项。 type TagCount struct { Name string `json:"name"` Count int `json:"count"` } // PublicTags 仅统计公开且未删除笔记的标签(§7.1 /api/tags)。 func (s *Store) PublicTags() ([]TagCount, error) { rows, err := s.db.Query(` SELECT je.value AS tag, COUNT(*) AS cnt FROM notes n, json_each(n.tags) je WHERE ` + PublicNoteFilter + ` GROUP BY je.value ORDER BY cnt DESC, tag ASC`) if err != nil { return nil, err } defer rows.Close() var out []TagCount for rows.Next() { var tc TagCount if err := rows.Scan(&tc.Name, &tc.Count); err != nil { return nil, err } out = append(out, tc) } return out, rows.Err() } func (s *Store) queryNotes(q string, args ...any) ([]Note, error) { rows, err := s.db.Query(q, args...) if err != nil { return nil, err } defer rows.Close() out := []Note{} for rows.Next() { n, err := scanNote(rows.Scan) if err != nil { return nil, err } out = append(out, *n) } return out, rows.Err() } func marshalTags(tags []string) (string, error) { if tags == nil { tags = []string{} } b, err := json.Marshal(tags) if err != nil { return "", err } return string(b), nil } func boolToInt(b bool) int { if b { return 1 } return 0 } // NormalizeTags 清洗标签:去空白、去空、去重、上限 20 个、单个 ≤64 字符。 func NormalizeTags(tags []string) []string { seen := map[string]bool{} out := []string{} for _, t := range tags { t = strings.TrimSpace(t) if t == "" || seen[t] { continue } if len([]rune(t)) > 64 { t = string([]rune(t)[:64]) } seen[t] = true out = append(out, t) if len(out) >= 20 { break } } return out }