Files
pure-note/internal/store/notes.go
T
wangairnan 5bac272f6d feat: 前台标签接入 antd 组件、列表封面图、favicon 随站点 Logo 及侧栏收窄
- 前台列表/详情/标签页标签统一 antd Tag;标签页改 CheckableTagGroup 单选
  (选中态由组件承接,再点已选标签回到全部);移除首页标签筛选条,
  标签入口收敛至顶部「标签」页
- 公开列表新增 cover 字段(取正文第一张站内图,store.FirstImageRef),
  首页条目左侧显示封面缩略图,无图条目保持纯文字
- favicon 随站点 Logo:webui.Meta.Icon 服务端注入 index.html,
  前端 useSiteTitle 兜底(dev 模板占位符场景)并即时生效;
  设置页「重置默认」清空 Logo 后回退内置 /favicon.svg
- 后台侧栏改 antd Menu(inline 分组),宽度 256→208、窄侧栏档 224→184
2026-09-09 11:51:06 +08:00

378 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
)
// Note 笔记实体。DeletedAt 非 nil 表示处于回收站(软删除)。
// PublishedAt 为对外展示的发布日期(可自选,§8.2);排序仍按 updated_at。
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"`
PublishedAt int64 `json:"published_at"`
}
// ErrNotFound 统一的「不存在」错误。
var ErrNotFound = errors.New("not found")
// published_at 经 v2 迁移回填,理论上恒非 NULL;COALESCE 兜底历史异常行。
const noteColumns = "id, slug, title, summary, content, status, tags, pinned, deleted_at, created_at, updated_at, COALESCE(published_at, created_at) AS published_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, &n.PublishedAt); 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, published_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
n.Slug, n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.PublishedAt, 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=?, published_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.PublishedAt, 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
}
// 含全文:列表页需从正文推导封面图(FirstImageRef),个人规模条数有限可接受
q := `SELECT n.id, n.slug, n.title, n.summary, n.content, n.status, n.tags, n.pinned, n.deleted_at, n.created_at, n.updated_at, COALESCE(n.published_at, n.created_at) AS published_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 相邻的上一篇/下一篇。
// 列表序为 pinned DESC, updated_at DESC, id DESC(notes.go ListPublicNotes),
// prev = 列表中紧邻 n 之前的一篇(元组 (pinned, updated_at, id) 字典序更大侧的最小项),
// next = 紧邻之后的一篇(字典序更小侧的最大项)。仅返回 slug 与 title。
func (s *Store) AdjacentPublicNote(n *Note, dir string) (slug, title string, err error) {
p := boolToInt(n.Pinned)
var q string
switch dir {
case "prev": // 列表中位于 n 之前的一篇
q = `SELECT slug, title FROM notes WHERE ` + PublicNoteFilter + `
AND (pinned > ? OR (pinned = ? AND (updated_at > ? OR (updated_at = ? AND id > ?))))
ORDER BY pinned ASC, updated_at ASC, id ASC LIMIT 1`
case "next": // 列表中位于 n 之后的一篇
q = `SELECT slug, title FROM notes WHERE ` + PublicNoteFilter + `
AND (pinned < ? OR (pinned = ? AND (updated_at < ? OR (updated_at = ? AND id < ?))))
ORDER BY pinned DESC, updated_at DESC, id DESC LIMIT 1`
default:
return "", "", fmt.Errorf("dir 必须为 prev|next")
}
row := s.db.QueryRow(q, p, p, 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, COALESCE(published_at, created_at) AS published_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, COALESCE(published_at, created_at) AS published_at
FROM notes WHERE deleted_at IS NOT NULL
ORDER BY deleted_at DESC`
return s.queryNotes(q)
}
// EmptyTrash 物理删除回收站全部笔记(refs 级联,需 foreign_keys=1),返回删除数。
func (s *Store) EmptyTrash() (int64, error) {
res, err := s.db.Exec(`DELETE FROM notes WHERE deleted_at IS NOT NULL`)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// 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
}