Files
姚章浩 e95a50ca3a fix(backup): 保存后即时反映主机状态;最近一次备份不再重复同一时间戳
- 后端 handleSaveBackupSchedule 改为先下发、再组装投影:此前页面保存成功那几行仍是
  下发前的主机计划,看起来像没生效(需等下一次刷新)
- 前端 runMoment:清单里的 created_at 就是目录戳,同值不再重复显示(原为
  '20261009T060736Z · 20261009T060736Z')
- 前端补 runMoment 用例(171 个 vitest 全绿)

面板实测:保存后立即显示 每天 03:30 / 保留 7 天 / 已一致,cron 条目 30 03 * * *
2026-10-09 14:12:10 +08:00

342 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpapi
import (
"context"
"net/http"
"strconv"
"strings"
"sync"
"time"
"knowledge-governance/internal/backuphost"
"knowledge-governance/internal/governance"
)
// BackupScheduleStore 持久化「系统设置 → 存储与备份」里的整库备份计划(migration 000045)。
type BackupScheduleStore interface {
LoadBackupSchedule(context.Context) (governance.BackupSchedule, error)
SaveBackupSchedule(context.Context, governance.BackupSchedule, string, time.Time) error
MarkBackupScheduleApplied(context.Context, string, time.Time) error
}
// BackupHost 在目标库所在主机上读回/下发计划并触发一次备份。未接线(Available=false)时
// 页面降级为"主机凭据未挂载",而不是报 500。
type BackupHost interface {
Available() bool
Target() string
Show(context.Context) (backuphost.ShowResult, error)
Apply(context.Context, bool, string, int, int) (backuphost.ApplyResult, error)
RunNow(context.Context) (backuphost.RunResult, error)
LastRun(context.Context) (backuphost.RunResult, error)
}
// BackupRunTracker 记录"立即备份一次"的在途状态:避免重复点击跑两次全库 dump,
// 并让页面轮询到"正在备份"而不是一个被 WriteTimeout 截断的响应。
// 进程重启会丢掉在途标记,最终事实以主机上的 MANIFEST.json 为准。
type BackupRunTracker struct {
mutex sync.Mutex
running bool
startedAt time.Time
finishedAt time.Time
lastError string
}
// Start 标记开始;已在跑时返回 false(调用方据此不重复触发)。
func (tracker *BackupRunTracker) Start(now time.Time) bool {
tracker.mutex.Lock()
defer tracker.mutex.Unlock()
if tracker.running {
return false
}
tracker.running = true
tracker.startedAt = now
tracker.lastError = ""
return true
}
// Finish 结束一次运行并记录错误(空字符串表示成功)。
func (tracker *BackupRunTracker) Finish(errText string, now time.Time) {
tracker.mutex.Lock()
defer tracker.mutex.Unlock()
tracker.running = false
tracker.finishedAt = now
tracker.lastError = errText
}
func (tracker *BackupRunTracker) snapshot() (bool, time.Time, time.Time, string) {
tracker.mutex.Lock()
defer tracker.mutex.Unlock()
return tracker.running, tracker.startedAt, tracker.finishedAt, tracker.lastError
}
type backupScheduleView struct {
Enabled bool `json:"enabled"`
ScheduleTime string `json:"schedule_time"`
RetentionDays int `json:"retention_days"`
RetentionMonths int `json:"retention_months"`
UpdatedBy string `json:"updated_by"`
UpdatedAt string `json:"updated_at"`
// LastApplied* 记录最后一次成功下发到主机的结果(页面"当前主机上的计划")。
LastAppliedAt string `json:"last_applied_at"`
LastAppliedSummary string `json:"last_applied_summary"`
// HostConfigured/HostReachable 区分"没接线"与"接线了但连不上"。
HostConfigured bool `json:"host_configured"`
HostTarget string `json:"host_target"`
HostReachable bool `json:"host_reachable"`
HostError string `json:"host_error"`
HostScheduleEnabled bool `json:"host_schedule_enabled"`
HostScheduleTime string `json:"host_schedule_time"`
HostKeepDays int `json:"host_keep_days"`
HostKeepMonths int `json:"host_keep_months"`
HostCronLines []string `json:"host_cron_lines"`
HostCronService string `json:"host_cron_service"`
HostScriptPresent bool `json:"host_script_present"`
LastRunStamp string `json:"last_run_stamp"`
LastRunCreatedAt string `json:"last_run_created_at"`
LastRunDurationSeconds int `json:"last_run_duration_seconds"`
LastRunTotalBytes int64 `json:"last_run_total_bytes"`
LastRunDatabases []string `json:"last_run_databases"`
LastRunFailedDatabases []string `json:"last_run_failed_databases"`
LastRunError string `json:"last_run_error"`
// InSync 表示库里的计划与主机上生效的计划一致(不一致时页面提示"待下发")。
InSync bool `json:"in_sync"`
// Applied/ApplyError 是本次请求是否成功下发的问题说明(保存并下发、重新下发、立即备份都用)。
Applied bool `json:"applied"`
ApplyError string `json:"apply_error"`
// Running 表示"立即备份一次"正在执行。
Running bool `json:"running"`
RunningStartedAt string `json:"running_started_at"`
RunLastError string `json:"run_last_error"`
}
// handleBackupSchedule 只读返回库里的计划(不探测主机,避免主机不可达时拖慢页面)。
func (config Config) handleBackupSchedule(w http.ResponseWriter, r *http.Request) {
view, err := config.backupSchedulePayload(r.Context(), false)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load backup schedule"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": view})
}
// handleBackupScheduleHost 探测目标主机:当前 cron 计划 + 最近一次备份结果。
func (config Config) handleBackupScheduleHost(w http.ResponseWriter, r *http.Request) {
view, err := config.backupSchedulePayload(r.Context(), true)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load backup schedule"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": view})
}
// handleSaveBackupSchedule 保存计划;body 里 apply(默认 true)为真时一并下发到主机。
func (config Config) handleSaveBackupSchedule(w http.ResponseWriter, r *http.Request) {
var input struct {
Enabled *bool `json:"enabled"`
ScheduleTime string `json:"schedule_time"`
RetentionDays *int `json:"retention_days"`
RetentionMonths *int `json:"retention_months"`
Apply *bool `json:"apply"`
}
if err := decodeStrictJSON(r, &input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
current, err := config.BackupSchedule.LoadBackupSchedule(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load backup schedule"})
return
}
next := current
if input.Enabled != nil {
next.Enabled = *input.Enabled
}
if strings.TrimSpace(input.ScheduleTime) != "" {
next.ScheduleTime = strings.TrimSpace(input.ScheduleTime)
}
if input.RetentionDays != nil {
next.RetentionDays = *input.RetentionDays
}
if input.RetentionMonths != nil {
next.RetentionMonths = *input.RetentionMonths
}
next = next.Normalize()
if err := next.Validate(); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if err := config.BackupSchedule.SaveBackupSchedule(r.Context(), next, actorFor(r), config.Now().UTC()); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not save backup schedule"})
return
}
// 先下发、再组装投影:否则页面保存后那几行还是"下发前"的主机状态,看着像没生效。
apply := input.Apply == nil || *input.Apply
problem, applied := "", false
if apply && config.BackupHost.Available() {
problem, applied = config.applyBackupSchedule(r.Context(), next)
}
view, err := config.backupSchedulePayload(r.Context(), true)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load backup schedule"})
return
}
view.Applied = applied
view.ApplyError = problem
writeJSON(w, http.StatusOK, map[string]any{"data": view, "applied": applied, "apply_error": problem})
}
// handleApplyBackupSchedule 不改设置,只把库里的计划重新下发一次。
func (config Config) handleApplyBackupSchedule(w http.ResponseWriter, r *http.Request) {
schedule, err := config.BackupSchedule.LoadBackupSchedule(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load backup schedule"})
return
}
if err := schedule.Validate(); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
problem, applied := config.applyBackupSchedule(r.Context(), schedule)
view, err := config.backupSchedulePayload(r.Context(), true)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load backup schedule"})
return
}
view.Applied = applied
view.ApplyError = problem
writeJSON(w, http.StatusOK, map[string]any{"data": view, "applied": applied, "apply_error": problem})
}
// handleRunBackupNow 触发一次整库备份。全库 dump 约 33 秒,超过 http 写超时,因此异步:
// 立即返回 202,页面轮询 GET /api/v1/backup-schedule/host 看到 running 与新的最近一次结果。
func (config Config) handleRunBackupNow(w http.ResponseWriter, r *http.Request) {
if !config.BackupHost.Available() {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "目标主机凭据未挂载,无法立即备份(见 BACKUP_HOST_SSH_CONFIG)"})
return
}
now := config.Now().UTC()
if !config.BackupRuns.Start(now) {
writeJSON(w, http.StatusAccepted, map[string]any{"data": map[string]any{"started": false, "running": true}})
return
}
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
_, err := config.BackupHost.RunNow(ctx)
message := ""
if err != nil {
message = err.Error()
}
config.BackupRuns.Finish(message, time.Now().UTC())
}()
writeJSON(w, http.StatusAccepted, map[string]any{"data": map[string]any{"started": true, "running": true}})
}
// applyBackupSchedule 把计划下发到目标主机,并把结果摘要写回库里(页面显示"最后下发")。
// 返回 (错误说明, 是否成功)。
func (config Config) applyBackupSchedule(ctx context.Context, schedule governance.BackupSchedule) (string, bool) {
result, err := config.BackupHost.Apply(ctx, schedule.Enabled, schedule.ScheduleTime,
schedule.RetentionDays, schedule.RetentionMonths)
if err != nil {
return err.Error(), false
}
summary := scheduleAppliedSummary(result)
if err := config.BackupSchedule.MarkBackupScheduleApplied(ctx, summary, config.Now().UTC()); err != nil {
return "计划已下发,但结果摘要未能写回:" + err.Error(), true
}
return "", true
}
func scheduleAppliedSummary(result backuphost.ApplyResult) string {
if !result.Enabled {
return "已关闭定时(已有备份保留)"
}
parts := []string{"每天 " + result.Time, "保留 " + itoa(result.Days) + " 天",
"每月归档 " + itoa(result.Months) + " 份"}
if result.NextRun != "" {
parts = append(parts, "下次 "+result.NextRun)
}
return strings.Join(parts, " · ")
}
// backupSchedulePayload 组装页面投影。probeHost=true 时探测主机(SSH 只读,短超时)。
func (config Config) backupSchedulePayload(ctx context.Context, probeHost bool) (backupScheduleView, error) {
schedule, err := config.BackupSchedule.LoadBackupSchedule(ctx)
if err != nil {
return backupScheduleView{}, err
}
view := backupScheduleView{
Enabled: schedule.Enabled, ScheduleTime: schedule.ScheduleTime,
RetentionDays: schedule.RetentionDays, RetentionMonths: schedule.RetentionMonths,
UpdatedBy: schedule.UpdatedBy, UpdatedAt: formatTime(schedule.UpdatedAt),
LastAppliedSummary: schedule.LastAppliedSummary,
InSync: true,
}
if schedule.LastAppliedAt != nil {
view.LastAppliedAt = formatTime(*schedule.LastAppliedAt)
}
view.HostConfigured = config.BackupHost.Available()
view.HostTarget = config.BackupHost.Target()
if !view.HostConfigured {
view.InSync = false
view.HostError = "目标主机凭据未挂载(BACKUP_HOST_SSH_CONFIG / BACKUP_HOST_TARGET)"
return view, nil
}
if !probeHost {
return view, nil
}
show, err := config.BackupHost.Show(ctx)
if err != nil {
view.HostReachable = false
view.HostError = err.Error()
view.InSync = false
} else {
view.HostReachable = true
view.HostScheduleEnabled = show.ScheduleEnabled
view.HostScheduleTime = show.ScheduleTime
view.HostKeepDays = show.KeepDays
view.HostKeepMonths = show.KeepMonths
view.HostCronLines = show.CronLines
view.HostCronService = show.CronService
view.HostScriptPresent = show.ScriptPresent
hostSchedule := governance.BackupSchedule{
Enabled: show.ScheduleEnabled, ScheduleTime: show.ScheduleTime,
RetentionDays: show.KeepDays, RetentionMonths: show.KeepMonths,
}
view.InSync = show.ScriptPresent && schedule.SameHostPlan(hostSchedule)
}
lastRun, runErr := config.BackupHost.LastRun(ctx)
if runErr != nil {
view.LastRunError = runErr.Error()
} else {
view.LastRunStamp = lastRun.Stamp
view.LastRunCreatedAt = lastRun.CreatedAt
view.LastRunDurationSeconds = lastRun.DurationSeconds
view.LastRunTotalBytes = lastRun.TotalBytes
view.LastRunDatabases = lastRun.Databases
view.LastRunFailedDatabases = lastRun.FailedDatabases
}
running, startedAt, _, lastError := config.BackupRuns.snapshot()
view.Running = running
view.RunLastError = lastError
if running {
view.RunningStartedAt = formatTime(startedAt)
}
return view, nil
}
func formatTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.UTC().Format(time.RFC3339)
}
func itoa(value int) string {
return strconv.Itoa(value)
}