MVP: 按 07 风格重写前端 + Go 后端落地(长文/短文、编辑器、后台管理)

This commit is contained in:
Sakurasan
2026-09-20 20:56:03 +08:00
parent edee708802
commit 4d8f2de3a4
96 changed files with 6005 additions and 2602 deletions
+669
View File
@@ -0,0 +1,669 @@
// Package store owns the schema and every SQL query. All SQL is written with
// `?` placeholders and re-bound to `$n` when running on PostgreSQL.
package store
import (
"database/sql"
"errors"
"fmt"
"regexp"
"strings"
"time"
"oneblog/internal/db"
"oneblog/internal/model"
"oneblog/internal/render"
)
func renderHTML(md string) string { return render.Markdown(md) }
func readingMinutes(md string) int { return render.ReadingMinutes(md) }
var ErrNotFound = errors.New("not found")
type Store struct {
db *db.DB
}
func New(d *db.DB) (*Store, error) {
s := &Store{db: d}
if err := s.migrate(); err != nil {
return nil, err
}
return s, nil
}
func now() string { return time.Now().UTC().Format(time.RFC3339) }
func (s *Store) migrate() error {
ai := s.db.AutoInc()
stmts := []string{
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS posts (
id %s,
kind TEXT NOT NULL DEFAULT 'long',
title TEXT NOT NULL DEFAULT '',
slug TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
content_md TEXT NOT NULL DEFAULT '',
content_html TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft',
published_at TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
reading_minutes INTEGER NOT NULL DEFAULT 1
)`, ai),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS tags (
id %s,
name TEXT NOT NULL,
slug TEXT NOT NULL
)`, ai),
`CREATE TABLE IF NOT EXISTS post_tags (
post_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (post_id, tag_id)
)`,
`CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
)`,
}
for _, q := range stmts {
if _, err := s.db.Exec(s.db.Q(q)); err != nil {
return fmt.Errorf("migrate: %w", err)
}
}
// Indexes/unique constraints need dialect-specific "IF NOT EXISTS" support.
indexes := []struct{ name, ddl string }{
{"idx_posts_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug)`},
{"idx_posts_feed", `CREATE INDEX IF NOT EXISTS idx_posts_feed ON posts(status, published_at DESC)`},
{"idx_tags_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_slug ON tags(slug)`},
{"idx_post_tags_tag", `CREATE INDEX IF NOT EXISTS idx_post_tags_tag ON post_tags(tag_id)`},
}
for _, ix := range indexes {
if _, err := s.db.Exec(s.db.Q(ix.ddl)); err != nil && !strings.Contains(err.Error(), "already exists") {
return fmt.Errorf("migrate index %s: %w", ix.name, err)
}
}
return s.seedSettings()
}
func (s *Store) seedSettings() error {
defs := map[string]string{
"site_title": "ONE · 一个博客",
"site_desc": "长文与短文,同一种节奏。",
"author_name": "ONE",
"author_bio": "写点长的,也写点短的。",
"footer_note": "© ONE · 一个博客",
"icp": "",
"posts_per_page": "10",
}
for k, v := range defs {
if s.db.Dialect == db.Postgres {
_, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?)
ON CONFLICT (key) DO NOTHING`), k, v)
if err != nil {
return err
}
continue
}
if _, err := s.db.Exec(s.db.Q(`INSERT OR IGNORE INTO settings(key,value) VALUES (?,?)`), k, v); err != nil {
return err
}
}
return nil
}
// ---------- settings ----------
func (s *Store) GetSettings() (model.Settings, error) {
rows, err := s.db.Query(s.db.Q(`SELECT key, value FROM settings`))
if err != nil {
return model.Settings{}, err
}
defer rows.Close()
m := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return model.Settings{}, err
}
m[k] = v
}
return settingsFromMap(m), rows.Err()
}
func settingsFromMap(m map[string]string) model.Settings {
st := model.Settings{
SiteTitle: m["site_title"],
SiteDesc: m["site_desc"],
AuthorName: m["author_name"],
AuthorBio: m["author_bio"],
FooterNote: m["footer_note"],
ICPLicense: m["icp"],
PostsPerPage: 10,
}
if n := atoi(m["posts_per_page"]); n > 0 {
st.PostsPerPage = n
}
return st
}
func atoi(v string) int {
n := 0
for _, r := range v {
if r < '0' || r > '9' {
return 0
}
n = n*10 + int(r-'0')
}
return n
}
func (s *Store) UpdateSettings(st model.Settings) error {
if st.PostsPerPage <= 0 {
st.PostsPerPage = 10
}
sets := map[string]string{
"site_title": st.SiteTitle,
"site_desc": st.SiteDesc,
"author_name": st.AuthorName,
"author_bio": st.AuthorBio,
"footer_note": st.FooterNote,
"icp": st.ICPLicense,
"posts_per_page": fmt.Sprint(st.PostsPerPage),
}
for k, v := range sets {
if s.db.Dialect == db.Postgres {
if _, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`), k, v); err != nil {
return err
}
continue
}
if _, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`), k, v); err != nil {
return err
}
}
return nil
}
// ---------- posts ----------
type ListOptions struct {
Kind string
Tag string
Query string
Status string // "" = published only (public), "any" = all (admin)
Page int
Size int
OrderBy string
}
const postCols = `id, kind, title, slug, summary, content_md, content_html, status,
published_at, created_at, updated_at, reading_minutes`
func scanPost(rows interface{ Scan(...any) error }) (model.Post, error) {
var p model.Post
err := rows.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.ContentMd,
&p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt, &p.ReadingMinutes)
p.Tags = []string{}
return p, err
}
func (s *Store) List(o ListOptions) (model.Page, error) {
if o.Page < 1 {
o.Page = 1
}
if o.Size < 1 || o.Size > 100 {
o.Size = 10
}
where := []string{}
args := []any{}
if o.Status == "any" {
// admin: no status filter
} else if o.Status != "" {
where = append(where, "status = ?")
args = append(args, o.Status)
} else {
where = append(where, "status = 'published'")
}
if o.Kind != "" {
where = append(where, "kind = ?")
args = append(args, o.Kind)
}
if o.Tag != "" {
where = append(where, `id IN (SELECT pt.post_id FROM post_tags pt JOIN tags t ON t.id = pt.tag_id
WHERE t.slug = ? OR t.name = ?)`)
args = append(args, o.Tag, o.Tag)
}
if o.Query != "" {
like := "%" + strings.ToLower(o.Query) + "%"
where = append(where, `(lower(title) LIKE ? OR lower(summary) LIKE ? OR lower(content_md) LIKE ?)`)
args = append(args, like, like, like)
}
w := ""
if len(where) > 0 {
w = "WHERE " + strings.Join(where, " AND ")
}
order := "published_at DESC"
if o.OrderBy != "" {
order = o.OrderBy
}
var total int
if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM posts `+w), args...).Scan(&total); err != nil {
return model.Page{}, err
}
q := s.db.Q(fmt.Sprintf(`SELECT %s FROM posts %s ORDER BY %s LIMIT ? OFFSET ?`, postCols, w, order))
rows, err := s.db.Query(q, append(args, o.Size, (o.Page-1)*o.Size)...)
if err != nil {
return model.Page{}, err
}
defer rows.Close()
items := []model.Post{}
for rows.Next() {
p, err := scanPost(rows)
if err != nil {
return model.Page{}, err
}
items = append(items, p)
}
if err := rows.Err(); err != nil {
return model.Page{}, err
}
if err := s.attachTags(items); err != nil {
return model.Page{}, err
}
return model.Page{Items: items, Total: total, Page: o.Page, Size: o.Size}, nil
}
func (s *Store) attachTags(posts []model.Post) error {
if len(posts) == 0 {
return nil
}
ids := make([]any, 0, len(posts))
idx := map[int64]int{}
for i, p := range posts {
ids = append(ids, p.ID)
idx[p.ID] = i
}
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
q := s.db.Q(fmt.Sprintf(`SELECT pt.post_id, t.name FROM post_tags pt
JOIN tags t ON t.id = pt.tag_id WHERE pt.post_id IN (%s) ORDER BY t.name`, ph))
rows, err := s.db.Query(q, ids...)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var pid int64
var name string
if err := rows.Scan(&pid, &name); err != nil {
return err
}
if i, ok := idx[pid]; ok {
posts[i].Tags = append(posts[i].Tags, name)
}
}
return rows.Err()
}
func (s *Store) Get(id int64) (model.Post, error) {
var p model.Post
row := s.db.QueryRow(s.db.Q(`SELECT `+postCols+` FROM posts WHERE id = ?`), id)
if err := scanPostInto(row, &p); err != nil {
return p, err
}
items := []model.Post{p}
if err := s.attachTags(items); err != nil {
return p, err
}
return items[0], nil
}
func (s *Store) GetBySlug(slug string) (model.Post, error) {
var p model.Post
row := s.db.QueryRow(s.db.Q(`SELECT `+postCols+` FROM posts WHERE slug = ?`), slug)
if err := scanPostInto(row, &p); err != nil {
return p, err
}
items := []model.Post{p}
if err := s.attachTags(items); err != nil {
return p, err
}
return items[0], nil
}
func scanPostInto(row *sql.Row, p *model.Post) error {
err := row.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.ContentMd,
&p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt, &p.ReadingMinutes)
if err == sql.ErrNoRows {
return ErrNotFound
}
if err != nil {
return err
}
p.Tags = []string{}
return nil
}
// ---------- slug ----------
var slugSep = regexp.MustCompile(`[^\p{L}\p{N}]+`)
func Slugify(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
s = slugSep.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
return s
}
func (s *Store) uniqueSlug(base string, excludeID int64) string {
base = Slugify(base)
if base == "" {
base = "post"
}
candidate := base
for i := 2; ; i++ {
var id int64
err := s.db.QueryRow(s.db.Q(`SELECT id FROM posts WHERE slug = ? AND id <> ?`), candidate, excludeID).Scan(&id)
if err == sql.ErrNoRows {
return candidate
}
if err != nil {
return fmt.Sprintf("%s-%d", base, time.Now().Unix())
}
candidate = fmt.Sprintf("%s-%d", base, i)
}
}
// ---------- write ----------
func (s *Store) Create(in model.PostInput) (model.Post, error) {
p := model.Post{
Kind: in.Kind,
Title: strings.TrimSpace(in.Title),
Slug: in.Slug,
Status: in.Status,
Tags: []string{},
}
if p.Kind == "" {
p.Kind = model.KindLong
}
if p.Status == "" {
p.Status = model.StatusDraft
}
if p.Slug == "" {
p.Slug = Slugify(p.Title)
}
if p.Slug == "" {
// Titles without any latin characters (typical for short notes) get a
// date-based slug instead of colliding on "post", "post-2", ...
p.Slug = "s-" + time.Now().UTC().Format("20060102-150405")
}
p.Slug = s.uniqueSlug(p.Slug, 0)
// Short posts have no visible title, but archive and tag listings still
// need something to index them by.
if p.Kind == model.KindShort && p.Title == "" {
p.Title = render.TitleFromMarkdown(in.ContentMd)
}
p.Summary = strings.TrimSpace(in.Summary)
p.ContentMd = in.ContentMd
p.ContentHTML = renderHTML(in.ContentMd)
p.PublishedAt = in.PublishedAt
p.CreatedAt = now()
p.UpdatedAt = p.CreatedAt
if p.PublishedAt == "" {
p.PublishedAt = p.CreatedAt
}
if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 {
p.ReadingMinutes = *in.ReadingMinutes
} else {
p.ReadingMinutes = readingMinutes(in.ContentMd)
}
var id int64
q := s.db.Q(`INSERT INTO posts (kind,title,slug,summary,content_md,content_html,status,
published_at,created_at,updated_at,reading_minutes)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`)
if s.db.Dialect == db.Postgres {
err := s.db.QueryRow(q, p.Kind, p.Title, p.Slug, p.Summary, p.ContentMd, p.ContentHTML,
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes).Scan(&id)
if err != nil {
return p, err
}
} else {
res, err := s.db.Exec(q, p.Kind, p.Title, p.Slug, p.Summary, p.ContentMd, p.ContentHTML,
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes)
if err != nil {
return p, err
}
id, err = res.LastInsertId()
if err != nil {
return p, err
}
}
p.ID = id
if err := s.setTags(id, in.Tags); err != nil {
return p, err
}
return s.Get(id)
}
func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) {
cur, err := s.Get(id)
if err != nil {
return cur, err
}
p := cur
if in.Kind != "" {
p.Kind = in.Kind
}
if in.Title != "" || in.Kind == model.KindShort {
p.Title = strings.TrimSpace(in.Title)
}
if in.Summary != "" {
p.Summary = strings.TrimSpace(in.Summary)
}
if in.ContentMd != "" {
p.ContentMd = in.ContentMd
p.ContentHTML = renderHTML(in.ContentMd)
}
if in.Status != "" {
p.Status = in.Status
}
if in.Slug != "" && in.Slug != cur.Slug {
p.Slug = s.uniqueSlug(in.Slug, id)
}
if in.PublishedAt != "" {
p.PublishedAt = in.PublishedAt
}
p.UpdatedAt = now()
if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 {
p.ReadingMinutes = *in.ReadingMinutes
} else if in.ContentMd != "" {
p.ReadingMinutes = readingMinutes(in.ContentMd)
}
if _, err := s.db.Exec(s.db.Q(`UPDATE posts SET kind=?,title=?,slug=?,summary=?,content_md=?,
content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=? WHERE id=?`),
p.Kind, p.Title, p.Slug, p.Summary, p.ContentMd, p.ContentHTML, p.Status,
p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, id); err != nil {
return p, err
}
if in.Tags != nil {
if err := s.setTags(id, in.Tags); err != nil {
return p, err
}
}
return s.Get(id)
}
func (s *Store) Delete(id int64) error {
if _, err := s.db.Exec(s.db.Q(`DELETE FROM post_tags WHERE post_id = ?`), id); err != nil {
return err
}
res, err := s.db.Exec(s.db.Q(`DELETE FROM posts WHERE id = ?`), id)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err == nil && n == 0 {
return ErrNotFound
}
return nil
}
// ---------- tags ----------
func (s *Store) setTags(postID int64, names []string) error {
if _, err := s.db.Exec(s.db.Q(`DELETE FROM post_tags WHERE post_id = ?`), postID); err != nil {
return err
}
seen := map[string]bool{}
for _, raw := range names {
name := strings.TrimSpace(raw)
if name == "" || seen[name] {
continue
}
seen[name] = true
tagID, err := s.upsertTag(name)
if err != nil {
return err
}
if _, err := s.db.Exec(s.db.Q(`INSERT INTO post_tags(post_id, tag_id) VALUES (?,?)`), postID, tagID); err != nil {
return err
}
}
return nil
}
func (s *Store) upsertTag(name string) (int64, error) {
slug := Slugify(name)
if slug == "" {
slug = "tag"
}
var id int64
err := s.db.QueryRow(s.db.Q(`SELECT id FROM tags WHERE slug = ?`), slug).Scan(&id)
if err == nil {
return id, nil
}
if err != sql.ErrNoRows {
return 0, err
}
if s.db.Dialect == db.Postgres {
err = s.db.QueryRow(s.db.Q(`INSERT INTO tags(name,slug) VALUES (?,?) RETURNING id`), name, slug).Scan(&id)
return id, err
}
res, err := s.db.Exec(s.db.Q(`INSERT INTO tags(name,slug) VALUES (?,?)`), name, slug)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
func (s *Store) ListTags() ([]model.Tag, error) {
q := s.db.Q(`SELECT t.id, t.name, t.slug, COUNT(pt.post_id) AS c
FROM tags t LEFT JOIN post_tags pt ON pt.tag_id = t.id
GROUP BY t.id, t.name, t.slug ORDER BY c DESC, t.name`)
rows, err := s.db.Query(q)
if err != nil {
return nil, err
}
defer rows.Close()
out := []model.Tag{}
for rows.Next() {
var t model.Tag
if err := rows.Scan(&t.ID, &t.Name, &t.Slug, &t.Count); err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
func (s *Store) CreateTag(name string) (model.Tag, error) {
name = strings.TrimSpace(name)
if name == "" {
return model.Tag{}, errors.New("tag name required")
}
id, err := s.upsertTag(name)
if err != nil {
return model.Tag{}, err
}
return model.Tag{ID: id, Name: name, Slug: Slugify(name)}, nil
}
func (s *Store) RenameTag(id int64, name string) (model.Tag, error) {
name = strings.TrimSpace(name)
slug := Slugify(name)
if name == "" {
return model.Tag{}, errors.New("tag name required")
}
if _, err := s.db.Exec(s.db.Q(`UPDATE tags SET name=?, slug=? WHERE id=?`), name, slug, id); err != nil {
return model.Tag{}, err
}
var t model.Tag
err := s.db.QueryRow(s.db.Q(`SELECT id, name, slug, 0 FROM tags WHERE id = ?`), id).
Scan(&t.ID, &t.Name, &t.Slug, &t.Count)
if err == sql.ErrNoRows {
return t, ErrNotFound
}
return t, err
}
func (s *Store) DeleteTag(id int64) error {
if _, err := s.db.Exec(s.db.Q(`DELETE FROM post_tags WHERE tag_id = ?`), id); err != nil {
return err
}
if _, err := s.db.Exec(s.db.Q(`DELETE FROM tags WHERE id = ?`), id); err != nil {
return err
}
return nil
}
// ---------- archive ----------
func (s *Store) Archive() ([]model.ArchiveYear, error) {
page, err := s.List(ListOptions{Status: model.StatusPublished, Page: 1, Size: 500})
if err != nil {
return nil, err
}
years := []model.ArchiveYear{}
yearIdx := map[string]int{}
monthIdx := map[string]int{}
for _, p := range page.Items {
y, m := splitDate(p.PublishedAt)
if y == "" {
continue
}
yi, ok := yearIdx[y]
if !ok {
years = append(years, model.ArchiveYear{Year: y, Months: []model.ArchiveMonth{}})
yi = len(years) - 1
yearIdx[y] = yi
}
key := y + "-" + m
mi, ok := monthIdx[key]
if !ok {
years[yi].Months = append(years[yi].Months, model.ArchiveMonth{Month: m, Posts: []model.Post{}})
mi = len(years[yi].Months) - 1
monthIdx[key] = mi
}
years[yi].Count++
years[yi].Months[mi].Posts = append(years[yi].Months[mi].Posts, p)
}
return years, nil
}
func splitDate(rfc3339 string) (string, string) {
if len(rfc3339) < 7 {
return "", ""
}
return rfc3339[0:4], rfc3339[5:7]
}
+42
View File
@@ -0,0 +1,42 @@
package store
import (
"strings"
"testing"
"oneblog/internal/db"
"oneblog/internal/model"
)
func TestSlugify(t *testing.T) {
cases := map[string]string{
"Hello World": "hello-world",
" Rebuild ONE ": "rebuild-one",
"Go 语言 / 2026": "go-语言-2026",
"!!!": "",
}
for in, want := range cases {
if got := Slugify(in); got != want {
t.Errorf("Slugify(%q) = %q, want %q", in, got, want)
}
}
}
func TestListOptionsRebind(t *testing.T) {
// 列表查询的占位符数量必须和参数数量一致,否则在 PostgreSQL 上会直接报错
d := &db.DB{Dialect: db.Postgres}
q := d.Q(`SELECT id FROM posts WHERE kind = ? AND status = ? AND title LIKE ? LIMIT ? OFFSET ?`)
if strings.Count(q, "$") != 5 {
t.Errorf("expected 5 placeholders, got %q", q)
}
}
func TestPostInputDefaults(t *testing.T) {
in := model.PostInput{Kind: model.KindShort, ContentMd: "一句话。"}
if in.Kind != "short" {
t.Errorf("kind = %q", in.Kind)
}
if in.Status != "" {
t.Errorf("empty status means draft is applied by the API layer, got %q", in.Status)
}
}