feat(api): 账号环境指纹更新端点——PUT /creator/accounts/:id/fingerprint 单独保存并按需重启

- Store.UpdateAccountFingerprint:更新账号绑定环境指纹,seed 保持账号派生值,携带代理直接拒绝(门禁对齐 CreateBoundEnv)
- 运行中的环境先停后启以应用新指纹,未运行仅落库下次启动生效;账号资源锁防并发冲突
- accountEnvironmentView 内联 fingerprint 回显,编辑页预填数据源
- 覆盖:store 层 seed 保留/校验拒绝/JSON 往返、路由畸形入参、PG 端到端(保存/回显/持久化/400/404)
This commit is contained in:
2026-09-30 17:12:56 +08:00
parent 50aada884f
commit 2c4fe307f7
8 changed files with 321 additions and 1 deletions
@@ -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,
}
}
+81 -1
View File
@@ -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())
}
}
+60
View File
@@ -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)
+16
View File
@@ -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()
+41
View File
@@ -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)
}
}