e95a50ca3a
- 后端 handleSaveBackupSchedule 改为先下发、再组装投影:此前页面保存成功那几行仍是 下发前的主机计划,看起来像没生效(需等下一次刷新) - 前端 runMoment:清单里的 created_at 就是目录戳,同值不再重复显示(原为 '20261009T060736Z · 20261009T060736Z') - 前端补 runMoment 用例(171 个 vitest 全绿) 面板实测:保存后立即显示 每天 03:30 / 保留 7 天 / 已一致,cron 条目 30 03 * * *
342 lines
13 KiB
Go
342 lines
13 KiB
Go
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)
|
||
}
|