Files
ONE/backend/internal/store/store.go
T
Sakurasan 34f0faedcc 文件上传:R2 对象存储 + 后台文件管理 + 编辑器/封面联动
存储抽象(internal/storage,新包)
- BlobStore 接口(Put / Open / Delete)+ 两个实现:R2(aws-sdk-go-v2 走
  R2 的 S3 兼容 API,path-style、region auto)与本地磁盘(DataDir/uploads,
  零配置兜底)。选择:Endpoint / S3Api / Bucket / AccessKey / SecretAccessKey
  齐全 → R2,缺任一项回落本地并在启动日志提示缺失的字段名(只报名字不报值)
- 端点 URL 里的路径段不交给 SDK:path-style 下它会被折进对象 key,
  导致「数据库 key」和「实际对象 key」对不上(直链 404,实测复现)。
  EndpointKeyPrefix 提取路径段给上传 handler 拼进 key,StripEndpointPath
  只取 scheme://host 给 SDK——数据库 / 存储端 / 直链三方一致

数据与 API
- files 表:id / key(唯一) / name / mime / size / sha256 / store(r2|local) /
  created_at;URL 不入库,按「store 来源 + PublicBase」响应时解析,
  切存储端不破坏存量链接
- POST /api/admin/files:multipart 多文件,单文件 ≤50MB(MaxBytesReader 64MB);
  类型白名单 = 图片(jpg/png/webp/gif/avif)+ 附件(pdf/zip/txt),
  扩展名 + http.DetectContentType 双重校验(实测拦截随机字节改名 .png),
  SVG 拒绝(同源脚本);内容 sha256 做 key(2026/09/{哈希前12位}{扩展名}),
  同内容重复上传自动去重复用
- GET /api/admin/files(分页 + 文件名搜索)、DELETE /{id}(先删对象再删行,
  存储端失败保留行可重试)
- 公开路由 GET /uploads/{key}(main.go 挂载):按 key 查行、存储层流式返回,
  Cache-Control immutable + ETag 304;R2 + PublicBase 时 302 直链(后端不出流量)
- URL 解析:FileURL(store, key, PublicBase)——R2 且配了公开域名走直链,
  否则 /uploads/ 流式

后台文件管理页(FilesView,「工作台 → 文件」)
- 点击 / 拖拽多选上传(uploadFiles 走 FormData 裸 fetch,401 广播与
  request() 一致);缩略图卡片网格(图片出图、其他出类型占位);
  复制链接(clipboard,非 https 回落 prompt)/ 打开 / 删除(确认提示);
  分页、loading/empty 沿用既有模式

编辑器联动(EditorView)
- 封面:URL 输入框旁「上传」按钮,选图自动填 cover_url
- wysiwyg:Crepe ImageBlock 官方 onUpload 钩子——粘贴 / 拖拽 / 插图
  全部走上传,返回 URL 后由 Crepe 插节点
- Markdown 模式:插图弹层加「上传」按钮 + 编辑器粘贴 / 拖拽图片,
  上传后在原光标处插入 ![](url)(异步上传先记光标位,逐张追踪偏移)

.env.example
- 模板入库(无敏感值):Endpoint = 公开访问域名(直链)、S3Api = 上传端点、
  Bucket / AccessKey / SecretAccessKey

验证
- 本地兜底全流程:上传 201、同内容去重复用、随机字节改名 .png 被
  内容嗅探拒绝、.svg 拒绝、公开路由 immutable 缓存头 + 内容一致、
  删除后存储与公开路由双清 404
- R2 真实链路(站主 .env):上传 store=r2、直链 200(过程中定位并修复
  S3Api 路径段折进 key 导致的直链 404,见 EndpointKeyPrefix)
- 后端 go build/test/vet 全绿;前端构建通过
2026-09-27 16:26:11 +08:00

1511 lines
42 KiB
Go

// 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
}