backup_service.go 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478
  1. package service
  2. import (
  3. "compress/gzip"
  4. "context"
  5. "crypto/sha256"
  6. "database/sql"
  7. "encoding/hex"
  8. "encoding/json"
  9. "errors"
  10. "fmt"
  11. "io"
  12. "os"
  13. "sort"
  14. "strings"
  15. "sync"
  16. "sync/atomic"
  17. "time"
  18. "github.com/google/uuid"
  19. "github.com/robfig/cron/v3"
  20. "github.com/Wei-Shaw/sub2api/internal/config"
  21. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  22. "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
  23. )
  24. const (
  25. settingKeyBackupS3Config = "backup_s3_config"
  26. settingKeyBackupSchedule = "backup_schedule"
  27. settingKeyBackupRecords = "backup_records"
  28. maxBackupRecords = 100
  29. backupObjectCleanupTimeout = 2 * time.Minute
  30. // backupScheduledLeaderLockKey gates the scheduled full-database backup so
  31. // that only one instance in a clustered deployment performs the
  32. // dump-and-upload each cycle. Without it every instance runs the cron
  33. // independently, producing N concurrent pg_dumps against the same database,
  34. // N× peak memory while the archive is uploaded, and N identical objects that
  35. // overwrite the same timestamped key. Every other periodic job in this
  36. // package is already gated the same way; the scheduled backup was the last
  37. // one that still fanned out across every instance.
  38. backupScheduledLeaderLockKey = "backup:scheduled:leader"
  39. // backupScheduledLeaderLockTTL bounds crash recovery only; the lock is
  40. // released as soon as the backup finishes. It must exceed the job's
  41. // worst-case runtime (the scheduled backup context is bounded at 30m) so the
  42. // lock cannot expire mid-dump and let a peer start a second backup.
  43. backupScheduledLeaderLockTTL = 35 * time.Minute
  44. )
  45. var (
  46. ErrBackupS3NotConfigured = infraerrors.BadRequest("BACKUP_S3_NOT_CONFIGURED", "backup S3 storage is not configured")
  47. ErrBackupNotFound = infraerrors.NotFound("BACKUP_NOT_FOUND", "backup record not found")
  48. ErrBackupInProgress = infraerrors.Conflict("BACKUP_IN_PROGRESS", "a backup is already in progress")
  49. ErrRestoreInProgress = infraerrors.Conflict("RESTORE_IN_PROGRESS", "a restore is already in progress")
  50. ErrBackupRecordsCorrupt = infraerrors.InternalServer("BACKUP_RECORDS_CORRUPT", "backup records data is corrupted")
  51. ErrBackupS3ConfigCorrupt = infraerrors.InternalServer("BACKUP_S3_CONFIG_CORRUPT", "backup S3 config data is corrupted")
  52. // ErrSecretEncryptionKeyNotConfigured is returned when an S3 SecretAccessKey
  53. // would be encrypted with an auto-generated (ephemeral) key. That key is
  54. // regenerated on every process start, so the persisted ciphertext becomes
  55. // undecryptable after a restart/upgrade ("cipher: message authentication
  56. // failed"), silently breaking S3 backup/image storage (#4524). Mirrors the
  57. // existing guards for payments (payment.ProvideEncryptionKey) and TOTP
  58. // enablement, which likewise refuse to depend on an auto-generated key.
  59. ErrSecretEncryptionKeyNotConfigured = infraerrors.BadRequest(
  60. "SECRET_ENCRYPTION_KEY_NOT_CONFIGURED",
  61. "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",
  62. )
  63. )
  64. // ─── 接口定义 ───
  65. // DBDumper abstracts database dump/restore operations
  66. type DBDumper interface {
  67. Dump(ctx context.Context) (io.ReadCloser, error)
  68. Restore(ctx context.Context, data io.Reader) error
  69. }
  70. // BackupObjectStore abstracts object storage for backup files
  71. type BackupObjectStore interface {
  72. Upload(ctx context.Context, key string, body io.Reader, contentType string) (sizeBytes int64, err error)
  73. UploadFile(ctx context.Context, key string, filePath string, contentType string) (sizeBytes int64, err error)
  74. Download(ctx context.Context, key string) (io.ReadCloser, error)
  75. Delete(ctx context.Context, key string) error
  76. PresignURL(ctx context.Context, key string, expiry time.Duration) (string, error)
  77. HeadBucket(ctx context.Context) error
  78. }
  79. // BackupObjectStoreFactory creates an object store from S3 config
  80. type BackupObjectStoreFactory func(ctx context.Context, cfg *BackupS3Config) (BackupObjectStore, error)
  81. // ─── 数据模型 ───
  82. // BackupS3Config S3 兼容存储配置(支持 Cloudflare R2)
  83. type BackupS3Config struct {
  84. Endpoint string `json:"endpoint"` // e.g. https://<account_id>.r2.cloudflarestorage.com
  85. Region string `json:"region"` // R2 用 "auto"
  86. Bucket string `json:"bucket"`
  87. AccessKeyID string `json:"access_key_id"`
  88. SecretAccessKey string `json:"secret_access_key,omitempty"` //nolint:revive // field name follows AWS convention
  89. Prefix string `json:"prefix"` // S3 key 前缀,如 "backups/"
  90. ForcePathStyle bool `json:"force_path_style"`
  91. }
  92. // IsConfigured 检查必要字段是否已配置
  93. func (c *BackupS3Config) IsConfigured() bool {
  94. return c.Bucket != "" && c.AccessKeyID != "" && c.SecretAccessKey != ""
  95. }
  96. // BackupScheduleConfig 定时备份配置
  97. type BackupScheduleConfig struct {
  98. Enabled bool `json:"enabled"`
  99. CronExpr string `json:"cron_expr"` // cron 表达式,如 "0 2 * * *" 每天凌晨2点
  100. RetainDays int `json:"retain_days"` // 备份文件过期天数,默认14,0=不自动清理
  101. RetainCount int `json:"retain_count"` // 最多保留份数,0=不限制
  102. }
  103. // BackupRecord 备份记录
  104. type BackupRecord struct {
  105. ID string `json:"id"`
  106. Status string `json:"status"` // pending, running, completed, failed
  107. BackupType string `json:"backup_type"` // postgres
  108. FileName string `json:"file_name"`
  109. S3Key string `json:"s3_key"`
  110. Parts []BackupPart `json:"parts,omitempty"`
  111. SizeBytes int64 `json:"size_bytes"`
  112. TriggeredBy string `json:"triggered_by"` // manual, scheduled
  113. ErrorMsg string `json:"error_message,omitempty"`
  114. StartedAt string `json:"started_at"`
  115. FinishedAt string `json:"finished_at,omitempty"`
  116. ExpiresAt string `json:"expires_at,omitempty"` // 过期时间
  117. Progress string `json:"progress,omitempty"` // "dumping", "uploading", ""
  118. RestoreStatus string `json:"restore_status,omitempty"` // "", "running", "completed", "failed"
  119. RestoreError string `json:"restore_error,omitempty"`
  120. RestoredAt string `json:"restored_at,omitempty"`
  121. }
  122. // BackupDownloadPart 描述一个可下载的备份分卷。
  123. type BackupDownloadPart struct {
  124. Index int `json:"index"`
  125. SizeBytes int64 `json:"size_bytes"`
  126. URL string `json:"url"`
  127. }
  128. // BackupDownloadResponse 是单文件和分卷下载响应的兼容表示。
  129. type BackupDownloadResponse struct {
  130. URL string `json:"url,omitempty"`
  131. Parts []BackupDownloadPart `json:"parts,omitempty"`
  132. }
  133. // BackupService 数据库备份恢复服务
  134. type BackupService struct {
  135. settingRepo SettingRepository
  136. dbCfg *config.DatabaseConfig
  137. encryptor SecretEncryptor
  138. // encryptionKeyConfigured mirrors cfg.Totp.EncryptionKeyConfigured: false
  139. // means the secret encryption key was auto-generated and does not survive a
  140. // restart. Durable-secret writers must refuse to persist new secrets in that
  141. // mode (#4524).
  142. encryptionKeyConfigured bool
  143. storeFactory BackupObjectStoreFactory
  144. dumper DBDumper
  145. opMu sync.Mutex // 保护 backingUp/restoring 标志
  146. backingUp bool
  147. restoring bool
  148. storeMu sync.Mutex // 保护 store/s3Cfg 缓存
  149. store BackupObjectStore
  150. s3Cfg *BackupS3Config
  151. recordsMu sync.Mutex // 保护 records 的 load/save 操作
  152. cronMu sync.Mutex
  153. cronSched *cron.Cron
  154. cronEntryID cron.EntryID
  155. // lockCache/db elect a single leader for the scheduled backup across
  156. // instances; instanceID identifies this process as the lock owner. Injected
  157. // via SetLeaderLock — when both are nil the backup runs ungated
  158. // (single-instance / test behavior).
  159. lockCache LeaderLockCache
  160. db *sql.DB
  161. instanceID string
  162. wg sync.WaitGroup // 追踪活跃的备份/恢复 goroutine
  163. shuttingDown atomic.Bool // 阻止新备份启动
  164. bgCtx context.Context // 所有后台操作的 parent context
  165. bgCancel context.CancelFunc // 取消所有活跃后台操作
  166. partSizeBytes int64 // 分卷阈值;生产使用 4 GiB,测试可注入更小值
  167. }
  168. func NewBackupService(
  169. settingRepo SettingRepository,
  170. cfg *config.Config,
  171. encryptor SecretEncryptor,
  172. storeFactory BackupObjectStoreFactory,
  173. dumper DBDumper,
  174. ) *BackupService {
  175. bgCtx, bgCancel := context.WithCancel(context.Background())
  176. return &BackupService{
  177. settingRepo: settingRepo,
  178. dbCfg: &cfg.Database,
  179. encryptor: encryptor,
  180. encryptionKeyConfigured: cfg.Totp.EncryptionKeyConfigured,
  181. storeFactory: storeFactory,
  182. dumper: dumper,
  183. bgCtx: bgCtx,
  184. bgCancel: bgCancel,
  185. partSizeBytes: defaultBackupPartSizeBytes,
  186. instanceID: uuid.NewString(),
  187. }
  188. }
  189. // SetLeaderLock injects the leader-lock cache and DB used to elect a single
  190. // instance for the scheduled backup. When both are nil the scheduled backup runs
  191. // ungated (single-instance / test behavior).
  192. func (s *BackupService) SetLeaderLock(lockCache LeaderLockCache, db *sql.DB) {
  193. if s == nil {
  194. return
  195. }
  196. s.lockCache = lockCache
  197. s.db = db
  198. }
  199. // Start 启动定时备份调度器并清理孤立记录
  200. func (s *BackupService) Start() {
  201. s.cronSched = cron.New()
  202. s.cronSched.Start()
  203. // 清理重启后孤立的 running 记录
  204. s.recoverStaleRecords()
  205. // 加载已有的定时配置
  206. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  207. defer cancel()
  208. schedule, err := s.GetSchedule(ctx)
  209. if err != nil {
  210. logger.LegacyPrintf("service.backup", "[Backup] 加载定时备份配置失败: %v", err)
  211. return
  212. }
  213. if schedule.Enabled && schedule.CronExpr != "" {
  214. if err := s.applyCronSchedule(schedule); err != nil {
  215. logger.LegacyPrintf("service.backup", "[Backup] 应用定时备份配置失败: %v", err)
  216. }
  217. }
  218. }
  219. // recoverStaleRecords 启动时将孤立的 running 记录标记为 failed,并清理已上传对象。
  220. func (s *BackupService) recoverStaleRecords() {
  221. loadCtx, loadCancel := context.WithTimeout(context.Background(), 10*time.Second)
  222. defer loadCancel()
  223. records, err := s.loadRecords(loadCtx)
  224. if err != nil {
  225. return
  226. }
  227. for i := range records {
  228. if records[i].Status == "running" {
  229. staleRecord := records[i]
  230. records[i].Status = "failed"
  231. records[i].ErrorMsg = "interrupted by server restart"
  232. records[i].Progress = ""
  233. records[i].FinishedAt = time.Now().Format(time.RFC3339)
  234. s.saveRecoveredRecord(&records[i])
  235. if cleanupErr := s.cleanupStaleBackupObjects(&staleRecord); cleanupErr != nil {
  236. records[i].ErrorMsg = fmt.Sprintf("interrupted by server restart; cleanup failed, manual deletion may be required: %v", cleanupErr)
  237. s.saveRecoveredRecord(&records[i])
  238. logger.LegacyPrintf("service.backup", "[Backup] failed to clean stale backup objects for %s: %v", records[i].ID, cleanupErr)
  239. }
  240. logger.LegacyPrintf("service.backup", "[Backup] recovered stale running record: %s", records[i].ID)
  241. }
  242. if records[i].RestoreStatus == "running" {
  243. records[i].RestoreStatus = "failed"
  244. records[i].RestoreError = "interrupted by server restart"
  245. s.saveRecoveredRecord(&records[i])
  246. logger.LegacyPrintf("service.backup", "[Backup] recovered stale restoring record: %s", records[i].ID)
  247. }
  248. }
  249. }
  250. func (s *BackupService) saveRecoveredRecord(record *BackupRecord) {
  251. ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
  252. defer cancel()
  253. if err := s.saveRecord(ctx, record); err != nil {
  254. logger.LegacyPrintf("service.backup", "[Backup] 保存恢复后的备份记录失败 %s: %v", record.ID, err)
  255. }
  256. }
  257. func (s *BackupService) cleanupStaleBackupObjects(record *BackupRecord) error {
  258. if len(backupObjectKeys(record)) == 0 {
  259. return nil
  260. }
  261. ctx, cancel := context.WithTimeout(context.Background(), backupObjectCleanupTimeout)
  262. defer cancel()
  263. return s.deleteBackupObjects(ctx, record)
  264. }
  265. // Stop 停止定时备份并等待活跃操作完成
  266. func (s *BackupService) Stop() {
  267. s.shuttingDown.Store(true)
  268. s.cronMu.Lock()
  269. if s.cronSched != nil {
  270. s.cronSched.Stop()
  271. }
  272. s.cronMu.Unlock()
  273. // 等待活跃备份/恢复完成(最多 5 分钟)
  274. done := make(chan struct{})
  275. go func() {
  276. s.wg.Wait()
  277. close(done)
  278. }()
  279. select {
  280. case <-done:
  281. logger.LegacyPrintf("service.backup", "[Backup] all active operations finished")
  282. case <-time.After(5 * time.Minute):
  283. logger.LegacyPrintf("service.backup", "[Backup] shutdown timeout after 5min, cancelling active operations")
  284. if s.bgCancel != nil {
  285. s.bgCancel() // 取消所有后台操作
  286. }
  287. // 给 goroutine 时间响应取消并完成清理
  288. select {
  289. case <-done:
  290. logger.LegacyPrintf("service.backup", "[Backup] active operations cancelled and cleaned up")
  291. case <-time.After(10 * time.Second):
  292. logger.LegacyPrintf("service.backup", "[Backup] goroutine cleanup timed out")
  293. }
  294. }
  295. }
  296. // ─── S3 配置管理 ───
  297. // EncryptionKeyConfigured reports whether a fixed (explicitly configured) secret
  298. // encryption key is in use. When false the key is auto-generated on every start
  299. // and secrets encrypted with it cannot be recovered after a restart, so callers
  300. // that persist durable secrets must refuse to do so (#4524).
  301. func (s *BackupService) EncryptionKeyConfigured() bool {
  302. return s != nil && s.encryptionKeyConfigured
  303. }
  304. func (s *BackupService) GetS3Config(ctx context.Context) (*BackupS3Config, error) {
  305. cfg, err := s.loadS3Config(ctx)
  306. if err != nil {
  307. return nil, err
  308. }
  309. if cfg == nil {
  310. return &BackupS3Config{}, nil
  311. }
  312. // 脱敏返回
  313. cfg.SecretAccessKey = ""
  314. return cfg, nil
  315. }
  316. func (s *BackupService) UpdateS3Config(ctx context.Context, cfg BackupS3Config) (*BackupS3Config, error) {
  317. // 如果没提供 secret,保留原有值
  318. if cfg.SecretAccessKey == "" {
  319. old, _ := s.loadS3Config(ctx)
  320. if old != nil {
  321. cfg.SecretAccessKey = old.SecretAccessKey
  322. }
  323. } else {
  324. // 拒绝用自动生成的临时密钥加密:该密钥每次重启都会变化,落库的密文在
  325. // 重启/升级后无法解密(#4524)。与支付、TOTP 的处理保持一致。
  326. if !s.encryptionKeyConfigured {
  327. return nil, ErrSecretEncryptionKeyNotConfigured
  328. }
  329. // 加密 SecretAccessKey
  330. encrypted, err := s.encryptor.Encrypt(cfg.SecretAccessKey)
  331. if err != nil {
  332. return nil, fmt.Errorf("encrypt secret: %w", err)
  333. }
  334. cfg.SecretAccessKey = encrypted
  335. }
  336. data, err := json.Marshal(cfg)
  337. if err != nil {
  338. return nil, fmt.Errorf("marshal s3 config: %w", err)
  339. }
  340. if err := s.settingRepo.Set(ctx, settingKeyBackupS3Config, string(data)); err != nil {
  341. return nil, fmt.Errorf("save s3 config: %w", err)
  342. }
  343. // 清除缓存的 S3 客户端
  344. s.storeMu.Lock()
  345. s.store = nil
  346. s.s3Cfg = nil
  347. s.storeMu.Unlock()
  348. cfg.SecretAccessKey = ""
  349. return &cfg, nil
  350. }
  351. func (s *BackupService) TestS3Connection(ctx context.Context, cfg BackupS3Config) error {
  352. // 如果没提供 secret,用已保存的
  353. if cfg.SecretAccessKey == "" {
  354. old, _ := s.loadS3Config(ctx)
  355. if old != nil {
  356. cfg.SecretAccessKey = old.SecretAccessKey
  357. }
  358. }
  359. if cfg.Bucket == "" || cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" {
  360. return fmt.Errorf("incomplete S3 config: bucket, access_key_id, secret_access_key are required")
  361. }
  362. store, err := s.storeFactory(ctx, &cfg)
  363. if err != nil {
  364. return err
  365. }
  366. return store.HeadBucket(ctx)
  367. }
  368. // ─── 定时备份管理 ───
  369. func (s *BackupService) GetSchedule(ctx context.Context) (*BackupScheduleConfig, error) {
  370. raw, err := s.settingRepo.GetValue(ctx, settingKeyBackupSchedule)
  371. if err != nil || raw == "" {
  372. return &BackupScheduleConfig{}, nil
  373. }
  374. var cfg BackupScheduleConfig
  375. if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
  376. return &BackupScheduleConfig{}, nil
  377. }
  378. return &cfg, nil
  379. }
  380. func (s *BackupService) UpdateSchedule(ctx context.Context, cfg BackupScheduleConfig) (*BackupScheduleConfig, error) {
  381. if cfg.Enabled && cfg.CronExpr == "" {
  382. return nil, infraerrors.BadRequest("INVALID_CRON", "cron expression is required when schedule is enabled")
  383. }
  384. // 验证 cron 表达式
  385. if cfg.CronExpr != "" {
  386. parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow)
  387. if _, err := parser.Parse(cfg.CronExpr); err != nil {
  388. return nil, infraerrors.BadRequest("INVALID_CRON", fmt.Sprintf("invalid cron expression: %v", err))
  389. }
  390. }
  391. data, err := json.Marshal(cfg)
  392. if err != nil {
  393. return nil, fmt.Errorf("marshal schedule config: %w", err)
  394. }
  395. if err := s.settingRepo.Set(ctx, settingKeyBackupSchedule, string(data)); err != nil {
  396. return nil, fmt.Errorf("save schedule config: %w", err)
  397. }
  398. // 应用或停止定时任务
  399. if cfg.Enabled {
  400. if err := s.applyCronSchedule(&cfg); err != nil {
  401. return nil, err
  402. }
  403. } else {
  404. s.removeCronSchedule()
  405. }
  406. return &cfg, nil
  407. }
  408. func (s *BackupService) applyCronSchedule(cfg *BackupScheduleConfig) error {
  409. s.cronMu.Lock()
  410. defer s.cronMu.Unlock()
  411. if s.cronSched == nil {
  412. return fmt.Errorf("cron scheduler not initialized")
  413. }
  414. // 移除旧任务
  415. if s.cronEntryID != 0 {
  416. s.cronSched.Remove(s.cronEntryID)
  417. s.cronEntryID = 0
  418. }
  419. entryID, err := s.cronSched.AddFunc(cfg.CronExpr, func() {
  420. s.runScheduledBackup()
  421. })
  422. if err != nil {
  423. return infraerrors.BadRequest("INVALID_CRON", fmt.Sprintf("failed to schedule: %v", err))
  424. }
  425. s.cronEntryID = entryID
  426. logger.LegacyPrintf("service.backup", "[Backup] 定时备份已启用: %s", cfg.CronExpr)
  427. return nil
  428. }
  429. func (s *BackupService) removeCronSchedule() {
  430. s.cronMu.Lock()
  431. defer s.cronMu.Unlock()
  432. if s.cronSched != nil && s.cronEntryID != 0 {
  433. s.cronSched.Remove(s.cronEntryID)
  434. s.cronEntryID = 0
  435. logger.LegacyPrintf("service.backup", "[Backup] 定时备份已停用")
  436. }
  437. }
  438. func (s *BackupService) runScheduledBackup() {
  439. s.wg.Add(1)
  440. defer s.wg.Done()
  441. ctx, cancel := context.WithTimeout(s.bgCtx, 30*time.Minute)
  442. defer cancel()
  443. // 多实例保护: 集群部署时只让 leader 执行定时备份, 避免每个实例各自对同一个
  444. // 数据库跑一次全量 dump、上传时峰值内存翻倍、以及多份同名对象互相覆盖。
  445. // 手动触发的备份 (CreateBackup/StartBackup) 不受此限, 运维仍可随时在任一节点强制备份。
  446. release, ok := tryAcquireSingletonLeaderLock(ctx, s.lockCache, s.db, backupScheduledLeaderLockKey, s.instanceID, backupScheduledLeaderLockTTL)
  447. if !ok {
  448. logger.LegacyPrintf("service.backup", "[Backup] 定时备份跳过: 本实例非 leader")
  449. return
  450. }
  451. defer release()
  452. // 读取定时备份配置中的过期天数
  453. schedule, _ := s.GetSchedule(ctx)
  454. expireDays := 14 // 默认14天过期
  455. if schedule != nil && schedule.RetainDays > 0 {
  456. expireDays = schedule.RetainDays
  457. }
  458. logger.LegacyPrintf("service.backup", "[Backup] 开始执行定时备份, 过期天数: %d", expireDays)
  459. record, err := s.CreateBackup(ctx, "scheduled", expireDays)
  460. if err != nil {
  461. if errors.Is(err, ErrBackupInProgress) {
  462. logger.LegacyPrintf("service.backup", "[Backup] 定时备份跳过: 已有备份正在进行中")
  463. } else {
  464. logger.LegacyPrintf("service.backup", "[Backup] 定时备份失败: %v", err)
  465. }
  466. return
  467. }
  468. logger.LegacyPrintf("service.backup", "[Backup] 定时备份完成: id=%s size=%d", record.ID, record.SizeBytes)
  469. // 清理过期备份(复用已加载的 schedule)
  470. if schedule == nil {
  471. return
  472. }
  473. if err := s.cleanupOldBackups(ctx, schedule); err != nil {
  474. logger.LegacyPrintf("service.backup", "[Backup] 清理过期备份失败: %v", err)
  475. }
  476. }
  477. // ─── 备份/恢复核心 ───
  478. // CreateBackup 创建全量数据库备份并上传到 S3。
  479. // expireDays: 备份过期天数,0=永不过期,默认14天
  480. func (s *BackupService) CreateBackup(ctx context.Context, triggeredBy string, expireDays int) (*BackupRecord, error) {
  481. if s.shuttingDown.Load() {
  482. return nil, infraerrors.ServiceUnavailable("SERVER_SHUTTING_DOWN", "server is shutting down")
  483. }
  484. s.opMu.Lock()
  485. if s.backingUp {
  486. s.opMu.Unlock()
  487. return nil, ErrBackupInProgress
  488. }
  489. s.backingUp = true
  490. s.opMu.Unlock()
  491. defer func() {
  492. s.opMu.Lock()
  493. s.backingUp = false
  494. s.opMu.Unlock()
  495. }()
  496. s3Cfg, err := s.loadS3Config(ctx)
  497. if err != nil {
  498. return nil, err
  499. }
  500. if s3Cfg == nil || !s3Cfg.IsConfigured() {
  501. return nil, ErrBackupS3NotConfigured
  502. }
  503. objectStore, err := s.getOrCreateStore(ctx, s3Cfg)
  504. if err != nil {
  505. return nil, fmt.Errorf("init object store: %w", err)
  506. }
  507. now := time.Now()
  508. backupID := uuid.New().String()[:8]
  509. fileName := fmt.Sprintf("%s_%s.sql.gz", s.dbCfg.DBName, now.Format("20060102_150405"))
  510. s3Key := s.buildS3Key(s3Cfg, fileName)
  511. var expiresAt string
  512. if expireDays > 0 {
  513. expiresAt = now.AddDate(0, 0, expireDays).Format(time.RFC3339)
  514. }
  515. record := &BackupRecord{
  516. ID: backupID,
  517. Status: "running",
  518. BackupType: "postgres",
  519. FileName: fileName,
  520. S3Key: s3Key,
  521. TriggeredBy: triggeredBy,
  522. StartedAt: now.Format(time.RFC3339),
  523. ExpiresAt: expiresAt,
  524. }
  525. archivePath, sizeBytes, err := s.createCompressedBackupFile(ctx)
  526. if err != nil {
  527. record.Status = "failed"
  528. record.ErrorMsg = err.Error()
  529. record.FinishedAt = time.Now().Format(time.RFC3339)
  530. _ = s.saveRecord(ctx, record)
  531. return record, err
  532. }
  533. defer func() { _ = cleanupBackupFiles(archivePath) }()
  534. record.SizeBytes = sizeBytes
  535. if err := s.saveRecord(ctx, record); err != nil {
  536. return nil, fmt.Errorf("save initial record: %w", err)
  537. }
  538. if err := s.uploadBackupArchive(ctx, record, objectStore, s3Cfg, archivePath); err != nil {
  539. record.Status = "failed"
  540. record.ErrorMsg = err.Error()
  541. record.FinishedAt = time.Now().Format(time.RFC3339)
  542. _ = s.saveRecord(ctx, record)
  543. return record, err
  544. }
  545. record.Status = "completed"
  546. record.FinishedAt = time.Now().Format(time.RFC3339)
  547. if err := s.saveRecord(ctx, record); err != nil {
  548. logger.LegacyPrintf("service.backup", "[Backup] 保存备份记录失败: %v", err)
  549. }
  550. return record, nil
  551. }
  552. // StartBackup 异步创建备份,立即返回 running 状态的记录
  553. func (s *BackupService) StartBackup(ctx context.Context, triggeredBy string, expireDays int) (*BackupRecord, error) {
  554. if s.shuttingDown.Load() {
  555. return nil, infraerrors.ServiceUnavailable("SERVER_SHUTTING_DOWN", "server is shutting down")
  556. }
  557. s.opMu.Lock()
  558. if s.backingUp {
  559. s.opMu.Unlock()
  560. return nil, ErrBackupInProgress
  561. }
  562. s.backingUp = true
  563. s.opMu.Unlock()
  564. // 初始化阶段出错时自动重置标志
  565. launched := false
  566. defer func() {
  567. if !launched {
  568. s.opMu.Lock()
  569. s.backingUp = false
  570. s.opMu.Unlock()
  571. }
  572. }()
  573. // 在返回前加载 S3 配置和创建 store,避免 goroutine 中配置被修改
  574. s3Cfg, err := s.loadS3Config(ctx)
  575. if err != nil {
  576. return nil, err
  577. }
  578. if s3Cfg == nil || !s3Cfg.IsConfigured() {
  579. return nil, ErrBackupS3NotConfigured
  580. }
  581. objectStore, err := s.getOrCreateStore(ctx, s3Cfg)
  582. if err != nil {
  583. return nil, fmt.Errorf("init object store: %w", err)
  584. }
  585. now := time.Now()
  586. backupID := uuid.New().String()[:8]
  587. fileName := fmt.Sprintf("%s_%s.sql.gz", s.dbCfg.DBName, now.Format("20060102_150405"))
  588. s3Key := s.buildS3Key(s3Cfg, fileName)
  589. var expiresAt string
  590. if expireDays > 0 {
  591. expiresAt = now.AddDate(0, 0, expireDays).Format(time.RFC3339)
  592. }
  593. record := &BackupRecord{
  594. ID: backupID,
  595. Status: "running",
  596. BackupType: "postgres",
  597. FileName: fileName,
  598. S3Key: s3Key,
  599. TriggeredBy: triggeredBy,
  600. StartedAt: now.Format(time.RFC3339),
  601. ExpiresAt: expiresAt,
  602. Progress: "pending",
  603. }
  604. if err := s.saveRecord(ctx, record); err != nil {
  605. return nil, fmt.Errorf("save initial record: %w", err)
  606. }
  607. launched = true
  608. // 在启动 goroutine 前完成拷贝,避免数据竞争
  609. result := *record
  610. s.wg.Add(1)
  611. go func() {
  612. defer s.wg.Done()
  613. defer func() {
  614. s.opMu.Lock()
  615. s.backingUp = false
  616. s.opMu.Unlock()
  617. }()
  618. defer func() {
  619. if r := recover(); r != nil {
  620. logger.LegacyPrintf("service.backup", "[Backup] panic recovered: %v", r)
  621. record.Status = "failed"
  622. record.ErrorMsg = fmt.Sprintf("internal panic: %v", r)
  623. record.Progress = ""
  624. record.FinishedAt = time.Now().Format(time.RFC3339)
  625. _ = s.saveRecord(context.Background(), record)
  626. }
  627. }()
  628. s.executeBackup(record, objectStore, s3Cfg)
  629. }()
  630. return &result, nil
  631. }
  632. // executeBackup 后台执行备份(独立于 HTTP context)
  633. func (s *BackupService) executeBackup(record *BackupRecord, objectStore BackupObjectStore, s3Cfg *BackupS3Config) {
  634. ctx, cancel := context.WithTimeout(s.bgCtx, 30*time.Minute)
  635. defer cancel()
  636. // 阶段1: pg_dump -> gzip 临时文件
  637. record.Progress = "dumping"
  638. _ = s.saveRecord(ctx, record)
  639. archivePath, sizeBytes, err := s.createCompressedBackupFile(ctx)
  640. if err != nil {
  641. record.Status = "failed"
  642. record.ErrorMsg = err.Error()
  643. record.Progress = ""
  644. record.FinishedAt = time.Now().Format(time.RFC3339)
  645. _ = s.saveRecord(context.Background(), record)
  646. return
  647. }
  648. defer func() { _ = cleanupBackupFiles(archivePath) }()
  649. record.SizeBytes = sizeBytes
  650. // 阶段2: 单对象或分卷上传
  651. record.Progress = "uploading"
  652. _ = s.saveRecord(ctx, record)
  653. if err := s.uploadBackupArchive(ctx, record, objectStore, s3Cfg, archivePath); err != nil {
  654. record.Status = "failed"
  655. record.ErrorMsg = err.Error()
  656. record.Progress = ""
  657. record.FinishedAt = time.Now().Format(time.RFC3339)
  658. _ = s.saveRecord(context.Background(), record)
  659. return
  660. }
  661. record.SizeBytes = sizeBytes
  662. record.Status = "completed"
  663. record.Progress = ""
  664. record.FinishedAt = time.Now().Format(time.RFC3339)
  665. if err := s.saveRecord(context.Background(), record); err != nil {
  666. logger.LegacyPrintf("service.backup", "[Backup] 保存备份记录失败: %v", err)
  667. }
  668. }
  669. func (s *BackupService) createCompressedBackupFile(ctx context.Context) (string, int64, error) {
  670. dumpReader, err := s.dumper.Dump(ctx)
  671. if err != nil {
  672. return "", 0, fmt.Errorf("pg_dump: %w", err)
  673. }
  674. archive, err := os.CreateTemp("", "sub2api-backup-*.sql.gz")
  675. if err != nil {
  676. _ = dumpReader.Close()
  677. return "", 0, fmt.Errorf("create backup archive: %w", err)
  678. }
  679. archivePath := archive.Name()
  680. gzWriter := gzip.NewWriter(archive)
  681. _, copyErr := io.Copy(gzWriter, dumpReader)
  682. if closeErr := gzWriter.Close(); copyErr == nil && closeErr != nil {
  683. copyErr = closeErr
  684. }
  685. if closeErr := dumpReader.Close(); copyErr == nil && closeErr != nil {
  686. copyErr = closeErr
  687. }
  688. if closeErr := archive.Close(); copyErr == nil && closeErr != nil {
  689. copyErr = closeErr
  690. }
  691. if copyErr != nil {
  692. _ = cleanupBackupFiles(archivePath)
  693. return "", 0, fmt.Errorf("gzip/dump failed: %w", copyErr)
  694. }
  695. info, err := os.Stat(archivePath)
  696. if err != nil {
  697. _ = cleanupBackupFiles(archivePath)
  698. return "", 0, fmt.Errorf("stat backup archive: %w", err)
  699. }
  700. return archivePath, info.Size(), nil
  701. }
  702. func (s *BackupService) uploadBackupArchive(ctx context.Context, record *BackupRecord, objectStore BackupObjectStore, cfg *BackupS3Config, archivePath string) error {
  703. info, err := os.Stat(archivePath)
  704. if err != nil {
  705. return fmt.Errorf("stat backup archive: %w", err)
  706. }
  707. partSize := s.partSizeBytes
  708. if partSize <= 0 {
  709. partSize = defaultBackupPartSizeBytes
  710. }
  711. if info.Size() <= partSize {
  712. if _, err := objectStore.UploadFile(ctx, record.S3Key, archivePath, "application/gzip"); err != nil {
  713. cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), backupObjectCleanupTimeout)
  714. cleanupErr := deleteBackupObjectKeys(cleanupCtx, objectStore, record)
  715. cleanupCancel()
  716. return errors.Join(fmt.Errorf("backup upload: %w", err), cleanupErr)
  717. }
  718. record.Parts = nil
  719. return nil
  720. }
  721. localParts, err := splitBackupFile(archivePath, partSize)
  722. if err != nil {
  723. return fmt.Errorf("split backup archive: %w", err)
  724. }
  725. defer func() {
  726. paths := make([]string, 0, len(localParts))
  727. for _, part := range localParts {
  728. paths = append(paths, part.Path)
  729. }
  730. _ = cleanupBackupFiles(paths...)
  731. }()
  732. if cfg == nil {
  733. return errors.New("backup S3 config is unavailable for split upload")
  734. }
  735. record.S3Key = ""
  736. record.Parts = make([]BackupPart, 0, len(localParts))
  737. partRoot := strings.TrimRight(s.buildS3Key(cfg, record.ID), "/")
  738. for _, part := range localParts {
  739. record.Parts = append(record.Parts, BackupPart{
  740. Index: part.Index,
  741. S3Key: s.buildBackupPartKey(partRoot, part.Index),
  742. SizeBytes: part.SizeBytes,
  743. SHA256: part.SHA256,
  744. })
  745. }
  746. if err := s.saveRecord(ctx, record); err != nil {
  747. return fmt.Errorf("save split backup plan: %w", err)
  748. }
  749. for i, part := range localParts {
  750. if _, err := objectStore.UploadFile(ctx, record.Parts[i].S3Key, part.Path, "application/octet-stream"); err != nil {
  751. // PUT 可能已经在对象存储端成功、但客户端因超时收到错误;
  752. // 因此失败时清理整份分卷计划,而不只清理此前返回成功的卷。
  753. cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), backupObjectCleanupTimeout)
  754. cleanupErr := deleteBackupObjectKeys(cleanupCtx, objectStore, record)
  755. cleanupCancel()
  756. return errors.Join(fmt.Errorf("upload backup part %d: %w", part.Index, err), cleanupErr)
  757. }
  758. }
  759. return nil
  760. }
  761. func (s *BackupService) buildBackupPartKey(root string, index int) string {
  762. return fmt.Sprintf("%s/payload.part-%06d", strings.TrimRight(root, "/"), index)
  763. }
  764. // RestoreBackup 从 S3 下载备份并流式恢复到数据库
  765. func (s *BackupService) RestoreBackup(ctx context.Context, backupID string) error {
  766. s.opMu.Lock()
  767. if s.restoring {
  768. s.opMu.Unlock()
  769. return ErrRestoreInProgress
  770. }
  771. s.restoring = true
  772. s.opMu.Unlock()
  773. defer func() {
  774. s.opMu.Lock()
  775. s.restoring = false
  776. s.opMu.Unlock()
  777. }()
  778. record, err := s.GetBackupRecord(ctx, backupID)
  779. if err != nil {
  780. return err
  781. }
  782. if record.Status != "completed" {
  783. return infraerrors.BadRequest("BACKUP_NOT_COMPLETED", "can only restore from a completed backup")
  784. }
  785. s3Cfg, err := s.loadS3Config(ctx)
  786. if err != nil {
  787. return err
  788. }
  789. objectStore, err := s.getOrCreateStore(ctx, s3Cfg)
  790. if err != nil {
  791. return fmt.Errorf("init object store: %w", err)
  792. }
  793. if len(record.Parts) > 0 {
  794. archivePath, err := s.downloadBackupParts(ctx, objectStore, record.Parts)
  795. if err != nil {
  796. return err
  797. }
  798. defer func() { _ = cleanupBackupFiles(archivePath) }()
  799. return s.restoreArchive(ctx, archivePath)
  800. }
  801. // 旧记录从 S3 流式下载
  802. body, err := objectStore.Download(ctx, record.S3Key)
  803. if err != nil {
  804. return fmt.Errorf("S3 download failed: %w", err)
  805. }
  806. defer func() { _ = body.Close() }()
  807. // 流式解压 gzip -> psql(不将全部数据加载到内存)
  808. gzReader, err := gzip.NewReader(body)
  809. if err != nil {
  810. return fmt.Errorf("gzip reader: %w", err)
  811. }
  812. defer func() { _ = gzReader.Close() }()
  813. // 流式恢复
  814. if err := s.dumper.Restore(ctx, gzReader); err != nil {
  815. return fmt.Errorf("pg restore: %w", err)
  816. }
  817. return nil
  818. }
  819. // StartRestore 异步恢复备份,立即返回
  820. func (s *BackupService) StartRestore(ctx context.Context, backupID string) (*BackupRecord, error) {
  821. if s.shuttingDown.Load() {
  822. return nil, infraerrors.ServiceUnavailable("SERVER_SHUTTING_DOWN", "server is shutting down")
  823. }
  824. s.opMu.Lock()
  825. if s.restoring {
  826. s.opMu.Unlock()
  827. return nil, ErrRestoreInProgress
  828. }
  829. s.restoring = true
  830. s.opMu.Unlock()
  831. // 初始化阶段出错时自动重置标志
  832. launched := false
  833. defer func() {
  834. if !launched {
  835. s.opMu.Lock()
  836. s.restoring = false
  837. s.opMu.Unlock()
  838. }
  839. }()
  840. record, err := s.GetBackupRecord(ctx, backupID)
  841. if err != nil {
  842. return nil, err
  843. }
  844. if record.Status != "completed" {
  845. return nil, infraerrors.BadRequest("BACKUP_NOT_COMPLETED", "can only restore from a completed backup")
  846. }
  847. s3Cfg, err := s.loadS3Config(ctx)
  848. if err != nil {
  849. return nil, err
  850. }
  851. objectStore, err := s.getOrCreateStore(ctx, s3Cfg)
  852. if err != nil {
  853. return nil, fmt.Errorf("init object store: %w", err)
  854. }
  855. record.RestoreStatus = "running"
  856. _ = s.saveRecord(ctx, record)
  857. launched = true
  858. result := *record
  859. s.wg.Add(1)
  860. go func() {
  861. defer s.wg.Done()
  862. defer func() {
  863. s.opMu.Lock()
  864. s.restoring = false
  865. s.opMu.Unlock()
  866. }()
  867. defer func() {
  868. if r := recover(); r != nil {
  869. logger.LegacyPrintf("service.backup", "[Backup] restore panic recovered: %v", r)
  870. record.RestoreStatus = "failed"
  871. record.RestoreError = fmt.Sprintf("internal panic: %v", r)
  872. _ = s.saveRecord(context.Background(), record)
  873. }
  874. }()
  875. s.executeRestore(record, objectStore)
  876. }()
  877. return &result, nil
  878. }
  879. // executeRestore 后台执行恢复
  880. func (s *BackupService) executeRestore(record *BackupRecord, objectStore BackupObjectStore) {
  881. ctx, cancel := context.WithTimeout(s.bgCtx, 30*time.Minute)
  882. defer cancel()
  883. if len(record.Parts) > 0 {
  884. archivePath, err := s.downloadBackupParts(ctx, objectStore, record.Parts)
  885. if err != nil {
  886. record.RestoreStatus = "failed"
  887. record.RestoreError = err.Error()
  888. _ = s.saveRecord(context.Background(), record)
  889. return
  890. }
  891. defer func() { _ = cleanupBackupFiles(archivePath) }()
  892. if err := s.restoreArchive(ctx, archivePath); err != nil {
  893. record.RestoreStatus = "failed"
  894. record.RestoreError = fmt.Sprintf("pg restore: %v", err)
  895. _ = s.saveRecord(context.Background(), record)
  896. return
  897. }
  898. record.RestoreStatus = "completed"
  899. record.RestoredAt = time.Now().Format(time.RFC3339)
  900. if err := s.saveRecord(context.Background(), record); err != nil {
  901. logger.LegacyPrintf("service.backup", "[Backup] 保存恢复记录失败: %v", err)
  902. }
  903. return
  904. }
  905. body, err := objectStore.Download(ctx, record.S3Key)
  906. if err != nil {
  907. record.RestoreStatus = "failed"
  908. record.RestoreError = fmt.Sprintf("S3 download failed: %v", err)
  909. _ = s.saveRecord(context.Background(), record)
  910. return
  911. }
  912. defer func() { _ = body.Close() }()
  913. gzReader, err := gzip.NewReader(body)
  914. if err != nil {
  915. record.RestoreStatus = "failed"
  916. record.RestoreError = fmt.Sprintf("gzip reader: %v", err)
  917. _ = s.saveRecord(context.Background(), record)
  918. return
  919. }
  920. defer func() { _ = gzReader.Close() }()
  921. if err := s.dumper.Restore(ctx, gzReader); err != nil {
  922. record.RestoreStatus = "failed"
  923. record.RestoreError = fmt.Sprintf("pg restore: %v", err)
  924. _ = s.saveRecord(context.Background(), record)
  925. return
  926. }
  927. record.RestoreStatus = "completed"
  928. record.RestoredAt = time.Now().Format(time.RFC3339)
  929. if err := s.saveRecord(context.Background(), record); err != nil {
  930. logger.LegacyPrintf("service.backup", "[Backup] 保存恢复记录失败: %v", err)
  931. }
  932. }
  933. func (s *BackupService) downloadBackupParts(ctx context.Context, objectStore BackupObjectStore, parts []BackupPart) (path string, err error) {
  934. if len(parts) == 0 {
  935. return "", errors.New("backup parts are empty")
  936. }
  937. ordered := append([]BackupPart(nil), parts...)
  938. sort.Slice(ordered, func(i, j int) bool { return ordered[i].Index < ordered[j].Index })
  939. for i, part := range ordered {
  940. if part.Index != i+1 || part.S3Key == "" || part.SizeBytes <= 0 {
  941. return "", fmt.Errorf("invalid backup part metadata at index %d", i+1)
  942. }
  943. }
  944. archive, err := os.CreateTemp("", "sub2api-restore-*.sql.gz")
  945. if err != nil {
  946. return "", fmt.Errorf("create restore archive: %w", err)
  947. }
  948. path = archive.Name()
  949. cleanup := func() {
  950. _ = archive.Close()
  951. _ = cleanupBackupFiles(path)
  952. }
  953. for _, part := range ordered {
  954. body, downloadErr := objectStore.Download(ctx, part.S3Key)
  955. if downloadErr != nil {
  956. cleanup()
  957. return "", fmt.Errorf("download backup part %d: %w", part.Index, downloadErr)
  958. }
  959. hash := sha256.New()
  960. written, copyErr := io.Copy(io.MultiWriter(archive, hash), body)
  961. closeErr := body.Close()
  962. if copyErr != nil {
  963. cleanup()
  964. return "", fmt.Errorf("read backup part %d: %w", part.Index, copyErr)
  965. }
  966. if closeErr != nil {
  967. cleanup()
  968. return "", fmt.Errorf("close backup part %d: %w", part.Index, closeErr)
  969. }
  970. if written != part.SizeBytes {
  971. cleanup()
  972. return "", fmt.Errorf("backup part %d size mismatch: got %d, want %d", part.Index, written, part.SizeBytes)
  973. }
  974. if part.SHA256 != "" && !strings.EqualFold(part.SHA256, hex.EncodeToString(hash.Sum(nil))) {
  975. cleanup()
  976. return "", fmt.Errorf("backup part %d checksum mismatch", part.Index)
  977. }
  978. }
  979. if err := archive.Close(); err != nil {
  980. _ = cleanupBackupFiles(path)
  981. return "", fmt.Errorf("close restore archive: %w", err)
  982. }
  983. return path, nil
  984. }
  985. func (s *BackupService) restoreArchive(ctx context.Context, archivePath string) error {
  986. archive, err := os.Open(archivePath)
  987. if err != nil {
  988. return fmt.Errorf("open restore archive: %w", err)
  989. }
  990. defer func() { _ = archive.Close() }()
  991. gzReader, err := gzip.NewReader(archive)
  992. if err != nil {
  993. return fmt.Errorf("gzip reader: %w", err)
  994. }
  995. defer func() { _ = gzReader.Close() }()
  996. if err := s.dumper.Restore(ctx, gzReader); err != nil {
  997. return fmt.Errorf("pg restore: %w", err)
  998. }
  999. return nil
  1000. }
  1001. // ─── 备份记录管理 ───
  1002. func (s *BackupService) ListBackups(ctx context.Context) ([]BackupRecord, error) {
  1003. records, err := s.loadRecords(ctx)
  1004. if err != nil {
  1005. return nil, err
  1006. }
  1007. // 倒序返回(最新在前)
  1008. sort.Slice(records, func(i, j int) bool {
  1009. return records[i].StartedAt > records[j].StartedAt
  1010. })
  1011. return records, nil
  1012. }
  1013. func (s *BackupService) GetBackupRecord(ctx context.Context, backupID string) (*BackupRecord, error) {
  1014. records, err := s.loadRecords(ctx)
  1015. if err != nil {
  1016. return nil, err
  1017. }
  1018. for i := range records {
  1019. if records[i].ID == backupID {
  1020. return &records[i], nil
  1021. }
  1022. }
  1023. return nil, ErrBackupNotFound
  1024. }
  1025. func (s *BackupService) DeleteBackup(ctx context.Context, backupID string) error {
  1026. s.recordsMu.Lock()
  1027. defer s.recordsMu.Unlock()
  1028. records, err := s.loadRecordsLocked(ctx)
  1029. if err != nil {
  1030. return err
  1031. }
  1032. var found *BackupRecord
  1033. var remaining []BackupRecord
  1034. for i := range records {
  1035. if records[i].ID == backupID {
  1036. found = &records[i]
  1037. } else {
  1038. remaining = append(remaining, records[i])
  1039. }
  1040. }
  1041. if found == nil {
  1042. return ErrBackupNotFound
  1043. }
  1044. if found.Status == "running" {
  1045. // 后台上传仍可能依赖 Parts 计划;删除对象会让随后完成的记录引用失效卷。
  1046. return ErrBackupInProgress
  1047. }
  1048. // 从对象存储删除所有单文件或分卷对象。删除不完整时保留记录,便于重试。
  1049. if err := s.deleteBackupObjects(ctx, found); err != nil {
  1050. return err
  1051. }
  1052. return s.saveRecordsLocked(ctx, remaining)
  1053. }
  1054. // GetBackupDownloadURL 获取备份文件预签名下载 URL
  1055. func (s *BackupService) GetBackupDownloadURL(ctx context.Context, backupID string) (BackupDownloadResponse, error) {
  1056. var download BackupDownloadResponse
  1057. record, err := s.GetBackupRecord(ctx, backupID)
  1058. if err != nil {
  1059. return download, err
  1060. }
  1061. if record.Status != "completed" {
  1062. return download, infraerrors.BadRequest("BACKUP_NOT_COMPLETED", "backup is not completed")
  1063. }
  1064. s3Cfg, err := s.loadS3Config(ctx)
  1065. if err != nil {
  1066. return download, err
  1067. }
  1068. objectStore, err := s.getOrCreateStore(ctx, s3Cfg)
  1069. if err != nil {
  1070. return download, err
  1071. }
  1072. if len(record.Parts) > 0 {
  1073. parts := append([]BackupPart(nil), record.Parts...)
  1074. sort.Slice(parts, func(i, j int) bool { return parts[i].Index < parts[j].Index })
  1075. for i, part := range parts {
  1076. if part.Index != i+1 || part.S3Key == "" || part.SizeBytes <= 0 {
  1077. return download, fmt.Errorf("invalid backup part metadata at index %d", i+1)
  1078. }
  1079. url, presignErr := objectStore.PresignURL(ctx, part.S3Key, 1*time.Hour)
  1080. if presignErr != nil {
  1081. return download, fmt.Errorf("presign backup part %d: %w", part.Index, presignErr)
  1082. }
  1083. download.Parts = append(download.Parts, BackupDownloadPart{
  1084. Index: part.Index,
  1085. SizeBytes: part.SizeBytes,
  1086. URL: url,
  1087. })
  1088. }
  1089. return download, nil
  1090. }
  1091. if record.S3Key == "" {
  1092. return download, errors.New("backup object key is empty")
  1093. }
  1094. url, err := objectStore.PresignURL(ctx, record.S3Key, 1*time.Hour)
  1095. if err != nil {
  1096. return download, fmt.Errorf("presign url: %w", err)
  1097. }
  1098. download.URL = url
  1099. return download, nil
  1100. }
  1101. // ─── 内部方法 ───
  1102. func (s *BackupService) loadS3Config(ctx context.Context) (*BackupS3Config, error) {
  1103. raw, err := s.settingRepo.GetValue(ctx, settingKeyBackupS3Config)
  1104. if err != nil || raw == "" {
  1105. return nil, nil //nolint:nilnil // no config is a valid state
  1106. }
  1107. var cfg BackupS3Config
  1108. if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
  1109. return nil, ErrBackupS3ConfigCorrupt
  1110. }
  1111. // 解密 SecretAccessKey
  1112. if cfg.SecretAccessKey != "" {
  1113. decrypted, err := s.encryptor.Decrypt(cfg.SecretAccessKey)
  1114. if err != nil {
  1115. // 兼容未加密的旧数据:如果解密失败,保持原值
  1116. logger.LegacyPrintf("service.backup", "[Backup] S3 SecretAccessKey 解密失败(可能是旧的未加密数据): %v", err)
  1117. } else {
  1118. cfg.SecretAccessKey = decrypted
  1119. }
  1120. }
  1121. return &cfg, nil
  1122. }
  1123. func (s *BackupService) getOrCreateStore(ctx context.Context, cfg *BackupS3Config) (BackupObjectStore, error) {
  1124. s.storeMu.Lock()
  1125. defer s.storeMu.Unlock()
  1126. if s.store != nil && s.s3Cfg != nil {
  1127. return s.store, nil
  1128. }
  1129. if cfg == nil {
  1130. return nil, ErrBackupS3NotConfigured
  1131. }
  1132. store, err := s.storeFactory(ctx, cfg)
  1133. if err != nil {
  1134. return nil, err
  1135. }
  1136. s.store = store
  1137. s.s3Cfg = cfg
  1138. return store, nil
  1139. }
  1140. func (s *BackupService) buildS3Key(cfg *BackupS3Config, fileName string) string {
  1141. prefix := strings.TrimRight(cfg.Prefix, "/")
  1142. if prefix == "" {
  1143. prefix = "backups"
  1144. }
  1145. return fmt.Sprintf("%s/%s/%s", prefix, time.Now().Format("2006/01/02"), fileName)
  1146. }
  1147. // loadRecords 加载备份记录,区分"无数据"和"数据损坏"
  1148. func (s *BackupService) loadRecords(ctx context.Context) ([]BackupRecord, error) {
  1149. s.recordsMu.Lock()
  1150. defer s.recordsMu.Unlock()
  1151. return s.loadRecordsLocked(ctx)
  1152. }
  1153. // loadRecordsLocked 在已持有 recordsMu 锁的情况下加载记录
  1154. func (s *BackupService) loadRecordsLocked(ctx context.Context) ([]BackupRecord, error) {
  1155. raw, err := s.settingRepo.GetValue(ctx, settingKeyBackupRecords)
  1156. if err != nil || raw == "" {
  1157. return nil, nil //nolint:nilnil // no records is a valid state
  1158. }
  1159. var records []BackupRecord
  1160. if err := json.Unmarshal([]byte(raw), &records); err != nil {
  1161. return nil, ErrBackupRecordsCorrupt
  1162. }
  1163. return records, nil
  1164. }
  1165. // saveRecordsLocked 在已持有 recordsMu 锁的情况下保存记录
  1166. func (s *BackupService) saveRecordsLocked(ctx context.Context, records []BackupRecord) error {
  1167. data, err := json.Marshal(records)
  1168. if err != nil {
  1169. return err
  1170. }
  1171. return s.settingRepo.Set(ctx, settingKeyBackupRecords, string(data))
  1172. }
  1173. // saveRecord 保存单条记录(带互斥锁保护)
  1174. func (s *BackupService) saveRecord(ctx context.Context, record *BackupRecord) error {
  1175. s.recordsMu.Lock()
  1176. defer s.recordsMu.Unlock()
  1177. records, _ := s.loadRecordsLocked(ctx)
  1178. // 更新已有记录或追加
  1179. found := false
  1180. for i := range records {
  1181. if records[i].ID == record.ID {
  1182. records[i] = *record
  1183. found = true
  1184. break
  1185. }
  1186. }
  1187. if !found {
  1188. records = append(records, *record)
  1189. }
  1190. // 限制记录数量
  1191. if len(records) > maxBackupRecords {
  1192. records = records[len(records)-maxBackupRecords:]
  1193. }
  1194. return s.saveRecordsLocked(ctx, records)
  1195. }
  1196. func (s *BackupService) cleanupOldBackups(ctx context.Context, schedule *BackupScheduleConfig) error {
  1197. if schedule == nil {
  1198. return nil
  1199. }
  1200. s.recordsMu.Lock()
  1201. defer s.recordsMu.Unlock()
  1202. records, err := s.loadRecordsLocked(ctx)
  1203. if err != nil {
  1204. return err
  1205. }
  1206. // 按时间倒序
  1207. sort.Slice(records, func(i, j int) bool {
  1208. return records[i].StartedAt > records[j].StartedAt
  1209. })
  1210. var toDelete []BackupRecord
  1211. var toKeep []BackupRecord
  1212. for i, r := range records {
  1213. shouldDelete := false
  1214. // 按保留份数清理
  1215. if schedule.RetainCount > 0 && i >= schedule.RetainCount {
  1216. shouldDelete = true
  1217. }
  1218. // 按保留天数清理
  1219. if schedule.RetainDays > 0 && r.StartedAt != "" {
  1220. startedAt, err := time.Parse(time.RFC3339, r.StartedAt)
  1221. if err == nil && time.Since(startedAt) > time.Duration(schedule.RetainDays)*24*time.Hour {
  1222. shouldDelete = true
  1223. }
  1224. }
  1225. if shouldDelete && r.Status == "completed" {
  1226. toDelete = append(toDelete, r)
  1227. } else {
  1228. toKeep = append(toKeep, r)
  1229. }
  1230. }
  1231. var cleanupErrs []error
  1232. deletedCount := 0
  1233. for _, r := range toDelete {
  1234. if err := s.deleteBackupObjects(ctx, &r); err != nil {
  1235. // 对象删除失败时保留记录,避免丢失后续重试所需的 key。
  1236. toKeep = append(toKeep, r)
  1237. cleanupErrs = append(cleanupErrs, fmt.Errorf("cleanup backup %s: %w", r.ID, err))
  1238. continue
  1239. }
  1240. deletedCount++
  1241. }
  1242. if len(toDelete) > 0 {
  1243. if err := s.saveRecordsLocked(ctx, toKeep); err != nil {
  1244. cleanupErrs = append(cleanupErrs, fmt.Errorf("save backup records after cleanup: %w", err))
  1245. }
  1246. if deletedCount > 0 {
  1247. logger.LegacyPrintf("service.backup", "[Backup] 自动清理了 %d 个过期备份", deletedCount)
  1248. }
  1249. return errors.Join(cleanupErrs...)
  1250. }
  1251. return nil
  1252. }
  1253. // backupObjectKeys 返回一条备份记录关联的全部对象 key。
  1254. // 新记录使用 Parts,旧记录使用 S3Key;两者同时存在时也全部返回,便于清理异常残留对象。
  1255. func backupObjectKeys(record *BackupRecord) []string {
  1256. if record == nil {
  1257. return nil
  1258. }
  1259. keys := make([]string, 0, len(record.Parts)+1)
  1260. seen := make(map[string]struct{}, len(record.Parts)+1)
  1261. appendKey := func(key string) {
  1262. if key == "" {
  1263. return
  1264. }
  1265. if _, ok := seen[key]; ok {
  1266. return
  1267. }
  1268. seen[key] = struct{}{}
  1269. keys = append(keys, key)
  1270. }
  1271. appendKey(record.S3Key)
  1272. parts := append([]BackupPart(nil), record.Parts...)
  1273. sort.Slice(parts, func(i, j int) bool { return parts[i].Index < parts[j].Index })
  1274. for _, part := range parts {
  1275. appendKey(part.S3Key)
  1276. }
  1277. return keys
  1278. }
  1279. // deleteBackupObjects 尝试删除记录关联的所有对象,并聚合删除错误。
  1280. func (s *BackupService) deleteBackupObjects(ctx context.Context, record *BackupRecord) error {
  1281. if len(backupObjectKeys(record)) == 0 {
  1282. return nil
  1283. }
  1284. s3Cfg, err := s.loadS3Config(ctx)
  1285. if err != nil {
  1286. return err
  1287. }
  1288. if s3Cfg == nil || !s3Cfg.IsConfigured() {
  1289. // 兼容没有配置对象存储的旧记录:记录仍可被删除。
  1290. return nil
  1291. }
  1292. objectStore, err := s.getOrCreateStore(ctx, s3Cfg)
  1293. if err != nil {
  1294. return err
  1295. }
  1296. return deleteBackupObjectKeys(ctx, objectStore, record)
  1297. }
  1298. func deleteBackupObjectKeys(ctx context.Context, objectStore BackupObjectStore, record *BackupRecord) error {
  1299. keys := backupObjectKeys(record)
  1300. if len(keys) == 0 {
  1301. return nil
  1302. }
  1303. var errs []error
  1304. for _, key := range keys {
  1305. if deleteErr := objectStore.Delete(ctx, key); deleteErr != nil {
  1306. errs = append(errs, fmt.Errorf("delete backup object %q: %w", key, deleteErr))
  1307. }
  1308. }
  1309. return errors.Join(errs...)
  1310. }