Files
tamabox/internal/cmd/backup.go
T
tamakyi 1e9ffc0718 feat(ops): 新增 TamaBox backup 命令,同时备份数据库与 R2 图片
此前没有任何备份手段,而站点的图片全在 Cloudflare R2、数据库只存 key/URL ——
只备份 DB 的话,恢复后 key 指向的图片若已不在 R2(bucket 误删 / 账号丢失 /
图片被清理),页面就全是裂图。

新增 TamaBox backup --output <dir>:
- 数据库:pg_dump 全量导出(schema + data,标准恢复格式),密码经 PGPASSWORD
  环境变量传递避免出现在 ps 进程参数里
- 图片:从 R2 下载 tamabox 站全部图片,保持原 key 路径,并写 images-manifest.json
  (key → size → md5)用于恢复后完整性校验
- --db-only / --images-only 分开关

R2 访问方式的坑:storage.GetS3Client 的 endpoint 带 /<bucket> 后缀,对
GetObject/PutObject 恰好可用,但 ListObjectsV2 会把 URL 拼成 /<bucket>/<bucket>
而 404 NoSuchKey。备份脚本独立构造客户端(剥离 endpoint 的 bucket 后缀 +
UsePathStyle),List prefix="tamabox/" 后用完整 key 下载 —— 这是 R2 上 List
唯一稳定可用的姿势。

实测:db-only 导出 46KB(21 表全含 schema_migrations);images-only 下载的文件
大小与 R2 List 返回完全一致,路径保持 tamabox/ 结构。
2026-10-29 19:27:13 +08:00

