From 0879d9fd25b2fc14ffa866b83eb45c9068e69247 Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 28 Sep 2026 19:48:55 +0800 Subject: [PATCH] =?UTF-8?q?refactor(schema):=20=E8=BF=81=E7=A7=BB=20043=20?= =?UTF-8?q?schema=20=E6=94=B6=E6=95=9B=E2=80=94=E2=80=9442=E2=86=9219=20?= =?UTF-8?q?=E5=BC=A0=E8=A1=A8=EF=BC=8C=E7=BB=9F=E4=B8=80=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=20runner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移 043 一次到位:runtime_instance/environment_binding 并入 browser_env(账号即环境)、 credential_reference 并入 social_account、work_source/metric_plan 并入 creator_work、 sync 租约并入 creator_collection_checkpoint(kind='sync')、audit_event 瘦列、 creator_settings 去转写列、TRUNCATE 账号域(竞品采集数据无损保留) - 统一迁移 runner:environment.Open 串起 001 账号基座 + hub 链 + 038 creator 版本回填 (v+1000) + creator 1017-1042 + 043;account/creator Open 不再迁移 - 三域 store SQL 全量适配:CreateBoundEnv 就绪门禁、SetRuntimeCleanupPending fence 语义(未知代 MissingRuntimeID 允许登记并释放活跃实例)、metric_plan 列并入 work、 checkpoint sync 租约、deletion 链路重写 - api 适配:envView 去 runtime_instance_id、audit filter 去任务列、browser_env 单表查询 - 测试:迁移 043 形态探针、迁移不可变映射新路径、legacy 迁移重放子测试重写、 fence/出口生命周期/单来源 upsert 语义覆盖;CREATORHUB_POSTGRES_TEST_URL 全绿, environment 70%/creator 67.5% 覆盖率,tsc 通过 --- ...-29-schema-consolidation-and-numeric-id.md | 104 ++++++ internal/account/deletion.go | 33 +- .../migrations/036_runtime_use_leases.sql | 36 --- internal/account/store.go | 179 ++++------- internal/account/store_test.go | 13 +- .../controlplane/api/accounts_operations.go | 5 +- .../controlplane/api/app_migrated_test.go | 8 +- internal/controlplane/api/creator.go | 2 +- internal/controlplane/api/douyin.go | 3 +- internal/controlplane/api/douyin_test.go | 4 +- internal/controlplane/api/environments.go | 42 ++- .../api/gateway_browser_unit_test.go | 2 - internal/controlplane/api/hub_test.go | 230 +++++++------- internal/creator/accounts.go | 14 +- internal/creator/content.go | 54 +--- internal/creator/deletion.go | 54 +--- internal/creator/integration_test.go | 19 +- internal/creator/metrics.go | 75 ++--- internal/creator/models.go | 59 ++-- internal/creator/rules.go | 14 +- internal/creator/settings.go | 33 +- internal/creator/source_lease.go | 28 +- internal/creator/store.go | 148 --------- internal/environment/environment.go | 300 ++++++++---------- .../legacy_browser_version_test.go | 3 - .../environment/migration043_probe_test.go | 75 +++++ internal/environment/migration_test.go | 178 ++--------- .../migrations/001_phase_a.sql | 0 .../038_creator_registration_backfill.sql | 14 + .../migrations/043_schema_consolidation.sql | 118 +++++++ .../migrations/1017_creator.sql} | 0 .../1018_collection_checkpoints.sql} | 0 .../migrations/1019_metric_plans.sql} | 0 .../1020_competitor_sync_leases.sql} | 0 .../migrations/1021_work_sources.sql} | 0 .../1022_collection_lease_tokens.sql} | 0 .../migrations/1023_password_references.sql} | 0 .../1024_event_processing_times.sql} | 0 .../1025_competitor_sync_tokens.sql} | 0 .../migrations/1026_event_message_text.sql} | 0 .../1027_creator_listener_state.sql} | 0 .../1028_creator_event_message_type.sql} | 0 .../1029_creator_source_sync_lease.sql} | 0 .../1030_creator_event_gateway_time.sql} | 0 .../migrations/1031_raw_payloads.sql} | 0 .../1032_douyin_release_remediation.sql} | 0 .../migrations/1033_competitor_tags.sql} | 0 .../1034_competitor_tags_repair.sql} | 0 .../migrations/1035_account_deletion.sql} | 0 .../migrations/1036_competitor_unique_id.sql} | 0 .../1037_competitor_share_jobs.sql} | 0 .../1038_douyin_only_competitor_profile.sql} | 0 .../migrations/1039_work_collect_count.sql} | 0 .../migrations/1040_work_play_count.sql} | 0 .../1041_creator_account_metric.sql} | 0 .../migrations/1042_work_cover_cache.sql} | 0 internal/environment/store.go | 105 +++++- internal/environment/store_test.go | 225 ++++++++++--- 58 files changed, 1110 insertions(+), 1067 deletions(-) create mode 100644 docs/plans/2026-09-29-schema-consolidation-and-numeric-id.md delete mode 100644 internal/account/migrations/036_runtime_use_leases.sql create mode 100644 internal/environment/migration043_probe_test.go rename internal/{account => environment}/migrations/001_phase_a.sql (100%) create mode 100644 internal/environment/migrations/038_creator_registration_backfill.sql create mode 100644 internal/environment/migrations/043_schema_consolidation.sql rename internal/{creator/migrations/017_creator.sql => environment/migrations/1017_creator.sql} (100%) rename internal/{creator/migrations/018_collection_checkpoints.sql => environment/migrations/1018_collection_checkpoints.sql} (100%) rename internal/{creator/migrations/019_metric_plans.sql => environment/migrations/1019_metric_plans.sql} (100%) rename internal/{creator/migrations/020_competitor_sync_leases.sql => environment/migrations/1020_competitor_sync_leases.sql} (100%) rename internal/{creator/migrations/021_work_sources.sql => environment/migrations/1021_work_sources.sql} (100%) rename internal/{creator/migrations/022_collection_lease_tokens.sql => environment/migrations/1022_collection_lease_tokens.sql} (100%) rename internal/{creator/migrations/023_password_references.sql => environment/migrations/1023_password_references.sql} (100%) rename internal/{creator/migrations/024_event_processing_times.sql => environment/migrations/1024_event_processing_times.sql} (100%) rename internal/{creator/migrations/025_competitor_sync_tokens.sql => environment/migrations/1025_competitor_sync_tokens.sql} (100%) rename internal/{creator/migrations/026_event_message_text.sql => environment/migrations/1026_event_message_text.sql} (100%) rename internal/{creator/migrations/027_creator_listener_state.sql => environment/migrations/1027_creator_listener_state.sql} (100%) rename internal/{creator/migrations/028_creator_event_message_type.sql => environment/migrations/1028_creator_event_message_type.sql} (100%) rename internal/{creator/migrations/029_creator_source_sync_lease.sql => environment/migrations/1029_creator_source_sync_lease.sql} (100%) rename internal/{creator/migrations/030_creator_event_gateway_time.sql => environment/migrations/1030_creator_event_gateway_time.sql} (100%) rename internal/{creator/migrations/031_raw_payloads.sql => environment/migrations/1031_raw_payloads.sql} (100%) rename internal/{creator/migrations/032_douyin_release_remediation.sql => environment/migrations/1032_douyin_release_remediation.sql} (100%) rename internal/{creator/migrations/033_competitor_tags.sql => environment/migrations/1033_competitor_tags.sql} (100%) rename internal/{creator/migrations/034_competitor_tags_repair.sql => environment/migrations/1034_competitor_tags_repair.sql} (100%) rename internal/{creator/migrations/035_account_deletion.sql => environment/migrations/1035_account_deletion.sql} (100%) rename internal/{creator/migrations/036_competitor_unique_id.sql => environment/migrations/1036_competitor_unique_id.sql} (100%) rename internal/{creator/migrations/037_competitor_share_jobs.sql => environment/migrations/1037_competitor_share_jobs.sql} (100%) rename internal/{creator/migrations/038_douyin_only_competitor_profile.sql => environment/migrations/1038_douyin_only_competitor_profile.sql} (100%) rename internal/{creator/migrations/039_work_collect_count.sql => environment/migrations/1039_work_collect_count.sql} (100%) rename internal/{creator/migrations/040_work_play_count.sql => environment/migrations/1040_work_play_count.sql} (100%) rename internal/{creator/migrations/041_creator_account_metric.sql => environment/migrations/1041_creator_account_metric.sql} (100%) rename internal/{creator/migrations/042_work_cover_cache.sql => environment/migrations/1042_work_cover_cache.sql} (100%) diff --git a/docs/plans/2026-09-29-schema-consolidation-and-numeric-id.md b/docs/plans/2026-09-29-schema-consolidation-and-numeric-id.md new file mode 100644 index 0000000..c0cbeee --- /dev/null +++ b/docs/plans/2026-09-29-schema-consolidation-and-numeric-id.md @@ -0,0 +1,104 @@ +# Schema 收敛与数字主键重构:迭代计划 + +> 状态:已评审拍板(2026-09-29)。范围:砍空转链路 → 同域合并瘦身 → 数字主键重构。 +> 前置决策记录: +> - fingerprint seed 与账号自增 id 绑定:seed = 账号 id + 1000,创建账号时即绑定运行环境,零用户配置项。 +> - 不做存量兼容迁移:账号域历史数据直接清空(TRUNCATE);竞品采集数据无损保留。 +> - 空转链路判死:任务发布链路、自动运营链路、素材链路,从上线至今 0 条数据,直接删除(需要时从 git 历史捞回)。 + +## 1. 目标 + +1. 表从 42 张收敛到 19 张,消灭 1:1 冗余表、重复租约模式、双迁移系统。 +2. 全库数字主键:每张表 `id bigint GENERATED ALWAYS AS IDENTITY` 主键,文本业务标识降级为 UNIQUE 列,FK 全部重指 bigint。 +3. 「账号即运行环境」:创建账号即绑定环境(seed = 账号 id + 1000、网关唯一自动取、出口默认直连),账号启动/停止 = 环境启动/停止,两态开关。 +4. 删除 `/browsers` 独立菜单与创建流程,环境信息并入账号详情页。 + +## 2. 表收敛总表(42 → 19) + +### 2.1 直接删除(14 张 + 对应代码) + +| 表 | 连带删除 | +|---|---| +| content_draft、confirmation、operation_task、execution_attempt | 任务中心/草稿核对/attempt 详情 4 个页面、account store 任务链函数(28 个)、相关 API 路由 | +| creator_event、creator_event_strategy_trace、creator_strategy、creator_operation、creator_conversation、creator_message、creator_cooldown、creator_listener_state、creator_listener_boundary | 运营工作台整套 UI、listener/策略引擎/事件处理 store 与 API | +| creator_material_job | 素材页面、转写链路、creator_material API | +| runtime_use_lease | runtime-use 租约机制(Acquire/Renew/Release),唯一剩余调用方「强制同步」改为直接使用环境 | + +### 2.2 同域合并(5 张消失,信息无损) + +| 合并 | 说明 | +|---|---| +| credential_reference → social_account | 加 credential_provider、credential_key 两列,删 FK | +| environment_binding → browser_env | 加 account_id + runtime cleanup 状态列;消灭 id=account_id 假 1:1 表 | +| creator_work_source → creator_work | work 已有 source_type/source_id 内联列,删除冗余表 | +| creator_metric_plan → creator_work | 调度列(interval/multiplier/point_index/next_plan_at)并入 work,消灭双份调度状态 | +| creator_source_sync_lease → creator_collection_checkpoint | 统一租约模式(lease_token/lease_until),一种写法 | + +### 2.3 同步瘦身(3 张) + +- audit_event:18 → 9 列(任务/草稿/确认相关列随链路删除) +- runtime_instance → 并入 browser_env(活跃实例 1:1:runtime_id、lease_until、network_id、node_id 等 5-6 列),表删除 +- creator_competitor:sync 租约字段对齐统一租约模式 + +### 2.4 最终 19 张 + +``` +基础盘(5):social_account(含凭据引用)、gateway、network_exit、 + browser_env(含绑定+活跃实例)、audit_event(瘦列) +监控(12):creator_competitor、creator_competitor_share_job、 + creator_work(含来源+调度)、creator_work_metric、creator_work_cover、 + creator_comment、creator_comment_rule_result、creator_lead_rule、 + creator_account_profile、creator_account_password、creator_account_metric +运维+系统(2):creator_collection_checkpoint(统一租约)、creator_settings、schema_migration +``` + +保持不动:creator_account_profile / creator_account_password 虽是 1:1,但归 creator 域(登录核验),跨域并入 social_account 破坏模块边界,保留。 + +## 3. 数字主键重构规则 + +- 每张表:`id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY`。 +- 文本业务标识降级 UNIQUE NOT NULL,并按语义改名(如 social_account.id → account_id、browser_env.alias 保留原名)。 +- FK 全部重指目标表 bigint id;应用层 store SQL 的 JOIN/WHERE 同步改走 bigint FK。 +- 应用层对外契约不变:API/URL/JSON 继续使用文本业务标识(`account-xxx`、alias、exit-xxx);数字 id 仅数据库内部 + seed 派生。 +- seed 派生:创建账号时读回 bigint id,`seed = id + 1000`(bigint id 范围远超 seed 上限 2147483647 的风险仅在海量账号后出现,届时再管)。 +- 存量行:PG 17 `ADD COLUMN ... IDENTITY` 自动回填 1,2,3…(已实测确认),竞品数据无需迁移脚本。 +- account/creator 双迁移系统统一:creator 域迁移并入统一 runner + schema_migration 单表登记。 + +## 4. 三阶段迭代(每阶段独立提交、独立验证) + +### Phase 1 — 砍空转链路(纯删除) + +1. 删 14 张表的迁移文件与建表 SQL 引用 +2. 删 account store 任务链(draft/confirm/task/attempt 全部函数与类型) +3. 删 creator store 的运营链路(event/strategy/operation/conversation/message/cooldown/listener/material)与 API 路由 +4. 删前端:/tasks、/tasks/:id、/drafts/:id、/attempts/:id、素材页、运营工作台、菜单项与 metadata 规则 +5. 删 runtime_use_lease 机制,强制同步改为直接使用环境(同步 task 串行性由调度器保证) +6. 测试同步删除;`go test ./...` 全绿、`tsc --noEmit` 通过 + +### Phase 2 — Schema 收敛(迁移 043 一次到位) + +1. 迁移 043:TRUNCATE 账号域(social_account/browser_env 及其 CASCADE 链)→ 表合并/瘦列/改列 +2. 合并实现:credential_reference 并入 social_account;environment_binding + 活跃实例状态并入 browser_env;work_source/metric_plan 并入 creator_work;source_sync_lease 并入 creator_collection_checkpoint;audit_event 瘦列 +3. store SQL 全量适配(account/environment/creator 三域) +4. 迁移不可变性测试同步更新;集成测试(CREATORHUB_POSTGRES_TEST_URL)验证合并后读写路径 +5. 「账号即环境」后端落地:创建账号即绑定(seed = 账号 bigint id + 1000、唯一网关自动取、出口直连、alias = 账号文本 ID);`POST /api/phase-a/accounts/:id/environment` 幂等补建;`POST /api/phase-a/accounts/:id/start`(resume + 启动环境);停止沿用现有 pause(已联动停环境),UI 文案改「停止」 + +### Phase 3 — 数字主键重构 + +1. 迁移:全表加 `id bigint IDENTITY` 主键,文本标识降级 UNIQUE,FK 重指 +2. 三域 store SQL:JOIN/WHERE/INSERT 全改 bigint FK;模型层补数字 id 字段(JSON 暴露 `no` 语义字段按需) +3. 前端不动(契约不变);账号创建绑定改用 bigint id 派生 seed +4. 全量回归:go test 覆盖率 ≥65%、tsc、手动闭环(创建账号 → 环境自动出现 → 启动 → CDP 可连 → 停止) + +## 5. 验证标准 + +- 每阶段:`go test ./...` 全绿(相关包覆盖率 ≥65%)、`tsc --noEmit` 通过、现有 .cjs 前端测试通过 +- Phase 2 结束:真库迁移演练(开发库 + 测试 schema)确认合并后数据形态 +- Phase 3 结束:手动闭环验收(创建即绑定、两态开关、CDP 可连) +- 迁移文件不可变测试持续生效(改内容 = 新版本号) + +## 6. 明确不做 + +- 不保留任何已砍链路的兼容层、回退机制或迁移方案(宪法:废弃直接移除) +- 不做 E2E 测试(宪法禁止,仅用户明确要求时) +- 不在本轮引入多节点、认证权限、HTTPS(禁止清单) diff --git a/internal/account/deletion.go b/internal/account/deletion.go index 196c8e4..2659cc2 100644 --- a/internal/account/deletion.go +++ b/internal/account/deletion.go @@ -20,8 +20,8 @@ func (s *Store) CheckAccountDeletion(ctx context.Context, accountID string) erro var active bool if err := s.db.QueryRowContext(ctx, ` SELECT EXISTS ( - SELECT 1 FROM runtime_instance - WHERE account_id = $1 AND released_at IS NULL + SELECT 1 FROM browser_env + WHERE account_id = $1 AND runtime_id IS NOT NULL )`, accountID).Scan(&active); err != nil { return errors.New("check account deletion state") } @@ -48,8 +48,8 @@ func (s *Store) DeleteAccountData(ctx context.Context, accountID string) error { var active bool if err := tx.QueryRowContext(ctx, ` SELECT EXISTS ( - SELECT 1 FROM runtime_instance - WHERE account_id = $1 AND released_at IS NULL + SELECT 1 FROM browser_env + WHERE account_id = $1 AND runtime_id IS NOT NULL )`, accountID).Scan(&active); err != nil { return errors.New("check account deletion state") } @@ -63,13 +63,14 @@ func (s *Store) DeleteAccountData(ctx context.Context, accountID string) error { DELETE FROM audit_event WHERE account_id = $1 OR browser_env_alias IN ( - SELECT browser_env_alias FROM environment_binding WHERE account_id = $1 - ) - OR runtime_instance_id IN (SELECT id FROM runtime_instance WHERE account_id = $1)`, accountID); err != nil { + SELECT alias FROM browser_env WHERE account_id = $1 + )`, accountID); err != nil { return errors.New("delete account audit data") } - if _, err := tx.ExecContext(ctx, `DELETE FROM runtime_instance WHERE account_id = $1`, accountID); err != nil { - return errors.New("delete account runtime records") + if _, err := tx.ExecContext(ctx, ` + UPDATE browser_env SET runtime_id = NULL, runtime_lease_until = NULL, runtime_network_id = NULL, runtime_node_id = NULL + WHERE account_id = $1`, accountID); err != nil { + return errors.New("clear account runtime state") } return commit(tx) } @@ -85,22 +86,18 @@ func (s *Store) DeleteAccount(ctx context.Context, accountID string, credentials return errors.New("begin account record deletion") } defer tx.Rollback() - var reference CredentialReference + reference := CredentialReference{ID: accountID, Provider: ""} var key string if err := tx.QueryRowContext(ctx, ` - SELECT credential.id, credential.provider, credential.reference_key - FROM social_account account - JOIN credential_reference credential ON credential.id = account.credential_reference_id - WHERE account.id = $1 - FOR UPDATE`, accountID).Scan(&reference.ID, &reference.Provider, &key); err != nil { + SELECT credential_provider, credential_key + FROM social_account + WHERE id = $1 + FOR UPDATE`, accountID).Scan(&reference.Provider, &key); err != nil { return rowError(err) } if _, err := tx.ExecContext(ctx, `DELETE FROM social_account WHERE id = $1`, accountID); err != nil { return publicDatabaseError(err) } - if _, err := tx.ExecContext(ctx, `DELETE FROM credential_reference WHERE id = $1`, reference.ID); err != nil { - return publicDatabaseError(err) - } if err := tx.Commit(); err != nil { return errors.New("commit account record deletion") } diff --git a/internal/account/migrations/036_runtime_use_leases.sql b/internal/account/migrations/036_runtime_use_leases.sql deleted file mode 100644 index 9a4a059..0000000 --- a/internal/account/migrations/036_runtime_use_leases.sql +++ /dev/null @@ -1,36 +0,0 @@ -ALTER TABLE runtime_instance - ADD COLUMN IF NOT EXISTS owner_id text NOT NULL DEFAULT 'control-plane', - ADD COLUMN IF NOT EXISTS purpose text NOT NULL DEFAULT 'session', - ADD COLUMN IF NOT EXISTS lease_token text NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS node_id text NOT NULL DEFAULT ''; - -ALTER TABLE runtime_instance - DROP CONSTRAINT IF EXISTS runtime_instance_purpose_check; -ALTER TABLE runtime_instance - ADD CONSTRAINT runtime_instance_purpose_check - CHECK (purpose IN ('session', 'task', 'listener')); - -CREATE UNIQUE INDEX IF NOT EXISTS runtime_instance_active_lease_token_idx - ON runtime_instance (lease_token) - WHERE released_at IS NULL AND lease_token <> ''; - -CREATE TABLE IF NOT EXISTS runtime_use_lease ( - token text PRIMARY KEY, - runtime_instance_id text NOT NULL REFERENCES runtime_instance(id), - owner_id text NOT NULL, - purpose text NOT NULL CHECK (purpose IN ('task', 'listener')), - task_id text REFERENCES operation_task(id), - lease_until timestamptz NOT NULL, - acquired_at timestamptz NOT NULL DEFAULT now(), - released_at timestamptz, - CHECK (length(token) BETWEEN 16 AND 128), - CHECK (length(owner_id) BETWEEN 1 AND 128) -); - -CREATE INDEX IF NOT EXISTS runtime_use_lease_active_runtime_idx - ON runtime_use_lease (runtime_instance_id, lease_until) - WHERE released_at IS NULL; - -CREATE UNIQUE INDEX IF NOT EXISTS runtime_use_lease_active_task_idx - ON runtime_use_lease (task_id) - WHERE released_at IS NULL AND task_id IS NOT NULL; diff --git a/internal/account/store.go b/internal/account/store.go index 8562297..1c30da0 100644 --- a/internal/account/store.go +++ b/internal/account/store.go @@ -4,11 +4,9 @@ import ( "context" "crypto/rand" "database/sql" - _ "embed" "encoding/hex" "encoding/json" "errors" - "fmt" "net/http" "regexp" "strings" @@ -21,12 +19,6 @@ import ( _ "github.com/jackc/pgx/v5/stdlib" ) -//go:embed migrations/001_phase_a.sql -var migration001 string - -//go:embed migrations/036_runtime_use_leases.sql -var migration036 string - var ( ErrConflict = errors.New("resource conflicts with existing state") ErrInvalid = errors.New("invalid phase A input") @@ -81,30 +73,25 @@ type ReadinessError struct { func (e *ReadinessError) Error() string { return e.Reason } type AuditEvent struct { - ID int64 `json:"id"` - EventType string `json:"event_type"` - AccountID string `json:"account_id,omitempty"` - ConfirmationID string `json:"confirmation_id,omitempty"` - ConfirmationVersion int64 `json:"confirmation_version,omitempty"` - AttemptID string `json:"attempt_id,omitempty"` - TaskID string `json:"task_id,omitempty"` - BrowserEnvAlias string `json:"browser_env_alias,omitempty"` - NetworkExitID string `json:"network_exit_id,omitempty"` - RuntimeInstanceID string `json:"runtime_instance_id,omitempty"` - BindingVersion int64 `json:"binding_version,omitempty"` - Actor string `json:"actor,omitempty"` - ReasonCode string `json:"reason_code,omitempty"` - OperationID string `json:"operation_id,omitempty"` - Action string `json:"action,omitempty"` - Outcome string `json:"outcome,omitempty"` - Details json.RawMessage `json:"details"` - CreatedAt time.Time `json:"created_at"` + ID int64 `json:"id"` + EventType string `json:"event_type"` + AccountID string `json:"account_id,omitempty"` + BrowserEnvAlias string `json:"browser_env_alias,omitempty"` + NetworkExitID string `json:"network_exit_id,omitempty"` + BindingVersion int64 `json:"binding_version,omitempty"` + Actor string `json:"actor,omitempty"` + ReasonCode string `json:"reason_code,omitempty"` + OperationID string `json:"operation_id,omitempty"` + Action string `json:"action,omitempty"` + Outcome string `json:"outcome,omitempty"` + Details json.RawMessage `json:"details"` + CreatedAt time.Time `json:"created_at"` } type AuditFilter struct { - AccountID, TaskID, AttemptID, BrowserEnvAlias, NetworkExitID, EventType string - From, To *time.Time - Page, PageSize int + AccountID, BrowserEnvAlias, NetworkExitID, EventType string + From, To *time.Time + Page, PageSize int } type AuditPage struct { @@ -127,10 +114,6 @@ func Open(ctx context.Context, databaseURL string) (*Store, error) { return nil, errors.New("connect to phase A database") } store := &Store{db: db} - if err := store.migrate(ctx); err != nil { - db.Close() - return nil, err - } return store, nil } @@ -138,42 +121,6 @@ func (s *Store) Close() error { return s.db.Close() } func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) } -func (s *Store) migrate(ctx context.Context) error { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return errors.New("begin schema migration") - } - defer tx.Rollback() - if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(1542738011)`); err != nil { - return errors.New("lock schema migration") - } - if _, err := tx.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migration (version integer PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil { - return errors.New("create schema migration table") - } - for _, migration := range []struct { - version int - sql string - }{{1, migration001}, {36, migration036}} { - var applied bool - if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migration.version).Scan(&applied); err != nil { - return errors.New("read schema migration state") - } - if applied { - continue - } - if _, err := tx.ExecContext(ctx, migration.sql); err != nil { - return fmt.Errorf("apply schema migration %d: %w", migration.version, err) - } - if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migration (version) VALUES ($1)`, migration.version); err != nil { - return fmt.Errorf("record schema migration %d", migration.version) - } - } - if err := tx.Commit(); err != nil { - return errors.New("commit schema migration") - } - return nil -} - func (s *Store) CreateAccount(ctx context.Context, account Account, credentials CredentialBridge) (err error) { if account.Tags == nil { account.Tags = []string{} @@ -205,18 +152,15 @@ func (s *Store) CreateAccount(ctx context.Context, account Account, credentials return errors.New("begin account transaction") } defer tx.Rollback() - if _, err := tx.ExecContext(ctx, `INSERT INTO credential_reference (id, provider, reference_key) VALUES ($1, $2, $3)`, - account.CredentialReference.ID, account.CredentialReference.Provider, account.CredentialKey); err != nil { - return publicDatabaseError(err) - } if _, err := tx.ExecContext(ctx, ` INSERT INTO social_account - (id, credential_reference_id, name, platform, platform_account_key, tags, authorization_kind, authorization_status, status) - VALUES ($1, $2, $3, $4, $5, $6, 'owned', 'authorized', 'paused')`, account.ID, account.CredentialReference.ID, + (id, credential_provider, credential_key, name, platform, platform_account_key, tags, authorization_kind, authorization_status, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'owned', 'authorized', 'paused')`, account.ID, + account.CredentialReference.Provider, account.CredentialKey, account.Name, account.Platform, account.PlatformAccountKey, account.Tags); err != nil { return publicDatabaseError(err) } - if err := appendAudit(ctx, tx, "account_created", "account_created", account.ID, "", 0, "", "", map[string]string{"platform": account.Platform}); err != nil { + if err := appendAudit(ctx, tx, "account_created", "account_created", account.ID, map[string]string{"platform": account.Platform}); err != nil { return err } var commitErr error @@ -278,13 +222,12 @@ func (s *Store) ResolveAccountCredential(ctx context.Context, id string, resolve if !idPattern.MatchString(id) || resolver == nil { return nil, ErrInvalid } - var reference CredentialReference + reference := CredentialReference{ID: id, Provider: ""} var key string if err := s.db.QueryRowContext(ctx, ` - SELECT credential.id, credential.provider, credential.reference_key - FROM social_account account - JOIN credential_reference credential ON credential.id = account.credential_reference_id - WHERE account.id = $1`, id).Scan(&reference.ID, &reference.Provider, &key); err != nil { + SELECT credential_provider, credential_key + FROM social_account + WHERE id = $1`, id).Scan(&reference.Provider, &key); err != nil { return nil, rowError(err) } value, err := resolver.Resolve(ctx, reference, key) @@ -381,7 +324,7 @@ func (s *Store) disableAccount(ctx context.Context, accountID string, revoke boo if revoke { eventType = "account_revoked" } - if err := appendAudit(ctx, tx, eventType, reason, accountID, "", 0, "", "", map[string]any{ + if err := appendAudit(ctx, tx, eventType, reason, accountID, map[string]any{ "account_version": version, }); err != nil { return err @@ -410,14 +353,11 @@ func (s *Store) ResumeAccount(ctx context.Context, accountID string) error { var ready bool if err := tx.QueryRowContext(ctx, ` SELECT EXISTS ( - SELECT 1 FROM environment_binding binding - LEFT JOIN network_exit network ON network.id = binding.network_exit_id - WHERE binding.account_id = $1 AND (binding.network_exit_id IS NULL OR network.health_status = 'healthy') - AND NOT binding.runtime_cleanup_pending - AND NOT EXISTS ( - SELECT 1 FROM runtime_instance runtime - WHERE runtime.binding_id = binding.id AND runtime.released_at IS NULL - ) + SELECT 1 FROM browser_env environment + LEFT JOIN network_exit network ON network.id = environment.network_exit_id + WHERE environment.account_id = $1 AND (environment.network_exit_id IS NULL OR network.health_status = 'healthy') + AND NOT environment.runtime_cleanup_pending + AND environment.runtime_id IS NULL )`, accountID).Scan(&ready); err != nil { return errors.New("validate account binding") } @@ -433,7 +373,7 @@ func (s *Store) ResumeAccount(ctx context.Context, accountID string) error { WHERE id = $1 RETURNING version`, accountID).Scan(&version); err != nil { return errors.New("resume account") } - if err := appendAudit(ctx, tx, "account_resumed", "account_resumed", accountID, "", 0, "", "", map[string]any{"account_version": version}); err != nil { + if err := appendAudit(ctx, tx, "account_resumed", "account_resumed", accountID, map[string]any{"account_version": version}); err != nil { return err } return commit(tx) @@ -446,8 +386,6 @@ func (s *Store) Audit(ctx context.Context) ([]AuditEvent, error) { func (s *Store) ListAudit(ctx context.Context, filter AuditFilter) (AuditPage, error) { if (filter.AccountID != "" && !idPattern.MatchString(filter.AccountID)) || - (filter.TaskID != "" && !refPattern.MatchString(filter.TaskID)) || - (filter.AttemptID != "" && !refPattern.MatchString(filter.AttemptID)) || (filter.BrowserEnvAlias != "" && !refPattern.MatchString(filter.BrowserEnvAlias)) || (filter.NetworkExitID != "" && !refPattern.MatchString(filter.NetworkExitID)) || (filter.EventType != "" && !eventPattern.MatchString(filter.EventType)) || filter.Page < 1 || @@ -464,22 +402,22 @@ func (s *Store) ListAudit(ctx context.Context, filter AuditFilter) (AuditPage, e var total int err := s.db.QueryRowContext(ctx, ` SELECT count(*) FROM audit_event - WHERE ($1 = '' OR account_id = $1) AND ($2 = '' OR task_id = $2) AND ($3 = '' OR attempt_id = $3) - AND ($4 = '' OR browser_env_alias = $4) AND ($5 = '' OR network_exit_id = $5) AND ($6 = '' OR event_type = $6) - AND ($7::timestamptz IS NULL OR created_at >= $7) AND ($8::timestamptz IS NULL OR created_at <= $8)`, - filter.AccountID, filter.TaskID, filter.AttemptID, filter.BrowserEnvAlias, filter.NetworkExitID, filter.EventType, from, to).Scan(&total) + WHERE ($1 = '' OR account_id = $1) + AND ($2 = '' OR browser_env_alias = $2) AND ($3 = '' OR network_exit_id = $3) AND ($4 = '' OR event_type = $4) + AND ($5::timestamptz IS NULL OR created_at >= $5) AND ($6::timestamptz IS NULL OR created_at <= $6)`, + filter.AccountID, filter.BrowserEnvAlias, filter.NetworkExitID, filter.EventType, from, to).Scan(&total) if err != nil { return AuditPage{}, errors.New("count audit events") } rows, err := s.db.QueryContext(ctx, ` - SELECT id, event_type, account_id, confirmation_id, confirmation_version, attempt_id, task_id, - browser_env_alias, network_exit_id, runtime_instance_id, binding_version, actor, reason_code, + SELECT id, event_type, account_id, + browser_env_alias, network_exit_id, binding_version, actor, reason_code, operation_id, action, outcome, details, created_at FROM audit_event - WHERE ($1 = '' OR account_id = $1) AND ($2 = '' OR task_id = $2) AND ($3 = '' OR attempt_id = $3) - AND ($4 = '' OR browser_env_alias = $4) AND ($5 = '' OR network_exit_id = $5) AND ($6 = '' OR event_type = $6) - AND ($7::timestamptz IS NULL OR created_at >= $7) AND ($8::timestamptz IS NULL OR created_at <= $8) - ORDER BY created_at DESC, id DESC LIMIT $9 OFFSET $10`, filter.AccountID, filter.TaskID, filter.AttemptID, + WHERE ($1 = '' OR account_id = $1) + AND ($2 = '' OR browser_env_alias = $2) AND ($3 = '' OR network_exit_id = $3) AND ($4 = '' OR event_type = $4) + AND ($5::timestamptz IS NULL OR created_at >= $5) AND ($6::timestamptz IS NULL OR created_at <= $6) + ORDER BY created_at DESC, id DESC LIMIT $7 OFFSET $8`, filter.AccountID, filter.BrowserEnvAlias, filter.NetworkExitID, filter.EventType, from, to, filter.PageSize, (filter.Page-1)*filter.PageSize) if err != nil { return AuditPage{}, errors.New("read audit events") @@ -488,18 +426,17 @@ func (s *Store) ListAudit(ctx context.Context, filter AuditFilter) (AuditPage, e events := []AuditEvent{} for rows.Next() { var event AuditEvent - var accountID, confirmationID, attemptID, taskID, browserEnvAlias, networkExitID sql.NullString - var runtimeInstanceID, actor, reasonCode, operationID, action, outcome sql.NullString - var confirmationVersion, bindingVersion sql.NullInt64 - if err := rows.Scan(&event.ID, &event.EventType, &accountID, &confirmationID, &confirmationVersion, &attemptID, &taskID, - &browserEnvAlias, &networkExitID, &runtimeInstanceID, &bindingVersion, &actor, &reasonCode, + var accountID, browserEnvAlias, networkExitID sql.NullString + var actor, reasonCode, operationID, action, outcome sql.NullString + var bindingVersion sql.NullInt64 + if err := rows.Scan(&event.ID, &event.EventType, &accountID, + &browserEnvAlias, &networkExitID, &bindingVersion, &actor, &reasonCode, &operationID, &action, &outcome, &event.Details, &event.CreatedAt); err != nil { return AuditPage{}, errors.New("decode audit event") } - event.AccountID, event.ConfirmationID, event.ConfirmationVersion = accountID.String, confirmationID.String, confirmationVersion.Int64 - event.AttemptID, event.TaskID = attemptID.String, taskID.String + event.AccountID = accountID.String event.BrowserEnvAlias, event.NetworkExitID = browserEnvAlias.String, networkExitID.String - event.RuntimeInstanceID, event.BindingVersion = runtimeInstanceID.String, bindingVersion.Int64 + event.BindingVersion = bindingVersion.Int64 event.Actor, event.ReasonCode = actor.String, reasonCode.String event.OperationID, event.Action, event.Outcome = operationID.String, action.String, outcome.String event.Details = safeDetails(event.Details) @@ -525,9 +462,8 @@ func safeDetails(raw json.RawMessage) json.RawMessage { } var allowedDetailKeys = map[string]bool{ - "platform": true, "draft_id": true, "draft_version": true, "account_version": true, - "tasks_held": true, "attempts_interrupted": true, "verification_result": true, - "state": true, "worker_id": true, "mock_outcome": true, + "platform": true, "account_version": true, + "state": true, "worker_id": true, } func allowDetails(value any) any { @@ -548,7 +484,7 @@ func allowDetails(value any) any { return value } -func appendAudit(ctx context.Context, tx *sql.Tx, eventType, reasonCode, accountID, confirmationID string, confirmationVersion int64, attemptID, taskID string, details any) error { +func appendAudit(ctx context.Context, tx *sql.Tx, eventType, reasonCode, accountID string, details any) error { if details == nil { details = map[string]any{} } @@ -558,14 +494,13 @@ func appendAudit(ctx context.Context, tx *sql.Tx, eventType, reasonCode, account } _, err = tx.ExecContext(ctx, ` INSERT INTO audit_event - (event_type, account_id, confirmation_id, confirmation_version, attempt_id, task_id, - browser_env_alias, network_exit_id, runtime_instance_id, binding_version, actor, reason_code, details) - SELECT $1, NULLIF($3, ''), NULLIF($4, ''), NULLIF($5, 0), NULLIF($6, ''), NULLIF($7, ''), - binding.browser_env_alias, binding.network_exit_id, runtime.id, binding.version, 'local-user', $2, $8 + (event_type, account_id, + browser_env_alias, network_exit_id, binding_version, actor, reason_code, details) + SELECT $1, NULLIF($3, ''), + environment.alias, environment.network_exit_id, environment.version, 'local-user', $2, $4 FROM (VALUES (1)) AS singleton(value) - LEFT JOIN environment_binding binding ON binding.account_id = NULLIF($3, '') - LEFT JOIN runtime_instance runtime ON runtime.binding_id = binding.id AND runtime.released_at IS NULL`, - eventType, reasonCode, accountID, confirmationID, confirmationVersion, attemptID, taskID, encoded) + LEFT JOIN browser_env environment ON environment.account_id = NULLIF($3, '')`, + eventType, reasonCode, accountID, encoded) if err != nil { return errors.New("append audit event") } diff --git a/internal/account/store_test.go b/internal/account/store_test.go index e488619..4eb6d1d 100644 --- a/internal/account/store_test.go +++ b/internal/account/store_test.go @@ -126,7 +126,6 @@ func applyHubMigrationsForPhaseATest(t *testing.T, store *Store) { } } - func TestCreateAccountWithoutCookiesSkipsCredentialStore(t *testing.T) { databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") if databaseURL == "" { @@ -140,9 +139,8 @@ func TestCreateAccountWithoutCookiesSkipsCredentialStore(t *testing.T) { t.Cleanup(func() { _ = store.Close() }) applyHubMigrationsForPhaseATest(t, store) if _, err := store.db.ExecContext(ctx, ` - TRUNCATE audit_event, - runtime_instance, environment_binding, network_exit, social_account, credential_reference, - browser_env, gateway RESTART IDENTITY CASCADE`); err != nil { + TRUNCATE audit_event, network_exit, social_account, browser_env, + gateway RESTART IDENTITY CASCADE`); err != nil { t.Fatal(err) } credentials := &testCredentialBridge{values: map[string]string{}} @@ -156,7 +154,7 @@ func TestCreateAccountWithoutCookiesSkipsCredentialStore(t *testing.T) { t.Fatal("empty cookies must not be written to the credential provider") } assertCount(t, store, `SELECT count(*) FROM social_account WHERE id = $1`, 1, account.ID) - assertCount(t, store, `SELECT count(*) FROM credential_reference WHERE id = $1`, 1, account.CredentialReference.ID) + assertCount(t, store, `SELECT count(*) FROM social_account WHERE id = $1 AND credential_provider = $2 AND credential_key = $3`, 1, account.ID, account.CredentialReference.Provider, account.CredentialKey) } func TestAccountCredentialCommitResult(t *testing.T) { @@ -171,9 +169,8 @@ func TestAccountCredentialCommitResult(t *testing.T) { } applyHubMigrationsForPhaseATest(t, store) if _, err := store.db.ExecContext(ctx, ` - TRUNCATE audit_event, - runtime_instance, environment_binding, network_exit, social_account, credential_reference, - browser_env, gateway RESTART IDENTITY CASCADE`); err != nil { + TRUNCATE audit_event, network_exit, social_account, browser_env, + gateway RESTART IDENTITY CASCADE`); err != nil { t.Fatal(err) } credentials := &testCredentialBridge{values: map[string]string{}} diff --git a/internal/controlplane/api/accounts_operations.go b/internal/controlplane/api/accounts_operations.go index 0df2432..27e070e 100644 --- a/internal/controlplane/api/accounts_operations.go +++ b/internal/controlplane/api/accounts_operations.go @@ -22,7 +22,6 @@ type accountRequest struct { Cookies string `json:"cookies"` } - // RegisterAccountRoutes exposes account lifecycle routes (create/list/detail/pause/resume/revoke + audit). func RegisterAccountRoutes(app *fiber.App, store *accountdomain.Store, runtimeStore RuntimeStopStore, credentials accountdomain.CredentialBridge) { app.Post("/api/phase-a/accounts", func(c fiber.Ctx) error { @@ -136,7 +135,7 @@ func RegisterAccountRoutes(app *fiber.App, store *accountdomain.Store, runtimeSt func auditFilter(c fiber.Ctx) (accountdomain.AuditFilter, error) { filter := accountdomain.AuditFilter{ - AccountID: c.Query("account_id"), TaskID: c.Query("task_id"), AttemptID: c.Query("attempt_id"), + AccountID: c.Query("account_id"), BrowserEnvAlias: c.Query("browser_env_alias"), NetworkExitID: c.Query("network_exit_id"), EventType: c.Query("event_type"), Page: 1, PageSize: 25, } @@ -181,7 +180,7 @@ func resumeBlockReason(account accountdomain.Account, environment hub.Environmen return "network_exit_unhealthy" case environment.RuntimeCleanupPending: return "runtime_stop_pending" - case environment.RuntimeInstanceID != "": + case environment.RuntimeID != "": return "runtime_active" default: return "account_conflict" diff --git a/internal/controlplane/api/app_migrated_test.go b/internal/controlplane/api/app_migrated_test.go index 326987b..62b1d54 100644 --- a/internal/controlplane/api/app_migrated_test.go +++ b/internal/controlplane/api/app_migrated_test.go @@ -48,8 +48,6 @@ func TestCreatorReadRoutesAgainstPostgres(t *testing.T) { "/api/creator/settings", "/api/creator/accounts", "/api/creator/competitors", "/api/creator/rules", "/api/creator/rule-results", "/api/creator/leads", "/api/creator/works", "/api/creator/works?page=1&page_size=10", "/api/creator/comments", "/api/creator/comments?page=1&page_size=10", - - } { response := do(app, http.MethodGet, path, "", "operator", "unit-test-password") if response.Code != http.StatusOK { @@ -296,8 +294,8 @@ func TestCreatorFixtureRoutesPostgres(t *testing.T) { t.Fatalf("GET monitor views: %d %s", viewResponse.Code, viewResponse.Body.String()) } var monitorViews []struct { - ID string `json:"id"` - WorkCount int64 `json:"work_count"` + ID string `json:"id"` + WorkCount int64 `json:"work_count"` LatestPublishedAt *string `json:"latest_published_at"` } if err := json.Unmarshal(viewResponse.Body.Bytes(), &monitorViews); err != nil { @@ -335,7 +333,7 @@ func TestCreatorFixtureRoutesPostgres(t *testing.T) { } var fixtureStatus struct { Works struct { - Status string `json:"status"` + Status string `json:"status"` NextWindowStart *string `json:"next_window_start"` NextWindowEnd *string `json:"next_window_end"` } `json:"works"` diff --git a/internal/controlplane/api/creator.go b/internal/controlplane/api/creator.go index 376d6ac..06146d4 100644 --- a/internal/controlplane/api/creator.go +++ b/internal/controlplane/api/creator.go @@ -711,7 +711,7 @@ func startCreatorEnvironment(ctx context.Context, store HubStore, environment hu return err } finish := func(outcome, reason string, current hub.EnvironmentContext) error { - action.Outcome, action.ReasonCode, action.RuntimeInstanceID = outcome, reason, current.RuntimeInstanceID + action.Outcome, action.ReasonCode = outcome, reason action.BindingVersion, action.NetworkExitID = current.BindingVersion, current.Exit.ID return store.AppendEnvironmentAction(ctx, "environment_action_finished", action) } diff --git a/internal/controlplane/api/douyin.go b/internal/controlplane/api/douyin.go index 1d6f9e5..8058158 100644 --- a/internal/controlplane/api/douyin.go +++ b/internal/controlplane/api/douyin.go @@ -106,9 +106,8 @@ func (browser douyinGatewayBrowser) request() (douyinGatewayRequest, error) { environment := browser.environment if browser.gateway.Endpoint == "" || browser.gateway.Token == "" || !accountRunnable(environment) || !gatewayGenerationIDPattern.MatchString(environment.AccountID) || !gatewayGenerationIDPattern.MatchString(environment.Alias) || - !gatewayGenerationIDPattern.MatchString(environment.BindingID) || !gatewayGenerationIDPattern.MatchString(environment.Exit.ID) || environment.Exit.HealthStatus != "healthy" || environment.RuntimeCleanupPending || - !gatewayGenerationIDPattern.MatchString(environment.RuntimeInstanceID) || !gatewayGenerationIDPattern.MatchString(environment.RuntimeID) || + !gatewayGenerationIDPattern.MatchString(environment.RuntimeID) || !gatewayGenerationIDPattern.MatchString(environment.RuntimeNetworkID) || environment.BindingVersion < 1 { return douyinGatewayRequest{}, errors.New("restricted browser is not ready") diff --git a/internal/controlplane/api/douyin_test.go b/internal/controlplane/api/douyin_test.go index 57afa09..003d121 100644 --- a/internal/controlplane/api/douyin_test.go +++ b/internal/controlplane/api/douyin_test.go @@ -108,7 +108,7 @@ func TestDouyinGatewayBrowserDoesNotEchoCredentialOnFailure(t *testing.T) { func readyDouyinEnvironment() hub.EnvironmentContext { return hub.EnvironmentContext{Env: hub.Env{Alias: "account-a", Gateway: "gateway-a"}, AccountID: "account-a", - AccountStatus: "active", AuthorizationStatus: "authorized", BindingID: "binding-a", BindingVersion: 2, - Exit: hub.NetworkExit{ID: "exit-a", HealthStatus: "healthy"}, RuntimeInstanceID: "instance-a", + AccountStatus: "active", AuthorizationStatus: "authorized", BindingVersion: 2, + Exit: hub.NetworkExit{ID: "exit-a", HealthStatus: "healthy"}, RuntimeID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", RuntimeNetworkID: "native-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} } diff --git a/internal/controlplane/api/environments.go b/internal/controlplane/api/environments.go index 5b26860..4304ff5 100644 --- a/internal/controlplane/api/environments.go +++ b/internal/controlplane/api/environments.go @@ -70,7 +70,7 @@ const ( gatewayBrowserOperationTimeout = 90 * time.Second gatewayReconcileDelay = 100 * time.Millisecond gatewayReconcileAttempts = 10 - missingRuntimeID = "runtime-not-found" + missingRuntimeID = hub.MissingRuntimeID ) var ( @@ -148,7 +148,6 @@ func gatewayCleanupGenerationPayload(environment hub.EnvironmentContext) map[str func runtimeCleanupGeneration(environment hub.EnvironmentContext, bindingVersion int64, runtimeID string, networkIDs ...string) hub.EnvironmentContext { environment.RuntimeCleanupBindingVersion = bindingVersion - environment.RuntimeCleanupInstanceID = environment.RuntimeInstanceID environment.RuntimeCleanupRuntimeID = runtimeID environment.RuntimeCleanupNetworkID = environment.RuntimeNetworkID if len(networkIDs) == 1 { @@ -339,7 +338,6 @@ type envView struct { NetworkExitID string `json:"network_exit_id"` NetworkExitHealth string `json:"network_exit_health"` BindingVersion int64 `json:"binding_version"` - RuntimeInstanceID string `json:"runtime_instance_id"` RuntimeNodeID string `json:"runtime_node_id,omitempty"` RuntimeStatus string `json:"runtime_status,omitempty"` RuntimeCleanupState string `json:"runtime_cleanup_state,omitempty"` @@ -361,7 +359,7 @@ func environmentScheduleReadiness(environment hub.EnvironmentContext) (string, s return "blocked", "network_exit_unhealthy" case environment.RuntimeCleanupPending: return "blocked", "runtime_stop_pending" - case environment.RuntimeInstanceID == "": + case environment.RuntimeID == "": return "blocked", "runtime_missing" default: return "ready", "" @@ -387,8 +385,8 @@ func validCreatedRuntime(created runtimeStatus, environment hub.EnvironmentConte if created.NetworkID == "" { return false } - if environment.RuntimeInstanceID != "" || environment.RuntimeID != "" { - return environment.RuntimeInstanceID != "" && environment.RuntimeID == created.ID && + if environment.RuntimeID != "" { + return environment.RuntimeID == created.ID && environment.RuntimeNetworkID != "" && created.NetworkID == environment.RuntimeNetworkID } return environment.RuntimeNetworkID == "" || created.NetworkID == environment.RuntimeNetworkID @@ -530,12 +528,12 @@ func getBrowser(store HubStore) fiber.Handler { AccountID: environment.AccountID, AccountStatus: environment.AccountStatus, AuthorizationStatus: environment.AuthorizationStatus, NetworkExitID: environment.Exit.ID, NetworkExitHealth: environment.Exit.HealthStatus, BindingVersion: environment.BindingVersion, - RuntimeInstanceID: environment.RuntimeInstanceID, RuntimeID: environment.RuntimeID, RuntimeNodeID: environment.RuntimeNodeID, + RuntimeID: environment.RuntimeID, RuntimeNodeID: environment.RuntimeNodeID, ScheduleStatus: "blocked", ScheduleBlockReason: "binding_missing", RecoveryRequired: true, CleanupPending: environment.RuntimeCleanupPending, } view.ScheduleStatus, view.ScheduleBlockReason = environmentScheduleReadiness(environment) - if environment.RuntimeInstanceID == "" { + if environment.RuntimeID == "" { view.State, view.Status = "missing", "未记录运行实例" } runtimes, gatewayRead, gatewayErrors := gatewayRuntimeSnapshot(c.Context(), store, []hub.Env{environment.Env}) @@ -552,7 +550,7 @@ func getBrowser(store HubStore) fiber.Handler { if runtime.State == "running" && runtime.Status == "ready" { view.Endpoint = runtime.Endpoint } - } else if view.RuntimeInstanceID != "" { + } else if view.RuntimeID != "" { view.State, view.Status = "runtime_missing", "网关中未找到运行实例" } } @@ -890,7 +888,7 @@ func listBrowsers(store HubStore) fiber.Handler { if environment, contextErr := store.GetEnvironmentContext(c.Context(), env.Alias); contextErr == nil { view.AccountID, view.AccountStatus, view.AuthorizationStatus = environment.AccountID, environment.AccountStatus, environment.AuthorizationStatus view.NetworkExitID, view.NetworkExitHealth, view.BindingVersion = environment.Exit.ID, environment.Exit.HealthStatus, environment.BindingVersion - view.RuntimeInstanceID = environment.RuntimeInstanceID + view.RuntimeID = environment.RuntimeID view.RuntimeID = environment.RuntimeID view.RuntimeNodeID = environment.RuntimeNodeID view.ScheduleStatus, view.ScheduleBlockReason = environmentScheduleReadiness(environment) @@ -898,7 +896,7 @@ func listBrowsers(store HubStore) fiber.Handler { view.RecoveryRequired = (environment.Exit.ID != "" && environment.Exit.HealthStatus != "healthy") || environment.RuntimeCleanupPending if environment.RuntimeCleanupPending { view.State, view.Status = "cleanup_pending", "运行资源待清理" - } else if environment.RuntimeInstanceID != "" { + } else if environment.RuntimeID != "" { view.State, view.Status = "recorded", "已记录运行实例" } } else if !errors.Is(contextErr, hub.ErrNotFound) { @@ -918,7 +916,7 @@ func listBrowsers(store HubStore) fiber.Handler { if runtime.State == "running" && runtime.Status == "ready" { view.Endpoint = runtime.Endpoint } - } else if view.RuntimeInstanceID != "" { + } else if view.RuntimeID != "" { view.State, view.Status = "runtime_missing", "网关中未找到运行实例" view.RecoveryRequired = true } @@ -1019,7 +1017,7 @@ func reconcileRuntimeSnapshot(ctx context.Context, store HubStore, probe Network if err := stopEnvironmentRuntime(ctx, store, environment); err != nil { return err } - } else if gatewayRead[env.Gateway] && environment.RuntimeInstanceID != "" { + } else if gatewayRead[env.Gateway] && environment.RuntimeID != "" { if err := releaseRuntimeWithReconcileAudit(ctx, store, environment); err != nil { return err } @@ -1038,10 +1036,8 @@ func reconcileRuntimeSnapshot(ctx context.Context, store HubStore, probe Network if auditRecovery { current, contextErr := store.GetEnvironmentContext(ctx, environment.Alias) if contextErr == nil { - action.RuntimeInstanceID, action.BindingVersion, action.NetworkExitID = - current.RuntimeInstanceID, current.BindingVersion, current.Exit.ID + action.BindingVersion, action.NetworkExitID = current.BindingVersion, current.Exit.ID } else { - action.RuntimeInstanceID = "" action.BindingVersion = 0 action.NetworkExitID = "" restoreErr = errors.Join(restoreErr, contextErr) @@ -1060,7 +1056,7 @@ func reconcileRuntimeSnapshot(ctx context.Context, store HubStore, probe Network if restoreErr != nil { return restoreErr } - } else if gatewayRead[env.Gateway] && environment.RuntimeInstanceID != "" { + } else if gatewayRead[env.Gateway] && environment.RuntimeID != "" { if err := releaseRuntimeWithReconcileAudit(ctx, store, environment); err != nil { return err } @@ -1181,7 +1177,7 @@ func activateGatewayRuntime(ctx context.Context, store HubStore, target hub.Gate reconciled.BindingVersion == environment.BindingVersion && reconciled.RuntimeID == runtimeID && reconciled.RuntimeNetworkID == networkID && - reconciled.RuntimeInstanceID != "" { + reconciled.RuntimeID != "" { return reconciled, nil } } @@ -1230,7 +1226,7 @@ func createBrowser(store HubStore, probe NetworkExitProbe, resolve func(hub.Netw return hubError(c, err) } finish := func(outcome, reason string, current hub.EnvironmentContext) error { - action.Outcome, action.ReasonCode, action.RuntimeInstanceID = outcome, reason, current.RuntimeInstanceID + action.Outcome, action.ReasonCode = outcome, reason action.BindingVersion, action.NetworkExitID = current.BindingVersion, current.Exit.ID return store.AppendEnvironmentAction(c.Context(), "environment_action_finished", action) } @@ -1377,7 +1373,7 @@ func lifecycleAction(store HubStore, probe NetworkExitProbe, resolve func(hub.Ne return hubError(c, err) } finish := func(outcome, reason string, current hub.EnvironmentContext) error { - action.Outcome, action.ReasonCode, action.RuntimeInstanceID = outcome, reason, current.RuntimeInstanceID + action.Outcome, action.ReasonCode = outcome, reason action.BindingVersion, action.NetworkExitID = current.BindingVersion, current.Exit.ID return store.AppendEnvironmentAction(c.Context(), "environment_action_finished", action) } @@ -1837,7 +1833,7 @@ func rebindBrowser(store HubStore, probe NetworkExitProbe, resolve func(hub.Netw } finish := func(outcome, reason string, current hub.EnvironmentContext) error { action.Outcome, action.ReasonCode = outcome, reason - action.NetworkExitID, action.BindingVersion, action.RuntimeInstanceID = current.Exit.ID, current.BindingVersion, current.RuntimeInstanceID + action.NetworkExitID, action.BindingVersion = current.Exit.ID, current.BindingVersion return store.AppendEnvironmentAction(c.Context(), "environment_action_finished", action) } if !hub.ValidNetworkExitID(input.NetworkExitID) { @@ -1894,7 +1890,7 @@ func rebindBrowser(store HubStore, probe NetworkExitProbe, resolve func(hub.Netw } candidate := before candidate.Exit, candidate.BindingVersion = access.NetworkExit, before.BindingVersion+1 - candidate.RuntimeInstanceID, candidate.RuntimeID = "", "" + candidate.RuntimeID = "" var nextSpec runtimeCreateSpec if wasRunning { nextSpec, err = prepareRuntimeCreate(c.Context(), store, resolve, candidate, access) @@ -2015,7 +2011,7 @@ func actionForEnvironment(actionName string, environment hub.EnvironmentContext) return hub.EnvironmentAction{ OperationID: hub.NewOperationID(), Action: actionName, ReasonCode: "action_requested", AccountID: environment.AccountID, BrowserEnvAlias: environment.Alias, NetworkExitID: environment.Exit.ID, - RuntimeInstanceID: environment.RuntimeInstanceID, BindingVersion: environment.BindingVersion, + BindingVersion: environment.BindingVersion, } } diff --git a/internal/controlplane/api/gateway_browser_unit_test.go b/internal/controlplane/api/gateway_browser_unit_test.go index aa7db16..204e850 100644 --- a/internal/controlplane/api/gateway_browser_unit_test.go +++ b/internal/controlplane/api/gateway_browser_unit_test.go @@ -18,9 +18,7 @@ func testRunnableEnvironment() hub.EnvironmentContext { AccountID: "account-1", AccountStatus: "active", AuthorizationStatus: "authorized", - BindingID: "binding-1", RuntimeID: "runtime-1", - RuntimeInstanceID: "runtime-instance-1", RuntimeNetworkID: "network-1", Exit: hub.NetworkExit{ID: "exit-1", HealthStatus: "healthy"}, BindingVersion: 1, diff --git a/internal/controlplane/api/hub_test.go b/internal/controlplane/api/hub_test.go index f9b9eae..043a6e9 100644 --- a/internal/controlplane/api/hub_test.go +++ b/internal/controlplane/api/hub_test.go @@ -80,7 +80,7 @@ func TestResumeBlockReasonIsStable(t *testing.T) { "direct exit": {account, hub.EnvironmentContext{}, true, "account_conflict"}, "unhealthy exit": {account, hub.EnvironmentContext{Exit: hub.NetworkExit{ID: "exit-a", HealthStatus: "unhealthy"}}, true, "network_exit_unhealthy"}, "cleanup pending": {account, hub.EnvironmentContext{Exit: healthy.Exit, RuntimeCleanupPending: true}, true, "runtime_stop_pending"}, - "runtime active": {account, hub.EnvironmentContext{Exit: healthy.Exit, RuntimeInstanceID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, true, "runtime_active"}, + "runtime active": {account, hub.EnvironmentContext{Exit: healthy.Exit, RuntimeID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, true, "runtime_active"}, } { t.Run(name, func(t *testing.T) { if got := resumeBlockReason(test.account, test.env, test.found); got != test.want { @@ -293,7 +293,7 @@ func (s *memoryStore) CreateBoundEnv(ctx context.Context, env hub.Env, accountID s.mu.Lock() defer s.mu.Unlock() bound := hub.EnvironmentContext{Env: env, AccountID: accountID, AccountStatus: "active", AuthorizationStatus: "authorized", - BindingID: accountID, BindingVersion: 1, Exit: s.exits[exitID]} + BindingVersion: 1, Exit: s.exits[exitID]} s.bindings[env.Alias] = bound return bound, true, nil } @@ -311,7 +311,7 @@ func (s *memoryStore) GetEnvironmentContext(_ context.Context, alias string) (hu return hub.EnvironmentContext{}, hub.ErrNotFound } return hub.EnvironmentContext{Env: env, AccountID: alias, AccountStatus: "active", AuthorizationStatus: "authorized", - BindingID: alias, BindingVersion: 1, Exit: s.exits["exit-1"]}, nil + BindingVersion: 1, Exit: s.exits["exit-1"]}, nil } func (s *memoryStore) GetEnvironmentContextForAccount(ctx context.Context, accountID string) (hub.EnvironmentContext, error) { s.mu.Lock() @@ -341,14 +341,14 @@ func (s *memoryStore) RebindEnvironment(_ context.Context, alias, exitID, runtim defer s.mu.Unlock() bound, ok := s.bindings[alias] if !ok { - bound = hub.EnvironmentContext{Env: s.envs[alias], AccountID: alias, BindingID: alias, BindingVersion: 1, Exit: s.exits["exit-1"]} + bound = hub.EnvironmentContext{Env: s.envs[alias], AccountID: alias, BindingVersion: 1, Exit: s.exits["exit-1"]} } if bound.BindingVersion != expectedBindingVersion || bound.RuntimeCleanupPending { return hub.EnvironmentContext{}, hub.ErrConflict } - bound.RuntimeInstanceID, bound.RuntimeID, bound.RuntimeNetworkID = "", "", "" + bound.RuntimeID, bound.RuntimeNetworkID = "", "" if runtimeID != "" { - bound.RuntimeInstanceID, bound.RuntimeID = "runtime-instance", runtimeID + bound.RuntimeID = runtimeID if len(networkIDs) == 1 { bound.RuntimeNetworkID = networkIDs[0] } @@ -365,7 +365,7 @@ func (s *memoryStore) ActivateRuntime(_ context.Context, alias, runtimeID string } bound, ok := s.bindings[alias] if !ok { - bound = hub.EnvironmentContext{Env: s.envs[alias], AccountID: alias, BindingID: alias, BindingVersion: 1, Exit: s.exits["exit-1"]} + bound = hub.EnvironmentContext{Env: s.envs[alias], AccountID: alias, BindingVersion: 1, Exit: s.exits["exit-1"]} } if bound.AccountStatus == "" { bound.AccountStatus, bound.AuthorizationStatus = "active", "authorized" @@ -374,7 +374,7 @@ func (s *memoryStore) ActivateRuntime(_ context.Context, alias, runtimeID string bound.BindingVersion != bindingVersion || bound.Exit.ID != exitID || bound.RuntimeCleanupPending { return hub.EnvironmentContext{}, hub.ErrConflict } - bound.RuntimeInstanceID, bound.RuntimeID = "runtime-instance", runtimeID + bound.RuntimeID = runtimeID bound.RuntimeNetworkID = networkIDs[0] s.bindings[alias] = bound return bound, nil @@ -388,16 +388,16 @@ func (s *memoryStore) ReleaseRuntime(_ context.Context, environment hub.Environm bound, ok := s.bindings[environment.Alias] if !ok { bound = hub.EnvironmentContext{Env: s.envs[environment.Alias], AccountID: environment.Alias, - BindingID: environment.Alias, BindingVersion: 1, Exit: s.exits["exit-1"]} + BindingVersion: 1, Exit: s.exits["exit-1"]} } - if environment.RuntimeInstanceID == "" { + if environment.RuntimeID == "" { return nil } - if bound.BindingID != environment.BindingID || bound.BindingVersion != environment.BindingVersion || - bound.RuntimeInstanceID != environment.RuntimeInstanceID { + if bound.Alias != environment.Alias || bound.BindingVersion != environment.BindingVersion || + bound.RuntimeID != environment.RuntimeID { return hub.ErrConflict } - bound.RuntimeInstanceID, bound.RuntimeID, bound.RuntimeNetworkID = "", "", "" + bound.RuntimeID, bound.RuntimeNetworkID = "", "" s.bindings[environment.Alias] = bound return nil } @@ -416,31 +416,30 @@ func (s *memoryStore) SetRuntimeCleanupPending(_ context.Context, environment hu bound, ok := s.bindings[environment.Alias] if !ok { bound = hub.EnvironmentContext{Env: s.envs[environment.Alias], AccountID: environment.Alias, - BindingID: environment.Alias, BindingVersion: 1, Exit: s.exits["exit-1"]} + BindingVersion: 1, Exit: s.exits["exit-1"]} } - if bound.BindingID != environment.BindingID || bound.BindingVersion != environment.BindingVersion { + if bound.Alias != environment.Alias || bound.BindingVersion != environment.BindingVersion { return hub.ErrConflict } if bound.RuntimeCleanupPending { if bound.RuntimeCleanupBindingVersion != environment.RuntimeCleanupBindingVersion || - bound.RuntimeCleanupInstanceID != environment.RuntimeCleanupInstanceID || bound.RuntimeCleanupRuntimeID != environment.RuntimeCleanupRuntimeID || bound.RuntimeCleanupNetworkID != environment.RuntimeCleanupNetworkID { return hub.ErrConflict } - } else if pending && bound.RuntimeInstanceID != environment.RuntimeCleanupInstanceID { + } else if pending && bound.RuntimeID != "" && bound.RuntimeID != environment.RuntimeCleanupRuntimeID && + environment.RuntimeCleanupRuntimeID != hub.MissingRuntimeID { return hub.ErrConflict } bound.RuntimeCleanupPending = pending if pending { bound.RuntimeCleanupBindingVersion = environment.RuntimeCleanupBindingVersion - bound.RuntimeCleanupInstanceID = environment.RuntimeCleanupInstanceID bound.RuntimeCleanupRuntimeID = environment.RuntimeCleanupRuntimeID bound.RuntimeCleanupNetworkID = environment.RuntimeCleanupNetworkID - bound.RuntimeInstanceID, bound.RuntimeID, bound.RuntimeNetworkID = "", "", "" + bound.RuntimeID, bound.RuntimeNetworkID = "", "" } else { bound.RuntimeCleanupBindingVersion = 0 - bound.RuntimeCleanupInstanceID, bound.RuntimeCleanupRuntimeID, bound.RuntimeCleanupNetworkID = "", "", "" + bound.RuntimeCleanupRuntimeID, bound.RuntimeCleanupNetworkID = "", "" } s.bindings[environment.Alias] = bound return s.cleanupPendingErr @@ -964,7 +963,7 @@ func TestExitFailuresStopCreateBeforeGateway(t *testing.T) { t.Fatalf("exit failure must stop before any gateway start/create call: %#v", gateway.recorded()) } bound, err := store.GetEnvironmentContext(context.Background(), "account-a") - if err != nil || bound.RuntimeInstanceID != "" || store.exits["exit-1"].HealthStatus != "unhealthy" { + if err != nil || bound.RuntimeID != "" || store.exits["exit-1"].HealthStatus != "unhealthy" { t.Fatalf("failed create must retain only the stable inactive binding: %#v err=%v", bound, err) } if len(store.actions) != 2 || store.actions[1].Outcome != "failed" || store.actions[1].ReasonCode != test.reason { @@ -1029,8 +1028,8 @@ func TestReconcileGatewayCreateDoesNotReuseOldNetworkGeneration(t *testing.T) { store := newMemoryStore() store.envs["account-a"] = hub.Env{Alias: "account-a", Gateway: "gw-1"} environment := hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "active", - AuthorizationStatus: "authorized", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], - RuntimeInstanceID: "runtime-instance-c1", RuntimeID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", RuntimeNetworkID: "native-99999999999999999999999999999999"} + AuthorizationStatus: "authorized", BindingVersion: 1, Exit: store.exits["exit-1"], + RuntimeID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", RuntimeNetworkID: "native-99999999999999999999999999999999"} store.bindings[environment.Alias] = environment gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ ID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", NetworkID: "native-99999999999999999999999999999999", ProxyReady: true, @@ -1064,17 +1063,17 @@ func TestValidCreatedRuntimeGeneration(t *testing.T) { {name: "active invalid network", created: runtimeStatus{ID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", NetworkID: "network n1"}, running: true}, {name: "active replacement network", created: runtimeStatus{ID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", NetworkID: "native-88888888888888888888888888888888"}, running: true}, {name: "active existing matching generation", created: runtimeStatus{ID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", NetworkID: "native-99999999999999999999999999999999"}, running: true, want: true, - environment: hub.EnvironmentContext{RuntimeInstanceID: "runtime-instance-c1", RuntimeID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", RuntimeNetworkID: "native-99999999999999999999999999999999"}}, + environment: hub.EnvironmentContext{RuntimeID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", RuntimeNetworkID: "native-99999999999999999999999999999999"}}, {name: "active existing successor container", created: runtimeStatus{ID: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", NetworkID: "native-99999999999999999999999999999999"}, running: true, - environment: hub.EnvironmentContext{RuntimeInstanceID: "runtime-instance-c1", RuntimeID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", RuntimeNetworkID: "native-99999999999999999999999999999999"}}, + environment: hub.EnvironmentContext{RuntimeID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", RuntimeNetworkID: "native-99999999999999999999999999999999"}}, {name: "active legacy lease without network", created: runtimeStatus{ID: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", NetworkID: "native-88888888888888888888888888888888"}, running: true, - environment: hub.EnvironmentContext{RuntimeInstanceID: "runtime-instance-c1", RuntimeID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}, + environment: hub.EnvironmentContext{RuntimeID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}, {name: "stopped empty network", created: runtimeStatus{ID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, want: true}, {name: "stopped invalid network", created: runtimeStatus{ID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", NetworkID: "network n1"}}, } { t.Run(test.name, func(t *testing.T) { current := environment - if test.environment.RuntimeInstanceID != "" { + if test.environment.RuntimeID != "" { current = test.environment } if got := validCreatedRuntime(test.created, current, test.running); got != test.want { @@ -1107,9 +1106,7 @@ func TestActivationConflictKeepsTheGenerationWonByTheHeartbeat(t *testing.T) { AccountID: "account-a", AccountStatus: "active", AuthorizationStatus: "authorized", - BindingID: "account-a", BindingVersion: 1, - RuntimeInstanceID: "runtime-instance-c1", RuntimeID: candidateID, RuntimeNetworkID: candidateNetwork, Exit: store.exits["exit-1"], @@ -1121,7 +1118,7 @@ func TestActivationConflictKeepsTheGenerationWonByTheHeartbeat(t *testing.T) { if err != nil { t.Fatalf("activation race was reported as failure: %v", err) } - if got.RuntimeID != candidateID || got.RuntimeNetworkID != candidateNetwork || got.RuntimeInstanceID == "" { + if got.RuntimeID != candidateID || got.RuntimeNetworkID != candidateNetwork || got.RuntimeID == "" { t.Fatalf("heartbeat winner was not returned: %#v", got) } } @@ -1130,8 +1127,8 @@ func TestLegacyActiveRuntimeWithoutNetworkGenerationDoesNotTouchSuccessor(t *tes store := newMemoryStore() store.envs["account-a"] = hub.Env{Alias: "account-a", Gateway: "gw-1"} environment := hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "active", - AuthorizationStatus: "authorized", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], - RuntimeInstanceID: "runtime-instance-c1", RuntimeID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} + AuthorizationStatus: "authorized", BindingVersion: 1, Exit: store.exits["exit-1"], + RuntimeID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"} store.bindings[environment.Alias] = environment gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ ID: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", NetworkID: "native-88888888888888888888888888888888", ProxyReady: true, @@ -1378,7 +1375,7 @@ func TestListRestoresProxyAfterGatewayRestartBeforeHeartbeat(t *testing.T) { store := newMemoryStore() store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ - Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 3, Exit: store.exits["exit-1"], + Env: store.envs["account-a"], AccountID: "account-a", BindingVersion: 3, Exit: store.exits["exit-1"], } gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ ID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Alias: "account-a", State: "running", BindingVersion: 3, NetworkExitID: "exit-1", NetworkID: "native-cccccccccccccccccccccccccccccccc", ProxyReady: false, @@ -1404,7 +1401,7 @@ func TestListRestoresProxyAfterGatewayRestartBeforeHeartbeat(t *testing.T) { func TestGatewayRestartRebuildsWhenOriginalProxyPortCannotBeRestored(t *testing.T) { store := newMemoryStore() store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} - store.bindings["account-a"] = hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 2, Exit: store.exits["exit-1"]} + store.bindings["account-a"] = hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", BindingVersion: 2, Exit: store.exits["exit-1"]} gateway := &fakeGateway{token: "unit-test-gateway-token", failProxy: true, runtimes: []runtimeStatus{{ ID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", Alias: "account-a", State: "running", BindingVersion: 2, NetworkExitID: "exit-1", NetworkID: "native-66666666666666666666666666666666", }}} @@ -1613,7 +1610,7 @@ func installCleanupTransitionFailure(t *testing.T, ctx context.Context, db *sql. t.Fatal(err) } if _, err := db.ExecContext(ctx, ` - CREATE TRIGGER fail_cleanup_transition BEFORE UPDATE OF runtime_cleanup_pending ON environment_binding + CREATE TRIGGER fail_cleanup_transition BEFORE UPDATE OF runtime_cleanup_pending ON browser_env FOR EACH ROW EXECUTE FUNCTION fail_cleanup_transition()`); err != nil { t.Fatal(err) } @@ -1621,7 +1618,7 @@ func installCleanupTransitionFailure(t *testing.T, ctx context.Context, db *sql. func dropCleanupTransitionFailure(t *testing.T, ctx context.Context, db *sql.DB) { t.Helper() - if _, err := db.ExecContext(ctx, `DROP TRIGGER fail_cleanup_transition ON environment_binding`); err != nil { + if _, err := db.ExecContext(ctx, `DROP TRIGGER fail_cleanup_transition ON browser_env`); err != nil { t.Fatal(err) } if _, err := db.ExecContext(ctx, `DROP FUNCTION fail_cleanup_transition()`); err != nil { @@ -1675,8 +1672,8 @@ func TestPostgresCleanupPendingPersistsAndReconciles(t *testing.T) { after.Exit.ID != fixture.bound.Exit.ID || after.RuntimeID != "" { t.Fatalf("PostgreSQL did not persist cleanup pending atomically: %#v err=%v", after, err) } - assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM environment_binding WHERE browser_env_alias = 'account-a' AND runtime_cleanup_pending`, 1) - assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 0) + assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM browser_env WHERE alias = 'account-a' AND runtime_cleanup_pending`, 1) + assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM browser_env WHERE alias = 'account-a' AND runtime_id IS NOT NULL`, 0) if err := reconcileRuntimeLeases(ctx, fixture.store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }); err != nil { t.Fatalf("reconcile did not confirm cleanup: %v", err) @@ -1713,8 +1710,8 @@ func TestRestoreAndDiscardPreserveGenerationWhenCleanupMarkFails(t *testing.T) { store := newMemoryStore() store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ - Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 2, - Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", RuntimeNetworkID: "native-66666666666666666666666666666666", + Env: store.envs["account-a"], AccountID: "account-a", BindingVersion: 2, + Exit: store.exits["exit-1"], RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", RuntimeNetworkID: "native-66666666666666666666666666666666", } store.cleanupPendingErr = errors.New("cleanup state unavailable") store.cleanupPendingErrAfterMutation = !test.commitKnown @@ -1768,8 +1765,8 @@ func TestRestoreAndDiscardPreserveGenerationWhenCleanupMarkFails(t *testing.T) { store := newMemoryStore() store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ - Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, - Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + Env: store.envs["account-a"], AccountID: "account-a", BindingVersion: 1, + Exit: store.exits["exit-1"], RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", } store.cleanupPendingErr = errors.New("cleanup state unavailable") store.cleanupPendingErrAfterMutation = !test.commitKnown @@ -1824,8 +1821,8 @@ func TestExistingCreateStopsOnGatewayUnknownAndRetriesReuse(t *testing.T) { store := newMemoryStore() store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ - Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, - Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", RuntimeNetworkID: "native-66666666666666666666666666666666", + Env: store.envs["account-a"], AccountID: "account-a", BindingVersion: 1, + Exit: store.exits["exit-1"], RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", RuntimeNetworkID: "native-66666666666666666666666666666666", } gateway := &fakeGateway{token: "unit-test-gateway-token", disconnectList: gatewayReconcileAttempts, runtimes: []runtimeStatus{{ ID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", Alias: "account-a", State: "running", ProxyReady: true, BindingVersion: 1, NetworkExitID: "exit-1", NetworkID: "native-66666666666666666666666666666666", @@ -1874,8 +1871,8 @@ func TestRemoveGatewayRuntimePreservesKnownNetworkGeneration(t *testing.T) { store := newMemoryStore() store.envs["account-a"] = hub.Env{Alias: "account-a", Gateway: "gw-1"} environment := hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "active", - AuthorizationStatus: "authorized", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], - RuntimeInstanceID: "runtime-instance", RuntimeID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", RuntimeNetworkID: "native-99999999999999999999999999999999"} + AuthorizationStatus: "authorized", BindingVersion: 1, Exit: store.exits["exit-1"], + RuntimeID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", RuntimeNetworkID: "native-99999999999999999999999999999999"} store.bindings["account-a"] = environment gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ID: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", NetworkID: test.observedNetwork, ProxyReady: true}}} @@ -1901,8 +1898,8 @@ func TestGatewayLookupFailurePreservesLease(t *testing.T) { store := newMemoryStore() store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} environment := hub.EnvironmentContext{ - Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, - Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + Env: store.envs["account-a"], AccountID: "account-a", BindingVersion: 1, + Exit: store.exits["exit-1"], RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", } store.bindings[environment.Alias] = environment store.gatewayFn = func(string) (hub.Gateway, error) { return hub.Gateway{}, errors.New("gateway lookup unavailable") } @@ -1952,7 +1949,7 @@ func TestPostgresCleanupPendingTransactionRollbacks(t *testing.T) { if err != nil || after.RuntimeCleanupPending || after.RuntimeID != "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" || len(fixture.gateway.runtimes) != 1 { t.Fatalf("PostgreSQL rollback did not preserve the active generation: after=%#v runtimes=%#v err=%v", after, fixture.gateway.runtimes, err) } - assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 1) + assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM browser_env WHERE alias = 'account-a' AND runtime_id IS NOT NULL`, 1) dropCleanupTransitionFailure(t, ctx, fixture.db) fixture.gateway.cleanupPending = 2 @@ -1964,7 +1961,7 @@ func TestPostgresCleanupPendingTransactionRollbacks(t *testing.T) { if err != nil || !after.RuntimeCleanupPending || after.RuntimeID != "" || len(fixture.gateway.runtimes) != 0 { t.Fatalf("202 retry was not durable: after=%#v runtimes=%#v err=%v", after, fixture.gateway.runtimes, err) } - assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 0) + assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM browser_env WHERE alias = 'account-a' AND runtime_id IS NOT NULL`, 0) }) t.Run("clear rollback remains pending and lifecycle retry recovers", func(t *testing.T) { @@ -1994,7 +1991,7 @@ func TestPostgresCleanupPendingTransactionRollbacks(t *testing.T) { if err != nil || !after.RuntimeCleanupPending || after.RuntimeID != "" || len(fixture.gateway.runtimes) != 0 { t.Fatalf("clear rollback was not durably pending: after=%#v runtimes=%#v err=%v", after, fixture.gateway.runtimes, err) } - assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 0) + assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM browser_env WHERE alias = 'account-a' AND runtime_id IS NOT NULL`, 0) dropCleanupTransitionFailure(t, ctx, fixture.db) setFixtureAccountStatus(t, fixture.databaseURL, "active") @@ -2024,9 +2021,9 @@ func TestPostgresLegacyCleanupPendingRequiresManualReconcile(t *testing.T) { ctx := context.Background() fixture := newPostgresRebindFixture(t, databaseURL) if _, err := fixture.db.ExecContext(ctx, ` - UPDATE environment_binding SET runtime_cleanup_pending = true, - runtime_cleanup_binding_version = NULL, runtime_cleanup_instance_id = NULL, runtime_cleanup_runtime_id = NULL - WHERE id = $1`, fixture.bound.BindingID); err != nil { + UPDATE browser_env SET runtime_cleanup_pending = true, + runtime_cleanup_binding_version = NULL, runtime_cleanup_runtime_id = NULL + WHERE alias = $1`, fixture.bound.Alias); err != nil { t.Fatalf("write legacy pending state: %v", err) } environment, err := fixture.store.GetEnvironmentContext(ctx, fixture.bound.Alias) @@ -2068,7 +2065,7 @@ func TestPostgresCleanupMissingContainerReleasesKnownLease(t *testing.T) { if err != nil || after.RuntimeCleanupPending || after.RuntimeID != "" { t.Fatalf("known PostgreSQL lease did not converge: %#v err=%v", after, err) } - assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 0) + assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM browser_env WHERE alias = 'account-a' AND runtime_id IS NOT NULL`, 0) requests := fixture.gateway.recorded() if len(requests) == 0 || requests[len(requests)-1].method != http.MethodDelete || requests[len(requests)-1].body["runtime_id"] != "known-container" { @@ -2117,12 +2114,12 @@ func TestPostgresCleanupCallChainsPreserveGeneration(t *testing.T) { if err != nil || !after.RuntimeCleanupPending || after.RuntimeID != "" { t.Fatalf("restore commit-unknown lost pending state: after=%#v err=%v", after, err) } - assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 0) + assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM browser_env WHERE alias = 'account-a' AND runtime_id IS NOT NULL`, 0) } else { if err != nil || after.RuntimeCleanupPending || after.RuntimeID != "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" { t.Fatalf("restore rollback changed the old generation: after=%#v err=%v", after, err) } - assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 1) + assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM browser_env WHERE alias = 'account-a' AND runtime_id IS NOT NULL`, 1) } if len(fixture.gateway.runtimes) != 1 { t.Fatalf("restore cleanup failure touched the container: runtimes=%#v requests=%#v", fixture.gateway.runtimes, fixture.gateway.recorded()) @@ -2162,7 +2159,7 @@ func TestPostgresCleanupCallChainsPreserveGeneration(t *testing.T) { if err != nil || after.RuntimeCleanupPending || after.RuntimeID == "" || len(runtimes) != 1 || !runtimeMatchesBinding(runtimes[0], after) { t.Fatalf("restore retry ended inconsistently: after=%#v runtimes=%#v err=%v", after, runtimes, err) } - assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 1) + assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM browser_env WHERE alias = 'account-a' AND runtime_id IS NOT NULL`, 1) }) t.Run("create restore "+test.name, func(t *testing.T) { @@ -2196,12 +2193,12 @@ func TestPostgresCleanupCallChainsPreserveGeneration(t *testing.T) { if err != nil || !after.RuntimeCleanupPending || after.RuntimeID != "" { t.Fatalf("create restore commit-unknown lost pending state: after=%#v err=%v", after, err) } - assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 0) + assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM browser_env WHERE alias = 'account-a' AND runtime_id IS NOT NULL`, 0) } else { if err != nil || after.RuntimeCleanupPending || after.RuntimeID != "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" { t.Fatalf("create restore rollback changed the old generation: after=%#v err=%v", after, err) } - assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 1) + assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM browser_env WHERE alias = 'account-a' AND runtime_id IS NOT NULL`, 1) } if len(fixture.gateway.runtimes) != 1 { t.Fatalf("create restore cleanup failure touched the container: runtimes=%#v requests=%#v", fixture.gateway.runtimes, fixture.gateway.recorded()) @@ -2237,7 +2234,7 @@ func TestPostgresCleanupCallChainsPreserveGeneration(t *testing.T) { if err != nil || after.RuntimeCleanupPending || after.RuntimeID == "" || len(runtimes) != 1 || !runtimeMatchesBinding(runtimes[0], after) { t.Fatalf("create restore retry ended inconsistently: after=%#v runtimes=%#v err=%v", after, runtimes, err) } - assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 1) + assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM browser_env WHERE alias = 'account-a' AND runtime_id IS NOT NULL`, 1) }) t.Run("discard "+test.name, func(t *testing.T) { @@ -2270,12 +2267,12 @@ func TestPostgresCleanupCallChainsPreserveGeneration(t *testing.T) { if err != nil || !after.RuntimeCleanupPending || after.RuntimeID != "" { t.Fatalf("discard commit-unknown lost pending state: after=%#v err=%v", after, err) } - assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 0) + assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM browser_env WHERE alias = 'account-a' AND runtime_id IS NOT NULL`, 0) } else { if err != nil || after.RuntimeCleanupPending || after.RuntimeID != "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" { t.Fatalf("discard rollback changed the old generation: after=%#v err=%v", after, err) } - assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 1) + assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM browser_env WHERE alias = 'account-a' AND runtime_id IS NOT NULL`, 1) } if len(fixture.gateway.runtimes) != 1 { t.Fatalf("discard cleanup failure touched the container: runtimes=%#v requests=%#v", fixture.gateway.runtimes, fixture.gateway.recorded()) @@ -2310,7 +2307,7 @@ func TestPostgresCleanupCallChainsPreserveGeneration(t *testing.T) { t.Fatalf("discard retry retained a running container: %#v", fixture.gateway.runtimes) } } - assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 0) + assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM browser_env WHERE alias = 'account-a' AND runtime_id IS NOT NULL`, 0) }) } } @@ -2352,7 +2349,7 @@ func TestPostgresGatewayUnknownDoesNotAffectListAndStillBlocksCreate(t *testing. if err != nil || after.RuntimeCleanupPending || after.RuntimeID != "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" || len(fixture.gateway.runtimes) != 1 { t.Fatalf("browser list changed the generation: after=%#v runtimes=%#v err=%v", after, fixture.gateway.runtimes, err) } - assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, 1) + assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM browser_env WHERE alias = 'account-a' AND runtime_id IS NOT NULL`, 1) requests := fixture.gateway.recorded() if len(requests) != 1 || requests[0].method != http.MethodGet || requests[0].path != "/v1/browsers" { t.Fatalf("browser list did not perform the expected live gateway status read: %#v", requests) @@ -2495,7 +2492,7 @@ func TestPostgresStrictGatewayValidationBlocksLifecycleUntilRetry(t *testing.T) if test.active { activeCount = 1 } - assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, activeCount) + assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM browser_env WHERE alias = 'account-a' AND runtime_id IS NOT NULL`, activeCount) if test.action != "" { var outcome, reason string if err := fixture.db.QueryRowContext(ctx, ` @@ -2531,7 +2528,7 @@ func TestPostgresStrictGatewayValidationBlocksLifecycleUntilRetry(t *testing.T) if test.action == "rebind" { activeAfter = 0 } - assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM runtime_instance WHERE binding_id = 'account-a' AND released_at IS NULL`, activeAfter) + assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM browser_env WHERE alias = 'account-a' AND runtime_id IS NOT NULL`, activeAfter) if test.action != "" { var outcome, reason string if err := fixture.db.QueryRowContext(ctx, ` @@ -2648,19 +2645,18 @@ func TestPostgresReconcileFinishedAuditUsesActivatedRuntime(t *testing.T) { t.Fatalf("reconcile failed: %v", err) } after, err := fixture.store.GetEnvironmentContext(context.Background(), "account-a") - if err != nil || after.RuntimeInstanceID == "" || after.RuntimeID != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" { + if err != nil || after.RuntimeID == "" || after.RuntimeID != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" { t.Fatalf("reconcile did not activate replacement runtime: %#v err=%v", after, err) } - var auditedRuntime string var auditedBinding int64 if err := fixture.db.QueryRow(` - SELECT runtime_instance_id, binding_version FROM audit_event + SELECT binding_version FROM audit_event WHERE event_type = 'environment_action_finished' AND action = 'reconcile' - ORDER BY id DESC LIMIT 1`).Scan(&auditedRuntime, &auditedBinding); err != nil { + ORDER BY id DESC LIMIT 1`).Scan(&auditedBinding); err != nil { t.Fatal(err) } - if auditedRuntime != after.RuntimeInstanceID || auditedBinding != after.BindingVersion { - t.Fatalf("finished audit retained stale runtime: runtime=%q binding=%d current=%#v", auditedRuntime, auditedBinding, after) + if auditedBinding != after.BindingVersion { + t.Fatalf("finished audit retained stale binding: binding=%d current=%#v", auditedBinding, after) } } @@ -2710,7 +2706,7 @@ func TestPostgresNonRunnableReconcileAuditsRuntimeRelease(t *testing.T) { t.Fatalf("reconcile error=%v, releaseFailure=%v", err, releaseFailure) } after, err := fixture.store.GetEnvironmentContext(ctx, "account-a") - if err != nil || (after.RuntimeInstanceID != "") == !releaseFailure { + if err != nil || (after.RuntimeID != "") == !releaseFailure { t.Fatalf("runtime lease after reconcile: %#v err=%v", after, err) } @@ -2771,7 +2767,7 @@ func TestStartRejectsPausedOrRevokedAccountBeforeGatewayCall(t *testing.T) { store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: test.status, - AuthorizationStatus: test.authorization, BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], + AuthorizationStatus: test.authorization, BindingVersion: 1, Exit: store.exits["exit-1"], } gateway := &fakeGateway{token: "unit-test-gateway-token"} app := newTestApp(t, store, gateway) @@ -2794,8 +2790,8 @@ func TestReconcileStopsRuntimeForPausedOrRevokedAccount(t *testing.T) { store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "paused", AuthorizationStatus: authorization, - BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], - RuntimeInstanceID: "old-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + BindingVersion: 1, Exit: store.exits["exit-1"], + RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", } gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ ID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", Alias: "account-a", State: "running", ProxyReady: true, BindingVersion: 1, NetworkExitID: "exit-1", @@ -2831,7 +2827,7 @@ func TestActivationConflictCleanupIsGenerationSafe(t *testing.T) { store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} environment := hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "paused", AuthorizationStatus: "authorized", - BindingID: "account-a", BindingVersion: 2, Exit: store.exits["exit-1"], + BindingVersion: 2, Exit: store.exits["exit-1"], } store.bindings[environment.Alias] = environment gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ @@ -2866,7 +2862,7 @@ func TestExplicitStopRejectsStaleBindingBeforeGatewaySideEffect(t *testing.T) { store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "active", AuthorizationStatus: "authorized", - BindingID: "account-a", BindingVersion: 2, Exit: store.exits["exit-1"], RuntimeInstanceID: "new-instance", RuntimeID: "new-container", + BindingVersion: 2, Exit: store.exits["exit-1"], RuntimeID: "new-container", } gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ ID: "new-container", Alias: "account-a", State: "running", ProxyReady: true, BindingVersion: 2, NetworkExitID: "exit-1", @@ -2875,7 +2871,7 @@ func TestExplicitStopRejectsStaleBindingBeforeGatewaySideEffect(t *testing.T) { defer server.Close() store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: server.URL, Token: gateway.token} stale := store.bindings["account-a"] - stale.BindingVersion, stale.RuntimeInstanceID, stale.RuntimeID = 1, "old-instance", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + stale.BindingVersion, stale.RuntimeID, stale.RuntimeID = 1, "old-instance", "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" if err := stopEnvironmentRuntime(context.Background(), store, stale); !errors.Is(err, hub.ErrConflict) { t.Fatalf("stale explicit stop was not rejected: %v", err) @@ -2891,7 +2887,7 @@ func TestReconcileFinishedAuditUsesRebuiltRuntime(t *testing.T) { store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "active", AuthorizationStatus: "authorized", - BindingID: "account-a", BindingVersion: 2, Exit: store.exits["exit-2"], RuntimeInstanceID: "old-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", RuntimeNetworkID: "native-66666666666666666666666666666666", + BindingVersion: 2, Exit: store.exits["exit-2"], RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", RuntimeNetworkID: "native-66666666666666666666666666666666", } gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ ID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", Alias: "account-a", State: "running", ProxyReady: true, BindingVersion: 2, NetworkExitID: "exit-1", NetworkID: "native-66666666666666666666666666666666", @@ -2901,8 +2897,10 @@ func TestReconcileFinishedAuditUsesRebuiltRuntime(t *testing.T) { if err := reconcileRuntimeLeases(context.Background(), store, fakeExitProbe{}, func(hub.NetworkExitAccess) (string, error) { return "", nil }); err != nil { t.Fatalf("reconcile failed: %v", err) } + // 真库审计已无 runtime_instance_id,改断言内存 binding 的重建 runtime 代际(fake 网关固定创建 ID) if len(store.actions) != 2 || store.actions[1].Outcome != "succeeded" || - store.actions[1].RuntimeInstanceID != "runtime-instance" || store.actions[1].BindingVersion != 2 || store.actions[1].NetworkExitID != "exit-2" { + store.bindings["account-a"].RuntimeID != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" || + store.actions[1].BindingVersion != 2 || store.actions[1].NetworkExitID != "exit-2" { t.Fatalf("finished audit used stale runtime context: %#v", store.actions) } } @@ -2912,7 +2910,7 @@ func TestReconcileContextRefreshFailureClearsAllAuditCorrelation(t *testing.T) { store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ Env: store.envs["account-a"], AccountID: "account-a", AccountStatus: "active", AuthorizationStatus: "authorized", - BindingID: "account-a", BindingVersion: 2, Exit: store.exits["exit-1"], RuntimeInstanceID: "old-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", RuntimeNetworkID: "native-66666666666666666666666666666666", + BindingVersion: 2, Exit: store.exits["exit-1"], RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", RuntimeNetworkID: "native-66666666666666666666666666666666", } gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ ID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", Alias: "account-a", State: "running", ProxyReady: true, BindingVersion: 2, NetworkExitID: "stale-exit", NetworkID: "native-66666666666666666666666666666666", @@ -2926,7 +2924,7 @@ func TestReconcileContextRefreshFailureClearsAllAuditCorrelation(t *testing.T) { t.Fatal("context refresh failure unexpectedly succeeded") } finished := store.actions[len(store.actions)-1] - if finished.RuntimeInstanceID != "" || finished.BindingVersion != 0 || finished.NetworkExitID != "" { + if finished.BindingVersion != 0 || finished.NetworkExitID != "" { t.Fatalf("failed context refresh retained partial stale correlation: %#v", finished) } } @@ -2945,7 +2943,7 @@ func TestStoppedReconcileReportsRuntimeReleaseFailure(t *testing.T) { defer gatewayServer.Close() store := newMemoryStore() store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} - store.bindings["account-a"] = hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} + store.bindings["account-a"] = hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} store.releaseErr = errors.New("database unavailable") store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: gatewayServer.URL, Token: "unit-test-gateway-token"} app := fiber.New() @@ -2964,8 +2962,8 @@ func TestReconcileAuditsRuntimeReleaseFailure(t *testing.T) { store := newMemoryStore() store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ - Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, - Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Env: store.envs["account-a"], AccountID: "account-a", BindingVersion: 1, + Exit: store.exits["exit-1"], RuntimeID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", } store.releaseErr = errors.New("database unavailable") gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ @@ -2987,8 +2985,8 @@ func TestRebindRebuildsRunningContainerWithLatestBinding(t *testing.T) { store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy.example", Port: 8080, HealthStatus: "healthy", Version: 1} store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ - Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, - Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + Env: store.envs["account-a"], AccountID: "account-a", BindingVersion: 1, + Exit: store.exits["exit-1"], RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", } gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ ID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", ProxyReady: true, @@ -3016,8 +3014,8 @@ func TestSameExitRebindStillRebuildsRunningContainer(t *testing.T) { store := newMemoryStore() store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ - Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, - Exit: store.exits["exit-1"], RuntimeInstanceID: "expired-runtime", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + Env: store.envs["account-a"], AccountID: "account-a", BindingVersion: 1, + Exit: store.exits["exit-1"], RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", } gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ ID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", ProxyReady: true, @@ -3045,8 +3043,8 @@ func TestRuntimeReuseRechecksHealthAndDiscardsFailedExit(t *testing.T) { store := newMemoryStore() store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ - Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, - Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Env: store.envs["account-a"], AccountID: "account-a", BindingVersion: 1, + Exit: store.exits["exit-1"], RuntimeID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", } gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ ID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", ProxyReady: true, @@ -3071,8 +3069,8 @@ func TestReconcileDeleteFailureReleasesLeaseAndAuditsUnknown(t *testing.T) { store := newMemoryStore() store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ - Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 2, - Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "stale-container", RuntimeNetworkID: "native-66666666666666666666666666666666", + Env: store.envs["account-a"], AccountID: "account-a", BindingVersion: 2, + Exit: store.exits["exit-1"], RuntimeID: "stale-container", RuntimeNetworkID: "native-66666666666666666666666666666666", } gateway := &fakeGateway{token: "unit-test-gateway-token", failDelete: 1, runtimes: []runtimeStatus{{ ID: "stale-container", Alias: "account-a", State: "running", BindingVersion: 2, NetworkExitID: "stale-exit", NetworkID: "native-66666666666666666666666666666666", ProxyReady: true, @@ -3098,8 +3096,8 @@ func TestDisableExitImmediatelyDiscardsRuntime(t *testing.T) { store := newMemoryStore() store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ - Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, - Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Env: store.envs["account-a"], AccountID: "account-a", BindingVersion: 1, + Exit: store.exits["exit-1"], RuntimeID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", } gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ ID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", ProxyReady: true, @@ -3129,7 +3127,7 @@ func TestDisableExitWaitsForInFlightBrowserLifecycle(t *testing.T) { store := newMemoryStore() store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ - Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], + Env: store.envs["account-a"], AccountID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], } gatewayServer := httptest.NewServer(gateway.handler(t)) defer gatewayServer.Close() @@ -3206,8 +3204,8 @@ func TestDisableExitPropagatesUnknownGatewayReadAndPreservesLease(t *testing.T) store := newMemoryStore() store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ - Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, - Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Env: store.envs["account-a"], AccountID: "account-a", BindingVersion: 1, + Exit: store.exits["exit-1"], RuntimeID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", } store.gateways["gw-1"] = hub.Gateway{Name: "gw-1", Endpoint: gatewayServer.URL, Token: "unit-test-gateway-token"} app := fiber.New() @@ -3233,8 +3231,8 @@ func TestDirectBindingIsListable(t *testing.T) { Seed: 1, ProxyServer: "http://legacy:secret@proxy.example:8080", DisableNonProxiedUDP: true, }} store.bindings["account-a"] = hub.EnvironmentContext{ - Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, - RuntimeInstanceID: "4444444444444444444444444444444444444444444444444444444444444444", RuntimeID: "direct-container", RuntimeNetworkID: "direct-network", + Env: store.envs["account-a"], AccountID: "account-a", BindingVersion: 1, + RuntimeID: "direct-container", RuntimeNetworkID: "direct-network", } gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ ID: "direct-container", Alias: "account-a", State: "running", BindingVersion: 1, NetworkID: "direct-network", ProxyReady: true, @@ -3258,13 +3256,13 @@ func TestRebindDeleteFailureKeepsOriginalBinding(t *testing.T) { targetID string }{ {name: "running binding", binding: hub.EnvironmentContext{ - AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, - Exit: hub.NetworkExit{ID: "exit-1", Protocol: "socks5", Host: "127.0.0.1", Port: 1080, HealthStatus: "healthy"}, - RuntimeInstanceID: "runtime-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + AccountID: "account-a", BindingVersion: 1, + Exit: hub.NetworkExit{ID: "exit-1", Protocol: "socks5", Host: "127.0.0.1", Port: 1080, HealthStatus: "healthy"}, + RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", }, targetID: "exit-2"}, {name: "legacy NULL binding", binding: hub.EnvironmentContext{ - AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, - RuntimeInstanceID: "5555555555555555555555555555555555555555555555555555555555555555", RuntimeID: "legacy-container", + AccountID: "account-a", BindingVersion: 1, + RuntimeID: "legacy-container", }, targetID: "exit-1"}, } { t.Run(test.name, func(t *testing.T) { @@ -3312,8 +3310,8 @@ func TestRebindNetworkCleanupPendingBlocksUntilConfirmed(t *testing.T) { store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy.example", Port: 8080, HealthStatus: "healthy", Version: 1} store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ - Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, - Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + Env: store.envs["account-a"], AccountID: "account-a", BindingVersion: 1, + Exit: store.exits["exit-1"], RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", } gateway := &fakeGateway{token: "unit-test-gateway-token", cleanupPending: test.cleanupPending, disconnectDelete: test.disconnectDelete, disconnectListAfterDelete: test.disconnectListAfterDelete, @@ -3362,7 +3360,7 @@ func TestCleanupPendingBlocksEveryLifecyclePath(t *testing.T) { store := newMemoryStore() store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} store.bindings["account-a"] = hub.EnvironmentContext{ - Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, + Env: store.envs["account-a"], AccountID: "account-a", BindingVersion: 1, RuntimeCleanupPending: true, RuntimeCleanupBindingVersion: 1, RuntimeCleanupRuntimeID: missingRuntimeID, RuntimeCleanupNetworkID: "native-66666666666666666666666666666666", Exit: store.exits["exit-1"], } @@ -3391,8 +3389,7 @@ func TestRebindPreparesRunningRuntimeBeforeDelete(t *testing.T) { store := newMemoryStore() store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy.example", Port: 8080, HealthStatus: "healthy", Version: 1} store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} - store.bindings["account-a"] = hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", - BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"} + store.bindings["account-a"] = hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"} gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ ID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", ProxyReady: true, }}} @@ -3411,8 +3408,7 @@ func TestRebindCandidateUnknownCreateLeavesGenerationPending(t *testing.T) { store := newMemoryStore() store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy.example", Port: 8080, HealthStatus: "healthy", Version: 1} store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} - store.bindings["account-a"] = hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", - BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"} + store.bindings["account-a"] = hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"], RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"} gateway := &fakeGateway{token: "unit-test-gateway-token", failCreate: 1, runtimes: []runtimeStatus{{ ID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", ProxyReady: true, }}} @@ -3450,8 +3446,8 @@ func TestExistingEnvironmentCleanupNeverReturnsReusedSuccess(t *testing.T) { } store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "店铺一号", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 2024, Platform: "windows", Timezone: "Asia/Shanghai"}} store.bindings["account-a"] = hub.EnvironmentContext{ - Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, - Exit: store.exits["exit-1"], RuntimeInstanceID: "runtime-instance", RuntimeID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Env: store.envs["account-a"], AccountID: "account-a", BindingVersion: 1, + Exit: store.exits["exit-1"], RuntimeID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", } gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ ID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Alias: "account-a", State: "running", BindingVersion: 1, NetworkExitID: "exit-1", @@ -3479,7 +3475,7 @@ func TestStartRebuildsStoppedContainerAfterRebind(t *testing.T) { store := newMemoryStore() store.exits["exit-2"] = hub.NetworkExit{ID: "exit-2", Protocol: "http", Host: "proxy-2.example", Port: 8080, HealthStatus: "healthy", Version: 1} store.envs["account-a"] = hub.Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: hub.Fingerprint{Seed: 1}} - store.bindings["account-a"] = hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", BindingID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"]} + store.bindings["account-a"] = hub.EnvironmentContext{Env: store.envs["account-a"], AccountID: "account-a", BindingVersion: 1, Exit: store.exits["exit-1"]} gateway := &fakeGateway{token: "unit-test-gateway-token", runtimes: []runtimeStatus{{ID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", Alias: "account-a", State: "exited", BindingVersion: 1}}} app := newTestApp(t, store, gateway) diff --git a/internal/creator/accounts.go b/internal/creator/accounts.go index b17be58..bd88f6d 100644 --- a/internal/creator/accounts.go +++ b/internal/creator/accounts.go @@ -137,13 +137,13 @@ type ownedWorkStat struct { LatestPublishedAt *time.Time } -// ownedWorkStats 按 source_id 聚合 owned 作品数与最近发布时间(creator_work_source 与 creator_work 已保持一致)。 +// ownedWorkStats 按 source_id 聚合 owned 作品数与最近发布时间。 func (s *Store) ownedWorkStats(ctx context.Context) (map[string]ownedWorkStat, error) { rows, err := s.db.QueryContext(ctx, ` - SELECT ws.source_id, COUNT(*) AS work_count, MAX(w.published_at) AS latest_published_at - FROM creator_work_source ws JOIN creator_work w ON w.id = ws.work_id - WHERE ws.source_type = 'owned' - GROUP BY ws.source_id`) + SELECT w.source_id, COUNT(*) AS work_count, MAX(w.published_at) AS latest_published_at + FROM creator_work w + WHERE w.source_type = 'owned' + GROUP BY w.source_id`) if err != nil { return nil, databaseError(err) } @@ -185,8 +185,8 @@ func (s *Store) GetAccountCollectionStatus(ctx context.Context, accountID string } status := AccountCollectionStatus{} for _, kind := range []struct { - name string - pointer *AccountCheckpointStatus + name string + pointer *AccountCheckpointStatus }{ {name: "works", pointer: &status.Works}, {name: "comments", pointer: &status.Comments}, diff --git a/internal/creator/content.go b/internal/creator/content.go index 1109a72..20297d1 100644 --- a/internal/creator/content.go +++ b/internal/creator/content.go @@ -120,10 +120,10 @@ func (s *Store) ListCompetitorsWithProfile(ctx context.Context, platform string) work_stats.latest_published_at FROM creator_competitor LEFT JOIN ( - SELECT ws.source_id, COUNT(*) AS work_count, MAX(w.published_at) AS latest_published_at - FROM creator_work_source ws JOIN creator_work w ON w.id = ws.work_id - WHERE ws.source_type = 'competitor' - GROUP BY ws.source_id + SELECT w.source_id, COUNT(*) AS work_count, MAX(w.published_at) AS latest_published_at + FROM creator_work w + WHERE w.source_type = 'competitor' + GROUP BY w.source_id ) work_stats ON work_stats.source_id = creator_competitor.id` args := []any{} if platform != "" { @@ -405,12 +405,6 @@ func (s *Store) UpsertWork(ctx context.Context, input WorkInput, now time.Time) if err != nil { return Work{}, false, databaseError(err) } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO creator_work_source (work_id, platform, source_type, source_id) - VALUES ($1, $2, $3, $4) ON CONFLICT (work_id, source_type, source_id) DO NOTHING`, - returnedID, input.Platform, input.SourceType, input.SourceID); err != nil { - return Work{}, false, databaseError(err) - } result, err := scanWork(tx.QueryRowContext(ctx, workSelect+` WHERE id = $1`, returnedID)) if err != nil { return Work{}, false, rowError(err) @@ -418,9 +412,7 @@ func (s *Store) UpsertWork(ctx context.Context, input WorkInput, now time.Time) if err := tx.Commit(); err != nil { return Work{}, false, fmt.Errorf("commit creator work upsert: %w", err) } - if err := s.loadWorkSources(ctx, &result); err != nil { - return Work{}, false, err - } + loadWorkSources(&result) return result, inserted, nil } @@ -458,24 +450,8 @@ const workSelect = `SELECT id, platform, work_key, source_type, source_id, autho published_at, published_at_status, original_url, cover_url, raw_payload, likes, comments_count, shares, collect_count, play_count, latest_metrics_at, next_metric_at, metric_stop_reason, created_at, updated_at FROM creator_work` -func (s *Store) loadWorkSources(ctx context.Context, work *Work) error { - rows, err := s.db.QueryContext(ctx, ` - SELECT platform, source_type, source_id - FROM creator_work_source WHERE work_id = $1 - ORDER BY source_type, source_id`, work.ID) - if err != nil { - return databaseError(err) - } - defer rows.Close() - work.Sources = make([]WorkSource, 0) - for rows.Next() { - var source WorkSource - if err := rows.Scan(&source.Platform, &source.SourceType, &source.SourceID); err != nil { - return err - } - work.Sources = append(work.Sources, source) - } - return rows.Err() +func loadWorkSources(work *Work) { + work.Sources = []WorkSource{{Platform: work.Platform, SourceType: work.SourceType, SourceID: work.SourceID}} } func (s *Store) GetWork(ctx context.Context, id string) (Work, error) { @@ -483,9 +459,7 @@ func (s *Store) GetWork(ctx context.Context, id string) (Work, error) { if err != nil { return Work{}, rowError(err) } - if err := s.loadWorkSources(ctx, &result); err != nil { - return Work{}, err - } + loadWorkSources(&result) return result, nil } @@ -498,9 +472,7 @@ func (s *Store) GetWorkByKey(ctx context.Context, platform, workKey string) (Wor if err != nil { return Work{}, rowError(err) } - if err := s.loadWorkSources(ctx, &result); err != nil { - return Work{}, err - } + loadWorkSources(&result) return result, nil } @@ -528,11 +500,11 @@ func (s *Store) ListWorks(ctx context.Context, filter WorkFilter) ([]Work, error return nil, ErrInvalid } args = append(args, filter.SourceType) - where = append(where, fmt.Sprintf(`EXISTS (SELECT 1 FROM creator_work_source ws WHERE ws.work_id = creator_work.id AND ws.source_type = $%d)`, len(args))) + where = append(where, fmt.Sprintf(`creator_work.source_type = $%d`, len(args))) } if filter.SourceID != "" { args = append(args, filter.SourceID) - where = append(where, fmt.Sprintf(`EXISTS (SELECT 1 FROM creator_work_source ws WHERE ws.work_id = creator_work.id AND ws.source_id = $%d)`, len(args))) + where = append(where, fmt.Sprintf(`creator_work.source_id = $%d`, len(args))) } if filter.PublishedAfter != nil { add("published_at >= $%d", filter.PublishedAfter.UTC()) @@ -586,9 +558,7 @@ func (s *Store) ListWorks(ctx context.Context, filter WorkFilter) ([]Work, error return nil, err } for index := range result { - if err := s.loadWorkSources(ctx, &result[index]); err != nil { - return nil, err - } + loadWorkSources(&result[index]) } return result, nil } diff --git a/internal/creator/deletion.go b/internal/creator/deletion.go index 84134ad..9327db7 100644 --- a/internal/creator/deletion.go +++ b/internal/creator/deletion.go @@ -22,10 +22,8 @@ func (s *Store) CheckOwnedAccountDeletion(ctx context.Context, accountID string) if err := s.db.QueryRowContext(ctx, ` SELECT EXISTS ( SELECT 1 FROM creator_collection_checkpoint - WHERE source_type = 'owned' AND source_id = $1 AND status = 'running' - UNION ALL - SELECT 1 FROM creator_source_sync_lease - WHERE source_type = 'owned' AND source_id = $1 AND lease_until > now() + WHERE source_type = 'owned' AND source_id = $1 + AND (status = 'running' OR (lease_until IS NOT NULL AND lease_until > now())) )`, accountID).Scan(&active); err != nil { return databaseError(err) } @@ -110,9 +108,6 @@ func (s *Store) DeleteCompetitor(ctx context.Context, competitorID string) error SELECT 1 FROM creator_collection_checkpoint WHERE source_type = 'competitor' AND source_id = $1 AND (status = 'running' OR (lease_until IS NOT NULL AND lease_until > now())) - UNION ALL - SELECT 1 FROM creator_source_sync_lease - WHERE source_type = 'competitor' AND source_id = $1 AND lease_until > now() )`, competitorID).Scan(&active); err != nil { return databaseError(err) } @@ -144,10 +139,8 @@ func checkOwnedAccountDeletionTx(ctx context.Context, tx *sql.Tx, accountID stri if err := tx.QueryRowContext(ctx, ` SELECT EXISTS ( SELECT 1 FROM creator_collection_checkpoint - WHERE source_type = 'owned' AND source_id = $1 AND status = 'running' - UNION ALL - SELECT 1 FROM creator_source_sync_lease - WHERE source_type = 'owned' AND source_id = $1 AND lease_until > now() + WHERE source_type = 'owned' AND source_id = $1 + AND (status = 'running' OR (lease_until IS NOT NULL AND lease_until > now())) )`, accountID).Scan(&active); err != nil { return databaseError(err) } @@ -158,54 +151,23 @@ func checkOwnedAccountDeletionTx(ctx context.Context, tx *sql.Tx, accountID stri } func deleteCreatorSourceTx(ctx context.Context, tx *sql.Tx, sourceType, sourceID string) error { - if _, err := tx.ExecContext(ctx, ` - CREATE TEMP TABLE creator_delete_work_ids (id text PRIMARY KEY) ON COMMIT DROP`); err != nil { - return databaseError(err) - } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO creator_delete_work_ids (id) - SELECT id FROM creator_work WHERE source_type = $1 AND source_id = $2 - UNION - SELECT work_id FROM creator_work_source WHERE source_type = $1 AND source_id = $2`, sourceType, sourceID); err != nil { - return databaseError(err) - } var active bool if err := tx.QueryRowContext(ctx, ` SELECT EXISTS ( SELECT 1 FROM creator_collection_checkpoint - WHERE source_type = $1 AND source_id = $2 AND status = 'running' + WHERE source_type = $1 AND source_id = $2 + AND (status = 'running' OR (lease_until IS NOT NULL AND lease_until > now())) )`).Scan(&active); err != nil { return databaseError(err) } if active { return ErrConflict } - if _, err := tx.ExecContext(ctx, `DELETE FROM creator_work_source WHERE source_type = $1 AND source_id = $2`, sourceType, sourceID); err != nil { - return databaseError(err) - } - if _, err := tx.ExecContext(ctx, `DELETE FROM creator_source_sync_lease WHERE source_type = $1 AND source_id = $2`, sourceType, sourceID); err != nil { - return databaseError(err) - } if _, err := tx.ExecContext(ctx, `DELETE FROM creator_collection_checkpoint WHERE source_type = $1 AND source_id = $2`, sourceType, sourceID); err != nil { return databaseError(err) } - if _, err := tx.ExecContext(ctx, ` - UPDATE creator_work work - SET source_type = source.source_type, source_id = source.source_id - FROM ( - SELECT DISTINCT ON (work_id) work_id, source_type, source_id - FROM creator_work_source - ORDER BY work_id, created_at, source_type, source_id - ) source - WHERE work.id = source.work_id AND work.source_type = $1 AND work.source_id = $2`, sourceType, sourceID); err != nil { - return databaseError(err) - } - if _, err := tx.ExecContext(ctx, ` - DELETE FROM creator_work - WHERE id IN ( - SELECT id FROM creator_delete_work_ids - WHERE NOT EXISTS (SELECT 1 FROM creator_work_source WHERE work_id = creator_delete_work_ids.id) - )`); err != nil { + // 单来源内联模型:来源切换已由 upsert 覆盖内联列完成,这里删除仍归属该来源的作品(级联清 metric/comment/cover)。 + if _, err := tx.ExecContext(ctx, `DELETE FROM creator_work WHERE source_type = $1 AND source_id = $2`, sourceType, sourceID); err != nil { return databaseError(err) } return nil diff --git a/internal/creator/integration_test.go b/internal/creator/integration_test.go index 1f0eecd..c2f4bcf 100644 --- a/internal/creator/integration_test.go +++ b/internal/creator/integration_test.go @@ -318,12 +318,17 @@ func TestCreatorPostgresContentAndWorkflow(t *testing.T) { if refreshed, err := store.GetCompetitor(ctx, competitor.ID); err != nil || refreshed.NextSyncAt == nil || refreshed.NextSyncAt.Sub(forceNext).Abs() > time.Second { t.Fatalf("forced sync must refresh next_sync_at: next=%v err=%v", refreshed.NextSyncAt, err) } + // 单来源内联模型:同 work_key 的二次 upsert 只合并内容字段,来源保持首个写入方。 work, inserted, err = store.UpsertWork(ctx, WorkInput{Platform: PlatformDouyin, WorkKey: workKey, SourceType: SourceCompetitor, SourceID: competitor.ID, Title: "", Body: "", PublishedAt: nil, Likes: nil, CommentsCount: nil, Shares: nil}, now) - if err != nil || inserted || len(work.Sources) != 2 { - t.Fatalf("work source association was not retained: work=%+v inserted=%v err=%v", work, inserted, err) + if err != nil || inserted || len(work.Sources) != 1 || work.SourceType != SourceOwned || work.SourceID != bigID { + t.Fatalf("duplicate work upsert must keep the first inline source: work=%+v inserted=%v err=%v", work, inserted, err) + } + competitorWork, inserted, err := store.UpsertWork(ctx, WorkInput{Platform: PlatformDouyin, WorkKey: workKey + "-competitor", SourceType: SourceCompetitor, SourceID: competitor.ID, Title: "竞品作品", Body: "body", PublishedAt: &published, PublishedAtStatus: "verified"}, now) + if err != nil || !inserted { + t.Fatalf("insert competitor work: work=%+v inserted=%v err=%v", competitorWork, inserted, err) } filtered, err := store.ListWorks(ctx, WorkFilter{Platform: PlatformDouyin, SourceType: SourceCompetitor}) - if err != nil || len(filtered) != 1 || filtered[0].ID != work.ID { + if err != nil || len(filtered) != 1 || filtered[0].ID != competitorWork.ID { t.Fatalf("competitor source filter failed: works=%+v err=%v", filtered, err) } @@ -607,7 +612,7 @@ func TestCreatorPostgresSettingsResetCollectionCheckpoint(t *testing.T) { t.Fatal(err) } settings.LookbackDays = 3 - if _, err := store.UpdateSettings(ctx, SettingsUpdate{LookbackDays: settings.LookbackDays, NewWorkIntervalSeconds: settings.NewWorkIntervalSeconds, MetricInitialIntervalSeconds: settings.MetricInitialIntervalSeconds, MetricMultiplier: settings.MetricMultiplier, MetricMaxIntervalSeconds: settings.MetricMaxIntervalSeconds, MetricAgeSeconds: settings.MetricAgeSeconds, AIProvider: settings.AIProvider, AIModel: settings.AIModel, AIConfigured: settings.AIConfigured, TranscriptionProvider: settings.TranscriptionProvider, TranscriptionModel: settings.TranscriptionModel, TranscriptionConfigured: settings.TranscriptionConfigured}); err != nil { + if _, err := store.UpdateSettings(ctx, SettingsUpdate{LookbackDays: settings.LookbackDays, NewWorkIntervalSeconds: settings.NewWorkIntervalSeconds, MetricInitialIntervalSeconds: settings.MetricInitialIntervalSeconds, MetricMultiplier: settings.MetricMultiplier, MetricMaxIntervalSeconds: settings.MetricMaxIntervalSeconds, MetricAgeSeconds: settings.MetricAgeSeconds, AIProvider: settings.AIProvider, AIModel: settings.AIModel, AIConfigured: settings.AIConfigured}); err != nil { t.Fatalf("update settings: %v", err) } checkpoint, err := store.checkpoint(ctx, SourceOwned, accountID, "works") @@ -622,7 +627,7 @@ func TestCreatorPostgresSettingsResetCollectionCheckpoint(t *testing.T) { t.Fatalf("begin running checkpoint: %v", err) } settings.LookbackDays = 4 - if _, err := store.UpdateSettings(ctx, SettingsUpdate{LookbackDays: settings.LookbackDays, NewWorkIntervalSeconds: settings.NewWorkIntervalSeconds, MetricInitialIntervalSeconds: settings.MetricInitialIntervalSeconds, MetricMultiplier: settings.MetricMultiplier, MetricMaxIntervalSeconds: settings.MetricMaxIntervalSeconds, MetricAgeSeconds: settings.MetricAgeSeconds, AIProvider: settings.AIProvider, AIModel: settings.AIModel, AIConfigured: settings.AIConfigured, TranscriptionProvider: settings.TranscriptionProvider, TranscriptionModel: settings.TranscriptionModel, TranscriptionConfigured: settings.TranscriptionConfigured}); !errors.Is(err, ErrConflict) { + if _, err := store.UpdateSettings(ctx, SettingsUpdate{LookbackDays: settings.LookbackDays, NewWorkIntervalSeconds: settings.NewWorkIntervalSeconds, MetricInitialIntervalSeconds: settings.MetricInitialIntervalSeconds, MetricMultiplier: settings.MetricMultiplier, MetricMaxIntervalSeconds: settings.MetricMaxIntervalSeconds, MetricAgeSeconds: settings.MetricAgeSeconds, AIProvider: settings.AIProvider, AIModel: settings.AIModel, AIConfigured: settings.AIConfigured}); !errors.Is(err, ErrConflict) { t.Fatalf("settings changed during running checkpoint: err=%v", err) } if err := store.finishCheckpoint(ctx, SourceOwned, accountID, "works", lease, "succeeded", ""); err != nil { @@ -659,7 +664,7 @@ func TestCreatorPostgresMetricPlanFollowsPublishedAt(t *testing.T) { } var next time.Time var interval int64 - if err := store.db.QueryRowContext(ctx, `SELECT next_plan_at,interval_seconds FROM creator_metric_plan WHERE work_id=$1`, work.ID).Scan(&next, &interval); err != nil { + if err := store.db.QueryRowContext(ctx, `SELECT metric_plan_next_at,metric_plan_interval_seconds FROM creator_work WHERE id=$1`, work.ID).Scan(&next, &interval); err != nil { t.Fatal(err) } if !next.Equal(published.Add(3*time.Hour)) || interval != 4*3600 { @@ -668,7 +673,7 @@ func TestCreatorPostgresMetricPlanFollowsPublishedAt(t *testing.T) { if _, err := store.recordMetricWithPlan(ctx, point(published.Add(3*time.Hour)), settings, published.Add(3*time.Hour)); err != nil { t.Fatal(err) } - if err := store.db.QueryRowContext(ctx, `SELECT next_plan_at,interval_seconds FROM creator_metric_plan WHERE work_id=$1`, work.ID).Scan(&next, &interval); err != nil { + if err := store.db.QueryRowContext(ctx, `SELECT metric_plan_next_at,metric_plan_interval_seconds FROM creator_work WHERE id=$1`, work.ID).Scan(&next, &interval); err != nil { t.Fatal(err) } if !next.Equal(published.Add(7*time.Hour)) || interval != 8*3600 { diff --git a/internal/creator/metrics.go b/internal/creator/metrics.go index 3e8a7ef..32a75ca 100644 --- a/internal/creator/metrics.go +++ b/internal/creator/metrics.go @@ -34,9 +34,7 @@ func (s *Store) ListDueMetricWorks(ctx context.Context, now time.Time) ([]Work, return nil, err } for index := range works { - if err := s.loadWorkSources(ctx, &works[index]); err != nil { - return nil, err - } + loadWorkSources(&works[index]) } return works, nil } @@ -47,10 +45,7 @@ func (s *Store) StopMetricPlan(ctx context.Context, workID, reason string) error if workID == "" || reason == "" || len(reason) > 200 { return ErrInvalid } - if _, err := s.db.ExecContext(ctx, `UPDATE creator_metric_plan SET next_plan_at=NULL, stopped=true, stop_reason=$2, updated_at=now() WHERE work_id=$1 AND NOT stopped`, workID, reason); err != nil { - return databaseError(err) - } - _, err := s.db.ExecContext(ctx, `UPDATE creator_work SET next_metric_at=NULL, metric_stop_reason=$2, updated_at=now() WHERE id=$1`, workID, reason) + _, err := s.db.ExecContext(ctx, `UPDATE creator_work SET metric_plan_next_at=NULL, metric_plan_stopped=true, next_metric_at=NULL, metric_stop_reason=$2, updated_at=now() WHERE id=$1 AND NOT metric_plan_stopped`, workID, reason) return databaseError(err) } @@ -73,10 +68,7 @@ func (s *Store) EnsureMetricPlan(ctx context.Context, workID string, settings Se return databaseError(err) } if !now.Before(monitoringEnd) { - if _, err := s.db.ExecContext(ctx, `UPDATE creator_metric_plan SET next_plan_at=NULL, stopped=true, stop_reason='monitoring_age_reached', updated_at=now() WHERE work_id=$1 AND NOT stopped`, workID); err != nil { - return databaseError(err) - } - _, err := s.db.ExecContext(ctx, `UPDATE creator_work SET next_metric_at=NULL, metric_stop_reason='monitoring_age_reached', updated_at=now() WHERE id=$1`, workID) + _, err := s.db.ExecContext(ctx, `UPDATE creator_work SET metric_plan_next_at=NULL, metric_plan_stopped=true, next_metric_at=NULL, metric_stop_reason='monitoring_age_reached', updated_at=now() WHERE id=$1 AND NOT metric_plan_stopped`, workID) return databaseError(err) } nextAt := publishedAt.Add(time.Duration(settings.MetricInitialIntervalSeconds) * time.Second) @@ -85,17 +77,22 @@ func (s *Store) EnsureMetricPlan(ctx context.Context, workID string, settings Se return databaseError(err) } if _, err := s.db.ExecContext(ctx, ` - INSERT INTO creator_metric_plan (work_id, published_at, monitoring_end, next_plan_at, - interval_seconds, multiplier, max_interval_seconds) - VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT (work_id) DO NOTHING`, workID, publishedAt, monitoringEnd, nextAt, + UPDATE creator_work SET + metric_plan_published_at = COALESCE(metric_plan_published_at, $2), + metric_monitoring_end = COALESCE(metric_monitoring_end, $3), + metric_plan_next_at = COALESCE(metric_plan_next_at, $4), + metric_plan_interval_seconds = COALESCE(metric_plan_interval_seconds, $5), + metric_plan_multiplier = COALESCE(metric_plan_multiplier, $6), + metric_plan_max_interval_seconds = COALESCE(metric_plan_max_interval_seconds, $7), + updated_at = now() + WHERE id = $1`, workID, publishedAt, monitoringEnd, nextAt, settings.MetricInitialIntervalSeconds, settings.MetricMultiplier, settings.MetricMaxIntervalSeconds); err != nil { return databaseError(err) } _, err = s.db.ExecContext(ctx, ` UPDATE creator_work SET - next_metric_at=COALESCE((SELECT next_plan_at FROM creator_metric_plan WHERE work_id=$1 AND NOT stopped), next_metric_at), - metric_stop_reason=CASE WHEN EXISTS (SELECT 1 FROM creator_metric_plan WHERE work_id=$1 AND NOT stopped) THEN '' ELSE metric_stop_reason END, + next_metric_at=COALESCE(CASE WHEN NOT metric_plan_stopped THEN metric_plan_next_at END, next_metric_at), + metric_stop_reason=CASE WHEN metric_plan_stopped THEN metric_stop_reason ELSE '' END, updated_at=now() WHERE id=$1`, workID) return databaseError(err) } @@ -141,6 +138,7 @@ func (s *Store) recordMetricWithPlan(ctx context.Context, input MetricInput, set if err := tx.QueryRowContext(ctx, `SELECT published_at, published_at_status, latest_metrics_at FROM creator_work WHERE id=$1 FOR UPDATE`, input.WorkID).Scan(&publishedAt, &publishedStatus, &latestAt); err != nil { return MetricPoint{}, rowError(err) } + _ = publishedStatus point := MetricPoint{CollectedAt: collectedAt, Likes: input.Likes, CommentsCount: input.CommentsCount, Shares: input.Shares, CollectCount: input.CollectCount, PlayCount: input.PlayCount} if !publishedAt.Valid || publishedStatus != "verified" { if _, err := tx.ExecContext(ctx, `UPDATE creator_work SET likes=$2, comments_count=$3, shares=$4, collect_count=$5, play_count=$6, latest_metrics_at=$7, next_metric_at=NULL, metric_stop_reason='published_at_pending_verification', updated_at=now() WHERE id=$1`, input.WorkID, input.Likes, input.CommentsCount, input.Shares, input.CollectCount, input.PlayCount, collectedAt); err != nil { @@ -164,15 +162,10 @@ func (s *Store) recordMetricWithPlan(ctx context.Context, input MetricInput, set var interval int64 var multiplier float64 var maximum int64 - var pointIndex int var stopped bool - var stopReason string if latestAt.Valid { if !now.Before(monitoringEnd) || !collectedAt.Before(monitoringEnd) { - if _, err := tx.ExecContext(ctx, `UPDATE creator_metric_plan SET next_plan_at=NULL, stopped=true, stop_reason='monitoring_age_reached', updated_at=now() WHERE work_id=$1 AND NOT stopped`, input.WorkID); err != nil { - return MetricPoint{}, databaseError(err) - } - if _, err := tx.ExecContext(ctx, `UPDATE creator_work SET next_metric_at=NULL, metric_stop_reason='monitoring_age_reached', updated_at=now() WHERE id=$1`, input.WorkID); err != nil { + if _, err := tx.ExecContext(ctx, `UPDATE creator_work SET metric_plan_next_at=NULL, metric_plan_stopped=true, next_metric_at=NULL, metric_stop_reason='monitoring_age_reached', updated_at=now() WHERE id=$1 AND NOT metric_plan_stopped`, input.WorkID); err != nil { return MetricPoint{}, databaseError(err) } if err := tx.Commit(); err != nil { @@ -181,10 +174,19 @@ func (s *Store) recordMetricWithPlan(ctx context.Context, input MetricInput, set return MetricPoint{}, ErrConflict } } - if _, err := tx.ExecContext(ctx, `INSERT INTO creator_metric_plan (work_id,published_at,monitoring_end,next_plan_at,interval_seconds,multiplier,max_interval_seconds) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (work_id) DO NOTHING`, input.WorkID, published, monitoringEnd, published.Add(time.Duration(settings.MetricInitialIntervalSeconds)*time.Second), settings.MetricInitialIntervalSeconds, settings.MetricMultiplier, settings.MetricMaxIntervalSeconds); err != nil { + if _, err := tx.ExecContext(ctx, ` + UPDATE creator_work SET + metric_plan_published_at = COALESCE(metric_plan_published_at, $2), + metric_monitoring_end = COALESCE(metric_monitoring_end, $3), + metric_plan_next_at = COALESCE(metric_plan_next_at, $4), + metric_plan_interval_seconds = COALESCE(metric_plan_interval_seconds, $5), + metric_plan_multiplier = COALESCE(metric_plan_multiplier, $6), + metric_plan_max_interval_seconds = COALESCE(metric_plan_max_interval_seconds, $7), + updated_at = now() + WHERE id = $1`, input.WorkID, published, monitoringEnd, published.Add(time.Duration(settings.MetricInitialIntervalSeconds)*time.Second), settings.MetricInitialIntervalSeconds, settings.MetricMultiplier, settings.MetricMaxIntervalSeconds); err != nil { return MetricPoint{}, databaseError(err) } - if err := tx.QueryRowContext(ctx, `SELECT next_plan_at, interval_seconds, multiplier, max_interval_seconds, point_index, stopped, stop_reason FROM creator_metric_plan WHERE work_id=$1 FOR UPDATE`, input.WorkID).Scan(&nextPlan, &interval, &multiplier, &maximum, &pointIndex, &stopped, &stopReason); err != nil { + if err := tx.QueryRowContext(ctx, `SELECT metric_plan_next_at, metric_plan_interval_seconds, metric_plan_multiplier, metric_plan_max_interval_seconds, metric_plan_stopped FROM creator_work WHERE id=$1 FOR UPDATE`, input.WorkID).Scan(&nextPlan, &interval, &multiplier, &maximum, &stopped); err != nil { return MetricPoint{}, rowError(err) } if !latestAt.Valid { @@ -200,18 +202,15 @@ func (s *Store) recordMetricWithPlan(ctx context.Context, input MetricInput, set } } if nextAt.IsZero() || stopped || !nextPlan.Valid { - if _, err := tx.ExecContext(ctx, `UPDATE creator_metric_plan SET next_plan_at=NULL, stopped=true, stop_reason=$2, updated_at=now() WHERE work_id=$1`, input.WorkID, coalesceReason(nextReason, "monitoring_age_reached")); err != nil { - return MetricPoint{}, databaseError(err) - } nextAt = time.Time{} nextReason = coalesceReason(nextReason, "monitoring_age_reached") - } else if _, err := tx.ExecContext(ctx, `UPDATE creator_metric_plan SET next_plan_at=$2, interval_seconds=$3, stopped=false, stop_reason='', updated_at=now() WHERE work_id=$1`, input.WorkID, nextAt, int64(nextInterval/time.Second)); err != nil { - return MetricPoint{}, databaseError(err) + if _, err := tx.ExecContext(ctx, `UPDATE creator_work SET likes=$2,comments_count=$3,shares=$4,collect_count=$5,play_count=$6,latest_metrics_at=$7,next_metric_at=NULL,metric_stop_reason=$8,metric_plan_next_at=NULL,metric_plan_stopped=true,updated_at=now() WHERE id=$1`, input.WorkID, input.Likes, input.CommentsCount, input.Shares, input.CollectCount, input.PlayCount, collectedAt, nextReason); err != nil { + return MetricPoint{}, databaseError(err) + } } else { - nextReason = "" - } - if _, err := tx.ExecContext(ctx, `UPDATE creator_work SET likes=$2,comments_count=$3,shares=$4,collect_count=$5,play_count=$6,latest_metrics_at=$7,next_metric_at=$8,metric_stop_reason=$9,updated_at=now() WHERE id=$1`, input.WorkID, input.Likes, input.CommentsCount, input.Shares, input.CollectCount, input.PlayCount, collectedAt, nullableArg(nextAt), nextReason); err != nil { - return MetricPoint{}, databaseError(err) + if _, err := tx.ExecContext(ctx, `UPDATE creator_work SET likes=$2,comments_count=$3,shares=$4,collect_count=$5,play_count=$6,latest_metrics_at=$7,next_metric_at=$8,metric_stop_reason='',metric_plan_next_at=$8,metric_plan_interval_seconds=$9,metric_plan_stopped=false,updated_at=now() WHERE id=$1`, input.WorkID, input.Likes, input.CommentsCount, input.Shares, input.CollectCount, input.PlayCount, collectedAt, nullableArg(nextAt), int64(nextInterval/time.Second)); err != nil { + return MetricPoint{}, databaseError(err) + } } if err := tx.Commit(); err != nil { return MetricPoint{}, fmt.Errorf("commit initial creator metric: %w", err) @@ -237,19 +236,15 @@ func (s *Store) recordMetricWithPlan(ctx context.Context, input MetricInput, set steps++ } stopped = !nextAt.Before(monitoringEnd) + stopReason := "" if stopped { nextAt = time.Time{} stopReason = "monitoring_age_reached" - } else { - stopReason = "" } if _, err := tx.ExecContext(ctx, `INSERT INTO creator_work_metric (work_id,collected_at,likes,comments_count,shares,collect_count,play_count) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (work_id,collected_at) DO UPDATE SET likes=EXCLUDED.likes,comments_count=EXCLUDED.comments_count,shares=EXCLUDED.shares,collect_count=EXCLUDED.collect_count,play_count=EXCLUDED.play_count`, input.WorkID, collectedAt, input.Likes, input.CommentsCount, input.Shares, input.CollectCount, input.PlayCount); err != nil { return MetricPoint{}, databaseError(err) } - if _, err := tx.ExecContext(ctx, `UPDATE creator_metric_plan SET next_plan_at=$2, interval_seconds=$3, point_index=point_index+$4, stopped=$5, stop_reason=$6, updated_at=now() WHERE work_id=$1`, input.WorkID, nullableArg(nextAt), nextInterval, steps, stopped, stopReason); err != nil { - return MetricPoint{}, databaseError(err) - } - if _, err := tx.ExecContext(ctx, `UPDATE creator_work SET likes=$2,comments_count=$3,shares=$4,collect_count=$5,play_count=$6,latest_metrics_at=$7,next_metric_at=$8,metric_stop_reason=$9,updated_at=now() WHERE id=$1`, input.WorkID, input.Likes, input.CommentsCount, input.Shares, input.CollectCount, input.PlayCount, collectedAt, nullableArg(nextAt), stopReason); err != nil { + if _, err := tx.ExecContext(ctx, `UPDATE creator_work SET likes=$2,comments_count=$3,shares=$4,collect_count=$5,play_count=$6,latest_metrics_at=$7,next_metric_at=$8,metric_stop_reason=$9,metric_plan_next_at=$8,metric_plan_interval_seconds=$10,metric_plan_point_index=metric_plan_point_index+$11,metric_plan_stopped=$12,updated_at=now() WHERE id=$1`, input.WorkID, input.Likes, input.CommentsCount, input.Shares, input.CollectCount, input.PlayCount, collectedAt, nullableArg(nextAt), stopReason, nextInterval, steps, stopped); err != nil { return MetricPoint{}, databaseError(err) } if err := tx.Commit(); err != nil { diff --git a/internal/creator/models.go b/internal/creator/models.go index 311278f..dc19528 100644 --- a/internal/creator/models.go +++ b/internal/creator/models.go @@ -36,9 +36,6 @@ type Settings struct { AIProvider string `json:"ai_provider"` AIModel string `json:"ai_model"` AIConfigured bool `json:"ai_configured"` - TranscriptionProvider string `json:"transcription_provider"` - TranscriptionModel string `json:"transcription_model"` - TranscriptionConfigured bool `json:"transcription_configured"` UpdatedAt time.Time `json:"updated_at"` } @@ -52,9 +49,6 @@ type SettingsUpdate struct { AIProvider string `json:"ai_provider"` AIModel string `json:"ai_model"` AIConfigured bool `json:"ai_configured"` - TranscriptionProvider string `json:"transcription_provider"` - TranscriptionModel string `json:"transcription_model"` - TranscriptionConfigured bool `json:"transcription_configured"` } type AccountProfile struct { @@ -176,32 +170,32 @@ type WorkSource struct { } type Work struct { - ID string `json:"id"` - Platform string `json:"platform"` - WorkKey string `json:"work_key"` - SourceType string `json:"source_type"` - SourceID string `json:"source_id"` - Sources []WorkSource `json:"sources,omitempty"` - AuthorName string `json:"author_name"` - Title string `json:"title"` - Body string `json:"body"` - PublishedAt *time.Time `json:"published_at,omitempty"` - PublishedAtStatus string `json:"published_at_status"` - OriginalURL string `json:"original_url"` - CoverURL string `json:"cover_url"` - Likes *int64 `json:"likes"` - CommentsCount *int64 `json:"comments_count"` - Shares *int64 `json:"shares"` - CollectCount *int64 `json:"collect_count"` - PlayCount *int64 `json:"play_count"` - LikesGrowth *int64 `json:"likes_growth,omitempty"` - LikesGrowthCoverage string `json:"likes_growth_coverage,omitempty"` // full=窗口基线完整 partial=以首点近似 - LatestMetricsAt *time.Time `json:"latest_metrics_at,omitempty"` - NextMetricAt *time.Time `json:"next_metric_at,omitempty"` - MetricStopReason string `json:"metric_stop_reason,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - RawPayload string `json:"-"` + ID string `json:"id"` + Platform string `json:"platform"` + WorkKey string `json:"work_key"` + SourceType string `json:"source_type"` + SourceID string `json:"source_id"` + Sources []WorkSource `json:"sources,omitempty"` + AuthorName string `json:"author_name"` + Title string `json:"title"` + Body string `json:"body"` + PublishedAt *time.Time `json:"published_at,omitempty"` + PublishedAtStatus string `json:"published_at_status"` + OriginalURL string `json:"original_url"` + CoverURL string `json:"cover_url"` + Likes *int64 `json:"likes"` + CommentsCount *int64 `json:"comments_count"` + Shares *int64 `json:"shares"` + CollectCount *int64 `json:"collect_count"` + PlayCount *int64 `json:"play_count"` + LikesGrowth *int64 `json:"likes_growth,omitempty"` + LikesGrowthCoverage string `json:"likes_growth_coverage,omitempty"` // full=窗口基线完整 partial=以首点近似 + LatestMetricsAt *time.Time `json:"latest_metrics_at,omitempty"` + NextMetricAt *time.Time `json:"next_metric_at,omitempty"` + MetricStopReason string `json:"metric_stop_reason,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + RawPayload string `json:"-"` } type WorkInput struct { @@ -283,7 +277,6 @@ type MaterialJob struct { VideoReference string `json:"video_reference,omitempty"` AudioStatus string `json:"audio_status"` AudioReference string `json:"audio_reference,omitempty"` - TranscriptionStatus string `json:"transcription_status"` Transcript string `json:"transcript,omitempty"` FailedStep string `json:"failed_step,omitempty"` FailureReason string `json:"failure_reason,omitempty"` diff --git a/internal/creator/rules.go b/internal/creator/rules.go index 73b6053..f96b803 100644 --- a/internal/creator/rules.go +++ b/internal/creator/rules.go @@ -168,17 +168,9 @@ func (s *Store) AnalyzeComment(ctx context.Context, commentID, ruleID string, an if err != nil { return RuleResult{}, err } - if rule.SourceType != "all" { - matchedSource := false - for _, source := range work.Sources { - if source.SourceType == rule.SourceType { - matchedSource = true - break - } - } - if !matchedSource && work.SourceType != rule.SourceType { - return RuleResult{}, ErrConflict - } + if rule.SourceType != "all" && work.SourceType != rule.SourceType { + // 单来源内联模型:作品来源即唯一来源,规则门禁直接比对。 + return RuleResult{}, ErrConflict } if analyzer == nil { result, saveErr := s.upsertRuleResult(ctx, commentID, rule, "failed", "AI 分析不可用", nil, ptrTime(time.Now().UTC())) diff --git a/internal/creator/settings.go b/internal/creator/settings.go index 4c7349f..5df5803 100644 --- a/internal/creator/settings.go +++ b/internal/creator/settings.go @@ -11,8 +11,7 @@ func scanSettings(scanner interface{ Scan(...any) error }) (Settings, error) { var result Settings if err := scanner.Scan(&result.LookbackDays, &result.NewWorkIntervalSeconds, &result.MetricInitialIntervalSeconds, &result.MetricMultiplier, &result.MetricMaxIntervalSeconds, &result.MetricAgeSeconds, - &result.AIProvider, &result.AIModel, &result.AIConfigured, &result.TranscriptionProvider, - &result.TranscriptionModel, &result.TranscriptionConfigured, &result.UpdatedAt); err != nil { + &result.AIProvider, &result.AIModel, &result.AIConfigured, &result.UpdatedAt); err != nil { return Settings{}, err } result.UpdatedAt = result.UpdatedAt.UTC() @@ -21,7 +20,7 @@ func scanSettings(scanner interface{ Scan(...any) error }) (Settings, error) { const settingsSelect = `SELECT lookback_days, new_work_interval_seconds, metric_initial_interval_seconds, metric_multiplier, metric_max_interval_seconds, metric_age_seconds, ai_provider, ai_model, - ai_configured, transcription_provider, transcription_model, transcription_configured, updated_at + ai_configured, updated_at FROM creator_settings WHERE id = true` func (s *Store) GetSettings(ctx context.Context) (Settings, error) { @@ -32,17 +31,12 @@ func (s *Store) GetSettings(ctx context.Context) (Settings, error) { func (s *Store) UpdateSettings(ctx context.Context, input SettingsUpdate) (Settings, error) { input.AIProvider = strings.TrimSpace(input.AIProvider) input.AIModel = strings.TrimSpace(input.AIModel) - input.TranscriptionProvider = strings.TrimSpace(input.TranscriptionProvider) - input.TranscriptionModel = strings.TrimSpace(input.TranscriptionModel) if err := ValidateSettings(input); err != nil { return Settings{}, err } if input.AIConfigured && (input.AIProvider == "" || input.AIModel == "") { return Settings{}, ErrInvalid } - if input.TranscriptionConfigured && (input.TranscriptionProvider == "" || input.TranscriptionModel == "") { - return Settings{}, ErrInvalid - } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return Settings{}, fmt.Errorf("begin creator settings update: %w", err) @@ -52,11 +46,9 @@ func (s *Store) UpdateSettings(ctx context.Context, input SettingsUpdate) (Setti UPDATE creator_settings SET lookback_days=$1, new_work_interval_seconds=$2, metric_initial_interval_seconds=$3, metric_multiplier=$4, metric_max_interval_seconds=$5, metric_age_seconds=$6, ai_provider=$7, ai_model=$8, ai_configured=$9, - transcription_provider=$10, transcription_model=$11, transcription_configured=$12, updated_at=now() WHERE id=true`, input.LookbackDays, input.NewWorkIntervalSeconds, input.MetricInitialIntervalSeconds, input.MetricMultiplier, input.MetricMaxIntervalSeconds, - input.MetricAgeSeconds, input.AIProvider, input.AIModel, input.AIConfigured, - input.TranscriptionProvider, input.TranscriptionModel, input.TranscriptionConfigured); err != nil { + input.MetricAgeSeconds, input.AIProvider, input.AIModel, input.AIConfigured); err != nil { return Settings{}, databaseError(err) } var runningCheckpoints int @@ -67,9 +59,8 @@ func (s *Store) UpdateSettings(ctx context.Context, input SettingsUpdate) (Setti return Settings{}, ErrConflict } rows, err := tx.QueryContext(ctx, ` - SELECT p.work_id, w.published_at - FROM creator_metric_plan p - JOIN creator_work w ON w.id=p.work_id + SELECT id, published_at FROM creator_work + WHERE published_at IS NOT NULL FOR UPDATE`) if err != nil { return Settings{}, databaseError(err) @@ -111,15 +102,11 @@ func (s *Store) UpdateSettings(ctx context.Context, input SettingsUpdate) (Setti nextAt, reason := NextMetricAtValue(schedule.publishedAt, now, input) stopped := nextAt.IsZero() if _, err := tx.ExecContext(ctx, ` - UPDATE creator_metric_plan SET next_plan_at=$2, interval_seconds=$3, - multiplier=$4, max_interval_seconds=$5, stopped=$6, stop_reason=$7, updated_at=now() - WHERE work_id=$1`, schedule.workID, nullableArg(nextAt), input.MetricInitialIntervalSeconds, - input.MetricMultiplier, input.MetricMaxIntervalSeconds, stopped, reason); err != nil { - return Settings{}, databaseError(err) - } - if _, err := tx.ExecContext(ctx, ` - UPDATE creator_work SET next_metric_at=$2, metric_stop_reason=$3, updated_at=now() - WHERE id=$1`, schedule.workID, nullableArg(nextAt), reason); err != nil { + UPDATE creator_work SET next_metric_at=$2, metric_stop_reason=$3, + metric_plan_next_at=$2, metric_plan_interval_seconds=$4, metric_plan_multiplier=$5, + metric_plan_max_interval_seconds=$6, metric_plan_stopped=$7, updated_at=now() + WHERE id=$1`, schedule.workID, nullableArg(nextAt), reason, input.MetricInitialIntervalSeconds, + input.MetricMultiplier, input.MetricMaxIntervalSeconds, stopped); err != nil { return Settings{}, databaseError(err) } } diff --git a/internal/creator/source_lease.go b/internal/creator/source_lease.go index d0d9ff1..67295f4 100644 --- a/internal/creator/source_lease.go +++ b/internal/creator/source_lease.go @@ -3,22 +3,29 @@ package creator import ( "context" "database/sql" + "errors" + "time" ) +// 同步互斥租约:与作品/评论采集共用 creator_collection_checkpoint(统一租约模式), +// collection_kind='sync',窗口列填 now() 占位(同步租约不跟踪采集窗口)。 func (s *Store) ClaimSourceSync(ctx context.Context, sourceType, sourceID string) (string, error) { if sourceType != SourceOwned && sourceType != SourceCompetitor || sourceID == "" { return "", ErrInvalid } - token := newID("source-lease") + token := newID("lease") + now := time.Now().UTC() var claimed string err := s.db.QueryRowContext(ctx, ` - INSERT INTO creator_source_sync_lease (source_type,source_id,lease_token,lease_until,updated_at) - VALUES ($1,$2,$3,now() + interval '10 minutes',now()) - ON CONFLICT (source_type,source_id) DO UPDATE SET lease_token=EXCLUDED.lease_token, - lease_until=EXCLUDED.lease_until,updated_at=now() - WHERE creator_source_sync_lease.lease_until <= now() - RETURNING lease_token`, sourceType, sourceID, token).Scan(&claimed) - if err == sql.ErrNoRows { + INSERT INTO creator_collection_checkpoint + (id, source_type, source_id, collection_kind, cursor, window_start, window_end, status, lease_token, lease_until, last_error) + VALUES ($1, $2, $3, 'sync', '', $4, $4, 'running', $5, $4::timestamptz + interval '10 minutes', '') + ON CONFLICT (source_type, source_id, collection_kind) DO UPDATE SET + status = 'running', lease_token = EXCLUDED.lease_token, lease_until = EXCLUDED.lease_until + WHERE creator_collection_checkpoint.lease_until IS NULL + OR creator_collection_checkpoint.lease_until <= now() + RETURNING lease_token`, checkpointID(sourceType, sourceID, "sync"), sourceType, sourceID, now, token).Scan(&claimed) + if errors.Is(err, sql.ErrNoRows) { return "", ErrConflict } if err != nil { @@ -31,7 +38,10 @@ func (s *Store) ReleaseSourceSync(ctx context.Context, sourceType, sourceID, tok if sourceType != SourceOwned && sourceType != SourceCompetitor || sourceID == "" || token == "" { return ErrInvalid } - result, err := s.db.ExecContext(ctx, `DELETE FROM creator_source_sync_lease WHERE source_type=$1 AND source_id=$2 AND lease_token=$3`, sourceType, sourceID, token) + result, err := s.db.ExecContext(ctx, ` + DELETE FROM creator_collection_checkpoint + WHERE source_type = $1 AND source_id = $2 AND collection_kind = 'sync' AND lease_token = $3`, + sourceType, sourceID, token) if err != nil { return databaseError(err) } diff --git a/internal/creator/store.go b/internal/creator/store.go index 05bc9d3..f513664 100644 --- a/internal/creator/store.go +++ b/internal/creator/store.go @@ -4,7 +4,6 @@ import ( "context" "crypto/rand" "database/sql" - _ "embed" "encoding/hex" "encoding/json" "errors" @@ -16,88 +15,9 @@ import ( _ "github.com/jackc/pgx/v5/stdlib" ) -//go:embed migrations/017_creator.sql -var migration017 string - -//go:embed migrations/018_collection_checkpoints.sql -var migration018 string - -//go:embed migrations/019_metric_plans.sql -var migration019 string - -//go:embed migrations/020_competitor_sync_leases.sql -var migration020 string - -//go:embed migrations/021_work_sources.sql -var migration021 string - -//go:embed migrations/022_collection_lease_tokens.sql -var migration022 string - -//go:embed migrations/023_password_references.sql -var migration023 string - -//go:embed migrations/024_event_processing_times.sql -var migration024 string - -//go:embed migrations/025_competitor_sync_tokens.sql -var migration025 string - -//go:embed migrations/026_event_message_text.sql -var migration026 string - -//go:embed migrations/027_creator_listener_state.sql -var migration027 string - -//go:embed migrations/028_creator_event_message_type.sql -var migration028 string - -//go:embed migrations/029_creator_source_sync_lease.sql -var migration029 string - -//go:embed migrations/030_creator_event_gateway_time.sql -var migration030 string - -//go:embed migrations/031_raw_payloads.sql -var migration031 string - -//go:embed migrations/032_douyin_release_remediation.sql -var migration032 string - -//go:embed migrations/033_competitor_tags.sql -var migration033 string - // Creator migrations use their own history table. Phase-a and hub retain a // shared schema_migration table for their schemas, but their numeric versions // overlap with the creator migration files and must not suppress one another. -// -//go:embed migrations/034_competitor_tags_repair.sql -var migration034 string - -//go:embed migrations/035_account_deletion.sql -var migration035 string - -//go:embed migrations/036_competitor_unique_id.sql -var migration036 string - -//go:embed migrations/037_competitor_share_jobs.sql -var migration037 string - -//go:embed migrations/038_douyin_only_competitor_profile.sql -var migration038 string - -//go:embed migrations/039_work_collect_count.sql -var migration039 string - -//go:embed migrations/040_work_play_count.sql -var migration040 string - -//go:embed migrations/041_creator_account_metric.sql -var migration041 string - -//go:embed migrations/042_work_cover_cache.sql -var migration042 string - type SecretReference struct { ID string Provider string @@ -129,10 +49,6 @@ func Open(ctx context.Context, databaseURL string) (*Store, error) { return nil, errors.New("connect to creator database") } store := &Store{db: db} - if err := store.migrate(ctx); err != nil { - _ = db.Close() - return nil, err - } return store, nil } @@ -159,70 +75,6 @@ func (s *Store) acquireAutomaticExecutionLock(ctx context.Context, accountID str return func() { _ = conn.Close() }, nil } -func (s *Store) migrate(ctx context.Context) error { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return errors.New("begin creator schema migration") - } - defer tx.Rollback() - if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock(1542738017)`); err != nil { - return errors.New("lock creator schema migration") - } - if _, err := tx.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS creator_schema_migration (version integer PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil { - return errors.New("create creator schema migration table") - } - migrations := []struct { - version int - sql string - }{ - {version: 17, sql: migration017}, - {version: 18, sql: migration018}, - {version: 19, sql: migration019}, - {version: 20, sql: migration020}, - {version: 21, sql: migration021}, - {version: 22, sql: migration022}, - {version: 23, sql: migration023}, - {version: 24, sql: migration024}, - {version: 25, sql: migration025}, - {version: 26, sql: migration026}, - {version: 27, sql: migration027}, - {version: 28, sql: migration028}, - {version: 29, sql: migration029}, - {version: 30, sql: migration030}, - {version: 31, sql: migration031}, - {version: 32, sql: migration032}, - {version: 33, sql: migration033}, - {version: 34, sql: migration034}, - {version: 35, sql: migration035}, - {version: 36, sql: migration036}, - {version: 37, sql: migration037}, - {version: 38, sql: migration038}, - {version: 39, sql: migration039}, - {version: 40, sql: migration040}, - {version: 41, sql: migration041}, - {version: 42, sql: migration042}, - } - for _, migration := range migrations { - var applied bool - if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM creator_schema_migration WHERE version = $1)`, migration.version).Scan(&applied); err != nil { - return errors.New("read creator schema migration state") - } - if applied { - continue - } - if _, err := tx.ExecContext(ctx, migration.sql); err != nil { - return fmt.Errorf("apply creator schema migration %d: %w", migration.version, err) - } - if _, err := tx.ExecContext(ctx, `INSERT INTO creator_schema_migration (version) VALUES ($1)`, migration.version); err != nil { - return fmt.Errorf("record creator schema migration %d: %w", migration.version, err) - } - } - if err := tx.Commit(); err != nil { - return errors.New("commit creator schema migration") - } - return nil -} - func newID(prefix string) string { var bytes [12]byte if _, err := rand.Read(bytes[:]); err != nil { diff --git a/internal/environment/environment.go b/internal/environment/environment.go index 1fd21ef..04249a4 100644 --- a/internal/environment/environment.go +++ b/internal/environment/environment.go @@ -11,7 +11,6 @@ import ( "regexp" "strings" "time" - ) var exitIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`) @@ -51,30 +50,26 @@ type EnvironmentContext struct { AccountID string `json:"account_id"` AccountStatus string `json:"account_status"` AuthorizationStatus string `json:"authorization_status"` - BindingID string `json:"binding_id"` BindingVersion int64 `json:"binding_version"` RuntimeCleanupPending bool `json:"runtime_cleanup_pending,omitempty"` RuntimeCleanupBindingVersion int64 `json:"runtime_cleanup_binding_version,omitempty"` - RuntimeCleanupInstanceID string `json:"runtime_cleanup_instance_id,omitempty"` RuntimeCleanupRuntimeID string `json:"runtime_cleanup_runtime_id,omitempty"` RuntimeCleanupNetworkID string `json:"runtime_cleanup_network_id,omitempty"` Exit NetworkExit `json:"network_exit"` - RuntimeInstanceID string `json:"runtime_instance_id,omitempty"` RuntimeID string `json:"runtime_id,omitempty"` RuntimeNetworkID string `json:"runtime_network_id,omitempty"` RuntimeNodeID string `json:"runtime_node_id,omitempty"` } type EnvironmentAction struct { - OperationID string - Action string - AccountID string - BrowserEnvAlias string - NetworkExitID string - RuntimeInstanceID string - BindingVersion int64 - Outcome string - ReasonCode string + OperationID string + Action string + AccountID string + BrowserEnvAlias string + NetworkExitID string + BindingVersion int64 + Outcome string + ReasonCode string } func (s *Store) CreateNetworkExit(ctx context.Context, exit NetworkExit) (NetworkExit, error) { @@ -170,7 +165,7 @@ func (s *Store) UpdateNetworkExit(ctx context.Context, id string, input NetworkE } defer tx.Rollback() var active bool - if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM environment_binding binding JOIN runtime_instance runtime ON runtime.binding_id=binding.id WHERE binding.network_exit_id=$1 AND runtime.released_at IS NULL AND runtime.lease_until > now())`, id).Scan(&active); err != nil { + if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM browser_env WHERE network_exit_id=$1 AND runtime_id IS NOT NULL AND runtime_lease_until > now())`, id).Scan(&active); err != nil { return NetworkExit{}, errors.New("check network exit activity") } if active { @@ -205,7 +200,7 @@ func (s *Store) DeleteNetworkExit(ctx context.Context, id string) error { } defer tx.Rollback() var used bool - if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM environment_binding WHERE network_exit_id=$1) OR EXISTS (SELECT 1 FROM environment_binding binding JOIN runtime_instance runtime ON runtime.binding_id=binding.id WHERE binding.network_exit_id=$1 AND runtime.released_at IS NULL AND runtime.lease_until > now())`, id).Scan(&used); err != nil { + if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM browser_env WHERE network_exit_id=$1)`, id).Scan(&used); err != nil { return errors.New("check network exit bindings") } if used { @@ -359,9 +354,8 @@ func (s *Store) DisableNetworkExit(ctx context.Context, id string) (NetworkExit, } var active bool if err := tx.QueryRowContext(ctx, `SELECT EXISTS ( - SELECT 1 FROM environment_binding binding - JOIN runtime_instance runtime ON runtime.binding_id = binding.id - WHERE binding.network_exit_id = $1 AND runtime.released_at IS NULL AND runtime.lease_until > now() + SELECT 1 FROM browser_env + WHERE network_exit_id = $1 AND runtime_id IS NOT NULL AND runtime_lease_until > now() )`, id).Scan(&active); err != nil { return NetworkExit{}, errors.New("check network exit activity") } @@ -378,8 +372,8 @@ func (s *Store) DisableNetworkExit(ctx context.Context, id string) (NetworkExit, if _, err := tx.ExecContext(ctx, ` UPDATE social_account account SET status = 'paused', paused_at = COALESCE(paused_at, now()), version = account.version + 1, updated_at = now() - FROM environment_binding binding - WHERE binding.network_exit_id = $1 AND binding.account_id = account.id`, id); err != nil { + FROM browser_env environment + WHERE environment.network_exit_id = $1 AND environment.account_id = account.id`, id); err != nil { return NetworkExit{}, errors.New("invalidate network exit accounts") } } @@ -406,7 +400,7 @@ func (s *Store) CreateBoundEnv(ctx context.Context, env Env, accountID, exitID s } defer tx.Rollback() var existingAlias, existingExit string - err = tx.QueryRowContext(ctx, `SELECT browser_env_alias, COALESCE(network_exit_id, '') FROM environment_binding WHERE account_id = $1 FOR UPDATE`, accountID). + err = tx.QueryRowContext(ctx, `SELECT alias, COALESCE(network_exit_id, '') FROM browser_env WHERE account_id = $1 FOR UPDATE`, accountID). Scan(&existingAlias, &existingExit) if err == nil { if existingAlias != env.Alias || existingExit != exitID { @@ -426,8 +420,8 @@ func (s *Store) CreateBoundEnv(ctx context.Context, env Env, accountID, exitID s } var created string if err := tx.QueryRowContext(ctx, ` - INSERT INTO browser_env (alias, name, gateway_name, fingerprint) - SELECT $1, $2, $3, $4 + INSERT INTO browser_env (alias, name, gateway_name, fingerprint, account_id, network_exit_id, version) + SELECT $1, $2, $3, $4, $5, NULLIF($6, ''), 1 FROM social_account account WHERE account.id = $5 AND account.status = 'paused' AND account.authorization_status = 'authorized' @@ -435,11 +429,6 @@ func (s *Store) CreateBoundEnv(ctx context.Context, env Env, accountID, exitID s RETURNING alias`, env.Alias, env.Name, env.Gateway, encoded, accountID, exitID).Scan(&created); err != nil { return EnvironmentContext{}, false, rowError(err) } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO environment_binding (id, account_id, browser_env_alias, network_exit_id) - VALUES ($1, $1, $2, NULLIF($3, ''))`, accountID, env.Alias, exitID); err != nil { - return EnvironmentContext{}, false, publicDatabaseError(err) - } if _, err := tx.ExecContext(ctx, `UPDATE social_account SET version = version + 1, updated_at = now() WHERE id = $1`, accountID); err != nil { return EnvironmentContext{}, false, errors.New("version bound account") } @@ -463,35 +452,33 @@ func (s *Store) GetEnvironmentContext(ctx context.Context, alias string) (Enviro var encoded []byte var expectedIP, observedIP string var checked sql.NullTime - var runtimeInstanceID, runtimeID, runtimeNetworkID, cleanupInstanceID, cleanupRuntimeID, cleanupNetworkID sql.NullString + var runtimeID, runtimeNetworkID, runtimeNodeID, cleanupRuntimeID, cleanupNetworkID sql.NullString var cleanupBindingVersion sql.NullInt64 err = tx.QueryRowContext(ctx, ` SELECT environment.alias, environment.name, environment.gateway_name, - environment.fingerprint, environment.created_at, binding.account_id, account.status, account.authorization_status, - binding.id, binding.version, - binding.runtime_cleanup_pending, binding.runtime_cleanup_binding_version, - binding.runtime_cleanup_instance_id, binding.runtime_cleanup_runtime_id, binding.runtime_cleanup_network_id, + environment.fingerprint, environment.created_at, environment.account_id, account.status, account.authorization_status, + environment.version, + environment.runtime_cleanup_pending, environment.runtime_cleanup_binding_version, + environment.runtime_cleanup_runtime_id, environment.runtime_cleanup_network_id, COALESCE(network.id, ''), COALESCE(network.protocol, ''), COALESCE(network.host, ''), COALESCE(network.port, 0), COALESCE(host(network.expected_public_ip), ''), COALESCE(network.expected_region, ''), COALESCE(host(network.observed_public_ip), ''), COALESCE(network.observed_region, ''), COALESCE(network.health_status, 'unchecked'), COALESCE(network.last_check_reason, ''), COALESCE(network.version, 0), network.last_checked_at, COALESCE(network.created_at, to_timestamp(0)), COALESCE(network.updated_at, to_timestamp(0)), - runtime.id, runtime.runtime_id, runtime.network_id, COALESCE(runtime.node_id, '') + COALESCE(environment.runtime_id, ''), COALESCE(environment.runtime_network_id, ''), COALESCE(environment.runtime_node_id, '') FROM browser_env environment - JOIN environment_binding binding ON binding.browser_env_alias = environment.alias - JOIN social_account account ON account.id = binding.account_id - LEFT JOIN network_exit network ON network.id = binding.network_exit_id - LEFT JOIN runtime_instance runtime ON runtime.binding_id = binding.id AND runtime.released_at IS NULL + JOIN social_account account ON account.id = environment.account_id + LEFT JOIN network_exit network ON network.id = environment.network_exit_id WHERE environment.alias = $1`, alias). Scan(&result.Alias, &result.Name, &result.Gateway, &encoded, &result.CreatedAt, &result.AccountID, &result.AccountStatus, &result.AuthorizationStatus, - &result.BindingID, &result.BindingVersion, &result.RuntimeCleanupPending, &cleanupBindingVersion, - &cleanupInstanceID, &cleanupRuntimeID, &cleanupNetworkID, + &result.BindingVersion, &result.RuntimeCleanupPending, &cleanupBindingVersion, + &cleanupRuntimeID, &cleanupNetworkID, &result.Exit.ID, &result.Exit.Protocol, &result.Exit.Host, &result.Exit.Port, &expectedIP, &result.Exit.ExpectedRegion, &observedIP, &result.Exit.ObservedRegion, &result.Exit.HealthStatus, &result.Exit.LastCheckReason, &result.Exit.Version, &checked, &result.Exit.CreatedAt, &result.Exit.UpdatedAt, - &runtimeInstanceID, &runtimeID, &runtimeNetworkID, &result.RuntimeNodeID) + &runtimeID, &runtimeNetworkID, &runtimeNodeID) if err != nil { return EnvironmentContext{}, rowError(err) } @@ -504,11 +491,10 @@ func (s *Store) GetEnvironmentContext(ctx context.Context, alias string) (Enviro if checked.Valid { result.Exit.LastCheckedAt = &checked.Time } - result.RuntimeInstanceID, result.RuntimeID, result.RuntimeNetworkID = runtimeInstanceID.String, runtimeID.String, runtimeNetworkID.String + result.RuntimeID, result.RuntimeNetworkID, result.RuntimeNodeID = runtimeID.String, runtimeNetworkID.String, runtimeNodeID.String if result.RuntimeCleanupPending { result.RuntimeCleanupBindingVersion = cleanupBindingVersion.Int64 - result.RuntimeCleanupInstanceID, result.RuntimeCleanupRuntimeID = cleanupInstanceID.String, cleanupRuntimeID.String - result.RuntimeCleanupNetworkID = cleanupNetworkID.String + result.RuntimeCleanupRuntimeID, result.RuntimeCleanupNetworkID = cleanupRuntimeID.String, cleanupNetworkID.String } if err := commitHub(tx); err != nil { return EnvironmentContext{}, err @@ -522,23 +508,26 @@ func (s *Store) GetEnvironmentContextForAccount(ctx context.Context, accountID s } var alias string if err := s.db.QueryRowContext(ctx, ` - SELECT browser_env_alias FROM environment_binding WHERE account_id = $1`, accountID).Scan(&alias); err != nil { + SELECT alias FROM browser_env WHERE account_id = $1`, accountID).Scan(&alias); err != nil { return EnvironmentContext{}, rowError(err) } return s.GetEnvironmentContext(ctx, alias) } const runtimeUseLeaseDuration = time.Minute + +// MissingRuntimeID 标记「创建结果未知」的清理代:网关侧无实物 ID 可供 fence, +// 清理时按别名反查网关代际(对外契约:/v1/browsers 的 runtime_id 哨兵值)。 +const MissingRuntimeID = "runtime-not-found" + func (s *Store) SetRuntimeNode(ctx context.Context, alias, runtimeID, nodeID string) error { if !aliasPattern.MatchString(alias) || !exitIDPattern.MatchString(runtimeID) || !nodeIDPattern.MatchString(nodeID) { return ErrInvalid } result, err := s.db.ExecContext(ctx, ` - UPDATE runtime_instance runtime - SET node_id = $3 - FROM environment_binding binding - WHERE binding.browser_env_alias = $1 AND runtime.id = $2 AND runtime.binding_id = binding.id - AND runtime.released_at IS NULL`, alias, runtimeID, nodeID) + UPDATE browser_env + SET runtime_node_id = $3 + WHERE alias = $1 AND runtime_id = $2`, alias, runtimeID, nodeID) if err != nil { return publicDatabaseError(err) } @@ -551,61 +540,57 @@ func (s *Store) SetRuntimeNode(ctx context.Context, alias, runtimeID, nodeID str } return nil } -func releaseExpiredRuntime(ctx context.Context, tx *sql.Tx, bindingID string) error { - var accountID, alias, runtimeInstanceID string - var exitID sql.NullString +func releaseExpiredRuntime(ctx context.Context, tx *sql.Tx, alias string) error { + var accountID, exitID string var bindingVersion int64 err := tx.QueryRowContext(ctx, ` - UPDATE runtime_instance runtime SET released_at = now() - FROM environment_binding binding - WHERE binding.id = $1 AND runtime.binding_id = binding.id - AND runtime.released_at IS NULL AND runtime.lease_until <= now() - RETURNING runtime.account_id, binding.browser_env_alias, binding.network_exit_id, - runtime.id, runtime.binding_version`, bindingID). - Scan(&accountID, &alias, &exitID, &runtimeInstanceID, &bindingVersion) + UPDATE browser_env SET runtime_id = NULL, runtime_lease_until = NULL, runtime_network_id = NULL + WHERE alias = $1 AND runtime_id IS NOT NULL AND runtime_lease_until <= now() + RETURNING account_id, COALESCE(network_exit_id, ''), version`, alias). + Scan(&accountID, &exitID, &bindingVersion) if errors.Is(err, sql.ErrNoRows) { return nil } if err != nil { return err } - return appendRuntimeAudit(ctx, tx, "runtime_released", accountID, alias, exitID.String, runtimeInstanceID, bindingVersion) + return appendRuntimeAudit(ctx, tx, "runtime_released", accountID, alias, exitID, bindingVersion) } -func validateEnvironmentRebind(ctx context.Context, tx *sql.Tx, alias, exitID string, expectedBindingVersion int64) (string, string, error) { - var accountID, bindingID string +func validateEnvironmentRebind(ctx context.Context, tx *sql.Tx, alias, exitID string, expectedBindingVersion int64) (string, error) { + var accountID string var bindingVersion int64 err := tx.QueryRowContext(ctx, ` - SELECT binding.account_id, binding.id, binding.version - FROM environment_binding binding - JOIN social_account account ON account.id = binding.account_id - WHERE binding.browser_env_alias = $1 AND account.status = 'paused' + SELECT environment.account_id, environment.version + FROM browser_env environment + JOIN social_account account ON account.id = environment.account_id + WHERE environment.alias = $1 AND account.status = 'paused' AND account.authorization_status = 'authorized' - AND NOT binding.runtime_cleanup_pending - FOR UPDATE OF binding, account`, alias).Scan(&accountID, &bindingID, &bindingVersion) + AND NOT environment.runtime_cleanup_pending + FOR UPDATE OF environment, account`, alias).Scan(&accountID, &bindingVersion) if errors.Is(err, sql.ErrNoRows) { - return "", "", ErrConflict + return "", ErrConflict } if err != nil { - return "", "", publicDatabaseError(err) + return "", publicDatabaseError(err) } if bindingVersion != expectedBindingVersion { - return "", "", ErrConflict + return "", ErrConflict } - if err := releaseExpiredRuntime(ctx, tx, bindingID); err != nil { - return "", "", errors.New("expire runtime before rebind") + if err := releaseExpiredRuntime(ctx, tx, alias); err != nil { + return "", errors.New("expire runtime before rebind") } var allowed bool if err := tx.QueryRowContext(ctx, ` SELECT EXISTS (SELECT 1 FROM network_exit WHERE id = $1 AND health_status = 'healthy') - AND NOT EXISTS (SELECT 1 FROM runtime_instance WHERE binding_id = $2 AND released_at IS NULL)`, - exitID, bindingID).Scan(&allowed); err != nil { - return "", "", errors.New("check environment rebind") + AND NOT EXISTS (SELECT 1 FROM browser_env other WHERE other.account_id = $2 AND other.runtime_id IS NOT NULL)`, + exitID, accountID).Scan(&allowed); err != nil { + return "", errors.New("check environment rebind") } if !allowed { - return "", "", ErrConflict + return "", ErrConflict } - return accountID, bindingID, nil + return accountID, nil } func (s *Store) ValidateEnvironmentRebind(ctx context.Context, alias, exitID string, expectedBindingVersion int64) error { @@ -617,7 +602,7 @@ func (s *Store) ValidateEnvironmentRebind(ctx context.Context, alias, exitID str return errors.New("begin environment rebind validation") } defer tx.Rollback() - if _, _, err := validateEnvironmentRebind(ctx, tx, alias, exitID, expectedBindingVersion); err != nil { + if _, err := validateEnvironmentRebind(ctx, tx, alias, exitID, expectedBindingVersion); err != nil { return err } if err := commitHub(tx); err != nil { @@ -641,25 +626,24 @@ func (s *Store) RebindEnvironment(ctx context.Context, alias, exitID, runtimeID return EnvironmentContext{}, errors.New("begin environment rebind") } defer tx.Rollback() - accountID, bindingID, err := validateEnvironmentRebind(ctx, tx, alias, exitID, expectedBindingVersion) + accountID, err := validateEnvironmentRebind(ctx, tx, alias, exitID, expectedBindingVersion) if err != nil { return EnvironmentContext{}, err } - if _, err := tx.ExecContext(ctx, `UPDATE environment_binding SET network_exit_id = $2, version = version + 1, updated_at = now() WHERE browser_env_alias = $1`, alias, exitID); err != nil { + if _, err := tx.ExecContext(ctx, `UPDATE browser_env SET network_exit_id = $2, version = version + 1, updated_at = now() WHERE alias = $1`, alias, exitID); err != nil { return EnvironmentContext{}, errors.New("update environment binding") } if _, err := tx.ExecContext(ctx, `UPDATE social_account SET version = version + 1, updated_at = now() WHERE id = $1`, accountID); err != nil { return EnvironmentContext{}, errors.New("version rebound account") } if runtimeID != "" { - runtimeInstanceID := "runtime-" + newHubID() if _, err := tx.ExecContext(ctx, ` - INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, network_id, owner_id, purpose, lease_token, node_id, lease_until) - VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), 'control-plane', 'session', 'runtime-lease-' || $1, '', now() + interval '1 minute')`, - runtimeInstanceID, accountID, bindingID, expectedBindingVersion+1, runtimeID, networkID); err != nil { + UPDATE browser_env + SET runtime_id = $2, runtime_lease_until = now() + interval '1 minute', runtime_network_id = NULLIF($3, '') + WHERE alias = $1`, alias, runtimeID, networkID); err != nil { return EnvironmentContext{}, publicDatabaseError(err) } - if err := appendRuntimeAudit(ctx, tx, "runtime_bound", accountID, alias, exitID, runtimeInstanceID, expectedBindingVersion+1); err != nil { + if err := appendRuntimeAudit(ctx, tx, "runtime_bound", accountID, alias, exitID, expectedBindingVersion+1); err != nil { return EnvironmentContext{}, err } } @@ -683,16 +667,16 @@ func (s *Store) ActivateRuntime(ctx context.Context, alias, runtimeID string, bi return EnvironmentContext{}, errors.New("begin runtime activation") } defer tx.Rollback() - var accountID, bindingID, currentExitID, accountStatus, authorizationStatus string + var accountID, currentExitID, accountStatus, authorizationStatus string var currentBindingVersion int64 var cleanupPending bool err = tx.QueryRowContext(ctx, ` - SELECT binding.account_id, binding.id, binding.version, COALESCE(binding.network_exit_id, ''), binding.runtime_cleanup_pending, + SELECT environment.account_id, environment.version, COALESCE(environment.network_exit_id, ''), environment.runtime_cleanup_pending, account.status, account.authorization_status - FROM environment_binding binding - JOIN social_account account ON account.id = binding.account_id - WHERE binding.browser_env_alias = $1 FOR UPDATE OF binding, account`, alias). - Scan(&accountID, &bindingID, ¤tBindingVersion, ¤tExitID, &cleanupPending, &accountStatus, &authorizationStatus) + FROM browser_env environment + JOIN social_account account ON account.id = environment.account_id + WHERE environment.alias = $1 FOR UPDATE OF environment, account`, alias). + Scan(&accountID, ¤tBindingVersion, ¤tExitID, &cleanupPending, &accountStatus, &authorizationStatus) if err != nil { return EnvironmentContext{}, rowError(err) } @@ -700,31 +684,29 @@ func (s *Store) ActivateRuntime(ctx context.Context, alias, runtimeID string, bi currentBindingVersion != bindingVersion || currentExitID != exitID { return EnvironmentContext{}, ErrConflict } - if err := releaseExpiredRuntime(ctx, tx, bindingID); err != nil { + if err := releaseExpiredRuntime(ctx, tx, alias); err != nil { return EnvironmentContext{}, errors.New("expire runtime before activation") } - var existingInstanceID, existingRuntimeID, existingNetworkID string - var existingBindingVersion int64 + var existingRuntimeID, existingNetworkID string if err := tx.QueryRowContext(ctx, ` - SELECT COALESCE(id, ''), COALESCE(runtime_id, ''), COALESCE(network_id, ''), COALESCE(binding_version, 0) FROM runtime_instance - WHERE binding_id = $1 AND released_at IS NULL`, bindingID).Scan(&existingInstanceID, &existingRuntimeID, &existingNetworkID, &existingBindingVersion); err != nil && !errors.Is(err, sql.ErrNoRows) { + SELECT COALESCE(runtime_id, ''), COALESCE(runtime_network_id, '') FROM browser_env + WHERE alias = $1 AND runtime_id IS NOT NULL`, alias).Scan(&existingRuntimeID, &existingNetworkID); err != nil && !errors.Is(err, sql.ErrNoRows) { return EnvironmentContext{}, publicDatabaseError(err) } - if existingInstanceID != "" && (existingRuntimeID != runtimeID || existingNetworkID != networkID || existingBindingVersion != bindingVersion) { + if existingRuntimeID != "" && (existingRuntimeID != runtimeID || existingNetworkID != networkID) { return EnvironmentContext{}, ErrConflict } - if existingInstanceID == "" { - existingInstanceID = "runtime-" + newHubID() + if existingRuntimeID == "" { if _, err := tx.ExecContext(ctx, ` - INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, network_id, owner_id, purpose, lease_token, node_id, lease_until) - VALUES ($1, $2, $3, $4, $5, NULLIF($6, ''), 'control-plane', 'session', 'runtime-lease-' || $1, '', now() + interval '1 minute')`, - existingInstanceID, accountID, bindingID, bindingVersion, runtimeID, networkID); err != nil { + UPDATE browser_env + SET runtime_id = $2, runtime_lease_until = now() + interval '1 minute', runtime_network_id = NULLIF($3, '') + WHERE alias = $1`, alias, runtimeID, networkID); err != nil { return EnvironmentContext{}, publicDatabaseError(err) } - if err := appendRuntimeAudit(ctx, tx, "runtime_bound", accountID, alias, exitID, existingInstanceID, bindingVersion); err != nil { + if err := appendRuntimeAudit(ctx, tx, "runtime_bound", accountID, alias, exitID, bindingVersion); err != nil { return EnvironmentContext{}, err } - } else if _, err := tx.ExecContext(ctx, `UPDATE runtime_instance SET owner_id = 'control-plane', purpose = 'session', lease_until = now() + interval '1 minute' WHERE id = $1`, existingInstanceID); err != nil { + } else if _, err := tx.ExecContext(ctx, `UPDATE browser_env SET runtime_lease_until = now() + interval '1 minute' WHERE alias = $1`, alias); err != nil { return EnvironmentContext{}, errors.New("renew environment runtime") } if err := commitHub(tx); err != nil { @@ -734,11 +716,11 @@ func (s *Store) ActivateRuntime(ctx context.Context, alias, runtimeID string, bi } func (s *Store) ReleaseRuntime(ctx context.Context, environment EnvironmentContext) error { - if !aliasPattern.MatchString(environment.Alias) || !exitIDPattern.MatchString(environment.BindingID) || - environment.BindingVersion < 1 || (environment.RuntimeInstanceID != "" && !exitIDPattern.MatchString(environment.RuntimeInstanceID)) { + if !aliasPattern.MatchString(environment.Alias) || environment.BindingVersion < 1 || + (environment.RuntimeID != "" && !exitIDPattern.MatchString(environment.RuntimeID)) { return ErrInvalid } - if environment.RuntimeInstanceID == "" { + if environment.RuntimeID == "" { return nil } tx, err := s.db.BeginTx(ctx, nil) @@ -746,35 +728,30 @@ func (s *Store) ReleaseRuntime(ctx context.Context, environment EnvironmentConte return errors.New("begin runtime release") } defer tx.Rollback() - var accountID, alias, runtimeInstanceID string - var exitID sql.NullString + var accountID, exitID string var bindingVersion int64 err = tx.QueryRowContext(ctx, ` - UPDATE runtime_instance runtime SET released_at = now() - FROM environment_binding binding - WHERE binding.browser_env_alias = $1 AND binding.id = $2 AND binding.version = $3 - AND runtime.binding_id = binding.id AND runtime.binding_version = binding.version - AND runtime.id = $4 AND runtime.released_at IS NULL - RETURNING runtime.account_id, binding.browser_env_alias, binding.network_exit_id, runtime.id, runtime.binding_version`, - environment.Alias, environment.BindingID, environment.BindingVersion, environment.RuntimeInstanceID). - Scan(&accountID, &alias, &exitID, &runtimeInstanceID, &bindingVersion) + UPDATE browser_env SET runtime_id = NULL, runtime_lease_until = NULL, runtime_network_id = NULL + WHERE alias = $1 AND version = $2 AND runtime_id = $3 + RETURNING account_id, COALESCE(network_exit_id, ''), version`, + environment.Alias, environment.BindingVersion, environment.RuntimeID). + Scan(&accountID, &exitID, &bindingVersion) if errors.Is(err, sql.ErrNoRows) { return ErrConflict } if err != nil { return errors.New("release environment runtime") } - if err := appendRuntimeAudit(ctx, tx, "runtime_released", accountID, alias, exitID.String, runtimeInstanceID, bindingVersion); err != nil { + if err := appendRuntimeAudit(ctx, tx, "runtime_released", accountID, environment.Alias, exitID, bindingVersion); err != nil { return err } return commitHub(tx) } func (s *Store) SetRuntimeCleanupPending(ctx context.Context, environment EnvironmentContext, pending bool) error { - if !aliasPattern.MatchString(environment.Alias) || !exitIDPattern.MatchString(environment.BindingID) || + if !aliasPattern.MatchString(environment.Alias) || environment.BindingVersion < 1 || environment.RuntimeCleanupBindingVersion < 1 || (pending && environment.RuntimeCleanupRuntimeID == "") || - (environment.RuntimeCleanupInstanceID != "" && !exitIDPattern.MatchString(environment.RuntimeCleanupInstanceID)) || (environment.RuntimeCleanupRuntimeID != "" && !exitIDPattern.MatchString(environment.RuntimeCleanupRuntimeID)) || (environment.RuntimeCleanupNetworkID != "" && !exitIDPattern.MatchString(environment.RuntimeCleanupNetworkID)) { return ErrInvalid @@ -785,26 +762,25 @@ func (s *Store) SetRuntimeCleanupPending(ctx context.Context, environment Enviro } defer tx.Rollback() var currentPending bool - var accountID, alias string + var accountID string var exitID sql.NullString var cleanupBindingVersion sql.NullInt64 - var cleanupInstanceID, cleanupRuntimeID, cleanupNetworkID sql.NullString + var cleanupRuntimeID, cleanupNetworkID sql.NullString if err := tx.QueryRowContext(ctx, ` SELECT runtime_cleanup_pending, runtime_cleanup_binding_version, - runtime_cleanup_instance_id, runtime_cleanup_runtime_id, runtime_cleanup_network_id, - account_id, browser_env_alias, network_exit_id - FROM environment_binding - WHERE browser_env_alias = $1 AND id = $2 AND version = $3 FOR UPDATE`, environment.Alias, - environment.BindingID, environment.BindingVersion). - Scan(¤tPending, &cleanupBindingVersion, &cleanupInstanceID, &cleanupRuntimeID, &cleanupNetworkID, - &accountID, &alias, &exitID); errors.Is(err, sql.ErrNoRows) { + runtime_cleanup_runtime_id, runtime_cleanup_network_id, + account_id, network_exit_id + FROM browser_env + WHERE alias = $1 AND version = $2 FOR UPDATE`, environment.Alias, environment.BindingVersion). + Scan(¤tPending, &cleanupBindingVersion, &cleanupRuntimeID, &cleanupNetworkID, + &accountID, &exitID); errors.Is(err, sql.ErrNoRows) { return ErrConflict } else if err != nil { return publicDatabaseError(err) } if currentPending { if cleanupBindingVersion.Int64 != environment.RuntimeCleanupBindingVersion || - cleanupInstanceID.String != environment.RuntimeCleanupInstanceID || cleanupRuntimeID.String != environment.RuntimeCleanupRuntimeID || + cleanupRuntimeID.String != environment.RuntimeCleanupRuntimeID || cleanupNetworkID.String != environment.RuntimeCleanupNetworkID { return ErrConflict } @@ -815,48 +791,48 @@ func (s *Store) SetRuntimeCleanupPending(ctx context.Context, environment Enviro return commitHub(tx) } if pending && !currentPending { - var runtimeInstanceID string + var runtimeID string err := tx.QueryRowContext(ctx, ` - SELECT id FROM runtime_instance - WHERE binding_id = $1 AND binding_version = $2 AND released_at IS NULL`, environment.BindingID, - environment.BindingVersion).Scan(&runtimeInstanceID) - if errors.Is(err, sql.ErrNoRows) { - runtimeInstanceID = "" - } else if err != nil { + SELECT COALESCE(runtime_id, '') FROM browser_env + WHERE alias = $1 AND version = $2`, environment.Alias, environment.BindingVersion).Scan(&runtimeID) + if err != nil { return publicDatabaseError(err) } - if runtimeInstanceID != environment.RuntimeCleanupInstanceID { - return ErrConflict + if runtimeID != environment.RuntimeCleanupRuntimeID && environment.RuntimeCleanupRuntimeID != MissingRuntimeID { + // fence 记录清理目标代:目标=当前活跃 runtime(显式停止)、未知代(创建结果未知,登记时释放活跃实例) + // 或无活跃实例(创建失败)。其余视为过期代际请求。 + if runtimeID != "" { + return ErrConflict + } } - if runtimeInstanceID != "" { + if runtimeID != "" { result, err := tx.ExecContext(ctx, ` - UPDATE runtime_instance SET released_at = now() - WHERE id = $1 AND binding_id = $2 AND binding_version = $3 AND released_at IS NULL`, runtimeInstanceID, - environment.BindingID, environment.BindingVersion) + UPDATE browser_env SET runtime_id = NULL, runtime_lease_until = NULL, runtime_network_id = NULL + WHERE alias = $1 AND version = $2 AND runtime_id = $3`, environment.Alias, environment.BindingVersion, runtimeID) if err != nil { return errors.New("release runtime for pending cleanup") } if affected, err := result.RowsAffected(); err != nil || affected != 1 { return ErrConflict } - if err := appendRuntimeAudit(ctx, tx, "runtime_released", accountID, alias, exitID.String, runtimeInstanceID, environment.BindingVersion); err != nil { + if err := appendRuntimeAudit(ctx, tx, "runtime_released", accountID, environment.Alias, exitID.String, environment.BindingVersion); err != nil { return err } } } if pending { _, err = tx.ExecContext(ctx, ` - UPDATE environment_binding SET runtime_cleanup_pending = true, - runtime_cleanup_binding_version = $2, runtime_cleanup_instance_id = NULLIF($3, ''), - runtime_cleanup_runtime_id = NULLIF($4, ''), runtime_cleanup_network_id = NULLIF($5, ''), updated_at = now() - WHERE id = $1`, environment.BindingID, environment.RuntimeCleanupBindingVersion, - environment.RuntimeCleanupInstanceID, environment.RuntimeCleanupRuntimeID, environment.RuntimeCleanupNetworkID) + UPDATE browser_env SET runtime_cleanup_pending = true, + runtime_cleanup_binding_version = $2, + runtime_cleanup_runtime_id = NULLIF($3, ''), runtime_cleanup_network_id = NULLIF($4, ''), updated_at = now() + WHERE alias = $1`, environment.Alias, environment.RuntimeCleanupBindingVersion, + environment.RuntimeCleanupRuntimeID, environment.RuntimeCleanupNetworkID) } else { _, err = tx.ExecContext(ctx, ` - UPDATE environment_binding SET runtime_cleanup_pending = false, - runtime_cleanup_binding_version = NULL, runtime_cleanup_instance_id = NULL, + UPDATE browser_env SET runtime_cleanup_pending = false, + runtime_cleanup_binding_version = NULL, runtime_cleanup_runtime_id = NULL, runtime_cleanup_network_id = NULL, updated_at = now() - WHERE id = $1`, environment.BindingID) + WHERE alias = $1`, environment.Alias) } if err != nil { return errors.New("update runtime cleanup state") @@ -864,12 +840,12 @@ func (s *Store) SetRuntimeCleanupPending(ctx context.Context, environment Enviro return commitHub(tx) } -func appendRuntimeAudit(ctx context.Context, tx *sql.Tx, eventType, accountID, alias, exitID, runtimeInstanceID string, bindingVersion int64) error { +func appendRuntimeAudit(ctx context.Context, tx *sql.Tx, eventType, accountID, alias, exitID string, bindingVersion int64) error { _, err := tx.ExecContext(ctx, ` INSERT INTO audit_event - (event_type, account_id, browser_env_alias, network_exit_id, runtime_instance_id, binding_version, actor, reason_code) - VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, 'local-user', $1)`, - eventType, accountID, alias, exitID, runtimeInstanceID, bindingVersion) + (event_type, account_id, browser_env_alias, network_exit_id, binding_version, actor, reason_code) + VALUES ($1, $2, $3, NULLIF($4, ''), $5, 'local-user', $1)`, + eventType, accountID, alias, exitID, bindingVersion) if err != nil { return errors.New("append runtime audit event") } @@ -884,11 +860,11 @@ func (s *Store) AppendEnvironmentAction(ctx context.Context, eventType string, a } _, err := s.db.ExecContext(ctx, ` INSERT INTO audit_event - (event_type, account_id, browser_env_alias, network_exit_id, runtime_instance_id, + (event_type, account_id, browser_env_alias, network_exit_id, binding_version, actor, reason_code, operation_id, action, outcome) - VALUES ($1, NULLIF($2, ''), NULLIF($3, ''), NULLIF($4, ''), NULLIF($5, ''), - NULLIF($6, 0), 'local-user', $7, $8, $9, NULLIF($10, ''))`, - eventType, action.AccountID, action.BrowserEnvAlias, action.NetworkExitID, action.RuntimeInstanceID, + VALUES ($1, NULLIF($2, ''), NULLIF($3, ''), NULLIF($4, ''), + NULLIF($5, 0), 'local-user', $6, $7, $8, NULLIF($9, ''))`, + eventType, action.AccountID, action.BrowserEnvAlias, action.NetworkExitID, action.BindingVersion, action.ReasonCode, action.OperationID, action.Action, action.Outcome) if err != nil { return errors.New("append environment action") diff --git a/internal/environment/legacy_browser_version_test.go b/internal/environment/legacy_browser_version_test.go index 6177d68..41b2e78 100644 --- a/internal/environment/legacy_browser_version_test.go +++ b/internal/environment/legacy_browser_version_test.go @@ -73,8 +73,5 @@ func (s *Store) UpgradeEnv(ctx context.Context, alias, version string) error { if err := tx.QueryRowContext(ctx, `UPDATE browser_env SET browser_version = $2, version = version + 1 WHERE alias = $1 RETURNING alias`, alias, version).Scan(&updated); err != nil { return rowError(err) } - if _, err := tx.ExecContext(ctx, `UPDATE environment_binding SET version = version + 1, updated_at = now() WHERE browser_env_alias = $1`, alias); err != nil { - return err - } return commitHub(tx) } diff --git a/internal/environment/migration043_probe_test.go b/internal/environment/migration043_probe_test.go new file mode 100644 index 0000000..c8d0ea8 --- /dev/null +++ b/internal/environment/migration043_probe_test.go @@ -0,0 +1,75 @@ +package environment + +import ( + "context" + "database/sql" + "os" + "strings" + "testing" + + _ "github.com/jackc/pgx/v5/stdlib" +) + +func TestMigration043ConsolidatedSchemaShape(t *testing.T) { + databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") + if databaseURL == "" { + t.Skip("set CREATORHUB_POSTGRES_TEST_URL") + } + ctx := context.Background() + testURL := isolatedDatabaseURL(t, databaseURL) + store, err := Open(ctx, testURL) + if err != nil { + t.Fatalf("open store (applies all migrations incl 043): %v", err) + } + defer store.Close() + + db, err := sql.Open("pgx", testURL) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + // 最终 19 张表 + assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema() + AND table_name IN ('social_account','gateway','network_exit','browser_env','audit_event', + 'creator_competitor','creator_competitor_share_job','creator_work','creator_work_metric','creator_work_cover', + 'creator_comment','creator_comment_rule_result','creator_lead_rule','creator_account_profile','creator_account_password', + 'creator_account_metric','creator_collection_checkpoint','creator_settings','schema_migration')`, 19) + // 已删表不再存在 + assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema() + AND table_name IN ('runtime_instance','environment_binding','credential_reference','content_draft','confirmation', + 'operation_task','execution_attempt','runtime_use_lease','creator_event','creator_strategy','creator_operation', + 'creator_conversation','creator_message','creator_cooldown','creator_listener_state','creator_listener_boundary', + 'creator_relation','creator_material_job','creator_event_strategy_trace','creator_work_source','creator_metric_plan', + 'creator_source_sync_lease','creator_schema_migration')`, 0) + // browser_env 合并列 + assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() + AND table_name = 'browser_env' AND column_name IN ('account_id','network_exit_id','runtime_cleanup_pending', + 'runtime_cleanup_binding_version','runtime_cleanup_runtime_id','runtime_cleanup_network_id', + 'runtime_id','runtime_lease_until','runtime_network_id','runtime_node_id','updated_at')`, 11) + // social_account 凭据列 + assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() + AND table_name = 'social_account' AND column_name IN ('credential_provider','credential_key')`, 2) + assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() + AND table_name = 'social_account' AND column_name = 'credential_reference_id'`, 0) + // creator_work 调度列 + assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() + AND table_name = 'creator_work' AND column_name IN ('metric_plan_published_at','metric_monitoring_end','metric_plan_next_at', + 'metric_plan_interval_seconds','metric_plan_multiplier','metric_plan_max_interval_seconds','metric_plan_point_index','metric_plan_stopped')`, 8) + // checkpoint 支持 sync kind + var kindCheck string + if err := db.QueryRow(`SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = 'creator_collection_checkpoint_collection_kind_check' AND conrelid = 'creator_collection_checkpoint'::regclass`).Scan(&kindCheck); err != nil { + t.Fatal(err) + } + if !strings.Contains(kindCheck, "'sync'") { + t.Fatalf("checkpoint kind check missing sync: %s", kindCheck) + } + // creator_settings 转写列已删 + assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() + AND table_name = 'creator_settings' AND column_name LIKE 'transcription%'`, 0) + // audit_event 任务列已删 + assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() + AND table_name = 'audit_event' AND column_name IN ('confirmation_id','confirmation_version','attempt_id','task_id','runtime_instance_id')`, 0) + // 统一登记表:1-38(除 36)、1017-1042、43 全部登记 + assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration`, 48) +} diff --git a/internal/environment/migration_test.go b/internal/environment/migration_test.go index cfca258..26ff0f9 100644 --- a/internal/environment/migration_test.go +++ b/internal/environment/migration_test.go @@ -30,19 +30,19 @@ func TestUnifiedAccountMigration(t *testing.T) { t.Fatal(err) } defer db.Close() - assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 17`, 17) - assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name IN ('social_account', 'browser_env', 'network_exit', 'environment_binding')`, 4) + assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration`, 48) + assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema() + AND table_name IN ('social_account', 'browser_env', 'network_exit', 'environment_binding')`, 3) assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'browser_image'`, 0) assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.tables WHERE table_schema = current_schema() AND table_name = 'browser_version'`, 0) - assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'social_account' AND column_name IN ('name', 'tags')`, 2) assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'social_account' AND column_name = 'cookies'`, 0) - assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'environment_binding' AND column_name = 'runtime_cleanup_pending'`, 1) + assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'browser_env' AND column_name = 'runtime_cleanup_pending'`, 1) assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'network_exit' AND column_name IN ('username', 'password')`, 2) assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'network_exit' AND column_name = 'credential_reference_id'`, 0) store = openFullyMigratedHub(t, ctx, testURL) store.Close() - assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version BETWEEN 1 AND 17`, 17) + assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration`, 48) }) t.Run("legacy migration 013 without account secrets is repaired forward", func(t *testing.T) { @@ -60,7 +60,7 @@ func TestUnifiedAccountMigration(t *testing.T) { assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = 'social_account' AND column_name = 'cookies'`, 0) assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() - AND table_name = 'social_account' AND column_name = 'credential_reference_id' AND is_nullable = 'NO'`, 1) + AND table_name = 'social_account' AND column_name = 'credential_key'`, 1) }) t.Run("legacy migration 013 with account secrets blocks upgrade", func(t *testing.T) { @@ -83,77 +83,10 @@ func TestUnifiedAccountMigration(t *testing.T) { assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version = 14`, 0) }) - t.Run("previous migration 008 already applied", func(t *testing.T) { - ctx := context.Background() - testURL := isolatedDatabaseURL(t, databaseURL) - store := openFullyMigratedHub(t, ctx, testURL) - store.Close() - db, err := sql.Open("pgx", testURL) - if err != nil { - t.Fatal(err) - } - defer db.Close() - if _, err := db.Exec(` - INSERT INTO credential_reference (id, provider, reference_key) - VALUES ('credential-upgrade', 'os_keyring', 'creatorhub/upgrade'); - INSERT INTO social_account - (id, credential_reference_id, platform, platform_account_key, authorization_kind, authorization_status, status) - VALUES ('upgrade', 'credential-upgrade', 'mock', 'upgrade', 'owned', 'authorized', 'paused'); - INSERT INTO gateway (name, endpoint, token) - VALUES ('upgrade-gateway', 'http://127.0.0.1:8081', 'upgrade-gateway-token'); - INSERT INTO browser_env (alias, name, gateway_name, fingerprint) - VALUES ('upgrade', 'Upgrade', 'upgrade-gateway', '{"seed":1}'); - INSERT INTO environment_binding (id, account_id, browser_env_alias) - VALUES ('upgrade', 'upgrade', 'upgrade'); - UPDATE environment_binding SET runtime_cleanup_pending = true, - runtime_cleanup_binding_version = version WHERE id = 'upgrade'; - DELETE FROM schema_migration WHERE version = 9; - ALTER TABLE environment_binding ADD CONSTRAINT runtime_cleanup_generation_consistent CHECK ( - (runtime_cleanup_pending AND runtime_cleanup_binding_version IS NOT NULL AND runtime_cleanup_binding_version > 0) - OR - (NOT runtime_cleanup_pending AND runtime_cleanup_binding_version IS NULL - AND runtime_cleanup_instance_id IS NULL AND runtime_cleanup_runtime_id IS NULL) - )`); err != nil { - t.Fatal(err) - } - if _, err := db.Exec(`UPDATE environment_binding SET runtime_cleanup_pending = false WHERE id = 'upgrade'`); err == nil { - t.Fatal("old migration 008 constraint unexpectedly allowed a legacy writer to clear pending") - } - - store, err = Open(ctx, testURL) - if err != nil { - t.Fatalf("apply forward compatibility migration: %v", err) - } - defer store.Close() - if _, err := db.Exec(`UPDATE environment_binding SET runtime_cleanup_pending = false WHERE id = 'upgrade'`); err != nil { - t.Fatalf("migration 009 did not unblock the old writer: %v", err) - } - context, err := store.GetEnvironmentContext(ctx, "upgrade") - if err != nil || context.RuntimeCleanupPending || context.RuntimeCleanupBindingVersion != 0 || context.RuntimeCleanupRuntimeID != "" { - t.Fatalf("new runner reused stale cleanup generation after old writer clear: %#v err=%v", context, err) - } - if _, err := db.Exec(`UPDATE environment_binding SET runtime_cleanup_pending = true WHERE id = 'upgrade'`); err != nil { - t.Fatalf("migration 009 did not allow the old writer to set pending: %v", err) - } - context, err = store.GetEnvironmentContext(ctx, "upgrade") - if err != nil || !context.RuntimeCleanupPending || context.RuntimeCleanupBindingVersion != 1 || context.RuntimeCleanupRuntimeID != "" { - t.Fatalf("new runner did not expose legacy pending for manual reconcile: %#v err=%v", context, err) - } - assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version IN (9, 10)`, 2) - assertDatabaseCount(t, db, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() AND - ((table_name = 'runtime_instance' AND column_name = 'network_id') OR - (table_name = 'environment_binding' AND column_name = 'runtime_cleanup_network_id'))`, 2) - assertDatabaseCount(t, db, `SELECT count(*) FROM pg_constraint WHERE conname = 'runtime_cleanup_generation_consistent'`, 0) - }) - t.Run("v1 and v2 data", func(t *testing.T) { ctx := context.Background() testURL := isolatedDatabaseURL(t, databaseURL) - phaseAStore, err := account.Open(ctx, testURL) - if err != nil { - t.Fatal(err) - } - phaseAStore.Close() + openLegacyPhaseASchema(t, ctx, testURL) db, err := sql.Open("pgx", testURL) if err != nil { @@ -190,77 +123,21 @@ func TestUnifiedAccountMigration(t *testing.T) { } store.Close() - assertDatabaseCount(t, db, `SELECT count(*) FROM social_account WHERE platform = 'mock' AND platform_account_key = id AND status = 'paused'`, 2) - assertDatabaseCount(t, db, `SELECT count(*) FROM social_account WHERE profile_id LIKE 'legacy-profile-%'`, 2) - assertDatabaseCount(t, db, `SELECT count(*) FROM environment_binding WHERE account_id = 'mapped' AND browser_env_alias = 'mapped' AND network_exit_id IS NULL`, 1) - assertDatabaseCount(t, db, `SELECT count(*) FROM environment_binding WHERE account_id = 'mapped' AND NOT runtime_cleanup_pending`, 1) - assertDatabaseCount(t, db, `SELECT count(*) FROM environment_binding WHERE account_id = 'unbound'`, 0) - assertDatabaseCount(t, db, `SELECT count(*) FROM runtime_instance WHERE id = 'instance-mapped' AND binding_id = 'mapped'`, 1) - assertDatabaseCount(t, db, `SELECT count(*) FROM runtime_instance WHERE id = 'instance-mapped' AND binding_version = 1`, 1) - assertDatabaseCount(t, db, `SELECT count(*) FROM runtime_instance WHERE id = 'instance-unbound' AND binding_id IS NULL`, 1) - assertDatabaseCount(t, db, `SELECT count(*) FROM audit_event WHERE event_type = 'legacy_event'`, 1) - assertDatabaseCount(t, db, `SELECT count(*) FROM browser_env WHERE alias = 'mapped' AND NOT (fingerprint ?| ARRAY['proxy_server', 'disable_non_proxied_udp'])`, 1) - if _, err := db.Exec(`UPDATE environment_binding SET runtime_cleanup_pending = true WHERE id = 'mapped'`); err != nil { - t.Fatalf("migration 008 blocked an old writer setting cleanup pending: %v", err) - } - assertDatabaseCount(t, db, `SELECT count(*) FROM environment_binding WHERE id = 'mapped' AND runtime_cleanup_pending AND runtime_cleanup_binding_version IS NULL AND runtime_cleanup_runtime_id IS NULL`, 1) - if _, err := db.Exec(`UPDATE environment_binding SET runtime_cleanup_pending = false WHERE id = 'mapped'`); err != nil { - t.Fatalf("migration 008 blocked an old writer clearing cleanup pending: %v", err) - } - store, err = Open(ctx, testURL) - if err != nil { + // 043 收敛清空账号域:legacy 绑定/实例数据清空,验证收敛形态与约束仍在 + assertDatabaseCount(t, db, `SELECT count(*) FROM social_account`, 0) + assertDatabaseCount(t, db, `SELECT count(*) FROM browser_env`, 0) + assertDatabaseCount(t, db, `SELECT count(*) FROM audit_event`, 0) + assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version IN (3, 4, 5, 6, 7, 8, 9)`, 7) + if _, err := db.Exec(` + INSERT INTO social_account (id, credential_provider, credential_key, platform, platform_account_key, authorization_kind, authorization_status) + VALUES ('mapped', 'os_keyring', 'creatorhub/mapped', 'mock', 'mapped', 'owned', 'authorized')`); err != nil { t.Fatal(err) } - legacy, err := store.GetEnvironmentContext(ctx, "mapped") - store.Close() - if err != nil || legacy.Exit.ID != "" || legacy.Fingerprint.ProxyServer != "" || legacy.Fingerprint.DisableNonProxiedUDP { - t.Fatalf("legacy NULL binding must remain visible without persisted proxy credentials: %#v err=%v", legacy, err) - } - if _, err := db.Exec(` - INSERT INTO credential_reference (id, provider, reference_key) VALUES ('credential-duplicate', 'os_keyring', 'creatorhub/duplicate'); - INSERT INTO social_account - (id, credential_reference_id, platform, platform_account_key, authorization_kind, authorization_status) - VALUES ('duplicate', 'credential-duplicate', 'mock', 'mapped', 'owned', 'authorized')`); err == nil { + INSERT INTO social_account (id, credential_provider, credential_key, platform, platform_account_key, authorization_kind, authorization_status) + VALUES ('duplicate', 'os_keyring', 'creatorhub/duplicate', 'mock', 'mapped', 'owned', 'authorized')`); err == nil { t.Fatal("duplicate platform account must fail") } - - if _, err := db.Exec(` - INSERT INTO network_exit (id, protocol, host, port, health_status) VALUES ('shared-exit', 'socks5', '127.0.0.1', 1080, 'healthy'); - UPDATE environment_binding SET network_exit_id = 'shared-exit' WHERE id = 'mapped'; - INSERT INTO environment_binding (id, account_id, browser_env_alias, network_exit_id) - VALUES ('unbound-binding', 'unbound', 'orphan-env', 'shared-exit')`); err != nil { - t.Fatal(err) - } - assertDatabaseCount(t, db, `SELECT count(*) FROM environment_binding WHERE network_exit_id = 'shared-exit'`, 2) - - if _, err := db.Exec(` - INSERT INTO browser_env (alias, name, gateway_name, image_version, fingerprint) - VALUES ('extra-env', 'Extra', 'legacy-gateway', '1', '{"seed":3}'); - INSERT INTO environment_binding (id, account_id, browser_env_alias) - VALUES ('duplicate-account-binding', 'mapped', 'extra-env')`); err == nil { - t.Fatal("one account must not bind two environments") - } - if _, err := db.Exec(` - INSERT INTO credential_reference (id, provider, reference_key) VALUES ('credential-extra', 'os_keyring', 'creatorhub/extra'); - INSERT INTO social_account - (id, credential_reference_id, platform, platform_account_key, authorization_kind, authorization_status) - VALUES ('extra-account', 'credential-extra', 'mock', 'extra-account', 'owned', 'authorized'); - INSERT INTO environment_binding (id, account_id, browser_env_alias) - VALUES ('duplicate-env-binding', 'extra-account', 'mapped')`); err == nil { - t.Fatal("one environment must not bind two accounts") - } - if _, err := db.Exec(` - INSERT INTO runtime_instance (id, account_id, binding_id, runtime_id, lease_until) - VALUES ('instance-conflict', 'mapped', 'mapped', 'runtime-new', now() + interval '1 hour')`); err == nil { - t.Fatal("one binding must not have two active runtime instances") - } - store, err = Open(ctx, testURL) - if err != nil { - t.Fatal(err) - } - store.Close() - assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version IN (3, 4, 5, 6, 7, 8, 9)`, 7) }) } @@ -304,13 +181,28 @@ func isolatedDatabaseURL(t *testing.T, databaseURL string) string { return parsed.String() } -func openLegacyAccountCreationSchema(t *testing.T, ctx context.Context, databaseURL string) *sql.DB { +// openLegacyPhaseASchema 在空 schema 上重放 001 账号基座(legacy 迁移重放测试用;account.Open 已不再执行迁移)。 +func openLegacyPhaseASchema(t *testing.T, ctx context.Context, databaseURL string) *sql.DB { t.Helper() - phaseAStore, err := account.Open(ctx, databaseURL) + db, err := sql.Open("pgx", databaseURL) if err != nil { t.Fatal(err) } - phaseAStore.Close() + if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migration (version integer PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(migration001); err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO schema_migration (version) VALUES (1)`); err != nil { + t.Fatal(err) + } + return db +} + +func openLegacyAccountCreationSchema(t *testing.T, ctx context.Context, databaseURL string) *sql.DB { + t.Helper() + openLegacyPhaseASchema(t, ctx, databaseURL) db, err := sql.Open("pgx", databaseURL) if err != nil { t.Fatal(err) diff --git a/internal/account/migrations/001_phase_a.sql b/internal/environment/migrations/001_phase_a.sql similarity index 100% rename from internal/account/migrations/001_phase_a.sql rename to internal/environment/migrations/001_phase_a.sql diff --git a/internal/environment/migrations/038_creator_registration_backfill.sql b/internal/environment/migrations/038_creator_registration_backfill.sql new file mode 100644 index 0000000..53bf3f4 --- /dev/null +++ b/internal/environment/migrations/038_creator_registration_backfill.sql @@ -0,0 +1,14 @@ +-- 统一迁移登记回填:历史库的 creator 域迁移(creator_schema_migration 17-42) +-- 折算为统一登记表的 1017-1042;fresh 库无 creator_schema_migration,直接跳过。 +-- creator_schema_migration 表本体由 043 收敛删除。 +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = current_schema() AND table_name = 'creator_schema_migration' + ) THEN + INSERT INTO schema_migration (version) + SELECT version + 1000 FROM creator_schema_migration + ON CONFLICT (version) DO NOTHING; + END IF; +END $$; diff --git a/internal/environment/migrations/043_schema_consolidation.sql b/internal/environment/migrations/043_schema_consolidation.sql new file mode 100644 index 0000000..d9a0cce --- /dev/null +++ b/internal/environment/migrations/043_schema_consolidation.sql @@ -0,0 +1,118 @@ +-- 043 Schema 收敛(42 → 19 张表):砍空转链路表、账号域清数据、同域合并、双迁移系统收尾。 +-- 决策依据:docs/plans/2026-09-29-schema-consolidation-and-numeric-id.md +-- 账号域历史数据直接清空(账号即环境重构前置);竞品采集数据无损保留。 + +-- 1) 空转链路表:任务发布链(content_draft/confirmation/operation_task/execution_attempt)、 +-- 自动运营链(creator_event/strategy/operation/conversation/message/cooldown/listener_*)、 +-- 素材链(creator_material_job)、双租约冗余(runtime_use_lease)、主小号关系(creator_relation)。 +DROP TABLE IF EXISTS runtime_use_lease CASCADE; +DROP TABLE IF EXISTS creator_event_strategy_trace CASCADE; +DROP TABLE IF EXISTS creator_material_job CASCADE; +DROP TABLE IF EXISTS creator_cooldown CASCADE; +DROP TABLE IF EXISTS creator_listener_boundary CASCADE; +DROP TABLE IF EXISTS creator_listener_state CASCADE; +DROP TABLE IF EXISTS creator_message CASCADE; +DROP TABLE IF EXISTS creator_conversation CASCADE; +DROP TABLE IF EXISTS creator_operation CASCADE; +DROP TABLE IF EXISTS creator_event CASCADE; +DROP TABLE IF EXISTS creator_strategy CASCADE; +DROP TABLE IF EXISTS creator_relation CASCADE; +DROP TABLE IF EXISTS execution_attempt CASCADE; +DROP TABLE IF EXISTS operation_task CASCADE; +DROP TABLE IF EXISTS confirmation CASCADE; +DROP TABLE IF EXISTS content_draft CASCADE; + +-- 2) audit_event 瘦身:任务/草稿/确认列与运行时实例列随链路删除。 +ALTER TABLE audit_event + DROP CONSTRAINT IF EXISTS audit_event_attempt_id_fkey, + DROP CONSTRAINT IF EXISTS audit_event_confirmation_id_fkey, + DROP CONSTRAINT IF EXISTS audit_event_task_id_fkey, + DROP CONSTRAINT IF EXISTS audit_event_runtime_instance_id_fkey; +ALTER TABLE audit_event + DROP COLUMN IF EXISTS confirmation_id, + DROP COLUMN IF EXISTS confirmation_version, + DROP COLUMN IF EXISTS attempt_id, + DROP COLUMN IF EXISTS task_id, + DROP COLUMN IF EXISTS runtime_instance_id; + +-- 3) 账号域清数据:账号、凭据引用、环境、绑定、运行时实例全部清空重建; +-- CASCADE 链上的 creator_account_profile / creator_account_password / creator_account_metric +-- 与 audit_event 一并清空。gateway / network_exit / 竞品采集数据不受影响。 +TRUNCATE runtime_instance, environment_binding, browser_env, social_account, credential_reference + RESTART IDENTITY CASCADE; + +-- 4) environment_binding + runtime_instance 并入 browser_env(1:1 绑定 + 1:1 活跃实例)。 +-- 绑定列:account_id(UNIQUE,账号即环境)、network_exit_id(version 绑定代由 003 引入,复用); +-- 清理 fence 列:runtime_cleanup_*;活跃实例列:runtime_id/runtime_lease_until/runtime_network_id/runtime_node_id。 +ALTER TABLE browser_env + ADD COLUMN account_id text NOT NULL UNIQUE REFERENCES social_account(id) ON DELETE CASCADE, + ADD COLUMN network_exit_id text REFERENCES network_exit(id), + ADD COLUMN runtime_cleanup_pending boolean NOT NULL DEFAULT false, + ADD COLUMN runtime_cleanup_binding_version bigint, + ADD COLUMN runtime_cleanup_runtime_id text, + ADD COLUMN runtime_cleanup_network_id text, + ADD COLUMN runtime_id text, + ADD COLUMN runtime_lease_until timestamptz, + ADD COLUMN runtime_network_id text, + ADD COLUMN runtime_node_id text, + ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now(); +DROP TABLE runtime_instance; +DROP TABLE environment_binding; + +-- 5) credential_reference 并入 social_account(provider + key 两列)。 +ALTER TABLE social_account + ADD COLUMN credential_provider text NOT NULL DEFAULT 'os_keyring' + CHECK (credential_provider IN ('os_keyring', 'secret_manager')), + ADD COLUMN credential_key text NOT NULL DEFAULT ''; +ALTER TABLE social_account DROP COLUMN credential_reference_id; +DROP TABLE credential_reference; + +-- 6) creator_work_source 并入 creator_work:work 已有 source_type/source_id 内联列, +-- 来源表是双份状态;删除后来源切换仍由 upsert 覆盖内联列完成。 +DROP TABLE creator_work_source; + +-- 7) creator_metric_plan 并入 creator_work:调度列(发布/窗口/节奏/停点)并入作品行, +-- work 原有的 next_metric_at / metric_stop_reason 继续作为调度投影。 +ALTER TABLE creator_work + ADD COLUMN metric_plan_published_at timestamptz, + ADD COLUMN metric_monitoring_end timestamptz, + ADD COLUMN metric_plan_next_at timestamptz, + ADD COLUMN metric_plan_interval_seconds bigint, + ADD COLUMN metric_plan_multiplier double precision, + ADD COLUMN metric_plan_max_interval_seconds bigint, + ADD COLUMN metric_plan_point_index integer NOT NULL DEFAULT 0 CHECK (metric_plan_point_index >= 0), + ADD COLUMN metric_plan_stopped boolean NOT NULL DEFAULT false; +UPDATE creator_work w SET + metric_plan_published_at = p.published_at, + metric_monitoring_end = p.monitoring_end, + metric_plan_next_at = p.next_plan_at, + metric_plan_interval_seconds = p.interval_seconds, + metric_plan_multiplier = p.multiplier, + metric_plan_max_interval_seconds = p.max_interval_seconds, + metric_plan_point_index = p.point_index, + metric_plan_stopped = p.stopped +FROM creator_metric_plan p WHERE p.work_id = w.id; +DROP TABLE creator_metric_plan; + +-- 8) creator_source_sync_lease 并入 creator_collection_checkpoint(统一租约模式): +-- 账号/竞品同步互斥租约成为 checkpoint 的一种 collection_kind='sync',与 works/comments 同写法。 +ALTER TABLE creator_collection_checkpoint DROP CONSTRAINT creator_collection_checkpoint_collection_kind_check; +ALTER TABLE creator_collection_checkpoint + ADD CONSTRAINT creator_collection_checkpoint_collection_kind_check + CHECK (collection_kind IN ('works', 'comments', 'sync')); +INSERT INTO creator_collection_checkpoint + (id, source_type, source_id, collection_kind, cursor, window_start, window_end, status, lease_token, lease_until, last_error) +SELECT 'sync:' || source_type || ':' || source_id, source_type, source_id, 'sync', '', now(), now(), 'idle', lease_token, lease_until, '' +FROM creator_source_sync_lease +ON CONFLICT (source_type, source_id, collection_kind) DO UPDATE SET + lease_token = EXCLUDED.lease_token, lease_until = EXCLUDED.lease_until; +DROP TABLE creator_source_sync_lease; + +-- 9) creator_settings 瘦身:转写配置列属于已砍素材链路。 +ALTER TABLE creator_settings + DROP COLUMN IF EXISTS transcription_provider, + DROP COLUMN IF EXISTS transcription_model, + DROP COLUMN IF EXISTS transcription_configured; + +-- 10) 双迁移系统收尾:creator 域登记已折算进 schema_migration(038),登记表删除。 +DROP TABLE IF EXISTS creator_schema_migration; diff --git a/internal/creator/migrations/017_creator.sql b/internal/environment/migrations/1017_creator.sql similarity index 100% rename from internal/creator/migrations/017_creator.sql rename to internal/environment/migrations/1017_creator.sql diff --git a/internal/creator/migrations/018_collection_checkpoints.sql b/internal/environment/migrations/1018_collection_checkpoints.sql similarity index 100% rename from internal/creator/migrations/018_collection_checkpoints.sql rename to internal/environment/migrations/1018_collection_checkpoints.sql diff --git a/internal/creator/migrations/019_metric_plans.sql b/internal/environment/migrations/1019_metric_plans.sql similarity index 100% rename from internal/creator/migrations/019_metric_plans.sql rename to internal/environment/migrations/1019_metric_plans.sql diff --git a/internal/creator/migrations/020_competitor_sync_leases.sql b/internal/environment/migrations/1020_competitor_sync_leases.sql similarity index 100% rename from internal/creator/migrations/020_competitor_sync_leases.sql rename to internal/environment/migrations/1020_competitor_sync_leases.sql diff --git a/internal/creator/migrations/021_work_sources.sql b/internal/environment/migrations/1021_work_sources.sql similarity index 100% rename from internal/creator/migrations/021_work_sources.sql rename to internal/environment/migrations/1021_work_sources.sql diff --git a/internal/creator/migrations/022_collection_lease_tokens.sql b/internal/environment/migrations/1022_collection_lease_tokens.sql similarity index 100% rename from internal/creator/migrations/022_collection_lease_tokens.sql rename to internal/environment/migrations/1022_collection_lease_tokens.sql diff --git a/internal/creator/migrations/023_password_references.sql b/internal/environment/migrations/1023_password_references.sql similarity index 100% rename from internal/creator/migrations/023_password_references.sql rename to internal/environment/migrations/1023_password_references.sql diff --git a/internal/creator/migrations/024_event_processing_times.sql b/internal/environment/migrations/1024_event_processing_times.sql similarity index 100% rename from internal/creator/migrations/024_event_processing_times.sql rename to internal/environment/migrations/1024_event_processing_times.sql diff --git a/internal/creator/migrations/025_competitor_sync_tokens.sql b/internal/environment/migrations/1025_competitor_sync_tokens.sql similarity index 100% rename from internal/creator/migrations/025_competitor_sync_tokens.sql rename to internal/environment/migrations/1025_competitor_sync_tokens.sql diff --git a/internal/creator/migrations/026_event_message_text.sql b/internal/environment/migrations/1026_event_message_text.sql similarity index 100% rename from internal/creator/migrations/026_event_message_text.sql rename to internal/environment/migrations/1026_event_message_text.sql diff --git a/internal/creator/migrations/027_creator_listener_state.sql b/internal/environment/migrations/1027_creator_listener_state.sql similarity index 100% rename from internal/creator/migrations/027_creator_listener_state.sql rename to internal/environment/migrations/1027_creator_listener_state.sql diff --git a/internal/creator/migrations/028_creator_event_message_type.sql b/internal/environment/migrations/1028_creator_event_message_type.sql similarity index 100% rename from internal/creator/migrations/028_creator_event_message_type.sql rename to internal/environment/migrations/1028_creator_event_message_type.sql diff --git a/internal/creator/migrations/029_creator_source_sync_lease.sql b/internal/environment/migrations/1029_creator_source_sync_lease.sql similarity index 100% rename from internal/creator/migrations/029_creator_source_sync_lease.sql rename to internal/environment/migrations/1029_creator_source_sync_lease.sql diff --git a/internal/creator/migrations/030_creator_event_gateway_time.sql b/internal/environment/migrations/1030_creator_event_gateway_time.sql similarity index 100% rename from internal/creator/migrations/030_creator_event_gateway_time.sql rename to internal/environment/migrations/1030_creator_event_gateway_time.sql diff --git a/internal/creator/migrations/031_raw_payloads.sql b/internal/environment/migrations/1031_raw_payloads.sql similarity index 100% rename from internal/creator/migrations/031_raw_payloads.sql rename to internal/environment/migrations/1031_raw_payloads.sql diff --git a/internal/creator/migrations/032_douyin_release_remediation.sql b/internal/environment/migrations/1032_douyin_release_remediation.sql similarity index 100% rename from internal/creator/migrations/032_douyin_release_remediation.sql rename to internal/environment/migrations/1032_douyin_release_remediation.sql diff --git a/internal/creator/migrations/033_competitor_tags.sql b/internal/environment/migrations/1033_competitor_tags.sql similarity index 100% rename from internal/creator/migrations/033_competitor_tags.sql rename to internal/environment/migrations/1033_competitor_tags.sql diff --git a/internal/creator/migrations/034_competitor_tags_repair.sql b/internal/environment/migrations/1034_competitor_tags_repair.sql similarity index 100% rename from internal/creator/migrations/034_competitor_tags_repair.sql rename to internal/environment/migrations/1034_competitor_tags_repair.sql diff --git a/internal/creator/migrations/035_account_deletion.sql b/internal/environment/migrations/1035_account_deletion.sql similarity index 100% rename from internal/creator/migrations/035_account_deletion.sql rename to internal/environment/migrations/1035_account_deletion.sql diff --git a/internal/creator/migrations/036_competitor_unique_id.sql b/internal/environment/migrations/1036_competitor_unique_id.sql similarity index 100% rename from internal/creator/migrations/036_competitor_unique_id.sql rename to internal/environment/migrations/1036_competitor_unique_id.sql diff --git a/internal/creator/migrations/037_competitor_share_jobs.sql b/internal/environment/migrations/1037_competitor_share_jobs.sql similarity index 100% rename from internal/creator/migrations/037_competitor_share_jobs.sql rename to internal/environment/migrations/1037_competitor_share_jobs.sql diff --git a/internal/creator/migrations/038_douyin_only_competitor_profile.sql b/internal/environment/migrations/1038_douyin_only_competitor_profile.sql similarity index 100% rename from internal/creator/migrations/038_douyin_only_competitor_profile.sql rename to internal/environment/migrations/1038_douyin_only_competitor_profile.sql diff --git a/internal/creator/migrations/039_work_collect_count.sql b/internal/environment/migrations/1039_work_collect_count.sql similarity index 100% rename from internal/creator/migrations/039_work_collect_count.sql rename to internal/environment/migrations/1039_work_collect_count.sql diff --git a/internal/creator/migrations/040_work_play_count.sql b/internal/environment/migrations/1040_work_play_count.sql similarity index 100% rename from internal/creator/migrations/040_work_play_count.sql rename to internal/environment/migrations/1040_work_play_count.sql diff --git a/internal/creator/migrations/041_creator_account_metric.sql b/internal/environment/migrations/1041_creator_account_metric.sql similarity index 100% rename from internal/creator/migrations/041_creator_account_metric.sql rename to internal/environment/migrations/1041_creator_account_metric.sql diff --git a/internal/creator/migrations/042_work_cover_cache.sql b/internal/environment/migrations/1042_work_cover_cache.sql similarity index 100% rename from internal/creator/migrations/042_work_cover_cache.sql rename to internal/environment/migrations/1042_work_cover_cache.sql diff --git a/internal/environment/store.go b/internal/environment/store.go index d63cc31..157d95f 100644 --- a/internal/environment/store.go +++ b/internal/environment/store.go @@ -77,6 +77,93 @@ var migration034 string //go:embed migrations/037_browser_env_image_version_repair.sql var migration037 string +//go:embed migrations/001_phase_a.sql +var migration001 string + +//go:embed migrations/038_creator_registration_backfill.sql +var migration038 string + +//go:embed migrations/043_schema_consolidation.sql +var migration043 string + +//go:embed migrations/1017_creator.sql +var migration1017 string + +//go:embed migrations/1018_collection_checkpoints.sql +var migration1018 string + +//go:embed migrations/1019_metric_plans.sql +var migration1019 string + +//go:embed migrations/1020_competitor_sync_leases.sql +var migration1020 string + +//go:embed migrations/1021_work_sources.sql +var migration1021 string + +//go:embed migrations/1022_collection_lease_tokens.sql +var migration1022 string + +//go:embed migrations/1023_password_references.sql +var migration1023 string + +//go:embed migrations/1024_event_processing_times.sql +var migration1024 string + +//go:embed migrations/1025_competitor_sync_tokens.sql +var migration1025 string + +//go:embed migrations/1026_event_message_text.sql +var migration1026 string + +//go:embed migrations/1027_creator_listener_state.sql +var migration1027 string + +//go:embed migrations/1028_creator_event_message_type.sql +var migration1028 string + +//go:embed migrations/1029_creator_source_sync_lease.sql +var migration1029 string + +//go:embed migrations/1030_creator_event_gateway_time.sql +var migration1030 string + +//go:embed migrations/1031_raw_payloads.sql +var migration1031 string + +//go:embed migrations/1032_douyin_release_remediation.sql +var migration1032 string + +//go:embed migrations/1033_competitor_tags.sql +var migration1033 string + +//go:embed migrations/1034_competitor_tags_repair.sql +var migration1034 string + +//go:embed migrations/1035_account_deletion.sql +var migration1035 string + +//go:embed migrations/1036_competitor_unique_id.sql +var migration1036 string + +//go:embed migrations/1037_competitor_share_jobs.sql +var migration1037 string + +//go:embed migrations/1038_douyin_only_competitor_profile.sql +var migration1038 string + +//go:embed migrations/1039_work_collect_count.sql +var migration1039 string + +//go:embed migrations/1040_work_play_count.sql +var migration1040 string + +//go:embed migrations/1041_creator_account_metric.sql +var migration1041 string + +//go:embed migrations/1042_work_cover_cache.sql +var migration1042 string + var ( ErrConflict = errors.New("resource conflicts with existing state") ErrInvalid = errors.New("invalid environment input") @@ -204,7 +291,16 @@ func (s *Store) migrate(ctx context.Context) error { for _, migration := range []struct { version int sql string - }{{2, migration002}, {3, migration003}, {4, migration004}, {5, migration005}, {6, migration006}, {7, migration007}, {8, migration008}, {9, migration009}, {10, migration010}, {11, migration011}, {12, migration012}, {13, migration013}, {14, migration014}, {15, migration015}, {16, migration016}, {17, migration017}, {33, migration033}, {34, migration034}, {37, migration037}} { + }{{1, migration001}, {2, migration002}, {3, migration003}, {4, migration004}, {5, migration005}, {6, migration006}, {7, migration007}, + {8, migration008}, {9, migration009}, {10, migration010}, {11, migration011}, {12, migration012}, {13, migration013}, {14, migration014}, + {15, migration015}, {16, migration016}, {17, migration017}, {33, migration033}, {34, migration034}, {37, migration037}, + {38, migration038}, + {1017, migration1017}, {1018, migration1018}, {1019, migration1019}, {1020, migration1020}, {1021, migration1021}, {1022, migration1022}, + {1023, migration1023}, {1024, migration1024}, {1025, migration1025}, {1026, migration1026}, {1027, migration1027}, {1028, migration1028}, + {1029, migration1029}, {1030, migration1030}, {1031, migration1031}, {1032, migration1032}, {1033, migration1033}, {1034, migration1034}, + {1035, migration1035}, {1036, migration1036}, {1037, migration1037}, {1038, migration1038}, {1039, migration1039}, {1040, migration1040}, + {1041, migration1041}, {1042, migration1042}, + {43, migration043}} { var applied bool if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migration.version).Scan(&applied); err != nil { return errors.New("read environment schema migration state") @@ -400,17 +496,14 @@ func (s *Store) DeleteAccountEnvironment(ctx context.Context, accountID string) defer tx.Rollback() var alias string if err := tx.QueryRowContext(ctx, ` - SELECT browser_env_alias - FROM environment_binding + SELECT alias + FROM browser_env WHERE account_id = $1 FOR UPDATE`, accountID).Scan(&alias); errors.Is(err, sql.ErrNoRows) { return nil } else if err != nil { return errors.New("read account environment binding") } - if _, err := tx.ExecContext(ctx, `DELETE FROM environment_binding WHERE account_id = $1`, accountID); err != nil { - return errors.New("delete account environment binding") - } if _, err := tx.ExecContext(ctx, `DELETE FROM browser_env WHERE alias = $1`, alias); err != nil { return errors.New("delete account browser environment") } diff --git a/internal/environment/store_test.go b/internal/environment/store_test.go index da1fa58..c35a5fb 100644 --- a/internal/environment/store_test.go +++ b/internal/environment/store_test.go @@ -11,7 +11,6 @@ import ( "sync/atomic" "testing" "time" - ) func TestNetworkExitCredentialValidation(t *testing.T) { @@ -256,13 +255,13 @@ func TestStoreValidationRejectsInvalidInputsBeforePersistence(t *testing.T) { t.Fatalf("expected invalid gateway update %s, got %v", test.name, err) } } - if err := store.CreateEnv(ctx, Env{Alias: "UP", Name: "甲", Gateway: "gw-1", Fingerprint: Fingerprint{Seed: 1}}); !errors.Is(err, ErrInvalid) { + if _, _, err := store.CreateBoundEnv(ctx, Env{Alias: "UP", Name: "甲", Gateway: "gw-1", Fingerprint: Fingerprint{Seed: 1}}, "account-a", ""); !errors.Is(err, ErrInvalid) { t.Fatalf("expected invalid alias, got %v", err) } - if err := store.CreateEnv(ctx, Env{Alias: "account-a", Name: strings.Repeat("名", 65), Gateway: "gw-1", Fingerprint: Fingerprint{Seed: 1}}); !errors.Is(err, ErrInvalid) { + if _, _, err := store.CreateBoundEnv(ctx, Env{Alias: "account-a", Name: strings.Repeat("名", 65), Gateway: "gw-1", Fingerprint: Fingerprint{Seed: 1}}, "account-a", ""); !errors.Is(err, ErrInvalid) { t.Fatalf("expected overlong name, got %v", err) } - if err := store.CreateEnv(ctx, Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: Fingerprint{Seed: 0}}); !errors.Is(err, ErrInvalid) { + if _, _, err := store.CreateBoundEnv(ctx, Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: Fingerprint{Seed: 0}}, "account-a", ""); !errors.Is(err, ErrInvalid) { t.Fatalf("expected invalid fingerprint, got %v", err) } for name, exit := range map[string]NetworkExit{ @@ -278,7 +277,7 @@ func TestStoreValidationRejectsInvalidInputsBeforePersistence(t *testing.T) { } }) } - if err := store.CreateEnv(ctx, Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: Fingerprint{Seed: 1, ProxyServer: "socks5://proxy.example:1080"}}); !errors.Is(err, ErrInvalid) { + if _, _, err := store.CreateBoundEnv(ctx, Env{Alias: "account-a", Name: "甲", Gateway: "gw-1", Fingerprint: Fingerprint{Seed: 1, ProxyServer: "socks5://proxy.example:1080"}}, "account-a", ""); !errors.Is(err, ErrInvalid) { t.Fatalf("stored fingerprint proxy must be rejected, got %v", err) } } @@ -291,17 +290,16 @@ func TestFingerprintSeedIsGloballyUnique(t *testing.T) { ctx := context.Background() store := openFullyMigratedHub(t, ctx, isolatedDatabaseURL(t, databaseURL)) t.Cleanup(func() { _ = store.Close() }) - if _, err := store.db.ExecContext(ctx, `TRUNCATE environment_binding, browser_env, social_account, credential_reference, gateway CASCADE`); err != nil { + if _, err := store.db.ExecContext(ctx, `TRUNCATE browser_env, social_account, gateway CASCADE`); err != nil { t.Fatal(err) } if _, err := store.CreateGateway(ctx, "gw-seed", "http://127.0.0.1:8081", "unit-test-gateway-token"); err != nil { t.Fatal(err) } if _, err := store.db.ExecContext(ctx, ` - INSERT INTO credential_reference (id, provider, reference_key) VALUES ('credential-seed-a', 'os_keyring', 'creatorhub/seed-a'), ('credential-seed-b', 'os_keyring', 'creatorhub/seed-b'); - INSERT INTO social_account (id, credential_reference_id, platform, platform_account_key, authorization_kind, authorization_status) - VALUES ('seed-account-a', 'credential-seed-a', 'mock', 'seed-account-a', 'owned', 'authorized'), - ('seed-account-b', 'credential-seed-b', 'mock', 'seed-account-b', 'owned', 'authorized')`); err != nil { + INSERT INTO social_account (id, credential_provider, credential_key, platform, platform_account_key, authorization_kind, authorization_status) + VALUES ('seed-account-a', 'os_keyring', 'creatorhub/seed-a', 'mock', 'seed-account-a', 'owned', 'authorized'), + ('seed-account-b', 'os_keyring', 'creatorhub/seed-b', 'mock', 'seed-account-b', 'owned', 'authorized')`); err != nil { t.Fatal(err) } env := Env{Alias: "seed-environment-a", Name: "Seed A", Gateway: "gw-seed", Fingerprint: Fingerprint{Seed: 77}} @@ -342,17 +340,27 @@ func TestHubWorkflow(t *testing.T) { t.Fatalf("expected duplicate gateway conflict, got %v", err) } + if _, err := store.db.ExecContext(ctx, ` + INSERT INTO social_account (id, credential_provider, credential_key, platform, platform_account_key, authorization_kind, authorization_status) + VALUES ('shop-owner', 'os_keyring', 'creatorhub/shop-owner', 'mock', 'shop-owner', 'owned', 'authorized')`); err != nil { + t.Fatal(err) + } env := Env{ Alias: "shop-01", Name: "店铺一号", Gateway: "gw-main", Fingerprint: Fingerprint{Seed: 1000, Timezone: "Asia/Shanghai", Lang: "zh-CN"}, } - if err := store.CreateEnv(ctx, env); err != nil { + if _, created, err := store.CreateBoundEnv(ctx, env, "shop-owner", ""); err != nil || !created { + t.Fatalf("create bound env: created=%v err=%v", created, err) + } + if _, created, err := store.CreateBoundEnv(ctx, env, "shop-owner", ""); err != nil || created { + t.Fatalf("expected idempotent environment reuse, created=%v err=%v", created, err) + } + if _, err := store.db.ExecContext(ctx, ` + INSERT INTO social_account (id, credential_provider, credential_key, platform, platform_account_key, authorization_kind, authorization_status) + VALUES ('shop-owner-2', 'os_keyring', 'creatorhub/shop-owner-2', 'mock', 'shop-owner-2', 'owned', 'authorized')`); err != nil { t.Fatal(err) } - if err := store.CreateEnv(ctx, env); !errors.Is(err, ErrConflict) { - t.Fatalf("expected duplicate alias conflict, got %v", err) - } - if err := store.CreateEnv(ctx, Env{Alias: "shop-02", Name: "店铺二号", Gateway: "missing", Fingerprint: Fingerprint{Seed: 1}}); !errors.Is(err, ErrConflict) { + if _, _, err := store.CreateBoundEnv(ctx, Env{Alias: "shop-02", Name: "店铺二号", Gateway: "missing", Fingerprint: Fingerprint{Seed: 2}}, "shop-owner-2", ""); !errors.Is(err, ErrConflict) { t.Fatalf("expected unknown gateway conflict, got %v", err) } @@ -409,16 +417,14 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) { ctx := context.Background() store := openFullyMigratedHub(t, ctx, isolatedDatabaseURL(t, databaseURL)) t.Cleanup(func() { _ = store.Close() }) - if _, err := store.db.ExecContext(ctx, `TRUNCATE audit_event, runtime_instance, environment_binding, network_exit, - social_account, credential_reference, browser_env, gateway CASCADE`); err != nil { + if _, err := store.db.ExecContext(ctx, `TRUNCATE audit_event, network_exit, + social_account, browser_env, gateway CASCADE`); err != nil { t.Fatal(err) } if _, err := store.db.ExecContext(ctx, ` - INSERT INTO credential_reference (id, provider, reference_key) - VALUES ('credential-account', 'os_keyring', 'creatorhub/account-a'); INSERT INTO social_account - (id, credential_reference_id, platform, platform_account_key, authorization_kind, authorization_status) - VALUES ('account-a', 'credential-account', 'mock', 'account-a', 'owned', 'authorized')`); err != nil { + (id, credential_provider, credential_key, platform, platform_account_key, authorization_kind, authorization_status) + VALUES ('account-a', 'os_keyring', 'creatorhub/account-a', 'mock', 'account-a', 'owned', 'authorized')`); err != nil { t.Fatal(err) } if _, err := store.CreateGateway(ctx, "gw-main", "http://127.0.0.1:8081", "unit-test-gateway-token"); err != nil { @@ -457,20 +463,20 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) { t.Fatalf("create stable binding: %#v created=%v err=%v", bound, created, err) } reused, created, err := store.CreateBoundEnv(ctx, env, "account-a", exit.ID) - if err != nil || created || reused.Alias != bound.Alias || reused.BindingID != bound.BindingID { + if err != nil || created || reused.Alias != bound.Alias { t.Fatalf("same account must reuse its environment: %#v created=%v err=%v", reused, created, err) } if _, err := store.db.ExecContext(ctx, `UPDATE social_account SET status = 'active' WHERE id = 'account-a'`); err != nil { t.Fatal(err) } active, err := store.ActivateRuntime(ctx, env.Alias, "runtime-a", bound.BindingVersion, bound.Exit.ID, "network-a") - if err != nil || active.RuntimeInstanceID == "" { + if err != nil || active.RuntimeID == "" { t.Fatalf("activate runtime: %#v err=%v", active, err) } assertDatabaseCount(t, store.db, `SELECT count(*) FROM audit_event WHERE event_type = 'runtime_bound' AND reason_code = 'runtime_bound' AND account_id = 'account-a' AND browser_env_alias = 'environment-a' - AND network_exit_id = $1 AND runtime_instance_id = $2 AND binding_version = $3 AND details = '{}'::jsonb`, - 1, active.Exit.ID, active.RuntimeInstanceID, active.BindingVersion) + AND network_exit_id = $1 AND binding_version = $2 AND details = '{}'::jsonb`, + 1, active.Exit.ID, active.BindingVersion) second, err := store.CreateNetworkExit(ctx, NetworkExit{Protocol: "http", Host: "proxy-2.example", Port: 8080}) if err != nil { @@ -481,38 +487,35 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) { t.Fatalf("prepare second exit: %#v err=%v", second, err) } if _, err := store.db.ExecContext(ctx, ` - INSERT INTO credential_reference (id, provider, reference_key) VALUES ('credential-account-b', 'os_keyring', 'creatorhub/account-b'); INSERT INTO social_account - (id, credential_reference_id, platform, platform_account_key, authorization_kind, authorization_status) - VALUES ('account-b', 'credential-account-b', 'mock', 'account-b', 'owned', 'authorized'); - INSERT INTO browser_env (alias, name, gateway_name, fingerprint) - VALUES ('environment-b', '环境 B', 'gw-main', '{"seed":2}'); - INSERT INTO environment_binding (id, account_id, browser_env_alias) - VALUES ('binding-b', 'account-b', 'environment-b')`); err != nil { + (id, credential_provider, credential_key, platform, platform_account_key, authorization_kind, authorization_status) + VALUES ('account-b', 'os_keyring', 'creatorhub/account-b', 'mock', 'account-b', 'owned', 'authorized'); + INSERT INTO browser_env (alias, name, gateway_name, fingerprint, account_id) + VALUES ('environment-b', '环境 B', 'gw-main', '{"seed":2}', 'account-b')`); err != nil { t.Fatal(err) } legacyRebound, err := store.RebindEnvironment(ctx, "environment-b", second.ID, "", 1) if err != nil || legacyRebound.Exit.ID != second.ID { - t.Fatalf("legacy binding without an exit must support explicit rebind: %#v err=%v", legacyRebound, err) + t.Fatalf("binding without an exit must support explicit rebind: %#v err=%v", legacyRebound, err) } if _, err := store.ActivateRuntime(ctx, env.Alias, "runtime-a", bound.BindingVersion+1, second.ID, "network-a"); !errors.Is(err, ErrConflict) { t.Fatalf("stale binding metadata must not activate a runtime: %v", err) } - if _, err := store.db.ExecContext(ctx, `UPDATE runtime_instance SET lease_until = now() + interval '10 seconds' WHERE id = $1`, active.RuntimeInstanceID); err != nil { + if _, err := store.db.ExecContext(ctx, `UPDATE browser_env SET runtime_lease_until = now() + interval '10 seconds' WHERE alias = $1`, env.Alias); err != nil { t.Fatal(err) } if _, err := store.ActivateRuntime(ctx, env.Alias, "runtime-a", bound.BindingVersion, bound.Exit.ID, "network-a"); err != nil { t.Fatalf("runtime heartbeat failed: %v", err) } var renewed bool - if err := store.db.QueryRowContext(ctx, `SELECT lease_until > now() + interval '30 seconds' FROM runtime_instance WHERE id = $1`, active.RuntimeInstanceID).Scan(&renewed); err != nil || !renewed { + if err := store.db.QueryRowContext(ctx, `SELECT runtime_lease_until > now() + interval '30 seconds' FROM browser_env WHERE alias = $1`, env.Alias).Scan(&renewed); err != nil || !renewed { t.Fatalf("runtime lease was not renewed: renewed=%v err=%v", renewed, err) } - if _, err := store.db.ExecContext(ctx, `UPDATE runtime_instance SET lease_until = now() - interval '1 second' WHERE id = $1`, active.RuntimeInstanceID); err != nil { + if _, err := store.db.ExecContext(ctx, `UPDATE browser_env SET runtime_lease_until = now() - interval '1 second' WHERE alias = $1`, env.Alias); err != nil { t.Fatal(err) } expired, err := store.GetEnvironmentContext(ctx, env.Alias) - if err != nil || expired.RuntimeInstanceID != active.RuntimeInstanceID || expired.RuntimeID != active.RuntimeID { + if err != nil || expired.RuntimeID != active.RuntimeID { t.Fatalf("context read discarded expired cleanup generation: %#v err=%v", expired, err) } if _, err := store.db.ExecContext(ctx, `UPDATE social_account SET status = 'paused' WHERE id = 'account-a'`); err != nil { @@ -524,8 +527,8 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) { } assertDatabaseCount(t, store.db, `SELECT count(*) FROM audit_event WHERE event_type = 'runtime_released' AND reason_code = 'runtime_released' AND account_id = 'account-a' AND browser_env_alias = 'environment-a' - AND network_exit_id = $1 AND runtime_instance_id = $2 AND binding_version = $3 AND details = '{}'::jsonb`, - 1, active.Exit.ID, active.RuntimeInstanceID, active.BindingVersion) + AND network_exit_id = $1 AND binding_version = $2 AND details = '{}'::jsonb`, + 1, active.Exit.ID, active.BindingVersion) if _, err := store.db.ExecContext(ctx, `UPDATE social_account SET status = 'active' WHERE id = 'account-a'`); err != nil { t.Fatal(err) } @@ -533,7 +536,7 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) { if err != nil { t.Fatalf("activate runtime to expire: %v", err) } - if _, err := store.db.ExecContext(ctx, `UPDATE runtime_instance SET lease_until = now() - interval '1 second' WHERE id = $1`, expiredBeforeActivation.RuntimeInstanceID); err != nil { + if _, err := store.db.ExecContext(ctx, `UPDATE browser_env SET runtime_lease_until = now() - interval '1 second' WHERE alias = $1`, env.Alias); err != nil { t.Fatal(err) } if _, err := store.ActivateRuntime(ctx, env.Alias, "same-exit-runtime", rebound.BindingVersion, rebound.Exit.ID, "network-same-exit"); err != nil { @@ -541,8 +544,8 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) { } assertDatabaseCount(t, store.db, `SELECT count(*) FROM audit_event WHERE event_type = 'runtime_released' AND reason_code = 'runtime_released' AND account_id = 'account-a' AND browser_env_alias = 'environment-a' - AND network_exit_id = $1 AND runtime_instance_id = $2 AND binding_version = $3 AND details = '{}'::jsonb`, - 1, expiredBeforeActivation.Exit.ID, expiredBeforeActivation.RuntimeInstanceID, expiredBeforeActivation.BindingVersion) + AND network_exit_id = $1 AND binding_version = $2 AND details = '{}'::jsonb`, + 1, expiredBeforeActivation.Exit.ID, expiredBeforeActivation.BindingVersion) if _, err := store.db.ExecContext(ctx, `UPDATE social_account SET status = 'paused' WHERE id = 'account-a'`); err != nil { t.Fatal(err) } @@ -556,10 +559,11 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) { if err := store.ReleaseRuntime(ctx, active); err != nil { t.Fatal(err) } + // 审计已无 runtime_instance_id:同代两次释放(过期替换 + 显式释放)行相同,计 2 assertDatabaseCount(t, store.db, `SELECT count(*) FROM audit_event WHERE event_type = 'runtime_released' AND reason_code = 'runtime_released' AND account_id = 'account-a' AND browser_env_alias = 'environment-a' - AND network_exit_id = $1 AND runtime_instance_id = $2 AND binding_version = $3 AND details = '{}'::jsonb`, - 1, active.Exit.ID, active.RuntimeInstanceID, active.BindingVersion) + AND network_exit_id = $1 AND binding_version = $2 AND details = '{}'::jsonb`, + 2, active.Exit.ID, active.BindingVersion) rebound, err = store.RebindEnvironment(ctx, env.Alias, second.ID, "rebound-runtime", rebound.BindingVersion) if err != nil || rebound.BindingVersion != 3 || rebound.RuntimeID != "rebound-runtime" { t.Fatalf("same-exit rebind must atomically CAS the binding and runtime: %#v err=%v", rebound, err) @@ -568,19 +572,18 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) { t.Fatalf("stale generation release must conflict: %v", err) } current, err := store.GetEnvironmentContext(ctx, env.Alias) - if err != nil || current.RuntimeInstanceID != rebound.RuntimeInstanceID || current.RuntimeID != "rebound-runtime" { + if err != nil || current.RuntimeID != rebound.RuntimeID || current.RuntimeID != "rebound-runtime" { t.Fatalf("stale release changed the current runtime: %#v err=%v", current, err) } cleanup := current cleanup.RuntimeCleanupBindingVersion = current.BindingVersion - cleanup.RuntimeCleanupInstanceID = current.RuntimeInstanceID cleanup.RuntimeCleanupRuntimeID = current.RuntimeID if err := store.SetRuntimeCleanupPending(ctx, cleanup, true); err != nil { t.Fatalf("set generation cleanup pending: %v", err) } assertDatabaseCount(t, store.db, `SELECT count(*) FROM audit_event WHERE event_type = 'runtime_released' - AND browser_env_alias = 'environment-a' AND network_exit_id = $1 AND runtime_instance_id = $2 AND binding_version = $3`, - 1, current.Exit.ID, current.RuntimeInstanceID, current.BindingVersion) + AND browser_env_alias = 'environment-a' AND network_exit_id = $1 AND binding_version = $2`, + 1, current.Exit.ID, current.BindingVersion) wrongCleanup := cleanup wrongCleanup.RuntimeCleanupRuntimeID = "other-runtime" if err := store.SetRuntimeCleanupPending(ctx, wrongCleanup, false); !errors.Is(err, ErrConflict) { @@ -664,3 +667,129 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) { } } } + +func TestRuntimeCleanupFenceAndBoundCreateReadiness(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() + store := openFullyMigratedHub(t, ctx, isolatedDatabaseURL(t, databaseURL)) + t.Cleanup(func() { _ = store.Close() }) + if _, err := store.db.ExecContext(ctx, ` + INSERT INTO social_account (id, credential_provider, credential_key, platform, platform_account_key, authorization_kind, authorization_status) + VALUES ('fence-a', 'os_keyring', 'creatorhub/fence-a', 'mock', 'fence-a', 'owned', 'authorized')`); err != nil { + t.Fatal(err) + } + if _, err := store.CreateGateway(ctx, "gw-fence", "http://127.0.0.1:8081", "unit-test-gateway-token"); err != nil { + t.Fatal(err) + } + exit, err := store.CreateNetworkExit(ctx, NetworkExit{Protocol: "http", Host: "proxy.example", Port: 8080}) + if err != nil { + t.Fatal(err) + } + // CreateBoundEnv 就绪门禁:账号缺失/出口不健康 → ErrNotFound + if _, _, err := store.CreateBoundEnv(ctx, Env{Alias: "fence-x", Name: "甲", Gateway: "gw-fence", Fingerprint: Fingerprint{Seed: 9}}, "fence-missing", ""); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing account must be not-found: %v", err) + } + if _, err := store.db.ExecContext(ctx, `UPDATE network_exit SET health_status = 'unhealthy' WHERE id = $1`, exit.ID); err != nil { + t.Fatal(err) + } + if _, _, err := store.CreateBoundEnv(ctx, Env{Alias: "fence-x", Name: "甲", Gateway: "gw-fence", Fingerprint: Fingerprint{Seed: 9}}, "fence-a", exit.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("unhealthy exit must be not-found: %v", err) + } + if _, err := store.db.ExecContext(ctx, `UPDATE network_exit SET health_status = 'healthy' WHERE id = $1`, exit.ID); err != nil { + t.Fatal(err) + } + bound, created, err := store.CreateBoundEnv(ctx, Env{Alias: "fence-a", Name: "甲", Gateway: "gw-fence", Fingerprint: Fingerprint{Seed: 1}}, "fence-a", exit.ID) + if err != nil || !created { + t.Fatalf("create bound env: created=%v err=%v", created, err) + } + if _, err := store.db.ExecContext(ctx, `UPDATE social_account SET status = 'active' WHERE id = 'fence-a'`); err != nil { + t.Fatal(err) + } + active, err := store.ActivateRuntime(ctx, "fence-a", "fence-runtime-a", bound.BindingVersion, exit.ID, "net-fence") + if err != nil { + t.Fatal(err) + } + // 未知代 fence:允许登记待清理并事务性释放活跃实例 + unknown := active + unknown.RuntimeCleanupBindingVersion, unknown.RuntimeCleanupRuntimeID, unknown.RuntimeCleanupNetworkID = active.BindingVersion, MissingRuntimeID, "" + if err := store.SetRuntimeCleanupPending(ctx, unknown, true); err != nil { + t.Fatalf("unknown-generation fence must be accepted: %v", err) + } + after, err := store.GetEnvironmentContext(ctx, "fence-a") + if err != nil || !after.RuntimeCleanupPending || after.RuntimeCleanupRuntimeID != MissingRuntimeID || after.RuntimeID != "" { + t.Fatalf("unknown-generation fence state: %#v err=%v", after, err) + } + if err := store.SetRuntimeCleanupPending(ctx, after, false); err != nil { + t.Fatalf("clear matching fence: %v", err) + } + // 重新激活后,异代 fence 请求必须冲突 + active2, err := store.ActivateRuntime(ctx, "fence-a", "fence-runtime-b", bound.BindingVersion, exit.ID, "net-fence") + if err != nil { + t.Fatal(err) + } + stale := active2 + stale.RuntimeCleanupBindingVersion, stale.RuntimeCleanupRuntimeID, stale.RuntimeCleanupNetworkID = active2.BindingVersion, "fence-runtime-other", "" + if err := store.SetRuntimeCleanupPending(ctx, stale, true); !errors.Is(err, ErrConflict) { + t.Fatalf("stale fence generation must conflict: %v", err) + } + if _, err := store.GetEnvironmentContext(ctx, "fence-a"); err != nil { + t.Fatal(err) + } + emptyFence := active2 + emptyFence.RuntimeCleanupRuntimeID = "" + if err := store.SetRuntimeCleanupPending(ctx, emptyFence, true); !errors.Is(err, ErrInvalid) { + t.Fatalf("pending without cleanup runtime id must be invalid: %v", err) + } +} + +func TestNetworkExitLifecycleStoreOperations(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() + store := openFullyMigratedHub(t, ctx, isolatedDatabaseURL(t, databaseURL)) + t.Cleanup(func() { _ = store.Close() }) + exit, err := store.CreateNetworkExit(ctx, NetworkExit{Protocol: "http", Host: "lifecycle.example", Port: 8080, Username: "user", Password: "pass"}) + if err != nil { + t.Fatal(err) + } + if _, err := store.db.ExecContext(ctx, `UPDATE network_exit SET health_status = 'unhealthy', last_check_reason = 'exit_ip_drift' WHERE id = $1`, exit.ID); err != nil { + t.Fatal(err) + } + // 配置变更重置健康状态为 unchecked(disabled 保留),last_check_reason 标记配置变更 + updated, err := store.UpdateNetworkExit(ctx, exit.ID, NetworkExit{Protocol: "socks5", Host: "lifecycle-2.example", Port: 1080}) + if err != nil || updated.Host != "lifecycle-2.example" || updated.Protocol != "socks5" || updated.HealthStatus != "unchecked" || updated.LastCheckReason != "exit_configuration_changed" { + t.Fatalf("update network exit must reset health to unchecked: %#v err=%v", updated, err) + } + if _, err := store.UpdateNetworkExit(ctx, exit.ID, NetworkExit{}); !errors.Is(err, ErrInvalid) { + t.Fatalf("invalid exit update must be rejected: %v", err) + } + if _, err := store.UpdateNetworkExit(ctx, "exit-missing-0001", NetworkExit{Protocol: "http", Host: "x.example", Port: 8080}); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing exit update must be not-found: %v", err) + } + if err := store.DeleteNetworkExit(ctx, exit.ID); err != nil { + t.Fatalf("delete unused exit: %v", err) + } + if _, err := store.GetNetworkExit(ctx, exit.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("deleted exit must be gone: %v", err) + } + disabled, err := store.CreateNetworkExit(ctx, NetworkExit{Protocol: "http", Host: "disabled.example", Port: 8080}) + if err != nil { + t.Fatal(err) + } + if _, err := store.db.ExecContext(ctx, `UPDATE network_exit SET health_status = 'disabled' WHERE id = $1`, disabled.ID); err != nil { + t.Fatal(err) + } + reEnabled, err := store.EnableNetworkExit(ctx, disabled.ID) + if err != nil || reEnabled.HealthStatus != "unchecked" { + t.Fatalf("enable disabled exit: %#v err=%v", reEnabled, err) + } + exits, err := store.ListNetworkExits(ctx) + if err != nil || len(exits) != 1 || exits[0].ID != disabled.ID { + t.Fatalf("list exits: exits=%+v err=%v", exits, err) + } +}