diff --git a/internal/controlplane/api/account_environment.go b/internal/controlplane/api/account_environment.go index 8832d29..3e8a662 100644 --- a/internal/controlplane/api/account_environment.go +++ b/internal/controlplane/api/account_environment.go @@ -27,8 +27,8 @@ func soleGateway(ctx context.Context, store HubStore) (hub.Gateway, error) { } // ensureAccountEnvironment 幂等补建账号环境:已绑定(任意出口)原样返回; -// 未绑定时以 alias=账号 ID、派生 seed、直连出口创建。 -func ensureAccountEnvironment(ctx context.Context, store HubStore, accountID string) (hub.EnvironmentContext, bool, error) { +// 未绑定时以 alias=账号 ID、派生 seed、指定指纹、直连出口创建。 +func ensureAccountEnvironment(ctx context.Context, store HubStore, accountID string, fingerprint hub.Fingerprint) (hub.EnvironmentContext, bool, error) { environment, err := store.GetEnvironmentContextForAccount(ctx, accountID) if err == nil { return environment, false, nil @@ -42,7 +42,7 @@ func ensureAccountEnvironment(ctx context.Context, store HubStore, accountID str } // seed 由 CreateBoundEnv 从账号 bigint id + 1000 派生(数字主键)。 return store.CreateBoundEnv(ctx, hub.Env{ - Alias: accountID, Name: accountID, Gateway: gateway.Name, + Alias: accountID, Name: accountID, Gateway: gateway.Name, Fingerprint: fingerprint, }, accountID, "") } @@ -50,7 +50,8 @@ func ensureAccountEnvironment(ctx context.Context, store HubStore, accountID str func startAccountEnvironment(ctx context.Context, store HubStore, accountID string) error { environment, err := store.GetEnvironmentContextForAccount(ctx, accountID) if errors.Is(err, hub.ErrNotFound) { - environment, _, err = ensureAccountEnvironment(ctx, store, accountID) + // start 自愈补建时创建表单不可得,零值指纹 = 派生 seed + 浏览器默认参数。 + environment, _, err = ensureAccountEnvironment(ctx, store, accountID, hub.Fingerprint{}) } if err != nil { return err diff --git a/internal/controlplane/api/account_fingerprint_test.go b/internal/controlplane/api/account_fingerprint_test.go new file mode 100644 index 0000000..9eef9e5 --- /dev/null +++ b/internal/controlplane/api/account_fingerprint_test.go @@ -0,0 +1,123 @@ +package api + +import ( + "context" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + accountdomain "git.ipao.vip/rogee/creator-hub/internal/account" + hub "git.ipao.vip/rogee/creator-hub/internal/environment" + "github.com/gofiber/fiber/v3" +) + +// 创建社媒账号的指纹浏览器环境表单:自定义指纹随创建请求落库; +// seed 由服务端从账号派生、proxy 由出口体系管理(客户端值被忽略/清空)。 +func TestAccountCreateAcceptsFingerprintForm(t *testing.T) { + databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") + if databaseURL == "" { + t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage") + } + ctx := context.Background() + databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL) + accountStore, err := accountdomain.Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = accountStore.Close() }) + hubStore, err := hub.Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = hubStore.Close() }) + gateway := &fakeGateway{token: "unit-test-gateway-token"} + gatewayServer := httptest.NewServer(gateway.handler(t)) + t.Cleanup(gatewayServer.Close) + app := fiber.New() + RegisterAccountRoutes(app, accountStore, hubStore, &testCredentialBridge{values: map[string]string{}}) + if _, err := hubStore.CreateGateway(ctx, "gw-main", gatewayServer.URL, gateway.token); err != nil { + t.Fatal(err) + } + + response := do(app, http.MethodPost, "/api/phase-a/accounts", + `{"name":"指纹账号","platform":"douyin","platform_account_key":"key-fp-1","cookies":"sessionid=1", + "fingerprint":{"seed":42,"platform":"windows","platform_version":"10.0.0","brand":"Chrome","brand_version":"132.0.6834.159", + "hardware_concurrency":8,"lang":"zh-CN","accept_lang":"zh-CN,en-US","timezone":"Asia/Shanghai", + "proxy_server":"socks5://proxy.example:1080","disable_spoofing":"canvas,gpu"}}`) + if response.Code != http.StatusCreated { + t.Fatalf("expected 201 create account with fingerprint, got %d: %s", response.Code, response.Body.String()) + } + var created struct { + ID string `json:"id"` + } + if err := json.Unmarshal(response.Body.Bytes(), &created); err != nil || created.ID == "" { + t.Fatalf("create payload: %s err=%v", response.Body.String(), err) + } + auditDB, err := sql.Open("pgx", databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = auditDB.Close() }) + var accountRowID int64 + if err := auditDB.QueryRowContext(ctx, `SELECT id FROM social_account WHERE account_id = $1`, created.ID).Scan(&accountRowID); err != nil { + t.Fatal(err) + } + environment, err := hubStore.GetEnvironmentContext(ctx, created.ID) + if err != nil { + t.Fatal(err) + } + fingerprint := environment.Fingerprint + if fingerprint.Platform != "windows" || fingerprint.PlatformVersion != "10.0.0" || + fingerprint.Brand != "Chrome" || fingerprint.BrandVersion != "132.0.6834.159" || + fingerprint.HardwareConcurrency != 8 || fingerprint.Lang != "zh-CN" || + fingerprint.AcceptLang != "zh-CN,en-US" || fingerprint.Timezone != "Asia/Shanghai" || + fingerprint.DisableSpoofing != "canvas,gpu" { + t.Fatalf("fingerprint not persisted as submitted: %#v", fingerprint) + } + if fingerprint.Seed != accountRowID+1000 || fingerprint.ProxyServer != "" { + t.Fatalf("seed must be server-derived and proxy cleared: %#v (row id %d)", fingerprint, accountRowID) + } + + // 非法指纹值 → 400,账号不落库(校验前置,无创建后绑定失败的中间态)。 + if response := do(app, http.MethodPost, "/api/phase-a/accounts", + `{"name":"坏指纹","platform":"douyin","platform_account_key":"key-fp-2","fingerprint":{"platform":"android"}}`); response.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for invalid fingerprint, got %d: %s", response.Code, response.Body.String()) + } + var invalidCount int + if err := auditDB.QueryRowContext(ctx, `SELECT count(*) FROM social_account WHERE platform_account_key = 'key-fp-2'`).Scan(&invalidCount); err != nil || invalidCount != 0 { + t.Fatalf("invalid fingerprint must not create account: rows=%d err=%v", invalidCount, err) + } + + // 幂等补建端点可携带同一指纹表单重试(创建时绑定失败的场景)。 + retry := do(app, http.MethodPost, "/api/phase-a/accounts", + `{"name":"补建账号","platform":"douyin","platform_account_key":"key-fp-3","fingerprint":{"timezone":"Asia/Shanghai"}}`) + if retry.Code != http.StatusCreated { + t.Fatalf("expected 201 create account for rebind retry, got %d: %s", retry.Code, retry.Body.String()) + } + var rebindCreated struct { + ID string `json:"id"` + } + if err := json.Unmarshal(retry.Body.Bytes(), &rebindCreated); err != nil || rebindCreated.ID == "" { + t.Fatalf("rebind create payload: %s err=%v", retry.Body.String(), err) + } + // 幂等补建端点可携带创建时未落库的指纹重试:先删除环境模拟"创建时绑定失败",补建后指纹落库。 + if err := hubStore.DeleteAccountEnvironment(ctx, rebindCreated.ID); err != nil { + t.Fatal(err) + } + rebind := do(app, http.MethodPost, "/api/phase-a/accounts/"+rebindCreated.ID+"/environment", + `{"fingerprint":{"platform":"linux","lang":"en-US"}}`) + if rebind.Code != http.StatusOK { + t.Fatalf("expected 200 environment rebind with fingerprint, got %d: %s", rebind.Code, rebind.Body.String()) + } + reboundEnvironment, err := hubStore.GetEnvironmentContext(ctx, rebindCreated.ID) + if err != nil { + t.Fatal(err) + } + // 补建以传入指纹为准(时区为空 = 创建时的时区不保留)。 + if reboundEnvironment.Fingerprint.Platform != "linux" || reboundEnvironment.Fingerprint.Lang != "en-US" || reboundEnvironment.Fingerprint.Timezone != "" { + t.Fatalf("rebind fingerprint mismatch: %#v", reboundEnvironment.Fingerprint) + } +} diff --git a/internal/controlplane/api/accounts_operations.go b/internal/controlplane/api/accounts_operations.go index 5fc62b1..1775b2b 100644 --- a/internal/controlplane/api/accounts_operations.go +++ b/internal/controlplane/api/accounts_operations.go @@ -15,11 +15,12 @@ import ( ) type accountRequest struct { - Name string `json:"name"` - Platform string `json:"platform"` - PlatformAccountKey string `json:"platform_account_key"` - Tags []string `json:"tags"` - Cookies string `json:"cookies"` + Name string `json:"name"` + Platform string `json:"platform"` + PlatformAccountKey string `json:"platform_account_key"` + Tags []string `json:"tags"` + Cookies string `json:"cookies"` + Fingerprint hub.Fingerprint `json:"fingerprint"` } // RegisterAccountRoutes exposes account lifecycle routes (create with auto-binding/list/detail/补建/start/pause/resume/revoke + audit). @@ -29,6 +30,12 @@ func RegisterAccountRoutes(app *fiber.App, store *accountdomain.Store, runtimeSt if err := decodePhaseA(c, &input); err != nil { return phaseAError(c, err) } + // 指纹表单校验前置:非法值直接 400,避免账号已建、环境绑定失败的中间态。 + input.Fingerprint.ProxyServer = "" + input.Fingerprint.DisableNonProxiedUDP = false + if err := input.Fingerprint.Validate(); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "fingerprint: " + err.Error()}) + } tags := input.Tags if tags == nil { tags = []string{} @@ -54,7 +61,7 @@ func RegisterAccountRoutes(app *fiber.App, store *accountdomain.Store, runtimeSt } // 账号即环境:创建即绑定(幂等)。绑定失败透传原因与账号 ID,客户端可用幂等补建端点重试。 if runtimeStore != nil { - if _, _, err := ensureAccountEnvironment(c.Context(), runtimeStore, accountID); err != nil { + if _, _, err := ensureAccountEnvironment(c.Context(), runtimeStore, accountID, input.Fingerprint); err != nil { return c.Status(fiber.StatusServiceUnavailable).JSON(map[string]string{ "error": err.Error(), "reason_code": "environment_binding_failed", "account_id": accountID, }) @@ -110,12 +117,26 @@ func RegisterAccountRoutes(app *fiber.App, store *accountdomain.Store, runtimeSt if runtimeStore == nil { return c.Status(fiber.StatusServiceUnavailable).JSON(map[string]string{"error": "environment store unavailable"}) } + // 幂等补建:空体保持零值指纹(seed 仍由账号派生);可携带创建时未落库的指纹表单重试。 + var rebind struct { + Fingerprint hub.Fingerprint `json:"fingerprint"` + } + if len(c.Body()) > 0 { + if err := decodePhaseA(c, &rebind); err != nil { + return phaseAError(c, err) + } + } + rebind.Fingerprint.ProxyServer = "" + rebind.Fingerprint.DisableNonProxiedUDP = false + if err := rebind.Fingerprint.Validate(); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(map[string]string{"error": "fingerprint: " + err.Error()}) + } unlock, err := lockAccountResources(c.Context(), runtimeStore, c.Params("id")) if err != nil { return hubError(c, err) } defer unlock() - environment, created, err := ensureAccountEnvironment(c.Context(), runtimeStore, c.Params("id")) + environment, created, err := ensureAccountEnvironment(c.Context(), runtimeStore, c.Params("id"), rebind.Fingerprint) if err != nil { return hubError(c, err) } diff --git a/web/src/pages/accounts/new.tsx b/web/src/pages/accounts/new.tsx index 804e45c..ddaffe6 100644 --- a/web/src/pages/accounts/new.tsx +++ b/web/src/pages/accounts/new.tsx @@ -1,19 +1,41 @@ // 创建社媒账号:语义对齐 web.archived AccountCreatePage + AccountCreateForm。 // cookies 非必填:留空代表创建后走扫码登录。创建成功跳编辑页。 +// 折叠区块:指纹浏览器环境(可选);seed 由后端从账号派生,代理由网络出口管理,均不在表单内。 import { useState } from 'react'; import { history } from '@umijs/max'; -import { Alert, Button, Card, Form, Input, Select } from 'antd'; +import { Alert, Button, Card, Col, Collapse, Flex, Form, Input, InputNumber, Row, Select, Typography } from 'antd'; import { create } from '@/services/api'; import { conflictMessage, platforms } from '@/utils/helpers'; +interface FingerprintValues { + platform?: string; + platform_version?: string; + brand?: string; + brand_version?: string; + hardware_concurrency?: number; + lang?: string; + accept_lang?: string; + timezone?: string; + disable_spoofing?: string[]; +} + interface FormValues { name: string; platform: string; platform_account_key: string; tags?: string[]; cookies?: string; + fingerprint?: FingerprintValues; } +const spoofingOptions = [ + { value: 'font', label: '字体' }, + { value: 'audio', label: '音频' }, + { value: 'canvas', label: 'Canvas' }, + { value: 'clientrects', label: 'ClientRects' }, + { value: 'gpu', label: 'GPU' }, +]; + export default function Page() { const [form] = Form.useForm(); const [busy, setBusy] = useState(false); @@ -31,6 +53,21 @@ export default function Page() { }; // cookies 非必填:留空代表创建后走扫码登录,凭据由后续同步链路补齐 if (values.cookies?.trim()) data.cookies = values.cookies.trim(); + // 指纹表单:只提交非空项;留空项由后端使用浏览器默认值 + const fp = values.fingerprint; + if (fp) { + const fingerprint: Record = {}; + if (fp.platform) fingerprint.platform = fp.platform; + if (fp.platform_version?.trim()) fingerprint.platform_version = fp.platform_version.trim(); + if (fp.brand) fingerprint.brand = fp.brand; + if (fp.brand_version?.trim()) fingerprint.brand_version = fp.brand_version.trim(); + if (fp.hardware_concurrency) fingerprint.hardware_concurrency = fp.hardware_concurrency; + if (fp.lang?.trim()) fingerprint.lang = fp.lang.trim(); + if (fp.accept_lang?.trim()) fingerprint.accept_lang = fp.accept_lang.trim(); + if (fp.timezone?.trim()) fingerprint.timezone = fp.timezone.trim(); + if (fp.disable_spoofing?.length) fingerprint.disable_spoofing = fp.disable_spoofing.join(','); + if (Object.keys(fingerprint).length) data.fingerprint = fingerprint; + } const result = await create('accounts', data); const id = result?.data?.id ?? result?.id; if (!id) throw new Error('创建账号未返回账号 ID'); @@ -67,11 +104,95 @@ export default function Page() { > + + + 留空项使用浏览器默认值;seed 由系统按账号自动派生并保证唯一,代理由网络出口统一管理,均不在此配置。 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +