Files
knowledge-governance/internal/httpapi/backup_schedule.go
T
姚章浩 3f603aa293 feat(backup): 系统设置里的存储与备份——定时时间与储存天数可配、下发到目标主机 cron
需求:整库逻辑备份做定时,但要在「系统设置 → 存储与备份」里设置定时时间与储存天数。

后端
- migration 000045 backup_schedule(单行:enabled/schedule_time/retention_days/retention_months/
  last_applied_*),store + 领域校验(HH:MM、1~365 天、0~24 月,非法值 400 且不落库不下发)
- internal/backuphost:通过 ssh 在目标主机上读回/下发计划并立即备份(未接线时 Available=false,
  页面降级提示;主机侧错误带输出回传)。最近一次只认有 MANIFEST.json 的完整目录,跳 *.incomplete
- 接口 GET/PUT /api/v1/backup-schedule、GET …/host、POST …/apply、POST …/run;
  写操作经 classifyRole 限管理员;run 为异步 202 + 在途单飞(http 写超时 30s,备份约 35s)
- openapi.yaml 补齐 5 条路由(契约测试要求每条都写 400/500)

主机侧
- scripts/install_backup_schedule.sh(幂等):写 /etc/knowledge-governance/backup-schedule.env 与
  /etc/cron.d/kb-logical-backup,参数校验,--show/--disable,最后一行 SCHEDULE_APPLIED 供平台核对
- logical-backup.sh 从该文件读保留策略;失败或被中断把目录改名 *.incomplete,不留'像成功'的半成品
- install --show 只回显计划行(原先把 SHELL=/PATH= 当计划显示)

前端
- web/src/backupSchedule.ts(严格解析:缺计划字段即报错,不把默认值伪装成设置)
- BackupScheduleSection:容器 + 纯视图(管理员表单/只读、已一致/待下发、在途轮询)

运行前提(已落实):运行镜像加 openssh-client,且容器 uid 1000 在 /etc/passwd 有名字
(否则 ssh 报 No user exists for uid 1000);凭据只读挂载 secrets/backup-host(git 忽略)

实测:主机 cron 每天 03:30 / 保留 7 天 / 每月 3 份;立即备份一次 34 秒 / 742.7 MiB / 失败 0;
Go 与前端测试全绿(170 vitest)。
2026-10-09 13:50:23 +08:00

342 lines
13 KiB
Go
Raw 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
view, err := config.backupSchedulePayload(r.Context(), true)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not load backup schedule"})
return
}
if !apply || !view.HostConfigured {
writeJSON(w, http.StatusOK, map[string]any{"data": view, "applied": false})
return
}
payload, applied := config.applyBackupSchedule(r.Context(), next)
view.Applied = applied
view.ApplyError = payload
writeJSON(w, http.StatusOK, map[string]any{"data": view, "applied": applied, "apply_error": payload})
}
// 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)
}