feat(db): 控制面 PG 定时备份、迁移前快照与原子恢复

This commit is contained in:
Lemon-miaow committed 2026-09-24 15:19:42 +08:00
1 parent abfe60d62d
commit c7db7d4126
31 files changed
+3217 -17

No files matched your search

+49
View File
@@ -0,0 +1,49 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"felis.lolicon.best/internal/dbbackup"
)
// Control-plane database backup freshness (admin-tier, read-only). The backups
// themselves run on the host — felis-db-backup.timer calls `felis db backup`,
// which records its newest success in platform_settings[dbbackup.StatusKey] — so
// the API can report them without reaching the host's backup directory. The
// panel shows this next to the update window: both answer "is it safe to change
// something on this install right now".
// dbBackupView is the wire shape. Last is null until the first backup has been
// recorded; Stale is true for a missing record too, so the panel has a single
// flag for "nobody could restore today's state".
type dbBackupView struct {
Last *dbbackup.Status `json:"last"`
Stale bool `json:"stale"`
MaxAgeSeconds int64 `json:"max_age_seconds"`
}
// handleGetDBBackup reports the newest recorded control-plane database backup.
// Only a missing key reads as "never"; any other store error is a 500 so a DB
// blip is never shown as a healthy or an absent backup.
func (a *API) handleGetDBBackup(w http.ResponseWriter, r *http.Request) {
view := dbBackupView{Stale: true, MaxAgeSeconds: int64(dbbackup.StaleAfter.Seconds())}
raw, err := a.Repo.GetSetting(r.Context(), dbbackup.StatusKey)
switch {
case errors.Is(err, ErrNotFound):
writeJSON(w, http.StatusOK, view)
return
case err != nil:
writeError(w, r, err)
return
}
var st dbbackup.Status
if err := json.Unmarshal(raw, &st); err != nil {
writeError(w, r, err)
return
}
view.Last = &st
view.Stale = st.At.IsZero() || a.now().Sub(st.At) > dbbackup.StaleAfter
writeJSON(w, http.StatusOK, view)
}