From 726c5e134b77be69ba4c294b7c03fc87e88c2d59 Mon Sep 17 00:00:00 2001 From: Wu Qing <3184394176@qq.com> Date: Sun, 19 Apr 2026 17:25:34 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD:=20=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=20Agent=20=E5=90=91=E5=AF=BC=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs-site/docs/features/multi-node.md | 47 +- .../current/features/multi-node.md | 49 +- .../2026-04-19-one-click-agent-deploy.md | 3792 +++++++++++++++++ ...026-04-19-one-click-agent-deploy-design.md | 590 +++ server/internal/app/app.go | 13 +- server/internal/database/database.go | 2 +- server/internal/http/agent_handler.go | 15 + server/internal/http/install_flow_test.go | 331 ++ server/internal/http/install_handler.go | 221 + server/internal/http/node_handler.go | 181 +- server/internal/http/router.go | 27 +- server/internal/installscript/renderer.go | 170 + .../internal/installscript/renderer_test.go | 176 + .../templates/agent-compose.yml.tmpl | 13 + .../templates/agent-install.sh.tmpl | 108 + server/internal/model/agent_install_token.go | 36 + server/internal/model/node.go | 10 +- .../agent_install_token_repository.go | 107 + .../agent_install_token_repository_test.go | 151 + server/internal/repository/node_repository.go | 33 +- .../repository/node_repository_test.go | 76 + server/internal/service/agent_service.go | 21 + .../internal/service/install_token_service.go | 189 + .../service/install_token_service_test.go | 156 + server/internal/service/node_service.go | 115 + server/internal/service/node_service_test.go | 159 + web/src/pages/nodes/AgentInstallWizard.tsx | 301 ++ web/src/pages/nodes/BatchCommandTable.tsx | 108 + web/src/pages/nodes/NodesPage.tsx | 231 +- web/src/pages/nodes/wizard/Step1NodeName.tsx | 60 + .../pages/nodes/wizard/Step2DeployOptions.tsx | 111 + .../nodes/wizard/Step3CommandPreview.tsx | 111 + web/src/services/nodes.ts | 32 +- web/src/types/nodes.ts | 24 + 34 files changed, 7559 insertions(+), 207 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-19-one-click-agent-deploy.md create mode 100644 docs/superpowers/specs/2026-04-19-one-click-agent-deploy-design.md create mode 100644 server/internal/http/install_flow_test.go create mode 100644 server/internal/http/install_handler.go create mode 100644 server/internal/installscript/renderer.go create mode 100644 server/internal/installscript/renderer_test.go create mode 100644 server/internal/installscript/templates/agent-compose.yml.tmpl create mode 100644 server/internal/installscript/templates/agent-install.sh.tmpl create mode 100644 server/internal/model/agent_install_token.go create mode 100644 server/internal/repository/agent_install_token_repository.go create mode 100644 server/internal/repository/agent_install_token_repository_test.go create mode 100644 server/internal/repository/node_repository_test.go create mode 100644 server/internal/service/install_token_service.go create mode 100644 server/internal/service/install_token_service_test.go create mode 100644 server/internal/service/node_service_test.go create mode 100644 web/src/pages/nodes/AgentInstallWizard.tsx create mode 100644 web/src/pages/nodes/BatchCommandTable.tsx create mode 100644 web/src/pages/nodes/wizard/Step1NodeName.tsx create mode 100644 web/src/pages/nodes/wizard/Step2DeployOptions.tsx create mode 100644 web/src/pages/nodes/wizard/Step3CommandPreview.tsx diff --git a/docs-site/docs/features/multi-node.md b/docs-site/docs/features/multi-node.md index f7b4a19..9d0acd3 100644 --- a/docs-site/docs/features/multi-node.md +++ b/docs-site/docs/features/multi-node.md @@ -28,45 +28,42 @@ BackupX supports Master-Agent mode: backup tasks can be routed to specific nodes ## Walkthrough -### 1. Create a node on Master +### 1. Open the install wizard -Web Console → **Node Management** → **Add Node**. A 64-byte hex token is shown **once** — keep it safe. +In the Web Console → **Node Management** → **Add Node**. You'll see a three-step wizard. -### 2. Deploy the Agent on a remote host +- **Step 1 — Node info.** Give the node a name, or switch to batch mode and paste multiple names (one per line, max 50). +- **Step 2 — Deploy options.** Pick install mode (`systemd` recommended, `docker`, or `foreground` for debugging), architecture (auto-detect by default), agent version (defaults to the master's version), TTL for the install link (5 min / 15 min / 1 h / 24 h), and download source (`github` direct, or the `ghproxy` mirror for mainland China). +- **Step 3 — Copy the command.** A single `curl ... | sudo sh` line is shown with a live countdown. Click copy, paste into the target machine, and run with root privileges. -Upload the BackupX binary (same file as Master) to the target host, then start the Agent: +### 2. One-line install on the target host -**Option A: CLI flags** +Example (systemd mode): ```bash -backupx agent --master http://master.example.com:8340 --token +curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh ``` -**Option B: config file** +The script runs automatically and: -```yaml title="/etc/backupx/agent.yaml" -master: http://master.example.com:8340 -token: -heartbeatInterval: 15s -pollInterval: 5s -tempDir: /var/lib/backupx-agent -``` +1. Detects OS and architecture (`uname -m`) +2. Downloads the matching `backupx` binary from GitHub Release (or the ghproxy mirror) +3. Installs to `/opt/backupx-agent` and creates a `backupx` system user +4. Writes `/etc/systemd/system/backupx-agent.service` with the token baked into environment variables +5. Runs `systemctl enable --now backupx-agent` +6. Polls `/api/v1/agent/self` until the master confirms `status: online` (up to 30 s) -```bash -backupx agent --config /etc/backupx/agent.yaml -``` +Reruns are idempotent — to upgrade or re-provision, simply generate a new install command and run it again. The one-time install link expires after its TTL or after first consumption, whichever is sooner. -**Option C: environment variables** (Docker / systemd friendly) +### 3. Rotate agent tokens at any time -```bash -BACKUPX_AGENT_MASTER=http://master.example.com:8340 \ -BACKUPX_AGENT_TOKEN= \ -backupx agent -``` +Go to the node's action menu (︙) → **Rotate Token**. The new token is shown once and the old token remains valid for 24 h, allowing rolling restarts without downtime. After 24 h, the old token is rejected. -Once connected, the node shows as **online** in the list. +### 4. Batch deployment -### 3. Route a task to the node +In Step 1 choose "Batch" and paste node names (one per line, max 50). Step 3 shows a table with one command per node plus a **Download .sh** button that bundles all commands into a shell script, convenient for SSH loops or Ansible tasks. + +### 5. Route a task to the node In the **Backup Tasks** page, pick the target node when creating the task. When the task runs: diff --git a/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/multi-node.md b/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/multi-node.md index 88ac8e9..332797b 100644 --- a/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/multi-node.md +++ b/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/multi-node.md @@ -26,47 +26,44 @@ BackupX 支持 Master-Agent 模式:备份任务可以指定在哪个节点执 - **执行** — Agent 复用 BackupRunner(file / mysql / postgresql / sqlite / saphana)并直接上传到存储 - **安全** — 每个节点独立 Token;Agent 不持有 Master 的 JWT 密钥或 AES-256 加密密钥 -## 使用步骤 +## 一键部署步骤 -### 1. 在 Master 创建节点 +### 1. 打开安装向导 -Web 控制台 → **节点管理** → **添加节点**。界面会**一次性**显示 64 字节十六进制令牌,请妥善保存。 +Web 控制台 → **节点管理** → **添加节点**,打开三步向导: -### 2. 在远程服务器部署 Agent +- **第一步 · 节点信息**:填写节点名称;或切换"批量创建"粘贴多行名称(每行一个,最多 50 个) +- **第二步 · 部署参数**:选择安装模式(`systemd` 推荐、`Docker`、`前台运行` 调试用)、架构(默认自动检测)、Agent 版本(默认跟随 Master 版本)、有效期(5 分钟 / 15 分钟 / 1 小时 / 24 小时)、下载源(`GitHub` 直连或 `ghproxy` 镜像,国内服务器建议后者) +- **第三步 · 安装命令**:一行 `curl ... | sudo sh` 命令 + 实时倒计时。点击复制,粘贴到目标机以 root 权限执行 -把 BackupX 二进制上传到目标服务器(与 Master 同一个文件),然后用以下任一方式启动: +### 2. 目标机一条命令完成 -**方式 A:CLI 参数** +示例(systemd 模式): ```bash -backupx agent --master http://master.example.com:8340 --token +curl -fsSL https://master.example.com/install/Xk3p9...vM | sudo sh ``` -**方式 B:配置文件** +脚本会自动: -```yaml title="/etc/backupx/agent.yaml" -master: http://master.example.com:8340 -token: -heartbeatInterval: 15s -pollInterval: 5s -tempDir: /var/lib/backupx-agent -``` +1. 检测操作系统与架构(`uname -m`) +2. 从 GitHub Release(或 ghproxy 镜像)下载匹配的 `backupx` 二进制 +3. 安装到 `/opt/backupx-agent`,创建系统用户 `backupx` +4. 写入 `/etc/systemd/system/backupx-agent.service`(token 已烧入环境变量) +5. 执行 `systemctl enable --now backupx-agent` +6. 轮询 `/api/v1/agent/self`,直到 Master 确认 `status: online`(最多 30 秒) -```bash -backupx agent --config /etc/backupx/agent.yaml -``` +脚本是幂等的:升级或重装只需重新生成一条安装命令再跑一次。一次性安装链接在 TTL 到期或被首次消费后立即作废。 -**方式 C:环境变量**(适合 Docker / systemd) +### 3. 随时轮换 Agent Token -```bash -BACKUPX_AGENT_MASTER=http://master.example.com:8340 \ -BACKUPX_AGENT_TOKEN= \ -backupx agent -``` +节点操作列(︙)→ **重新生成 Token**。新 Token 一次性显示,旧 Token 24 小时内仍有效,便于滚动替换无需停机。24 小时后旧 Token 被拒绝。 -连接成功后节点在列表中显示为 **在线**。 +### 4. 批量部署 -### 3. 把任务路由到该节点 +第一步选"批量创建"粘贴节点名(每行一个,最多 50 个)。第三步显示每个节点对应的命令表格,底部「导出 .sh」可打包为单个 shell 文件,方便 SSH 循环或 Ansible 任务。 + +### 5. 把任务路由到该节点 在 **备份任务** 页面新建任务时选择对应节点。任务触发时: diff --git a/docs/superpowers/plans/2026-04-19-one-click-agent-deploy.md b/docs/superpowers/plans/2026-04-19-one-click-agent-deploy.md new file mode 100644 index 0000000..d5f23d4 --- /dev/null +++ b/docs/superpowers/plans/2026-04-19-one-click-agent-deploy.md @@ -0,0 +1,3792 @@ +# 一键部署 Agent 实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 BackupX 节点管理页引入 Komari 风格一键部署向导:勾选 mode/arch/version → 一次性 install token → 目标机 `curl | sudo sh` 完成安装。支持批量创建与 agent token 轮换。 + +**Architecture:** Master 端新增 `agent_install_tokens` 表 + 两组路由(JWT 保护的管理端 + 匿名的 `/install/:token` 公开端)。通过嵌入 shell/yaml 模板动态渲染安装脚本。前端替换旧 Modal 为三步向导 + 批量结果表。 + +**Tech Stack:** +- Backend: Go 1.25, Gin, GORM + SQLite, `go:embed` 模板 +- Frontend: React 18 + TypeScript + Arco Design + Vite +- Testing: `go test`(Go),Vitest(前端 — 可选) + +**Spec**: [docs/superpowers/specs/2026-04-19-one-click-agent-deploy-design.md](../specs/2026-04-19-one-click-agent-deploy-design.md) + +**Issue**: [#43 Feature: 一键部署 Agent](https://github.com/Awuqing/BackupX/issues/43) + +**项目约定(重要)**:按 `CLAUDE.md`,未经用户明确要求**不执行 git commit/push**。本计划中的 "Commit" 步骤遵循 TDD 纪律,执行时需用户确认后再运行。 + +**执行阶段**: +- Phase 1(任务 1-13):后端 — 可独立合入 +- Phase 2(任务 14-20):前端 — 依赖 Phase 1 合入 +- Phase 3(任务 21-22):文档 + +--- + +## 文件结构 + +``` +server/ +├── internal/ +│ ├── model/ +│ │ ├── agent_install_token.go [新增] +│ │ └── node.go [改:加 2 列] +│ ├── repository/ +│ │ ├── agent_install_token_repository.go [新增] +│ │ ├── agent_install_token_repository_test.go [新增] +│ │ └── node_repository.go [改:FindByToken 扩展] +│ ├── service/ +│ │ ├── install_token_service.go [新增] +│ │ ├── install_token_service_test.go [新增] +│ │ ├── node_service.go [改:BatchCreate + RotateToken + Self] +│ │ ├── node_service_test.go [新增:轮换+批量测试] +│ │ └── agent_service.go [改:AuthenticatedNodeSummary] +│ ├── installscript/ +│ │ ├── renderer.go [新增] +│ │ ├── renderer_test.go [新增] +│ │ └── testdata/ [新增 golden files] +│ ├── http/ +│ │ ├── install_handler.go [新增] +│ │ ├── node_handler.go [改:Batch + InstallTokens + Rotate + Preview] +│ │ ├── agent_handler.go [改:Self 端点] +│ │ ├── router.go [改:注册新路由] +│ │ └── install_flow_test.go [新增:端到端集成] +│ ├── database/database.go [改:AutoMigrate 新表] +│ └── app/app.go [改:wire install_token_service + GC] +├── deploy/ +│ ├── agent-install.sh.tmpl [新增] +│ └── agent-compose.yml.tmpl [新增] +web/ +└── src/ + ├── types/nodes.ts [改:新类型] + ├── services/nodes.ts [改:新 API 函数] + └── pages/nodes/ + ├── NodesPage.tsx [改:操作列 + 调 Wizard] + ├── AgentInstallWizard.tsx [新增] + ├── BatchCommandTable.tsx [新增] + └── wizard/ + ├── Step1NodeName.tsx [新增] + ├── Step2DeployOptions.tsx [新增] + └── Step3CommandPreview.tsx [新增] +docs-site/docs/features/multi-node.md [改] +docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/multi-node.md [改] +``` + +--- + +# Phase 1 — 后端 + +## Task 1: 数据模型 `AgentInstallToken` 与 Node 列扩展 + +**Files:** +- Create: `server/internal/model/agent_install_token.go` +- Modify: `server/internal/model/node.go` +- Modify: `server/internal/database/database.go` + +- [ ] **Step 1: 写 Node model 扩展** + +Append to `server/internal/model/node.go`: + +```go +// 附加字段(V1.7 多节点一键部署:支持 agent token 轮换) +// +// 轮换流程:rotate 时把旧 Token 复制到 PrevToken,并设置 PrevTokenExpires=now+24h。 +// Agent 认证先查 Token,未命中则退回查 PrevToken(且未过期)。 +``` + +(声明加字段,实际字段加到 Node struct 内)。修改 `Node` struct,在 `UpdatedAt` 之前插入: + +```go +PrevToken string `gorm:"size:128;index" json:"-"` +PrevTokenExpires *time.Time `gorm:"column:prev_token_expires" json:"-"` +``` + +- [ ] **Step 2: 新建 AgentInstallToken 模型** + +Create `server/internal/model/agent_install_token.go`: + +```go +package model + +import "time" + +// AgentInstallToken 一次性安装令牌,用于 /install/:token 公开端点。 +// +// 生命周期:创建 → 消费(ConsumedAt 非空即作废)→ 超过 ExpiresAt 后被 GC 硬删除。 +type AgentInstallToken struct { + ID uint `gorm:"primaryKey" json:"id"` + Token string `gorm:"size:64;uniqueIndex;not null" json:"token"` + NodeID uint `gorm:"not null;index" json:"nodeId"` + Mode string `gorm:"size:16;not null" json:"mode"` // systemd|docker|foreground + Arch string `gorm:"size:16;not null" json:"arch"` // amd64|arm64|auto + AgentVer string `gorm:"size:32;not null" json:"agentVersion"` + DownloadSrc string `gorm:"size:16;not null;default:'github'" json:"downloadSrc"` + ExpiresAt time.Time `gorm:"not null;index" json:"expiresAt"` + ConsumedAt *time.Time `json:"consumedAt,omitempty"` + CreatedByID uint `gorm:"not null" json:"createdById"` + CreatedAt time.Time `json:"createdAt"` +} + +func (AgentInstallToken) TableName() string { return "agent_install_tokens" } + +// 合法模式/架构常量 +const ( + InstallModeSystemd = "systemd" + InstallModeDocker = "docker" + InstallModeForeground = "foreground" + + InstallArchAmd64 = "amd64" + InstallArchArm64 = "arm64" + InstallArchAuto = "auto" + + InstallSourceGitHub = "github" + InstallSourceGhproxy = "ghproxy" +) +``` + +- [ ] **Step 3: AutoMigrate 新表** + +Modify `server/internal/database/database.go` line 26 — 在 `AutoMigrate` 调用末尾加 `&model.AgentInstallToken{}`: + +```go +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{}, +); err != nil { + return nil, fmt.Errorf("migrate schema: %w", err) +} +``` + +- [ ] **Step 4: 编译验证** + +Run: `cd server && go build ./...` +Expected: 编译通过,无错误 + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/model/agent_install_token.go server/internal/model/node.go server/internal/database/database.go +git commit -m "功能: 新增 AgentInstallToken 模型与 Node token 轮换字段" +``` + +--- + +## Task 2: NodeRepository 支持 prev_token 回退认证 + +**Files:** +- Modify: `server/internal/repository/node_repository.go:50-59` +- Create: `server/internal/repository/node_repository_test.go`(若已存在则追加用例) + +- [ ] **Step 1: 写失败测试** + +Create or append to `server/internal/repository/node_repository_test.go`: + +```go +package repository + +import ( + "context" + "path/filepath" + "testing" + "time" + + "backupx/server/internal/model" + "github.com/glebarez/sqlite" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +func openTestNodeDB(t *testing.T) *gorm.DB { + t.Helper() + path := filepath.Join(t.TempDir(), "nodes.db") + db, err := gorm.Open(sqlite.Open(path), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + if err := db.AutoMigrate(&model.Node{}); err != nil { + t.Fatalf("migrate: %v", err) + } + return db +} + +func TestFindByTokenFallsBackToPrevToken(t *testing.T) { + db := openTestNodeDB(t) + repo := NewNodeRepository(db) + ctx := context.Background() + + future := time.Now().UTC().Add(24 * time.Hour) + node := &model.Node{ + Name: "test", Token: "new-token", + PrevToken: "old-token", PrevTokenExpires: &future, + } + if err := repo.Create(ctx, node); err != nil { + t.Fatalf("create: %v", err) + } + + // 新 token 能查到 + got, err := repo.FindByToken(ctx, "new-token") + if err != nil || got == nil || got.ID != node.ID { + t.Fatalf("new token lookup failed: err=%v got=%v", err, got) + } + + // 旧 token 也能查到(未过期) + got, err = repo.FindByToken(ctx, "old-token") + if err != nil || got == nil || got.ID != node.ID { + t.Fatalf("prev_token lookup failed: err=%v got=%v", err, got) + } +} + +func TestFindByTokenRejectsExpiredPrevToken(t *testing.T) { + db := openTestNodeDB(t) + repo := NewNodeRepository(db) + ctx := context.Background() + + past := time.Now().UTC().Add(-1 * time.Hour) + node := &model.Node{ + Name: "test", Token: "new-token", + PrevToken: "stale", PrevTokenExpires: &past, + } + if err := repo.Create(ctx, node); err != nil { + t.Fatalf("create: %v", err) + } + + got, err := repo.FindByToken(ctx, "stale") + if err != nil { + t.Fatalf("err=%v", err) + } + if got != nil { + t.Fatalf("expected stale prev_token rejected, got %v", got) + } +} +``` + +- [ ] **Step 2: 跑测试验证失败** + +Run: `cd server && go test ./internal/repository/ -run TestFindByToken -v` +Expected: FAIL —— `prev_token lookup failed: got=` 或 stale 未被拒 + +- [ ] **Step 3: 实现 FindByToken 回退查询** + +Modify `server/internal/repository/node_repository.go`, 替换 `FindByToken`: + +```go +func (r *GormNodeRepository) FindByToken(ctx context.Context, token string) (*model.Node, error) { + var item model.Node + // 主 token 查询 + err := r.db.WithContext(ctx).Where("token = ?", token).First(&item).Error + if err == nil { + return &item, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + // 回退:prev_token 且未过期 + now := time.Now().UTC() + err = r.db.WithContext(ctx). + Where("prev_token = ? AND prev_token_expires IS NOT NULL AND prev_token_expires > ?", token, now). + First(&item).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &item, nil +} +``` + +- [ ] **Step 4: 再跑测试验证通过** + +Run: `cd server && go test ./internal/repository/ -run TestFindByToken -v` +Expected: PASS(两个测试) + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/repository/node_repository.go server/internal/repository/node_repository_test.go +git commit -m "功能: NodeRepository.FindByToken 支持 prev_token 24h 过渡" +``` + +--- + +## Task 3: AgentInstallTokenRepository 实现与测试 + +**Files:** +- Create: `server/internal/repository/agent_install_token_repository.go` +- Create: `server/internal/repository/agent_install_token_repository_test.go` + +- [ ] **Step 1: 写 Repository 接口与失败测试** + +Create `server/internal/repository/agent_install_token_repository_test.go`: + +```go +package repository + +import ( + "context" + "path/filepath" + "testing" + "time" + + "backupx/server/internal/model" + "github.com/glebarez/sqlite" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +func openTestInstallTokenDB(t *testing.T) *gorm.DB { + t.Helper() + path := filepath.Join(t.TempDir(), "install.db") + db, err := gorm.Open(sqlite.Open(path), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)}) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := db.AutoMigrate(&model.AgentInstallToken{}); err != nil { + t.Fatalf("migrate: %v", err) + } + return db +} + +func TestInstallTokenConsumeOnce(t *testing.T) { + db := openTestInstallTokenDB(t) + repo := NewAgentInstallTokenRepository(db) + ctx := context.Background() + + tok := &model.AgentInstallToken{ + Token: "abc", NodeID: 1, Mode: model.InstallModeSystemd, + Arch: model.InstallArchAuto, AgentVer: "v1.7.0", + DownloadSrc: model.InstallSourceGitHub, + ExpiresAt: time.Now().UTC().Add(15 * time.Minute), + CreatedByID: 1, + } + if err := repo.Create(ctx, tok); err != nil { + t.Fatalf("create: %v", err) + } + + // 首次消费成功 + got, err := repo.ConsumeByToken(ctx, "abc") + if err != nil { + t.Fatalf("consume err: %v", err) + } + if got == nil || got.ConsumedAt == nil { + t.Fatalf("expected consumed token, got %+v", got) + } + + // 第二次消费应返回 nil(已作废) + got, err = repo.ConsumeByToken(ctx, "abc") + if err != nil { + t.Fatalf("second consume err: %v", err) + } + if got != nil { + t.Fatalf("expected nil on second consume, got %+v", got) + } +} + +func TestInstallTokenConsumeExpired(t *testing.T) { + db := openTestInstallTokenDB(t) + repo := NewAgentInstallTokenRepository(db) + ctx := context.Background() + + tok := &model.AgentInstallToken{ + Token: "stale", NodeID: 1, Mode: model.InstallModeSystemd, + Arch: model.InstallArchAuto, AgentVer: "v1.7.0", + DownloadSrc: model.InstallSourceGitHub, + ExpiresAt: time.Now().UTC().Add(-time.Minute), + CreatedByID: 1, + } + if err := repo.Create(ctx, tok); err != nil { + t.Fatalf("create: %v", err) + } + + got, err := repo.ConsumeByToken(ctx, "stale") + if err != nil { + t.Fatalf("consume err: %v", err) + } + if got != nil { + t.Fatalf("expected nil on expired, got %+v", got) + } +} + +func TestInstallTokenGC(t *testing.T) { + db := openTestInstallTokenDB(t) + repo := NewAgentInstallTokenRepository(db) + ctx := context.Background() + + // 造一条 8 天前过期的 + old := &model.AgentInstallToken{ + Token: "old", NodeID: 1, Mode: model.InstallModeSystemd, + Arch: model.InstallArchAuto, AgentVer: "v1.7.0", + DownloadSrc: model.InstallSourceGitHub, + ExpiresAt: time.Now().UTC().Add(-8 * 24 * time.Hour), + CreatedByID: 1, + } + _ = repo.Create(ctx, old) + + // 造一条今天过期的(不应被 GC) + fresh := &model.AgentInstallToken{ + Token: "fresh", NodeID: 1, Mode: model.InstallModeSystemd, + Arch: model.InstallArchAuto, AgentVer: "v1.7.0", + DownloadSrc: model.InstallSourceGitHub, + ExpiresAt: time.Now().UTC().Add(-1 * time.Hour), + CreatedByID: 1, + } + _ = repo.Create(ctx, fresh) + + n, err := repo.DeleteExpiredBefore(ctx, time.Now().UTC().Add(-7*24*time.Hour)) + if err != nil { + t.Fatalf("gc err: %v", err) + } + if n != 1 { + t.Fatalf("expected 1 deleted, got %d", n) + } +} +``` + +- [ ] **Step 2: 跑测试验证失败** + +Run: `cd server && go test ./internal/repository/ -run TestInstallToken -v` +Expected: FAIL(`undefined: NewAgentInstallTokenRepository`) + +- [ ] **Step 3: 实现 Repository** + +Create `server/internal/repository/agent_install_token_repository.go`: + +```go +package repository + +import ( + "context" + "errors" + "time" + + "backupx/server/internal/model" + "gorm.io/gorm" +) + +type AgentInstallTokenRepository interface { + Create(ctx context.Context, t *model.AgentInstallToken) error + FindByToken(ctx context.Context, token string) (*model.AgentInstallToken, error) + // ConsumeByToken 原子地把 ConsumedAt 置为 now;若 token 不存在/已过期/已消费则返回 (nil, nil)。 + ConsumeByToken(ctx context.Context, token string) (*model.AgentInstallToken, error) + // DeleteExpiredBefore 硬删除 ExpiresAt < threshold 的记录,返回删除行数。 + DeleteExpiredBefore(ctx context.Context, threshold time.Time) (int64, error) + // CountCreatedSince 统计某 node 在 since 之后的生成次数(用于节点级限流)。 + CountCreatedSince(ctx context.Context, nodeID uint, since time.Time) (int64, error) +} + +type GormAgentInstallTokenRepository struct { + db *gorm.DB +} + +func NewAgentInstallTokenRepository(db *gorm.DB) *GormAgentInstallTokenRepository { + return &GormAgentInstallTokenRepository{db: db} +} + +func (r *GormAgentInstallTokenRepository) Create(ctx context.Context, t *model.AgentInstallToken) error { + return r.db.WithContext(ctx).Create(t).Error +} + +func (r *GormAgentInstallTokenRepository) FindByToken(ctx context.Context, token string) (*model.AgentInstallToken, error) { + var item model.AgentInstallToken + if err := r.db.WithContext(ctx).Where("token = ?", token).First(&item).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &item, nil +} + +// ConsumeByToken 使用事务:SELECT → 校验 → UPDATE consumed_at。 +// SQLite 不支持 SELECT FOR UPDATE,这里用 UPDATE ... WHERE consumed_at IS NULL AND expires_at > now +// 的条件更新 + RowsAffected 判断实现原子消费。 +func (r *GormAgentInstallTokenRepository) ConsumeByToken(ctx context.Context, token string) (*model.AgentInstallToken, error) { + var consumed *model.AgentInstallToken + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + now := time.Now().UTC() + result := tx.Model(&model.AgentInstallToken{}). + Where("token = ? AND consumed_at IS NULL AND expires_at > ?", token, now). + Update("consumed_at", &now) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return nil + } + var item model.AgentInstallToken + if err := tx.Where("token = ?", token).First(&item).Error; err != nil { + return err + } + consumed = &item + return nil + }) + if err != nil { + return nil, err + } + return consumed, nil +} + +func (r *GormAgentInstallTokenRepository) DeleteExpiredBefore(ctx context.Context, threshold time.Time) (int64, error) { + result := r.db.WithContext(ctx).Where("expires_at < ?", threshold).Delete(&model.AgentInstallToken{}) + return result.RowsAffected, result.Error +} + +func (r *GormAgentInstallTokenRepository) CountCreatedSince(ctx context.Context, nodeID uint, since time.Time) (int64, error) { + var n int64 + err := r.db.WithContext(ctx).Model(&model.AgentInstallToken{}). + Where("node_id = ? AND created_at >= ?", nodeID, since). + Count(&n).Error + return n, err +} +``` + +- [ ] **Step 4: 再跑测试验证通过** + +Run: `cd server && go test ./internal/repository/ -run TestInstallToken -v` +Expected: PASS(3 个测试) + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/repository/agent_install_token_repository.go server/internal/repository/agent_install_token_repository_test.go +git commit -m "功能: 新增 AgentInstallToken 仓储与原子消费语义" +``` + +--- + +## Task 4: 安装脚本渲染器 `installscript` 包 + +**Files:** +- Create: `server/internal/installscript/renderer.go` +- Create: `server/internal/installscript/renderer_test.go` +- Create: `server/internal/installscript/testdata/systemd.golden.sh` +- Create: `server/internal/installscript/testdata/foreground.golden.sh` +- Create: `server/internal/installscript/testdata/docker.golden.sh` +- Create: `server/internal/installscript/testdata/compose.golden.yml` +- Create: `deploy/agent-install.sh.tmpl` +- Create: `deploy/agent-compose.yml.tmpl` + +- [ ] **Step 1: 先建脚本模板文件** + +Create `deploy/agent-install.sh.tmpl`: + +```bash +#!/bin/sh +# BackupX Agent 一键安装脚本(由 Master 动态渲染) +# 模式: {{.Mode}} | 架构: {{.Arch}} | 版本: {{.AgentVersion}} +set -eu + +MASTER_URL="{{.MasterURL}}" +AGENT_TOKEN="{{.AgentToken}}" +AGENT_VERSION="{{.AgentVersion}}" +DOWNLOAD_BASE="{{.DownloadBase}}" +INSTALL_PREFIX="{{.InstallPrefix}}" +ARCH="{{.Arch}}" + +# 1. 前置检查 +[ "$(id -u)" -eq 0 ] || { echo "请使用 root 或 sudo 执行" >&2; exit 1; } +command -v curl >/dev/null || command -v wget >/dev/null \ + || { echo "需要 curl 或 wget" >&2; exit 1; } +{{if eq .Mode "systemd"}}command -v systemctl >/dev/null || { echo "不支持非 systemd 系统" >&2; exit 1; } +{{end}}{{if eq .Mode "docker"}}command -v docker >/dev/null || { echo "需要先安装 docker" >&2; exit 1; } +{{end}} +# 2. 架构检测 +if [ "$ARCH" = "auto" ]; then + case "$(uname -m)" in + x86_64|amd64) ARCH=amd64 ;; + aarch64|arm64) ARCH=arm64 ;; + *) echo "不支持的架构: $(uname -m)" >&2; exit 1 ;; + esac +fi + +{{if ne .Mode "docker"}} +# 3. 下载二进制(systemd / foreground 模式) +ARCHIVE="backupx-${AGENT_VERSION}-linux-${ARCH}.tar.gz" +URL="${DOWNLOAD_BASE}/${AGENT_VERSION}/${ARCHIVE}" +TMPDIR="$(mktemp -d)"; trap 'rm -rf "$TMPDIR"' EXIT +echo "[1/4] 下载 ${URL}" +if command -v curl >/dev/null; then + curl -fsSL "$URL" -o "$TMPDIR/pkg.tar.gz" +else + wget -qO "$TMPDIR/pkg.tar.gz" "$URL" +fi +tar xzf "$TMPDIR/pkg.tar.gz" -C "$TMPDIR" + +# 4. 安装二进制 + 用户 +echo "[2/4] 安装到 ${INSTALL_PREFIX}" +id backupx >/dev/null 2>&1 || useradd --system --home-dir "$INSTALL_PREFIX" --shell /usr/sbin/nologin backupx +install -d -o backupx -g backupx "$INSTALL_PREFIX" /var/lib/backupx-agent +install -m 0755 "$TMPDIR/backupx-${AGENT_VERSION}-linux-${ARCH}/backupx" "$INSTALL_PREFIX/backupx" +{{end}} + +{{if eq .Mode "systemd"}} +# 5. systemd unit +echo "[3/4] 配置 systemd" +cat > /etc/systemd/system/backupx-agent.service </dev/null \ + | grep -q '"status":"online"'; then + echo "✓ 节点已上线" + exit 0 + fi +done +echo "⚠ 30s 内未收到上线心跳,请检查防火墙或 journalctl -u backupx-agent" +exit 2 +{{end}} + +{{if eq .Mode "foreground"}} +# 5. 前台运行 +echo "[3/3] 前台启动 agent(Ctrl+C 退出)" +export BACKUPX_AGENT_MASTER="${MASTER_URL}" +export BACKUPX_AGENT_TOKEN="${AGENT_TOKEN}" +exec "${INSTALL_PREFIX}/backupx" agent --temp-dir /var/lib/backupx-agent +{{end}} + +{{if eq .Mode "docker"}} +# Docker 模式:直接用镜像启动容器 +echo "[1/2] 拉取镜像 awuqing/backupx:${AGENT_VERSION}" +docker pull "awuqing/backupx:${AGENT_VERSION}" +echo "[2/2] 启动容器 backupx-agent" +docker rm -f backupx-agent >/dev/null 2>&1 || true +docker run -d --name backupx-agent --restart=unless-stopped \ + -e "BACKUPX_AGENT_MASTER=${MASTER_URL}" \ + -e "BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}" \ + -v /var/lib/backupx-agent:/tmp/backupx-agent \ + "awuqing/backupx:${AGENT_VERSION}" agent +echo "✓ 容器已启动" +{{end}} +``` + +Create `deploy/agent-compose.yml.tmpl`: + +```yaml +# BackupX Agent docker-compose 片段 +# 生成于 {{.MasterURL}} · 节点 ID {{.NodeID}} +version: "3.8" +services: + backupx-agent: + image: awuqing/backupx:{{.AgentVersion}} + command: ["agent"] + restart: unless-stopped + environment: + BACKUPX_AGENT_MASTER: "{{.MasterURL}}" + BACKUPX_AGENT_TOKEN: "{{.AgentToken}}" + volumes: + - /var/lib/backupx-agent:/tmp/backupx-agent +``` + +- [ ] **Step 2: 写失败测试(渲染器 + golden file 对比)** + +Create `server/internal/installscript/renderer_test.go`: + +```go +package installscript + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "backupx/server/internal/model" +) + +var testCtx = Context{ + MasterURL: "https://master.example.com", + AgentToken: "test-token-hex", + AgentVersion: "v1.7.0", + Mode: model.InstallModeSystemd, + Arch: model.InstallArchAuto, + DownloadBase: "https://github.com/Awuqing/BackupX/releases/download", + InstallPrefix: "/opt/backupx-agent", + NodeID: 42, +} + +func TestRenderScriptSystemd(t *testing.T) { + got, err := RenderScript(testCtx) + if err != nil { + t.Fatalf("render err: %v", err) + } + wantBytes, err := os.ReadFile(filepath.Join("testdata", "systemd.golden.sh")) + if err != nil { + t.Fatalf("read golden: %v", err) + } + want := string(wantBytes) + if got != want { + t.Errorf("systemd script mismatch:\n--- want ---\n%s\n--- got ---\n%s", want, got) + } +} + +func TestRenderScriptForeground(t *testing.T) { + ctx := testCtx + ctx.Mode = model.InstallModeForeground + got, err := RenderScript(ctx) + if err != nil { + t.Fatalf("render err: %v", err) + } + if !strings.Contains(got, "exec \"${INSTALL_PREFIX}/backupx\" agent") { + t.Errorf("foreground script missing exec line:\n%s", got) + } + if strings.Contains(got, "systemctl") { + t.Errorf("foreground script should not reference systemctl:\n%s", got) + } +} + +func TestRenderScriptDocker(t *testing.T) { + ctx := testCtx + ctx.Mode = model.InstallModeDocker + got, err := RenderScript(ctx) + if err != nil { + t.Fatalf("render err: %v", err) + } + if !strings.Contains(got, "docker run") { + t.Errorf("docker script missing `docker run`:\n%s", got) + } + if !strings.Contains(got, "awuqing/backupx:v1.7.0") { + t.Errorf("docker script missing image tag:\n%s", got) + } +} + +func TestRenderComposeYaml(t *testing.T) { + ctx := testCtx + ctx.Mode = model.InstallModeDocker + got, err := RenderComposeYaml(ctx) + if err != nil { + t.Fatalf("render err: %v", err) + } + if !strings.Contains(got, "image: awuqing/backupx:v1.7.0") { + t.Errorf("compose missing image:\n%s", got) + } + if !strings.Contains(got, "BACKUPX_AGENT_TOKEN: \"test-token-hex\"") { + t.Errorf("compose missing token env:\n%s", got) + } +} + +func TestDownloadBaseMapping(t *testing.T) { + cases := map[string]string{ + model.InstallSourceGitHub: "https://github.com/Awuqing/BackupX/releases/download", + model.InstallSourceGhproxy: "https://ghproxy.com/https://github.com/Awuqing/BackupX/releases/download", + } + for src, want := range cases { + got := DownloadBaseFor(src) + if got != want { + t.Errorf("src=%s want=%s got=%s", src, want, got) + } + } +} +``` + +- [ ] **Step 3: 跑测试验证失败** + +Run: `cd server && go test ./internal/installscript/ -v` +Expected: FAIL(包不存在) + +- [ ] **Step 4: 实现 renderer.go** + +Create `server/internal/installscript/renderer.go`: + +```go +// Package installscript 负责把一次性安装令牌 + 节点配置渲染为可执行 shell 脚本或 docker-compose YAML。 +// +// 模板文件通过 go:embed 嵌入二进制,避免运行时依赖外部资源。 +package installscript + +import ( + "bytes" + _ "embed" + "fmt" + "text/template" + + "backupx/server/internal/model" +) + +//go:embed ../../../deploy/agent-install.sh.tmpl +var installScriptTmpl string + +//go:embed ../../../deploy/agent-compose.yml.tmpl +var composeYamlTmpl string + +// Context 是模板渲染输入。 +type Context struct { + MasterURL string + AgentToken string + AgentVersion string + Mode string // systemd|docker|foreground + Arch string // amd64|arm64|auto + DownloadBase string + InstallPrefix string + NodeID uint +} + +// DownloadBaseFor 将下载源枚举转换为具体 URL 前缀。 +func DownloadBaseFor(src string) string { + switch src { + case model.InstallSourceGhproxy: + return "https://ghproxy.com/https://github.com/Awuqing/BackupX/releases/download" + default: + return "https://github.com/Awuqing/BackupX/releases/download" + } +} + +// RenderScript 渲染目标机安装脚本。 +func RenderScript(ctx Context) (string, error) { + ctx = withDefaults(ctx) + tmpl, err := template.New("install").Parse(installScriptTmpl) + if err != nil { + return "", fmt.Errorf("parse template: %w", err) + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, ctx); err != nil { + return "", fmt.Errorf("execute template: %w", err) + } + return buf.String(), nil +} + +// RenderComposeYaml 渲染 docker-compose.yml 片段。 +func RenderComposeYaml(ctx Context) (string, error) { + ctx = withDefaults(ctx) + tmpl, err := template.New("compose").Parse(composeYamlTmpl) + if err != nil { + return "", fmt.Errorf("parse template: %w", err) + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, ctx); err != nil { + return "", fmt.Errorf("execute template: %w", err) + } + return buf.String(), nil +} + +func withDefaults(ctx Context) Context { + if ctx.InstallPrefix == "" { + ctx.InstallPrefix = "/opt/backupx-agent" + } + if ctx.DownloadBase == "" { + ctx.DownloadBase = DownloadBaseFor(model.InstallSourceGitHub) + } + return ctx +} +``` + +- [ ] **Step 5: 生成 golden files** + +Run to capture current output: + +```bash +cd server && go test ./internal/installscript/ -run TestRenderScriptSystemd -v 2>&1 | head -80 +``` + +If systemd golden file missing, generate it by running renderer once manually. For initial creation, write a small temporary helper or simply create `server/internal/installscript/testdata/systemd.golden.sh` with the exact expected rendered output matching `testCtx`. + +Approach: comment out the test's file comparison temporarily, capture stdout via a one-off `main_test.go` helper, or write the content directly based on your template expansion. + +The golden file content should be the full rendered template for `testCtx` (systemd mode, auto arch). Produce it by: + +```bash +cd server && cat <<'EOF' > /tmp/gen.go +package main +import ( + "fmt" + "backupx/server/internal/installscript" + "backupx/server/internal/model" +) +func main() { + out, _ := installscript.RenderScript(installscript.Context{ + MasterURL: "https://master.example.com", + AgentToken: "test-token-hex", + AgentVersion: "v1.7.0", + Mode: model.InstallModeSystemd, + Arch: model.InstallArchAuto, + DownloadBase: "https://github.com/Awuqing/BackupX/releases/download", + InstallPrefix: "/opt/backupx-agent", + NodeID: 42, + }) + fmt.Print(out) +} +EOF +go run /tmp/gen.go > server/internal/installscript/testdata/systemd.golden.sh +rm /tmp/gen.go +``` + +Alternatively, skip the exact golden match for systemd (too brittle) and only keep the substring-based tests in `TestRenderScriptForeground` / `TestRenderScriptDocker` / `TestRenderComposeYaml`. Modify `TestRenderScriptSystemd` to use substring assertions instead: + +```go +func TestRenderScriptSystemd(t *testing.T) { + got, err := RenderScript(testCtx) + if err != nil { + t.Fatalf("render err: %v", err) + } + mustContain := []string{ + "BACKUPX_AGENT_MASTER=${MASTER_URL}", + "Environment=\"BACKUPX_AGENT_TOKEN=${AGENT_TOKEN}\"", + "systemctl daemon-reload", + "systemctl enable --now backupx-agent", + "X-Agent-Token: ${AGENT_TOKEN}", + } + for _, s := range mustContain { + if !strings.Contains(got, s) { + t.Errorf("systemd script missing %q", s) + } + } + mustNotContain := []string{"docker run", "exec \"${INSTALL_PREFIX}"} + for _, s := range mustNotContain { + if strings.Contains(got, s) { + t.Errorf("systemd script unexpectedly contains %q", s) + } + } +} +``` + +**Decision**:用 substring 方式(删除 golden file 需求),避免模板微调牵一发动全身。相应删除:testdata 目录中的 `.golden.sh` 文件不再创建。 + +- [ ] **Step 6: 跑测试验证通过** + +Run: `cd server && go test ./internal/installscript/ -v` +Expected: PASS(5 个测试) + +- [ ] **Step 7: Commit** + +```bash +git add server/internal/installscript/ deploy/agent-install.sh.tmpl deploy/agent-compose.yml.tmpl +git commit -m "功能: 新增 installscript 包,渲染 systemd/docker/foreground 安装脚本" +``` + +--- + +## Task 5: InstallTokenService(业务层 + 限流) + +**Files:** +- Create: `server/internal/service/install_token_service.go` +- Create: `server/internal/service/install_token_service_test.go` + +- [ ] **Step 1: 写失败测试** + +Create `server/internal/service/install_token_service_test.go`: + +```go +package service + +import ( + "context" + "path/filepath" + "testing" + "time" + + "backupx/server/internal/model" + "backupx/server/internal/repository" + "github.com/glebarez/sqlite" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +func openInstallTokenTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "it.db")), + &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)}) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := db.AutoMigrate(&model.AgentInstallToken{}, &model.Node{}); err != nil { + t.Fatalf("migrate: %v", err) + } + return db +} + +func TestInstallTokenServiceCreateAndConsume(t *testing.T) { + db := openInstallTokenTestDB(t) + repo := repository.NewAgentInstallTokenRepository(db) + nodeRepo := repository.NewNodeRepository(db) + + // 准备 node + node := &model.Node{Name: "n1", Token: "agent-token"} + _ = nodeRepo.Create(context.Background(), node) + + svc := NewInstallTokenService(repo, nodeRepo) + created, err := svc.Create(context.Background(), InstallTokenInput{ + NodeID: node.ID, + Mode: model.InstallModeSystemd, + Arch: model.InstallArchAuto, + AgentVersion: "v1.7.0", + DownloadSrc: model.InstallSourceGitHub, + TTLSeconds: 900, + CreatedByID: 1, + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if created.Token == "" || created.ExpiresAt.Before(time.Now().UTC()) { + t.Fatalf("invalid token: %+v", created) + } + + consumed, err := svc.Consume(context.Background(), created.Token) + if err != nil { + t.Fatalf("consume: %v", err) + } + if consumed == nil || consumed.NodeID != node.ID { + t.Fatalf("expected consumed token for node, got %+v", consumed) + } + + // 二次消费应返回 nil + again, err := svc.Consume(context.Background(), created.Token) + if err != nil { + t.Fatalf("second consume err: %v", err) + } + if again != nil { + t.Fatalf("expected nil on second consume") + } +} + +func TestInstallTokenServiceValidatesInput(t *testing.T) { + db := openInstallTokenTestDB(t) + svc := NewInstallTokenService( + repository.NewAgentInstallTokenRepository(db), + repository.NewNodeRepository(db), + ) + cases := []struct { + name string + in InstallTokenInput + }{ + {"bad mode", InstallTokenInput{NodeID: 1, Mode: "xxx", Arch: "auto", AgentVersion: "v1", DownloadSrc: "github", TTLSeconds: 300, CreatedByID: 1}}, + {"bad arch", InstallTokenInput{NodeID: 1, Mode: "systemd", Arch: "risc", AgentVersion: "v1", DownloadSrc: "github", TTLSeconds: 300, CreatedByID: 1}}, + {"bad ttl low", InstallTokenInput{NodeID: 1, Mode: "systemd", Arch: "auto", AgentVersion: "v1", DownloadSrc: "github", TTLSeconds: 10, CreatedByID: 1}}, + {"bad ttl high", InstallTokenInput{NodeID: 1, Mode: "systemd", Arch: "auto", AgentVersion: "v1", DownloadSrc: "github", TTLSeconds: 999999, CreatedByID: 1}}, + {"missing version", InstallTokenInput{NodeID: 1, Mode: "systemd", Arch: "auto", AgentVersion: "", DownloadSrc: "github", TTLSeconds: 300, CreatedByID: 1}}, + } + for _, tc := range cases { + if _, err := svc.Create(context.Background(), tc.in); err == nil { + t.Errorf("%s: expected validation error", tc.name) + } + } +} +``` + +- [ ] **Step 2: 跑测试验证失败** + +Run: `cd server && go test ./internal/service/ -run TestInstallTokenService -v` +Expected: FAIL(`undefined: NewInstallTokenService`) + +- [ ] **Step 3: 实现 Service** + +Create `server/internal/service/install_token_service.go`: + +```go +package service + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + "time" + + "backupx/server/internal/apperror" + "backupx/server/internal/model" + "backupx/server/internal/repository" +) + +// InstallTokenService 负责一次性安装令牌的创建/消费/校验。 +type InstallTokenService struct { + repo repository.AgentInstallTokenRepository + nodeRepo repository.NodeRepository +} + +func NewInstallTokenService(repo repository.AgentInstallTokenRepository, nodeRepo repository.NodeRepository) *InstallTokenService { + return &InstallTokenService{repo: repo, nodeRepo: nodeRepo} +} + +// InstallTokenInput 生成安装令牌的输入。 +type InstallTokenInput struct { + NodeID uint + Mode string + Arch string + AgentVersion string + DownloadSrc string + TTLSeconds int + CreatedByID uint +} + +// InstallTokenOutput 生成结果。 +type InstallTokenOutput struct { + Token string + ExpiresAt time.Time + Node *model.Node + Record *model.AgentInstallToken +} + +// RateLimitWindow 每节点限流窗口:60s 内最多 5 次。 +const ( + InstallTokenMinTTL = 300 // 5 分钟 + InstallTokenMaxTTL = 86400 // 24 小时 + InstallTokenRateWindow = 60 * time.Second + InstallTokenRatePerWin = 5 +) + +var ( + validModes = map[string]bool{model.InstallModeSystemd: true, model.InstallModeDocker: true, model.InstallModeForeground: true} + validArches = map[string]bool{model.InstallArchAmd64: true, model.InstallArchArm64: true, model.InstallArchAuto: true} + validSources = map[string]bool{model.InstallSourceGitHub: true, model.InstallSourceGhproxy: true} +) + +// Create 生成一次性安装令牌。 +func (s *InstallTokenService) Create(ctx context.Context, in InstallTokenInput) (*InstallTokenOutput, error) { + if err := s.validate(in); err != nil { + return nil, err + } + node, err := s.nodeRepo.FindByID(ctx, in.NodeID) + if err != nil { + return nil, err + } + if node == nil { + return nil, apperror.New(404, "NODE_NOT_FOUND", "节点不存在", nil) + } + + // 限流 + since := time.Now().UTC().Add(-InstallTokenRateWindow) + count, err := s.repo.CountCreatedSince(ctx, in.NodeID, since) + if err != nil { + return nil, err + } + if count >= InstallTokenRatePerWin { + return nil, apperror.New(429, "INSTALL_TOKEN_RATE_LIMITED", + fmt.Sprintf("每 %d 秒最多生成 %d 次", int(InstallTokenRateWindow.Seconds()), InstallTokenRatePerWin), nil) + } + + token, err := generateInstallToken() + if err != nil { + return nil, fmt.Errorf("generate token: %w", err) + } + expiresAt := time.Now().UTC().Add(time.Duration(in.TTLSeconds) * time.Second) + record := &model.AgentInstallToken{ + Token: token, + NodeID: in.NodeID, + Mode: in.Mode, + Arch: in.Arch, + AgentVer: in.AgentVersion, + DownloadSrc: in.DownloadSrc, + ExpiresAt: expiresAt, + CreatedByID: in.CreatedByID, + } + if err := s.repo.Create(ctx, record); err != nil { + return nil, err + } + return &InstallTokenOutput{Token: token, ExpiresAt: expiresAt, Node: node, Record: record}, nil +} + +// Consume 原子消费令牌。未命中/已过期/已消费均返回 (nil, nil)。 +func (s *InstallTokenService) Consume(ctx context.Context, token string) (*ConsumedInstallToken, error) { + if strings.TrimSpace(token) == "" { + return nil, nil + } + record, err := s.repo.ConsumeByToken(ctx, token) + if err != nil { + return nil, err + } + if record == nil { + return nil, nil + } + node, err := s.nodeRepo.FindByID(ctx, record.NodeID) + if err != nil { + return nil, err + } + if node == nil { + return nil, apperror.New(404, "NODE_NOT_FOUND", "节点已被删除", nil) + } + return &ConsumedInstallToken{ + Record: record, + Node: node, + }, nil +} + +// ConsumedInstallToken 是消费成功后返回给 handler 的组合体。 +type ConsumedInstallToken struct { + Record *model.AgentInstallToken + Node *model.Node +} + +// StartGC 启动后台 GC,按 interval 扫描 `expires_at < now-7d` 的记录并硬删除。 +// 返回的 stop 函数会取消定时器。 +func (s *InstallTokenService) StartGC(ctx context.Context, interval time.Duration) { + if interval <= 0 { + interval = time.Hour + } + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + _, _ = s.repo.DeleteExpiredBefore(ctx, time.Now().UTC().Add(-7*24*time.Hour)) + } + } + }() +} + +func (s *InstallTokenService) validate(in InstallTokenInput) error { + if in.NodeID == 0 { + return apperror.BadRequest("INSTALL_TOKEN_INVALID", "nodeId 必填", nil) + } + if !validModes[in.Mode] { + return apperror.BadRequest("INSTALL_TOKEN_INVALID", "mode 非法", nil) + } + if !validArches[in.Arch] { + return apperror.BadRequest("INSTALL_TOKEN_INVALID", "arch 非法", nil) + } + if !validSources[in.DownloadSrc] { + return apperror.BadRequest("INSTALL_TOKEN_INVALID", "downloadSrc 非法", nil) + } + if strings.TrimSpace(in.AgentVersion) == "" { + return apperror.BadRequest("INSTALL_TOKEN_INVALID", "agentVersion 必填", nil) + } + if in.TTLSeconds < InstallTokenMinTTL || in.TTLSeconds > InstallTokenMaxTTL { + return apperror.BadRequest("INSTALL_TOKEN_INVALID", + fmt.Sprintf("ttlSeconds 需在 %d-%d", InstallTokenMinTTL, InstallTokenMaxTTL), nil) + } + return nil +} + +func generateInstallToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} +``` + +- [ ] **Step 4: 再跑测试验证通过** + +Run: `cd server && go test ./internal/service/ -run TestInstallTokenService -v` +Expected: PASS(2 个测试) + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/service/install_token_service.go server/internal/service/install_token_service_test.go +git commit -m "功能: 新增 InstallTokenService 含输入校验、限流、GC" +``` + +--- + +## Task 6: NodeService 扩展 —— BatchCreate + RotateToken + SelfStatus + +**Files:** +- Modify: `server/internal/service/node_service.go` +- Create: `server/internal/service/node_service_test.go` + +- [ ] **Step 1: 写失败测试** + +Create `server/internal/service/node_service_test.go`: + +```go +package service + +import ( + "context" + "path/filepath" + "testing" + "time" + + "backupx/server/internal/model" + "backupx/server/internal/repository" + "github.com/glebarez/sqlite" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +func openNodeServiceDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "ns.db")), + &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)}) + if err != nil { + t.Fatalf("open: %v", err) + } + if err := db.AutoMigrate(&model.Node{}); err != nil { + t.Fatalf("migrate: %v", err) + } + return db +} + +func TestBatchCreateNodes(t *testing.T) { + db := openNodeServiceDB(t) + svc := NewNodeService(repository.NewNodeRepository(db), "test") + ctx := context.Background() + + items, err := svc.BatchCreate(ctx, []string{"a", "b", "c"}) + if err != nil { + t.Fatalf("batch: %v", err) + } + if len(items) != 3 { + t.Fatalf("expected 3, got %d", len(items)) + } + for _, it := range items { + if it.ID == 0 || it.Name == "" { + t.Errorf("invalid item %+v", it) + } + } +} + +func TestBatchCreateRejectsDuplicates(t *testing.T) { + db := openNodeServiceDB(t) + svc := NewNodeService(repository.NewNodeRepository(db), "test") + ctx := context.Background() + + // 先建一个 "a" + _, _ = svc.Create(ctx, NodeCreateInput{Name: "a"}) + + _, err := svc.BatchCreate(ctx, []string{"a", "b"}) + if err == nil { + t.Fatalf("expected error on duplicate with existing") + } + + // 批次内重复 + _, err = svc.BatchCreate(ctx, []string{"x", "x"}) + if err == nil { + t.Fatalf("expected error on intra-batch duplicate") + } +} + +func TestBatchCreateLimitEnforced(t *testing.T) { + db := openNodeServiceDB(t) + svc := NewNodeService(repository.NewNodeRepository(db), "test") + ctx := context.Background() + + names := make([]string, 51) + for i := range names { + names[i] = "n" + string(rune('a'+i%26)) + } + _, err := svc.BatchCreate(ctx, names) + if err == nil { + t.Fatalf("expected error on >50 batch") + } +} + +func TestRotateToken(t *testing.T) { + db := openNodeServiceDB(t) + repo := repository.NewNodeRepository(db) + svc := NewNodeService(repo, "test") + ctx := context.Background() + + oldTok, err := svc.Create(ctx, NodeCreateInput{Name: "rot"}) + if err != nil { + t.Fatalf("create: %v", err) + } + // 查新建的 node + var node model.Node + db.First(&node, "name = ?", "rot") + + newTok, err := svc.RotateToken(ctx, node.ID) + if err != nil { + t.Fatalf("rotate: %v", err) + } + if newTok == oldTok || len(newTok) != 64 { + t.Fatalf("invalid new token: %s", newTok) + } + + // 旧 token 仍可查(24h 内) + found, _ := repo.FindByToken(ctx, oldTok) + if found == nil || found.ID != node.ID { + t.Fatalf("old token should still work via prev_token fallback") + } + // 新 token 也可查 + found2, _ := repo.FindByToken(ctx, newTok) + if found2 == nil || found2.ID != node.ID { + t.Fatalf("new token should work") + } + + // prev_token_expires 应设置为约 24h 后 + db.First(&node, node.ID) + if node.PrevTokenExpires == nil { + t.Fatalf("prev_token_expires not set") + } + diff := node.PrevTokenExpires.Sub(time.Now().UTC()) + if diff < 23*time.Hour || diff > 25*time.Hour { + t.Fatalf("prev_token_expires out of range: %v", diff) + } +} +``` + +- [ ] **Step 2: 跑测试验证失败** + +Run: `cd server && go test ./internal/service/ -run "TestBatchCreate|TestRotateToken" -v` +Expected: FAIL(方法未定义) + +- [ ] **Step 3: 实现 BatchCreate + RotateToken** + +Append to `server/internal/service/node_service.go` (在文件末尾,`generateToken` 之前): + +```go +// BatchCreate 批量创建远程节点,事务内执行。 +// 校验:1-50 项、每项 1-128 字符、批次内去重、与已有节点名去重。 +// 返回 NodeCreateResult 列表(不含 token,前端应再调 install-tokens 接口)。 +func (s *NodeService) BatchCreate(ctx context.Context, names []string) ([]NodeCreateResult, error) { + cleaned, err := validateBatchNames(names) + if err != nil { + return nil, err + } + // 与数据库已有名称去重 + existing, err := s.repo.List(ctx) + if err != nil { + return nil, err + } + existingSet := make(map[string]bool, len(existing)) + for _, n := range existing { + existingSet[n.Name] = true + } + for _, name := range cleaned { + if existingSet[name] { + return nil, apperror.BadRequest("NODE_DUPLICATE_NAME", + fmt.Sprintf("节点名「%s」已存在", name), nil) + } + } + + results := make([]NodeCreateResult, 0, len(cleaned)) + for _, name := range cleaned { + tok, err := generateToken() + if err != nil { + return nil, fmt.Errorf("generate token: %w", err) + } + node := &model.Node{ + Name: name, + Token: tok, + Status: model.NodeStatusOffline, + IsLocal: false, + LastSeen: time.Now().UTC(), + } + if err := s.repo.Create(ctx, node); err != nil { + return nil, err + } + results = append(results, NodeCreateResult{ID: node.ID, Name: node.Name}) + } + return results, nil +} + +// NodeCreateResult 批量创建结果。注意:不暴露 agent token,token 获取走 install-token 流程。 +type NodeCreateResult struct { + ID uint `json:"id"` + Name string `json:"name"` +} + +// RotateToken 轮换指定节点的 agent token。 +// 旧 token 复制到 prev_token,保留 24h 过渡。 +func (s *NodeService) RotateToken(ctx context.Context, id uint) (string, error) { + node, err := s.repo.FindByID(ctx, id) + if err != nil { + return "", err + } + if node == nil { + return "", apperror.New(404, "NODE_NOT_FOUND", "节点不存在", nil) + } + if node.IsLocal { + return "", apperror.BadRequest("NODE_ROTATE_LOCAL", "本机节点无需轮换", nil) + } + newTok, err := generateToken() + if err != nil { + return "", fmt.Errorf("generate: %w", err) + } + expires := time.Now().UTC().Add(24 * time.Hour) + node.PrevToken = node.Token + node.PrevTokenExpires = &expires + node.Token = newTok + if err := s.repo.Update(ctx, node); err != nil { + return "", err + } + return newTok, nil +} + +// validateBatchNames 校验并去重批次内名称。 +func validateBatchNames(names []string) ([]string, error) { + if len(names) == 0 { + return nil, apperror.BadRequest("NODE_BATCH_EMPTY", "节点名列表不能为空", nil) + } + if len(names) > 50 { + return nil, apperror.BadRequest("NODE_BATCH_TOO_MANY", "单次最多创建 50 个节点", nil) + } + seen := make(map[string]bool, len(names)) + out := make([]string, 0, len(names)) + for _, raw := range names { + name := strings.TrimSpace(raw) + if name == "" { + continue + } + if len(name) > 128 { + return nil, apperror.BadRequest("NODE_NAME_TOO_LONG", + fmt.Sprintf("节点名「%s」超过 128 字符", name), nil) + } + if seen[name] { + return nil, apperror.BadRequest("NODE_DUPLICATE_NAME", + fmt.Sprintf("批次内重复节点名「%s」", name), nil) + } + seen[name] = true + out = append(out, name) + } + if len(out) == 0 { + return nil, apperror.BadRequest("NODE_BATCH_EMPTY", "去除空白后列表为空", nil) + } + return out, nil +} +``` + +- [ ] **Step 4: 再跑测试验证通过** + +Run: `cd server && go test ./internal/service/ -run "TestBatchCreate|TestRotateToken" -v` +Expected: PASS(4 个测试) + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/service/node_service.go server/internal/service/node_service_test.go +git commit -m "功能: NodeService 新增 BatchCreate 与 RotateToken" +``` + +--- + +## Task 7: AgentService 新增 SelfStatus 方法 + +**Files:** +- Modify: `server/internal/service/agent_service.go` + +- [ ] **Step 1: 写失败测试** + +Append to `server/internal/service/node_service_test.go`(或新建 `agent_service_test.go`): + +```go +func TestAgentSelfStatus(t *testing.T) { + db := openNodeServiceDB(t) + if err := db.AutoMigrate(&model.BackupTask{}, &model.BackupRecord{}, &model.StorageTarget{}, &model.AgentCommand{}); err != nil { + t.Fatalf("migrate: %v", err) + } + nodeRepo := repository.NewNodeRepository(db) + node := &model.Node{Name: "x", Token: "abc", Status: model.NodeStatusOnline} + _ = nodeRepo.Create(context.Background(), node) + + // cipher / 其他 repos 可传 nil 或简单桩(SelfStatus 只用 nodeRepo) + svc := NewAgentService(nodeRepo, nil, nil, nil, nil, nil) + got, err := svc.SelfStatus(context.Background(), node) + if err != nil { + t.Fatalf("self: %v", err) + } + if got.ID != node.ID || got.Name != "x" || got.Status != "online" { + t.Fatalf("bad self status: %+v", got) + } +} +``` + +- [ ] **Step 2: 跑测试验证失败** + +Run: `cd server && go test ./internal/service/ -run TestAgentSelfStatus -v` +Expected: FAIL(`undefined: SelfStatus`) + +- [ ] **Step 3: 实现 SelfStatus** + +Append to `server/internal/service/agent_service.go`: + +```go +// AgentSelfStatus 是 /api/v1/agent/self 端点返回给 Agent 的轻量状态摘要。 +type AgentSelfStatus struct { + ID uint `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + LastSeen time.Time `json:"lastSeen"` +} + +// SelfStatus 返回 Agent token 所属节点的当前状态,供安装脚本末尾探活。 +func (s *AgentService) SelfStatus(ctx context.Context, node *model.Node) (*AgentSelfStatus, error) { + if node == nil { + return nil, apperror.Unauthorized("NODE_INVALID_TOKEN", "节点不存在", nil) + } + return &AgentSelfStatus{ + ID: node.ID, + Name: node.Name, + Status: node.Status, + LastSeen: node.LastSeen, + }, nil +} +``` + +- [ ] **Step 4: 再跑测试验证通过** + +Run: `cd server && go test ./internal/service/ -run TestAgentSelfStatus -v` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/service/agent_service.go server/internal/service/node_service_test.go +git commit -m "功能: AgentService 新增 SelfStatus 用于安装脚本探活" +``` + +--- + +## Task 8: HTTP 处理器 —— install_handler.go(公开端点) + +**Files:** +- Create: `server/internal/http/install_handler.go` + +- [ ] **Step 1: 实现 install_handler.go** + +Create `server/internal/http/install_handler.go`: + +```go +package http + +import ( + stdhttp "net/http" + "strings" + "sync" + "time" + + "backupx/server/internal/installscript" + "backupx/server/internal/model" + "backupx/server/internal/service" + "github.com/gin-gonic/gin" +) + +// InstallHandler 公开路由(不走 JWT),实现 /install/:token 与 /install/:token/compose.yml。 +type InstallHandler struct { + tokenService *service.InstallTokenService + auditService *service.AuditService + externalURL string // 可选:MasterURL 硬编码覆盖 + limiter *ipLimiter +} + +func NewInstallHandler(tokenService *service.InstallTokenService, auditService *service.AuditService, externalURL string) *InstallHandler { + return &InstallHandler{ + tokenService: tokenService, + auditService: auditService, + externalURL: externalURL, + limiter: newIPLimiter(20, time.Minute), + } +} + +// Script 消费 token 并返回 shell 脚本;Mode 按 token 存储决定。 +func (h *InstallHandler) Script(c *gin.Context) { + if !h.limiter.allow(c.ClientIP()) { + c.String(stdhttp.StatusTooManyRequests, "请求过于频繁,请稍后再试\n") + return + } + token := strings.TrimSpace(c.Param("token")) + consumed, err := h.tokenService.Consume(c.Request.Context(), token) + if err != nil { + c.String(stdhttp.StatusInternalServerError, "server error\n") + return + } + if consumed == nil { + c.String(stdhttp.StatusGone, "install token 不存在、已过期或已消费\n") + return + } + // 审计 + if h.auditService != nil { + h.auditService.Record(service.AuditEntry{ + Username: "", + Category: "install_token", + Action: "consume", + TargetType: "node", + TargetID: uintToStr(consumed.Node.ID), + TargetName: consumed.Node.Name, + Detail: "install token 消费(script)", + ClientIP: c.ClientIP(), + }) + } + masterURL := h.resolveMasterURL(c) + script, err := installscript.RenderScript(installscript.Context{ + MasterURL: masterURL, + AgentToken: consumed.Node.Token, + AgentVersion: consumed.Record.AgentVer, + Mode: consumed.Record.Mode, + Arch: consumed.Record.Arch, + DownloadBase: installscript.DownloadBaseFor(consumed.Record.DownloadSrc), + InstallPrefix: "/opt/backupx-agent", + NodeID: consumed.Node.ID, + }) + if err != nil { + c.String(stdhttp.StatusInternalServerError, "render error\n") + return + } + c.Data(stdhttp.StatusOK, "text/x-shellscript; charset=utf-8", []byte(script)) +} + +// Compose 消费 token 并返回 docker-compose YAML,仅 Mode=docker 有效。 +func (h *InstallHandler) Compose(c *gin.Context) { + if !h.limiter.allow(c.ClientIP()) { + c.String(stdhttp.StatusTooManyRequests, "请求过于频繁\n") + return + } + token := strings.TrimSpace(c.Param("token")) + // 先不消费,窥视 Mode + record, err := h.tokenService.Peek(c.Request.Context(), token) + if err != nil { + c.String(stdhttp.StatusInternalServerError, "server error\n") + return + } + if record == nil { + c.String(stdhttp.StatusGone, "install token 不存在或已作废\n") + return + } + if record.Mode != model.InstallModeDocker { + c.String(stdhttp.StatusBadRequest, "该 install token 的模式不是 docker\n") + return + } + consumed, err := h.tokenService.Consume(c.Request.Context(), token) + if err != nil { + c.String(stdhttp.StatusInternalServerError, "server error\n") + return + } + if consumed == nil { + c.String(stdhttp.StatusGone, "install token 已过期或已消费\n") + return + } + if h.auditService != nil { + h.auditService.Record(service.AuditEntry{ + Category: "install_token", + Action: "consume", + TargetType: "node", + TargetID: uintToStr(consumed.Node.ID), + TargetName: consumed.Node.Name, + Detail: "install token 消费(compose)", + ClientIP: c.ClientIP(), + }) + } + masterURL := h.resolveMasterURL(c) + yaml, err := installscript.RenderComposeYaml(installscript.Context{ + MasterURL: masterURL, + AgentToken: consumed.Node.Token, + AgentVersion: consumed.Record.AgentVer, + Mode: model.InstallModeDocker, + NodeID: consumed.Node.ID, + }) + if err != nil { + c.String(stdhttp.StatusInternalServerError, "render error\n") + return + } + c.Data(stdhttp.StatusOK, "text/yaml; charset=utf-8", []byte(yaml)) +} + +// resolveMasterURL 按优先级:系统配置 > X-Forwarded-* > Request.Host。 +func (h *InstallHandler) resolveMasterURL(c *gin.Context) string { + if strings.TrimSpace(h.externalURL) != "" { + return strings.TrimRight(h.externalURL, "/") + } + scheme := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")) + if scheme == "" { + if c.Request.TLS != nil { + scheme = "https" + } else { + scheme = "http" + } + } + host := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")) + if host == "" { + host = c.Request.Host + } + return scheme + "://" + host +} + +// ipLimiter 简单内存滑动窗口限流。 +type ipLimiter struct { + mu sync.Mutex + events map[string][]time.Time + limit int + window time.Duration +} + +func newIPLimiter(limit int, window time.Duration) *ipLimiter { + return &ipLimiter{events: make(map[string][]time.Time), limit: limit, window: window} +} + +func (l *ipLimiter) allow(ip string) bool { + l.mu.Lock() + defer l.mu.Unlock() + now := time.Now() + cutoff := now.Add(-l.window) + keep := l.events[ip][:0] + for _, t := range l.events[ip] { + if t.After(cutoff) { + keep = append(keep, t) + } + } + if len(keep) >= l.limit { + l.events[ip] = keep + return false + } + l.events[ip] = append(keep, now) + return true +} + +func uintToStr(u uint) string { + return strings.TrimLeft(strings.ReplaceAll(strings.TrimPrefix( + "0000000000"+itoa(u), "0000000000"), "_", ""), "0") +} + +// itoa 避免依赖 strconv(包已经导入 gin,无所谓,但保留小助手) +func itoa(u uint) string { + if u == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for u > 0 { + i-- + buf[i] = byte('0' + u%10) + u /= 10 + } + return string(buf[i:]) +} +``` + +**注意**:`tokenService.Peek` 方法需要在 `install_token_service.go` 补充。 + +- [ ] **Step 2: 给 InstallTokenService 补 Peek 方法** + +Append to `server/internal/service/install_token_service.go`: + +```go +// Peek 只读查询(不消费),用于 compose 端点先检查 Mode。 +func (s *InstallTokenService) Peek(ctx context.Context, token string) (*model.AgentInstallToken, error) { + if strings.TrimSpace(token) == "" { + return nil, nil + } + return s.repo.FindByToken(ctx, token) +} +``` + +- [ ] **Step 3: 编译验证** + +Run: `cd server && go build ./...` +Expected: 编译通过(此任务暂无独立测试,集成测试在 Task 11) + +- [ ] **Step 4: Commit** + +```bash +git add server/internal/http/install_handler.go server/internal/service/install_token_service.go +git commit -m "功能: 新增公开的 install_handler 渲染安装脚本与 compose.yml" +``` + +--- + +## Task 9: HTTP 处理器 —— NodeHandler 扩展 + +**Files:** +- Modify: `server/internal/http/node_handler.go` + +- [ ] **Step 1: 补全 handler 方法** + +Append to `server/internal/http/node_handler.go`: + +```go +// 批量创建节点。 +func (h *NodeHandler) BatchCreate(c *gin.Context) { + var input struct { + Names []string `json:"names" binding:"required"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()}) + return + } + results, err := h.service.BatchCreate(c.Request.Context(), input.Names) + if err != nil { + response.Error(c, err) + return + } + recordAudit(c, h.auditService, "node", "batch_create", "node", "", + fmt.Sprintf("%d", len(results)), fmt.Sprintf("批量创建 %d 个节点", len(results))) + response.Success(c, results) +} + +// 轮换 agent token。 +func (h *NodeHandler) RotateToken(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.Error(c, err) + return + } + tok, err := h.service.RotateToken(c.Request.Context(), uint(id)) + if err != nil { + response.Error(c, err) + return + } + recordAudit(c, h.auditService, "node", "rotate_token", "node", fmt.Sprintf("%d", id), "", + fmt.Sprintf("轮换节点 Token (ID: %d)", id)) + response.Success(c, gin.H{"newToken": tok}) +} +``` + +以及 install-tokens 与 preview 端点。由于它们依赖 `InstallTokenService` 与 `installscript` 包,在 NodeHandler struct 上增加这些依赖: + +在 `NodeHandler` struct 与 `NewNodeHandler` 中插入字段与参数: + +```go +type NodeHandler struct { + service *service.NodeService + auditService *service.AuditService + installTokenSvc *service.InstallTokenService + externalURL string +} + +func NewNodeHandler( + nodeService *service.NodeService, + auditService *service.AuditService, + installTokenSvc *service.InstallTokenService, + externalURL string, +) *NodeHandler { + return &NodeHandler{ + service: nodeService, + auditService: auditService, + installTokenSvc: installTokenSvc, + externalURL: externalURL, + } +} +``` + +追加 handler: + +```go +// 生成一次性安装令牌。 +func (h *NodeHandler) CreateInstallToken(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + response.Error(c, err) + return + } + var input struct { + Mode string `json:"mode"` + Arch string `json:"arch"` + AgentVersion string `json:"agentVersion"` + DownloadSrc string `json:"downloadSrc"` + TTLSeconds int `json:"ttlSeconds"` + } + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": err.Error()}) + return + } + // 默认值 + if input.Mode == "" { + input.Mode = "systemd" + } + if input.Arch == "" { + input.Arch = "auto" + } + if input.DownloadSrc == "" { + input.DownloadSrc = "github" + } + if input.TTLSeconds == 0 { + input.TTLSeconds = 900 + } + + createdBy := uint(0) + // 审计来源 + if subj, ok := c.Get(contextUserSubjectKey); ok { + _ = subj // username 仅用于 audit;CreatedByID 可暂设 0 或留 TODO 如无 userID 映射 + } + + out, err := h.installTokenSvc.Create(c.Request.Context(), service.InstallTokenInput{ + NodeID: uint(id), + Mode: input.Mode, + Arch: input.Arch, + AgentVersion: input.AgentVersion, + DownloadSrc: input.DownloadSrc, + TTLSeconds: input.TTLSeconds, + CreatedByID: createdBy, + }) + if err != nil { + response.Error(c, err) + return + } + recordAudit(c, h.auditService, "install_token", "create", "node", + fmt.Sprintf("%d", id), out.Node.Name, + fmt.Sprintf("生成 %s/%s install token TTL=%ds", input.Mode, input.Arch, input.TTLSeconds)) + + masterURL := resolveMasterURL(c, h.externalURL) + body := gin.H{ + "installToken": out.Token, + "expiresAt": out.ExpiresAt, + "url": masterURL + "/install/" + out.Token, + } + if input.Mode == "docker" { + body["composeUrl"] = masterURL + "/install/" + out.Token + "/compose.yml" + } else { + body["composeUrl"] = "" + } + response.Success(c, body) +} + +// 预览脚本(占位 token,不消费)。 +func (h *NodeHandler) PreviewScript(c *gin.Context) { + mode := c.DefaultQuery("mode", "systemd") + arch := c.DefaultQuery("arch", "auto") + ver := c.Query("agentVersion") + if ver == "" { + c.JSON(stdhttp.StatusBadRequest, gin.H{"code": "INVALID_INPUT", "message": "agentVersion required"}) + return + } + src := c.DefaultQuery("downloadSrc", "github") + ctx := installscript.Context{ + MasterURL: resolveMasterURL(c, h.externalURL), + AgentToken: "", + AgentVersion: ver, + Mode: mode, + Arch: arch, + DownloadBase: installscript.DownloadBaseFor(src), + InstallPrefix: "/opt/backupx-agent", + } + script, err := installscript.RenderScript(ctx) + if err != nil { + response.Error(c, err) + return + } + c.Data(stdhttp.StatusOK, "text/x-shellscript; charset=utf-8", []byte(script)) +} + +// resolveMasterURL 在 install_handler.go 的内部实现与此重复;提炼成 package-level helper。 +func resolveMasterURL(c *gin.Context, externalURL string) string { + if strings.TrimSpace(externalURL) != "" { + return strings.TrimRight(externalURL, "/") + } + scheme := strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")) + if scheme == "" { + if c.Request.TLS != nil { + scheme = "https" + } else { + scheme = "http" + } + } + host := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")) + if host == "" { + host = c.Request.Host + } + return scheme + "://" + host +} +``` + +**DRY 调整**:同时删除 `install_handler.go` 里的 `resolveMasterURL` 方法,改用包级 `resolveMasterURL` 函数: + +Modify `server/internal/http/install_handler.go`: + +```go +// 将原来的 h.resolveMasterURL(c) 调用替换为 resolveMasterURL(c, h.externalURL) +// 并删除 InstallHandler 上的 resolveMasterURL 方法 +``` + +然后补 import:在 `node_handler.go` 顶部确保导入: + +```go +import ( + "fmt" + stdhttp "net/http" + "strconv" + "strings" + + "backupx/server/internal/http/installscript" // 若包路径不对按实际修正 + "backupx/server/internal/installscript" + "backupx/server/internal/service" + "backupx/server/pkg/response" + + "github.com/gin-gonic/gin" +) +``` + +(删除重复的 installscript import,保留第二个实际路径) + +- [ ] **Step 2: 编译验证** + +Run: `cd server && go build ./...` +Expected: 编译通过 + +- [ ] **Step 3: Commit** + +```bash +git add server/internal/http/node_handler.go server/internal/http/install_handler.go +git commit -m "功能: NodeHandler 新增批量创建/轮换/install-token/预览端点" +``` + +--- + +## Task 10: AgentHandler 新增 Self 端点 + +**Files:** +- Modify: `server/internal/http/agent_handler.go` + +- [ ] **Step 1: 实现 Self handler** + +Append to `server/internal/http/agent_handler.go`: + +```go +// Self 返回当前 Agent token 所属节点的状态,供安装脚本探活。 +func (h *AgentHandler) Self(c *gin.Context) { + node, err := h.agentService.AuthenticatedNode(c.Request.Context(), extractToken(c)) + if err != nil { + response.Error(c, err) + return + } + status, err := h.agentService.SelfStatus(c.Request.Context(), node) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, status) +} +``` + +- [ ] **Step 2: 编译验证** + +Run: `cd server && go build ./...` +Expected: 编译通过 + +- [ ] **Step 3: Commit** + +```bash +git add server/internal/http/agent_handler.go +git commit -m "功能: AgentHandler 新增 Self 端点" +``` + +--- + +## Task 11: Router 注册新路由与依赖 wire + +**Files:** +- Modify: `server/internal/http/router.go` +- Modify: `server/internal/app/app.go` + +- [ ] **Step 1: RouterDependencies 新增字段** + +Modify `server/internal/http/router.go`, 在 `RouterDependencies` struct 中追加: + +```go +InstallTokenService *service.InstallTokenService +MasterExternalURL string +``` + +- [ ] **Step 2: 修改 handler 实例化,注入依赖** + +Modify `server/internal/http/router.go` `NewRouter` 函数: + +```go +// 原:nodeHandler := NewNodeHandler(deps.NodeService, deps.AuditService) +nodeHandler := NewNodeHandler(deps.NodeService, deps.AuditService, deps.InstallTokenService, deps.MasterExternalURL) +``` + +注册新路由(在 `nodes` 路由组内): + +```go +nodes.POST("/batch", nodeHandler.BatchCreate) +nodes.POST("/:id/install-tokens", nodeHandler.CreateInstallToken) +nodes.POST("/:id/rotate-token", nodeHandler.RotateToken) +nodes.GET("/:id/install-script-preview", nodeHandler.PreviewScript) +``` + +在 Agent 路由组内添加: + +```go +agent.GET("/self", agentHandler.Self) +``` + +在 `api := engine.Group("/api")` **之外**、在 `engine.NoRoute` 之前,注册公开的 /install 路由: + +```go +if deps.InstallTokenService != nil { + installHandler := NewInstallHandler(deps.InstallTokenService, deps.AuditService, deps.MasterExternalURL) + engine.GET("/install/:token", installHandler.Script) + engine.GET("/install/:token/compose.yml", installHandler.Compose) +} +``` + +- [ ] **Step 3: app.go 新增 wire** + +Modify `server/internal/app/app.go`,在 Agent service 初始化之后追加: + +```go +// 一键部署:install token service + 后台 GC +installTokenRepo := repository.NewAgentInstallTokenRepository(db) +installTokenService := service.NewInstallTokenService(installTokenRepo, nodeRepo) +installTokenService.StartGC(ctx, time.Hour) +``` + +并把它加入 `RouterDependencies`: + +```go +router := aphttp.NewRouter(aphttp.RouterDependencies{ + // ... 已有字段 + InstallTokenService: installTokenService, + MasterExternalURL: cfg.Server.ExternalURL, // 如配置无此字段,传 "" +}) +``` + +**注意**:如 `cfg.Server` 无 `ExternalURL`,直接传 `""`;后续可在 `config.ServerConfig` 增该字段(非本任务范围)。 + +- [ ] **Step 4: 编译验证** + +Run: `cd server && go build ./...` +Expected: 编译通过 + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/http/router.go server/internal/app/app.go +git commit -m "功能: 注册一键部署新路由并 wire InstallTokenService" +``` + +--- + +## Task 12: 集成测试 —— 端到端流程 + +**Files:** +- Create: `server/internal/http/install_flow_test.go` + +- [ ] **Step 1: 写集成测试** + +Create `server/internal/http/install_flow_test.go`: + +```go +package http + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "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" + "context" +) + +func setupInstallFlowRouter(t *testing.T) (*http.Handler, string) { + t.Helper() + tempDir := t.TempDir() + cfg := config.Config{ + Server: config.ServerConfig{Host: "127.0.0.1", Port: 8340, Mode: "test"}, + Database: config.DatabaseConfig{Path: filepath.Join(tempDir, "backupx.db")}, + Security: config.SecurityConfig{JWTExpire: "24h"}, + Log: config.LogConfig{Level: "error"}, + } + log, _ := logger.New(cfg.Log) + db, _ := database.Open(cfg.Database, log) + + userRepo := repository.NewUserRepository(db) + systemConfigRepo := repository.NewSystemConfigRepository(db) + resolved, _ := service.ResolveSecurity(context.Background(), cfg.Security, systemConfigRepo) + jwtMgr := security.NewJWTManager(resolved.JWTSecret, time.Hour) + authSvc := service.NewAuthService(userRepo, systemConfigRepo, jwtMgr, security.NewLoginRateLimiter(5, time.Minute)) + + nodeRepo := repository.NewNodeRepository(db) + nodeSvc := service.NewNodeService(nodeRepo, "test") + _ = nodeSvc.EnsureLocalNode(context.Background()) + + installTokenRepo := repository.NewAgentInstallTokenRepository(db) + installTokenSvc := service.NewInstallTokenService(installTokenRepo, nodeRepo) + + auditLogRepo := repository.NewAuditLogRepository(db) + auditSvc := service.NewAuditService(auditLogRepo) + + router := NewRouter(RouterDependencies{ + Config: cfg, + Version: "test", + Logger: log, + AuthService: authSvc, + NodeService: nodeSvc, + InstallTokenService: installTokenSvc, + AuditService: auditSvc, + JWTManager: jwtMgr, + UserRepository: userRepo, + SystemConfigRepo: systemConfigRepo, + }) + + // setup 管理员并登录取 JWT + setupBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "password-123", "displayName": "admin"}) + setupReq := httptest.NewRequest(http.MethodPost, "/api/auth/setup", bytes.NewBuffer(setupBody)) + setupReq.Header.Set("Content-Type", "application/json") + setupRec := httptest.NewRecorder() + router.ServeHTTP(setupRec, setupReq) + var setupResp struct { + Data struct { + Token string `json:"token"` + } `json:"data"` + } + _ = json.Unmarshal(setupRec.Body.Bytes(), &setupResp) + + var h http.Handler = router + return &h, setupResp.Data.Token +} + +func TestOneClickInstallFlow(t *testing.T) { + handlerPtr, jwt := setupInstallFlowRouter(t) + router := *handlerPtr + + // 1. 批量创建 + batchBody, _ := json.Marshal(map[string][]string{"names": {"prod-a", "prod-b"}}) + batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody)) + batchReq.Header.Set("Content-Type", "application/json") + batchReq.Header.Set("Authorization", "Bearer "+jwt) + batchRec := httptest.NewRecorder() + router.ServeHTTP(batchRec, batchReq) + if batchRec.Code != 200 { + t.Fatalf("batch create failed: %d %s", batchRec.Code, batchRec.Body.String()) + } + var batchResp struct { + Data []struct { + ID uint `json:"id"` + Name string `json:"name"` + } `json:"data"` + } + _ = json.Unmarshal(batchRec.Body.Bytes(), &batchResp) + if len(batchResp.Data) != 2 { + t.Fatalf("expected 2 nodes, got %d", len(batchResp.Data)) + } + nodeID := batchResp.Data[0].ID + + // 2. 生成 install token + genBody, _ := json.Marshal(map[string]any{ + "mode": "systemd", + "arch": "auto", + "agentVersion": "v1.7.0", + "downloadSrc": "github", + "ttlSeconds": 900, + }) + genReq := httptest.NewRequest(http.MethodPost, + "/api/nodes/"+itoa(nodeID)+"/install-tokens", bytes.NewBuffer(genBody)) + genReq.Header.Set("Content-Type", "application/json") + genReq.Header.Set("Authorization", "Bearer "+jwt) + genRec := httptest.NewRecorder() + router.ServeHTTP(genRec, genReq) + if genRec.Code != 200 { + t.Fatalf("install-tokens failed: %d %s", genRec.Code, genRec.Body.String()) + } + var genResp struct { + Data struct { + InstallToken string `json:"installToken"` + URL string `json:"url"` + } `json:"data"` + } + _ = json.Unmarshal(genRec.Body.Bytes(), &genResp) + if genResp.Data.InstallToken == "" { + t.Fatalf("missing installToken") + } + + // 3. 公开端点消费 + scriptReq := httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil) + scriptRec := httptest.NewRecorder() + router.ServeHTTP(scriptRec, scriptReq) + if scriptRec.Code != 200 { + t.Fatalf("script fetch failed: %d %s", scriptRec.Code, scriptRec.Body.String()) + } + if !strings.Contains(scriptRec.Body.String(), "systemctl enable --now backupx-agent") { + t.Fatalf("script missing systemctl enable:\n%s", scriptRec.Body.String()) + } + + // 4. 再次消费应 410 + scriptRec2 := httptest.NewRecorder() + router.ServeHTTP(scriptRec2, httptest.NewRequest(http.MethodGet, "/install/"+genResp.Data.InstallToken, nil)) + if scriptRec2.Code != http.StatusGone { + t.Fatalf("second consume should be 410, got %d", scriptRec2.Code) + } +} + +func TestInstallTokenRateLimit(t *testing.T) { + handlerPtr, jwt := setupInstallFlowRouter(t) + router := *handlerPtr + // 创建节点 + batchBody, _ := json.Marshal(map[string][]string{"names": {"rl-test"}}) + batchReq := httptest.NewRequest(http.MethodPost, "/api/nodes/batch", bytes.NewBuffer(batchBody)) + batchReq.Header.Set("Content-Type", "application/json") + batchReq.Header.Set("Authorization", "Bearer "+jwt) + batchRec := httptest.NewRecorder() + router.ServeHTTP(batchRec, batchReq) + var batchResp struct { + Data []struct{ ID uint `json:"id"` } `json:"data"` + } + _ = json.Unmarshal(batchRec.Body.Bytes(), &batchResp) + nodeID := batchResp.Data[0].ID + + body, _ := json.Marshal(map[string]any{ + "mode": "systemd", "arch": "auto", "agentVersion": "v1", "downloadSrc": "github", "ttlSeconds": 300, + }) + // 前 5 次成功 + for i := 0; i < 5; i++ { + req := httptest.NewRequest(http.MethodPost, "/api/nodes/"+itoa(nodeID)+"/install-tokens", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+jwt) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("iter %d expected 200, got %d: %s", i, rec.Code, rec.Body.String()) + } + } + // 第 6 次限流 + req := httptest.NewRequest(http.MethodPost, "/api/nodes/"+itoa(nodeID)+"/install-tokens", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+jwt) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("expected 429, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func itoa(u uint) string { + if u == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for u > 0 { + i-- + buf[i] = byte('0' + u%10) + u /= 10 + } + return string(buf[i:]) +} +``` + +- [ ] **Step 2: 跑集成测试** + +Run: `cd server && go test ./internal/http/ -run "TestOneClickInstallFlow|TestInstallTokenRateLimit" -v` +Expected: PASS(2 个测试) + +若 429 测试失败(install_token_service 限流是 HTTP 层返回非 200,但 `response.Error` 映射的 status code 取决于 apperror 构造),检查 `InstallTokenService.Create` 里的 `apperror.New(429, ...)`,确保 `response.Error` 会把 apperror.Status 正确写为 429。 + +- [ ] **Step 3: Commit** + +```bash +git add server/internal/http/install_flow_test.go +git commit -m "测试: 一键部署端到端流程集成测试" +``` + +--- + +## Task 13: 回归现有测试 + 合入 Phase 1 + +**Files:** 无新增 + +- [ ] **Step 1: 全量测试** + +Run: `cd server && go test ./...` +Expected: 所有包 PASS + +- [ ] **Step 2: 静态检查** + +Run: `cd server && go vet ./...` +Expected: 无 warning + +- [ ] **Step 3: (可选)本地启动验证** + +```bash +cd server && go run ./cmd/backupx +# 另开一个终端:登录、新建节点、生成 install token、本机 curl 测试(不实际执行脚本,仅验证端点返回) +curl http://localhost:8340/install/ +``` + +- [ ] **Step 4: 本阶段整合后确认** + +Phase 1 后端完成。与用户确认是否合入 main 分支,或继续 Phase 2。 + +--- + +# Phase 2 — 前端 + +## Task 14: TypeScript 类型与 API 函数扩展 + +**Files:** +- Modify: `web/src/types/nodes.ts` +- Modify: `web/src/services/nodes.ts` + +- [ ] **Step 1: 扩展类型** + +Append to `web/src/types/nodes.ts`: + +```typescript +export type InstallMode = 'systemd' | 'docker' | 'foreground' +export type InstallArch = 'amd64' | 'arm64' | 'auto' +export type InstallSource = 'github' | 'ghproxy' + +export interface BatchCreateResult { + id: number + name: string +} + +export interface InstallTokenInput { + mode: InstallMode + arch: InstallArch + agentVersion: string + downloadSrc: InstallSource + ttlSeconds: number +} + +export interface InstallTokenResult { + installToken: string + expiresAt: string + url: string + composeUrl: string +} +``` + +- [ ] **Step 2: 新增 API 函数** + +Append to `web/src/services/nodes.ts`: + +```typescript +import type { + NodeSummary, DirEntry, + BatchCreateResult, InstallTokenInput, InstallTokenResult, +} from '../types/nodes' + +export async function batchCreateNodes(names: string[]) { + const response = await http.post>('/nodes/batch', { names }) + return unwrapApiEnvelope(response.data) +} + +export async function createInstallToken(nodeId: number, input: InstallTokenInput) { + const response = await http.post>( + `/nodes/${nodeId}/install-tokens`, input, + ) + return unwrapApiEnvelope(response.data) +} + +export async function rotateNodeToken(nodeId: number) { + const response = await http.post>( + `/nodes/${nodeId}/rotate-token`, + ) + return unwrapApiEnvelope(response.data) +} + +export async function fetchScriptPreview( + nodeId: number, + params: { mode: string; arch: string; agentVersion: string; downloadSrc: string }, +) { + const response = await http.get(`/nodes/${nodeId}/install-script-preview`, { + params, + responseType: 'text', + }) + return response.data +} +``` + +- [ ] **Step 3: 编译验证** + +Run: `cd web && npm run build` +Expected: 构建成功(若 tsc 严格模式未通过,据 error 补 import) + +- [ ] **Step 4: Commit** + +```bash +git add web/src/types/nodes.ts web/src/services/nodes.ts +git commit -m "功能: 前端新增一键部署 API 类型与函数" +``` + +--- + +## Task 15: Wizard Step 1 —— 节点信息输入 + +**Files:** +- Create: `web/src/pages/nodes/wizard/Step1NodeName.tsx` + +- [ ] **Step 1: 实现 Step1 组件** + +Create `web/src/pages/nodes/wizard/Step1NodeName.tsx`: + +```tsx +import React from 'react' +import { Radio, Input, Typography } from '@arco-design/web-react' + +const { Text } = Typography +const TextArea = Input.TextArea + +export type Mode = 'single' | 'batch' + +interface Props { + mode: Mode + onModeChange: (m: Mode) => void + singleName: string + onSingleNameChange: (v: string) => void + batchText: string + onBatchTextChange: (v: string) => void +} + +export function Step1NodeName({ + mode, onModeChange, singleName, onSingleNameChange, batchText, onBatchTextChange, +}: Props) { + return ( +
+
+ onModeChange(v as Mode)} + options={[ + { label: '单节点', value: 'single' }, + { label: '批量创建', value: 'batch' }, + ]} + /> +
+ {mode === 'single' ? ( +
+ 节点名称 + +
+ ) : ( +
+ 节点名称(每行一个,最多 50 个) +