mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-24 19:13:41 +08:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50ce6587d8 | ||
|
|
f7599dd9bd | ||
|
|
bf0e91db57 | ||
|
|
37092f3167 | ||
|
|
51e4b0b0ce | ||
|
|
493e1faff5 | ||
|
|
68bb964350 | ||
|
|
65cf3a04d4 | ||
|
|
90b58d58d6 | ||
|
|
f584a0802a | ||
|
|
992fc24150 | ||
|
|
386f12a11b | ||
|
|
f807ce10e6 | ||
|
|
a0d1e66199 | ||
|
|
74e29a0753 | ||
|
|
01ce536ca8 | ||
|
|
ef2e15f500 | ||
|
|
bdf68eef7a | ||
|
|
8747d6a21b | ||
|
|
04ad3c29f4 | ||
|
|
e63b8f0be8 | ||
|
|
45bc210313 | ||
|
|
0f30e7bf52 | ||
|
|
e4c52fd8f4 | ||
|
|
17f4ec63ae | ||
|
|
5a936ee162 | ||
|
|
d39335bdde |
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -6,6 +6,10 @@ on:
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
# 最小权限:构建/测试仅需读取仓库内容,显式声明以收敛默认的可写令牌。
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Go Build & Test
|
||||
|
||||
106
CONTRIBUTING.md
Normal file
106
CONTRIBUTING.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# Contributing to BackupX
|
||||
|
||||
感谢你对 BackupX 的关注!本指南介绍如何搭建开发环境并提交贡献。
|
||||
Thanks for your interest in contributing to BackupX! This guide covers how to set up your environment and submit changes.
|
||||
|
||||
## 开发环境 / Development Setup
|
||||
|
||||
### 依赖 / Prerequisites
|
||||
|
||||
- **Go** 1.25+(见 `server/go.mod`)
|
||||
- **Node.js** 20+(CI 与 Docker 使用 Node 20)
|
||||
- **npm** 9+
|
||||
|
||||
### 快速开始 / Quick Start
|
||||
|
||||
分别在两个终端启动前后端(后端 :8340,前端 Vite HMR):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
|
||||
|
||||
# 终端 1 —— 后端(默认 http://localhost:8340)
|
||||
make dev-server
|
||||
|
||||
# 终端 2 —— 前端(Vite 热更新,/api 代理到 8340)
|
||||
make dev-web
|
||||
```
|
||||
|
||||
### 构建 / Building
|
||||
|
||||
```bash
|
||||
make build # 同时构建后端与前端
|
||||
make build-server # 仅后端 → server/bin/backupx
|
||||
make build-web # 仅前端 → web/dist
|
||||
make docker # 构建 Docker 镜像
|
||||
make docker-cn # 国内镜像源加速构建
|
||||
```
|
||||
|
||||
> 后端会自动托管 `web/dist`(或 `server.web_root` 指定目录),因此本地裸机部署无需额外的反向代理即可访问控制台。
|
||||
|
||||
## 测试 / Testing
|
||||
|
||||
提交前请确保测试通过:
|
||||
|
||||
```bash
|
||||
make test # 后端 + 前端全部测试
|
||||
make test-server # 仅后端:cd server && go test ./...
|
||||
make test-web # 仅前端:cd web && npm run test(vitest)
|
||||
```
|
||||
|
||||
新增功能或修复缺陷时,请尽量补充对应测试。
|
||||
|
||||
## 提交信息规范 / Commit Messages
|
||||
|
||||
本项目采用 **Conventional Commits**,正文用中文撰写:
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
```
|
||||
|
||||
| type | 说明 |
|
||||
|------|------|
|
||||
| `feat` | 新功能 |
|
||||
| `fix` | 缺陷修复 |
|
||||
| `docs` | 文档变更 |
|
||||
| `style` | 不影响逻辑的格式调整 |
|
||||
| `refactor` | 重构 |
|
||||
| `perf` | 性能优化 |
|
||||
| `test` | 测试相关 |
|
||||
| `chore` | 构建/依赖/工具链 |
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
feat(storage): 新增 Wasabi S3 后端支持
|
||||
fix(cluster): 修复跨节点恢复的终态处理
|
||||
docs: 补充 CONTRIBUTING 指南
|
||||
```
|
||||
|
||||
## Pull Request 流程
|
||||
|
||||
1. **Fork** 仓库并从最新的 `main` 切出特性分支;
|
||||
2. **开发**功能或修复,必要时补充测试;
|
||||
3. **自测**:确保 `make test` 通过;
|
||||
4. **提交**:使用上述 Conventional Commits(中文);
|
||||
5. **推送**并对着 `main` 发起 PR。
|
||||
|
||||
### PR 描述建议
|
||||
|
||||
- 清晰说明本 PR 做了什么;
|
||||
- 对新功能/修复,补充动机与背景;
|
||||
- 关联相关 Issue(如 `Closes #62`);
|
||||
- 纯文档 PR 可不附测试。
|
||||
|
||||
> 请保持分支基于较新的 `main`:基线过旧的分支容易产生大范围冲突,难以评审与合入。
|
||||
|
||||
## 编码规范 / Coding Conventions
|
||||
|
||||
- **Go**:所有错误必须处理(禁止 `_ = err`),日志使用项目已有库(`zap`),禁止 `fmt.Println`;提交前执行 `gofmt`。
|
||||
- **前端**:遵循项目 ESLint/Prettier/tsconfig 配置,不擅自引入新的 CSS 框架或 UI 库。
|
||||
- **包管理**:`web/` 使用 npm,请提交对应的 `package-lock.json`。
|
||||
|
||||
## License
|
||||
|
||||
向 BackupX 贡献即表示你同意你的贡献以 [Apache License 2.0](LICENSE) 授权。
|
||||
10
README.md
10
README.md
@@ -94,6 +94,16 @@ See the [development guide](https://awuqing.github.io/BackupX/docs/development/s
|
||||
|
||||
Issues and pull requests welcome. Please read the [contributing guide](https://awuqing.github.io/BackupX/docs/development/contributing) before opening a PR — commit messages and PRs on this project are written in Chinese.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=Awuqing%2FBackupX&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Awuqing/BackupX&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Awuqing/BackupX&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Awuqing/BackupX&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## License
|
||||
|
||||
[Apache License 2.0](LICENSE)
|
||||
|
||||
@@ -59,8 +59,11 @@ cp -R "$WEB_SOURCE/." "$PREFIX/web/"
|
||||
chown -R "$APP_USER:$APP_GROUP" "$PREFIX"
|
||||
|
||||
if [ ! -f "$ETC_DIR/config.yaml" ]; then
|
||||
install -m 0640 "$CONFIG_TEMPLATE" "$ETC_DIR/config.yaml"
|
||||
install -o "$APP_USER" -g "$APP_GROUP" -m 0640 "$CONFIG_TEMPLATE" "$ETC_DIR/config.yaml"
|
||||
fi
|
||||
# 确保服务账户能读取配置:历史版本曾以 root:root 0640 安装配置,
|
||||
# 导致以 backupx 身份运行的服务因无权读取配置而启动失败(exit 1)。
|
||||
chown "$APP_USER:$APP_GROUP" "$ETC_DIR/config.yaml"
|
||||
|
||||
if [ -f "$SERVICE_SOURCE" ]; then
|
||||
install -m 0644 "$SERVICE_SOURCE" "/etc/systemd/system/$SERVICE_NAME.service"
|
||||
@@ -105,6 +108,14 @@ cat <<MESSAGE
|
||||
- 配置文件:$ETC_DIR/config.yaml
|
||||
- systemd 服务:/etc/systemd/system/$SERVICE_NAME.service
|
||||
|
||||
Web 控制台已由后端直接托管,无需额外的 nginx 反向代理即可访问:
|
||||
http://<本机IP>:8340
|
||||
|
||||
(如已安装 nginx,脚本会自动写入反向代理配置,可继续用 80 端口访问。)
|
||||
|
||||
排查:若服务未监听端口,请查看日志:
|
||||
journalctl -u "$SERVICE_NAME" -n 50 --no-pager
|
||||
|
||||
如需修改监听地址、数据库路径或日志级别,请编辑 "$ETC_DIR/config.yaml" 后执行:
|
||||
systemctl restart "$SERVICE_NAME"
|
||||
MESSAGE
|
||||
|
||||
237
docs-site/package-lock.json
generated
237
docs-site/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "docs-site-tmp",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "docs-site-tmp",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.10.0",
|
||||
"@docusaurus/faster": "3.10.0",
|
||||
@@ -164,7 +164,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/@algolia/client-search/-/client-search-5.50.2.tgz",
|
||||
"integrity": "sha512-ypSboUJ3XJoQz5DeDo82hCnrRuwq3q9ZdFhVKAik9TnZh1DvLqoQsrbBjXg7C7zQOtV/Qbge/HmyoV6V5L7MhQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@algolia/client-common": "5.50.2",
|
||||
"@algolia/requester-browser-xhr": "5.50.2",
|
||||
@@ -263,12 +262,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
@@ -290,7 +289,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -326,13 +324,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz",
|
||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
@@ -451,9 +449,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-globals": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -473,27 +471,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-imports": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
||||
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-transforms": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.28.6",
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/traverse": "^7.28.6"
|
||||
"@babel/helper-module-imports": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -515,9 +513,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-plugin-utils": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
|
||||
"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
|
||||
"integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -571,18 +569,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -625,12 +623,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz",
|
||||
"integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.0"
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -1215,15 +1213,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-modules-systemjs": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz",
|
||||
"integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz",
|
||||
"integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-transforms": "^7.28.6",
|
||||
"@babel/helper-plugin-utils": "^7.28.6",
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/traverse": "^7.29.0"
|
||||
"@babel/helper-module-transforms": "^7.29.7",
|
||||
"@babel/helper-plugin-utils": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1920,31 +1918,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz",
|
||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz",
|
||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
||||
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-globals": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1952,13 +1950,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -2081,7 +2079,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -2104,7 +2101,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -2214,7 +2210,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@@ -2636,7 +2631,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@@ -3549,7 +3543,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/@docusaurus/faster/-/faster-3.10.0.tgz",
|
||||
"integrity": "sha512-GNPtVH14ISjHfSwnHu3KiFGf86ICmJSQDeSv/QaanpBgiZGOtgZaslnC5q8WiguxM1EVkwcGxPuD8BXF4eggKw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@docusaurus/types": "3.10.0",
|
||||
"@rspack/core": "^1.7.10",
|
||||
@@ -3680,7 +3673,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/@docusaurus/plugin-content-docs/-/plugin-content-docs-3.10.0.tgz",
|
||||
"integrity": "sha512-9BjHhf15ct8Z7TThTC0xRndKDVvMKmVsAGAN7W9FpNRzfMdScOGcXtLmcCWtJGvAezjOJIm6CxOYCy3Io5+RnQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.10.0",
|
||||
"@docusaurus/logger": "3.10.0",
|
||||
@@ -4712,7 +4704,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/@mdx-js/react/-/react-3.1.1.tgz",
|
||||
"integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/mdx": "^2.0.0"
|
||||
},
|
||||
@@ -5417,7 +5408,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/@svgr/core/-/core-8.1.0.tgz",
|
||||
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.21.3",
|
||||
"@svgr/babel-preset": "8.1.0",
|
||||
@@ -5522,7 +5512,6 @@
|
||||
"integrity": "sha512-tglZGyx8N5PC+x1Nd/JrZxqpqlcZoSuG9gTDKO6AuFToFiVB3uS8HvbKFuO7g3lJzvFf9riAb94xs9HU2UhAHQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@swc/counter": "^0.1.3",
|
||||
"@swc/types": "^0.1.26"
|
||||
@@ -6247,7 +6236,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -6589,7 +6577,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz",
|
||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6657,7 +6644,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -6703,7 +6689,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/algoliasearch/-/algoliasearch-5.50.2.tgz",
|
||||
"integrity": "sha512-Tfp26yoNWurUjfgK4GOrVJQhSNXu9tJtHfFFNosgT2YClG+vPyUjX/gbC8rG39qLncnZg8Fj34iarQWpMkqefw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@algolia/abtesting": "1.16.2",
|
||||
"@algolia/client-abtesting": "5.50.2",
|
||||
@@ -7057,9 +7042,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.4.tgz",
|
||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||
"version": "1.20.5",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
@@ -7070,7 +7055,7 @@
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.14.0",
|
||||
"qs": "~6.15.1",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
@@ -7082,7 +7067,7 @@
|
||||
},
|
||||
"node_modules/body-parser/node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -7091,7 +7076,7 @@
|
||||
},
|
||||
"node_modules/body-parser/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7100,7 +7085,7 @@
|
||||
},
|
||||
"node_modules/body-parser/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -7183,7 +7168,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.10.12",
|
||||
"caniuse-lite": "^1.0.30001782",
|
||||
@@ -7842,7 +7826,7 @@
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -8150,7 +8134,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@@ -9280,14 +9263,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmmirror.com/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.3",
|
||||
"body-parser": "~1.20.5",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
@@ -9306,7 +9289,7 @@
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.14.0",
|
||||
"qs": "~6.15.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
@@ -9414,9 +9397,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -9524,7 +9507,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -10571,7 +10553,7 @@
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -12069,7 +12051,7 @@
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14204,7 +14186,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -14260,7 +14241,7 @@
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14719,9 +14700,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -14737,7 +14718,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -15641,7 +15621,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@@ -16335,9 +16314,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.14.2.tgz",
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
@@ -16401,7 +16380,7 @@
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -16416,7 +16395,7 @@
|
||||
},
|
||||
"node_modules/raw-body/node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -16458,7 +16437,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.5.tgz",
|
||||
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -16468,7 +16446,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.5.tgz",
|
||||
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -16524,7 +16501,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/@docusaurus/react-loadable/-/react-loadable-6.0.0.tgz",
|
||||
"integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
},
|
||||
@@ -16553,7 +16529,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/react-router/-/react-router-5.3.4.tgz",
|
||||
"integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.13",
|
||||
"history": "^4.9.0",
|
||||
@@ -17229,7 +17204,7 @@
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -17600,7 +17575,7 @@
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -17619,7 +17594,7 @@
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -17635,7 +17610,7 @@
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -17653,7 +17628,7 @@
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -18328,8 +18303,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsyringe": {
|
||||
"version": "4.10.0",
|
||||
@@ -18363,7 +18337,7 @@
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -18376,7 +18350,7 @@
|
||||
},
|
||||
"node_modules/type-is/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -18385,7 +18359,7 @@
|
||||
},
|
||||
"node_modules/type-is/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -18410,7 +18384,6 @@
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -18752,7 +18725,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -18951,7 +18923,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/webpack/-/webpack-5.106.2.tgz",
|
||||
"integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/eslint-scope": "^3.7.7",
|
||||
"@types/estree": "^1.0.8",
|
||||
@@ -19093,9 +19064,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-dev-server": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/webpack-dev-server/-/webpack-dev-server-5.2.3.tgz",
|
||||
"integrity": "sha512-9Gyu2F7+bg4Vv+pjbovuYDhHX+mqdqITykfzdM9UyKqKHlsE5aAjRhR+oOEfXW5vBeu8tarzlJFIZva4ZjAdrQ==",
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.4.tgz",
|
||||
"integrity": "sha512-GqDPGZN9bRqKBTkp4aWkobDDHMsrXKoGSdOH56smIri8qR0JG8gfL8/v/f/OZR3/OKXjG8uwJbFVhKm/FNU/UA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/bonjour": "^3.5.13",
|
||||
|
||||
@@ -4,6 +4,8 @@ server:
|
||||
port: 8340
|
||||
mode: "release" # debug | release
|
||||
external_url: "" # 可选:Master 对 Agent 可达的 URL,例如 https://backup.example.com
|
||||
web_root: "" # 前端静态目录;留空自动探测(./web、/opt/backupx/web 等)。
|
||||
# 命中后后端直接托管 Web 控制台,无需额外 nginx 反向代理。
|
||||
|
||||
database:
|
||||
path: "./data/backupx.db" # SQLite 数据库路径
|
||||
|
||||
@@ -6,14 +6,15 @@ require (
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/klauspost/compress v1.18.1
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/rclone/rclone v1.73.3
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/rclone/rclone v1.73.5
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/spf13/viper v1.20.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
google.golang.org/api v0.255.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -29,10 +30,10 @@ require (
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.0.2-0.20251110135918-10b7b7e7cd26 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
|
||||
github.com/BurntSushi/toml v1.6.0 // indirect
|
||||
github.com/FilenCloudDienste/filen-sdk-go v0.0.37 // indirect
|
||||
github.com/FilenCloudDienste/filen-sdk-go v0.0.38 // indirect
|
||||
github.com/Files-com/files-sdk-go/v3 v3.2.264 // indirect
|
||||
github.com/IBM/go-sdk-core/v5 v5.18.5 // indirect
|
||||
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd // indirect
|
||||
@@ -49,25 +50,26 @@ require (
|
||||
github.com/anchore/go-lzo v0.1.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/aws/smithy-go v1.25.1 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/boombuler/barcode v1.1.0 // indirect
|
||||
@@ -109,7 +111,7 @@ require (
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.5 // indirect
|
||||
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.9.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
@@ -148,7 +150,6 @@ require (
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7 // indirect
|
||||
github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 // indirect
|
||||
github.com/klauspost/compress v1.18.1 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988 // indirect
|
||||
github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6 // indirect
|
||||
@@ -224,22 +225,22 @@ require (
|
||||
go.mongodb.org/mongo-driver v1.17.6 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
|
||||
go.opentelemetry.io/otel v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/arch v0.14.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/image v0.32.0 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/image v0.41.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/term v0.42.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/grpc v1.79.3 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
|
||||
122
server/go.sum
122
server/go.sum
@@ -51,8 +51,8 @@ github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 h1:ZJJNFaQ86GVKQ9ehw
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3/go.mod h1:URuDvhmATVKqHBH9/0nOiNKk0+YcwfQ3WkK5PqHKxc8=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 h1:sxgSqOB9CDToiaVFpxuvb5wGgGqWa3lCShcm5o0n3bE=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3/go.mod h1:XdED8i399lEVblYHTZM8eXaP07gv4Z58IL6ueMlVlrg=
|
||||
github.com/Azure/go-ntlmssp v0.0.2-0.20251110135918-10b7b7e7cd26 h1:gy/jrlpp8EfSyA73a51fofoSfhp5rPNQAUvDr4Dm91c=
|
||||
github.com/Azure/go-ntlmssp v0.0.2-0.20251110135918-10b7b7e7cd26/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
|
||||
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||
@@ -61,8 +61,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/FilenCloudDienste/filen-sdk-go v0.0.37 h1:W8S9TrAyZ4//3PXsU6+Bi+fe/6uIL986GyS7PVzIDL4=
|
||||
github.com/FilenCloudDienste/filen-sdk-go v0.0.37/go.mod h1:0cBhKXQg49XbKZZfk5TCDa3sVLP+xMxZTWL+7KY0XR0=
|
||||
github.com/FilenCloudDienste/filen-sdk-go v0.0.38 h1:YCyHs3wUXEe2BEWn40vcoyaQ2ruHNmNwkasfo3Th16A=
|
||||
github.com/FilenCloudDienste/filen-sdk-go v0.0.38/go.mod h1:0cBhKXQg49XbKZZfk5TCDa3sVLP+xMxZTWL+7KY0XR0=
|
||||
github.com/Files-com/files-sdk-go/v3 v3.2.264 h1:lMHTplAYI9FtmCo/QOcpRxmPA5REVAct1r2riQmDQKw=
|
||||
github.com/Files-com/files-sdk-go/v3 v3.2.264/go.mod h1:wGqkOzRu/ClJibvDgcfuJNAqI2nLhe8g91tPlDKRCdE=
|
||||
github.com/IBM/go-sdk-core/v5 v5.18.5 h1:g0JRl3sYXJczB/yuDlrN6x22LJ6jIxhp0Sa4ARNW60c=
|
||||
@@ -102,44 +102,46 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc h1:LoL75er+LKDHDUfU5tRvFwxH0LjPpZN8OoG8Ll+liGU=
|
||||
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc/go.mod h1:w648aMHEgFYS6xb0KVMMtZ2uMeemhiKCuD2vj6gY52A=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3 h1:4kQ/fa22KjDt13QCy1+bYADvdgcxpfH18f0zP542kZA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.3/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6 h1:N4lRUXZpZ1KVEUn6hxtco/1d2lgYhNn1fHkkl8WhlyQ=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.6/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.17/go.mod h1:V8P7ILjp/Uef/aX8TjGk6OHZN6IKPM5YW6S78QnRD5c=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.8 h1:iu+64gwDKEoKnyTQskSku72dAwggKI5sV6rNvgSMpMs=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.8/go.mod h1:MI2XvA+qDi3i9AJxX1E2fu730syEBzp/jnXrjxuHwgI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 h1:NdV8cwCcAXrCWyxArt58BrvZJ9pZ9Fhf9w6Uh5W3Uyc=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.11/go.mod h1:30yY2zqkMPdrvxBqzI9xQCM+WrlrZKSOpSJEsylVU+8=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 h1:INUvJxmhdEbVulJYHI061k4TVuS3jzzthNvjqvVvTKM=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19/go.mod h1:FpZN2QISLdEBWkayloda+sZjVJL+e9Gl0k1SyTgcswU=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.4 h1:2fjfz3/G9BRvIKuNZ655GwzpklC2kEH0cowZQGO7uBg=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.4/go.mod h1:Ymws824lvMypLFPwyyUXM52SXuGgxpu0+DISLfKvB+c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19 h1:/sECfyq2JTifMI2JPyZ4bdRN77zJmr6SrS1eL3augIA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.19/go.mod h1:dMf8A5oAqr9/oxOfLkC/c2LU/uMcALP0Rgn2BD5LWn0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19 h1:AWeJMk33GTBf6J20XJe6qZoRSJo0WfUhsMdUKhoODXE=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.19/go.mod h1:+GWrYoaAsV7/4pNHpwh1kiNLXkKaSoppxQq9lbH8Ejw=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.1 h1:IbWiN670htmBioc+Zj32vSpJgQ2+OYSlvTvfQ1nCORQ=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.1/go.mod h1:tw/B596EUhBWDFGdDGuLC21fVU4A3s4/5Efy8S39W18=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20 h1:qi3e/dmpdONhj1RyIZdi6DKKpDXS5Lb8ftr3p7cyHJc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.20/go.mod h1:V1K+TeJVD5JOk3D9e5tsX2KUdL7BlB+FV6cBhdobN8c=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6 h1:XAq62tBTJP/85lFD5oqOOe7YYgWxY9LvWq8plyDvDVg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.6/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11 h1:BYf7XNsJMzl4mObARUBUib+j2tf0U//JAAtTnYqvqCw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.11/go.mod h1:aEUS4WrNk/+FxkBZZa7tVgp4pGH+kFGW40Y8rCPqt5g=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19 h1:X1Tow7suZk9UCJHE1Iw9GMZJJl0dAnKXXP1NaSDHwmw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.19/go.mod h1:/rARO8psX+4sfjUQXp5LLifjUt8DuATZ31WptNJTyQA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19 h1:JnQeStZvPHFHeyky/7LbMlyQjUa+jIBj36OlWm0pzIk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.19/go.mod h1:HGyasyHvYdFQeJhvDHfH7HXkHh57htcJGKDZ+7z+I24=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4 h1:4ExZyubQ6LQQVuF2Qp9OsfEvsTdAWh5Gfwf6PgIdLdk=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.4/go.mod h1:NF3JcMGOiARAss1ld3WGORCw71+4ExDD2cbbdKS5PpA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 h1:Y2cAXlClHsXkkOvWZFXATr34b0hxxloeQu/pAZz2row=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7/go.mod h1:idzZ7gmDeqeNrSPkdbtMp9qWMgcBwykA7P7Rzh5DXVU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 h1:iSsvB9EtQ09YrsmIc44Heqlx5ByGErqhPK1ZQLppias=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12/go.mod h1:fEWYKTRGoZNl8tZ77i61/ccwOMJdGxwOhWCkp6TXAr0=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 h1:EnUdUqRP1CNzt2DkV67tJx6XDN4xlfBFm+bzeNOQVb0=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16/go.mod h1:Jic/xv0Rq/pFNCh3WwpH4BEqdbSAl+IyHro8LbibHD8=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 h1:XQTQTF75vnug2TXS8m7CVJfC2nniYPZnO1D4Np761Oo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8/go.mod h1:Xgx+PR1NUOjNmQY+tRMnouRp83JRM8pRMw/vCaVhPkI=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
|
||||
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -256,8 +258,8 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 h1:JnrjqG5iR07/8k7NqrLNilRsl3s1EPRQEGvbPyOce68=
|
||||
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348/go.mod h1:Czxo/d1g948LtrALAZdL04TL/HnkopquAjxYUuI02bo=
|
||||
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
|
||||
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
|
||||
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
|
||||
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
@@ -530,8 +532,8 @@ github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 h1:4MI2
|
||||
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11/go.mod h1:3HLX7dwZgvB7nt+Yl/xdzVPcargQ1yBmJEUg3n+jMKM=
|
||||
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 h1:Lc+d3ISfQaMJKWZOE7z4ZSY4RVmdzbn1B0IM8xN18qM=
|
||||
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18/go.mod h1:LB2kCEaZMzNn3ocdz+qYfxXmuLxxN0ka62KJd2x53Bc=
|
||||
github.com/rclone/rclone v1.73.3 h1:XKlobcnXxxzxnB6UBSVtRB+UeZmYDV9B4QExVSSGoAY=
|
||||
github.com/rclone/rclone v1.73.3/go.mod h1:QJDWatpAY9sKGXfpKZUXbThvtHoeo78DcFP2+/cbkvc=
|
||||
github.com/rclone/rclone v1.73.5 h1:r8a9JHYIWUqk7hNRJuMJ3cROkKfB2zmfkADg8ZLYh6I=
|
||||
github.com/rclone/rclone v1.73.5/go.mod h1:WVv8gvA/lEl/Y37e8I8yosm7ZY+Szq7ujXbJS8Ol63o=
|
||||
github.com/relvacode/iso8601 v1.7.0 h1:BXy+V60stMP6cpswc+a93Mq3e65PfXCgDFfhvNNGrdo=
|
||||
github.com/relvacode/iso8601 v1.7.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
@@ -651,16 +653,16 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
@@ -688,8 +690,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -700,12 +702,12 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
|
||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
|
||||
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
|
||||
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
|
||||
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
@@ -730,8 +732,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -771,8 +773,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -796,8 +798,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -847,8 +849,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -860,8 +862,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -877,8 +879,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -931,8 +933,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -190,6 +190,7 @@ type RestoreSpec struct {
|
||||
Storage StorageTargetConfig `json:"storage"`
|
||||
StoragePath string `json:"storagePath"`
|
||||
FileName string `json:"fileName"`
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
}
|
||||
|
||||
// RestoreUpdate 与 service.AgentRestoreUpdate 对齐
|
||||
|
||||
@@ -34,6 +34,7 @@ func NewExecutor(client *MasterClient, tempDir string) *Executor {
|
||||
backup.NewMySQLRunner(nil),
|
||||
backup.NewPostgreSQLRunner(nil),
|
||||
backup.NewSAPHANARunner(nil),
|
||||
backup.NewMongoDBRunner(nil),
|
||||
)
|
||||
storageRegistry := storage.NewRegistry(
|
||||
storageRclone.NewLocalDiskFactory(),
|
||||
@@ -106,6 +107,14 @@ func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) er
|
||||
return compressErr
|
||||
}
|
||||
finalPath = compressedPath
|
||||
} else if strings.EqualFold(spec.Compression, "zstd") && !strings.HasSuffix(strings.ToLower(finalPath), ".zst") {
|
||||
e.appendLog(ctx, recordID, "[agent] 开始压缩备份文件(zstd)\n")
|
||||
compressedPath, compressErr := compress.ZstdFile(finalPath)
|
||||
if compressErr != nil {
|
||||
e.reportRecordFailure(ctx, recordID, fmt.Sprintf("压缩失败: %v", compressErr))
|
||||
return compressErr
|
||||
}
|
||||
finalPath = compressedPath
|
||||
}
|
||||
info, err := os.Stat(finalPath)
|
||||
if err != nil {
|
||||
@@ -379,6 +388,24 @@ func (e *Executor) ExecuteRestore(ctx context.Context, restoreRecordID uint) err
|
||||
return err
|
||||
}
|
||||
|
||||
// 2.5) 完整性校验:还原前比对下载对象的 SHA-256(与 Master 本地恢复路径一致)。
|
||||
// 拒绝还原损坏或被篡改的备份;早期无 checksum 的备份跳过(向后兼容)。
|
||||
if strings.TrimSpace(spec.Checksum) != "" {
|
||||
e.appendRestoreLog(ctx, restoreRecordID, "[agent] 校验备份完整性(SHA-256)\n")
|
||||
actual, sumErr := computeFileSHA256(artifactPath)
|
||||
if sumErr != nil {
|
||||
msg := fmt.Sprintf("计算校验和失败: %v", sumErr)
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, msg)
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
if !strings.EqualFold(actual, spec.Checksum) {
|
||||
msg := "备份文件完整性校验失败:SHA-256 不匹配,文件可能已损坏或被篡改"
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, msg)
|
||||
return fmt.Errorf("%s(期望 %s,实际 %s)", msg, spec.Checksum, actual)
|
||||
}
|
||||
e.appendRestoreLog(ctx, restoreRecordID, "[agent] 完整性校验通过\n")
|
||||
}
|
||||
|
||||
// 3) 解压(Agent 不支持加密,遇到 .enc 会直接失败)
|
||||
preparedPath := artifactPath
|
||||
if strings.HasSuffix(strings.ToLower(preparedPath), ".enc") {
|
||||
@@ -395,6 +422,15 @@ func (e *Executor) ExecuteRestore(ctx context.Context, restoreRecordID uint) err
|
||||
}
|
||||
preparedPath = decompressed
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(preparedPath), ".zst") {
|
||||
e.appendRestoreLog(ctx, restoreRecordID, "[agent] 解压 zstd 压缩\n")
|
||||
decompressed, err := compress.UnzstdFile(preparedPath)
|
||||
if err != nil {
|
||||
e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("解压失败: %v", err))
|
||||
return err
|
||||
}
|
||||
preparedPath = decompressed
|
||||
}
|
||||
|
||||
// 4) 运行 runner.Restore
|
||||
taskSpec := buildRestoreBackupTaskSpec(spec, time.Now().UTC(), tmpDir)
|
||||
|
||||
@@ -82,7 +82,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
backupTaskService := service.NewBackupTaskService(backupTaskRepo, storageTargetRepo, configCipher)
|
||||
backupTaskService.SetRecordsAndStorage(backupRecordRepo, storageRegistry)
|
||||
// nodeRepo 在下方 Cluster 节点管理区块才实例化,这里延后注入
|
||||
backupRunnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil), backup.NewSAPHANARunner(nil))
|
||||
backupRunnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil), backup.NewSAPHANARunner(nil), backup.NewMongoDBRunner(nil))
|
||||
logHub := backup.NewLogHub()
|
||||
retentionService := backupretention.NewService(backupRecordRepo)
|
||||
notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier())
|
||||
@@ -104,6 +104,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
restoreRecordRepo := repository.NewRestoreRecordRepository(db)
|
||||
restoreLogHub := backup.NewLogHub()
|
||||
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
|
||||
reportService := service.NewReportService(backupTaskRepo, backupRecordRepo)
|
||||
settingsService := service.NewSettingsService(systemConfigRepo)
|
||||
|
||||
// Audit
|
||||
@@ -113,6 +114,8 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
schedulerService.SetAuditRecorder(auditService)
|
||||
// 审计日志外输:启动时用当前 settings 初始化 webhook,后续前端修改立即生效
|
||||
settingsService.SetAuditWebhookConfigurer(ctx, auditService)
|
||||
// 审计日志保留期清理:每 6h 读取 audit_retention_days 设置并清理超期日志(0/缺省=永久保留)
|
||||
auditService.StartRetentionMonitor(ctx, systemConfigRepo, 6*time.Hour)
|
||||
|
||||
// Database discovery(集群依赖在 agentService 创建后注入)
|
||||
databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor())
|
||||
@@ -268,6 +271,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
||||
ApiKeyService: apiKeyService,
|
||||
NotificationService: notificationService,
|
||||
DashboardService: dashboardService,
|
||||
ReportService: reportService,
|
||||
SettingsService: settingsService,
|
||||
NodeService: nodeService,
|
||||
AgentService: agentService,
|
||||
|
||||
@@ -3,6 +3,7 @@ package backup
|
||||
import (
|
||||
"archive/tar"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -52,6 +53,20 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R
|
||||
defer tw.Close()
|
||||
|
||||
excludes := normalizeExcludePatterns(task.ExcludePatterns)
|
||||
|
||||
// 差异备份:基于上次全量清单仅打包新增/变更条目并记录删除;
|
||||
// 全量备份:记录完整清单(manifest)供后续差异比对。
|
||||
differential := task.Differential && len(task.BaseManifest.Entries) > 0
|
||||
baseIndex := map[string]ManifestEntry{}
|
||||
seen := map[string]struct{}{}
|
||||
var manifest *Manifest
|
||||
if differential {
|
||||
baseIndex = task.BaseManifest.index()
|
||||
writer.WriteLine(fmt.Sprintf("差异备份模式:基线含 %d 个条目", len(baseIndex)))
|
||||
} else {
|
||||
manifest = &Manifest{Entries: make([]ManifestEntry, 0)}
|
||||
}
|
||||
|
||||
totalFileCount := 0
|
||||
totalDirCount := 0
|
||||
|
||||
@@ -88,6 +103,16 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R
|
||||
return nil
|
||||
}
|
||||
|
||||
entry := entryFromInfo(archiveName, currentInfo)
|
||||
if differential {
|
||||
seen[entry.Path] = struct{}{}
|
||||
if !changedSince(baseIndex, entry) {
|
||||
return nil // 自全量以来未变更,跳过
|
||||
}
|
||||
} else {
|
||||
manifest.Entries = append(manifest.Entries, entry)
|
||||
}
|
||||
|
||||
if currentInfo.IsDir() {
|
||||
dirCount++
|
||||
writer.WriteLine(fmt.Sprintf("📁 进入目录 %s", archiveName))
|
||||
@@ -103,13 +128,19 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R
|
||||
}
|
||||
|
||||
if currentInfo.Mode().IsRegular() {
|
||||
file, err := os.Open(currentPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := io.CopyN(tw, file, currentInfo.Size()); err != nil && err != io.EOF {
|
||||
return err
|
||||
// 每个文件在独立作用域内打开并关闭,避免在大目录树中累积打开的文件句柄。
|
||||
if copyErr := func() error {
|
||||
file, err := os.Open(currentPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := io.CopyN(tw, file, currentInfo.Size()); err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}(); copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
fileCount++
|
||||
if fileCount%100 == 0 {
|
||||
@@ -130,10 +161,16 @@ func (r *FileRunner) Run(_ context.Context, task TaskSpec, writer LogWriter) (*R
|
||||
totalDirCount += dirCount
|
||||
}
|
||||
|
||||
if len(sourcePaths) > 1 {
|
||||
if differential {
|
||||
deletions := deletedPaths(baseIndex, seen)
|
||||
if err := writeDeletionsEntry(tw, deletions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
writer.WriteLine(fmt.Sprintf("差异备份完成(%d 个目录、%d 个文件变更,删除 %d 项)", totalDirCount, totalFileCount, len(deletions)))
|
||||
} else if len(sourcePaths) > 1 {
|
||||
writer.WriteLine(fmt.Sprintf("全部源路径打包完成(共 %d 个目录,%d 个文件)", totalDirCount, totalFileCount))
|
||||
}
|
||||
return &RunResult{ArtifactPath: artifactPath, FileName: filepath.Base(artifactPath), TempDir: tempDir}, nil
|
||||
return &RunResult{ArtifactPath: artifactPath, FileName: filepath.Base(artifactPath), TempDir: tempDir, Manifest: manifest}, nil
|
||||
}
|
||||
|
||||
func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath string, writer LogWriter) error {
|
||||
@@ -148,9 +185,15 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri
|
||||
restoreSource = task.SourcePaths[0]
|
||||
}
|
||||
targetParent := filepath.Dir(filepath.Clean(strings.TrimSpace(restoreSource)))
|
||||
// 恢复到指定位置:非空时归档解压到用户指定目录,而非原始源父目录。
|
||||
if override := strings.TrimSpace(task.RestoreTargetPath); override != "" {
|
||||
targetParent = filepath.Clean(override)
|
||||
writer.WriteLine(fmt.Sprintf("恢复到指定目录:%s", targetParent))
|
||||
}
|
||||
if err := os.MkdirAll(targetParent, 0o755); err != nil {
|
||||
return fmt.Errorf("create restore parent: %w", err)
|
||||
}
|
||||
var pendingDeletions []string
|
||||
tr := tar.NewReader(artifactFile)
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
@@ -160,13 +203,27 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri
|
||||
if err != nil {
|
||||
return fmt.Errorf("read tar entry: %w", err)
|
||||
}
|
||||
// 差异归档的删除清单不落地,留待提取完成后统一应用(避免被同批新增条目误删)。
|
||||
if header.Name == deletionsEntryName {
|
||||
data, readErr := io.ReadAll(tr)
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("read deletions entry: %w", readErr)
|
||||
}
|
||||
if jsonErr := json.Unmarshal(data, &pendingDeletions); jsonErr != nil {
|
||||
return fmt.Errorf("parse deletions entry: %w", jsonErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
cleanName := path.Clean(strings.TrimSpace(header.Name))
|
||||
if cleanName == "." || cleanName == "" {
|
||||
continue
|
||||
}
|
||||
targetPath := filepath.Clean(filepath.Join(targetParent, filepath.FromSlash(cleanName)))
|
||||
parentWithSep := filepath.Clean(targetParent) + string(filepath.Separator)
|
||||
if targetPath != filepath.Clean(targetParent) && !strings.HasPrefix(targetPath, parentWithSep) {
|
||||
// 选择性恢复:仅提取被选中的文件/目录(及其子项)。
|
||||
if len(task.SelectedPaths) > 0 && !pathSelected(cleanName, task.SelectedPaths) {
|
||||
continue
|
||||
}
|
||||
targetPath, ok := resolveWithinParent(targetParent, cleanName)
|
||||
if !ok {
|
||||
return fmt.Errorf("tar entry escapes restore path")
|
||||
}
|
||||
switch header.Typeflag {
|
||||
@@ -191,10 +248,94 @@ func (r *FileRunner) Restore(_ context.Context, task TaskSpec, artifactPath stri
|
||||
}
|
||||
}
|
||||
}
|
||||
// 选择性恢复时仅对选中范围应用删除,避免误删未选中的文件。
|
||||
if len(task.SelectedPaths) > 0 {
|
||||
pendingDeletions = filterSelectedPaths(pendingDeletions, task.SelectedPaths)
|
||||
}
|
||||
if err := applyDeletions(targetParent, pendingDeletions, writer); err != nil {
|
||||
return err
|
||||
}
|
||||
writer.WriteLine("文件恢复完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
// pathSelected 判断归档条目名是否落在选中集合内(精确匹配或位于选中目录之下)。
|
||||
func pathSelected(name string, selected []string) bool {
|
||||
for _, sel := range selected {
|
||||
clean := path.Clean(strings.TrimSpace(sel))
|
||||
if clean == "" || clean == "." {
|
||||
continue
|
||||
}
|
||||
if name == clean || strings.HasPrefix(name, clean+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// filterSelectedPaths 仅保留落在选中集合内的路径。
|
||||
func filterSelectedPaths(paths []string, selected []string) []string {
|
||||
filtered := make([]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
if pathSelected(path.Clean(strings.TrimSpace(p)), selected) {
|
||||
filtered = append(filtered, p)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// resolveWithinParent 将归档相对名安全解析为 targetParent 下的绝对路径;
|
||||
// 越界(路径穿越)时返回 ok=false。提取与删除共用此校验,杜绝逃逸。
|
||||
func resolveWithinParent(targetParent, name string) (string, bool) {
|
||||
targetPath := filepath.Clean(filepath.Join(targetParent, filepath.FromSlash(name)))
|
||||
cleanParent := filepath.Clean(targetParent)
|
||||
if targetPath == cleanParent {
|
||||
return targetPath, true
|
||||
}
|
||||
if !strings.HasPrefix(targetPath, cleanParent+string(filepath.Separator)) {
|
||||
return "", false
|
||||
}
|
||||
return targetPath, true
|
||||
}
|
||||
|
||||
// writeDeletionsEntry 将差异备份的删除路径列表写入归档特殊条目。
|
||||
func writeDeletionsEntry(tw *tar.Writer, deletions []string) error {
|
||||
payload, err := json.Marshal(deletions)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal deletions: %w", err)
|
||||
}
|
||||
header := &tar.Header{Name: deletionsEntryName, Mode: 0o600, Size: int64(len(payload)), Typeflag: tar.TypeReg}
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return fmt.Errorf("write deletions header: %w", err)
|
||||
}
|
||||
if _, err := tw.Write(payload); err != nil {
|
||||
return fmt.Errorf("write deletions body: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyDeletions 在基线恢复之上删除差异归档记录的路径(仅差异备份恢复时存在)。
|
||||
// 每个路径经 resolveWithinParent 校验,越界即报错;目标不存在视为已删除。
|
||||
func applyDeletions(targetParent string, deletions []string, writer LogWriter) error {
|
||||
for _, name := range deletions {
|
||||
clean := path.Clean(strings.TrimSpace(name))
|
||||
if clean == "." || clean == "" {
|
||||
continue
|
||||
}
|
||||
targetPath, ok := resolveWithinParent(targetParent, clean)
|
||||
if !ok {
|
||||
return fmt.Errorf("deletion entry escapes restore path")
|
||||
}
|
||||
if err := os.RemoveAll(targetPath); err != nil {
|
||||
return fmt.Errorf("apply deletion %s: %w", clean, err)
|
||||
}
|
||||
}
|
||||
if len(deletions) > 0 {
|
||||
writer.WriteLine(fmt.Sprintf("已应用差异删除 %d 项", len(deletions)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeExcludePatterns(items []string) []string {
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
|
||||
182
server/internal/backup/file_runner_diff_test.go
Normal file
182
server/internal/backup/file_runner_diff_test.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func diffWrite(t *testing.T, p, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", filepath.Dir(p), err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
func diffAssertContent(t *testing.T, p, want string) {
|
||||
t.Helper()
|
||||
got, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", p, err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Fatalf("%s content = %q, want %q", p, string(got), want)
|
||||
}
|
||||
}
|
||||
|
||||
func diffAssertAbsent(t *testing.T, p string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected %s to be absent, stat err=%v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
func diffArchiveNames(t *testing.T, artifactPath string) map[string]bool {
|
||||
t.Helper()
|
||||
f, err := os.Open(artifactPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open artifact: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
names := map[string]bool{}
|
||||
tr := tar.NewReader(f)
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("read tar: %v", err)
|
||||
}
|
||||
names[h.Name] = true
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// TestFileRunnerDifferentialRoundTrip 验证差异备份的端到端正确性:
|
||||
// 全量 → 修改源(变更/删除/新增)→ 差异 → 链式恢复(全量+差异)→ 结果与修改后源一致。
|
||||
func TestFileRunnerDifferentialRoundTrip(t *testing.T) {
|
||||
work := t.TempDir()
|
||||
src := filepath.Join(work, "src")
|
||||
diffWrite(t, filepath.Join(src, "a.txt"), "alpha")
|
||||
diffWrite(t, filepath.Join(src, "b.txt"), "bravo")
|
||||
diffWrite(t, filepath.Join(src, "sub", "c.txt"), "charlie")
|
||||
|
||||
runner := NewFileRunner()
|
||||
|
||||
full, err := runner.Run(context.Background(), TaskSpec{Name: "diff", Type: "file", SourcePath: src, TempDir: t.TempDir()}, NopLogWriter{})
|
||||
if err != nil {
|
||||
t.Fatalf("full Run: %v", err)
|
||||
}
|
||||
if full.Manifest == nil || len(full.Manifest.Entries) == 0 {
|
||||
t.Fatalf("full backup must produce a manifest, got %#v", full.Manifest)
|
||||
}
|
||||
|
||||
// 变更 a.txt(内容变长 → size 差异必被检出)、删除 b.txt、新增 d.txt;sub/c.txt 不变
|
||||
diffWrite(t, filepath.Join(src, "a.txt"), "ALPHA-modified-and-longer")
|
||||
if err := os.Remove(filepath.Join(src, "b.txt")); err != nil {
|
||||
t.Fatalf("remove b.txt: %v", err)
|
||||
}
|
||||
diffWrite(t, filepath.Join(src, "d.txt"), "delta")
|
||||
|
||||
diff, err := runner.Run(context.Background(), TaskSpec{Name: "diff", Type: "file", SourcePath: src, TempDir: t.TempDir(), Differential: true, BaseManifest: *full.Manifest}, NopLogWriter{})
|
||||
if err != nil {
|
||||
t.Fatalf("differential Run: %v", err)
|
||||
}
|
||||
if diff.Manifest != nil {
|
||||
t.Fatalf("differential backup must not produce a manifest")
|
||||
}
|
||||
|
||||
// 差异归档应包含变更/新增条目与删除清单,但不含未变更的 sub/c.txt
|
||||
names := diffArchiveNames(t, diff.ArtifactPath)
|
||||
if !names["src/a.txt"] || !names["src/d.txt"] {
|
||||
t.Fatalf("differential archive missing changed/new entries: %v", names)
|
||||
}
|
||||
if names["src/sub/c.txt"] {
|
||||
t.Fatalf("differential archive should not contain unchanged file sub/c.txt")
|
||||
}
|
||||
if !names[deletionsEntryName] {
|
||||
t.Fatalf("differential archive missing deletions entry: %v", names)
|
||||
}
|
||||
|
||||
// 链式恢复到全新目标
|
||||
restoreRoot := t.TempDir()
|
||||
restoreSrc := filepath.Join(restoreRoot, "src")
|
||||
restoreTask := TaskSpec{Name: "diff", Type: "file", SourcePath: restoreSrc}
|
||||
if err := runner.Restore(context.Background(), restoreTask, full.ArtifactPath, NopLogWriter{}); err != nil {
|
||||
t.Fatalf("restore full: %v", err)
|
||||
}
|
||||
if err := runner.Restore(context.Background(), restoreTask, diff.ArtifactPath, NopLogWriter{}); err != nil {
|
||||
t.Fatalf("restore differential: %v", err)
|
||||
}
|
||||
|
||||
diffAssertContent(t, filepath.Join(restoreSrc, "a.txt"), "ALPHA-modified-and-longer")
|
||||
diffAssertContent(t, filepath.Join(restoreSrc, "sub", "c.txt"), "charlie")
|
||||
diffAssertContent(t, filepath.Join(restoreSrc, "d.txt"), "delta")
|
||||
diffAssertAbsent(t, filepath.Join(restoreSrc, "b.txt"))
|
||||
}
|
||||
|
||||
func TestPathSelected(t *testing.T) {
|
||||
sel := []string{"src/a.txt", "src/sub"}
|
||||
cases := map[string]bool{
|
||||
"src/a.txt": true,
|
||||
"src/sub": true,
|
||||
"src/sub/c.txt": true, // 选中目录下的子项
|
||||
"src/b.txt": false, // 未选中文件
|
||||
"src/subother": false, // 前缀相近但非子项,不应误判
|
||||
}
|
||||
for name, want := range cases {
|
||||
if got := pathSelected(name, sel); got != want {
|
||||
t.Errorf("pathSelected(%q) = %v, want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileRunnerSelectiveRestore 验证按需恢复:仅选中的文件与目录被还原,未选中的文件不出现。
|
||||
func TestFileRunnerSelectiveRestore(t *testing.T) {
|
||||
work := t.TempDir()
|
||||
src := filepath.Join(work, "src")
|
||||
diffWrite(t, filepath.Join(src, "a.txt"), "alpha")
|
||||
diffWrite(t, filepath.Join(src, "b.txt"), "bravo")
|
||||
diffWrite(t, filepath.Join(src, "sub", "c.txt"), "charlie")
|
||||
|
||||
runner := NewFileRunner()
|
||||
full, err := runner.Run(context.Background(), TaskSpec{Name: "sel", Type: "file", SourcePath: src, TempDir: t.TempDir()}, NopLogWriter{})
|
||||
if err != nil {
|
||||
t.Fatalf("full Run: %v", err)
|
||||
}
|
||||
|
||||
restoreRoot := t.TempDir()
|
||||
restoreSrc := filepath.Join(restoreRoot, "src")
|
||||
task := TaskSpec{Name: "sel", Type: "file", SourcePath: restoreSrc, SelectedPaths: []string{"src/a.txt", "src/sub"}}
|
||||
if err := runner.Restore(context.Background(), task, full.ArtifactPath, NopLogWriter{}); err != nil {
|
||||
t.Fatalf("selective Restore: %v", err)
|
||||
}
|
||||
diffAssertContent(t, filepath.Join(restoreSrc, "a.txt"), "alpha")
|
||||
diffAssertContent(t, filepath.Join(restoreSrc, "sub", "c.txt"), "charlie") // 选中目录 → 子项一并恢复
|
||||
diffAssertAbsent(t, filepath.Join(restoreSrc, "b.txt")) // 未选中 → 不恢复
|
||||
}
|
||||
|
||||
// TestFileRunnerDifferentialWithoutBaseIsFull 验证无基线时差异请求回退为全量(产出清单、含全部文件)。
|
||||
func TestFileRunnerDifferentialWithoutBaseIsFull(t *testing.T) {
|
||||
src := filepath.Join(t.TempDir(), "src")
|
||||
diffWrite(t, filepath.Join(src, "a.txt"), "alpha")
|
||||
|
||||
runner := NewFileRunner()
|
||||
res, err := runner.Run(context.Background(), TaskSpec{Name: "diff", Type: "file", SourcePath: src, TempDir: t.TempDir(), Differential: true}, NopLogWriter{})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if res.Manifest == nil {
|
||||
t.Fatalf("differential without base must fall back to full and produce a manifest")
|
||||
}
|
||||
if names := diffArchiveNames(t, res.ArtifactPath); !names["src/a.txt"] || names[deletionsEntryName] {
|
||||
t.Fatalf("fallback-full archive unexpected: %v", names)
|
||||
}
|
||||
}
|
||||
92
server/internal/backup/manifest.go
Normal file
92
server/internal/backup/manifest.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// deletionsEntryName 是差异备份归档中记录「自全量以来被删除路径」的特殊条目名。
|
||||
// 恢复时该条目不落地为文件,而是用于在基线之上删除对应路径。
|
||||
const deletionsEntryName = ".backupx/deletions.json"
|
||||
|
||||
// ManifestEntry 记录一次全量备份中单个归档条目(文件或目录)的指纹,
|
||||
// 供差异备份比对「自全量以来的变化」。Path 为归档内相对名(slash 分隔,
|
||||
// 与 tar header.Name 一致)。字段使用短键以压缩清单体积。
|
||||
type ManifestEntry struct {
|
||||
Path string `json:"p"`
|
||||
Size int64 `json:"s"`
|
||||
ModTimeNs int64 `json:"m"`
|
||||
Mode uint32 `json:"o"`
|
||||
IsDir bool `json:"d,omitempty"`
|
||||
}
|
||||
|
||||
// Manifest 是一次全量备份的完整条目清单(文件与目录)。
|
||||
type Manifest struct {
|
||||
Entries []ManifestEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// EncodeManifest 将清单序列化为紧凑 JSON。
|
||||
func EncodeManifest(m Manifest) ([]byte, error) {
|
||||
return json.Marshal(m)
|
||||
}
|
||||
|
||||
// DecodeManifest 反序列化清单;空输入返回空清单(视为「无基线」)。
|
||||
func DecodeManifest(data []byte) (Manifest, error) {
|
||||
m := Manifest{}
|
||||
if len(data) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// index 构建 path -> entry 映射,便于差异比对 O(1) 查找。
|
||||
func (m Manifest) index() map[string]ManifestEntry {
|
||||
idx := make(map[string]ManifestEntry, len(m.Entries))
|
||||
for _, e := range m.Entries {
|
||||
idx[e.Path] = e
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
// entryFromInfo 由归档名与文件信息构造指纹条目。
|
||||
func entryFromInfo(archiveName string, info os.FileInfo) ManifestEntry {
|
||||
return ManifestEntry{
|
||||
Path: filepath.ToSlash(archiveName),
|
||||
Size: info.Size(),
|
||||
ModTimeNs: info.ModTime().UnixNano(),
|
||||
Mode: uint32(info.Mode().Perm()),
|
||||
IsDir: info.IsDir(),
|
||||
}
|
||||
}
|
||||
|
||||
// changedSince 判断当前条目相对基线是否为「新增或变更」(即应纳入差异归档)。
|
||||
// - 不在基线中 → 新增,纳入;
|
||||
// - 已存在的目录 → 不携带数据,跳过(其下变更文件会各自判定);
|
||||
// - 文件大小或 mtime 变化 → 变更,纳入(rsync 风格启发式)。
|
||||
func changedSince(base map[string]ManifestEntry, cur ManifestEntry) bool {
|
||||
prev, ok := base[cur.Path]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if cur.IsDir {
|
||||
return false
|
||||
}
|
||||
return prev.Size != cur.Size || prev.ModTimeNs != cur.ModTimeNs
|
||||
}
|
||||
|
||||
// deletedPaths 返回基线中存在、但本次遍历未出现的路径(被删除的条目),按路径升序。
|
||||
func deletedPaths(base map[string]ManifestEntry, seen map[string]struct{}) []string {
|
||||
deleted := make([]string, 0)
|
||||
for p := range base {
|
||||
if _, ok := seen[p]; !ok {
|
||||
deleted = append(deleted, p)
|
||||
}
|
||||
}
|
||||
sort.Strings(deleted)
|
||||
return deleted
|
||||
}
|
||||
79
server/internal/backup/manifest_test.go
Normal file
79
server/internal/backup/manifest_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncodeDecodeManifestRoundTrip(t *testing.T) {
|
||||
m := Manifest{Entries: []ManifestEntry{
|
||||
{Path: "src/a.txt", Size: 10, ModTimeNs: 100, Mode: 0o644},
|
||||
{Path: "src", Size: 0, ModTimeNs: 50, Mode: 0o755, IsDir: true},
|
||||
}}
|
||||
data, err := EncodeManifest(m)
|
||||
if err != nil {
|
||||
t.Fatalf("EncodeManifest: %v", err)
|
||||
}
|
||||
got, err := DecodeManifest(data)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeManifest: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, m) {
|
||||
t.Fatalf("roundtrip mismatch:\n got %#v\nwant %#v", got, m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeManifestEmpty(t *testing.T) {
|
||||
got, err := DecodeManifest(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeManifest(nil): %v", err)
|
||||
}
|
||||
if len(got.Entries) != 0 {
|
||||
t.Fatalf("expected empty manifest, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangedSince(t *testing.T) {
|
||||
base := Manifest{Entries: []ManifestEntry{
|
||||
{Path: "a.txt", Size: 10, ModTimeNs: 100},
|
||||
{Path: "dir", IsDir: true, ModTimeNs: 100},
|
||||
}}.index()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cur ManifestEntry
|
||||
want bool
|
||||
}{
|
||||
{"unchanged file", ManifestEntry{Path: "a.txt", Size: 10, ModTimeNs: 100}, false},
|
||||
{"size changed", ManifestEntry{Path: "a.txt", Size: 11, ModTimeNs: 100}, true},
|
||||
{"mtime changed", ManifestEntry{Path: "a.txt", Size: 10, ModTimeNs: 200}, true},
|
||||
{"new file", ManifestEntry{Path: "b.txt", Size: 1, ModTimeNs: 1}, true},
|
||||
{"existing dir skipped", ManifestEntry{Path: "dir", IsDir: true, ModTimeNs: 999}, false},
|
||||
{"new dir included", ManifestEntry{Path: "newdir", IsDir: true, ModTimeNs: 1}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := changedSince(base, tc.cur); got != tc.want {
|
||||
t.Errorf("%s: changedSince=%v want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletedPaths(t *testing.T) {
|
||||
base := Manifest{Entries: []ManifestEntry{
|
||||
{Path: "a"}, {Path: "b"}, {Path: "c"},
|
||||
}}.index()
|
||||
seen := map[string]struct{}{"a": {}, "c": {}}
|
||||
got := deletedPaths(base, seen)
|
||||
want := []string{"b"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("deletedPaths=%v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletedPathsNoneWhenAllSeen(t *testing.T) {
|
||||
base := Manifest{Entries: []ManifestEntry{{Path: "a"}, {Path: "b"}}}.index()
|
||||
seen := map[string]struct{}{"a": {}, "b": {}}
|
||||
if got := deletedPaths(base, seen); len(got) != 0 {
|
||||
t.Fatalf("expected no deletions, got %v", got)
|
||||
}
|
||||
}
|
||||
119
server/internal/backup/mongodb_runner.go
Normal file
119
server/internal/backup/mongodb_runner.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MongoDBRunner 通过 mongodump/mongorestore 备份与恢复 MongoDB 数据库。
|
||||
// 采用 --archive 流式模式(dump 写 stdout、restore 读 stdin),与 MySQLRunner
|
||||
// 的 mysqldump/mysql 管线保持一致;产物为未压缩的 mongo archive,由备份管线统一压缩/加密。
|
||||
type MongoDBRunner struct {
|
||||
executor CommandExecutor
|
||||
}
|
||||
|
||||
func NewMongoDBRunner(executor CommandExecutor) *MongoDBRunner {
|
||||
if executor == nil {
|
||||
executor = NewOSCommandExecutor()
|
||||
}
|
||||
return &MongoDBRunner{executor: executor}
|
||||
}
|
||||
|
||||
func (r *MongoDBRunner) Type() string {
|
||||
return "mongodb"
|
||||
}
|
||||
|
||||
func (r *MongoDBRunner) Run(ctx context.Context, task TaskSpec, writer LogWriter) (*RunResult, error) {
|
||||
if _, err := r.executor.LookPath("mongodump"); err != nil {
|
||||
return nil, fmt.Errorf("未找到 mongodump 命令 (请确保服务器已安装 mongodb-database-tools)")
|
||||
}
|
||||
startedAt := task.StartedAt
|
||||
if startedAt.IsZero() {
|
||||
startedAt = time.Now().UTC()
|
||||
}
|
||||
tempDir, err := CreateTaskTempDir(task.Name, startedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fileName := BuildArtifactName(task.Name, startedAt, "archive")
|
||||
artifactPath := filepath.Join(tempDir, fileName)
|
||||
file, err := os.Create(artifactPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create mongodump archive file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
args := mongoConnArgs(task.Database)
|
||||
dbNames := normalizeDatabaseNames(task.Database.Names)
|
||||
if len(dbNames) == 1 {
|
||||
args = append(args, "--db", dbNames[0])
|
||||
writer.WriteLine(fmt.Sprintf("备份数据库: %s", dbNames[0]))
|
||||
} else {
|
||||
writer.WriteLine("备份全部数据库")
|
||||
}
|
||||
args = append(args, "--archive") // 归档流式写入 stdout
|
||||
|
||||
writer.WriteLine(fmt.Sprintf("连接到 MongoDB: %s:%d", task.Database.Host, task.Database.Port))
|
||||
stderrWriter := newLogLineWriter(writer, "mongodump")
|
||||
writer.WriteLine("开始执行 mongodump")
|
||||
if err := r.executor.Run(ctx, "mongodump", args, CommandOptions{Stdout: file, Stderr: stderrWriter}); err != nil {
|
||||
return nil, fmt.Errorf("run mongodump: %w: %s", err, stderrWriter.collected())
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat mongodump archive: %w", err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
return nil, fmt.Errorf("mongodump 产物为空,请检查数据库连接与权限")
|
||||
}
|
||||
writer.WriteLine(fmt.Sprintf("MongoDB 导出完成(文件大小: %s)", formatFileSize(info.Size())))
|
||||
return &RunResult{ArtifactPath: artifactPath, FileName: fileName, TempDir: tempDir, Size: info.Size(), StorageKey: BuildStorageKey("mongodb", startedAt, fileName)}, nil
|
||||
}
|
||||
|
||||
func (r *MongoDBRunner) Restore(ctx context.Context, task TaskSpec, artifactPath string, writer LogWriter) error {
|
||||
if _, err := r.executor.LookPath("mongorestore"); err != nil {
|
||||
return fmt.Errorf("未找到 mongorestore 命令 (请确保服务器已安装 mongodb-database-tools)")
|
||||
}
|
||||
input, err := os.Open(filepath.Clean(artifactPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("open mongodb restore archive: %w", err)
|
||||
}
|
||||
defer input.Close()
|
||||
|
||||
args := mongoConnArgs(task.Database)
|
||||
// --drop:恢复前删除同名集合,保证恢复后与归档一致(与 mysql 恢复的整库覆盖语义对齐)。
|
||||
args = append(args, "--drop", "--archive")
|
||||
stderr := &bytes.Buffer{}
|
||||
writer.WriteLine("开始执行 mongorestore")
|
||||
if err := r.executor.Run(ctx, "mongorestore", args, CommandOptions{Stdin: input, Stderr: stderr}); err != nil {
|
||||
return fmt.Errorf("run mongorestore: %w: %s", err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
writer.WriteLine("MongoDB 恢复完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
// mongoConnArgs 构造 mongodump/mongorestore 的连接与认证参数。
|
||||
// 注意:mongodb-database-tools 无类似 MYSQL_PWD 的密码环境变量,密码只能经 --password 传入;
|
||||
// 认证库默认 admin(绝大多数部署的管理账号所在库)。
|
||||
func mongoConnArgs(db DatabaseSpec) []string {
|
||||
args := make([]string, 0, 8)
|
||||
if strings.TrimSpace(db.Host) != "" {
|
||||
args = append(args, "--host", db.Host)
|
||||
}
|
||||
if db.Port > 0 {
|
||||
args = append(args, "--port", strconv.Itoa(db.Port))
|
||||
}
|
||||
if strings.TrimSpace(db.User) != "" {
|
||||
args = append(args, "--username", db.User, "--authenticationDatabase", "admin")
|
||||
if strings.TrimSpace(db.Password) != "" {
|
||||
args = append(args, "--password", db.Password)
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
102
server/internal/backup/mongodb_runner_test.go
Normal file
102
server/internal/backup/mongodb_runner_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func argIndex(args []string, target string) int {
|
||||
for i, a := range args {
|
||||
if a == target {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func TestMongoDBRunnerRunUsesMongodump(t *testing.T) {
|
||||
executor := &fakeCommandExecutor{runFunc: func(name string, args []string, options CommandOptions) error {
|
||||
if options.Stdout != nil {
|
||||
_, _ = io.WriteString(options.Stdout, "mongo archive bytes")
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
runner := NewMongoDBRunner(executor)
|
||||
result, err := runner.Run(context.Background(), TaskSpec{Name: "mongo", Type: "mongodb", Database: DatabaseSpec{Host: "127.0.0.1", Port: 27017, User: "admin", Password: "secret", Names: []string{"app"}}}, NopLogWriter{})
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if executor.lastName != "mongodump" {
|
||||
t.Fatalf("expected mongodump, got %s", executor.lastName)
|
||||
}
|
||||
args := executor.lastArgs
|
||||
if argIndex(args, "--archive") < 0 {
|
||||
t.Fatalf("expected --archive flag, got %#v", args)
|
||||
}
|
||||
if i := argIndex(args, "--db"); i < 0 || i+1 >= len(args) || args[i+1] != "app" {
|
||||
t.Fatalf("expected --db app, got %#v", args)
|
||||
}
|
||||
if i := argIndex(args, "--username"); i < 0 || args[i+1] != "admin" {
|
||||
t.Fatalf("expected --username admin, got %#v", args)
|
||||
}
|
||||
if argIndex(args, "--authenticationDatabase") < 0 || argIndex(args, "--password") < 0 {
|
||||
t.Fatalf("expected auth args, got %#v", args)
|
||||
}
|
||||
if _, err := os.Stat(result.ArtifactPath); err != nil {
|
||||
t.Fatalf("artifact file missing: %v", err)
|
||||
}
|
||||
if result.StorageKey == "" || !strings.HasSuffix(result.FileName, ".archive") {
|
||||
t.Fatalf("unexpected result metadata: %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMongoDBRunnerRunBackupsAllWhenNoDatabase(t *testing.T) {
|
||||
executor := &fakeCommandExecutor{runFunc: func(name string, args []string, options CommandOptions) error {
|
||||
_, _ = io.WriteString(options.Stdout, "all dbs")
|
||||
return nil
|
||||
}}
|
||||
runner := NewMongoDBRunner(executor)
|
||||
_, err := runner.Run(context.Background(), TaskSpec{Name: "mongo", Type: "mongodb", Database: DatabaseSpec{Host: "127.0.0.1", Port: 27017}}, NopLogWriter{})
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if argIndex(executor.lastArgs, "--db") >= 0 {
|
||||
t.Fatalf("expected no --db when backing up all databases, got %#v", executor.lastArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMongoDBRunnerRunRejectsEmptyOutput(t *testing.T) {
|
||||
executor := &fakeCommandExecutor{} // runFunc nil → writes nothing
|
||||
runner := NewMongoDBRunner(executor)
|
||||
_, err := runner.Run(context.Background(), TaskSpec{Name: "mongo", Type: "mongodb", Database: DatabaseSpec{Host: "127.0.0.1", Port: 27017, Names: []string{"app"}}}, NopLogWriter{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty mongodump output")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMongoDBRunnerRestoreUsesMongorestore(t *testing.T) {
|
||||
executor := &fakeCommandExecutor{}
|
||||
runner := NewMongoDBRunner(executor)
|
||||
artifact := filepathJoinTempFile(t, "dump.archive", "mongo archive bytes")
|
||||
if err := runner.Restore(context.Background(), TaskSpec{Name: "mongo", Type: "mongodb", Database: DatabaseSpec{Host: "127.0.0.1", Port: 27017, User: "admin", Password: "secret"}}, artifact, NopLogWriter{}); err != nil {
|
||||
t.Fatalf("Restore returned error: %v", err)
|
||||
}
|
||||
if executor.lastName != "mongorestore" {
|
||||
t.Fatalf("expected mongorestore, got %s", executor.lastName)
|
||||
}
|
||||
if argIndex(executor.lastArgs, "--drop") < 0 || argIndex(executor.lastArgs, "--archive") < 0 {
|
||||
t.Fatalf("expected --drop --archive, got %#v", executor.lastArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMongoDBRunnerRunReturnsLookupError(t *testing.T) {
|
||||
runner := NewMongoDBRunner(&fakeCommandExecutor{lookupErr: errors.New("missing")})
|
||||
_, err := runner.Run(context.Background(), TaskSpec{Name: "mongo", Type: "mongodb", Database: DatabaseSpec{Host: "127.0.0.1", Port: 27017, Names: []string{"app"}}}, NopLogWriter{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when mongodump is missing")
|
||||
}
|
||||
}
|
||||
55
server/internal/backup/retention/differential_test.go
Normal file
55
server/internal/backup/retention/differential_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package retention
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"backupx/server/internal/model"
|
||||
)
|
||||
|
||||
func retentionRecIDs(records []model.BackupRecord) []uint {
|
||||
ids := make([]uint, 0, len(records))
|
||||
for _, r := range records {
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// 基线全量仍被「不在删除集合中的差异」依赖 → 必须保留,否则差异无法恢复。
|
||||
func TestProtectDifferentialBasesKeepsBaseWithSurvivingDiff(t *testing.T) {
|
||||
all := []model.BackupRecord{
|
||||
{ID: 1, BackupKind: model.BackupKindFull},
|
||||
{ID: 2, BackupKind: model.BackupKindDifferential, BaseRecordID: 1},
|
||||
}
|
||||
candidates := []model.BackupRecord{{ID: 1, BackupKind: model.BackupKindFull}}
|
||||
if got := protectDifferentialBases(all, candidates); len(got) != 0 {
|
||||
t.Fatalf("base with surviving diff must be protected, got %v", retentionRecIDs(got))
|
||||
}
|
||||
}
|
||||
|
||||
// 基线全量与其全部差异都在删除集合中 → 可一并删除(无残留差异失去基线)。
|
||||
func TestProtectDifferentialBasesDeletesBaseWhenDiffAlsoDeleted(t *testing.T) {
|
||||
all := []model.BackupRecord{
|
||||
{ID: 1, BackupKind: model.BackupKindFull},
|
||||
{ID: 2, BackupKind: model.BackupKindDifferential, BaseRecordID: 1},
|
||||
}
|
||||
candidates := []model.BackupRecord{
|
||||
{ID: 1, BackupKind: model.BackupKindFull},
|
||||
{ID: 2, BackupKind: model.BackupKindDifferential, BaseRecordID: 1},
|
||||
}
|
||||
if got := protectDifferentialBases(all, candidates); len(got) != 2 {
|
||||
t.Fatalf("base+diff both expired should both be deleted, got %v", retentionRecIDs(got))
|
||||
}
|
||||
}
|
||||
|
||||
// 无差异备份时原样透传(不影响既有全量保留逻辑)。
|
||||
func TestProtectDifferentialBasesNoDiffsPassThrough(t *testing.T) {
|
||||
all := []model.BackupRecord{
|
||||
{ID: 1, BackupKind: model.BackupKindFull},
|
||||
{ID: 2, BackupKind: model.BackupKindFull},
|
||||
}
|
||||
candidates := []model.BackupRecord{{ID: 1, BackupKind: model.BackupKindFull}}
|
||||
got := protectDifferentialBases(all, candidates)
|
||||
if len(got) != 1 || got[0].ID != 1 {
|
||||
t.Fatalf("no diffs should pass through unchanged, got %v", retentionRecIDs(got))
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package retention
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -56,7 +57,15 @@ func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list successful records: %w", err)
|
||||
}
|
||||
candidates := selectRecordsToDelete(records, task.RetentionDays, task.MaxBackups, s.now())
|
||||
var candidates []model.BackupRecord
|
||||
if gfsEnabled(task) {
|
||||
// GFS 策略:按天/周/月/年分层保留代表性备份,取代简单的天数/数量策略。
|
||||
candidates = selectGFSToDelete(records, task.KeepDaily, task.KeepWeekly, task.KeepMonthly, task.KeepYearly)
|
||||
} else {
|
||||
candidates = selectRecordsToDelete(records, task.RetentionDays, task.MaxBackups, s.now())
|
||||
}
|
||||
// 差异链保护:保留仍被存活差异依赖的全量,避免删除基线后差异无法恢复。
|
||||
candidates = protectDifferentialBases(records, candidates)
|
||||
result := &CleanupResult{}
|
||||
for _, record := range candidates {
|
||||
if strings.TrimSpace(record.StoragePath) != "" {
|
||||
@@ -90,7 +99,50 @@ func (s *Service) Cleanup(ctx context.Context, task *model.BackupTask, provider
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// protectDifferentialBases 从删除候选中剔除「仍被存活差异依赖的全量」,
|
||||
// 避免删除基线后其差异备份失去依据、无法恢复。全量仅当其全部差异都已过期/删除时才会被清理。
|
||||
func protectDifferentialBases(all []model.BackupRecord, candidates []model.BackupRecord) []model.BackupRecord {
|
||||
deleting := make(map[uint]struct{}, len(candidates))
|
||||
for _, r := range candidates {
|
||||
deleting[r.ID] = struct{}{}
|
||||
}
|
||||
protected := make(map[uint]struct{})
|
||||
for _, r := range all {
|
||||
if r.BackupKind != model.BackupKindDifferential || r.BaseRecordID == 0 {
|
||||
continue
|
||||
}
|
||||
if _, beingDeleted := deleting[r.ID]; beingDeleted {
|
||||
continue // 该差异本身也将被删除,无需保护其基线
|
||||
}
|
||||
protected[r.BaseRecordID] = struct{}{}
|
||||
}
|
||||
if len(protected) == 0 {
|
||||
return candidates
|
||||
}
|
||||
filtered := make([]model.BackupRecord, 0, len(candidates))
|
||||
for _, r := range candidates {
|
||||
if r.BackupKind == model.BackupKindFull {
|
||||
if _, keep := protected[r.ID]; keep {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func selectRecordsToDelete(records []model.BackupRecord, retentionDays int, maxBackups int, now time.Time) []model.BackupRecord {
|
||||
// 保留锁定(法律保留)的记录永不参与清理:先从候选集中剔除,
|
||||
// 锁定备份既不被删除,也不占用 maxBackups 轮转名额。
|
||||
if hasLocked(records) {
|
||||
unlocked := make([]model.BackupRecord, 0, len(records))
|
||||
for _, r := range records {
|
||||
if !r.Locked {
|
||||
unlocked = append(unlocked, r)
|
||||
}
|
||||
}
|
||||
records = unlocked
|
||||
}
|
||||
selected := make(map[uint]model.BackupRecord)
|
||||
if maxBackups > 0 && len(records) > maxBackups {
|
||||
for _, record := range records[maxBackups:] {
|
||||
@@ -113,3 +165,81 @@ func selectRecordsToDelete(records []model.BackupRecord, retentionDays int, maxB
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func hasLocked(records []model.BackupRecord) bool {
|
||||
for i := range records {
|
||||
if records[i].Locked {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// gfsEnabled 判定任务是否启用 GFS 分层保留(任一层级 > 0)。
|
||||
func gfsEnabled(task *model.BackupTask) bool {
|
||||
return task.KeepDaily > 0 || task.KeepWeekly > 0 || task.KeepMonthly > 0 || task.KeepYearly > 0
|
||||
}
|
||||
|
||||
func recordTime(r *model.BackupRecord) time.Time {
|
||||
if r.CompletedAt != nil {
|
||||
return *r.CompletedAt
|
||||
}
|
||||
return r.StartedAt
|
||||
}
|
||||
|
||||
func isoWeekKey(t time.Time) string {
|
||||
y, w := t.ISOWeek()
|
||||
return fmt.Sprintf("%d-W%02d", y, w)
|
||||
}
|
||||
|
||||
// selectGFSToDelete 按 GFS(祖父-父-子)策略选出应删除的记录。
|
||||
//
|
||||
// 规则:对每个层级(天/周/月/年),在按时间降序排列后,保留最近 keep 个不同周期中
|
||||
// 每个周期最新的一份备份;各层级保留集合取并集即「保留集」,其余删除。
|
||||
// 锁定(法律保留)的记录始终排除在删除候选之外。
|
||||
func selectGFSToDelete(records []model.BackupRecord, daily, weekly, monthly, yearly int) []model.BackupRecord {
|
||||
active := make([]model.BackupRecord, 0, len(records))
|
||||
for i := range records {
|
||||
if !records[i].Locked {
|
||||
active = append(active, records[i])
|
||||
}
|
||||
}
|
||||
sort.SliceStable(active, func(i, j int) bool {
|
||||
return recordTime(&active[i]).After(recordTime(&active[j]))
|
||||
})
|
||||
|
||||
keep := make(map[uint]bool, len(active))
|
||||
keepTier := func(count int, key func(time.Time) string) {
|
||||
if count <= 0 {
|
||||
return
|
||||
}
|
||||
periods := 0
|
||||
lastPeriod := ""
|
||||
havePrev := false
|
||||
for i := range active {
|
||||
p := key(recordTime(&active[i]))
|
||||
if havePrev && p == lastPeriod {
|
||||
continue // 同周期已保留代表(最新一份)
|
||||
}
|
||||
if periods >= count {
|
||||
break // 该层级已保留足够多的周期
|
||||
}
|
||||
keep[active[i].ID] = true
|
||||
lastPeriod = p
|
||||
havePrev = true
|
||||
periods++
|
||||
}
|
||||
}
|
||||
keepTier(daily, func(t time.Time) string { return t.Format("2006-01-02") })
|
||||
keepTier(weekly, isoWeekKey)
|
||||
keepTier(monthly, func(t time.Time) string { return t.Format("2006-01") })
|
||||
keepTier(yearly, func(t time.Time) string { return t.Format("2006") })
|
||||
|
||||
del := make([]model.BackupRecord, 0)
|
||||
for i := range active {
|
||||
if !keep[active[i].ID] {
|
||||
del = append(del, active[i])
|
||||
}
|
||||
}
|
||||
return del
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ func (r *fakeRecordRepository) ListByTask(_ context.Context, _ uint) ([]model.Ba
|
||||
func (r *fakeRecordRepository) ListSuccessfulByTask(_ context.Context, _ uint) ([]model.BackupRecord, error) {
|
||||
return r.records, nil
|
||||
}
|
||||
func (r *fakeRecordRepository) CountDependentDifferentials(context.Context, uint) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
func (r *fakeRecordRepository) Count(context.Context) (int64, error) { return 0, nil }
|
||||
func (r *fakeRecordRepository) CountSince(context.Context, time.Time) (int64, error) { return 0, nil }
|
||||
func (r *fakeRecordRepository) CountSuccessSince(context.Context, time.Time) (int64, error) {
|
||||
@@ -93,6 +96,105 @@ func TestSelectRecordsToDelete(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func gfsRecord(id uint, ts time.Time, locked bool) model.BackupRecord {
|
||||
completed := ts
|
||||
return model.BackupRecord{ID: id, StartedAt: ts, CompletedAt: &completed, Locked: locked}
|
||||
}
|
||||
|
||||
func gfsDay(y, m, d, h int) time.Time {
|
||||
return time.Date(y, time.Month(m), d, h, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func deletedIDSet(records []model.BackupRecord) map[uint]bool {
|
||||
out := make(map[uint]bool, len(records))
|
||||
for i := range records {
|
||||
out[records[i].ID] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assertDeleted(t *testing.T, del []model.BackupRecord, want ...uint) {
|
||||
t.Helper()
|
||||
got := deletedIDSet(del)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("deleted set size = %d %v, want %d %v", len(got), got, len(want), want)
|
||||
}
|
||||
for _, id := range want {
|
||||
if !got[id] {
|
||||
t.Fatalf("expected id %d to be deleted; got %v", id, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectGFSToDelete_DailyTier 验证按天分层:每天仅保留最新一份,且只保留最近 N 天。
|
||||
func TestSelectGFSToDelete_DailyTier(t *testing.T) {
|
||||
records := []model.BackupRecord{
|
||||
gfsRecord(5, gfsDay(2026, 3, 7, 12), false), // 今天,最新 → 保留
|
||||
gfsRecord(4, gfsDay(2026, 3, 7, 6), false), // 今天,较早 → 删除(非当天代表)
|
||||
gfsRecord(3, gfsDay(2026, 3, 6, 12), false), // 昨天 → 保留
|
||||
gfsRecord(2, gfsDay(2026, 3, 5, 12), false), // 前天 → 超出 daily=2 → 删除
|
||||
gfsRecord(1, gfsDay(2026, 3, 4, 12), false), // 更早 → 删除
|
||||
}
|
||||
del := selectGFSToDelete(records, 2, 0, 0, 0)
|
||||
assertDeleted(t, del, 4, 2, 1)
|
||||
}
|
||||
|
||||
// TestSelectGFSToDelete_TierUnion 验证多层级取并集:月度层级保留日度层级会删除的旧备份。
|
||||
func TestSelectGFSToDelete_TierUnion(t *testing.T) {
|
||||
records := []model.BackupRecord{
|
||||
gfsRecord(3, gfsDay(2026, 3, 7, 12), false), // 3 月(最新)
|
||||
gfsRecord(2, gfsDay(2026, 2, 15, 12), false), // 2 月
|
||||
gfsRecord(1, gfsDay(2026, 1, 15, 12), false), // 1 月
|
||||
}
|
||||
// daily=1 只留 ID3;monthly=2 留最近两个月(3 月=ID3、2 月=ID2)。并集={3,2},删除 ID1。
|
||||
del := selectGFSToDelete(records, 1, 0, 2, 0)
|
||||
assertDeleted(t, del, 1)
|
||||
}
|
||||
|
||||
// TestSelectGFSToDelete_SkipsLocked 验证锁定记录即使超出所有层级也永不删除。
|
||||
func TestSelectGFSToDelete_SkipsLocked(t *testing.T) {
|
||||
records := []model.BackupRecord{
|
||||
gfsRecord(3, gfsDay(2026, 3, 7, 12), false),
|
||||
gfsRecord(2, gfsDay(2026, 3, 6, 12), false),
|
||||
gfsRecord(1, gfsDay(2020, 1, 1, 12), true), // 远超 daily=1 但已锁定 → 不删
|
||||
}
|
||||
del := selectGFSToDelete(records, 1, 0, 0, 0)
|
||||
assertDeleted(t, del, 2) // 仅 ID2 被删;ID1 锁定豁免,ID3 为当日代表
|
||||
}
|
||||
|
||||
func TestGFSEnabled(t *testing.T) {
|
||||
if gfsEnabled(&model.BackupTask{}) {
|
||||
t.Fatal("empty GFS config should be disabled")
|
||||
}
|
||||
if !gfsEnabled(&model.BackupTask{KeepWeekly: 4}) {
|
||||
t.Fatal("KeepWeekly>0 should enable GFS")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelectRecordsToDelete_SkipsLocked 验证保留锁定(法律保留)的记录永不被选中删除,
|
||||
// 即使它既超过保留期、又超过 maxBackups 名额。
|
||||
func TestSelectRecordsToDelete_SkipsLocked(t *testing.T) {
|
||||
now := time.Date(2026, 3, 7, 16, 0, 0, 0, time.UTC)
|
||||
completedNew := now.Add(-24 * time.Hour)
|
||||
completedOld := now.Add(-15 * 24 * time.Hour)
|
||||
records := []model.BackupRecord{
|
||||
{ID: 3, CompletedAt: &completedNew},
|
||||
{ID: 2, CompletedAt: &completedNew},
|
||||
{ID: 1, CompletedAt: &completedOld, Locked: true}, // 超期但锁定 → 不应删除
|
||||
}
|
||||
selected := selectRecordsToDelete(records, 7, 2, now)
|
||||
for _, r := range selected {
|
||||
if r.ID == 1 {
|
||||
t.Fatalf("locked record #1 must never be selected for deletion: %#v", selected)
|
||||
}
|
||||
}
|
||||
// 锁定记录不占 maxBackups 名额:未锁定仅 2 条,maxBackups=2 → 无超额删除,
|
||||
// 且无未锁定记录超期 → 选中集为空。
|
||||
if len(selected) != 0 {
|
||||
t.Fatalf("expected no deletions (locked excluded), got %#v", selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupDeletesExpiredRecords(t *testing.T) {
|
||||
now := time.Date(2026, 3, 7, 16, 0, 0, 0, time.UTC)
|
||||
completedNew := now.Add(-24 * time.Hour)
|
||||
|
||||
@@ -36,6 +36,15 @@ type TaskSpec struct {
|
||||
MaxBackups int
|
||||
StartedAt time.Time
|
||||
TempDir string
|
||||
// Differential 为 true 时执行差异备份:仅打包自 BaseManifest 以来新增/变更的条目,
|
||||
// 并记录被删除的路径。仅文件类型任务支持;BaseManifest 为空时回退为全量。
|
||||
Differential bool
|
||||
BaseManifest Manifest
|
||||
// SelectedPaths 非空时仅恢复这些归档相对路径(及其子项),用于按需(选择性)恢复;仅文件类型生效。
|
||||
SelectedPaths []string
|
||||
// RestoreTargetPath 仅用于恢复:非空时,文件类型恢复将归档解压到该目录,
|
||||
// 而非默认的原始源路径父目录。用于「恢复到指定位置」(迁移/测试/并排恢复)。
|
||||
RestoreTargetPath string
|
||||
}
|
||||
|
||||
type RunResult struct {
|
||||
@@ -44,6 +53,8 @@ type RunResult struct {
|
||||
TempDir string
|
||||
Size int64
|
||||
StorageKey string
|
||||
// Manifest 为全量备份产出的条目清单,供后续差异备份比对;差异备份运行时为 nil。
|
||||
Manifest *Manifest
|
||||
}
|
||||
|
||||
type LogEvent struct {
|
||||
@@ -62,7 +73,7 @@ type ProgressInfo struct {
|
||||
BytesSent int64 `json:"bytesSent"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
Percent float64 `json:"percent"`
|
||||
SpeedBps float64 `json:"speedBps"` // bytes/sec
|
||||
SpeedBps float64 `json:"speedBps"` // bytes/sec
|
||||
TargetName string `json:"targetName"`
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ type ServerConfig struct {
|
||||
Port int `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"`
|
||||
ExternalURL string `mapstructure:"external_url"`
|
||||
// WebRoot 指向前端构建产物目录。留空时后端会按部署惯例自动探测
|
||||
// (./web、./web/dist、/opt/backupx/web 等)。探测命中后后端直接托管
|
||||
// 前端 SPA,无需额外的 nginx 反向代理即可访问 Web 控制台。
|
||||
WebRoot string `mapstructure:"web_root"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
@@ -138,6 +142,7 @@ func applyDefaults(v *viper.Viper) {
|
||||
v.SetDefault("server.port", 8340)
|
||||
v.SetDefault("server.mode", "release")
|
||||
v.SetDefault("server.external_url", "")
|
||||
v.SetDefault("server.web_root", "")
|
||||
v.SetDefault("database.path", "./data/backupx.db")
|
||||
v.SetDefault("security.jwt_expire", "24h")
|
||||
v.SetDefault("backup.temp_dir", "/tmp/backupx")
|
||||
|
||||
@@ -52,6 +52,20 @@ func (h *BackupRecordHandler) Get(c *gin.Context) {
|
||||
response.Success(c, item)
|
||||
}
|
||||
|
||||
// Contents 返回备份记录的文件清单(内容浏览,只读)。
|
||||
func (h *BackupRecordHandler) Contents(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
contents, err := h.service.ListContents(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, contents)
|
||||
}
|
||||
|
||||
func (h *BackupRecordHandler) StreamLogs(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
@@ -137,7 +151,14 @@ func (h *BackupRecordHandler) Restore(c *gin.Context) {
|
||||
if subject, exists := c.Get(contextUserSubjectKey); exists {
|
||||
triggeredBy = strings.TrimSpace(fmt.Sprintf("%v", subject))
|
||||
}
|
||||
detail, err := h.restoreService.Start(c.Request.Context(), id, triggeredBy)
|
||||
// 可选请求体:selectedPaths 按需(选择性)恢复;targetPath 恢复到指定目录(仅文件类型本机恢复)。
|
||||
// 无 body 时为整体恢复到原始路径。
|
||||
var body struct {
|
||||
SelectedPaths []string `json:"selectedPaths"`
|
||||
TargetPath string `json:"targetPath"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
detail, err := h.restoreService.StartSelective(c.Request.Context(), id, body.SelectedPaths, strings.TrimSpace(body.TargetPath), triggeredBy)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
@@ -161,6 +182,32 @@ func (h *BackupRecordHandler) Delete(c *gin.Context) {
|
||||
response.Success(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
// SetLock 设置/解除备份记录的保留锁定(法律保留)。
|
||||
func (h *BackupRecordHandler) SetLock(c *gin.Context) {
|
||||
id, ok := parseUintParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Locked bool `json:"locked"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, apperror.BadRequest("BACKUP_RECORD_LOCK_INVALID", "锁定参数不合法", err))
|
||||
return
|
||||
}
|
||||
detail, err := h.service.SetLock(c.Request.Context(), id, input.Locked)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
action, desc := "unlock", fmt.Sprintf("解除备份记录保留锁定 (ID: %d)", id)
|
||||
if input.Locked {
|
||||
action, desc = "lock", fmt.Sprintf("设置备份记录保留锁定 (ID: %d)", id)
|
||||
}
|
||||
recordAudit(c, h.auditService, "backup_record", action, "backup_record", fmt.Sprintf("%d", id), "", desc)
|
||||
response.Success(c, detail)
|
||||
}
|
||||
|
||||
func (h *BackupRecordHandler) BatchDelete(c *gin.Context) {
|
||||
var input struct {
|
||||
IDs []uint `json:"ids" binding:"required,min=1"`
|
||||
@@ -216,7 +263,7 @@ func writeSSEEvent(writer io.Writer, event backup.LogEvent) error {
|
||||
}
|
||||
|
||||
func parseUintString(value string) (uint, bool) {
|
||||
parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
|
||||
parsed, err := strconv.ParseUint(strings.TrimSpace(value), 10, 0)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
95
server/internal/http/report_handler.go
Normal file
95
server/internal/http/report_handler.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/service"
|
||||
"backupx/server/pkg/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ReportHandler struct {
|
||||
service *service.ReportService
|
||||
}
|
||||
|
||||
func NewReportHandler(reportService *service.ReportService) *ReportHandler {
|
||||
return &ReportHandler{service: reportService}
|
||||
}
|
||||
|
||||
func reportDays(c *gin.Context) int {
|
||||
days := 30
|
||||
if v := strings.TrimSpace(c.Query("days")); v != "" {
|
||||
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
||||
days = parsed
|
||||
}
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
// Compliance 返回 JSON 合规报表(按任务的备份合规证据 + 汇总)。
|
||||
func (h *ReportHandler) Compliance(c *gin.Context) {
|
||||
payload, err := h.service.ComplianceReport(c.Request.Context(), reportDays(c))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, payload)
|
||||
}
|
||||
|
||||
// ComplianceCSV 把合规报表导出为 CSV(供审计归档)。带 UTF-8 BOM 以便 Excel 正确识别中文。
|
||||
func (h *ReportHandler) ComplianceCSV(c *gin.Context) {
|
||||
report, err := h.service.ComplianceReport(c.Request.Context(), reportDays(c))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
filename := fmt.Sprintf("backupx-compliance-%s.csv", report.GeneratedAt.Format("20060102-150405"))
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||||
_, _ = c.Writer.WriteString("\ufeff") // UTF-8 BOM
|
||||
w := csv.NewWriter(c.Writer)
|
||||
_ = w.Write([]string{
|
||||
"任务ID", "任务名", "类型", "启用", "节点", "加密", "保留天数", "SLA(RPO小时)",
|
||||
"周期内运行", "成功", "失败", "成功率", "最近状态", "最近运行(UTC)", "最近成功(UTC)", "保护字节数", "合规判定",
|
||||
})
|
||||
for _, row := range report.Tasks {
|
||||
_ = w.Write([]string{
|
||||
strconv.FormatUint(uint64(row.TaskID), 10),
|
||||
row.TaskName,
|
||||
row.Type,
|
||||
boolCN(row.Enabled),
|
||||
row.NodeName,
|
||||
boolCN(row.Encrypted),
|
||||
strconv.Itoa(row.RetentionDays),
|
||||
strconv.Itoa(row.SLAHoursRPO),
|
||||
strconv.Itoa(row.TotalRuns),
|
||||
strconv.Itoa(row.Successes),
|
||||
strconv.Itoa(row.Failures),
|
||||
fmt.Sprintf("%.2f%%", row.SuccessRate*100),
|
||||
row.LastStatus,
|
||||
fmtTimePtr(row.LastRunAt),
|
||||
fmtTimePtr(row.LastSuccessAt),
|
||||
strconv.FormatInt(row.ProtectedBytes, 10),
|
||||
row.Risk,
|
||||
})
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
|
||||
func boolCN(b bool) string {
|
||||
if b {
|
||||
return "是"
|
||||
}
|
||||
return "否"
|
||||
}
|
||||
|
||||
func fmtTimePtr(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
@@ -41,6 +41,7 @@ type RouterDependencies struct {
|
||||
ApiKeyService *service.ApiKeyService
|
||||
NotificationService *service.NotificationService
|
||||
DashboardService *service.DashboardService
|
||||
ReportService *service.ReportService
|
||||
SettingsService *service.SettingsService
|
||||
NodeService *service.NodeService
|
||||
AgentService *service.AgentService
|
||||
@@ -166,9 +167,11 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
backupRecords.GET("/:id", backupRecordHandler.Get)
|
||||
backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs)
|
||||
backupRecords.GET("/:id/download", backupRecordHandler.Download)
|
||||
backupRecords.GET("/:id/contents", backupRecordHandler.Contents)
|
||||
backupRecords.POST("/:id/restore", RequireNotViewer(), backupRecordHandler.Restore)
|
||||
backupRecords.POST("/batch-delete", RequireNotViewer(), backupRecordHandler.BatchDelete)
|
||||
backupRecords.DELETE("/:id", RequireNotViewer(), backupRecordHandler.Delete)
|
||||
backupRecords.PUT("/:id/lock", RequireNotViewer(), backupRecordHandler.SetLock)
|
||||
|
||||
// 恢复记录独立命名空间:列表/详情/SSE 日志流。
|
||||
// 创建恢复仍然走 POST /backup/records/:id/restore(以源备份记录为触发点)。
|
||||
@@ -211,6 +214,15 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
// 基于备份记录的验证入口:与 restore 对称
|
||||
backupRecords.POST("/:id/verify", RequireNotViewer(), verificationHandler.TriggerByRecord)
|
||||
}
|
||||
// 企业合规报表:按任务的可导出备份合规证据(区别于 Dashboard 的实时聚合视图)。
|
||||
if deps.ReportService != nil {
|
||||
reportHandler := NewReportHandler(deps.ReportService)
|
||||
reports := api.Group("/reports")
|
||||
reports.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
||||
reports.GET("/compliance", reportHandler.Compliance)
|
||||
reports.GET("/compliance/export", reportHandler.ComplianceCSV)
|
||||
}
|
||||
|
||||
dashboard := api.Group("/dashboard")
|
||||
dashboard.Use(AuthMiddleware(deps.JWTManager, apiKeyAuth))
|
||||
dashboard.GET("/stats", dashboardHandler.Stats)
|
||||
@@ -292,7 +304,10 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
nodes.POST("", RequireRole("admin"), nodeHandler.Create)
|
||||
nodes.PUT("/:id", RequireRole("admin"), nodeHandler.Update)
|
||||
nodes.DELETE("/:id", RequireRole("admin"), nodeHandler.Delete)
|
||||
nodes.GET("/:id/fs/list", nodeHandler.ListDirectory)
|
||||
// 文件浏览会枚举节点文件系统目录(含 /etc、/root 等),属敏感读操作:
|
||||
// 限制为非 viewer(admin/operator),与"创建备份任务需选源路径"的权限对齐,
|
||||
// 避免只读 viewer 借此探查服务器目录结构。
|
||||
nodes.GET("/:id/fs/list", RequireNotViewer(), nodeHandler.ListDirectory)
|
||||
nodes.POST("/batch", RequireRole("admin"), nodeHandler.BatchCreate)
|
||||
nodes.POST("/:id/install-tokens", RequireRole("admin"), nodeHandler.CreateInstallToken)
|
||||
nodes.POST("/:id/rotate-token", RequireRole("admin"), nodeHandler.RotateToken)
|
||||
@@ -353,9 +368,25 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
||||
engine.GET("/api/install/:token/compose.yml", installHandler.Compose)
|
||||
}
|
||||
|
||||
engine.NoRoute(func(c *gin.Context) {
|
||||
// 未匹配路由处理:
|
||||
// - 找到前端产物目录时,托管 SPA(静态文件 + index.html 回退),
|
||||
// 使后端在无 nginx 反向代理时也能直接提供 Web 控制台(issue #62);
|
||||
// - 未找到前端目录时退化为纯 API 服务,统一返回结构化 JSON 404。
|
||||
apiNotFound := func(c *gin.Context) {
|
||||
response.Error(c, apperror.New(stdhttp.StatusNotFound, "NOT_FOUND", "接口不存在", errors.New("route not found")))
|
||||
})
|
||||
}
|
||||
if webRoot := resolveWebRoot(deps.Config.Server.WebRoot); webRoot != "" {
|
||||
if deps.Logger != nil {
|
||||
deps.Logger.Info("serving web frontend", zap.String("web_root", webRoot))
|
||||
}
|
||||
engine.NoRoute(spaFileServer(webRoot, apiNotFound))
|
||||
} else {
|
||||
if deps.Logger != nil {
|
||||
deps.Logger.Warn("web frontend directory not found; serving API only",
|
||||
zap.String("hint", "set server.web_root in config, or place the built frontend at ./web"))
|
||||
}
|
||||
engine.NoRoute(apiNotFound)
|
||||
}
|
||||
|
||||
return engine
|
||||
}
|
||||
|
||||
86
server/internal/http/spa.go
Normal file
86
server/internal/http/spa.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
stdhttp "net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// resolveWebRoot 返回前端静态资源目录。优先使用显式配置的路径,
|
||||
// 否则按部署惯例依次探测常见位置,返回首个包含 index.html 的目录。
|
||||
// 返回空字符串表示未找到前端产物,此时后端退化为纯 API 服务。
|
||||
func resolveWebRoot(configured string) string {
|
||||
candidates := []string{
|
||||
configured,
|
||||
"./web/dist", // 源码树根目录构建产物(优先于 ./web,避免命中前端源码模板)
|
||||
"./web", // systemd:WorkingDirectory=/opt/backupx → /opt/backupx/web;容器 WORKDIR=/app → /app/web
|
||||
"../web/dist", // 从 server/ 目录运行(make dev-server)
|
||||
"/opt/backupx/web",
|
||||
"/app/web",
|
||||
}
|
||||
for _, dir := range candidates {
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
continue
|
||||
}
|
||||
if hasIndexHTML(dir) {
|
||||
if abs, err := filepath.Abs(dir); err == nil {
|
||||
return abs
|
||||
}
|
||||
return dir
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hasIndexHTML(dir string) bool {
|
||||
info, err := os.Stat(filepath.Join(dir, "index.html"))
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// isReservedBackendPath 判断请求是否命中后端保留前缀(API、探针、安装脚本)。
|
||||
// 这些路径即使未匹配到具体路由,也应返回结构化 JSON 404,而不是回退到
|
||||
// 前端 index.html —— 否则反向代理/安装脚本会把 HTML 当成接口响应(参考 issue #46)。
|
||||
func isReservedBackendPath(p string) bool {
|
||||
switch p {
|
||||
case "/health", "/ready", "/metrics", "/api", "/install":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/install/")
|
||||
}
|
||||
|
||||
// spaFileServer 构造 SPA 静态资源处理器,用作 gin 的 NoRoute 回退:
|
||||
// - 后端保留前缀返回 apiNotFound(JSON 404);
|
||||
// - 其余 GET/HEAD 请求若在 webRoot 内命中真实文件则直接返回该文件;
|
||||
// - 未命中文件的路径回退到 index.html,交由前端路由处理(history 模式刷新)。
|
||||
func spaFileServer(webRoot string, apiNotFound gin.HandlerFunc) gin.HandlerFunc {
|
||||
indexPath := filepath.Join(webRoot, "index.html")
|
||||
return func(c *gin.Context) {
|
||||
reqPath := c.Request.URL.Path
|
||||
if isReservedBackendPath(reqPath) {
|
||||
apiNotFound(c)
|
||||
return
|
||||
}
|
||||
if c.Request.Method != stdhttp.MethodGet && c.Request.Method != stdhttp.MethodHead {
|
||||
apiNotFound(c)
|
||||
return
|
||||
}
|
||||
|
||||
// 防目录穿越:以 webRoot 为根清理路径,确保最终目标仍位于 webRoot 内。
|
||||
clean := filepath.Clean("/" + strings.TrimPrefix(reqPath, "/"))
|
||||
target := filepath.Join(webRoot, clean)
|
||||
if rel, err := filepath.Rel(webRoot, target); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
apiNotFound(c)
|
||||
return
|
||||
}
|
||||
|
||||
if info, err := os.Stat(target); err == nil && !info.IsDir() {
|
||||
c.File(target)
|
||||
return
|
||||
}
|
||||
// 前端 SPA 路由(/dashboard、/tasks 等)回退到 index.html。
|
||||
c.File(indexPath)
|
||||
}
|
||||
}
|
||||
175
server/internal/http/spa_test.go
Normal file
175
server/internal/http/spa_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestResolveWebRoot(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("explicit configured dir with index.html", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeIndex(t, dir)
|
||||
got := resolveWebRoot(dir)
|
||||
abs, _ := filepath.Abs(dir)
|
||||
if got != abs {
|
||||
t.Fatalf("resolveWebRoot(%q) = %q, want %q", dir, got, abs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("configured dir without index.html falls through to none", func(t *testing.T) {
|
||||
dir := t.TempDir() // no index.html, and no conventional ./web in CWD during test
|
||||
if got := resolveWebRoot(dir); got != "" {
|
||||
// 允许 CWD 恰好存在约定目录的环境,但临时目录本身不应被选中。
|
||||
abs, _ := filepath.Abs(dir)
|
||||
if got == abs {
|
||||
t.Fatalf("expected dir without index.html to be skipped, got %q", got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty configured uses auto-detect order", func(t *testing.T) {
|
||||
// 切到一个仅含 ./web/dist/index.html 的临时工作目录,验证自动探测。
|
||||
root := t.TempDir()
|
||||
distDir := filepath.Join(root, "web", "dist")
|
||||
if err := os.MkdirAll(distDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeIndex(t, distDir)
|
||||
|
||||
restore := chdir(t, root)
|
||||
defer restore()
|
||||
|
||||
// 以 chdir 之后的实际工作目录为基准计算期望值,避免 macOS 上
|
||||
// /var → /private/var 符号链接导致字符串不一致。
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(wd, "web", "dist")
|
||||
|
||||
got := resolveWebRoot("")
|
||||
if got != want {
|
||||
t.Fatalf("auto-detect = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsReservedBackendPath(t *testing.T) {
|
||||
reserved := []string{"/health", "/ready", "/metrics", "/api", "/install", "/api/", "/api/system/info", "/install/abc", "/install/abc/compose.yml"}
|
||||
for _, p := range reserved {
|
||||
if !isReservedBackendPath(p) {
|
||||
t.Errorf("isReservedBackendPath(%q) = false, want true", p)
|
||||
}
|
||||
}
|
||||
notReserved := []string{"/", "/dashboard", "/assets/app.js", "/installer", "/apidocs", "/favicon.ico"}
|
||||
for _, p := range notReserved {
|
||||
if isReservedBackendPath(p) {
|
||||
t.Errorf("isReservedBackendPath(%q) = true, want false", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpaFileServer(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
webRoot := t.TempDir()
|
||||
writeIndexContent(t, webRoot, "<!doctype html><title>BackupX</title>")
|
||||
assetsDir := filepath.Join(webRoot, "assets")
|
||||
if err := os.MkdirAll(assetsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(assetsDir, "app.js"), []byte("console.log(1)"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
apiNotFoundHit := false
|
||||
apiNotFound := func(c *gin.Context) {
|
||||
apiNotFoundHit = true
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": "NOT_FOUND"})
|
||||
}
|
||||
|
||||
engine := gin.New()
|
||||
engine.NoRoute(spaFileServer(webRoot, apiNotFound))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
wantStatus int
|
||||
wantBody string // 子串;为空表示不校验
|
||||
wantAPI404 bool
|
||||
}{
|
||||
{name: "root serves index", method: http.MethodGet, path: "/", wantStatus: 200, wantBody: "BackupX"},
|
||||
{name: "spa route falls back to index", method: http.MethodGet, path: "/dashboard", wantStatus: 200, wantBody: "BackupX"},
|
||||
{name: "real asset served", method: http.MethodGet, path: "/assets/app.js", wantStatus: 200, wantBody: "console.log"},
|
||||
{name: "api path returns json 404", method: http.MethodGet, path: "/api/garbage", wantStatus: 404, wantAPI404: true},
|
||||
{name: "health returns json 404 via reserved", method: http.MethodGet, path: "/health", wantStatus: 404, wantAPI404: true},
|
||||
{name: "non-GET on spa path is api 404", method: http.MethodPost, path: "/dashboard", wantStatus: 404, wantAPI404: true},
|
||||
{name: "directory falls back to index", method: http.MethodGet, path: "/assets/", wantStatus: 200, wantBody: "BackupX"},
|
||||
// 含 ".." 的请求路径被 net/http 在文件服务层直接拒绝(400 invalid URL path),
|
||||
// 绝不会泄露 webRoot 之外的文件;这是在 filepath.Rel 校验之上的纵深防御。
|
||||
{name: "traversal rejected, never serves passwd", method: http.MethodGet, path: "/../../etc/passwd", wantStatus: 400, wantBody: "invalid URL path"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
apiNotFoundHit = false
|
||||
req := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
engine.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != tc.wantStatus {
|
||||
t.Fatalf("status = %d, want %d (body=%q)", rec.Code, tc.wantStatus, rec.Body.String())
|
||||
}
|
||||
if tc.wantBody != "" && !contains(rec.Body.String(), tc.wantBody) {
|
||||
t.Fatalf("body %q does not contain %q", rec.Body.String(), tc.wantBody)
|
||||
}
|
||||
if tc.wantAPI404 != apiNotFoundHit {
|
||||
t.Fatalf("apiNotFoundHit = %v, want %v", apiNotFoundHit, tc.wantAPI404)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func writeIndex(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
writeIndexContent(t, dir, "<!doctype html><title>BackupX</title>")
|
||||
}
|
||||
|
||||
func writeIndexContent(t *testing.T, dir, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func chdir(t *testing.T, dir string) func() {
|
||||
t.Helper()
|
||||
orig, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return func() { _ = os.Chdir(orig) }
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(sub) == 0 || (len(s) >= len(sub) && indexOf(s, sub) >= 0)
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -199,7 +199,7 @@ func (h *StorageTargetHandler) GoogleDriveProfile(c *gin.Context) {
|
||||
|
||||
func parseUintParam(c *gin.Context, key string) (uint, bool) {
|
||||
value := strings.TrimSpace(c.Param(key))
|
||||
parsed, err := strconv.ParseUint(value, 10, 64)
|
||||
parsed, err := strconv.ParseUint(value, 10, 0)
|
||||
if err != nil {
|
||||
response.Error(c, apperror.BadRequest("INVALID_ID", fmt.Sprintf("参数 %s 不合法", key), err))
|
||||
return 0, false
|
||||
|
||||
@@ -70,6 +70,8 @@ Environment="BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}"
|
||||
ExecStart=${INSTALL_PREFIX}/backupx agent --temp-dir /var/lib/backupx-agent/tmp
|
||||
Restart=on-failure
|
||||
RestartSec=10s
|
||||
# Agent 需以 root 运行以读取任意源数据;与单机服务端保持一致的资源/句柄上限。
|
||||
LimitNOFILE=65535
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -8,28 +8,43 @@ const (
|
||||
BackupRecordStatusFailed = "failed"
|
||||
)
|
||||
|
||||
const (
|
||||
// BackupKindFull 全量备份;BackupKindDifferential 差异备份(仅含自基线全量以来的变更)。
|
||||
BackupKindFull = "full"
|
||||
BackupKindDifferential = "differential"
|
||||
)
|
||||
|
||||
type BackupRecord struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
|
||||
Task BackupTask `json:"task,omitempty"`
|
||||
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"`
|
||||
StorageTarget StorageTarget `json:"storageTarget,omitempty"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
|
||||
Task BackupTask `json:"task,omitempty"`
|
||||
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"`
|
||||
StorageTarget StorageTarget `json:"storageTarget,omitempty"`
|
||||
// NodeID 执行该次备份的节点(0 = 本机 Master)。用于集群中识别 local_disk 类型
|
||||
// 存储的归属节点,避免 Master 端试图跨节点访问远程 Agent 的本地存储。
|
||||
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
|
||||
Status string `gorm:"size:20;index;not null" json:"status"`
|
||||
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
|
||||
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
|
||||
Checksum string `gorm:"column:checksum;size:64" json:"checksum"`
|
||||
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
|
||||
StorageUploadResults string `gorm:"column:storage_upload_results;type:text" json:"-"`
|
||||
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
|
||||
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
|
||||
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
|
||||
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
|
||||
Status string `gorm:"size:20;index;not null" json:"status"`
|
||||
FileName string `gorm:"column:file_name;size:255" json:"fileName"`
|
||||
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
|
||||
Checksum string `gorm:"column:checksum;size:64" json:"checksum"`
|
||||
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
|
||||
StorageUploadResults string `gorm:"column:storage_upload_results;type:text" json:"-"`
|
||||
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
|
||||
// Locked 保留锁定(法律保留):为 true 时该备份不参与保留期/数量自动清理,
|
||||
// 且禁止手动删除,直到显式解锁。用于保护合规快照、迁移前基线等关键备份。
|
||||
Locked bool `gorm:"column:locked;not null;default:false;index" json:"locked"`
|
||||
// BackupKind 备份类型:full(全量)/ differential(差异)。
|
||||
BackupKind string `gorm:"column:backup_kind;size:16;not null;default:'full';index" json:"backupKind"`
|
||||
// BaseRecordID 差异备份所基于的全量备份记录 ID(全量记录为 0)。
|
||||
BaseRecordID uint `gorm:"column:base_record_id;index;not null;default:0" json:"baseRecordId"`
|
||||
// Manifest 全量备份的条目清单(JSON),供后续差异备份比对;差异记录为空。
|
||||
Manifest string `gorm:"column:manifest;type:text" json:"-"`
|
||||
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
|
||||
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
|
||||
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (BackupRecord) TableName() string {
|
||||
|
||||
@@ -8,6 +8,13 @@ const (
|
||||
BackupTaskTypeSQLite = "sqlite"
|
||||
BackupTaskTypePostgreSQL = "postgresql"
|
||||
BackupTaskTypeSAPHANA = "saphana"
|
||||
BackupTaskTypeMongoDB = "mongodb"
|
||||
)
|
||||
|
||||
const (
|
||||
// BackupModeFull 全量模式(默认);BackupModeDifferential 差异模式(仅文件类型本机任务)。
|
||||
BackupModeFull = "full"
|
||||
BackupModeDifferential = "differential"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -18,42 +25,53 @@ const (
|
||||
)
|
||||
|
||||
type BackupTask struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:100;uniqueIndex;not null" json:"name"`
|
||||
Type string `gorm:"size:20;index;not null" json:"type"`
|
||||
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
||||
CronExpr string `gorm:"column:cron_expr;size:64" json:"cronExpr"`
|
||||
SourcePath string `gorm:"column:source_path;size:500" json:"sourcePath"`
|
||||
SourcePaths string `gorm:"column:source_paths;type:text" json:"sourcePaths"`
|
||||
ExcludePatterns string `gorm:"column:exclude_patterns;type:text" json:"excludePatterns"`
|
||||
DBHost string `gorm:"column:db_host;size:255" json:"dbHost"`
|
||||
DBPort int `gorm:"column:db_port" json:"dbPort"`
|
||||
DBUser string `gorm:"column:db_user;size:100" json:"dbUser"`
|
||||
DBPasswordCiphertext string `gorm:"column:db_password_ciphertext;type:text" json:"-"`
|
||||
DBName string `gorm:"column:db_name;size:255" json:"dbName"`
|
||||
DBPath string `gorm:"column:db_path;size:500" json:"dbPath"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:100;uniqueIndex;not null" json:"name"`
|
||||
Type string `gorm:"size:20;index;not null" json:"type"`
|
||||
Enabled bool `gorm:"not null;default:true" json:"enabled"`
|
||||
CronExpr string `gorm:"column:cron_expr;size:64" json:"cronExpr"`
|
||||
SourcePath string `gorm:"column:source_path;size:500" json:"sourcePath"`
|
||||
SourcePaths string `gorm:"column:source_paths;type:text" json:"sourcePaths"`
|
||||
ExcludePatterns string `gorm:"column:exclude_patterns;type:text" json:"excludePatterns"`
|
||||
DBHost string `gorm:"column:db_host;size:255" json:"dbHost"`
|
||||
DBPort int `gorm:"column:db_port" json:"dbPort"`
|
||||
DBUser string `gorm:"column:db_user;size:100" json:"dbUser"`
|
||||
DBPasswordCiphertext string `gorm:"column:db_password_ciphertext;type:text" json:"-"`
|
||||
DBName string `gorm:"column:db_name;size:255" json:"dbName"`
|
||||
DBPath string `gorm:"column:db_path;size:500" json:"dbPath"`
|
||||
// ExtraConfig 类型特有的扩展配置(JSON),如 SAP HANA 的 backupLevel / backupChannels 等
|
||||
ExtraConfig string `gorm:"column:extra_config;type:text" json:"extraConfig"`
|
||||
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"` // deprecated: 保留兼容
|
||||
StorageTarget StorageTarget `json:"storageTarget,omitempty"` // deprecated: 保留兼容
|
||||
StorageTargets []StorageTarget `gorm:"many2many:backup_task_storage_targets" json:"storageTargets,omitempty"`
|
||||
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
|
||||
Node Node `json:"node,omitempty"`
|
||||
ExtraConfig string `gorm:"column:extra_config;type:text" json:"extraConfig"`
|
||||
StorageTargetID uint `gorm:"column:storage_target_id;index;not null" json:"storageTargetId"` // deprecated: 保留兼容
|
||||
StorageTarget StorageTarget `json:"storageTarget,omitempty"` // deprecated: 保留兼容
|
||||
StorageTargets []StorageTarget `gorm:"many2many:backup_task_storage_targets" json:"storageTargets,omitempty"`
|
||||
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
|
||||
Node Node `json:"node,omitempty"`
|
||||
// NodePoolTag 节点池标签(可选)。非空且 NodeID=0 时,调度器会从 Node.Labels 包含该 tag
|
||||
// 的在线节点中动态挑选一台执行(按运行中任务数最少原则),失败会 best-effort 切换到下一个候选。
|
||||
// 典型场景:NodePoolTag="db" 让 MySQL 备份任务在任意标有 "db" 的数据库节点执行。
|
||||
NodePoolTag string `gorm:"column:node_pool_tag;size:64;index" json:"nodePoolTag"`
|
||||
Tags string `gorm:"column:tags;size:500" json:"tags"`
|
||||
RetentionDays int `gorm:"column:retention_days;not null;default:30" json:"retentionDays"`
|
||||
Compression string `gorm:"size:10;not null;default:'gzip'" json:"compression"`
|
||||
Encrypt bool `gorm:"not null;default:false" json:"encrypt"`
|
||||
MaxBackups int `gorm:"column:max_backups;not null;default:10" json:"maxBackups"`
|
||||
LastRunAt *time.Time `gorm:"column:last_run_at" json:"lastRunAt,omitempty"`
|
||||
LastStatus string `gorm:"column:last_status;size:20;not null;default:'idle'" json:"lastStatus"`
|
||||
NodePoolTag string `gorm:"column:node_pool_tag;size:64;index" json:"nodePoolTag"`
|
||||
Tags string `gorm:"column:tags;size:500" json:"tags"`
|
||||
RetentionDays int `gorm:"column:retention_days;not null;default:30" json:"retentionDays"`
|
||||
Compression string `gorm:"size:10;not null;default:'gzip'" json:"compression"`
|
||||
Encrypt bool `gorm:"not null;default:false" json:"encrypt"`
|
||||
MaxBackups int `gorm:"column:max_backups;not null;default:10" json:"maxBackups"`
|
||||
// BackupMode 备份模式:full(全量,默认)/ differential(差异)。差异仅支持本机文件任务。
|
||||
BackupMode string `gorm:"column:backup_mode;size:16;not null;default:'full'" json:"backupMode"`
|
||||
// DiffFullIntervalDays 差异模式下强制全量的间隔(天):最近全量超过该天数则本次自动改为全量,
|
||||
// 限制差异链跨度与单个差异体积。默认 7。
|
||||
DiffFullIntervalDays int `gorm:"column:diff_full_interval_days;not null;default:7" json:"diffFullIntervalDays"`
|
||||
// GFS(祖父-父-子)保留:分别保留最近 N 天 / M 周 / K 月 / Y 年的代表性备份(每周期保留最新一份)。
|
||||
// 任一 > 0 即启用 GFS,取代 RetentionDays/MaxBackups 简单策略;全为 0 时维持简单策略(向后兼容)。
|
||||
KeepDaily int `gorm:"column:keep_daily;not null;default:0" json:"keepDaily"`
|
||||
KeepWeekly int `gorm:"column:keep_weekly;not null;default:0" json:"keepWeekly"`
|
||||
KeepMonthly int `gorm:"column:keep_monthly;not null;default:0" json:"keepMonthly"`
|
||||
KeepYearly int `gorm:"column:keep_yearly;not null;default:0" json:"keepYearly"`
|
||||
LastRunAt *time.Time `gorm:"column:last_run_at" json:"lastRunAt,omitempty"`
|
||||
LastStatus string `gorm:"column:last_status;size:20;not null;default:'idle'" json:"lastStatus"`
|
||||
// 验证(恢复演练)配置 — 定期自动校验备份可恢复性
|
||||
VerifyEnabled bool `gorm:"column:verify_enabled;not null;default:false" json:"verifyEnabled"`
|
||||
VerifyCronExpr string `gorm:"column:verify_cron_expr;size:64" json:"verifyCronExpr"`
|
||||
VerifyMode string `gorm:"column:verify_mode;size:20;not null;default:'quick'" json:"verifyMode"`
|
||||
VerifyEnabled bool `gorm:"column:verify_enabled;not null;default:false" json:"verifyEnabled"`
|
||||
VerifyCronExpr string `gorm:"column:verify_cron_expr;size:64" json:"verifyCronExpr"`
|
||||
VerifyMode string `gorm:"column:verify_mode;size:20;not null;default:'quick'" json:"verifyMode"`
|
||||
// SLA 配置 — RPO(期望最长未备份间隔)与告警阈值
|
||||
SLAHoursRPO int `gorm:"column:sla_hours_rpo;not null;default:0" json:"slaHoursRpo"`
|
||||
AlertOnConsecutiveFails int `gorm:"column:alert_on_consecutive_fails;not null;default:1" json:"alertOnConsecutiveFails"`
|
||||
@@ -68,9 +86,9 @@ type BackupTask struct {
|
||||
// 语义:上游任务成功后自动触发本任务,形成工作流(如 DB 备份完成 → 归档压缩)。
|
||||
// 调度器继续按本任务自己的 cron 触发,仅"自动触发"路径响应依赖完成事件。
|
||||
// 循环依赖检查在 service 层完成,避免配置阶段即出错。
|
||||
DependsOnTaskIDs string `gorm:"column:depends_on_task_ids;size:500" json:"dependsOnTaskIds"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DependsOnTaskIDs string `gorm:"column:depends_on_task_ids;size:500" json:"dependsOnTaskIds"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (BackupTask) TableName() string {
|
||||
|
||||
@@ -10,22 +10,46 @@ const (
|
||||
NodeStatusOffline = "offline"
|
||||
)
|
||||
|
||||
// OfflineGracePeriod 节点心跳超时判定阈值:超过该时长未心跳的远程节点视为离线。
|
||||
// Agent 默认 15s 心跳一次,预留 3 次重试空间。
|
||||
const OfflineGracePeriod = 45 * time.Second
|
||||
|
||||
// EffectiveStatus 返回节点的「实时」在线状态。
|
||||
//
|
||||
// 存储字段 Status 由心跳置 online、由后台离线监控置 offline,二者之间存在最长一个
|
||||
// 监控周期的滞后窗口;期间 List/Get/调度器可能读到过期的 "online",进而把任务下发
|
||||
// 给一台刚刚失联的节点。本方法直接以 LastSeen 推导:远程节点若超过 OfflineGracePeriod
|
||||
// 未心跳即视为 offline,消除该滞后导致的误判。本机节点恒以存储状态为准(它就是 Master
|
||||
// 自身,不依赖心跳)。
|
||||
func (n *Node) EffectiveStatus(now time.Time) string {
|
||||
if n == nil {
|
||||
return NodeStatusOffline
|
||||
}
|
||||
if n.IsLocal {
|
||||
return n.Status
|
||||
}
|
||||
if now.Sub(n.LastSeen) > OfflineGracePeriod {
|
||||
return NodeStatusOffline
|
||||
}
|
||||
return n.Status
|
||||
}
|
||||
|
||||
// Node represents a managed server node in the cluster.
|
||||
// The default "local" node is auto-created for single-machine backward compatibility.
|
||||
type Node struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
|
||||
Hostname string `gorm:"size:255" json:"hostname"`
|
||||
IPAddress string `gorm:"column:ip_address;size:64" json:"ipAddress"`
|
||||
Token string `gorm:"size:128;uniqueIndex;not null" json:"-"`
|
||||
Status string `gorm:"size:20;not null;default:'offline'" json:"status"`
|
||||
IsLocal bool `gorm:"not null;default:false" json:"isLocal"`
|
||||
OS string `gorm:"size:64" json:"os"`
|
||||
Arch string `gorm:"size:32" json:"arch"`
|
||||
AgentVer string `gorm:"column:agent_version;size:32" json:"agentVersion"`
|
||||
LastSeen time.Time `gorm:"column:last_seen" json:"lastSeen"`
|
||||
PrevToken string `gorm:"size:128;index" json:"-"`
|
||||
PrevTokenExpires *time.Time `gorm:"column:prev_token_expires" json:"-"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
|
||||
Hostname string `gorm:"size:255" json:"hostname"`
|
||||
IPAddress string `gorm:"column:ip_address;size:64" json:"ipAddress"`
|
||||
Token string `gorm:"size:128;uniqueIndex;not null" json:"-"`
|
||||
Status string `gorm:"size:20;not null;default:'offline'" json:"status"`
|
||||
IsLocal bool `gorm:"not null;default:false" json:"isLocal"`
|
||||
OS string `gorm:"size:64" json:"os"`
|
||||
Arch string `gorm:"size:32" json:"arch"`
|
||||
AgentVer string `gorm:"column:agent_version;size:32" json:"agentVersion"`
|
||||
LastSeen time.Time `gorm:"column:last_seen" json:"lastSeen"`
|
||||
PrevToken string `gorm:"size:128;index" json:"-"`
|
||||
PrevTokenExpires *time.Time `gorm:"column:prev_token_expires" json:"-"`
|
||||
// MaxConcurrent 该节点允许的最大并发任务数(0=不限制,沿用全局 cfg.Backup.MaxConcurrent)。
|
||||
// 用于大集群中限制单节点资源占用:例如小内存 Agent 节点可配 1,避免多个大备份同时跑挤爆。
|
||||
MaxConcurrent int `gorm:"column:max_concurrent;not null;default:0" json:"maxConcurrent"`
|
||||
|
||||
58
server/internal/model/node_status_test.go
Normal file
58
server/internal/model/node_status_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNodeEffectiveStatus(t *testing.T) {
|
||||
now := time.Date(2026, 5, 26, 12, 0, 0, 0, time.UTC)
|
||||
cases := []struct {
|
||||
name string
|
||||
node *Node
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "remote fresh heartbeat → stored online",
|
||||
node: &Node{IsLocal: false, Status: NodeStatusOnline, LastSeen: now.Add(-10 * time.Second)},
|
||||
want: NodeStatusOnline,
|
||||
},
|
||||
{
|
||||
name: "remote stale heartbeat but stored online → derived offline",
|
||||
node: &Node{IsLocal: false, Status: NodeStatusOnline, LastSeen: now.Add(-90 * time.Second)},
|
||||
want: NodeStatusOffline,
|
||||
},
|
||||
{
|
||||
name: "remote just past grace period → offline",
|
||||
node: &Node{IsLocal: false, Status: NodeStatusOnline, LastSeen: now.Add(-(OfflineGracePeriod + time.Second))},
|
||||
want: NodeStatusOffline,
|
||||
},
|
||||
{
|
||||
name: "remote within grace period → online",
|
||||
node: &Node{IsLocal: false, Status: NodeStatusOnline, LastSeen: now.Add(-(OfflineGracePeriod - time.Second))},
|
||||
want: NodeStatusOnline,
|
||||
},
|
||||
{
|
||||
name: "local node ignores LastSeen → stored online",
|
||||
node: &Node{IsLocal: true, Status: NodeStatusOnline, LastSeen: now.Add(-24 * time.Hour)},
|
||||
want: NodeStatusOnline,
|
||||
},
|
||||
{
|
||||
name: "remote stored offline stays offline",
|
||||
node: &Node{IsLocal: false, Status: NodeStatusOffline, LastSeen: now.Add(-5 * time.Second)},
|
||||
want: NodeStatusOffline,
|
||||
},
|
||||
{
|
||||
name: "nil node → offline",
|
||||
node: nil,
|
||||
want: NodeStatusOffline,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := tc.node.EffectiveStatus(now); got != tc.want {
|
||||
t.Fatalf("EffectiveStatus = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,21 +11,23 @@ const (
|
||||
)
|
||||
|
||||
type RestoreRecord struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
BackupRecordID uint `gorm:"column:backup_record_id;index;not null" json:"backupRecordId"`
|
||||
BackupRecord BackupRecord `json:"backupRecord,omitempty"`
|
||||
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
|
||||
Task BackupTask `json:"task,omitempty"`
|
||||
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
|
||||
Status string `gorm:"size:20;index;not null" json:"status"`
|
||||
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
|
||||
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
|
||||
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
|
||||
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
|
||||
TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
BackupRecordID uint `gorm:"column:backup_record_id;index;not null" json:"backupRecordId"`
|
||||
BackupRecord BackupRecord `json:"backupRecord,omitempty"`
|
||||
TaskID uint `gorm:"column:task_id;index;not null" json:"taskId"`
|
||||
Task BackupTask `json:"task,omitempty"`
|
||||
NodeID uint `gorm:"column:node_id;index;default:0" json:"nodeId"`
|
||||
// TargetPath 恢复到指定目录(仅文件类型本机恢复);空 = 恢复到原始源路径。
|
||||
TargetPath string `gorm:"column:target_path;size:500" json:"targetPath"`
|
||||
Status string `gorm:"size:20;index;not null" json:"status"`
|
||||
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
|
||||
LogContent string `gorm:"column:log_content;type:text" json:"logContent"`
|
||||
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
|
||||
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
|
||||
TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (RestoreRecord) TableName() string {
|
||||
|
||||
@@ -37,13 +37,12 @@ func (n *EmailNotifier) Send(_ context.Context, config map[string]any, message M
|
||||
from := strings.TrimSpace(asString(config["from"]))
|
||||
toList := splitCommaValues(asString(config["to"]))
|
||||
address := host + ":" + strconv.Itoa(port)
|
||||
headers := []string{"From: " + from, "To: " + strings.Join(toList, ", "), "Subject: " + message.Title, "MIME-Version: 1.0", "Content-Type: text/plain; charset=UTF-8", "", message.Body}
|
||||
var auth smtp.Auth
|
||||
if username != "" {
|
||||
auth = smtp.PlainAuth("", username, password, host)
|
||||
}
|
||||
|
||||
rawMessage := []byte(strings.Join(headers, "\r\n"))
|
||||
rawMessage := buildRawMessage(from, toList, message)
|
||||
|
||||
if port == 465 {
|
||||
tlsConfig := &tls.Config{ServerName: host}
|
||||
@@ -86,3 +85,31 @@ func (n *EmailNotifier) Send(_ context.Context, config map[string]any, message M
|
||||
|
||||
return smtp.SendMail(address, auth, from, toList, rawMessage)
|
||||
}
|
||||
|
||||
// buildRawMessage 构造 RFC 5322 邮件原文。所有头部值都会剔除 CR/LF,
|
||||
// 防止 SMTP 头注入:备份任务名等用户可控内容会进入 Subject,若包含
|
||||
// 换行符可被用来注入额外头部(如 Bcc)或伪造正文。正文本身不做处理,
|
||||
// 允许包含换行。
|
||||
func buildRawMessage(from string, toList []string, message Message) []byte {
|
||||
sanitizedTo := make([]string, 0, len(toList))
|
||||
for _, addr := range toList {
|
||||
if s := sanitizeHeaderValue(addr); s != "" {
|
||||
sanitizedTo = append(sanitizedTo, s)
|
||||
}
|
||||
}
|
||||
headers := []string{
|
||||
"From: " + sanitizeHeaderValue(from),
|
||||
"To: " + strings.Join(sanitizedTo, ", "),
|
||||
"Subject: " + sanitizeHeaderValue(message.Title),
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"",
|
||||
message.Body,
|
||||
}
|
||||
return []byte(strings.Join(headers, "\r\n"))
|
||||
}
|
||||
|
||||
// sanitizeHeaderValue 移除头部值中的 CR 与 LF,消除头注入向量。
|
||||
func sanitizeHeaderValue(value string) string {
|
||||
return strings.NewReplacer("\r", "", "\n", "").Replace(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
55
server/internal/notify/email_test.go
Normal file
55
server/internal/notify/email_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBuildRawMessageStripsHeaderInjection 验证用户可控内容(如备份任务名
|
||||
// 进入 Subject)中的 CR/LF 被剔除,无法注入额外头部或伪造正文。
|
||||
func TestBuildRawMessageStripsHeaderInjection(t *testing.T) {
|
||||
msg := Message{
|
||||
Title: "备份失败\r\nBcc: attacker@evil.com\r\n\r\n伪造正文",
|
||||
Body: "正文第一行\n正文第二行",
|
||||
}
|
||||
raw := string(buildRawMessage("sender@example.com", []string{"ops@example.com"}, msg))
|
||||
|
||||
parts := strings.SplitN(raw, "\r\n\r\n", 2)
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("缺少头部/正文分隔符,原文=%q", raw)
|
||||
}
|
||||
headerBlock, body := parts[0], parts[1]
|
||||
|
||||
// 头部区不得出现独立的注入头行。
|
||||
for _, line := range strings.Split(headerBlock, "\r\n") {
|
||||
if strings.HasPrefix(line, "Bcc:") {
|
||||
t.Fatalf("检测到头注入:出现独立 Bcc 头行 %q", line)
|
||||
}
|
||||
}
|
||||
// 头部区应恰好是固定的 5 行(From/To/Subject/MIME-Version/Content-Type)。
|
||||
if got := len(strings.Split(headerBlock, "\r\n")); got != 5 {
|
||||
t.Fatalf("头部行数=%d,期望 5;headerBlock=%q", got, headerBlock)
|
||||
}
|
||||
// 正文必须保持原样(正文中的 \n 合法,不应被处理)。
|
||||
if body != "正文第一行\n正文第二行" {
|
||||
t.Fatalf("正文被篡改:%q", body)
|
||||
}
|
||||
// Subject 行必须包含原始标题文本(CRLF 被移除后拼接在同一行)。
|
||||
if !strings.Contains(headerBlock, "Subject: 备份失败Bcc: attacker@evil.com伪造正文") {
|
||||
t.Fatalf("Subject 行不符合预期:%q", headerBlock)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeHeaderValue(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
" normal ": "normal",
|
||||
"a\r\nb": "ab",
|
||||
"x\ny\rz": "xyz",
|
||||
"no-control-chars": "no-control-chars",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := sanitizeHeaderValue(in); got != want {
|
||||
t.Errorf("sanitizeHeaderValue(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -21,9 +22,18 @@ func (n *WebhookNotifier) Type() string { return "webhook" }
|
||||
func (n *WebhookNotifier) SensitiveFields() []string { return []string{"secret"} }
|
||||
|
||||
func (n *WebhookNotifier) Validate(config map[string]any) error {
|
||||
if strings.TrimSpace(asString(config["url"])) == "" {
|
||||
raw := strings.TrimSpace(asString(config["url"]))
|
||||
if raw == "" {
|
||||
return fmt.Errorf("webhook url is required")
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webhook url is invalid: %w", err)
|
||||
}
|
||||
// 仅允许 http/https,杜绝 file://、gopher:// 等可被用于 SSRF 的协议。
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return fmt.Errorf("webhook url must use http or https scheme")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ type AuditLogRepository interface {
|
||||
Create(ctx context.Context, log *model.AuditLog) error
|
||||
List(ctx context.Context, opts AuditLogListOptions) (*AuditLogListResult, error)
|
||||
ListAll(ctx context.Context, opts AuditLogListOptions) ([]model.AuditLog, error)
|
||||
// DeleteBefore 删除 created_at 早于 cutoff 的审计日志,返回删除行数。用于保留期清理。
|
||||
DeleteBefore(ctx context.Context, cutoff time.Time) (int64, error)
|
||||
}
|
||||
|
||||
type gormAuditLogRepository struct {
|
||||
@@ -43,6 +45,11 @@ func (r *gormAuditLogRepository) Create(_ context.Context, log *model.AuditLog)
|
||||
return r.db.Create(log).Error
|
||||
}
|
||||
|
||||
func (r *gormAuditLogRepository) DeleteBefore(ctx context.Context, cutoff time.Time) (int64, error) {
|
||||
result := r.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(&model.AuditLog{})
|
||||
return result.RowsAffected, result.Error
|
||||
}
|
||||
|
||||
func (r *gormAuditLogRepository) List(_ context.Context, opts AuditLogListOptions) (*AuditLogListResult, error) {
|
||||
query := r.buildQuery(opts)
|
||||
var total int64
|
||||
|
||||
68
server/internal/repository/backup_record_manifest_test.go
Normal file
68
server/internal/repository/backup_record_manifest_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/model"
|
||||
)
|
||||
|
||||
// 列表查询应省略 Manifest 列(避免拖出大 JSON),而 FindByID 仍保留(内容浏览/差异基线需要)。
|
||||
func TestListSuccessfulByTaskOmitsManifest(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newBackupRecordTestRepository(t)
|
||||
rec := &model.BackupRecord{TaskID: 1, StorageTargetID: 1, Status: "success", BackupKind: model.BackupKindFull, Manifest: `{"entries":[{"p":"x"}]}`, StartedAt: time.Now().UTC()}
|
||||
if err := repo.Create(ctx, rec); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
|
||||
items, err := repo.ListSuccessfulByTask(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("ListSuccessfulByTask: %v", err)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
t.Fatal("expected at least one record")
|
||||
}
|
||||
for _, it := range items {
|
||||
if it.Manifest != "" {
|
||||
t.Fatalf("ListSuccessfulByTask must omit Manifest, got %q", it.Manifest)
|
||||
}
|
||||
}
|
||||
|
||||
full, err := repo.FindByID(ctx, rec.ID)
|
||||
if err != nil || full == nil {
|
||||
t.Fatalf("FindByID: %v / %v", full, err)
|
||||
}
|
||||
if full.Manifest == "" {
|
||||
t.Fatal("FindByID must retain Manifest (browse/diff depend on it)")
|
||||
}
|
||||
}
|
||||
|
||||
// 仅统计「成功且依赖给定全量」的差异备份:失败的或依赖其他全量的不计入。
|
||||
func TestCountDependentDifferentials(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newBackupRecordTestRepository(t)
|
||||
now := time.Now().UTC()
|
||||
base := &model.BackupRecord{TaskID: 1, StorageTargetID: 1, Status: "success", BackupKind: model.BackupKindFull, StartedAt: now}
|
||||
if err := repo.Create(ctx, base); err != nil {
|
||||
t.Fatalf("create base: %v", err)
|
||||
}
|
||||
mk := func(status, kind string, baseID uint) {
|
||||
if err := repo.Create(ctx, &model.BackupRecord{TaskID: 1, StorageTargetID: 1, Status: status, BackupKind: kind, BaseRecordID: baseID, StartedAt: now}); err != nil {
|
||||
t.Fatalf("create dependent: %v", err)
|
||||
}
|
||||
}
|
||||
mk(model.BackupRecordStatusSuccess, model.BackupKindDifferential, base.ID) // 计入
|
||||
mk(model.BackupRecordStatusSuccess, model.BackupKindDifferential, base.ID) // 计入
|
||||
mk(model.BackupRecordStatusFailed, model.BackupKindDifferential, base.ID) // 失败 → 不计
|
||||
mk(model.BackupRecordStatusSuccess, model.BackupKindDifferential, 99999) // 依赖其他全量 → 不计
|
||||
|
||||
n, err := repo.CountDependentDifferentials(ctx, base.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("CountDependentDifferentials: %v", err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("want 2 dependent successful differentials, got %d", n)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ type BackupRecordRepository interface {
|
||||
ListRecent(context.Context, int) ([]model.BackupRecord, error)
|
||||
ListByTask(context.Context, uint) ([]model.BackupRecord, error)
|
||||
ListSuccessfulByTask(context.Context, uint) ([]model.BackupRecord, error)
|
||||
CountDependentDifferentials(context.Context, uint) (int64, error)
|
||||
Count(context.Context) (int64, error)
|
||||
CountSince(context.Context, time.Time) (int64, error)
|
||||
CountSuccessSince(context.Context, time.Time) (int64, error)
|
||||
@@ -57,7 +58,8 @@ func NewBackupRecordRepository(db *gorm.DB) *GormBackupRecordRepository {
|
||||
}
|
||||
|
||||
func (r *GormBackupRecordRepository) List(ctx context.Context, options BackupRecordListOptions) ([]model.BackupRecord, error) {
|
||||
query := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Preload("Task").Preload("Task.StorageTarget").Order("started_at desc")
|
||||
// Omit("Manifest"):列表不需要可能很大的清单 JSON,避免每行拖出该 TEXT 列。
|
||||
query := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Omit("Manifest").Preload("Task").Preload("Task.StorageTarget").Order("started_at desc")
|
||||
if options.TaskID != nil {
|
||||
query = query.Where("task_id = ?", *options.TaskID)
|
||||
}
|
||||
@@ -125,7 +127,7 @@ func (r *GormBackupRecordRepository) ListRecent(ctx context.Context, limit int)
|
||||
limit = 10
|
||||
}
|
||||
var items []model.BackupRecord
|
||||
if err := r.db.WithContext(ctx).Preload("Task").Preload("Task.StorageTarget").Order("started_at desc").Limit(limit).Find(&items).Error; err != nil {
|
||||
if err := r.db.WithContext(ctx).Omit("Manifest").Preload("Task").Preload("Task.StorageTarget").Order("started_at desc").Limit(limit).Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
@@ -133,7 +135,7 @@ func (r *GormBackupRecordRepository) ListRecent(ctx context.Context, limit int)
|
||||
|
||||
func (r *GormBackupRecordRepository) ListByTask(ctx context.Context, taskID uint) ([]model.BackupRecord, error) {
|
||||
var items []model.BackupRecord
|
||||
if err := r.db.WithContext(ctx).Where("task_id = ?", taskID).Order("id desc").Find(&items).Error; err != nil {
|
||||
if err := r.db.WithContext(ctx).Omit("Manifest").Where("task_id = ?", taskID).Order("id desc").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
@@ -141,12 +143,21 @@ func (r *GormBackupRecordRepository) ListByTask(ctx context.Context, taskID uint
|
||||
|
||||
func (r *GormBackupRecordRepository) ListSuccessfulByTask(ctx context.Context, taskID uint) ([]model.BackupRecord, error) {
|
||||
var items []model.BackupRecord
|
||||
if err := r.db.WithContext(ctx).Where("task_id = ? AND status = ?", taskID, "success").Order("completed_at desc, id desc").Find(&items).Error; err != nil {
|
||||
if err := r.db.WithContext(ctx).Omit("Manifest").Where("task_id = ? AND status = ?", taskID, "success").Order("completed_at desc, id desc").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// CountDependentDifferentials 统计依赖某全量记录(作为基线)的成功差异备份数量。
|
||||
func (r *GormBackupRecordRepository) CountDependentDifferentials(ctx context.Context, baseID uint) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).Model(&model.BackupRecord{}).
|
||||
Where("base_record_id = ? AND backup_kind = ? AND status = ?", baseID, model.BackupKindDifferential, model.BackupRecordStatusSuccess).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *GormBackupRecordRepository) Count(ctx context.Context) (int64, error) {
|
||||
var count int64
|
||||
if err := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Count(&count).Error; err != nil {
|
||||
|
||||
@@ -167,7 +167,8 @@ func (s *Service) syncTaskLocked(task *model.BackupTask) error {
|
||||
// 集群感知:若任务绑定了离线的远程节点,跳过本轮触发避免堆积 failed 记录
|
||||
if taskNodeID > 0 && s.nodes != nil {
|
||||
node, err := s.nodes.FindByID(context.Background(), taskNodeID)
|
||||
if err == nil && node != nil && !node.IsLocal && node.Status != model.NodeStatusOnline {
|
||||
// 用实时推导的状态判定,避免后台监控刷新前把任务下发给刚失联的节点。
|
||||
if err == nil && node != nil && !node.IsLocal && node.EffectiveStatus(time.Now().UTC()) != model.NodeStatusOnline {
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("skip scheduled run: target node offline",
|
||||
zap.Uint("task_id", taskID), zap.String("task_name", taskName),
|
||||
|
||||
86
server/internal/service/audit_retention_test.go
Normal file
86
server/internal/service/audit_retention_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/config"
|
||||
"backupx/server/internal/database"
|
||||
"backupx/server/internal/logger"
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/repository"
|
||||
)
|
||||
|
||||
func TestAuditRetention(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
log, err := logger.New(config.LogConfig{Level: "error"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(baseDir, "a.db")}, log)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auditRepo := repository.NewAuditLogRepository(db)
|
||||
configRepo := repository.NewSystemConfigRepository(db)
|
||||
svc := NewAuditService(auditRepo)
|
||||
ctx := context.Background()
|
||||
|
||||
// seed 写一条审计日志并把 created_at 强制改到 daysAgo 天前。
|
||||
seed := func(daysAgo int) {
|
||||
rec := &model.AuditLog{Username: "t", Category: "test", Action: "seed"}
|
||||
if err := auditRepo.Create(ctx, rec); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
ts := time.Now().UTC().AddDate(0, 0, -daysAgo)
|
||||
if err := db.Model(&model.AuditLog{}).Where("id = ?", rec.ID).Update("created_at", ts).Error; err != nil {
|
||||
t.Fatalf("backdate: %v", err)
|
||||
}
|
||||
}
|
||||
count := func() int64 {
|
||||
var n int64
|
||||
db.Model(&model.AuditLog{}).Count(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
seed(100)
|
||||
seed(40)
|
||||
seed(0)
|
||||
if count() != 3 {
|
||||
t.Fatalf("expected 3 seeded, got %d", count())
|
||||
}
|
||||
|
||||
// days<=0 不清理。
|
||||
if n, _ := svc.PurgeOlderThan(ctx, 0); n != 0 || count() != 3 {
|
||||
t.Fatalf("days<=0 must not purge (n=%d count=%d)", n, count())
|
||||
}
|
||||
|
||||
// 保留 50 天 → 删除 100 天前那条。
|
||||
n, err := svc.PurgeOlderThan(ctx, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("purge: %v", err)
|
||||
}
|
||||
if n != 1 || count() != 2 {
|
||||
t.Fatalf("expected 1 purged / 2 remaining, got n=%d count=%d", n, count())
|
||||
}
|
||||
|
||||
// 设置驱动:retention=10 天 → 再删 40 天前那条。
|
||||
if err := configRepo.Upsert(ctx, &model.SystemConfig{Key: SettingKeyAuditRetentionDays, Value: "10"}); err != nil {
|
||||
t.Fatalf("upsert setting: %v", err)
|
||||
}
|
||||
svc.runRetentionOnce(ctx, configRepo)
|
||||
if count() != 1 {
|
||||
t.Fatalf("expected 1 remaining after retention=10, got %d", count())
|
||||
}
|
||||
|
||||
// retention=0 → 永久保留,不再删除。
|
||||
if err := configRepo.Upsert(ctx, &model.SystemConfig{Key: SettingKeyAuditRetentionDays, Value: "0"}); err != nil {
|
||||
t.Fatalf("upsert setting: %v", err)
|
||||
}
|
||||
svc.runRetentionOnce(ctx, configRepo)
|
||||
if count() != 1 {
|
||||
t.Fatalf("retention=0 must keep all, got %d", count())
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -51,6 +52,59 @@ func NewAuditService(repo repository.AuditLogRepository) *AuditService {
|
||||
}
|
||||
}
|
||||
|
||||
// PurgeOlderThan 删除早于 days 天前的审计日志,返回删除条数。days<=0 时不清理。
|
||||
func (s *AuditService) PurgeOlderThan(ctx context.Context, days int) (int64, error) {
|
||||
if days <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -days)
|
||||
return s.repo.DeleteBefore(ctx, cutoff)
|
||||
}
|
||||
|
||||
// StartRetentionMonitor 启动后台审计保留期清理:按 interval 周期读取
|
||||
// audit_retention_days 设置,>0 时删除超期审计日志。缺省/0 表示永久保留
|
||||
// (向后兼容,默认不删任何历史)。ctx 取消后退出。
|
||||
func (s *AuditService) StartRetentionMonitor(ctx context.Context, configs repository.SystemConfigRepository, interval time.Duration) {
|
||||
if s == nil || configs == nil {
|
||||
return
|
||||
}
|
||||
if interval <= 0 {
|
||||
interval = 6 * time.Hour
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
s.runRetentionOnce(ctx, configs) // 启动后立即跑一次
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.runRetentionOnce(ctx, configs)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *AuditService) runRetentionOnce(ctx context.Context, configs repository.SystemConfigRepository) {
|
||||
cfg, err := configs.GetByKey(ctx, SettingKeyAuditRetentionDays)
|
||||
if err != nil || cfg == nil {
|
||||
return
|
||||
}
|
||||
days, err := strconv.Atoi(strings.TrimSpace(cfg.Value))
|
||||
if err != nil || days <= 0 {
|
||||
return
|
||||
}
|
||||
deleted, err := s.PurgeOlderThan(ctx, days)
|
||||
if err != nil {
|
||||
log.Printf("[audit] retention purge failed: %v", err)
|
||||
return
|
||||
}
|
||||
if deleted > 0 {
|
||||
log.Printf("[audit] retention purge: deleted %d logs older than %d days", deleted, days)
|
||||
}
|
||||
}
|
||||
|
||||
// SetWebhook 动态配置审计事件转发 URL 与签名密钥。
|
||||
// - url 为空字符串时禁用转发
|
||||
// - secret 非空时对 payload 计算 HMAC-SHA256,作为 X-BackupX-Signature header
|
||||
|
||||
@@ -46,6 +46,10 @@ func (r *fakeAuditRepo) ListAll(context.Context, repository.AuditLogListOptions)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *fakeAuditRepo) DeleteBefore(context.Context, time.Time) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func TestAuditService_WebhookDeliversSignedPayload(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
var got struct {
|
||||
|
||||
@@ -293,7 +293,7 @@ func (s *AuthService) verifyLoginMFA(ctx context.Context, user *model.User, inpu
|
||||
}
|
||||
|
||||
func (s *AuthService) userBySubject(ctx context.Context, subject string) (*model.User, error) {
|
||||
userID, err := strconv.ParseUint(subject, 10, 64)
|
||||
userID, err := strconv.ParseUint(subject, 10, 0)
|
||||
if err != nil {
|
||||
return nil, apperror.Unauthorized("AUTH_INVALID_SUBJECT", "无效用户身份", err)
|
||||
}
|
||||
|
||||
@@ -276,6 +276,21 @@ func (s *BackupExecutionService) DeleteRecord(ctx context.Context, recordID uint
|
||||
if record == nil {
|
||||
return apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", fmt.Errorf("backup record %d not found", recordID))
|
||||
}
|
||||
if record.Locked {
|
||||
return apperror.BadRequest("BACKUP_RECORD_LOCKED",
|
||||
"该备份已保留锁定(法律保留),请先解锁再删除", nil)
|
||||
}
|
||||
// 差异链保护:禁止删除仍被差异备份依赖的全量,否则这些差异将无法恢复(与保留清理的保护一致)。
|
||||
if record.BackupKind == model.BackupKindFull {
|
||||
deps, depErr := s.records.CountDependentDifferentials(ctx, record.ID)
|
||||
if depErr != nil {
|
||||
return apperror.Internal("BACKUP_RECORD_DELETE_FAILED", "无法检查差异备份依赖", depErr)
|
||||
}
|
||||
if deps > 0 {
|
||||
return apperror.BadRequest("BACKUP_RECORD_HAS_DEPENDENTS",
|
||||
fmt.Sprintf("该全量备份仍有 %d 个差异备份依赖它,删除会导致这些差异无法恢复。请先删除相关差异备份或等待其过期。", deps), nil)
|
||||
}
|
||||
}
|
||||
if remote, err := s.deleteRemoteLocalDiskObject(ctx, record); err != nil {
|
||||
return err
|
||||
} else if !remote && strings.TrimSpace(record.StoragePath) != "" {
|
||||
@@ -332,28 +347,8 @@ func (s *BackupExecutionService) deleteRemoteLocalDiskObject(ctx context.Context
|
||||
// provider 指向的是 Master 本机的同名路径,访问会静默取错文件或 404。明确拒绝
|
||||
// 让用户知情,避免假成功。
|
||||
func (s *BackupExecutionService) validateClusterAccessible(ctx context.Context, record *model.BackupRecord) error {
|
||||
if record == nil || record.NodeID == 0 {
|
||||
return nil
|
||||
}
|
||||
// 检查是否为远程节点
|
||||
if s.nodeRepo == nil {
|
||||
return nil
|
||||
}
|
||||
node, err := s.nodeRepo.FindByID(ctx, record.NodeID)
|
||||
if err != nil || node == nil || node.IsLocal {
|
||||
return nil
|
||||
}
|
||||
// 检查存储类型是否为 local_disk(跨节点不可达)
|
||||
target, err := s.targets.FindByID(ctx, record.StorageTargetID)
|
||||
if err != nil || target == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(target.Type, "local_disk") {
|
||||
return apperror.BadRequest("BACKUP_RECORD_CROSS_NODE_LOCAL_DISK",
|
||||
fmt.Sprintf("该备份位于节点 %s 的本地磁盘(local_disk),Master 无法跨节点访问。请登录该节点或改用云存储后再操作。", node.Name),
|
||||
nil)
|
||||
}
|
||||
return nil
|
||||
return validateCrossNodeLocalDisk(ctx, s.nodeRepo, s.targets, record,
|
||||
"BACKUP_RECORD_CROSS_NODE_LOCAL_DISK", "访问。请登录该节点或改用云存储后再操作")
|
||||
}
|
||||
|
||||
func (s *BackupExecutionService) startTask(ctx context.Context, id uint, async bool) (*BackupRecordDetail, error) {
|
||||
@@ -593,14 +588,46 @@ func (s *BackupExecutionService) isRemoteNode(ctx context.Context, nodeID uint)
|
||||
// resolveRemoteNode 返回 NodeID 对应的远程节点指针,或 nil 表示本机执行。
|
||||
// 相比 isRemoteNode,它让调用方能读取节点状态(在线/离线)做进一步判断。
|
||||
func (s *BackupExecutionService) resolveRemoteNode(ctx context.Context, nodeID uint) *model.Node {
|
||||
if s.nodeRepo == nil || s.agentDispatcher == nil || nodeID == 0 {
|
||||
return nil
|
||||
return resolveRemoteExecutionNode(ctx, s.nodeRepo, s.agentDispatcher != nil, nodeID)
|
||||
}
|
||||
|
||||
// resolveDifferentialBase 为差异备份解析基线全量:仅本机(NodeID=0)文件任务且 BackupMode=differential 时生效。
|
||||
// 返回最近一次「成功、含清单、未超过 DiffFullIntervalDays」的全量记录 ID 及其清单;
|
||||
// 无合适基线(首次备份 / 最近全量已过期 / 清单缺失)时 ok=false,调用方回退为全量。
|
||||
func (s *BackupExecutionService) resolveDifferentialBase(ctx context.Context, task *model.BackupTask) (uint, backup.Manifest, bool) {
|
||||
if task.Type != model.BackupTaskTypeFile || task.NodeID != 0 || !strings.EqualFold(task.BackupMode, model.BackupModeDifferential) {
|
||||
return 0, backup.Manifest{}, false
|
||||
}
|
||||
node, err := s.nodeRepo.FindByID(ctx, nodeID)
|
||||
if err != nil || node == nil || node.IsLocal {
|
||||
return nil
|
||||
records, err := s.records.ListSuccessfulByTask(ctx, task.ID)
|
||||
if err != nil {
|
||||
return 0, backup.Manifest{}, false
|
||||
}
|
||||
return node
|
||||
intervalDays := task.DiffFullIntervalDays
|
||||
if intervalDays <= 0 {
|
||||
intervalDays = 7
|
||||
}
|
||||
cutoff := time.Now().Add(-time.Duration(intervalDays) * 24 * time.Hour)
|
||||
for i := range records {
|
||||
rec := records[i]
|
||||
if rec.BackupKind != model.BackupKindFull {
|
||||
continue
|
||||
}
|
||||
// 最近的全量已超过强制全量间隔 → 触发新全量,限制差异链跨度与单个差异体积。
|
||||
if rec.StartedAt.Before(cutoff) {
|
||||
return 0, backup.Manifest{}, false
|
||||
}
|
||||
// 列表查询已省略 Manifest 列,这里按需单独加载最近全量的清单(FindByID 含 Manifest)。
|
||||
full, ferr := s.records.FindByID(ctx, rec.ID)
|
||||
if ferr != nil || full == nil || strings.TrimSpace(full.Manifest) == "" {
|
||||
return 0, backup.Manifest{}, false
|
||||
}
|
||||
manifest, decErr := backup.DecodeManifest([]byte(full.Manifest))
|
||||
if decErr != nil || len(manifest.Entries) == 0 {
|
||||
return 0, backup.Manifest{}, false
|
||||
}
|
||||
return rec.ID, manifest, true
|
||||
}
|
||||
return 0, backup.Manifest{}, false
|
||||
}
|
||||
|
||||
func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.BackupTask, recordID uint, startedAt time.Time) {
|
||||
@@ -627,6 +654,10 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
||||
var storagePath string
|
||||
selectedStorageTargetID := task.StorageTargetID
|
||||
var uploadResults []StorageUploadResultItem
|
||||
// 差异备份链信息:实际类型(全量/差异)、基线全量 ID、全量清单 JSON。
|
||||
backupKind := model.BackupKindFull
|
||||
var baseRecordID uint
|
||||
var manifestJSON string
|
||||
completeRecord := func() {
|
||||
if finalizeErr := s.finalizeRecord(ctx, task, recordID, startedAt, status, errMessage, logger.String(), fileName, fileSize, checksum, storagePath, selectedStorageTargetID); finalizeErr != nil {
|
||||
logger.Errorf("写回备份记录失败:%v", finalizeErr)
|
||||
@@ -642,6 +673,17 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
||||
}
|
||||
}
|
||||
}
|
||||
// 持久化差异链信息:全量记录其清单(供后续差异比对),差异记录其基线全量 ID。
|
||||
if status == model.BackupRecordStatusSuccess && (backupKind != model.BackupKindFull || baseRecordID != 0 || manifestJSON != "") {
|
||||
if record, findErr := s.records.FindByID(ctx, recordID); findErr == nil && record != nil {
|
||||
record.BackupKind = backupKind
|
||||
record.BaseRecordID = baseRecordID
|
||||
record.Manifest = manifestJSON
|
||||
if updErr := s.records.Update(ctx, record); updErr != nil {
|
||||
logger.Warnf("写回差异链信息失败:%v", updErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.shouldNotify(ctx, task, status) {
|
||||
if err := s.notifier.NotifyBackupResult(ctx, BackupExecutionNotification{Task: task, Record: &model.BackupRecord{ID: recordID, TaskID: task.ID, Status: status, FileName: fileName, FileSize: fileSize, StoragePath: storagePath, ErrorMessage: errMessage, StartedAt: startedAt}, Error: buildOptionalError(errMessage)}); err != nil {
|
||||
logger.Warnf("发送备份通知失败:%v", err)
|
||||
@@ -659,6 +701,13 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
||||
logger.Errorf("构建任务运行时配置失败:%v", err)
|
||||
return
|
||||
}
|
||||
// 差异备份:解析基线全量,命中则切换为差异模式(仅本机文件任务)。
|
||||
if baseID, baseManifest, ok := s.resolveDifferentialBase(ctx, task); ok {
|
||||
spec.Differential = true
|
||||
spec.BaseManifest = baseManifest
|
||||
baseRecordID = baseID
|
||||
logger.Infof("差异备份模式:基于全量备份 #%d 仅打包变更", baseID)
|
||||
}
|
||||
runner, err := s.runnerRegistry.Runner(spec.Type)
|
||||
if err != nil {
|
||||
errMessage = err.Error()
|
||||
@@ -672,6 +721,17 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(result.TempDir)
|
||||
// 依据运行器产出判定实际类型:产出清单 → 全量(记录清单供后续差异比对);否则为差异。
|
||||
if result.Manifest != nil {
|
||||
backupKind = model.BackupKindFull
|
||||
if data, encErr := backup.EncodeManifest(*result.Manifest); encErr == nil {
|
||||
manifestJSON = string(data)
|
||||
} else {
|
||||
logger.Warnf("备份清单序列化失败(不影响本次备份,但将禁用后续差异):%v", encErr)
|
||||
}
|
||||
} else {
|
||||
backupKind = model.BackupKindDifferential
|
||||
}
|
||||
finalPath := result.ArtifactPath
|
||||
if strings.EqualFold(task.Compression, "gzip") && !strings.HasSuffix(strings.ToLower(finalPath), ".gz") {
|
||||
logger.Infof("开始压缩备份文件")
|
||||
@@ -682,6 +742,15 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
||||
return
|
||||
}
|
||||
finalPath = compressedPath
|
||||
} else if strings.EqualFold(task.Compression, "zstd") && !strings.HasSuffix(strings.ToLower(finalPath), ".zst") {
|
||||
logger.Infof("开始压缩备份文件(zstd)")
|
||||
compressedPath, compressErr := compress.ZstdFile(finalPath)
|
||||
if compressErr != nil {
|
||||
errMessage = compressErr.Error()
|
||||
logger.Errorf("压缩备份文件失败:%v", compressErr)
|
||||
return
|
||||
}
|
||||
finalPath = compressedPath
|
||||
}
|
||||
if task.Encrypt {
|
||||
logger.Infof("开始加密备份文件")
|
||||
@@ -952,78 +1021,11 @@ func (s *BackupExecutionService) resolveProviderForNode(ctx context.Context, tar
|
||||
LowLevelRetries: s.retries,
|
||||
BandwidthLimit: s.effectiveBandwidth(ctx, nodeID),
|
||||
})
|
||||
target, err := s.targets.FindByID(ctx, targetID)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("BACKUP_STORAGE_TARGET_GET_FAILED", "无法获取存储目标详情", err)
|
||||
}
|
||||
if target == nil {
|
||||
return nil, apperror.BadRequest("BACKUP_STORAGE_TARGET_INVALID", "关联的存储目标不存在", nil)
|
||||
}
|
||||
configMap := map[string]any{}
|
||||
if err := s.cipher.DecryptJSON(target.ConfigCiphertext, &configMap); err != nil {
|
||||
return nil, apperror.Internal("BACKUP_STORAGE_TARGET_DECRYPT_FAILED", "无法解密存储目标配置", err)
|
||||
}
|
||||
provider, err := s.storageRegistry.Create(ctx, target.Type, configMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return provider, nil
|
||||
return resolveStorageProvider(ctx, s.targets, s.storageRegistry, s.cipher, targetID)
|
||||
}
|
||||
|
||||
func (s *BackupExecutionService) buildTaskSpec(task *model.BackupTask, startedAt time.Time) (backup.TaskSpec, error) {
|
||||
excludePatterns := []string{}
|
||||
if strings.TrimSpace(task.ExcludePatterns) != "" {
|
||||
if err := json.Unmarshal([]byte(task.ExcludePatterns), &excludePatterns); err != nil {
|
||||
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析排除规则", err)
|
||||
}
|
||||
}
|
||||
password := ""
|
||||
if strings.TrimSpace(task.DBPasswordCiphertext) != "" {
|
||||
plain, err := s.cipher.Decrypt(task.DBPasswordCiphertext)
|
||||
if err != nil {
|
||||
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECRYPT_FAILED", "无法解密数据库密码", err)
|
||||
}
|
||||
password = string(plain)
|
||||
}
|
||||
sourcePaths := []string{}
|
||||
if strings.TrimSpace(task.SourcePaths) != "" {
|
||||
if err := json.Unmarshal([]byte(task.SourcePaths), &sourcePaths); err != nil {
|
||||
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析源路径配置", err)
|
||||
}
|
||||
}
|
||||
dbSpec := backup.DatabaseSpec{
|
||||
Host: task.DBHost,
|
||||
Port: task.DBPort,
|
||||
User: task.DBUser,
|
||||
Password: password,
|
||||
Names: []string{task.DBName},
|
||||
Path: task.DBPath,
|
||||
}
|
||||
// 解析 ExtraConfig 填充类型特有字段(目前主要用于 SAP HANA)
|
||||
if strings.TrimSpace(task.ExtraConfig) != "" {
|
||||
extra := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(task.ExtraConfig), &extra); err != nil {
|
||||
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析扩展配置", err)
|
||||
}
|
||||
applyHANAExtraConfig(&dbSpec, extra)
|
||||
}
|
||||
return backup.TaskSpec{
|
||||
ID: task.ID,
|
||||
Name: task.Name,
|
||||
Type: task.Type,
|
||||
SourcePath: task.SourcePath,
|
||||
SourcePaths: sourcePaths,
|
||||
ExcludePatterns: excludePatterns,
|
||||
StorageTargetID: task.StorageTargetID,
|
||||
StorageTargetType: "",
|
||||
Compression: task.Compression,
|
||||
Encrypt: task.Encrypt,
|
||||
RetentionDays: task.RetentionDays,
|
||||
MaxBackups: task.MaxBackups,
|
||||
StartedAt: startedAt,
|
||||
TempDir: s.tempDir,
|
||||
Database: dbSpec,
|
||||
}, nil
|
||||
return buildBackupTaskSpec(s.cipher, task, startedAt, s.tempDir)
|
||||
}
|
||||
|
||||
// applyHANAExtraConfig 从 ExtraConfig map 中提取 SAP HANA 字段填入 DatabaseSpec。
|
||||
@@ -1065,22 +1067,7 @@ func (s *BackupExecutionService) loadRecordProvider(ctx context.Context, recordI
|
||||
}
|
||||
|
||||
func (s *BackupExecutionService) prepareArtifactForRestore(artifactPath string) (string, error) {
|
||||
currentPath := artifactPath
|
||||
if strings.HasSuffix(strings.ToLower(currentPath), ".enc") {
|
||||
decryptedPath, err := backupcrypto.DecryptFile(s.cipher.Key(), currentPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
currentPath = decryptedPath
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(currentPath), ".gz") {
|
||||
decompressedPath, err := compress.GunzipFile(currentPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
currentPath = decompressedPath
|
||||
}
|
||||
return currentPath, nil
|
||||
return prepareBackupArtifact(s.cipher, artifactPath, nil)
|
||||
}
|
||||
|
||||
func (s *BackupExecutionService) getRecordDetail(ctx context.Context, recordID uint) (*BackupRecordDetail, error) {
|
||||
|
||||
104
server/internal/service/backup_record_lock_test.go
Normal file
104
server/internal/service/backup_record_lock_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"backupx/server/internal/backup"
|
||||
"backupx/server/internal/config"
|
||||
"backupx/server/internal/database"
|
||||
"backupx/server/internal/logger"
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/storage"
|
||||
"backupx/server/internal/storage/codec"
|
||||
storageRclone "backupx/server/internal/storage/rclone"
|
||||
)
|
||||
|
||||
func newLockTestHarness(t *testing.T) (*BackupRecordService, *BackupExecutionService) {
|
||||
t.Helper()
|
||||
baseDir := t.TempDir()
|
||||
sourceDir := filepath.Join(baseDir, "data")
|
||||
storeDir := filepath.Join(baseDir, "store")
|
||||
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceDir, "f.txt"), []byte("lock-data"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
log, err := logger.New(config.LogConfig{Level: "error"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(baseDir, "backupx.db")}, log)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cipher := codec.NewConfigCipher("lock-secret")
|
||||
targets := repository.NewStorageTargetRepository(db)
|
||||
tasks := repository.NewBackupTaskRepository(db)
|
||||
records := repository.NewBackupRecordRepository(db)
|
||||
cfg, err := cipher.EncryptJSON(map[string]any{"basePath": storeDir})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := targets.Create(context.Background(), &model.StorageTarget{Name: "s", Type: string(storage.ProviderTypeLocalDisk), Enabled: true, ConfigCiphertext: cfg, ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &model.BackupTask{Name: "lock-task", Type: "file", Enabled: true, SourcePath: sourceDir, StorageTargetID: 1, NodeID: 0, RetentionDays: 30, Compression: "gzip", MaxBackups: 10, LastStatus: "idle"}
|
||||
if err := tasks.Create(context.Background(), task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logHub := backup.NewLogHub()
|
||||
runnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil))
|
||||
storageRegistry := storage.NewRegistry(storageRclone.NewLocalDiskFactory())
|
||||
execution := NewBackupExecutionService(tasks, records, targets, storageRegistry, runnerRegistry, logHub, nil, cipher, nil, baseDir, 2, 10, "")
|
||||
recordService := NewBackupRecordService(records, execution, logHub)
|
||||
return recordService, execution
|
||||
}
|
||||
|
||||
// TestBackupRecordLock_BlocksDeletion 验证保留锁定后手动删除被拒绝,解锁后可删除。
|
||||
func TestBackupRecordLock_BlocksDeletion(t *testing.T) {
|
||||
recordService, execution := newLockTestHarness(t)
|
||||
ctx := context.Background()
|
||||
|
||||
bd, err := execution.RunTaskByIDSync(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("RunTaskByIDSync: %v", err)
|
||||
}
|
||||
if bd.Status != "success" {
|
||||
t.Fatalf("backup not success: %s", bd.Status)
|
||||
}
|
||||
|
||||
// 锁定。
|
||||
detail, err := recordService.SetLock(ctx, bd.ID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("SetLock(true): %v", err)
|
||||
}
|
||||
if !detail.Locked {
|
||||
t.Fatal("expected detail.Locked = true")
|
||||
}
|
||||
|
||||
// 锁定状态下删除应被拒绝。
|
||||
if err := execution.DeleteRecord(ctx, bd.ID); err == nil {
|
||||
t.Fatal("expected delete of locked record to be rejected")
|
||||
} else if !strings.Contains(err.Error(), "保留锁定") {
|
||||
t.Fatalf("unexpected delete error: %v", err)
|
||||
}
|
||||
|
||||
// 记录仍然存在。
|
||||
if got, _ := recordService.Get(ctx, bd.ID); got == nil {
|
||||
t.Fatal("locked record must still exist after rejected delete")
|
||||
}
|
||||
|
||||
// 解锁后可删除。
|
||||
if _, err := recordService.SetLock(ctx, bd.ID, false); err != nil {
|
||||
t.Fatalf("SetLock(false): %v", err)
|
||||
}
|
||||
if err := execution.DeleteRecord(ctx, bd.ID); err != nil {
|
||||
t.Fatalf("delete after unlock should succeed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -36,13 +37,15 @@ type BackupRecordSummary struct {
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
Locked bool `json:"locked"`
|
||||
BackupKind string `json:"backupKind"`
|
||||
}
|
||||
|
||||
type BackupRecordDetail struct {
|
||||
BackupRecordSummary
|
||||
LogContent string `json:"logContent"`
|
||||
LogEvents []backup.LogEvent `json:"logEvents,omitempty"`
|
||||
StorageUploadResults []StorageUploadResultItem `json:"storageUploadResults,omitempty"`
|
||||
StorageUploadResults []StorageUploadResultItem `json:"storageUploadResults,omitempty"`
|
||||
}
|
||||
|
||||
type BackupRecordService struct {
|
||||
@@ -78,6 +81,64 @@ func (s *BackupRecordService) Get(ctx context.Context, id uint) (*BackupRecordDe
|
||||
return toBackupRecordDetail(item, s.logHub), nil
|
||||
}
|
||||
|
||||
// BackupContentEntry 描述备份内单个条目(文件或目录),用于内容浏览。
|
||||
type BackupContentEntry struct {
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
IsDir bool `json:"isDir"`
|
||||
}
|
||||
|
||||
// BackupRecordContents 是一次备份的内容清单视图。
|
||||
type BackupRecordContents struct {
|
||||
RecordID uint `json:"recordId"`
|
||||
Total int `json:"total"`
|
||||
Truncated bool `json:"truncated"`
|
||||
BasedOnFull uint `json:"basedOnFull,omitempty"` // 差异记录时,清单取自该基线全量
|
||||
Entries []BackupContentEntry `json:"entries"`
|
||||
}
|
||||
|
||||
const backupContentsMaxEntries = 10000
|
||||
|
||||
// ListContents 返回某备份记录的文件清单(仅文件类型的新全量备份会记录清单)。
|
||||
// 差异记录回退到其基线全量的清单,近似展示恢复后的目录结构。无清单时返回明确错误。
|
||||
func (s *BackupRecordService) ListContents(ctx context.Context, id uint) (*BackupRecordContents, error) {
|
||||
item, err := s.records.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录", err)
|
||||
}
|
||||
if item == nil {
|
||||
return nil, apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", nil)
|
||||
}
|
||||
manifestJSON := item.Manifest
|
||||
basedOnFull := uint(0)
|
||||
if strings.TrimSpace(manifestJSON) == "" && item.BaseRecordID != 0 {
|
||||
if base, baseErr := s.records.FindByID(ctx, item.BaseRecordID); baseErr == nil && base != nil {
|
||||
manifestJSON = base.Manifest
|
||||
basedOnFull = base.ID
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(manifestJSON) == "" {
|
||||
return nil, apperror.New(422, "BACKUP_CONTENTS_UNAVAILABLE", "该备份未记录文件清单(仅文件类型的新全量备份支持内容浏览),请重新执行一次全量备份后再试。", nil)
|
||||
}
|
||||
manifest, decErr := backup.DecodeManifest([]byte(manifestJSON))
|
||||
if decErr != nil {
|
||||
return nil, apperror.Internal("BACKUP_CONTENTS_DECODE_FAILED", "解析备份清单失败", decErr)
|
||||
}
|
||||
entries := manifest.Entries
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Path < entries[j].Path })
|
||||
total := len(entries)
|
||||
truncated := false
|
||||
if total > backupContentsMaxEntries {
|
||||
entries = entries[:backupContentsMaxEntries]
|
||||
truncated = true
|
||||
}
|
||||
result := &BackupRecordContents{RecordID: item.ID, Total: total, Truncated: truncated, BasedOnFull: basedOnFull, Entries: make([]BackupContentEntry, 0, len(entries))}
|
||||
for _, e := range entries {
|
||||
result.Entries = append(result.Entries, BackupContentEntry{Path: e.Path, Size: e.Size, IsDir: e.IsDir})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *BackupRecordService) SubscribeLogs(ctx context.Context, id uint, buffer int) (<-chan backup.LogEvent, func(), error) {
|
||||
item, err := s.records.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
@@ -102,6 +163,25 @@ func (s *BackupRecordService) Delete(ctx context.Context, id uint) error {
|
||||
return s.execution.DeleteRecord(ctx, id)
|
||||
}
|
||||
|
||||
// SetLock 设置或解除备份记录的保留锁定(法律保留)。
|
||||
// 锁定后该记录免于保留期/数量自动清理,且禁止手动删除,直至显式解锁。
|
||||
func (s *BackupRecordService) SetLock(ctx context.Context, id uint, locked bool) (*BackupRecordDetail, error) {
|
||||
item, err := s.records.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录详情", err)
|
||||
}
|
||||
if item == nil {
|
||||
return nil, apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", nil)
|
||||
}
|
||||
if item.Locked != locked {
|
||||
item.Locked = locked
|
||||
if err := s.records.Update(ctx, item); err != nil {
|
||||
return nil, apperror.Internal("BACKUP_RECORD_LOCK_FAILED", "无法更新备份锁定状态", err)
|
||||
}
|
||||
}
|
||||
return toBackupRecordDetail(item, s.logHub), nil
|
||||
}
|
||||
|
||||
func toBackupRecordSummary(item *model.BackupRecord) BackupRecordSummary {
|
||||
return BackupRecordSummary{
|
||||
ID: item.ID,
|
||||
@@ -118,6 +198,8 @@ func toBackupRecordSummary(item *model.BackupRecord) BackupRecordSummary {
|
||||
ErrorMessage: item.ErrorMessage,
|
||||
StartedAt: item.StartedAt,
|
||||
CompletedAt: item.CompletedAt,
|
||||
Locked: item.Locked,
|
||||
BackupKind: item.BackupKind,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ const backupTaskMaskedValue = "********"
|
||||
|
||||
type BackupTaskUpsertInput struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=100"`
|
||||
Type string `json:"type" binding:"required,oneof=file mysql sqlite postgresql pgsql saphana"`
|
||||
Type string `json:"type" binding:"required,oneof=file mysql sqlite postgresql pgsql saphana mongodb"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CronExpr string `json:"cronExpr" binding:"max=64"`
|
||||
SourcePath string `json:"sourcePath" binding:"max=500"`
|
||||
@@ -40,7 +40,7 @@ type BackupTaskUpsertInput struct {
|
||||
NodePoolTag string `json:"nodePoolTag" binding:"max=64"`
|
||||
Tags string `json:"tags" binding:"max=500"` // 逗号分隔标签
|
||||
RetentionDays int `json:"retentionDays"`
|
||||
Compression string `json:"compression" binding:"omitempty,oneof=gzip none"`
|
||||
Compression string `json:"compression" binding:"omitempty,oneof=gzip zstd none"`
|
||||
Encrypt bool `json:"encrypt"`
|
||||
MaxBackups int `json:"maxBackups"`
|
||||
// ExtraConfig 类型特有扩展配置(如 SAP HANA 的 backupLevel/backupChannels)
|
||||
@@ -52,6 +52,14 @@ type BackupTaskUpsertInput struct {
|
||||
// SLA 配置
|
||||
SLAHoursRPO int `json:"slaHoursRpo"`
|
||||
AlertOnConsecutiveFails int `json:"alertOnConsecutiveFails"`
|
||||
// GFS 分层保留(任一 > 0 启用,取代 RetentionDays/MaxBackups)
|
||||
KeepDaily int `json:"keepDaily"`
|
||||
KeepWeekly int `json:"keepWeekly"`
|
||||
KeepMonthly int `json:"keepMonthly"`
|
||||
KeepYearly int `json:"keepYearly"`
|
||||
// BackupMode 备份模式:full(默认)/ differential(差异,仅文件类型本机任务)
|
||||
BackupMode string `json:"backupMode" binding:"omitempty,oneof=full differential"`
|
||||
DiffFullIntervalDays int `json:"diffFullIntervalDays"`
|
||||
// 备份复制目标存储 ID 列表(3-2-1 规则)
|
||||
ReplicationTargetIDs []uint `json:"replicationTargetIds"`
|
||||
// 维护窗口(CSV,详见 backup/window.go)
|
||||
@@ -90,6 +98,12 @@ type BackupTaskSummary struct {
|
||||
VerifyMode string `json:"verifyMode"`
|
||||
SLAHoursRPO int `json:"slaHoursRpo"`
|
||||
AlertOnConsecutiveFails int `json:"alertOnConsecutiveFails"`
|
||||
KeepDaily int `json:"keepDaily"`
|
||||
KeepWeekly int `json:"keepWeekly"`
|
||||
KeepMonthly int `json:"keepMonthly"`
|
||||
KeepYearly int `json:"keepYearly"`
|
||||
BackupMode string `json:"backupMode"`
|
||||
DiffFullIntervalDays int `json:"diffFullIntervalDays"`
|
||||
// 备份复制目标(3-2-1)
|
||||
ReplicationTargetIDs []uint `json:"replicationTargetIds"`
|
||||
MaintenanceWindows string `json:"maintenanceWindows"`
|
||||
@@ -508,6 +522,14 @@ func (s *BackupTaskService) validateInput(ctx context.Context, existing *model.B
|
||||
return apperror.BadRequest("BACKUP_TASK_REMOTE_ENCRYPT_UNSUPPORTED",
|
||||
"远程节点暂不支持加密备份。请关闭加密,或将任务固定在 Master 本机执行。", nil)
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(input.BackupMode), model.BackupModeDifferential) {
|
||||
if input.Type != model.BackupTaskTypeFile {
|
||||
return apperror.BadRequest("BACKUP_TASK_DIFF_UNSUPPORTED", "差异备份仅支持文件目录类型任务", nil)
|
||||
}
|
||||
if strings.TrimSpace(input.NodePoolTag) != "" || (fixedNode != nil && !fixedNode.IsLocal) {
|
||||
return apperror.BadRequest("BACKUP_TASK_DIFF_REMOTE_UNSUPPORTED", "差异备份当前仅支持本机 Master 执行,请将任务固定在本机或改用全量备份。", nil)
|
||||
}
|
||||
}
|
||||
if input.RetentionDays < 0 {
|
||||
return apperror.BadRequest("BACKUP_TASK_INVALID", "保留天数不能小于 0", nil)
|
||||
}
|
||||
@@ -569,7 +591,7 @@ func validateTaskTypeSpecificFields(input BackupTaskUpsertInput, passwordRequire
|
||||
if !hasSourcePaths {
|
||||
return apperror.BadRequest("BACKUP_TASK_INVALID", "文件备份必须填写源路径", nil)
|
||||
}
|
||||
case "mysql", "postgresql", "saphana":
|
||||
case "mysql", "postgresql", "saphana", "mongodb":
|
||||
if strings.TrimSpace(input.DBHost) == "" {
|
||||
return apperror.BadRequest("BACKUP_TASK_INVALID", "数据库主机不能为空", nil)
|
||||
}
|
||||
@@ -674,6 +696,12 @@ func (s *BackupTaskService) buildTask(existing *model.BackupTask, input BackupTa
|
||||
VerifyMode: normalizeVerifyMode(input.VerifyMode),
|
||||
SLAHoursRPO: maxInt(0, input.SLAHoursRPO),
|
||||
AlertOnConsecutiveFails: alertThreshold(input.AlertOnConsecutiveFails),
|
||||
KeepDaily: maxInt(0, input.KeepDaily),
|
||||
KeepWeekly: maxInt(0, input.KeepWeekly),
|
||||
KeepMonthly: maxInt(0, input.KeepMonthly),
|
||||
KeepYearly: maxInt(0, input.KeepYearly),
|
||||
BackupMode: normalizeBackupMode(input.BackupMode, input.Type),
|
||||
DiffFullIntervalDays: diffFullInterval(input.DiffFullIntervalDays),
|
||||
ReplicationTargetIDs: encodeUintCSV(input.ReplicationTargetIDs),
|
||||
MaintenanceWindows: strings.TrimSpace(input.MaintenanceWindows),
|
||||
DependsOnTaskIDs: encodeUintCSV(input.DependsOnTaskIDs),
|
||||
@@ -766,6 +794,12 @@ func toBackupTaskSummary(item *model.BackupTask) BackupTaskSummary {
|
||||
VerifyMode: item.VerifyMode,
|
||||
SLAHoursRPO: item.SLAHoursRPO,
|
||||
AlertOnConsecutiveFails: item.AlertOnConsecutiveFails,
|
||||
KeepDaily: item.KeepDaily,
|
||||
KeepWeekly: item.KeepWeekly,
|
||||
KeepMonthly: item.KeepMonthly,
|
||||
KeepYearly: item.KeepYearly,
|
||||
BackupMode: item.BackupMode,
|
||||
DiffFullIntervalDays: item.DiffFullIntervalDays,
|
||||
ReplicationTargetIDs: parseUintCSV(item.ReplicationTargetIDs),
|
||||
MaintenanceWindows: item.MaintenanceWindows,
|
||||
DependsOnTaskIDs: parseUintCSV(item.DependsOnTaskIDs),
|
||||
@@ -901,6 +935,22 @@ func decodeExtraConfig(value string) (map[string]any, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// normalizeBackupMode 归一化备份模式:仅文件类型可启用差异,其余一律全量(双保险,防绕过校验)。
|
||||
func normalizeBackupMode(mode, taskType string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(mode), model.BackupModeDifferential) && normalizeBackupTaskType(taskType) == model.BackupTaskTypeFile {
|
||||
return model.BackupModeDifferential
|
||||
}
|
||||
return model.BackupModeFull
|
||||
}
|
||||
|
||||
// diffFullInterval 归一化差异模式下的强制全量间隔(天),非正值回退默认 7。
|
||||
func diffFullInterval(days int) int {
|
||||
if days <= 0 {
|
||||
return 7
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
func normalizeBackupTaskType(value string) string {
|
||||
normalized := strings.TrimSpace(strings.ToLower(value))
|
||||
if normalized == "pgsql" {
|
||||
|
||||
214
server/internal/service/execution_helpers.go
Normal file
214
server/internal/service/execution_helpers.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/backup"
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/storage"
|
||||
"backupx/server/internal/storage/codec"
|
||||
"backupx/server/pkg/compress"
|
||||
backupcrypto "backupx/server/pkg/crypto"
|
||||
)
|
||||
|
||||
// 本文件集中放置「备份执行 / 恢复 / 验证 / 复制」四个执行服务共享的执行期辅助逻辑。
|
||||
//
|
||||
// 历史上这些函数(解密存储配置创建 provider、按后缀解密解压归档、判定远程节点、
|
||||
// 跨节点 local_disk 保护、构建任务执行规格)在四个服务里各复制了一份,差异仅在
|
||||
// 字段名与少量错误码/日志文案。重复实现既增加维护成本,也容易出现"改了一处忘了
|
||||
// 另一处"的不一致缺陷。这里抽取为单一实现,各服务通过薄封装方法委托调用,调用方
|
||||
// 无需改动。
|
||||
|
||||
// fileSHA256 计算文件内容的 SHA-256(小写 hex),与备份上传时记录到
|
||||
// BackupRecord.Checksum 的格式一致,用于恢复/复制前的完整性校验。
|
||||
func fileSHA256(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// verifyArtifactChecksum 校验下载到本地的备份对象与记录的 SHA-256 是否一致。
|
||||
// expected 为空时跳过(兼容早期未记录 checksum 的备份);不一致返回结构化错误,
|
||||
// 调用方应据此中止恢复,避免还原已损坏或被篡改的数据。
|
||||
func verifyArtifactChecksum(path, expected string) error {
|
||||
expected = strings.TrimSpace(expected)
|
||||
if expected == "" {
|
||||
return nil
|
||||
}
|
||||
actual, err := fileSHA256(path)
|
||||
if err != nil {
|
||||
return apperror.Internal("BACKUP_CHECKSUM_READ_FAILED", "无法读取备份文件计算校验和", err)
|
||||
}
|
||||
if !strings.EqualFold(actual, expected) {
|
||||
// 包装错误同样使用中文并附上期望/实际哈希:apperror.Error() 会优先返回包装错误,
|
||||
// 而恢复记录的 ErrorMessage 取自 err.Error(),需保证对用户可读。
|
||||
return apperror.BadRequest("BACKUP_CHECKSUM_MISMATCH",
|
||||
"备份文件完整性校验失败:SHA-256 不匹配,文件可能已损坏或被篡改",
|
||||
fmt.Errorf("备份文件完整性校验失败:SHA-256 不匹配(期望 %s,实际 %s),文件可能已损坏或被篡改", expected, actual))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveStorageProvider 查询存储目标、解密其配置并创建 provider。
|
||||
func resolveStorageProvider(ctx context.Context, targets repository.StorageTargetRepository, registry *storage.Registry, cipher *codec.ConfigCipher, targetID uint) (storage.StorageProvider, error) {
|
||||
target, err := targets.FindByID(ctx, targetID)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("BACKUP_STORAGE_TARGET_GET_FAILED", "无法获取存储目标详情", err)
|
||||
}
|
||||
if target == nil {
|
||||
return nil, apperror.BadRequest("BACKUP_STORAGE_TARGET_INVALID", "关联的存储目标不存在", nil)
|
||||
}
|
||||
configMap := map[string]any{}
|
||||
if err := cipher.DecryptJSON(target.ConfigCiphertext, &configMap); err != nil {
|
||||
return nil, apperror.Internal("BACKUP_STORAGE_TARGET_DECRYPT_FAILED", "无法解密存储目标配置", err)
|
||||
}
|
||||
return registry.Create(ctx, target.Type, configMap)
|
||||
}
|
||||
|
||||
// prepareBackupArtifact 按文件后缀依次解密(.enc)与解压(.gz),返回最终可读路径。
|
||||
// logger 可为 nil(此时静默执行)。
|
||||
func prepareBackupArtifact(cipher *codec.ConfigCipher, artifactPath string, logger *backup.ExecutionLogger) (string, error) {
|
||||
current := artifactPath
|
||||
if strings.HasSuffix(strings.ToLower(current), ".enc") {
|
||||
if logger != nil {
|
||||
logger.Infof("检测到加密后缀,开始解密")
|
||||
}
|
||||
decrypted, err := backupcrypto.DecryptFile(cipher.Key(), current)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
current = decrypted
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(current), ".gz") {
|
||||
if logger != nil {
|
||||
logger.Infof("检测到 gzip 压缩,开始解压")
|
||||
}
|
||||
decompressed, err := compress.GunzipFile(current)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
current = decompressed
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(current), ".zst") {
|
||||
if logger != nil {
|
||||
logger.Infof("检测到 zstd 压缩,开始解压")
|
||||
}
|
||||
decompressed, err := compress.UnzstdFile(current)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
current = decompressed
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
// resolveRemoteExecutionNode 返回远程(非本机)节点指针,用于判定任务应下发给
|
||||
// Agent 还是在 Master 本地执行。clusterEnabled 通常为「该服务是否注入了 Agent
|
||||
// 下发能力」。本机 / 未启用集群 / nodeID=0 / 未找到时返回 nil(走本地执行)。
|
||||
func resolveRemoteExecutionNode(ctx context.Context, nodeRepo repository.NodeRepository, clusterEnabled bool, nodeID uint) *model.Node {
|
||||
if nodeRepo == nil || !clusterEnabled || nodeID == 0 {
|
||||
return nil
|
||||
}
|
||||
node, err := nodeRepo.FindByID(ctx, nodeID)
|
||||
if err != nil || node == nil || node.IsLocal {
|
||||
return nil
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// validateCrossNodeLocalDisk 跨节点 local_disk 保护:若备份记录归属某远程节点,
|
||||
// 且其存储目标是 local_disk(数据位于该节点本地磁盘),Master 无法跨节点访问,
|
||||
// 直接返回错误。errCode/opName 由各服务定制,以给出贴合场景的提示文案。
|
||||
func validateCrossNodeLocalDisk(ctx context.Context, nodeRepo repository.NodeRepository, targets repository.StorageTargetRepository, record *model.BackupRecord, errCode, opName string) error {
|
||||
if record == nil || record.NodeID == 0 || nodeRepo == nil {
|
||||
return nil
|
||||
}
|
||||
node, err := nodeRepo.FindByID(ctx, record.NodeID)
|
||||
if err != nil || node == nil || node.IsLocal {
|
||||
return nil
|
||||
}
|
||||
target, err := targets.FindByID(ctx, record.StorageTargetID)
|
||||
if err != nil || target == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(target.Type, "local_disk") {
|
||||
return apperror.BadRequest(errCode,
|
||||
fmt.Sprintf("备份位于节点 %s 的本地磁盘(local_disk),Master 无法跨节点%s。", node.Name, opName),
|
||||
nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildBackupTaskSpec 由备份任务构建执行规格:解析排除规则/源路径、解密 DB 密码、
|
||||
// 套用 ExtraConfig(SAP HANA 等类型特有字段)。被备份执行与恢复服务共享。
|
||||
func buildBackupTaskSpec(cipher *codec.ConfigCipher, task *model.BackupTask, startedAt time.Time, tempDir string) (backup.TaskSpec, error) {
|
||||
excludePatterns := []string{}
|
||||
if strings.TrimSpace(task.ExcludePatterns) != "" {
|
||||
if err := json.Unmarshal([]byte(task.ExcludePatterns), &excludePatterns); err != nil {
|
||||
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析排除规则", err)
|
||||
}
|
||||
}
|
||||
password := ""
|
||||
if strings.TrimSpace(task.DBPasswordCiphertext) != "" {
|
||||
plain, err := cipher.Decrypt(task.DBPasswordCiphertext)
|
||||
if err != nil {
|
||||
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECRYPT_FAILED", "无法解密数据库密码", err)
|
||||
}
|
||||
password = string(plain)
|
||||
}
|
||||
sourcePaths := []string{}
|
||||
if strings.TrimSpace(task.SourcePaths) != "" {
|
||||
if err := json.Unmarshal([]byte(task.SourcePaths), &sourcePaths); err != nil {
|
||||
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析源路径配置", err)
|
||||
}
|
||||
}
|
||||
dbSpec := backup.DatabaseSpec{
|
||||
Host: task.DBHost,
|
||||
Port: task.DBPort,
|
||||
User: task.DBUser,
|
||||
Password: password,
|
||||
Names: []string{task.DBName},
|
||||
Path: task.DBPath,
|
||||
}
|
||||
// 解析 ExtraConfig 填充类型特有字段(目前主要用于 SAP HANA)
|
||||
if strings.TrimSpace(task.ExtraConfig) != "" {
|
||||
extra := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(task.ExtraConfig), &extra); err != nil {
|
||||
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析扩展配置", err)
|
||||
}
|
||||
applyHANAExtraConfig(&dbSpec, extra)
|
||||
}
|
||||
return backup.TaskSpec{
|
||||
ID: task.ID,
|
||||
Name: task.Name,
|
||||
Type: task.Type,
|
||||
SourcePath: task.SourcePath,
|
||||
SourcePaths: sourcePaths,
|
||||
ExcludePatterns: excludePatterns,
|
||||
StorageTargetID: task.StorageTargetID,
|
||||
Compression: task.Compression,
|
||||
Encrypt: task.Encrypt,
|
||||
RetentionDays: task.RetentionDays,
|
||||
MaxBackups: task.MaxBackups,
|
||||
StartedAt: startedAt,
|
||||
TempDir: tempDir,
|
||||
Database: dbSpec,
|
||||
}, nil
|
||||
}
|
||||
59
server/internal/service/execution_helpers_test.go
Normal file
59
server/internal/service/execution_helpers_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFileSHA256(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "f.bin")
|
||||
if err := os.WriteFile(p, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// echo -n hello | sha256sum
|
||||
const want = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||||
got, err := fileSHA256(p)
|
||||
if err != nil {
|
||||
t.Fatalf("fileSHA256: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("fileSHA256 = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyArtifactChecksum(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "artifact.tar.gz")
|
||||
if err := os.WriteFile(p, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const sum = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
|
||||
|
||||
t.Run("empty expected skips (backward compat)", func(t *testing.T) {
|
||||
if err := verifyArtifactChecksum(p, ""); err != nil {
|
||||
t.Fatalf("empty expected should skip, got %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("matching checksum passes (case-insensitive)", func(t *testing.T) {
|
||||
if err := verifyArtifactChecksum(p, strings.ToUpper(sum)); err != nil {
|
||||
t.Fatalf("matching checksum should pass, got %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("mismatch is rejected", func(t *testing.T) {
|
||||
err := verifyArtifactChecksum(p, "deadbeef")
|
||||
if err == nil {
|
||||
t.Fatal("mismatch should error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "完整性校验失败") {
|
||||
t.Fatalf("unexpected error message: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("missing file errors", func(t *testing.T) {
|
||||
if err := verifyArtifactChecksum(filepath.Join(dir, "nope"), sum); err == nil {
|
||||
t.Fatal("missing file should error")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -171,12 +171,14 @@ func (s *NodeService) loadQueueSummaries(ctx context.Context) map[uint]repositor
|
||||
}
|
||||
|
||||
func (s *NodeService) toNodeSummary(node *model.Node, queue repository.AgentCommandQueueSummary) NodeSummary {
|
||||
// 以 LastSeen 实时推导状态,避免读到后台监控尚未刷新的过期 "online"。
|
||||
effStatus := node.EffectiveStatus(time.Now().UTC())
|
||||
summary := NodeSummary{
|
||||
ID: node.ID,
|
||||
Name: node.Name,
|
||||
Hostname: node.Hostname,
|
||||
IPAddress: node.IPAddress,
|
||||
Status: node.Status,
|
||||
Status: effStatus,
|
||||
IsLocal: node.IsLocal,
|
||||
OS: node.OS,
|
||||
Arch: node.Arch,
|
||||
@@ -195,7 +197,7 @@ func (s *NodeService) toNodeSummary(node *model.Node, queue repository.AgentComm
|
||||
},
|
||||
RunningTasks: queue.Running,
|
||||
LastError: queue.LastError,
|
||||
Health: nodeHealth(node, queue),
|
||||
Health: nodeHealth(effStatus, queue),
|
||||
}
|
||||
if queue.OldestActiveAt != nil {
|
||||
summary.Queue.OldestActiveAgeS = int(time.Since(*queue.OldestActiveAt).Seconds())
|
||||
@@ -203,8 +205,8 @@ func (s *NodeService) toNodeSummary(node *model.Node, queue repository.AgentComm
|
||||
return summary
|
||||
}
|
||||
|
||||
func nodeHealth(node *model.Node, queue repository.AgentCommandQueueSummary) string {
|
||||
if node.Status != model.NodeStatusOnline {
|
||||
func nodeHealth(status string, queue repository.AgentCommandQueueSummary) string {
|
||||
if status != model.NodeStatusOnline {
|
||||
return "offline"
|
||||
}
|
||||
if queue.Timeouts > 0 || strings.TrimSpace(queue.LastError) != "" {
|
||||
@@ -303,9 +305,9 @@ func (s *NodeService) ListDirectory(ctx context.Context, nodeID uint, path strin
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// OfflineThreshold 节点被判定为离线的心跳超时阈值。
|
||||
// Agent 默认 15s 心跳一次;45s 未见视为离线,预留 3 次重试空间。
|
||||
const OfflineThreshold = 45 * time.Second
|
||||
// OfflineThreshold 节点被判定为离线的心跳超时阈值,与 model.EffectiveStatus 共用同一阈值,
|
||||
// 保证「后台监控持久化的 offline」与「读路径实时推导的 offline」判定一致。
|
||||
const OfflineThreshold = model.OfflineGracePeriod
|
||||
|
||||
// StartOfflineMonitor 启动后台 goroutine,定期把超时未心跳的节点标记为离线。
|
||||
// 传入的 ctx 被取消后退出。
|
||||
|
||||
@@ -82,22 +82,22 @@ func (s *ReplicationService) SetEventDispatcher(dispatcher EventDispatcher) {
|
||||
|
||||
// ReplicationRecordSummary 列表项。
|
||||
type ReplicationRecordSummary struct {
|
||||
ID uint `json:"id"`
|
||||
BackupRecordID uint `json:"backupRecordId"`
|
||||
TaskID uint `json:"taskId"`
|
||||
SourceTargetID uint `json:"sourceTargetId"`
|
||||
SourceTargetName string `json:"sourceTargetName"`
|
||||
DestTargetID uint `json:"destTargetId"`
|
||||
DestTargetName string `json:"destTargetName"`
|
||||
Status string `json:"status"`
|
||||
StoragePath string `json:"storagePath"`
|
||||
FileSize int64 `json:"fileSize"`
|
||||
Checksum string `json:"checksum"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
DurationSeconds int `json:"durationSeconds"`
|
||||
TriggeredBy string `json:"triggeredBy"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
ID uint `json:"id"`
|
||||
BackupRecordID uint `json:"backupRecordId"`
|
||||
TaskID uint `json:"taskId"`
|
||||
SourceTargetID uint `json:"sourceTargetId"`
|
||||
SourceTargetName string `json:"sourceTargetName"`
|
||||
DestTargetID uint `json:"destTargetId"`
|
||||
DestTargetName string `json:"destTargetName"`
|
||||
Status string `json:"status"`
|
||||
StoragePath string `json:"storagePath"`
|
||||
FileSize int64 `json:"fileSize"`
|
||||
Checksum string `json:"checksum"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
DurationSeconds int `json:"durationSeconds"`
|
||||
TriggeredBy string `json:"triggeredBy"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
CompletedAt *time.Time `json:"completedAt,omitempty"`
|
||||
}
|
||||
|
||||
type ReplicationRecordListInput struct {
|
||||
@@ -262,39 +262,13 @@ func (s *ReplicationService) executeReplication(ctx context.Context, repID uint)
|
||||
}
|
||||
|
||||
func (s *ReplicationService) resolveProvider(ctx context.Context, targetID uint) (storage.StorageProvider, error) {
|
||||
target, err := s.targets.FindByID(ctx, targetID)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("STORAGE_TARGET_GET_FAILED", "无法获取存储目标", err)
|
||||
}
|
||||
if target == nil {
|
||||
return nil, apperror.BadRequest("STORAGE_TARGET_INVALID", "存储目标不存在", nil)
|
||||
}
|
||||
configMap := map[string]any{}
|
||||
if err := s.cipher.DecryptJSON(target.ConfigCiphertext, &configMap); err != nil {
|
||||
return nil, apperror.Internal("STORAGE_TARGET_DECRYPT_FAILED", "无法解密存储配置", err)
|
||||
}
|
||||
return s.storageRegistry.Create(ctx, target.Type, configMap)
|
||||
return resolveStorageProvider(ctx, s.targets, s.storageRegistry, s.cipher, targetID)
|
||||
}
|
||||
|
||||
// validateClusterAccessible 拒绝跨节点 local_disk 源(Master 无法拉取)
|
||||
// validateClusterAccessible 拒绝跨节点 local_disk 源(Master 无法拉取)。
|
||||
func (s *ReplicationService) validateClusterAccessible(ctx context.Context, record *model.BackupRecord) error {
|
||||
if record == nil || record.NodeID == 0 || s.nodeRepo == nil {
|
||||
return nil
|
||||
}
|
||||
node, err := s.nodeRepo.FindByID(ctx, record.NodeID)
|
||||
if err != nil || node == nil || node.IsLocal {
|
||||
return nil
|
||||
}
|
||||
target, err := s.targets.FindByID(ctx, record.StorageTargetID)
|
||||
if err != nil || target == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(target.Type, "local_disk") {
|
||||
return apperror.BadRequest("REPLICATION_CROSS_NODE_LOCAL_DISK",
|
||||
fmt.Sprintf("备份位于节点 %s 的本地磁盘(local_disk),Master 无法跨节点复制。请改用云存储作为主备份。", node.Name),
|
||||
nil)
|
||||
}
|
||||
return nil
|
||||
return validateCrossNodeLocalDisk(ctx, s.nodeRepo, s.targets, record,
|
||||
"REPLICATION_CROSS_NODE_LOCAL_DISK", "复制。请改用云存储作为主备份")
|
||||
}
|
||||
|
||||
func (s *ReplicationService) dispatchFailed(ctx context.Context, rep *model.ReplicationRecord, message string) {
|
||||
@@ -304,12 +278,12 @@ func (s *ReplicationService) dispatchFailed(ctx context.Context, rep *model.Repl
|
||||
title := "BackupX 备份复制失败"
|
||||
body := fmt.Sprintf("备份记录:#%d\n源 → 目标:#%d → #%d\n错误:%s", rep.BackupRecordID, rep.SourceTargetID, rep.DestTargetID, message)
|
||||
fields := map[string]any{
|
||||
"replicationId": rep.ID,
|
||||
"backupRecordId": rep.BackupRecordID,
|
||||
"taskId": rep.TaskID,
|
||||
"sourceTargetId": rep.SourceTargetID,
|
||||
"destTargetId": rep.DestTargetID,
|
||||
"error": message,
|
||||
"replicationId": rep.ID,
|
||||
"backupRecordId": rep.BackupRecordID,
|
||||
"taskId": rep.TaskID,
|
||||
"sourceTargetId": rep.SourceTargetID,
|
||||
"destTargetId": rep.DestTargetID,
|
||||
"error": message,
|
||||
}
|
||||
_ = s.eventDispatcher.DispatchEvent(ctx, model.NotificationEventReplicationFailed, title, body, fields)
|
||||
}
|
||||
|
||||
154
server/internal/service/replication_service_test.go
Normal file
154
server/internal/service/replication_service_test.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/backup"
|
||||
"backupx/server/internal/config"
|
||||
"backupx/server/internal/database"
|
||||
"backupx/server/internal/logger"
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/storage"
|
||||
"backupx/server/internal/storage/codec"
|
||||
storageRclone "backupx/server/internal/storage/rclone"
|
||||
)
|
||||
|
||||
type replicationTestHarness struct {
|
||||
repl *ReplicationService
|
||||
execution *BackupExecutionService
|
||||
records repository.BackupRecordRepository
|
||||
destDir string
|
||||
srcDir string
|
||||
}
|
||||
|
||||
func newReplicationTestHarness(t *testing.T) *replicationTestHarness {
|
||||
t.Helper()
|
||||
baseDir := t.TempDir()
|
||||
sourceData := filepath.Join(baseDir, "data")
|
||||
srcStore := filepath.Join(baseDir, "src-store")
|
||||
destStore := filepath.Join(baseDir, "dest-store")
|
||||
if err := os.MkdirAll(sourceData, 0o755); err != nil {
|
||||
t.Fatalf("mkdir data: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceData, "index.html"), []byte("hello-replicate"), 0o644); err != nil {
|
||||
t.Fatalf("write data: %v", err)
|
||||
}
|
||||
log, err := logger.New(config.LogConfig{Level: "error"})
|
||||
if err != nil {
|
||||
t.Fatalf("logger.New: %v", err)
|
||||
}
|
||||
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(baseDir, "backupx.db")}, log)
|
||||
if err != nil {
|
||||
t.Fatalf("database.Open: %v", err)
|
||||
}
|
||||
cipher := codec.NewConfigCipher("replicate-secret")
|
||||
targets := repository.NewStorageTargetRepository(db)
|
||||
tasks := repository.NewBackupTaskRepository(db)
|
||||
records := repository.NewBackupRecordRepository(db)
|
||||
replications := repository.NewReplicationRecordRepository(db)
|
||||
nodes := repository.NewNodeRepository(db)
|
||||
|
||||
mkTarget := func(name, basePath string) {
|
||||
cfg, err := cipher.EncryptJSON(map[string]any{"basePath": basePath})
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptJSON: %v", err)
|
||||
}
|
||||
if err := targets.Create(context.Background(), &model.StorageTarget{Name: name, Type: string(storage.ProviderTypeLocalDisk), Enabled: true, ConfigCiphertext: cfg, ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil {
|
||||
t.Fatalf("create target %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
mkTarget("src", srcStore) // ID 1
|
||||
mkTarget("dest", destStore) // ID 2
|
||||
|
||||
task := &model.BackupTask{Name: "repl-test", Type: "file", Enabled: true, SourcePath: sourceData, StorageTargetID: 1, NodeID: 0, RetentionDays: 30, Compression: "gzip", MaxBackups: 10, LastStatus: "idle"}
|
||||
if err := tasks.Create(context.Background(), task); err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
|
||||
logHub := backup.NewLogHub()
|
||||
runnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil))
|
||||
storageRegistry := storage.NewRegistry(storageRclone.NewLocalDiskFactory())
|
||||
execution := NewBackupExecutionService(tasks, records, targets, storageRegistry, runnerRegistry, logHub, nil, cipher, nil, baseDir, 2, 10, "")
|
||||
repl := NewReplicationService(replications, records, targets, nodes, storageRegistry, cipher, baseDir, 2)
|
||||
|
||||
return &replicationTestHarness{repl: repl, execution: execution, records: records, destDir: destStore, srcDir: srcStore}
|
||||
}
|
||||
|
||||
func countFiles(t *testing.T, dir string) int {
|
||||
t.Helper()
|
||||
n := 0
|
||||
_ = filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
|
||||
if err == nil && info != nil && !info.IsDir() {
|
||||
n++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return n
|
||||
}
|
||||
|
||||
// TestReplicationService_MirrorsToDestTarget 覆盖正常路径:把成功备份从源存储复制到目标存储,
|
||||
// 目标出现对象、源保留(复制非移动),记录终态为 success。
|
||||
func TestReplicationService_MirrorsToDestTarget(t *testing.T) {
|
||||
h := newReplicationTestHarness(t)
|
||||
ctx := context.Background()
|
||||
backupDetail, err := h.execution.RunTaskByIDSync(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("RunTaskByIDSync: %v", err)
|
||||
}
|
||||
if backupDetail.Status != "success" {
|
||||
t.Fatalf("expected backup success, got %s", backupDetail.Status)
|
||||
}
|
||||
if countFiles(t, h.destDir) != 0 {
|
||||
t.Fatalf("dest store should be empty before replication")
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
h.repl.async = func(job func()) {
|
||||
go func() { job(); close(done) }()
|
||||
}
|
||||
summary, err := h.repl.Start(ctx, backupDetail.ID, 2, "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("replication Start: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("replication did not complete in time")
|
||||
}
|
||||
|
||||
final, err := h.repl.Get(ctx, summary.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if final.Status != model.ReplicationStatusSuccess {
|
||||
t.Fatalf("expected replication success, got %s (err=%s)", final.Status, final.ErrorMessage)
|
||||
}
|
||||
if countFiles(t, h.destDir) == 0 {
|
||||
t.Fatal("dest store should contain the replicated object")
|
||||
}
|
||||
if countFiles(t, h.srcDir) == 0 {
|
||||
t.Fatal("source object must remain after replication (copy, not move)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplicationService_RejectsSameTarget 校验:目标与源相同时同步拒绝。
|
||||
func TestReplicationService_RejectsSameTarget(t *testing.T) {
|
||||
h := newReplicationTestHarness(t)
|
||||
ctx := context.Background()
|
||||
backupDetail, err := h.execution.RunTaskByIDSync(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("RunTaskByIDSync: %v", err)
|
||||
}
|
||||
// 备份写到 target 1;以 target 1 作为复制目标应被拒绝。
|
||||
if _, err := h.repl.Start(ctx, backupDetail.ID, 1, "tester"); err == nil {
|
||||
t.Fatal("expected error when dest target equals source")
|
||||
} else if !strings.Contains(err.Error(), "目标存储无效或与源相同") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
199
server/internal/service/report_service.go
Normal file
199
server/internal/service/report_service.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/apperror"
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/repository"
|
||||
)
|
||||
|
||||
// ReportService 生成企业合规报表。区别于 Dashboard 的实时聚合视图,
|
||||
// 本服务产出「按任务、可导出、可归档」的时间点合规证据(供 SOC2 / ISO27001 等审计)。
|
||||
type ReportService struct {
|
||||
tasks repository.BackupTaskRepository
|
||||
records repository.BackupRecordRepository
|
||||
}
|
||||
|
||||
func NewReportService(tasks repository.BackupTaskRepository, records repository.BackupRecordRepository) *ReportService {
|
||||
return &ReportService{tasks: tasks, records: records}
|
||||
}
|
||||
|
||||
// ComplianceTaskRow 单个备份任务的合规明细行。
|
||||
type ComplianceTaskRow struct {
|
||||
TaskID uint `json:"taskId"`
|
||||
TaskName string `json:"taskName"`
|
||||
Type string `json:"type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
NodeName string `json:"nodeName"`
|
||||
CronExpr string `json:"cronExpr"`
|
||||
Encrypted bool `json:"encrypted"`
|
||||
RetentionDays int `json:"retentionDays"`
|
||||
SLAHoursRPO int `json:"slaHoursRpo"`
|
||||
TotalRuns int `json:"totalRuns"`
|
||||
Successes int `json:"successes"`
|
||||
Failures int `json:"failures"`
|
||||
SuccessRate float64 `json:"successRate"`
|
||||
LastStatus string `json:"lastStatus"`
|
||||
LastRunAt *time.Time `json:"lastRunAt,omitempty"`
|
||||
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
|
||||
ProtectedBytes int64 `json:"protectedBytes"`
|
||||
Compliant bool `json:"compliant"`
|
||||
Risk string `json:"risk"` // ok | at_risk | not_applicable
|
||||
}
|
||||
|
||||
// ComplianceSummary 报表汇总。
|
||||
type ComplianceSummary struct {
|
||||
TotalTasks int `json:"totalTasks"`
|
||||
EnabledTasks int `json:"enabledTasks"`
|
||||
CompliantTasks int `json:"compliantTasks"`
|
||||
AtRiskTasks int `json:"atRiskTasks"`
|
||||
EncryptedTasks int `json:"encryptedTasks"`
|
||||
OverallSuccessRate float64 `json:"overallSuccessRate"`
|
||||
TotalProtectedB int64 `json:"totalProtectedBytes"`
|
||||
}
|
||||
|
||||
// ComplianceReport 完整合规报表。
|
||||
type ComplianceReport struct {
|
||||
GeneratedAt time.Time `json:"generatedAt"`
|
||||
RangeDays int `json:"rangeDays"`
|
||||
Summary ComplianceSummary `json:"summary"`
|
||||
Tasks []ComplianceTaskRow `json:"tasks"`
|
||||
}
|
||||
|
||||
const (
|
||||
reportMinDays = 1
|
||||
reportMaxDays = 365
|
||||
)
|
||||
|
||||
// ComplianceReport 生成最近 days 天的合规报表。
|
||||
func (s *ReportService) ComplianceReport(ctx context.Context, days int) (*ComplianceReport, error) {
|
||||
if days < reportMinDays || days > reportMaxDays {
|
||||
return nil, apperror.BadRequest("REPORT_RANGE_INVALID",
|
||||
"统计天数需在 1-365 之间", nil)
|
||||
}
|
||||
tasks, err := s.tasks.List(ctx, repository.BackupTaskListOptions{})
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("REPORT_TASKS_FAILED", "无法获取任务列表", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
since := now.AddDate(0, 0, -days)
|
||||
|
||||
report := &ComplianceReport{GeneratedAt: now, RangeDays: days, Tasks: make([]ComplianceTaskRow, 0, len(tasks))}
|
||||
var totalRuns, totalSuccess int
|
||||
for i := range tasks {
|
||||
task := tasks[i]
|
||||
records, err := s.records.ListByTask(ctx, task.ID)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("REPORT_RECORDS_FAILED", "无法获取任务备份记录", err)
|
||||
}
|
||||
row := s.buildTaskRow(&task, records, now, since)
|
||||
report.Tasks = append(report.Tasks, row)
|
||||
|
||||
report.Summary.TotalTasks++
|
||||
if task.Enabled {
|
||||
report.Summary.EnabledTasks++
|
||||
}
|
||||
if task.Encrypt {
|
||||
report.Summary.EncryptedTasks++
|
||||
}
|
||||
switch row.Risk {
|
||||
case "ok":
|
||||
report.Summary.CompliantTasks++
|
||||
case "at_risk":
|
||||
report.Summary.AtRiskTasks++
|
||||
}
|
||||
report.Summary.TotalProtectedB += row.ProtectedBytes
|
||||
totalRuns += row.TotalRuns
|
||||
totalSuccess += row.Successes
|
||||
}
|
||||
if totalRuns > 0 {
|
||||
report.Summary.OverallSuccessRate = roundRate(float64(totalSuccess) / float64(totalRuns))
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func (s *ReportService) buildTaskRow(task *model.BackupTask, records []model.BackupRecord, now, since time.Time) ComplianceTaskRow {
|
||||
row := ComplianceTaskRow{
|
||||
TaskID: task.ID,
|
||||
TaskName: task.Name,
|
||||
Type: task.Type,
|
||||
Enabled: task.Enabled,
|
||||
NodeName: task.Node.Name,
|
||||
CronExpr: task.CronExpr,
|
||||
Encrypted: task.Encrypt,
|
||||
RetentionDays: task.RetentionDays,
|
||||
SLAHoursRPO: task.SLAHoursRPO,
|
||||
LastStatus: "none",
|
||||
}
|
||||
var lastRun, lastSuccess *model.BackupRecord
|
||||
for i := range records {
|
||||
rec := &records[i]
|
||||
// 统计窗口内的运行情况(按 StartedAt 落在 [since, now])。
|
||||
if !rec.StartedAt.Before(since) {
|
||||
row.TotalRuns++
|
||||
switch rec.Status {
|
||||
case model.BackupRecordStatusSuccess:
|
||||
row.Successes++
|
||||
case model.BackupRecordStatusFailed:
|
||||
row.Failures++
|
||||
}
|
||||
}
|
||||
// 最近一次运行 / 最近一次成功(不限窗口,反映当前保护态)。
|
||||
if lastRun == nil || rec.StartedAt.After(lastRun.StartedAt) {
|
||||
lastRun = rec
|
||||
}
|
||||
if rec.Status == model.BackupRecordStatusSuccess {
|
||||
if lastSuccess == nil || rec.StartedAt.After(lastSuccess.StartedAt) {
|
||||
lastSuccess = rec
|
||||
}
|
||||
}
|
||||
}
|
||||
if row.TotalRuns > 0 {
|
||||
row.SuccessRate = roundRate(float64(row.Successes) / float64(row.TotalRuns))
|
||||
}
|
||||
if lastRun != nil {
|
||||
row.LastStatus = lastRun.Status
|
||||
started := lastRun.StartedAt
|
||||
row.LastRunAt = &started
|
||||
}
|
||||
if lastSuccess != nil {
|
||||
when := lastSuccess.StartedAt
|
||||
if lastSuccess.CompletedAt != nil {
|
||||
when = *lastSuccess.CompletedAt
|
||||
}
|
||||
row.LastSuccessAt = &when
|
||||
row.ProtectedBytes = lastSuccess.FileSize
|
||||
}
|
||||
row.Compliant, row.Risk = evaluateCompliance(task, lastSuccess, now)
|
||||
return row
|
||||
}
|
||||
|
||||
// evaluateCompliance 判定任务合规性:
|
||||
// - 禁用任务:not_applicable(不计入合规/风险)。
|
||||
// - 从未成功:at_risk。
|
||||
// - 配置了 SLA(RPO 小时):最近成功在 RPO 内为 ok,否则 at_risk。
|
||||
// - 未配置 SLA:只要存在成功备份即视为 ok。
|
||||
func evaluateCompliance(task *model.BackupTask, lastSuccess *model.BackupRecord, now time.Time) (bool, string) {
|
||||
if !task.Enabled {
|
||||
return false, "not_applicable"
|
||||
}
|
||||
if lastSuccess == nil {
|
||||
return false, "at_risk"
|
||||
}
|
||||
if task.SLAHoursRPO > 0 {
|
||||
when := lastSuccess.StartedAt
|
||||
if lastSuccess.CompletedAt != nil {
|
||||
when = *lastSuccess.CompletedAt
|
||||
}
|
||||
if now.Sub(when).Hours() > float64(task.SLAHoursRPO) {
|
||||
return false, "at_risk"
|
||||
}
|
||||
}
|
||||
return true, "ok"
|
||||
}
|
||||
|
||||
func roundRate(v float64) float64 {
|
||||
return float64(int(v*10000+0.5)) / 10000
|
||||
}
|
||||
136
server/internal/service/report_service_test.go
Normal file
136
server/internal/service/report_service_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"backupx/server/internal/backup"
|
||||
"backupx/server/internal/config"
|
||||
"backupx/server/internal/database"
|
||||
"backupx/server/internal/logger"
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/storage"
|
||||
"backupx/server/internal/storage/codec"
|
||||
storageRclone "backupx/server/internal/storage/rclone"
|
||||
)
|
||||
|
||||
func newReportTestHarness(t *testing.T) (*ReportService, *BackupExecutionService) {
|
||||
t.Helper()
|
||||
baseDir := t.TempDir()
|
||||
sourceDir := filepath.Join(baseDir, "data")
|
||||
storeDir := filepath.Join(baseDir, "store")
|
||||
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceDir, "f.txt"), []byte("report-data"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
log, err := logger.New(config.LogConfig{Level: "error"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(baseDir, "backupx.db")}, log)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cipher := codec.NewConfigCipher("report-secret")
|
||||
targets := repository.NewStorageTargetRepository(db)
|
||||
tasks := repository.NewBackupTaskRepository(db)
|
||||
records := repository.NewBackupRecordRepository(db)
|
||||
cfg, err := cipher.EncryptJSON(map[string]any{"basePath": storeDir})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := targets.Create(context.Background(), &model.StorageTarget{Name: "s", Type: string(storage.ProviderTypeLocalDisk), Enabled: true, ConfigCiphertext: cfg, ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &model.BackupTask{Name: "rep-task", Type: "file", Enabled: true, SourcePath: sourceDir, StorageTargetID: 1, NodeID: 0, RetentionDays: 30, Compression: "gzip", MaxBackups: 10, LastStatus: "idle"}
|
||||
if err := tasks.Create(context.Background(), task); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil))
|
||||
storageRegistry := storage.NewRegistry(storageRclone.NewLocalDiskFactory())
|
||||
execution := NewBackupExecutionService(tasks, records, targets, storageRegistry, runnerRegistry, backup.NewLogHub(), nil, cipher, nil, baseDir, 2, 10, "")
|
||||
return NewReportService(tasks, records), execution
|
||||
}
|
||||
|
||||
func findRow(rows []ComplianceTaskRow, taskID uint) *ComplianceTaskRow {
|
||||
for i := range rows {
|
||||
if rows[i].TaskID == taskID {
|
||||
return &rows[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestComplianceReport_ReflectsBackupOutcome(t *testing.T) {
|
||||
report, execution := newReportTestHarness(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// 备份前:任务启用但从未成功 → at_risk。
|
||||
before, err := report.ComplianceReport(ctx, 30)
|
||||
if err != nil {
|
||||
t.Fatalf("ComplianceReport: %v", err)
|
||||
}
|
||||
row := findRow(before.Tasks, 1)
|
||||
if row == nil {
|
||||
t.Fatal("task row missing before backup")
|
||||
}
|
||||
if row.Risk != "at_risk" || row.Compliant {
|
||||
t.Fatalf("expected at_risk before any success, got risk=%s compliant=%v", row.Risk, row.Compliant)
|
||||
}
|
||||
if before.Summary.AtRiskTasks != 1 || before.Summary.CompliantTasks != 0 {
|
||||
t.Fatalf("unexpected summary before: %+v", before.Summary)
|
||||
}
|
||||
|
||||
// 跑一次成功备份。
|
||||
bd, err := execution.RunTaskByIDSync(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("RunTaskByIDSync: %v", err)
|
||||
}
|
||||
if bd.Status != "success" {
|
||||
t.Fatalf("backup not success: %s", bd.Status)
|
||||
}
|
||||
|
||||
// 备份后:合规、成功率 1.0、保护字节数 > 0。
|
||||
after, err := report.ComplianceReport(ctx, 30)
|
||||
if err != nil {
|
||||
t.Fatalf("ComplianceReport after: %v", err)
|
||||
}
|
||||
row = findRow(after.Tasks, 1)
|
||||
if row == nil {
|
||||
t.Fatal("task row missing after backup")
|
||||
}
|
||||
if !row.Compliant || row.Risk != "ok" {
|
||||
t.Fatalf("expected ok/compliant after success, got risk=%s compliant=%v", row.Risk, row.Compliant)
|
||||
}
|
||||
if row.TotalRuns != 1 || row.Successes != 1 || row.Failures != 0 {
|
||||
t.Fatalf("unexpected counts: runs=%d ok=%d fail=%d", row.TotalRuns, row.Successes, row.Failures)
|
||||
}
|
||||
if row.SuccessRate != 1 {
|
||||
t.Fatalf("expected success rate 1.0, got %v", row.SuccessRate)
|
||||
}
|
||||
if row.LastStatus != "success" || row.LastSuccessAt == nil || row.ProtectedBytes <= 0 {
|
||||
t.Fatalf("unexpected last/protected: status=%s lastSuccess=%v bytes=%d", row.LastStatus, row.LastSuccessAt, row.ProtectedBytes)
|
||||
}
|
||||
if after.Summary.CompliantTasks != 1 || after.Summary.AtRiskTasks != 0 || after.Summary.OverallSuccessRate != 1 {
|
||||
t.Fatalf("unexpected summary after: %+v", after.Summary)
|
||||
}
|
||||
if after.Summary.TotalProtectedB <= 0 {
|
||||
t.Fatalf("expected protected bytes > 0, got %d", after.Summary.TotalProtectedB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComplianceReport_RejectsInvalidRange(t *testing.T) {
|
||||
report, _ := newReportTestHarness(t)
|
||||
ctx := context.Background()
|
||||
if _, err := report.ComplianceReport(ctx, 0); err == nil {
|
||||
t.Fatal("expected error for days=0")
|
||||
}
|
||||
if _, err := report.ComplianceReport(ctx, 9999); err == nil {
|
||||
t.Fatal("expected error for days>365")
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,6 @@ import (
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/storage"
|
||||
"backupx/server/internal/storage/codec"
|
||||
"backupx/server/pkg/compress"
|
||||
backupcrypto "backupx/server/pkg/crypto"
|
||||
)
|
||||
|
||||
// RestoreService 管理恢复记录生命周期并在集群中路由执行。
|
||||
@@ -122,6 +120,13 @@ type RestoreRecordDetail struct {
|
||||
// 若任务绑定远程节点:入队 AgentCommand 后立即返回(状态为 running)
|
||||
// 若本地:异步执行并立即返回。
|
||||
func (s *RestoreService) Start(ctx context.Context, backupRecordID uint, triggeredBy string) (*RestoreRecordDetail, error) {
|
||||
return s.StartSelective(ctx, backupRecordID, nil, "", triggeredBy)
|
||||
}
|
||||
|
||||
// StartSelective 启动恢复。两个可选项均仅适用于本机文件备份:
|
||||
// - selectedPaths 非空时仅恢复选中的文件/目录(及其子项),用于按需(选择性)恢复;
|
||||
// - targetPath 非空时把归档恢复到该绝对目录而非原始源路径父目录(迁移/测试/并排恢复)。
|
||||
func (s *RestoreService) StartSelective(ctx context.Context, backupRecordID uint, selectedPaths []string, targetPath string, triggeredBy string) (*RestoreRecordDetail, error) {
|
||||
record, err := s.records.FindByID(ctx, backupRecordID)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录", err)
|
||||
@@ -139,13 +144,37 @@ func (s *RestoreService) Start(ctx context.Context, backupRecordID uint, trigger
|
||||
if task == nil {
|
||||
return nil, apperror.New(404, "BACKUP_TASK_NOT_FOUND", "关联的备份任务不存在", fmt.Errorf("backup task %d not found", record.TaskID))
|
||||
}
|
||||
if len(selectedPaths) > 0 {
|
||||
if task.Type != model.BackupTaskTypeFile {
|
||||
return nil, apperror.BadRequest("RESTORE_SELECTIVE_UNSUPPORTED", "按需(选择性)恢复仅支持文件类型备份", nil)
|
||||
}
|
||||
if s.resolveRemoteNode(ctx, s.resolveRestoreNodeID(record, task)) != nil {
|
||||
return nil, apperror.BadRequest("RESTORE_SELECTIVE_REMOTE_UNSUPPORTED", "按需恢复当前仅支持本机 Master 执行", nil)
|
||||
}
|
||||
}
|
||||
|
||||
startedAt := s.now()
|
||||
restoreNodeID := s.resolveRestoreNodeID(record, task)
|
||||
|
||||
// 恢复到指定目录:仅文件类型 + 本机执行支持;需为绝对路径。
|
||||
targetPath = strings.TrimSpace(targetPath)
|
||||
if targetPath != "" {
|
||||
if task.Type != model.BackupTaskTypeFile {
|
||||
return nil, apperror.BadRequest("RESTORE_TARGET_UNSUPPORTED", "仅文件类型备份支持恢复到指定目录", nil)
|
||||
}
|
||||
if !filepath.IsAbs(targetPath) {
|
||||
return nil, apperror.BadRequest("RESTORE_TARGET_INVALID", "恢复目录必须是绝对路径", nil)
|
||||
}
|
||||
if s.isRemoteNode(ctx, restoreNodeID) {
|
||||
return nil, apperror.BadRequest("RESTORE_TARGET_REMOTE_UNSUPPORTED", "远程节点恢复暂不支持指定目录,请在该节点本地操作", nil)
|
||||
}
|
||||
}
|
||||
|
||||
restore := &model.RestoreRecord{
|
||||
BackupRecordID: backupRecordID,
|
||||
TaskID: record.TaskID,
|
||||
NodeID: restoreNodeID,
|
||||
TargetPath: targetPath,
|
||||
Status: model.RestoreRecordStatusRunning,
|
||||
StartedAt: startedAt,
|
||||
TriggeredBy: strings.TrimSpace(triggeredBy),
|
||||
@@ -180,7 +209,7 @@ func (s *RestoreService) Start(ctx context.Context, backupRecordID uint, trigger
|
||||
|
||||
// 本地节点:异步执行
|
||||
run := func() {
|
||||
s.executeLocally(context.Background(), restore.ID, task, record)
|
||||
s.executeLocally(context.Background(), restore.ID, task, record, selectedPaths, targetPath)
|
||||
}
|
||||
s.async(run)
|
||||
return s.getDetail(ctx, restore.ID)
|
||||
@@ -203,18 +232,11 @@ func (s *RestoreService) isRemoteNode(ctx context.Context, nodeID uint) bool {
|
||||
|
||||
// resolveRemoteNode 返回远程节点指针(含 Status),用于离线判定。
|
||||
func (s *RestoreService) resolveRemoteNode(ctx context.Context, nodeID uint) *model.Node {
|
||||
if s.nodeRepo == nil || s.dispatcher == nil || nodeID == 0 {
|
||||
return nil
|
||||
}
|
||||
node, err := s.nodeRepo.FindByID(ctx, nodeID)
|
||||
if err != nil || node == nil || node.IsLocal {
|
||||
return nil
|
||||
}
|
||||
return node
|
||||
return resolveRemoteExecutionNode(ctx, s.nodeRepo, s.dispatcher != nil, nodeID)
|
||||
}
|
||||
|
||||
// executeLocally 在 Master 本地执行恢复。
|
||||
func (s *RestoreService) executeLocally(ctx context.Context, restoreID uint, task *model.BackupTask, backupRecord *model.BackupRecord) {
|
||||
func (s *RestoreService) executeLocally(ctx context.Context, restoreID uint, task *model.BackupTask, backupRecord *model.BackupRecord, selectedPaths []string, targetPath string) {
|
||||
s.semaphore <- struct{}{}
|
||||
defer func() { <-s.semaphore }()
|
||||
|
||||
@@ -232,10 +254,27 @@ func (s *RestoreService) executeLocally(ctx context.Context, restoreID uint, tas
|
||||
}()
|
||||
|
||||
logger.Infof("开始在本地执行恢复(备份记录 #%d)", backupRecord.ID)
|
||||
provider, providerErr := s.resolveProvider(ctx, backupRecord.StorageTargetID)
|
||||
if providerErr != nil {
|
||||
errMessage = providerErr.Error()
|
||||
logger.Errorf("创建存储客户端失败:%v", providerErr)
|
||||
|
||||
spec, specErr := s.buildTaskSpec(task, backupRecord.StartedAt)
|
||||
if specErr != nil {
|
||||
errMessage = specErr.Error()
|
||||
logger.Errorf("构建恢复规格失败:%v", specErr)
|
||||
return
|
||||
}
|
||||
if len(selectedPaths) > 0 {
|
||||
spec.SelectedPaths = selectedPaths
|
||||
logger.Infof("按需恢复:仅恢复选中的 %d 个路径", len(selectedPaths))
|
||||
}
|
||||
// 恢复到指定目录(已在 StartSelective 校验为文件类型+绝对路径+本机);
|
||||
// 应用于恢复链中的每个归档(全量铺底与差异覆盖均落到该目录)。
|
||||
if targetPath != "" {
|
||||
spec.RestoreTargetPath = targetPath
|
||||
logger.Infof("恢复到指定目录:%s", targetPath)
|
||||
}
|
||||
runner, runnerErr := s.runnerRegistry.Runner(spec.Type)
|
||||
if runnerErr != nil {
|
||||
errMessage = runnerErr.Error()
|
||||
logger.Errorf("不支持的备份类型:%v", runnerErr)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -252,52 +291,89 @@ func (s *RestoreService) executeLocally(ctx context.Context, restoreID uint, tas
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
fileName := backupRecord.FileName
|
||||
if strings.TrimSpace(fileName) == "" {
|
||||
fileName = filepath.Base(backupRecord.StoragePath)
|
||||
}
|
||||
artifactPath := filepath.Join(tempDir, filepath.Base(fileName))
|
||||
logger.Infof("开始下载备份文件:%s", backupRecord.StoragePath)
|
||||
reader, downloadErr := provider.Download(ctx, backupRecord.StoragePath)
|
||||
if downloadErr != nil {
|
||||
errMessage = downloadErr.Error()
|
||||
logger.Errorf("下载备份文件失败:%v", downloadErr)
|
||||
// 恢复链:全量 → [自身];差异 → [基线全量, 自身],按序应用(全量铺底,差异覆盖并删除)。
|
||||
chain, chainErr := s.buildRestoreChain(ctx, backupRecord)
|
||||
if chainErr != nil {
|
||||
errMessage = chainErr.Error()
|
||||
logger.Errorf("%v", chainErr)
|
||||
return
|
||||
}
|
||||
if writeErr := writeReaderToFile(artifactPath, reader); writeErr != nil {
|
||||
errMessage = writeErr.Error()
|
||||
logger.Errorf("写入恢复文件失败:%v", writeErr)
|
||||
return
|
||||
}
|
||||
preparedPath, prepareErr := s.prepareArtifact(artifactPath, logger)
|
||||
if prepareErr != nil {
|
||||
errMessage = prepareErr.Error()
|
||||
logger.Errorf("准备恢复文件失败:%v", prepareErr)
|
||||
return
|
||||
}
|
||||
|
||||
spec, specErr := s.buildTaskSpec(task, backupRecord.StartedAt)
|
||||
if specErr != nil {
|
||||
errMessage = specErr.Error()
|
||||
logger.Errorf("构建恢复规格失败:%v", specErr)
|
||||
return
|
||||
}
|
||||
runner, runnerErr := s.runnerRegistry.Runner(spec.Type)
|
||||
if runnerErr != nil {
|
||||
errMessage = runnerErr.Error()
|
||||
logger.Errorf("不支持的备份类型:%v", runnerErr)
|
||||
return
|
||||
}
|
||||
logger.Infof("开始执行 %s 恢复", spec.Type)
|
||||
if restoreErr := runner.Restore(ctx, spec, preparedPath, logger); restoreErr != nil {
|
||||
errMessage = restoreErr.Error()
|
||||
logger.Errorf("恢复执行失败:%v", restoreErr)
|
||||
return
|
||||
logger.Infof("开始执行 %s 恢复(恢复链含 %d 个备份)", spec.Type, len(chain))
|
||||
for idx := range chain {
|
||||
rec := chain[idx]
|
||||
if len(chain) > 1 {
|
||||
logger.Infof("恢复链 [%d/%d]:应用备份记录 #%d(%s)", idx+1, len(chain), rec.ID, backupKindLabel(rec.BackupKind))
|
||||
}
|
||||
if err := s.restoreArtifact(ctx, &rec, spec, runner, tempDir, logger); err != nil {
|
||||
errMessage = err.Error()
|
||||
logger.Errorf("恢复执行失败:%v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
status = model.RestoreRecordStatusSuccess
|
||||
logger.Infof("恢复执行成功")
|
||||
}
|
||||
|
||||
// restoreArtifact 下载、完整性校验、解密解压并通过 runner 应用单个备份记录的归档。
|
||||
// 每个记录使用独立子目录,避免恢复链中基线/差异的同名归档相互覆盖。
|
||||
func (s *RestoreService) restoreArtifact(ctx context.Context, record *model.BackupRecord, spec backup.TaskSpec, runner backup.BackupRunner, parentTempDir string, logger *backup.ExecutionLogger) error {
|
||||
provider, err := s.resolveProvider(ctx, record.StorageTargetID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建存储客户端失败:%w", err)
|
||||
}
|
||||
recDir, err := os.MkdirTemp(parentTempDir, fmt.Sprintf("rec-%d-*", record.ID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建恢复子目录失败:%w", err)
|
||||
}
|
||||
fileName := record.FileName
|
||||
if strings.TrimSpace(fileName) == "" {
|
||||
fileName = filepath.Base(record.StoragePath)
|
||||
}
|
||||
artifactPath := filepath.Join(recDir, filepath.Base(fileName))
|
||||
logger.Infof("开始下载备份文件:%s", record.StoragePath)
|
||||
reader, err := provider.Download(ctx, record.StoragePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("下载备份文件失败:%w", err)
|
||||
}
|
||||
if err := writeReaderToFile(artifactPath, reader); err != nil {
|
||||
return fmt.Errorf("写入恢复文件失败:%w", err)
|
||||
}
|
||||
// 完整性校验:解密/解压前比对 SHA-256;早期无 checksum 的备份跳过(向后兼容)。
|
||||
if record.Checksum != "" {
|
||||
if err := verifyArtifactChecksum(artifactPath, record.Checksum); err != nil {
|
||||
return fmt.Errorf("完整性校验失败:%w", err)
|
||||
}
|
||||
}
|
||||
preparedPath, err := s.prepareArtifact(artifactPath, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("准备恢复文件失败:%w", err)
|
||||
}
|
||||
return runner.Restore(ctx, spec, preparedPath, logger)
|
||||
}
|
||||
|
||||
// buildRestoreChain 返回恢复某记录所需、按应用顺序排列的记录链:
|
||||
// 全量 → [自身];差异 → [基线全量, 自身]。基线缺失/不可用时报错,杜绝残缺恢复。
|
||||
func (s *RestoreService) buildRestoreChain(ctx context.Context, record *model.BackupRecord) ([]model.BackupRecord, error) {
|
||||
if record.BackupKind != model.BackupKindDifferential || record.BaseRecordID == 0 {
|
||||
return []model.BackupRecord{*record}, nil
|
||||
}
|
||||
base, err := s.records.FindByID(ctx, record.BaseRecordID)
|
||||
if err != nil || base == nil {
|
||||
return nil, fmt.Errorf("差异备份的基线全量 #%d 不存在,无法恢复", record.BaseRecordID)
|
||||
}
|
||||
if base.Status != model.BackupRecordStatusSuccess || strings.TrimSpace(base.StoragePath) == "" {
|
||||
return nil, fmt.Errorf("差异备份的基线全量 #%d 不可用,无法恢复", record.BaseRecordID)
|
||||
}
|
||||
return []model.BackupRecord{*base, *record}, nil
|
||||
}
|
||||
|
||||
func backupKindLabel(kind string) string {
|
||||
if kind == model.BackupKindDifferential {
|
||||
return "差异"
|
||||
}
|
||||
return "全量"
|
||||
}
|
||||
|
||||
// dispatchRestoreEvent 按终态向事件总线派发 restore_success 或 restore_failed。
|
||||
// eventDispatcher 未注入时静默忽略,保持向后兼容。
|
||||
func (s *RestoreService) dispatchRestoreEvent(ctx context.Context, restoreID uint, status, errMessage string, task *model.BackupTask) {
|
||||
@@ -335,97 +411,19 @@ func (s *RestoreService) dispatchRestoreEvent(ctx context.Context, restoreID uin
|
||||
_ = s.eventDispatcher.DispatchEvent(ctx, eventType, title, body, fields)
|
||||
}
|
||||
|
||||
// resolveProvider 复用 BackupExecutionService 的逻辑(解密 → 创建 provider)。
|
||||
// resolveProvider 解密存储目标配置并创建 provider(共享实现)。
|
||||
func (s *RestoreService) resolveProvider(ctx context.Context, targetID uint) (storage.StorageProvider, error) {
|
||||
target, err := s.targets.FindByID(ctx, targetID)
|
||||
if err != nil {
|
||||
return nil, apperror.Internal("BACKUP_STORAGE_TARGET_GET_FAILED", "无法获取存储目标详情", err)
|
||||
}
|
||||
if target == nil {
|
||||
return nil, apperror.BadRequest("BACKUP_STORAGE_TARGET_INVALID", "关联的存储目标不存在", nil)
|
||||
}
|
||||
configMap := map[string]any{}
|
||||
if err := s.cipher.DecryptJSON(target.ConfigCiphertext, &configMap); err != nil {
|
||||
return nil, apperror.Internal("BACKUP_STORAGE_TARGET_DECRYPT_FAILED", "无法解密存储目标配置", err)
|
||||
}
|
||||
return s.storageRegistry.Create(ctx, target.Type, configMap)
|
||||
return resolveStorageProvider(ctx, s.targets, s.storageRegistry, s.cipher, targetID)
|
||||
}
|
||||
|
||||
// prepareArtifact 根据文件后缀依次解密、解压。
|
||||
// prepareArtifact 根据文件后缀依次解密、解压(共享实现)。
|
||||
func (s *RestoreService) prepareArtifact(artifactPath string, logger *backup.ExecutionLogger) (string, error) {
|
||||
currentPath := artifactPath
|
||||
if strings.HasSuffix(strings.ToLower(currentPath), ".enc") {
|
||||
logger.Infof("检测到加密后缀,开始解密")
|
||||
decrypted, err := backupcrypto.DecryptFile(s.cipher.Key(), currentPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
currentPath = decrypted
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(currentPath), ".gz") {
|
||||
logger.Infof("检测到 gzip 压缩,开始解压")
|
||||
decompressed, err := compress.GunzipFile(currentPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
currentPath = decompressed
|
||||
}
|
||||
return currentPath, nil
|
||||
return prepareBackupArtifact(s.cipher, artifactPath, logger)
|
||||
}
|
||||
|
||||
// buildTaskSpec 复刻 BackupExecutionService.buildTaskSpec 的核心逻辑。
|
||||
// buildTaskSpec 由任务构建执行规格(共享实现)。
|
||||
func (s *RestoreService) buildTaskSpec(task *model.BackupTask, startedAt time.Time) (backup.TaskSpec, error) {
|
||||
excludePatterns := []string{}
|
||||
if strings.TrimSpace(task.ExcludePatterns) != "" {
|
||||
if err := json.Unmarshal([]byte(task.ExcludePatterns), &excludePatterns); err != nil {
|
||||
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析排除规则", err)
|
||||
}
|
||||
}
|
||||
password := ""
|
||||
if strings.TrimSpace(task.DBPasswordCiphertext) != "" {
|
||||
plain, err := s.cipher.Decrypt(task.DBPasswordCiphertext)
|
||||
if err != nil {
|
||||
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECRYPT_FAILED", "无法解密数据库密码", err)
|
||||
}
|
||||
password = string(plain)
|
||||
}
|
||||
sourcePaths := []string{}
|
||||
if strings.TrimSpace(task.SourcePaths) != "" {
|
||||
if err := json.Unmarshal([]byte(task.SourcePaths), &sourcePaths); err != nil {
|
||||
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析源路径配置", err)
|
||||
}
|
||||
}
|
||||
dbSpec := backup.DatabaseSpec{
|
||||
Host: task.DBHost,
|
||||
Port: task.DBPort,
|
||||
User: task.DBUser,
|
||||
Password: password,
|
||||
Names: []string{task.DBName},
|
||||
Path: task.DBPath,
|
||||
}
|
||||
if strings.TrimSpace(task.ExtraConfig) != "" {
|
||||
extra := map[string]any{}
|
||||
if err := json.Unmarshal([]byte(task.ExtraConfig), &extra); err != nil {
|
||||
return backup.TaskSpec{}, apperror.Internal("BACKUP_TASK_DECODE_FAILED", "无法解析扩展配置", err)
|
||||
}
|
||||
applyHANAExtraConfig(&dbSpec, extra)
|
||||
}
|
||||
return backup.TaskSpec{
|
||||
ID: task.ID,
|
||||
Name: task.Name,
|
||||
Type: task.Type,
|
||||
SourcePath: task.SourcePath,
|
||||
SourcePaths: sourcePaths,
|
||||
ExcludePatterns: excludePatterns,
|
||||
StorageTargetID: task.StorageTargetID,
|
||||
Compression: task.Compression,
|
||||
Encrypt: task.Encrypt,
|
||||
RetentionDays: task.RetentionDays,
|
||||
MaxBackups: task.MaxBackups,
|
||||
StartedAt: startedAt,
|
||||
TempDir: s.tempDir,
|
||||
Database: dbSpec,
|
||||
}, nil
|
||||
return buildBackupTaskSpec(s.cipher, task, startedAt, s.tempDir)
|
||||
}
|
||||
|
||||
// finalize 只更新状态和错误信息,不写 log(用于失败的 dispatch 路径)。
|
||||
@@ -539,6 +537,8 @@ type AgentRestoreSpec struct {
|
||||
Storage AgentStorageTargetConfig `json:"storage"`
|
||||
StoragePath string `json:"storagePath"`
|
||||
FileName string `json:"fileName"`
|
||||
// Checksum 源备份对象的 SHA-256(小写 hex);Agent 在还原前据此校验完整性。
|
||||
Checksum string `json:"checksum,omitempty"`
|
||||
}
|
||||
|
||||
// AgentRestoreUpdate Agent 回传的增量更新。
|
||||
@@ -625,6 +625,7 @@ func (s *RestoreService) GetAgentRestoreSpec(ctx context.Context, node *model.No
|
||||
},
|
||||
StoragePath: backupRecord.StoragePath,
|
||||
FileName: backupRecord.FileName,
|
||||
Checksum: backupRecord.Checksum,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -197,6 +198,132 @@ func TestRestoreServiceStart_LocalNodeExecutesInline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreServiceStart_RejectsCorruptedBackup 验证恢复在还原前做 SHA-256 完整性
|
||||
// 校验:若已存储的备份对象被损坏/篡改,恢复必须失败且不触碰源数据。
|
||||
func TestRestoreServiceStart_RejectsCorruptedBackup(t *testing.T) {
|
||||
h := newRestoreTestHarness(t, false)
|
||||
ctx := context.Background()
|
||||
|
||||
backupDetail, err := h.execution.RunTaskByIDSync(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("RunTaskByIDSync: %v", err)
|
||||
}
|
||||
if backupDetail.Status != "success" {
|
||||
t.Fatalf("expected backup success, got %s", backupDetail.Status)
|
||||
}
|
||||
|
||||
// 破坏已存储的备份对象:追加垃圾字节,使其 SHA-256 与记录不符。
|
||||
corrupted := false
|
||||
if walkErr := filepath.Walk(h.storageDir, func(p string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil || info.IsDir() {
|
||||
return walkErr
|
||||
}
|
||||
f, openErr := os.OpenFile(p, os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if openErr != nil {
|
||||
return openErr
|
||||
}
|
||||
defer f.Close()
|
||||
if _, writeErr := f.WriteString("corrupt"); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
corrupted = true
|
||||
return nil
|
||||
}); walkErr != nil {
|
||||
t.Fatalf("corrupt walk: %v", walkErr)
|
||||
}
|
||||
if !corrupted {
|
||||
t.Fatal("did not find a stored backup object to corrupt")
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(h.sourceDir); err != nil {
|
||||
t.Fatalf("remove source: %v", err)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
h.service.async = func(job func()) {
|
||||
go func() { job(); close(done) }()
|
||||
}
|
||||
detail, err := h.service.Start(ctx, backupDetail.ID, "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("Start: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatalf("restore did not complete in time")
|
||||
}
|
||||
|
||||
final, err := h.service.Get(ctx, detail.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get final: %v", err)
|
||||
}
|
||||
if final.Status != model.RestoreRecordStatusFailed {
|
||||
t.Fatalf("expected restore to FAIL on corrupted backup, got %s (err=%s)", final.Status, final.ErrorMessage)
|
||||
}
|
||||
if !strings.Contains(final.ErrorMessage, "完整性校验失败") && !strings.Contains(final.ErrorMessage, "SHA-256") {
|
||||
t.Fatalf("expected checksum failure message, got %q", final.ErrorMessage)
|
||||
}
|
||||
// 校验阶段即中止,不应触碰源数据。
|
||||
if _, statErr := os.Stat(filepath.Join(h.sourceDir, "index.html")); statErr == nil {
|
||||
t.Fatal("source must not be restored when checksum verification fails")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreServiceStart_RestoresToAlternatePath 验证恢复到指定目录:归档落在指定目录,
|
||||
// 且相对路径被拒绝。
|
||||
func TestRestoreServiceStart_RestoresToAlternatePath(t *testing.T) {
|
||||
h := newRestoreTestHarness(t, false)
|
||||
ctx := context.Background()
|
||||
|
||||
backupDetail, err := h.execution.RunTaskByIDSync(ctx, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("RunTaskByIDSync: %v", err)
|
||||
}
|
||||
if backupDetail.Status != "success" {
|
||||
t.Fatalf("expected backup success, got %s", backupDetail.Status)
|
||||
}
|
||||
|
||||
altDir := filepath.Join(t.TempDir(), "restore-here")
|
||||
|
||||
// 相对路径应被拒绝(且不创建恢复记录)。
|
||||
if _, relErr := h.service.StartSelective(ctx, backupDetail.ID, nil, "relative/path", "tester"); relErr == nil {
|
||||
t.Fatal("relative target path should be rejected")
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
h.service.async = func(job func()) {
|
||||
go func() {
|
||||
job()
|
||||
close(done)
|
||||
}()
|
||||
}
|
||||
detail, err := h.service.StartSelective(ctx, backupDetail.ID, nil, altDir, "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("StartSelective(altDir): %v", err)
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("restore did not complete in time")
|
||||
}
|
||||
|
||||
final, err := h.service.Get(ctx, detail.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if final.Status != model.RestoreRecordStatusSuccess {
|
||||
t.Fatalf("expected success, got %s (err=%s)", final.Status, final.ErrorMessage)
|
||||
}
|
||||
// 源目录 basename 为 "source",归档解压到 altDir/source/index.html。
|
||||
got, err := os.ReadFile(filepath.Join(altDir, "source", "index.html"))
|
||||
if err != nil {
|
||||
t.Fatalf("read restored file at alternate path: %v", err)
|
||||
}
|
||||
if string(got) != "hello-restore" {
|
||||
t.Fatalf("unexpected restored content at alt path: %q", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreServiceStart_RemoteNodeEnqueuesCommand(t *testing.T) {
|
||||
h := newRestoreTestHarness(t, true)
|
||||
ctx := context.Background()
|
||||
@@ -307,6 +434,7 @@ func TestRestoreServiceAgentRestoreAccessUsesRestoreRecordNode(t *testing.T) {
|
||||
Status: model.BackupRecordStatusSuccess,
|
||||
FileName: "remote.tar.gz",
|
||||
StoragePath: "file/2026/05/09/remote.tar.gz",
|
||||
Checksum: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: &completedAt,
|
||||
}
|
||||
@@ -332,6 +460,10 @@ func TestRestoreServiceAgentRestoreAccessUsesRestoreRecordNode(t *testing.T) {
|
||||
if spec.RestoreRecordID != restore.ID || spec.StoragePath != backupRecord.StoragePath {
|
||||
t.Fatalf("unexpected restore spec: %#v", spec)
|
||||
}
|
||||
// Agent 端完整性校验依赖 spec 透传源备份 checksum。
|
||||
if spec.Checksum != backupRecord.Checksum {
|
||||
t.Fatalf("expected spec.Checksum=%q, got %q", backupRecord.Checksum, spec.Checksum)
|
||||
}
|
||||
if _, err := h.service.GetAgentRestoreSpec(ctx, other, restore.ID); err == nil {
|
||||
t.Fatal("expected non-owner node to be forbidden from restore spec")
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ const (
|
||||
SettingKeyBandwidthLimit = "bandwidth_limit"
|
||||
SettingKeyAuditWebhookURL = "audit_webhook_url"
|
||||
SettingKeyAuditWebhookSecret = "audit_webhook_secret"
|
||||
// SettingKeyAuditRetentionDays 审计日志保留天数(0/缺省=永久保留)。
|
||||
SettingKeyAuditRetentionDays = "audit_retention_days"
|
||||
)
|
||||
|
||||
var settingsKeys = []string{
|
||||
@@ -57,6 +59,7 @@ var settingsKeys = []string{
|
||||
SettingKeyBandwidthLimit,
|
||||
SettingKeyAuditWebhookURL,
|
||||
SettingKeyAuditWebhookSecret,
|
||||
SettingKeyAuditRetentionDays,
|
||||
}
|
||||
|
||||
func (s *SettingsService) GetAll(ctx context.Context) (map[string]string, error) {
|
||||
|
||||
@@ -15,8 +15,6 @@ import (
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/storage"
|
||||
"backupx/server/internal/storage/codec"
|
||||
"backupx/server/pkg/compress"
|
||||
backupcrypto "backupx/server/pkg/crypto"
|
||||
)
|
||||
|
||||
// VerificationService 管理备份验证(恢复演练)记录生命周期。
|
||||
@@ -92,11 +90,11 @@ func (v *VerificationEventNotifier) NotifyVerificationResult(ctx context.Context
|
||||
title := "BackupX 备份验证失败"
|
||||
body := fmt.Sprintf("任务:%s\n验证记录:#%d\n错误:%s", taskName, record.ID, record.ErrorMessage)
|
||||
fields := map[string]any{
|
||||
"taskId": record.TaskID,
|
||||
"taskName": taskName,
|
||||
"verifyId": record.ID,
|
||||
"taskId": record.TaskID,
|
||||
"taskName": taskName,
|
||||
"verifyId": record.ID,
|
||||
"backupRecordId": record.BackupRecordID,
|
||||
"error": record.ErrorMessage,
|
||||
"error": record.ErrorMessage,
|
||||
}
|
||||
return v.dispatcher.DispatchEvent(ctx, model.NotificationEventVerifyFailed, title, body, fields)
|
||||
}
|
||||
@@ -243,23 +241,8 @@ func (s *VerificationService) Start(ctx context.Context, backupRecordID uint, mo
|
||||
// validateClusterAccessible 复刻 BackupExecutionService 的跨节点 local_disk 保护。
|
||||
// 避免 Master 端在错误机器下载/校验到假数据。
|
||||
func (s *VerificationService) validateClusterAccessible(ctx context.Context, record *model.BackupRecord) error {
|
||||
if record == nil || record.NodeID == 0 || s.nodeRepo == nil {
|
||||
return nil
|
||||
}
|
||||
node, err := s.nodeRepo.FindByID(ctx, record.NodeID)
|
||||
if err != nil || node == nil || node.IsLocal {
|
||||
return nil
|
||||
}
|
||||
target, err := s.targets.FindByID(ctx, record.StorageTargetID)
|
||||
if err != nil || target == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(target.Type, "local_disk") {
|
||||
return apperror.BadRequest("VERIFY_CROSS_NODE_LOCAL_DISK",
|
||||
fmt.Sprintf("备份位于节点 %s 的本地磁盘(local_disk),Master 无法跨节点验证。", node.Name),
|
||||
nil)
|
||||
}
|
||||
return nil
|
||||
return validateCrossNodeLocalDisk(ctx, s.nodeRepo, s.targets, record,
|
||||
"VERIFY_CROSS_NODE_LOCAL_DISK", "验证")
|
||||
}
|
||||
|
||||
// executeLocally 异步执行验证:下载 → 解密 → 解压 → 按类型校验。
|
||||
@@ -333,14 +316,26 @@ func (s *VerificationService) executeLocally(ctx context.Context, verID uint, ta
|
||||
logger.Errorf("写入沙箱失败:%v", err)
|
||||
return
|
||||
}
|
||||
// 完整性校验:先比对下载对象的 SHA-256(外层对象,与备份记录一致),再做结构校验。
|
||||
// 与恢复路径保持一致的强度;早期无 checksum 的备份跳过(向后兼容)。
|
||||
if backupRecord.Checksum != "" {
|
||||
logger.Infof("校验备份完整性(SHA-256)")
|
||||
if csErr := verifyArtifactChecksum(artifactPath, backupRecord.Checksum); csErr != nil {
|
||||
errMessage = csErr.Error()
|
||||
summary = "完整性校验失败"
|
||||
logger.Errorf("完整性校验失败:%v", csErr)
|
||||
return
|
||||
}
|
||||
logger.Infof("完整性校验通过")
|
||||
}
|
||||
preparedPath, err := s.prepareArtifact(artifactPath, logger)
|
||||
if err != nil {
|
||||
errMessage = err.Error()
|
||||
logger.Errorf("准备归档失败:%v", err)
|
||||
return
|
||||
}
|
||||
// 按任务类型分派校验
|
||||
report, verifyErr := s.verifyByType(task.Type, preparedPath, backupRecord.Checksum, logger)
|
||||
// 按任务类型做结构校验(外层 SHA-256 已在上方单独校验)。
|
||||
report, verifyErr := s.verifyByType(task.Type, preparedPath, logger)
|
||||
if verifyErr != nil {
|
||||
errMessage = verifyErr.Error()
|
||||
if report != nil && report.Detail != "" {
|
||||
@@ -358,28 +353,11 @@ func (s *VerificationService) executeLocally(ctx context.Context, verID uint, ta
|
||||
|
||||
// prepareArtifact 按后缀解密/解压,返回可读路径。
|
||||
func (s *VerificationService) prepareArtifact(artifactPath string, logger *backup.ExecutionLogger) (string, error) {
|
||||
current := artifactPath
|
||||
if strings.HasSuffix(strings.ToLower(current), ".enc") {
|
||||
logger.Infof("检测到加密后缀,开始解密")
|
||||
decrypted, err := backupcrypto.DecryptFile(s.cipher.Key(), current)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
current = decrypted
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(current), ".gz") {
|
||||
logger.Infof("检测到 gzip,解压")
|
||||
decompressed, err := compress.GunzipFile(current)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
current = decompressed
|
||||
}
|
||||
return current, nil
|
||||
return prepareBackupArtifact(s.cipher, artifactPath, logger)
|
||||
}
|
||||
|
||||
// verifyByType 按任务类型分派到对应 Verify* 策略。
|
||||
func (s *VerificationService) verifyByType(taskType, artifactPath, checksum string, logger *backup.ExecutionLogger) (*backup.VerifyReport, error) {
|
||||
// verifyByType 按任务类型分派到对应 Verify* 结构校验策略(不含 SHA-256 比对)。
|
||||
func (s *VerificationService) verifyByType(taskType, artifactPath string, logger *backup.ExecutionLogger) (*backup.VerifyReport, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(taskType)) {
|
||||
case "file":
|
||||
logger.Infof("执行文件归档校验")
|
||||
|
||||
159
server/internal/service/verification_service_test.go
Normal file
159
server/internal/service/verification_service_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"backupx/server/internal/backup"
|
||||
"backupx/server/internal/config"
|
||||
"backupx/server/internal/database"
|
||||
"backupx/server/internal/logger"
|
||||
"backupx/server/internal/model"
|
||||
"backupx/server/internal/repository"
|
||||
"backupx/server/internal/storage"
|
||||
"backupx/server/internal/storage/codec"
|
||||
storageRclone "backupx/server/internal/storage/rclone"
|
||||
)
|
||||
|
||||
type verifyTestHarness struct {
|
||||
verify *VerificationService
|
||||
execution *BackupExecutionService
|
||||
records repository.BackupRecordRepository
|
||||
storageDir string
|
||||
}
|
||||
|
||||
func newVerifyTestHarness(t *testing.T) *verifyTestHarness {
|
||||
t.Helper()
|
||||
baseDir := t.TempDir()
|
||||
sourceDir := filepath.Join(baseDir, "source")
|
||||
storageDir := filepath.Join(baseDir, "storage")
|
||||
if err := os.MkdirAll(sourceDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir source: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceDir, "index.html"), []byte("hello-verify"), 0o644); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
log, err := logger.New(config.LogConfig{Level: "error"})
|
||||
if err != nil {
|
||||
t.Fatalf("logger.New: %v", err)
|
||||
}
|
||||
db, err := database.Open(config.DatabaseConfig{Path: filepath.Join(baseDir, "backupx.db")}, log)
|
||||
if err != nil {
|
||||
t.Fatalf("database.Open: %v", err)
|
||||
}
|
||||
cipher := codec.NewConfigCipher("verify-secret")
|
||||
targets := repository.NewStorageTargetRepository(db)
|
||||
tasks := repository.NewBackupTaskRepository(db)
|
||||
records := repository.NewBackupRecordRepository(db)
|
||||
verifications := repository.NewVerificationRecordRepository(db)
|
||||
nodes := repository.NewNodeRepository(db)
|
||||
targetCipher, err := cipher.EncryptJSON(map[string]any{"basePath": storageDir})
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptJSON: %v", err)
|
||||
}
|
||||
if err := targets.Create(context.Background(), &model.StorageTarget{Name: "local", Type: string(storage.ProviderTypeLocalDisk), Enabled: true, ConfigCiphertext: targetCipher, ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil {
|
||||
t.Fatalf("create target: %v", err)
|
||||
}
|
||||
task := &model.BackupTask{Name: "verify-test", Type: "file", Enabled: true, SourcePath: sourceDir, StorageTargetID: 1, NodeID: 0, RetentionDays: 30, Compression: "gzip", MaxBackups: 10, LastStatus: "idle"}
|
||||
if err := tasks.Create(context.Background(), task); err != nil {
|
||||
t.Fatalf("create task: %v", err)
|
||||
}
|
||||
|
||||
logHub := backup.NewLogHub()
|
||||
runnerRegistry := backup.NewRegistry(backup.NewFileRunner(), backup.NewSQLiteRunner(), backup.NewMySQLRunner(nil), backup.NewPostgreSQLRunner(nil))
|
||||
storageRegistry := storage.NewRegistry(storageRclone.NewLocalDiskFactory())
|
||||
execution := NewBackupExecutionService(tasks, records, targets, storageRegistry, runnerRegistry, logHub, nil, cipher, nil, baseDir, 2, 10, "")
|
||||
verify := NewVerificationService(verifications, records, tasks, targets, nodes, storageRegistry, backup.NewLogHub(), cipher, baseDir, 2)
|
||||
|
||||
return &verifyTestHarness{verify: verify, execution: execution, records: records, storageDir: storageDir}
|
||||
}
|
||||
|
||||
// runVerify 同步执行一次验证并返回终态记录。
|
||||
func (h *verifyTestHarness) runVerify(t *testing.T, backupRecordID uint) *VerificationRecordDetail {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
done := make(chan struct{})
|
||||
h.verify.async = func(job func()) {
|
||||
go func() { job(); close(done) }()
|
||||
}
|
||||
detail, err := h.verify.Start(ctx, backupRecordID, "quick", "tester")
|
||||
if err != nil {
|
||||
t.Fatalf("verify Start: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("verify did not complete in time")
|
||||
}
|
||||
final, err := h.verify.Get(ctx, detail.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("verify Get: %v", err)
|
||||
}
|
||||
return final
|
||||
}
|
||||
|
||||
// TestVerificationService_Success 覆盖正常路径:对一个有效(gzip 压缩)的备份做验证应通过。
|
||||
// 同时回归保护 #77——新增的 SHA-256 校验不得误伤合法的压缩备份。
|
||||
func TestVerificationService_Success(t *testing.T) {
|
||||
h := newVerifyTestHarness(t)
|
||||
backupDetail, err := h.execution.RunTaskByIDSync(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("RunTaskByIDSync: %v", err)
|
||||
}
|
||||
if backupDetail.Status != "success" {
|
||||
t.Fatalf("expected backup success, got %s", backupDetail.Status)
|
||||
}
|
||||
|
||||
final := h.runVerify(t, backupDetail.ID)
|
||||
if final.Status != model.VerificationRecordStatusSuccess {
|
||||
t.Fatalf("expected verify success, got %s (err=%s)", final.Status, final.ErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerificationService_RejectsCorruptedBackup 验证 #77 的完整性校验:
|
||||
// 存储对象被损坏时验证必须失败并给出 checksum 失败信息。
|
||||
func TestVerificationService_RejectsCorruptedBackup(t *testing.T) {
|
||||
h := newVerifyTestHarness(t)
|
||||
backupDetail, err := h.execution.RunTaskByIDSync(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("RunTaskByIDSync: %v", err)
|
||||
}
|
||||
if backupDetail.Status != "success" {
|
||||
t.Fatalf("expected backup success, got %s", backupDetail.Status)
|
||||
}
|
||||
|
||||
// 破坏已存储的备份对象,使其 SHA-256 与记录不符。
|
||||
corrupted := false
|
||||
if walkErr := filepath.Walk(h.storageDir, func(p string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil || info.IsDir() {
|
||||
return walkErr
|
||||
}
|
||||
f, openErr := os.OpenFile(p, os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if openErr != nil {
|
||||
return openErr
|
||||
}
|
||||
defer f.Close()
|
||||
if _, writeErr := f.WriteString("corrupt"); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
corrupted = true
|
||||
return nil
|
||||
}); walkErr != nil {
|
||||
t.Fatalf("corrupt walk: %v", walkErr)
|
||||
}
|
||||
if !corrupted {
|
||||
t.Fatal("did not find a stored backup object to corrupt")
|
||||
}
|
||||
|
||||
final := h.runVerify(t, backupDetail.ID)
|
||||
if final.Status != model.VerificationRecordStatusFailed {
|
||||
t.Fatalf("expected verify to FAIL on corrupted backup, got %s", final.Status)
|
||||
}
|
||||
if !strings.Contains(final.ErrorMessage, "完整性校验失败") && !strings.Contains(final.ErrorMessage, "SHA-256") {
|
||||
t.Fatalf("expected checksum failure message, got %q", final.ErrorMessage)
|
||||
}
|
||||
}
|
||||
65
server/pkg/compress/zstd.go
Normal file
65
server/pkg/compress/zstd.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package compress
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/klauspost/compress/zstd"
|
||||
)
|
||||
|
||||
// ZstdFile 将文件压缩为 .zst(zstd),返回压缩产物路径。
|
||||
// 相比 gzip,zstd 在相近 CPU 开销下提供更高压缩率与显著更快的解压速度。
|
||||
func ZstdFile(sourcePath string) (string, error) {
|
||||
source, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open source file: %w", err)
|
||||
}
|
||||
defer source.Close()
|
||||
targetPath := sourcePath + ".zst"
|
||||
target, err := os.Create(targetPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create zstd file: %w", err)
|
||||
}
|
||||
defer target.Close()
|
||||
writer, err := zstd.NewWriter(target)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create zstd writer: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(writer, source); err != nil {
|
||||
_ = writer.Close()
|
||||
return "", fmt.Errorf("zstd source file: %w", err)
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
return "", fmt.Errorf("close zstd writer: %w", err)
|
||||
}
|
||||
return targetPath, nil
|
||||
}
|
||||
|
||||
// UnzstdFile 解压 .zst 文件,返回解压产物路径。
|
||||
func UnzstdFile(sourcePath string) (string, error) {
|
||||
source, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open zstd file: %w", err)
|
||||
}
|
||||
defer source.Close()
|
||||
reader, err := zstd.NewReader(source)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create zstd reader: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
targetPath := strings.TrimSuffix(sourcePath, ".zst")
|
||||
if targetPath == sourcePath {
|
||||
targetPath += ".out"
|
||||
}
|
||||
target, err := os.Create(targetPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create target file: %w", err)
|
||||
}
|
||||
defer target.Close()
|
||||
if _, err := io.Copy(target, reader); err != nil {
|
||||
return "", fmt.Errorf("unzstd file: %w", err)
|
||||
}
|
||||
return targetPath, nil
|
||||
}
|
||||
40
server/pkg/compress/zstd_test.go
Normal file
40
server/pkg/compress/zstd_test.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package compress
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestZstdRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "data.txt")
|
||||
content := []byte("hello zstd roundtrip 差异压缩测试 " + strings.Repeat("payload-", 2000))
|
||||
if err := os.WriteFile(src, content, 0o644); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
compressed, err := ZstdFile(src)
|
||||
if err != nil {
|
||||
t.Fatalf("ZstdFile: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(compressed, ".zst") {
|
||||
t.Fatalf("expected .zst suffix, got %s", compressed)
|
||||
}
|
||||
// 删除原文件,确保后续读到的是解压结果而非残留原文件。
|
||||
if err := os.Remove(src); err != nil {
|
||||
t.Fatalf("remove source: %v", err)
|
||||
}
|
||||
out, err := UnzstdFile(compressed)
|
||||
if err != nil {
|
||||
t.Fatalf("UnzstdFile: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Fatalf("read decompressed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, content) {
|
||||
t.Fatalf("roundtrip mismatch: got %d bytes, want %d bytes", len(got), len(content))
|
||||
}
|
||||
}
|
||||
89
web/package-lock.json
generated
89
web/package-lock.json
generated
@@ -9,7 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@arco-design/web-react": "^2.66.0",
|
||||
"axios": "^1.8.4",
|
||||
"axios": "^1.16.0",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"i18next": "^25.8.14",
|
||||
@@ -29,7 +29,7 @@
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^26.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.2.1",
|
||||
"vite": "^6.4.2",
|
||||
"vitest": "^3.0.8"
|
||||
}
|
||||
},
|
||||
@@ -127,7 +127,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -476,7 +475,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
@@ -500,7 +498,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -1458,7 +1455,8 @@
|
||||
"resolved": "https://registry.npmmirror.com/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
@@ -1536,7 +1534,6 @@
|
||||
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -1554,7 +1551,6 @@
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1566,7 +1562,6 @@
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
@@ -1723,6 +1718,7 @@
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -1733,6 +1729,7 @@
|
||||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
@@ -1767,14 +1764,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.11",
|
||||
"follow-redirects": "^1.16.0",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/b-tween": {
|
||||
@@ -1822,7 +1819,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2080,7 +2076,8 @@
|
||||
"resolved": "https://registry.npmmirror.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
@@ -2317,9 +2314,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -2543,7 +2540,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
@@ -2604,7 +2600,6 @@
|
||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssstyle": "^4.2.1",
|
||||
"data-urls": "^5.0.0",
|
||||
@@ -2666,9 +2661,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
@@ -2706,6 +2701,7 @@
|
||||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
@@ -2853,12 +2849,11 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -2867,9 +2862,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2901,6 +2896,7 @@
|
||||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
@@ -2915,7 +2911,8 @@
|
||||
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
@@ -2935,10 +2932,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
@@ -2955,7 +2955,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -2980,7 +2979,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -3434,7 +3432,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -3529,18 +3526,16 @@
|
||||
"resolved": "https://registry.npmmirror.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"version": "6.4.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
|
||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.4",
|
||||
@@ -3793,9 +3788,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@arco-design/web-react": "^2.66.0",
|
||||
"axios": "^1.8.4",
|
||||
"axios": "^1.16.0",
|
||||
"echarts": "^6.0.0",
|
||||
"echarts-for-react": "^3.0.6",
|
||||
"i18next": "^25.8.14",
|
||||
@@ -31,7 +31,7 @@
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^26.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.2.1",
|
||||
"vite": "^6.4.2",
|
||||
"vitest": "^3.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
125
web/src/components/backup-records/BackupRecordContentsModal.tsx
Normal file
125
web/src/components/backup-records/BackupRecordContentsModal.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Alert, Button, Input, Modal, Spin, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { getBackupRecordContents } from '../../services/backup-records'
|
||||
import type { BackupRecordContentEntry, BackupRecordContents } from '../../types/backup-records'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { formatBytes } from '../../utils/format'
|
||||
|
||||
interface BackupRecordContentsModalProps {
|
||||
visible: boolean
|
||||
recordId?: number
|
||||
onClose: () => void
|
||||
// onRestoreSelected 提供时启用按需恢复:勾选条目后回调选中的归档路径。
|
||||
onRestoreSelected?: (paths: string[]) => void
|
||||
}
|
||||
|
||||
// BackupRecordContentsModal 浏览某次备份捕获的文件清单(只读)。
|
||||
// 数据来源于全量备份记录的清单,无需下载归档,秒级展示并支持按路径筛选。
|
||||
export function BackupRecordContentsModal({ visible, recordId, onClose, onRestoreSelected }: BackupRecordContentsModalProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [contents, setContents] = useState<BackupRecordContents | null>(null)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !recordId) {
|
||||
return
|
||||
}
|
||||
let active = true
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setKeyword('')
|
||||
setContents(null)
|
||||
setSelectedKeys([])
|
||||
void (async () => {
|
||||
try {
|
||||
const data = await getBackupRecordContents(recordId)
|
||||
if (active) {
|
||||
setContents(data)
|
||||
}
|
||||
} catch (e) {
|
||||
if (active) {
|
||||
setError(resolveErrorMessage(e, '加载备份内容失败'))
|
||||
}
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [visible, recordId])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const entries = contents?.entries ?? []
|
||||
const kw = keyword.trim().toLowerCase()
|
||||
if (!kw) {
|
||||
return entries
|
||||
}
|
||||
return entries.filter((e) => e.path.toLowerCase().includes(kw))
|
||||
}, [contents, keyword])
|
||||
|
||||
return (
|
||||
<Modal visible={visible} title="备份内容" footer={null} onCancel={onClose} unmountOnExit style={{ width: 760 }}>
|
||||
{loading ? (
|
||||
<Spin style={{ display: 'block', textAlign: 'center', padding: 40 }} />
|
||||
) : error ? (
|
||||
<Alert type="warning" content={error} />
|
||||
) : contents ? (
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
共 {contents.total} 个条目
|
||||
{contents.truncated ? `(清单较大,仅展示前 ${contents.entries.length} 个)` : ''}
|
||||
{contents.basedOnFull ? `;差异备份,清单取自基线全量 #${contents.basedOnFull}` : ''}
|
||||
</Typography.Text>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', margin: '8px 0' }}>
|
||||
<Input.Search allowClear placeholder="按路径筛选" value={keyword} onChange={setKeyword} style={{ flex: 1 }} />
|
||||
{onRestoreSelected && (
|
||||
<Button type="primary" status="warning" disabled={selectedKeys.length === 0} onClick={() => onRestoreSelected(selectedKeys)}>
|
||||
恢复选中({selectedKeys.length})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="path"
|
||||
data={filtered}
|
||||
rowSelection={
|
||||
onRestoreSelected
|
||||
? { type: 'checkbox', selectedRowKeys: selectedKeys, onChange: (keys) => setSelectedKeys(keys as string[]) }
|
||||
: undefined
|
||||
}
|
||||
pagination={{ pageSize: 50, sizeCanChange: false }}
|
||||
scroll={{ y: 420 }}
|
||||
columns={[
|
||||
{
|
||||
title: '路径',
|
||||
dataIndex: 'path',
|
||||
render: (_: unknown, row: BackupRecordContentEntry) => (
|
||||
<span>
|
||||
{row.isDir ? (
|
||||
<Tag size="small" color="arcoblue">
|
||||
目录
|
||||
</Tag>
|
||||
) : null}{' '}
|
||||
{row.path}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '大小',
|
||||
dataIndex: 'size',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
render: (_: unknown, row: BackupRecordContentEntry) => (row.isDir ? '-' : formatBytes(row.size)),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type { BackupTaskDetail } from '../../types/backup-tasks'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { formatBytes, formatDateTime, formatDuration } from '../../utils/format'
|
||||
import { RestoreConfirmModal } from '../restore-records/RestoreConfirmModal'
|
||||
import { BackupRecordContentsModal } from './BackupRecordContentsModal'
|
||||
|
||||
interface BackupRecordLogDrawerProps {
|
||||
visible: boolean
|
||||
@@ -53,6 +54,7 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
|
||||
const [restoreLoading, setRestoreLoading] = useState(false)
|
||||
const [restorePreparing, setRestorePreparing] = useState(false)
|
||||
const [verifyLoading, setVerifyLoading] = useState(false)
|
||||
const [contentsVisible, setContentsVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !recordId) {
|
||||
@@ -191,13 +193,13 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmRestore() {
|
||||
async function handleConfirmRestore(targetPath?: string) {
|
||||
if (!recordId) {
|
||||
return
|
||||
}
|
||||
setRestoreLoading(true)
|
||||
try {
|
||||
const restore = await startRestoreFromBackup(recordId)
|
||||
const restore = await startRestoreFromBackup(recordId, undefined, targetPath)
|
||||
Message.success('恢复已启动,正在打开日志')
|
||||
setRestoreModalVisible(false)
|
||||
setRestoreTask(null)
|
||||
@@ -211,6 +213,26 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
|
||||
}
|
||||
}
|
||||
|
||||
// handleSelectiveRestore 按需恢复:仅还原内容浏览中勾选的文件/目录到原位置。
|
||||
async function handleSelectiveRestore(paths: string[]) {
|
||||
if (!recordId || paths.length === 0) {
|
||||
return
|
||||
}
|
||||
if (!window.confirm(`确定将选中的 ${paths.length} 项恢复到原位置吗?这会覆盖目标位置的现有文件,不可撤销。`)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const restore = await startRestoreFromBackup(recordId, paths)
|
||||
Message.success('按需恢复已启动,正在打开日志')
|
||||
setContentsVisible(false)
|
||||
await onChanged?.()
|
||||
navigate(`/restore/records?restoreId=${restore.id}`)
|
||||
onCancel()
|
||||
} catch (restoreError) {
|
||||
Message.error(resolveErrorMessage(restoreError, '启动按需恢复失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!recordId) {
|
||||
return
|
||||
@@ -268,6 +290,7 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
|
||||
<Button loading={acting} onClick={handleDownload}>
|
||||
下载
|
||||
</Button>
|
||||
<Button onClick={() => setContentsVisible(true)}>查看内容</Button>
|
||||
{writable && (
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -322,7 +345,13 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }
|
||||
setRestoreModalVisible(false)
|
||||
setRestoreTask(null)
|
||||
}}
|
||||
onConfirm={() => void handleConfirmRestore()}
|
||||
onConfirm={(targetPath) => void handleConfirmRestore(targetPath)}
|
||||
/>
|
||||
<BackupRecordContentsModal
|
||||
visible={contentsVisible}
|
||||
recordId={recordId}
|
||||
onClose={() => setContentsVisible(false)}
|
||||
onRestoreSelected={writable && record?.status === 'success' ? (paths) => void handleSelectiveRestore(paths) : undefined}
|
||||
/>
|
||||
</Drawer>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { CronInput } from '../CronInput'
|
||||
import type { StorageTargetDetail, StorageTargetPayload, StorageTargetSummary } from '../../types/storage-targets'
|
||||
import type { StorageConnectionTestResult } from '../../types/storage-targets'
|
||||
import type { BackupTaskDetail, BackupTaskPayload, BackupTaskType } from '../../types/backup-tasks'
|
||||
import type { BackupMode, BackupTaskDetail, BackupTaskPayload, BackupTaskType } from '../../types/backup-tasks'
|
||||
import type { NodeSummary } from '../../types/nodes'
|
||||
import { DatabasePicker } from '../common/DatabasePicker'
|
||||
import { DirectoryPicker } from '../common/DirectoryPicker'
|
||||
@@ -65,6 +65,12 @@ function createEmptyDraft(storageTargets?: StorageTargetSummary[]): BackupTaskPa
|
||||
compression: 'gzip',
|
||||
encrypt: false,
|
||||
maxBackups: 10,
|
||||
keepDaily: 0,
|
||||
keepWeekly: 0,
|
||||
keepMonthly: 0,
|
||||
keepYearly: 0,
|
||||
backupMode: 'full',
|
||||
diffFullIntervalDays: 7,
|
||||
extraConfig: undefined,
|
||||
verifyEnabled: false,
|
||||
verifyCronExpr: '',
|
||||
@@ -134,6 +140,12 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
compression: initialValue.compression,
|
||||
encrypt: initialValue.encrypt,
|
||||
maxBackups: initialValue.maxBackups,
|
||||
keepDaily: initialValue.keepDaily ?? 0,
|
||||
keepWeekly: initialValue.keepWeekly ?? 0,
|
||||
keepMonthly: initialValue.keepMonthly ?? 0,
|
||||
keepYearly: initialValue.keepYearly ?? 0,
|
||||
backupMode: initialValue.backupMode ?? 'full',
|
||||
diffFullIntervalDays: initialValue.diffFullIntervalDays ?? 7,
|
||||
extraConfig: initialValue.extraConfig,
|
||||
verifyEnabled: initialValue.verifyEnabled ?? false,
|
||||
verifyCronExpr: initialValue.verifyCronExpr ?? '',
|
||||
@@ -190,11 +202,11 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
sourcePath: value === 'file' ? current.sourcePath : '',
|
||||
sourcePaths: value === 'file' ? current.sourcePaths : [''],
|
||||
excludePatterns: value === 'file' ? current.excludePatterns : [],
|
||||
dbHost: value === 'mysql' || value === 'postgresql' || value === 'saphana' ? current.dbHost : '',
|
||||
dbPort: value === 'mysql' || value === 'postgresql' || value === 'saphana' ? current.dbPort || getDefaultPort(value) : 0,
|
||||
dbUser: value === 'mysql' || value === 'postgresql' || value === 'saphana' ? current.dbUser : '',
|
||||
dbPassword: value === 'mysql' || value === 'postgresql' || value === 'saphana' ? current.dbPassword : '',
|
||||
dbName: value === 'mysql' || value === 'postgresql' || value === 'saphana' ? current.dbName : '',
|
||||
dbHost: isDatabaseBackupTask(value) ? current.dbHost : '',
|
||||
dbPort: isDatabaseBackupTask(value) ? current.dbPort || getDefaultPort(value) : 0,
|
||||
dbUser: isDatabaseBackupTask(value) ? current.dbUser : '',
|
||||
dbPassword: isDatabaseBackupTask(value) ? current.dbPassword : '',
|
||||
dbName: isDatabaseBackupTask(value) ? current.dbName : '',
|
||||
dbPath: value === 'sqlite' ? current.dbPath : '',
|
||||
// 切换到 SAP HANA 时初始化扩展配置;切换到其他类型时清空
|
||||
extraConfig: value === 'saphana'
|
||||
@@ -580,6 +592,34 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
<Typography.Text>压缩策略</Typography.Text>
|
||||
<Select value={draft.compression} options={backupCompressionOptions as unknown as { label: string; value: string }[]} onChange={(value) => updateDraft({ compression: value as BackupTaskPayload['compression'] })} />
|
||||
</div>
|
||||
{isFileBackupTask(draft.type) && (
|
||||
<div>
|
||||
<Typography.Text>备份模式</Typography.Text>
|
||||
<Select
|
||||
value={draft.backupMode}
|
||||
options={[
|
||||
{ label: '全量备份', value: 'full' },
|
||||
{ label: '差异备份(仅文件、本机)', value: 'differential' },
|
||||
]}
|
||||
onChange={(value) => updateDraft({ backupMode: value as BackupMode })}
|
||||
/>
|
||||
{draft.backupMode === 'differential' && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
差异备份仅打包自上次全量以来的变更,显著减小体积;超过下列天数将自动重做一次全量以控制差异链长度。恢复时自动按「全量 + 差异」链还原。
|
||||
</Typography.Text>
|
||||
<InputNumber
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
placeholder="强制全量间隔(天)"
|
||||
prefix="全量间隔(天)"
|
||||
min={1}
|
||||
value={draft.diffFullIntervalDays}
|
||||
onChange={(value) => updateDraft({ diffFullIntervalDays: Number(value ?? 7) })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Typography.Text>保留天数</Typography.Text>
|
||||
<InputNumber style={{ width: '100%' }} value={draft.retentionDays} min={0} onChange={(value) => updateDraft({ retentionDays: Number(value ?? 0) })} />
|
||||
@@ -588,6 +628,25 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
|
||||
<Typography.Text>最大保留份数</Typography.Text>
|
||||
<InputNumber style={{ width: '100%' }} value={draft.maxBackups} min={0} onChange={(value) => updateDraft({ maxBackups: Number(value ?? 0) })} />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
GFS 分层保留(任一 > 0 即启用,覆盖上面的天数/份数;每天/周/月/年仅保留最新一份)
|
||||
</Typography.Text>
|
||||
<Grid.Row gutter={8} style={{ marginTop: 4 }}>
|
||||
<Grid.Col span={6}>
|
||||
<InputNumber style={{ width: '100%' }} placeholder="日" prefix="日" min={0} value={draft.keepDaily} onChange={(value) => updateDraft({ keepDaily: Number(value ?? 0) })} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<InputNumber style={{ width: '100%' }} placeholder="周" prefix="周" min={0} value={draft.keepWeekly} onChange={(value) => updateDraft({ keepWeekly: Number(value ?? 0) })} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<InputNumber style={{ width: '100%' }} placeholder="月" prefix="月" min={0} value={draft.keepMonthly} onChange={(value) => updateDraft({ keepMonthly: Number(value ?? 0) })} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<InputNumber style={{ width: '100%' }} placeholder="年" prefix="年" min={0} value={draft.keepYearly} onChange={(value) => updateDraft({ keepYearly: Number(value ?? 0) })} />
|
||||
</Grid.Col>
|
||||
</Grid.Row>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>标签(逗号分隔,用于分组与筛选)</Typography.Text>
|
||||
<Input
|
||||
|
||||
@@ -6,10 +6,12 @@ export const backupTaskTypeOptions = [
|
||||
{ label: 'SQLite', value: 'sqlite' },
|
||||
{ label: 'PostgreSQL', value: 'postgresql' },
|
||||
{ label: 'SAP HANA', value: 'saphana' },
|
||||
{ label: 'MongoDB', value: 'mongodb' },
|
||||
] as const
|
||||
|
||||
export const backupCompressionOptions = [
|
||||
{ label: 'Gzip 压缩', value: 'gzip' },
|
||||
{ label: 'Zstd 压缩(更快/更小)', value: 'zstd' },
|
||||
{ label: '不压缩', value: 'none' },
|
||||
] as const
|
||||
|
||||
@@ -25,6 +27,8 @@ export function getBackupTaskTypeLabel(type: BackupTaskType) {
|
||||
return 'PostgreSQL'
|
||||
case 'saphana':
|
||||
return 'SAP HANA'
|
||||
case 'mongodb':
|
||||
return 'MongoDB'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
@@ -67,7 +71,7 @@ export function isSQLiteBackupTask(type: BackupTaskType) {
|
||||
}
|
||||
|
||||
export function isDatabaseBackupTask(type: BackupTaskType) {
|
||||
return type === 'mysql' || type === 'postgresql' || type === 'saphana'
|
||||
return type === 'mysql' || type === 'postgresql' || type === 'saphana' || type === 'mongodb'
|
||||
}
|
||||
|
||||
export function getDefaultPort(type: BackupTaskType) {
|
||||
@@ -78,13 +82,22 @@ export function getDefaultPort(type: BackupTaskType) {
|
||||
return 5432
|
||||
case 'saphana':
|
||||
return 30015
|
||||
case 'mongodb':
|
||||
return 27017
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export function getCompressionLabel(compression: BackupCompression) {
|
||||
return compression === 'gzip' ? 'Gzip' : '无'
|
||||
switch (compression) {
|
||||
case 'gzip':
|
||||
return 'Gzip'
|
||||
case 'zstd':
|
||||
return 'Zstd'
|
||||
default:
|
||||
return '无'
|
||||
}
|
||||
}
|
||||
|
||||
/** SAP HANA 备份级别选项 */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Alert, Descriptions, Modal, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { Alert, Descriptions, Input, Modal, Space, Tag, Typography } from '@arco-design/web-react'
|
||||
import { useState } from 'react'
|
||||
import type { BackupRecordDetail } from '../../types/backup-records'
|
||||
import type { BackupTaskDetail } from '../../types/backup-tasks'
|
||||
|
||||
@@ -8,21 +9,26 @@ interface RestoreConfirmModalProps {
|
||||
backupRecord: BackupRecordDetail | null
|
||||
task: BackupTaskDetail | null
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
onConfirm: (targetPath?: string) => void
|
||||
}
|
||||
|
||||
// RestoreConfirmModal 展示即将恢复的备份摘要与覆盖风险,强制用户二次确认。
|
||||
// 恢复是破坏性操作:会覆盖任务配置的源路径/数据库,不可撤销。
|
||||
// 文件类型 + 本机恢复时,允许指定「恢复到其他目录」以避免覆盖原位置。
|
||||
export function RestoreConfirmModal({ visible, loading, backupRecord, task, onCancel, onConfirm }: RestoreConfirmModalProps) {
|
||||
const [targetPath, setTargetPath] = useState('')
|
||||
|
||||
if (!backupRecord || !task) {
|
||||
return (
|
||||
<Modal visible={visible} title="确认恢复" onCancel={onCancel} onOk={onConfirm} confirmLoading={loading} unmountOnExit>
|
||||
<Modal visible={visible} title="确认恢复" onCancel={onCancel} onOk={() => onConfirm()} confirmLoading={loading} unmountOnExit>
|
||||
<Alert type="info" content="正在加载任务与备份信息..." />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const restoreTarget = renderRestoreTarget(task)
|
||||
const isLocal = !task.nodeId || task.nodeId === 0
|
||||
const allowAltPath = task.type === 'file' && isLocal
|
||||
const nodeLabel = task.nodeId && task.nodeId > 0
|
||||
? (task.nodeName ? `${task.nodeName}(远程节点)` : `节点 #${task.nodeId}`)
|
||||
: '本机 Master'
|
||||
@@ -35,8 +41,9 @@ export function RestoreConfirmModal({ visible, loading, backupRecord, task, onCa
|
||||
cancelText="取消"
|
||||
okButtonProps={{ status: 'danger', loading }}
|
||||
onCancel={onCancel}
|
||||
onOk={onConfirm}
|
||||
onOk={() => onConfirm(targetPath.trim() || undefined)}
|
||||
unmountOnExit
|
||||
afterClose={() => setTargetPath('')}
|
||||
>
|
||||
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
|
||||
<Alert
|
||||
@@ -51,9 +58,23 @@ export function RestoreConfirmModal({ visible, loading, backupRecord, task, onCa
|
||||
{ label: '类型', value: <Tag color="arcoblue" bordered>{task.type.toUpperCase()}</Tag> },
|
||||
{ label: '执行节点', value: nodeLabel },
|
||||
{ label: '源备份', value: backupRecord.fileName || '-' },
|
||||
{ label: '恢复目标', value: restoreTarget },
|
||||
{ label: '恢复目标', value: targetPath.trim() ? <Typography.Text code>{targetPath.trim()}</Typography.Text> : restoreTarget },
|
||||
]}
|
||||
/>
|
||||
{allowAltPath && (
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
恢复到指定目录(可选,绝对路径;留空则恢复到原始位置)
|
||||
</Typography.Text>
|
||||
<Input
|
||||
style={{ marginTop: 4 }}
|
||||
allowClear
|
||||
value={targetPath}
|
||||
placeholder="/path/to/restore-here"
|
||||
onChange={(value) => setTargetPath(value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
)
|
||||
@@ -80,7 +101,7 @@ function renderRestoreTarget(task: BackupTaskDetail) {
|
||||
if (task.type === 'sqlite') {
|
||||
return <Typography.Text code>{task.dbPath || '-'}</Typography.Text>
|
||||
}
|
||||
if (task.type === 'mysql' || task.type === 'postgresql' || task.type === 'saphana') {
|
||||
if (task.type === 'mysql' || task.type === 'postgresql' || task.type === 'saphana' || task.type === 'mongodb') {
|
||||
return (
|
||||
<Typography.Text>
|
||||
{task.dbUser}@{task.dbHost}:{task.dbPort} / <Typography.Text code>{task.dbName || '-'}</Typography.Text>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
IconCloud,
|
||||
IconDesktop,
|
||||
IconList,
|
||||
IconFilePdf,
|
||||
} from '@arco-design/web-react/icon'
|
||||
import { useState } from 'react'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
@@ -106,6 +107,7 @@ interface MenuItemConfig {
|
||||
|
||||
const menuItems: MenuItemConfig[] = [
|
||||
{ key: '/dashboard', label: '仪表盘', icon: <IconDashboard /> },
|
||||
{ key: '/reports', label: '合规报表', icon: <IconFilePdf /> },
|
||||
{ key: '/backup/tasks', label: '备份任务', icon: <IconFile /> },
|
||||
{ key: '/backup/records', label: '备份记录', icon: <IconHistory /> },
|
||||
{ key: '/restore/records', label: '恢复记录', icon: <IconRefresh /> },
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Button, DatePicker, Input, Message, PageHeader, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { Button, DatePicker, Input, InputNumber, Message, PageHeader, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import type { ColumnProps } from '@arco-design/web-react/es/Table'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { exportAuditLogs, listAuditLogs } from '../../services/audit'
|
||||
import { fetchSettings, updateSettings } from '../../services/system'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { isAdmin } from '../../utils/permissions'
|
||||
import type { AuditLog } from '../../types/audit'
|
||||
import { formatDateTime } from '../../utils/format'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
@@ -112,6 +115,9 @@ export function AuditLogsPage() {
|
||||
const [dateRange, setDateRange] = useState<string[] | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const admin = isAdmin(useAuthStore((state) => state.user))
|
||||
const [retentionDays, setRetentionDays] = useState(0)
|
||||
const [savingRetention, setSavingRetention] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async (currentPage: number) => {
|
||||
setLoading(true)
|
||||
@@ -139,6 +145,31 @@ export function AuditLogsPage() {
|
||||
void fetchData(page)
|
||||
}, [page, fetchData])
|
||||
|
||||
// 管理员加载当前审计日志保留期设置。
|
||||
useEffect(() => {
|
||||
if (!admin) return
|
||||
void (async () => {
|
||||
try {
|
||||
const settings = await fetchSettings()
|
||||
setRetentionDays(Number(settings.audit_retention_days ?? 0) || 0)
|
||||
} catch {
|
||||
/* 读取失败时保持默认 0(永久保留),不打断主流程 */
|
||||
}
|
||||
})()
|
||||
}, [admin])
|
||||
|
||||
async function handleSaveRetention() {
|
||||
setSavingRetention(true)
|
||||
try {
|
||||
await updateSettings({ audit_retention_days: String(retentionDays) })
|
||||
Message.success(retentionDays > 0 ? `已设置:保留最近 ${retentionDays} 天` : '已设置:永久保留')
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '保存保留期失败'))
|
||||
} finally {
|
||||
setSavingRetention(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
setExporting(true)
|
||||
try {
|
||||
@@ -171,6 +202,27 @@ export function AuditLogsPage() {
|
||||
style={{ paddingBottom: 0 }}
|
||||
title="审计日志"
|
||||
subTitle="记录系统中所有关键操作,保障数据操作链可溯源。支持高级筛选与 CSV 导出(最多 10000 行)。"
|
||||
extra={
|
||||
admin ? (
|
||||
<Space>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
日志保留期
|
||||
</Typography.Text>
|
||||
<InputNumber
|
||||
style={{ width: 150 }}
|
||||
min={0}
|
||||
max={3650}
|
||||
value={retentionDays}
|
||||
onChange={(value) => setRetentionDays(Number(value) || 0)}
|
||||
suffix="天"
|
||||
placeholder="0=永久"
|
||||
/>
|
||||
<Button type="primary" size="small" loading={savingRetention} onClick={() => void handleSaveRetention()}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{error ? <Typography.Text type="error">{error}</Typography.Text> : null}
|
||||
<Space wrap>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Button, Card, Empty, Message, Select, Space, Table, Tag, Typography } f
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { BackupRecordLogDrawer } from '../../components/backup-records/BackupRecordLogDrawer'
|
||||
import { listBackupRecords } from '../../services/backup-records'
|
||||
import { listBackupRecords, setBackupRecordLock } from '../../services/backup-records'
|
||||
import { listBackupTasks } from '../../services/backup-tasks'
|
||||
import type { BackupRecordStatus, BackupRecordSummary } from '../../types/backup-records'
|
||||
import type { BackupTaskSummary } from '../../types/backup-tasks'
|
||||
@@ -33,6 +33,7 @@ export function BackupRecordsPage() {
|
||||
const [tasks, setTasks] = useState<BackupTaskSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [lockBusyId, setLockBusyId] = useState<number | null>(null)
|
||||
|
||||
const selectedTaskId = Number(searchParams.get('taskId') ?? 0) || undefined
|
||||
const selectedRecordId = Number(searchParams.get('recordId') ?? 0) || undefined
|
||||
@@ -64,6 +65,19 @@ export function BackupRecordsPage() {
|
||||
void loadData()
|
||||
}, [loadData])
|
||||
|
||||
async function handleToggleLock(record: BackupRecordSummary) {
|
||||
setLockBusyId(record.id)
|
||||
try {
|
||||
await setBackupRecordLock(record.id, !record.locked)
|
||||
Message.success(record.locked ? '已解除保留锁定' : '已锁定:该备份将豁免保留清理与删除')
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '操作失败'))
|
||||
} finally {
|
||||
setLockBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
function updateSearchParam(key: 'taskId' | 'status' | 'recordId', value?: string) {
|
||||
const nextParams = new URLSearchParams(searchParams)
|
||||
if (!value || value === '0') {
|
||||
@@ -96,7 +110,11 @@ export function BackupRecordsPage() {
|
||||
dataIndex: 'fileName',
|
||||
render: (_: unknown, record: BackupRecordSummary) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text>{record.fileName || '-'}</Typography.Text>
|
||||
<Space size={4}>
|
||||
<Typography.Text>{record.fileName || '-'}</Typography.Text>
|
||||
{record.locked && <Tag color="orange" size="small" bordered>已锁定</Tag>}
|
||||
{record.backupKind === 'differential' && <Tag color="purple" size="small" bordered>差异</Tag>}
|
||||
</Space>
|
||||
<Typography.Text type="secondary">{formatBytes(record.fileSize)}</Typography.Text>
|
||||
{record.checksum && (
|
||||
<Typography.Text type="secondary" copyable style={{ fontSize: 11 }}>
|
||||
@@ -129,11 +147,24 @@ export function BackupRecordsPage() {
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'actions',
|
||||
width: 120,
|
||||
width: 180,
|
||||
render: (_: unknown, record: BackupRecordSummary) => (
|
||||
<Button size="small" type="text" onClick={() => updateSearchParam('recordId', String(record.id))}>
|
||||
查看日志
|
||||
</Button>
|
||||
<Space size={4}>
|
||||
<Button size="small" type="text" onClick={() => updateSearchParam('recordId', String(record.id))}>
|
||||
查看日志
|
||||
</Button>
|
||||
{record.status === 'success' && (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
status={record.locked ? 'warning' : 'default'}
|
||||
loading={lockBusyId === record.id}
|
||||
onClick={() => void handleToggleLock(record)}
|
||||
>
|
||||
{record.locked ? '解锁' : '锁定'}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
185
web/src/pages/reports/ReportsPage.tsx
Normal file
185
web/src/pages/reports/ReportsPage.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { Button, Card, Grid, Message, Select, Space, Statistic, Table, Tag, Typography } from '@arco-design/web-react'
|
||||
import { IconDownload, IconRefresh } from '@arco-design/web-react/icon'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { downloadComplianceCSV, fetchComplianceReport } from '../../services/reports'
|
||||
import type { ComplianceReport, ComplianceRisk, ComplianceTaskRow } from '../../types/reports'
|
||||
import { resolveErrorMessage } from '../../utils/error'
|
||||
import { formatBytes, formatDateTime, formatPercent } from '../../utils/format'
|
||||
|
||||
const { Row, Col } = Grid
|
||||
const { Title, Text } = Typography
|
||||
|
||||
const rangeOptions = [
|
||||
{ label: '近 7 天', value: 7 },
|
||||
{ label: '近 30 天', value: 30 },
|
||||
{ label: '近 90 天', value: 90 },
|
||||
{ label: '近 180 天', value: 180 },
|
||||
{ label: '近 365 天', value: 365 },
|
||||
]
|
||||
|
||||
function riskTag(risk: ComplianceRisk) {
|
||||
switch (risk) {
|
||||
case 'ok':
|
||||
return <Tag color="green">合规</Tag>
|
||||
case 'at_risk':
|
||||
return <Tag color="red">风险</Tag>
|
||||
default:
|
||||
return <Tag color="gray">未启用</Tag>
|
||||
}
|
||||
}
|
||||
|
||||
export function ReportsPage() {
|
||||
const [days, setDays] = useState(30)
|
||||
const [report, setReport] = useState<ComplianceReport | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
const loadData = useCallback(async (range: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await fetchComplianceReport(range)
|
||||
setReport(data)
|
||||
setError('')
|
||||
} catch (e) {
|
||||
setError(resolveErrorMessage(e, '加载合规报表失败'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadData(days)
|
||||
}, [days, loadData])
|
||||
|
||||
async function handleExport() {
|
||||
setExporting(true)
|
||||
try {
|
||||
await downloadComplianceCSV(days)
|
||||
Message.success('已导出 CSV')
|
||||
} catch (e) {
|
||||
Message.error(resolveErrorMessage(e, '导出失败'))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const summary = report?.summary
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '任务',
|
||||
dataIndex: 'taskName',
|
||||
render: (_: unknown, row: ComplianceTaskRow) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Text style={{ fontWeight: 600 }}>{row.taskName}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.type} · {row.nodeName || '本机'}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '状态', dataIndex: 'risk', render: (_: unknown, row: ComplianceTaskRow) => riskTag(row.risk) },
|
||||
{
|
||||
title: '成功率',
|
||||
dataIndex: 'successRate',
|
||||
render: (value: number, row: ComplianceTaskRow) => (row.totalRuns > 0 ? formatPercent(value) : '—'),
|
||||
},
|
||||
{
|
||||
title: '周期内(成功/失败)',
|
||||
dataIndex: 'totalRuns',
|
||||
render: (_: unknown, row: ComplianceTaskRow) => `${row.successes} / ${row.failures}`,
|
||||
},
|
||||
{
|
||||
title: '最近成功',
|
||||
dataIndex: 'lastSuccessAt',
|
||||
render: (value?: string) => (value ? formatDateTime(value) : <Text type="secondary">从未</Text>),
|
||||
},
|
||||
{ title: '保护量', dataIndex: 'protectedBytes', render: (value: number) => formatBytes(value) },
|
||||
{
|
||||
title: '加密',
|
||||
dataIndex: 'encrypted',
|
||||
render: (value: boolean) =>
|
||||
value ? <Tag color="arcoblue" size="small">已加密</Tag> : <Text type="secondary">否</Text>,
|
||||
},
|
||||
{ title: 'SLA(RPO)', dataIndex: 'slaHoursRpo', render: (value: number) => (value > 0 ? `${value}h` : '—') },
|
||||
]
|
||||
|
||||
const statCards = [
|
||||
{ title: '受保护任务', value: summary?.enabledTasks ?? 0, suffix: `/ ${summary?.totalTasks ?? 0}` },
|
||||
{ title: '合规任务', value: summary?.compliantTasks ?? 0, color: 'rgb(var(--green-6))' },
|
||||
{ title: '风险任务', value: summary?.atRiskTasks ?? 0, color: 'rgb(var(--red-6))' },
|
||||
{ title: '已加密任务', value: summary?.encryptedTasks ?? 0 },
|
||||
]
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
|
||||
<div>
|
||||
<Title heading={5} style={{ margin: 0 }}>
|
||||
合规报表
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{report
|
||||
? `生成于 ${formatDateTime(report.generatedAt)} · 统计窗口 ${report.rangeDays} 天`
|
||||
: '按任务的备份合规证据,可导出归档以供审计'}
|
||||
</Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Select value={days} onChange={(value) => setDays(value as number)} options={rangeOptions} style={{ width: 130 }} />
|
||||
<Button icon={<IconRefresh />} onClick={() => void loadData(days)} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" icon={<IconDownload />} onClick={() => void handleExport()} loading={exporting} disabled={loading}>
|
||||
导出 CSV
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Row gutter={16}>
|
||||
{statCards.map((card) => (
|
||||
<Col span={6} key={card.title}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={card.title}
|
||||
value={card.value}
|
||||
suffix={card.suffix}
|
||||
groupSeparator
|
||||
styleValue={card.color ? { color: card.color } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Card>
|
||||
<Statistic title="整体成功率" value={summary ? Number((summary.overallSuccessRate * 100).toFixed(1)) : 0} suffix="%" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card>
|
||||
<Statistic title="受保护数据总量" value={formatBytes(summary?.totalProtectedBytes)} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{error ? (
|
||||
<Card>
|
||||
<Text type="error">{error}</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Table
|
||||
rowKey="taskId"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
data={report?.tasks ?? []}
|
||||
pagination={{ pageSize: 20, sizeCanChange: true }}
|
||||
border={false}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { GoogleDriveCallbackPage } from '../pages/storage-targets/GoogleDriveCal
|
||||
import { StorageTargetsPage } from '../pages/storage-targets/StorageTargetsPage'
|
||||
import { SettingsPage } from '../pages/settings/SettingsPage'
|
||||
import { AuditLogsPage } from '../pages/audit/AuditLogsPage'
|
||||
import { ReportsPage } from '../pages/reports/ReportsPage'
|
||||
import NodesPage from '../pages/nodes/NodesPage'
|
||||
import { ProtectedRoute } from './ProtectedRoute'
|
||||
|
||||
@@ -32,6 +33,7 @@ export function RouterView() {
|
||||
>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="backup/tasks" element={<BackupTasksPage />} />
|
||||
<Route path="backup/records" element={<BackupRecordsPage />} />
|
||||
<Route path="restore/records" element={<RestoreRecordsPage />} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { http, getAccessToken, type ApiEnvelope, unwrapApiEnvelope } from './http'
|
||||
import type { BackupLogEvent, BackupRecordDetail, BackupRecordListFilter, BackupRecordSummary } from '../types/backup-records'
|
||||
import type { BackupLogEvent, BackupRecordContents, BackupRecordDetail, BackupRecordListFilter, BackupRecordSummary } from '../types/backup-records'
|
||||
import { resolveErrorMessage } from '../utils/error'
|
||||
|
||||
interface RecordLogStreamHandlers {
|
||||
@@ -69,6 +69,12 @@ export async function getBackupRecord(id: number) {
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
// getBackupRecordContents 获取备份记录的文件清单(内容浏览,只读)。
|
||||
export async function getBackupRecordContents(id: number) {
|
||||
const response = await http.get<ApiEnvelope<BackupRecordContents>>(`/backup/records/${id}/contents`)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export async function downloadBackupRecord(id: number) {
|
||||
const response = await http.get<Blob>(`/backup/records/${id}/download`, { responseType: 'blob' })
|
||||
return {
|
||||
@@ -89,6 +95,12 @@ export async function deleteBackupRecord(id: number) {
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
// setBackupRecordLock 设置/解除备份记录的保留锁定(法律保留)。
|
||||
export async function setBackupRecordLock(id: number, locked: boolean) {
|
||||
const response = await http.put<ApiEnvelope<BackupRecordDetail>>(`/backup/records/${id}/lock`, { locked })
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
export function streamBackupRecordLogs(recordId: number, handlers: RecordLogStreamHandlers) {
|
||||
const controller = new AbortController()
|
||||
|
||||
|
||||
24
web/src/services/reports.ts
Normal file
24
web/src/services/reports.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { http, type ApiEnvelope, unwrapApiEnvelope } from './http'
|
||||
import type { ComplianceReport } from '../types/reports'
|
||||
|
||||
export async function fetchComplianceReport(days = 30) {
|
||||
const response = await http.get<ApiEnvelope<ComplianceReport>>('/reports/compliance', { params: { days } })
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
// downloadComplianceCSV 通过带认证的 http 客户端拉取 CSV blob 并触发浏览器下载。
|
||||
export async function downloadComplianceCSV(days = 30) {
|
||||
const response = await http.get('/reports/compliance/export', {
|
||||
params: { days },
|
||||
responseType: 'blob',
|
||||
})
|
||||
const blob = new Blob([response.data], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `backupx-compliance-${days}d.csv`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -39,9 +39,19 @@ export async function getRestoreRecord(id: number) {
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
// startRestoreFromBackup 通过源备份记录启动恢复。返回新建的恢复记录详情。
|
||||
export async function startRestoreFromBackup(backupRecordId: number) {
|
||||
const response = await http.post<ApiEnvelope<RestoreRecordDetail>>(`/backup/records/${backupRecordId}/restore`)
|
||||
// startRestoreFromBackup 通过源备份记录启动恢复。两个可选项互不影响:
|
||||
// - selectedPaths 非空时为按需(选择性)恢复,仅还原选中的文件/目录;
|
||||
// - targetPath 非空时把文件归档恢复到该绝对目录而非原始路径(仅文件类型本机恢复)。
|
||||
// 返回新建的恢复记录详情。
|
||||
export async function startRestoreFromBackup(backupRecordId: number, selectedPaths?: string[], targetPath?: string) {
|
||||
const body: { selectedPaths?: string[]; targetPath?: string } = {}
|
||||
if (selectedPaths && selectedPaths.length > 0) {
|
||||
body.selectedPaths = selectedPaths
|
||||
}
|
||||
if (targetPath && targetPath.trim()) {
|
||||
body.targetPath = targetPath.trim()
|
||||
}
|
||||
const response = await http.post<ApiEnvelope<RestoreRecordDetail>>(`/backup/records/${backupRecordId}/restore`, body)
|
||||
return unwrapApiEnvelope(response.data)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,22 @@ export interface BackupRecordSummary {
|
||||
errorMessage: string
|
||||
startedAt: string
|
||||
completedAt?: string
|
||||
locked: boolean
|
||||
backupKind: 'full' | 'differential'
|
||||
}
|
||||
|
||||
export interface BackupRecordContentEntry {
|
||||
path: string
|
||||
size: number
|
||||
isDir: boolean
|
||||
}
|
||||
|
||||
export interface BackupRecordContents {
|
||||
recordId: number
|
||||
total: number
|
||||
truncated: boolean
|
||||
basedOnFull?: number
|
||||
entries: BackupRecordContentEntry[]
|
||||
}
|
||||
|
||||
export interface StorageUploadResultItem {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type BackupTaskType = 'file' | 'mysql' | 'sqlite' | 'postgresql' | 'saphana'
|
||||
export type BackupTaskType = 'file' | 'mysql' | 'sqlite' | 'postgresql' | 'saphana' | 'mongodb'
|
||||
export type BackupTaskStatus = 'idle' | 'running' | 'success' | 'failed'
|
||||
export type BackupCompression = 'gzip' | 'none'
|
||||
export type BackupCompression = 'gzip' | 'zstd' | 'none'
|
||||
export type BackupMode = 'full' | 'differential'
|
||||
|
||||
export interface BackupTaskSummary {
|
||||
id: number
|
||||
@@ -21,6 +22,12 @@ export interface BackupTaskSummary {
|
||||
compression: BackupCompression
|
||||
encrypt: boolean
|
||||
maxBackups: number
|
||||
keepDaily: number
|
||||
keepWeekly: number
|
||||
keepMonthly: number
|
||||
keepYearly: number
|
||||
backupMode: BackupMode
|
||||
diffFullIntervalDays: number
|
||||
lastRunAt?: string
|
||||
lastStatus: BackupTaskStatus
|
||||
verifyEnabled: boolean
|
||||
@@ -73,6 +80,12 @@ export interface BackupTaskPayload {
|
||||
compression: BackupCompression
|
||||
encrypt: boolean
|
||||
maxBackups: number
|
||||
keepDaily: number
|
||||
keepWeekly: number
|
||||
keepMonthly: number
|
||||
keepYearly: number
|
||||
backupMode: BackupMode
|
||||
diffFullIntervalDays: number
|
||||
/** 类型特有的扩展配置(如 SAP HANA 的 backupLevel/backupChannels 等) */
|
||||
extraConfig?: Record<string, unknown>
|
||||
verifyEnabled: boolean
|
||||
|
||||
40
web/src/types/reports.ts
Normal file
40
web/src/types/reports.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export type ComplianceRisk = 'ok' | 'at_risk' | 'not_applicable'
|
||||
|
||||
export interface ComplianceTaskRow {
|
||||
taskId: number
|
||||
taskName: string
|
||||
type: string
|
||||
enabled: boolean
|
||||
nodeName: string
|
||||
cronExpr: string
|
||||
encrypted: boolean
|
||||
retentionDays: number
|
||||
slaHoursRpo: number
|
||||
totalRuns: number
|
||||
successes: number
|
||||
failures: number
|
||||
successRate: number
|
||||
lastStatus: string
|
||||
lastRunAt?: string
|
||||
lastSuccessAt?: string
|
||||
protectedBytes: number
|
||||
compliant: boolean
|
||||
risk: ComplianceRisk
|
||||
}
|
||||
|
||||
export interface ComplianceSummary {
|
||||
totalTasks: number
|
||||
enabledTasks: number
|
||||
compliantTasks: number
|
||||
atRiskTasks: number
|
||||
encryptedTasks: number
|
||||
overallSuccessRate: number
|
||||
totalProtectedBytes: number
|
||||
}
|
||||
|
||||
export interface ComplianceReport {
|
||||
generatedAt: string
|
||||
rangeDays: number
|
||||
summary: ComplianceSummary
|
||||
tasks: ComplianceTaskRow[]
|
||||
}
|
||||
Reference in New Issue
Block a user