// 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 ( "context" "database/sql" "encoding/json" "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 } // Ping 供 /api/health 探活数据库连接。 func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) } 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 '', cover_url 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, color TEXT NOT NULL DEFAULT '' )`, 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 '' )`, fmt.Sprintf(`CREATE TABLE IF NOT EXISTS projects ( id %s, title TEXT NOT NULL DEFAULT '', slug TEXT NOT NULL, summary TEXT NOT NULL DEFAULT '', cover_url TEXT NOT NULL DEFAULT '', url TEXT NOT NULL DEFAULT '', repo_url TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'published', position INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, ai), fmt.Sprintf(`CREATE TABLE IF NOT EXISTS files ( id %s, key TEXT NOT NULL, name TEXT NOT NULL, mime TEXT NOT NULL DEFAULT '', size INTEGER NOT NULL DEFAULT 0, sha256 TEXT NOT NULL DEFAULT '', store TEXT NOT NULL DEFAULT 'local', created_at TEXT NOT NULL )`, ai), } for _, q := range stmts { if _, err := s.db.Exec(s.db.Q(q)); err != nil { return fmt.Errorf("migrate: %w", err) } } // Lightweight column-add migrations for older databases. SQLite supports // ADD COLUMN; Postgres doesn't support IF NOT EXISTS on column add, so we // swallow "already exists" errors either way. columnAdds := []string{ `ALTER TABLE posts ADD COLUMN cover_url TEXT NOT NULL DEFAULT ''`, `ALTER TABLE tags ADD COLUMN color TEXT NOT NULL DEFAULT ''`, } for _, q := range columnAdds { if _, err := s.db.Exec(s.db.Q(q)); err != nil && !strings.Contains(err.Error(), "already exists") && !strings.Contains(err.Error(), "duplicate column") { return fmt.Errorf("migrate column: %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)`}, {"idx_projects_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_slug ON projects(slug)`}, {"idx_files_key", `CREATE UNIQUE INDEX IF NOT EXISTS idx_files_key ON files(key)`}, {"idx_files_created", `CREATE INDEX IF NOT EXISTS idx_files_created ON files(created_at DESC)`}, } 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, } // LightSkinID is the new canonical name; fall back to legacy theme_id // for clients that haven't been updated, and finally to "paper". st.LightSkinID = m["light_skin_id"] if st.LightSkinID == "" { st.LightSkinID = m["theme_id"] } if st.LightSkinID == "" { st.LightSkinID = "paper" } // Mirror the value into ThemeID so legacy API consumers still see it // in the JSON response. st.ThemeID = st.LightSkinID st.UIID = sanitizeUI(m["ui_id"]) st.CustomCSS = decodeCSSMap(m["custom_css"]) st.CustomJS = m["custom_js"] st.SocialLinks = decodeSocialLinks(m["social_links"]) if n := atoi(m["posts_per_page"]); n > 0 { st.PostsPerPage = n } return st } // decodeSocialLinks 把 KV 里的 JSON 数组还原成社交链接。容错:空串、坏 JSON、 // 缺 label/url 的条目一律丢弃,返回空切片(前台区块自动隐藏)。 func decodeSocialLinks(s string) []model.SocialLink { out := []model.SocialLink{} if strings.TrimSpace(s) == "" { return out } if err := json.Unmarshal([]byte(s), &out); err != nil { return []model.SocialLink{} } clean := make([]model.SocialLink, 0, len(out)) for _, l := range out { l.Label = strings.TrimSpace(l.Label) l.URL = strings.TrimSpace(l.URL) if l.Label != "" && l.URL != "" { clean = append(clean, l) } } return clean } func encodeSocialLinks(ls []model.SocialLink) string { if len(ls) == 0 { return "" } b, err := json.Marshal(ls) if err != nil { return "" } return string(b) } 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 } // ValidLightSkins is the whitelist of light-side skin ids the API accepts. // Anything outside this set falls back to "paper" on write. var ValidLightSkins = map[string]bool{ "paper": true, "sage": true, "rose": true, } const ( // UIDefault is the UI used when nothing valid is stored. UIDefault = "classic" // UIClassic is the original minimalist front-end. UIClassic = "classic" // UIVivid is the livelier front-end, which also owns custom-CSS support. UIVivid = "vivid" ) // ValidUIs is the whitelist of front-end UI ids. Anything else falls back to // "classic" on both read and write. var ValidUIs = map[string]bool{ UIClassic: true, UIVivid: true, } // ValidCSSSections whitelists the page sections an owner may target with // custom CSS. Keep in sync with frontend/src/ui/sections.js. var ValidCSSSections = map[string]bool{ "global": true, "home": true, "post": true, "archive": true, "tags": true, "projects": true, "about": true, } const ( // maxCustomCSSBytes caps the whole stylesheet set; maxSectionCSSBytes caps // any single section. Both are generous for hand-written CSS but keep a // runaway paste from bloating every /api/site response. maxCustomCSSBytes = 64 << 10 maxSectionCSSBytes = 16 << 10 ) func sanitizeUI(id string) string { id = strings.TrimSpace(id) if ValidUIs[id] { return id } return UIDefault } // decodeCSSMap turns the stored JSON blob into a section→CSS map. It never // returns nil, and it drops unknown sections and over-long values even if the // row was hand-edited, so callers can trust the result. func decodeCSSMap(raw string) map[string]string { out := map[string]string{} if strings.TrimSpace(raw) == "" { return out } var parsed map[string]string if err := json.Unmarshal([]byte(raw), &parsed); err != nil { return out } total := 0 for section, css := range parsed { if !ValidCSSSections[section] { continue } css = strings.ReplaceAll(css, "\x00", "") if strings.TrimSpace(css) == "" || len(css) > maxSectionCSSBytes { continue } if total+len(css) > maxCustomCSSBytes { continue } total += len(css) out[section] = css } return out } // encodeCSSMap is the write-side counterpart: unknown sections are dropped and // oversized values are refused, then the map is serialized. A nil or empty map // encodes to "{}" so a full-replace PUT clears everything deterministically. func encodeCSSMap(in map[string]string) string { clean := map[string]string{} total := 0 for section, css := range in { if !ValidCSSSections[section] { continue } css = strings.ReplaceAll(css, "\x00", "") if strings.TrimSpace(css) == "" || len(css) > maxSectionCSSBytes { continue } if total+len(css) > maxCustomCSSBytes { continue } total += len(css) clean[section] = css } b, err := json.Marshal(clean) if err != nil { return "{}" } return string(b) } func (s *Store) UpdateSettings(st model.Settings) error { if st.PostsPerPage <= 0 { st.PostsPerPage = 10 } if !ValidLightSkins[st.LightSkinID] { st.LightSkinID = "paper" } st.UIID = sanitizeUI(st.UIID) 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), "light_skin_id": st.LightSkinID, // Mirror to theme_id so any older client still sees something. "theme_id": st.LightSkinID, "ui_id": st.UIID, // Full replace: an omitted/empty map clears every section's CSS. "custom_css": encodeCSSMap(st.CustomCSS), // 原样存:站主自己的代码,不做任何转义/清洗。 "custom_js": st.CustomJS, // 空数组存空串:KV 里不留 "null"。 "social_links": encodeSocialLinks(st.SocialLinks), } 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 } // OrderBy 来自查询参数,必须白名单校验后才能拼进 SQL。 var allowedOrder = map[string]bool{ "published_at desc": true, "published_at asc": true, "updated_at desc": true, "updated_at asc": true, "created_at desc": true, "reading_minutes desc": true, "reading_minutes asc": true, } func sanitizeOrder(o string) string { key := strings.ToLower(strings.Join(strings.Fields(o), " ")) if allowedOrder[key] { return key } return "published_at desc" } const postCols = `id, kind, title, slug, summary, cover_url, content_md, content_html, status, published_at, created_at, updated_at, reading_minutes, LENGTH(content_md)` // listCols 用于列表/时间线:不传 content_md(前端不用), // 长文 content_html 只截 600 字符供无摘要时提取纯文本,短文保留全文渲染。 const listCols = `id, kind, title, slug, summary, cover_url, '' AS content_md, CASE WHEN kind = 'short' THEN content_html ELSE substr(content_html, 1, 600) END AS content_html, status, published_at, created_at, updated_at, reading_minutes, LENGTH(content_md)` 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.CoverURL, &p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt, &p.ReadingMinutes, &p.ContentLen) 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 := sanitizeOrder(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 ?`, listCols, 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 } // Neighbors 返回某篇已发布文章在时间线上的前后邻居(详情页侧栏用)。 // 返回顺序与时间线一致(published_at DESC):最旧在最前、最新在最后, // 目标文章夹在中间,前后各取最多 2 篇。 func (s *Store) Neighbors(slug string) ([]model.Post, error) { rows, err := s.db.Query(s.db.Q(`SELECT `+listCols+` FROM posts WHERE status = ? ORDER BY published_at DESC`), model.StatusPublished) if err != nil { return nil, err } defer rows.Close() all := []model.Post{} for rows.Next() { p, err := scanPost(rows) if err != nil { return nil, err } all = append(all, p) } if err := rows.Err(); err != nil { return nil, err } idx := -1 for i, p := range all { if p.Slug == slug { idx = i break } } if idx < 0 { return []model.Post{}, nil } lo, hi := idx-2, idx+2 if lo < 0 { lo = 0 } if hi > len(all)-1 { hi = len(all) - 1 } out := all[lo : hi+1] if err := s.attachTags(out); err != nil { return nil, err } return out, 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.CoverURL, &p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt, &p.ReadingMinutes, &p.ContentLen) 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) } } func (s *Store) uniqueProjectSlug(base string, excludeID int64) string { base = Slugify(base) if base == "" { base = "project" } candidate := base for i := 2; ; i++ { var id int64 err := s.db.QueryRow(s.db.Q(`SELECT id FROM projects 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) } } // ---------- projects ---------- func scanProject(row interface{ Scan(...any) error }) (model.Project, error) { var p model.Project err := row.Scan(&p.ID, &p.Title, &p.Slug, &p.Summary, &p.CoverURL, &p.URL, &p.RepoURL, &p.Status, &p.Position, &p.CreatedAt, &p.UpdatedAt) return p, err } // ListProjects returns projects with an optional status filter. Pass "" to // get every project (admin), or model.StatusPublished / model.StatusDraft to // narrow. Results are ordered by explicit Position then recency. func (s *Store) ListProjects(status string) ([]model.Project, error) { where := "" args := []any{} if status == model.StatusPublished || status == model.StatusDraft { where = " WHERE status = ?" args = append(args, status) } q := s.db.Q(`SELECT id,title,slug,summary,cover_url,url,repo_url,status,position,created_at,updated_at FROM projects` + where + ` ORDER BY position ASC, created_at DESC`) rows, err := s.db.Query(q, args...) if err != nil { return nil, err } defer rows.Close() out := []model.Project{} for rows.Next() { p, err := scanProject(rows) if err != nil { return nil, err } out = append(out, p) } return out, rows.Err() } func (s *Store) GetProject(id int64) (model.Project, error) { var p model.Project err := s.db.QueryRow(s.db.Q(`SELECT id,title,slug,summary,cover_url,url,repo_url,status,position,created_at,updated_at FROM projects WHERE id = ?`), id).Scan(&p.ID, &p.Title, &p.Slug, &p.Summary, &p.CoverURL, &p.URL, &p.RepoURL, &p.Status, &p.Position, &p.CreatedAt, &p.UpdatedAt) if err == sql.ErrNoRows { return p, ErrNotFound } return p, err } func (s *Store) CreateProject(in model.ProjectInput) (model.Project, error) { p := model.Project{ Title: strings.TrimSpace(in.Title), Slug: strings.TrimSpace(in.Slug), Summary: strings.TrimSpace(in.Summary), CoverURL: strings.TrimSpace(in.CoverURL), URL: strings.TrimSpace(in.URL), RepoURL: strings.TrimSpace(in.RepoURL), Status: NormalizeStatus(in.Status), Position: in.Position, } if p.Status == "" { p.Status = model.StatusPublished } if p.Slug == "" { p.Slug = Slugify(p.Title) } if p.Slug == "" { p.Slug = "project-" + time.Now().UTC().Format("20060102-150405") } p.Slug = s.uniqueProjectSlug(p.Slug, 0) p.CreatedAt = now() p.UpdatedAt = p.CreatedAt q := s.db.Q(`INSERT INTO projects (title,slug,summary,cover_url,url,repo_url,status,position,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)`) var id int64 if s.db.Dialect == db.Postgres { err := s.db.QueryRow(q, p.Title, p.Slug, p.Summary, p.CoverURL, p.URL, p.RepoURL, p.Status, p.Position, p.CreatedAt, p.UpdatedAt).Scan(&id) if err != nil { return p, err } } else { res, err := s.db.Exec(q, p.Title, p.Slug, p.Summary, p.CoverURL, p.URL, p.RepoURL, p.Status, p.Position, p.CreatedAt, p.UpdatedAt) if err != nil { return p, err } id, err = res.LastInsertId() if err != nil { return p, err } } return s.GetProject(id) } func (s *Store) UpdateProject(id int64, in model.ProjectInput) (model.Project, error) { cur, err := s.GetProject(id) if err != nil { return cur, err } p := cur if in.Title != "" { p.Title = strings.TrimSpace(in.Title) } if in.Summary != "" { p.Summary = strings.TrimSpace(in.Summary) } if in.CoverURL != "" { p.CoverURL = strings.TrimSpace(in.CoverURL) } if in.URL != "" { p.URL = strings.TrimSpace(in.URL) } if in.RepoURL != "" { p.RepoURL = strings.TrimSpace(in.RepoURL) } if in.Status != "" { p.Status = NormalizeStatus(in.Status) } if in.Slug != "" && in.Slug != cur.Slug { p.Slug = s.uniqueProjectSlug(in.Slug, id) } p.Position = in.Position p.UpdatedAt = now() if _, err := s.db.Exec(s.db.Q(`UPDATE projects SET title=?,slug=?,summary=?,cover_url=?,url=?,repo_url=?, status=?,position=?,updated_at=? WHERE id=?`), p.Title, p.Slug, p.Summary, p.CoverURL, p.URL, p.RepoURL, p.Status, p.Position, p.UpdatedAt, id); err != nil { return p, err } return s.GetProject(id) } func (s *Store) DeleteProject(id int64) error { res, err := s.db.Exec(s.db.Q(`DELETE FROM projects WHERE id = ?`), id) if err != nil { return err } if n, err := res.RowsAffected(); err == nil && n == 0 { return ErrNotFound } return nil } // ---------- 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, CoverURL: strings.TrimSpace(in.CoverURL), 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,cover_url,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.CoverURL, 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.CoverURL, 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 } // CoverURL is taken verbatim from input. The frontend always sends the // current value (empty string included), so any update round-trips with // whatever the user last saved. p.CoverURL = strings.TrimSpace(in.CoverURL) 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=?,cover_url=?,content_md=?, content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=? WHERE id=?`), p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, 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) } // BulkUpdateStatus flips the status of many posts in a single transaction. func (s *Store) BulkUpdateStatus(ids []int64, status string) (int, error) { if len(ids) == 0 { return 0, nil } status = NormalizeStatus(status) placeholders := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",") args := []any{status, now()} for _, id := range ids { args = append(args, id) } q := s.db.Q(`UPDATE posts SET status=?, updated_at=? WHERE id IN (` + placeholders + `)`) res, err := s.db.Exec(q, args...) if err != nil { return 0, err } n, _ := res.RowsAffected() return int(n), nil } // NormalizeStatus coerces an arbitrary input string into a known post status // value, defaulting to draft when nothing matches. func NormalizeStatus(s string) string { switch strings.ToLower(strings.TrimSpace(s)) { case model.StatusDraft, model.StatusPublished: return strings.ToLower(strings.TrimSpace(s)) default: return model.StatusDraft } } func (s *Store) Delete(id int64) error { return s.tx(func(tx *sql.Tx) error { if _, err := tx.Exec(s.db.Q(`DELETE FROM post_tags WHERE post_id = ?`), id); err != nil { return err } res, err := tx.Exec(s.db.Q(`DELETE FROM posts WHERE id = ?`), id) if err != nil { return err } if n, err := res.RowsAffected(); err == nil && n == 0 { return ErrNotFound } return nil }) } // tx 把多语句写包进一个事务:中途失败整体回滚,不留半截状态。 func (s *Store) tx(fn func(tx *sql.Tx) error) error { tx, err := s.db.Begin() if err != nil { return err } if err := fn(tx); err != nil { _ = tx.Rollback() return err } return tx.Commit() } // ---------- tags ---------- func (s *Store) setTags(postID int64, names []string) error { return s.tx(func(tx *sql.Tx) error { if _, err := tx.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.upsertTagOn(tx, name) if err != nil { return err } if _, err := tx.Exec(s.db.Q(`INSERT INTO post_tags(post_id, tag_id) VALUES (?,?)`), postID, tagID); err != nil { return err } } return nil }) } // execer 让 upsertTag 在普通连接和事务里都能跑。 type execer interface { Exec(query string, args ...any) (sql.Result, error) QueryRow(query string, args ...any) *sql.Row } func (s *Store) upsertTag(name string) (int64, error) { return s.upsertTagOn(s.db, name) } func (s *Store) upsertTagOn(x execer, name string) (int64, error) { slug := Slugify(name) if slug == "" { slug = "tag" } var id int64 err := x.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 = x.QueryRow(s.db.Q(`INSERT INTO tags(name,slug) VALUES (?,?) RETURNING id`), name, slug).Scan(&id) return id, err } res, err := x.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, t.color, 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, t.color 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.Color, &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 } // CreateTagFull is used by the admin API when color is supplied. func (s *Store) CreateTagFull(name, color string) (model.Tag, error) { name = strings.TrimSpace(name) if name == "" { return model.Tag{}, errors.New("tag name required") } slug := Slugify(name) if slug == "" { return model.Tag{}, errors.New("tag slug required") } var id int64 err := s.db.QueryRow(s.db.Q(`SELECT id FROM tags WHERE slug = ?`), slug).Scan(&id) if err != nil && err != sql.ErrNoRows { return model.Tag{}, err } if err == nil { // already exists · update color and return if _, err := s.db.Exec(s.db.Q(`UPDATE tags SET color=? WHERE id=?`), color, id); err != nil { return model.Tag{}, err } return s.getTag(id) } color = strings.TrimSpace(color) if s.db.Dialect == db.Postgres { err := s.db.QueryRow(s.db.Q(`INSERT INTO tags(name,slug,color) VALUES (?,?,?) RETURNING id`), name, slug, color).Scan(&id) if err != nil { return model.Tag{}, err } } else { res, err := s.db.Exec(s.db.Q(`INSERT INTO tags(name,slug,color) VALUES (?,?,?)`), name, slug, color) if err != nil { return model.Tag{}, err } id, err = res.LastInsertId() if err != nil { return model.Tag{}, err } } return s.getTag(id) } func (s *Store) getTag(id int64) (model.Tag, error) { var t model.Tag err := s.db.QueryRow(s.db.Q(`SELECT t.id, t.name, t.slug, t.color, COUNT(pt.post_id) FROM tags t LEFT JOIN post_tags pt ON pt.tag_id = t.id WHERE t.id = ? GROUP BY t.id`), id).Scan(&t.ID, &t.Name, &t.Slug, &t.Color, &t.Count) if err == sql.ErrNoRows { return t, ErrNotFound } return t, err } 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 } return s.getTag(id) } // UpdateTag is the combined rename + recolor used by the admin UI. func (s *Store) UpdateTag(id int64, name, color string) (model.Tag, error) { name = strings.TrimSpace(name) slug := Slugify(name) if name == "" { return model.Tag{}, errors.New("tag name required") } color = strings.TrimSpace(color) if _, err := s.db.Exec(s.db.Q(`UPDATE tags SET name=?, slug=?, color=? WHERE id=?`), name, slug, color, id); err != nil { return model.Tag{}, err } return s.getTag(id) } // MergeTags moves every post from fromID to toID, then deletes fromID. It is a // no-op if the ids are equal or either doesn't exist. func (s *Store) MergeTags(fromID, toID int64) (model.Tag, error) { if fromID == toID { return model.Tag{}, errors.New("source and target are the same tag") } if _, err := s.getTag(fromID); err != nil { return model.Tag{}, err } if _, err := s.getTag(toID); err != nil { return model.Tag{}, err } // De-duplicate before the move so we don't end up with two rows pointing // at the same post. if err := s.tx(func(tx *sql.Tx) error { if _, err := tx.Exec(s.db.Q(`DELETE FROM post_tags WHERE post_id IN (SELECT post_id FROM post_tags WHERE tag_id = ?) AND tag_id = ?`), toID, toID); err != nil { return err } if _, err := tx.Exec(s.db.Q(`UPDATE post_tags SET tag_id=? WHERE tag_id=?`), toID, fromID); err != nil { return err } _, err := tx.Exec(s.db.Q(`DELETE FROM tags WHERE id=?`), fromID) return err }); err != nil { return model.Tag{}, err } return s.getTag(toID) } func (s *Store) DeleteTag(id int64) error { return s.tx(func(tx *sql.Tx) error { if _, err := tx.Exec(s.db.Q(`DELETE FROM post_tags WHERE tag_id = ?`), id); err != nil { return err } _, err := tx.Exec(s.db.Q(`DELETE FROM tags WHERE id = ?`), id) return err }) } // ---------- archive ---------- func (s *Store) Archive() ([]model.ArchiveYear, error) { var items []model.Post if err := s.scanPostsInto(s.db.Q(`SELECT `+listCols+` FROM posts WHERE status = ? ORDER BY published_at DESC`), []any{model.StatusPublished}, &items); err != nil { return nil, err } years := []model.ArchiveYear{} yearIdx := map[string]int{} monthIdx := map[string]int{} for _, p := range 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] } // ---------- dashboard ---------- // Dashboard produces the snapshot rendered on the admin home page. func (s *Store) Dashboard() (model.Dashboard, error) { d := model.Dashboard{ RecentPosts: []model.Post{}, RecentDrafts: []model.Post{}, TopTags: []model.Tag{}, PublishedByMonth: []model.MonthBucket{}, } // status / kind counts -------------------------------------------------- rows, err := s.db.Query(s.db.Q(`SELECT status, kind, COUNT(*) FROM posts GROUP BY status, kind`)) if err != nil { return d, err } for rows.Next() { var status, kind string var n int if err := rows.Scan(&status, &kind, &n); err != nil { rows.Close() return d, err } d.TotalPosts += n switch status { case model.StatusPublished: d.PublishedPosts += n case model.StatusDraft: d.DraftPosts += n } switch kind { case model.KindShort: d.ShortPosts += n case model.KindLong: d.LongPosts += n } } rows.Close() // total tag count ------------------------------------------------------- if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM tags`)).Scan(&d.TotalTags); err != nil { return d, err } // total word count: sum of content_md length ---------------------------- if err := s.db.QueryRow(s.db.Q(`SELECT COALESCE(SUM(LENGTH(content_md)),0) FROM posts`)).Scan(&d.TotalWords); err != nil { return d, err } // recent published posts (5) -------------------------------------------- if err := s.scanPostsInto(s.db.Q(`SELECT `+listCols+` FROM posts WHERE status=? ORDER BY published_at DESC LIMIT 5`), []any{model.StatusPublished}, &d.RecentPosts); err != nil { return d, err } // recent drafts (5) ----------------------------------------------------- if err := s.scanPostsInto(s.db.Q(`SELECT `+listCols+` FROM posts WHERE status=? ORDER BY updated_at DESC LIMIT 5`), []any{model.StatusDraft}, &d.RecentDrafts); err != nil { return d, err } // top tags (10) --------------------------------------------------------- if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM tags`)).Scan(&d.TotalTags); err != nil { return d, err } tagRows, err := s.db.Query(s.db.Q(`SELECT t.id, t.name, t.slug, t.color, 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, t.color ORDER BY c DESC, t.name LIMIT 10`)) if err != nil { return d, err } for tagRows.Next() { var t model.Tag if err := tagRows.Scan(&t.ID, &t.Name, &t.Slug, &t.Color, &t.Count); err != nil { tagRows.Close() return d, err } d.TopTags = append(d.TopTags, t) } tagRows.Close() // published_by_month · last 12 months ----------------------------------- monthRows, err := s.db.Query(s.db.Q(`SELECT substr(published_at,1,7) AS m, COUNT(*) AS c FROM posts WHERE status=? AND length(published_at) >= 7 GROUP BY m ORDER BY m DESC LIMIT 12`), model.StatusPublished) if err != nil { return d, err } defer monthRows.Close() for monthRows.Next() { var b model.MonthBucket if err := monthRows.Scan(&b.Month, &b.Count); err != nil { return d, err } d.PublishedByMonth = append(d.PublishedByMonth, b) } // reverse so we can render left-to-right on the chart for i, j := 0, len(d.PublishedByMonth)-1; i < j; i, j = i+1, j-1 { d.PublishedByMonth[i], d.PublishedByMonth[j] = d.PublishedByMonth[j], d.PublishedByMonth[i] } return d, nil } // scanPostsInto is a small helper that runs a query and scans rows into the // given slice, attaching tags at the end. func (s *Store) scanPostsInto(query string, args []any, dst *[]model.Post) error { rows, err := s.db.Query(query, args...) if err != nil { return err } defer rows.Close() out := []model.Post{} for rows.Next() { p, err := scanPost(rows) if err != nil { return err } out = append(out, p) } if err := rows.Err(); err != nil { return err } if err := s.attachTags(out); err != nil { return err } *dst = out return nil } // ---------- files ---------- func scanFile(sc interface{ Scan(...any) error }) (model.File, error) { var f model.File err := sc.Scan(&f.ID, &f.Key, &f.Name, &f.Mime, &f.Size, &f.SHA256, &f.Store, &f.CreatedAt) return f, err } func (s *Store) CreateFile(f model.File) (model.File, error) { f.CreatedAt = now() q := s.db.Q(`INSERT INTO files (key,name,mime,size,sha256,store,created_at) VALUES (?,?,?,?,?,?,?)`) var id int64 if s.db.Dialect == db.Postgres { err := s.db.QueryRow(q+` RETURNING id`, f.Key, f.Name, f.Mime, f.Size, f.SHA256, f.Store, f.CreatedAt).Scan(&id) if err != nil { return model.File{}, err } } else { res, err := s.db.Exec(q, f.Key, f.Name, f.Mime, f.Size, f.SHA256, f.Store, f.CreatedAt) if err != nil { return model.File{}, err } if id, err = res.LastInsertId(); err != nil { return model.File{}, err } } f.ID = id return f, nil } // ListFiles 按创建时间倒序分页,q 模糊匹配原始文件名 / key。 func (s *Store) ListFiles(page, size int, q string) (model.FilePage, error) { if page < 1 { page = 1 } if size < 1 || size > 100 { size = 20 } where := "" var args []any if query := strings.TrimSpace(q); query != "" { where = ` WHERE name LIKE ? OR key LIKE ?` like := "%" + query + "%" args = append(args, like, like) } var total int if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM files`+where), args...).Scan(&total); err != nil { return model.FilePage{}, err } args = append(args, size, (page-1)*size) rows, err := s.db.Query(s.db.Q(`SELECT id,key,name,mime,size,sha256,store,created_at FROM files`+ where+` ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`), args...) if err != nil { return model.FilePage{}, err } defer rows.Close() out := []model.File{} for rows.Next() { f, err := scanFile(rows) if err != nil { return model.FilePage{}, err } out = append(out, f) } return model.FilePage{Items: out, Total: total, Page: page, Size: size}, rows.Err() } func (s *Store) GetFile(id int64) (model.File, error) { f, err := scanFile(s.db.QueryRow(s.db.Q(`SELECT id,key,name,mime,size,sha256,store,created_at FROM files WHERE id = ?`), id)) if err != nil { if errors.Is(err, sql.ErrNoRows) { return model.File{}, ErrNotFound } return model.File{}, err } return f, nil } func (s *Store) GetFileByKey(key string) (model.File, error) { f, err := scanFile(s.db.QueryRow(s.db.Q(`SELECT id,key,name,mime,size,sha256,store,created_at FROM files WHERE key = ?`), key)) if err != nil { if errors.Is(err, sql.ErrNoRows) { return model.File{}, ErrNotFound } return model.File{}, err } return f, nil } // DeleteFile 删索引行并返回被删的行(调用方负责先删对象存储里的本体)。 func (s *Store) DeleteFile(id int64) (model.File, error) { f, err := s.GetFile(id) if err != nil { return model.File{}, err } res, err := s.db.Exec(s.db.Q(`DELETE FROM files WHERE id = ?`), id) if err != nil { return model.File{}, err } if n, _ := res.RowsAffected(); n == 0 { return model.File{}, ErrNotFound } return f, nil }