317 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 tamakyi. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.
package cmd
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
"github.com/tamakyi/TamaBox/internal/conf"
)
// Backup 全量备份数据库与对象存储(Cloudflare R2)图片到本地目录。
//
// 为什么必须同时备份图片:站点的所有图片(头像 / 背景 / 二维码 logo / 提问图 / 回答图)
// 都以 picture/YYYY/MM/DD/<hex>(新)或 tamabox/YYYY/MM/<hex>.<ext>(历史)的形式存在
// R2 上,数据库里只存 key(upload_images.key)或 URL(users.avatar/background 里的完整
// CDN 地址)。只备份数据库的话,恢复后这些引用指向的图片若已不在 R2(bucket 误删 /
// 账号丢失 / 图片被清理),页面就全是裂图。因此备份必须把 R2 上的图片一并落盘。
//
// 输出目录结构:
//
// <output>/
// db-<20060102-150405>.sql # pg_dump 全量导出(schema + data,可用 psql 恢复)
// images/<key> # R2 对象,保持原 key 路径
// images-manifest.json # key → md5 / size 清单,用于恢复后完整性校验
//
// 用法:TamaBox backup --output /var/backups/tamabox
//
// TamaBox backup --output . --db-only # 只备份数据库
// TamaBox backup --output . --images-only # 只备份图片
var Backup = &cli.Command{
Name: "backup",
Usage: "Back up the database and R2 images to a local directory",
Action: runBackup,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "output",
Value: ".",
Usage: "Output directory for the backup files",
},
&cli.BoolFlag{
Name: "db-only",
Usage: "Only back up the database (skip R2 images)",
},
&cli.BoolFlag{
Name: "images-only",
Usage: "Only back up R2 images (skip the database)",
},
},
}
func runBackup(ctx *cli.Context) error {
if err := conf.Init(); err != nil {
return errors.Wrap(err, "load configuration")
}
outDir := ctx.String("output")
if outDir == "" {
return errors.New("--output must not be empty")
}
if err := os.MkdirAll(outDir, 0o700); err != nil {
return errors.Wrapf(err, "create output directory %q", outDir)
}
dbOnly := ctx.Bool("db-only")
imagesOnly := ctx.Bool("images-only")
if !imagesOnly {
if err := backupDatabase(ctx.Context, outDir); err != nil {
return err
}
}
if !dbOnly {
if err := backupImages(ctx.Context, outDir); err != nil {
return err
}
}
logrus.WithField("dir", outDir).Info("备份完成")
return nil
}
// backupDatabase 用 pg_dump 导出 PostgreSQL 全量数据。
//
// 为什么用外部 pg_dump 而不是在进程内逐表 SELECT:pg_dump 输出的 .sql 是标准恢复格式,
// 直接用 psql 恢复,且能正确处理序列、外键、软删除行、大对象等进程内手写导出极易漏掉的
// 细节。项目实际使用 PostgreSQL(见 code-review-2026-08-28.md P1-4)。
func backupDatabase(ctx context.Context, outDir string) error {
if conf.Database.Type != "" && conf.Database.Type != "postgres" {
return errors.Errorf("备份当前仅支持 PostgreSQL,配置为 %q", conf.Database.Type)
}
pgDump, err := exec.LookPath("pg_dump")
if err != nil {
return errors.Wrap(err, "未找到 pg_dump(PostgreSQL 客户端工具),请安装 postgresql-client")
}
ts := time.Now().Format("20060102-150405")
outFile := filepath.Join(outDir, fmt.Sprintf("db-%s.sql", ts))
// 密码经环境变量传递,避免出现在进程参数里(ps 可见)。
cmd := exec.CommandContext(ctx, pgDump,
"--host", conf.Database.Host,
"--port", fmt.Sprintf("%d", conf.Database.Port),
"--username", conf.Database.User,
"--dbname", conf.Database.Name,
"--no-owner", "--no-privileges",
"--format", "plain",
)
cmd.Env = append(os.Environ(), "PGPASSWORD="+conf.Database.Password)
f, err := os.OpenFile(outFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return errors.Wrapf(err, "create backup file %q", outFile)
}
defer f.Close()
var dumpErr strings.Builder
cmd.Stdout = f
cmd.Stderr = &dumpErr
if err := cmd.Run(); err != nil {
return errors.Wrapf(err, "pg_dump failed: %s", strings.TrimSpace(dumpErr.String()))
}
logrus.WithField("file", outFile).Info("数据库已备份")
return nil
}
// imageManifestEntry 图片备份清单里的一条:key → 大小 → md5(用于恢复后校验完整性)。
type imageManifestEntry struct {
Key string `json:"key"`
Size int64 `json:"size"`
MD5 string `json:"md5,omitempty"`
}
// backupImages 从 R2 下载 tamabox 站的全部图片对象到本地,并生成 manifest。
//
// R2 里 tamabox 站图片的 key 都带 `tamabox/` 前缀(如 tamabox/2025/12/<ts>.jpg),
// 这是「bucket 用 path-style 访问时 List 返回的 key 带 bucket 名作前缀」这一 R2 行为。
// 因此只需列出 `tamabox/` 前缀即可覆盖全部图片。blog/ 是同一 R2 account 下另一个站点
// 的内容,不在 tamabox 的备份范围。
func backupImages(ctx context.Context, outDir string) error {
client, err := backupS3Client()
if err != nil {
return errors.Wrap(err, "get R2 client for backup")
}
imageDir := filepath.Join(outDir, "images")
if err := os.MkdirAll(imageDir, 0o700); err != nil {
return errors.Wrap(err, "create images directory")
}
var manifest []imageManifestEntry
var count int64
var totalBytes int64
// 见函数注释:tamabox/ 前缀即覆盖全部图片(含新旧 key 格式)。
if err := backupPrefix(ctx, client, imageDir, "tamabox/", &manifest, &count, &totalBytes); err != nil {
return err
}
// 写 manifest:记录 key → 大小 → md5,恢复后可用于校验完整性。
mf, err := os.Create(filepath.Join(outDir, "images-manifest.json"))
if err != nil {
return errors.Wrap(err, "create manifest file")
}
defer mf.Close()
enc := json.NewEncoder(mf)
enc.SetIndent("", " ")
if err := enc.Encode(manifest); err != nil {
return errors.Wrap(err, "write manifest")
}
logrus.WithFields(logrus.Fields{
"objects": count,
"bytes": totalBytes,
"dir": imageDir,
}).Info("图片已备份")
return nil
}
// backupPrefix 分页列出并下载指定前缀下的全部对象。
func backupPrefix(ctx context.Context, client *s3.Client, imageDir, prefix string, manifest *[]imageManifestEntry, count, totalBytes *int64) error {
paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{
Bucket: aws.String(conf.Upload.ImageBucket),
Prefix: aws.String(prefix),
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
return errors.Wrapf(err, "list R2 objects prefix %q", prefix)
}
for _, obj := range page.Contents {
if obj.Key == nil {
continue
}
key := *obj.Key
// 落盘路径 = imageDir + "/" + key,保持对象 key 结构(含 tamabox/ 前缀),
// 恢复时可直接用同一 key 回传 R2。
dst := filepath.Join(imageDir, filepath.FromSlash(key))
if err := os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
return errors.Wrapf(err, "create dir for %q", key)
}
if err := downloadObject(ctx, client, conf.Upload.ImageBucket, key, dst); err != nil {
return err
}
size := int64(0)
if obj.Size != nil {
size = *obj.Size
}
md5 := ""
if obj.ETag != nil {
md5 = strings.Trim(*obj.ETag, `"`)
}
*manifest = append(*manifest, imageManifestEntry{Key: key, Size: size, MD5: md5})
*count++
*totalBytes += size
}
}
return nil
}
// backupS3Client 构造用于备份的 R2 客户端。
//
// 不能直接复用 storage.GetS3Client 做 List:那个客户端把 bucket 路径拼进了 endpoint
// (image_endpoint 形如 https://<account>.r2.cloudflarestorage.com/<bucket>),
// 它对 GetObject/PutObject 恰好可用(R2 容错),但 ListObjectsV2 会把 URL 拼成
// /<bucket>/<bucket>?list-type=2,R2 找不到这个 key 前缀而返回 404 NoSuchKey。
//
// 这里剥离 endpoint 的 bucket 后缀,得到纯 account endpoint,并启用 path-style:
// List/Get 都用 Bucket 参数 + 完整 key(List 返回的 key 自带 bucket 名作前缀,
// 如 tamabox/2025/12/<ts>.jpg),这是 R2 上唯一稳定可用的姿势。
func backupS3Client() (*s3.Client, error) {
endpoint := strings.TrimRight(conf.Upload.ImageEndpoint, "/")
// 剥离最后一个路径段(bucket 名)。endpoint 形如
// https://<account>.r2.cloudflarestorage.com/<bucket>,去掉 /<bucket>。
if i := strings.LastIndex(endpoint, "/"); i > len("https://") {
endpoint = endpoint[:i]
}
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
return aws.Endpoint{
URL: endpoint,
HostnameImmutable: true,
Source: aws.EndpointSourceCustom,
}, nil
})
cfg, err := config.LoadDefaultConfig(context.Background(),
config.WithEndpointResolverWithOptions(resolver),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
conf.Upload.ImageAccessID,
conf.Upload.ImageAccessSecret,
"",
)),
config.WithRegion("auto"),
)
if err != nil {
return nil, errors.Wrap(err, "load R2 config")
}
return s3.NewFromConfig(cfg, func(o *s3.Options) {
o.UsePathStyle = true
}), nil
}
// downloadObject 下载单个对象到本地文件,失败时删除半成品避免留下损坏文件。
func downloadObject(ctx context.Context, client *s3.Client, bucket, key, dst string) error {
out, err := client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
if err != nil {
return errors.Wrapf(err, "get R2 object %q", key)
}
defer out.Body.Close()
tmp := dst + ".part"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return errors.Wrapf(err, "create %q", tmp)
}
if _, err := io.Copy(f, out.Body); err != nil {
f.Close()
os.Remove(tmp)
return errors.Wrapf(err, "download %q", key)
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return errors.Wrapf(err, "close %q", tmp)
}
if err := os.Rename(tmp, dst); err != nil {
os.Remove(tmp)
return errors.Wrapf(err, "finalize %q", dst)
}
return nil
}