| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478 |
- package service
- import (
- "compress/gzip"
- "context"
- "crypto/sha256"
- "database/sql"
- "encoding/hex"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "os"
- "sort"
- "strings"
- "sync"
- "sync/atomic"
- "time"
- "github.com/google/uuid"
- "github.com/robfig/cron/v3"
- "github.com/Wei-Shaw/sub2api/internal/config"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- )
- const (
- settingKeyBackupS3Config = "backup_s3_config"
- settingKeyBackupSchedule = "backup_schedule"
- settingKeyBackupRecords = "backup_records"
- maxBackupRecords = 100
- backupObjectCleanupTimeout = 2 * time.Minute
- // backupScheduledLeaderLockKey gates the scheduled full-database backup so
- // that only one instance in a clustered deployment performs the
- // dump-and-upload each cycle. Without it every instance runs the cron
- // independently, producing N concurrent pg_dumps against the same database,
- // N× peak memory while the archive is uploaded, and N identical objects that
- // overwrite the same timestamped key. Every other periodic job in this
- // package is already gated the same way; the scheduled backup was the last
- // one that still fanned out across every instance.
- backupScheduledLeaderLockKey = "backup:scheduled:leader"
- // backupScheduledLeaderLockTTL bounds crash recovery only; the lock is
- // released as soon as the backup finishes. It must exceed the job's
- // worst-case runtime (the scheduled backup context is bounded at 30m) so the
- // lock cannot expire mid-dump and let a peer start a second backup.
- backupScheduledLeaderLockTTL = 35 * time.Minute
- )
- var (
- ErrBackupS3NotConfigured = infraerrors.BadRequest("BACKUP_S3_NOT_CONFIGURED", "backup S3 storage is not configured")
- ErrBackupNotFound = infraerrors.NotFound("BACKUP_NOT_FOUND", "backup record not found")
- ErrBackupInProgress = infraerrors.Conflict("BACKUP_IN_PROGRESS", "a backup is already in progress")
- ErrRestoreInProgress = infraerrors.Conflict("RESTORE_IN_PROGRESS", "a restore is already in progress")
- ErrBackupRecordsCorrupt = infraerrors.InternalServer("BACKUP_RECORDS_CORRUPT", "backup records data is corrupted")
- ErrBackupS3ConfigCorrupt = infraerrors.InternalServer("BACKUP_S3_CONFIG_CORRUPT", "backup S3 config data is corrupted")
- // ErrSecretEncryptionKeyNotConfigured is returned when an S3 SecretAccessKey
- // would be encrypted with an auto-generated (ephemeral) key. That key is
- // regenerated on every process start, so the persisted ciphertext becomes
- // undecryptable after a restart/upgrade ("cipher: message authentication
- // failed"), silently breaking S3 backup/image storage (#4524). Mirrors the
- // existing guards for payments (payment.ProvideEncryptionKey) and TOTP
- // enablement, which likewise refuse to depend on an auto-generated key.
- ErrSecretEncryptionKeyNotConfigured = infraerrors.BadRequest(
- "SECRET_ENCRYPTION_KEY_NOT_CONFIGURED",
- "cannot store the S3 secret access key: no fixed secret encryption key is configured, so the auto-generated key would change on every restart and make the stored secret undecryptable after a restart or upgrade. Set a fixed TOTP_ENCRYPTION_KEY (e.g. generate one with `openssl rand -hex 32`) and try again",
- )
- )
- // ─── 接口定义 ───
- // DBDumper abstracts database dump/restore operations
- type DBDumper interface {
- Dump(ctx context.Context) (io.ReadCloser, error)
- Restore(ctx context.Context, data io.Reader) error
- }
- // BackupObjectStore abstracts object storage for backup files
- type BackupObjectStore interface {
- Upload(ctx context.Context, key string, body io.Reader, contentType string) (sizeBytes int64, err error)
- UploadFile(ctx context.Context, key string, filePath string, contentType string) (sizeBytes int64, err error)
- Download(ctx context.Context, key string) (io.ReadCloser, error)
- Delete(ctx context.Context, key string) error
- PresignURL(ctx context.Context, key string, expiry time.Duration) (string, error)
- HeadBucket(ctx context.Context) error
- }
- // BackupObjectStoreFactory creates an object store from S3 config
- type BackupObjectStoreFactory func(ctx context.Context, cfg *BackupS3Config) (BackupObjectStore, error)
- // ─── 数据模型 ───
- // BackupS3Config S3 兼容存储配置(支持 Cloudflare R2)
- type BackupS3Config struct {
- Endpoint string `json:"endpoint"` // e.g. https://<account_id>.r2.cloudflarestorage.com
- Region string `json:"region"` // R2 用 "auto"
- Bucket string `json:"bucket"`
- AccessKeyID string `json:"access_key_id"`
- SecretAccessKey string `json:"secret_access_key,omitempty"` //nolint:revive // field name follows AWS convention
- Prefix string `json:"prefix"` // S3 key 前缀,如 "backups/"
- ForcePathStyle bool `json:"force_path_style"`
- }
- // IsConfigured 检查必要字段是否已配置
- func (c *BackupS3Config) IsConfigured() bool {
- return c.Bucket != "" && c.AccessKeyID != "" && c.SecretAccessKey != ""
- }
- // BackupScheduleConfig 定时备份配置
- type BackupScheduleConfig struct {
- Enabled bool `json:"enabled"`
- CronExpr string `json:"cron_expr"` // cron 表达式,如 "0 2 * * *" 每天凌晨2点
- RetainDays int `json:"retain_days"` // 备份文件过期天数,默认14,0=不自动清理
- RetainCount int `json:"retain_count"` // 最多保留份数,0=不限制
- }
- // BackupRecord 备份记录
- type BackupRecord struct {
- ID string `json:"id"`
- Status string `json:"status"` // pending, running, completed, failed
- BackupType string `json:"backup_type"` // postgres
- FileName string `json:"file_name"`
- S3Key string `json:"s3_key"`
- Parts []BackupPart `json:"parts,omitempty"`
- SizeBytes int64 `json:"size_bytes"`
- TriggeredBy string `json:"triggered_by"` // manual, scheduled
- ErrorMsg string `json:"error_message,omitempty"`
- StartedAt string `json:"started_at"`
- FinishedAt string `json:"finished_at,omitempty"`
- ExpiresAt string `json:"expires_at,omitempty"` // 过期时间
- Progress string `json:"progress,omitempty"` // "dumping", "uploading", ""
- RestoreStatus string `json:"restore_status,omitempty"` // "", "running", "completed", "failed"
- RestoreError string `json:"restore_error,omitempty"`
- RestoredAt string `json:"restored_at,omitempty"`
- }
- // BackupDownloadPart 描述一个可下载的备份分卷。
- type BackupDownloadPart struct {
- Index int `json:"index"`
- SizeBytes int64 `json:"size_bytes"`
- URL string `json:"url"`
- }
- // BackupDownloadResponse 是单文件和分卷下载响应的兼容表示。
- type BackupDownloadResponse struct {
- URL string `json:"url,omitempty"`
- Parts []BackupDownloadPart `json:"parts,omitempty"`
- }
- // BackupService 数据库备份恢复服务
- type BackupService struct {
- settingRepo SettingRepository
- dbCfg *config.DatabaseConfig
- encryptor SecretEncryptor
- // encryptionKeyConfigured mirrors cfg.Totp.EncryptionKeyConfigured: false
- // means the secret encryption key was auto-generated and does not survive a
- // restart. Durable-secret writers must refuse to persist new secrets in that
- // mode (#4524).
- encryptionKeyConfigured bool
- storeFactory BackupObjectStoreFactory
- dumper DBDumper
- opMu sync.Mutex // 保护 backingUp/restoring 标志
- backingUp bool
- restoring bool
- storeMu sync.Mutex // 保护 store/s3Cfg 缓存
- store BackupObjectStore
- s3Cfg *BackupS3Config
- recordsMu sync.Mutex // 保护 records 的 load/save 操作
- cronMu sync.Mutex
- cronSched *cron.Cron
- cronEntryID cron.EntryID
- // lockCache/db elect a single leader for the scheduled backup across
- // instances; instanceID identifies this process as the lock owner. Injected
- // via SetLeaderLock — when both are nil the backup runs ungated
- // (single-instance / test behavior).
- lockCache LeaderLockCache
- db *sql.DB
- instanceID string
- wg sync.WaitGroup // 追踪活跃的备份/恢复 goroutine
- shuttingDown atomic.Bool // 阻止新备份启动
- bgCtx context.Context // 所有后台操作的 parent context
- bgCancel context.CancelFunc // 取消所有活跃后台操作
- partSizeBytes int64 // 分卷阈值;生产使用 4 GiB,测试可注入更小值
- }
- func NewBackupService(
- settingRepo SettingRepository,
- cfg *config.Config,
- encryptor SecretEncryptor,
- storeFactory BackupObjectStoreFactory,
- dumper DBDumper,
- ) *BackupService {
- bgCtx, bgCancel := context.WithCancel(context.Background())
- return &BackupService{
- settingRepo: settingRepo,
- dbCfg: &cfg.Database,
- encryptor: encryptor,
- encryptionKeyConfigured: cfg.Totp.EncryptionKeyConfigured,
- storeFactory: storeFactory,
- dumper: dumper,
- bgCtx: bgCtx,
- bgCancel: bgCancel,
- partSizeBytes: defaultBackupPartSizeBytes,
- instanceID: uuid.NewString(),
- }
- }
- // SetLeaderLock injects the leader-lock cache and DB used to elect a single
- // instance for the scheduled backup. When both are nil the scheduled backup runs
- // ungated (single-instance / test behavior).
- func (s *BackupService) SetLeaderLock(lockCache LeaderLockCache, db *sql.DB) {
- if s == nil {
- return
- }
- s.lockCache = lockCache
- s.db = db
- }
- // Start 启动定时备份调度器并清理孤立记录
- func (s *BackupService) Start() {
- s.cronSched = cron.New()
- s.cronSched.Start()
- // 清理重启后孤立的 running 记录
- s.recoverStaleRecords()
- // 加载已有的定时配置
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- schedule, err := s.GetSchedule(ctx)
- if err != nil {
- logger.LegacyPrintf("service.backup", "[Backup] 加载定时备份配置失败: %v", err)
- return
- }
- if schedule.Enabled && schedule.CronExpr != "" {
- if err := s.applyCronSchedule(schedule); err != nil {
- logger.LegacyPrintf("service.backup", "[Backup] 应用定时备份配置失败: %v", err)
- }
- }
- }
- // recoverStaleRecords 启动时将孤立的 running 记录标记为 failed,并清理已上传对象。
- func (s *BackupService) recoverStaleRecords() {
- loadCtx, loadCancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer loadCancel()
- records, err := s.loadRecords(loadCtx)
- if err != nil {
- return
- }
- for i := range records {
- if records[i].Status == "running" {
- staleRecord := records[i]
- records[i].Status = "failed"
- records[i].ErrorMsg = "interrupted by server restart"
- records[i].Progress = ""
- records[i].FinishedAt = time.Now().Format(time.RFC3339)
- s.saveRecoveredRecord(&records[i])
- if cleanupErr := s.cleanupStaleBackupObjects(&staleRecord); cleanupErr != nil {
- records[i].ErrorMsg = fmt.Sprintf("interrupted by server restart; cleanup failed, manual deletion may be required: %v", cleanupErr)
- s.saveRecoveredRecord(&records[i])
- logger.LegacyPrintf("service.backup", "[Backup] failed to clean stale backup objects for %s: %v", records[i].ID, cleanupErr)
- }
- logger.LegacyPrintf("service.backup", "[Backup] recovered stale running record: %s", records[i].ID)
- }
- if records[i].RestoreStatus == "running" {
- records[i].RestoreStatus = "failed"
- records[i].RestoreError = "interrupted by server restart"
- s.saveRecoveredRecord(&records[i])
- logger.LegacyPrintf("service.backup", "[Backup] recovered stale restoring record: %s", records[i].ID)
- }
- }
- }
- func (s *BackupService) saveRecoveredRecord(record *BackupRecord) {
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- if err := s.saveRecord(ctx, record); err != nil {
- logger.LegacyPrintf("service.backup", "[Backup] 保存恢复后的备份记录失败 %s: %v", record.ID, err)
- }
- }
- func (s *BackupService) cleanupStaleBackupObjects(record *BackupRecord) error {
- if len(backupObjectKeys(record)) == 0 {
- return nil
- }
- ctx, cancel := context.WithTimeout(context.Background(), backupObjectCleanupTimeout)
- defer cancel()
- return s.deleteBackupObjects(ctx, record)
- }
- // Stop 停止定时备份并等待活跃操作完成
- func (s *BackupService) Stop() {
- s.shuttingDown.Store(true)
- s.cronMu.Lock()
- if s.cronSched != nil {
- s.cronSched.Stop()
- }
- s.cronMu.Unlock()
- // 等待活跃备份/恢复完成(最多 5 分钟)
- done := make(chan struct{})
- go func() {
- s.wg.Wait()
- close(done)
- }()
- select {
- case <-done:
- logger.LegacyPrintf("service.backup", "[Backup] all active operations finished")
- case <-time.After(5 * time.Minute):
- logger.LegacyPrintf("service.backup", "[Backup] shutdown timeout after 5min, cancelling active operations")
- if s.bgCancel != nil {
- s.bgCancel() // 取消所有后台操作
- }
- // 给 goroutine 时间响应取消并完成清理
- select {
- case <-done:
- logger.LegacyPrintf("service.backup", "[Backup] active operations cancelled and cleaned up")
- case <-time.After(10 * time.Second):
- logger.LegacyPrintf("service.backup", "[Backup] goroutine cleanup timed out")
- }
- }
- }
- // ─── S3 配置管理 ───
- // EncryptionKeyConfigured reports whether a fixed (explicitly configured) secret
- // encryption key is in use. When false the key is auto-generated on every start
- // and secrets encrypted with it cannot be recovered after a restart, so callers
- // that persist durable secrets must refuse to do so (#4524).
- func (s *BackupService) EncryptionKeyConfigured() bool {
- return s != nil && s.encryptionKeyConfigured
- }
- func (s *BackupService) GetS3Config(ctx context.Context) (*BackupS3Config, error) {
- cfg, err := s.loadS3Config(ctx)
- if err != nil {
- return nil, err
- }
- if cfg == nil {
- return &BackupS3Config{}, nil
- }
- // 脱敏返回
- cfg.SecretAccessKey = ""
- return cfg, nil
- }
- func (s *BackupService) UpdateS3Config(ctx context.Context, cfg BackupS3Config) (*BackupS3Config, error) {
- // 如果没提供 secret,保留原有值
- if cfg.SecretAccessKey == "" {
- old, _ := s.loadS3Config(ctx)
- if old != nil {
- cfg.SecretAccessKey = old.SecretAccessKey
- }
- } else {
- // 拒绝用自动生成的临时密钥加密:该密钥每次重启都会变化,落库的密文在
- // 重启/升级后无法解密(#4524)。与支付、TOTP 的处理保持一致。
- if !s.encryptionKeyConfigured {
- return nil, ErrSecretEncryptionKeyNotConfigured
- }
- // 加密 SecretAccessKey
- encrypted, err := s.encryptor.Encrypt(cfg.SecretAccessKey)
- if err != nil {
- return nil, fmt.Errorf("encrypt secret: %w", err)
- }
- cfg.SecretAccessKey = encrypted
- }
- data, err := json.Marshal(cfg)
- if err != nil {
- return nil, fmt.Errorf("marshal s3 config: %w", err)
- }
- if err := s.settingRepo.Set(ctx, settingKeyBackupS3Config, string(data)); err != nil {
- return nil, fmt.Errorf("save s3 config: %w", err)
- }
- // 清除缓存的 S3 客户端
- s.storeMu.Lock()
- s.store = nil
- s.s3Cfg = nil
- s.storeMu.Unlock()
- cfg.SecretAccessKey = ""
- return &cfg, nil
- }
- func (s *BackupService) TestS3Connection(ctx context.Context, cfg BackupS3Config) error {
- // 如果没提供 secret,用已保存的
- if cfg.SecretAccessKey == "" {
- old, _ := s.loadS3Config(ctx)
- if old != nil {
- cfg.SecretAccessKey = old.SecretAccessKey
- }
- }
- if cfg.Bucket == "" || cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" {
- return fmt.Errorf("incomplete S3 config: bucket, access_key_id, secret_access_key are required")
- }
- store, err := s.storeFactory(ctx, &cfg)
- if err != nil {
- return err
- }
- return store.HeadBucket(ctx)
- }
- // ─── 定时备份管理 ───
- func (s *BackupService) GetSchedule(ctx context.Context) (*BackupScheduleConfig, error) {
- raw, err := s.settingRepo.GetValue(ctx, settingKeyBackupSchedule)
- if err != nil || raw == "" {
- return &BackupScheduleConfig{}, nil
- }
- var cfg BackupScheduleConfig
- if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
- return &BackupScheduleConfig{}, nil
- }
- return &cfg, nil
- }
- func (s *BackupService) UpdateSchedule(ctx context.Context, cfg BackupScheduleConfig) (*BackupScheduleConfig, error) {
- if cfg.Enabled && cfg.CronExpr == "" {
- return nil, infraerrors.BadRequest("INVALID_CRON", "cron expression is required when schedule is enabled")
- }
- // 验证 cron 表达式
- if cfg.CronExpr != "" {
- parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
- if _, err := parser.Parse(cfg.CronExpr); err != nil {
- return nil, infraerrors.BadRequest("INVALID_CRON", fmt.Sprintf("invalid cron expression: %v", err))
- }
- }
- data, err := json.Marshal(cfg)
- if err != nil {
- return nil, fmt.Errorf("marshal schedule config: %w", err)
- }
- if err := s.settingRepo.Set(ctx, settingKeyBackupSchedule, string(data)); err != nil {
- return nil, fmt.Errorf("save schedule config: %w", err)
- }
- // 应用或停止定时任务
- if cfg.Enabled {
- if err := s.applyCronSchedule(&cfg); err != nil {
- return nil, err
- }
- } else {
- s.removeCronSchedule()
- }
- return &cfg, nil
- }
- func (s *BackupService) applyCronSchedule(cfg *BackupScheduleConfig) error {
- s.cronMu.Lock()
- defer s.cronMu.Unlock()
- if s.cronSched == nil {
- return fmt.Errorf("cron scheduler not initialized")
- }
- // 移除旧任务
- if s.cronEntryID != 0 {
- s.cronSched.Remove(s.cronEntryID)
- s.cronEntryID = 0
- }
- entryID, err := s.cronSched.AddFunc(cfg.CronExpr, func() {
- s.runScheduledBackup()
- })
- if err != nil {
- return infraerrors.BadRequest("INVALID_CRON", fmt.Sprintf("failed to schedule: %v", err))
- }
- s.cronEntryID = entryID
- logger.LegacyPrintf("service.backup", "[Backup] 定时备份已启用: %s", cfg.CronExpr)
- return nil
- }
- func (s *BackupService) removeCronSchedule() {
- s.cronMu.Lock()
- defer s.cronMu.Unlock()
- if s.cronSched != nil && s.cronEntryID != 0 {
- s.cronSched.Remove(s.cronEntryID)
- s.cronEntryID = 0
- logger.LegacyPrintf("service.backup", "[Backup] 定时备份已停用")
- }
- }
- func (s *BackupService) runScheduledBackup() {
- s.wg.Add(1)
- defer s.wg.Done()
- ctx, cancel := context.WithTimeout(s.bgCtx, 30*time.Minute)
- defer cancel()
- // 多实例保护: 集群部署时只让 leader 执行定时备份, 避免每个实例各自对同一个
- // 数据库跑一次全量 dump、上传时峰值内存翻倍、以及多份同名对象互相覆盖。
- // 手动触发的备份 (CreateBackup/StartBackup) 不受此限, 运维仍可随时在任一节点强制备份。
- release, ok := tryAcquireSingletonLeaderLock(ctx, s.lockCache, s.db, backupScheduledLeaderLockKey, s.instanceID, backupScheduledLeaderLockTTL)
- if !ok {
- logger.LegacyPrintf("service.backup", "[Backup] 定时备份跳过: 本实例非 leader")
- return
- }
- defer release()
- // 读取定时备份配置中的过期天数
- schedule, _ := s.GetSchedule(ctx)
- expireDays := 14 // 默认14天过期
- if schedule != nil && schedule.RetainDays > 0 {
- expireDays = schedule.RetainDays
- }
- logger.LegacyPrintf("service.backup", "[Backup] 开始执行定时备份, 过期天数: %d", expireDays)
- record, err := s.CreateBackup(ctx, "scheduled", expireDays)
- if err != nil {
- if errors.Is(err, ErrBackupInProgress) {
- logger.LegacyPrintf("service.backup", "[Backup] 定时备份跳过: 已有备份正在进行中")
- } else {
- logger.LegacyPrintf("service.backup", "[Backup] 定时备份失败: %v", err)
- }
- return
- }
- logger.LegacyPrintf("service.backup", "[Backup] 定时备份完成: id=%s size=%d", record.ID, record.SizeBytes)
- // 清理过期备份(复用已加载的 schedule)
- if schedule == nil {
- return
- }
- if err := s.cleanupOldBackups(ctx, schedule); err != nil {
- logger.LegacyPrintf("service.backup", "[Backup] 清理过期备份失败: %v", err)
- }
- }
- // ─── 备份/恢复核心 ───
- // CreateBackup 创建全量数据库备份并上传到 S3。
- // expireDays: 备份过期天数,0=永不过期,默认14天
- func (s *BackupService) CreateBackup(ctx context.Context, triggeredBy string, expireDays int) (*BackupRecord, error) {
- if s.shuttingDown.Load() {
- return nil, infraerrors.ServiceUnavailable("SERVER_SHUTTING_DOWN", "server is shutting down")
- }
- s.opMu.Lock()
- if s.backingUp {
- s.opMu.Unlock()
- return nil, ErrBackupInProgress
- }
- s.backingUp = true
- s.opMu.Unlock()
- defer func() {
- s.opMu.Lock()
- s.backingUp = false
- s.opMu.Unlock()
- }()
- s3Cfg, err := s.loadS3Config(ctx)
- if err != nil {
- return nil, err
- }
- if s3Cfg == nil || !s3Cfg.IsConfigured() {
- return nil, ErrBackupS3NotConfigured
- }
- objectStore, err := s.getOrCreateStore(ctx, s3Cfg)
- if err != nil {
- return nil, fmt.Errorf("init object store: %w", err)
- }
- now := time.Now()
- backupID := uuid.New().String()[:8]
- fileName := fmt.Sprintf("%s_%s.sql.gz", s.dbCfg.DBName, now.Format("20060102_150405"))
- s3Key := s.buildS3Key(s3Cfg, fileName)
- var expiresAt string
- if expireDays > 0 {
- expiresAt = now.AddDate(0, 0, expireDays).Format(time.RFC3339)
- }
- record := &BackupRecord{
- ID: backupID,
- Status: "running",
- BackupType: "postgres",
- FileName: fileName,
- S3Key: s3Key,
- TriggeredBy: triggeredBy,
- StartedAt: now.Format(time.RFC3339),
- ExpiresAt: expiresAt,
- }
- archivePath, sizeBytes, err := s.createCompressedBackupFile(ctx)
- if err != nil {
- record.Status = "failed"
- record.ErrorMsg = err.Error()
- record.FinishedAt = time.Now().Format(time.RFC3339)
- _ = s.saveRecord(ctx, record)
- return record, err
- }
- defer func() { _ = cleanupBackupFiles(archivePath) }()
- record.SizeBytes = sizeBytes
- if err := s.saveRecord(ctx, record); err != nil {
- return nil, fmt.Errorf("save initial record: %w", err)
- }
- if err := s.uploadBackupArchive(ctx, record, objectStore, s3Cfg, archivePath); err != nil {
- record.Status = "failed"
- record.ErrorMsg = err.Error()
- record.FinishedAt = time.Now().Format(time.RFC3339)
- _ = s.saveRecord(ctx, record)
- return record, err
- }
- record.Status = "completed"
- record.FinishedAt = time.Now().Format(time.RFC3339)
- if err := s.saveRecord(ctx, record); err != nil {
- logger.LegacyPrintf("service.backup", "[Backup] 保存备份记录失败: %v", err)
- }
- return record, nil
- }
- // StartBackup 异步创建备份,立即返回 running 状态的记录
- func (s *BackupService) StartBackup(ctx context.Context, triggeredBy string, expireDays int) (*BackupRecord, error) {
- if s.shuttingDown.Load() {
- return nil, infraerrors.ServiceUnavailable("SERVER_SHUTTING_DOWN", "server is shutting down")
- }
- s.opMu.Lock()
- if s.backingUp {
- s.opMu.Unlock()
- return nil, ErrBackupInProgress
- }
- s.backingUp = true
- s.opMu.Unlock()
- // 初始化阶段出错时自动重置标志
- launched := false
- defer func() {
- if !launched {
- s.opMu.Lock()
- s.backingUp = false
- s.opMu.Unlock()
- }
- }()
- // 在返回前加载 S3 配置和创建 store,避免 goroutine 中配置被修改
- s3Cfg, err := s.loadS3Config(ctx)
- if err != nil {
- return nil, err
- }
- if s3Cfg == nil || !s3Cfg.IsConfigured() {
- return nil, ErrBackupS3NotConfigured
- }
- objectStore, err := s.getOrCreateStore(ctx, s3Cfg)
- if err != nil {
- return nil, fmt.Errorf("init object store: %w", err)
- }
- now := time.Now()
- backupID := uuid.New().String()[:8]
- fileName := fmt.Sprintf("%s_%s.sql.gz", s.dbCfg.DBName, now.Format("20060102_150405"))
- s3Key := s.buildS3Key(s3Cfg, fileName)
- var expiresAt string
- if expireDays > 0 {
- expiresAt = now.AddDate(0, 0, expireDays).Format(time.RFC3339)
- }
- record := &BackupRecord{
- ID: backupID,
- Status: "running",
- BackupType: "postgres",
- FileName: fileName,
- S3Key: s3Key,
- TriggeredBy: triggeredBy,
- StartedAt: now.Format(time.RFC3339),
- ExpiresAt: expiresAt,
- Progress: "pending",
- }
- if err := s.saveRecord(ctx, record); err != nil {
- return nil, fmt.Errorf("save initial record: %w", err)
- }
- launched = true
- // 在启动 goroutine 前完成拷贝,避免数据竞争
- result := *record
- s.wg.Add(1)
- go func() {
- defer s.wg.Done()
- defer func() {
- s.opMu.Lock()
- s.backingUp = false
- s.opMu.Unlock()
- }()
- defer func() {
- if r := recover(); r != nil {
- logger.LegacyPrintf("service.backup", "[Backup] panic recovered: %v", r)
- record.Status = "failed"
- record.ErrorMsg = fmt.Sprintf("internal panic: %v", r)
- record.Progress = ""
- record.FinishedAt = time.Now().Format(time.RFC3339)
- _ = s.saveRecord(context.Background(), record)
- }
- }()
- s.executeBackup(record, objectStore, s3Cfg)
- }()
- return &result, nil
- }
- // executeBackup 后台执行备份(独立于 HTTP context)
- func (s *BackupService) executeBackup(record *BackupRecord, objectStore BackupObjectStore, s3Cfg *BackupS3Config) {
- ctx, cancel := context.WithTimeout(s.bgCtx, 30*time.Minute)
- defer cancel()
- // 阶段1: pg_dump -> gzip 临时文件
- record.Progress = "dumping"
- _ = s.saveRecord(ctx, record)
- archivePath, sizeBytes, err := s.createCompressedBackupFile(ctx)
- if err != nil {
- record.Status = "failed"
- record.ErrorMsg = err.Error()
- record.Progress = ""
- record.FinishedAt = time.Now().Format(time.RFC3339)
- _ = s.saveRecord(context.Background(), record)
- return
- }
- defer func() { _ = cleanupBackupFiles(archivePath) }()
- record.SizeBytes = sizeBytes
- // 阶段2: 单对象或分卷上传
- record.Progress = "uploading"
- _ = s.saveRecord(ctx, record)
- if err := s.uploadBackupArchive(ctx, record, objectStore, s3Cfg, archivePath); err != nil {
- record.Status = "failed"
- record.ErrorMsg = err.Error()
- record.Progress = ""
- record.FinishedAt = time.Now().Format(time.RFC3339)
- _ = s.saveRecord(context.Background(), record)
- return
- }
- record.SizeBytes = sizeBytes
- record.Status = "completed"
- record.Progress = ""
- record.FinishedAt = time.Now().Format(time.RFC3339)
- if err := s.saveRecord(context.Background(), record); err != nil {
- logger.LegacyPrintf("service.backup", "[Backup] 保存备份记录失败: %v", err)
- }
- }
- func (s *BackupService) createCompressedBackupFile(ctx context.Context) (string, int64, error) {
- dumpReader, err := s.dumper.Dump(ctx)
- if err != nil {
- return "", 0, fmt.Errorf("pg_dump: %w", err)
- }
- archive, err := os.CreateTemp("", "sub2api-backup-*.sql.gz")
- if err != nil {
- _ = dumpReader.Close()
- return "", 0, fmt.Errorf("create backup archive: %w", err)
- }
- archivePath := archive.Name()
- gzWriter := gzip.NewWriter(archive)
- _, copyErr := io.Copy(gzWriter, dumpReader)
- if closeErr := gzWriter.Close(); copyErr == nil && closeErr != nil {
- copyErr = closeErr
- }
- if closeErr := dumpReader.Close(); copyErr == nil && closeErr != nil {
- copyErr = closeErr
- }
- if closeErr := archive.Close(); copyErr == nil && closeErr != nil {
- copyErr = closeErr
- }
- if copyErr != nil {
- _ = cleanupBackupFiles(archivePath)
- return "", 0, fmt.Errorf("gzip/dump failed: %w", copyErr)
- }
- info, err := os.Stat(archivePath)
- if err != nil {
- _ = cleanupBackupFiles(archivePath)
- return "", 0, fmt.Errorf("stat backup archive: %w", err)
- }
- return archivePath, info.Size(), nil
- }
- func (s *BackupService) uploadBackupArchive(ctx context.Context, record *BackupRecord, objectStore BackupObjectStore, cfg *BackupS3Config, archivePath string) error {
- info, err := os.Stat(archivePath)
- if err != nil {
- return fmt.Errorf("stat backup archive: %w", err)
- }
- partSize := s.partSizeBytes
- if partSize <= 0 {
- partSize = defaultBackupPartSizeBytes
- }
- if info.Size() <= partSize {
- if _, err := objectStore.UploadFile(ctx, record.S3Key, archivePath, "application/gzip"); err != nil {
- cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), backupObjectCleanupTimeout)
- cleanupErr := deleteBackupObjectKeys(cleanupCtx, objectStore, record)
- cleanupCancel()
- return errors.Join(fmt.Errorf("backup upload: %w", err), cleanupErr)
- }
- record.Parts = nil
- return nil
- }
- localParts, err := splitBackupFile(archivePath, partSize)
- if err != nil {
- return fmt.Errorf("split backup archive: %w", err)
- }
- defer func() {
- paths := make([]string, 0, len(localParts))
- for _, part := range localParts {
- paths = append(paths, part.Path)
- }
- _ = cleanupBackupFiles(paths...)
- }()
- if cfg == nil {
- return errors.New("backup S3 config is unavailable for split upload")
- }
- record.S3Key = ""
- record.Parts = make([]BackupPart, 0, len(localParts))
- partRoot := strings.TrimRight(s.buildS3Key(cfg, record.ID), "/")
- for _, part := range localParts {
- record.Parts = append(record.Parts, BackupPart{
- Index: part.Index,
- S3Key: s.buildBackupPartKey(partRoot, part.Index),
- SizeBytes: part.SizeBytes,
- SHA256: part.SHA256,
- })
- }
- if err := s.saveRecord(ctx, record); err != nil {
- return fmt.Errorf("save split backup plan: %w", err)
- }
- for i, part := range localParts {
- if _, err := objectStore.UploadFile(ctx, record.Parts[i].S3Key, part.Path, "application/octet-stream"); err != nil {
- // PUT 可能已经在对象存储端成功、但客户端因超时收到错误;
- // 因此失败时清理整份分卷计划,而不只清理此前返回成功的卷。
- cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), backupObjectCleanupTimeout)
- cleanupErr := deleteBackupObjectKeys(cleanupCtx, objectStore, record)
- cleanupCancel()
- return errors.Join(fmt.Errorf("upload backup part %d: %w", part.Index, err), cleanupErr)
- }
- }
- return nil
- }
- func (s *BackupService) buildBackupPartKey(root string, index int) string {
- return fmt.Sprintf("%s/payload.part-%06d", strings.TrimRight(root, "/"), index)
- }
- // RestoreBackup 从 S3 下载备份并流式恢复到数据库
- func (s *BackupService) RestoreBackup(ctx context.Context, backupID string) error {
- s.opMu.Lock()
- if s.restoring {
- s.opMu.Unlock()
- return ErrRestoreInProgress
- }
- s.restoring = true
- s.opMu.Unlock()
- defer func() {
- s.opMu.Lock()
- s.restoring = false
- s.opMu.Unlock()
- }()
- record, err := s.GetBackupRecord(ctx, backupID)
- if err != nil {
- return err
- }
- if record.Status != "completed" {
- return infraerrors.BadRequest("BACKUP_NOT_COMPLETED", "can only restore from a completed backup")
- }
- s3Cfg, err := s.loadS3Config(ctx)
- if err != nil {
- return err
- }
- objectStore, err := s.getOrCreateStore(ctx, s3Cfg)
- if err != nil {
- return fmt.Errorf("init object store: %w", err)
- }
- if len(record.Parts) > 0 {
- archivePath, err := s.downloadBackupParts(ctx, objectStore, record.Parts)
- if err != nil {
- return err
- }
- defer func() { _ = cleanupBackupFiles(archivePath) }()
- return s.restoreArchive(ctx, archivePath)
- }
- // 旧记录从 S3 流式下载
- body, err := objectStore.Download(ctx, record.S3Key)
- if err != nil {
- return fmt.Errorf("S3 download failed: %w", err)
- }
- defer func() { _ = body.Close() }()
- // 流式解压 gzip -> psql(不将全部数据加载到内存)
- gzReader, err := gzip.NewReader(body)
- if err != nil {
- return fmt.Errorf("gzip reader: %w", err)
- }
- defer func() { _ = gzReader.Close() }()
- // 流式恢复
- if err := s.dumper.Restore(ctx, gzReader); err != nil {
- return fmt.Errorf("pg restore: %w", err)
- }
- return nil
- }
- // StartRestore 异步恢复备份,立即返回
- func (s *BackupService) StartRestore(ctx context.Context, backupID string) (*BackupRecord, error) {
- if s.shuttingDown.Load() {
- return nil, infraerrors.ServiceUnavailable("SERVER_SHUTTING_DOWN", "server is shutting down")
- }
- s.opMu.Lock()
- if s.restoring {
- s.opMu.Unlock()
- return nil, ErrRestoreInProgress
- }
- s.restoring = true
- s.opMu.Unlock()
- // 初始化阶段出错时自动重置标志
- launched := false
- defer func() {
- if !launched {
- s.opMu.Lock()
- s.restoring = false
- s.opMu.Unlock()
- }
- }()
- record, err := s.GetBackupRecord(ctx, backupID)
- if err != nil {
- return nil, err
- }
- if record.Status != "completed" {
- return nil, infraerrors.BadRequest("BACKUP_NOT_COMPLETED", "can only restore from a completed backup")
- }
- s3Cfg, err := s.loadS3Config(ctx)
- if err != nil {
- return nil, err
- }
- objectStore, err := s.getOrCreateStore(ctx, s3Cfg)
- if err != nil {
- return nil, fmt.Errorf("init object store: %w", err)
- }
- record.RestoreStatus = "running"
- _ = s.saveRecord(ctx, record)
- launched = true
- result := *record
- s.wg.Add(1)
- go func() {
- defer s.wg.Done()
- defer func() {
- s.opMu.Lock()
- s.restoring = false
- s.opMu.Unlock()
- }()
- defer func() {
- if r := recover(); r != nil {
- logger.LegacyPrintf("service.backup", "[Backup] restore panic recovered: %v", r)
- record.RestoreStatus = "failed"
- record.RestoreError = fmt.Sprintf("internal panic: %v", r)
- _ = s.saveRecord(context.Background(), record)
- }
- }()
- s.executeRestore(record, objectStore)
- }()
- return &result, nil
- }
- // executeRestore 后台执行恢复
- func (s *BackupService) executeRestore(record *BackupRecord, objectStore BackupObjectStore) {
- ctx, cancel := context.WithTimeout(s.bgCtx, 30*time.Minute)
- defer cancel()
- if len(record.Parts) > 0 {
- archivePath, err := s.downloadBackupParts(ctx, objectStore, record.Parts)
- if err != nil {
- record.RestoreStatus = "failed"
- record.RestoreError = err.Error()
- _ = s.saveRecord(context.Background(), record)
- return
- }
- defer func() { _ = cleanupBackupFiles(archivePath) }()
- if err := s.restoreArchive(ctx, archivePath); err != nil {
- record.RestoreStatus = "failed"
- record.RestoreError = fmt.Sprintf("pg restore: %v", err)
- _ = s.saveRecord(context.Background(), record)
- return
- }
- record.RestoreStatus = "completed"
- record.RestoredAt = time.Now().Format(time.RFC3339)
- if err := s.saveRecord(context.Background(), record); err != nil {
- logger.LegacyPrintf("service.backup", "[Backup] 保存恢复记录失败: %v", err)
- }
- return
- }
- body, err := objectStore.Download(ctx, record.S3Key)
- if err != nil {
- record.RestoreStatus = "failed"
- record.RestoreError = fmt.Sprintf("S3 download failed: %v", err)
- _ = s.saveRecord(context.Background(), record)
- return
- }
- defer func() { _ = body.Close() }()
- gzReader, err := gzip.NewReader(body)
- if err != nil {
- record.RestoreStatus = "failed"
- record.RestoreError = fmt.Sprintf("gzip reader: %v", err)
- _ = s.saveRecord(context.Background(), record)
- return
- }
- defer func() { _ = gzReader.Close() }()
- if err := s.dumper.Restore(ctx, gzReader); err != nil {
- record.RestoreStatus = "failed"
- record.RestoreError = fmt.Sprintf("pg restore: %v", err)
- _ = s.saveRecord(context.Background(), record)
- return
- }
- record.RestoreStatus = "completed"
- record.RestoredAt = time.Now().Format(time.RFC3339)
- if err := s.saveRecord(context.Background(), record); err != nil {
- logger.LegacyPrintf("service.backup", "[Backup] 保存恢复记录失败: %v", err)
- }
- }
- func (s *BackupService) downloadBackupParts(ctx context.Context, objectStore BackupObjectStore, parts []BackupPart) (path string, err error) {
- if len(parts) == 0 {
- return "", errors.New("backup parts are empty")
- }
- ordered := append([]BackupPart(nil), parts...)
- sort.Slice(ordered, func(i, j int) bool { return ordered[i].Index < ordered[j].Index })
- for i, part := range ordered {
- if part.Index != i+1 || part.S3Key == "" || part.SizeBytes <= 0 {
- return "", fmt.Errorf("invalid backup part metadata at index %d", i+1)
- }
- }
- archive, err := os.CreateTemp("", "sub2api-restore-*.sql.gz")
- if err != nil {
- return "", fmt.Errorf("create restore archive: %w", err)
- }
- path = archive.Name()
- cleanup := func() {
- _ = archive.Close()
- _ = cleanupBackupFiles(path)
- }
- for _, part := range ordered {
- body, downloadErr := objectStore.Download(ctx, part.S3Key)
- if downloadErr != nil {
- cleanup()
- return "", fmt.Errorf("download backup part %d: %w", part.Index, downloadErr)
- }
- hash := sha256.New()
- written, copyErr := io.Copy(io.MultiWriter(archive, hash), body)
- closeErr := body.Close()
- if copyErr != nil {
- cleanup()
- return "", fmt.Errorf("read backup part %d: %w", part.Index, copyErr)
- }
- if closeErr != nil {
- cleanup()
- return "", fmt.Errorf("close backup part %d: %w", part.Index, closeErr)
- }
- if written != part.SizeBytes {
- cleanup()
- return "", fmt.Errorf("backup part %d size mismatch: got %d, want %d", part.Index, written, part.SizeBytes)
- }
- if part.SHA256 != "" && !strings.EqualFold(part.SHA256, hex.EncodeToString(hash.Sum(nil))) {
- cleanup()
- return "", fmt.Errorf("backup part %d checksum mismatch", part.Index)
- }
- }
- if err := archive.Close(); err != nil {
- _ = cleanupBackupFiles(path)
- return "", fmt.Errorf("close restore archive: %w", err)
- }
- return path, nil
- }
- func (s *BackupService) restoreArchive(ctx context.Context, archivePath string) error {
- archive, err := os.Open(archivePath)
- if err != nil {
- return fmt.Errorf("open restore archive: %w", err)
- }
- defer func() { _ = archive.Close() }()
- gzReader, err := gzip.NewReader(archive)
- if err != nil {
- return fmt.Errorf("gzip reader: %w", err)
- }
- defer func() { _ = gzReader.Close() }()
- if err := s.dumper.Restore(ctx, gzReader); err != nil {
- return fmt.Errorf("pg restore: %w", err)
- }
- return nil
- }
- // ─── 备份记录管理 ───
- func (s *BackupService) ListBackups(ctx context.Context) ([]BackupRecord, error) {
- records, err := s.loadRecords(ctx)
- if err != nil {
- return nil, err
- }
- // 倒序返回(最新在前)
- sort.Slice(records, func(i, j int) bool {
- return records[i].StartedAt > records[j].StartedAt
- })
- return records, nil
- }
- func (s *BackupService) GetBackupRecord(ctx context.Context, backupID string) (*BackupRecord, error) {
- records, err := s.loadRecords(ctx)
- if err != nil {
- return nil, err
- }
- for i := range records {
- if records[i].ID == backupID {
- return &records[i], nil
- }
- }
- return nil, ErrBackupNotFound
- }
- func (s *BackupService) DeleteBackup(ctx context.Context, backupID string) error {
- s.recordsMu.Lock()
- defer s.recordsMu.Unlock()
- records, err := s.loadRecordsLocked(ctx)
- if err != nil {
- return err
- }
- var found *BackupRecord
- var remaining []BackupRecord
- for i := range records {
- if records[i].ID == backupID {
- found = &records[i]
- } else {
- remaining = append(remaining, records[i])
- }
- }
- if found == nil {
- return ErrBackupNotFound
- }
- if found.Status == "running" {
- // 后台上传仍可能依赖 Parts 计划;删除对象会让随后完成的记录引用失效卷。
- return ErrBackupInProgress
- }
- // 从对象存储删除所有单文件或分卷对象。删除不完整时保留记录,便于重试。
- if err := s.deleteBackupObjects(ctx, found); err != nil {
- return err
- }
- return s.saveRecordsLocked(ctx, remaining)
- }
- // GetBackupDownloadURL 获取备份文件预签名下载 URL
- func (s *BackupService) GetBackupDownloadURL(ctx context.Context, backupID string) (BackupDownloadResponse, error) {
- var download BackupDownloadResponse
- record, err := s.GetBackupRecord(ctx, backupID)
- if err != nil {
- return download, err
- }
- if record.Status != "completed" {
- return download, infraerrors.BadRequest("BACKUP_NOT_COMPLETED", "backup is not completed")
- }
- s3Cfg, err := s.loadS3Config(ctx)
- if err != nil {
- return download, err
- }
- objectStore, err := s.getOrCreateStore(ctx, s3Cfg)
- if err != nil {
- return download, err
- }
- if len(record.Parts) > 0 {
- parts := append([]BackupPart(nil), record.Parts...)
- sort.Slice(parts, func(i, j int) bool { return parts[i].Index < parts[j].Index })
- for i, part := range parts {
- if part.Index != i+1 || part.S3Key == "" || part.SizeBytes <= 0 {
- return download, fmt.Errorf("invalid backup part metadata at index %d", i+1)
- }
- url, presignErr := objectStore.PresignURL(ctx, part.S3Key, 1*time.Hour)
- if presignErr != nil {
- return download, fmt.Errorf("presign backup part %d: %w", part.Index, presignErr)
- }
- download.Parts = append(download.Parts, BackupDownloadPart{
- Index: part.Index,
- SizeBytes: part.SizeBytes,
- URL: url,
- })
- }
- return download, nil
- }
- if record.S3Key == "" {
- return download, errors.New("backup object key is empty")
- }
- url, err := objectStore.PresignURL(ctx, record.S3Key, 1*time.Hour)
- if err != nil {
- return download, fmt.Errorf("presign url: %w", err)
- }
- download.URL = url
- return download, nil
- }
- // ─── 内部方法 ───
- func (s *BackupService) loadS3Config(ctx context.Context) (*BackupS3Config, error) {
- raw, err := s.settingRepo.GetValue(ctx, settingKeyBackupS3Config)
- if err != nil || raw == "" {
- return nil, nil //nolint:nilnil // no config is a valid state
- }
- var cfg BackupS3Config
- if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
- return nil, ErrBackupS3ConfigCorrupt
- }
- // 解密 SecretAccessKey
- if cfg.SecretAccessKey != "" {
- decrypted, err := s.encryptor.Decrypt(cfg.SecretAccessKey)
- if err != nil {
- // 兼容未加密的旧数据:如果解密失败,保持原值
- logger.LegacyPrintf("service.backup", "[Backup] S3 SecretAccessKey 解密失败(可能是旧的未加密数据): %v", err)
- } else {
- cfg.SecretAccessKey = decrypted
- }
- }
- return &cfg, nil
- }
- func (s *BackupService) getOrCreateStore(ctx context.Context, cfg *BackupS3Config) (BackupObjectStore, error) {
- s.storeMu.Lock()
- defer s.storeMu.Unlock()
- if s.store != nil && s.s3Cfg != nil {
- return s.store, nil
- }
- if cfg == nil {
- return nil, ErrBackupS3NotConfigured
- }
- store, err := s.storeFactory(ctx, cfg)
- if err != nil {
- return nil, err
- }
- s.store = store
- s.s3Cfg = cfg
- return store, nil
- }
- func (s *BackupService) buildS3Key(cfg *BackupS3Config, fileName string) string {
- prefix := strings.TrimRight(cfg.Prefix, "/")
- if prefix == "" {
- prefix = "backups"
- }
- return fmt.Sprintf("%s/%s/%s", prefix, time.Now().Format("2006/01/02"), fileName)
- }
- // loadRecords 加载备份记录,区分"无数据"和"数据损坏"
- func (s *BackupService) loadRecords(ctx context.Context) ([]BackupRecord, error) {
- s.recordsMu.Lock()
- defer s.recordsMu.Unlock()
- return s.loadRecordsLocked(ctx)
- }
- // loadRecordsLocked 在已持有 recordsMu 锁的情况下加载记录
- func (s *BackupService) loadRecordsLocked(ctx context.Context) ([]BackupRecord, error) {
- raw, err := s.settingRepo.GetValue(ctx, settingKeyBackupRecords)
- if err != nil || raw == "" {
- return nil, nil //nolint:nilnil // no records is a valid state
- }
- var records []BackupRecord
- if err := json.Unmarshal([]byte(raw), &records); err != nil {
- return nil, ErrBackupRecordsCorrupt
- }
- return records, nil
- }
- // saveRecordsLocked 在已持有 recordsMu 锁的情况下保存记录
- func (s *BackupService) saveRecordsLocked(ctx context.Context, records []BackupRecord) error {
- data, err := json.Marshal(records)
- if err != nil {
- return err
- }
- return s.settingRepo.Set(ctx, settingKeyBackupRecords, string(data))
- }
- // saveRecord 保存单条记录(带互斥锁保护)
- func (s *BackupService) saveRecord(ctx context.Context, record *BackupRecord) error {
- s.recordsMu.Lock()
- defer s.recordsMu.Unlock()
- records, _ := s.loadRecordsLocked(ctx)
- // 更新已有记录或追加
- found := false
- for i := range records {
- if records[i].ID == record.ID {
- records[i] = *record
- found = true
- break
- }
- }
- if !found {
- records = append(records, *record)
- }
- // 限制记录数量
- if len(records) > maxBackupRecords {
- records = records[len(records)-maxBackupRecords:]
- }
- return s.saveRecordsLocked(ctx, records)
- }
- func (s *BackupService) cleanupOldBackups(ctx context.Context, schedule *BackupScheduleConfig) error {
- if schedule == nil {
- return nil
- }
- s.recordsMu.Lock()
- defer s.recordsMu.Unlock()
- records, err := s.loadRecordsLocked(ctx)
- if err != nil {
- return err
- }
- // 按时间倒序
- sort.Slice(records, func(i, j int) bool {
- return records[i].StartedAt > records[j].StartedAt
- })
- var toDelete []BackupRecord
- var toKeep []BackupRecord
- for i, r := range records {
- shouldDelete := false
- // 按保留份数清理
- if schedule.RetainCount > 0 && i >= schedule.RetainCount {
- shouldDelete = true
- }
- // 按保留天数清理
- if schedule.RetainDays > 0 && r.StartedAt != "" {
- startedAt, err := time.Parse(time.RFC3339, r.StartedAt)
- if err == nil && time.Since(startedAt) > time.Duration(schedule.RetainDays)*24*time.Hour {
- shouldDelete = true
- }
- }
- if shouldDelete && r.Status == "completed" {
- toDelete = append(toDelete, r)
- } else {
- toKeep = append(toKeep, r)
- }
- }
- var cleanupErrs []error
- deletedCount := 0
- for _, r := range toDelete {
- if err := s.deleteBackupObjects(ctx, &r); err != nil {
- // 对象删除失败时保留记录,避免丢失后续重试所需的 key。
- toKeep = append(toKeep, r)
- cleanupErrs = append(cleanupErrs, fmt.Errorf("cleanup backup %s: %w", r.ID, err))
- continue
- }
- deletedCount++
- }
- if len(toDelete) > 0 {
- if err := s.saveRecordsLocked(ctx, toKeep); err != nil {
- cleanupErrs = append(cleanupErrs, fmt.Errorf("save backup records after cleanup: %w", err))
- }
- if deletedCount > 0 {
- logger.LegacyPrintf("service.backup", "[Backup] 自动清理了 %d 个过期备份", deletedCount)
- }
- return errors.Join(cleanupErrs...)
- }
- return nil
- }
- // backupObjectKeys 返回一条备份记录关联的全部对象 key。
- // 新记录使用 Parts,旧记录使用 S3Key;两者同时存在时也全部返回,便于清理异常残留对象。
- func backupObjectKeys(record *BackupRecord) []string {
- if record == nil {
- return nil
- }
- keys := make([]string, 0, len(record.Parts)+1)
- seen := make(map[string]struct{}, len(record.Parts)+1)
- appendKey := func(key string) {
- if key == "" {
- return
- }
- if _, ok := seen[key]; ok {
- return
- }
- seen[key] = struct{}{}
- keys = append(keys, key)
- }
- appendKey(record.S3Key)
- parts := append([]BackupPart(nil), record.Parts...)
- sort.Slice(parts, func(i, j int) bool { return parts[i].Index < parts[j].Index })
- for _, part := range parts {
- appendKey(part.S3Key)
- }
- return keys
- }
- // deleteBackupObjects 尝试删除记录关联的所有对象,并聚合删除错误。
- func (s *BackupService) deleteBackupObjects(ctx context.Context, record *BackupRecord) error {
- if len(backupObjectKeys(record)) == 0 {
- return nil
- }
- s3Cfg, err := s.loadS3Config(ctx)
- if err != nil {
- return err
- }
- if s3Cfg == nil || !s3Cfg.IsConfigured() {
- // 兼容没有配置对象存储的旧记录:记录仍可被删除。
- return nil
- }
- objectStore, err := s.getOrCreateStore(ctx, s3Cfg)
- if err != nil {
- return err
- }
- return deleteBackupObjectKeys(ctx, objectStore, record)
- }
- func deleteBackupObjectKeys(ctx context.Context, objectStore BackupObjectStore, record *BackupRecord) error {
- keys := backupObjectKeys(record)
- if len(keys) == 0 {
- return nil
- }
- var errs []error
- for _, key := range keys {
- if deleteErr := objectStore.Delete(ctx, key); deleteErr != nil {
- errs = append(errs, fmt.Errorf("delete backup object %q: %w", key, deleteErr))
- }
- }
- return errors.Join(errs...)
- }
|