Files
knowledge-governance/internal/platform/postgres/backup_schedule.go
姚章浩 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

73 lines
2.8 KiB
Go

package postgres
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"knowledge-governance/internal/governance"
)
// LoadBackupSchedule reads the single active backup schedule row. A missing row
// (fresh database before the seed, or a rolled-back migration) reads back as the
// defaults instead of an error, so the settings page never hard-fails.
func (s *Store) LoadBackupSchedule(ctx context.Context) (governance.BackupSchedule, error) {
schedule := governance.BackupSchedule{
ID: "active",
Enabled: false,
ScheduleTime: governance.BackupScheduleDefaultTime,
RetentionDays: 7,
RetentionMonths: 3,
}
err := s.db.QueryRow(ctx, `
SELECT enabled,schedule_time,retention_days,retention_months,last_applied_at,last_applied_summary,updated_by,updated_at
FROM backup_schedule WHERE id='active'`).
Scan(&schedule.Enabled, &schedule.ScheduleTime, &schedule.RetentionDays, &schedule.RetentionMonths,
&schedule.LastAppliedAt, &schedule.LastAppliedSummary, &schedule.UpdatedBy, &schedule.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return schedule, nil
}
if err != nil {
return governance.BackupSchedule{}, errors.New("load backup schedule")
}
return schedule, nil
}
// SaveBackupSchedule upserts the schedule row. Validation belongs to the caller
// (governance.BackupSchedule.Validate); this only persists what was accepted.
func (s *Store) SaveBackupSchedule(ctx context.Context, schedule governance.BackupSchedule, updatedBy string, now time.Time) error {
if now.IsZero() {
return errors.New("backup schedule save requires a timestamp")
}
if _, err := s.db.Exec(ctx, `
INSERT INTO backup_schedule(id,enabled,schedule_time,retention_days,retention_months,updated_by,updated_at)
VALUES('active',$1,$2,$3,$4,$5,$6)
ON CONFLICT(id) DO UPDATE SET
enabled=EXCLUDED.enabled,
schedule_time=EXCLUDED.schedule_time,
retention_days=EXCLUDED.retention_days,
retention_months=EXCLUDED.retention_months,
updated_by=EXCLUDED.updated_by,
updated_at=EXCLUDED.updated_at`,
schedule.Enabled, schedule.ScheduleTime, schedule.RetentionDays, schedule.RetentionMonths, updatedBy, now); err != nil {
return errors.New("save backup schedule")
}
return nil
}
// MarkBackupScheduleApplied records the last successful push to the data-plane
// host (cron file + next run), which the page shows as "当前主机上的计划".
func (s *Store) MarkBackupScheduleApplied(ctx context.Context, summary string, appliedAt time.Time) error {
if appliedAt.IsZero() {
return errors.New("backup schedule apply requires a timestamp")
}
if _, err := s.db.Exec(ctx, `
UPDATE backup_schedule SET last_applied_at=$1, last_applied_summary=$2 WHERE id='active'`,
appliedAt, summary); err != nil {
return errors.New("mark backup schedule applied")
}
return nil
}