diff --git a/internal/controlplane/api/accounts_operations.go b/internal/controlplane/api/accounts_operations.go index 28c3129..2698d14 100644 --- a/internal/controlplane/api/accounts_operations.go +++ b/internal/controlplane/api/accounts_operations.go @@ -296,6 +296,8 @@ func accountEnvironmentView(environment hub.EnvironmentContext) map[string]any { "runtime_cleanup_pending": environment.RuntimeCleanupPending, "network_exit_id": environment.Exit.ID, "network_exit_health": environment.Exit.HealthStatus, "schedule_status": scheduleStatus, "schedule_block_reason": scheduleBlockReason, + // 指纹回显:编辑页预填用;seed 属账号派生、代理属出口管理,均不外发。 + "fingerprint": environment.Fingerprint, } } diff --git a/internal/controlplane/api/app_migrated_test.go b/internal/controlplane/api/app_migrated_test.go index 919645c..76a389c 100644 --- a/internal/controlplane/api/app_migrated_test.go +++ b/internal/controlplane/api/app_migrated_test.go @@ -304,7 +304,7 @@ func TestCreatorFixtureRoutesPostgres(t *testing.T) { ID string `json:"id"` WorkCount int64 `json:"work_count"` LatestPublishedAt *string `json:"latest_published_at"` - Environment *struct { + Environment *struct { Gateway string `json:"gateway"` Name string `json:"name"` RuntimeID string `json:"runtime_id"` @@ -481,3 +481,83 @@ func authenticate(username, password string) fiber.Handler { return c.Next() } } + +func TestCreatorFingerprintRouteAgainstPostgres(t *testing.T) { + databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") + if databaseURL == "" { + t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run creator route coverage") + } + ctx := context.Background() + databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL) + phaseAStore, err := account.Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = phaseAStore.Close() }) + hubStore, err := hub.Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = hubStore.Close() }) + creatorStore, err := creator.Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = creatorStore.Close() }) + + credentials := &testCredentialBridge{values: make(map[string]string)} + if err := phaseAStore.CreateAccount(ctx, account.Account{ + ID: "fp-account", Name: "Fingerprint Account", Platform: creator.PlatformDouyin, + PlatformAccountKey: "fp-platform", Tags: []string{}, Cookies: "", + CredentialReference: account.CredentialReference{ID: "fp-account-credential", Provider: "os_keyring"}, + CredentialKey: "creatorhub/fp-account/cookies", + }, credentials); err != nil { + t.Fatal(err) + } + // 账号即环境:网关 + 绑定环境(与生产创建链路同构,账号默认 paused 满足就绪门禁)。 + if _, err := hubStore.CreateGateway(ctx, "gw-fp", "http://127.0.0.1:8081", "unit-test-gateway-token"); err != nil { + t.Fatal(err) + } + if _, created, err := hubStore.CreateBoundEnv(ctx, hub.Env{Alias: "fp-account", Name: "fp-account", Gateway: "gw-fp"}, "fp-account", ""); err != nil || !created { + t.Fatalf("bind account environment: created=%v err=%v", created, err) + } + app := newHandlerWithCreator(t.TempDir(), "operator", "unit-test-password", phaseAStore, hubStore, nil, creatorStore) + + // 指纹保存:合法入参 → 200,且响应内联回显更新后的指纹。 + body := `{"platform":"windows","timezone":"Asia/Shanghai","lang":"zh-CN","brand":"Edge","hardware_concurrency":8,"disable_spoofing":"font,gpu"}` + response := do(app, http.MethodPut, "/api/creator/accounts/fp-account/fingerprint", body, "operator", "unit-test-password") + if response.Code != http.StatusOK { + t.Fatalf("update fingerprint: %d %s", response.Code, response.Body.String()) + } + for _, fragment := range []string{`"fingerprint"`, `"platform":"windows"`, `"timezone":"Asia/Shanghai"`, `"brand":"Edge"`, `"disable_spoofing":"font,gpu"`} { + if !strings.Contains(response.Body.String(), fragment) { + t.Fatalf("fingerprint view must echo updated values (%s): %s", fragment, response.Body.String()) + } + } + + // 持久化:重新从 store 读取,指纹保持;seed 保持账号派生值不被表单覆盖。 + environment, err := hubStore.GetEnvironmentContextForAccount(ctx, "fp-account") + if err != nil { + t.Fatal(err) + } + if environment.Fingerprint.Platform != "windows" || environment.Fingerprint.Timezone != "Asia/Shanghai" || + environment.Fingerprint.Brand != "Edge" || environment.Fingerprint.Seed < 1 { + t.Fatalf("fingerprint not persisted: %+v", environment.Fingerprint) + } + + // 非法入参(平台值越界)→ 400,且不污染已保存指纹。 + badResponse := do(app, http.MethodPut, "/api/creator/accounts/fp-account/fingerprint", `{"platform":"android"}`, "operator", "unit-test-password") + if badResponse.Code != http.StatusBadRequest { + t.Fatalf("invalid fingerprint must be rejected: %d %s", badResponse.Code, badResponse.Body.String()) + } + environment, err = hubStore.GetEnvironmentContextForAccount(ctx, "fp-account") + if err != nil || environment.Fingerprint.Platform != "windows" { + t.Fatalf("failed update must not persist: %+v err=%v", environment.Fingerprint, err) + } + + // 未知账号 → 404。 + missingResponse := do(app, http.MethodPut, "/api/creator/accounts/missing/fingerprint", `{}`, "operator", "unit-test-password") + if missingResponse.Code != http.StatusNotFound { + t.Fatalf("unknown account must 404: %d %s", missingResponse.Code, missingResponse.Body.String()) + } +} diff --git a/internal/controlplane/api/creator.go b/internal/controlplane/api/creator.go index 2cbd8c9..8661092 100644 --- a/internal/controlplane/api/creator.go +++ b/internal/controlplane/api/creator.go @@ -194,6 +194,21 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto } return c.JSON(profile) }) + // 编辑页指纹浏览器环境:单独保存;运行中的环境停后以新指纹重启,未运行仅落库下次启动生效。 + app.Put("/api/creator/accounts/:id/fingerprint", func(c fiber.Ctx) error { + var input hub.Fingerprint + if err := decodeCreator(c, &input); err != nil { + return creatorError(c, err) + } + if hubStore == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(map[string]string{"error": "environment store unavailable"}) + } + view, err := updateAccountEnvironmentFingerprint(c.Context(), hubStore, c.Params("id"), input) + if err != nil { + return creatorError(c, err) + } + return c.JSON(view) + }) app.Put("/api/creator/accounts/:id/tags", func(c fiber.Ctx) error { var input struct { Tags []string `json:"tags"` @@ -739,6 +754,51 @@ func startCreatorEnvironment(ctx context.Context, store HubStore, environment hu return startBrowserRuntime(ctx, store, defaultNetworkExitProbe(), environment, finish) } +// updateAccountEnvironmentFingerprint 编辑页指纹浏览器环境单独保存: +// 运行中的环境先停后启以应用新指纹;未运行仅落库,下次启动自然生效。 +// seed 属账号派生、代理属出口管理,Store 层拒绝透传入参携带。 +func updateAccountEnvironmentFingerprint(ctx context.Context, hubStore HubStore, accountID string, input hub.Fingerprint) (map[string]any, error) { + unlock, err := lockAccountResources(ctx, hubStore, accountID) + if err != nil { + return nil, fmt.Errorf("%w: lock account environment: %v", creator.ErrUnavailable, err) + } + defer unlock() + + environment, err := hubStore.GetEnvironmentContextForAccount(ctx, accountID) + if errors.Is(err, hub.ErrNotFound) { + return nil, creator.ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("%w: load account environment: %v", creator.ErrUnavailable, err) + } + restart := environment.RuntimeID != "" + if restart { + if err := stopEnvironmentRuntime(ctx, hubStore, environment); err != nil { + return nil, fmt.Errorf("%w: stop environment before fingerprint update: %v", creator.ErrUnavailable, err) + } + } + environment, err = hubStore.UpdateAccountFingerprint(ctx, accountID, input) + if errors.Is(err, hub.ErrInvalid) { + return nil, creator.ErrInvalid + } + if errors.Is(err, hub.ErrNotFound) { + return nil, creator.ErrNotFound + } + if err != nil { + return nil, fmt.Errorf("%w: persist fingerprint: %v", creator.ErrUnavailable, err) + } + if restart { + if err := startCreatorEnvironment(ctx, hubStore, environment); err != nil { + return nil, fmt.Errorf("%w: restart environment with updated fingerprint: %v", creator.ErrUnavailable, err) + } + environment, err = hubStore.GetEnvironmentContextForAccount(ctx, accountID) + if err != nil { + return nil, fmt.Errorf("%w: reload environment after fingerprint restart: %v", creator.ErrUnavailable, err) + } + } + return accountEnvironmentView(environment), nil +} + func creatorLoginQRCode(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore HubStore, accountID string) (map[string]any, error) { if store == nil || phaseAStore == nil || hubStore == nil || strings.TrimSpace(accountID) == "" { return nil, creator.ErrUnavailable diff --git a/internal/controlplane/api/creator_route_validation_test.go b/internal/controlplane/api/creator_route_validation_test.go index 30609c6..27d090a 100644 --- a/internal/controlplane/api/creator_route_validation_test.go +++ b/internal/controlplane/api/creator_route_validation_test.go @@ -46,6 +46,7 @@ func TestCreatorWriteRoutesRejectMalformedInputBeforeStoreAccess(t *testing.T) { }{ {method: http.MethodPut, path: "/api/creator/settings"}, {method: http.MethodPut, path: "/api/creator/accounts/account-1/profile"}, + {method: http.MethodPut, path: "/api/creator/accounts/account-1/fingerprint"}, {method: http.MethodPut, path: "/api/creator/accounts/account-1/tags"}, {method: http.MethodPost, path: "/api/creator/accounts/account-1/login-result"}, {method: http.MethodPost, path: "/api/creator/accounts/account-1/big-account"}, diff --git a/internal/controlplane/api/environments.go b/internal/controlplane/api/environments.go index e1e32f9..7ab9c06 100644 --- a/internal/controlplane/api/environments.go +++ b/internal/controlplane/api/environments.go @@ -38,6 +38,7 @@ type HubStore interface { CreateBoundEnv(ctx context.Context, env hub.Env, accountID, exitID string) (hub.EnvironmentContext, bool, error) GetEnvironmentContext(ctx context.Context, alias string) (hub.EnvironmentContext, error) GetEnvironmentContextForAccount(ctx context.Context, accountID string) (hub.EnvironmentContext, error) + UpdateAccountFingerprint(ctx context.Context, accountID string, fingerprint hub.Fingerprint) (hub.EnvironmentContext, error) ValidateEnvironmentRebind(ctx context.Context, alias, exitID string, expectedBindingVersion int64) error RebindEnvironment(ctx context.Context, alias, exitID, runtimeID string, expectedBindingVersion int64, networkID ...string) (hub.EnvironmentContext, error) ActivateRuntime(ctx context.Context, alias, runtimeID string, bindingVersion int64, exitID string, networkID ...string) (hub.EnvironmentContext, error) diff --git a/internal/controlplane/api/hub_test.go b/internal/controlplane/api/hub_test.go index 4c5e152..daaab5a 100644 --- a/internal/controlplane/api/hub_test.go +++ b/internal/controlplane/api/hub_test.go @@ -350,6 +350,22 @@ func (s *memoryStore) GetEnvironmentContextForAccount(ctx context.Context, accou s.mu.Unlock() return hub.EnvironmentContext{}, hub.ErrNotFound } +func (s *memoryStore) UpdateAccountFingerprint(_ context.Context, accountID string, fingerprint hub.Fingerprint) (hub.EnvironmentContext, error) { + if fingerprint.ProxyServer != "" || fingerprint.Validate() != nil { + return hub.EnvironmentContext{}, hub.ErrInvalid + } + s.mu.Lock() + defer s.mu.Unlock() + for alias, bound := range s.bindings { + if bound.AccountID == accountID { + fingerprint.Seed = bound.Fingerprint.Seed + bound.Fingerprint = fingerprint + s.bindings[alias] = bound + return bound, nil + } + } + return hub.EnvironmentContext{}, hub.ErrNotFound +} func (s *memoryStore) ValidateEnvironmentRebind(_ context.Context, alias, exitID string, expectedBindingVersion int64) error { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/environment/environment.go b/internal/environment/environment.go index a905548..5846522 100644 --- a/internal/environment/environment.go +++ b/internal/environment/environment.go @@ -523,6 +523,47 @@ func (s *Store) GetEnvironmentContextForAccount(ctx context.Context, accountID s return s.GetEnvironmentContext(ctx, alias) } +// UpdateAccountFingerprint 更新账号绑定环境的指纹参数并返回最新环境上下文。 +// seed 保持账号派生值不可改,代理由网络出口管理不可存(直连环境强制空代理)。 +func (s *Store) UpdateAccountFingerprint(ctx context.Context, accountID string, fingerprint Fingerprint) (EnvironmentContext, error) { + // 门禁对齐 CreateBoundEnv:存储层拒绝携带代理的指纹(代理由网络出口管理,传错入口直接报错)。 + if !aliasPattern.MatchString(accountID) || fingerprint.ProxyServer != "" || fingerprint.Validate() != nil { + return EnvironmentContext{}, ErrInvalid + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return EnvironmentContext{}, errors.New("begin account fingerprint update") + } + defer tx.Rollback() + var alias string + var encoded []byte + err = tx.QueryRowContext(ctx, ` + SELECT environment.alias, environment.fingerprint + FROM browser_env environment + JOIN social_account account ON account.id = environment.account_id + WHERE account.account_id = $1 + FOR UPDATE OF environment`, accountID).Scan(&alias, &encoded) + if err != nil { + return EnvironmentContext{}, rowError(err) + } + var stored Fingerprint + if err := json.Unmarshal(encoded, &stored); err != nil { + return EnvironmentContext{}, errors.New("decode environment fingerprint") + } + fingerprint.Seed = stored.Seed + updated, err := json.Marshal(fingerprint) + if err != nil { + return EnvironmentContext{}, errors.New("encode environment fingerprint") + } + if _, err := tx.ExecContext(ctx, `UPDATE browser_env SET fingerprint = $2 WHERE alias = $1`, alias, updated); err != nil { + return EnvironmentContext{}, errors.New("update environment fingerprint") + } + if err := commitHub(tx); err != nil { + return EnvironmentContext{}, err + } + return s.GetEnvironmentContext(ctx, alias) +} + const runtimeUseLeaseDuration = time.Minute // MissingRuntimeID 标记「创建结果未知」的清理代:网关侧无实物 ID 可供 fence, diff --git a/internal/environment/fingerprint_update_test.go b/internal/environment/fingerprint_update_test.go new file mode 100644 index 0000000..fbfa935 --- /dev/null +++ b/internal/environment/fingerprint_update_test.go @@ -0,0 +1,119 @@ +package environment + +import ( + "context" + "encoding/json" + "errors" + "os" + "strings" + "testing" +) + +// seedFingerprintAccount 造一条可直接建绑定的账号 + 网关,返回 store。 +func seedFingerprintAccount(t *testing.T, ctx context.Context, accountID string) *Store { + t.Helper() + databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") + if databaseURL == "" { + t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage") + } + databaseURL = isolatedDatabaseURL(t, databaseURL) + store := openFullyMigratedHub(t, ctx, databaseURL) + t.Cleanup(func() { _ = store.Close() }) + if _, err := store.CreateGateway(ctx, "gw-fp", "http://127.0.0.1:8081", ""); err != nil { + t.Fatal(err) + } + if _, err := store.db.ExecContext(ctx, ` + INSERT INTO social_account (account_id, credential_provider, credential_key, platform, platform_account_key, status) + VALUES ($1, 'os_keyring', $2, 'mock', $1, 'paused')`, accountID, "creatorhub/"+accountID); err != nil { + t.Fatal(err) + } + return store +} + +func TestUpdateAccountFingerprintPreservesDerivedSeed(t *testing.T) { + ctx := context.Background() + store := seedFingerprintAccount(t, ctx, "fp-owner") + if _, created, err := store.CreateBoundEnv(ctx, Env{Alias: "fp-owner", Name: "fp-owner", Gateway: "gw-fp", Fingerprint: Fingerprint{Timezone: "Asia/Shanghai"}}, "fp-owner", ""); err != nil || !created { + t.Fatalf("create bound env: created=%v err=%v", created, err) + } + + updated, err := store.UpdateAccountFingerprint(ctx, "fp-owner", Fingerprint{ + Platform: "linux", Timezone: "Asia/Tokyo", Lang: "ja-JP", + }) + if err != nil { + t.Fatalf("update account fingerprint: %v", err) + } + if updated.Fingerprint.Platform != "linux" || updated.Fingerprint.Timezone != "Asia/Tokyo" || updated.Fingerprint.Lang != "ja-JP" { + t.Fatalf("fingerprint fields not updated: %+v", updated.Fingerprint) + } + if updated.Fingerprint.Seed < 1 { + t.Fatalf("derived seed must be preserved, got %d", updated.Fingerprint.Seed) + } + // 直连环境不可携带代理:更新入口必须清洗代理字段。 + if updated.Fingerprint.ProxyServer != "" || updated.Fingerprint.DisableNonProxiedUDP { + t.Fatalf("proxy fields must stay empty: %+v", updated.Fingerprint) + } + // 更新对后续读取可见(落库持久化)。 + reloaded, err := store.GetEnvironmentContextForAccount(ctx, "fp-owner") + if err != nil || reloaded.Alias != "fp-owner" { + t.Fatalf("reload environment: %v", err) + } + if reloaded.Fingerprint.Timezone != "Asia/Tokyo" || reloaded.Fingerprint.Platform != "linux" { + t.Fatalf("update not persisted: %+v", reloaded.Fingerprint) + } +} + +func TestUpdateAccountFingerprintRejectsInvalidInput(t *testing.T) { + ctx := context.Background() + store := seedFingerprintAccount(t, ctx, "fp-owner") + if _, _, err := store.CreateBoundEnv(ctx, Env{Alias: "fp-owner", Name: "fp-owner", Gateway: "gw-fp"}, "fp-owner", ""); err != nil { + t.Fatal(err) + } + + if _, err := store.UpdateAccountFingerprint(ctx, "fp-owner", Fingerprint{Platform: "android"}); !errors.Is(err, ErrInvalid) { + t.Fatalf("expected ErrInvalid for bad platform, got %v", err) + } + if _, err := store.UpdateAccountFingerprint(ctx, "fp-owner", Fingerprint{ProxyServer: "socks5://proxy.example:1080", Timezone: "Asia/Shanghai"}); !errors.Is(err, ErrInvalid) { + t.Fatalf("stored fingerprint proxy must be rejected, got %v", err) + } + if _, err := store.UpdateAccountFingerprint(ctx, "fp-owner", Fingerprint{HardwareConcurrency: -1}); !errors.Is(err, ErrInvalid) { + t.Fatalf("expected ErrInvalid for bad concurrency, got %v", err) + } + if _, err := store.UpdateAccountFingerprint(ctx, "missing-owner", Fingerprint{Timezone: "Asia/Shanghai"}); !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound for unbound account, got %v", err) + } + // 失败更新不得污染落库值。 + current, err := store.GetEnvironmentContextForAccount(ctx, "fp-owner") + if err != nil { + t.Fatal(err) + } + if current.Fingerprint.Platform != "" || current.Fingerprint.Timezone != "" { + t.Fatalf("failed update must not persist: %+v", current.Fingerprint) + } +} + +func TestUpdateAccountFingerprintJSONRoundTrip(t *testing.T) { + ctx := context.Background() + store := seedFingerprintAccount(t, ctx, "fp-owner") + if _, _, err := store.CreateBoundEnv(ctx, Env{Alias: "fp-owner", Name: "fp-owner", Gateway: "gw-fp"}, "fp-owner", ""); err != nil { + t.Fatal(err) + } + if _, err := store.UpdateAccountFingerprint(ctx, "fp-owner", Fingerprint{ + Brand: "Edge", AcceptLang: "zh-CN,en-US", DisableSpoofing: "font,gpu", HardwareConcurrency: 8, + }); err != nil { + t.Fatal(err) + } + updated, err := store.GetEnvironmentContextForAccount(ctx, "fp-owner") + if err != nil { + t.Fatal(err) + } + if updated.Fingerprint.Brand != "Edge" || updated.Fingerprint.AcceptLang != "zh-CN,en-US" || + updated.Fingerprint.DisableSpoofing != "font,gpu" || updated.Fingerprint.HardwareConcurrency != 8 { + t.Fatalf("complex fingerprint not persisted: %+v", updated.Fingerprint) + } + // 落库 JSON 必须可再次反序列化(browser_env.fingerprint jsonb 契约)。 + encoded, err := json.Marshal(updated.Fingerprint) + if err != nil || !strings.Contains(string(encoded), `"brand":"Edge"`) { + t.Fatalf("fingerprint json roundtrip: %s err=%v", encoded, err) + } +}