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()