Merge pull request 'fix(gateways): 支持编辑名称地址和令牌' (#44) from feat/gateway-edit into main

This commit is contained in:
2026-09-03 13:21:01 +08:00
9 changed files with 276 additions and 42 deletions
+16
View File
@@ -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)
+59
View File
@@ -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 可认证。
+1
View File
@@ -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},
+2 -2
View File
@@ -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) {
@@ -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;
+26 -1
View File
@@ -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`)
+33
View File
@@ -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)
}
+95 -39
View File
@@ -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 }) {
<Modal
open={open}
onClose={onClose}
title="注册网关"
labelledBy="gateway-create-title"
title={editing ? "编辑网关" : "注册网关"}
labelledBy={`gateway-${mode}-title`}
footer={
<>
<Button onClick={onClose} disabled={busy}>
@@ -114,17 +123,17 @@ function GatewayCreateModal({ open, onClose, onSubmit, busy, error }) {
<Button
variant="primary"
type="submit"
form="gateway-create-form"
form={`gateway-${mode}-form`}
busy={busy}
busyText="注册中…"
busyText={editing ? "保存中…" : "注册中…"}
disabled={!valid}
>
注册网关
{editing ? "保存" : "注册网关"}
</Button>
</>
}
>
<form id="gateway-create-form" onSubmit={submit} noValidate>
<form id={`gateway-${mode}-form`} onSubmit={submit} noValidate>
{error ? (
<Alert variant="destructive" className="mb-4">
{error.message}
@@ -132,14 +141,14 @@ function GatewayCreateModal({ open, onClose, onSubmit, busy, error }) {
) : null}
<div className="space-y-4">
<Field
id="gateway-name"
id={`gateway-${mode}-name`}
label="名称"
required
error={nameInvalid ? "字母、数字与 . _ -,最长 64 字符" : undefined}
helper="网关唯一标识,如 gw-main"
helper={editing ? "改名会同步更新引用该网关的环境" : "网关唯一标识,如 gw-main"}
>
<Input
id="gateway-name"
id={`gateway-${mode}-name`}
required
maxLength={64}
value={form.name}
@@ -149,14 +158,14 @@ function GatewayCreateModal({ open, onClose, onSubmit, busy, error }) {
/>
</Field>
<Field
id="gateway-endpoint"
id={`gateway-${mode}-endpoint`}
label="Endpoint"
required
error={endpointInvalid ? "须为 http(s) URL" : undefined}
helper="网关进程的可访问地址"
>
<Input
id="gateway-endpoint"
id={`gateway-${mode}-endpoint`}
required
value={form.endpoint}
onChange={(event) => update("endpoint", event.target.value)}
@@ -165,19 +174,17 @@ function GatewayCreateModal({ open, onClose, onSubmit, busy, error }) {
/>
</Field>
<Field
id="gateway-token"
label="令牌(可选)"
error={
tokenInvalid ? "至少 16 个字符,字母、数字与 . _ -" : undefined
}
helper="填写则须与网关 GATEWAY_TOKEN 一致;留空由平台生成"
id={`gateway-${mode}-token`}
label={editing ? "令牌(留空保持不变)" : "令牌(可选)"}
error={tokenInvalid ? "至少 16 个字符,字母、数字与 . _ -" : undefined}
helper={editing ? "填写时须与网关 GATEWAY_TOKEN 一致" : "填写则须与网关 GATEWAY_TOKEN 一致;留空由平台生成"}
>
<Input
id="gateway-token"
id={`gateway-${mode}-token`}
maxLength={128}
value={form.token}
onChange={(event) => update("token", event.target.value)}
placeholder="留空由平台生成"
placeholder={editing ? "留空保持现有令牌" : "留空由平台生成"}
invalid={tokenInvalid}
/>
</Field>
@@ -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) => (
<button
type="button"
aria-label={`删除 ${gateway.name}`}
disabled={busy === gateway.name}
onClick={() => setDeleting(gateway)}
className="rounded p-1.5 text-danger hover:bg-[#fdf1f0] disabled:opacity-50"
>
<i className="ri-delete-bin-line" aria-hidden="true" />
</button>
<div className="flex items-center justify-end gap-1">
<button
type="button"
aria-label={`编辑 ${gateway.name}`}
disabled={busy === gateway.name || busy === "edit"}
onClick={() => {
setEditing(gateway);
setEditError(null);
}}
className="rounded p-1.5 text-muted hover:bg-[#f5f7fa] hover:text-primary disabled:opacity-50"
>
<i className="ri-edit-line" aria-hidden="true" />
</button>
<button
type="button"
aria-label={`删除 ${gateway.name}`}
disabled={busy === gateway.name || busy === "edit"}
onClick={() => setDeleting(gateway)}
className="rounded p-1.5 text-danger hover:bg-[#fdf1f0] disabled:opacity-50"
>
<i className="ri-delete-bin-line" aria-hidden="true" />
</button>
</div>
),
},
]}
@@ -346,8 +390,9 @@ export function GatewayList() {
rowKey={(gateway) => gateway.name}
/>
</PageState>
<GatewayCreateModal
<GatewayFormModal
open={createOpen}
initial={null}
onClose={() => {
setCreateOpen(false);
setCreateError(null);
@@ -356,6 +401,17 @@ export function GatewayList() {
busy={busy === "create"}
error={createError}
/>
<GatewayFormModal
open={editing !== null}
initial={editing}
onClose={() => {
setEditing(null);
setEditError(null);
}}
onSubmit={updateGateway}
busy={busy === "edit"}
error={editError}
/>
<ConfirmDialog
open={deleting !== null}
onClose={() => setDeleting(null)}
+40
View File
@@ -127,6 +127,46 @@ describe("GatewayList", () => {
expect(dataProvider.create).not.toHaveBeenCalled();
});
it("edits and renames a gateway", async () => {
const update = vi.fn().mockResolvedValue({ data: { id: "gw-main" } });
const dataProvider = provider({ update });
renderGateways(dataProvider);
fireEvent.click(await screen.findByLabelText("编辑 gw-1"));
const dialog = screen.getByRole("dialog");
const name = within(dialog).getByRole("textbox", { name: "名称" });
const endpoint = within(dialog).getByRole("textbox", { name: "Endpoint" });
expect(name.value).toBe("gw-1");
expect(endpoint.value).toBe("http://docker-gateway:8081");
fireEvent.change(name, { target: { value: "gw-main" } });
fireEvent.change(endpoint, { target: { value: "http://gw-main:8081" } });
fireEvent.change(within(dialog).getByRole("textbox", { name: "令牌(留空保持不变)" }), {
target: { value: "rotated-gateway-token" },
});
fireEvent.click(within(dialog).getByRole("button", { name: "保存" }));
await waitFor(() =>
expect(update).toHaveBeenCalledWith({
resource: "gateways",
id: "gw-1",
variables: { name: "gw-main", endpoint: "http://gw-main:8081", token: "rotated-gateway-token" },
}),
);
expect(dataProvider.getList).toHaveBeenCalledTimes(2);
});
it("shows the edit failure without closing the form", async () => {
const update = vi.fn().mockRejectedValue(new Error("网关名称已存在"));
renderGateways(provider({ update }));
fireEvent.click(await screen.findByLabelText("编辑 gw-1"));
const dialog = screen.getByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: "保存" }));
expect((await within(dialog).findByRole("alert")).textContent).toContain("网关名称已存在");
expect(screen.getByRole("dialog")).toBeTruthy();
});
it("deletes a gateway after confirm", async () => {
const dataProvider = provider();
renderGateways(dataProvider);