邻接查询 tie-break 与 pinned 排序键与列表序对齐,补表驱动测试(round2 P1-1); GC DELETE 带条件复查,防 SELECT 与 DELETE 间恢复/引用竞态(P2-6); 备份经 umask 收紧创建即 0600,消除 chmod 前暴露窗口(P2-7); page_size 改 strconv.Atoi 全文解析,拒部分解析脏值(P2-13); OpenData 不迁移、不做版本守卫(D6/P1-5)。
365 lines
11 KiB
Go
365 lines
11 KiB
Go
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 相邻的上一篇/下一篇。
|
||
// 列表序为 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
|
||
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
|
||
}
|