feat(api): 账号环境指纹更新端点——PUT /creator/accounts/:id/fingerprint 单独保存并按需重启
- Store.UpdateAccountFingerprint:更新账号绑定环境指纹,seed 保持账号派生值,携带代理直接拒绝(门禁对齐 CreateBoundEnv) - 运行中的环境先停后启以应用新指纹,未运行仅落库下次启动生效;账号资源锁防并发冲突 - accountEnvironmentView 内联 fingerprint 回显,编辑页预填数据源 - 覆盖:store 层 seed 保留/校验拒绝/JSON 往返、路由畸形入参、PG 端到端(保存/回显/持久化/400/404)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user