From c06bbec383a77965414977061d01d754cb8528eb Mon Sep 17 00:00:00 2001 From: Awuqing <3184394176@qq.com> Date: Wed, 26 Aug 2026 11:32:00 +0800 Subject: [PATCH] refactor: simplify architecture and harden lifecycle Remove obsolete implementations, centralize background task ownership and terminal-state recovery, consolidate frontend routing and log streaming, and enforce project-wide verification in CI. --- .DS_Store | Bin 6148 -> 0 bytes .github/workflows/ci.yml | 84 +- .github/workflows/release.yml | 31 +- Dockerfile | 4 +- Makefile | 3 +- docs-site/docs/development/contributing.md | 14 +- .../current/development/contributing.md | 14 +- server/.DS_Store | Bin 6148 -> 0 bytes server/Makefile | 11 +- server/cmd/backupx/agent.go | 14 + server/internal/agent/agent.go | 98 +- server/internal/agent/agent_test.go | 36 + server/internal/agent/executor.go | 117 +- server/internal/agent/executor_test.go | 29 + server/internal/agent/fs.go | 14 +- server/internal/app/app.go | 132 +- server/internal/app/app_test.go | 68 + server/internal/backint/agent.go | 22 +- server/internal/backup/command_executor.go | 37 - .../internal/backup/log_line_writer_test.go | 145 ++ server/internal/backup/mongodb_runner.go | 6 +- server/internal/backup/mysql_runner.go | 61 +- server/internal/backup/postgres_runner.go | 171 --- server/internal/backup/postgresql_runner.go | 8 +- server/internal/backup/registry.go | 12 + server/internal/backup/saphana_runner.go | 1 + server/internal/database/database.go | 115 +- server/internal/database/database_test.go | 178 +++ server/internal/http/health_handler.go | 4 +- server/internal/http/router.go | 1 + server/internal/httpapi/auth_handler.go | 98 -- server/internal/httpapi/context.go | 23 - server/internal/httpapi/middleware.go | 92 -- server/internal/httpapi/router.go | 38 - server/internal/httpapi/router_test.go | 96 -- server/internal/httpapi/system_handler.go | 25 - server/internal/lifecycle/supervisor.go | 86 ++ server/internal/lifecycle/supervisor_test.go | 54 + server/internal/metrics/collector.go | 34 +- .../metrics/collector_lifecycle_test.go | 64 + server/internal/scheduler/service.go | 16 +- server/internal/security/jwt.go | 60 - server/internal/security/jwt_test.go | 25 - server/internal/security/limiter.go | 54 - server/internal/security/secret_store.go | 93 -- server/internal/service/agent_service.go | 197 ++- server/internal/service/agent_service_test.go | 65 + .../internal/service/api_key_service_test.go | 1 + .../internal/service/audit_retention_test.go | 5 + server/internal/service/audit_service.go | 111 +- .../service/audit_service_webhook_test.go | 38 + .../service/background_monitors_test.go | 84 ++ server/internal/service/background_tasks.go | 62 + .../service/backup_execution_service.go | 241 ++-- .../service/backup_execution_service_test.go | 71 +- .../service/backup_record_lock_test.go | 1 + .../internal/service/backup_record_service.go | 4 - .../internal/service/backup_task_service.go | 19 +- .../service/backup_task_service_test.go | 101 ++ server/internal/service/cluster_version.go | 15 +- .../dashboard_notification_service_test.go | 1 + server/internal/service/dashboard_service.go | 15 +- .../google_drive_oauth_service_test.go | 1 + .../internal/service/install_token_service.go | 17 +- .../service/install_token_service_test.go | 1 + server/internal/service/node_service.go | 25 +- server/internal/service/node_service_test.go | 1 + .../internal/service/notification_service.go | 10 +- .../internal/service/replication_service.go | 85 +- .../service/replication_service_test.go | 10 +- .../internal/service/report_service_test.go | 1 + server/internal/service/restore_service.go | 64 +- .../internal/service/restore_service_test.go | 76 +- server/internal/service/retention_service.go | 71 - .../service/storage_target_service.go | 37 +- .../internal/service/task_export_service.go | 3 +- .../internal/service/task_template_service.go | 4 - server/internal/service/test_database_test.go | 22 + .../internal/service/verification_service.go | 47 +- .../service/verification_service_test.go | 53 +- server/internal/storage/rclone/registry.go | 21 + server/internal/storage/registry.go | 132 +- server/internal/storage/types.go | 2 + server/pkg/response/response.go | 44 +- server/pkg/response/response_test.go | 39 + web/.prettierignore | 4 + web/.prettierrc.json | 6 + web/eslint.config.js | 38 + web/package-lock.json | 1275 ++++++++++++++++- web/package.json | 12 +- web/src/app.tsx | 13 - web/src/components/CronInput/CronInput.tsx | 52 +- .../components/account/AccountControls.tsx | 69 + .../account/ChangePasswordModal.tsx | 71 + .../components/account/MfaSettingsModal.tsx | 501 +++++++ web/src/components/admin/AdminDataSection.tsx | 9 +- web/src/components/auth-guard.test.tsx | 58 - web/src/components/auth-guard.tsx | 25 - .../BackupRecordContentsModal.tsx | 40 +- .../backup-records/BackupRecordLogDrawer.tsx | 109 +- .../backup-tasks/BackupTaskDetailDrawer.tsx | 62 +- .../backup-tasks/BackupTaskFormDrawer.tsx | 356 +++-- .../backup-tasks/SourceServerSelector.test.ts | 7 +- .../backup-tasks/SourceServerSelector.tsx | 18 +- .../backup-tasks/TaskDependencyGraph.tsx | 18 +- web/src/components/common/DatabasePicker.tsx | 24 +- web/src/components/common/DirectoryPicker.tsx | 92 +- web/src/components/common/EventCenter.tsx | 44 +- web/src/components/common/GlobalSearch.tsx | 29 +- .../components/common/LanguageSwitcher.tsx | 7 +- .../icons/BackupServerIllustration.tsx | 73 +- .../notifications/NotificationFormDrawer.tsx | 116 +- .../components/notifications/field-config.ts | 57 +- web/src/components/page-card.tsx | 14 - .../restore-records/RestoreConfirmModal.tsx | 70 +- .../RestoreRecordLogDrawer.tsx | 29 +- .../StorageTargetFormDrawer.tsx | 207 ++- .../storage-targets/field-config.ts | 214 ++- .../VerificationRecordLogDrawer.tsx | 31 +- web/src/i18n.ts | 4 +- web/src/layouts/AppLayout.tsx | 578 +------- web/src/layouts/protected-layout.tsx | 84 -- web/src/pages/admin/AdminLayout.tsx | 4 +- web/src/pages/admin/ApiKeysPage.test.ts | 19 +- web/src/pages/admin/ApiKeysPage.tsx | 122 +- web/src/pages/admin/UsersPage.tsx | 198 ++- web/src/pages/audit/AuditLogsPage.tsx | 103 +- .../backup-records/BackupRecordsPage.tsx | 113 +- .../pages/backup-tasks/BackupTasksPage.tsx | 257 +++- web/src/pages/dashboard/DashboardPage.tsx | 654 ++++++--- web/src/pages/dashboard/page.tsx | 30 - web/src/pages/login/LoginPage.test.tsx | 15 +- web/src/pages/login/LoginPage.tsx | 226 ++- web/src/pages/login/page.tsx | 79 - web/src/pages/nodes/AgentInstallWizard.tsx | 51 +- web/src/pages/nodes/BatchCommandTable.tsx | 66 +- web/src/pages/nodes/NodesPage.tsx | 245 +++- web/src/pages/nodes/installCommands.test.ts | 15 +- web/src/pages/nodes/installCommands.ts | 78 +- .../pages/nodes/useAgentDeployFlow.test.ts | 19 +- web/src/pages/nodes/useAgentDeployFlow.ts | 25 +- .../wizard/AgentConnectionOptions.test.ts | 18 +- .../nodes/wizard/AgentConnectionOptions.tsx | 51 +- .../nodes/wizard/InstallCommandBlock.tsx | 33 +- web/src/pages/nodes/wizard/Step1NodeName.tsx | 11 +- .../pages/nodes/wizard/Step2DeployOptions.tsx | 11 +- .../nodes/wizard/Step3CommandPreview.tsx | 77 +- .../pages/notifications/NotificationsPage.tsx | 81 +- .../ReplicationRecordsPage.tsx | 95 +- web/src/pages/reports/ReportsPage.tsx | 76 +- .../restore-records/RestoreRecordsPage.tsx | 61 +- web/src/pages/settings/SettingsPage.tsx | 119 +- .../GoogleDriveCallbackPage.tsx | 13 +- .../storage-targets/StorageTargetsPage.tsx | 129 +- web/src/pages/system-info/SystemInfoPage.tsx | 67 - web/src/pages/system-info/page.tsx | 97 -- .../task-templates/TaskTemplatesPage.tsx | 212 ++- .../VerificationRecordsPage.tsx | 54 +- web/src/router/index.tsx | 50 +- web/src/router/routes.test.tsx | 21 + web/src/router/routes.tsx | 164 +++ web/src/services/api-keys.ts | 4 +- web/src/services/audit.ts | 5 +- web/src/services/auth.ts | 98 +- web/src/services/backup-records.ts | 133 +- web/src/services/backup-tasks.ts | 21 +- web/src/services/dashboard.ts | 21 +- web/src/services/database.ts | 6 +- web/src/services/log-stream.test.ts | 102 ++ web/src/services/log-stream.ts | 115 ++ web/src/services/nodes.ts | 15 +- web/src/services/notifications.ts | 18 +- web/src/services/replication-records.ts | 13 +- web/src/services/reports.ts | 4 +- web/src/services/restore-records.ts | 122 +- web/src/services/storage-targets.ts | 32 +- web/src/services/system.ts | 17 +- web/src/services/task-templates.ts | 5 +- web/src/services/verification-records.ts | 102 +- web/src/stores/auth.test.ts | 52 - web/src/stores/auth.ts | 9 +- web/src/styles/global.css | 9 +- web/src/types/api.ts | 10 - web/src/types/auth.ts | 33 - web/src/types/system.ts | 7 - web/src/utils/permissions.ts | 2 +- web/src/utils/webauthn.ts | 28 +- web/tsconfig.json | 16 +- web/vite.config.ts | 2 - 189 files changed, 8990 insertions(+), 4271 deletions(-) delete mode 100644 .DS_Store delete mode 100644 server/.DS_Store create mode 100644 server/internal/agent/agent_test.go create mode 100644 server/internal/app/app_test.go delete mode 100644 server/internal/backup/command_executor.go create mode 100644 server/internal/backup/log_line_writer_test.go delete mode 100644 server/internal/backup/postgres_runner.go delete mode 100644 server/internal/httpapi/auth_handler.go delete mode 100644 server/internal/httpapi/context.go delete mode 100644 server/internal/httpapi/middleware.go delete mode 100644 server/internal/httpapi/router.go delete mode 100644 server/internal/httpapi/router_test.go delete mode 100644 server/internal/httpapi/system_handler.go create mode 100644 server/internal/lifecycle/supervisor.go create mode 100644 server/internal/lifecycle/supervisor_test.go create mode 100644 server/internal/metrics/collector_lifecycle_test.go delete mode 100644 server/internal/security/jwt.go delete mode 100644 server/internal/security/jwt_test.go delete mode 100644 server/internal/security/limiter.go delete mode 100644 server/internal/security/secret_store.go create mode 100644 server/internal/service/background_monitors_test.go create mode 100644 server/internal/service/background_tasks.go delete mode 100644 server/internal/service/retention_service.go create mode 100644 server/internal/service/test_database_test.go create mode 100644 server/internal/storage/rclone/registry.go create mode 100644 server/pkg/response/response_test.go create mode 100644 web/.prettierignore create mode 100644 web/.prettierrc.json create mode 100644 web/eslint.config.js delete mode 100644 web/src/app.tsx create mode 100644 web/src/components/account/AccountControls.tsx create mode 100644 web/src/components/account/ChangePasswordModal.tsx create mode 100644 web/src/components/account/MfaSettingsModal.tsx delete mode 100644 web/src/components/auth-guard.test.tsx delete mode 100644 web/src/components/auth-guard.tsx delete mode 100644 web/src/components/page-card.tsx delete mode 100644 web/src/layouts/protected-layout.tsx delete mode 100644 web/src/pages/dashboard/page.tsx delete mode 100644 web/src/pages/login/page.tsx delete mode 100644 web/src/pages/system-info/SystemInfoPage.tsx delete mode 100644 web/src/pages/system-info/page.tsx create mode 100644 web/src/router/routes.test.tsx create mode 100644 web/src/router/routes.tsx create mode 100644 web/src/services/log-stream.test.ts create mode 100644 web/src/services/log-stream.ts delete mode 100644 web/src/stores/auth.test.ts delete mode 100644 web/src/types/api.ts delete mode 100644 web/src/types/auth.ts delete mode 100644 web/src/types/system.ts diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 574bd244e6b14ab203f57a3a01983c773cf40a4b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKL2uJA6n<_?n=&Ew0Maf+OCO643|-#s6#{!3S;9ss$UrUxola@ zjIdLv9FjgBCGu^U$Z#v#4p;@O0{@x4*+# zOi#(D@y2fF=PeV&tEe<7@9L@ohRr=0`LFZ%li1N>PRJwZdpphU{}X z)yZjV7+cTx9;3MIG&s&r>Q*0{9yuTtFjvgFW~}>(wffvd78+SRb@_{ za;OqkRQU|grYh)KyOIi+-<7j-C0B13Z56Nz{7C^mA6yhh&tRcZ9UZ9D69Cx2urjpy zEx{bupl7hq2oFqXDo|5}d144n$GB_aJcETsO($U06l|+MvTDh9|0wUt*ionRe_)5f|m9G diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9d7df5..c8f1be4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v7 with: - go-version: '1.25' + go-version: "1.25" cache-dependency-path: server/go.sum - name: Verify modules @@ -54,6 +54,31 @@ jobs: working-directory: server run: go test ./... -v + backend-windows: + name: Go Test (Windows) + runs-on: windows-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version: "1.25" + cache-dependency-path: server/go.sum + + - name: Verify modules + working-directory: server + run: go mod verify + + - name: Build + working-directory: server + run: go build ./... + + - name: Test + working-directory: server + run: go test ./... + frontend: name: React Build & Test runs-on: ubuntu-latest @@ -64,14 +89,22 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v7 with: - node-version: '24' - cache: 'npm' + node-version: "24" + cache: "npm" cache-dependency-path: web/package-lock.json - name: Install dependencies working-directory: web run: npm ci + - name: Audit production dependencies + working-directory: web + run: npm audit --omit=dev --audit-level=high + + - name: Lint and formatting + working-directory: web + run: npm run lint && npm run format:check + - name: Test working-directory: web run: npm run test @@ -79,3 +112,48 @@ jobs: - name: Type Check & Build working-directory: web run: npm run build + + docs: + name: Documentation Build + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: docs-site/package-lock.json + + - name: Install dependencies + working-directory: docs-site + run: npm ci + + - name: Type Check & Build + working-directory: docs-site + run: npm run typecheck && npm run build + + container: + name: Container Build + needs: [backend, backend-windows, frontend, docs] + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + + - name: Validate Compose configuration + run: docker compose config --quiet + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build image + uses: docker/build-push-action@v7 + with: + context: . + push: false + tags: backupx:ci + cache-from: type=gha,scope=backupx-ci + cache-to: type=gha,mode=max,scope=backupx-ci diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c5fbbb6..6626680 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,11 +18,11 @@ name: Release on: push: tags: - - 'v*' + - "v*" workflow_dispatch: inputs: version: - description: '版本号(如 v1.2.3)' + description: "版本号(如 v1.2.3)" required: true type: string @@ -65,7 +65,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v7 with: - go-version: '1.25' + go-version: "1.25" cache-dependency-path: server/go.sum - name: Verify backend @@ -83,14 +83,17 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v7 with: - node-version: '24' - cache: 'npm' + node-version: "24" + cache: "npm" cache-dependency-path: web/package-lock.json - name: Verify frontend working-directory: web run: | npm ci + npm audit --omit=dev --audit-level=high + npm run lint + npm run format:check npm run test # ─── Job 1: 构建前端 ─── @@ -105,8 +108,8 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v7 with: - node-version: '24' - cache: 'npm' + node-version: "24" + cache: "npm" cache-dependency-path: web/package-lock.json - name: Install & Build @@ -143,7 +146,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v7 with: - go-version: '1.25' + go-version: "1.25" cache-dependency-path: server/go.sum - name: Download frontend artifact @@ -157,7 +160,7 @@ jobs: env: GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} - CGO_ENABLED: '0' + CGO_ENABLED: "0" run: | go build \ -trimpath \ @@ -172,13 +175,13 @@ jobs: cp backupx "${ARCHIVE_NAME}/" cp -r web/dist "${ARCHIVE_NAME}/web" cp server/config.example.yaml "${ARCHIVE_NAME}/" - cp deploy/install.sh "${ARCHIVE_NAME}/" 2>/dev/null || true - cp deploy/backupx.service "${ARCHIVE_NAME}/" 2>/dev/null || true + cp deploy/install.sh "${ARCHIVE_NAME}/" + cp deploy/backupx.service "${ARCHIVE_NAME}/" # v2.2+: 随发布包提供 Grafana dashboard 与 nginx.conf 模板 if [ -d deploy/grafana ]; then cp -r deploy/grafana "${ARCHIVE_NAME}/grafana" fi - cp deploy/nginx.conf "${ARCHIVE_NAME}/nginx.conf" 2>/dev/null || true + cp deploy/nginx.conf "${ARCHIVE_NAME}/nginx.conf" tar czf "${ARCHIVE_NAME}.tar.gz" "${ARCHIVE_NAME}" cp "${ARCHIVE_NAME}.tar.gz" "backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz" sha256sum "${ARCHIVE_NAME}.tar.gz" > "${ARCHIVE_NAME}.tar.gz.sha256" @@ -199,7 +202,7 @@ jobs: # ─── Job 3: Docker 多架构 → Docker Hub ─── build-docker: name: Build & Push Docker - needs: build-web + needs: verify runs-on: ubuntu-latest timeout-minutes: 45 steps: @@ -226,7 +229,7 @@ jobs: build-args: | VERSION=${{ env.VERSION }} tags: | - ${{ secrets.DOCKERHUB_USERNAME }}/backupx:latest ${{ secrets.DOCKERHUB_USERNAME }}/backupx:${{ env.VERSION }} + ${{ !contains(env.VERSION, '-') && format('{0}/backupx:latest', secrets.DOCKERHUB_USERNAME) || '' }} cache-from: type=gha cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index 10e0184..dcfcc13 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ ARG USE_CHINA_MIRROR=false # ---- Stage 1: Build frontend ---- -FROM node:26-alpine AS web-builder +FROM node:24-alpine AS web-builder ARG USE_CHINA_MIRROR # 国内镜像:npm 使用淘宝源 @@ -26,7 +26,7 @@ RUN npm run build # ---- Stage 2: Build backend ---- -FROM golang:1.26-alpine AS server-builder +FROM golang:1.25-alpine AS server-builder ARG USE_CHINA_MIRROR ARG VERSION=dev diff --git a/Makefile b/Makefile index a5cefa8..02bf80b 100644 --- a/Makefile +++ b/Makefile @@ -51,7 +51,8 @@ verify: verify-server verify-web verify-docs verify-server: format-check vet-server test-server build-server -verify-web: test-web build-web +verify-web: + cd web && npm run lint && npm run format:check && npm run test && npm run build verify-docs: check-docs diff --git a/docs-site/docs/development/contributing.md b/docs-site/docs/development/contributing.md index a502ed1..2471ae8 100644 --- a/docs-site/docs/development/contributing.md +++ b/docs-site/docs/development/contributing.md @@ -25,17 +25,17 @@ For significant features or refactors, open an issue first to align on scope bef ## Pull requests 1. Fork and create a topic branch (e.g. `fix/windows-path-escape`) -2. Run `make test` and make sure everything passes +2. Run `make verify` and make sure formatting, tests, builds, and documentation checks pass 3. Keep changes focused — one concern per PR -4. Write commit messages in Chinese following `类型: 简要描述` — examples: - - `功能: 新增审计日志模块` - - `修复: 目录浏览器无法进入子目录` - - `重构: 简化存储目标解密逻辑` - - Types: `功能` / `修复` / `重构` / `文档` / `构建` / `测试` +4. Write Conventional Commits with a Chinese subject — examples: + - `feat(audit): 新增审计日志模块` + - `fix(browser): 修复目录浏览器无法进入子目录` + - `refactor(storage): 简化存储目标解密逻辑` + - Types: `feat` / `fix` / `docs` / `style` / `refactor` / `perf` / `test` / `chore` 5. PR title and body in Chinese too. Describe the why and how, not just the what. ## Coding guidelines - **Go** — handle every error (no `_ = err`); use the existing logger (`zap`); no `fmt.Println` in production paths -- **TypeScript** — strict mode, no implicit any, follow existing ESLint/Prettier configs +- **TypeScript** — strict mode, no implicit any, and pass the repository ESLint and Prettier checks - **Commit scope** — one logical change per commit; don't mix drive-by cleanups with feature work diff --git a/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/development/contributing.md b/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/development/contributing.md index 0618a4a..ac9237d 100644 --- a/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/development/contributing.md +++ b/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/development/contributing.md @@ -25,17 +25,17 @@ BackupX 使用 Apache License 2.0 开源,欢迎提交 Issue 与 Pull Request ## 提交 PR 1. Fork 仓库,创建主题分支(如 `fix/windows-path-escape`) -2. 执行 `make test` 确认本地全通过 +2. 执行 `make verify`,确认格式、测试、构建和文档检查全部通过 3. 保持每个 PR 只做一件事 -4. Commit message 使用中文,格式 `类型: 简要描述`: - - `功能: 新增审计日志模块` - - `修复: 目录浏览器无法进入子目录` - - `重构: 简化存储目标解密逻辑` - - 类型:`功能` / `修复` / `重构` / `文档` / `构建` / `测试` +4. Commit message 使用 Conventional Commits,主题使用中文: + - `feat(audit): 新增审计日志模块` + - `fix(browser): 修复目录浏览器无法进入子目录` + - `refactor(storage): 简化存储目标解密逻辑` + - 类型:`feat` / `fix` / `docs` / `style` / `refactor` / `perf` / `test` / `chore` 5. PR 标题和正文同样使用中文,描述"为什么"和"怎么做",而非仅仅"做了什么" ## 代码规范 - **Go** — 所有错误必须处理(禁止 `_ = err`),日志使用现有 `zap`,禁止生产路径中出现 `fmt.Println` -- **TypeScript** — 严格模式,禁止隐式 any,遵循现有 ESLint/Prettier 配置 +- **TypeScript** — 严格模式,禁止隐式 any,并通过仓库中的 ESLint 和 Prettier 检查 - **Commit 粒度** — 每个 commit 一件事,不要把顺手的小修改和功能代码混在一起 diff --git a/server/.DS_Store b/server/.DS_Store deleted file mode 100644 index 4b8a3cb2429e7d29c4bd05681cf72059a22254fc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK&x_MQ6n@jz-PA=asIVS{fY;J(S6L}uVyy=cUX18Lr6x`7h9;TPA66-ap7nq5 z;$88!coqcj`oDP6?@gv_lil?w;?9GaZ)V>2rk!tIG8rNgt!dC8st}Qd!dNKJ1` zmm^ltBj{E?;^pZTp4JksqMz2&a%FZILUYn9Ka9fh zq+b6n3dPcyjZJIQ+OnRy`*P+^yvcOj@Di?3dWa5QMZ=?dk zpa;s6M`04kSxZinV60-@)M1saa;LIApEqtatM+cQv8dYf=5D=eU%$4uSd^_xS9fmS z?+l_ik*}4CB=DwGc2468-k@=~?x^2zyD}cK(>eHhdEFPwc3ZF2dbd;f{PF!4-mAmd zSO$Oe5BJyR{$wNcckByylteP=V~qdj2KG>5*J9MUoi8wFJwI05F-wobJQ zX7NS=qrhX$K({$-(WC_-O7gstc#oD@xM6fg>86)2|N3ZMUX@C=`D|IY@Q zo>9Ok@Lwq)N^Q5@!jkmadSr2Y*4ik~P}rEaOi_iP(#NrC@KL;tq6|Kp8$d_nGDVEQ P%!h!I!Bj?pzpB75H7D45 diff --git a/server/Makefile b/server/Makefile index 6b372ff..885ca6d 100644 --- a/server/Makefile +++ b/server/Makefile @@ -1,15 +1,10 @@ -APP_NAME=backupx -BUILD_DIR=./bin -VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") - .PHONY: build run test build: - mkdir -p $(BUILD_DIR) - go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o $(BUILD_DIR)/$(APP_NAME) ./cmd/backupx + $(MAKE) -C .. build-server run: - go run -ldflags "-X main.version=$(VERSION)" ./cmd/backupx + $(MAKE) -C .. dev-server test: - go test ./... + $(MAKE) -C .. test-server diff --git a/server/cmd/backupx/agent.go b/server/cmd/backupx/agent.go index 418a48a..3980906 100644 --- a/server/cmd/backupx/agent.go +++ b/server/cmd/backupx/agent.go @@ -9,6 +9,8 @@ import ( "syscall" "backupx/server/internal/agent" + "backupx/server/internal/config" + applogger "backupx/server/internal/logger" ) // runAgent 是 `backupx agent` 子命令入口。 @@ -59,11 +61,23 @@ func runAgent(args []string) { os.Exit(2) } + agentLogger, err := applogger.New(config.LogConfig{Level: "info"}) + if err != nil { + fmt.Fprintf(os.Stderr, "agent: init logger: %v\n", err) + os.Exit(1) + } + defer func() { + if syncErr := agentLogger.Sync(); syncErr != nil { + fmt.Fprintf(os.Stderr, "agent: flush logger: %v\n", syncErr) + } + }() + a, err := agent.New(cfg, version) if err != nil { fmt.Fprintf(os.Stderr, "agent: init: %v\n", err) os.Exit(1) } + a.SetLogger(agentLogger) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() diff --git a/server/internal/agent/agent.go b/server/internal/agent/agent.go index cf5d4d7..a75fec1 100644 --- a/server/internal/agent/agent.go +++ b/server/internal/agent/agent.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "log" "net" "os" "runtime" @@ -13,6 +12,7 @@ import ( "time" "backupx/server/internal/backup" + "go.uber.org/zap" ) // Agent 是 Agent 进程的主控制器。 @@ -21,6 +21,7 @@ type Agent struct { client *MasterClient executor *Executor version string + logger *zap.Logger mu sync.Mutex started bool @@ -44,9 +45,20 @@ func New(cfg *Config, version string) (*Agent, error) { client: client, executor: executor, version: version, + logger: zap.NewNop(), }, nil } +// SetLogger attaches the process logger used by the Agent runtime loop. +func (a *Agent) SetLogger(logger *zap.Logger) { + if logger != nil { + a.logger = logger + if a.executor != nil { + a.executor.SetLogger(logger) + } + } +} + // Run 启动 Agent 主循环,阻塞直到 ctx 被取消。 func (a *Agent) Run(ctx context.Context) error { a.mu.Lock() @@ -64,7 +76,7 @@ func (a *Agent) Run(ctx context.Context) error { if err := a.heartbeatOnce(ctx); err != nil { return fmt.Errorf("initial heartbeat failed: %w", err) } - log.Printf("[agent] connected to master %s", a.cfg.Master) + a.logger.Info("agent connected to master", zap.String("master", a.cfg.Master)) var wg sync.WaitGroup wg.Add(2) @@ -90,14 +102,17 @@ func (a *Agent) heartbeatLoop(ctx context.Context, interval time.Duration) { return case <-ticker.C: if err := a.heartbeatOnce(ctx); err != nil { - log.Printf("[agent] heartbeat failed: %v", err) + a.logger.Warn("agent heartbeat failed", zap.Error(err)) } } } } func (a *Agent) heartbeatOnce(ctx context.Context) error { - hostname, _ := os.Hostname() + hostname, err := os.Hostname() + if err != nil { + a.logger.Warn("resolve agent hostname failed", zap.Error(err)) + } req := HeartbeatRequest{ Hostname: hostname, IPAddress: detectLocalIP(), @@ -105,7 +120,7 @@ func (a *Agent) heartbeatOnce(ctx context.Context) error { OS: runtime.GOOS, Arch: runtime.GOARCH, } - _, err := a.client.Heartbeat(ctx, req) + _, err = a.client.Heartbeat(ctx, req) return err } @@ -126,13 +141,13 @@ func (a *Agent) pollLoop(ctx context.Context, interval time.Duration) { func (a *Agent) pollAndHandleOnce(ctx context.Context) { cmd, err := a.client.PollCommand(ctx) if err != nil { - log.Printf("[agent] poll command failed: %v", err) + a.logger.Warn("poll agent command failed", zap.Error(err)) return } if cmd == nil { return } - log.Printf("[agent] received command #%d type=%s", cmd.ID, cmd.Type) + a.logger.Info("agent command received", zap.Uint("command_id", cmd.ID), zap.String("command_type", cmd.Type)) switch cmd.Type { case "run_task": a.handleRunTask(ctx, cmd) @@ -146,8 +161,8 @@ func (a *Agent) pollAndHandleOnce(ctx context.Context) { a.handleDeleteStorageObject(ctx, cmd) default: msg := fmt.Sprintf("unknown command type: %s", cmd.Type) - log.Printf("[agent] %s", msg) - _ = a.client.SubmitCommandResult(ctx, cmd.ID, false, msg, nil) + a.logger.Warn("unknown agent command", zap.Uint("command_id", cmd.ID), zap.String("command_type", cmd.Type)) + a.submitCommandResult(ctx, cmd.ID, false, msg, nil) } } @@ -158,14 +173,14 @@ func (a *Agent) handleRunTask(ctx context.Context, cmd *CommandPayload) { RecordID uint `json:"recordId"` } if err := json.Unmarshal(cmd.Payload, &payload); err != nil { - _ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil) + a.submitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil) return } if err := a.executor.ExecuteRunTask(ctx, payload.TaskID, payload.RecordID); err != nil { - _ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil) + a.submitCommandResult(ctx, cmd.ID, false, err.Error(), nil) return } - _ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{ + a.submitCommandResult(ctx, cmd.ID, true, "", map[string]any{ "taskId": payload.TaskID, "recordId": payload.RecordID, }) @@ -177,18 +192,18 @@ func (a *Agent) handleRestoreRecord(ctx context.Context, cmd *CommandPayload) { RestoreRecordID uint `json:"restoreRecordId"` } if err := json.Unmarshal(cmd.Payload, &payload); err != nil { - _ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil) + a.submitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil) return } if payload.RestoreRecordID == 0 { - _ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "restoreRecordId is required", nil) + a.submitCommandResult(ctx, cmd.ID, false, "restoreRecordId is required", nil) return } if err := a.executor.ExecuteRestore(ctx, payload.RestoreRecordID); err != nil { - _ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil) + a.submitCommandResult(ctx, cmd.ID, false, err.Error(), nil) return } - _ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{ + a.submitCommandResult(ctx, cmd.ID, true, "", map[string]any{ "restoreRecordId": payload.RestoreRecordID, }) } @@ -202,23 +217,23 @@ func (a *Agent) handleDeleteStorageObject(ctx context.Context, cmd *CommandPaylo StoragePath string `json:"storagePath"` } if err := json.Unmarshal(cmd.Payload, &payload); err != nil { - _ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil) + a.submitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil) return } if strings.TrimSpace(payload.StoragePath) == "" { - _ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "storagePath is required", nil) + a.submitCommandResult(ctx, cmd.ID, false, "storagePath is required", nil) return } provider, err := a.executor.storageRegistry.Create(ctx, payload.TargetType, payload.TargetConfig) if err != nil { - _ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "create provider: "+err.Error(), nil) + a.submitCommandResult(ctx, cmd.ID, false, "create provider: "+err.Error(), nil) return } if err := provider.Delete(ctx, payload.StoragePath); err != nil { - _ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "delete object: "+err.Error(), nil) + a.submitCommandResult(ctx, cmd.ID, false, "delete object: "+err.Error(), nil) return } - _ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{"deleted": true}) + a.submitCommandResult(ctx, cmd.ID, true, "", map[string]any{"deleted": true}) } // handleDiscoverDB 处理 discover_db 命令:在 Agent 本机执行 mysql/psql 列出数据库。 @@ -231,7 +246,7 @@ func (a *Agent) handleDiscoverDB(ctx context.Context, cmd *CommandPayload) { Password string `json:"password"` } if err := json.Unmarshal(cmd.Payload, &payload); err != nil { - _ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil) + a.submitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil) return } databases, err := backup.DiscoverDatabases(ctx, backup.NewOSCommandExecutor(), backup.DiscoverRequest{ @@ -242,10 +257,10 @@ func (a *Agent) handleDiscoverDB(ctx context.Context, cmd *CommandPayload) { Password: payload.Password, }) if err != nil { - _ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil) + a.submitCommandResult(ctx, cmd.ID, false, err.Error(), nil) return } - _ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{"databases": databases}) + a.submitCommandResult(ctx, cmd.ID, true, "", map[string]any{"databases": databases}) } // handleListDir 处理 list_dir 命令(阶段四实现) @@ -254,15 +269,44 @@ func (a *Agent) handleListDir(ctx context.Context, cmd *CommandPayload) { Path string `json:"path"` } if err := json.Unmarshal(cmd.Payload, &payload); err != nil { - _ = a.client.SubmitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil) + a.submitCommandResult(ctx, cmd.ID, false, "invalid payload: "+err.Error(), nil) return } entries, err := listLocalDir(payload.Path) if err != nil { - _ = a.client.SubmitCommandResult(ctx, cmd.ID, false, err.Error(), nil) + a.submitCommandResult(ctx, cmd.ID, false, err.Error(), nil) return } - _ = a.client.SubmitCommandResult(ctx, cmd.ID, true, "", map[string]any{"entries": entries}) + a.submitCommandResult(ctx, cmd.ID, true, "", map[string]any{"entries": entries}) +} + +func (a *Agent) submitCommandResult(ctx context.Context, commandID uint, success bool, message string, data any) { + reportCtx, cancel := agentFinalizationContext(ctx) + defer cancel() + var err error + attempts := 0 +retryLoop: + for attempts < 3 { + attempts++ + err = a.client.SubmitCommandResult(reportCtx, commandID, success, message, data) + if err == nil { + return + } + if attempts < 3 { + timer := time.NewTimer(time.Duration(attempts) * 100 * time.Millisecond) + select { + case <-reportCtx.Done(): + timer.Stop() + break retryLoop + case <-timer.C: + } + } + } + a.logger.Error("submit agent command result failed", + zap.Uint("command_id", commandID), + zap.Bool("success", success), + zap.Int("attempts", attempts), + zap.Error(err)) } // 辅助函数 diff --git a/server/internal/agent/agent_test.go b/server/internal/agent/agent_test.go new file mode 100644 index 0000000..660a291 --- /dev/null +++ b/server/internal/agent/agent_test.go @@ -0,0 +1,36 @@ +package agent + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "go.uber.org/zap" +) + +func TestSubmitCommandResultRetriesWithCanceledCommandContext(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if requests.Add(1) < 3 { + http.Error(w, "temporarily unavailable", http.StatusServiceUnavailable) + return + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + agent := &Agent{ + client: NewMasterClient(server.URL, "token", false), + logger: zap.NewNop(), + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + agent.submitCommandResult(ctx, 17, false, "backup failed", nil) + + if got := requests.Load(); got != 3 { + t.Fatalf("submit requests = %d, want 3", got) + } +} diff --git a/server/internal/agent/executor.go b/server/internal/agent/executor.go index e58ac7d..255ef25 100644 --- a/server/internal/agent/executor.go +++ b/server/internal/agent/executor.go @@ -17,6 +17,7 @@ import ( "backupx/server/internal/storage" storageRclone "backupx/server/internal/storage/rclone" "backupx/server/pkg/compress" + "go.uber.org/zap" ) // Executor 负责在 Agent 本地执行命令。 @@ -25,35 +26,26 @@ type Executor struct { tempDir string backupRegistry *backup.Registry storageRegistry *storage.Registry + logger *zap.Logger } // NewExecutor 构造执行器。预先初始化 backup runner 与 storage registry。 func NewExecutor(client *MasterClient, tempDir string) *Executor { - backupRegistry := backup.NewRegistry( - backup.NewFileRunner(), - backup.NewSQLiteRunner(), - backup.NewMySQLRunner(nil), - backup.NewPostgreSQLRunner(nil), - backup.NewSAPHANARunner(nil), - backup.NewMongoDBRunner(nil), - ) - storageRegistry := storage.NewRegistry( - storageRclone.NewLocalDiskFactory(), - storageRclone.NewS3Factory(), - storageRclone.NewWebDAVFactory(), - storageRclone.NewGoogleDriveFactory(), - storageRclone.NewAliyunOSSFactory(), - storageRclone.NewTencentCOSFactory(), - storageRclone.NewQiniuKodoFactory(), - storageRclone.NewFTPFactory(), - storageRclone.NewRcloneFactory(), - ) - storageRclone.RegisterAllBackends(storageRegistry) + backupRegistry := backup.NewDefaultRegistry() + storageRegistry := storageRclone.NewDefaultRegistry() return &Executor{ client: client, tempDir: tempDir, backupRegistry: backupRegistry, storageRegistry: storageRegistry, + logger: zap.NewNop(), + } +} + +// SetLogger attaches the Agent process logger to execution and reporting paths. +func (e *Executor) SetLogger(logger *zap.Logger) { + if logger != nil { + e.logger = logger } } @@ -90,7 +82,7 @@ func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) er } // 3) 运行 runner - logger := newRecordLogger(ctx, e.client, recordID) + logger := newRecordLogger(ctx, e.client, e.logger, recordID) result, err := runner.Run(ctx, backupSpec, logger) if err != nil { e.reportRecordFailure(ctx, recordID, err.Error()) @@ -177,7 +169,9 @@ func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) er } // 6) 上报最终成功 - return e.client.UpdateRecord(ctx, recordID, RecordUpdate{ + reportCtx, cancel := agentFinalizationContext(ctx) + defer cancel() + if err := e.client.UpdateRecord(reportCtx, recordID, RecordUpdate{ Status: "success", FileName: fileName, FileSize: fileSize, @@ -187,7 +181,10 @@ func (e *Executor) ExecuteRunTask(ctx context.Context, taskID, recordID uint) er StorageTransferMode: selectedStorageTransferMode, StorageUploadResults: uploadResults, LogAppend: fmt.Sprintf("[agent] 任务完成,总计 %d 字节\n", fileSize), - }) + }); err != nil { + return fmt.Errorf("report backup success to master: %w", err) + } + return nil } // uploadToTarget 上传单个目标。为保持简化不做上传级重试(rclone 本身已有 low-level 重试)。 @@ -222,7 +219,11 @@ func (e *Executor) uploadToTarget(ctx context.Context, recordID uint, target Sto // appendLog 追加日志到 Master 记录(尽力而为,失败不中断主流程) func (e *Executor) appendLog(ctx context.Context, recordID uint, line string) { - _ = e.client.UpdateRecord(ctx, recordID, RecordUpdate{LogAppend: line}) + if err := e.client.UpdateRecord(ctx, recordID, RecordUpdate{LogAppend: line}); err != nil { + e.logger.Warn("append backup record log to master failed", + zap.Uint("record_id", recordID), + zap.Error(err)) + } } // reportRecordFailure 上报失败状态 @@ -231,12 +232,27 @@ func (e *Executor) reportRecordFailure(ctx context.Context, recordID uint, msg s } func (e *Executor) reportRecordFailureWithUploadResults(ctx context.Context, recordID uint, msg string, uploadResults []StorageResultItem) { - _ = e.client.UpdateRecord(ctx, recordID, RecordUpdate{ + reportCtx, cancel := agentFinalizationContext(ctx) + defer cancel() + if err := e.client.UpdateRecord(reportCtx, recordID, RecordUpdate{ Status: "failed", ErrorMessage: msg, StorageUploadResults: uploadResults, LogAppend: fmt.Sprintf("[agent] 错误: %s\n", msg), - }) + }); err != nil { + e.logger.Error("report backup failure to master failed", + zap.Uint("record_id", recordID), + zap.Error(err)) + } +} + +// agentFinalizationContext lets terminal state reach the Master even when the +// command context was canceled, while bounding shutdown/network delays. +func agentFinalizationContext(ctx context.Context) (context.Context, context.CancelFunc) { + if ctx == nil { + ctx = context.Background() + } + return context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) } // buildBackupTaskSpec 把 AgentTaskSpec 转换为 backup.TaskSpec。 @@ -301,30 +317,40 @@ func compactStringList(items []string) []string { type recordLogger struct { ctx context.Context client *MasterClient + logger *zap.Logger recordID uint } -func newRecordLogger(ctx context.Context, client *MasterClient, recordID uint) *recordLogger { - return &recordLogger{ctx: ctx, client: client, recordID: recordID} +func newRecordLogger(ctx context.Context, client *MasterClient, logger *zap.Logger, recordID uint) *recordLogger { + return &recordLogger{ctx: ctx, client: client, logger: logger, recordID: recordID} } func (l *recordLogger) WriteLine(message string) { - _ = l.client.UpdateRecord(l.ctx, l.recordID, RecordUpdate{LogAppend: message + "\n"}) + if err := l.client.UpdateRecord(l.ctx, l.recordID, RecordUpdate{LogAppend: message + "\n"}); err != nil { + l.logger.Warn("append backup runner log to master failed", + zap.Uint("record_id", l.recordID), + zap.Error(err)) + } } // restoreLogger 把 runner 日志回传到 Master 恢复记录。 type restoreLogger struct { ctx context.Context client *MasterClient + logger *zap.Logger restoreID uint } -func newRestoreLogger(ctx context.Context, client *MasterClient, restoreID uint) *restoreLogger { - return &restoreLogger{ctx: ctx, client: client, restoreID: restoreID} +func newRestoreLogger(ctx context.Context, client *MasterClient, logger *zap.Logger, restoreID uint) *restoreLogger { + return &restoreLogger{ctx: ctx, client: client, logger: logger, restoreID: restoreID} } func (l *restoreLogger) WriteLine(message string) { - _ = l.client.UpdateRestore(l.ctx, l.restoreID, RestoreUpdate{LogAppend: message + "\n"}) + if err := l.client.UpdateRestore(l.ctx, l.restoreID, RestoreUpdate{LogAppend: message + "\n"}); err != nil { + l.logger.Warn("append restore runner log to master failed", + zap.Uint("restore_record_id", l.restoreID), + zap.Error(err)) + } } // DeleteStorageObject 在 Agent 本机上删除指定存储对象(供跨节点清理调用)。 @@ -453,29 +479,44 @@ func (e *Executor) ExecuteRestore(ctx context.Context, restoreRecordID uint) err e.reportRestoreFailure(ctx, restoreRecordID, fmt.Sprintf("不支持的备份类型: %v", err)) return err } - logger := newRestoreLogger(ctx, e.client, restoreRecordID) + logger := newRestoreLogger(ctx, e.client, e.logger, restoreRecordID) if err := runner.Restore(ctx, taskSpec, preparedPath, logger); err != nil { e.reportRestoreFailure(ctx, restoreRecordID, err.Error()) return err } // 5) 上报成功 - return e.client.UpdateRestore(ctx, restoreRecordID, RestoreUpdate{ + reportCtx, cancel := agentFinalizationContext(ctx) + defer cancel() + if err := e.client.UpdateRestore(reportCtx, restoreRecordID, RestoreUpdate{ Status: "success", LogAppend: "[agent] 恢复执行完成\n", - }) + }); err != nil { + return fmt.Errorf("report restore success to master: %w", err) + } + return nil } func (e *Executor) appendRestoreLog(ctx context.Context, restoreID uint, line string) { - _ = e.client.UpdateRestore(ctx, restoreID, RestoreUpdate{LogAppend: line}) + if err := e.client.UpdateRestore(ctx, restoreID, RestoreUpdate{LogAppend: line}); err != nil { + e.logger.Warn("append restore log to master failed", + zap.Uint("restore_record_id", restoreID), + zap.Error(err)) + } } func (e *Executor) reportRestoreFailure(ctx context.Context, restoreID uint, msg string) { - _ = e.client.UpdateRestore(ctx, restoreID, RestoreUpdate{ + reportCtx, cancel := agentFinalizationContext(ctx) + defer cancel() + if err := e.client.UpdateRestore(reportCtx, restoreID, RestoreUpdate{ Status: "failed", ErrorMessage: msg, LogAppend: fmt.Sprintf("[agent] 错误: %s\n", msg), - }) + }); err != nil { + e.logger.Error("report restore failure to master failed", + zap.Uint("restore_record_id", restoreID), + zap.Error(err)) + } } // buildRestoreBackupTaskSpec 把 RestoreSpec 转成 backup.TaskSpec。 diff --git a/server/internal/agent/executor_test.go b/server/internal/agent/executor_test.go index b197418..029ff4f 100644 --- a/server/internal/agent/executor_test.go +++ b/server/internal/agent/executor_test.go @@ -18,8 +18,35 @@ import ( "time" "backupx/server/internal/storage" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" ) +func TestReportRecordFailureUsesFinalizationContextAndLogsUpdateError(t *testing.T) { + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + http.Error(w, "master unavailable", http.StatusServiceUnavailable) + })) + defer server.Close() + + core, observed := observer.New(zapcore.ErrorLevel) + executor := NewExecutor(NewMasterClient(server.URL, "token", false), t.TempDir()) + executor.SetLogger(zap.New(core)) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + executor.reportRecordFailure(ctx, 42, "backup failed") + + if requestCount != 1 { + t.Fatalf("terminal update requests = %d, want 1 despite canceled command context", requestCount) + } + if observed.Len() != 1 || observed.All()[0].Message != "report backup failure to master failed" { + t.Fatalf("observed logs = %#v", observed.All()) + } +} + func TestBuildBackupTaskSpecParsesJSONSourcePaths(t *testing.T) { spec := &TaskSpec{ TaskID: 7, @@ -323,6 +350,8 @@ func (f *agentTestStorageFactory) Type() storage.ProviderType { return "agent_test_storage" } +func (f *agentTestStorageFactory) SensitiveFields() []string { return nil } + func (f *agentTestStorageFactory) New(_ context.Context, config map[string]any) (storage.StorageProvider, error) { name, _ := config["name"].(string) provider := f.providers[name] diff --git a/server/internal/agent/fs.go b/server/internal/agent/fs.go index d76d990..72c2138 100644 --- a/server/internal/agent/fs.go +++ b/server/internal/agent/fs.go @@ -20,7 +20,7 @@ type DirEntry struct { func listLocalDir(path string) ([]DirEntry, error) { cleaned := filepath.Clean(strings.TrimSpace(path)) if strings.TrimSpace(path) == "" || cleaned == "." { - cleaned = "/" + cleaned = localFilesystemRoot() } entries, err := os.ReadDir(cleaned) if err != nil { @@ -48,3 +48,15 @@ func listLocalDir(path string) ([]DirEntry, error) { }) return result, nil } + +func localFilesystemRoot() string { + root := string(os.PathSeparator) + workingDir, err := os.Getwd() + if err != nil { + return root + } + if volume := filepath.VolumeName(workingDir); volume != "" { + return volume + root + } + return root +} diff --git a/server/internal/app/app.go b/server/internal/app/app.go index 2cfac10..0bf6413 100644 --- a/server/internal/app/app.go +++ b/server/internal/app/app.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" stdhttp "net/http" + "sync" "time" "backupx/server/internal/backup" @@ -12,6 +13,7 @@ import ( "backupx/server/internal/config" "backupx/server/internal/database" aphttp "backupx/server/internal/http" + "backupx/server/internal/lifecycle" "backupx/server/internal/logger" "backupx/server/internal/metrics" "backupx/server/internal/notify" @@ -19,7 +21,6 @@ import ( "backupx/server/internal/scheduler" "backupx/server/internal/security" "backupx/server/internal/service" - "backupx/server/internal/storage" "backupx/server/internal/storage/codec" storageRclone "backupx/server/internal/storage/rclone" "go.uber.org/zap" @@ -33,6 +34,10 @@ type Application struct { db *gorm.DB httpServer *stdhttp.Server scheduler *scheduler.Service + background *lifecycle.Supervisor + + shutdownMu sync.Mutex + closeOnce sync.Once } func New(ctx context.Context, cfg config.Config, version string) (*Application, error) { @@ -55,34 +60,30 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application, oauthSessionRepo := repository.NewOAuthSessionRepository(db) resolvedSecurity, err := service.ResolveSecurity(ctx, cfg.Security, systemConfigRepo) if err != nil { - return nil, fmt.Errorf("resolve security config: %w", err) + resolveErr := fmt.Errorf("resolve security config: %w", err) + if sqlDB, handleErr := db.DB(); handleErr != nil { + resolveErr = errors.Join(resolveErr, fmt.Errorf("get database handle for cleanup: %w", handleErr)) + } else if closeErr := sqlDB.Close(); closeErr != nil { + resolveErr = errors.Join(resolveErr, fmt.Errorf("close database after bootstrap failure: %w", closeErr)) + } + return nil, resolveErr } + background := lifecycle.NewSupervisor(ctx) jwtManager := security.NewJWTManager(resolvedSecurity.JWTSecret, config.MustJWTDuration(cfg.Security)) rateLimiter := security.NewLoginRateLimiter(5, time.Minute) configCipher := codec.NewConfigCipher(resolvedSecurity.EncryptionKey) authService := service.NewAuthService(userRepo, systemConfigRepo, jwtManager, rateLimiter, configCipher) systemService := service.NewSystemService(cfg, version, time.Now().UTC()) - storageRegistry := storage.NewRegistry( - storageRclone.NewLocalDiskFactory(), - storageRclone.NewS3Factory(), - storageRclone.NewWebDAVFactory(), - storageRclone.NewGoogleDriveFactory(), - storageRclone.NewAliyunOSSFactory(), - storageRclone.NewTencentCOSFactory(), - storageRclone.NewQiniuKodoFactory(), - storageRclone.NewFTPFactory(), - storageRclone.NewRcloneFactory(), - ) - // 将全部 rclone 后端注册为独立存储类型(sftp、azureblob、dropbox 等与 s3、ftp 完全平级) - storageRclone.RegisterAllBackends(storageRegistry) + storageRegistry := storageRclone.NewDefaultRegistry() storageTargetService := service.NewStorageTargetService(storageTargetRepo, oauthSessionRepo, storageRegistry, configCipher) + storageTargetService.SetBackgroundRunner(background) storageTargetService.SetBackupTaskRepository(backupTaskRepo) storageTargetService.SetBackupRecordRepository(backupRecordRepo) 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), backup.NewMongoDBRunner(nil)) + backupRunnerRegistry := backup.NewDefaultRegistry() logHub := backup.NewLogHub() retentionService := backupretention.NewService(backupRecordRepo, configCipher.Key()) notifyRegistry := notify.NewRegistry(notify.NewEmailNotifier(), notify.NewWebhookNotifier(), notify.NewTelegramNotifier()) @@ -96,6 +97,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application, storageRclone.StartAccounting(rcloneCtx) backupExecutionService := service.NewBackupExecutionService(backupTaskRepo, backupRecordRepo, storageTargetRepo, storageRegistry, backupRunnerRegistry, logHub, retentionService, configCipher, notificationService, cfg.Backup.TempDir, cfg.Backup.MaxConcurrent, cfg.Backup.Retries, cfg.Backup.BandwidthLimit) + backupExecutionService.SetBackgroundRunner(background) schedulerService := scheduler.NewService(backupTaskRepo, backupExecutionService, appLogger) backupTaskService.SetScheduler(schedulerService) // 审计日志注入延迟到 auditService 创建后(见下方) @@ -104,12 +106,15 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application, restoreRecordRepo := repository.NewRestoreRecordRepository(db) restoreLogHub := backup.NewLogHub() dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo) + dashboardService.SetBackgroundRunner(background) reportService := service.NewReportService(backupTaskRepo, backupRecordRepo) settingsService := service.NewSettingsService(systemConfigRepo) // Audit auditLogRepo := repository.NewAuditLogRepository(db) auditService := service.NewAuditService(auditLogRepo) + auditService.SetLogger(appLogger) + auditService.SetBackgroundRunner(background) authService.SetAuditService(auditService) schedulerService.SetAuditRecorder(auditService) // 审计日志外输:启动时用当前 settings 初始化 webhook,后续前端修改立即生效 @@ -125,6 +130,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application, backupTaskService.SetNodeRepository(nodeRepo) schedulerService.SetNodeRepository(nodeRepo) nodeService := service.NewNodeService(nodeRepo, version) + nodeService.SetBackgroundRunner(background) nodeService.SetTaskRepository(backupTaskRepo) if err := nodeService.EnsureLocalNode(ctx); err != nil { appLogger.Warn("failed to ensure local node", zap.Error(err)) @@ -136,12 +142,15 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application, agentCmdRepo := repository.NewAgentCommandRepository(db) nodeService.SetAgentCommandRepository(agentCmdRepo) agentService := service.NewAgentService(nodeRepo, backupTaskRepo, backupRecordRepo, storageTargetRepo, agentCmdRepo, configCipher, storageRegistry) + agentService.SetLogger(appLogger) + agentService.SetBackgroundRunner(background) agentService.SetRestoreRepository(restoreRecordRepo) agentService.StartCommandTimeoutMonitor(ctx, 30*time.Second, 10*time.Minute) // 一键部署:install token service + 后台 GC installTokenRepo := repository.NewAgentInstallTokenRepository(db) installTokenService := service.NewInstallTokenService(installTokenRepo, nodeRepo) + installTokenService.SetBackgroundRunner(background) installTokenService.StartGC(ctx, time.Hour) // 把 Agent 下发能力注入到备份执行服务,实现多节点路由 @@ -166,6 +175,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application, cfg.Backup.TempDir, cfg.Backup.MaxConcurrent, ) + restoreService.SetBackgroundRunner(background) // 验证服务:定期校验备份可恢复性(企业合规刚需) verificationRecordRepo := repository.NewVerificationRecordRepository(db) @@ -182,6 +192,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application, cfg.Backup.TempDir, cfg.Backup.MaxConcurrent, ) + verificationService.SetBackgroundRunner(background) // 验证失败通知:通过 NotificationService 的事件总线派发 verify_failed verificationService.SetNotifier(service.NewVerificationEventNotifier(notificationService)) // 恢复完成/失败事件派发(restore_success / restore_failed) @@ -206,6 +217,8 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application, nodeRepo, storageRegistry, configCipher, cfg.Backup.TempDir, cfg.Backup.MaxConcurrent, ) + replicationService.SetLogger(appLogger) + replicationService.SetBackgroundRunner(background) replicationService.SetEventDispatcher(notificationService) backupExecutionService.SetReplicationTrigger(replicationService) // 备份成功后触发下游依赖任务(任务依赖链工作流) @@ -229,6 +242,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application, // 集群版本监控:每 30 分钟扫描,节点 24 小时内只告警一次 clusterVersionMonitor := service.NewClusterVersionMonitor(nodeRepo, version) clusterVersionMonitor.SetEventDispatcher(notificationService) + clusterVersionMonitor.SetBackgroundRunner(background) clusterVersionMonitor.Start(ctx, 30*time.Minute, 24*time.Hour) // Dashboard 集群概览依赖注入 @@ -247,6 +261,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application, metrics.NewRepoSource(storageTargetRepo, backupRecordRepo, nodeRepo, backupTaskRepo, agentCmdRepo), 30*time.Second, ) + metricsCollector.SetBackgroundRunner(background) metricsCollector.Start(ctx) router := aphttp.NewRouter(aphttp.RouterDependencies{ @@ -299,13 +314,21 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application, db: db, httpServer: httpServer, scheduler: schedulerService, + background: background, }, nil } func (a *Application) Run(ctx context.Context) error { if a.scheduler != nil { - if err := a.scheduler.Start(context.Background()); err != nil { - return fmt.Errorf("start scheduler: %w", err) + runCtx := ctx + if a.background != nil { + runCtx = a.background.Context() + } + if err := a.scheduler.Start(runCtx); err != nil { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + shutdownErr := a.Shutdown(shutdownCtx) + return errors.Join(fmt.Errorf("start scheduler: %w", err), shutdownErr) } } errCh := make(chan error, 1) @@ -320,30 +343,77 @@ func (a *Application) Run(ctx context.Context) error { select { case <-ctx.Done(): - shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() a.logger.Info("shutdown signal received") - if err := a.httpServer.Shutdown(shutdownCtx); err != nil { - return fmt.Errorf("shutdown http server: %w", err) - } - if a.scheduler != nil { - if err := a.scheduler.Stop(context.Background()); err != nil { - return fmt.Errorf("stop scheduler: %w", err) - } - } - return nil + return a.Shutdown(shutdownCtx) case err := <-errCh: + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + shutdownErr := a.Shutdown(shutdownCtx) if err != nil { - return fmt.Errorf("serve http: %w", err) + return errors.Join(fmt.Errorf("serve http: %w", err), shutdownErr) } + return shutdownErr + } +} + +// Shutdown stops new scheduled and HTTP work before canceling and waiting for +// application-owned background tasks. Every phase is attempted even if an +// earlier phase fails, so a timeout cannot leave workers detached. +func (a *Application) Shutdown(ctx context.Context) error { + if a == nil { return nil } + if ctx == nil { + ctx = context.Background() + } + a.shutdownMu.Lock() + defer a.shutdownMu.Unlock() + var shutdownErrors []error + if a.scheduler != nil { + if err := a.scheduler.Stop(ctx); err != nil { + shutdownErrors = append(shutdownErrors, fmt.Errorf("stop scheduler: %w", err)) + } + } + if a.httpServer != nil { + if err := a.httpServer.Shutdown(ctx); err != nil { + shutdownErrors = append(shutdownErrors, fmt.Errorf("shutdown http server: %w", err)) + } + } + if a.background != nil { + if err := a.background.Shutdown(ctx); err != nil { + shutdownErrors = append(shutdownErrors, fmt.Errorf("stop background tasks: %w", err)) + } + } + return errors.Join(shutdownErrors...) } func (a *Application) Close() { - if a.logger != nil { - _ = a.logger.Sync() + if a == nil { + return } + a.closeOnce.Do(func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := a.Shutdown(shutdownCtx); err != nil && a.logger != nil { + a.logger.Warn("application cleanup incomplete", zap.Error(err)) + } + if a.db != nil { + if sqlDB, err := a.db.DB(); err != nil { + if a.logger != nil { + a.logger.Warn("get database handle for close failed", zap.Error(err)) + } + } else if err := sqlDB.Close(); err != nil && a.logger != nil { + a.logger.Warn("close database failed", zap.Error(err)) + } + } + if a.logger != nil { + if err := a.logger.Sync(); err != nil { + a.logger.Warn("flush logger failed", zap.Error(err)) + } + } + }) } func (a *Application) Logger() *zap.Logger { diff --git a/server/internal/app/app_test.go b/server/internal/app/app_test.go new file mode 100644 index 0000000..6917ba5 --- /dev/null +++ b/server/internal/app/app_test.go @@ -0,0 +1,68 @@ +package app + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "backupx/server/internal/lifecycle" + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" +) + +func TestApplicationCloseStopsBackgroundAndClosesDatabaseOnce(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "app.db")), &gorm.Config{}) + if err != nil { + t.Fatalf("open database: %v", err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("get database handle: %v", err) + } + + background := lifecycle.NewSupervisor(context.Background()) + started := make(chan struct{}) + finished := make(chan struct{}) + if !background.Go(func(ctx context.Context) { + close(started) + <-ctx.Done() + close(finished) + }) { + t.Fatal("expected background task to be accepted") + } + <-started + + application := &Application{db: db, logger: zap.NewNop(), background: background} + application.Close() + application.Close() + + select { + case <-finished: + default: + t.Fatal("Close returned before the background task exited") + } + if err := sqlDB.Ping(); err == nil { + t.Fatal("database remained open after Close") + } +} + +func TestApplicationShutdownCanRetryAfterWaitTimeout(t *testing.T) { + background := lifecycle.NewSupervisor(context.Background()) + release := make(chan struct{}) + if !background.Go(func(context.Context) { <-release }) { + t.Fatal("expected background task to be accepted") + } + application := &Application{logger: zap.NewNop(), background: background} + + waitCtx, cancel := context.WithCancel(context.Background()) + cancel() + if err := application.Shutdown(waitCtx); !errors.Is(err, context.Canceled) { + t.Fatalf("first Shutdown error = %v, want context.Canceled", err) + } + close(release) + if err := application.Shutdown(context.Background()); err != nil { + t.Fatalf("second Shutdown returned error: %v", err) + } +} diff --git a/server/internal/backint/agent.go b/server/internal/backint/agent.go index d41de13..592d4f2 100644 --- a/server/internal/backint/agent.go +++ b/server/internal/backint/agent.go @@ -30,7 +30,7 @@ type Agent struct { // NewAgent 构造 Agent,初始化 storage provider 与 catalog。 func NewAgent(ctx context.Context, cfg *Config) (*Agent, error) { - registry := buildStorageRegistry() + registry := storageRclone.NewDefaultRegistry() provider, err := registry.Create(ctx, cfg.StorageType, cfg.StorageConfig) if err != nil { return nil, fmt.Errorf("create storage provider: %w", err) @@ -337,23 +337,3 @@ func boolStr(b bool) string { } return "false" } - -// buildStorageRegistry 构造与主程序一致的 storage registry。 -// -// Backint Agent 作为独立 CLI 进程运行,不依赖 BackupX HTTP 服务, -// 因此这里直接引用 storage/rclone 包注册所有后端。 -func buildStorageRegistry() *storage.Registry { - registry := storage.NewRegistry( - storageRclone.NewLocalDiskFactory(), - storageRclone.NewS3Factory(), - storageRclone.NewWebDAVFactory(), - storageRclone.NewGoogleDriveFactory(), - storageRclone.NewAliyunOSSFactory(), - storageRclone.NewTencentCOSFactory(), - storageRclone.NewQiniuKodoFactory(), - storageRclone.NewFTPFactory(), - storageRclone.NewRcloneFactory(), - ) - storageRclone.RegisterAllBackends(registry) - return registry -} diff --git a/server/internal/backup/command_executor.go b/server/internal/backup/command_executor.go deleted file mode 100644 index 68b5476..0000000 --- a/server/internal/backup/command_executor.go +++ /dev/null @@ -1,37 +0,0 @@ -//go:build ignore - -package backup - -import ( - "context" - "io" - "os" - "os/exec" -) - -type CommandExecutor interface { - LookPath(file string) (string, error) - Run(ctx context.Context, name string, args []string, env map[string]string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error -} - -type OSCommandExecutor struct{} - -func NewOSCommandExecutor() *OSCommandExecutor { - return &OSCommandExecutor{} -} - -func (e *OSCommandExecutor) LookPath(file string) (string, error) { - return exec.LookPath(file) -} - -func (e *OSCommandExecutor) Run(ctx context.Context, name string, args []string, env map[string]string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error { - command := exec.CommandContext(ctx, name, args...) - command.Stdin = stdin - command.Stdout = stdout - command.Stderr = stderr - command.Env = os.Environ() - for key, value := range env { - command.Env = append(command.Env, key+"="+value) - } - return command.Run() -} diff --git a/server/internal/backup/log_line_writer_test.go b/server/internal/backup/log_line_writer_test.go new file mode 100644 index 0000000..0427b11 --- /dev/null +++ b/server/internal/backup/log_line_writer_test.go @@ -0,0 +1,145 @@ +package backup + +import ( + "context" + "io" + "os" + "reflect" + "testing" +) + +type capturingLogWriter struct { + lines []string +} + +func (w *capturingLogWriter) WriteLine(message string) { + w.lines = append(w.lines, message) +} + +func TestLogLineWriterHandlesFragmentedWrites(t *testing.T) { + log := &capturingLogWriter{} + w := newLogLineWriter(log, "tool") + + assertWrite := func(chunk string) { + t.Helper() + n, err := w.Write([]byte(chunk)) + if err != nil || n != len(chunk) { + t.Fatalf("Write(%q) = (%d, %v), want (%d, nil)", chunk, n, err, len(chunk)) + } + } + assertWrite("fir") + if len(log.lines) != 0 || string(w.pending) != "fir" { + t.Fatalf("first fragment logged or buffered incorrectly: lines=%#v pending=%q", log.lines, w.pending) + } + assertWrite("st\nsec") + if !reflect.DeepEqual(log.lines, []string{"[tool] first"}) || string(w.pending) != "sec" { + t.Fatalf("second fragment handled incorrectly: lines=%#v pending=%q", log.lines, w.pending) + } + assertWrite("ond\n") + if !reflect.DeepEqual(log.lines, []string{"[tool] first", "[tool] second"}) || len(w.pending) != 0 { + t.Fatalf("completed fragments handled incorrectly: lines=%#v pending=%q", log.lines, w.pending) + } + if got := w.collected(); got != "first\nsecond" { + t.Fatalf("collected() = %q, want complete raw output", got) + } +} + +func TestLogLineWriterEmitsMultipleCompleteLinesOnce(t *testing.T) { + log := &capturingLogWriter{} + w := newLogLineWriter(log, "tool") + + n, err := w.Write([]byte("one\ntwo\n\n three \r\n")) + if err != nil || n != len("one\ntwo\n\n three \r\n") { + t.Fatalf("Write() = (%d, %v)", n, err) + } + want := []string{"[tool] one", "[tool] two", "[tool] three"} + if !reflect.DeepEqual(log.lines, want) { + t.Fatalf("lines = %#v, want %#v", log.lines, want) + } + if len(w.pending) != 0 { + t.Fatalf("complete input left pending bytes: %q", w.pending) + } +} + +func TestLogLineWriterFlushIsIdempotentAndPreservesCollection(t *testing.T) { + log := &capturingLogWriter{} + w := newLogLineWriter(log, "tool") + _, _ = w.Write([]byte("complete\n tail ")) + + w.Flush() + w.Flush() + want := []string{"[tool] complete", "[tool] tail"} + if !reflect.DeepEqual(log.lines, want) { + t.Fatalf("lines after repeated Flush = %#v, want %#v", log.lines, want) + } + if len(w.pending) != 0 { + t.Fatalf("Flush left pending bytes: %q", w.pending) + } + if got := w.collected(); got != "complete\n tail" { + t.Fatalf("collected() = %q, want complete stderr independent of Flush", got) + } +} + +func TestPostgreSQLRunnerFlushesEachCommandTail(t *testing.T) { + executor := &fakeCommandExecutor{runFunc: func(_ string, args []string, options CommandOptions) error { + name := args[len(args)-1] + _, _ = io.WriteString(options.Stdout, name) + _, _ = io.WriteString(options.Stderr, "warning "+name) + return nil + }} + log := &capturingLogWriter{} + runner := NewPostgreSQLRunner(executor) + result, err := runner.Run(context.Background(), TaskSpec{ + Name: "pg-log-lines", + TempDir: t.TempDir(), + Database: DatabaseSpec{ + Host: "127.0.0.1", Port: 5432, User: "postgres", Names: []string{"app", "audit"}, + }, + }, log) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(result.TempDir) }) + + for _, expected := range []string{"[pg_dump] warning app", "[pg_dump] warning audit"} { + count := 0 + for _, line := range log.lines { + if line == expected { + count++ + } + } + if count != 1 { + t.Fatalf("line %q occurred %d times in %#v", expected, count, log.lines) + } + } +} + +func TestMongoDBRunnerFlushesUnterminatedStderr(t *testing.T) { + executor := &fakeCommandExecutor{runFunc: func(_ string, _ []string, options CommandOptions) error { + _, _ = io.WriteString(options.Stdout, "archive") + _, _ = io.WriteString(options.Stderr, "tail warning") + return nil + }} + log := &capturingLogWriter{} + runner := NewMongoDBRunner(executor) + result, err := runner.Run(context.Background(), TaskSpec{ + Name: "mongo-log-tail", + Database: DatabaseSpec{ + Host: "127.0.0.1", Port: 27017, Names: []string{"app"}, + }, + }, log) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(result.TempDir) }) + + count := 0 + for _, line := range log.lines { + if line == "[mongodump] tail warning" { + count++ + } + } + if count != 1 { + t.Fatalf("unterminated stderr line occurred %d times in %#v", count, log.lines) + } +} diff --git a/server/internal/backup/mongodb_runner.go b/server/internal/backup/mongodb_runner.go index 12a99c9..18623e8 100644 --- a/server/internal/backup/mongodb_runner.go +++ b/server/internal/backup/mongodb_runner.go @@ -62,8 +62,10 @@ func (r *MongoDBRunner) Run(ctx context.Context, task TaskSpec, writer LogWriter 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()) + runErr := r.executor.Run(ctx, "mongodump", args, CommandOptions{Stdout: file, Stderr: stderrWriter}) + stderrWriter.Flush() + if runErr != nil { + return nil, fmt.Errorf("run mongodump: %w: %s", runErr, stderrWriter.collected()) } info, err := file.Stat() if err != nil { diff --git a/server/internal/backup/mysql_runner.go b/server/internal/backup/mysql_runner.go index 7350697..23392ac 100644 --- a/server/internal/backup/mysql_runner.go +++ b/server/internal/backup/mysql_runner.go @@ -1,7 +1,6 @@ package backup import ( - "bufio" "bytes" "context" "fmt" @@ -70,8 +69,10 @@ func (r *MySQLRunner) Run(ctx context.Context, task TaskSpec, writer LogWriter) stderrWriter := newLogLineWriter(writer, "mysqldump") writer.WriteLine("开始执行 mysqldump") - if err := r.executor.Run(ctx, "mysqldump", args, CommandOptions{Stdout: file, Stderr: stderrWriter, Env: mysqlEnv(task.Database.Password)}); err != nil { - return nil, fmt.Errorf("run mysqldump: %w: %s", err, stderrWriter.collected()) + runErr := r.executor.Run(ctx, "mysqldump", args, CommandOptions{Stdout: file, Stderr: stderrWriter, Env: mysqlEnv(task.Database.Password)}) + stderrWriter.Flush() + if runErr != nil { + return nil, fmt.Errorf("run mysqldump: %w: %s", runErr, stderrWriter.collected()) } info, err := file.Stat() if err != nil { @@ -109,9 +110,10 @@ func mysqlEnv(password string) []string { // logLineWriter streams each line of output to a LogWriter in real-time. type logLineWriter struct { - writer LogWriter - prefix string - buf bytes.Buffer + writer LogWriter + prefix string + pending []byte + output []byte } func newLogLineWriter(w LogWriter, prefix string) *logLineWriter { @@ -119,28 +121,43 @@ func newLogLineWriter(w LogWriter, prefix string) *logLineWriter { } func (w *logLineWriter) Write(p []byte) (int, error) { - n := len(p) - w.buf.Write(p) - scanner := bufio.NewScanner(strings.NewReader(w.buf.String())) - var remaining string - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line != "" { - w.writer.WriteLine(fmt.Sprintf("[%s] %s", w.prefix, line)) + w.output = append(w.output, p...) + w.pending = append(w.pending, p...) + consumed := 0 + for { + newline := bytes.IndexByte(w.pending[consumed:], '\n') + if newline < 0 { + break } + end := consumed + newline + w.emit(w.pending[consumed:end]) + consumed = end + 1 } - // Keep any partial last line (no newline yet) - lastNl := bytes.LastIndexByte(p, '\n') - if lastNl >= 0 { - remaining = w.buf.String()[w.buf.Len()-(len(p)-lastNl-1):] - w.buf.Reset() - w.buf.WriteString(remaining) + if consumed > 0 { + copy(w.pending, w.pending[consumed:]) + w.pending = w.pending[:len(w.pending)-consumed] + } + return len(p), nil +} + +// Flush emits the final unterminated line. It is safe to call more than once. +func (w *logLineWriter) Flush() { + if len(w.pending) == 0 { + return + } + w.emit(w.pending) + w.pending = w.pending[:0] +} + +func (w *logLineWriter) emit(raw []byte) { + line := strings.TrimSpace(string(raw)) + if line != "" { + w.writer.WriteLine(fmt.Sprintf("[%s] %s", w.prefix, line)) } - return n, nil } func (w *logLineWriter) collected() string { - return strings.TrimSpace(w.buf.String()) + return strings.TrimSpace(string(w.output)) } func formatFileSize(size int64) string { diff --git a/server/internal/backup/postgres_runner.go b/server/internal/backup/postgres_runner.go deleted file mode 100644 index d7fff0d..0000000 --- a/server/internal/backup/postgres_runner.go +++ /dev/null @@ -1,171 +0,0 @@ -//go:build ignore - -package backup - -import ( - "bytes" - "context" - "fmt" - "os" - "path/filepath" - "strings" -) - -type PostgreSQLRunner struct { - executor CommandExecutor -} - -func NewPostgreSQLRunner(executor CommandExecutor) *PostgreSQLRunner { - if executor == nil { - executor = NewOSCommandExecutor() - } - return &PostgreSQLRunner{executor: executor} -} - -func (r *PostgreSQLRunner) Type() string { - return "postgresql" -} - -func (r *PostgreSQLRunner) Run(ctx context.Context, spec TaskSpec, logger LogSink) (*Result, error) { - if _, err := r.executor.LookPath("pg_dump"); err != nil { - return nil, fmt.Errorf("pg_dump is required: %w", err) - } - databases := splitDatabaseNames(spec.DBName) - if len(databases) == 0 { - return nil, fmt.Errorf("postgresql database name is required") - } - tempDir, err := CreateTaskTempDir(spec.TaskName, spec.StartedAt) - if err != nil { - return nil, err - } - if len(databases) == 1 { - return r.dumpSingleDatabase(ctx, spec, databases[0], tempDir, logger) - } - multiDumpDir := filepath.Join(tempDir, "postgres-dumps") - if err := os.MkdirAll(multiDumpDir, 0o755); err != nil { - return nil, fmt.Errorf("create postgres multi dump directory: %w", err) - } - for _, databaseName := range databases { - if _, err := r.dumpDatabaseToFile(ctx, spec, databaseName, filepath.Join(multiDumpDir, sanitizeDumpName(databaseName)+".sql"), logger); err != nil { - return nil, err - } - } - fileName := BuildArtifactName(spec.TaskName, spec.StartedAt, "tar.gz") - artifactPath := filepath.Join(tempDir, fileName) - size, err := CreateTarGz(ctx, multiDumpDir, nil, artifactPath, logger) - if err != nil { - return nil, err - } - return &Result{ArtifactPath: artifactPath, FileName: fileName, Size: size, StorageKey: BuildStorageKey("postgresql", spec.StartedAt, fileName)}, nil -} - -func (r *PostgreSQLRunner) Restore(ctx context.Context, spec TaskSpec, artifactPath string, logger LogSink) error { - if _, err := r.executor.LookPath("psql"); err != nil { - return fmt.Errorf("psql is required: %w", err) - } - databases := splitDatabaseNames(spec.DBName) - if len(databases) == 0 { - return fmt.Errorf("postgresql database name is required") - } - if strings.HasSuffix(strings.ToLower(artifactPath), ".tar.gz") { - restoreDir, err := CreateTaskTempDir(spec.TaskName+"-restore", spec.StartedAt) - if err != nil { - return err - } - if err := ExtractTarGz(ctx, artifactPath, restoreDir, logger); err != nil { - return err - } - for _, databaseName := range databases { - filePath := filepath.Join(restoreDir, filepath.Base(restoreDir), sanitizeDumpName(databaseName)+".sql") - if _, err := os.Stat(filePath); err != nil { - fallback := filepath.Join(restoreDir, "postgres-dumps", sanitizeDumpName(databaseName)+".sql") - filePath = fallback - } - if err := r.restoreDatabaseFromFile(ctx, spec, databaseName, filePath, logger); err != nil { - return err - } - } - return nil - } - return r.restoreDatabaseFromFile(ctx, spec, databases[0], artifactPath, logger) -} - -func (r *PostgreSQLRunner) dumpSingleDatabase(ctx context.Context, spec TaskSpec, databaseName string, tempDir string, logger LogSink) (*Result, error) { - fileName := BuildArtifactName(spec.TaskName, spec.StartedAt, "sql") - artifactPath := filepath.Join(tempDir, fileName) - size, err := r.dumpDatabaseToFile(ctx, spec, databaseName, artifactPath, logger) - if err != nil { - return nil, err - } - return &Result{ArtifactPath: artifactPath, FileName: fileName, Size: size, StorageKey: BuildStorageKey("postgresql", spec.StartedAt, fileName)}, nil -} - -func (r *PostgreSQLRunner) dumpDatabaseToFile(ctx context.Context, spec TaskSpec, databaseName string, artifactPath string, logger LogSink) (int64, error) { - output, err := os.Create(filepath.Clean(artifactPath)) - if err != nil { - return 0, fmt.Errorf("create postgres dump file: %w", err) - } - defer output.Close() - stderr := &bytes.Buffer{} - args := []string{"-h", spec.DBHost, "-p", fmt.Sprintf("%d", spec.DBPort), "-U", spec.DBUser, "-d", databaseName, "--no-owner", "--no-privileges"} - if logger != nil { - logger.Infof("开始执行 pg_dump:%s", databaseName) - } - if err := r.executor.Run(ctx, "pg_dump", args, postgresEnv(spec.DBPassword), nil, output, stderr); err != nil { - return 0, fmt.Errorf("run pg_dump: %w: %s", err, strings.TrimSpace(stderr.String())) - } - info, err := output.Stat() - if err != nil { - return 0, fmt.Errorf("stat postgres dump file: %w", err) - } - return info.Size(), nil -} - -func (r *PostgreSQLRunner) restoreDatabaseFromFile(ctx context.Context, spec TaskSpec, databaseName string, artifactPath string, logger LogSink) error { - input, err := os.Open(filepath.Clean(artifactPath)) - if err != nil { - return fmt.Errorf("open postgres restore file: %w", err) - } - defer input.Close() - stderr := &bytes.Buffer{} - args := []string{"-h", spec.DBHost, "-p", fmt.Sprintf("%d", spec.DBPort), "-U", spec.DBUser, "-d", databaseName} - if logger != nil { - logger.Infof("开始执行 psql 恢复:%s", databaseName) - } - if err := r.executor.Run(ctx, "psql", args, postgresEnv(spec.DBPassword), input, nil, stderr); err != nil { - return fmt.Errorf("run psql restore: %w: %s", err, strings.TrimSpace(stderr.String())) - } - return nil -} - -func postgresEnv(password string) map[string]string { - if strings.TrimSpace(password) == "" { - return nil - } - return map[string]string{"PGPASSWORD": password} -} - -func splitDatabaseNames(value string) []string { - parts := strings.Split(value, ",") - result := make([]string, 0, len(parts)) - for _, part := range parts { - trimmed := strings.TrimSpace(part) - if trimmed == "" { - continue - } - result = append(result, trimmed) - } - return result -} - -func sanitizeDumpName(value string) string { - trimmed := strings.TrimSpace(strings.ToLower(value)) - trimmed = strings.ReplaceAll(trimmed, " ", "-") - trimmed = strings.ReplaceAll(trimmed, "/", "-") - trimmed = strings.ReplaceAll(trimmed, "\\", "-") - trimmed = strings.Trim(trimmed, "-._") - if trimmed == "" { - return "database" - } - return trimmed -} diff --git a/server/internal/backup/postgresql_runner.go b/server/internal/backup/postgresql_runner.go index b262c3d..82740ad 100644 --- a/server/internal/backup/postgresql_runner.go +++ b/server/internal/backup/postgresql_runner.go @@ -43,12 +43,14 @@ func (r *PostgreSQLRunner) Run(ctx context.Context, task TaskSpec, writer LogWri } writer.WriteLine(fmt.Sprintf("连接到 PostgreSQL: %s:%d", task.Database.Host, task.Database.Port)) writer.WriteLine(fmt.Sprintf("备份数据库: %s", strings.Join(dbNames, ", "))) - stderrWriter := newLogLineWriter(writer, "pg_dump") for index, name := range dbNames { args := []string{"--clean", "--if-exists", "--create", "--format=plain", "-h", task.Database.Host, "-p", strconv.Itoa(task.Database.Port), "-U", task.Database.User, "--dbname", name} writer.WriteLine(fmt.Sprintf("开始导出数据库 [%d/%d]: %s", index+1, len(dbNames), name)) - if err := r.executor.Run(ctx, "pg_dump", args, CommandOptions{Stdout: file, Stderr: stderrWriter, Env: append(os.Environ(), "PGPASSWORD="+task.Database.Password)}); err != nil { - return nil, fmt.Errorf("run pg_dump for %s: %w", name, err) + stderrWriter := newLogLineWriter(writer, "pg_dump") + runErr := r.executor.Run(ctx, "pg_dump", args, CommandOptions{Stdout: file, Stderr: stderrWriter, Env: append(os.Environ(), "PGPASSWORD="+task.Database.Password)}) + stderrWriter.Flush() + if runErr != nil { + return nil, fmt.Errorf("run pg_dump for %s: %w", name, runErr) } writer.WriteLine(fmt.Sprintf("数据库 %s 导出完成", name)) if index < len(dbNames)-1 { diff --git a/server/internal/backup/registry.go b/server/internal/backup/registry.go index fcaf60f..9e6dac4 100644 --- a/server/internal/backup/registry.go +++ b/server/internal/backup/registry.go @@ -20,6 +20,18 @@ func NewRegistry(runners ...BackupRunner) *Registry { return registry } +// NewDefaultRegistry returns the runner set shared by Master and Agent. +func NewDefaultRegistry() *Registry { + return NewRegistry( + NewFileRunner(), + NewSQLiteRunner(), + NewMySQLRunner(nil), + NewPostgreSQLRunner(nil), + NewSAPHANARunner(nil), + NewMongoDBRunner(nil), + ) +} + func (r *Registry) Register(runner BackupRunner) { if runner == nil { return diff --git a/server/internal/backup/saphana_runner.go b/server/internal/backup/saphana_runner.go index a1b4669..4aace88 100644 --- a/server/internal/backup/saphana_runner.go +++ b/server/internal/backup/saphana_runner.go @@ -304,6 +304,7 @@ func (r *SAPHANARunner) runHdbsqlWithRetry(ctx context.Context, name string, arg } stderrWriter := newLogLineWriter(writer, "hdbsql") err := r.executor.Run(ctx, name, args, CommandOptions{Stderr: stderrWriter}) + stderrWriter.Flush() if err == nil { return nil } diff --git a/server/internal/database/database.go b/server/internal/database/database.go index 91be2e1..6d170d2 100644 --- a/server/internal/database/database.go +++ b/server/internal/database/database.go @@ -1,10 +1,12 @@ package database import ( + "encoding/json" "fmt" "os" "path/filepath" "strings" + "time" "backupx/server/internal/config" "backupx/server/internal/model" @@ -30,6 +32,22 @@ func Open(cfg config.DatabaseConfig, logger *zap.Logger) (*gorm.DB, error) { if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) } + initialized := false + defer func() { + if initialized { + return + } + sqlDB, dbErr := db.DB() + if dbErr != nil { + if logger != nil { + logger.Warn("get database handle after initialization failure", zap.Error(dbErr)) + } + return + } + if closeErr := sqlDB.Close(); closeErr != nil && logger != nil { + logger.Warn("close database after initialization failure", zap.Error(closeErr)) + } + }() if err := db.AutoMigrate(&model.User{}, &model.SystemConfig{}, &model.StorageTarget{}, &model.OAuthSession{}, &model.BackupTask{}, &model.BackupRecord{}, &model.Notification{}, &model.Node{}, &model.BackupTaskStorageTarget{}, &model.AuditLog{}, &model.AgentCommand{}, &model.AgentInstallToken{}, &model.RestoreRecord{}, &model.VerificationRecord{}, &model.ApiKey{}, &model.ReplicationRecord{}, &model.TaskTemplate{}); err != nil { return nil, fmt.Errorf("migrate schema: %w", err) @@ -37,11 +55,104 @@ func Open(cfg config.DatabaseConfig, logger *zap.Logger) (*gorm.DB, error) { // 一次性数据迁移:从 backup_tasks.storage_target_id 回填到多对多中间表 var count int64 - db.Model(&model.BackupTaskStorageTarget{}).Count(&count) + if err := db.Model(&model.BackupTaskStorageTarget{}).Count(&count).Error; err != nil { + return nil, fmt.Errorf("count backup task storage target mappings: %w", err) + } if count == 0 { - db.Exec("INSERT INTO backup_task_storage_targets (backup_task_id, storage_target_id) SELECT id, storage_target_id FROM backup_tasks WHERE storage_target_id > 0") + if err := db.Exec("INSERT INTO backup_task_storage_targets (backup_task_id, storage_target_id) SELECT id, storage_target_id FROM backup_tasks WHERE storage_target_id > 0").Error; err != nil { + return nil, fmt.Errorf("backfill backup task storage target mappings: %w", err) + } + } + + reconciled, err := reconcileInterruptedOperations(db, time.Now().UTC()) + if err != nil { + return nil, fmt.Errorf("reconcile interrupted operations: %w", err) + } + if reconciled > 0 { + logger.Warn("interrupted operations marked as failed", zap.Int64("records", reconciled)) } logger.Info("database initialized", zap.String("path", cfg.Path)) + initialized = true return db, nil } + +func reconcileInterruptedOperations(db *gorm.DB, completedAt time.Time) (int64, error) { + const message = "应用在任务完成前重启,执行状态已自动收敛为失败" + var reconciled int64 + err := db.Transaction(func(tx *gorm.DB) error { + // Pending/dispatched Agent commands survive a Master restart. Their Agent + // may still be executing (or may claim the pending command after startup), + // so their linked records must not be mistaken for orphaned local work. + var activeCommands []model.AgentCommand + if err := tx.Where("status IN ? AND type IN ?", + []string{model.AgentCommandStatusPending, model.AgentCommandStatusDispatched}, + []string{model.AgentCommandTypeRunTask, model.AgentCommandTypeRestoreRecord}). + Find(&activeCommands).Error; err != nil { + return fmt.Errorf("active agent commands: %w", err) + } + activeBackupRecordIDs := make([]uint, 0, len(activeCommands)) + activeRestoreRecordIDs := make([]uint, 0, len(activeCommands)) + for i := range activeCommands { + cmd := &activeCommands[i] + switch cmd.Type { + case model.AgentCommandTypeRunTask: + var payload struct { + RecordID uint `json:"recordId"` + } + if json.Unmarshal([]byte(cmd.Payload), &payload) == nil && payload.RecordID > 0 { + activeBackupRecordIDs = append(activeBackupRecordIDs, payload.RecordID) + } + case model.AgentCommandTypeRestoreRecord: + var payload struct { + RestoreRecordID uint `json:"restoreRecordId"` + } + if json.Unmarshal([]byte(cmd.Payload), &payload) == nil && payload.RestoreRecordID > 0 { + activeRestoreRecordIDs = append(activeRestoreRecordIDs, payload.RestoreRecordID) + } + } + } + + markFailed := func(entity any, runningStatus, failedStatus string, activeAgentRecordIDs []uint) error { + query := tx.Model(entity).Where("status = ?", runningStatus) + if len(activeAgentRecordIDs) > 0 { + query = query.Where("id NOT IN ?", activeAgentRecordIDs) + } + result := query. + Updates(map[string]any{ + "status": failedStatus, + "error_message": message, + "completed_at": completedAt, + "duration_seconds": gorm.Expr("CAST(MAX(0, (julianday(?) - julianday(started_at)) * 86400) AS INTEGER)", completedAt), + }) + if result.Error != nil { + return result.Error + } + reconciled += result.RowsAffected + return nil + } + + if err := markFailed(&model.BackupRecord{}, model.BackupRecordStatusRunning, model.BackupRecordStatusFailed, activeBackupRecordIDs); err != nil { + return fmt.Errorf("backup records: %w", err) + } + if err := markFailed(&model.RestoreRecord{}, model.RestoreRecordStatusRunning, model.RestoreRecordStatusFailed, activeRestoreRecordIDs); err != nil { + return fmt.Errorf("restore records: %w", err) + } + if err := markFailed(&model.VerificationRecord{}, model.VerificationRecordStatusRunning, model.VerificationRecordStatusFailed, nil); err != nil { + return fmt.Errorf("verification records: %w", err) + } + if err := markFailed(&model.ReplicationRecord{}, model.ReplicationStatusRunning, model.ReplicationStatusFailed, nil); err != nil { + return fmt.Errorf("replication records: %w", err) + } + + result := tx.Model(&model.BackupTask{}). + Where("last_status = ? AND NOT EXISTS (SELECT 1 FROM backup_records WHERE backup_records.task_id = backup_tasks.id AND backup_records.status = ?)", model.BackupTaskStatusRunning, model.BackupRecordStatusRunning). + Update("last_status", model.BackupTaskStatusFailed) + if result.Error != nil { + return fmt.Errorf("backup tasks: %w", result.Error) + } + reconciled += result.RowsAffected + return nil + }) + return reconciled, err +} diff --git a/server/internal/database/database_test.go b/server/internal/database/database_test.go index 8cc9065..b149df0 100644 --- a/server/internal/database/database_test.go +++ b/server/internal/database/database_test.go @@ -1,11 +1,14 @@ package database import ( + "fmt" "path/filepath" "testing" + "time" "backupx/server/internal/config" "backupx/server/internal/logger" + "backupx/server/internal/model" ) func TestOpenConfiguresSQLiteForSingleMasterConcurrency(t *testing.T) { @@ -38,3 +41,178 @@ func TestOpenConfiguresSQLiteForSingleMasterConcurrency(t *testing.T) { t.Fatalf("busy_timeout = %d, want 5000", busyTimeout) } } + +func TestReconcileInterruptedOperations(t *testing.T) { + log, err := logger.New(config.LogConfig{Level: "error"}) + if err != nil { + t.Fatal(err) + } + db, err := Open(config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "reconcile.db")}, log) + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := sqlDB.Close(); err != nil { + t.Errorf("close database: %v", err) + } + }) + + startedAt := time.Now().UTC().Add(-time.Minute) + task := model.BackupTask{Name: "interrupted", Type: model.BackupTaskTypeFile, LastStatus: model.BackupTaskStatusRunning} + if err := db.Create(&task).Error; err != nil { + t.Fatal(err) + } + items := []any{ + &model.BackupRecord{TaskID: task.ID, Status: model.BackupRecordStatusRunning, StartedAt: startedAt}, + &model.RestoreRecord{TaskID: task.ID, Status: model.RestoreRecordStatusRunning, StartedAt: startedAt}, + &model.VerificationRecord{TaskID: task.ID, Status: model.VerificationRecordStatusRunning, StartedAt: startedAt}, + &model.ReplicationRecord{TaskID: task.ID, Status: model.ReplicationStatusRunning, StartedAt: startedAt}, + } + for _, item := range items { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + + completedAt := time.Now().UTC() + count, err := reconcileInterruptedOperations(db, completedAt) + if err != nil { + t.Fatal(err) + } + if count != 5 { + t.Fatalf("reconciled records = %d, want 5", count) + } + + var runningRecords int64 + for _, entity := range []any{&model.BackupRecord{}, &model.RestoreRecord{}, &model.VerificationRecord{}, &model.ReplicationRecord{}} { + if err := db.Model(entity).Where("status = ?", "running").Count(&runningRecords).Error; err != nil { + t.Fatal(err) + } + if runningRecords != 0 { + t.Fatalf("%T still has %d running records", entity, runningRecords) + } + } + if err := db.First(&task, task.ID).Error; err != nil { + t.Fatal(err) + } + if task.LastStatus != model.BackupTaskStatusFailed { + t.Fatalf("task last status = %q, want failed", task.LastStatus) + } + var backupRecord model.BackupRecord + if err := db.Where("task_id = ?", task.ID).First(&backupRecord).Error; err != nil { + t.Fatal(err) + } + if backupRecord.DurationSeconds <= 0 { + t.Fatalf("backup duration = %d, want positive duration", backupRecord.DurationSeconds) + } +} + +func TestReconcileInterruptedOperationsPreservesRemoteAgentWork(t *testing.T) { + log, err := logger.New(config.LogConfig{Level: "error"}) + if err != nil { + t.Fatal(err) + } + db, err := Open(config.DatabaseConfig{Path: filepath.Join(t.TempDir(), "remote-reconcile.db")}, log) + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := sqlDB.Close(); err != nil { + t.Errorf("close database: %v", err) + } + }) + + localNode := model.Node{Name: "local", Token: "local-token", Status: model.NodeStatusOnline, IsLocal: true} + remoteNode := model.Node{Name: "remote", Token: "remote-token", Status: model.NodeStatusOnline, IsLocal: false} + if err := db.Create(&localNode).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&remoteNode).Error; err != nil { + t.Fatal(err) + } + localTask := model.BackupTask{Name: "local-interrupted", Type: model.BackupTaskTypeFile, LastStatus: model.BackupTaskStatusRunning} + remoteTask := model.BackupTask{Name: "remote-still-running", Type: model.BackupTaskTypeFile, LastStatus: model.BackupTaskStatusRunning} + orphanRemoteTask := model.BackupTask{Name: "remote-without-command", Type: model.BackupTaskTypeFile, LastStatus: model.BackupTaskStatusRunning} + if err := db.Create(&localTask).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&remoteTask).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&orphanRemoteTask).Error; err != nil { + t.Fatal(err) + } + startedAt := time.Now().UTC().Add(-time.Minute) + localBackup := &model.BackupRecord{TaskID: localTask.ID, NodeID: localNode.ID, Status: model.BackupRecordStatusRunning, StartedAt: startedAt} + localRestore := &model.RestoreRecord{TaskID: localTask.ID, NodeID: localNode.ID, Status: model.RestoreRecordStatusRunning, StartedAt: startedAt} + remoteBackup := &model.BackupRecord{TaskID: remoteTask.ID, NodeID: remoteNode.ID, Status: model.BackupRecordStatusRunning, StartedAt: startedAt} + remoteRestore := &model.RestoreRecord{TaskID: remoteTask.ID, NodeID: remoteNode.ID, Status: model.RestoreRecordStatusRunning, StartedAt: startedAt} + orphanRemoteBackup := &model.BackupRecord{TaskID: orphanRemoteTask.ID, NodeID: remoteNode.ID, Status: model.BackupRecordStatusRunning, StartedAt: startedAt} + items := []any{localBackup, localRestore, remoteBackup, remoteRestore, orphanRemoteBackup} + for _, item := range items { + if err := db.Create(item).Error; err != nil { + t.Fatal(err) + } + } + activeCommands := []model.AgentCommand{ + {NodeID: remoteNode.ID, Type: model.AgentCommandTypeRunTask, Status: model.AgentCommandStatusDispatched, Payload: fmt.Sprintf(`{"recordId":%d}`, remoteBackup.ID)}, + {NodeID: remoteNode.ID, Type: model.AgentCommandTypeRestoreRecord, Status: model.AgentCommandStatusPending, Payload: fmt.Sprintf(`{"restoreRecordId":%d}`, remoteRestore.ID)}, + } + if err := db.Create(&activeCommands).Error; err != nil { + t.Fatal(err) + } + + count, err := reconcileInterruptedOperations(db, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if count != 5 { + t.Fatalf("reconciled rows = %d, want local and unlinked remote records plus their tasks", count) + } + + for _, entity := range []any{&model.BackupRecord{}, &model.RestoreRecord{}} { + var remoteRunning int64 + if err := db.Model(entity).Where("task_id = ? AND status = ?", remoteTask.ID, "running").Count(&remoteRunning).Error; err != nil { + t.Fatal(err) + } + if remoteRunning != 1 { + t.Fatalf("%T remote running records = %d, want 1", entity, remoteRunning) + } + var localRunning int64 + if err := db.Model(entity).Where("task_id = ? AND status = ?", localTask.ID, "running").Count(&localRunning).Error; err != nil { + t.Fatal(err) + } + if localRunning != 0 { + t.Fatalf("%T local running records = %d, want 0", entity, localRunning) + } + } + if err := db.First(&localTask, localTask.ID).Error; err != nil { + t.Fatal(err) + } + if err := db.First(&remoteTask, remoteTask.ID).Error; err != nil { + t.Fatal(err) + } + if err := db.First(&orphanRemoteTask, orphanRemoteTask.ID).Error; err != nil { + t.Fatal(err) + } + if localTask.LastStatus != model.BackupTaskStatusFailed || remoteTask.LastStatus != model.BackupTaskStatusRunning { + t.Fatalf("task statuses = local:%q remote:%q", localTask.LastStatus, remoteTask.LastStatus) + } + if orphanRemoteTask.LastStatus != model.BackupTaskStatusFailed { + t.Fatalf("unlinked remote task status = %q, want failed", orphanRemoteTask.LastStatus) + } + if err := db.First(orphanRemoteBackup, orphanRemoteBackup.ID).Error; err != nil { + t.Fatal(err) + } + if orphanRemoteBackup.Status != model.BackupRecordStatusFailed { + t.Fatalf("unlinked remote backup status = %q, want failed", orphanRemoteBackup.Status) + } +} diff --git a/server/internal/http/health_handler.go b/server/internal/http/health_handler.go index 430c483..a173695 100644 --- a/server/internal/http/health_handler.go +++ b/server/internal/http/health_handler.go @@ -46,9 +46,7 @@ func (h *HealthHandler) Ready(c *gin.Context) { checks["database"] = "error: " + err.Error() overallOK = false } else { - ctx, cancel := c.Request.Context(), func() {} - _ = cancel - if err := sqlDB.PingContext(ctx); err != nil { + if err := sqlDB.PingContext(c.Request.Context()); err != nil { checks["database"] = "ping failed: " + err.Error() overallOK = false } else { diff --git a/server/internal/http/router.go b/server/internal/http/router.go index 2c02949..f3ca532 100644 --- a/server/internal/http/router.go +++ b/server/internal/http/router.go @@ -399,6 +399,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine { func requestLogger(logger *zap.Logger) gin.HandlerFunc { return func(c *gin.Context) { + response.SetLogger(c, logger) c.Next() logger.Info("http request", zap.String("method", c.Request.Method), diff --git a/server/internal/httpapi/auth_handler.go b/server/internal/httpapi/auth_handler.go deleted file mode 100644 index 60b5486..0000000 --- a/server/internal/httpapi/auth_handler.go +++ /dev/null @@ -1,98 +0,0 @@ -//go:build ignore - -package httpapi - -import ( - "net/http" - - "backupx/server/internal/service" - "backupx/server/pkg/response" - "github.com/gin-gonic/gin" - "go.uber.org/zap" -) - -type authHandler struct { - service *service.AuthService - logger *zap.Logger -} - -type setupRequest struct { - Username string `json:"username" binding:"required,min=3,max=64"` - Password string `json:"password" binding:"required,min=8,max=128"` - DisplayName string `json:"displayName" binding:"required,min=1,max=128"` -} - -type loginRequest struct { - Username string `json:"username" binding:"required,min=3,max=64"` - Password string `json:"password" binding:"required,min=8,max=128"` -} - -func newAuthHandler(service *service.AuthService, logger *zap.Logger) *authHandler { - return &authHandler{service: service, logger: logger} -} - -func (h *authHandler) registerRoutes(router gin.IRouter, protected gin.IRouter) { - router.GET("/auth/setup/status", h.getSetupStatus) - router.POST("/auth/setup", h.setup) - router.POST("/auth/login", h.login) - protected.GET("/auth/profile", h.profile) -} - -func (h *authHandler) getSetupStatus(c *gin.Context) { - initialized, err := h.service.GetSetupStatus(c.Request.Context()) - if err != nil { - writeError(c, h.logger, err) - return - } - response.Success(c, gin.H{"initialized": initialized}) -} - -func (h *authHandler) setup(c *gin.Context) { - payload, err := bindJSON[setupRequest](c, h.logger) - if err != nil { - writeError(c, h.logger, err) - return - } - result, err := h.service.Setup(c.Request.Context(), service.SetupInput{ - Username: payload.Username, - Password: payload.Password, - DisplayName: payload.DisplayName, - }) - if err != nil { - writeError(c, h.logger, err) - return - } - c.JSON(http.StatusCreated, response.Envelope{Code: "OK", Message: "success", Data: result}) -} - -func (h *authHandler) login(c *gin.Context) { - payload, err := bindJSON[loginRequest](c, h.logger) - if err != nil { - writeError(c, h.logger, err) - return - } - result, err := h.service.Login(c.Request.Context(), service.LoginInput{ - Username: payload.Username, - Password: payload.Password, - RemoteAddr: c.ClientIP(), - }) - if err != nil { - writeError(c, h.logger, err) - return - } - response.Success(c, result) -} - -func (h *authHandler) profile(c *gin.Context) { - userID, err := getUserID(c) - if err != nil { - response.Error(c, http.StatusUnauthorized, "AUTH_UNAUTHORIZED", "认证信息无效") - return - } - result, err := h.service.GetCurrentUser(c.Request.Context(), userID) - if err != nil { - writeError(c, h.logger, err) - return - } - response.Success(c, result) -} diff --git a/server/internal/httpapi/context.go b/server/internal/httpapi/context.go deleted file mode 100644 index 2a86fd8..0000000 --- a/server/internal/httpapi/context.go +++ /dev/null @@ -1,23 +0,0 @@ -//go:build ignore - -package httpapi - -import ( - "fmt" - - "github.com/gin-gonic/gin" -) - -const claimsContextKey = "authClaims" - -func getUserID(c *gin.Context) (uint, error) { - value, ok := c.Get(claimsContextKey) - if !ok { - return 0, fmt.Errorf("missing auth claims") - } - claims, ok := value.(AuthClaims) - if !ok { - return 0, fmt.Errorf("invalid auth claims") - } - return claims.UserID, nil -} diff --git a/server/internal/httpapi/middleware.go b/server/internal/httpapi/middleware.go deleted file mode 100644 index 494298d..0000000 --- a/server/internal/httpapi/middleware.go +++ /dev/null @@ -1,92 +0,0 @@ -//go:build ignore - -package httpapi - -import ( - "errors" - "fmt" - "net/http" - "strings" - - "backupx/server/internal/apperror" - "backupx/server/internal/security" - "backupx/server/pkg/response" - "github.com/gin-gonic/gin" - "go.uber.org/zap" -) - -type AuthClaims struct { - UserID uint - Username string - Role string -} - -func Recovery(logger *zap.Logger) gin.HandlerFunc { - return func(c *gin.Context) { - defer func() { - if recovered := recover(); recovered != nil { - logger.Error("panic recovered", zap.Any("panic", recovered), zap.String("path", c.Request.URL.Path)) - response.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", "服务器内部错误") - c.Abort() - } - }() - c.Next() - } -} - -func RequestLogger(logger *zap.Logger) gin.HandlerFunc { - return func(c *gin.Context) { - c.Next() - logger.Info("http request", - zap.String("method", c.Request.Method), - zap.String("path", c.Request.URL.Path), - zap.Int("status", c.Writer.Status()), - zap.String("client_ip", c.ClientIP()), - ) - } -} - -func AuthMiddleware(jwtManager *security.JWTManager) gin.HandlerFunc { - return func(c *gin.Context) { - authorization := strings.TrimSpace(c.GetHeader("Authorization")) - if authorization == "" || !strings.HasPrefix(strings.ToLower(authorization), "bearer ") { - response.Error(c, http.StatusUnauthorized, "AUTH_UNAUTHORIZED", "缺少有效的认证令牌") - c.Abort() - return - } - tokenValue := strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer")) - if tokenValue == authorization { - tokenValue = strings.TrimSpace(strings.TrimPrefix(authorization, "bearer")) - } - claims, err := jwtManager.Parse(tokenValue) - if err != nil { - response.Error(c, http.StatusUnauthorized, "AUTH_UNAUTHORIZED", "认证令牌无效或已过期") - c.Abort() - return - } - c.Set(claimsContextKey, AuthClaims{UserID: claims.UserID, Username: claims.Username, Role: claims.Role}) - c.Next() - } -} - -func writeError(c *gin.Context, logger *zap.Logger, err error) { - var appErr *apperror.AppError - if errors.As(err, &appErr) { - if appErr.Err != nil { - logger.Warn("request failed", zap.String("code", appErr.Code), zap.Error(appErr.Err)) - } - response.Error(c, appErr.Status, appErr.Code, appErr.Message) - return - } - logger.Error("unexpected error", zap.Error(err)) - response.Error(c, http.StatusInternalServerError, "INTERNAL_ERROR", "服务器内部错误") -} - -func bindJSON[T any](c *gin.Context, logger *zap.Logger) (*T, error) { - var payload T - if err := c.ShouldBindJSON(&payload); err != nil { - logger.Warn("bind json failed", zap.Error(err)) - return nil, apperror.Wrap(http.StatusBadRequest, "INVALID_REQUEST", fmt.Sprintf("请求参数错误: %v", err), err) - } - return &payload, nil -} diff --git a/server/internal/httpapi/router.go b/server/internal/httpapi/router.go deleted file mode 100644 index 6f5cb62..0000000 --- a/server/internal/httpapi/router.go +++ /dev/null @@ -1,38 +0,0 @@ -//go:build ignore - -package httpapi - -import ( - "backupx/server/internal/security" - "backupx/server/internal/service" - "github.com/gin-gonic/gin" - "go.uber.org/zap" -) - -type Dependencies struct { - Logger *zap.Logger - AuthService *service.AuthService - SystemService *service.SystemService - JWTManager *security.JWTManager - Mode string -} - -func NewRouter(deps Dependencies) *gin.Engine { - gin.SetMode(deps.Mode) - router := gin.New() - router.Use(Recovery(deps.Logger), RequestLogger(deps.Logger)) - - api := router.Group("/api") - authHandler := newAuthHandler(deps.AuthService, deps.Logger) - systemHandler := newSystemHandler(deps.SystemService) - protected := api.Group("") - protected.Use(AuthMiddleware(deps.JWTManager)) - - authHandler.registerRoutes(api, protected) - systemHandler.registerRoutes(protected) - api.GET("/healthz", func(c *gin.Context) { - c.JSON(200, gin.H{"status": "ok"}) - }) - - return router -} diff --git a/server/internal/httpapi/router_test.go b/server/internal/httpapi/router_test.go deleted file mode 100644 index 947227c..0000000 --- a/server/internal/httpapi/router_test.go +++ /dev/null @@ -1,96 +0,0 @@ -//go:build ignore - -package httpapi - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "path/filepath" - "testing" - "time" - - "backupx/server/internal/config" - "backupx/server/internal/database" - "backupx/server/internal/logger" - "backupx/server/internal/repository" - "backupx/server/internal/security" - "backupx/server/internal/service" -) - -func TestSetupLoginProfileAndSystemInfo(t *testing.T) { - tmpDir := t.TempDir() - cfg := config.Config{ - Server: config.ServerConfig{Mode: "test"}, - Database: config.DatabaseConfig{Path: filepath.Join(tmpDir, "backupx.db")}, - Security: config.SecurityConfig{JWTSecret: "test-jwt-secret", JWTExpire: "1h", EncryptionKey: "test-encryption-key"}, - Log: config.LogConfig{Level: "error"}, - } - log, err := logger.New(cfg.Log) - if err != nil { - t.Fatalf("logger.New() error = %v", err) - } - db, err := database.Open(cfg.Database, log) - if err != nil { - t.Fatalf("database.Open() error = %v", err) - } - jwtManager := security.NewJWTManager(cfg.Security.JWTSecret, time.Hour) - authService := service.NewAuthService(repository.NewUserRepository(db), jwtManager, security.NewLoginLimiter(5, time.Minute)) - systemService := service.NewSystemService(cfg, "test", time.Now().Add(-time.Minute)) - router := NewRouter(Dependencies{Logger: log, AuthService: authService, SystemService: systemService, JWTManager: jwtManager, Mode: "test"}) - - setupBody := map[string]string{"username": "admin", "password": "super-secret", "displayName": "管理员"} - setupResp := performJSONRequest(t, router, http.MethodPost, "/api/auth/setup", setupBody, "") - if setupResp.Code != http.StatusCreated { - t.Fatalf("unexpected setup status: %d body=%s", setupResp.Code, setupResp.Body.String()) - } - var setupPayload struct { - Code string `json:"code"` - Data struct { - Token string `json:"token"` - } `json:"data"` - } - if err := json.Unmarshal(setupResp.Body.Bytes(), &setupPayload); err != nil { - t.Fatalf("decode setup response: %v", err) - } - if setupPayload.Data.Token == "" { - t.Fatal("expected token in setup response") - } - - profileResp := performJSONRequest(t, router, http.MethodGet, "/api/auth/profile", nil, setupPayload.Data.Token) - if profileResp.Code != http.StatusOK { - t.Fatalf("unexpected profile status: %d body=%s", profileResp.Code, profileResp.Body.String()) - } - - loginBody := map[string]string{"username": "admin", "password": "super-secret"} - loginResp := performJSONRequest(t, router, http.MethodPost, "/api/auth/login", loginBody, "") - if loginResp.Code != http.StatusOK { - t.Fatalf("unexpected login status: %d body=%s", loginResp.Code, loginResp.Body.String()) - } - - systemResp := performJSONRequest(t, router, http.MethodGet, "/api/system/info", nil, setupPayload.Data.Token) - if systemResp.Code != http.StatusOK { - t.Fatalf("unexpected system info status: %d body=%s", systemResp.Code, systemResp.Body.String()) - } -} - -func performJSONRequest(t *testing.T, handler http.Handler, method string, path string, payload any, token string) *httptest.ResponseRecorder { - t.Helper() - var body []byte - if payload != nil { - encoded, err := json.Marshal(payload) - if err != nil { - t.Fatalf("json.Marshal() error = %v", err) - } - body = encoded - } - request := httptest.NewRequest(method, path, bytes.NewReader(body)) - request.Header.Set("Content-Type", "application/json") - if token != "" { - request.Header.Set("Authorization", "Bearer "+token) - } - response := httptest.NewRecorder() - handler.ServeHTTP(response, request) - return response -} diff --git a/server/internal/httpapi/system_handler.go b/server/internal/httpapi/system_handler.go deleted file mode 100644 index 68cc948..0000000 --- a/server/internal/httpapi/system_handler.go +++ /dev/null @@ -1,25 +0,0 @@ -//go:build ignore - -package httpapi - -import ( - "backupx/server/internal/service" - "backupx/server/pkg/response" - "github.com/gin-gonic/gin" -) - -type systemHandler struct { - service *service.SystemService -} - -func newSystemHandler(service *service.SystemService) *systemHandler { - return &systemHandler{service: service} -} - -func (h *systemHandler) registerRoutes(protected gin.IRouter) { - protected.GET("/system/info", h.info) -} - -func (h *systemHandler) info(c *gin.Context) { - response.Success(c, h.service.GetInfo()) -} diff --git a/server/internal/lifecycle/supervisor.go b/server/internal/lifecycle/supervisor.go new file mode 100644 index 0000000..f73e323 --- /dev/null +++ b/server/internal/lifecycle/supervisor.go @@ -0,0 +1,86 @@ +package lifecycle + +import ( + "context" + "sync" +) + +// Supervisor owns application background tasks. It rejects new work once +// shutdown starts, cancels the shared task context, and waits for accepted work. +type Supervisor struct { + ctx context.Context + cancel context.CancelFunc + + mu sync.Mutex + stopping bool + wg sync.WaitGroup + done chan struct{} + stopOnce sync.Once +} + +func NewSupervisor(parent context.Context) *Supervisor { + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithCancel(parent) + return &Supervisor{ + ctx: ctx, + cancel: cancel, + done: make(chan struct{}), + } +} + +// Context is the root context passed to every accepted task. +func (s *Supervisor) Context() context.Context { + return s.ctx +} + +// Go starts task unless shutdown has begun or the root context is already +// canceled. The lock makes Add and the transition to Wait mutually exclusive. +func (s *Supervisor) Go(task func(context.Context)) bool { + if s == nil || task == nil { + return false + } + s.mu.Lock() + if s.stopping || s.ctx.Err() != nil { + s.mu.Unlock() + return false + } + s.wg.Add(1) + s.mu.Unlock() + + go func() { + defer s.wg.Done() + task(s.ctx) + }() + return true +} + +// Shutdown is idempotent. Cancellation always happens, even when waitCtx has +// already expired; callers may call Shutdown again to wait for eventual exit. +func (s *Supervisor) Shutdown(waitCtx context.Context) error { + if s == nil { + return nil + } + if waitCtx == nil { + waitCtx = context.Background() + } + s.stopOnce.Do(func() { + s.mu.Lock() + s.stopping = true + s.cancel() + s.mu.Unlock() + + go func() { + s.wg.Wait() + close(s.done) + }() + }) + + select { + case <-s.done: + return nil + case <-waitCtx.Done(): + return waitCtx.Err() + } +} diff --git a/server/internal/lifecycle/supervisor_test.go b/server/internal/lifecycle/supervisor_test.go new file mode 100644 index 0000000..50b5620 --- /dev/null +++ b/server/internal/lifecycle/supervisor_test.go @@ -0,0 +1,54 @@ +package lifecycle + +import ( + "context" + "errors" + "testing" + "time" +) + +func TestSupervisorShutdownCancelsAndWaits(t *testing.T) { + supervisor := NewSupervisor(context.Background()) + started := make(chan struct{}) + finished := make(chan struct{}) + if !supervisor.Go(func(ctx context.Context) { + close(started) + <-ctx.Done() + close(finished) + }) { + t.Fatal("expected task to be accepted") + } + <-started + + waitCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := supervisor.Shutdown(waitCtx); err != nil { + t.Fatalf("Shutdown returned error: %v", err) + } + select { + case <-finished: + default: + t.Fatal("Shutdown returned before the task finished") + } + if supervisor.Go(func(context.Context) {}) { + t.Fatal("expected task submitted after shutdown to be rejected") + } +} + +func TestSupervisorShutdownHonorsWaitContext(t *testing.T) { + supervisor := NewSupervisor(context.Background()) + release := make(chan struct{}) + if !supervisor.Go(func(context.Context) { <-release }) { + t.Fatal("expected task to be accepted") + } + + waitCtx, cancel := context.WithCancel(context.Background()) + cancel() + if err := supervisor.Shutdown(waitCtx); !errors.Is(err, context.Canceled) { + t.Fatalf("Shutdown error = %v, want context.Canceled", err) + } + close(release) + if err := supervisor.Shutdown(context.Background()); err != nil { + t.Fatalf("second Shutdown returned error: %v", err) + } +} diff --git a/server/internal/metrics/collector.go b/server/internal/metrics/collector.go index 68a2dcd..19fadea 100644 --- a/server/internal/metrics/collector.go +++ b/server/internal/metrics/collector.go @@ -17,6 +17,11 @@ type SampleSource interface { CountSLABreach(ctx context.Context) (int, error) } +// BackgroundRunner is implemented by the application lifecycle supervisor. +type BackgroundRunner interface { + Go(func(context.Context)) bool +} + // repoSource 把 repository 适配到 SampleSource。 type repoSource struct { targets repository.StorageTargetRepository @@ -90,9 +95,10 @@ func (s *repoSource) CountSLABreach(ctx context.Context) (int, error) { // Collector 周期性采集 gauge 类指标(存储用量、节点在线、SLA 违约)。 // 用后台 goroutine 驱动,避免在 /metrics 请求路径做慢 IO。 type Collector struct { - metrics *Metrics - source SampleSource - interval time.Duration + metrics *Metrics + source SampleSource + interval time.Duration + background BackgroundRunner } // NewCollector 创建周期采集器。interval=0 走默认 30s。 @@ -103,25 +109,37 @@ func NewCollector(m *Metrics, source SampleSource, interval time.Duration) *Coll return &Collector{metrics: m, source: source, interval: interval} } +func (c *Collector) SetBackgroundRunner(runner BackgroundRunner) { + c.background = runner +} + // Start 在后台运行采集循环;随 ctx 取消而终止。 // 启动时立即采一次,之后按 interval 轮询。 func (c *Collector) Start(ctx context.Context) { if c == nil || c.metrics == nil || c.source == nil { return } - go func() { - c.collect(ctx) + run := func(runCtx context.Context) { + c.collect(runCtx) ticker := time.NewTicker(c.interval) defer ticker.Stop() for { select { - case <-ctx.Done(): + case <-runCtx.Done(): return case <-ticker.C: - c.collect(ctx) + c.collect(runCtx) } } - }() + } + if c.background != nil { + c.background.Go(run) + return + } + if ctx == nil { + ctx = context.Background() + } + go run(ctx) } // collect 执行一次采样;单轮失败不影响下次。 diff --git a/server/internal/metrics/collector_lifecycle_test.go b/server/internal/metrics/collector_lifecycle_test.go new file mode 100644 index 0000000..f8a8101 --- /dev/null +++ b/server/internal/metrics/collector_lifecycle_test.go @@ -0,0 +1,64 @@ +package metrics + +import ( + "context" + "testing" + "time" + + "backupx/server/internal/model" + "backupx/server/internal/repository" +) + +type lifecycleSampleSource struct{} + +func (lifecycleSampleSource) ListStorageTargets(context.Context) ([]model.StorageTarget, error) { + return nil, nil +} + +func (lifecycleSampleSource) StorageUsage(context.Context) ([]repository.BackupStorageUsageItem, error) { + return nil, nil +} + +func (lifecycleSampleSource) ListNodes(context.Context) ([]model.Node, error) { + return nil, nil +} + +func (lifecycleSampleSource) AgentQueueSummaries(context.Context) (map[uint]repository.AgentCommandQueueSummary, error) { + return nil, nil +} + +func (lifecycleSampleSource) CountSLABreach(context.Context) (int, error) { + return 0, nil +} + +type capturingCollectorRunner struct { + task func(context.Context) +} + +func (r *capturingCollectorRunner) Go(task func(context.Context)) bool { + r.task = task + return true +} + +func TestCollectorUsesConfiguredBackgroundRunner(t *testing.T) { + runner := &capturingCollectorRunner{} + collector := NewCollector(New("test"), lifecycleSampleSource{}, time.Hour) + collector.SetBackgroundRunner(runner) + collector.Start(context.Background()) + if runner.task == nil { + t.Fatal("collector did not register with the background runner") + } + + runCtx, cancel := context.WithCancel(context.Background()) + cancel() + done := make(chan struct{}) + go func() { + runner.task(runCtx) + close(done) + }() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("collector did not stop when background runner context was canceled") + } +} diff --git a/server/internal/scheduler/service.go b/server/internal/scheduler/service.go index 6e64247..07e0aa3 100644 --- a/server/internal/scheduler/service.go +++ b/server/internal/scheduler/service.go @@ -38,6 +38,7 @@ type Service struct { verifyRunner VerifyRunner logger *zap.Logger audit AuditRecorder + runCtx context.Context entries map[uint]cron.EntryID // 备份 cron 条目 verifyEntries map[uint]cron.EntryID // 验证 cron 条目 } @@ -49,6 +50,7 @@ func NewService(tasks repository.BackupTaskRepository, runner TaskRunner, logger tasks: tasks, runner: runner, logger: logger, + runCtx: context.Background(), entries: make(map[uint]cron.EntryID), verifyEntries: make(map[uint]cron.EntryID), } @@ -72,6 +74,12 @@ func (s *Service) SetNodeRepository(nodes repository.NodeRepository) { } func (s *Service) Start(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + s.mu.Lock() + s.runCtx = ctx + s.mu.Unlock() if err := s.Reload(ctx); err != nil { return err } @@ -163,10 +171,11 @@ func (s *Service) syncTaskLocked(task *model.BackupTask) error { taskNodeID := task.NodeID cronExpr := task.CronExpr maintenanceWindows := task.MaintenanceWindows + runCtx := s.runCtx entryID, err := s.cron.AddFunc(cronExpr, func() { // 集群感知:若任务绑定了离线的远程节点,跳过本轮触发避免堆积 failed 记录 if taskNodeID > 0 && s.nodes != nil { - node, err := s.nodes.FindByID(context.Background(), taskNodeID) + node, err := s.nodes.FindByID(runCtx, taskNodeID) // 用实时推导的状态判定,避免后台监控刷新前把任务下发给刚失联的节点。 if err == nil && node != nil && !node.IsLocal && node.EffectiveStatus(time.Now().UTC()) != model.NodeStatusOnline { if s.logger != nil { @@ -213,7 +222,7 @@ func (s *Service) syncTaskLocked(task *model.BackupTask) error { TargetName: taskName, Detail: fmt.Sprintf("定时调度触发备份任务: %s (cron: %s)", taskName, cronExpr), }) } - if _, runErr := s.runner.RunTaskByID(context.Background(), taskID); runErr != nil && s.logger != nil { + if _, runErr := s.runner.RunTaskByID(runCtx, taskID); runErr != nil && s.logger != nil { s.logger.Warn("scheduled backup run failed", zap.Uint("task_id", taskID), zap.Error(runErr)) } }) @@ -245,6 +254,7 @@ func (s *Service) syncVerifyTaskLocked(task *model.BackupTask) error { taskName := task.Name mode := task.VerifyMode verifyCron := task.VerifyCronExpr + runCtx := s.runCtx entryID, err := s.cron.AddFunc(verifyCron, func() { if s.audit != nil { s.audit.Record(servicepkg.AuditEntry{ @@ -253,7 +263,7 @@ func (s *Service) syncVerifyTaskLocked(task *model.BackupTask) error { TargetName: taskName, Detail: fmt.Sprintf("定时验证演练: %s (cron: %s, mode: %s)", taskName, verifyCron, mode), }) } - if _, runErr := s.verifyRunner.StartByTask(context.Background(), taskID, mode, "system"); runErr != nil && s.logger != nil { + if _, runErr := s.verifyRunner.StartByTask(runCtx, taskID, mode, "system"); runErr != nil && s.logger != nil { s.logger.Warn("scheduled verify run failed", zap.Uint("task_id", taskID), zap.Error(runErr)) } }) diff --git a/server/internal/security/jwt.go b/server/internal/security/jwt.go deleted file mode 100644 index 285c86a..0000000 --- a/server/internal/security/jwt.go +++ /dev/null @@ -1,60 +0,0 @@ -//go:build ignore - -package security - -import ( - "fmt" - "time" - - "backupx/server/internal/model" - "github.com/golang-jwt/jwt/v5" -) - -type Claims struct { - UserID uint `json:"userId"` - Username string `json:"username"` - Role string `json:"role"` - jwt.RegisteredClaims -} - -type JWTManager struct { - secret []byte - duration time.Duration -} - -func NewJWTManager(secret string, duration time.Duration) *JWTManager { - return &JWTManager{secret: []byte(secret), duration: duration} -} - -func (m *JWTManager) IssueToken(user *model.User) (string, error) { - now := time.Now().UTC() - claims := Claims{ - UserID: user.ID, - Username: user.Username, - Role: user.Role, - RegisteredClaims: jwt.RegisteredClaims{ - Subject: fmt.Sprintf("%d", user.ID), - IssuedAt: jwt.NewNumericDate(now), - ExpiresAt: jwt.NewNumericDate(now.Add(m.duration)), - }, - } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString(m.secret) -} - -func (m *JWTManager) Parse(tokenValue string) (*Claims, error) { - token, err := jwt.ParseWithClaims(tokenValue, &Claims{}, func(token *jwt.Token) (any, error) { - if token.Method != jwt.SigningMethodHS256 { - return nil, fmt.Errorf("unexpected signing method") - } - return m.secret, nil - }) - if err != nil { - return nil, err - } - claims, ok := token.Claims.(*Claims) - if !ok || !token.Valid { - return nil, fmt.Errorf("invalid token") - } - return claims, nil -} diff --git a/server/internal/security/jwt_test.go b/server/internal/security/jwt_test.go deleted file mode 100644 index 9b6e455..0000000 --- a/server/internal/security/jwt_test.go +++ /dev/null @@ -1,25 +0,0 @@ -//go:build ignore - -package security - -import ( - "testing" - "time" - - "backupx/server/internal/model" -) - -func TestJWTManagerIssueAndParse(t *testing.T) { - manager := NewJWTManager("test-secret", time.Hour) - token, err := manager.IssueToken(&model.User{ID: 7, Username: "admin", Role: "admin"}) - if err != nil { - t.Fatalf("IssueToken() error = %v", err) - } - claims, err := manager.Parse(token) - if err != nil { - t.Fatalf("Parse() error = %v", err) - } - if claims.UserID != 7 || claims.Username != "admin" { - t.Fatalf("unexpected claims: %+v", claims) - } -} diff --git a/server/internal/security/limiter.go b/server/internal/security/limiter.go deleted file mode 100644 index 91f5b24..0000000 --- a/server/internal/security/limiter.go +++ /dev/null @@ -1,54 +0,0 @@ -//go:build ignore - -package security - -import ( - "sync" - "time" -) - -type limiterEntry struct { - Count int - ResetAt time.Time -} - -type LoginLimiter struct { - mu sync.Mutex - window time.Duration - max int - records map[string]limiterEntry -} - -func NewLoginLimiter(max int, window time.Duration) *LoginLimiter { - return &LoginLimiter{window: window, max: max, records: make(map[string]limiterEntry)} -} - -func (l *LoginLimiter) Allow(key string) bool { - l.mu.Lock() - defer l.mu.Unlock() - entry, ok := l.records[key] - if !ok || time.Now().After(entry.ResetAt) { - delete(l.records, key) - return true - } - return entry.Count < l.max -} - -func (l *LoginLimiter) RegisterFailure(key string) { - l.mu.Lock() - defer l.mu.Unlock() - now := time.Now() - entry, ok := l.records[key] - if !ok || now.After(entry.ResetAt) { - l.records[key] = limiterEntry{Count: 1, ResetAt: now.Add(l.window)} - return - } - entry.Count++ - l.records[key] = entry -} - -func (l *LoginLimiter) Reset(key string) { - l.mu.Lock() - defer l.mu.Unlock() - delete(l.records, key) -} diff --git a/server/internal/security/secret_store.go b/server/internal/security/secret_store.go deleted file mode 100644 index b43a1f2..0000000 --- a/server/internal/security/secret_store.go +++ /dev/null @@ -1,93 +0,0 @@ -//go:build ignore - -package security - -import ( - "crypto/rand" - "encoding/hex" - "encoding/json" - "fmt" - "os" - "path/filepath" - - "backupx/server/internal/config" -) - -type PersistedSecrets struct { - JWTSecret string `json:"jwtSecret"` - EncryptionKey string `json:"encryptionKey"` -} - -func EnsureSecrets(cfg *config.Config) error { - if cfg.Security.JWTSecret != "" && cfg.Security.EncryptionKey != "" { - return nil - } - - storePath := filepath.Join(filepath.Dir(cfg.Database.Path), "backupx.secrets.json") - current, err := loadSecrets(storePath) - if err != nil { - return err - } - if current == nil { - current = &PersistedSecrets{} - } - if current.JWTSecret == "" { - current.JWTSecret, err = randomHex(32) - if err != nil { - return err - } - } - if current.EncryptionKey == "" { - current.EncryptionKey, err = randomHex(32) - if err != nil { - return err - } - } - if err := saveSecrets(storePath, current); err != nil { - return err - } - if cfg.Security.JWTSecret == "" { - cfg.Security.JWTSecret = current.JWTSecret - } - if cfg.Security.EncryptionKey == "" { - cfg.Security.EncryptionKey = current.EncryptionKey - } - return nil -} - -func loadSecrets(path string) (*PersistedSecrets, error) { - content, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, fmt.Errorf("read secrets: %w", err) - } - var secrets PersistedSecrets - if err := json.Unmarshal(content, &secrets); err != nil { - return nil, fmt.Errorf("decode secrets: %w", err) - } - return &secrets, nil -} - -func saveSecrets(path string, secrets *PersistedSecrets) error { - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return fmt.Errorf("create secrets dir: %w", err) - } - content, err := json.MarshalIndent(secrets, "", " ") - if err != nil { - return fmt.Errorf("encode secrets: %w", err) - } - if err := os.WriteFile(path, content, 0o600); err != nil { - return fmt.Errorf("write secrets: %w", err) - } - return nil -} - -func randomHex(size int) (string, error) { - bytes := make([]byte, size) - if _, err := rand.Read(bytes); err != nil { - return "", fmt.Errorf("generate random secret: %w", err) - } - return hex.EncodeToString(bytes), nil -} diff --git a/server/internal/service/agent_service.go b/server/internal/service/agent_service.go index 9a32e14..9f5f010 100644 --- a/server/internal/service/agent_service.go +++ b/server/internal/service/agent_service.go @@ -19,6 +19,7 @@ import ( "backupx/server/internal/repository" "backupx/server/internal/storage" "backupx/server/internal/storage/codec" + "go.uber.org/zap" ) // AgentService 实现 Master 端 Agent 协议,提供给远程 Agent 通过 HTTP 调用。 @@ -32,6 +33,8 @@ type AgentService struct { restoreRepo repository.RestoreRecordRepository registry *storage.Registry cipher *codec.ConfigCipher + logger *zap.Logger + background BackgroundRunner } func NewAgentService( @@ -51,9 +54,24 @@ func NewAgentService( cmdRepo: cmdRepo, registry: registry, cipher: cipher, + logger: zap.NewNop(), } } +// SetLogger attaches the application logger used by background command +// reconciliation. The no-op default keeps the service safe in tests. +func (s *AgentService) SetLogger(logger *zap.Logger) { + if logger != nil { + s.logger = logger + } +} + +// SetBackgroundRunner makes the command timeout monitor part of the +// application lifecycle so shutdown waits for an in-flight reconciliation. +func (s *AgentService) SetBackgroundRunner(background BackgroundRunner) { + s.background = background +} + // SetRestoreRepository 注入恢复记录仓储,用于命令超时时联动 restore_record 状态。 // 可选注入:未注入时恢复命令超时仅标记命令 timeout,记录需另行查验。 func (s *AgentService) SetRestoreRepository(repo repository.RestoreRecordRepository) { @@ -117,6 +135,16 @@ func (s *AgentService) SubmitCommandResult(ctx context.Context, node *model.Node if cmd.NodeID != node.ID { return apperror.Unauthorized("AGENT_COMMAND_FORBIDDEN", "命令不属于当前节点", nil) } + // A failed terminal report may be retried after the command row was already + // completed but before its linked business record was updated. Re-run that + // idempotent convergence step so a transient database error cannot leave a + // backup or restore record stuck in running forever. + if cmd.Status == model.AgentCommandStatusFailed { + if result.Success { + return nil + } + return s.failLinkedRecord(ctx, cmd, agentCommandFailureMessage(result.ErrorMessage)) + } now := time.Now().UTC() if result.Success { cmd.Status = model.AgentCommandStatusSucceeded @@ -128,8 +156,21 @@ func (s *AgentService) SubmitCommandResult(ctx context.Context, node *model.Node cmd.Result = string(result.Result) } cmd.CompletedAt = &now - _, err = s.cmdRepo.CompleteDispatched(ctx, cmd) - return err + completed, err := s.cmdRepo.CompleteDispatched(ctx, cmd) + if err != nil || !completed || result.Success { + return err + } + persistCtx, cancel := finalizationContext(ctx) + defer cancel() + return s.failLinkedRecord(persistCtx, cmd, agentCommandFailureMessage(result.ErrorMessage)) +} + +func agentCommandFailureMessage(message string) string { + message = strings.TrimSpace(message) + if message == "" { + return "Agent 命令执行失败" + } + return "Agent 命令执行失败:" + message } // AgentTaskSpec 给 Agent 返回的任务规格,包含解密后的存储配置,供 Agent 直接执行。 @@ -616,19 +657,29 @@ func (s *AgentService) StartCommandTimeoutMonitor(ctx context.Context, interval if timeout <= 0 { timeout = 10 * time.Minute } - ticker := time.NewTicker(interval) - go func() { + monitor := func(runCtx context.Context) { + ticker := time.NewTicker(interval) defer ticker.Stop() for { select { - case <-ctx.Done(): + case <-runCtx.Done(): return case <-ticker.C: threshold := time.Now().UTC().Add(-timeout) - s.processStaleCommands(ctx, threshold) + s.processStaleCommands(runCtx, threshold) } } - }() + } + if s.background != nil { + if !s.background.Go(monitor) { + s.logger.Warn("agent command timeout monitor not started: application is shutting down") + } + return + } + if ctx == nil { + ctx = context.Background() + } + go monitor(ctx) } // processStaleCommands 扫描已超时的 pending/dispatched 命令并联动关联记录。 @@ -636,12 +687,24 @@ func (s *AgentService) StartCommandTimeoutMonitor(ctx context.Context, interval // 单条失败不影响后续处理。 func (s *AgentService) processStaleCommands(ctx context.Context, threshold time.Time) { commands, err := s.cmdRepo.ListStaleActive(ctx, threshold) - if err != nil || len(commands) == 0 { + if err != nil { + s.logger.Error("list stale agent commands failed", zap.Error(err)) + return + } + if len(commands) == 0 { return } for i := range commands { cmd := commands[i] - if s.commandStillActive(ctx, &cmd, threshold) { + stillActive, activeErr := s.commandStillActive(ctx, &cmd, threshold) + if activeErr != nil { + s.logger.Warn("check stale agent command activity failed", + zap.Uint("command_id", cmd.ID), + zap.String("command_type", cmd.Type), + zap.Error(activeErr)) + continue + } + if stillActive { continue } now := time.Now().UTC() @@ -649,18 +712,33 @@ func (s *AgentService) processStaleCommands(ctx context.Context, threshold time. cmd.ErrorMessage = "agent did not report result before timeout" cmd.CompletedAt = &now timedOut, err := s.cmdRepo.TimeoutActive(ctx, &cmd) - if err != nil || !timedOut { + if err != nil { + s.logger.Error("mark agent command timed out failed", + zap.Uint("command_id", cmd.ID), + zap.String("command_type", cmd.Type), + zap.Error(err)) continue } - s.failLinkedRecord(ctx, &cmd) + if !timedOut { + continue + } + persistCtx, cancel := finalizationContext(ctx) + failErr := s.failLinkedRecord(persistCtx, &cmd) + cancel() + if failErr != nil { + s.logger.Error("mark timed-out agent command record failed", + zap.Uint("command_id", cmd.ID), + zap.String("command_type", cmd.Type), + zap.Error(failErr)) + } } } // commandStillActive 用关联记录状态、记录更新时间和节点心跳作为长任务续租信号。 // 仅 run_task / restore_record 允许续租,避免短 RPC 命令被在线节点长期保留。 -func (s *AgentService) commandStillActive(ctx context.Context, cmd *model.AgentCommand, threshold time.Time) bool { +func (s *AgentService) commandStillActive(ctx context.Context, cmd *model.AgentCommand, threshold time.Time) (bool, error) { if cmd.Status != model.AgentCommandStatusDispatched { - return false + return false, nil } switch cmd.Type { case model.AgentCommandTypeRunTask: @@ -668,90 +746,121 @@ func (s *AgentService) commandStillActive(ctx context.Context, cmd *model.AgentC RecordID uint `json:"recordId"` } if err := json.Unmarshal([]byte(cmd.Payload), &payload); err != nil || payload.RecordID == 0 { - return false + return false, nil } record, err := s.recordRepo.FindByID(ctx, payload.RecordID) - if err != nil || record == nil || record.Status != model.BackupRecordStatusRunning { - return false + if err != nil { + return false, fmt.Errorf("find backup record %d: %w", payload.RecordID, err) } - if s.nodeRecentlySeen(ctx, cmd.NodeID, threshold) { - return true + if record == nil || record.Status != model.BackupRecordStatusRunning { + return false, nil } - return record.UpdatedAt.After(threshold) + nodeActive, err := s.nodeRecentlySeen(ctx, cmd.NodeID, threshold) + if err != nil { + return false, err + } + return nodeActive || record.UpdatedAt.After(threshold), nil case model.AgentCommandTypeRestoreRecord: if s.restoreRepo == nil { - return false + return false, nil } var payload struct { RestoreRecordID uint `json:"restoreRecordId"` } if err := json.Unmarshal([]byte(cmd.Payload), &payload); err != nil || payload.RestoreRecordID == 0 { - return false + return false, nil } restore, err := s.restoreRepo.FindByID(ctx, payload.RestoreRecordID) - if err != nil || restore == nil || restore.Status != model.RestoreRecordStatusRunning { - return false + if err != nil { + return false, fmt.Errorf("find restore record %d: %w", payload.RestoreRecordID, err) } - if s.nodeRecentlySeen(ctx, cmd.NodeID, threshold) { - return true + if restore == nil || restore.Status != model.RestoreRecordStatusRunning { + return false, nil } - return restore.UpdatedAt.After(threshold) + nodeActive, err := s.nodeRecentlySeen(ctx, cmd.NodeID, threshold) + if err != nil { + return false, err + } + return nodeActive || restore.UpdatedAt.After(threshold), nil default: - return false + return false, nil } } -func (s *AgentService) nodeRecentlySeen(ctx context.Context, nodeID uint, threshold time.Time) bool { +func (s *AgentService) nodeRecentlySeen(ctx context.Context, nodeID uint, threshold time.Time) (bool, error) { node, err := s.nodeRepo.FindByID(ctx, nodeID) - if err != nil || node == nil { - return false + if err != nil { + return false, fmt.Errorf("find agent node %d: %w", nodeID, err) } - return node.Status == model.NodeStatusOnline && node.LastSeen.After(threshold) + if node == nil { + return false, nil + } + return node.Status == model.NodeStatusOnline && node.LastSeen.After(threshold), nil } // failLinkedRecord 根据命令类型把关联记录标记为 failed。 // 只对仍然处于 running 状态的记录生效,避免覆盖已完成的结果。 -func (s *AgentService) failLinkedRecord(ctx context.Context, cmd *model.AgentCommand) { - const failureMessage = "Agent 未在超时前回传状态(节点可能已离线或崩溃)" +func (s *AgentService) failLinkedRecord(ctx context.Context, cmd *model.AgentCommand, messages ...string) error { + failureMessage := "Agent 未在超时前回传状态(节点可能已离线或崩溃)" + if len(messages) > 0 && strings.TrimSpace(messages[0]) != "" { + failureMessage = strings.TrimSpace(messages[0]) + } switch cmd.Type { case model.AgentCommandTypeRunTask: var payload struct { RecordID uint `json:"recordId"` } - if err := json.Unmarshal([]byte(cmd.Payload), &payload); err != nil || payload.RecordID == 0 { - return + if err := json.Unmarshal([]byte(cmd.Payload), &payload); err != nil { + return fmt.Errorf("decode run_task payload: %w", err) + } + if payload.RecordID == 0 { + return errors.New("run_task payload has no recordId") } record, err := s.recordRepo.FindByID(ctx, payload.RecordID) - if err != nil || record == nil || record.Status != model.BackupRecordStatusRunning { - return + if err != nil { + return fmt.Errorf("find backup record %d: %w", payload.RecordID, err) + } + if record == nil || record.Status != model.BackupRecordStatusRunning { + return nil } completedAt := time.Now().UTC() record.Status = model.BackupRecordStatusFailed record.ErrorMessage = failureMessage record.CompletedAt = &completedAt record.DurationSeconds = int(completedAt.Sub(record.StartedAt).Seconds()) - _ = s.recordRepo.Update(ctx, record) + if err := s.recordRepo.Update(ctx, record); err != nil { + return fmt.Errorf("update backup record %d: %w", record.ID, err) + } case model.AgentCommandTypeRestoreRecord: if s.restoreRepo == nil { - return + return errors.New("restore record repository is not configured") } var payload struct { RestoreRecordID uint `json:"restoreRecordId"` } - if err := json.Unmarshal([]byte(cmd.Payload), &payload); err != nil || payload.RestoreRecordID == 0 { - return + if err := json.Unmarshal([]byte(cmd.Payload), &payload); err != nil { + return fmt.Errorf("decode restore_record payload: %w", err) + } + if payload.RestoreRecordID == 0 { + return errors.New("restore_record payload has no restoreRecordId") } restore, err := s.restoreRepo.FindByID(ctx, payload.RestoreRecordID) - if err != nil || restore == nil || restore.Status != model.RestoreRecordStatusRunning { - return + if err != nil { + return fmt.Errorf("find restore record %d: %w", payload.RestoreRecordID, err) + } + if restore == nil || restore.Status != model.RestoreRecordStatusRunning { + return nil } completedAt := time.Now().UTC() restore.Status = model.RestoreRecordStatusFailed restore.ErrorMessage = failureMessage restore.CompletedAt = &completedAt restore.DurationSeconds = int(completedAt.Sub(restore.StartedAt).Seconds()) - _ = s.restoreRepo.Update(ctx, restore) + if err := s.restoreRepo.Update(ctx, restore); err != nil { + return fmt.Errorf("update restore record %d: %w", restore.ID, err) + } } + return nil } // AgentSelfStatus 是 /api/v1/agent/self 端点返回给 Agent 的轻量状态摘要。 diff --git a/server/internal/service/agent_service_test.go b/server/internal/service/agent_service_test.go index 4a66dfd..b5b4995 100644 --- a/server/internal/service/agent_service_test.go +++ b/server/internal/service/agent_service_test.go @@ -24,6 +24,15 @@ import ( "gorm.io/gorm" ) +type failingUpdateBackupRecordRepository struct { + repository.BackupRecordRepository + updateErr error +} + +func (r *failingUpdateBackupRecordRepository) Update(context.Context, *model.BackupRecord) error { + return r.updateErr +} + func newAgentServicePoolTestHarness(t *testing.T) (*AgentService, *gorm.DB, repository.BackupRecordRepository, repository.AgentCommandRepository, *model.Node, *model.Node) { t.Helper() log, err := logger.New(config.LogConfig{Level: "error"}) @@ -34,6 +43,7 @@ func newAgentServicePoolTestHarness(t *testing.T) (*AgentService, *gorm.DB, repo if err != nil { t.Fatalf("database.Open returned error: %v", err) } + closeTestDatabase(t, db) cipher := codec.NewConfigCipher("agent-service-secret") nodeRepo := repository.NewNodeRepository(db) taskRepo := repository.NewBackupTaskRepository(db) @@ -87,6 +97,23 @@ func newAgentServicePoolTestHarness(t *testing.T) (*AgentService, *gorm.DB, repo return NewAgentService(nodeRepo, taskRepo, recordRepo, storageRepo, cmdRepo, cipher, storageRegistry), db, recordRepo, cmdRepo, owner, other } +func TestAgentServiceFailLinkedRecordPropagatesTerminalUpdateError(t *testing.T) { + svc, _, records, _, _, _ := newAgentServicePoolTestHarness(t) + wantErr := errors.New("record update failed") + svc.recordRepo = &failingUpdateBackupRecordRepository{ + BackupRecordRepository: records, + updateErr: wantErr, + } + + err := svc.failLinkedRecord(context.Background(), &model.AgentCommand{ + Type: model.AgentCommandTypeRunTask, + Payload: `{"recordId":1}`, + }) + if err == nil || !errors.Is(err, wantErr) { + t.Fatalf("failLinkedRecord error = %v, want wrapped update error", err) + } +} + func TestAgentServicePooledTaskUsesRecordNodeForSpecAndRecordUpdates(t *testing.T) { svc, _, records, _, owner, other := newAgentServicePoolTestHarness(t) ctx := context.Background() @@ -752,6 +779,44 @@ func TestAgentServiceSubmitCommandResultDoesNotOverwriteTerminalCommand(t *testi } } +func TestAgentServiceSubmitFailedCommandConvergesLinkedRecord(t *testing.T) { + svc, _, records, commands, owner, _ := newAgentServicePoolTestHarness(t) + ctx := context.Background() + dispatchedAt := time.Now().UTC() + command := &model.AgentCommand{ + NodeID: owner.ID, + Type: model.AgentCommandTypeRunTask, + Status: model.AgentCommandStatusDispatched, + Payload: `{"recordId":1}`, + DispatchedAt: &dispatchedAt, + } + if err := commands.Create(ctx, command); err != nil { + t.Fatalf("Create command returned error: %v", err) + } + + if err := svc.SubmitCommandResult(ctx, owner, command.ID, AgentCommandResult{ + Success: false, + ErrorMessage: "terminal update could not reach Master", + }); err != nil { + t.Fatalf("SubmitCommandResult returned error: %v", err) + } + + record, err := records.FindByID(ctx, 1) + if err != nil { + t.Fatalf("FindByID record returned error: %v", err) + } + if record.Status != model.BackupRecordStatusFailed || !strings.Contains(record.ErrorMessage, "terminal update") { + t.Fatalf("linked record did not converge: %#v", record) + } + updatedCommand, err := commands.FindByID(ctx, command.ID) + if err != nil { + t.Fatalf("FindByID command returned error: %v", err) + } + if updatedCommand.Status != model.AgentCommandStatusFailed { + t.Fatalf("command status = %q, want failed", updatedCommand.Status) + } +} + func TestAgentServiceUpdateRecordDoesNotOverwriteTerminalRecord(t *testing.T) { svc, _, records, _, owner, _ := newAgentServicePoolTestHarness(t) ctx := context.Background() diff --git a/server/internal/service/api_key_service_test.go b/server/internal/service/api_key_service_test.go index c22dd89..fe68f1e 100644 --- a/server/internal/service/api_key_service_test.go +++ b/server/internal/service/api_key_service_test.go @@ -24,6 +24,7 @@ func newApiKeyTestService(t *testing.T) *ApiKeyService { if err != nil { t.Fatalf("database.Open: %v", err) } + closeTestDatabase(t, db) return NewApiKeyService(repository.NewApiKeyRepository(db)) } diff --git a/server/internal/service/audit_retention_test.go b/server/internal/service/audit_retention_test.go index 2dfad6a..5a28ac5 100644 --- a/server/internal/service/audit_retention_test.go +++ b/server/internal/service/audit_retention_test.go @@ -23,6 +23,11 @@ func TestAuditRetention(t *testing.T) { if err != nil { t.Fatal(err) } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) auditRepo := repository.NewAuditLogRepository(db) configRepo := repository.NewSystemConfigRepository(db) svc := NewAuditService(auditRepo) diff --git a/server/internal/service/audit_service.go b/server/internal/service/audit_service.go index 1cf1745..a169905 100644 --- a/server/internal/service/audit_service.go +++ b/server/internal/service/audit_service.go @@ -8,7 +8,6 @@ import ( "encoding/hex" "encoding/json" "fmt" - "log" "net/http" "strconv" "strings" @@ -18,6 +17,7 @@ import ( "backupx/server/internal/apperror" "backupx/server/internal/model" "backupx/server/internal/repository" + "go.uber.org/zap" ) // AuditEntry 是记录审计日志的输入结构 @@ -41,14 +41,35 @@ type AuditService struct { webhookURL string webhookSecret string httpClient *http.Client + async func(func(context.Context)) bool + logger *zap.Logger + inFlight chan struct{} } +const maxAuditInFlight = 64 + func NewAuditService(repo repository.AuditLogRepository) *AuditService { return &AuditService{ repo: repo, httpClient: &http.Client{ Timeout: 3 * time.Second, // 短超时:审计 webhook 不应拖慢业务 }, + async: runDetached, + logger: zap.NewNop(), + inFlight: make(chan struct{}, maxAuditInFlight), + } +} + +func (s *AuditService) SetLogger(logger *zap.Logger) { + if logger != nil { + s.logger = logger + } +} + +// SetBackgroundRunner binds audit persistence and webhook delivery to the application lifecycle. +func (s *AuditService) SetBackgroundRunner(runner BackgroundRunner) { + if runner != nil { + s.async = runner.Go } } @@ -71,37 +92,55 @@ func (s *AuditService) StartRetentionMonitor(ctx context.Context, configs reposi if interval <= 0 { interval = 6 * time.Hour } - go func() { + if ctx == nil { + ctx = context.Background() + } + accepted := s.async(func(workerCtx context.Context) { + monitorCtx, cancel := context.WithCancel(workerCtx) + defer cancel() + stopLink := context.AfterFunc(ctx, cancel) + defer stopLink() ticker := time.NewTicker(interval) defer ticker.Stop() - s.runRetentionOnce(ctx, configs) // 启动后立即跑一次 + s.runRetentionOnce(monitorCtx, configs) // 启动后立即跑一次 for { select { - case <-ctx.Done(): + case <-monitorCtx.Done(): return case <-ticker.C: - s.runRetentionOnce(ctx, configs) + s.runRetentionOnce(monitorCtx, configs) } } - }() + }) + if !accepted { + s.logger.Warn("audit retention monitor not started: application is shutting down") + } } func (s *AuditService) runRetentionOnce(ctx context.Context, configs repository.SystemConfigRepository) { cfg, err := configs.GetByKey(ctx, SettingKeyAuditRetentionDays) - if err != nil || cfg == nil { + if err != nil { + s.logger.Warn("read audit retention setting failed", zap.Error(err)) + return + } + if cfg == nil { return } days, err := strconv.Atoi(strings.TrimSpace(cfg.Value)) - if err != nil || days <= 0 { + if err != nil { + s.logger.Warn("invalid audit retention setting", zap.String("value", cfg.Value), zap.Error(err)) + return + } + if days <= 0 { return } deleted, err := s.PurgeOlderThan(ctx, days) if err != nil { - log.Printf("[audit] retention purge failed: %v", err) + s.logger.Warn("audit retention purge failed", zap.Error(err)) return } if deleted > 0 { - log.Printf("[audit] retention purge: deleted %d logs older than %d days", deleted, days) + s.logger.Info("audit retention purge completed", zap.Int64("deleted", deleted), zap.Int("retention_days", days)) } } @@ -123,12 +162,24 @@ func (s *AuditService) SetWebhook(url, secret string) { s.webhookSecret = strings.TrimSpace(secret) } -// Record 异步 fire-and-forget 写入审计日志,不阻塞业务逻辑 +// Record asynchronously persists an audit event without blocking the request. func (s *AuditService) Record(entry AuditEntry) { if s == nil || s.repo == nil { return } - go func() { + select { + case s.inFlight <- struct{}{}: + default: + s.logger.Error("audit event rejected: in-flight limit reached", + zap.Int("limit", cap(s.inFlight)), + zap.String("category", entry.Category), + zap.String("action", entry.Action)) + return + } + accepted := s.async(func(workerCtx context.Context) { + defer func() { <-s.inFlight }() + persistCtx, cancel := finalizationContext(workerCtx) + defer cancel() record := &model.AuditLog{ UserID: entry.UserID, Username: entry.Username, @@ -140,24 +191,30 @@ func (s *AuditService) Record(entry AuditEntry) { Detail: entry.Detail, ClientIP: entry.ClientIP, } - if err := s.repo.Create(context.Background(), record); err != nil { - log.Printf("[audit] failed to write audit log: %v", err) + if err := s.repo.Create(persistCtx, record); err != nil { + s.logger.Error("failed to write audit log", zap.String("category", entry.Category), zap.String("action", entry.Action), zap.Error(err)) + } + if err := s.fireWebhook(persistCtx, record); err != nil { + s.logger.Warn("audit webhook delivery failed", zap.String("category", entry.Category), zap.String("action", entry.Action), zap.Error(err)) } - s.fireWebhook(record) - }() + }) + if !accepted { + <-s.inFlight + s.logger.Warn("audit event rejected: application is shutting down", zap.String("category", entry.Category), zap.String("action", entry.Action)) + } } -// fireWebhook 异步向外部系统转发审计事件。失败降级到本地日志,永不影响主流程。 -func (s *AuditService) fireWebhook(record *model.AuditLog) { +// fireWebhook forwards an audit event. The caller owns asynchronous execution. +func (s *AuditService) fireWebhook(ctx context.Context, record *model.AuditLog) error { if s == nil { - return + return nil } s.webhookMu.RLock() url := s.webhookURL secret := s.webhookSecret s.webhookMu.RUnlock() if url == "" { - return + return nil } payload := map[string]any{ "eventType": "audit.log", @@ -176,13 +233,11 @@ func (s *AuditService) fireWebhook(record *model.AuditLog) { } body, err := json.Marshal(payload) if err != nil { - log.Printf("[audit] webhook marshal failed: %v", err) - return + return fmt.Errorf("marshal audit webhook: %w", err) } - req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, url, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { - log.Printf("[audit] webhook build request failed: %v", err) - return + return fmt.Errorf("build audit webhook request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "BackupX-Audit/1.0") @@ -193,13 +248,13 @@ func (s *AuditService) fireWebhook(record *model.AuditLog) { } resp, err := s.httpClient.Do(req) if err != nil { - log.Printf("[audit] webhook POST failed: %v", err) - return + return fmt.Errorf("post audit webhook: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 400 { - log.Printf("[audit] webhook returned status %d", resp.StatusCode) + return fmt.Errorf("audit webhook returned status %d", resp.StatusCode) } + return nil } // List 分页查询审计日志 diff --git a/server/internal/service/audit_service_webhook_test.go b/server/internal/service/audit_service_webhook_test.go index 97e6f81..6342ae7 100644 --- a/server/internal/service/audit_service_webhook_test.go +++ b/server/internal/service/audit_service_webhook_test.go @@ -14,6 +14,7 @@ import ( "testing" "time" + "backupx/server/internal/lifecycle" "backupx/server/internal/model" "backupx/server/internal/repository" ) @@ -131,3 +132,40 @@ func TestAuditService_WebhookDisabledWhenURLEmpty(t *testing.T) { time.Sleep(100 * time.Millisecond) // 无显式断言:能不 panic 即算通过 } + +func TestAuditServiceSupervisorShutdownFlushesAcceptedRecord(t *testing.T) { + repo := newFakeAuditRepo() + supervisor := lifecycle.NewSupervisor(context.Background()) + svc := NewAuditService(repo) + svc.SetBackgroundRunner(supervisor) + + svc.Record(AuditEntry{Username: "alice", Category: "auth", Action: "logout"}) + waitCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := supervisor.Shutdown(waitCtx); err != nil { + t.Fatalf("Shutdown: %v", err) + } + select { + case <-repo.created: + default: + t.Fatal("accepted audit record was not flushed during shutdown") + } +} + +func TestAuditServiceBoundsInFlightWork(t *testing.T) { + repo := newFakeAuditRepo() + svc := NewAuditService(repo) + svc.inFlight = make(chan struct{}, 1) + + accepted := 0 + svc.async = func(func(context.Context)) bool { + accepted++ + return true + } + + svc.Record(AuditEntry{Category: "auth", Action: "first"}) + svc.Record(AuditEntry{Category: "auth", Action: "second"}) + if accepted != 1 { + t.Fatalf("accepted tasks = %d, want 1", accepted) + } +} diff --git a/server/internal/service/background_monitors_test.go b/server/internal/service/background_monitors_test.go new file mode 100644 index 0000000..995ed73 --- /dev/null +++ b/server/internal/service/background_monitors_test.go @@ -0,0 +1,84 @@ +package service + +import ( + "context" + "testing" + "time" +) + +type capturingMonitorRunner struct { + tasks []func(context.Context) +} + +func (r *capturingMonitorRunner) Go(task func(context.Context)) bool { + r.tasks = append(r.tasks, task) + return true +} + +func TestLongRunningMonitorsUseConfiguredBackgroundRunner(t *testing.T) { + runner := &capturingMonitorRunner{} + + nodes := NewNodeService(nil, "test") + nodes.SetBackgroundRunner(runner) + nodes.StartOfflineMonitor(context.Background(), time.Hour) + + installTokens := NewInstallTokenService(nil, nil) + installTokens.SetBackgroundRunner(runner) + installTokens.StartGC(context.Background(), time.Hour) + + dashboard := NewDashboardService(nil, nil, nil) + dashboard.SetBackgroundRunner(runner) + dashboard.StartSLAMonitor(context.Background(), nil, time.Hour, time.Hour) + + versions := NewClusterVersionMonitor(nil, "test") + versions.SetBackgroundRunner(runner) + versions.Start(context.Background(), time.Hour, time.Hour) + + storageTargets := NewStorageTargetService(nil, nil, nil, nil) + storageTargets.SetBackgroundRunner(runner) + storageTargets.StartHealthMonitor(context.Background(), nil, time.Hour) + + if len(runner.tasks) != 5 { + t.Fatalf("background runner received %d tasks, want 5", len(runner.tasks)) + } + + // The monitor must listen to the supervisor-provided context, not retain + // the context passed to Start. Running one captured task is sufficient to + // lock this ownership contract for the shared helper. + runCtx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + runner.tasks[0](runCtx) + close(done) + }() + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("monitor did not stop when background runner context was canceled") + } +} + +func TestBackgroundMonitorFallbackUsesCallerContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + started := make(chan struct{}) + done := make(chan struct{}) + if !startBackgroundMonitor(nil, ctx, func(runCtx context.Context) { + close(started) + <-runCtx.Done() + close(done) + }) { + t.Fatal("fallback monitor was rejected") + } + select { + case <-started: + case <-time.After(time.Second): + t.Fatal("fallback monitor did not start") + } + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("fallback monitor did not stop when caller context was canceled") + } +} diff --git a/server/internal/service/background_tasks.go b/server/internal/service/background_tasks.go new file mode 100644 index 0000000..2b85c17 --- /dev/null +++ b/server/internal/service/background_tasks.go @@ -0,0 +1,62 @@ +package service + +import ( + "context" + "net/http" + "time" + + "backupx/server/internal/apperror" +) + +// BackgroundRunner is the narrow lifecycle dependency used by asynchronous +// services. lifecycle.Supervisor implements it at the application boundary. +type BackgroundRunner interface { + Go(func(context.Context)) bool +} + +func runDetached(task func(context.Context)) bool { + if task == nil { + return false + } + go task(context.Background()) + return true +} + +// startBackgroundMonitor keeps the legacy caller-owned context when no +// lifecycle runner is configured, while allowing the application supervisor +// to own and wait for long-running monitors in production. +func startBackgroundMonitor(runner BackgroundRunner, fallbackCtx context.Context, task func(context.Context)) bool { + if task == nil { + return false + } + if runner != nil { + return runner.Go(task) + } + if fallbackCtx == nil { + fallbackCtx = context.Background() + } + go task(fallbackCtx) + return true +} + +func backgroundTaskUnavailable(code string) *apperror.AppError { + return apperror.New(http.StatusServiceUnavailable, code, "服务正在关闭,无法启动新的后台任务", context.Canceled) +} + +// finalizationContext lets a canceled task persist its terminal state. It is +// intentionally short-lived so shutdown cannot wait forever on cleanup I/O. +func finalizationContext(ctx context.Context) (context.Context, context.CancelFunc) { + if ctx == nil { + ctx = context.Background() + } + return context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) +} + +func acquireBackgroundSlot(ctx context.Context, semaphore chan struct{}) bool { + select { + case semaphore <- struct{}{}: + return true + case <-ctx.Done(): + return false + } +} diff --git a/server/internal/service/backup_execution_service.go b/server/internal/service/backup_execution_service.go index 1e31bef..70a8ea9 100644 --- a/server/internal/service/backup_execution_service.go +++ b/server/internal/service/backup_execution_service.go @@ -105,7 +105,7 @@ type BackupExecutionService struct { agentDispatcher AgentDispatcher replicationHook ReplicationTrigger dependentsResolver DependentsResolver - async func(func()) + async func(func(context.Context)) bool now func() time.Time tempDir string semaphore chan struct{} @@ -127,6 +127,13 @@ func (s *BackupExecutionService) SetMetrics(m *metrics.Metrics) { s.metrics = m } +// SetBackgroundRunner binds local asynchronous executions to the application lifecycle. +func (s *BackupExecutionService) SetBackgroundRunner(runner BackgroundRunner) { + if runner != nil { + s.async = runner.Go + } +} + // ReplicationTrigger 抽象备份成功后的副本派发(实现者:ReplicationService)。 type ReplicationTrigger interface { TriggerAutoReplication(ctx context.Context, task *model.BackupTask, record *model.BackupRecord) @@ -194,14 +201,12 @@ func NewBackupExecutionService( retention: retention, cipher: cipher, notifier: notifier, - async: func(job func()) { - go job() - }, - now: func() time.Time { return time.Now().UTC() }, - tempDir: tempDir, - semaphore: make(chan struct{}, maxConcurrent), - retries: retries, - bandwidthLimit: bandwidthLimit, + async: runDetached, + now: func() time.Time { return time.Now().UTC() }, + tempDir: tempDir, + semaphore: make(chan struct{}, maxConcurrent), + retries: retries, + bandwidthLimit: bandwidthLimit, } } @@ -259,62 +264,6 @@ func (s *BackupExecutionService) DownloadRecord(ctx context.Context, recordID ui return &DownloadedArtifact{FileName: fileName, Reader: reader}, nil } -func (s *BackupExecutionService) RestoreRecord(ctx context.Context, recordID uint) error { - record, provider, err := s.loadRecordProvider(ctx, recordID) - if err != nil { - return err - } - task, err := s.tasks.FindByID(ctx, record.TaskID) - if err != nil { - return apperror.Internal("BACKUP_TASK_GET_FAILED", "无法获取关联备份任务", err) - } - if task == nil { - return apperror.New(404, "BACKUP_TASK_NOT_FOUND", "关联的备份任务不存在,无法执行恢复", fmt.Errorf("backup task %d not found", record.TaskID)) - } - if record.BackupKind == model.BackupKindRepository { - spec, specErr := s.buildTaskSpec(task, record.StartedAt) - if specErr != nil { - return specErr - } - if err := backup.NewRepositoryStore(s.cipher.Key()).Restore(ctx, provider, record.StoragePath, record.Checksum, spec, backup.NopLogWriter{}); err != nil { - return apperror.Internal("BACKUP_RECORD_RESTORE_FAILED", "从 CDC 仓库恢复备份失败", err) - } - return nil - } - tempDir, err := os.MkdirTemp("", "backupx-restore-*") - if err != nil { - return apperror.Internal("BACKUP_RECORD_RESTORE_FAILED", "无法创建恢复目录", err) - } - defer os.RemoveAll(tempDir) - artifactPath := filepath.Join(tempDir, filepath.Base(record.FileName)) - if strings.TrimSpace(filepath.Base(record.FileName)) == "" { - artifactPath = filepath.Join(tempDir, filepath.Base(record.StoragePath)) - } - reader, err := provider.Download(ctx, record.StoragePath) - if err != nil { - return apperror.Internal("BACKUP_RECORD_RESTORE_FAILED", "无法下载备份文件", err) - } - if err := writeReaderToFile(artifactPath, reader); err != nil { - return apperror.Internal("BACKUP_RECORD_RESTORE_FAILED", "无法写入恢复文件", err) - } - preparedPath, err := s.prepareArtifactForRestore(artifactPath) - if err != nil { - return apperror.Internal("BACKUP_RECORD_RESTORE_FAILED", "无法准备恢复文件", err) - } - spec, err := s.buildTaskSpec(task, record.StartedAt) - if err != nil { - return err - } - runner, err := s.runnerRegistry.Runner(spec.Type) - if err != nil { - return apperror.BadRequest("BACKUP_TASK_INVALID", "不支持的备份任务类型", err) - } - if err := runner.Restore(ctx, spec, preparedPath, backup.NopLogWriter{}); err != nil { - return apperror.Internal("BACKUP_RECORD_RESTORE_FAILED", "恢复备份失败", err) - } - return nil -} - func (s *BackupExecutionService) DeleteRecord(ctx context.Context, recordID uint) error { record, err := s.records.FindByID(ctx, recordID) if err != nil { @@ -504,6 +453,11 @@ func (s *BackupExecutionService) startTask(ctx context.Context, id uint, async b task.LastRunAt = &startedAt task.LastStatus = "running" if err := s.tasks.Update(ctx, task); err != nil { + finalizeErr := s.finalizeRecord(ctx, &runTask, record.ID, startedAt, model.BackupRecordStatusFailed, + "无法更新任务状态: "+err.Error(), "", "", 0, "", "", primaryTargetID) + if finalizeErr != nil { + err = errors.Join(err, finalizeErr) + } return nil, apperror.Internal("BACKUP_TASK_UPDATE_FAILED", "无法更新任务状态", err) } // 多节点路由:task.NodeID 指向远程节点时,把执行任务入队给 Agent; @@ -512,8 +466,10 @@ func (s *BackupExecutionService) startTask(ctx context.Context, id uint, async b // 节点离线 → 立即把刚创建的 running 记录标记 failed,返回明确错误 if remoteNode.Status != model.NodeStatusOnline { offlineMsg := fmt.Sprintf("节点 %s 当前离线,无法执行备份任务", remoteNode.Name) - _ = s.finalizeRecord(ctx, &runTask, record.ID, startedAt, model.BackupRecordStatusFailed, - offlineMsg, "", "", 0, "", "", primaryTargetID) + if finalizeErr := s.finalizeRecord(ctx, &runTask, record.ID, startedAt, model.BackupRecordStatusFailed, + offlineMsg, "", "", 0, "", "", primaryTargetID); finalizeErr != nil { + return nil, apperror.Internal("BACKUP_RECORD_FINALIZE_FAILED", "无法写回备份失败状态", finalizeErr) + } return nil, apperror.BadRequest("NODE_OFFLINE", offlineMsg, nil) } if _, enqueueErr := s.agentDispatcher.EnqueueCommand(ctx, resolvedNodeID, model.AgentCommandTypeRunTask, map[string]any{ @@ -521,19 +477,28 @@ func (s *BackupExecutionService) startTask(ctx context.Context, id uint, async b "recordId": record.ID, }); enqueueErr != nil { // 入队失败 → 在记录中标记失败,继续返回详情 - _ = s.finalizeRecord(ctx, &runTask, record.ID, startedAt, model.BackupRecordStatusFailed, - "无法下发任务到远程节点: "+enqueueErr.Error(), "", "", 0, "", "", primaryTargetID) + if finalizeErr := s.finalizeRecord(ctx, &runTask, record.ID, startedAt, model.BackupRecordStatusFailed, + "无法下发任务到远程节点: "+enqueueErr.Error(), "", "", 0, "", "", primaryTargetID); finalizeErr != nil { + enqueueErr = errors.Join(enqueueErr, finalizeErr) + } return nil, apperror.Internal("AGENT_COMMAND_ENQUEUE_FAILED", "无法下发任务到远程节点", enqueueErr) } return s.getRecordDetail(ctx, record.ID) } - run := func() { - s.executeTask(context.Background(), &runTask, record.ID, startedAt) + run := func(runCtx context.Context) { + s.executeTask(runCtx, &runTask, record.ID, startedAt) } if async { - s.async(run) + if !s.async(run) { + message := "服务正在关闭,备份任务未启动" + if finalizeErr := s.finalizeRecord(ctx, &runTask, record.ID, startedAt, model.BackupRecordStatusFailed, + message, "", "", 0, "", "", primaryTargetID); finalizeErr != nil { + return nil, apperror.Internal("BACKUP_RECORD_FINALIZE_FAILED", "无法写回备份失败状态", finalizeErr) + } + return nil, backgroundTaskUnavailable("BACKUP_SERVICE_SHUTTING_DOWN") + } } else { - run() + run(ctx) } return s.getRecordDetail(ctx, record.ID) } @@ -844,20 +809,23 @@ func (s *BackupExecutionService) executeRepositoryTask(ctx context.Context, task logger.Warnf("部分存储目标 CDC 仓库同步失败:%s", strings.Join(failures, "; ")) } if s.dependentsResolver != nil { - go func(upstreamID uint, upstreamName string) { - dependents, resolveErr := s.dependentsResolver.TriggerDependents(context.Background(), upstreamID) + accepted := s.async(func(runCtx context.Context) { + dependents, resolveErr := s.dependentsResolver.TriggerDependents(runCtx, task.ID) if resolveErr != nil { - logger.Warnf("解析任务 %s 的下游依赖失败:%v", upstreamName, resolveErr) + logger.Warnf("解析任务 %s 的下游依赖失败:%v", task.Name, resolveErr) return } for _, dependentID := range dependents { - if _, runErr := s.RunTaskByID(context.Background(), dependentID); runErr != nil { - logger.Warnf("触发下游任务 #%d 失败(上游: %s):%v", dependentID, upstreamName, runErr) + if _, runErr := s.RunTaskByID(runCtx, dependentID); runErr != nil { + logger.Warnf("触发下游任务 #%d 失败(上游: %s):%v", dependentID, task.Name, runErr) } else { - logger.Infof("已触发下游任务 #%d(上游: %s)", dependentID, upstreamName) + logger.Infof("已触发下游任务 #%d(上游: %s)", dependentID, task.Name) } } - }(task.ID, task.Name) + }) + if !accepted { + logger.Warnf("服务正在关闭,跳过触发任务 %s 的下游依赖", task.Name) + } } return result, nil } @@ -871,20 +839,6 @@ func (s *BackupExecutionService) acquireRepositoryLock(targetID uint) func() { } func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.BackupTask, recordID uint, startedAt time.Time) { - // 节点级并发限流:当任务绑定节点且节点配置了 MaxConcurrent>0, - // 该节点上所有任务共享一个节点专属 semaphore,互相排队 - nodeSem := s.acquireNodeSemaphore(ctx, task.NodeID) - if nodeSem != nil { - nodeSem <- struct{}{} - defer func() { <-nodeSem }() - } - s.semaphore <- struct{}{} - defer func() { <-s.semaphore }() - - // Prometheus: running gauge + 完成时 observe 耗时/字节/状态 - s.metrics.IncTaskRunning() - defer s.metrics.DecTaskRunning() - logger := backup.NewExecutionLogger(recordID, s.logHub) status := "failed" errMessage := "" @@ -900,8 +854,10 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba var manifestJSON string var repositoryProviders map[uint]storage.StorageProvider completeRecord := func() { + persistCtx, cancel := finalizationContext(ctx) + defer cancel() readyForRepositoryRetention := status == model.BackupRecordStatusSuccess - if finalizeErr := s.finalizeRecord(ctx, task, recordID, startedAt, status, errMessage, logger.String(), fileName, fileSize, checksum, storagePath, selectedStorageTargetID); finalizeErr != nil { + if finalizeErr := s.finalizeRecord(persistCtx, task, recordID, startedAt, status, errMessage, logger.String(), fileName, fileSize, checksum, storagePath, selectedStorageTargetID); finalizeErr != nil { logger.Errorf("写回备份记录失败:%v", finalizeErr) readyForRepositoryRetention = false } @@ -913,7 +869,7 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba if marshalErr != nil { logger.Warnf("序列化多目标上传结果失败:%v", marshalErr) readyForRepositoryRetention = false - } else if record, findErr := s.records.FindByID(ctx, recordID); findErr != nil || record == nil { + } else if record, findErr := s.records.FindByID(persistCtx, recordID); findErr != nil || record == nil { if findErr != nil { logger.Warnf("读取备份记录以写回多目标结果失败:%v", findErr) } else { @@ -922,7 +878,7 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba readyForRepositoryRetention = false } else { record.StorageUploadResults = string(resultsJSON) - if updateErr := s.records.Update(ctx, record); updateErr != nil { + if updateErr := s.records.Update(persistCtx, record); updateErr != nil { logger.Warnf("写回多目标上传结果失败:%v", updateErr) readyForRepositoryRetention = false } @@ -930,11 +886,11 @@ 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 { + if record, findErr := s.records.FindByID(persistCtx, recordID); findErr == nil && record != nil { record.BackupKind = backupKind record.BaseRecordID = baseRecordID record.Manifest = manifestJSON - if updErr := s.records.Update(ctx, record); updErr != nil { + if updErr := s.records.Update(persistCtx, record); updErr != nil { logger.Warnf("写回差异链信息失败:%v", updErr) readyForRepositoryRetention = false } @@ -947,7 +903,7 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba readyForRepositoryRetention = false } } - if readyForRepositoryRetention && backupKind == model.BackupKindRepository && s.retention != nil && len(repositoryProviders) > 0 { + if ctx.Err() == nil && readyForRepositoryRetention && backupKind == model.BackupKindRepository && s.retention != nil && len(repositoryProviders) > 0 { targetIDs := make([]uint, 0, len(repositoryProviders)) for targetID := range repositoryProviders { targetIDs = append(targetIDs, targetID) @@ -969,8 +925,8 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba } } } - 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 { + if s.shouldNotify(persistCtx, task, status) { + if err := s.notifier.NotifyBackupResult(persistCtx, 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) } } else { @@ -980,6 +936,28 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba } defer completeRecord() + // 节点级并发限流:当任务绑定节点且节点配置了 MaxConcurrent>0, + // 该节点上所有任务共享一个节点专属 semaphore,互相排队。 + nodeSem := s.acquireNodeSemaphore(ctx, task.NodeID) + if nodeSem != nil { + if !acquireBackgroundSlot(ctx, nodeSem) { + errMessage = ctx.Err().Error() + logger.Warnf("等待节点执行槽时任务被取消:%v", ctx.Err()) + return + } + defer func() { <-nodeSem }() + } + if !acquireBackgroundSlot(ctx, s.semaphore) { + errMessage = ctx.Err().Error() + logger.Warnf("等待全局执行槽时任务被取消:%v", ctx.Err()) + return + } + defer func() { <-s.semaphore }() + + // Prometheus: running gauge + 完成时 observe 耗时/字节/状态 + s.metrics.IncTaskRunning() + defer s.metrics.DecTaskRunning() + spec, err := s.buildTaskSpec(task, startedAt) if err != nil { errMessage = err.Error() @@ -1217,20 +1195,24 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba // 自动派发复制(3-2-1):任务配置 ReplicationTargetIDs 且本次有任意目标成功时生效 // 触发下游依赖任务(best-effort,失败仅 warn) if s.dependentsResolver != nil { - go func(upstreamID uint, upstreamName string) { - dependents, err := s.dependentsResolver.TriggerDependents(context.Background(), upstreamID) + accepted := s.async(func(runCtx context.Context) { + dependents, err := s.dependentsResolver.TriggerDependents(runCtx, task.ID) if err != nil { + logger.Warnf("解析任务 %s 的下游依赖失败:%v", task.Name, err) return } for _, depID := range dependents { - _, runErr := s.RunTaskByID(context.Background(), depID) + _, runErr := s.RunTaskByID(runCtx, depID) if runErr != nil { - logger.Warnf("触发下游任务 #%d 失败(上游: %s): %v", depID, upstreamName, runErr) + logger.Warnf("触发下游任务 #%d 失败(上游: %s): %v", depID, task.Name, runErr) } else { - logger.Infof("已触发下游任务 #%d(上游: %s)", depID, upstreamName) + logger.Infof("已触发下游任务 #%d(上游: %s)", depID, task.Name) } } - }(task.ID, task.Name) + }) + if !accepted { + logger.Warnf("服务正在关闭,跳过触发任务 %s 的下游依赖", task.Name) + } } if s.replicationHook != nil && strings.TrimSpace(task.ReplicationTargetIDs) != "" { record := &model.BackupRecord{ @@ -1253,7 +1235,7 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba } } logger.Infof("触发自动复制(3-2-1 规则):%s", task.ReplicationTargetIDs) - s.replicationHook.TriggerAutoReplication(context.Background(), task, record) + s.replicationHook.TriggerAutoReplication(ctx, task, record) } } else { errMessage = strings.Join(failedMessages, "; ") @@ -1356,28 +1338,6 @@ func applyHANAExtraConfig(spec *backup.DatabaseSpec, extra map[string]any) { } } -func (s *BackupExecutionService) loadRecordProvider(ctx context.Context, recordID uint) (*model.BackupRecord, storage.StorageProvider, error) { - record, err := s.records.FindByID(ctx, recordID) - if err != nil { - return nil, nil, apperror.Internal("BACKUP_RECORD_GET_FAILED", "无法获取备份记录详情", err) - } - if record == nil { - return nil, nil, apperror.New(404, "BACKUP_RECORD_NOT_FOUND", "备份记录不存在", fmt.Errorf("backup record %d not found", recordID)) - } - if err := s.validateClusterAccessible(ctx, record); err != nil { - return nil, nil, err - } - provider, err := s.resolveProvider(ctx, record.StorageTargetID) - if err != nil { - return nil, nil, err - } - return record, provider, nil -} - -func (s *BackupExecutionService) prepareArtifactForRestore(artifactPath string) (string, error) { - return prepareBackupArtifact(s.cipher, artifactPath, nil) -} - func (s *BackupExecutionService) getRecordDetail(ctx context.Context, recordID uint) (*BackupRecordDetail, error) { record, err := s.records.FindByID(ctx, recordID) if err != nil { @@ -1410,25 +1370,6 @@ func buildOptionalError(message string) error { return fmt.Errorf("%s", message) } -func buildStorageProviderFromRepos(ctx context.Context, storageTargetID uint, storageTargets repository.StorageTargetRepository, storageRegistry *storage.Registry, cipher *codec.ConfigCipher) (storage.StorageProvider, *model.StorageTarget, error) { - target, err := storageTargets.FindByID(ctx, storageTargetID) - if err != nil { - return nil, nil, apperror.Internal("BACKUP_STORAGE_TARGET_LOOKUP_FAILED", "无法读取存储目标", err) - } - if target == nil { - return nil, nil, apperror.BadRequest("BACKUP_STORAGE_TARGET_INVALID", "存储目标不存在", nil) - } - var configMap map[string]any - if err := cipher.DecryptJSON(target.ConfigCiphertext, &configMap); err != nil { - return nil, nil, apperror.Internal("BACKUP_STORAGE_TARGET_DECRYPT_FAILED", "无法解密存储目标配置", err) - } - provider, err := storageRegistry.Create(ctx, storage.ParseProviderType(target.Type), configMap) - if err != nil { - return nil, nil, err - } - return provider, target, nil -} - // hashingReader 在上传过程中同步计算字节数和 SHA-256,零额外 I/O type hashingReader struct { reader io.Reader diff --git a/server/internal/service/backup_execution_service_test.go b/server/internal/service/backup_execution_service_test.go index 9a1900c..781a6b1 100644 --- a/server/internal/service/backup_execution_service_test.go +++ b/server/internal/service/backup_execution_service_test.go @@ -33,6 +33,8 @@ func (f *testStorageFactory) Type() storage.ProviderType { return "test_storage" } +func (f *testStorageFactory) SensitiveFields() []string { return nil } + func (f *testStorageFactory) New(_ context.Context, config map[string]any) (storage.StorageProvider, error) { name, _ := config["name"].(string) provider := f.providers[name] @@ -250,20 +252,6 @@ func TestBackupExecutionServiceRepositoryModeRoundTrip(t *testing.T) { t.Fatalf("unexpected repository export: name=%s size=%d", download.FileName, len(exported)) } - if err := os.WriteFile(largePath, bytes.Repeat([]byte{0}, len(large)), 0o640); err != nil { - t.Fatalf("damage source before restore: %v", err) - } - if err := executionService.RestoreRecord(ctx, second.ID); err != nil { - t.Fatalf("restore repository record returned error: %v", err) - } - restored, err := os.ReadFile(largePath) - if err != nil { - t.Fatalf("read restored source: %v", err) - } - if !bytes.Equal(restored, large) { - t.Fatalf("repository restore did not reproduce the source") - } - if err := recordService.Delete(ctx, first.ID); err != nil { t.Fatalf("delete first repository record: %v", err) } @@ -428,40 +416,6 @@ func TestBackupExecutionServiceDeleteRecordDispatchesRemoteLocalDiskCleanup(t *t } } -func TestBackupExecutionServiceRestoreRecordRejectsRemoteLocalDisk(t *testing.T) { - executionService, _, tasks, _, records, _, _ := newExecutionTestServices(t) - ctx := context.Background() - executionService.SetClusterDependencies(&nodeRepoStub{nodes: []model.Node{ - {ID: 10, Name: "edge-a", Token: "edge-a-token", Status: model.NodeStatusOnline}, - }}, &fakeDispatcher{}) - task, err := tasks.FindByID(ctx, 1) - if err != nil { - t.Fatalf("FindByID task returned error: %v", err) - } - completedAt := time.Now().UTC() - record := &model.BackupRecord{ - TaskID: task.ID, - StorageTargetID: task.StorageTargetID, - NodeID: 10, - Status: model.BackupRecordStatusSuccess, - FileName: "remote.tar.gz", - StoragePath: "file/2026/05/09/remote.tar.gz", - StartedAt: completedAt.Add(-time.Second), - CompletedAt: &completedAt, - } - if err := records.Create(ctx, record); err != nil { - t.Fatalf("Create record returned error: %v", err) - } - - err = executionService.RestoreRecord(ctx, record.ID) - if err == nil { - t.Fatal("expected remote local_disk restore to be rejected") - } - if !strings.Contains(err.Error(), "Master 无法跨节点访问") { - t.Fatalf("expected cross-node local_disk error, got %v", err) - } -} - func TestBackupExecutionServiceDownloadsMasterRelayedLocalDiskRecord(t *testing.T) { executionService, _, tasks, _, records, _, storageDir := newExecutionTestServices(t) ctx := context.Background() @@ -712,27 +666,6 @@ func TestBackupExecutionServiceContinuesWhenStorageUsageSnapshotFails(t *testing } } -func TestBackupRecordServiceRestore(t *testing.T) { - executionService, recordService, _, _, _, sourceDir, _ := newExecutionTestServices(t) - detail, err := executionService.RunTaskByIDSync(context.Background(), 1) - if err != nil { - t.Fatalf("RunTaskByIDSync returned error: %v", err) - } - if err := os.RemoveAll(sourceDir); err != nil { - t.Fatalf("RemoveAll returned error: %v", err) - } - if err := recordService.Restore(context.Background(), detail.ID); err != nil { - t.Fatalf("Restore returned error: %v", err) - } - content, err := os.ReadFile(filepath.Join(sourceDir, "index.html")) - if err != nil { - t.Fatalf("ReadFile returned error: %v", err) - } - if string(content) != "hello" { - t.Fatalf("unexpected restored content: %s", string(content)) - } -} - type storageUsageCountingRecordRepo struct { repository.BackupRecordRepository mu sync.Mutex diff --git a/server/internal/service/backup_record_lock_test.go b/server/internal/service/backup_record_lock_test.go index 3a0799f..87077ba 100644 --- a/server/internal/service/backup_record_lock_test.go +++ b/server/internal/service/backup_record_lock_test.go @@ -37,6 +37,7 @@ func newLockTestHarness(t *testing.T) (*BackupRecordService, *BackupExecutionSer if err != nil { t.Fatal(err) } + closeTestDatabase(t, db) cipher := codec.NewConfigCipher("lock-secret") targets := repository.NewStorageTargetRepository(db) tasks := repository.NewBackupTaskRepository(db) diff --git a/server/internal/service/backup_record_service.go b/server/internal/service/backup_record_service.go index 3b2461a..308ebfa 100644 --- a/server/internal/service/backup_record_service.go +++ b/server/internal/service/backup_record_service.go @@ -156,10 +156,6 @@ func (s *BackupRecordService) Download(ctx context.Context, id uint) (*Downloade return s.execution.DownloadRecord(ctx, id) } -func (s *BackupRecordService) Restore(ctx context.Context, id uint) error { - return s.execution.RestoreRecord(ctx, id) -} - func (s *BackupRecordService) Delete(ctx context.Context, id uint) error { return s.execution.DeleteRecord(ctx, id) } diff --git a/server/internal/service/backup_task_service.go b/server/internal/service/backup_task_service.go index 87eee42..44e5c71 100644 --- a/server/internal/service/backup_task_service.go +++ b/server/internal/service/backup_task_service.go @@ -3,6 +3,7 @@ package service import ( "context" "encoding/json" + "errors" "fmt" "net/http" "strconv" @@ -389,7 +390,15 @@ func (s *BackupTaskService) Delete(ctx context.Context, id uint) (*DeleteResult, return nil, apperror.New(http.StatusNotFound, "BACKUP_TASK_NOT_FOUND", "备份任务不存在", fmt.Errorf("backup task %d not found", id)) } if s.scheduler != nil { - _ = s.scheduler.RemoveTask(ctx, id) + if err := s.scheduler.RemoveTask(ctx, id); err != nil { + rollbackCtx, cancel := finalizationContext(ctx) + rollbackErr := s.scheduler.SyncTask(rollbackCtx, existing) + cancel() + if rollbackErr != nil { + err = errors.Join(err, fmt.Errorf("restore task schedule: %w", rollbackErr)) + } + return nil, apperror.Internal("BACKUP_TASK_UNSCHEDULE_FAILED", "无法移除备份任务调度", err) + } } // 清理远端存储文件(尽力而为,不阻止删除) @@ -397,6 +406,14 @@ func (s *BackupTaskService) Delete(ctx context.Context, id uint) (*DeleteResult, result.RecordCount, result.CleanedFiles = s.cleanupRemoteFiles(ctx, id) if err := s.tasks.Delete(ctx, id); err != nil { + if s.scheduler != nil { + rollbackCtx, cancel := finalizationContext(ctx) + rollbackErr := s.scheduler.SyncTask(rollbackCtx, existing) + cancel() + if rollbackErr != nil { + err = errors.Join(err, fmt.Errorf("restore task schedule: %w", rollbackErr)) + } + } return nil, apperror.Internal("BACKUP_TASK_DELETE_FAILED", "无法删除备份任务", err) } return result, nil diff --git a/server/internal/service/backup_task_service_test.go b/server/internal/service/backup_task_service_test.go index a62420b..da32f56 100644 --- a/server/internal/service/backup_task_service_test.go +++ b/server/internal/service/backup_task_service_test.go @@ -2,10 +2,12 @@ package service import ( "context" + "errors" "path/filepath" "strings" "testing" + "backupx/server/internal/apperror" "backupx/server/internal/config" "backupx/server/internal/database" "backupx/server/internal/logger" @@ -14,6 +16,34 @@ import ( "backupx/server/internal/storage/codec" ) +type backupTaskSchedulerStub struct { + removeErr error + syncErr error + removedIDs []uint + syncedTasks []model.BackupTask +} + +func (s *backupTaskSchedulerStub) SyncTask(_ context.Context, task *model.BackupTask) error { + if task != nil { + s.syncedTasks = append(s.syncedTasks, *task) + } + return s.syncErr +} + +func (s *backupTaskSchedulerStub) RemoveTask(_ context.Context, taskID uint) error { + s.removedIDs = append(s.removedIDs, taskID) + return s.removeErr +} + +type failingDeleteBackupTaskRepository struct { + repository.BackupTaskRepository + deleteErr error +} + +func (r *failingDeleteBackupTaskRepository) Delete(context.Context, uint) error { + return r.deleteErr +} + func newBackupTaskServiceForTest(t *testing.T) (*BackupTaskService, repository.StorageTargetRepository, repository.BackupTaskRepository) { t.Helper() log, err := logger.New(config.LogConfig{Level: "error"}) @@ -24,6 +54,7 @@ func newBackupTaskServiceForTest(t *testing.T) (*BackupTaskService, repository.S if err != nil { t.Fatalf("database.Open returned error: %v", err) } + closeTestDatabase(t, db) targets := repository.NewStorageTargetRepository(db) tasks := repository.NewBackupTaskRepository(db) service := NewBackupTaskService(tasks, targets, codec.NewConfigCipher("task-service-secret")) @@ -138,6 +169,76 @@ func TestBackupTaskServiceCreateAndGet(t *testing.T) { } } +func TestBackupTaskServiceDeleteKeepsTaskWhenUnscheduleFails(t *testing.T) { + ctx := context.Background() + service, targets, tasks := newBackupTaskServiceForTest(t) + if err := targets.Create(ctx, &model.StorageTarget{Name: "local", Type: "local_disk", Enabled: true, ConfigCiphertext: "ciphertext", ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil { + t.Fatal(err) + } + created, err := service.Create(ctx, BackupTaskUpsertInput{ + Name: "unschedule-failure", Type: "file", Enabled: true, SourcePath: "/srv/data", + StorageTargetID: 1, RetentionDays: 7, Compression: "gzip", MaxBackups: 3, + }) + if err != nil { + t.Fatal(err) + } + scheduler := &backupTaskSchedulerStub{removeErr: errors.New("remove failed")} + service.SetScheduler(scheduler) + + if _, err := service.Delete(ctx, created.ID); err == nil { + t.Fatal("Delete should fail when the scheduler cannot remove the task") + } else { + var appErr *apperror.AppError + if !errors.As(err, &appErr) || appErr.Code != "BACKUP_TASK_UNSCHEDULE_FAILED" { + t.Fatalf("Delete error = %#v", err) + } + } + stored, err := tasks.FindByID(ctx, created.ID) + if err != nil || stored == nil { + t.Fatalf("task should remain after unschedule failure: task=%#v err=%v", stored, err) + } + if len(scheduler.removedIDs) != 1 || len(scheduler.syncedTasks) != 1 { + t.Fatalf("scheduler calls = remove:%v sync:%d", scheduler.removedIDs, len(scheduler.syncedTasks)) + } +} + +func TestBackupTaskServiceDeleteRestoresScheduleWhenPersistenceFails(t *testing.T) { + ctx := context.Background() + service, targets, tasks := newBackupTaskServiceForTest(t) + if err := targets.Create(ctx, &model.StorageTarget{Name: "local", Type: "local_disk", Enabled: true, ConfigCiphertext: "ciphertext", ConfigVersion: 1, LastTestStatus: "unknown"}); err != nil { + t.Fatal(err) + } + created, err := service.Create(ctx, BackupTaskUpsertInput{ + Name: "delete-failure", Type: "file", Enabled: true, SourcePath: "/srv/data", + StorageTargetID: 1, RetentionDays: 7, Compression: "gzip", MaxBackups: 3, + }) + if err != nil { + t.Fatal(err) + } + service.tasks = &failingDeleteBackupTaskRepository{ + BackupTaskRepository: tasks, + deleteErr: errors.New("database delete failed"), + } + scheduler := &backupTaskSchedulerStub{} + service.SetScheduler(scheduler) + + if _, err := service.Delete(ctx, created.ID); err == nil { + t.Fatal("Delete should fail when persistence fails") + } else { + var appErr *apperror.AppError + if !errors.As(err, &appErr) || appErr.Code != "BACKUP_TASK_DELETE_FAILED" { + t.Fatalf("Delete error = %#v", err) + } + } + if len(scheduler.removedIDs) != 1 || len(scheduler.syncedTasks) != 1 || scheduler.syncedTasks[0].ID != created.ID { + t.Fatalf("scheduler rollback calls = remove:%v sync:%#v", scheduler.removedIDs, scheduler.syncedTasks) + } + stored, err := tasks.FindByID(ctx, created.ID) + if err != nil || stored == nil { + t.Fatalf("task should remain after persistence failure: task=%#v err=%v", stored, err) + } +} + func TestBackupTaskServiceKeepsMaskedPasswordOnUpdate(t *testing.T) { ctx := context.Background() service, targets, tasks := newBackupTaskServiceForTest(t) diff --git a/server/internal/service/cluster_version.go b/server/internal/service/cluster_version.go index b84183d..529afc0 100644 --- a/server/internal/service/cluster_version.go +++ b/server/internal/service/cluster_version.go @@ -21,6 +21,7 @@ type ClusterVersionMonitor struct { nodeRepo repository.NodeRepository eventDispatcher EventDispatcher masterVersion string + background BackgroundRunner mu sync.Mutex notified map[uint]time.Time } @@ -37,6 +38,10 @@ func (m *ClusterVersionMonitor) SetEventDispatcher(dispatcher EventDispatcher) { m.eventDispatcher = dispatcher } +func (m *ClusterVersionMonitor) SetBackgroundRunner(runner BackgroundRunner) { + m.background = runner +} + // Start 启动后台扫描。ctx 取消时退出。 // scanInterval 建议 30 分钟;resetInterval 建议 24 小时。 func (m *ClusterVersionMonitor) Start(ctx context.Context, scanInterval, resetInterval time.Duration) { @@ -47,19 +52,19 @@ func (m *ClusterVersionMonitor) Start(ctx context.Context, scanInterval, resetIn resetInterval = 24 * time.Hour } // 启动立即跑一次,让控制台尽快看到 - go func() { - m.scan(ctx, resetInterval) + startBackgroundMonitor(m.background, ctx, func(runCtx context.Context) { + m.scan(runCtx, resetInterval) ticker := time.NewTicker(scanInterval) defer ticker.Stop() for { select { - case <-ctx.Done(): + case <-runCtx.Done(): return case <-ticker.C: - m.scan(ctx, resetInterval) + m.scan(runCtx, resetInterval) } } - }() + }) } func (m *ClusterVersionMonitor) scan(ctx context.Context, resetInterval time.Duration) { diff --git a/server/internal/service/dashboard_notification_service_test.go b/server/internal/service/dashboard_notification_service_test.go index 8629206..3ac18ae 100644 --- a/server/internal/service/dashboard_notification_service_test.go +++ b/server/internal/service/dashboard_notification_service_test.go @@ -45,6 +45,7 @@ func newDashboardNotificationTestDeps(t *testing.T) (*DashboardService, *Notific if err != nil { t.Fatalf("database.Open returned error: %v", err) } + closeTestDatabase(t, db) tasks := repository.NewBackupTaskRepository(db) records := repository.NewBackupRecordRepository(db) targets := repository.NewStorageTargetRepository(db) diff --git a/server/internal/service/dashboard_service.go b/server/internal/service/dashboard_service.go index 00f6bd1..4330d8c 100644 --- a/server/internal/service/dashboard_service.go +++ b/server/internal/service/dashboard_service.go @@ -34,6 +34,7 @@ type DashboardService struct { targets repository.StorageTargetRepository nodes repository.NodeRepository masterVersion string + background BackgroundRunner // slaMonitor 内部跟踪已告警的违约任务,避免每次扫描重复派发事件 slaNotified map[uint]time.Time slaMu sync.Mutex @@ -43,6 +44,10 @@ func NewDashboardService(tasks repository.BackupTaskRepository, records reposito return &DashboardService{tasks: tasks, records: records, targets: targets, slaNotified: map[uint]time.Time{}} } +func (s *DashboardService) SetBackgroundRunner(runner BackgroundRunner) { + s.background = runner +} + // SetClusterDependencies 注入节点仓储与 Master 版本,启用集群概览。 func (s *DashboardService) SetClusterDependencies(nodes repository.NodeRepository, masterVersion string) { s.nodes = nodes @@ -561,18 +566,18 @@ func (s *DashboardService) StartSLAMonitor(ctx context.Context, dispatcher Event if resetInterval <= 0 { resetInterval = 6 * time.Hour } - ticker := time.NewTicker(scanInterval) - go func() { + startBackgroundMonitor(s.background, ctx, func(runCtx context.Context) { + ticker := time.NewTicker(scanInterval) defer ticker.Stop() for { select { - case <-ctx.Done(): + case <-runCtx.Done(): return case <-ticker.C: - s.scanAndDispatchSLA(ctx, dispatcher, resetInterval) + s.scanAndDispatchSLA(runCtx, dispatcher, resetInterval) } } - }() + }) } // scanAndDispatchSLA 执行一次 SLA 违约扫描并按需派发事件。 diff --git a/server/internal/service/google_drive_oauth_service_test.go b/server/internal/service/google_drive_oauth_service_test.go index 7c314f5..bf354f1 100644 --- a/server/internal/service/google_drive_oauth_service_test.go +++ b/server/internal/service/google_drive_oauth_service_test.go @@ -27,6 +27,7 @@ func TestGoogleDriveOAuthServiceStartAndComplete(t *testing.T) { if err != nil { t.Fatalf("database.Open returned error: %v", err) } + closeTestDatabase(t, db) sessions := repository.NewOAuthSessionRepository(db) service := NewGoogleDriveOAuthService(sessions, codec.New("encryption-secret")) service.now = func() time.Time { return time.Date(2026, 3, 7, 0, 0, 0, 0, time.UTC) } diff --git a/server/internal/service/install_token_service.go b/server/internal/service/install_token_service.go index 27a5481..081029c 100644 --- a/server/internal/service/install_token_service.go +++ b/server/internal/service/install_token_service.go @@ -18,14 +18,19 @@ import ( // InstallTokenService 负责一次性安装令牌的创建/消费/校验。 type InstallTokenService struct { - repo repository.AgentInstallTokenRepository - nodeRepo repository.NodeRepository + repo repository.AgentInstallTokenRepository + nodeRepo repository.NodeRepository + background BackgroundRunner } func NewInstallTokenService(repo repository.AgentInstallTokenRepository, nodeRepo repository.NodeRepository) *InstallTokenService { return &InstallTokenService{repo: repo, nodeRepo: nodeRepo} } +func (s *InstallTokenService) SetBackgroundRunner(runner BackgroundRunner) { + s.background = runner +} + // InstallTokenInput 生成一次性安装令牌的输入。 type InstallTokenInput struct { NodeID uint @@ -247,18 +252,18 @@ func (s *InstallTokenService) StartGC(ctx context.Context, interval time.Duratio if interval <= 0 { interval = time.Hour } - go func() { + startBackgroundMonitor(s.background, ctx, func(runCtx context.Context) { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { - case <-ctx.Done(): + case <-runCtx.Done(): return case <-ticker.C: - _, _ = s.repo.DeleteExpiredBefore(ctx, time.Now().UTC().Add(-7*24*time.Hour)) + _, _ = s.repo.DeleteExpiredBefore(runCtx, time.Now().UTC().Add(-7*24*time.Hour)) } } - }() + }) } func (s *InstallTokenService) validate(in InstallTokenInput) error { diff --git a/server/internal/service/install_token_service_test.go b/server/internal/service/install_token_service_test.go index e9a7bf0..68b0fa8 100644 --- a/server/internal/service/install_token_service_test.go +++ b/server/internal/service/install_token_service_test.go @@ -22,6 +22,7 @@ func openInstallTokenTestDB(t *testing.T) *gorm.DB { if err != nil { t.Fatalf("open: %v", err) } + closeTestDatabase(t, db) if err := db.AutoMigrate(&model.AgentInstallToken{}, &model.Node{}); err != nil { t.Fatalf("migrate: %v", err) } diff --git a/server/internal/service/node_service.go b/server/internal/service/node_service.go index 57433f8..19e9d28 100644 --- a/server/internal/service/node_service.go +++ b/server/internal/service/node_service.go @@ -67,11 +67,12 @@ type NodeUpdateInput struct { // NodeService manages the cluster nodes. type NodeService struct { - repo repository.NodeRepository - taskRepo repository.BackupTaskRepository - agentRPC NodeAgentRPC - cmdRepo repository.AgentCommandRepository - version string + repo repository.NodeRepository + taskRepo repository.BackupTaskRepository + agentRPC NodeAgentRPC + cmdRepo repository.AgentCommandRepository + version string + background BackgroundRunner } // NodeAgentRPC 抽象 Agent 远程调用能力(避免 service 内循环依赖)。 @@ -85,6 +86,10 @@ func NewNodeService(repo repository.NodeRepository, version string) *NodeService return &NodeService{repo: repo, version: version} } +func (s *NodeService) SetBackgroundRunner(runner BackgroundRunner) { + s.background = runner +} + // SetTaskRepository 注入任务仓储以支持删除前引用检查。可选注入,便于测试。 func (s *NodeService) SetTaskRepository(taskRepo repository.BackupTaskRepository) { s.taskRepo = taskRepo @@ -315,19 +320,19 @@ func (s *NodeService) StartOfflineMonitor(ctx context.Context, interval time.Dur if interval <= 0 { interval = 15 * time.Second } - ticker := time.NewTicker(interval) - go func() { + startBackgroundMonitor(s.background, ctx, func(runCtx context.Context) { + ticker := time.NewTicker(interval) defer ticker.Stop() for { select { - case <-ctx.Done(): + case <-runCtx.Done(): return case <-ticker.C: threshold := time.Now().UTC().Add(-OfflineThreshold) - _, _ = s.repo.MarkStaleOffline(ctx, threshold) + _, _ = s.repo.MarkStaleOffline(runCtx, threshold) } } - }() + }) } // Heartbeat updates the node status when an agent reports in. diff --git a/server/internal/service/node_service_test.go b/server/internal/service/node_service_test.go index 7896b0e..7debe63 100644 --- a/server/internal/service/node_service_test.go +++ b/server/internal/service/node_service_test.go @@ -20,6 +20,7 @@ func openNodeServiceDB(t *testing.T) *gorm.DB { if err != nil { t.Fatalf("open: %v", err) } + closeTestDatabase(t, db) if err := db.AutoMigrate(&model.Node{}); err != nil { t.Fatalf("migrate: %v", err) } diff --git a/server/internal/service/notification_service.go b/server/internal/service/notification_service.go index a37d576..1b5c76a 100644 --- a/server/internal/service/notification_service.go +++ b/server/internal/service/notification_service.go @@ -172,7 +172,7 @@ func (s *NotificationService) NotifyBackupResult(ctx context.Context, event Back if success { eventType = model.NotificationEventBackupSuccess } - items, err := s.collectSubscribers(ctx, eventType, success) + items, err := s.collectSubscribers(ctx, eventType) if err != nil { return err } @@ -194,9 +194,7 @@ func (s *NotificationService) DispatchEvent(ctx context.Context, eventType strin if s.broadcaster != nil { _ = s.broadcaster.Publish(ctx, eventType, title, body, fields) } - // 将 fallback 布尔用于旧语义场景(backup_success / backup_failed)。 - fallbackSuccess := eventType == model.NotificationEventBackupSuccess - items, err := s.collectSubscribers(ctx, eventType, fallbackSuccess) + items, err := s.collectSubscribers(ctx, eventType) if err != nil { return err } @@ -254,7 +252,7 @@ func (s *NotificationService) sendFirstByType(ctx context.Context, notificationT // collectSubscribers 按事件类型收集启用的订阅者。 // 列出启用通知后按事件类型再过滤(避免引入新 repository 方法)。 -func (s *NotificationService) collectSubscribers(ctx context.Context, eventType string, fallbackSuccess bool) ([]model.Notification, error) { +func (s *NotificationService) collectSubscribers(ctx context.Context, eventType string) ([]model.Notification, error) { all, err := s.notifications.List(ctx) if err != nil { return nil, err @@ -284,8 +282,6 @@ func (s *NotificationService) collectSubscribers(ctx context.Context, eventType // 其他事件类型必须显式订阅才推送 continue } - // 额外校验 fallbackSuccess 参数,保持历史行为一致 - _ = fallbackSuccess } matched = append(matched, item) } diff --git a/server/internal/service/replication_service.go b/server/internal/service/replication_service.go index b811472..d0720f9 100644 --- a/server/internal/service/replication_service.go +++ b/server/internal/service/replication_service.go @@ -15,6 +15,7 @@ import ( "backupx/server/internal/repository" "backupx/server/internal/storage" "backupx/server/internal/storage/codec" + "go.uber.org/zap" ) // ReplicationService 实现备份复制(3-2-1 规则核心)。 @@ -36,9 +37,10 @@ type ReplicationService struct { eventDispatcher EventDispatcher tempDir string semaphore chan struct{} - async func(func()) + async func(func(context.Context)) bool now func() time.Time metrics *metrics.Metrics + logger *zap.Logger } // SetMetrics 注入 Prometheus 采集器。 @@ -46,6 +48,19 @@ func (s *ReplicationService) SetMetrics(m *metrics.Metrics) { s.metrics = m } +func (s *ReplicationService) SetLogger(logger *zap.Logger) { + if logger != nil { + s.logger = logger + } +} + +// SetBackgroundRunner binds replication work to the application lifecycle. +func (s *ReplicationService) SetBackgroundRunner(runner BackgroundRunner) { + if runner != nil { + s.async = runner.Go + } +} + func NewReplicationService( replications repository.ReplicationRecordRepository, records repository.BackupRecordRepository, @@ -71,8 +86,9 @@ func NewReplicationService( cipher: cipher, tempDir: tempDir, semaphore: make(chan struct{}, maxConcurrent), - async: func(job func()) { go job() }, + async: runDetached, now: func() time.Time { return time.Now().UTC() }, + logger: zap.NewNop(), } } @@ -123,13 +139,16 @@ func (s *ReplicationService) TriggerAutoReplication(ctx context.Context, task *m } // 跨节点 local_disk 场景保护:Master 无法访问远程节点本地文件 if err := s.validateClusterAccessible(ctx, record); err != nil { + s.logger.Warn("automatic replication skipped: source is not accessible", zap.Uint("backup_record_id", record.ID), zap.Error(err)) return } for _, destID := range destIDs { if destID == record.StorageTargetID { continue // 源与目标相同,跳过 } - _, _ = s.Start(ctx, record.ID, destID, "system") + if _, err := s.Start(ctx, record.ID, destID, "system"); err != nil { + s.logger.Warn("automatic replication start failed", zap.Uint("backup_record_id", record.ID), zap.Uint("dest_target_id", destID), zap.Error(err)) + } } } @@ -172,20 +191,24 @@ func (s *ReplicationService) Start(ctx context.Context, backupRecordID, destTarg if err := s.replications.Create(ctx, rep); err != nil { return nil, apperror.Internal("REPLICATION_CREATE_FAILED", "无法创建复制记录", err) } - s.async(func() { - s.executeReplication(context.Background(), rep.ID) - }) + repForRun := *rep + if !s.async(func(runCtx context.Context) { + s.executeReplication(runCtx, &repForRun) + }) { + message := "服务正在关闭,复制任务未启动" + if finalizeErr := s.finalizeReplication(ctx, rep, model.ReplicationStatusFailed, message, 0); finalizeErr != nil { + return nil, apperror.Internal("REPLICATION_FINALIZE_FAILED", "无法写回复制失败状态", finalizeErr) + } + return nil, backgroundTaskUnavailable("REPLICATION_SERVICE_SHUTTING_DOWN") + } summary := s.toSummary(rep, "", dest.Name) return &summary, nil } // executeReplication 实际执行:下载源对象到本地临时文件 → 上传到目标存储。 -func (s *ReplicationService) executeReplication(ctx context.Context, repID uint) { - s.semaphore <- struct{}{} - defer func() { <-s.semaphore }() - - rep, err := s.replications.FindByID(ctx, repID) - if err != nil || rep == nil { +func (s *ReplicationService) executeReplication(ctx context.Context, rep *model.ReplicationRecord) { + if rep == nil { + s.logger.Error("replication record is nil") return } status := model.ReplicationStatusFailed @@ -193,19 +216,25 @@ func (s *ReplicationService) executeReplication(ctx context.Context, repID uint) fileSize := int64(0) defer func() { - completedAt := s.now() - rep.Status = status - rep.FileSize = fileSize - rep.ErrorMessage = strings.TrimSpace(errMessage) - rep.DurationSeconds = int(completedAt.Sub(rep.StartedAt).Seconds()) - rep.CompletedAt = &completedAt - _ = s.replications.Update(ctx, rep) + persistCtx, cancel := finalizationContext(ctx) + defer cancel() + if finalizeErr := s.finalizeReplication(persistCtx, rep, status, errMessage, fileSize); finalizeErr != nil { + s.logger.Error("finalize replication record failed", zap.Uint("replication_id", rep.ID), zap.Error(finalizeErr)) + } s.metrics.ObserveReplication(status) if status == model.ReplicationStatusFailed { - s.dispatchFailed(ctx, rep, errMessage) + if dispatchErr := s.dispatchFailed(persistCtx, rep, errMessage); dispatchErr != nil { + s.logger.Warn("dispatch replication failure event failed", zap.Uint("replication_id", rep.ID), zap.Error(dispatchErr)) + } } }() + if !acquireBackgroundSlot(ctx, s.semaphore) { + errMessage = ctx.Err().Error() + return + } + defer func() { <-s.semaphore }() + sourceProvider, err := s.resolveProvider(ctx, rep.SourceTargetID) if err != nil { errMessage = err.Error() @@ -271,9 +300,19 @@ func (s *ReplicationService) validateClusterAccessible(ctx context.Context, reco "REPLICATION_CROSS_NODE_LOCAL_DISK", "复制。请改用云存储作为主备份") } -func (s *ReplicationService) dispatchFailed(ctx context.Context, rep *model.ReplicationRecord, message string) { +func (s *ReplicationService) finalizeReplication(ctx context.Context, rep *model.ReplicationRecord, status, message string, fileSize int64) error { + completedAt := s.now() + rep.Status = status + rep.FileSize = fileSize + rep.ErrorMessage = strings.TrimSpace(message) + rep.DurationSeconds = int(completedAt.Sub(rep.StartedAt).Seconds()) + rep.CompletedAt = &completedAt + return s.replications.Update(ctx, rep) +} + +func (s *ReplicationService) dispatchFailed(ctx context.Context, rep *model.ReplicationRecord, message string) error { if s.eventDispatcher == nil || rep == nil { - return + return nil } title := "BackupX 备份复制失败" body := fmt.Sprintf("备份记录:#%d\n源 → 目标:#%d → #%d\n错误:%s", rep.BackupRecordID, rep.SourceTargetID, rep.DestTargetID, message) @@ -285,7 +324,7 @@ func (s *ReplicationService) dispatchFailed(ctx context.Context, rep *model.Repl "destTargetId": rep.DestTargetID, "error": message, } - _ = s.eventDispatcher.DispatchEvent(ctx, model.NotificationEventReplicationFailed, title, body, fields) + return s.eventDispatcher.DispatchEvent(ctx, model.NotificationEventReplicationFailed, title, body, fields) } // List / Get / toSummary diff --git a/server/internal/service/replication_service_test.go b/server/internal/service/replication_service_test.go index a5c657d..e1308f6 100644 --- a/server/internal/service/replication_service_test.go +++ b/server/internal/service/replication_service_test.go @@ -47,6 +47,11 @@ func newReplicationTestHarness(t *testing.T) *replicationTestHarness { if err != nil { t.Fatalf("database.Open: %v", err) } + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("db.DB: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) cipher := codec.NewConfigCipher("replicate-secret") targets := repository.NewStorageTargetRepository(db) tasks := repository.NewBackupTaskRepository(db) @@ -109,8 +114,9 @@ func TestReplicationService_MirrorsToDestTarget(t *testing.T) { } done := make(chan struct{}) - h.repl.async = func(job func()) { - go func() { job(); close(done) }() + h.repl.async = func(job func(context.Context)) bool { + go func() { job(context.Background()); close(done) }() + return true } summary, err := h.repl.Start(ctx, backupDetail.ID, 2, "tester") if err != nil { diff --git a/server/internal/service/report_service_test.go b/server/internal/service/report_service_test.go index 768000f..0a6d4ba 100644 --- a/server/internal/service/report_service_test.go +++ b/server/internal/service/report_service_test.go @@ -36,6 +36,7 @@ func newReportTestHarness(t *testing.T) (*ReportService, *BackupExecutionService if err != nil { t.Fatal(err) } + closeTestDatabase(t, db) cipher := codec.NewConfigCipher("report-secret") targets := repository.NewStorageTargetRepository(db) tasks := repository.NewBackupTaskRepository(db) diff --git a/server/internal/service/restore_service.go b/server/internal/service/restore_service.go index 5d67531..1f62072 100644 --- a/server/internal/service/restore_service.go +++ b/server/internal/service/restore_service.go @@ -3,6 +3,7 @@ package service import ( "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -39,7 +40,7 @@ type RestoreService struct { eventDispatcher EventDispatcher tempDir string semaphore chan struct{} - async func(func()) + async func(func(context.Context)) bool now func() time.Time metrics *metrics.Metrics } @@ -49,6 +50,13 @@ func (s *RestoreService) SetMetrics(m *metrics.Metrics) { s.metrics = m } +// SetBackgroundRunner binds local restore work to the application lifecycle. +func (s *RestoreService) SetBackgroundRunner(runner BackgroundRunner) { + if runner != nil { + s.async = runner.Go + } +} + // NewRestoreService 构造恢复服务。maxConcurrent 控制本地并发恢复数。 func NewRestoreService( restores repository.RestoreRecordRepository, @@ -83,7 +91,7 @@ func NewRestoreService( dispatcher: dispatcher, tempDir: tempDir, semaphore: make(chan struct{}, maxConcurrent), - async: func(job func()) { go job() }, + async: runDetached, now: func() time.Time { return time.Now().UTC() }, } } @@ -187,12 +195,18 @@ func (s *RestoreService) StartSelective(ctx context.Context, backupRecordID uint // 远程节点路由 if remoteNode := s.resolveRemoteNode(ctx, restoreNodeID); remoteNode != nil { if s.dispatcher == nil { + message := "Agent 下发通道未就绪" + if finalizeErr := s.finalize(ctx, restore.ID, model.RestoreRecordStatusFailed, message); finalizeErr != nil { + return nil, apperror.Internal("RESTORE_FINALIZE_FAILED", "无法写回恢复失败状态", finalizeErr) + } return nil, apperror.Internal("RESTORE_DISPATCH_UNAVAILABLE", "Agent 下发通道未就绪", nil) } // 节点离线 → 立即标记 failed,避免记录永远卡在 running if remoteNode.Status != model.NodeStatusOnline { offlineMsg := fmt.Sprintf("节点 %s 当前离线,无法执行恢复", remoteNode.Name) - _ = s.finalize(ctx, restore.ID, model.RestoreRecordStatusFailed, offlineMsg) + if finalizeErr := s.finalize(ctx, restore.ID, model.RestoreRecordStatusFailed, offlineMsg); finalizeErr != nil { + return nil, apperror.Internal("RESTORE_FINALIZE_FAILED", "无法写回恢复失败状态", finalizeErr) + } s.logHub.Append(restore.ID, "error", offlineMsg) s.logHub.Complete(restore.ID, model.RestoreRecordStatusFailed) return nil, apperror.BadRequest("NODE_OFFLINE", offlineMsg, nil) @@ -200,8 +214,10 @@ func (s *RestoreService) StartSelective(ctx context.Context, backupRecordID uint if _, dispatchErr := s.dispatcher.EnqueueCommand(ctx, restoreNodeID, model.AgentCommandTypeRestoreRecord, map[string]any{ "restoreRecordId": restore.ID, }); dispatchErr != nil { - _ = s.finalize(ctx, restore.ID, model.RestoreRecordStatusFailed, - "下发恢复任务到远程节点失败: "+dispatchErr.Error()) + if finalizeErr := s.finalize(ctx, restore.ID, model.RestoreRecordStatusFailed, + "下发恢复任务到远程节点失败: "+dispatchErr.Error()); finalizeErr != nil { + dispatchErr = errors.Join(dispatchErr, finalizeErr) + } return nil, apperror.Internal("AGENT_COMMAND_ENQUEUE_FAILED", "无法下发恢复任务到远程节点", dispatchErr) } s.logHub.Append(restore.ID, "info", fmt.Sprintf("已下发恢复任务到节点 %s(#%d),等待 Agent 执行", remoteNode.Name, restoreNodeID)) @@ -209,10 +225,16 @@ func (s *RestoreService) StartSelective(ctx context.Context, backupRecordID uint } // 本地节点:异步执行 - run := func() { - s.executeLocally(context.Background(), restore.ID, task, record, selectedPaths, targetPath) + run := func(runCtx context.Context) { + s.executeLocally(runCtx, restore.ID, task, record, selectedPaths, targetPath) + } + if !s.async(run) { + message := "服务正在关闭,恢复任务未启动" + if finalizeErr := s.finalize(ctx, restore.ID, model.RestoreRecordStatusFailed, message); finalizeErr != nil { + return nil, apperror.Internal("RESTORE_FINALIZE_FAILED", "无法写回恢复失败状态", finalizeErr) + } + return nil, backgroundTaskUnavailable("RESTORE_SERVICE_SHUTTING_DOWN") } - s.async(run) return s.getDetail(ctx, restore.ID) } @@ -238,22 +260,30 @@ func (s *RestoreService) resolveRemoteNode(ctx context.Context, nodeID uint) *mo // executeLocally 在 Master 本地执行恢复。 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 }() - logger := backup.NewExecutionLogger(restoreID, s.logHub) status := model.RestoreRecordStatusFailed errMessage := "" defer func() { - finalizeErr := s.finalizeWithLog(ctx, restoreID, status, errMessage, logger.String()) + persistCtx, cancel := finalizationContext(ctx) + defer cancel() + finalizeErr := s.finalizeWithLog(persistCtx, restoreID, status, errMessage, logger.String()) if finalizeErr != nil { logger.Errorf("写回恢复记录失败:%v", finalizeErr) } s.logHub.Complete(restoreID, status) - s.dispatchRestoreEvent(ctx, restoreID, status, errMessage, task) + if dispatchErr := s.dispatchRestoreEvent(persistCtx, restoreID, status, errMessage, task); dispatchErr != nil { + logger.Warnf("派发恢复结果事件失败:%v", dispatchErr) + } }() + if !acquireBackgroundSlot(ctx, s.semaphore) { + errMessage = ctx.Err().Error() + logger.Warnf("等待恢复执行槽时任务被取消:%v", ctx.Err()) + return + } + defer func() { <-s.semaphore }() + logger.Infof("开始在本地执行恢复(备份记录 #%d)", backupRecord.ID) spec, specErr := s.buildTaskSpec(task, backupRecord.StartedAt) @@ -387,9 +417,9 @@ func backupKindLabel(kind string) string { // dispatchRestoreEvent 按终态向事件总线派发 restore_success 或 restore_failed。 // eventDispatcher 未注入时静默忽略,保持向后兼容。 -func (s *RestoreService) dispatchRestoreEvent(ctx context.Context, restoreID uint, status, errMessage string, task *model.BackupTask) { +func (s *RestoreService) dispatchRestoreEvent(ctx context.Context, restoreID uint, status, errMessage string, task *model.BackupTask) error { if s.eventDispatcher == nil { - return + return nil } var eventType, title string switch status { @@ -400,7 +430,7 @@ func (s *RestoreService) dispatchRestoreEvent(ctx context.Context, restoreID uin eventType = model.NotificationEventRestoreFailed title = "BackupX 恢复失败" default: - return + return nil } taskName := "未知任务" if task != nil { @@ -419,7 +449,7 @@ func (s *RestoreService) dispatchRestoreEvent(ctx context.Context, restoreID uin if task != nil { fields["taskId"] = task.ID } - _ = s.eventDispatcher.DispatchEvent(ctx, eventType, title, body, fields) + return s.eventDispatcher.DispatchEvent(ctx, eventType, title, body, fields) } // resolveProvider 解密存储目标配置并创建 provider(共享实现)。 diff --git a/server/internal/service/restore_service_test.go b/server/internal/service/restore_service_test.go index a411728..c5fb42c 100644 --- a/server/internal/service/restore_service_test.go +++ b/server/internal/service/restore_service_test.go @@ -84,6 +84,11 @@ func newRestoreTestHarness(t *testing.T, remoteNode bool) *restoreTestHarness { if err != nil { t.Fatalf("database.Open: %v", err) } + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("db.DB: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) cipher := codec.NewConfigCipher("restore-secret") targets := repository.NewStorageTargetRepository(db) tasks := repository.NewBackupTaskRepository(db) @@ -159,11 +164,12 @@ func TestRestoreServiceStart_LocalNodeExecutesInline(t *testing.T) { // 用同步 async 让测试可等待 done := make(chan struct{}) - h.service.async = func(job func()) { + h.service.async = func(job func(context.Context)) bool { go func() { - job() + job(context.Background()) close(done) }() + return true } detail, err := h.service.Start(ctx, backupDetail.ID, "tester") if err != nil { @@ -200,6 +206,62 @@ func TestRestoreServiceStart_LocalNodeExecutesInline(t *testing.T) { } } +func TestRestoreServiceStart_RepositoryRecord(t *testing.T) { + h := newRestoreTestHarness(t, false) + ctx := context.Background() + task, err := h.tasks.FindByID(ctx, 1) + if err != nil { + t.Fatalf("FindByID task: %v", err) + } + task.BackupMode = model.BackupModeRepository + if err := h.tasks.Update(ctx, task); err != nil { + t.Fatalf("Update repository task: %v", err) + } + + backupDetail, err := h.execution.RunTaskByIDSync(ctx, task.ID) + if err != nil { + t.Fatalf("RunTaskByIDSync repository: %v", err) + } + if backupDetail.BackupKind != model.BackupKindRepository { + t.Fatalf("expected repository backup, got %#v", backupDetail) + } + if err := os.RemoveAll(h.sourceDir); err != nil { + t.Fatalf("remove source: %v", err) + } + + done := make(chan struct{}) + h.service.async = func(job func(context.Context)) bool { + go func() { + job(context.Background()) + close(done) + }() + return true + } + detail, err := h.service.Start(ctx, backupDetail.ID, "repository-test") + if err != nil { + t.Fatalf("Start repository restore: %v", err) + } + select { + case <-done: + case <-time.After(15 * time.Second): + t.Fatal("repository restore did not complete in time") + } + final, err := h.service.Get(ctx, detail.ID) + if err != nil { + t.Fatalf("Get repository restore: %v", err) + } + if final.Status != model.RestoreRecordStatusSuccess { + t.Fatalf("expected repository restore success, got %s (err=%s)", final.Status, final.ErrorMessage) + } + content, err := os.ReadFile(filepath.Join(h.sourceDir, "index.html")) + if err != nil { + t.Fatalf("read repository-restored file: %v", err) + } + if string(content) != "hello-restore" { + t.Fatalf("unexpected repository-restored content: %q", content) + } +} + // TestRestoreServiceStart_RejectsCorruptedBackup 验证恢复在还原前做 SHA-256 完整性 // 校验:若已存储的备份对象被损坏/篡改,恢复必须失败且不触碰源数据。 func TestRestoreServiceStart_RejectsCorruptedBackup(t *testing.T) { @@ -242,8 +304,9 @@ func TestRestoreServiceStart_RejectsCorruptedBackup(t *testing.T) { } done := make(chan struct{}) - h.service.async = func(job func()) { - go func() { job(); close(done) }() + h.service.async = func(job func(context.Context)) bool { + go func() { job(context.Background()); close(done) }() + return true } detail, err := h.service.Start(ctx, backupDetail.ID, "tester") if err != nil { @@ -293,11 +356,12 @@ func TestRestoreServiceStart_RestoresToAlternatePath(t *testing.T) { } done := make(chan struct{}) - h.service.async = func(job func()) { + h.service.async = func(job func(context.Context)) bool { go func() { - job() + job(context.Background()) close(done) }() + return true } detail, err := h.service.StartSelective(ctx, backupDetail.ID, nil, altDir, "tester") if err != nil { diff --git a/server/internal/service/retention_service.go b/server/internal/service/retention_service.go deleted file mode 100644 index 68184f0..0000000 --- a/server/internal/service/retention_service.go +++ /dev/null @@ -1,71 +0,0 @@ -package service - -import ( - "context" - "time" - - "backupx/server/internal/model" - "backupx/server/internal/repository" - "backupx/server/internal/storage" - "backupx/server/internal/storage/codec" -) - -type RetentionService struct { - records repository.BackupRecordRepository - storageTargets repository.StorageTargetRepository - storageRegistry *storage.Registry - cipher *codec.ConfigCipher -} - -func NewRetentionService(records repository.BackupRecordRepository, storageTargets repository.StorageTargetRepository, storageRegistry *storage.Registry, cipher *codec.ConfigCipher) *RetentionService { - return &RetentionService{records: records, storageTargets: storageTargets, storageRegistry: storageRegistry, cipher: cipher} -} - -func (s *RetentionService) Apply(ctx context.Context, task *model.BackupTask) error { - if task == nil || (task.RetentionDays <= 0 && task.MaxBackups <= 0) { - return nil - } - items, err := s.records.ListSuccessfulByTask(ctx, task.ID) - if err != nil { - return err - } - removeSet := make(map[uint]model.BackupRecord) - if task.RetentionDays > 0 { - cutoff := time.Now().UTC().AddDate(0, 0, -task.RetentionDays) - for _, item := range items { - if item.CompletedAt != nil && item.CompletedAt.Before(cutoff) { - removeSet[item.ID] = item - } - } - } - if task.MaxBackups > 0 { - kept := 0 - for _, item := range items { - if _, marked := removeSet[item.ID]; marked { - continue - } - kept++ - if kept > task.MaxBackups { - removeSet[item.ID] = item - } - } - } - if len(removeSet) == 0 { - return nil - } - provider, _, err := buildStorageProviderFromRepos(ctx, task.StorageTargetID, s.storageTargets, s.storageRegistry, s.cipher) - if err != nil { - return err - } - for _, item := range removeSet { - if item.StoragePath != "" { - if err := provider.Delete(ctx, item.StoragePath); err != nil { - return err - } - } - if err := s.records.Delete(ctx, item.ID); err != nil { - return err - } - } - return nil -} diff --git a/server/internal/service/storage_target_service.go b/server/internal/service/storage_target_service.go index bc8b552..56ccf6e 100644 --- a/server/internal/service/storage_target_service.go +++ b/server/internal/service/storage_target_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "net/http" "strings" @@ -95,6 +96,7 @@ type StorageTargetService struct { records repository.BackupRecordRepository registry *storage.Registry cipher *codec.ConfigCipher + background BackgroundRunner } func NewStorageTargetService( @@ -114,6 +116,10 @@ func (s *StorageTargetService) SetBackupRecordRepository(records repository.Back s.records = records } +func (s *StorageTargetService) SetBackgroundRunner(runner BackgroundRunner) { + s.background = runner +} + func (s *StorageTargetService) List(ctx context.Context) ([]StorageTargetSummary, error) { items, err := s.targets.List(ctx) if err != nil { @@ -254,7 +260,12 @@ func (s *StorageTargetService) TestConnection(ctx context.Context, input Storage item.LastTestMessage = "连接成功" } if item.ID != 0 { - _ = s.targets.Update(ctx, item) + if updateErr := s.targets.Update(ctx, item); updateErr != nil { + if testErr != nil { + return apperror.BadRequest("STORAGE_TARGET_TEST_FAILED", sanitizeMessage(testErr.Error()), errors.Join(testErr, fmt.Errorf("save connection test result: %w", updateErr))) + } + return apperror.Internal("STORAGE_TARGET_TEST_RESULT_SAVE_FAILED", "连接成功,但无法保存测试结果", updateErr) + } } if testErr != nil { return apperror.BadRequest("STORAGE_TARGET_TEST_FAILED", sanitizeMessage(testErr.Error()), testErr) @@ -269,23 +280,23 @@ func (s *StorageTargetService) StartHealthMonitor(ctx context.Context, dispatche if interval <= 0 { interval = 5 * time.Minute } - ticker := time.NewTicker(interval) // notified 跟踪已告警的目标,避免每轮重复 notified := map[uint]bool{} capacityNotified := map[uint]bool{} var mu sync.Mutex - go func() { + startBackgroundMonitor(s.background, ctx, func(runCtx context.Context) { + ticker := time.NewTicker(interval) defer ticker.Stop() for { select { - case <-ctx.Done(): + case <-runCtx.Done(): return case <-ticker.C: - s.runHealthCheckOnce(ctx, dispatcher, &mu, notified) - s.runCapacityCheckOnce(ctx, dispatcher, &mu, capacityNotified) + s.runHealthCheckOnce(runCtx, dispatcher, &mu, notified) + s.runCapacityCheckOnce(runCtx, dispatcher, &mu, capacityNotified) } } - }() + }) } // StorageCapacityWarningThreshold 存储使用率告警阈值(85%)。 @@ -371,7 +382,6 @@ func (s *StorageTargetService) runHealthCheckOnce(ctx context.Context, dispatche if !target.Enabled { continue } - previousStatus := target.LastTestStatus configMap := map[string]any{} if err := s.cipher.DecryptJSON(target.ConfigCiphertext, &configMap); err != nil { continue @@ -380,13 +390,13 @@ func (s *StorageTargetService) runHealthCheckOnce(ctx context.Context, dispatche now := time.Now().UTC() if err != nil { s.applyHealthResult(ctx, &target, now, false, err.Error()) - s.notifyUnhealthyTransition(ctx, dispatcher, mu, notified, &target, previousStatus, err.Error()) + s.notifyUnhealthyTransition(ctx, dispatcher, mu, notified, &target, err.Error()) continue } testErr := provider.TestConnection(ctx) if testErr != nil { s.applyHealthResult(ctx, &target, now, false, testErr.Error()) - s.notifyUnhealthyTransition(ctx, dispatcher, mu, notified, &target, previousStatus, testErr.Error()) + s.notifyUnhealthyTransition(ctx, dispatcher, mu, notified, &target, testErr.Error()) continue } s.applyHealthResult(ctx, &target, now, true, "连接成功") @@ -408,7 +418,7 @@ func (s *StorageTargetService) applyHealthResult(ctx context.Context, target *mo _ = s.targets.Update(ctx, target) } -func (s *StorageTargetService) notifyUnhealthyTransition(ctx context.Context, dispatcher EventDispatcher, mu *sync.Mutex, notified map[uint]bool, target *model.StorageTarget, previousStatus string, message string) { +func (s *StorageTargetService) notifyUnhealthyTransition(ctx context.Context, dispatcher EventDispatcher, mu *sync.Mutex, notified map[uint]bool, target *model.StorageTarget, message string) { if dispatcher == nil { return } @@ -423,7 +433,6 @@ func (s *StorageTargetService) notifyUnhealthyTransition(ctx context.Context, di if already { return } - _ = previousStatus // 保留参数便于未来扩展:区分"从未测试"与"从 success 掉线" title := "BackupX 存储目标连接失败" body := fmt.Sprintf("存储目标:%s (类型: %s)\n错误:%s", target.Name, target.Type, message) fields := map[string]any{ @@ -473,7 +482,9 @@ func (s *StorageTargetService) CompleteGoogleDriveOAuth(ctx context.Context, inp // Mark used immediately to prevent duplicate requests (e.g. React StrictMode double invocation) now := time.Now().UTC() session.UsedAt = &now - _ = s.oauthSessions.Update(ctx, session) + if err := s.oauthSessions.Update(ctx, session); err != nil { + return nil, apperror.Internal("STORAGE_GOOGLE_OAUTH_SESSION_FAILED", "无法锁定 Google Drive 授权会话", err) + } var draft googleDriveOAuthDraft if err := s.cipher.DecryptJSON(session.PayloadCiphertext, &draft); err != nil { diff --git a/server/internal/service/task_export_service.go b/server/internal/service/task_export_service.go index 1e54ccb..8c78a47 100644 --- a/server/internal/service/task_export_service.go +++ b/server/internal/service/task_export_service.go @@ -180,7 +180,7 @@ func (s *TaskExportService) Import(ctx context.Context, payload ExportPayload) ( results = append(results, ImportResult{Name: t.Name, TaskID: detail.ID, Success: true}) } // 第二阶段:依赖链接(上游任务名 → 新 ID) - for i, t := range payload.Tasks { + for _, t := range payload.Tasks { if len(t.DependsOnTaskNames) == 0 { continue } @@ -206,7 +206,6 @@ func (s *TaskExportService) Import(ctx context.Context, payload ExportPayload) ( break } } - _ = i } } return results, nil diff --git a/server/internal/service/task_template_service.go b/server/internal/service/task_template_service.go index 1731d22..70d3a41 100644 --- a/server/internal/service/task_template_service.go +++ b/server/internal/service/task_template_service.go @@ -3,7 +3,6 @@ package service import ( "context" "encoding/json" - "fmt" "strings" "backupx/server/internal/apperror" @@ -235,6 +234,3 @@ func toTemplateSummary(item *model.TaskTemplate) TaskTemplateSummary { UpdatedAt: item.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), } } - -// 确保未使用告警 -var _ = fmt.Sprintf diff --git a/server/internal/service/test_database_test.go b/server/internal/service/test_database_test.go new file mode 100644 index 0000000..52ead6c --- /dev/null +++ b/server/internal/service/test_database_test.go @@ -0,0 +1,22 @@ +package service + +import ( + "testing" + + "gorm.io/gorm" +) + +// closeTestDatabase releases SQLite file handles before testing.TempDir cleanup. +// Windows does not permit removal of an open database file. +func closeTestDatabase(t *testing.T, db *gorm.DB) { + t.Helper() + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("get test database handle: %v", err) + } + t.Cleanup(func() { + if err := sqlDB.Close(); err != nil { + t.Errorf("close test database: %v", err) + } + }) +} diff --git a/server/internal/service/verification_service.go b/server/internal/service/verification_service.go index 45e7f40..c175351 100644 --- a/server/internal/service/verification_service.go +++ b/server/internal/service/verification_service.go @@ -39,7 +39,7 @@ type VerificationService struct { notifier VerificationNotifier tempDir string semaphore chan struct{} - async func(func()) + async func(func(context.Context)) bool now func() time.Time metrics *metrics.Metrics } @@ -49,6 +49,13 @@ func (s *VerificationService) SetMetrics(m *metrics.Metrics) { s.metrics = m } +// SetBackgroundRunner binds local verification work to the application lifecycle. +func (s *VerificationService) SetBackgroundRunner(runner BackgroundRunner) { + if runner != nil { + s.async = runner.Go + } +} + // VerificationNotifier 给用户推送验证完成/失败通知。 // 可选注入:未注入时仅写记录。 type VerificationNotifier interface { @@ -129,7 +136,7 @@ func NewVerificationService( notifier: noopVerificationNotifier{}, tempDir: tempDir, semaphore: make(chan struct{}, maxConcurrent), - async: func(job func()) { go job() }, + async: runDetached, now: func() time.Time { return time.Now().UTC() }, } } @@ -231,10 +238,16 @@ func (s *VerificationService) Start(ctx context.Context, backupRecordID uint, mo if err := s.verifications.Create(ctx, verification); err != nil { return nil, apperror.Internal("VERIFY_RECORD_CREATE_FAILED", "无法创建验证记录", err) } - run := func() { - s.executeLocally(context.Background(), verification.ID, task, record) + run := func(runCtx context.Context) { + s.executeLocally(runCtx, verification.ID, task, record) + } + if !s.async(run) { + message := "服务正在关闭,验证任务未启动" + if finalizeErr := s.finalize(ctx, verification.ID, model.VerificationRecordStatusFailed, message, "", ""); finalizeErr != nil { + return nil, apperror.Internal("VERIFY_FINALIZE_FAILED", "无法写回验证失败状态", finalizeErr) + } + return nil, backgroundTaskUnavailable("VERIFY_SERVICE_SHUTTING_DOWN") } - s.async(run) return s.getDetail(ctx, verification.ID) } @@ -247,25 +260,37 @@ func (s *VerificationService) validateClusterAccessible(ctx context.Context, rec // executeLocally 异步执行验证:下载 → 解密 → 解压 → 按类型校验。 func (s *VerificationService) executeLocally(ctx context.Context, verID uint, task *model.BackupTask, backupRecord *model.BackupRecord) { - s.semaphore <- struct{}{} - defer func() { <-s.semaphore }() - logger := backup.NewExecutionLogger(verID, s.logHub) status := model.VerificationRecordStatusFailed errMessage := "" summary := "" defer func() { - _ = s.finalize(ctx, verID, status, errMessage, summary, logger.String()) + persistCtx, cancel := finalizationContext(ctx) + defer cancel() + if finalizeErr := s.finalize(persistCtx, verID, status, errMessage, summary, logger.String()); finalizeErr != nil { + logger.Errorf("写回验证记录失败:%v", finalizeErr) + } s.logHub.Complete(verID, status) // 失败时推送通知(best-effort) if status == model.VerificationRecordStatusFailed && s.notifier != nil { - if record, err := s.verifications.FindByID(ctx, verID); err == nil && record != nil { - _ = s.notifier.NotifyVerificationResult(ctx, task, record) + if record, findErr := s.verifications.FindByID(persistCtx, verID); findErr != nil { + logger.Warnf("读取验证记录以发送通知失败:%v", findErr) + } else if record != nil { + if notifyErr := s.notifier.NotifyVerificationResult(persistCtx, task, record); notifyErr != nil { + logger.Warnf("发送验证失败通知失败:%v", notifyErr) + } } } }() + if !acquireBackgroundSlot(ctx, s.semaphore) { + errMessage = ctx.Err().Error() + logger.Warnf("等待验证执行槽时任务被取消:%v", ctx.Err()) + return + } + defer func() { <-s.semaphore }() + logger.Infof("开始验证备份记录 #%d(模式:%s)", backupRecord.ID, model.VerificationModeQuick) if err := os.MkdirAll(s.tempDir, 0o755); err != nil { diff --git a/server/internal/service/verification_service_test.go b/server/internal/service/verification_service_test.go index eb8a619..31bf3de 100644 --- a/server/internal/service/verification_service_test.go +++ b/server/internal/service/verification_service_test.go @@ -11,6 +11,7 @@ import ( "backupx/server/internal/backup" "backupx/server/internal/config" "backupx/server/internal/database" + "backupx/server/internal/lifecycle" "backupx/server/internal/logger" "backupx/server/internal/model" "backupx/server/internal/repository" @@ -45,6 +46,11 @@ func newVerifyTestHarness(t *testing.T) *verifyTestHarness { if err != nil { t.Fatalf("database.Open: %v", err) } + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("db.DB: %v", err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) cipher := codec.NewConfigCipher("verify-secret") targets := repository.NewStorageTargetRepository(db) tasks := repository.NewBackupTaskRepository(db) @@ -77,8 +83,9 @@ func (h *verifyTestHarness) runVerify(t *testing.T, backupRecordID uint) *Verifi t.Helper() ctx := context.Background() done := make(chan struct{}) - h.verify.async = func(job func()) { - go func() { job(); close(done) }() + h.verify.async = func(job func(context.Context)) bool { + go func() { job(context.Background()); close(done) }() + return true } detail, err := h.verify.Start(ctx, backupRecordID, "quick", "tester") if err != nil { @@ -96,6 +103,48 @@ func (h *verifyTestHarness) runVerify(t *testing.T, backupRecordID uint) *Verifi return final } +func TestVerificationServiceSupervisorCancellationFinalizesRecord(t *testing.T) { + h := newVerifyTestHarness(t) + backupDetail, err := h.execution.RunTaskByIDSync(context.Background(), 1) + if err != nil { + t.Fatalf("RunTaskByIDSync: %v", err) + } + + supervisor := lifecycle.NewSupervisor(context.Background()) + h.verify.SetBackgroundRunner(supervisor) + // Occupy the only available execution path before cancellation so the test + // deterministically exercises cancellation while queued. + for i := 0; i < cap(h.verify.semaphore); i++ { + h.verify.semaphore <- struct{}{} + } + detail, err := h.verify.Start(context.Background(), backupDetail.ID, model.VerificationModeQuick, "tester") + if err != nil { + t.Fatalf("Start: %v", err) + } + waitCtx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := supervisor.Shutdown(waitCtx); err != nil { + t.Fatalf("Shutdown: %v", err) + } + for i := 0; i < cap(h.verify.semaphore); i++ { + <-h.verify.semaphore + } + + final, err := h.verify.Get(context.Background(), detail.ID) + if err != nil { + t.Fatalf("Get: %v", err) + } + if final.Status != model.VerificationRecordStatusFailed { + t.Fatalf("status = %q, want failed", final.Status) + } + if final.CompletedAt == nil { + t.Fatal("canceled verification was not finalized") + } + if !strings.Contains(strings.ToLower(final.ErrorMessage), "canceled") { + t.Fatalf("error message = %q, want cancellation", final.ErrorMessage) + } +} + // TestVerificationService_Success 覆盖正常路径:对一个有效(gzip 压缩)的备份做验证应通过。 // 同时回归保护 #77——新增的 SHA-256 校验不得误伤合法的压缩备份。 func TestVerificationService_Success(t *testing.T) { diff --git a/server/internal/storage/rclone/registry.go b/server/internal/storage/rclone/registry.go new file mode 100644 index 0000000..f8291bc --- /dev/null +++ b/server/internal/storage/rclone/registry.go @@ -0,0 +1,21 @@ +package rclone + +import "backupx/server/internal/storage" + +// NewDefaultRegistry returns the storage factory set shared by Master, Agent, +// and the standalone Backint process. +func NewDefaultRegistry() *storage.Registry { + registry := storage.NewRegistry( + NewLocalDiskFactory(), + NewS3Factory(), + NewWebDAVFactory(), + NewGoogleDriveFactory(), + NewAliyunOSSFactory(), + NewTencentCOSFactory(), + NewQiniuKodoFactory(), + NewFTPFactory(), + NewRcloneFactory(), + ) + RegisterAllBackends(registry) + return registry +} diff --git a/server/internal/storage/registry.go b/server/internal/storage/registry.go index be2d4b1..0f0a74d 100644 --- a/server/internal/storage/registry.go +++ b/server/internal/storage/registry.go @@ -2,7 +2,6 @@ package storage import ( "context" - "encoding/json" "fmt" "sort" "sync" @@ -10,26 +9,6 @@ import ( "backupx/server/internal/apperror" ) -type providerFactoryWithNew interface { - New(context.Context, map[string]any) (StorageProvider, error) -} - -type providerFactoryWithCreate interface { - Create(context.Context, json.RawMessage) (StorageProvider, error) -} - -type providerFactoryWithSensitiveFields interface { - SensitiveFields() []string -} - -type providerFactoryWithSensitiveKeys interface { - SensitiveKeys() []string -} - -type providerFactoryWithValidate interface { - Validate(json.RawMessage) error -} - type Registry struct { mu sync.RWMutex factories map[ProviderType]ProviderFactory @@ -78,115 +57,20 @@ func (r *Registry) SensitiveFields(providerType string) []string { if !ok { return nil } - if typed, ok := factory.(providerFactoryWithSensitiveFields); ok { - return typed.SensitiveFields() - } - if typed, ok := factory.(providerFactoryWithSensitiveKeys); ok { - return typed.SensitiveKeys() - } - return nil -} - -func (r *Registry) SensitiveKeys(providerType string) []string { - return r.SensitiveFields(providerType) -} - -func (r *Registry) Validate(providerType string, raw json.RawMessage) error { - factory, ok := r.Factory(providerType) - if !ok { - return apperror.BadRequest("STORAGE_PROVIDER_UNSUPPORTED", "不支持的存储类型", fmt.Errorf("unsupported storage provider type: %s", providerType)) - } - if typed, ok := factory.(providerFactoryWithValidate); ok { - if err := typed.Validate(raw); err != nil { - return apperror.BadRequest("STORAGE_TARGET_INVALID_CONFIG", "存储目标配置不合法", err) - } - return nil - } - configMap, err := decodeConfigMap(raw) - if err != nil { - return apperror.BadRequest("STORAGE_TARGET_INVALID_CONFIG", "存储目标配置不合法", err) - } - if typed, ok := factory.(providerFactoryWithNew); ok { - if _, err := typed.New(context.Background(), configMap); err != nil { - return apperror.BadRequest("STORAGE_TARGET_INVALID_CONFIG", "存储目标配置不合法", err) - } - return nil - } - return apperror.BadRequest("STORAGE_TARGET_INVALID_CONFIG", "存储目标配置不合法", fmt.Errorf("provider %s has no validator", providerType)) + return factory.SensitiveFields() } -func (r *Registry) Create(ctx context.Context, providerType string, rawConfig any) (StorageProvider, error) { +func (r *Registry) Create(ctx context.Context, providerType string, config map[string]any) (StorageProvider, error) { factory, ok := r.Factory(providerType) if !ok { return nil, apperror.BadRequest("STORAGE_PROVIDER_UNSUPPORTED", "不支持的存储类型", fmt.Errorf("unsupported storage provider type: %s", providerType)) } - raw, configMap, err := normalizeConfig(rawConfig) - if err != nil { - return nil, apperror.BadRequest("STORAGE_TARGET_INVALID_CONFIG", "存储目标配置不合法", err) - } - if typed, ok := factory.(providerFactoryWithNew); ok { - provider, err := typed.New(ctx, configMap) - if err != nil { - return nil, apperror.BadRequest("STORAGE_TARGET_INVALID_CONFIG", "无法创建存储客户端", err) - } - return provider, nil + if config == nil { + config = map[string]any{} } - if typed, ok := factory.(providerFactoryWithCreate); ok { - provider, err := typed.Create(ctx, raw) - if err != nil { - return nil, apperror.BadRequest("STORAGE_TARGET_INVALID_CONFIG", "无法创建存储客户端", err) - } - return provider, nil - } - return nil, apperror.BadRequest("STORAGE_TARGET_INVALID_CONFIG", "无法创建存储客户端", fmt.Errorf("provider %s has no constructor", providerType)) -} - -func normalizeConfig(rawConfig any) (json.RawMessage, map[string]any, error) { - switch value := rawConfig.(type) { - case nil: - return json.RawMessage("{}"), map[string]any{}, nil - case map[string]any: - raw, err := json.Marshal(value) - if err != nil { - return nil, nil, fmt.Errorf("marshal config: %w", err) - } - return raw, value, nil - case json.RawMessage: - configMap, err := decodeConfigMap(value) - if err != nil { - return nil, nil, err - } - return value, configMap, nil - case []byte: - raw := json.RawMessage(value) - configMap, err := decodeConfigMap(raw) - if err != nil { - return nil, nil, err - } - return raw, configMap, nil - default: - raw, err := json.Marshal(value) - if err != nil { - return nil, nil, fmt.Errorf("marshal config: %w", err) - } - configMap, err := decodeConfigMap(raw) - if err != nil { - return nil, nil, err - } - return raw, configMap, nil - } -} - -func decodeConfigMap(raw json.RawMessage) (map[string]any, error) { - if len(raw) == 0 { - return map[string]any{}, nil - } - var configMap map[string]any - if err := json.Unmarshal(raw, &configMap); err != nil { - return nil, fmt.Errorf("decode config: %w", err) - } - if configMap == nil { - return map[string]any{}, nil + provider, err := factory.New(ctx, config) + if err != nil { + return nil, apperror.BadRequest("STORAGE_TARGET_INVALID_CONFIG", "无法创建存储客户端", err) } - return configMap, nil + return provider, nil } diff --git a/server/internal/storage/types.go b/server/internal/storage/types.go index aae226a..1376ab1 100644 --- a/server/internal/storage/types.go +++ b/server/internal/storage/types.go @@ -66,6 +66,8 @@ type StorageRangeDownloader interface { type ProviderFactory interface { Type() ProviderType + SensitiveFields() []string + New(context.Context, map[string]any) (StorageProvider, error) } // StorageAbout 是可选能力接口,支持查询远端存储空间。 diff --git a/server/pkg/response/response.go b/server/pkg/response/response.go index 54ca6b5..63cedc7 100644 --- a/server/pkg/response/response.go +++ b/server/pkg/response/response.go @@ -2,13 +2,15 @@ package response import ( "errors" - "fmt" "net/http" "backupx/server/internal/apperror" "github.com/gin-gonic/gin" + "go.uber.org/zap" ) +const loggerContextKey = "backupx.response.logger" + type Envelope struct { Code string `json:"code"` Message string `json:"message"` @@ -19,12 +21,50 @@ func Success(c *gin.Context, data any) { c.JSON(http.StatusOK, Envelope{Code: "OK", Message: "success", Data: data}) } +// SetLogger attaches the application logger to a request so response helpers +// can report failures without relying on a process-global logger. +func SetLogger(c *gin.Context, logger *zap.Logger) { + if logger != nil { + c.Set(loggerContextKey, logger) + } +} + func Error(c *gin.Context, err error) { - fmt.Printf("HTTP Error: %v\n", err) var appErr *apperror.AppError if errors.As(err, &appErr) { + logError(c, appErr.Status, appErr.Code, err) c.JSON(appErr.Status, Envelope{Code: appErr.Code, Message: appErr.Message}) return } + logError(c, http.StatusInternalServerError, "INTERNAL_ERROR", err) c.JSON(http.StatusInternalServerError, Envelope{Code: "INTERNAL_ERROR", Message: "服务器内部错误"}) } + +func logError(c *gin.Context, status int, code string, err error) { + value, exists := c.Get(loggerContextKey) + if !exists { + return + } + logger, ok := value.(*zap.Logger) + if !ok || logger == nil { + return + } + method := "" + path := "" + if c.Request != nil { + method = c.Request.Method + path = c.Request.URL.Path + } + fields := []zap.Field{ + zap.Int("status", status), + zap.String("code", code), + zap.String("method", method), + zap.String("path", path), + zap.Error(err), + } + if status >= http.StatusInternalServerError { + logger.Error("http request failed", fields...) + return + } + logger.Warn("http request rejected", fields...) +} diff --git a/server/pkg/response/response_test.go b/server/pkg/response/response_test.go new file mode 100644 index 0000000..4e5b472 --- /dev/null +++ b/server/pkg/response/response_test.go @@ -0,0 +1,39 @@ +package response + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" +) + +func TestErrorLogsInternalFailureAndHidesDetail(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodGet, "/api/test", nil) + core, observed := observer.New(zap.WarnLevel) + SetLogger(ctx, zap.New(core)) + + Error(ctx, errors.New("database password should stay private")) + + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusInternalServerError) + } + if strings.Contains(recorder.Body.String(), "database password") { + t.Fatalf("internal detail leaked in response: %s", recorder.Body.String()) + } + entries := observed.All() + if len(entries) != 1 || entries[0].Level != zap.ErrorLevel { + t.Fatalf("observed logs = %#v, want one error", entries) + } + context := entries[0].ContextMap() + if context["code"] != "INTERNAL_ERROR" || context["path"] != "/api/test" { + t.Fatalf("unexpected log context: %#v", context) + } +} diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 0000000..de54113 --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,4 @@ +coverage +dist +node_modules +package-lock.json diff --git a/web/.prettierrc.json b/web/.prettierrc.json new file mode 100644 index 0000000..0965e7e --- /dev/null +++ b/web/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100 +} diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..ef24650 --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,38 @@ +import js from '@eslint/js' +import eslintConfigPrettier from 'eslint-config-prettier' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { + ignores: ['coverage', 'dist'], + }, + { + files: ['**/*.{js,mjs,cjs}'], + ...js.configs.recommended, + }, + { + files: ['**/*.{ts,tsx}'], + extends: [...tseslint.configs.recommended], + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + varsIgnorePattern: '^_', + }, + ], + 'react-hooks/exhaustive-deps': 'error', + 'react-hooks/rules-of-hooks': 'error', + 'react-refresh/only-export-components': 'off', + }, + }, + eslintConfigPrettier, +) diff --git a/web/package-lock.json b/web/package-lock.json index eb1838a..c6e15cb 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16,10 +16,11 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^16.5.6", - "react-router-dom": "^7.0.0", + "react-router-dom": "^7.18.2", "zustand": "^5.0.15" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^14.6.4", @@ -27,8 +28,14 @@ "@types/react": "^18.3.20", "@types/react-dom": "^18.3.6", "@vitejs/plugin-react": "^4.3.4", + "eslint": "^10.9.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.4", "jsdom": "^26.0.0", + "prettier": "^3.9.6", "typescript": "^5.7.3", + "typescript-eslint": "^8.68.0", "vite": "^6.4.3", "vitest": "^4.1.10" }, @@ -947,6 +954,200 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1515,12 +1716,6 @@ "assertion-error": "^2.0.1" } }, - "node_modules/@types/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "license": "MIT" - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -1528,6 +1723,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", @@ -1535,6 +1737,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -1573,6 +1782,249 @@ "@types/react": "^18.0.0" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", + "integrity": "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/type-utils": "8.68.0", + "@typescript-eslint/utils": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.68.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz", + "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz", + "integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.68.0", + "@typescript-eslint/types": "^8.68.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz", + "integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz", + "integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz", + "integrity": "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/utils": "8.68.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz", + "integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz", + "integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.68.0", + "@typescript-eslint/tsconfig-utils": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.68.0.tgz", + "integrity": "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz", + "integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.68.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1707,6 +2159,29 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", @@ -1717,6 +2192,23 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1817,6 +2309,16 @@ "integrity": "sha512-iCvCkGFskbaYtfQ0a3GmcQCHl/Sv1GufXFGuUQ+FE+WJa7A/espLOuFIn09B944V8/ImPj71T4+rTASxO2PAuA==", "license": "MIT" }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.12", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", @@ -1830,6 +2332,19 @@ "node": ">=6.0.0" } }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/browserslist": { "version": "4.28.7", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", @@ -1981,6 +2496,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmmirror.com/css.escape/-/css.escape-1.5.1.tgz", @@ -2052,6 +2582,13 @@ "dev": true, "license": "MIT" }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2263,6 +2800,210 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz", + "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.4.tgz", + "integrity": "sha512-7bqTKz7T0r+HKWFarNXByDE9/5+73wI2ru+M3zuqGbR7s/b/5/pQJXZoufWlrngqGqoZto73ZkGumCdLxk+4rw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -2273,6 +3014,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz", @@ -2289,24 +3040,89 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "engines": { + "node": ">=16" } }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/focus-lock": { "version": "1.3.6", "resolved": "https://registry.npmmirror.com/focus-lock/-/focus-lock-1.3.6.tgz", @@ -2426,6 +3242,19 @@ "node": ">= 0.4" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2477,6 +3306,23 @@ "node": ">= 0.4" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -2571,6 +3417,26 @@ "node": ">=0.10.0" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/indent-string/-/indent-string-4.0.0.tgz", @@ -2587,6 +3453,29 @@ "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", "license": "MIT" }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -2594,6 +3483,13 @@ "dev": true, "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2653,6 +3549,27 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", @@ -2666,6 +3583,46 @@ "node": ">=6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -2755,6 +3712,22 @@ "node": ">=4" } }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", @@ -2780,6 +3753,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.53", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", @@ -2826,6 +3806,56 @@ "node": ">=12.20.0" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz", @@ -2839,6 +3869,26 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2895,6 +3945,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-27.5.1.tgz", @@ -3059,15 +4135,13 @@ } }, "node_modules/react-router": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.0.0.tgz", - "integrity": "sha512-1xf+yMVhUjAzZGY90ZnYJ9KVe1R8FggpugzRBkh+p6vl4cC1sHDe3nO+r5rxWTAgCMf8uN5hfoV2M7rLVTWPUA==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", "license": "MIT", "dependencies": { - "@types/cookie": "^0.6.0", "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0", - "turbo-stream": "2.4.0" + "set-cookie-parser": "^2.6.0" }, "engines": { "node": ">=20.0.0" @@ -3083,12 +4157,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.0.0.tgz", - "integrity": "sha512-2QAxXpwgQuh423C64oZiV2cCKPCNUgZxcvZaS8O0PAHPZ/z8kTq7YbGD4KTNZm6Yj66d+HAfGkWPp8MCpdtD+Q==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", "license": "MIT", "dependencies": { - "react-router": "7.0.0" + "react-router": "7.18.2" }, "engines": { "node": ">=20.0.0" @@ -3246,6 +4320,29 @@ "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", "license": "MIT" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", @@ -3402,17 +4499,37 @@ "node": ">=18" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/turbo-stream": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz", - "integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==", - "license": "ISC" + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } }, "node_modules/typescript": { "version": "5.9.3", @@ -3428,6 +4545,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.68.0.tgz", + "integrity": "sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.68.0", + "@typescript-eslint/parser": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/utils": "8.68.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -3466,6 +4607,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -3752,6 +4903,22 @@ "node": ">=18" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -3769,6 +4936,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", @@ -3815,6 +4992,42 @@ "dev": true, "license": "ISC" }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/zrender": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", diff --git a/web/package.json b/web/package.json index 9c9c147..0f4f983 100644 --- a/web/package.json +++ b/web/package.json @@ -8,6 +8,9 @@ }, "scripts": { "dev": "vite", + "lint": "eslint . --max-warnings 0", + "format": "prettier --write .", + "format:check": "prettier --check .", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json", "build": "npm run typecheck && vite build", "preview": "vite preview", @@ -22,10 +25,11 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^16.5.6", - "react-router-dom": "^7.0.0", + "react-router-dom": "^7.18.2", "zustand": "^5.0.15" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^14.6.4", @@ -33,8 +37,14 @@ "@types/react": "^18.3.20", "@types/react-dom": "^18.3.6", "@vitejs/plugin-react": "^4.3.4", + "eslint": "^10.9.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.4", "jsdom": "^26.0.0", + "prettier": "^3.9.6", "typescript": "^5.7.3", + "typescript-eslint": "^8.68.0", "vite": "^6.4.3", "vitest": "^4.1.10" } diff --git a/web/src/app.tsx b/web/src/app.tsx deleted file mode 100644 index 9351236..0000000 --- a/web/src/app.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { BrowserRouter } from 'react-router-dom' -import { RouterView } from './router' -import { AuthBootstrap } from './components/AuthBootstrap' - -export function App() { - return ( - - - - - - ) -} diff --git a/web/src/components/CronInput/CronInput.tsx b/web/src/components/CronInput/CronInput.tsx index e08d8fd..17bd937 100644 --- a/web/src/components/CronInput/CronInput.tsx +++ b/web/src/components/CronInput/CronInput.tsx @@ -1,4 +1,4 @@ -import { Button, Divider, Input, Select, Space, Switch, Typography } from '@arco-design/web-react' +import { Button, Input, Select, Space, Switch, Typography } from '@arco-design/web-react' import { useEffect, useMemo, useState } from 'react' export interface CronInputProps { @@ -72,8 +72,20 @@ function describeCron(expr: string): string { // 每周某天 if (day === '*' && week !== '*') { - const weekNames: Record = { '0': '日', '1': '一', '2': '二', '3': '三', '4': '四', '5': '五', '6': '六', '7': '日' } - const days = week.split(',').map((w) => `周${weekNames[w] || w}`).join('、') + const weekNames: Record = { + '0': '日', + '1': '一', + '2': '二', + '3': '三', + '4': '四', + '5': '五', + '6': '六', + '7': '日', + } + const days = week + .split(',') + .map((w) => `周${weekNames[w] || w}`) + .join('、') return `每${days} ${time} 执行` } // 每月某日 @@ -103,7 +115,7 @@ export function CronInput({ value, onChange }: CronInputProps) { // 从 prop 同步 useEffect(() => { - if (value !== undefined && value !== cronExpr) { + if (value !== undefined) { setCronExpr(value || DEFAULT_CRON) } }, [value]) @@ -205,12 +217,12 @@ export function CronInput({ value, onChange }: CronInputProps) { if (isAdvanced) emit(val) }} /> - {description && ( - {description} - )} + {description && {description}}
- 手动输入 + + 手动输入 + - - - - diff --git a/web/src/components/account/AccountControls.tsx b/web/src/components/account/AccountControls.tsx new file mode 100644 index 0000000..2ac8bca --- /dev/null +++ b/web/src/components/account/AccountControls.tsx @@ -0,0 +1,69 @@ +import { Avatar, Button, Dropdown, Menu } from '@arco-design/web-react' +import { useState } from 'react' +import { IconDown, IconLock, IconPoweroff, IconSafe } from '../icons' +import type { UserInfo } from '../../services/auth' +import { useAuthStore } from '../../stores/auth' +import { roleLabel } from '../../utils/permissions' +import { ChangePasswordModal } from './ChangePasswordModal' +import { MfaSettingsModal } from './MfaSettingsModal' + +interface AccountControlsProps { + user: UserInfo | null +} + +export function AccountControls({ user }: AccountControlsProps) { + const [passwordVisible, setPasswordVisible] = useState(false) + const [securityVisible, setSecurityVisible] = useState(false) + const logout = useAuthStore((state) => state.logout) + + const droplist = ( + { + if (key === 'password') { + setPasswordVisible(true) + } else if (key === 'two-factor') { + setSecurityVisible(true) + } else if (key === 'logout') { + logout() + } + }} + > + + + 修改密码 + + + + 多因素认证 + + + + 退出登录 + + + ) + + return ( + <> + + + + + {passwordVisible ? ( + setPasswordVisible(false)} /> + ) : null} + {securityVisible ? ( + setSecurityVisible(false)} /> + ) : null} + + ) +} diff --git a/web/src/components/account/ChangePasswordModal.tsx b/web/src/components/account/ChangePasswordModal.tsx new file mode 100644 index 0000000..b27c627 --- /dev/null +++ b/web/src/components/account/ChangePasswordModal.tsx @@ -0,0 +1,71 @@ +import { Form, Input, Message, Modal } from '@arco-design/web-react' +import { useState } from 'react' +import { + changePassword, + clearTrustedDeviceToken, + type ChangePasswordPayload, +} from '../../services/auth' +import { resolveErrorMessage } from '../../utils/error' + +interface ChangePasswordModalProps { + username?: string + onClose: () => void +} + +export function ChangePasswordModal({ username, onClose }: ChangePasswordModalProps) { + const [loading, setLoading] = useState(false) + const [form] = Form.useForm() + + function close() { + form.resetFields() + onClose() + } + + async function handleChangePassword() { + try { + const values = await form.validate() + if (values.newPassword !== values.confirmPassword) { + Message.error('两次输入的新密码不一致') + return + } + setLoading(true) + await changePassword({ oldPassword: values.oldPassword, newPassword: values.newPassword }) + clearTrustedDeviceToken(username) + Message.success('密码修改成功') + close() + } catch (error) { + if (error) { + Message.error(resolveErrorMessage(error, '密码修改失败')) + } + } finally { + setLoading(false) + } + } + + return ( + +
+ + + + + + + + + +
+
+ ) +} diff --git a/web/src/components/account/MfaSettingsModal.tsx b/web/src/components/account/MfaSettingsModal.tsx new file mode 100644 index 0000000..8788979 --- /dev/null +++ b/web/src/components/account/MfaSettingsModal.tsx @@ -0,0 +1,501 @@ +import { + Alert, + Button, + Divider, + Form, + Input, + Message, + Modal, + Space, + Tag, + Typography, +} from '@arco-design/web-react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { + beginWebAuthnRegistration, + clearTrustedDeviceToken, + configureOtp, + deleteWebAuthnCredential, + disableTwoFactor, + enableTwoFactor, + finishWebAuthnRegistration, + listTrustedDevices, + listWebAuthnCredentials, + prepareTwoFactor, + regenerateRecoveryCodes, + revokeTrustedDevice, + type TrustedDevice, + type TwoFactorSetupResult, + type UserInfo, + type WebAuthnCredential, +} from '../../services/auth' +import { useAuthStore } from '../../stores/auth' +import { resolveErrorMessage } from '../../utils/error' +import { createWebAuthnCredential } from '../../utils/webauthn' + +interface MfaSettingsModalProps { + user: UserInfo | null + onClose: () => void +} + +export function MfaSettingsModal({ user, onClose }: MfaSettingsModalProps) { + const [loading, setLoading] = useState(false) + const [setup, setSetup] = useState(null) + const [recoveryCodes, setRecoveryCodes] = useState([]) + const [webAuthnCredentials, setWebAuthnCredentials] = useState([]) + const [trustedDevices, setTrustedDevices] = useState([]) + const [detailsLoading, setDetailsLoading] = useState(false) + const [form] = Form.useForm<{ + currentPassword: string + code: string + email: string + phone: string + }>() + const setUser = useAuthStore((state) => state.setUser) + const initializedRef = useRef(false) + + const loadSecurityDetails = useCallback(async () => { + setDetailsLoading(true) + try { + const [credentials, devices] = await Promise.all([ + listWebAuthnCredentials(), + listTrustedDevices(), + ]) + setWebAuthnCredentials(credentials) + setTrustedDevices(devices) + } catch (error) { + Message.error(resolveErrorMessage(error, '加载安全配置失败')) + } finally { + setDetailsLoading(false) + } + }, []) + + useEffect(() => { + if (initializedRef.current) { + return + } + initializedRef.current = true + form.setFieldValue('email', user?.email ?? '') + form.setFieldValue('phone', user?.phone ?? '') + void loadSecurityDetails() + }, [form, loadSecurityDetails, user?.email, user?.phone]) + + function applySecurityUserUpdate(updated: UserInfo) { + setUser(updated) + if (!updated.mfaEnabled) { + clearTrustedDeviceToken(updated.username) + } + } + + async function copyRecoveryCodes() { + if (recoveryCodes.length === 0) return + try { + await navigator.clipboard.writeText(recoveryCodes.join('\n')) + Message.success('已复制到剪贴板') + } catch { + Message.info('请手动选择文本复制') + } + } + + async function handleTwoFactorSetupAction() { + try { + const values = await form.validate() + setLoading(true) + if (!setup) { + const result = await prepareTwoFactor({ currentPassword: values.currentPassword }) + setSetup(result) + Message.success('TOTP 密钥已生成') + return + } + const result = await enableTwoFactor({ code: values.code }) + setUser(result.user) + setRecoveryCodes(result.recoveryCodes) + Message.success('TOTP 已启用') + } catch (error) { + if (error) { + Message.error(resolveErrorMessage(error, 'TOTP 操作失败')) + } + } finally { + setLoading(false) + } + } + + async function handleRegenerateRecoveryCodes() { + try { + const values = await form.validate() + setLoading(true) + const result = await regenerateRecoveryCodes({ + currentPassword: values.currentPassword, + code: values.code, + }) + setUser(result.user) + setRecoveryCodes(result.recoveryCodes) + form.resetFields() + Message.success('恢复码已重新生成') + } catch (error) { + if (error) { + Message.error(resolveErrorMessage(error, '恢复码生成失败')) + } + } finally { + setLoading(false) + } + } + + async function handleDisableTwoFactor() { + try { + const values = await form.validate() + setLoading(true) + const updated = await disableTwoFactor({ + currentPassword: values.currentPassword, + code: values.code, + }) + applySecurityUserUpdate(updated) + Message.success('TOTP 已关闭') + onClose() + } catch (error) { + if (error) { + Message.error(resolveErrorMessage(error, '关闭 TOTP 失败')) + } + } finally { + setLoading(false) + } + } + + function readCurrentPassword() { + const currentPassword = String(form.getFieldValue('currentPassword') ?? '') + if (currentPassword.trim().length < 8) { + Message.error('请输入当前密码') + return '' + } + return currentPassword + } + + async function handleRegisterWebAuthn() { + const currentPassword = readCurrentPassword() + if (!currentPassword) return + try { + setLoading(true) + const options = await beginWebAuthnRegistration({ currentPassword }) + const credential = await createWebAuthnCredential(options) + const updated = await finishWebAuthnRegistration({ + name: navigator.userAgent.slice(0, 120), + credential, + }) + applySecurityUserUpdate(updated) + await loadSecurityDetails() + Message.success('通行密钥已注册') + } catch (error) { + Message.error(resolveErrorMessage(error, '通行密钥注册失败')) + } finally { + setLoading(false) + } + } + + async function handleDeleteWebAuthnCredential(id: string) { + const currentPassword = readCurrentPassword() + if (!currentPassword) return + try { + setLoading(true) + const updated = await deleteWebAuthnCredential(id, { currentPassword }) + applySecurityUserUpdate(updated) + await loadSecurityDetails() + Message.success('通行密钥已删除') + } catch (error) { + Message.error(resolveErrorMessage(error, '删除通行密钥失败')) + } finally { + setLoading(false) + } + } + + async function handleConfigureOtp(channel: 'email' | 'sms', enabled: boolean) { + const currentPassword = readCurrentPassword() + if (!currentPassword) return + const email = String(form.getFieldValue('email') ?? '') + const phone = String(form.getFieldValue('phone') ?? '') + try { + setLoading(true) + const updated = await configureOtp({ currentPassword, channel, enabled, email, phone }) + applySecurityUserUpdate(updated) + form.setFieldValue('email', updated.email ?? '') + form.setFieldValue('phone', updated.phone ?? '') + Message.success(enabled ? 'OTP 已启用' : 'OTP 已关闭') + } catch (error) { + Message.error(resolveErrorMessage(error, 'OTP 配置失败')) + } finally { + setLoading(false) + } + } + + async function handleRevokeTrustedDevice(id: string) { + const currentPassword = readCurrentPassword() + if (!currentPassword) return + try { + setLoading(true) + await revokeTrustedDevice(id, { currentPassword }) + clearTrustedDeviceToken(user?.username) + await loadSecurityDetails() + Message.success('可信设备已移除') + } catch (error) { + Message.error(resolveErrorMessage(error, '移除可信设备失败')) + } finally { + setLoading(false) + } + } + + function renderFooter() { + if (recoveryCodes.length > 0) { + return ( + + + + + ) + } + if (user?.twoFactorEnabled) { + return ( + + + + + + ) + } + return ( + + + + + ) + } + + return ( + + {recoveryCodes.length > 0 ? ( + + + + + ) : ( +
+ {user?.twoFactorEnabled ? ( + <> + + + + + + + + + ) : ( + <> + {!setup ? ( + <> + + + + + + ) : ( + <> + +
+ TOTP 二维码 + + 手动密钥 + + +
+ + + + + )} + + )} + + + + + 通行密钥 + + 0 ? 'green' : 'gray'} bordered> + {webAuthnCredentials.length > 0 ? `${webAuthnCredentials.length} 个` : '未注册'} + + + + 支持浏览器 Passkey、平台验证器或安全密钥,用于登录时替代验证码。 + + + + {detailsLoading ? ( + 正在加载通行密钥... + ) : null} + {webAuthnCredentials.map((item) => ( +
+ + {item.name} + + {item.lastUsedAt ? `最近使用 ${item.lastUsedAt}` : `创建于 ${item.createdAt}`} + + + +
+ ))} +
+
+ + + + 邮件 / 短信 OTP + + + + + 邮件 OTP {user?.emailOtpEnabled ? '已启用' : '未启用'} + + + 短信 OTP {user?.smsOtpEnabled ? '已启用' : '未启用'} + + + + + + + + + + + + + + + + + + 可信设备 + + 0 ? 'green' : 'gray'} bordered> + {trustedDevices.length} 个 + + + + 登录时勾选“信任此设备”后,30 天内该设备可在密码校验通过后跳过多因素验证。 + + + {trustedDevices.map((item) => ( +
+ + {item.name} + + 最近使用 {item.lastUsedAt || '-'},到期 {item.expiresAt} + + + +
+ ))} + {!detailsLoading && trustedDevices.length === 0 ? ( + 暂无可信设备 + ) : null} +
+
+ + )} +
+ ) +} diff --git a/web/src/components/admin/AdminDataSection.tsx b/web/src/components/admin/AdminDataSection.tsx index b1ae313..2b46aba 100644 --- a/web/src/components/admin/AdminDataSection.tsx +++ b/web/src/components/admin/AdminDataSection.tsx @@ -16,7 +16,14 @@ interface AdminDataSectionProps { children: ReactNode } -export function AdminDataSection({ title, description, actions, metrics, toolbar, children }: AdminDataSectionProps) { +export function AdminDataSection({ + title, + description, + actions, + metrics, + toolbar, + children, +}: AdminDataSectionProps) { return (
diff --git a/web/src/components/auth-guard.test.tsx b/web/src/components/auth-guard.test.tsx deleted file mode 100644 index cb5724d..0000000 --- a/web/src/components/auth-guard.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { MemoryRouter, Route, Routes } from 'react-router-dom'; - -import { AuthGuard } from './auth-guard'; -import { useAuthStore } from '../stores/auth'; - -function renderWithRoutes(initialEntry: string) { - return render( - - - login-page
} /> - -
protected-page
- - } - /> - - , - ); -} - -describe('AuthGuard', () => { - beforeEach(() => { - useAuthStore.setState({ - token: null, - user: null, - hydrated: true, - status: 'anonymous', - }); - }); - - it('redirects anonymous users to login page', async () => { - renderWithRoutes('/'); - - expect(await screen.findByText('login-page')).toBeInTheDocument(); - }); - - it('renders children for authenticated users', async () => { - useAuthStore.setState({ - token: 'token', - user: { - id: 1, - username: 'admin', - displayName: '管理员', - role: 'admin', - }, - hydrated: true, - status: 'authenticated', - }); - - renderWithRoutes('/'); - - expect(await screen.findByText('protected-page')).toBeInTheDocument(); - }); -}); diff --git a/web/src/components/auth-guard.tsx b/web/src/components/auth-guard.tsx deleted file mode 100644 index 3421405..0000000 --- a/web/src/components/auth-guard.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Spin } from '@arco-design/web-react'; -import type { PropsWithChildren } from 'react'; -import { Navigate, useLocation } from 'react-router-dom'; - -import { useAuthStore } from '../stores/auth'; - -export function AuthGuard({ children }: PropsWithChildren) { - const hydrated = useAuthStore((state) => state.hydrated); - const status = useAuthStore((state) => state.status); - const location = useLocation(); - - if (!hydrated || status === 'bootstrapping' || status === 'idle') { - return ( -
- -
- ); - } - - if (status !== 'authenticated') { - return ; - } - - return <>{children}; -} diff --git a/web/src/components/backup-records/BackupRecordContentsModal.tsx b/web/src/components/backup-records/BackupRecordContentsModal.tsx index 7042f67..d2d83de 100644 --- a/web/src/components/backup-records/BackupRecordContentsModal.tsx +++ b/web/src/components/backup-records/BackupRecordContentsModal.tsx @@ -15,7 +15,12 @@ interface BackupRecordContentsModalProps { // BackupRecordContentsModal 浏览某次备份捕获的文件清单(只读)。 // 数据来源于全量备份记录的清单,无需下载归档,秒级展示并支持按路径筛选。 -export function BackupRecordContentsModal({ visible, recordId, onClose, onRestoreSelected }: BackupRecordContentsModalProps) { +export function BackupRecordContentsModal({ + visible, + recordId, + onClose, + onRestoreSelected, +}: BackupRecordContentsModalProps) { const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [contents, setContents] = useState(null) @@ -63,7 +68,14 @@ export function BackupRecordContentsModal({ visible, recordId, onClose, onRestor }, [contents, keyword]) return ( - + {loading ? ( ) : error ? ( @@ -76,9 +88,20 @@ export function BackupRecordContentsModal({ visible, recordId, onClose, onRestor {contents.basedOnFull ? `;差异备份,清单取自基线全量 #${contents.basedOnFull}` : ''}
- + {onRestoreSelected && ( - )} @@ -89,7 +112,11 @@ export function BackupRecordContentsModal({ visible, recordId, onClose, onRestor data={filtered} rowSelection={ onRestoreSelected - ? { type: 'checkbox', selectedRowKeys: selectedKeys, onChange: (keys) => setSelectedKeys(keys as string[]) } + ? { + type: 'checkbox', + selectedRowKeys: selectedKeys, + onChange: (keys) => setSelectedKeys(keys as string[]), + } : undefined } pagination={{ pageSize: 50, sizeCanChange: false }} @@ -114,7 +141,8 @@ export function BackupRecordContentsModal({ visible, recordId, onClose, onRestor dataIndex: 'size', width: 120, align: 'right', - render: (_: unknown, row: BackupRecordContentEntry) => (row.isDir ? '-' : formatBytes(row.size)), + render: (_: unknown, row: BackupRecordContentEntry) => + row.isDir ? '-' : formatBytes(row.size), }, ]} /> diff --git a/web/src/components/backup-records/BackupRecordLogDrawer.tsx b/web/src/components/backup-records/BackupRecordLogDrawer.tsx index 9380ef6..3aa10e8 100644 --- a/web/src/components/backup-records/BackupRecordLogDrawer.tsx +++ b/web/src/components/backup-records/BackupRecordLogDrawer.tsx @@ -1,13 +1,33 @@ -import { Alert, Button, Descriptions, Drawer, Message, Space, Spin, Tag, Typography } from '@arco-design/web-react' +import { + Alert, + Button, + Descriptions, + Drawer, + Message, + Space, + Spin, + Tag, + Typography, +} from '@arco-design/web-react' import { useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { deleteBackupRecord, downloadBackupRecord, getBackupRecord, streamBackupRecordLogs } from '../../services/backup-records' +import { + deleteBackupRecord, + downloadBackupRecord, + getBackupRecord, + streamBackupRecordLogs, +} from '../../services/backup-records' import { getBackupTask } from '../../services/backup-tasks' import { startRestoreFromBackup } from '../../services/restore-records' import { startVerifyByRecord } from '../../services/verification-records' import { useAuthStore } from '../../stores/auth' import { canWrite } from '../../utils/permissions' -import type { BackupLogEvent, BackupRecordDetail, BackupRecordStatus, StorageUploadResultItem } from '../../types/backup-records' +import type { + BackupLogEvent, + BackupRecordDetail, + BackupRecordStatus, + StorageUploadResultItem, +} from '../../types/backup-records' import type { BackupTaskDetail } from '../../types/backup-tasks' import { resolveErrorMessage } from '../../utils/error' import { formatBytes, formatDateTime, formatDuration } from '../../utils/format' @@ -39,7 +59,12 @@ function buildLogText(record: BackupRecordDetail | null, events: BackupLogEvent[ return record?.logContent ?? '' } -export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged }: BackupRecordLogDrawerProps) { +export function BackupRecordLogDrawer({ + visible, + recordId, + onCancel, + onChanged, +}: BackupRecordLogDrawerProps) { const navigate = useNavigate() const currentUser = useAuthStore((state) => state.user) const writable = canWrite(currentUser) @@ -90,7 +115,9 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged } return [...current, event] }) if (event.completed) { - setRecord((current) => (current ? { ...current, status: event.status as BackupRecordStatus } : current)) + setRecord((current) => + current ? { ...current, status: event.status as BackupRecordStatus } : current, + ) } }, onDone: () => { @@ -218,7 +245,11 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged } if (!recordId || paths.length === 0) { return } - if (!window.confirm(`确定将选中的 ${paths.length} 项恢复到原位置吗?这会覆盖目标位置的现有文件,不可撤销。`)) { + if ( + !window.confirm( + `确定将选中的 ${paths.length} 项恢复到原位置吗?这会覆盖目标位置的现有文件,不可撤销。`, + ) + ) { return } try { @@ -268,10 +299,20 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged } {record.status && ( - {record.status === 'success' ? '成功' : record.status === 'failed' ? '失败' : record.status === 'running' ? '执行中' : record.status} + {record.status === 'success' + ? '成功' + : record.status === 'failed' + ? '失败' + : record.status === 'running' + ? '执行中' + : record.status} + + )} + {record.storageTargetName && ( + + {record.storageTargetName} )} - {record.storageTargetName && {record.storageTargetName}}
)} - {record.storageUploadResults && (record.storageUploadResults.length > 1 || record.storageUploadResults.some((result) => result.transferMode)) && ( -
- 存储目标上传结果 - ({ - label: r.storageTargetName, - value: r.status === 'success' - ? `上传成功${r.transferMode === 'master_relay' ? ' · Master 流式中转' : r.transferMode === 'direct' ? ' · Agent 直传' : ''}` - : `上传失败: ${r.error || '未知错误'}`, - }))} - /> -
- )} + {record.storageUploadResults && + (record.storageUploadResults.length > 1 || + record.storageUploadResults.some((result) => result.transferMode)) && ( +
+ 存储目标上传结果 + ({ + label: r.storageTargetName, + value: + r.status === 'success' + ? `上传成功${r.transferMode === 'master_relay' ? ' · Master 流式中转' : r.transferMode === 'direct' ? ' · Agent 直传' : ''}` + : `上传失败: ${r.error || '未知错误'}`, + }))} + /> +
+ )}
执行日志 @@ -357,7 +408,11 @@ export function BackupRecordLogDrawer({ visible, recordId, onCancel, onChanged } visible={contentsVisible} recordId={recordId} onClose={() => setContentsVisible(false)} - onRestoreSelected={writable && record?.status === 'success' ? (paths) => void handleSelectiveRestore(paths) : undefined} + onRestoreSelected={ + writable && record?.status === 'success' + ? (paths) => void handleSelectiveRestore(paths) + : undefined + } /> ) diff --git a/web/src/components/backup-tasks/BackupTaskDetailDrawer.tsx b/web/src/components/backup-tasks/BackupTaskDetailDrawer.tsx index 55bf1c5..57b2146 100644 --- a/web/src/components/backup-tasks/BackupTaskDetailDrawer.tsx +++ b/web/src/components/backup-tasks/BackupTaskDetailDrawer.tsx @@ -1,7 +1,11 @@ import { Descriptions, Drawer, Space, Tag, Typography } from '@arco-design/web-react' import type { BackupTaskDetail } from '../../types/backup-tasks' import { formatDateTime } from '../../utils/format' -import { getBackupTaskStatusColor, getBackupTaskStatusLabel, getBackupTaskTypeLabel } from './field-config' +import { + getBackupTaskStatusColor, + getBackupTaskStatusLabel, + getBackupTaskTypeLabel, +} from './field-config' interface BackupTaskDetailDrawerProps { visible: boolean @@ -19,9 +23,15 @@ export function BackupTaskDetailDrawer({ visible, task, onCancel }: BackupTaskDe {task.name} - {getBackupTaskTypeLabel(task.type)} - {task.enabled ? '已启用' : '已停用'} - {getBackupTaskStatusLabel(task.lastStatus)} + + {getBackupTaskTypeLabel(task.type)} + + + {task.enabled ? '已启用' : '已停用'} + + + {getBackupTaskStatusLabel(task.lastStatus)} +
0 ? task.storageTargetNames.join('、') : (task.storageTargetName || task.storageTargetId) }, + { + label: '存储目标', + value: + task.storageTargetNames?.length > 0 + ? task.storageTargetNames.join('、') + : task.storageTargetName || task.storageTargetId, + }, { label: '保留天数', value: task.retentionDays }, { label: '最大保留份数', value: task.maxBackups }, { label: '压缩', value: task.compression }, @@ -40,17 +56,28 @@ export function BackupTaskDetailDrawer({ visible, task, onCancel }: BackupTaskDe ]} /> {task.type === 'file' ? ( - 0 - ? task.sourcePaths.join('\n') - : (task.sourcePath || '-'), - }, - { label: '排除规则', value: task.excludePatterns.join(', ') || '-' }, - ]} /> + 0 + ? task.sourcePaths.join('\n') + : task.sourcePath || '-', + }, + { label: '排除规则', value: task.excludePatterns.join(', ') || '-' }, + ]} + /> + ) : null} + {task.type === 'sqlite' ? ( + ) : null} - {task.type === 'sqlite' ? : null} {task.type === 'mysql' || task.type === 'postgresql' ? ( ) : null} diff --git a/web/src/components/backup-tasks/BackupTaskFormDrawer.tsx b/web/src/components/backup-tasks/BackupTaskFormDrawer.tsx index 71ee7f4..b7b1b09 100644 --- a/web/src/components/backup-tasks/BackupTaskFormDrawer.tsx +++ b/web/src/components/backup-tasks/BackupTaskFormDrawer.tsx @@ -1,10 +1,32 @@ -import { Alert, Button, Divider, Drawer, Input, InputNumber, Select, Space, Steps, Switch, Typography, Grid } from '@arco-design/web-react' +import { + Alert, + Button, + Divider, + Drawer, + Input, + InputNumber, + Select, + Space, + Steps, + Switch, + Typography, + Grid, +} from '@arco-design/web-react' import { IconDelete, IconPlus } from '../icons' import { useEffect, useMemo, useState } from 'react' import { CronInput } from '../CronInput' -import type { StorageTargetDetail, StorageTargetPayload, StorageTargetSummary } from '../../types/storage-targets' +import type { + StorageTargetDetail, + StorageTargetPayload, + StorageTargetSummary, +} from '../../types/storage-targets' import type { StorageConnectionTestResult } from '../../types/storage-targets' -import type { BackupMode, 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' @@ -37,7 +59,10 @@ interface BackupTaskFormDrawerProps { onCancel: () => void onSubmit: (value: BackupTaskPayload, taskId?: number) => Promise onCreateStorageTarget?: (value: StorageTargetPayload) => Promise - onTestStorageTarget?: (value: StorageTargetPayload, targetId?: number) => Promise + onTestStorageTarget?: ( + value: StorageTargetPayload, + targetId?: number, + ) => Promise onGoogleDriveAuth?: (value: StorageTargetPayload, targetId?: number) => Promise onStorageTargetCreated?: () => Promise } @@ -85,7 +110,21 @@ function createEmptyDraft(storageTargets?: StorageTargetSummary[]): BackupTaskPa } } -export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTargets, localNodeId, nodes, allTasks, onCancel, onSubmit, onCreateStorageTarget, onTestStorageTarget, onGoogleDriveAuth, onStorageTargetCreated }: BackupTaskFormDrawerProps) { +export function BackupTaskFormDrawer({ + visible, + loading, + initialValue, + storageTargets, + localNodeId, + nodes, + allTasks, + onCancel, + onSubmit, + onCreateStorageTarget, + onTestStorageTarget, + onGoogleDriveAuth, + onStorageTargetCreated, +}: BackupTaskFormDrawerProps) { const [draft, setDraft] = useState(createEmptyDraft()) const [excludePatternsText, setExcludePatternsText] = useState('') const [currentStep, setCurrentStep] = useState(0) @@ -108,17 +147,19 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa return } - const editTargetIds = initialValue.storageTargetIds?.length > 0 - ? initialValue.storageTargetIds - : initialValue.storageTargetId > 0 - ? [initialValue.storageTargetId] - : [] + const editTargetIds = + initialValue.storageTargetIds?.length > 0 + ? initialValue.storageTargetIds + : initialValue.storageTargetId > 0 + ? [initialValue.storageTargetId] + : [] // 编辑时:sourcePaths 优先,为空回退 sourcePath - const editSourcePaths = initialValue.sourcePaths?.length > 0 - ? initialValue.sourcePaths - : initialValue.sourcePath - ? [initialValue.sourcePath] - : [''] + const editSourcePaths = + initialValue.sourcePaths?.length > 0 + ? initialValue.sourcePaths + : initialValue.sourcePath + ? [initialValue.sourcePath] + : [''] setDraft({ name: initialValue.name, type: initialValue.type, @@ -163,20 +204,17 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa setError('') }, [initialValue, storageTargets, visible]) - const storageTargetOptions = useMemo( - () => { - const sorted = [...storageTargets].sort((a, b) => { - if (a.starred !== b.starred) return a.starred ? -1 : 1 - return 0 - }) - return sorted.map((item) => ({ - label: , - value: item.id, - disabled: !item.enabled, - })) - }, - [storageTargets], - ) + const storageTargetOptions = useMemo(() => { + const sorted = [...storageTargets].sort((a, b) => { + if (a.starred !== b.starred) return a.starred ? -1 : 1 + return 0 + }) + return sorted.map((item) => ({ + label: , + value: item.id, + disabled: !item.enabled, + })) + }, [storageTargets]) function updateDraft(patch: Partial) { setDraft((current) => ({ ...current, ...patch })) @@ -196,9 +234,13 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa dbName: isDatabaseBackupTask(value) ? current.dbName : '', dbPath: value === 'sqlite' ? current.dbPath : '', // 切换到 SAP HANA 时初始化扩展配置;切换到其他类型时清空 - extraConfig: value === 'saphana' - ? ({ ...defaultSapHanaExtraConfig(), ...(current.extraConfig as SapHanaExtraConfig | undefined) } as unknown as Record) - : undefined, + extraConfig: + value === 'saphana' + ? ({ + ...defaultSapHanaExtraConfig(), + ...(current.extraConfig as SapHanaExtraConfig | undefined), + } as unknown as Record) + : undefined, })) if (value !== 'file') { setExcludePatternsText('') @@ -238,7 +280,10 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa if (validPaths.length === 0 && !value.sourcePath.trim()) { return '请输入至少一个源路径' } - if (value.backupMode === 'repository' && (((value.nodeId ?? 0) > 0 && value.nodeId !== localNodeId) || value.nodePoolTag?.trim())) { + if ( + value.backupMode === 'repository' && + (((value.nodeId ?? 0) > 0 && value.nodeId !== localNodeId) || value.nodePoolTag?.trim()) + ) { return 'CDC 仓库模式当前仅支持 Master 本机执行' } if (value.backupMode === 'repository' && value.replicationTargetIds.length > 0) { @@ -293,11 +338,19 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
任务名称 - updateDraft({ name: value })} /> + updateDraft({ name: value })} + />
备份类型 - updateTaskType(value as BackupTaskType)} + />
{ // 固定源服务器与服务器池互斥;CDC 仓库仍固定在 Master 单写者。 - updateDraft(nodeId > 0 - ? { - nodeId, - nodePoolTag: '', - backupMode: nodeId !== localNodeId && draft.backupMode === 'repository' ? 'full' : draft.backupMode, - } - : { nodeId }) + updateDraft( + nodeId > 0 + ? { + nodeId, + nodePoolTag: '', + backupMode: + nodeId !== localNodeId && draft.backupMode === 'repository' + ? 'full' + : draft.backupMode, + } + : { nodeId }, + ) }} - onNodePoolTagChange={(value) => updateDraft({ - nodePoolTag: value, - backupMode: value.trim() && draft.backupMode === 'repository' ? 'full' : draft.backupMode, - })} + onNodePoolTagChange={(value) => + updateDraft({ + nodePoolTag: value, + backupMode: + value.trim() && draft.backupMode === 'repository' ? 'full' : draft.backupMode, + }) + } />
Cron 表达式 - updateDraft({ cronExpr: value })} /> + updateDraft({ cronExpr: value })} + /> 留空表示仅手动执行;已填写时由服务端调度器自动触发。
启用任务 - updateDraft({ enabled: checked })} /> + updateDraft({ enabled: checked })} + />
) @@ -423,23 +490,44 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa <>
数据库主机 - updateDraft({ dbHost: value })} /> + updateDraft({ dbHost: value })} + />
数据库端口 - updateDraft({ dbPort: Number(value ?? 0) })} /> + updateDraft({ dbPort: Number(value ?? 0) })} + />
数据库用户名 - updateDraft({ dbUser: value })} /> + updateDraft({ dbUser: value })} + />
数据库密码 - updateDraft({ dbPassword: value })} /> + updateDraft({ dbPassword: value })} + />
数据库名称 - {(draft.type === 'mysql' || draft.type === 'postgresql') ? ( + {draft.type === 'mysql' || draft.type === 'postgresql' ? ( updateDraft({ dbName: value })} /> ) : ( - updateDraft({ dbName: value })} /> + updateDraft({ dbName: value })} + /> )}
{isSapHanaBackupTask(draft.type) ? renderSapHanaExtraFields() : null} @@ -477,7 +569,9 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa style={{ width: '100%' }} value={hana.backupType} options={[...sapHanaBackupTypeOptions]} - onChange={(value) => updateHanaExtraConfig({ backupType: value as SapHanaExtraConfig['backupType'] })} + onChange={(value) => + updateHanaExtraConfig({ backupType: value as SapHanaExtraConfig['backupType'] }) + } />
@@ -487,10 +581,14 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa value={hana.backupLevel} options={[...sapHanaBackupLevelOptions]} disabled={hana.backupType === 'log'} - onChange={(value) => updateHanaExtraConfig({ backupLevel: value as SapHanaExtraConfig['backupLevel'] })} + onChange={(value) => + updateHanaExtraConfig({ backupLevel: value as SapHanaExtraConfig['backupLevel'] }) + } /> {hana.backupType === 'log' ? ( - 日志备份不支持级别配置 + + 日志备份不支持级别配置 + ) : null}
@@ -502,7 +600,9 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa max={32} onChange={(value) => updateHanaExtraConfig({ backupChannels: Number(value ?? 1) })} /> - {'>'} 1 时启用 HANA 多路径并行备份 + + {'>'} 1 时启用 HANA 多路径并行备份 +
失败重试次数 @@ -543,7 +643,10 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa } } - async function handleQuickCreateTest(value: StorageTargetPayload, targetId?: number): Promise { + async function handleQuickCreateTest( + value: StorageTargetPayload, + targetId?: number, + ): Promise { if (!onTestStorageTarget) return { success: false, message: '测试不可用' } setQuickCreateTesting(true) try { @@ -565,7 +668,9 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa value={draft.storageTargetIds?.length > 0 ? draft.storageTargetIds : undefined} placeholder="请选择存储目标(可多选)" options={storageTargetOptions} - onChange={(values: number[]) => updateDraft({ storageTargetIds: values, storageTargetId: values[0] ?? 0 })} + onChange={(values: number[]) => + updateDraft({ storageTargetIds: values, storageTargetId: values[0] ?? 0 }) + } /> {onCreateStorageTarget && ( )} - {((draft.nodeId ?? 0) > 0 && draft.nodeId !== localNodeId) || draft.nodePoolTag?.trim() ? ( + {((draft.nodeId ?? 0) > 0 && draft.nodeId !== localNodeId) || + draft.nodePoolTag?.trim() ? ( - 远程源服务器会直传 S3、WebDAV 等网络存储;本地磁盘目标启用 Master 中转后,文件经 Agent 认证 API 流式写入中央目录。跨公网部署请为 Master 配置 HTTPS。 + 远程源服务器会直传 S3、WebDAV 等网络存储;本地磁盘目标启用 Master 中转后,文件经 Agent + 认证 API 流式写入中央目录。跨公网部署请为 Master 配置 HTTPS。 ) : null}
压缩策略 - + updateDraft({ compression: value as BackupTaskPayload['compression'] }) + } + />
{isFileBackupTask(draft.type) && (
@@ -593,14 +706,24 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa { label: '差异备份(仅文件、本机)', value: 'differential' }, { label: 'CDC 去重仓库(仅文件、本机)', value: 'repository' }, ]} - onChange={(value) => updateDraft(value === 'repository' - ? { backupMode: value as BackupMode, nodeId: 0, nodePoolTag: '', replicationTargetIds: [] } - : { backupMode: value as BackupMode })} + onChange={(value) => + updateDraft( + value === 'repository' + ? { + backupMode: value as BackupMode, + nodeId: 0, + nodePoolTag: '', + replicationTargetIds: [], + } + : { backupMode: value as BackupMode }, + ) + } /> {draft.backupMode === 'differential' && (
- 差异备份仅打包自上次全量以来的变更,显著减小体积;超过下列天数将自动重做一次全量以控制差异链长度。恢复时自动按「全量 + 差异」链还原。 + 差异备份仅打包自上次全量以来的变更,显著减小体积;超过下列天数将自动重做一次全量以控制差异链长度。恢复时自动按「全量 + + 差异」链还原。 - 文件按内容边界切块并写入全局分块池;相同数据跨文件、跨快照只上传一次。新块会合并为 pack,恢复时通过索引按需读取。当前版本采用单写者索引,因此固定在 Master 本机执行;需要多副本时请直接多选上方存储目标。 + 文件按内容边界切块并写入全局分块池;相同数据跨文件、跨快照只上传一次。新块会合并为 + pack,恢复时通过索引按需读取。当前版本采用单写者索引,因此固定在 Master + 本机执行;需要多副本时请直接多选上方存储目标。 )}
)}
保留天数 - updateDraft({ retentionDays: Number(value ?? 0) })} /> + updateDraft({ retentionDays: Number(value ?? 0) })} + />
最大保留份数 - updateDraft({ maxBackups: Number(value ?? 0) })} /> + updateDraft({ maxBackups: Number(value ?? 0) })} + />
@@ -633,16 +768,44 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa - updateDraft({ keepDaily: Number(value ?? 0) })} /> + updateDraft({ keepDaily: Number(value ?? 0) })} + /> - updateDraft({ keepWeekly: Number(value ?? 0) })} /> + updateDraft({ keepWeekly: Number(value ?? 0) })} + /> - updateDraft({ keepMonthly: Number(value ?? 0) })} /> + updateDraft({ keepMonthly: Number(value ?? 0) })} + /> - updateDraft({ keepYearly: Number(value ?? 0) })} /> + updateDraft({ keepYearly: Number(value ?? 0) })} + />
@@ -656,7 +819,10 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa
备份后加密 - updateDraft({ encrypt: checked })} /> + updateDraft({ encrypt: checked })} + /> @@ -699,7 +865,8 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa onChange={(v) => updateDraft({ maintenanceWindows: v })} /> - 留空 = 无限制。非窗口时间调度会自动跳过,手动执行会被拒绝。多段用 ; 分隔。 + 留空 = 无限制。非窗口时间调度会自动跳过,手动执行会被拒绝。多段用{' '} + ; 分隔。 @@ -734,7 +901,9 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa style={{ width: '100%' }} value={draft.replicationTargetIds} placeholder="选择副本目标(不选 = 不启用复制)" - options={storageTargetOptions.filter((opt) => !(draft.storageTargetIds ?? []).includes(opt.value as number))} + options={storageTargetOptions.filter( + (opt) => !(draft.storageTargetIds ?? []).includes(opt.value as number), + )} disabled={draft.backupMode === 'repository'} onChange={(values: number[]) => updateDraft({ replicationTargetIds: values })} /> @@ -750,13 +919,19 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa 启用定时验证 - updateDraft({ verifyEnabled: checked })} /> + updateDraft({ verifyEnabled: checked })} + /> {draft.verifyEnabled && ( <>
验证 Cron 表达式 - updateDraft({ verifyCronExpr: value })} /> + updateDraft({ verifyCronExpr: value })} + /> 定期从最新成功备份自动校验可恢复性,满足企业合规(SOC2/ISO27001)。 @@ -765,9 +940,7 @@ export function BackupTaskFormDrawer({ visible, loading, initialValue, storageTa 验证模式 onNodeChange(Number(value ?? 0))} /> + + return ( + + ) } return ( @@ -137,40 +159,60 @@ export function DirectoryPicker({ value, onChange, placeholder, mode = 'director unmountOnExit > {/* 当前选中路径 */} -
- +
+ {selectedPath ? ( - + {selectedPath} ) : ( - 请在下方目录树中选择路径 + + 请在下方目录树中选择路径 + )}
{/* 目录树 */} {loading ? ( - + ) : treeData.length === 0 ? ( ) : ( -
+
= { +const EVENT_CONFIG: Record< + string, + { label: string; toast?: 'success' | 'error' | 'warning' | 'info'; color: string } +> = { backup_success: { label: '备份成功', toast: 'success', color: 'green' }, backup_failed: { label: '备份失败', toast: 'error', color: 'red' }, restore_success: { label: '恢复成功', toast: 'success', color: 'green' }, @@ -62,11 +74,7 @@ export function EventCenter() { return ( <> 0 && unreadCount <= 0}> - @@ -85,7 +93,9 @@ export function EventCenter() { onCancel={() => setDrawerOpen(false)} footer={ - + } > @@ -108,15 +118,27 @@ export function EventCenter() { > - {labelFor(e.type)} - {e.title} + + {labelFor(e.type)} + + + {e.title} + {formatDateTime(e.timestamp)} {e.body ? ( - + {e.body} ) : null} diff --git a/web/src/components/common/GlobalSearch.tsx b/web/src/components/common/GlobalSearch.tsx index dcfc2bf..21d757d 100644 --- a/web/src/components/common/GlobalSearch.tsx +++ b/web/src/components/common/GlobalSearch.tsx @@ -2,7 +2,12 @@ import { Empty, Input, Modal, Space, Spin, Tag, Typography } from '@arco-design/ import { IconSearch } from '../icons' import { useCallback, useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { globalSearch, type SearchKind, type SearchResult, type SearchResultItem } from '../../services/search' +import { + globalSearch, + type SearchKind, + type SearchResult, + type SearchResultItem, +} from '../../services/search' const KIND_LABELS: Record = { task: '任务', @@ -96,13 +101,23 @@ export function GlobalSearch() { marginBottom: 4, backgroundColor: 'var(--color-fill-1)', }} - onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--color-primary-light-1)' }} - onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'var(--color-fill-1)' }} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = 'var(--color-primary-light-1)' + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'var(--color-fill-1)' + }} > - {KIND_LABELS[kind]} + + {KIND_LABELS[kind]} + {item.title} - {item.subtitle && {item.subtitle}} + {item.subtitle && ( + + {item.subtitle} + + )}
))} @@ -151,7 +166,9 @@ export function GlobalSearch() { />
{loading ? ( -
+
+ +
) : !result || result.totalCount === 0 ? ( ) : ( diff --git a/web/src/components/common/LanguageSwitcher.tsx b/web/src/components/common/LanguageSwitcher.tsx index 10a89f8..81a5bf6 100644 --- a/web/src/components/common/LanguageSwitcher.tsx +++ b/web/src/components/common/LanguageSwitcher.tsx @@ -1,6 +1,11 @@ import { Select } from '@arco-design/web-react' import { useTranslation } from 'react-i18next' -import { languageOptions, normalizeLanguage, setApplicationLanguage, type SupportedLanguage } from '../../i18n' +import { + languageOptions, + normalizeLanguage, + setApplicationLanguage, + type SupportedLanguage, +} from '../../i18n' export function LanguageSwitcher() { const { t, i18n } = useTranslation() diff --git a/web/src/components/icons/BackupServerIllustration.tsx b/web/src/components/icons/BackupServerIllustration.tsx index 7bb3260..6d18b00 100644 --- a/web/src/components/icons/BackupServerIllustration.tsx +++ b/web/src/components/icons/BackupServerIllustration.tsx @@ -14,35 +14,88 @@ export function BackupServerIllustration(props: SVGProps) { > - + - - + + - + - + - + - + - + - - + + diff --git a/web/src/components/notifications/NotificationFormDrawer.tsx b/web/src/components/notifications/NotificationFormDrawer.tsx index 9b285b0..8ec22f5 100644 --- a/web/src/components/notifications/NotificationFormDrawer.tsx +++ b/web/src/components/notifications/NotificationFormDrawer.tsx @@ -1,7 +1,25 @@ -import { Alert, Button, Drawer, Input, InputNumber, Select, Space, Switch, Typography } from '@arco-design/web-react' +import { + Alert, + Button, + Drawer, + Input, + InputNumber, + Select, + Space, + Switch, + Typography, +} from '@arco-design/web-react' import { useEffect, useMemo, useState } from 'react' -import type { NotificationDetail, NotificationPayload, NotificationType } from '../../types/notifications' -import { getNotificationFieldConfigs, getNotificationTypeLabel, notificationTypeOptions } from './field-config' +import type { + NotificationDetail, + NotificationPayload, + NotificationType, +} from '../../types/notifications' +import { + getNotificationFieldConfigs, + getNotificationTypeLabel, + notificationTypeOptions, +} from './field-config' interface NotificationFormDrawerProps { visible: boolean @@ -24,7 +42,15 @@ function createEmptyDraft(): NotificationPayload { } } -export function NotificationFormDrawer({ visible, loading, testing, initialValue, onCancel, onSubmit, onTest }: NotificationFormDrawerProps) { +export function NotificationFormDrawer({ + visible, + loading, + testing, + initialValue, + onCancel, + onSubmit, + onTest, +}: NotificationFormDrawerProps) { const [draft, setDraft] = useState(createEmptyDraft()) const [error, setError] = useState('') @@ -79,7 +105,10 @@ export function NotificationFormDrawer({ visible, loading, testing, initialValue if (typeof currentValue === 'string' && currentValue.trim()) { continue } - if (initialValue?.maskedFields?.includes(field.key) && (currentValue === '' || currentValue === undefined)) { + if ( + initialValue?.maskedFields?.includes(field.key) && + (currentValue === '' || currentValue === undefined) + ) { continue } return `请填写${field.label}` @@ -108,28 +137,51 @@ export function NotificationFormDrawer({ visible, loading, testing, initialValue } return ( - + {error ? : null}
名称 - updateDraft({ name: value })} /> + updateDraft({ name: value })} + />
类型 - updateDraft({ type: value as NotificationType, config: {} })} + />
启用 - updateDraft({ enabled: checked })} /> + updateDraft({ enabled: checked })} + /> 成功时通知 - updateDraft({ onSuccess: checked })} /> + updateDraft({ onSuccess: checked })} + /> 失败时通知 - updateDraft({ onFailure: checked })} /> + updateDraft({ onFailure: checked })} + />
@@ -138,7 +190,12 @@ export function NotificationFormDrawer({ visible, loading, testing, initialValue {fieldConfigs.map((field) => { const currentValue = draft.config[field.key] - const normalizedValue = typeof currentValue === 'number' || typeof currentValue === 'string' ? currentValue : field.type === 'number' ? 0 : '' + const normalizedValue = + typeof currentValue === 'number' || typeof currentValue === 'string' + ? currentValue + : field.type === 'number' + ? 0 + : '' return (
@@ -147,21 +204,44 @@ export function NotificationFormDrawer({ visible, loading, testing, initialValue {field.required ? ' *' : ''} {field.type === 'password' ? ( - updateConfig(field.key, value)} /> + updateConfig(field.key, value)} + /> ) : field.type === 'number' ? ( - updateConfig(field.key, Number(value ?? 0))} /> + updateConfig(field.key, Number(value ?? 0))} + /> ) : field.type === 'textarea' ? ( - updateConfig(field.key, value)} /> + updateConfig(field.key, value)} + /> ) : ( - updateConfig(field.key, value)} /> + updateConfig(field.key, value)} + /> )} {field.description ? ( - + {field.description} ) : null} {initialValue?.maskedFields?.includes(field.key) && !draft.config[field.key] ? ( - + 已存在敏感配置,留空则保持不变。 ) : null} diff --git a/web/src/components/notifications/field-config.ts b/web/src/components/notifications/field-config.ts index 0b59f12..ab49736 100644 --- a/web/src/components/notifications/field-config.ts +++ b/web/src/components/notifications/field-config.ts @@ -2,20 +2,63 @@ import type { NotificationFieldConfig, NotificationType } from '../../types/noti const FIELD_CONFIG_MAP: Record = { email: [ - { key: 'host', label: 'SMTP Host', type: 'input', required: true, placeholder: 'smtp.example.com' }, + { + key: 'host', + label: 'SMTP Host', + type: 'input', + required: true, + placeholder: 'smtp.example.com', + }, { key: 'port', label: 'SMTP Port', type: 'number', required: true, placeholder: '587' }, { key: 'username', label: '用户名', type: 'input', placeholder: '可选' }, - { key: 'password', label: '密码', type: 'password', placeholder: '留空表示保持原密码', sensitive: true }, - { key: 'from', label: '发件人', type: 'input', required: true, placeholder: 'backupx@example.com' }, - { key: 'to', label: '收件人', type: 'input', required: true, placeholder: 'ops@example.com,dev@example.com' }, + { + key: 'password', + label: '密码', + type: 'password', + placeholder: '留空表示保持原密码', + sensitive: true, + }, + { + key: 'from', + label: '发件人', + type: 'input', + required: true, + placeholder: 'backupx@example.com', + }, + { + key: 'to', + label: '收件人', + type: 'input', + required: true, + placeholder: 'ops@example.com,dev@example.com', + }, ], webhook: [ - { key: 'url', label: 'Webhook URL', type: 'input', required: true, placeholder: 'https://hooks.example.com/backupx' }, + { + key: 'url', + label: 'Webhook URL', + type: 'input', + required: true, + placeholder: 'https://hooks.example.com/backupx', + }, { key: 'secret', label: '共享密钥', type: 'password', placeholder: '可选', sensitive: true }, ], telegram: [ - { key: 'botToken', label: 'Bot Token', type: 'password', required: true, placeholder: '123456:ABC', sensitive: true }, - { key: 'chatId', label: 'Chat ID', type: 'input', required: true, placeholder: '-100xxxxxxxxxx' }, + { + key: 'botToken', + label: 'Bot Token', + type: 'password', + required: true, + placeholder: '123456:ABC', + sensitive: true, + }, + { + key: 'chatId', + label: 'Chat ID', + type: 'input', + required: true, + placeholder: '-100xxxxxxxxxx', + }, ], } diff --git a/web/src/components/page-card.tsx b/web/src/components/page-card.tsx deleted file mode 100644 index 6ba20ff..0000000 --- a/web/src/components/page-card.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Card } from '@arco-design/web-react'; -import type { PropsWithChildren, ReactNode } from 'react'; - -interface PageCardProps extends PropsWithChildren { - title: ReactNode; -} - -export function PageCard({ title, children }: PageCardProps) { - return ( - - {children} - - ); -} diff --git a/web/src/components/restore-records/RestoreConfirmModal.tsx b/web/src/components/restore-records/RestoreConfirmModal.tsx index 2155b17..1aedaa9 100644 --- a/web/src/components/restore-records/RestoreConfirmModal.tsx +++ b/web/src/components/restore-records/RestoreConfirmModal.tsx @@ -15,12 +15,26 @@ interface RestoreConfirmModalProps { // RestoreConfirmModal 展示即将恢复的备份摘要与覆盖风险,强制用户二次确认。 // 恢复是破坏性操作:会覆盖任务配置的源路径/数据库,不可撤销。 // 文件类型 + 本机恢复时,允许指定「恢复到其他目录」以避免覆盖原位置。 -export function RestoreConfirmModal({ visible, loading, backupRecord, task, onCancel, onConfirm }: RestoreConfirmModalProps) { +export function RestoreConfirmModal({ + visible, + loading, + backupRecord, + task, + onCancel, + onConfirm, +}: RestoreConfirmModalProps) { const [targetPath, setTargetPath] = useState('') if (!backupRecord || !task) { return ( - onConfirm()} confirmLoading={loading} unmountOnExit> + onConfirm()} + confirmLoading={loading} + unmountOnExit + > ) @@ -29,9 +43,12 @@ export function RestoreConfirmModal({ visible, loading, backupRecord, task, onCa 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' + const nodeLabel = + task.nodeId && task.nodeId > 0 + ? task.nodeName + ? `${task.nodeName}(远程节点)` + : `节点 #${task.nodeId}` + : '本机 Master' return ( {task.name} }, - { label: '类型', value: {task.type.toUpperCase()} }, + { + label: '类型', + value: ( + + {task.type.toUpperCase()} + + ), + }, { label: '执行节点', value: nodeLabel }, { label: '源备份', value: backupRecord.fileName || '-' }, - { label: '恢复目标', value: targetPath.trim() ? {targetPath.trim()} : restoreTarget }, + { + label: '恢复目标', + value: targetPath.trim() ? ( + {targetPath.trim()} + ) : ( + restoreTarget + ), + }, ]} /> {allowAltPath && ( @@ -82,18 +113,21 @@ export function RestoreConfirmModal({ visible, loading, backupRecord, task, onCa function renderRestoreTarget(task: BackupTaskDetail) { if (task.type === 'file') { - const paths = task.sourcePaths && task.sourcePaths.length > 0 - ? task.sourcePaths - : task.sourcePath - ? [task.sourcePath] - : [] + const paths = + task.sourcePaths && task.sourcePaths.length > 0 + ? task.sourcePaths + : task.sourcePath + ? [task.sourcePath] + : [] if (paths.length === 0) { return 未配置源路径 } return ( {paths.map((p) => ( - {p} + + {p} + ))} ) @@ -101,10 +135,16 @@ function renderRestoreTarget(task: BackupTaskDetail) { if (task.type === 'sqlite') { return {task.dbPath || '-'} } - if (task.type === 'mysql' || task.type === 'postgresql' || task.type === 'saphana' || task.type === 'mongodb') { + if ( + task.type === 'mysql' || + task.type === 'postgresql' || + task.type === 'saphana' || + task.type === 'mongodb' + ) { return ( - {task.dbUser}@{task.dbHost}:{task.dbPort} / {task.dbName || '-'} + {task.dbUser}@{task.dbHost}:{task.dbPort} /{' '} + {task.dbName || '-'} ) } diff --git a/web/src/components/restore-records/RestoreRecordLogDrawer.tsx b/web/src/components/restore-records/RestoreRecordLogDrawer.tsx index bde98c1..918bdd8 100644 --- a/web/src/components/restore-records/RestoreRecordLogDrawer.tsx +++ b/web/src/components/restore-records/RestoreRecordLogDrawer.tsx @@ -30,7 +30,11 @@ function buildLogText(record: RestoreRecordDetail | null, events: BackupLogEvent return record?.logContent ?? '' } -export function RestoreRecordLogDrawer({ visible, restoreId, onCancel }: RestoreRecordLogDrawerProps) { +export function RestoreRecordLogDrawer({ + visible, + restoreId, + onCancel, +}: RestoreRecordLogDrawerProps) { const [record, setRecord] = useState(null) const [events, setEvents] = useState([]) const [loading, setLoading] = useState(false) @@ -69,7 +73,9 @@ export function RestoreRecordLogDrawer({ visible, restoreId, onCancel }: Restore return [...current, event] }) if (event.completed) { - setRecord((current) => (current ? { ...current, status: event.status as RestoreRecordStatus } : current)) + setRecord((current) => + current ? { ...current, status: event.status as RestoreRecordStatus } : current, + ) } }, onDone: () => { @@ -131,12 +137,20 @@ export function RestoreRecordLogDrawer({ visible, restoreId, onCancel }: Restore - {record.status === 'success' ? '成功' : record.status === 'failed' ? '失败' : '执行中'} + {record.status === 'success' + ? '成功' + : record.status === 'failed' + ? '失败' + : '执行中'} {record.nodeName ? ( - 节点: {record.nodeName} + + 节点: {record.nodeName} + ) : record.nodeId === 0 ? ( - 节点: 本机 Master + + 节点: 本机 Master + ) : null} {record.triggeredBy && 触发人: {record.triggeredBy}} @@ -144,7 +158,10 @@ export function RestoreRecordLogDrawer({ visible, restoreId, onCancel }: Restore (createEmptyDraft()) const [error, setError] = useState('') @@ -39,7 +72,10 @@ export function StorageTargetFormDrawer({ useEffect(() => { if (visible && !backendsLoaded) { listRcloneBackends() - .then((data) => { setRcloneBackends(data); setBackendsLoaded(true) }) + .then((data) => { + setRcloneBackends(data) + setBackendsLoaded(true) + }) .catch(() => setBackendsLoaded(true)) } }, [visible, backendsLoaded]) @@ -105,41 +141,80 @@ export function StorageTargetFormDrawer({ } async function handleSubmit() { - const e = validate(draft); if (e) { setError(e); return } - setError(''); await onSubmit(draft, initialValue?.id) + const e = validate(draft) + if (e) { + setError(e) + return + } + setError('') + await onSubmit(draft, initialValue?.id) } async function handleTest() { - const e = validate(draft); if (e) { setError(e); return } - setError(''); setTestResult(await onTest(draft, initialValue?.id)) + const e = validate(draft) + if (e) { + setError(e) + return + } + setError('') + setTestResult(await onTest(draft, initialValue?.id)) } async function handleGoogleDriveAuth() { - const e = validate(draft); if (e) { setError(e); return } - setError(''); await onGoogleDriveAuth(draft, initialValue?.id) + const e = validate(draft) + if (e) { + setError(e) + return + } + setError('') + await onGoogleDriveAuth(draft, initialValue?.id) } // 渲染静态字段(内置类型) function renderStaticFields() { return staticFields.map((field) => { const value = draft.config[field.key] - const normalized = typeof value === 'boolean' ? value : typeof value === 'string' ? value : field.type === 'switch' ? false : '' + const normalized = + typeof value === 'boolean' + ? value + : typeof value === 'string' + ? value + : field.type === 'switch' + ? false + : '' return (
- {field.label}{field.required ? ' *' : ''} + + {field.label} + {field.required ? ' *' : ''} + {field.type === 'switch' ? ( updateConfig(field.key, v)} /> - {field.description && {field.description}} + {field.description && ( + {field.description} + )} ) : field.type === 'password' ? ( - updateConfig(field.key, v)} /> + updateConfig(field.key, v)} + /> ) : ( - updateConfig(field.key, v)} /> + updateConfig(field.key, v)} + /> )} {field.description && field.type !== 'switch' && ( - {field.description} + + {field.description} + )} {initialValue?.maskedFields?.includes(field.key) && !draft.config[field.key] && ( - 已存在敏感配置,留空则保持不变。 + + 已存在敏感配置,留空则保持不变。 + )}
) @@ -150,14 +225,31 @@ export function StorageTargetFormDrawer({ function renderDynamicOption(opt: RcloneBackendOption) { return (
- {opt.key}{opt.required ? ' *' : ''} + + {opt.key} + {opt.required ? ' *' : ''} + {opt.isPassword ? ( - updateConfig(opt.key, v)} /> + updateConfig(opt.key, v)} + /> ) : ( - updateConfig(opt.key, v)} /> + updateConfig(opt.key, v)} + /> )} {opt.label && ( - {opt.label} + + {opt.label} + )}
) @@ -172,14 +264,24 @@ export function StorageTargetFormDrawer({ <>
远端路径 - updateConfig('root', v)} /> - 远端根路径、桶名或挂载点,留空使用根目录 + updateConfig('root', v)} + /> + + 远端根路径、桶名或挂载点,留空使用根目录 +
{requiredOptions.map(renderDynamicOption)} {optionalOptions.length > 0 && ( 高级配置({optionalOptions.length} 个可选项)} + header={ + + 高级配置({optionalOptions.length} 个可选项) + + } name="advanced" > @@ -193,14 +295,33 @@ export function StorageTargetFormDrawer({ } return ( - + - {error ? : } - {testResult && } + {error ? ( + + ) : ( + + )} + {testResult && ( + + )}
名称 - setDraft((c) => ({ ...c, name: v }))} /> + setDraft((c) => ({ ...c, name: v }))} + />
@@ -214,7 +335,8 @@ export function StorageTargetFormDrawer({ return label.toLowerCase().includes(input.toLowerCase()) }} onChange={(value) => { - const config: StorageTargetPayload['config'] = value === 'local_disk' ? { masterRelay: true } : {} + const config: StorageTargetPayload['config'] = + value === 'local_disk' ? { masterRelay: true } : {} setDraft((c) => ({ ...c, type: value as string, config })) setTestResult(null) }} @@ -222,7 +344,9 @@ export function StorageTargetFormDrawer({ {Object.entries(groupedOptions).map(([group, options]) => ( {options.map((opt) => ( - {opt.label} + + {opt.label} + ))} ))} @@ -231,7 +355,11 @@ export function StorageTargetFormDrawer({
描述 - setDraft((c) => ({ ...c, description: v }))} /> + setDraft((c) => ({ ...c, description: v }))} + />
容量配额(GB,0 = 不限制) @@ -251,7 +379,10 @@ export function StorageTargetFormDrawer({ 启用 - setDraft((c) => ({ ...c, enabled: v }))} /> + setDraft((c) => ({ ...c, enabled: v }))} + /> 环境配置 @@ -266,13 +397,17 @@ export function StorageTargetFormDrawer({
- + {draft.type === 'google_drive' && ( )} - + diff --git a/web/src/components/storage-targets/field-config.ts b/web/src/components/storage-targets/field-config.ts index 3d15c3e..2d26301 100644 --- a/web/src/components/storage-targets/field-config.ts +++ b/web/src/components/storage-targets/field-config.ts @@ -6,49 +6,128 @@ import type { StorageTargetFieldConfig, StorageTargetType } from '../../types/st const BUILTIN_FIELD_CONFIG: Record = { local_disk: [ - { key: 'basePath', label: '基础目录', type: 'input', required: true, placeholder: '/data/backups', description: 'BackupX 将在该目录下创建和管理备份文件。' }, - { key: 'masterRelay', label: '远程备份经 Master 中转', type: 'switch', description: '开启后,远程 Agent 会把产物流式传给 Master 并写入上述目录;关闭则沿用 Agent 本机目录。' }, + { + key: 'basePath', + label: '基础目录', + type: 'input', + required: true, + placeholder: '/data/backups', + description: 'BackupX 将在该目录下创建和管理备份文件。', + }, + { + key: 'masterRelay', + label: '远程备份经 Master 中转', + type: 'switch', + description: + '开启后,远程 Agent 会把产物流式传给 Master 并写入上述目录;关闭则沿用 Agent 本机目录。', + }, ], s3: [ - { key: 'endpoint', label: 'Endpoint', type: 'input', required: true, placeholder: 'https://s3.amazonaws.com' }, + { + key: 'endpoint', + label: 'Endpoint', + type: 'input', + required: true, + placeholder: 'https://s3.amazonaws.com', + }, { key: 'region', label: '区域', type: 'input', required: true, placeholder: 'ap-east-1' }, { key: 'bucket', label: 'Bucket', type: 'input', required: true, placeholder: 'backupx-prod' }, - { key: 'accessKeyId', label: 'Access Key ID', type: 'input', required: true, sensitive: true, placeholder: 'AKIA...' }, - { key: 'secretAccessKey', label: 'Secret Access Key', type: 'password', required: true, sensitive: true }, - { key: 'forcePathStyle', label: '强制 Path Style', type: 'switch', description: 'MinIO 等兼容存储需要开启。' }, + { + key: 'accessKeyId', + label: 'Access Key ID', + type: 'input', + required: true, + sensitive: true, + placeholder: 'AKIA...', + }, + { + key: 'secretAccessKey', + label: 'Secret Access Key', + type: 'password', + required: true, + sensitive: true, + }, + { + key: 'forcePathStyle', + label: '强制 Path Style', + type: 'switch', + description: 'MinIO 等兼容存储需要开启。', + }, ], webdav: [ - { key: 'endpoint', label: 'WebDAV 地址', type: 'input', required: true, placeholder: 'https://dav.example.com/...' }, + { + key: 'endpoint', + label: 'WebDAV 地址', + type: 'input', + required: true, + placeholder: 'https://dav.example.com/...', + }, { key: 'username', label: '用户名', type: 'input', required: true }, { key: 'password', label: '密码', type: 'password', required: true, sensitive: true }, { key: 'basePath', label: '基础目录', type: 'input', placeholder: '/backupx' }, ], google_drive: [ { key: 'clientId', label: 'Client ID', type: 'input', required: true, sensitive: true }, - { key: 'clientSecret', label: 'Client Secret', type: 'password', required: true, sensitive: true }, + { + key: 'clientSecret', + label: 'Client Secret', + type: 'password', + required: true, + sensitive: true, + }, { key: 'folderId', label: '目标文件夹 ID', type: 'input', placeholder: '留空则使用根目录' }, ], aliyun_oss: [ { key: 'region', label: '区域', type: 'input', required: true, placeholder: 'cn-hangzhou' }, { key: 'bucket', label: 'Bucket', type: 'input', required: true }, { key: 'accessKeyId', label: 'AccessKey ID', type: 'input', required: true, sensitive: true }, - { key: 'secretAccessKey', label: 'AccessKey Secret', type: 'password', required: true, sensitive: true }, + { + key: 'secretAccessKey', + label: 'AccessKey Secret', + type: 'password', + required: true, + sensitive: true, + }, { key: 'internalNetwork', label: '使用内网', type: 'switch' }, ], tencent_cos: [ { key: 'region', label: '区域', type: 'input', required: true, placeholder: 'ap-guangzhou' }, - { key: 'bucket', label: 'Bucket', type: 'input', required: true, placeholder: 'backup-1250000000' }, + { + key: 'bucket', + label: 'Bucket', + type: 'input', + required: true, + placeholder: 'backup-1250000000', + }, { key: 'accessKeyId', label: 'SecretId', type: 'input', required: true, sensitive: true }, - { key: 'secretAccessKey', label: 'SecretKey', type: 'password', required: true, sensitive: true }, + { + key: 'secretAccessKey', + label: 'SecretKey', + type: 'password', + required: true, + sensitive: true, + }, ], qiniu_kodo: [ { key: 'region', label: '区域', type: 'input', required: true, placeholder: 'z0' }, { key: 'bucket', label: 'Bucket', type: 'input', required: true }, { key: 'accessKeyId', label: 'AccessKey', type: 'input', required: true, sensitive: true }, - { key: 'secretAccessKey', label: 'SecretKey', type: 'password', required: true, sensitive: true }, + { + key: 'secretAccessKey', + label: 'SecretKey', + type: 'password', + required: true, + sensitive: true, + }, ], ftp: [ - { key: 'host', label: '主机地址', type: 'input', required: true, placeholder: 'ftp.example.com' }, + { + key: 'host', + label: '主机地址', + type: 'input', + required: true, + placeholder: 'ftp.example.com', + }, { key: 'port', label: '端口', type: 'input', placeholder: '21' }, { key: 'username', label: '用户名', type: 'input', required: true }, { key: 'password', label: '密码', type: 'password', required: true, sensitive: true }, @@ -79,8 +158,18 @@ export interface TypeOption { // rclone 后端中不适合做存储目标的(工具类/代理类/只读类) const EXCLUDED_BACKENDS = new Set([ - 'alias', 'cache', 'http', 'archive', 'memory', 'tardigrade', // tardigrade = storj 别名 - 'union', 'crypt', 'chunker', 'compress', 'hasher', 'combine', + 'alias', + 'cache', + 'http', + 'archive', + 'memory', + 'tardigrade', // tardigrade = storj 别名 + 'union', + 'crypt', + 'chunker', + 'compress', + 'hasher', + 'combine', 'local', // 用内置 local_disk 替代 'drive', // 用内置 google_drive 替代(避免和 rclone 的 drive 重复) ]) @@ -99,45 +188,47 @@ const BUILTIN_OPTIONS: TypeOption[] = [ // rclone 后端的中文标注(仅标注常见的,其余用原始描述) const RCLONE_LABELS: Record = { - sftp: { label: 'SFTP(SSH 文件传输)', group: '文件传输' }, - smb: { label: 'SMB / CIFS(Windows 共享)', group: '文件传输' }, - azureblob: { label: 'Azure Blob 存储', group: '云存储' }, - azurefiles: { label: 'Azure Files 存储', group: '云存储' }, + sftp: { label: 'SFTP(SSH 文件传输)', group: '文件传输' }, + smb: { label: 'SMB / CIFS(Windows 共享)', group: '文件传输' }, + azureblob: { label: 'Azure Blob 存储', group: '云存储' }, + azurefiles: { label: 'Azure Files 存储', group: '云存储' }, 'google cloud storage': { label: 'Google Cloud Storage(GCS)', group: '云存储' }, - b2: { label: 'Backblaze B2', group: '云存储' }, - swift: { label: 'OpenStack Swift', group: '云存储' }, - dropbox: { label: 'Dropbox', group: '网盘' }, - onedrive: { label: 'Microsoft OneDrive', group: '网盘' }, - box: { label: 'Box', group: '网盘' }, - pcloud: { label: 'pCloud', group: '网盘' }, - mega: { label: 'MEGA', group: '网盘' }, - 'google photos': { label: 'Google Photos', group: '网盘' }, - yandex: { label: 'Yandex Disk', group: '网盘' }, - pikpak: { label: 'PikPak', group: '网盘' }, - iclouddrive: { label: 'iCloud Drive', group: '网盘' }, - jottacloud: { label: 'Jottacloud', group: '网盘' }, - hidrive: { label: 'HiDrive', group: '网盘' }, - protondrive: { label: 'Proton Drive', group: '网盘' }, - mailru: { label: 'Mail.ru Cloud', group: '网盘' }, - sugarsync: { label: 'SugarSync', group: '网盘' }, - putio: { label: 'Put.io', group: '网盘' }, - zoho: { label: 'Zoho WorkDrive', group: '网盘' }, - internxt: { label: 'Internxt Drive', group: '网盘' }, - seafile: { label: 'Seafile', group: '自建存储' }, - storj: { label: 'Storj 去中心化存储', group: '云存储' }, - hdfs: { label: 'Hadoop HDFS', group: '企业存储' }, + b2: { label: 'Backblaze B2', group: '云存储' }, + swift: { label: 'OpenStack Swift', group: '云存储' }, + dropbox: { label: 'Dropbox', group: '网盘' }, + onedrive: { label: 'Microsoft OneDrive', group: '网盘' }, + box: { label: 'Box', group: '网盘' }, + pcloud: { label: 'pCloud', group: '网盘' }, + mega: { label: 'MEGA', group: '网盘' }, + 'google photos': { label: 'Google Photos', group: '网盘' }, + yandex: { label: 'Yandex Disk', group: '网盘' }, + pikpak: { label: 'PikPak', group: '网盘' }, + iclouddrive: { label: 'iCloud Drive', group: '网盘' }, + jottacloud: { label: 'Jottacloud', group: '网盘' }, + hidrive: { label: 'HiDrive', group: '网盘' }, + protondrive: { label: 'Proton Drive', group: '网盘' }, + mailru: { label: 'Mail.ru Cloud', group: '网盘' }, + sugarsync: { label: 'SugarSync', group: '网盘' }, + putio: { label: 'Put.io', group: '网盘' }, + zoho: { label: 'Zoho WorkDrive', group: '网盘' }, + internxt: { label: 'Internxt Drive', group: '网盘' }, + seafile: { label: 'Seafile', group: '自建存储' }, + storj: { label: 'Storj 去中心化存储', group: '云存储' }, + hdfs: { label: 'Hadoop HDFS', group: '企业存储' }, oracleobjectstorage: { label: 'Oracle 对象存储', group: '云存储' }, - qingstor: { label: '青云 QingStor', group: '云存储' }, - sharefile: { label: 'Citrix ShareFile', group: '企业存储' }, - filefabric: { label: 'Enterprise File Fabric', group: '企业存储' }, - netstorage: { label: 'Akamai NetStorage', group: '企业存储' }, - sia: { label: 'Sia 去中心化存储', group: '云存储' }, - koofr: { label: 'Koofr / Digi Storage', group: '网盘' }, - opendrive: { label: 'OpenDrive', group: '网盘' }, + qingstor: { label: '青云 QingStor', group: '云存储' }, + sharefile: { label: 'Citrix ShareFile', group: '企业存储' }, + filefabric: { label: 'Enterprise File Fabric', group: '企业存储' }, + netstorage: { label: 'Akamai NetStorage', group: '企业存储' }, + sia: { label: 'Sia 去中心化存储', group: '云存储' }, + koofr: { label: 'Koofr / Digi Storage', group: '网盘' }, + opendrive: { label: 'OpenDrive', group: '网盘' }, } /** 构建完整类型选项列表(内置 + rclone,去重+分类) */ -export function buildAllTypeOptions(rcloneBackends: { name: string; description: string }[]): TypeOption[] { +export function buildAllTypeOptions( + rcloneBackends: { name: string; description: string }[], +): TypeOption[] { const result = [...BUILTIN_OPTIONS] const existingValues = new Set(BUILTIN_OPTIONS.map((o) => o.value)) @@ -162,12 +253,25 @@ export function buildAllTypeOptions(rcloneBackends: { name: string; description: // --------------------------------------------------------------------------- const TYPE_LABELS: Record = { - local_disk: '本地磁盘', google_drive: 'Google Drive', s3: 'S3 Compatible', - webdav: 'WebDAV', aliyun_oss: '阿里云 OSS', tencent_cos: '腾讯云 COS', - qiniu_kodo: '七牛云 Kodo', ftp: 'FTP', - sftp: 'SFTP', smb: 'SMB', azureblob: 'Azure Blob', dropbox: 'Dropbox', - onedrive: 'OneDrive', b2: 'Backblaze B2', mega: 'MEGA', pcloud: 'pCloud', - box: 'Box', swift: 'Swift', pikpak: 'PikPak', + local_disk: '本地磁盘', + google_drive: 'Google Drive', + s3: 'S3 Compatible', + webdav: 'WebDAV', + aliyun_oss: '阿里云 OSS', + tencent_cos: '腾讯云 COS', + qiniu_kodo: '七牛云 Kodo', + ftp: 'FTP', + sftp: 'SFTP', + smb: 'SMB', + azureblob: 'Azure Blob', + dropbox: 'Dropbox', + onedrive: 'OneDrive', + b2: 'Backblaze B2', + mega: 'MEGA', + pcloud: 'pCloud', + box: 'Box', + swift: 'Swift', + pikpak: 'PikPak', } export function getStorageTargetTypeLabel(type: StorageTargetType): string { diff --git a/web/src/components/verification-records/VerificationRecordLogDrawer.tsx b/web/src/components/verification-records/VerificationRecordLogDrawer.tsx index 542603d..1e4077b 100644 --- a/web/src/components/verification-records/VerificationRecordLogDrawer.tsx +++ b/web/src/components/verification-records/VerificationRecordLogDrawer.tsx @@ -1,8 +1,14 @@ import { Alert, Descriptions, Drawer, Space, Spin, Tag, Typography } from '@arco-design/web-react' import { useEffect, useMemo, useState } from 'react' -import { getVerificationRecord, streamVerificationRecordLogs } from '../../services/verification-records' +import { + getVerificationRecord, + streamVerificationRecordLogs, +} from '../../services/verification-records' import type { BackupLogEvent } from '../../types/backup-records' -import type { VerificationRecordDetail, VerificationRecordStatus } from '../../types/verification-records' +import type { + VerificationRecordDetail, + VerificationRecordStatus, +} from '../../types/verification-records' import { resolveErrorMessage } from '../../utils/error' import { formatDateTime, formatDuration } from '../../utils/format' @@ -69,9 +75,17 @@ export function VerificationRecordLogDrawer({ visible, verifyId, onCancel }: Pro unsubscribe = streamVerificationRecordLogs(current, { onEvent: (event) => { if (!active) return - setEvents((existing) => (existing.some((i) => i.sequence === event.sequence) ? existing : [...existing, event])) + setEvents((existing) => + existing.some((i) => i.sequence === event.sequence) + ? existing + : [...existing, event], + ) if (event.completed) { - setRecord((existing) => (existing ? { ...existing, status: event.status as VerificationRecordStatus } : existing)) + setRecord((existing) => + existing + ? { ...existing, status: event.status as VerificationRecordStatus } + : existing, + ) } }, onDone: () => { @@ -123,7 +137,9 @@ export function VerificationRecordLogDrawer({ visible, verifyId, onCancel }: Pro {record.taskName} - {statusLabel(record.status)} + + {statusLabel(record.status)} + {record.mode === 'deep' ? '深度模式' : '快速模式'} {record.triggeredBy && 触发: {record.triggeredBy}} @@ -131,7 +147,10 @@ export function VerificationRecordLogDrawer({ visible, verifyId, onCancel }: Pro }, - { key: '/reports', label: '合规报表', icon: }, - { key: '/backup/tasks', label: '备份任务', icon: }, - { key: '/backup/records', label: '备份记录', icon: }, - { key: '/restore/records', label: '恢复记录', icon: }, - { key: '/verify/records', label: '验证演练', icon: }, - { key: '/replication/records', label: '备份复制', icon: }, - { key: '/task-templates', label: '任务模板', icon: }, - { key: '/storage-targets', label: '存储目标', icon: }, - { key: '/nodes', label: '节点管理', icon: }, - { key: '/settings/notifications', label: '通知配置', icon: }, - { key: '/admin', label: '访问管理', icon: , adminOnly: true }, - { key: '/audit', label: '审计日志', icon: }, - { key: '/settings', label: '系统设置', icon: }, -] - export function AppLayout() { const [collapsed, setCollapsed] = useState(false) - const [pwdVisible, setPwdVisible] = useState(false) - const [pwdLoading, setPwdLoading] = useState(false) - const [twoFactorVisible, setTwoFactorVisible] = useState(false) - const [twoFactorLoading, setTwoFactorLoading] = useState(false) - const [twoFactorSetup, setTwoFactorSetup] = useState(null) - const [recoveryCodes, setRecoveryCodes] = useState([]) - const [webAuthnCredentials, setWebAuthnCredentials] = useState([]) - const [trustedDevices, setTrustedDevices] = useState([]) - const [securityDetailsLoading, setSecurityDetailsLoading] = useState(false) - const [pwdForm] = Form.useForm() - const [twoFactorForm] = Form.useForm<{ currentPassword: string; code: string; email: string; phone: string }>() const location = useLocation() const navigate = useNavigate() const user = useAuthStore((state) => state.user) - const logout = useAuthStore((state) => state.logout) - const setUser = useAuthStore((state) => state.setUser) - - function applySecurityUserUpdate(updated: UserInfo) { - setUser(updated) - if (!updated.mfaEnabled) { - clearTrustedDeviceToken(updated.username) - } - } - - async function handleChangePassword() { - try { - const values = await pwdForm.validate() - if (values.newPassword !== values.confirmPassword) { - Message.error('两次输入的新密码不一致') - return - } - setPwdLoading(true) - await changePassword({ oldPassword: values.oldPassword, newPassword: values.newPassword }) - clearTrustedDeviceToken(user?.username) - Message.success('密码修改成功') - setPwdVisible(false) - pwdForm.resetFields() - } catch (err) { - if (err) { - Message.error(resolveErrorMessage(err, '密码修改失败')) - } - } finally { - setPwdLoading(false) - } - } - - function closeTwoFactorModal() { - setTwoFactorVisible(false) - setTwoFactorSetup(null) - setRecoveryCodes([]) - setWebAuthnCredentials([]) - setTrustedDevices([]) - twoFactorForm.resetFields() - } - - async function openSecurityModal() { - setTwoFactorVisible(true) - twoFactorForm.setFieldValue('email', user?.email ?? '') - twoFactorForm.setFieldValue('phone', user?.phone ?? '') - await loadSecurityDetails() - } - - async function loadSecurityDetails() { - setSecurityDetailsLoading(true) - try { - const [credentials, devices] = await Promise.all([listWebAuthnCredentials(), listTrustedDevices()]) - setWebAuthnCredentials(credentials) - setTrustedDevices(devices) - } catch (err) { - Message.error(resolveErrorMessage(err, '加载安全配置失败')) - } finally { - setSecurityDetailsLoading(false) - } - } - - async function copyRecoveryCodes() { - if (recoveryCodes.length === 0) return - try { - await navigator.clipboard.writeText(recoveryCodes.join('\n')) - Message.success('已复制到剪贴板') - } catch { - Message.info('请手动选择文本复制') - } - } - - async function handleTwoFactorSetupAction() { - try { - const values = await twoFactorForm.validate() - setTwoFactorLoading(true) - if (!twoFactorSetup) { - const setup = await prepareTwoFactor({ currentPassword: values.currentPassword }) - setTwoFactorSetup(setup) - Message.success('TOTP 密钥已生成') - return - } - const result = await enableTwoFactor({ code: values.code }) - setUser(result.user) - setRecoveryCodes(result.recoveryCodes) - Message.success('TOTP 已启用') - } catch (err) { - if (err) { - Message.error(resolveErrorMessage(err, 'TOTP 操作失败')) - } - } finally { - setTwoFactorLoading(false) - } - } - - async function handleRegenerateRecoveryCodes() { - try { - const values = await twoFactorForm.validate() - setTwoFactorLoading(true) - const result = await regenerateRecoveryCodes({ - currentPassword: values.currentPassword, - code: values.code, - }) - setUser(result.user) - setRecoveryCodes(result.recoveryCodes) - twoFactorForm.resetFields() - Message.success('恢复码已重新生成') - } catch (err) { - if (err) { - Message.error(resolveErrorMessage(err, '恢复码生成失败')) - } - } finally { - setTwoFactorLoading(false) - } - } - - async function handleDisableTwoFactor() { - try { - const values = await twoFactorForm.validate() - setTwoFactorLoading(true) - const updated = await disableTwoFactor({ - currentPassword: values.currentPassword, - code: values.code, - }) - applySecurityUserUpdate(updated) - Message.success('TOTP 已关闭') - closeTwoFactorModal() - } catch (err) { - if (err) { - Message.error(resolveErrorMessage(err, '关闭 TOTP 失败')) - } - } finally { - setTwoFactorLoading(false) - } - } - - function readCurrentPassword() { - const currentPassword = String(twoFactorForm.getFieldValue('currentPassword') ?? '') - if (currentPassword.trim().length < 8) { - Message.error('请输入当前密码') - return '' - } - return currentPassword - } - - async function handleRegisterWebAuthn() { - const currentPassword = readCurrentPassword() - if (!currentPassword) return - try { - setTwoFactorLoading(true) - const options = await beginWebAuthnRegistration({ currentPassword }) - const credential = await createWebAuthnCredential(options) - const updated = await finishWebAuthnRegistration({ name: navigator.userAgent.slice(0, 120), credential }) - applySecurityUserUpdate(updated) - await loadSecurityDetails() - Message.success('通行密钥已注册') - } catch (err) { - Message.error(resolveErrorMessage(err, '通行密钥注册失败')) - } finally { - setTwoFactorLoading(false) - } - } - - async function handleDeleteWebAuthnCredential(id: string) { - const currentPassword = readCurrentPassword() - if (!currentPassword) return - try { - setTwoFactorLoading(true) - const updated = await deleteWebAuthnCredential(id, { currentPassword }) - applySecurityUserUpdate(updated) - await loadSecurityDetails() - Message.success('通行密钥已删除') - } catch (err) { - Message.error(resolveErrorMessage(err, '删除通行密钥失败')) - } finally { - setTwoFactorLoading(false) - } - } - - async function handleConfigureOtp(channel: 'email' | 'sms', enabled: boolean) { - const currentPassword = readCurrentPassword() - if (!currentPassword) return - const email = String(twoFactorForm.getFieldValue('email') ?? '') - const phone = String(twoFactorForm.getFieldValue('phone') ?? '') - try { - setTwoFactorLoading(true) - const updated = await configureOtp({ currentPassword, channel, enabled, email, phone }) - applySecurityUserUpdate(updated) - twoFactorForm.setFieldValue('email', updated.email ?? '') - twoFactorForm.setFieldValue('phone', updated.phone ?? '') - Message.success(enabled ? 'OTP 已启用' : 'OTP 已关闭') - } catch (err) { - Message.error(resolveErrorMessage(err, 'OTP 配置失败')) - } finally { - setTwoFactorLoading(false) - } - } - - async function handleRevokeTrustedDevice(id: string) { - const currentPassword = readCurrentPassword() - if (!currentPassword) return - try { - setTwoFactorLoading(true) - await revokeTrustedDevice(id, { currentPassword }) - clearTrustedDeviceToken(user?.username) - await loadSecurityDetails() - Message.success('可信设备已移除') - } catch (err) { - Message.error(resolveErrorMessage(err, '移除可信设备失败')) - } finally { - setTwoFactorLoading(false) - } - } - - function renderTwoFactorFooter() { - if (recoveryCodes.length > 0) { - return ( - - - - - ) - } - if (user?.twoFactorEnabled) { - return ( - - - - - - ) - } - return ( - - - - - ) - } - - const userDroplist = ( - { - if (key === 'password') { - setPwdVisible(true) - } else if (key === 'two-factor') { - void openSecurityModal() - } else if (key === 'logout') { - logout() - } - }}> - 修改密码 - 多因素认证 - 退出登录 - - ) return (
- {!collapsed && BackupX} + {!collapsed && ( + + BackupX + + )}
- navigate(key)}> - {menuItems + navigate(key)} + > + {appMenuItems .filter((item) => !item.adminOnly || isAdmin(user)) .map((item) => ( @@ -410,12 +45,23 @@ export function AppLayout() { {!collapsed && (
- v1.0.0 + + v1.0.0 +
)} -
+
- +
- - { setPwdVisible(false); pwdForm.resetFields() }} - onOk={handleChangePassword} - confirmLoading={pwdLoading} - unmountOnExit - > -
- - - - - - - - - -
-
- - - {recoveryCodes.length > 0 ? ( - - - - - ) : ( -
- {user?.twoFactorEnabled ? ( - <> - - - - - - - - - ) : ( - <> - {!twoFactorSetup ? ( - <> - - - - - - ) : ( - <> - -
- TOTP 二维码 - - 手动密钥 - - -
- - - - - )} - - )} - - - - 通行密钥 - 0 ? 'green' : 'gray'} bordered> - {webAuthnCredentials.length > 0 ? `${webAuthnCredentials.length} 个` : '未注册'} - - - - 支持浏览器 Passkey、平台验证器或安全密钥,用于登录时替代验证码。 - - - - {securityDetailsLoading ? 正在加载通行密钥... : null} - {webAuthnCredentials.map((item) => ( -
- - {item.name} - {item.lastUsedAt ? `最近使用 ${item.lastUsedAt}` : `创建于 ${item.createdAt}`} - - -
- ))} -
-
- - - 邮件 / 短信 OTP - - - 邮件 OTP {user?.emailOtpEnabled ? '已启用' : '未启用'} - 短信 OTP {user?.smsOtpEnabled ? '已启用' : '未启用'} - - - - - - - - - - - - - - - - 可信设备 - 0 ? 'green' : 'gray'} bordered>{trustedDevices.length} 个 - - - 登录时勾选“信任此设备”后,30 天内该设备可在密码校验通过后跳过多因素验证。 - - - {trustedDevices.map((item) => ( -
- - {item.name} - 最近使用 {item.lastUsedAt || '-'},到期 {item.expiresAt} - - -
- ))} - {!securityDetailsLoading && trustedDevices.length === 0 ? 暂无可信设备 : null} -
-
- - )} -
) } diff --git a/web/src/layouts/protected-layout.tsx b/web/src/layouts/protected-layout.tsx deleted file mode 100644 index 6c15661..0000000 --- a/web/src/layouts/protected-layout.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { - Button, - Layout, - Menu, - Space, - Typography, -} from '@arco-design/web-react'; -import { - IconDashboard, - IconInfoCircle, - IconPoweroff, -} from '../components/icons'; -import { Outlet, useLocation, useNavigate } from 'react-router-dom'; - -import { useAuthStore } from '../stores/auth'; - -const { Sider, Header, Content } = Layout; - -const menuItems = [ - { - key: '/', - label: '仪表盘', - icon: , - }, - { - key: '/system-info', - label: '系统信息', - icon: , - }, -]; - -export function ProtectedLayout() { - const navigate = useNavigate(); - const location = useLocation(); - const user = useAuthStore((state) => state.user); - const logout = useAuthStore((state) => state.logout); - - return ( - - -
BackupX
- { - navigate(key); - }} - style={{ width: '100%' }} - > - {menuItems.map((item) => ( - - {item.icon} - {item.label} - - ))} - -
- -
- - - BackupX 管理台 - - - {user?.displayName ?? user?.username ?? '未登录'} - - - -
- - - -
-
- ); -} diff --git a/web/src/pages/admin/AdminLayout.tsx b/web/src/pages/admin/AdminLayout.tsx index cddabf0..5f0d5a9 100644 --- a/web/src/pages/admin/AdminLayout.tsx +++ b/web/src/pages/admin/AdminLayout.tsx @@ -25,11 +25,11 @@ export function AdminLayout() { className="admin-page__header" title="访问管理" subTitle="统一管理系统账号、角色权限、多因素认证与程序化访问凭据。" - extra={( + extra={ - )} + } />