From 94df4125796285c834db70a61c28e032679fbd6e Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 6 Oct 2026 10:28:58 +0800 Subject: [PATCH] fix(accounts): restore gateway selection and use creation modal --- AGENTS.md | 2 + .../api/account_fingerprint_test.go | 6 +- .../api/environment_create_test.go | 93 +++++++++++ .../controlplane/api/environment_login.go | 10 +- .../accounts/AccountCreateModal.tsx | 82 ++++++++++ .../accounts/AccountManagementList.tsx | 20 ++- .../components/accounts/FingerprintFields.tsx | 4 +- web/src/pages/accounts/new.tsx | 45 ------ web/src/utils/metadata.tsx | 3 +- web/tests/account-create-modal.test.cjs | 148 ++++++++++++++++++ web/tests/account-edit.test.cjs | 10 +- 11 files changed, 353 insertions(+), 70 deletions(-) create mode 100644 internal/controlplane/api/environment_create_test.go create mode 100644 web/src/components/accounts/AccountCreateModal.tsx delete mode 100644 web/src/pages/accounts/new.tsx create mode 100644 web/tests/account-create-modal.test.cjs diff --git a/AGENTS.md b/AGENTS.md index 584dccd..909175f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,6 +41,8 @@ 环境先行:创建时只创建抖音浏览器环境,不填写昵称、UID 或 Cookie,不创建占位账号。未登录环境在账号列表独立显示。首次浏览器身份核验成功后,同一事务创建/关联账号并同步真实 UID、昵称、头像、抖音号与 secUID;同一 UID 只能绑定一个账号,已有绑定禁止换绑或自动覆盖。后续同 UID 核验更新平台资料,不覆盖本地备注和业务设置。浏览器 profile_id 与指纹 seed 在环境创建时确定,账号绑定不得改变它们;旧环境保持原 profile_id 和 seed,不重建浏览器或清空 Cookie。指纹 seed 由环境独立序列分配,不再依赖账号 ID。 +账号创建交互:在“我的账号”列表通过 Modal 创建,网关必选,浏览器指纹为默认折叠的可选配置;创建成功关闭弹窗并刷新列表,不保留独立新增页面。未登录环境及空昵称仅在前端显示“待登录”,不将此文案写入数据库。 + 前端框架:Umi Max 4.7 + React 19; 组件库:antd 6.6.5 + @ant-design/pro-components 3.x(beta 线)+ @ant-design/icons;仅使用 antd/pro 默认组件原样实现,禁止自定义封装与样式魔改;组件不满足业务时改交互逻辑适配组件; diff --git a/internal/controlplane/api/account_fingerprint_test.go b/internal/controlplane/api/account_fingerprint_test.go index 1dbbc74..76c7aef 100644 --- a/internal/controlplane/api/account_fingerprint_test.go +++ b/internal/controlplane/api/account_fingerprint_test.go @@ -48,14 +48,14 @@ func TestEnvironmentCreationDoesNotCreatePlatformAccount(t *testing.T) { t.Cleanup(func() { _ = db.Close() }) app := fiber.New() registerEnvironmentLoginRoutes(app, cs, hs) - response := do(app, http.MethodPost, "/api/creator/environments", `{}`) + response := do(app, http.MethodPost, "/api/creator/environments", `{"gateway":"gw-main"}`) if response.Code != http.StatusNotFound { t.Fatalf("missing gateway: %d %s", response.Code, response.Body.String()) } if _, err := hs.CreateGateway(ctx, "gw-main", "http://127.0.0.1:28187", "test-token-environment"); err != nil { t.Fatal(err) } - response = do(app, http.MethodPost, "/api/creator/environments", `{"fingerprint":{"platform":"windows","lang":"zh-CN","timezone":"Asia/Shanghai"}}`) + response = do(app, http.MethodPost, "/api/creator/environments", `{"gateway":"gw-main","fingerprint":{"platform":"windows","lang":"zh-CN","timezone":"Asia/Shanghai"}}`) if response.Code != http.StatusCreated { t.Fatalf("create: %d %s", response.Code, response.Body.String()) } @@ -74,7 +74,7 @@ func TestEnvironmentCreationDoesNotCreatePlatformAccount(t *testing.T) { if err != nil || len(pending) != 1 { t.Fatalf("pending: %v %v", pending, err) } - for _, body := range []string{`{"name":"手填昵称"}`, `{"platform_account_key":"123"}`, `{"fingerprint":{"platform":"android"}}`, `{"fingerprint":{"seed":42}}`, `{"fingerprint":{"proxy_server":"http://invalid:8080"}}`} { + for _, body := range []string{`{"gateway":"gw-main","name":"手填昵称"}`, `{"gateway":"gw-main","platform_account_key":"123"}`, `{"gateway":"gw-main","fingerprint":{"platform":"android"}}`, `{"gateway":"gw-main","fingerprint":{"seed":42}}`, `{"gateway":"gw-main","fingerprint":{"proxy_server":"http://invalid:8080"}}`} { if response := do(app, http.MethodPost, "/api/creator/environments", body); response.Code != http.StatusBadRequest { t.Fatalf("invalid input accepted: %s %d %s", body, response.Code, response.Body.String()) } diff --git a/internal/controlplane/api/environment_create_test.go b/internal/controlplane/api/environment_create_test.go new file mode 100644 index 0000000..63365ab --- /dev/null +++ b/internal/controlplane/api/environment_create_test.go @@ -0,0 +1,93 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "os" + "testing" + + "git.ipao.vip/rogee/creator-hub/internal/creator" + hub "git.ipao.vip/rogee/creator-hub/internal/environment" + "github.com/gofiber/fiber/v3" +) + +func TestCreatePendingEnvironmentUsesSelectedGateway(t *testing.T) { + raw := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") + if raw == "" { + t.Skip("set CREATORHUB_POSTGRES_TEST_URL") + } + ctx := context.Background() + dbURL := isolatedControlPlaneDatabaseURL(t, raw) + hs, err := hub.Open(ctx, dbURL) + if err != nil { + t.Fatal(err) + } + defer hs.Close() + cs, err := creator.Open(ctx, dbURL) + if err != nil { + t.Fatal(err) + } + defer cs.Close() + for _, name := range []string{"gw-a", "gw-b"} { + if _, err := hs.CreateGateway(ctx, name, "http://"+name+":9100", "test-gateway-token-"+name); err != nil { + t.Fatal(err) + } + } + app := fiber.New() + registerEnvironmentLoginRoutes(app, cs, hs) + for _, input := range []struct { + body string + status int + }{ + {`{"gateway":"gw-b","fingerprint":{"timezone":"Asia/Shanghai"}}`, http.StatusCreated}, + {`{"gateway":"gw-a"}`, http.StatusCreated}, + {`{}`, http.StatusBadRequest}, + {`{"gateway":""}`, http.StatusBadRequest}, + {`{"gateway":"missing"}`, http.StatusNotFound}, + {`{"gateway":"bad gateway"}`, http.StatusBadRequest}, + {`{"gateway":"gw-a","fingerprint":{"seed":123}}`, http.StatusBadRequest}, + {`{"gateway":"gw-a","fingerprint":{"proxy_server":"http://example.com"}}`, http.StatusBadRequest}, + {`{"gateway":"gw-a","fingerprint":{"hardware_concurrency":-1}}`, http.StatusBadRequest}, + {`{`, http.StatusBadRequest}, + } { + t.Run(input.body, func(t *testing.T) { + response := do(app, http.MethodPost, "/api/creator/environments", input.body) + if response.Code != input.status { + t.Fatalf("status=%d want=%d: %s", response.Code, input.status, response.Body.String()) + } + if input.status != http.StatusCreated { + return + } + var env hub.EnvironmentContext + if err := json.Unmarshal(response.Body.Bytes(), &env); err != nil { + t.Fatal(err) + } + var payload struct { + Gateway string `json:"gateway"` + Fingerprint hub.Fingerprint `json:"fingerprint"` + } + if err := json.Unmarshal([]byte(input.body), &payload); err != nil { + t.Fatal(err) + } + if env.Gateway != payload.Gateway || env.Alias == "" || env.ProfileID != env.Alias || env.Fingerprint.Seed == 0 || env.AccountID != "" || env.Fingerprint.Timezone != payload.Fingerprint.Timezone { + t.Fatalf("invalid pending environment: %#v", env) + } + stored, err := hs.GetEnvironmentContext(ctx, env.Alias) + if err != nil || stored.Gateway != payload.Gateway { + t.Fatalf("selected gateway not stored: %#v %v", stored, err) + } + }) + } + pending, err := hs.ListPendingEnvironments(ctx) + if err != nil || len(pending) != 2 { + t.Fatalf("invalid pending list: %#v %v", pending, err) + } + if pending[0].Fingerprint.Seed == pending[1].Fingerprint.Seed { + t.Fatal("environments must have independent fingerprint seeds") + } + profiles, err := cs.ListAccountProfiles(ctx) + if err != nil || len(profiles) != 0 { + t.Fatalf("creation wrote placeholder account: %#v %v", profiles, err) + } +} diff --git a/internal/controlplane/api/environment_login.go b/internal/controlplane/api/environment_login.go index 382c78f..a32b46e 100644 --- a/internal/controlplane/api/environment_login.go +++ b/internal/controlplane/api/environment_login.go @@ -22,20 +22,18 @@ func registerEnvironmentLoginRoutes(app *fiber.App, store *creator.Store, hubSto }) app.Post("/api/creator/environments", func(c fiber.Ctx) error { var input struct { + Gateway string `json:"gateway"` Fingerprint hub.Fingerprint `json:"fingerprint"` } if err := decodePhaseA(c, &input); err != nil { return phaseAError(c, err) } - gateway, err := soleGateway(c.Context(), hubStore) + env, err := hubStore.CreateStandaloneEnv(c.Context(), input.Gateway, input.Fingerprint) if err != nil { + logrus.WithError(err).WithFields(logrus.Fields{"event_type": "login_environment_create_failed", "gateway": input.Gateway}).Error("browser environment creation failed") return hubError(c, err) } - env, err := hubStore.CreateStandaloneEnv(c.Context(), gateway.Name, input.Fingerprint) - if err != nil { - return hubError(c, err) - } - logrus.WithFields(logrus.Fields{"event_type": "login_environment_created", "alias": env.Alias, "gateway": gateway.Name}).Info("browser environment created without platform account") + logrus.WithFields(logrus.Fields{"event_type": "login_environment_created", "alias": env.Alias, "gateway": env.Gateway}).Info("browser environment created without platform account") return c.Status(fiber.StatusCreated).JSON(env) }) app.Post("/api/creator/environments/:alias/verify", func(c fiber.Ctx) error { diff --git a/web/src/components/accounts/AccountCreateModal.tsx b/web/src/components/accounts/AccountCreateModal.tsx new file mode 100644 index 0000000..29987bf --- /dev/null +++ b/web/src/components/accounts/AccountCreateModal.tsx @@ -0,0 +1,82 @@ +import { useEffect, useState } from 'react'; +import { Alert, Button, Collapse, Form, Modal, Select, Typography } from 'antd'; +import { creatorCreate, creatorGet } from '@/services/api'; +import { conflictMessage } from '@/utils/helpers'; +import FingerprintFields, { fingerprintPayload, type FingerprintValues } from '@/components/accounts/FingerprintFields'; + +interface Values { + gateway: string; + fingerprint?: FingerprintValues; +} + +export default function AccountCreateModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) { + const [form] = Form.useForm(); + const [gateways, setGateways] = useState<{ name: string }[]>([]); + const [loading, setLoading] = useState(true); + const [gatewayError, setGatewayError] = useState(null); + const [gatewayAttempt, setGatewayAttempt] = useState(0); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let active = true; + async function load() { + setLoading(true); + setGatewayError(null); + try { + const gateways = await creatorGet('/gateways'); + if (active) setGateways(gateways); + } catch (error) { + if (active) setGatewayError(error); + } finally { + if (active) setLoading(false); + } + } + void load(); + return () => { active = false; }; + }, [gatewayAttempt]); + + async function onFinish(values: Values) { + setBusy(true); + setError(null); + try { + const environment = await creatorCreate('/creator/environments', { + gateway: values.gateway, + fingerprint: fingerprintPayload(values.fingerprint), + }); + if (!environment?.alias) throw new Error('创建环境未返回环境标识'); + } catch (error) { + setError(error); + return; + } finally { + setBusy(false); + } + onSuccess(); + } + + return ( + { if (!busy) onClose(); }} + onOk={() => form.submit()} confirmLoading={busy} okText="创建" cancelText="取消" + okButtonProps={{ disabled: loading || Boolean(gatewayError) || gateways.length === 0 }} + cancelButtonProps={{ disabled: busy }} + closable={!busy} keyboard={!busy} mask={{ closable: !busy }}> + {gatewayError ? setGatewayAttempt(attempt => attempt + 1)} loading={loading}>重试} /> : null} + {!loading && !gatewayError && gateways.length === 0 ? : null} + {error ? : null} +
+ + 创建抖音浏览器环境后扫码登录。昵称、UID 和头像自动同步;登录前不创建账号记录。 + + +