From a2669620daea6c629914460427ce0d5c92546a17 Mon Sep 17 00:00:00 2001 From: Rogee Date: Thu, 3 Sep 2026 13:14:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(gateways):=20=E6=94=AF=E6=8C=81=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=90=8D=E7=A7=B0=E5=9C=B0=E5=9D=80=E5=92=8C=E4=BB=A4?= =?UTF-8?q?=E7=89=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 PUT /api/gateways/:name,支持改名、修改 Endpoint 与令牌 - 网关改名通过 PostgreSQL ON UPDATE CASCADE 原子同步环境引用 - 编辑令牌留空时保留原值,避免只改地址时意外轮换 - 网关列表增加编辑弹窗并覆盖成功、冲突、缺失与失败展示 --- cmd/control-plane/hub.go | 16 +++ cmd/control-plane/hub_test.go | 59 ++++++++ cmd/control-plane/main_test.go | 1 + internal/hub/migration_test.go | 4 +- .../migrations/015_gateway_rename_cascade.sql | 4 + internal/hub/store.go | 27 +++- internal/hub/store_test.go | 33 +++++ web/src/GatewaysPage.jsx | 134 +++++++++++++----- web/src/GatewaysPage.test.jsx | 40 ++++++ 9 files changed, 276 insertions(+), 42 deletions(-) create mode 100644 internal/hub/migrations/015_gateway_rename_cascade.sql diff --git a/cmd/control-plane/hub.go b/cmd/control-plane/hub.go index 83c0643..1f7c7b6 100644 --- a/cmd/control-plane/hub.go +++ b/cmd/control-plane/hub.go @@ -20,6 +20,7 @@ import ( type hubStore interface { LockResources(ctx context.Context, aliases, exitIDs, imageVersions []string) (func(), error) CreateGateway(ctx context.Context, name, endpoint, token string) (hub.Gateway, error) + UpdateGateway(ctx context.Context, currentName, name, endpoint, token string) (hub.Gateway, error) ListGateways(ctx context.Context) ([]hub.Gateway, error) GetGateway(ctx context.Context, name string) (hub.Gateway, error) DeleteGateway(ctx context.Context, name string) error @@ -540,6 +541,21 @@ func registerHubWithNetwork(app *fiber.App, store hubStore, probe networkExitPro } return c.Status(fiber.StatusCreated).JSON(gateway) }) + app.Put("/api/gateways/:name", func(c fiber.Ctx) error { + input := struct { + Name string `json:"name"` + Endpoint string `json:"endpoint"` + Token string `json:"token"` + }{} + if err := decodeHubJSON(c, &input); err != nil { + return hubError(c, err) + } + gateway, err := store.UpdateGateway(c.Context(), c.Params("name"), input.Name, input.Endpoint, input.Token) + if err != nil { + return hubError(c, err) + } + return c.JSON(gateway) + }) app.Delete("/api/gateways/:name", func(c fiber.Ctx) error { if err := store.DeleteGateway(c.Context(), c.Params("name")); err != nil { return hubError(c, err) diff --git a/cmd/control-plane/hub_test.go b/cmd/control-plane/hub_test.go index 801e7cd..1ebfc1e 100644 --- a/cmd/control-plane/hub_test.go +++ b/cmd/control-plane/hub_test.go @@ -156,6 +156,32 @@ func (s *memoryStore) lock(key string) func() { func (s *memoryStore) CreateGateway(_ context.Context, _, _, _ string) (hub.Gateway, error) { return hub.Gateway{}, nil } +func (s *memoryStore) UpdateGateway(_ context.Context, currentName, name, endpoint, token string) (hub.Gateway, error) { + s.mu.Lock() + defer s.mu.Unlock() + gateway, exists := s.gateways[currentName] + if !exists { + return hub.Gateway{}, hub.ErrNotFound + } + if currentName != name { + if _, exists := s.gateways[name]; exists { + return hub.Gateway{}, hub.ErrConflict + } + delete(s.gateways, currentName) + for alias, env := range s.envs { + if env.Gateway == currentName { + env.Gateway = name + s.envs[alias] = env + } + } + } + gateway.Name, gateway.Endpoint = name, endpoint + if token != "" { + gateway.Token = token + } + s.gateways[name] = gateway + return gateway, nil +} func (s *memoryStore) ListGateways(context.Context) ([]hub.Gateway, error) { s.mu.Lock() defer s.mu.Unlock() @@ -867,6 +893,39 @@ func do(app *fiber.App, method, path, body string, credentials ...string) *httpt const createEnvBody = `{"alias":"account-a","name":"店铺一号","gateway":"gw-1","image_version":"148.0.7778.215",` + `"fingerprint":{"seed":2024,"platform":"windows","timezone":"Asia/Shanghai"},"account_id":"account-a","network_exit_id":"exit-1"}` +func TestUpdateGatewayRenamesAndPreservesReferences(t *testing.T) { + store := newMemoryStore() + store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: "http://gw-1:8081", Token: "unit-test-gateway-token"} + store.gateways["gw-existing"] = hub.Gateway{Name: "gw-existing", Endpoint: "http://gw-existing:8081", Token: "existing-gateway-token"} + store.envs["account-a"] = hub.Env{Alias: "account-a", Gateway: "gw-1"} + app := fiber.New() + registerHubWithNetwork(app, store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }) + + response := do(app, http.MethodPut, "/api/gateways/gw-1", `{"name":"gw-main","endpoint":"http://gw-main:8081","token":""}`) + if response.Code != http.StatusOK { + t.Fatalf("update gateway returned %d: %s", response.Code, response.Body.String()) + } + var updated hub.Gateway + if err := json.Unmarshal(response.Body.Bytes(), &updated); err != nil { + t.Fatal(err) + } + if updated.Name != "gw-main" || updated.Endpoint != "http://gw-main:8081" || updated.Token != "unit-test-gateway-token" { + t.Fatalf("gateway update lost fields: %#v", updated) + } + if store.envs["account-a"].Gateway != "gw-main" { + t.Fatalf("environment gateway reference was not renamed: %#v", store.envs["account-a"]) + } + + response = do(app, http.MethodPut, "/api/gateways/gw-main", `{"name":"gw-existing","endpoint":"http://gw-main:8081","token":""}`) + if response.Code != http.StatusConflict { + t.Fatalf("rename conflict returned %d: %s", response.Code, response.Body.String()) + } + response = do(app, http.MethodPut, "/api/gateways/missing", `{"name":"gw-new","endpoint":"http://gw-new:8081","token":""}`) + if response.Code != http.StatusNotFound { + t.Fatalf("missing gateway update returned %d: %s", response.Code, response.Body.String()) + } +} + func TestListGatewaysExposesConnectivityAndHealth(t *testing.T) { store := newMemoryStore() // gw-1:正常网关,/healthz 可达且 /v1/browsers 可认证。 diff --git a/cmd/control-plane/main_test.go b/cmd/control-plane/main_test.go index 654f305..b129d4f 100644 --- a/cmd/control-plane/main_test.go +++ b/cmd/control-plane/main_test.go @@ -338,6 +338,7 @@ func controlPlaneRouteMatrix() []controlPlaneRouteCase { {http.MethodGet, "/api/gateways", "/api/gateways", "", http.StatusOK}, {http.MethodPost, "/api/gateways", "/api/gateways", "", http.StatusBadRequest}, + {http.MethodPut, "/api/gateways/:name", "/api/gateways/missing", `{"name":"gw-missing","endpoint":"http://gw-missing:8081","token":""}`, http.StatusNotFound}, {http.MethodDelete, "/api/gateways/:name", "/api/gateways/missing", "", http.StatusNotFound}, {http.MethodPost, "/api/phase-a/accounts", "/api/phase-a/accounts", "", http.StatusBadRequest}, diff --git a/internal/hub/migration_test.go b/internal/hub/migration_test.go index 5af85ee..7a51fc6 100644 --- a/internal/hub/migration_test.go +++ b/internal/hub/migration_test.go @@ -33,7 +33,7 @@ func TestUnifiedAccountMigration(t *testing.T) { t.Fatal(err) } defer db.Close() - assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 14`, 14) + assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 15`, 15) assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name IN ('social_account', 'browser_env', 'network_exit', 'environment_binding')`, 4) assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'social_account' AND column_name IN ('name', 'tags')`, 2) assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'social_account' AND column_name = 'cookies'`, 0) @@ -42,7 +42,7 @@ func TestUnifiedAccountMigration(t *testing.T) { store = openFullyMigratedHub(t, ctx, testURL) store.Close() - assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 14`, 14) + assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 15`, 15) }) t.Run("legacy migration 013 without account secrets is repaired forward", func(t *testing.T) { diff --git a/internal/hub/migrations/015_gateway_rename_cascade.sql b/internal/hub/migrations/015_gateway_rename_cascade.sql new file mode 100644 index 0000000..1aa34ad --- /dev/null +++ b/internal/hub/migrations/015_gateway_rename_cascade.sql @@ -0,0 +1,4 @@ +-- 网关名称是 browser_env 的引用键;改名时由数据库原子级联,避免环境引用悬空。 +ALTER TABLE browser_env DROP CONSTRAINT browser_env_gateway_name_fkey; +ALTER TABLE browser_env ADD CONSTRAINT browser_env_gateway_name_fkey + FOREIGN KEY (gateway_name) REFERENCES gateway(name) ON UPDATE CASCADE; diff --git a/internal/hub/store.go b/internal/hub/store.go index 1769b5a..5704f72 100644 --- a/internal/hub/store.go +++ b/internal/hub/store.go @@ -60,6 +60,9 @@ var migration013 string //go:embed migrations/014_account_creation_compatibility.sql var migration014 string +//go:embed migrations/015_gateway_rename_cascade.sql +var migration015 string + var ( ErrConflict = errors.New("resource conflicts with existing state") ErrInvalid = errors.New("invalid hub input") @@ -216,7 +219,7 @@ func (s *Store) migrate(ctx context.Context) error { for _, migration := range []struct { version int sql string - }{{2, migration002}, {3, migration003}, {4, migration004}, {5, migration005}, {6, migration006}, {7, migration007}, {8, migration008}, {9, migration009}, {10, migration010}, {11, migration011}, {12, migration012}, {13, migration013}, {14, migration014}} { + }{{2, migration002}, {3, migration003}, {4, migration004}, {5, migration005}, {6, migration006}, {7, migration007}, {8, migration008}, {9, migration009}, {10, migration010}, {11, migration011}, {12, migration012}, {13, migration013}, {14, migration014}, {15, migration015}} { var applied bool if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migration.version).Scan(&applied); err != nil { return errors.New("read hub schema migration state") @@ -261,6 +264,28 @@ func (s *Store) CreateGateway(ctx context.Context, name, endpoint, token string) return gateway, nil } +// UpdateGateway 修改网关名称、Endpoint 和令牌。名称变更由数据库外键 ON UPDATE CASCADE +// 原子同步 browser_env 引用;空令牌表示保留当前令牌,避免只改地址时意外轮换凭证。 +func (s *Store) UpdateGateway(ctx context.Context, currentName, name, endpoint, token string) (Gateway, error) { + currentName, name = strings.TrimSpace(currentName), strings.TrimSpace(name) + endpoint, token = strings.TrimSpace(endpoint), strings.TrimSpace(token) + if !gatewayNamePattern.MatchString(currentName) || !gatewayNamePattern.MatchString(name) || !validHTTPURL(endpoint) || + (token != "" && !tokenPattern.MatchString(token)) { + return Gateway{}, ErrInvalid + } + var gateway Gateway + err := s.db.QueryRowContext(ctx, ` + UPDATE gateway SET name = $1, endpoint = $2, + token = CASE WHEN $3 = '' THEN token ELSE $3 END, updated_at = now() + WHERE name = $4 + RETURNING name, endpoint, token, created_at, updated_at`, name, endpoint, token, currentName). + Scan(&gateway.Name, &gateway.Endpoint, &gateway.Token, &gateway.CreatedAt, &gateway.UpdatedAt) + if err != nil { + return Gateway{}, rowError(err) + } + return gateway, nil +} + func (s *Store) ListGateways(ctx context.Context) ([]Gateway, error) { rows, err := s.db.QueryContext(ctx, ` SELECT name, endpoint, token, created_at, updated_at FROM gateway ORDER BY created_at, name`) diff --git a/internal/hub/store_test.go b/internal/hub/store_test.go index c9b829a..163fb7b 100644 --- a/internal/hub/store_test.go +++ b/internal/hub/store_test.go @@ -220,6 +220,22 @@ func TestStoreValidationRejectsInvalidInputsBeforePersistence(t *testing.T) { if _, err := store.CreateGateway(ctx, "gw-1", "http://gw:8081", "short-token"); !errors.Is(err, ErrInvalid) { t.Fatalf("expected invalid gateway token, got %v", err) } + for _, test := range []struct { + name string + currentName string + newName string + endpoint string + token string + }{ + {name: "current name", currentName: "bad name!", newName: "gw-2", endpoint: "http://gw:8081"}, + {name: "new name", currentName: "gw-1", newName: "bad name!", endpoint: "http://gw:8081"}, + {name: "endpoint", currentName: "gw-1", newName: "gw-2", endpoint: "ftp://gw:8081"}, + {name: "token", currentName: "gw-1", newName: "gw-2", endpoint: "http://gw:8081", token: "short-token"}, + } { + if _, err := store.UpdateGateway(ctx, test.currentName, test.newName, test.endpoint, test.token); !errors.Is(err, ErrInvalid) { + t.Fatalf("expected invalid gateway update %s, got %v", test.name, err) + } + } if err := store.CreateImage(ctx, Image{Version: "v1", ImageRef: "registry/img:1"}); !errors.Is(err, ErrInvalid) { t.Fatalf("expected invalid image version, got %v", err) } @@ -315,6 +331,23 @@ func TestHubWorkflow(t *testing.T) { if listed[0].Name != "店铺一号" || listed[0].Fingerprint.Seed != 1000 || listed[0].Fingerprint.Timezone != "Asia/Shanghai" { t.Fatalf("fingerprint must round trip through jsonb: %#v", listed[0]) } + updatedGateway, err := store.UpdateGateway(ctx, "gw-main", "gw-renamed", "http://127.0.0.4:8081", "") + if err != nil || updatedGateway.Name != "gw-renamed" || updatedGateway.Endpoint != "http://127.0.0.4:8081" || updatedGateway.Token != gateway.Token { + t.Fatalf("gateway update did not preserve the token: %#v err=%v", updatedGateway, err) + } + renamedEnv, err := store.GetEnv(ctx, "shop-01") + if err != nil || renamedEnv.Gateway != "gw-renamed" { + t.Fatalf("gateway rename did not cascade to environment: %#v err=%v", renamedEnv, err) + } + if _, err := store.UpdateGateway(ctx, "gw-renamed", "gw-custom", "http://127.0.0.4:8081", ""); !errors.Is(err, ErrConflict) { + t.Fatalf("expected gateway rename conflict, got %v", err) + } + if _, err := store.UpdateGateway(ctx, "missing", "gw-missing", "http://127.0.0.5:8081", ""); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected missing gateway on update, got %v", err) + } + if _, err := store.UpdateGateway(ctx, "gw-renamed", "gw-main", "http://127.0.0.1:8081", ""); err != nil { + t.Fatalf("restore gateway name after cascade check: %v", err) + } if _, err := store.GetEnv(ctx, "ghost"); !errors.Is(err, ErrNotFound) { t.Fatalf("expected missing env, got %v", err) } diff --git a/web/src/GatewaysPage.jsx b/web/src/GatewaysPage.jsx index 27fbd1e..067e5d9 100644 --- a/web/src/GatewaysPage.jsx +++ b/web/src/GatewaysPage.jsx @@ -1,10 +1,9 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useDataProvider, useList } from "@refinedev/core"; import { Alert, Button, ConfirmDialog, - Copyable, Field, Input, Modal, @@ -72,8 +71,17 @@ function TokenCell({ token, name }) { ); } -function GatewayCreateModal({ open, onClose, onSubmit, busy, error }) { +function GatewayFormModal({ open, onClose, onSubmit, busy, error, initial }) { + const editing = initial !== null; const [form, setForm] = useState({ name: "", endpoint: "", token: "" }); + useEffect(() => { + if (!open) return; + setForm( + editing + ? { name: initial.name, endpoint: initial.endpoint, token: "" } + : { name: "", endpoint: "", token: "" }, + ); + }, [editing, initial, open]); const update = (key, value) => setForm((current) => ({ ...current, [key]: value })); const token = form.token.trim(); @@ -85,17 +93,18 @@ function GatewayCreateModal({ open, onClose, onSubmit, busy, error }) { const endpointInvalid = form.endpoint !== "" && !/^https?:\/\/\S+$/.test(form.endpoint); const tokenInvalid = token !== "" && !tokenPattern.test(token); + const mode = editing ? "edit" : "create"; async function submit(event) { event.preventDefault(); if (!valid) return; - const created = await onSubmit({ - name: form.name, - endpoint: form.endpoint, - token, - }); - if (created) { - setForm({ name: "", endpoint: "", token: "" }); + if ( + await onSubmit({ + name: form.name, + endpoint: form.endpoint, + token, + }) + ) { onClose(); } } @@ -104,8 +113,8 @@ function GatewayCreateModal({ open, onClose, onSubmit, busy, error }) { } > -
+ {error ? ( {error.message} @@ -132,14 +141,14 @@ function GatewayCreateModal({ open, onClose, onSubmit, busy, error }) { ) : null}
update("endpoint", event.target.value)} @@ -165,19 +174,17 @@ function GatewayCreateModal({ open, onClose, onSubmit, busy, error }) { /> update("token", event.target.value)} - placeholder="留空由平台生成" + placeholder={editing ? "留空保持现有令牌" : "留空由平台生成"} invalid={tokenInvalid} /> @@ -197,6 +204,8 @@ export function GatewayList() { const [busy, setBusy] = useState(""); const [createError, setCreateError] = useState(null); const [created, setCreated] = useState(null); + const [editing, setEditing] = useState(null); + const [editError, setEditError] = useState(null); const [deleting, setDeleting] = useState(null); useTitle("CreatorHub · 网关管理"); @@ -219,6 +228,27 @@ export function GatewayList() { } } + async function updateGateway(data) { + const currentName = editing.name; + setBusy("edit"); + setEditError(null); + try { + await dataProvider.update({ + resource: "gateways", + id: currentName, + variables: data, + }); + await query.refetch(); + setEditing(null); + return true; + } catch (reason) { + setEditError(reason); + return false; + } finally { + setBusy(""); + } + } + async function removeGateway() { const gateway = deleting; setBusy(gateway.name); @@ -330,15 +360,29 @@ export function GatewayList() { width: "12%", align: "right", render: (gateway) => ( -