Files
pure-note/internal/store/notes.go
T
wangairnan 5457aeaa33 feat: 新增发布日期自选、站点 Logo、回收站清空等十项改进
- 编辑页标题改 filled 变体;标签改 tags 选择器(可勾选既有/输入新建)
- 笔记发布日期可自选(schema v2 新增 published_at 并回填),前台展示发布日期
- 登录页移除「请输入管理密码以继续」「连续失败将被暂时锁定」文案
- 站点设置每页条数收敛为 10/20/30/50 选择器,后端白名单同源校验
- 站点设置两卡片宽屏左右/窄屏上下;修改密码按钮改常规大小
- 后台各页顶栏固定高度,切换页面不再抖动
- 站点 Logo:settings 新增 site_logo(站内路径),gc/孤儿清单豁免 Logo 图片,
  博客 header 站点名前展示,设置页支持上传/更换/清除
- 回收站右上角一键清空(DELETE /api/admin/trash,含确认弹窗)
- pn init 写入 Markdown 语法示例文档(slug welcome,公开)与程序生成的示例图片
2026-09-09 11:10:30 +08:00

377 lines
11 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
}
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, 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
}