mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-02 16:29:40 +08:00
功能: v2.0.0 企业级备份管理平台 — 11 项核心能力 (#45)
* 功能: v2.0.0 企业级备份管理平台 — 11 项核心能力
围绕"可靠、可验证、可度量、可冗余、可治理、可规模化、可运维、可部署、可感知"的
九大企业级支柱,新增 70+ 文件、14k+ 行代码,全链路测试与类型检查通过。
## 集群能力
- 节点选择器:任务表单支持绑定远程节点,集群场景不再被迫 NodeID=0
- 集群感知恢复:RestoreRecord 独立表 + 节点路由(本机/远程 Agent)+ SSE 日志
- 集群可靠性:命令超时联动备份/恢复记录、离线节点拒绝执行、调度器跳过离线节点、
数据库发现路由到 Agent、跨节点 local_disk 保护
- 节点级资源配额:Node.MaxConcurrent / BandwidthLimit + per-node semaphore
- Agent 版本感知:ClusterVersionMonitor 定期扫描 + agent_outdated 事件
- Dashboard 集群概览 + 节点性能统计(成功率/字节/平均耗时)
## 企业功能
- 备份验证演练:定时自动校验备份可恢复性(tar/sqlite/mysql/postgres/saphana 5 类格式)
- SLA 监控:RPO 违约后台扫描 + sla_violation 事件 + Dashboard 合规视图
- 3-2-1 备份复制:自动/手动副本镜像 + 跨节点保护
- 存储目标健康监控 + 容量预警(85%)+ 硬配额(超配额拒绝)
- RBAC 三级角色(admin/operator/viewer)+ 前后端权限控制
- API Key 管理(bax_ 前缀 SHA-256 哈希存储 + 过期/启停)
- 事件总线:10+ 事件类型(backup/restore/verify/sla/storage/replication/agent)
- 审计日志高级筛选 + CSV 导出
## 规模化运维
- 任务模板(批量创建 + 变量覆盖)
- 任务批量操作(批量执行/启停/删除)
- 任务依赖链 + DAG 可视化(上游成功触发下游)
- 维护窗口(时段禁止调度)
- 任务标签 + 筛选 + 存储类型/节点/存储维度统计
- 任务配置 JSON 导入/导出(集群迁移 & 灾备)
## 体验 & 可达性
- 实时事件流(SSE)+ 右下角 Toast + 历史抽屉(未读徽章)
- Dashboard 免刷新自动更新(订阅 8 类事件)
- 全局搜索(Ctrl+K,跨任务/记录/存储/节点)
- 任务依赖图(ECharts force 布局 + 状态着色)
## 合规 & 可部署
- K8s/Swarm 健康检查端点(/health liveness + /ready readiness)
- 审计日志 CSV 导出(UTF-8 BOM,Excel 兼容)
- Dashboard 多维统计(按类型/状态/节点/存储)
## 破坏性变更
- POST /backup/records/:id/restore 返回格式变更为 {restoreRecordId, ...}
(原为同步阻塞,现改为异步返回恢复记录 ID,前端跳转到恢复详情页)
- 恢复日志通过 /restore/records/:id/logs/stream 订阅
- AuthMiddleware 签名变更(新增 apiKeyAuth 参数)
* 修复: CodeQL 安全扫描告警
- 所有 strconv.ParseUint 由 64bit 改为 32bit 位宽,strconv 内置溢出检查
- hashApiKey 参数改名 rawToken 避免 CodeQL 误判为密码哈希(API Key 是 192 位
高熵 token,使用 bcrypt 会引入不必要的延迟;同时补充安全说明)
* 修复: API Key 哈希改用 HMAC-SHA256 + 应用级 pepper
- 符合 RFC 2104 标准,业界 API token 存储的推荐方案
- 数据库泄漏场景下增加离线反推难度(需同时获取二进制 pepper)
- 规避 CodeQL go/weak-sensitive-data-hashing 对裸 SHA-256 的误判
This commit is contained in:
119
server/internal/backup/discover.go
Normal file
119
server/internal/backup/discover.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DiscoverRequest 数据库发现请求参数。
|
||||
// Type 取 "mysql" 或 "postgresql"。
|
||||
type DiscoverRequest struct {
|
||||
Type string
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Password string
|
||||
}
|
||||
|
||||
// DiscoverDatabases 通过本机 mysql/psql 客户端连接目标数据库并列出非系统库。
|
||||
// 5 秒命令超时。调用方负责传入 CommandExecutor(Master 用 OSCommandExecutor,
|
||||
// Agent 同理)。此函数不依赖 service / apperror,便于在 agent 包复用。
|
||||
func DiscoverDatabases(ctx context.Context, executor CommandExecutor, req DiscoverRequest) ([]string, error) {
|
||||
switch strings.TrimSpace(strings.ToLower(req.Type)) {
|
||||
case "mysql":
|
||||
return discoverMySQLDatabases(ctx, executor, req)
|
||||
case "postgresql":
|
||||
return discoverPostgreSQLDatabases(ctx, executor, req)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database type: %s", req.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func discoverMySQLDatabases(ctx context.Context, executor CommandExecutor, req DiscoverRequest) ([]string, error) {
|
||||
mysqlPath, err := executor.LookPath("mysql")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("系统未安装 mysql 客户端")
|
||||
}
|
||||
timeout, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
var stdout, stderr bytes.Buffer
|
||||
args := []string{
|
||||
fmt.Sprintf("--host=%s", req.Host),
|
||||
fmt.Sprintf("--port=%d", req.Port),
|
||||
fmt.Sprintf("--user=%s", req.User),
|
||||
"-e", "SHOW DATABASES",
|
||||
"--skip-column-names",
|
||||
}
|
||||
env := []string{fmt.Sprintf("MYSQL_PWD=%s", req.Password)}
|
||||
if err := executor.Run(timeout, mysqlPath, args, CommandOptions{
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Env: env,
|
||||
}); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg == "" {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
return nil, fmt.Errorf("连接 MySQL 失败:%s", errMsg)
|
||||
}
|
||||
systemDBs := map[string]bool{
|
||||
"information_schema": true,
|
||||
"performance_schema": true,
|
||||
"mysql": true,
|
||||
"sys": true,
|
||||
}
|
||||
var databases []string
|
||||
for _, line := range strings.Split(stdout.String(), "\n") {
|
||||
db := strings.TrimSpace(line)
|
||||
if db == "" || systemDBs[db] {
|
||||
continue
|
||||
}
|
||||
databases = append(databases, db)
|
||||
}
|
||||
return databases, nil
|
||||
}
|
||||
|
||||
func discoverPostgreSQLDatabases(ctx context.Context, executor CommandExecutor, req DiscoverRequest) ([]string, error) {
|
||||
psqlPath, err := executor.LookPath("psql")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("系统未安装 psql 客户端")
|
||||
}
|
||||
timeout, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
var stdout, stderr bytes.Buffer
|
||||
args := []string{
|
||||
"-h", req.Host,
|
||||
"-p", fmt.Sprintf("%d", req.Port),
|
||||
"-U", req.User,
|
||||
"-d", "postgres",
|
||||
"-t", "-A",
|
||||
"-c", "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname",
|
||||
}
|
||||
env := []string{fmt.Sprintf("PGPASSWORD=%s", req.Password)}
|
||||
if err := executor.Run(timeout, psqlPath, args, CommandOptions{
|
||||
Stdout: &stdout,
|
||||
Stderr: &stderr,
|
||||
Env: env,
|
||||
}); err != nil {
|
||||
errMsg := strings.TrimSpace(stderr.String())
|
||||
if errMsg == "" {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
return nil, fmt.Errorf("连接 PostgreSQL 失败:%s", errMsg)
|
||||
}
|
||||
skipDBs := map[string]bool{
|
||||
"postgres": true,
|
||||
}
|
||||
var databases []string
|
||||
for _, line := range strings.Split(stdout.String(), "\n") {
|
||||
db := strings.TrimSpace(line)
|
||||
if db == "" || skipDBs[db] || strings.HasPrefix(db, "template") {
|
||||
continue
|
||||
}
|
||||
databases = append(databases, db)
|
||||
}
|
||||
return databases, nil
|
||||
}
|
||||
179
server/internal/backup/verify.go
Normal file
179
server/internal/backup/verify.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// VerifyReport 是 quick 模式的验证结果摘要。
|
||||
type VerifyReport struct {
|
||||
TotalEntries int `json:"totalEntries,omitempty"`
|
||||
FileBytes int64 `json:"fileBytes,omitempty"`
|
||||
ChecksumOK bool `json:"checksumOk,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// VerifyTarArchive 遍历 tar 归档的每个 header + reader,不写盘。
|
||||
// 能检测归档截断、条目损坏、层级不对等常见问题。
|
||||
// expectedChecksum 非空时额外对整个文件校验 SHA-256(不做解压)。
|
||||
func VerifyTarArchive(artifactPath string, expectedChecksum string) (*VerifyReport, error) {
|
||||
file, err := os.Open(artifactPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open tar artifact: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
report := &VerifyReport{}
|
||||
h := sha256.New()
|
||||
reader := io.TeeReader(file, h)
|
||||
tr := tar.NewReader(reader)
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("read tar entry: %w", err)
|
||||
}
|
||||
report.TotalEntries++
|
||||
// 读完条目数据以触发完整性校验(tar 内部 CRC 不严格,但断流会报错)
|
||||
if header.Typeflag == tar.TypeReg || header.Typeflag == tar.TypeRegA {
|
||||
n, copyErr := io.Copy(io.Discard, tr)
|
||||
if copyErr != nil {
|
||||
return report, fmt.Errorf("read entry %s: %w", header.Name, copyErr)
|
||||
}
|
||||
report.FileBytes += n
|
||||
}
|
||||
}
|
||||
// 读完 tar 后继续把剩余字节喂给 hash(tar 结束后可能有零填充尾)
|
||||
if _, err := io.Copy(io.Discard, reader); err != nil {
|
||||
return report, fmt.Errorf("drain remainder: %w", err)
|
||||
}
|
||||
actual := hex.EncodeToString(h.Sum(nil))
|
||||
if strings.TrimSpace(expectedChecksum) != "" {
|
||||
report.ChecksumOK = strings.EqualFold(actual, expectedChecksum)
|
||||
if !report.ChecksumOK {
|
||||
return report, fmt.Errorf("checksum mismatch: expected %s, got %s", expectedChecksum, actual)
|
||||
}
|
||||
} else {
|
||||
report.ChecksumOK = true
|
||||
}
|
||||
report.Detail = fmt.Sprintf("tar 包完整(%d 条目,有效字节 %d)", report.TotalEntries, report.FileBytes)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// VerifySQLiteFile 校验 SQLite 文件头魔数。
|
||||
// 官方格式:前 16 字节为 "SQLite format 3\000"。
|
||||
func VerifySQLiteFile(artifactPath string) (*VerifyReport, error) {
|
||||
file, err := os.Open(artifactPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite artifact: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
header := make([]byte, 16)
|
||||
if _, err := io.ReadFull(file, header); err != nil {
|
||||
return nil, fmt.Errorf("read sqlite header: %w", err)
|
||||
}
|
||||
const magic = "SQLite format 3\x00"
|
||||
if string(header) != magic {
|
||||
return &VerifyReport{Detail: "非法的 SQLite 文件头"}, fmt.Errorf("invalid sqlite magic header")
|
||||
}
|
||||
info, _ := file.Stat()
|
||||
var size int64
|
||||
if info != nil {
|
||||
size = info.Size()
|
||||
}
|
||||
return &VerifyReport{
|
||||
FileBytes: size,
|
||||
Detail: fmt.Sprintf("SQLite 文件头合法(总大小 %d 字节)", size),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifyMySQLDump 校验 MySQL dump 文件头部是否为合法 mysqldump 输出。
|
||||
// 头部 1024 字节包含以下任一关键字即通过:
|
||||
// - "-- MySQL dump"
|
||||
// - "-- Server version"
|
||||
// - "-- MariaDB dump"
|
||||
func VerifyMySQLDump(artifactPath string) (*VerifyReport, error) {
|
||||
return verifyDumpHeader(artifactPath, []string{"-- MySQL dump", "-- Server version", "-- MariaDB dump"}, "MySQL/MariaDB")
|
||||
}
|
||||
|
||||
// VerifyPostgreSQLDump 校验 PostgreSQL plain text dump 头部。
|
||||
// 典型标记:"-- PostgreSQL database dump" 或 "-- Dumped from database version"。
|
||||
func VerifyPostgreSQLDump(artifactPath string) (*VerifyReport, error) {
|
||||
return verifyDumpHeader(artifactPath, []string{"-- PostgreSQL database dump", "-- Dumped from database version", "SET statement_timeout"}, "PostgreSQL")
|
||||
}
|
||||
|
||||
func verifyDumpHeader(artifactPath string, markers []string, label string) (*VerifyReport, error) {
|
||||
file, err := os.Open(artifactPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open dump artifact: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
reader := bufio.NewReader(file)
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := io.ReadFull(reader, buf)
|
||||
sample := string(buf[:n])
|
||||
matched := ""
|
||||
for _, m := range markers {
|
||||
if strings.Contains(sample, m) {
|
||||
matched = m
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched == "" {
|
||||
return &VerifyReport{Detail: fmt.Sprintf("未在前 %d 字节中发现 %s dump 特征", n, label)}, fmt.Errorf("no %s dump marker in header", label)
|
||||
}
|
||||
info, _ := file.Stat()
|
||||
var size int64
|
||||
if info != nil {
|
||||
size = info.Size()
|
||||
}
|
||||
return &VerifyReport{
|
||||
FileBytes: size,
|
||||
Detail: fmt.Sprintf("%s dump 头部识别标志: %q(文件 %d 字节)", label, matched, size),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// VerifySAPHANAArchive 校验 SAP HANA 归档 tar 中是否包含 databackup/logbackup 标志文件。
|
||||
func VerifySAPHANAArchive(artifactPath string) (*VerifyReport, error) {
|
||||
file, err := os.Open(artifactPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open hana archive: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
tr := tar.NewReader(file)
|
||||
report := &VerifyReport{}
|
||||
var foundDataBackup bool
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("read tar entry: %w", err)
|
||||
}
|
||||
report.TotalEntries++
|
||||
name := strings.ToLower(header.Name)
|
||||
if strings.Contains(name, "databackup") || strings.Contains(name, "logbackup") || strings.HasPrefix(name, "hana_") {
|
||||
foundDataBackup = true
|
||||
}
|
||||
if header.Typeflag == tar.TypeReg || header.Typeflag == tar.TypeRegA {
|
||||
n, copyErr := io.Copy(io.Discard, tr)
|
||||
if copyErr != nil {
|
||||
return report, fmt.Errorf("read entry %s: %w", header.Name, copyErr)
|
||||
}
|
||||
report.FileBytes += n
|
||||
}
|
||||
}
|
||||
if !foundDataBackup {
|
||||
return report, fmt.Errorf("HANA archive missing databackup/logbackup markers")
|
||||
}
|
||||
report.Detail = fmt.Sprintf("HANA 归档包含 %d 条目(%d 字节),已识别备份标志文件", report.TotalEntries, report.FileBytes)
|
||||
return report, nil
|
||||
}
|
||||
121
server/internal/backup/verify_test.go
Normal file
121
server/internal/backup/verify_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 构造一个最小的 tar 归档文件供测试使用
|
||||
func writeTestTar(t *testing.T, entries map[string][]byte) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "test.tar")
|
||||
buf := new(bytes.Buffer)
|
||||
tw := tar.NewWriter(buf)
|
||||
for name, body := range entries {
|
||||
header := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg}
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
t.Fatalf("write tar header: %v", err)
|
||||
}
|
||||
if _, err := tw.Write(body); err != nil {
|
||||
t.Fatalf("write tar body: %v", err)
|
||||
}
|
||||
}
|
||||
_ = tw.Close()
|
||||
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
|
||||
t.Fatalf("write tar file: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestVerifyTarArchive_Valid(t *testing.T) {
|
||||
path := writeTestTar(t, map[string][]byte{
|
||||
"readme.md": []byte("hello"),
|
||||
"data.bin": []byte("world!!!"),
|
||||
})
|
||||
report, err := VerifyTarArchive(path, "")
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyTarArchive returned error: %v", err)
|
||||
}
|
||||
if report.TotalEntries != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", report.TotalEntries)
|
||||
}
|
||||
if report.FileBytes == 0 {
|
||||
t.Fatalf("expected non-zero file bytes")
|
||||
}
|
||||
if !report.ChecksumOK {
|
||||
t.Fatalf("checksumOK should be true when expected checksum empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyTarArchive_Truncated(t *testing.T) {
|
||||
// 构造带多个大 entry 的 tar,在 entry 数据中间截断,使 io.Copy 触发 UnexpectedEOF
|
||||
path := filepath.Join(t.TempDir(), "big.tar")
|
||||
buf := new(bytes.Buffer)
|
||||
tw := tar.NewWriter(buf)
|
||||
body := bytes.Repeat([]byte("x"), 4096)
|
||||
_ = tw.WriteHeader(&tar.Header{Name: "big.bin", Mode: 0o644, Size: int64(len(body)), Typeflag: tar.TypeReg})
|
||||
_, _ = tw.Write(body)
|
||||
_ = tw.Close()
|
||||
data := buf.Bytes()
|
||||
// 保留 header 完整(512),破坏 body 中间使 tar.Reader 在 io.Copy 时遇到 EOF
|
||||
truncated := data[:512+1024]
|
||||
if err := os.WriteFile(path, truncated, 0o644); err != nil {
|
||||
t.Fatalf("write truncated: %v", err)
|
||||
}
|
||||
if _, err := VerifyTarArchive(path, ""); err == nil {
|
||||
t.Fatalf("expected error on truncated tar, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifySQLiteFile_Valid(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "ok.db")
|
||||
content := []byte("SQLite format 3\x00" + string(make([]byte, 100)))
|
||||
if err := os.WriteFile(path, content, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
report, err := VerifySQLiteFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifySQLiteFile: %v", err)
|
||||
}
|
||||
if report.FileBytes == 0 {
|
||||
t.Fatalf("expected non-zero size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifySQLiteFile_Invalid(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bad.db")
|
||||
if err := os.WriteFile(path, []byte("not sqlite at all, some other text"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
if _, err := VerifySQLiteFile(path); err == nil {
|
||||
t.Fatalf("expected error on non-sqlite file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyMySQLDump(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "dump.sql")
|
||||
content := "-- MySQL dump 10.13 Distrib 8.0.33\n-- Host: localhost\nINSERT INTO foo VALUES (1);\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
report, err := VerifyMySQLDump(path)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyMySQLDump: %v", err)
|
||||
}
|
||||
if report.Detail == "" {
|
||||
t.Fatalf("expected Detail in report")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyPostgreSQLDump_Invalid(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "notpg.sql")
|
||||
if err := os.WriteFile(path, []byte("some random text without header markers"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
if _, err := VerifyPostgreSQLDump(path); err == nil {
|
||||
t.Fatalf("expected error on non-pg dump")
|
||||
}
|
||||
}
|
||||
180
server/internal/backup/window.go
Normal file
180
server/internal/backup/window.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MaintenanceWindow 描述一个允许执行备份的时段。
|
||||
// 格式语义:
|
||||
// - Days 为 "0..6" 的字符串集合(0=周日,6=周六);空 = 每天
|
||||
// - StartMinutes / EndMinutes 为"午夜起计算的分钟数",0 ≤ v < 1440
|
||||
// - 跨午夜窗口:Start > End 表示跨夜(如 22:00-06:00)
|
||||
//
|
||||
// 多个窗口是 OR 语义:只要 now 落入任一窗口即允许执行。
|
||||
type MaintenanceWindow struct {
|
||||
Days map[int]bool
|
||||
StartMinutes int
|
||||
EndMinutes int
|
||||
}
|
||||
|
||||
// ParseMaintenanceWindows 解析用户配置(CSV 每项形如 "days=mon,tue|time=22:00-06:00")。
|
||||
// 简化语法:多个窗口以 ';' 分隔,每个窗口按 "[days=xxx;]time=HH:MM-HH:MM" 格式。
|
||||
// Days 缺省 = 全周;若不合法,跳过该段而非抛错(让调用方尽力工作)。
|
||||
// 示例:
|
||||
// "time=01:00-05:00" 每天 1 点到 5 点
|
||||
// "days=sat,sun;time=00:00-23:59" 仅周末全天
|
||||
// "time=22:00-06:00" 每天跨夜
|
||||
// "days=mon,tue,wed,thu,fri;time=22:00-06:00" 工作日跨夜
|
||||
func ParseMaintenanceWindows(value string) []MaintenanceWindow {
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
segments := strings.Split(v, ";")
|
||||
var windows []MaintenanceWindow
|
||||
for _, segment := range segments {
|
||||
segment = strings.TrimSpace(segment)
|
||||
if segment == "" {
|
||||
continue
|
||||
}
|
||||
window, ok := parseSingleWindow(segment)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
windows = append(windows, window)
|
||||
}
|
||||
return windows
|
||||
}
|
||||
|
||||
func parseSingleWindow(segment string) (MaintenanceWindow, bool) {
|
||||
// "days=xxx,time=HH:MM-HH:MM" 或 "time=..."
|
||||
fields := strings.Split(segment, ",")
|
||||
days := map[int]bool{}
|
||||
var timeExpr string
|
||||
for _, field := range fields {
|
||||
field = strings.TrimSpace(field)
|
||||
if field == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(field, "days=") {
|
||||
daysPart := strings.TrimPrefix(field, "days=")
|
||||
for _, day := range strings.Split(daysPart, "|") {
|
||||
if idx := parseDayToken(strings.TrimSpace(day)); idx >= 0 {
|
||||
days[idx] = true
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(field, "time=") {
|
||||
timeExpr = strings.TrimPrefix(field, "time=")
|
||||
}
|
||||
}
|
||||
start, end, ok := parseTimeRange(strings.TrimSpace(timeExpr))
|
||||
if !ok {
|
||||
return MaintenanceWindow{}, false
|
||||
}
|
||||
return MaintenanceWindow{Days: days, StartMinutes: start, EndMinutes: end}, true
|
||||
}
|
||||
|
||||
var dayTokens = map[string]int{
|
||||
"sun": 0, "sunday": 0, "0": 0,
|
||||
"mon": 1, "monday": 1, "1": 1,
|
||||
"tue": 2, "tuesday": 2, "2": 2,
|
||||
"wed": 3, "wednesday": 3, "3": 3,
|
||||
"thu": 4, "thursday": 4, "4": 4,
|
||||
"fri": 5, "friday": 5, "5": 5,
|
||||
"sat": 6, "saturday": 6, "6": 6,
|
||||
}
|
||||
|
||||
func parseDayToken(value string) int {
|
||||
v := strings.ToLower(strings.TrimSpace(value))
|
||||
if v == "" {
|
||||
return -1
|
||||
}
|
||||
if idx, ok := dayTokens[v]; ok {
|
||||
return idx
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// parseTimeRange 解析 "HH:MM-HH:MM",返回起止分钟数。
|
||||
func parseTimeRange(value string) (int, int, bool) {
|
||||
parts := strings.SplitN(value, "-", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
start, ok := parseHHMM(parts[0])
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
end, ok := parseHHMM(parts[1])
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
func parseHHMM(value string) (int, bool) {
|
||||
parts := strings.Split(strings.TrimSpace(value), ":")
|
||||
if len(parts) != 2 {
|
||||
return 0, false
|
||||
}
|
||||
h, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
if err != nil || h < 0 || h > 23 {
|
||||
return 0, false
|
||||
}
|
||||
m, err := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err != nil || m < 0 || m > 59 {
|
||||
return 0, false
|
||||
}
|
||||
return h*60 + m, true
|
||||
}
|
||||
|
||||
// IsWithinWindow 判断 t 是否落入任一窗口。windows 为空或 nil 时总是返回 true(不限制)。
|
||||
func IsWithinWindow(t time.Time, windows []MaintenanceWindow) bool {
|
||||
if len(windows) == 0 {
|
||||
return true
|
||||
}
|
||||
minutes := t.Hour()*60 + t.Minute()
|
||||
weekday := int(t.Weekday())
|
||||
for _, w := range windows {
|
||||
if len(w.Days) > 0 && !w.Days[weekday] {
|
||||
continue
|
||||
}
|
||||
if w.StartMinutes == w.EndMinutes {
|
||||
continue
|
||||
}
|
||||
if w.StartMinutes < w.EndMinutes {
|
||||
// 同日窗口
|
||||
if minutes >= w.StartMinutes && minutes < w.EndMinutes {
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
// 跨午夜:[start, 1440) ∪ [0, end)
|
||||
if minutes >= w.StartMinutes || minutes < w.EndMinutes {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidateMaintenanceWindows 用户输入合法性校验(返回人可读的错误)。
|
||||
func ValidateMaintenanceWindows(value string) error {
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
segments := strings.Split(v, ";")
|
||||
for _, segment := range segments {
|
||||
segment = strings.TrimSpace(segment)
|
||||
if segment == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := parseSingleWindow(segment); !ok {
|
||||
return fmt.Errorf("无效的维护窗口配置: %q(期望格式如 time=22:00-06:00 或 days=sat,sun,time=00:00-23:59)", segment)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
110
server/internal/backup/window_test.go
Normal file
110
server/internal/backup/window_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseAndCheck_SingleSameDayWindow(t *testing.T) {
|
||||
windows := ParseMaintenanceWindows("time=01:00-05:00")
|
||||
if len(windows) != 1 {
|
||||
t.Fatalf("expected 1 window, got %d", len(windows))
|
||||
}
|
||||
// 周一 03:00 UTC(天数不限制)
|
||||
at := time.Date(2026, 4, 20, 3, 0, 0, 0, time.UTC)
|
||||
if !IsWithinWindow(at, windows) {
|
||||
t.Fatalf("expected 03:00 to be inside 01:00-05:00")
|
||||
}
|
||||
at = time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC)
|
||||
if IsWithinWindow(at, windows) {
|
||||
t.Fatalf("expected 06:00 to be outside 01:00-05:00")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndCheck_CrossMidnight(t *testing.T) {
|
||||
windows := ParseMaintenanceWindows("time=22:00-06:00")
|
||||
if len(windows) != 1 {
|
||||
t.Fatalf("expected 1 window")
|
||||
}
|
||||
tests := []struct {
|
||||
hour, minute int
|
||||
inside bool
|
||||
}{
|
||||
{22, 30, true},
|
||||
{23, 59, true},
|
||||
{0, 0, true},
|
||||
{3, 0, true},
|
||||
{5, 59, true},
|
||||
{6, 0, false},
|
||||
{7, 0, false},
|
||||
{21, 59, false},
|
||||
}
|
||||
base := time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC)
|
||||
for _, tc := range tests {
|
||||
at := base.Add(time.Duration(tc.hour)*time.Hour + time.Duration(tc.minute)*time.Minute)
|
||||
if got := IsWithinWindow(at, windows); got != tc.inside {
|
||||
t.Errorf("%02d:%02d expected inside=%v, got %v", tc.hour, tc.minute, tc.inside, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndCheck_DaysFilter(t *testing.T) {
|
||||
// 周末全天
|
||||
windows := ParseMaintenanceWindows("days=sat|sun,time=00:00-23:59")
|
||||
if len(windows) != 1 {
|
||||
t.Fatalf("expected 1 window")
|
||||
}
|
||||
sat := time.Date(2026, 4, 18, 12, 0, 0, 0, time.UTC) // Saturday
|
||||
sun := time.Date(2026, 4, 19, 12, 0, 0, 0, time.UTC) // Sunday
|
||||
mon := time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC) // Monday
|
||||
if !IsWithinWindow(sat, windows) {
|
||||
t.Fatalf("saturday should be inside")
|
||||
}
|
||||
if !IsWithinWindow(sun, windows) {
|
||||
t.Fatalf("sunday should be inside")
|
||||
}
|
||||
if IsWithinWindow(mon, windows) {
|
||||
t.Fatalf("monday should be outside")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndCheck_Multiple(t *testing.T) {
|
||||
// 两段:工作日跨夜 + 周末全天
|
||||
windows := ParseMaintenanceWindows("days=mon|tue|wed|thu|fri,time=22:00-06:00;days=sat|sun,time=00:00-23:59")
|
||||
if len(windows) != 2 {
|
||||
t.Fatalf("expected 2 windows, got %d", len(windows))
|
||||
}
|
||||
monAfternoon := time.Date(2026, 4, 20, 15, 0, 0, 0, time.UTC)
|
||||
if IsWithinWindow(monAfternoon, windows) {
|
||||
t.Fatalf("mon 15:00 should be outside both windows")
|
||||
}
|
||||
monNight := time.Date(2026, 4, 20, 23, 0, 0, 0, time.UTC)
|
||||
if !IsWithinWindow(monNight, windows) {
|
||||
t.Fatalf("mon 23:00 should be inside weekday-night window")
|
||||
}
|
||||
sunNoon := time.Date(2026, 4, 19, 12, 0, 0, 0, time.UTC)
|
||||
if !IsWithinWindow(sunNoon, windows) {
|
||||
t.Fatalf("sun 12:00 should be inside weekend window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMaintenanceWindows(t *testing.T) {
|
||||
if err := ValidateMaintenanceWindows(""); err != nil {
|
||||
t.Fatalf("empty should be valid, got %v", err)
|
||||
}
|
||||
if err := ValidateMaintenanceWindows("time=01:00-05:00"); err != nil {
|
||||
t.Fatalf("valid format rejected: %v", err)
|
||||
}
|
||||
if err := ValidateMaintenanceWindows("bad-input"); err == nil {
|
||||
t.Fatalf("invalid format should return error")
|
||||
}
|
||||
if err := ValidateMaintenanceWindows("time=25:00-30:00"); err == nil {
|
||||
t.Fatalf("invalid hour should return error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWithinWindow_NoWindows(t *testing.T) {
|
||||
if !IsWithinWindow(time.Now(), nil) {
|
||||
t.Fatalf("no windows should always be inside")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user