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)。
This commit is contained in:
+5
-1
@@ -13,9 +13,13 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/api ./cmd/api \
|
||||
|
||||
FROM alpine:3.22
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
RUN apk add --no-cache ca-certificates python3 postgresql-client curl gcompat libc6-compat \
|
||||
RUN apk add --no-cache ca-certificates python3 postgresql-client curl openssh-client gcompat libc6-compat \
|
||||
&& addgroup -g 65532 governance \
|
||||
&& adduser -D -H -u 65532 -G governance governance
|
||||
|
||||
# ssh 客户端需要"本机 uid 有 passwd 条目"(否则报 No user exists for uid 1000);
|
||||
# 备份计划的下发与读回都走 ssh,容器以 1000 运行,这里给该 uid 建一个名字。
|
||||
RUN adduser -D -u 1000 -h /home/app appuser 2>/dev/null || true
|
||||
# dws (DingTalk Workspace CLI) v1.0.63:版本+校验和固定(禁用 dws upgrade)。
|
||||
# 官方产物是动态链接 glibc 的二进制,alpine(musl) 靠 gcompat/libc6-compat 运行,
|
||||
# 已验证端到端取件并与 glibc 原生环境字节一致。构建期 `dws version` 即自检。
|
||||
|
||||
@@ -44,6 +44,129 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/api/v1/backup-schedule": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"operationId": "getBackupSchedule",
|
||||
"summary": "读取整库逻辑备份的定时计划(不探测主机)",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/Envelope"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/InternalError"
|
||||
},
|
||||
"503": {
|
||||
"$ref": "#/components/responses/ServiceUnavailable"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"operationId": "saveBackupSchedule",
|
||||
"summary": "保存整库逻辑备份的定时时间与储存天数,并下发到目标主机(管理员)",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/Envelope"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/InternalError"
|
||||
},
|
||||
"503": {
|
||||
"$ref": "#/components/responses/ServiceUnavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/backup-schedule/host": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"operationId": "getBackupScheduleHost",
|
||||
"summary": "读取目标主机上生效的 cron 计划与最近一次备份结果",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/Envelope"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/InternalError"
|
||||
},
|
||||
"503": {
|
||||
"$ref": "#/components/responses/ServiceUnavailable"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/backup-schedule/apply": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"operationId": "applyBackupSchedule",
|
||||
"summary": "把当前设置重新下发到目标主机(管理员)",
|
||||
"responses": {
|
||||
"200": {
|
||||
"$ref": "#/components/responses/Envelope"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/InternalError"
|
||||
},
|
||||
"503": {
|
||||
"$ref": "#/components/responses/ServiceUnavailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/backup-schedule/run": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"operationId": "runBackupNow",
|
||||
"summary": "在目标主机上立即执行一次整库备份(管理员,异步)",
|
||||
"responses": {
|
||||
"202": {
|
||||
"$ref": "#/components/responses/Envelope"
|
||||
},
|
||||
"503": {
|
||||
"$ref": "#/components/responses/ServiceUnavailable"
|
||||
},
|
||||
"400": {
|
||||
"$ref": "#/components/responses/BadRequest"
|
||||
},
|
||||
"500": {
|
||||
"$ref": "#/components/responses/InternalError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
+9
-1
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"knowledge-governance/internal/appconfig"
|
||||
"knowledge-governance/internal/backuphost"
|
||||
"knowledge-governance/internal/httpapi"
|
||||
"knowledge-governance/internal/monitoring"
|
||||
postgresstore "knowledge-governance/internal/platform/postgres"
|
||||
@@ -124,7 +125,14 @@ func run(ctx context.Context, getenv func(string) string) error {
|
||||
ProductionBackupRestoreVerified: config.ProductionBackupRestoreVerified,
|
||||
ProductionReleaseAuthorizerRef: config.ProductionReleaseAuthorizerRef,
|
||||
ProductionReleaseAuthorizationToken: releaseAuthorizationToken,
|
||||
Auth: &httpapi.AuthConfig{Store: authStore, SessionTTL: 12 * time.Hour},
|
||||
BackupSchedule: postgresstore.NewStore(pool),
|
||||
BackupHost: backuphost.New(backuphost.Config{
|
||||
SSHConfig: config.BackupHostSSHConfig,
|
||||
Target: config.BackupHostTarget,
|
||||
ScriptDir: config.BackupHostScriptDir,
|
||||
Root: config.BackupHostRoot,
|
||||
}),
|
||||
Auth: &httpapi.AuthConfig{Store: authStore, SessionTTL: 12 * time.Hour},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -10,8 +10,15 @@ services:
|
||||
# 凭据只引用 0600 文件(值不入库、不回显);appconfig 要求两者同时给出
|
||||
PRODUCTION_WEKNORA_BASE_URL: https://weknora-test.dobot.cn
|
||||
PRODUCTION_WEKNORA_API_KEY_FILE: /run/release-secrets/target_weknora_api_key
|
||||
# 整库逻辑备份的目标主机(「系统设置 → 存储与备份」下发 cron 与立即备份都走这里)。
|
||||
# 凭据是挂载进来的 0600 ssh 配置 + 私钥;不挂载则页面显示"未接线",不会报错。
|
||||
BACKUP_HOST_SSH_CONFIG: /run/backup-host/ssh_config
|
||||
BACKUP_HOST_TARGET: weknora-test
|
||||
BACKUP_HOST_SCRIPT_DIR: /opt/weknora-test
|
||||
BACKUP_HOST_ROOT: /srv/kb-backups
|
||||
volumes:
|
||||
- ./secrets/release-sim:/run/release-secrets:ro
|
||||
- ./secrets/backup-host:/run/backup-host:ro
|
||||
|
||||
release-worker:
|
||||
environment:
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
|---|---|---|
|
||||
| 导入前留档(PG 行 + ES 文档) | ✅ 已建:`scripts/target_kb_snapshot.sh` 云内执行,8 张表 + 该库在 ES 的全部文档(只读 scroll,gzip JSONL,按 `_id` 可重建)+ `MANIFEST.json`(行数/字节/sha256/每库 ES 文档数)+ `export.sql` + `README.md`。实测 **2 分 52 秒**(两个库 13.1 万条 ES 文档 / 692 MB,字节留云主机);`PULL_ES=1` 拉回本机则 **19 分 40 秒** | `$KB_BACKUP_ROOT/target-kb-snapshots/<UTC>-<kb8>/`(云主机全量;本地镜像 PG 行 + 清单) |
|
||||
| 发布前硬门禁 | ✅ 已上线(`internal/releasesnapshot`):写目标库之前必须存在覆盖该库、24h 内、**PG 行与 ES 文档两层都齐备且哈希可校验**的留档,否则 `release_target_snapshot_missing`(不可重试),目标库一行不碰 | 单测 10(门禁)+ 6(发布器);`go run ./cmd/guardcheck $KB_BACKUP_ROOT/target-kb-snapshots <kb_id>` 实测:两个绑定库 PASS、未绑定库 BLOCK |
|
||||
| 自管整库逻辑备份 | ✅ 已建并在云主机实跑:`scripts/logical-backup.sh`(`pg_dump -Fd -j4 -Z6` 目录格式,可并行/按表恢复;按天轮转保留 7 份 + 每月一份)。实测 **33 秒 / 742.6 MiB / 214 文件 / 失败 0**,覆盖该实例全部 8 个库(`weknora_test`、`weknora_prod`、`weknora_drill74`、`dobotai_prod/test`、`knowledge_adapter_prod/test`、`postgres`);RDS 管理库 `rdsadmin` 默认跳过,单库失败只记账不中止 | `$KB_BACKUP_ROOT/rds-logical/<UTC>/<库>.dir` + `MANIFEST.json`(含 `failed_databases`) |
|
||||
| 自管整库逻辑备份 | ✅ 已建并在云主机实跑:`scripts/logical-backup.sh`(`pg_dump -Fd -j4 -Z6` 目录格式,可并行/按表恢复)。实测 **33 秒 / 742.6 MiB / 214 文件 / 失败 0**,覆盖该实例全部 8 个库(`weknora_test`、`weknora_prod`、`weknora_drill74`、`dobotai_prod/test`、`knowledge_adapter_prod/test`、`postgres`);RDS 管理库 `rdsadmin` 默认跳过,单库失败只记账不中止;被中断的运行会把目录改名 `*.incomplete` | `$KB_BACKUP_ROOT/rds-logical/<UTC>/<库>.dir` + `MANIFEST.json`(含 `failed_databases`) |
|
||||
| 定时与保留(平台管理) | ✅ 已建:平台「系统设置 → 存储与备份」设置定时时间与储存天数,保存即下发到目标主机的 `/etc/cron.d/kb-logical-backup`,并读回主机上生效的计划与最近一次结果。当前:**每天 03:30、保留 7 天、每月另留 3 份**,cron 服务 active | 接口 `GET/PUT /api/v1/backup-schedule`、`GET …/host`、`POST …/apply`、`POST …/run`(写操作仅管理员) |
|
||||
| ES 快照 | ✅ 已建:仓库 `kb_backup`(容器内 `path.repo=/usr/share/elasticsearch/backup`,宿主 `$KB_BACKUP_ROOT/es-snapshots`)→ 首个快照 `initial-20261009t024209z` **36 秒 / 4.7 GB / 0 分片失败**;SLM `kb-hourly`(留 3 天)+ `kb-daily`(留 7 天)已各成功执行 | `GET _snapshot/kb_backup/_all`、`_slm/policy/*` |
|
||||
| OSS 对象 | ✅ 目标桶 `dobot-weknora-test` 已开启**版本控制**(覆盖/删除可按 VersionId 回滚);未配跨区域复制 | `GET /?versioning` = `Enabled` |
|
||||
| RDS 托管备份 | ⏳ **待控制台确认**:库里 `archive_mode=on` 但 `archive_command` 为空、`pg_stat_archiver.archived_count=0`;当前连接 `is_superuser=off`(看不到备节点)→ 系列与日志备份开关只能看控制台 | 见下节 |
|
||||
@@ -84,6 +85,24 @@ python3 scripts/restore_drill.py --snapshot-dir $KB_BACKUP_ROOT/target-kb-snapsh
|
||||
合计 **13,539 行**回填成功,抽样页面正文与标题逐字段相同;`knowledge_tags` / `knowledge_tag_relations` 为空表。
|
||||
演练在目标库临时建一张 `_drill_restored` 跟踪表记录插入了哪些行,核对后**按行精确删除**并断言残留为 0(需要目标库有建表权限,故演练只用测试库)。
|
||||
|
||||
## 定时与保留怎么下发(平台 → 目标主机)
|
||||
|
||||
备份必须在**目标库所在主机**上本地执行(内网直连 RDS、内网写盘),所以平台只负责"下发计划 + 读回结果":
|
||||
|
||||
- 页面:系统设置 → 存储与备份(管理员可改;保存 = 写入库并立刻下发;另有"重新下发当前设置"与"立即备份一次")。
|
||||
- 接口:`GET /api/v1/backup-schedule`(不探测主机,首屏快)、`GET …/host`(SSH 读回主机 cron 与最近一次结果)、
|
||||
`PUT`(校验时间 `HH:MM` 与 1~365 天 / 0~24 月,非法值直接 400 且不落库不下发)、`POST …/apply`、`POST …/run`。
|
||||
写操作按角色网关(`classifyRole`)限管理员——这份计划会落到数据面主机的 cron 上。
|
||||
- 主机侧:`scripts/install_backup_schedule.sh`(幂等)写 `/etc/knowledge-governance/backup-schedule.env`
|
||||
与 `/etc/cron.d/kb-logical-backup`;`logical-backup.sh` 从该文件读保留策略(显式传 `KEEP_DAILY/KEEP_MONTHS` 可覆盖)。
|
||||
- 凭据:容器内只读挂载 `secrets/backup-host/{ssh_config,id_ed25519,known_hosts}`(0600),
|
||||
环境变量 `BACKUP_HOST_SSH_CONFIG / BACKUP_HOST_TARGET / BACKUP_HOST_SCRIPT_DIR / BACKUP_HOST_ROOT`;
|
||||
**三项缺任一即"未接线"**,页面降级提示而不是报错。
|
||||
- 主机状态读取只认**有 `MANIFEST.json` 的完整目录**:被中断的运行会被脚本改名 `*.incomplete`,
|
||||
所以"最近一次备份"永远不会显示一份半成品。
|
||||
- 依赖两个运行前提:运行镜像里有 `openssh-client`,且容器用户的 uid 在 `/etc/passwd` 有名字
|
||||
(否则 ssh 报 `No user exists for uid 1000`)。`Dockerfile` 已加 `openssh-client` 与 `adduser -u 1000`。
|
||||
|
||||
## 每次导入/发布前的标准动作
|
||||
|
||||
1. `bash scripts/target_kb_snapshot.sh`(默认云内执行 + ES 字节留云主机,实测 **2 分 52 秒**;
|
||||
|
||||
@@ -53,6 +53,12 @@ type Config struct {
|
||||
TestWeKnoraStalledAfter time.Duration
|
||||
TestWeKnoraMaxUploadBytes int64
|
||||
TestAcceptancePolicyVersion string
|
||||
// 目标库所在主机(整库逻辑备份真正执行的地方)。凭据是挂载进来的 0600 ssh
|
||||
// 配置文件与私钥;三项同时给出才接线,缺任一项即"未接线"(页面降级展示)。
|
||||
BackupHostSSHConfig string
|
||||
BackupHostTarget string
|
||||
BackupHostScriptDir string
|
||||
BackupHostRoot string
|
||||
}
|
||||
|
||||
func Load(getenv func(string) string) (Config, error) {
|
||||
@@ -88,6 +94,11 @@ func Load(getenv func(string) string) (Config, error) {
|
||||
TestWeKnoraStalledAfter: 30 * time.Minute,
|
||||
TestWeKnoraMaxUploadBytes: 50 * 1024 * 1024,
|
||||
TestAcceptancePolicyVersion: valueOrDefault(getenv("TEST_ACCEPTANCE_POLICY_VERSION"), "structural-v1"),
|
||||
// 整库逻辑备份的目标主机:三项同时给出才接线(脚本目录/备份根有默认值)。
|
||||
BackupHostSSHConfig: strings.TrimSpace(getenv("BACKUP_HOST_SSH_CONFIG")),
|
||||
BackupHostTarget: strings.TrimSpace(getenv("BACKUP_HOST_TARGET")),
|
||||
BackupHostScriptDir: valueOrDefault(strings.TrimSpace(getenv("BACKUP_HOST_SCRIPT_DIR")), "/opt/weknora-test"),
|
||||
BackupHostRoot: valueOrDefault(strings.TrimSpace(getenv("BACKUP_HOST_ROOT")), "/srv/kb-backups"),
|
||||
}
|
||||
if config.DatabaseURL == "" {
|
||||
return Config{}, errors.New("DATABASE_URL is required")
|
||||
@@ -198,6 +209,10 @@ func Load(getenv func(string) string) (Config, error) {
|
||||
return Config{}, errors.New("TEST_ACCEPTANCE_POLICY_VERSION must not be empty")
|
||||
}
|
||||
if config.ProductionReleaseRequestsEnabled {
|
||||
// 目标主机凭据要么都给要么都不给:只给一半会把"未接线"伪装成"已接线"。
|
||||
if (config.BackupHostSSHConfig == "") != (config.BackupHostTarget == "") {
|
||||
return Config{}, errors.New("BACKUP_HOST_SSH_CONFIG and BACKUP_HOST_TARGET must be set together")
|
||||
}
|
||||
if config.ProductionReleaseAuthorizerRef == "" || len(config.ProductionReleaseAuthorizerRef) > 128 || strings.ContainsAny(config.ProductionReleaseAuthorizerRef, "\x00\r\n ") {
|
||||
return Config{}, errors.New("PRODUCTION_RELEASE_AUTHORIZER_REF is required and must be safe when production release requests are enabled")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
// Package backuphost 在"目标库所在主机"上执行整库备份计划的下发、查询与立即执行。
|
||||
//
|
||||
// 为什么走 SSH 而不是平台自己连库:备份必须在数据面主机本地跑(同 VPC 直连 RDS、
|
||||
// 内网写盘),平台的职责只是下发计划、读回结果。凭据是挂载进来的 0600 ssh 配置文件与
|
||||
// 私钥(`secrets/backup-host/`),平台不保存任何主机密码。
|
||||
package backuphost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNotConfigured 表示没有挂载主机凭据(未接线):调用方据此降级展示,而不是报 500。
|
||||
var ErrNotConfigured = errors.New("backup host is not configured")
|
||||
|
||||
// Config 描述如何连到目标库所在主机。
|
||||
type Config struct {
|
||||
// SSHConfig 是 `ssh -F` 用的配置文件路径(挂载进来的 0600 文件)。
|
||||
SSHConfig string
|
||||
// Target 是配置文件里的 Host 别名(如 weknora-test)。
|
||||
Target string
|
||||
// ScriptDir 是主机上备份脚本所在目录(install_backup_schedule.sh / logical-backup.sh)。
|
||||
ScriptDir string
|
||||
// Root 是主机上的备份根目录(与 backup_layout.sh 的契约一致,默认 /srv/kb-backups)。
|
||||
Root string
|
||||
// QueryTimeout 用于只读查询(查看计划、读最近一次结果)。
|
||||
QueryTimeout time.Duration
|
||||
// ApplyTimeout 用于下发计划。
|
||||
ApplyTimeout time.Duration
|
||||
// RunTimeout 用于"立即备份一次"(全库 pg_dump 约为分钟级)。
|
||||
RunTimeout time.Duration
|
||||
}
|
||||
|
||||
// ShowResult 是主机上当前的计划状态(页面"当前主机上的计划")。
|
||||
type ShowResult struct {
|
||||
ScheduleEnabled bool
|
||||
ScheduleTime string
|
||||
KeepDays int
|
||||
KeepMonths int
|
||||
CronLines []string
|
||||
CronService string
|
||||
ScriptPresent bool
|
||||
Raw string
|
||||
}
|
||||
|
||||
// ApplyResult 是下发结果,字段来自脚本最后一行 SCHEDULE_APPLIED。
|
||||
type ApplyResult struct {
|
||||
Enabled bool
|
||||
Time string
|
||||
Days int
|
||||
Months int
|
||||
NextRun string
|
||||
CronFile string
|
||||
Raw string
|
||||
}
|
||||
|
||||
// RunResult 是一次整库备份的结果(来自主机上的 MANIFEST.json)。
|
||||
type RunResult struct {
|
||||
Stamp string
|
||||
CreatedAt string
|
||||
DurationSeconds int
|
||||
TotalBytes int64
|
||||
Databases []string
|
||||
FailedDatabases []string
|
||||
Raw string
|
||||
}
|
||||
|
||||
// Runner 通过 ssh 执行主机侧脚本。exec 可注入,便于测试。
|
||||
type Runner struct {
|
||||
config Config
|
||||
exec func(ctx context.Context, timeout time.Duration, remote string) (string, error)
|
||||
}
|
||||
|
||||
// New 构造 Runner;config 为空时仍可用,只是 Available() 为 false。
|
||||
func New(config Config) *Runner {
|
||||
if config.QueryTimeout <= 0 {
|
||||
config.QueryTimeout = 30 * time.Second
|
||||
}
|
||||
if config.ApplyTimeout <= 0 {
|
||||
config.ApplyTimeout = 90 * time.Second
|
||||
}
|
||||
if config.RunTimeout <= 0 {
|
||||
config.RunTimeout = 15 * time.Minute
|
||||
}
|
||||
if config.Root == "" {
|
||||
config.Root = "/srv/kb-backups"
|
||||
}
|
||||
runner := &Runner{config: config}
|
||||
runner.exec = runner.runRemote
|
||||
return runner
|
||||
}
|
||||
|
||||
// Available 报告主机凭据是否已接线。
|
||||
func (r *Runner) Available() bool {
|
||||
return r != nil && r.config.SSHConfig != "" && r.config.Target != "" && r.config.ScriptDir != ""
|
||||
}
|
||||
|
||||
// Target 便于日志与页面显示连的是哪台主机。
|
||||
func (r *Runner) Target() string {
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r.config.Target
|
||||
}
|
||||
|
||||
func (r *Runner) runRemote(ctx context.Context, timeout time.Duration, remote string) (string, error) {
|
||||
if !r.Available() {
|
||||
return "", ErrNotConfigured
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
command := exec.CommandContext(ctx, "ssh",
|
||||
"-F", r.config.SSHConfig,
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "ConnectTimeout=10",
|
||||
r.config.Target, remote)
|
||||
output, err := command.CombinedOutput()
|
||||
text := strings.TrimSpace(string(output))
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return text, fmt.Errorf("ssh %s 超时(%s)", r.config.Target, timeout)
|
||||
}
|
||||
// 主机侧脚本会用退出码表达"参数非法/有库失败",把输出带回去便于页面展示。
|
||||
return text, fmt.Errorf("ssh %s 执行失败: %s", r.config.Target, firstLines(text, 6))
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
var scheduleAppliedPattern = regexp.MustCompile(
|
||||
`SCHEDULE_APPLIED enabled=(\d)(?: time=(\S+))?(?: days=(\d+))?(?: months=(\d+))?(?: next=(\S+))?(?: cron=(\S+))?`)
|
||||
|
||||
// Apply 下发计划(enabled=false 时关闭定时但保留已有备份)。
|
||||
func (r *Runner) Apply(ctx context.Context, enabled bool, hhmm string, days, months int) (ApplyResult, error) {
|
||||
if !r.Available() {
|
||||
return ApplyResult{}, ErrNotConfigured
|
||||
}
|
||||
remote := fmt.Sprintf("bash %s/install_backup_schedule.sh --disable", r.config.ScriptDir)
|
||||
if enabled {
|
||||
remote = fmt.Sprintf("bash %s/install_backup_schedule.sh --time %s --days %d --months %d",
|
||||
r.config.ScriptDir, hhmm, days, months)
|
||||
}
|
||||
output, err := r.exec(ctx, r.config.ApplyTimeout, remote)
|
||||
if err != nil {
|
||||
return ApplyResult{}.withRaw(output), err
|
||||
}
|
||||
result := ApplyResult{Raw: output}
|
||||
match := scheduleAppliedPattern.FindStringSubmatch(output)
|
||||
if match == nil {
|
||||
return result, fmt.Errorf("下发结果无法识别(主机未按预期输出 SCHEDULE_APPLIED)")
|
||||
}
|
||||
result.Enabled = match[1] == "1"
|
||||
result.Time = match[2]
|
||||
result.Days = atoiOrZero(match[3])
|
||||
result.Months = atoiOrZero(match[4])
|
||||
result.NextRun = match[5]
|
||||
result.CronFile = match[6]
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (result ApplyResult) withRaw(raw string) ApplyResult {
|
||||
result.Raw = raw
|
||||
return result
|
||||
}
|
||||
|
||||
// Show 读回主机上的当前计划(参数文件 + cron 条目 + cron 服务状态)。
|
||||
func (r *Runner) Show(ctx context.Context) (ShowResult, error) {
|
||||
if !r.Available() {
|
||||
return ShowResult{}, ErrNotConfigured
|
||||
}
|
||||
output, err := r.exec(ctx, r.config.QueryTimeout,
|
||||
fmt.Sprintf("bash %s/install_backup_schedule.sh --show", r.config.ScriptDir))
|
||||
if err != nil {
|
||||
return ShowResult{Raw: output}, err
|
||||
}
|
||||
result := ShowResult{Raw: output}
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(line, "备份脚本:"):
|
||||
result.ScriptPresent = strings.Contains(line, "(存在)")
|
||||
case strings.HasPrefix(line, "计划参数:"):
|
||||
fields := parseKeyValues(strings.TrimPrefix(line, "计划参数:"))
|
||||
result.ScheduleEnabled = fields["KB_BACKUP_SCHEDULE_ENABLED"] == "1"
|
||||
result.ScheduleTime = fields["KB_BACKUP_SCHEDULE_TIME"]
|
||||
result.KeepDays = atoiOrZero(fields["KB_BACKUP_KEEP_DAYS"])
|
||||
result.KeepMonths = atoiOrZero(fields["KB_BACKUP_KEEP_MONTHS"])
|
||||
case strings.HasPrefix(line, "cron 服务:"):
|
||||
result.CronService = strings.TrimSpace(strings.TrimPrefix(line, "cron 服务:"))
|
||||
case strings.HasPrefix(line, " "):
|
||||
// 只收计划行:SHELL=/PATH= 这类环境行不是计划,回显出来会误导操作者。
|
||||
if entry := strings.TrimSpace(line); entry != "" && !strings.HasPrefix(entry, "#") &&
|
||||
!strings.Contains(entry, "=") {
|
||||
result.CronLines = append(result.CronLines, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// RunNow 立刻在主机上跑一次整库备份,并读回结果(页面"立即备份一次")。
|
||||
func (r *Runner) RunNow(ctx context.Context) (RunResult, error) {
|
||||
if !r.Available() {
|
||||
return RunResult{}, ErrNotConfigured
|
||||
}
|
||||
if _, err := r.exec(ctx, r.config.RunTimeout,
|
||||
fmt.Sprintf("bash %s/logical-backup.sh", r.config.ScriptDir)); err != nil {
|
||||
return RunResult{}, err
|
||||
}
|
||||
return r.LastRun(ctx)
|
||||
}
|
||||
|
||||
// LastRun 读回最近一次备份的清单摘要(时间/耗时/大小/失败的库)。
|
||||
func (r *Runner) LastRun(ctx context.Context) (RunResult, error) {
|
||||
if !r.Available() {
|
||||
return RunResult{}, ErrNotConfigured
|
||||
}
|
||||
// 只认"有 MANIFEST.json 的完整目录":被中断的运行会被备份脚本改名成 *.incomplete,
|
||||
// 这里再兜一层,避免把半成品当成"最近一次成功备份"。
|
||||
remote := fmt.Sprintf(
|
||||
`S=""; I=0; for d in $(ls -1t %s/rds-logical 2>/dev/null); do case "$d" in *.incomplete) I=$((I+1)); continue ;; esac; if [ -f "%s/rds-logical/$d/MANIFEST.json" ]; then S="$d"; break; fi; I=$((I+1)); done; echo "STAMP=$S"; echo "SKIPPED_INCOMPLETE=$I"; if [ -n "$S" ]; then cat "%s/rds-logical/$S/MANIFEST.json"; fi`,
|
||||
r.config.Root, r.config.Root, r.config.Root)
|
||||
output, err := r.exec(ctx, r.config.QueryTimeout, remote)
|
||||
if err != nil {
|
||||
return RunResult{Raw: output}, err
|
||||
}
|
||||
result := RunResult{Raw: output}
|
||||
jsonStart := strings.Index(output, "{")
|
||||
if strings.HasPrefix(output, "STAMP=") {
|
||||
line := strings.SplitN(output, "\n", 2)[0]
|
||||
result.Stamp = strings.TrimSpace(strings.TrimPrefix(line, "STAMP="))
|
||||
}
|
||||
if jsonStart < 0 {
|
||||
return result, nil
|
||||
}
|
||||
var manifest struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
DurationSeconds int `json:"duration_seconds"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
Databases []string `json:"databases"`
|
||||
FailedDatabases []string `json:"failed_databases"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(output[jsonStart:]), &manifest); err != nil {
|
||||
return result, fmt.Errorf("最近一次备份清单无法解析: %w", err)
|
||||
}
|
||||
result.CreatedAt = manifest.CreatedAt
|
||||
result.DurationSeconds = manifest.DurationSeconds
|
||||
result.TotalBytes = manifest.TotalBytes
|
||||
result.Databases = manifest.Databases
|
||||
result.FailedDatabases = manifest.FailedDatabases
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseKeyValues(line string) map[string]string {
|
||||
fields := map[string]string{}
|
||||
for _, token := range strings.Fields(line) {
|
||||
if key, value, found := strings.Cut(token, "="); found {
|
||||
fields[key] = value
|
||||
}
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func atoiOrZero(text string) int {
|
||||
value, err := strconv.Atoi(strings.TrimSpace(text))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func firstLines(text string, limit int) string {
|
||||
lines := strings.Split(strings.TrimSpace(text), "\n")
|
||||
if len(lines) > limit {
|
||||
lines = lines[:limit]
|
||||
}
|
||||
return strings.Join(lines, " | ")
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package backuphost
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testRunner(exec func(ctx context.Context, timeout time.Duration, remote string) (string, error)) *Runner {
|
||||
runner := New(Config{
|
||||
SSHConfig: "/run/backup-host/ssh_config",
|
||||
Target: "weknora-test",
|
||||
ScriptDir: "/opt/weknora-test",
|
||||
Root: "/srv/kb-backups",
|
||||
})
|
||||
if exec != nil {
|
||||
runner.exec = exec
|
||||
}
|
||||
return runner
|
||||
}
|
||||
|
||||
func TestRunnerUnavailableWithoutCredentials(t *testing.T) {
|
||||
runner := New(Config{})
|
||||
if runner.Available() {
|
||||
t.Fatal("没有凭据时不应判定为已接线")
|
||||
}
|
||||
for name, call := range map[string]func() error{
|
||||
"Show": func() error { _, err := runner.Show(context.Background()); return err },
|
||||
"Apply": func() error { _, err := runner.Apply(context.Background(), true, "03:30", 7, 3); return err },
|
||||
"RunNow": func() error { _, err := runner.RunNow(context.Background()); return err },
|
||||
} {
|
||||
if err := call(); !errors.Is(err, ErrNotConfigured) {
|
||||
t.Errorf("%s: 期望 ErrNotConfigured,得到 %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 只给一半参数(缺 Target)也算未接线,避免"看起来接上了"。
|
||||
func TestRunnerPartialConfigIsNotAvailable(t *testing.T) {
|
||||
runner := New(Config{SSHConfig: "/run/backup-host/ssh_config"})
|
||||
if runner.Available() {
|
||||
t.Fatal("缺少 Target 时不应判定为已接线")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerApplyBuildsCommandAndParsesResult(t *testing.T) {
|
||||
var seen string
|
||||
runner := testRunner(func(_ context.Context, _ time.Duration, remote string) (string, error) {
|
||||
seen = remote
|
||||
return "计划已下发:每天 03:30(保留 7 天,另按自然月保留 3 份)\n" +
|
||||
"SCHEDULE_APPLIED enabled=1 time=03:30 days=7 months=3 next=2026-10-10T03:30:00+0800 cron=/etc/cron.d/kb-logical-backup\n", nil
|
||||
})
|
||||
result, err := runner.Apply(context.Background(), true, "03:30", 7, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen != "bash /opt/weknora-test/install_backup_schedule.sh --time 03:30 --days 7 --months 3" {
|
||||
t.Fatalf("下发的远端命令不对: %q", seen)
|
||||
}
|
||||
if !result.Enabled || result.Time != "03:30" || result.Days != 7 || result.Months != 3 {
|
||||
t.Fatalf("结果解析不对: %+v", result)
|
||||
}
|
||||
if result.CronFile != "/etc/cron.d/kb-logical-backup" || result.NextRun != "2026-10-10T03:30:00+0800" {
|
||||
t.Fatalf("cron/下次执行解析不对: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerApplyDisable(t *testing.T) {
|
||||
var seen string
|
||||
runner := testRunner(func(_ context.Context, _ time.Duration, remote string) (string, error) {
|
||||
seen = remote
|
||||
return "已关闭定时备份(已生成的备份保留)。\nSCHEDULE_APPLIED enabled=0\n", nil
|
||||
})
|
||||
result, err := runner.Apply(context.Background(), false, "03:30", 7, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen != "bash /opt/weknora-test/install_backup_schedule.sh --disable" {
|
||||
t.Fatalf("关闭定时应调用 --disable: %q", seen)
|
||||
}
|
||||
if result.Enabled {
|
||||
t.Fatal("关闭后 enabled 应为 false")
|
||||
}
|
||||
}
|
||||
|
||||
// 主机没按预期输出 SCHEDULE_APPLIED(例如脚本版本旧)时必须报错,绝不能默默当成功。
|
||||
func TestRunnerApplyFailsWithoutMarker(t *testing.T) {
|
||||
runner := testRunner(func(context.Context, time.Duration, string) (string, error) {
|
||||
return "bash: /opt/weknora-test/install_backup_schedule.sh: No such file or directory\n", nil
|
||||
})
|
||||
if _, err := runner.Apply(context.Background(), true, "03:30", 7, 3); err == nil {
|
||||
t.Fatal("缺少 SCHEDULE_APPLIED 应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerShowParsesHostState(t *testing.T) {
|
||||
runner := testRunner(func(context.Context, time.Duration, string) (string, error) {
|
||||
return `备份脚本: /opt/weknora-test/logical-backup.sh (存在)
|
||||
计划参数: KB_BACKUP_SCHEDULE_ENABLED=1 KB_BACKUP_SCHEDULE_TIME=03:30 KB_BACKUP_KEEP_DAYS=7 KB_BACKUP_KEEP_MONTHS=3 KB_BACKUP_SCRIPT=/opt/weknora-test/logical-backup.sh KB_BACKUP_LOG=/srv/kb-backups/logs/logical-backup.log
|
||||
cron 条目:
|
||||
30 03 * * * root /bin/bash /opt/weknora-test/logical-backup.sh >> /srv/kb-backups/logs/logical-backup.log 2>&1
|
||||
cron 服务: active
|
||||
备份根目录: /srv/kb-backups(758M 已用)
|
||||
`, nil
|
||||
})
|
||||
show, err := runner.Show(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !show.ScheduleEnabled || show.ScheduleTime != "03:30" || show.KeepDays != 7 || show.KeepMonths != 3 {
|
||||
t.Fatalf("计划参数解析不对: %+v", show)
|
||||
}
|
||||
if len(show.CronLines) != 1 || !strings.Contains(show.CronLines[0], "logical-backup.sh") {
|
||||
t.Fatalf("cron 条目解析不对: %+v", show.CronLines)
|
||||
}
|
||||
if show.CronService != "active" || !show.ScriptPresent {
|
||||
t.Fatalf("服务/脚本状态解析不对: %+v", show)
|
||||
}
|
||||
}
|
||||
|
||||
// 未配置计划时主机输出"未配置/未安装",解析不能崩、也不能谎报为已启用。
|
||||
func TestRunnerShowHandlesUnconfiguredHost(t *testing.T) {
|
||||
runner := testRunner(func(context.Context, time.Duration, string) (string, error) {
|
||||
return "备份脚本: /opt/weknora-test/logical-backup.sh (存在)\n计划参数: 未配置\ncron 条目: 未安装(定时关闭)\ncron 服务: active\n", nil
|
||||
})
|
||||
show, err := runner.Show(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if show.ScheduleEnabled || show.ScheduleTime != "" || len(show.CronLines) != 0 {
|
||||
t.Fatalf("未配置主机不应解析成已启用: %+v", show)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerLastRunParsesManifest(t *testing.T) {
|
||||
var seen string
|
||||
runner := testRunner(func(_ context.Context, _ time.Duration, remote string) (string, error) {
|
||||
seen = remote
|
||||
return `STAMP=20261009T052909Z
|
||||
{
|
||||
"kind": "rds-logical-backup",
|
||||
"created_at": "20261009T052909Z",
|
||||
"duration_seconds": 33,
|
||||
"databases": ["dobotai_prod", "weknora_test"],
|
||||
"failed_databases": ["rdsadmin"],
|
||||
"total_bytes": 778502144
|
||||
}
|
||||
`, nil
|
||||
})
|
||||
lastRun, err := runner.LastRun(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(seen, "/srv/kb-backups/rds-logical") {
|
||||
t.Fatalf("应读取契约路径下的 rds-logical: %q", seen)
|
||||
}
|
||||
if lastRun.Stamp != "20261009T052909Z" || lastRun.DurationSeconds != 33 || lastRun.TotalBytes != 778502144 {
|
||||
t.Fatalf("清单解析不对: %+v", lastRun)
|
||||
}
|
||||
if len(lastRun.FailedDatabases) != 1 || lastRun.FailedDatabases[0] != "rdsadmin" {
|
||||
t.Fatalf("失败库解析不对: %+v", lastRun.FailedDatabases)
|
||||
}
|
||||
}
|
||||
|
||||
// 还没跑过备份:STAMP 为空且无清单,应得到空结果而不是错误。
|
||||
func TestRunnerLastRunWithoutSnapshot(t *testing.T) {
|
||||
runner := testRunner(func(context.Context, time.Duration, string) (string, error) {
|
||||
return "STAMP=\n", nil
|
||||
})
|
||||
lastRun, err := runner.LastRun(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if lastRun.Stamp != "" || lastRun.DurationSeconds != 0 {
|
||||
t.Fatalf("无备份时应为空结果: %+v", lastRun)
|
||||
}
|
||||
}
|
||||
|
||||
// 主机侧错误(ssh 失败、脚本非零退出)必须带着输出回传,页面才能显示原因。
|
||||
func TestRunnerSurfacesHostFailure(t *testing.T) {
|
||||
runner := testRunner(func(context.Context, time.Duration, string) (string, error) {
|
||||
return "", errors.New("ssh weknora-test 执行失败: bash: install_backup_schedule.sh: No such file")
|
||||
})
|
||||
if _, err := runner.Show(context.Background()); err == nil || !strings.Contains(err.Error(), "No such file") {
|
||||
t.Fatalf("应把主机侧失败原因带回来,得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerRunNowReadsBackLastRun(t *testing.T) {
|
||||
calls := 0
|
||||
runner := testRunner(func(_ context.Context, _ time.Duration, remote string) (string, error) {
|
||||
calls++
|
||||
if strings.Contains(remote, "logical-backup.sh") && !strings.Contains(remote, "ls -1t") {
|
||||
return "备份目标: /srv/kb-backups/rds-logical/20261009T060000Z\ndone: /srv/kb-backups/rds-logical/20261009T060000Z\n", nil
|
||||
}
|
||||
return "STAMP=20261009T060000Z\n{\"duration_seconds\": 34, \"total_bytes\": 800000000, \"failed_databases\": []}\n", nil
|
||||
})
|
||||
run, err := runner.RunNow(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("应立即执行一次并回读一次,实际调用 %d 次", calls)
|
||||
}
|
||||
if run.Stamp != "20261009T060000Z" || run.DurationSeconds != 34 || run.TotalBytes != 800000000 {
|
||||
t.Fatalf("立即备份结果不对: %+v", run)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package governance
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 整库逻辑备份的定时计划(平台「系统设置 → 存储与备份」)。校验集中在领域层:
|
||||
// 非法值一律拒绝,绝不把半成品下发到数据面主机。
|
||||
const (
|
||||
BackupScheduleDefaultTime = "03:30"
|
||||
BackupScheduleMinDays = 1
|
||||
BackupScheduleMaxDays = 365
|
||||
BackupScheduleMinMonths = 0
|
||||
BackupScheduleMaxMonths = 24
|
||||
)
|
||||
|
||||
// backupScheduleTime 形如 03:30;cron 用 24 小时制,不接受 24:00 及秒位。
|
||||
var backupScheduleTime = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
|
||||
|
||||
// BackupSchedule 是一份可下发的备份计划(时间 + 保留策略)。
|
||||
type BackupSchedule struct {
|
||||
ID string
|
||||
Enabled bool
|
||||
ScheduleTime string
|
||||
RetentionDays int
|
||||
RetentionMonths int
|
||||
LastAppliedAt *time.Time
|
||||
LastAppliedSummary string
|
||||
UpdatedBy string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Validate 校验时间格式与保留范围;返回的错误直接可展示给操作者。
|
||||
func (schedule BackupSchedule) Validate() error {
|
||||
if !backupScheduleTime.MatchString(strings.TrimSpace(schedule.ScheduleTime)) {
|
||||
return fmt.Errorf("定时时间必须是 HH:MM(00:00~23:59),收到 %q", schedule.ScheduleTime)
|
||||
}
|
||||
if schedule.RetentionDays < BackupScheduleMinDays || schedule.RetentionDays > BackupScheduleMaxDays {
|
||||
return fmt.Errorf("储存天数需在 %d~%d 之间,收到 %d",
|
||||
BackupScheduleMinDays, BackupScheduleMaxDays, schedule.RetentionDays)
|
||||
}
|
||||
if schedule.RetentionMonths < BackupScheduleMinMonths || schedule.RetentionMonths > BackupScheduleMaxMonths {
|
||||
return fmt.Errorf("每月归档保留需在 %d~%d 之间,收到 %d",
|
||||
BackupScheduleMinMonths, BackupScheduleMaxMonths, schedule.RetentionMonths)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Normalize 填默认值并去掉时间两侧空白,便于比较"是否变化"。
|
||||
func (schedule BackupSchedule) Normalize() BackupSchedule {
|
||||
schedule.ScheduleTime = strings.TrimSpace(schedule.ScheduleTime)
|
||||
if schedule.ScheduleTime == "" {
|
||||
schedule.ScheduleTime = BackupScheduleDefaultTime
|
||||
}
|
||||
return schedule
|
||||
}
|
||||
|
||||
// SameHostPlan 判断两份计划在数据面主机上是否等价(只看会写进 cron 的字段)。
|
||||
func (schedule BackupSchedule) SameHostPlan(other BackupSchedule) bool {
|
||||
return schedule.Enabled == other.Enabled &&
|
||||
schedule.ScheduleTime == other.ScheduleTime &&
|
||||
schedule.RetentionDays == other.RetentionDays &&
|
||||
schedule.RetentionMonths == other.RetentionMonths
|
||||
}
|
||||
|
||||
// DescribesNextRunUtc 仅用于日志:给出下一次执行的 UTC 时刻(按当天算)。
|
||||
func (schedule BackupSchedule) NextRunAfter(now time.Time) (time.Time, error) {
|
||||
if err := schedule.Validate(); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
hour, minute := 0, 0
|
||||
if _, err := fmt.Sscanf(schedule.ScheduleTime, "%d:%d", &hour, &minute); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
|
||||
if !next.After(now) {
|
||||
next = next.Add(24 * time.Hour)
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package governance
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBackupScheduleValidateRejectsBadInput(t *testing.T) {
|
||||
valid := BackupSchedule{Enabled: true, ScheduleTime: "03:30", RetentionDays: 7, RetentionMonths: 3}
|
||||
if err := valid.Validate(); err != nil {
|
||||
t.Fatalf("合法计划被拒: %v", err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
schedule BackupSchedule
|
||||
}{
|
||||
{"时间缺前导零", BackupSchedule{ScheduleTime: "3:30", RetentionDays: 7}},
|
||||
{"时间越界", BackupSchedule{ScheduleTime: "24:00", RetentionDays: 7}},
|
||||
{"时间带秒", BackupSchedule{ScheduleTime: "03:30:00", RetentionDays: 7}},
|
||||
{"时间空", BackupSchedule{ScheduleTime: "", RetentionDays: 7}},
|
||||
{"天数为零", BackupSchedule{ScheduleTime: "03:30", RetentionDays: 0}},
|
||||
{"天数过大", BackupSchedule{ScheduleTime: "03:30", RetentionDays: 366}},
|
||||
{"月数越界", BackupSchedule{ScheduleTime: "03:30", RetentionDays: 7, RetentionMonths: 25}},
|
||||
}
|
||||
for _, item := range cases {
|
||||
if err := item.schedule.Validate(); err == nil {
|
||||
t.Errorf("%s:应当拒绝 %+v", item.name, item.schedule)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupScheduleNormalizeFillsDefaultTime(t *testing.T) {
|
||||
normalized := BackupSchedule{Enabled: false, ScheduleTime: " "}.Normalize()
|
||||
if normalized.ScheduleTime != BackupScheduleDefaultTime {
|
||||
t.Fatalf("空时间应回落到默认 %q,得到 %q", BackupScheduleDefaultTime, normalized.ScheduleTime)
|
||||
}
|
||||
if got := (BackupSchedule{ScheduleTime: " 03:30 "}).Normalize().ScheduleTime; got != "03:30" {
|
||||
t.Fatalf("应去掉两侧空白,得到 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 页面靠 SameHostPlan 判断"库里的设置"和"主机上生效的计划"是否一致(不一致显示"待下发")。
|
||||
func TestBackupScheduleSameHostPlan(t *testing.T) {
|
||||
base := BackupSchedule{Enabled: true, ScheduleTime: "03:30", RetentionDays: 7, RetentionMonths: 3}
|
||||
if !base.SameHostPlan(base) {
|
||||
t.Fatal("同一份计划应判定一致")
|
||||
}
|
||||
if base.SameHostPlan(BackupSchedule{Enabled: true, ScheduleTime: "03:30", RetentionDays: 7, RetentionMonths: 3, UpdatedBy: "someone"}) == false {
|
||||
t.Fatal("非下发字段(updated_by)不应影响一致性")
|
||||
}
|
||||
changes := []BackupSchedule{
|
||||
{Enabled: false, ScheduleTime: "03:30", RetentionDays: 7, RetentionMonths: 3},
|
||||
{Enabled: true, ScheduleTime: "04:30", RetentionDays: 7, RetentionMonths: 3},
|
||||
{Enabled: true, ScheduleTime: "03:30", RetentionDays: 14, RetentionMonths: 3},
|
||||
{Enabled: true, ScheduleTime: "03:30", RetentionDays: 7, RetentionMonths: 6},
|
||||
}
|
||||
for _, changed := range changes {
|
||||
if base.SameHostPlan(changed) {
|
||||
t.Errorf("以下变化应判定不一致: %+v", changed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupScheduleNextRunAfter(t *testing.T) {
|
||||
schedule := BackupSchedule{ScheduleTime: "03:30", RetentionDays: 7}
|
||||
morning := time.Date(2026, 10, 9, 1, 0, 0, 0, time.UTC)
|
||||
next, err := schedule.NextRunAfter(morning)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if next.Hour() != 3 || next.Minute() != 30 || next.Day() != 9 {
|
||||
t.Fatalf("当天还没到点应给当天,得到 %s", next)
|
||||
}
|
||||
evening := time.Date(2026, 10, 9, 22, 0, 0, 0, time.UTC)
|
||||
next, err = schedule.NextRunAfter(evening)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if next.Day() != 10 {
|
||||
t.Fatalf("已过点应给次日,得到 %s", next)
|
||||
}
|
||||
if _, err := (BackupSchedule{ScheduleTime: "99:99", RetentionDays: 7}).NextRunAfter(evening); err == nil {
|
||||
t.Fatal("非法时间应返回错误")
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,11 @@ func classifyRole(method, path string) string {
|
||||
if path == "/api/v1/production-kb-bindings" && method != http.MethodGet {
|
||||
return roleAdmin
|
||||
}
|
||||
// 整库备份的定时计划会落到数据面主机的 cron 上(可删数据、可占满磁盘),
|
||||
// 与"谁能改生产绑定"同级:读任何角色可看,写只能管理员。
|
||||
if strings.HasPrefix(path, "/api/v1/backup-schedule") && method != http.MethodGet {
|
||||
return roleAdmin
|
||||
}
|
||||
// Everything else that mutates (review, batch, seal, confirm, start, retry,
|
||||
// route visibility, disable) is engineer work; viewers cannot POST at all.
|
||||
return roleEngineer
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"knowledge-governance/internal/backuphost"
|
||||
"knowledge-governance/internal/governance"
|
||||
)
|
||||
|
||||
type fakeBackupScheduleStore struct {
|
||||
schedule governance.BackupSchedule
|
||||
loadErr error
|
||||
saveErr error
|
||||
saved []governance.BackupSchedule
|
||||
appliedAt []string
|
||||
}
|
||||
|
||||
func (store *fakeBackupScheduleStore) LoadBackupSchedule(context.Context) (governance.BackupSchedule, error) {
|
||||
if store.loadErr != nil {
|
||||
return governance.BackupSchedule{}, store.loadErr
|
||||
}
|
||||
return store.schedule, nil
|
||||
}
|
||||
|
||||
func (store *fakeBackupScheduleStore) SaveBackupSchedule(_ context.Context, schedule governance.BackupSchedule, _ string, _ time.Time) error {
|
||||
if store.saveErr != nil {
|
||||
return store.saveErr
|
||||
}
|
||||
store.schedule = schedule
|
||||
store.saved = append(store.saved, schedule)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *fakeBackupScheduleStore) MarkBackupScheduleApplied(_ context.Context, summary string, _ time.Time) error {
|
||||
store.appliedAt = append(store.appliedAt, summary)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeBackupHost struct {
|
||||
available bool
|
||||
target string
|
||||
show backuphost.ShowResult
|
||||
showErr error
|
||||
apply backuphost.ApplyResult
|
||||
applyErr error
|
||||
lastRun backuphost.RunResult
|
||||
lastRunErr error
|
||||
runErr error
|
||||
applied int
|
||||
runs int
|
||||
}
|
||||
|
||||
func (host *fakeBackupHost) Available() bool { return host.available }
|
||||
func (host *fakeBackupHost) Target() string { return host.target }
|
||||
func (host *fakeBackupHost) Show(context.Context) (backuphost.ShowResult, error) {
|
||||
return host.show, host.showErr
|
||||
}
|
||||
func (host *fakeBackupHost) Apply(context.Context, bool, string, int, int) (backuphost.ApplyResult, error) {
|
||||
host.applied++
|
||||
return host.apply, host.applyErr
|
||||
}
|
||||
func (host *fakeBackupHost) RunNow(context.Context) (backuphost.RunResult, error) {
|
||||
host.runs++
|
||||
return host.lastRun, host.runErr
|
||||
}
|
||||
func (host *fakeBackupHost) LastRun(context.Context) (backuphost.RunResult, error) {
|
||||
return host.lastRun, host.lastRunErr
|
||||
}
|
||||
|
||||
func backupConfig(store BackupScheduleStore, host BackupHost) Config {
|
||||
return Config{
|
||||
Store: nil,
|
||||
BackupSchedule: store,
|
||||
BackupHost: host,
|
||||
BackupRuns: &BackupRunTracker{},
|
||||
Now: func() time.Time { return time.Date(2026, 10, 9, 12, 0, 0, 0, time.UTC) },
|
||||
}
|
||||
}
|
||||
|
||||
func decodeData(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any {
|
||||
t.Helper()
|
||||
var payload struct {
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("响应不是 JSON: %v(%s)", err, recorder.Body.String())
|
||||
}
|
||||
return payload.Data
|
||||
}
|
||||
|
||||
var goldenSchedule = governance.BackupSchedule{
|
||||
Enabled: true, ScheduleTime: "03:30", RetentionDays: 7, RetentionMonths: 3, UpdatedBy: "admin",
|
||||
}
|
||||
|
||||
func TestHandleBackupScheduleReadsPlanWithoutProbingHost(t *testing.T) {
|
||||
host := &fakeBackupHost{available: true, target: "weknora-test", showErr: errors.New("ssh: connect timed out")}
|
||||
config := backupConfig(&fakeBackupScheduleStore{schedule: goldenSchedule}, host)
|
||||
recorder := httptest.NewRecorder()
|
||||
config.handleBackupSchedule(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/backup-schedule", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,得到 %d", recorder.Code)
|
||||
}
|
||||
data := decodeData(t, recorder)
|
||||
if data["schedule_time"] != "03:30" || data["retention_days"] != float64(7) || data["enabled"] != true {
|
||||
t.Fatalf("计划字段不对: %+v", data)
|
||||
}
|
||||
// 只读接口不探测主机:即使主机不可达也不应带错误(首屏不被 SSH 拖慢)。
|
||||
if data["host_error"] != "" {
|
||||
t.Fatalf("只读接口不应探测主机: %+v", data["host_error"])
|
||||
}
|
||||
if data["host_configured"] != true || data["host_target"] != "weknora-test" {
|
||||
t.Fatalf("应报告已接线与目标主机: %+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBackupScheduleHostReportsPlanAndLastRun(t *testing.T) {
|
||||
host := &fakeBackupHost{
|
||||
available: true, target: "weknora-test",
|
||||
show: backuphost.ShowResult{
|
||||
ScheduleEnabled: true, ScheduleTime: "03:30", KeepDays: 7, KeepMonths: 3,
|
||||
CronLines: []string{"30 03 * * * root /bin/bash /opt/weknora-test/logical-backup.sh"}, CronService: "active",
|
||||
ScriptPresent: true,
|
||||
},
|
||||
lastRun: backuphost.RunResult{
|
||||
Stamp: "20261009T052909Z", DurationSeconds: 33, TotalBytes: 778502144,
|
||||
Databases: []string{"weknora_test"}, FailedDatabases: []string{},
|
||||
},
|
||||
}
|
||||
config := backupConfig(&fakeBackupScheduleStore{schedule: goldenSchedule}, host)
|
||||
recorder := httptest.NewRecorder()
|
||||
config.handleBackupScheduleHost(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/backup-schedule/host", nil))
|
||||
data := decodeData(t, recorder)
|
||||
if data["host_reachable"] != true || data["host_cron_service"] != "active" {
|
||||
t.Fatalf("主机状态不对: %+v", data)
|
||||
}
|
||||
if data["last_run_stamp"] != "20261009T052909Z" || data["last_run_duration_seconds"] != float64(33) {
|
||||
t.Fatalf("最近一次结果不对: %+v", data)
|
||||
}
|
||||
// 主机上的计划与库里的设置一致 → in_sync=true(页面显示"已一致")
|
||||
if data["in_sync"] != true {
|
||||
t.Fatalf("一致的计划应判为已同步: %+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBackupScheduleHostFlagsDrift(t *testing.T) {
|
||||
host := &fakeBackupHost{
|
||||
available: true, target: "weknora-test",
|
||||
show: backuphost.ShowResult{ScheduleEnabled: true, ScheduleTime: "05:00", KeepDays: 7, KeepMonths: 3, ScriptPresent: true},
|
||||
}
|
||||
config := backupConfig(&fakeBackupScheduleStore{schedule: goldenSchedule}, host)
|
||||
recorder := httptest.NewRecorder()
|
||||
config.handleBackupScheduleHost(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/backup-schedule/host", nil))
|
||||
data := decodeData(t, recorder)
|
||||
if data["in_sync"] != false {
|
||||
t.Fatalf("主机是 05:00、设置是 03:30,应判为待下发: %+v", data)
|
||||
}
|
||||
if data["host_schedule_time"] != "05:00" {
|
||||
t.Fatalf("应回显主机上真实时间: %+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBackupScheduleHostUnconfiguredDegrades(t *testing.T) {
|
||||
config := backupConfig(&fakeBackupScheduleStore{schedule: goldenSchedule}, &fakeBackupHost{available: false})
|
||||
recorder := httptest.NewRecorder()
|
||||
config.handleBackupScheduleHost(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/backup-schedule/host", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("未接线应降级为 200(页面显示未接线),得到 %d", recorder.Code)
|
||||
}
|
||||
data := decodeData(t, recorder)
|
||||
if data["host_configured"] != false || data["in_sync"] != false {
|
||||
t.Fatalf("未接线应明确报告: %+v", data)
|
||||
}
|
||||
if !strings.Contains(data["host_error"].(string), "未挂载") {
|
||||
t.Fatalf("应说明缺少主机凭据: %+v", data["host_error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSaveBackupScheduleRejectsInvalidAndDoesNotTouchHost(t *testing.T) {
|
||||
store := &fakeBackupScheduleStore{schedule: goldenSchedule}
|
||||
host := &fakeBackupHost{available: true, target: "weknora-test"}
|
||||
config := backupConfig(store, host)
|
||||
body := `{"schedule_time":"25:99","retention_days":0,"retention_months":3,"enabled":true}`
|
||||
recorder := httptest.NewRecorder()
|
||||
config.handleSaveBackupSchedule(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/backup-schedule", strings.NewReader(body)))
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("非法计划应 400,得到 %d(%s)", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "定时时间必须是 HH:MM") {
|
||||
t.Fatalf("应给出中文原因: %s", recorder.Body.String())
|
||||
}
|
||||
if len(store.saved) != 0 {
|
||||
t.Fatal("非法计划不应写库")
|
||||
}
|
||||
if host.applied != 0 {
|
||||
t.Fatal("非法计划不应下发到主机")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSaveBackupScheduleAppliesToHost(t *testing.T) {
|
||||
store := &fakeBackupScheduleStore{schedule: governance.BackupSchedule{Enabled: false, ScheduleTime: "03:30", RetentionDays: 7, RetentionMonths: 3}}
|
||||
host := &fakeBackupHost{
|
||||
available: true, target: "weknora-test",
|
||||
apply: backuphost.ApplyResult{Enabled: true, Time: "04:15", Days: 14, Months: 6, NextRun: "2026-10-10T04:15:00+0800", CronFile: "/etc/cron.d/kb-logical-backup"},
|
||||
show: backuphost.ShowResult{ScheduleEnabled: true, ScheduleTime: "04:15", KeepDays: 14, KeepMonths: 6, ScriptPresent: true},
|
||||
}
|
||||
config := backupConfig(store, host)
|
||||
body := `{"enabled":true,"schedule_time":"04:15","retention_days":14,"retention_months":6}`
|
||||
recorder := httptest.NewRecorder()
|
||||
config.handleSaveBackupSchedule(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/backup-schedule", strings.NewReader(body)))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("期望 200,得到 %d(%s)", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if len(store.saved) != 1 || store.saved[0].ScheduleTime != "04:15" || !store.saved[0].Enabled {
|
||||
t.Fatalf("应把新计划写库: %+v", store.saved)
|
||||
}
|
||||
if host.applied != 1 {
|
||||
t.Fatalf("默认应下发到主机,实际 %d 次", host.applied)
|
||||
}
|
||||
if len(store.appliedAt) != 1 || !strings.Contains(store.appliedAt[0], "每天 04:15") {
|
||||
t.Fatalf("应记录下发摘要: %+v", store.appliedAt)
|
||||
}
|
||||
var payload struct {
|
||||
Applied bool `json:"applied"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !payload.Applied || payload.Data["in_sync"] != true {
|
||||
t.Fatalf("下发后应报告 applied=true 且已一致: %+v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSaveBackupScheduleWithoutHostCredentials(t *testing.T) {
|
||||
store := &fakeBackupScheduleStore{schedule: goldenSchedule}
|
||||
host := &fakeBackupHost{available: false}
|
||||
config := backupConfig(store, host)
|
||||
recorder := httptest.NewRecorder()
|
||||
config.handleSaveBackupSchedule(recorder, httptest.NewRequest(http.MethodPut, "/api/v1/backup-schedule",
|
||||
strings.NewReader(`{"schedule_time":"04:15","retention_days":14,"retention_months":6,"enabled":true}`)))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("未接线时保存仍应成功(只是不下发),得到 %d", recorder.Code)
|
||||
}
|
||||
var payload struct {
|
||||
Applied bool `json:"applied"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload.Applied {
|
||||
t.Fatal("未接线不应报告已下发")
|
||||
}
|
||||
if host.applied != 0 {
|
||||
t.Fatal("未接线不应调用主机")
|
||||
}
|
||||
if len(store.saved) != 1 {
|
||||
t.Fatal("计划仍应写库")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleApplyBackupSchedulePushesCurrentSettings(t *testing.T) {
|
||||
store := &fakeBackupScheduleStore{schedule: goldenSchedule}
|
||||
host := &fakeBackupHost{available: true, target: "weknora-test",
|
||||
apply: backuphost.ApplyResult{Enabled: true, Time: "03:30", Days: 7, Months: 3}}
|
||||
config := backupConfig(store, host)
|
||||
recorder := httptest.NewRecorder()
|
||||
config.handleApplyBackupSchedule(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/backup-schedule/apply", nil))
|
||||
if recorder.Code != http.StatusOK || host.applied != 1 {
|
||||
t.Fatalf("应重新下发一次: code=%d applied=%d", recorder.Code, host.applied)
|
||||
}
|
||||
if len(store.saved) != 0 {
|
||||
t.Fatal("重新下发不应改库里的设置")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRunBackupNowIsAsyncAndSingleFlight(t *testing.T) {
|
||||
store := &fakeBackupScheduleStore{schedule: goldenSchedule}
|
||||
host := &fakeBackupHost{available: true, target: "weknora-test",
|
||||
lastRun: backuphost.RunResult{Stamp: "20261009T060000Z", DurationSeconds: 34}}
|
||||
config := backupConfig(store, host)
|
||||
// 先占住在途标记:重复点击不能跑第二次整库 dump。
|
||||
config.BackupRuns.Start(config.Now())
|
||||
recorder := httptest.NewRecorder()
|
||||
config.handleRunBackupNow(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/backup-schedule/run", nil))
|
||||
if recorder.Code != http.StatusAccepted {
|
||||
t.Fatalf("期望 202(异步),得到 %d", recorder.Code)
|
||||
}
|
||||
data := decodeData(t, recorder)
|
||||
if data["started"] != false || data["running"] != true {
|
||||
t.Fatalf("已在跑时不应重复启动: %+v", data)
|
||||
}
|
||||
if host.runs != 0 {
|
||||
t.Fatal("已被占住时不应触发主机执行")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRunBackupNowStartsAndFinishesTracking(t *testing.T) {
|
||||
host := &fakeBackupHost{available: true, target: "weknora-test",
|
||||
lastRun: backuphost.RunResult{Stamp: "20261009T060000Z", DurationSeconds: 34}}
|
||||
config := backupConfig(&fakeBackupScheduleStore{schedule: goldenSchedule}, host)
|
||||
recorder := httptest.NewRecorder()
|
||||
config.handleRunBackupNow(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/backup-schedule/run", nil))
|
||||
if recorder.Code != http.StatusAccepted {
|
||||
t.Fatalf("期望 202,得到 %d", recorder.Code)
|
||||
}
|
||||
if data := decodeData(t, recorder); data["started"] != true {
|
||||
t.Fatalf("应报告已启动: %+v", data)
|
||||
}
|
||||
// 后台 goroutine 完成(假 RunNow 立即返回)。
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if running, _, _, _ := config.BackupRuns.snapshot(); !running {
|
||||
if host.runs != 1 {
|
||||
t.Fatalf("应恰好执行一次,实际 %d", host.runs)
|
||||
}
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("在途标记未在预期时间内结束")
|
||||
}
|
||||
|
||||
func TestHandleRunBackupNowWithoutHostCredentials(t *testing.T) {
|
||||
config := backupConfig(&fakeBackupScheduleStore{schedule: goldenSchedule}, &fakeBackupHost{available: false})
|
||||
recorder := httptest.NewRecorder()
|
||||
config.handleRunBackupNow(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/backup-schedule/run", nil))
|
||||
if recorder.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("未接线应 503,得到 %d", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 写操作必须是管理员:计划会落到数据面主机的 cron(能删数据、能占满磁盘)。
|
||||
func TestBackupScheduleRoleClassification(t *testing.T) {
|
||||
cases := []struct {
|
||||
method, path, want string
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/backup-schedule", roleViewer},
|
||||
{http.MethodGet, "/api/v1/backup-schedule/host", roleViewer},
|
||||
{http.MethodPut, "/api/v1/backup-schedule", roleAdmin},
|
||||
{http.MethodPost, "/api/v1/backup-schedule/apply", roleAdmin},
|
||||
{http.MethodPost, "/api/v1/backup-schedule/run", roleAdmin},
|
||||
}
|
||||
for _, item := range cases {
|
||||
if got := classifyRole(item.method, item.path); got != item.want {
|
||||
t.Errorf("%s %s: 期望 %s,得到 %s", item.method, item.path, item.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"knowledge-governance/internal/backuphost"
|
||||
"knowledge-governance/internal/governance"
|
||||
"knowledge-governance/internal/jobs"
|
||||
"knowledge-governance/internal/monitoring"
|
||||
@@ -108,6 +109,11 @@ type Config struct {
|
||||
KnowledgeBases KnowledgeBaseLister
|
||||
// ProductionBindings is the 源库 → 目标库 mapping store; ProductionKnowledgeBases
|
||||
// enumerates the production-side bases the operator can bind.
|
||||
// BackupSchedule 是「系统设置 → 存储与备份」的计划存储;BackupHost 负责在目标库
|
||||
// 所在主机上读回/下发计划并触发一次整库备份(未接线时页面降级展示)。
|
||||
BackupSchedule BackupScheduleStore
|
||||
BackupHost BackupHost
|
||||
BackupRuns *BackupRunTracker
|
||||
ProductionBindings ProductionBindingStore
|
||||
ProductionKnowledgeBases ProductionKnowledgeBaseLister
|
||||
ProductionWeKnoraBaseURL string
|
||||
@@ -132,6 +138,13 @@ func NewHandler(config Config) (http.Handler, error) {
|
||||
if config.Monitor == nil {
|
||||
config.Monitor = monitoring.NewSimulationProvider(config.Now)
|
||||
}
|
||||
if config.BackupRuns == nil {
|
||||
config.BackupRuns = &BackupRunTracker{}
|
||||
}
|
||||
if config.BackupHost == nil {
|
||||
// 未挂载主机凭据:Available()=false,页面显示"未接线"而不是 500。
|
||||
config.BackupHost = backuphost.New(backuphost.Config{})
|
||||
}
|
||||
for _, knowledgeBaseID := range []string{config.InternalRoutingKnowledgeBaseID, config.ExternalRoutingKnowledgeBaseID} {
|
||||
if knowledgeBaseID != "" && !validCatalogID(knowledgeBaseID) {
|
||||
return nil, errors.New("routing knowledge base ID is invalid")
|
||||
@@ -236,6 +249,41 @@ func NewHandler(config Config) (http.Handler, error) {
|
||||
}
|
||||
config.handleSaveRoutingBindings(w, r)
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/backup-schedule", func(w http.ResponseWriter, r *http.Request) {
|
||||
if config.BackupSchedule == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "backup schedule storage unavailable"})
|
||||
return
|
||||
}
|
||||
config.handleBackupSchedule(w, r)
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/backup-schedule/host", func(w http.ResponseWriter, r *http.Request) {
|
||||
if config.BackupSchedule == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "backup schedule storage unavailable"})
|
||||
return
|
||||
}
|
||||
config.handleBackupScheduleHost(w, r)
|
||||
})
|
||||
mux.HandleFunc("PUT /api/v1/backup-schedule", func(w http.ResponseWriter, r *http.Request) {
|
||||
if config.BackupSchedule == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "backup schedule storage unavailable"})
|
||||
return
|
||||
}
|
||||
config.handleSaveBackupSchedule(w, r)
|
||||
})
|
||||
mux.HandleFunc("POST /api/v1/backup-schedule/apply", func(w http.ResponseWriter, r *http.Request) {
|
||||
if config.BackupSchedule == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "backup schedule storage unavailable"})
|
||||
return
|
||||
}
|
||||
config.handleApplyBackupSchedule(w, r)
|
||||
})
|
||||
mux.HandleFunc("POST /api/v1/backup-schedule/run", func(w http.ResponseWriter, r *http.Request) {
|
||||
if config.BackupSchedule == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "backup schedule storage unavailable"})
|
||||
return
|
||||
}
|
||||
config.handleRunBackupNow(w, r)
|
||||
})
|
||||
mux.HandleFunc("GET /api/v1/production-kb-bindings", func(w http.ResponseWriter, r *http.Request) {
|
||||
if config.ProductionBindings == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "production bindings storage unavailable"})
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS backup_schedule;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- 整库逻辑备份(pg_dump 目录格式,scripts/logical-backup.sh)的定时计划由平台
|
||||
-- 「系统设置 → 存储与备份」管理:定时时间与保留策略存在这里,下发后在**目标库所在主机**
|
||||
-- 上落成 /etc/cron.d 条目。备份必须在数据面主机本地执行(内网直连 RDS、内网写盘),
|
||||
-- 平台只下发计划并读回结果,见 docs/PRODUCTION_BACKUP_RECOVERY.md。
|
||||
--
|
||||
-- 单行表:last_applied_* 记录最后一次成功下发的结果,供页面显示"当前主机上的计划"。
|
||||
CREATE TABLE backup_schedule (
|
||||
id TEXT PRIMARY KEY DEFAULT 'active' CHECK (id = 'active'),
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
schedule_time VARCHAR(5) NOT NULL DEFAULT '03:30',
|
||||
retention_days INTEGER NOT NULL DEFAULT 7,
|
||||
retention_months INTEGER NOT NULL DEFAULT 3,
|
||||
last_applied_at TIMESTAMPTZ,
|
||||
last_applied_summary TEXT NOT NULL DEFAULT '',
|
||||
updated_by VARCHAR(64) NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO backup_schedule(id, updated_by) VALUES('active', 'system:migration')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env bash
|
||||
# 在"目标库所在主机"上安装/更新**整库逻辑备份**的定时计划(幂等)。
|
||||
# 平台的「系统设置 → 存储与备份」通过 SSH 下发时会调用它;也可在主机上手工执行。
|
||||
#
|
||||
# 用法(在目标主机上,需要 root):
|
||||
# bash install_backup_schedule.sh --show # 只打印当前计划
|
||||
# bash install_backup_schedule.sh --time 03:30 --days 7 --months 3
|
||||
# bash install_backup_schedule.sh --disable # 关闭定时(保留已生成的备份)
|
||||
#
|
||||
# 落盘:
|
||||
# /etc/knowledge-governance/backup-schedule.env 计划参数(0600)
|
||||
# /etc/cron.d/kb-logical-backup cron 条目(0644,root:root)
|
||||
# $KB_BACKUP_ROOT/logs/logical-backup.log 运行日志
|
||||
#
|
||||
# 输出最后一行固定为 SCHEDULE_APPLIED …,供平台解析核对。
|
||||
set -euo pipefail
|
||||
|
||||
if [ -f /etc/knowledge-governance/backup.env ]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/knowledge-governance/backup.env
|
||||
fi
|
||||
KB_BACKUP_ROOT="${KB_BACKUP_ROOT:-/srv/kb-backups}"
|
||||
BACKUP_SCRIPT="${BACKUP_SCRIPT:-/opt/weknora-test/logical-backup.sh}"
|
||||
SCHEDULE_ENV="/etc/knowledge-governance/backup-schedule.env"
|
||||
CRON_FILE="/etc/cron.d/kb-logical-backup"
|
||||
LOG_FILE="$KB_BACKUP_ROOT/logs/logical-backup.log"
|
||||
|
||||
TIME=""; DAYS=""; MONTHS=""; MODE="apply"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--time) TIME="${2:-}"; shift 2 ;;
|
||||
--days) DAYS="${2:-}"; shift 2 ;;
|
||||
--months) MONTHS="${2:-}"; shift 2 ;;
|
||||
--disable) MODE="disable"; shift ;;
|
||||
--show) MODE="show"; shift ;;
|
||||
*) echo "未知参数: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
show_state() {
|
||||
echo "备份脚本: $BACKUP_SCRIPT $([ -x "$BACKUP_SCRIPT" ] || [ -f "$BACKUP_SCRIPT" ] && echo '(存在)' || echo '(缺失)')"
|
||||
if [ -f "$SCHEDULE_ENV" ]; then
|
||||
echo "计划参数: $(grep -v '^#' "$SCHEDULE_ENV" | paste -sd' ' -)"
|
||||
else
|
||||
echo "计划参数: 未配置"
|
||||
fi
|
||||
if [ -f "$CRON_FILE" ]; then
|
||||
echo "cron 条目:"
|
||||
# 只列计划行(去掉 SHELL=/PATH= 这类环境行),否则平台会把环境变量当计划回显
|
||||
grep -E '^[0-9*@]' "$CRON_FILE" | sed 's/^/ /'
|
||||
else
|
||||
echo "cron 条目: 未安装(定时关闭)"
|
||||
fi
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
echo "cron 服务: $(systemctl is-active cron 2>/dev/null || systemctl is-active crond 2>/dev/null || echo unknown)"
|
||||
fi
|
||||
echo "备份根目录: $KB_BACKUP_ROOT($(du -sh "$KB_BACKUP_ROOT/rds-logical" 2>/dev/null | cut -f1 || echo 0) 已用)"
|
||||
}
|
||||
|
||||
if [ "$MODE" = "show" ]; then
|
||||
show_state
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$MODE" = "disable" ]; then
|
||||
rm -f "$CRON_FILE"
|
||||
if [ -f "$SCHEDULE_ENV" ]; then
|
||||
sed -i 's/^KB_BACKUP_SCHEDULE_ENABLED=.*/KB_BACKUP_SCHEDULE_ENABLED=0/' "$SCHEDULE_ENV" || true
|
||||
fi
|
||||
echo "已关闭定时备份(已生成的备份保留)。"
|
||||
echo "SCHEDULE_APPLIED enabled=0"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---- 参数校验(错就拒绝,不落半成品)----
|
||||
case "$TIME" in
|
||||
[0-2][0-9]:[0-5][0-9]) ;;
|
||||
*) echo "--time 必须是 HH:MM(00:00~23:59),收到 '$TIME'" >&2; exit 2 ;;
|
||||
esac
|
||||
case "$DAYS" in ''|*[!0-9]*) echo "--days 必须是整数" >&2; exit 2 ;; esac
|
||||
case "$MONTHS" in ''|*[!0-9]*) echo "--months 必须是整数" >&2; exit 2 ;; esac
|
||||
[ "$DAYS" -ge 1 ] && [ "$DAYS" -le 365 ] || { echo "--days 需在 1~365" >&2; exit 2; }
|
||||
[ "$MONTHS" -ge 0 ] && [ "$MONTHS" -le 24 ] || { echo "--months 需在 0~24" >&2; exit 2; }
|
||||
[ -f "$BACKUP_SCRIPT" ] || { echo "备份脚本不存在: $BACKUP_SCRIPT" >&2; exit 2; }
|
||||
|
||||
HOUR="${TIME%%:*}"; MINUTE="${TIME##*:}"
|
||||
# cron 里不能出现未转义的 %;这里全程不使用 % 以免歧义。
|
||||
CRON_LINE="$MINUTE $HOUR * * * root /bin/bash $BACKUP_SCRIPT >> $LOG_FILE 2>&1"
|
||||
|
||||
mkdir -p "$KB_BACKUP_ROOT/logs" "$(dirname "$SCHEDULE_ENV")"
|
||||
cat >"$SCHEDULE_ENV" <<EOF
|
||||
# 由 install_backup_schedule.sh 写入(平台「系统设置 → 存储与备份」下发)
|
||||
KB_BACKUP_SCHEDULE_ENABLED=1
|
||||
KB_BACKUP_SCHEDULE_TIME=$TIME
|
||||
KB_BACKUP_KEEP_DAYS=$DAYS
|
||||
KB_BACKUP_KEEP_MONTHS=$MONTHS
|
||||
KB_BACKUP_SCRIPT=$BACKUP_SCRIPT
|
||||
KB_BACKUP_LOG=$LOG_FILE
|
||||
EOF
|
||||
chmod 600 "$SCHEDULE_ENV"
|
||||
|
||||
cat >"$CRON_FILE" <<EOF
|
||||
# 整库逻辑备份(目标库所在主机本地执行;参数来自 $SCHEDULE_ENV)
|
||||
SHELL=/bin/bash
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
$CRON_LINE
|
||||
EOF
|
||||
chmod 644 "$CRON_FILE"
|
||||
chown root:root "$CRON_FILE" 2>/dev/null || true
|
||||
|
||||
NEXT_RUN="$(date -d "today $TIME" '+%Y-%m-%dT%H:%M:%S%z' 2>/dev/null || echo unknown)"
|
||||
if [ "$NEXT_RUN" != "unknown" ] && [ "$(date -d "today $TIME" +%s)" -le "$(date +%s)" ]; then
|
||||
NEXT_RUN="$(date -d "tomorrow $TIME" '+%Y-%m-%dT%H:%M:%S%z')"
|
||||
fi
|
||||
|
||||
echo "计划已下发:每天 $TIME(保留 $DAYS 天,另按自然月保留 $MONTHS 份)"
|
||||
echo "cron 文件:$CRON_FILE"
|
||||
echo "下次执行:$NEXT_RUN"
|
||||
echo "SCHEDULE_APPLIED enabled=1 time=$TIME days=$DAYS months=$MONTHS next=$NEXT_RUN cron=$CRON_FILE"
|
||||
@@ -35,8 +35,16 @@ BACKUP_ROOT="${BACKUP_ROOT:-$KB_BACKUP_ROOT/rds-logical}"
|
||||
APP_CONTAINER="${APP_CONTAINER:-weknora-test-v071-app-1}"
|
||||
PG_IMAGE="${PG_IMAGE:-postgres:17.9-alpine}"
|
||||
JOBS="${JOBS:-4}"
|
||||
KEEP_DAILY="${KEEP_DAILY:-7}"
|
||||
KEEP_MONTHS="${KEEP_MONTHS:-3}"
|
||||
# 定时计划(平台「存储与备份」下发到 /etc/knowledge-governance/backup-schedule.env)优先,
|
||||
# 显式传入的 KEEP_DAILY / KEEP_MONTHS 仍可覆盖(便于手工跑一次性保留策略)。
|
||||
if [ -z "${KEEP_DAILY:-}" ] || [ -z "${KEEP_MONTHS:-}" ]; then
|
||||
if [ -f /etc/knowledge-governance/backup-schedule.env ]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/knowledge-governance/backup-schedule.env
|
||||
fi
|
||||
fi
|
||||
KEEP_DAILY="${KEEP_DAILY:-${KB_BACKUP_KEEP_DAYS:-7}}"
|
||||
KEEP_MONTHS="${KEEP_MONTHS:-${KB_BACKUP_KEEP_MONTHS:-3}}"
|
||||
DRY_RUN="${DRY_RUN:-0}"
|
||||
DBS="${DBS:-}"
|
||||
EXCLUDE_DBS="${EXCLUDE_DBS:-rdsadmin}"
|
||||
@@ -74,9 +82,21 @@ fi
|
||||
if [ -z "$DBS" ]; then echo "没有可备份的库" >&2; exit 1; fi
|
||||
|
||||
TARGET="$BACKUP_ROOT/$STAMP"
|
||||
|
||||
# 失败或被中断(含被 head/管道 SIGPIPE 杀掉):把目录显式标记为半成品,
|
||||
# 这样"最近一次备份"的选择器与人都不会把它误当成功结果。
|
||||
mark_incomplete() {
|
||||
if [ -d "$TARGET" ] && [ ! -f "$TARGET/MANIFEST.json" ]; then
|
||||
rm -rf "${TARGET}.incomplete"
|
||||
mv "$TARGET" "${TARGET}.incomplete" 2>/dev/null || true
|
||||
echo "本次备份未完成,已标记为 ${TARGET}.incomplete" >&2
|
||||
fi
|
||||
}
|
||||
trap mark_incomplete EXIT
|
||||
echo "备份目标: $TARGET"
|
||||
echo "库列表 : $DBS"
|
||||
echo "镜像 : $PG_IMAGE (并行 $JOBS)"
|
||||
echo "保留策略: 每日 $KEEP_DAILY 份 / 每月 $KEEP_MONTHS 份"
|
||||
if [ "$DRY_RUN" = "1" ]; then echo "DRY_RUN=1,不执行"; exit 0; fi
|
||||
|
||||
mkdir -p "$TARGET"
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { BackupScheduleView, backupDaysValid, backupMonthsValid, backupTimeValid, formatBackupBytes } from './BackupScheduleSection'
|
||||
import type { BackupSchedule } from './backupSchedule'
|
||||
|
||||
const base: BackupSchedule = {
|
||||
enabled: true, schedule_time: '03:30', retention_days: 7, retention_months: 3,
|
||||
updated_by: 'yaozhanghao', updated_at: '2026-10-09T12:00:00Z',
|
||||
last_applied_at: '2026-10-09T12:00:00Z', last_applied_summary: '每天 03:30 · 保留 7 天 · 每月归档 3 份',
|
||||
host_configured: true, host_target: 'weknora-test', host_reachable: true, host_error: '',
|
||||
host_schedule_enabled: true, host_schedule_time: '03:30', host_keep_days: 7, host_keep_months: 3,
|
||||
host_cron_lines: ['30 03 * * * root /bin/bash /opt/weknora-test/logical-backup.sh >> /srv/kb-backups/logs/logical-backup.log 2>&1'],
|
||||
host_cron_service: 'active', host_script_present: true,
|
||||
last_run_stamp: '20261009T052909Z', last_run_created_at: '2026-10-09T05:29:09Z', last_run_duration_seconds: 33,
|
||||
last_run_total_bytes: 778_502_144, last_run_databases: ['weknora_test', 'dobotai_prod'],
|
||||
last_run_failed_databases: [], last_run_error: '', in_sync: true,
|
||||
applied: true, apply_error: '', running: false, running_started_at: '', run_last_error: '',
|
||||
}
|
||||
|
||||
const view = (overrides: Partial<BackupSchedule> = {}, extra: Record<string, unknown> = {}) => {
|
||||
const schedule = { ...base, ...overrides }
|
||||
return renderToStaticMarkup(<BackupScheduleView
|
||||
schedule={schedule}
|
||||
isAdmin={(extra.isAdmin as boolean) ?? true}
|
||||
loading={false} probing={false} busy={(extra.busy as 'save' | 'apply' | 'run' | null) ?? null}
|
||||
error={(extra.error as string | null) ?? null} message={(extra.message as string) ?? ''}
|
||||
enabledDraft={(extra.enabledDraft as boolean) ?? schedule.enabled}
|
||||
timeDraft={(extra.timeDraft as string) ?? schedule.schedule_time}
|
||||
daysDraft={(extra.daysDraft as string) ?? String(schedule.retention_days)}
|
||||
monthsDraft={(extra.monthsDraft as string) ?? String(schedule.retention_months)}
|
||||
onEnabledChange={vi.fn()} onTimeChange={vi.fn()} onDaysChange={vi.fn()} onMonthsChange={vi.fn()}
|
||||
onSave={vi.fn()} onApply={vi.fn()} onRunNow={vi.fn()} onReload={vi.fn()} />)
|
||||
}
|
||||
|
||||
describe('BackupScheduleView', () => {
|
||||
it('管理员看到定时时间/储存天数与下发按钮,并显示主机上生效的计划与最近一次结果', () => {
|
||||
const html = view()
|
||||
for (const text of ['存储与备份', '目标库所在主机', '定时时间', '储存天数', '每月归档保留',
|
||||
'保存并下发到主机', '重新下发当前设置', '立即备份一次', 'weknora-test · 已连接',
|
||||
'每天 03:30 · 保留 7 天 · 每月 3 份', '已一致', '20261009T052909Z', '33 秒', '742.4 MiB',
|
||||
'weknora_test、dobotai_prod']) expect(html).toContain(text)
|
||||
expect(html).toContain('type="time"')
|
||||
expect(html).toContain('value="03:30"')
|
||||
})
|
||||
|
||||
it('非管理员只读:没有保存/下发/立即备份按钮,只给当前设置', () => {
|
||||
const html = view({}, { isAdmin: false })
|
||||
expect(html).toContain('定时备份设置仅管理员可修改')
|
||||
expect(html).not.toContain('保存并下发到主机')
|
||||
expect(html).not.toContain('立即备份一次')
|
||||
})
|
||||
|
||||
it('未接线:显式说明缺少主机凭据,下发与立即备份不可点', () => {
|
||||
const html = view({ host_configured: false, host_reachable: false, host_error: '目标主机凭据未挂载(BACKUP_HOST_SSH_CONFIG / BACKUP_HOST_TARGET)', in_sync: false })
|
||||
expect(html).toContain('未接线(缺少主机凭据)')
|
||||
expect(html).toContain('待下发')
|
||||
expect(html).toContain('目标主机凭据未挂载')
|
||||
expect(html).toContain('disabled')
|
||||
})
|
||||
|
||||
it('待下发(主机上的计划与设置不一致)时提示', () => {
|
||||
const html = view({ host_schedule_time: '05:00', in_sync: false })
|
||||
expect(html).toContain('待下发')
|
||||
expect(html).toContain('每天 05:00')
|
||||
})
|
||||
|
||||
it('在途备份:按钮变"备份进行中…"并给出开始时间', () => {
|
||||
const html = view({ running: true, running_started_at: '2026-10-09T12:30:00Z' })
|
||||
expect(html).toContain('备份进行中…')
|
||||
expect(html).toContain('正在备份')
|
||||
})
|
||||
|
||||
it('非法输入就地拦截,且保存按钮在未改动时不可点', () => {
|
||||
const html = view({}, { timeDraft: '25:99', daysDraft: '0' })
|
||||
expect(html).toContain('定时时间需为 HH:MM(00:00~23:59),储存天数 1~365,每月归档 0~24。')
|
||||
expect(html).toContain('disabled')
|
||||
expect(backupTimeValid('03:30')).toBe(true)
|
||||
expect(backupTimeValid('24:00')).toBe(false)
|
||||
expect(backupDaysValid(0)).toBe(false)
|
||||
expect(backupDaysValid(365)).toBe(true)
|
||||
expect(backupMonthsValid(0)).toBe(true)
|
||||
expect(backupMonthsValid(25)).toBe(false)
|
||||
})
|
||||
|
||||
it('展示失败的库与最近一次读取错误', () => {
|
||||
const html = view({ last_run_failed_databases: ['rdsadmin'], last_run_error: '清单无法解析' })
|
||||
expect(html).toContain('rdsadmin')
|
||||
expect(html).toContain('清单无法解析')
|
||||
})
|
||||
|
||||
it('体积格式化:MiB/GiB', () => {
|
||||
expect(formatBackupBytes(0)).toBe('—')
|
||||
expect(formatBackupBytes(1024 * 1024)).toBe('1.0 MiB')
|
||||
expect(formatBackupBytes(2 * 1024 * 1024 * 1024)).toBe('2.00 GiB')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useOptionalAuth } from './auth'
|
||||
import { applyBackupSchedule, fetchBackupSchedule, fetchBackupScheduleHost, runBackupNow, saveBackupSchedule } from './backupSchedule'
|
||||
import type { BackupSchedule } from './backupSchedule'
|
||||
|
||||
export const formatBackupBytes = (bytes: number): string => {
|
||||
if (!bytes) return '—'
|
||||
const mib = bytes / (1024 * 1024)
|
||||
return mib >= 1024 ? `${(mib / 1024).toFixed(2)} GiB` : `${mib.toFixed(1)} MiB`
|
||||
}
|
||||
|
||||
export const formatBackupMoment = (value: string): string => {
|
||||
if (!value) return '—'
|
||||
const parsed = new Date(value)
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
export const backupTimeValid = (value: string): boolean => /^([01][0-9]|2[0-3]):[0-5][0-9]$/.test(value)
|
||||
export const backupDaysValid = (value: number): boolean => Number.isInteger(value) && value >= 1 && value <= 365
|
||||
export const backupMonthsValid = (value: number): boolean => Number.isInteger(value) && value >= 0 && value <= 24
|
||||
|
||||
export interface BackupScheduleViewProps {
|
||||
schedule: BackupSchedule
|
||||
isAdmin: boolean
|
||||
loading: boolean
|
||||
probing: boolean
|
||||
busy: 'save' | 'apply' | 'run' | null
|
||||
error: string | null
|
||||
message: string
|
||||
enabledDraft: boolean
|
||||
timeDraft: string
|
||||
daysDraft: string
|
||||
monthsDraft: string
|
||||
onEnabledChange: (next: boolean) => void
|
||||
onTimeChange: (next: string) => void
|
||||
onDaysChange: (next: string) => void
|
||||
onMonthsChange: (next: string) => void
|
||||
onSave: () => void
|
||||
onApply: () => void
|
||||
onRunNow: () => void
|
||||
onReload: () => void
|
||||
}
|
||||
|
||||
// 纯视图:所有状态由 props 驱动,便于在测试里直接渲染各种状态(管理员/只读、已一致/待下发、在途)。
|
||||
export function BackupScheduleView(props: BackupScheduleViewProps) {
|
||||
const {
|
||||
schedule, isAdmin, loading, probing, busy, error, message,
|
||||
enabledDraft, timeDraft, daysDraft, monthsDraft,
|
||||
onEnabledChange, onTimeChange, onDaysChange, onMonthsChange, onSave, onApply, onRunNow, onReload,
|
||||
} = props
|
||||
const days = Number(daysDraft)
|
||||
const months = Number(monthsDraft)
|
||||
const valid = backupTimeValid(timeDraft) && backupDaysValid(days) && backupMonthsValid(months)
|
||||
const dirty = enabledDraft !== schedule.enabled || timeDraft !== schedule.schedule_time ||
|
||||
days !== schedule.retention_days || months !== schedule.retention_months
|
||||
const hostLine = !schedule.host_configured
|
||||
? '未接线(缺少主机凭据)'
|
||||
: schedule.host_reachable
|
||||
? `${schedule.host_target} · 已连接`
|
||||
: `${schedule.host_target} · 连接失败`
|
||||
|
||||
return <section className="settings-panel wide storage-settings-panel" id="settings-storage">
|
||||
<div className="settings-section-heading"><div>
|
||||
<h2>存储与备份</h2>
|
||||
<p>整库逻辑备份(pg_dump 目录格式)在<strong>目标库所在主机</strong>上本地执行:这里设置定时时间与储存天数,保存即下发到主机 cron,并可读回主机上生效的计划与最近一次结果。</p>
|
||||
</div><button onClick={onReload} disabled={loading || probing}>{loading || probing ? '读取中…' : '重新读取'}</button></div>
|
||||
{error ? <p className="error-message" role="alert">{error}</p> : null}
|
||||
<div className="storage-settings-grid">
|
||||
<dl>
|
||||
<dt>目标主机</dt><dd>{hostLine}</dd>
|
||||
<dt>主机 cron</dt><dd>{schedule.host_cron_service ? `服务 ${schedule.host_cron_service}` : '—'}{schedule.host_script_present ? '' : ' · 备份脚本缺失'}</dd>
|
||||
<dt>最近一次备份</dt><dd>{schedule.last_run_stamp || '—'}{schedule.last_run_created_at ? ` · ${formatBackupMoment(schedule.last_run_created_at)}` : ''}</dd>
|
||||
<dt>备份耗时 / 体积</dt><dd>{schedule.last_run_stamp ? `${schedule.last_run_duration_seconds} 秒 · ${formatBackupBytes(schedule.last_run_total_bytes)}` : '—'}</dd>
|
||||
</dl>
|
||||
<dl>
|
||||
<dt>覆盖的库</dt><dd>{schedule.last_run_databases.length ? schedule.last_run_databases.join('、') : '—'}</dd>
|
||||
<dt>失败的库</dt><dd>{schedule.last_run_failed_databases.length ? schedule.last_run_failed_databases.join('、') : '无'}</dd>
|
||||
<dt>主机上生效的计划</dt><dd>{schedule.host_schedule_enabled ? `每天 ${schedule.host_schedule_time} · 保留 ${schedule.host_keep_days} 天 · 每月 ${schedule.host_keep_months} 份` : schedule.host_configured ? '未启用定时' : '—'}</dd>
|
||||
<dt>与设置是否一致</dt><dd>{schedule.in_sync ? '已一致' : '待下发'}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
{schedule.host_error ? <p className="hint">主机探测:{schedule.host_error}</p> : null}
|
||||
{schedule.last_run_error ? <p className="hint">最近一次结果读取:{schedule.last_run_error}</p> : null}
|
||||
{schedule.host_cron_lines.length ? <p className="hint">主机 cron 条目:<code>{schedule.host_cron_lines.join(' ; ')}</code></p> : null}
|
||||
{schedule.last_applied_at ? <p className="hint">最后下发:{formatBackupMoment(schedule.last_applied_at)}{schedule.last_applied_summary ? ` · ${schedule.last_applied_summary}` : ''}</p> : null}
|
||||
<p className="hint">留档分层:PG 行级留档(发布前,含 ES 文档)+ ES 快照仓 + OSS 版本控制 + 本节的整库逻辑备份 + RDS 托管备份(控制台配置)。详见 <code>docs/PRODUCTION_BACKUP_RECOVERY.md</code>。</p>
|
||||
{isAdmin ? <div className="kb-binding-editor">
|
||||
<label>定时时间 <input type="time" value={timeDraft} onChange={(event) => onTimeChange(event.target.value)} aria-label="定时时间" /></label>
|
||||
<label>储存天数 <input type="number" min={1} max={365} value={daysDraft} onChange={(event) => onDaysChange(event.target.value)} aria-label="储存天数" /></label>
|
||||
<label>每月归档保留 <input type="number" min={0} max={24} value={monthsDraft} onChange={(event) => onMonthsChange(event.target.value)} aria-label="每月归档保留" /></label>
|
||||
<label className="checkbox-label"><input type="checkbox" checked={enabledDraft} onChange={(event) => onEnabledChange(event.target.checked)} /> 启用定时整库备份</label>
|
||||
{!valid ? <p className="error-message">定时时间需为 HH:MM(00:00~23:59),储存天数 1~365,每月归档 0~24。</p> : null}
|
||||
<button onClick={onSave} disabled={busy !== null || !valid || !dirty}>{busy === 'save' ? '正在保存并下发…' : '保存并下发到主机'}</button>
|
||||
<button onClick={onApply} disabled={busy !== null || !schedule.host_configured}>{busy === 'apply' ? '正在下发…' : '重新下发当前设置'}</button>
|
||||
<button onClick={onRunNow} disabled={busy !== null || !schedule.host_configured || schedule.running}>{schedule.running ? '备份进行中…' : '立即备份一次'}</button>
|
||||
{message ? <p className="success-message" role="status">{message}</p> : null}
|
||||
{schedule.running ? <p className="hint">正在备份({formatBackupMoment(schedule.running_started_at)} 开始),完成后自动刷新。</p> : null}
|
||||
{schedule.run_last_error && !schedule.running ? <p className="error-message">上一次立即备份失败:{schedule.run_last_error}</p> : null}
|
||||
</div> : <p className="hint">定时备份设置仅管理员可修改;当前设置:{schedule.enabled ? `每天 ${schedule.schedule_time} · 保留 ${schedule.retention_days} 天` : '未启用'}。</p>}
|
||||
</section>
|
||||
}
|
||||
|
||||
// 容器:读取 + 下发 + 在途轮询。真正执行备份的是目标库所在主机(同 VPC 直连 RDS)。
|
||||
export function BackupScheduleSection() {
|
||||
const auth = useOptionalAuth()
|
||||
const isAdmin = auth?.identity?.role === 'admin'
|
||||
const [schedule, setSchedule] = useState<BackupSchedule | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [probing, setProbing] = useState(false)
|
||||
const [busy, setBusy] = useState<'save' | 'apply' | 'run' | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState('')
|
||||
const [enabledDraft, setEnabledDraft] = useState(false)
|
||||
const [timeDraft, setTimeDraft] = useState('03:30')
|
||||
const [daysDraft, setDaysDraft] = useState('7')
|
||||
const [monthsDraft, setMonthsDraft] = useState('3')
|
||||
const polling = useRef<number | null>(null)
|
||||
|
||||
const adopt = useCallback((next: BackupSchedule) => {
|
||||
setSchedule(next)
|
||||
setEnabledDraft(next.enabled)
|
||||
setTimeDraft(next.schedule_time || '03:30')
|
||||
setDaysDraft(String(next.retention_days ?? 7))
|
||||
setMonthsDraft(String(next.retention_months ?? 3))
|
||||
}, [])
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError(null)
|
||||
try {
|
||||
adopt(await fetchBackupSchedule())
|
||||
setProbing(true)
|
||||
try {
|
||||
adopt(await fetchBackupScheduleHost())
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '读取目标主机上的备份状态失败')
|
||||
} finally { setProbing(false) }
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : '读取备份计划失败')
|
||||
} finally { setLoading(false) }
|
||||
}, [adopt])
|
||||
|
||||
useEffect(() => { void load() }, [load])
|
||||
|
||||
// 立即备份约 30~60 秒:在途时轮询主机状态,直到 running 转假并出现新的最近一次结果。
|
||||
useEffect(() => {
|
||||
if (!schedule?.running) {
|
||||
if (polling.current !== null) { window.clearInterval(polling.current); polling.current = null }
|
||||
return
|
||||
}
|
||||
if (polling.current !== null) return
|
||||
polling.current = window.setInterval(() => {
|
||||
void fetchBackupScheduleHost().then(adopt).catch(() => undefined)
|
||||
}, 5000)
|
||||
return () => {
|
||||
if (polling.current !== null) { window.clearInterval(polling.current); polling.current = null }
|
||||
}
|
||||
}, [schedule?.running, adopt])
|
||||
|
||||
const save = async () => {
|
||||
setBusy('save'); setError(null); setMessage('')
|
||||
try {
|
||||
const next = await saveBackupSchedule({
|
||||
enabled: enabledDraft, schedule_time: timeDraft,
|
||||
retention_days: Number(daysDraft), retention_months: Number(monthsDraft), apply: true,
|
||||
})
|
||||
adopt(next)
|
||||
if (!next.host_configured) {
|
||||
setMessage('计划已保存。目标主机凭据未挂载,因此还不能下发到主机上的 cron。')
|
||||
} else if (next.applied) {
|
||||
setMessage(`计划已保存并下发:每天 ${next.schedule_time} 执行,保留 ${next.retention_days} 天(每月另留 ${next.retention_months} 份)。`)
|
||||
} else {
|
||||
setMessage(`计划已保存,但下发失败:${next.apply_error}`)
|
||||
}
|
||||
} catch (cause) { setError(cause instanceof Error ? cause.message : '保存备份计划失败') }
|
||||
finally { setBusy(null) }
|
||||
}
|
||||
|
||||
const apply = async () => {
|
||||
setBusy('apply'); setError(null); setMessage('')
|
||||
try {
|
||||
const next = await applyBackupSchedule()
|
||||
adopt(next)
|
||||
setMessage(next.applied ? '已按当前设置重新下发到目标主机。' : `下发失败:${next.apply_error}`)
|
||||
} catch (cause) { setError(cause instanceof Error ? cause.message : '下发备份计划失败') }
|
||||
finally { setBusy(null) }
|
||||
}
|
||||
|
||||
const runNow = async () => {
|
||||
setBusy('run'); setError(null); setMessage('')
|
||||
try {
|
||||
const start = await runBackupNow()
|
||||
setMessage(start.started ? '已在目标主机上开始备份(整库 pg_dump,约 30~60 秒),下方会自动刷新结果。' : '已有一个备份任务在执行中。')
|
||||
adopt(await fetchBackupScheduleHost())
|
||||
} catch (cause) { setError(cause instanceof Error ? cause.message : '触发备份失败') }
|
||||
finally { setBusy(null) }
|
||||
}
|
||||
|
||||
if (!schedule) {
|
||||
return <section className="settings-panel wide storage-settings-panel" id="settings-storage">
|
||||
<div className="settings-section-heading"><div><h2>存储与备份</h2></div></div>
|
||||
{error ? <p className="error-message" role="alert">{error}</p> : null}
|
||||
<p className="hint">{loading ? '正在读取备份计划…' : '备份计划暂不可用。'}</p>
|
||||
</section>
|
||||
}
|
||||
|
||||
return <BackupScheduleView
|
||||
schedule={schedule} isAdmin={isAdmin} loading={loading} probing={probing} busy={busy}
|
||||
error={error} message={message}
|
||||
enabledDraft={enabledDraft} timeDraft={timeDraft} daysDraft={daysDraft} monthsDraft={monthsDraft}
|
||||
onEnabledChange={setEnabledDraft} onTimeChange={setTimeDraft}
|
||||
onDaysChange={setDaysDraft} onMonthsChange={setMonthsDraft}
|
||||
onSave={() => void save()} onApply={() => void apply()} onRunNow={() => void runNow()}
|
||||
onReload={() => void load()}
|
||||
/>
|
||||
}
|
||||
@@ -32,8 +32,10 @@ describe('SystemSettingsView',()=>{
|
||||
const servicesHtml=renderToStaticMarkup(<SystemSettingsView sources={sources} connections={connections} monitor={monitor} settings={settings} productionEnabled={false} routingCapabilities={{internal_enabled:true,external_enabled:true}} loading={false} saving={false} error={null} saveMessage={null} onSave={vi.fn()} onSourcesChanged={vi.fn()} onRefresh={vi.fn()} initialSection="services" />)
|
||||
for(const text of ['测试 WeKnora','生产 WeKnora','测试地址','生产地址','生产 ID','知识库 ID'])expect(servicesHtml).toContain(text)
|
||||
const storageHtml=renderToStaticMarkup(<SystemSettingsView sources={sources} connections={connections} monitor={monitor} settings={settings} productionEnabled={false} routingCapabilities={{internal_enabled:true,external_enabled:true}} loading={false} saving={false} error={null} saveMessage={null} onSave={vi.fn()} onSourcesChanged={vi.fn()} onRefresh={vi.fn()} initialSection="storage" />)
|
||||
expect(storageHtml).toContain('生产发布请求')
|
||||
expect(storageHtml).toContain('仅保存凭据引用,不可回读')
|
||||
// 存储与备份分区已换成真实功能(整库逻辑备份的定时计划),静态占位文案不再存在。
|
||||
expect(storageHtml).toContain('存储与备份')
|
||||
expect(storageHtml).toContain('正在读取备份计划…')
|
||||
expect(storageHtml).not.toContain('仅保存凭据引用,不可回读')
|
||||
const refreshHtml=renderToStaticMarkup(<SystemSettingsView sources={sources} connections={connections} monitor={monitor} settings={settings} productionEnabled={false} routingCapabilities={{internal_enabled:true,external_enabled:true}} loading={false} saving={false} error={null} saveMessage="刷新与告警参数已验证并启用:文档15秒、资源30秒、停滞30分钟、finalizing 2小时" onSave={vi.fn()} onSourcesChanged={vi.fn()} onRefresh={vi.fn()} initialSection="refresh" />)
|
||||
for(const text of ['刷新与告警参数已验证并启用','停滞30分钟','finalizing 2小时','文档状态刷新间隔','15秒','解析停滞判定','30分钟','保存并应用刷新与告警参数'])expect(refreshHtml).toContain(text)
|
||||
expect(visibleSaveMessage('已保存',true)).toBeNull();expect(visibleSaveMessage('已保存',false)).toBe('已保存')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { BackupScheduleSection } from './BackupScheduleSection'
|
||||
import { MembersSection } from './MembersSection'
|
||||
import { DingtalkSessionControl } from './DingtalkSessionControl'
|
||||
import { RoutingBindingsSection } from './RoutingBindingsSection'
|
||||
@@ -264,10 +265,7 @@ export function SystemSettingsView({ sources, connections, monitor, settings, pr
|
||||
</section> : null}
|
||||
{section === 'bindings' ? <RoutingBindingsSection /> : null}
|
||||
{section === 'production-bindings' ? <ProductionBindingsSection /> : null}
|
||||
{section === 'storage' ? <section className="settings-panel wide storage-settings-panel" id="settings-storage">
|
||||
<div className="settings-section-heading"><div><h2>存储与备份</h2><p>文件资源与生产发布前快照使用独立受限配置。</p></div></div>
|
||||
<div className="storage-settings-grid"><dl><dt>Endpoint / Bucket</dt><dd>尚未配置</dd><dt>AccessKey / Secret</dt><dd>仅保存凭据引用,不可回读</dd><dt>CDN / 公开访问域名</dt><dd>尚未配置</dd><dt>临时签名有效期</dt><dd>待配置</dd></dl><dl><dt>测试备份前缀</dt><dd>/backup/weknora/test/</dd><dt>生产备份前缀</dt><dd>/backup/weknora/prod/</dd><dt>快照保留</dt><dd>最近 30 个生产快照</dd><dt>恢复验证</dt><dd>由生产门禁读回</dd></dl><ul className="target-list"><li>PostgreSQL <b>未配置</b></li><li>Elasticsearch <b>未配置</b></li><li>对象存储 <b>未配置</b></li><li>Neo4j <b>未配置</b></li><li>生产发布请求 <b>{productionEnabled ? '等待管理员核对' : '关闭'}</b></li><li>Release Worker <b>网页不可启用</b></li></ul></div>
|
||||
</section> : null}
|
||||
{section === 'storage' ? <BackupScheduleSection /> : null}
|
||||
{section === 'members' && identity?.role === 'admin' ? <MembersSection /> : null}
|
||||
{section === 'refresh' ? <section className="settings-panel wide" id="settings-refresh">
|
||||
<div className="settings-section-heading"><div><h2>刷新策略</h2><p>这些值保存在服务器活动版本中;保存不会修改端点、凭据或生产开关。</p></div><span className="safe-badge">当前活动版本</span></div>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { fetchBackupSchedule, fetchBackupScheduleHost, runBackupNow, saveBackupSchedule } from './backupSchedule'
|
||||
|
||||
const payload = {
|
||||
enabled: true, schedule_time: '03:30', retention_days: 7, retention_months: 3,
|
||||
updated_by: 'yaozhanghao', updated_at: '2026-10-09T12:00:00Z', last_applied_at: '', last_applied_summary: '',
|
||||
host_configured: true, host_target: 'weknora-test', host_reachable: true, host_error: '',
|
||||
host_schedule_enabled: true, host_schedule_time: '03:30', host_keep_days: 7, host_keep_months: 3,
|
||||
host_cron_lines: ['30 03 * * * root …'], host_cron_service: 'active', host_script_present: true,
|
||||
last_run_stamp: '20261009T052909Z', last_run_created_at: '2026-10-09T05:29:09Z',
|
||||
last_run_duration_seconds: 33, last_run_total_bytes: 778502144,
|
||||
last_run_databases: ['weknora_test'], last_run_failed_databases: [], last_run_error: '',
|
||||
in_sync: true, applied: true, apply_error: '', running: false, running_started_at: '', run_last_error: '',
|
||||
}
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
afterEach(() => { vi.unstubAllGlobals() })
|
||||
|
||||
describe('backupSchedule 客户端', () => {
|
||||
it('解析计划三要素与主机回执', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ data: payload })))
|
||||
const schedule = await fetchBackupSchedule()
|
||||
expect(schedule.schedule_time).toBe('03:30')
|
||||
expect(schedule.retention_days).toBe(7)
|
||||
expect(schedule.host_cron_lines).toHaveLength(1)
|
||||
expect(schedule.last_run_failed_databases).toEqual([])
|
||||
})
|
||||
|
||||
it('缺计划字段就报错,绝不把默认值伪装成真实设置', async () => {
|
||||
const broken = { ...payload } as Record<string, unknown>
|
||||
delete broken.retention_days
|
||||
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ data: broken })))
|
||||
await expect(fetchBackupScheduleHost()).rejects.toThrow('存储与备份响应缺少计划字段')
|
||||
})
|
||||
|
||||
it('保存失败时把后端的中文原因透出来', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: '储存天数需在 1~365 之间,收到 0' }, 400)))
|
||||
await expect(saveBackupSchedule({ enabled: true, schedule_time: '03:30', retention_days: 0, retention_months: 3 }))
|
||||
.rejects.toThrow('储存天数需在 1~365 之间,收到 0')
|
||||
})
|
||||
|
||||
it('立即备份返回 202 时识别为已启动', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ data: { started: true, running: true } }, 202)))
|
||||
await expect(runBackupNow()).resolves.toEqual({ started: true, running: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
import { newOperationID } from './operationKey'
|
||||
|
||||
// 「系统设置 → 存储与备份」:整库逻辑备份的定时计划(时间 + 储存天数)与主机回执。
|
||||
export interface BackupSchedule {
|
||||
enabled: boolean
|
||||
schedule_time: string
|
||||
retention_days: number
|
||||
retention_months: number
|
||||
updated_by: string
|
||||
updated_at: string
|
||||
last_applied_at: string
|
||||
last_applied_summary: string
|
||||
host_configured: boolean
|
||||
host_target: string
|
||||
host_reachable: boolean
|
||||
host_error: string
|
||||
host_schedule_enabled: boolean
|
||||
host_schedule_time: string
|
||||
host_keep_days: number
|
||||
host_keep_months: number
|
||||
host_cron_lines: string[]
|
||||
host_cron_service: string
|
||||
host_script_present: boolean
|
||||
last_run_stamp: string
|
||||
last_run_created_at: string
|
||||
last_run_duration_seconds: number
|
||||
last_run_total_bytes: number
|
||||
last_run_databases: string[]
|
||||
last_run_failed_databases: string[]
|
||||
last_run_error: string
|
||||
in_sync: boolean
|
||||
applied: boolean
|
||||
apply_error: string
|
||||
running: boolean
|
||||
running_started_at: string
|
||||
run_last_error: string
|
||||
}
|
||||
|
||||
export interface BackupRunStart {
|
||||
started: boolean
|
||||
running: boolean
|
||||
}
|
||||
|
||||
const record = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||||
|
||||
const asString = (value: unknown): string => (typeof value === 'string' ? value : '')
|
||||
const asNumber = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
|
||||
const asStringList = (value: unknown): string[] =>
|
||||
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
||||
|
||||
// 严格解析:计划三要素(时间/天数/月数)必须是正确类型,缺一个就整块报错,
|
||||
// 而不是让界面显示成"默认 03:30 / 7 天"这种假状态。
|
||||
function scheduleProjection(item: Record<string, unknown>): BackupSchedule {
|
||||
if (typeof item.schedule_time !== 'string' || typeof item.retention_days !== 'number' || typeof item.retention_months !== 'number') {
|
||||
throw new Error('存储与备份响应缺少计划字段(schedule_time / retention_days / retention_months)')
|
||||
}
|
||||
return {
|
||||
enabled: item.enabled === true,
|
||||
schedule_time: item.schedule_time,
|
||||
retention_days: item.retention_days,
|
||||
retention_months: item.retention_months,
|
||||
updated_by: asString(item.updated_by),
|
||||
updated_at: asString(item.updated_at),
|
||||
last_applied_at: asString(item.last_applied_at),
|
||||
last_applied_summary: asString(item.last_applied_summary),
|
||||
host_configured: item.host_configured === true,
|
||||
host_target: asString(item.host_target),
|
||||
host_reachable: item.host_reachable === true,
|
||||
host_error: asString(item.host_error),
|
||||
host_schedule_enabled: item.host_schedule_enabled === true,
|
||||
host_schedule_time: asString(item.host_schedule_time),
|
||||
host_keep_days: asNumber(item.host_keep_days),
|
||||
host_keep_months: asNumber(item.host_keep_months),
|
||||
host_cron_lines: asStringList(item.host_cron_lines),
|
||||
host_cron_service: asString(item.host_cron_service),
|
||||
host_script_present: item.host_script_present === true,
|
||||
last_run_stamp: asString(item.last_run_stamp),
|
||||
last_run_created_at: asString(item.last_run_created_at),
|
||||
last_run_duration_seconds: asNumber(item.last_run_duration_seconds),
|
||||
last_run_total_bytes: asNumber(item.last_run_total_bytes),
|
||||
last_run_databases: asStringList(item.last_run_databases),
|
||||
last_run_failed_databases: asStringList(item.last_run_failed_databases),
|
||||
last_run_error: asString(item.last_run_error),
|
||||
in_sync: item.in_sync === true,
|
||||
applied: item.applied === true,
|
||||
apply_error: asString(item.apply_error),
|
||||
running: item.running === true,
|
||||
running_started_at: asString(item.running_started_at),
|
||||
run_last_error: asString(item.run_last_error),
|
||||
}
|
||||
}
|
||||
|
||||
async function readSchedule(path: string, failure: string, signal?: AbortSignal): Promise<BackupSchedule> {
|
||||
const response = await fetch(path, { signal, headers: { Accept: 'application/json' } })
|
||||
if (!response.ok) throw new Error(`${failure}(HTTP ${response.status})`)
|
||||
const value = await response.json() as unknown
|
||||
if (!record(value) || !record(value.data)) throw new Error('存储与备份响应无效')
|
||||
return scheduleProjection(value.data)
|
||||
}
|
||||
|
||||
export function fetchBackupSchedule(signal?: AbortSignal): Promise<BackupSchedule> {
|
||||
return readSchedule('/api/v1/backup-schedule', '读取备份计划失败', signal)
|
||||
}
|
||||
|
||||
// host 端点会 SSH 探测目标主机(计划 + 最近一次结果),单独调用以免拖慢首屏。
|
||||
export function fetchBackupScheduleHost(signal?: AbortSignal): Promise<BackupSchedule> {
|
||||
return readSchedule('/api/v1/backup-schedule/host', '读取目标主机上的备份状态失败', signal)
|
||||
}
|
||||
|
||||
async function writeSchedule(path: string, method: string, body: unknown, failure: string, signal?: AbortSignal): Promise<BackupSchedule> {
|
||||
const response = await fetch(path, {
|
||||
method, signal,
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'Idempotency-Key': newOperationID() },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
if (!response.ok) {
|
||||
let reason = `${failure}(HTTP ${response.status})`
|
||||
try {
|
||||
const payload = await response.json() as unknown
|
||||
if (record(payload) && typeof payload.error === 'string') reason = payload.error
|
||||
} catch { /* keep the status text */ }
|
||||
throw new Error(reason)
|
||||
}
|
||||
const value = await response.json() as unknown
|
||||
if (!record(value) || !record(value.data)) throw new Error('存储与备份响应无效')
|
||||
return scheduleProjection(value.data)
|
||||
}
|
||||
|
||||
export interface BackupScheduleInput {
|
||||
enabled: boolean
|
||||
schedule_time: string
|
||||
retention_days: number
|
||||
retention_months: number
|
||||
// 默认 true:保存后立刻下发到目标主机(页面按钮"保存并下发")。
|
||||
apply?: boolean
|
||||
}
|
||||
|
||||
export function saveBackupSchedule(input: BackupScheduleInput, signal?: AbortSignal): Promise<BackupSchedule> {
|
||||
return writeSchedule('/api/v1/backup-schedule', 'PUT', input, '保存备份计划失败', signal)
|
||||
}
|
||||
|
||||
export function applyBackupSchedule(signal?: AbortSignal): Promise<BackupSchedule> {
|
||||
return writeSchedule('/api/v1/backup-schedule/apply', 'POST', undefined, '下发备份计划失败', signal)
|
||||
}
|
||||
|
||||
export async function runBackupNow(signal?: AbortSignal): Promise<BackupRunStart> {
|
||||
const response = await fetch('/api/v1/backup-schedule/run', {
|
||||
method: 'POST', signal,
|
||||
headers: { Accept: 'application/json', 'Idempotency-Key': newOperationID() },
|
||||
})
|
||||
if (!response.ok) {
|
||||
let reason = `触发备份失败(HTTP ${response.status})`
|
||||
try {
|
||||
const payload = await response.json() as unknown
|
||||
if (record(payload) && typeof payload.error === 'string') reason = payload.error
|
||||
} catch { /* keep the status text */ }
|
||||
throw new Error(reason)
|
||||
}
|
||||
const value = await response.json() as unknown
|
||||
if (!record(value) || !record(value.data)) throw new Error('触发备份响应无效')
|
||||
return { started: value.data.started === true, running: value.data.running === true }
|
||||
}
|
||||
Reference in New Issue
Block a user