Files
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

179 lines
5.1 KiB
Go
Raw Permalink 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 SQLite 打开、版本化迁移与全部数据访问。
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"path/filepath"
"time"
_ "modernc.org/sqlite"
)
// bgCtx DAO 内部使用的后台 context(个人规模查询均为快速查询)。
var bgCtx = context.Background()
// ErrSchemaNewer 数据库 schema 版本超出代码支持范围(§10.4 启动守卫)。
var ErrSchemaNewer = errors.New("数据库 schema 版本高于本程序支持的最高版本,拒绝启动(可用 --allow-newer 显式放行)")
// migrations 仅追加式:禁止删列/重命名/改类型(§6.2)。
var migrations = []string{
// v1: 初始 schema(§6.1 DDL)
`
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'private'
CHECK (status IN ('public','private')),
tags TEXT NOT NULL DEFAULT '[]',
pinned INTEGER NOT NULL DEFAULT 0,
deleted_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notes_public
ON notes (status, pinned, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_notes_deleted ON notes (deleted_at);
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sha256 TEXT NOT NULL UNIQUE,
mime TEXT NOT NULL,
size INTEGER NOT NULL,
data BLOB NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS image_refs (
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
PRIMARY KEY (image_id, note_id)
);
CREATE INDEX IF NOT EXISTS idx_image_refs_note ON image_refs (note_id);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
csrf_token TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions (expires_at);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`,
// v2: 笔记发布日期(可自选);存量笔记回填为 created_at
`
ALTER TABLE notes ADD COLUMN published_at INTEGER;
UPDATE notes SET published_at = created_at WHERE published_at IS NULL;
`,
}
// MaxSchemaVersion 代码支持的最高 schema 版本。
var MaxSchemaVersion = len(migrations)
// Store 封装 *sql.DB;单连接串行写(§6.2 并发策略)。
type Store struct {
db *sql.DB
}
// Open 打开数据库并执行迁移(start/init 用)。dbPath 为文件绝对/相对路径。
// allowNewer 跳过 user_version 上界守卫(§10.4)。
func Open(dbPath string, allowNewer bool) (*Store, error) {
db, err := openDB(dbPath)
if err != nil {
return nil, err
}
if err := migrateDB(db, allowNewer); err != nil {
db.Close()
return nil, err
}
return &Store{db: db}, nil
}
// OpenData 打开数据库仅做数据操作:**不迁移、不做版本守卫**
// (passwd/backup/gc 维护子命令,D6:在更新 schema 的库上备份/清理
// 不应被守卫阻断,也不应产生迁移副作用)。
func OpenData(dbPath string) (*Store, error) {
db, err := openDB(dbPath)
if err != nil {
return nil, err
}
return &Store{db: db}, nil
}
func openDB(dbPath string) (*sql.DB, error) {
// SQLite URI 不接受相对路径
abs, err := filepath.Abs(dbPath)
if err != nil {
return nil, err
}
dsn := url.URL{
Scheme: "file",
Path: abs,
RawQuery: "_pragma=journal_mode(WAL)" +
"&_pragma=busy_timeout(5000)" +
"&_pragma=foreign_keys(1)" +
"&_pragma=synchronous(NORMAL)",
}
db, err := sql.Open("sqlite", dsn.String())
if err != nil {
return nil, err
}
// 单写者串行化(§6.2)
db.SetMaxOpenConns(1)
return db, nil
}
func migrateDB(db *sql.DB, allowNewer bool) error {
var v int
if err := db.QueryRow("PRAGMA user_version").Scan(&v); err != nil {
return err
}
if v > MaxSchemaVersion && !allowNewer {
return fmt.Errorf("%w: 库版本 %d > 支持上限 %d", ErrSchemaNewer, v, MaxSchemaVersion)
}
for i := v; i < MaxSchemaVersion; i++ {
tx, err := db.Begin()
if err != nil {
return err
}
if _, err := tx.Exec(migrations[i]); err != nil {
tx.Rollback()
return fmt.Errorf("执行迁移 v%d 失败: %w", i+1, err)
}
// PRAGMA 不能参数化,i 为内部 int 常量,无注入面。
if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", i+1)); err != nil {
tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}
// SchemaVersion 返回当前库版本。
func (s *Store) SchemaVersion() (int, error) {
var v int
err := s.db.QueryRow("PRAGMA user_version").Scan(&v)
return v, err
}
// Close 关闭连接。
func (s *Store) Close() error { return s.db.Close() }
// DB 暴露底层连接(仅备份等维护路径使用)。
func (s *Store) DB() *sql.DB { return s.db }
func nowUnix() int64 { return time.Now().Unix() }