From 799087fcb144f99d93a94113a7dd2b567d6b70ec Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 28 Sep 2026 22:30:31 +0800 Subject: [PATCH] =?UTF-8?q?test(api):=20=E8=B4=A6=E5=8F=B7=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E9=93=BE=E8=B7=AF=20PG=20=E9=9B=86=E6=88=90=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=20+=20=E6=B5=8B=E8=AF=95=E7=9B=B4=E5=86=99=20SQL=20?= =?UTF-8?q?=E9=80=82=E9=85=8D=20044=20=E5=88=97=E5=90=8D=EF=BC=88social=5F?= =?UTF-8?q?account/browser=5Fenv/audit=5Fevent=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controlplane/api/account_environment.go | 11 +- .../api/account_environment_unit_test.go | 124 ++++++++++++++---- internal/controlplane/api/environments.go | 1 - internal/controlplane/api/hub_test.go | 10 +- 4 files changed, 101 insertions(+), 45 deletions(-) diff --git a/internal/controlplane/api/account_environment.go b/internal/controlplane/api/account_environment.go index 254dfc5..8832d29 100644 --- a/internal/controlplane/api/account_environment.go +++ b/internal/controlplane/api/account_environment.go @@ -3,7 +3,6 @@ package api import ( "context" "errors" - "hash/fnv" hub "git.ipao.vip/rogee/creator-hub/internal/environment" ) @@ -11,14 +10,6 @@ import ( // 账号即环境:账号创建即绑定运行环境。 // 规则:alias = 账号文本 ID、唯一网关自动取、出口直连、seed 由账号 ID 派生。 -// deriveSeed 由账号文本 ID 确定性派生 fingerprint seed(1001..2147483647)。 -// 数字主键落地后改为 seed = 账号 bigint id + 1000,仅替换本函数。 -func deriveSeed(accountID string) int64 { - hasher := fnv.New64a() - _, _ = hasher.Write([]byte(accountID)) - return int64(hasher.Sum64()%(2147483647-1000)) + 1000 -} - // soleGateway 返回平台唯一网关;零配置绑定要求恰有一个网关: // 缺失返回 ErrNotFound,多个返回 ErrConflict(无法自动选择)。 func soleGateway(ctx context.Context, store HubStore) (hub.Gateway, error) { @@ -49,9 +40,9 @@ func ensureAccountEnvironment(ctx context.Context, store HubStore, accountID str if err != nil { return hub.EnvironmentContext{}, false, err } + // seed 由 CreateBoundEnv 从账号 bigint id + 1000 派生(数字主键)。 return store.CreateBoundEnv(ctx, hub.Env{ Alias: accountID, Name: accountID, Gateway: gateway.Name, - Fingerprint: hub.Fingerprint{Seed: deriveSeed(accountID)}, }, accountID, "") } diff --git a/internal/controlplane/api/account_environment_unit_test.go b/internal/controlplane/api/account_environment_unit_test.go index cb10056..aef0985 100644 --- a/internal/controlplane/api/account_environment_unit_test.go +++ b/internal/controlplane/api/account_environment_unit_test.go @@ -10,31 +10,11 @@ import ( "testing" accountdomain "git.ipao.vip/rogee/creator-hub/internal/account" + "git.ipao.vip/rogee/creator-hub/internal/creator" hub "git.ipao.vip/rogee/creator-hub/internal/environment" "github.com/gofiber/fiber/v3" ) -func TestDeriveSeed(t *testing.T) { - // 确定性:同 ID 派生同 seed - if deriveSeed("account-0123456789abcdef01234567") != deriveSeed("account-0123456789abcdef01234567") { - t.Fatal("deriveSeed must be deterministic") - } - // 值域:1001..2147483647(seed 上限约束,偏移 1000 对齐未来 bigint id + 1000) - for _, accountID := range []string{"account-a", "account-000000000000000000000000", "account-zzzzzzzzzzzzzzzzzzzzzzzz", ""} { - seed := deriveSeed(accountID) - if seed < 1001 || seed > 2147483647 { - t.Fatalf("seed out of range for %q: %d", accountID, seed) - } - } - // 不同 ID 派生不同 seed(固定向量,防回归) - if deriveSeed("account-a") == deriveSeed("account-b") { - t.Fatal("distinct accounts must derive distinct seeds") - } - if got := deriveSeed("account-a"); got != 1816671480 { - t.Fatalf("deriveSeed vector drifted: %d", got) - } -} - func TestAccountEnvironmentAutoBindingAndStart(t *testing.T) { databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") if databaseURL == "" { @@ -76,6 +56,12 @@ func TestAccountEnvironmentAutoBindingAndStart(t *testing.T) { t.Fatalf("expected 404 environment rebind without gateway, got %d: %s", response.Code, response.Body.String()) } + auditDB, err := sql.Open("pgx", databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = auditDB.Close() }) + // 注册唯一网关后:补建成功,alias=账号 ID、出口直连、seed 派生 if _, err := hubStore.CreateGateway(ctx, "gw-main", gatewayServer.URL, gateway.token); err != nil { t.Fatal(err) @@ -92,9 +78,13 @@ func TestAccountEnvironmentAutoBindingAndStart(t *testing.T) { if err := json.Unmarshal(response.Body.Bytes(), &bound); err != nil || bound.Alias != accountID || bound.Gateway != "gw-main" || !bound.Created { t.Fatalf("rebind payload: %s err=%v", response.Body.String(), err) } + var accountRowID int64 + if err := auditDB.QueryRowContext(ctx, `SELECT id FROM social_account WHERE account_id = $1`, accountID).Scan(&accountRowID); err != nil { + t.Fatal(err) + } environment, err := hubStore.GetEnvironmentContext(ctx, accountID) - if err != nil || environment.Fingerprint.Seed != deriveSeed(accountID) || environment.Exit.ID != "" { - t.Fatalf("auto-bound environment: %#v err=%v", environment, err) + if err != nil || environment.Fingerprint.Seed != accountRowID+1000 || environment.Exit.ID != "" { + t.Fatalf("auto-bound environment: %#v err=%v (account row id %d)", environment, err, accountRowID) } // 幂等:重复补建返回既有环境 response = do(app, http.MethodPost, "/api/phase-a/accounts/"+accountID+"/environment", "") @@ -114,14 +104,9 @@ func TestAccountEnvironmentAutoBindingAndStart(t *testing.T) { if err != nil || environment.RuntimeID == "" { t.Fatalf("start must activate the environment runtime: %#v err=%v", environment, err) } - auditDB, err := sql.Open("pgx", databaseURL) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = auditDB.Close() }) var startAudits int if err := auditDB.QueryRowContext(ctx, - `SELECT count(*) FROM audit_event WHERE account_id = $1 AND action = 'start' AND reason_code IN ('action_requested','environment_started')`, accountID).Scan(&startAudits); err != nil || startAudits != 2 { + `SELECT count(*) FROM audit_event a JOIN social_account sa ON sa.id = a.account_id WHERE sa.account_id = $1 AND action = 'start' AND reason_code IN ('action_requested','environment_started')`, accountID).Scan(&startAudits); err != nil || startAudits != 2 { t.Fatalf("start audit pair missing: rows=%d err=%v", startAudits, err) } @@ -138,3 +123,84 @@ func TestAccountEnvironmentAutoBindingAndStart(t *testing.T) { t.Fatalf("start conflict payload: %s err=%v", response.Body.String(), err) } } + +func TestAccountEnvironmentDeletionLifecycle(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) + creatorStore, err := creator.Open(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = creatorStore.Close() }) + app := fiber.New() + RegisterAccountRoutes(app, accountStore, hubStore, &testCredentialBridge{values: map[string]string{}}) + RegisterAccountDeletion(app, accountStore, hubStore, creatorStore, &testCredentialBridge{values: map[string]string{}}) + if _, err := hubStore.CreateGateway(ctx, "gw-main", gatewayServer.URL, gateway.token); err != nil { + t.Fatal(err) + } + + // 删除不存在的账号 → 404 + if response := do(app, http.MethodDelete, "/api/phase-a/accounts/account-missing0000000000000", ""); response.Code != http.StatusNotFound { + t.Fatalf("expected 404 deleting missing account, got %d: %s", response.Code, response.Body.String()) + } + + // 创建即绑定 → 审计与浏览器环境随账号生成 + response := do(app, http.MethodPost, "/api/phase-a/accounts", + `{"name":"待删账号","platform":"douyin","platform_account_key":"key-delete-1","cookies":"sessionid=1"}`) + if response.Code != http.StatusCreated { + t.Fatalf("expected 201 create account, 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) + } + accountID := created.ID + if _, err := hubStore.GetEnvironmentContext(ctx, accountID); err != nil { + t.Fatalf("auto-bound environment missing: %v", err) + } + + // 删除账号 → 204;账号、环境与审计全部清除 + if response := do(app, http.MethodDelete, "/api/phase-a/accounts/"+accountID, ""); response.Code != http.StatusNoContent { + t.Fatalf("expected 204 delete account, got %d: %s", response.Code, response.Body.String()) + } + if _, err := accountStore.GetAccount(ctx, accountID); err == nil { + t.Fatal("account row must be gone after delete") + } + if _, err := hubStore.GetEnvironmentContext(ctx, accountID); err == nil { + t.Fatal("environment must be gone after delete") + } + auditDB, err := sql.Open("pgx", databaseURL) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = auditDB.Close() }) + var auditCount int + if err := auditDB.QueryRowContext(ctx, ` + SELECT count(*) FROM audit_event audit + JOIN social_account account ON account.id = audit.account_id + WHERE account.account_id = $1`, accountID).Scan(&auditCount); err != nil { + t.Fatal(err) + } + if auditCount != 0 { + t.Fatalf("account audit events must be purged with the account: rows=%d", auditCount) + } +} diff --git a/internal/controlplane/api/environments.go b/internal/controlplane/api/environments.go index 6f2bf21..1ba5fe6 100644 --- a/internal/controlplane/api/environments.go +++ b/internal/controlplane/api/environments.go @@ -26,7 +26,6 @@ type HubStore interface { ListGateways(ctx context.Context) ([]hub.Gateway, error) GetGateway(ctx context.Context, name string) (hub.Gateway, error) DeleteGateway(ctx context.Context, name string) error - CreateEnv(ctx context.Context, env hub.Env) error ListEnvs(ctx context.Context) ([]hub.Env, error) GetEnv(ctx context.Context, alias string) (hub.Env, error) CreateNetworkExit(ctx context.Context, exit hub.NetworkExit) (hub.NetworkExit, error) diff --git a/internal/controlplane/api/hub_test.go b/internal/controlplane/api/hub_test.go index 043a6e9..f481da5 100644 --- a/internal/controlplane/api/hub_test.go +++ b/internal/controlplane/api/hub_test.go @@ -184,7 +184,7 @@ func (s *memoryStore) GetGateway(_ context.Context, name string) (hub.Gateway, e return gateway, nil } func (s *memoryStore) DeleteGateway(context.Context, string) error { return nil } -func (s *memoryStore) CreateEnv(_ context.Context, env hub.Env) error { +func (s *memoryStore) insertEnv(_ context.Context, env hub.Env) error { s.mu.Lock() defer s.mu.Unlock() if _, exists := s.envs[env.Alias]; exists { @@ -287,7 +287,7 @@ func (s *memoryStore) CreateBoundEnv(ctx context.Context, env hub.Env, accountID return existing, false, nil } s.mu.Unlock() - if err := s.CreateEnv(ctx, env); err != nil { + if err := s.insertEnv(ctx, env); err != nil { return hub.EnvironmentContext{}, false, err } s.mu.Lock() @@ -1362,7 +1362,7 @@ func TestCreateBrowserRejectsInvalidFingerprintBeforeSideEffects(t *testing.T) { app := newTestApp(t, store, gateway) response := do(app, http.MethodPost, "/api/browsers", - `{"alias":"account-a","name":"甲","gateway":"gw-1","fingerprint":{"seed":0}}`) + `{"alias":"account-a","name":"甲","gateway":"gw-1","fingerprint":{"seed":-1}}`) if response.Code != http.StatusBadRequest { t.Fatalf("expected 400 for invalid fingerprint, got %d: %s", response.Code, response.Body.String()) } @@ -1448,7 +1448,7 @@ func setFixtureAccountStatus(t *testing.T, databaseURL, status string) { t.Fatal(err) } defer db.Close() - if _, err := db.Exec(`UPDATE social_account SET status = $1 WHERE id = 'account-a'`, status); err != nil { + if _, err := db.Exec(`UPDATE social_account SET status = $1 WHERE account_id = 'account-a'`, status); err != nil { t.Fatal(err) } } @@ -2683,7 +2683,7 @@ func TestPostgresNonRunnableReconcileAuditsRuntimeRelease(t *testing.T) { t.Fatal(err) } if _, err := fixture.db.ExecContext(ctx, ` - UPDATE social_account SET status = 'paused', authorization_status = $1 WHERE id = 'account-a'`, authorization); err != nil { + UPDATE social_account SET status = 'paused', authorization_status = $1 WHERE account_id = 'account-a'`, authorization); err != nil { t.Fatal(err) } if observation == "stopped" {