// 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/(新)或 tamabox/YYYY/MM/.(历史)的形式存在 // R2 上,数据库里只存 key(upload_images.key)或 URL(users.avatar/background 里的完整 // CDN 地址)。只备份数据库的话,恢复后这些引用指向的图片若已不在 R2(bucket 误删 / // 账号丢失 / 图片被清理),页面就全是裂图。因此备份必须把 R2 上的图片一并落盘。 // // 输出目录结构: // // / // db-<20060102-150405>.sql # pg_dump 全量导出(schema + data,可用 psql 恢复) // images/ # 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/.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://.r2.cloudflarestorage.com/), // 它对 GetObject/PutObject 恰好可用(R2 容错),但 ListObjectsV2 会把 URL 拼成 // //?list-type=2,R2 找不到这个 key 前缀而返回 404 NoSuchKey。 // // 这里剥离 endpoint 的 bucket 后缀,得到纯 account endpoint,并启用 path-style: // List/Get 都用 Bucket 参数 + 完整 key(List 返回的 key 自带 bucket 名作前缀, // 如 tamabox/2025/12/.jpg),这是 R2 上唯一稳定可用的姿势。 func backupS3Client() (*s3.Client, error) { endpoint := strings.TrimRight(conf.Upload.ImageEndpoint, "/") // 剥离最后一个路径段(bucket 名)。endpoint 形如 // https://.r2.cloudflarestorage.com/,去掉 /。 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 }