mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-03 06:52:13 +08:00
1. S3 凭证加密存储:使用 SecretEncryptor (AES-256-GCM) 加密 SecretAccessKey, 防止备份文件中泄露 S3 凭证,兼容旧的未加密数据 2. 修复 saveRecord 竞态条件:添加 recordsMu 互斥锁保护 records 的 load/save 3. 恢复操作增加服务端验证:handler 层要求重新输入管理员密码,通过 bcrypt 校验,前端弹出密码输入框 4. pg_dump/psql/S3 操作抽象为接口:定义 DBDumper 和 BackupObjectStore 接口, 实现放入 repository 层,遵循项目依赖注入架构规范 5. 改为流式处理避免大数据库 OOM:备份时 pg_dump stdout -> gzip -> io.Pipe -> S3 upload;恢复时 S3 download -> gzip reader -> psql stdin,不再全量加载 6. loadRecords 区分"无数据"和"数据损坏"场景:JSON 解析失败返回明确错误 7. 添加 18 个核心逻辑单元测试:覆盖加密、并发、流式备份/恢复、错误处理等 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
99 lines
2.3 KiB
Go
99 lines
2.3 KiB
Go
package repository
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"io"
|
||
"os/exec"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||
)
|
||
|
||
// PgDumper implements service.DBDumper using pg_dump/psql
|
||
type PgDumper struct {
|
||
cfg *config.DatabaseConfig
|
||
}
|
||
|
||
// NewPgDumper creates a new PgDumper
|
||
func NewPgDumper(cfg *config.Config) service.DBDumper {
|
||
return &PgDumper{cfg: &cfg.Database}
|
||
}
|
||
|
||
// Dump executes pg_dump and returns a streaming reader of the output
|
||
func (d *PgDumper) Dump(ctx context.Context) (io.ReadCloser, error) {
|
||
args := []string{
|
||
"-h", d.cfg.Host,
|
||
"-p", fmt.Sprintf("%d", d.cfg.Port),
|
||
"-U", d.cfg.User,
|
||
"-d", d.cfg.DBName,
|
||
"--no-owner",
|
||
"--no-acl",
|
||
"--clean",
|
||
"--if-exists",
|
||
}
|
||
|
||
cmd := exec.CommandContext(ctx, "pg_dump", args...)
|
||
if d.cfg.Password != "" {
|
||
cmd.Env = append(cmd.Environ(), "PGPASSWORD="+d.cfg.Password)
|
||
}
|
||
if d.cfg.SSLMode != "" {
|
||
cmd.Env = append(cmd.Environ(), "PGSSLMODE="+d.cfg.SSLMode)
|
||
}
|
||
|
||
stdout, err := cmd.StdoutPipe()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("create stdout pipe: %w", err)
|
||
}
|
||
|
||
if err := cmd.Start(); err != nil {
|
||
return nil, fmt.Errorf("start pg_dump: %w", err)
|
||
}
|
||
|
||
// 返回一个 ReadCloser:读 stdout,关闭时等待进程退出
|
||
return &cmdReadCloser{ReadCloser: stdout, cmd: cmd}, nil
|
||
}
|
||
|
||
// Restore executes psql to restore from a streaming reader
|
||
func (d *PgDumper) Restore(ctx context.Context, data io.Reader) error {
|
||
args := []string{
|
||
"-h", d.cfg.Host,
|
||
"-p", fmt.Sprintf("%d", d.cfg.Port),
|
||
"-U", d.cfg.User,
|
||
"-d", d.cfg.DBName,
|
||
"--single-transaction",
|
||
}
|
||
|
||
cmd := exec.CommandContext(ctx, "psql", args...)
|
||
if d.cfg.Password != "" {
|
||
cmd.Env = append(cmd.Environ(), "PGPASSWORD="+d.cfg.Password)
|
||
}
|
||
if d.cfg.SSLMode != "" {
|
||
cmd.Env = append(cmd.Environ(), "PGSSLMODE="+d.cfg.SSLMode)
|
||
}
|
||
|
||
cmd.Stdin = data
|
||
|
||
output, err := cmd.CombinedOutput()
|
||
if err != nil {
|
||
return fmt.Errorf("%v: %s", err, string(output))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// cmdReadCloser wraps a command stdout pipe and waits for the process on Close
|
||
type cmdReadCloser struct {
|
||
io.ReadCloser
|
||
cmd *exec.Cmd
|
||
}
|
||
|
||
func (c *cmdReadCloser) Close() error {
|
||
// Close the pipe first
|
||
_ = c.ReadCloser.Close()
|
||
// Wait for the process to exit
|
||
if err := c.cmd.Wait(); err != nil {
|
||
return fmt.Errorf("pg_dump exited with error: %w", err)
|
||
}
|
||
return nil
|
||
}
|