From c567080248d1caa1a0ba2595ca3401a1fdd0603a Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 28 Sep 2026 17:14:00 +0800 Subject: [PATCH] =?UTF-8?q?refactor(api):=20=E5=88=A0=E9=99=A4=E8=BF=90?= =?UTF-8?q?=E8=90=A5=E9=93=BE=E8=B7=AF=E4=B8=8E=20runtime=5Fuse=5Flease?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 删除任务链(drafts/confirmations/tasks/attempts)、creator 运营链(策略/事件/操作/会话/消息/监听器/素材/关系)、runtime_use_lease 机制及其 API/UI;taskstate 包与对应测试一并移除。 --- internal/account/deletion.go | 29 - internal/account/store.go | 1369 +---------------- internal/account/store_test.go | 814 +--------- internal/controlplane/api/account_deletion.go | 3 - .../controlplane/api/accounts/register.go | 2 +- .../controlplane/api/accounts_operations.go | 175 +-- .../controlplane/api/app_migrated_test.go | 118 +- internal/controlplane/api/creator.go | 666 +------- internal/controlplane/api/creator/register.go | 4 - internal/controlplane/api/creator_events.go | 607 -------- .../controlplane/api/creator_events_test.go | 190 --- .../api/creator_events_unit_test.go | 101 -- .../controlplane/api/creator_helper_test.go | 53 - .../controlplane/api/creator_history_test.go | 68 - internal/controlplane/api/creator_material.go | 348 ----- .../controlplane/api/creator_material_test.go | 89 -- .../api/creator_pure_unit_test.go | 3 - .../api/creator_route_validation_test.go | 12 +- internal/controlplane/api/creator_updates.go | 39 + internal/controlplane/api/hub_test.go | 133 +- internal/controlplane/api/runtime.go | 102 -- internal/controlplane/api/runtime_use_test.go | 131 -- internal/controlplane/app/app.go | 54 +- internal/controlplane/app/app_test.go | 63 +- internal/controlplane/workers/listeners.go | 13 - internal/creator/accounts.go | 155 -- internal/creator/actions.go | 1299 ---------------- internal/creator/content.go | 268 ---- internal/creator/content_test.go | 17 - internal/creator/coverage_integration_test.go | 68 - internal/creator/coverage_unit_test.go | 66 - internal/creator/deletion.go | 36 - internal/creator/integration_test.go | 180 --- internal/creator/listener.go | 170 -- internal/creator/listener_test.go | 29 - internal/creator/recovery_integration_test.go | 202 --- internal/creator/settings.go | 10 - internal/environment/environment.go | 195 +-- internal/environment/migration_test.go | 128 -- internal/environment/store.go | 37 - internal/environment/store_test.go | 148 +- internal/taskstate/transition.go | 7 - web/src/pages/accounts/$id/edit.tsx | 297 +--- web/src/services/api.ts | 4 - 44 files changed, 135 insertions(+), 8367 deletions(-) delete mode 100644 internal/controlplane/api/creator_events.go delete mode 100644 internal/controlplane/api/creator_events_test.go delete mode 100644 internal/controlplane/api/creator_events_unit_test.go delete mode 100644 internal/controlplane/api/creator_history_test.go delete mode 100644 internal/controlplane/api/creator_material.go delete mode 100644 internal/controlplane/api/creator_material_test.go create mode 100644 internal/controlplane/api/creator_updates.go delete mode 100644 internal/controlplane/api/runtime.go delete mode 100644 internal/controlplane/api/runtime_use_test.go delete mode 100644 internal/controlplane/workers/listeners.go delete mode 100644 internal/creator/actions.go delete mode 100644 internal/creator/listener.go delete mode 100644 internal/creator/listener_test.go delete mode 100644 internal/creator/recovery_integration_test.go delete mode 100644 internal/taskstate/transition.go diff --git a/internal/account/deletion.go b/internal/account/deletion.go index 9f4542b..196c8e4 100644 --- a/internal/account/deletion.go +++ b/internal/account/deletion.go @@ -22,9 +22,6 @@ func (s *Store) CheckAccountDeletion(ctx context.Context, accountID string) erro SELECT EXISTS ( SELECT 1 FROM runtime_instance WHERE account_id = $1 AND released_at IS NULL - UNION ALL - SELECT 1 FROM operation_task - WHERE account_id = $1 AND state = 'executing' )`, accountID).Scan(&active); err != nil { return errors.New("check account deletion state") } @@ -53,9 +50,6 @@ func (s *Store) DeleteAccountData(ctx context.Context, accountID string) error { SELECT EXISTS ( SELECT 1 FROM runtime_instance WHERE account_id = $1 AND released_at IS NULL - UNION ALL - SELECT 1 FROM operation_task - WHERE account_id = $1 AND state = 'executing' )`, accountID).Scan(&active); err != nil { return errors.New("check account deletion state") } @@ -68,35 +62,12 @@ func (s *Store) DeleteAccountData(ctx context.Context, accountID string) error { if _, err := tx.ExecContext(ctx, ` DELETE FROM audit_event WHERE account_id = $1 - OR confirmation_id IN (SELECT id FROM confirmation WHERE account_id = $1) - OR task_id IN (SELECT id FROM operation_task WHERE account_id = $1) - OR attempt_id IN ( - SELECT id FROM execution_attempt - WHERE task_id IN (SELECT id FROM operation_task 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 { return errors.New("delete account audit data") } - if _, err := tx.ExecContext(ctx, `UPDATE operation_task SET current_attempt_id = NULL WHERE account_id = $1`, accountID); err != nil { - return errors.New("detach account task attempts") - } - if _, err := tx.ExecContext(ctx, ` - DELETE FROM execution_attempt - WHERE task_id IN (SELECT id FROM operation_task WHERE account_id = $1)`, accountID); err != nil { - return errors.New("delete account task attempts") - } - if _, err := tx.ExecContext(ctx, `DELETE FROM operation_task WHERE account_id = $1`, accountID); err != nil { - return errors.New("delete account tasks") - } - if _, err := tx.ExecContext(ctx, `DELETE FROM confirmation WHERE account_id = $1`, accountID); err != nil { - return errors.New("delete account confirmations") - } - if _, err := tx.ExecContext(ctx, `DELETE FROM content_draft WHERE account_id = $1`, accountID); err != nil { - return errors.New("delete account drafts") - } if _, err := tx.ExecContext(ctx, `DELETE FROM runtime_instance WHERE account_id = $1`, accountID); err != nil { return errors.New("delete account runtime records") } diff --git a/internal/account/store.go b/internal/account/store.go index 72f26e3..8562297 100644 --- a/internal/account/store.go +++ b/internal/account/store.go @@ -3,7 +3,6 @@ package account import ( "context" "crypto/rand" - "crypto/sha256" "database/sql" _ "embed" "encoding/hex" @@ -16,7 +15,6 @@ import ( "time" "unicode/utf8" - "git.ipao.vip/rogee/creator-hub/internal/taskstate" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" @@ -43,7 +41,6 @@ var ( type Store struct { db *sql.DB - notify taskstate.Notifier accountCommit func(*sql.Tx) error } @@ -76,87 +73,6 @@ type CredentialResolver interface { Resolve(context.Context, CredentialReference, string) ([]byte, error) } -type Draft struct { - ID string `json:"id"` - AccountID string `json:"account_id"` - Version int64 `json:"version"` - Content string `json:"content"` - CreatedAt time.Time `json:"created_at"` -} - -type Confirmation struct { - ID string `json:"id"` - AccountID string `json:"account_id"` - AccountVersion int64 `json:"account_version"` - DraftID string `json:"draft_id"` - DraftVersion int64 `json:"draft_version"` - Version int64 `json:"version"` - ConfirmedAt time.Time `json:"confirmed_at"` -} - -type Task struct { - ID string `json:"id"` - IdempotencyKey string `json:"idempotency_key"` - AccountID string `json:"account_id"` - AccountVersion int64 `json:"account_version"` - DraftID string `json:"draft_id"` - DraftVersion int64 `json:"draft_version"` - ConfirmationID string `json:"confirmation_id"` - ConfirmationVersion int64 `json:"confirmation_version"` - State string `json:"state"` - HoldReason string `json:"hold_reason,omitempty"` - VerificationResult string `json:"verification_result,omitempty"` - VerifiedAt *time.Time `json:"verified_at,omitempty"` - VerifiedBy string `json:"verified_by,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type TaskAttempt struct { - ID string `json:"id"` - TaskID string `json:"task_id,omitempty"` - StartedAt time.Time `json:"started_at"` - FinishedAt *time.Time `json:"finished_at,omitempty"` - Outcome string `json:"outcome,omitempty"` - Evidence map[string]string `json:"evidence"` -} - -type TaskAttemptDetail struct { - TaskAttempt - 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"` -} - -type TaskDetail struct { - Task - Confirmation ConfirmationSnapshot `json:"confirmation"` - Attempts []TaskAttempt `json:"attempts"` - 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"` - AllowedAction string `json:"allowed_action,omitempty"` - ReadinessReason string `json:"readiness_reason,omitempty"` -} - -type ConfirmationSnapshot struct { - Confirmation - 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"` -} - -type DraftDetail struct { - Draft - Account Account `json:"account"` - Versions []Draft `json:"versions"` - Confirmations []ConfirmationSnapshot `json:"confirmations"` - Tasks []Task `json:"tasks"` -} - type ReadinessError struct { Reason string Unavailable bool @@ -164,16 +80,6 @@ type ReadinessError struct { func (e *ReadinessError) Error() string { return e.Reason } -type Execution struct { - TaskID string `json:"task_id"` - AttemptID string `json:"attempt_id"` - AccountID string `json:"account_id"` - ConfirmationID string `json:"confirmation_id"` - ConfirmationVersion int64 `json:"confirmation_version"` - State string `json:"state"` - WasClaimed bool `json:"was_claimed"` -} - type AuditEvent struct { ID int64 `json:"id"` EventType string `json:"event_type"` @@ -232,17 +138,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) SetTaskNotifier(notify taskstate.Notifier) { s.notify = notify } - -func (s *Store) notifyTransitions(transitions []taskstate.Transition) { - if s.notify == nil { - return - } - for _, transition := range transitions { - s.notify(transition) - } -} - func (s *Store) migrate(ctx context.Context) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { @@ -443,631 +338,6 @@ func validAccount(account Account) bool { return true } -func (s *Store) CreateDraft(ctx context.Context, draft Draft) error { - if !refPattern.MatchString(draft.ID) || !idPattern.MatchString(draft.AccountID) || draft.Version < 1 || strings.TrimSpace(draft.Content) == "" { - return ErrInvalid - } - _, err := s.db.ExecContext(ctx, `INSERT INTO content_draft (id, account_id, version, content) VALUES ($1, $2, $3, $4)`, - draft.ID, draft.AccountID, draft.Version, draft.Content) - return publicDatabaseError(err) -} - -func (s *Store) CreateDraftVersion(ctx context.Context, accountID, content string) (Draft, error) { - if !idPattern.MatchString(accountID) || strings.TrimSpace(content) == "" { - return Draft{}, ErrInvalid - } - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return Draft{}, errors.New("begin draft transaction") - } - defer tx.Rollback() - if err := tx.QueryRowContext(ctx, `SELECT id FROM social_account WHERE id = $1 FOR UPDATE`, accountID).Scan(&accountID); err != nil { - return Draft{}, rowError(err) - } - draft := Draft{ID: "draft-" + newID()[:24], AccountID: accountID, Content: content} - if err := tx.QueryRowContext(ctx, ` - INSERT INTO content_draft (id, account_id, version, content) - SELECT $1, $2, COALESCE(max(version), 0) + 1, $3 FROM content_draft WHERE account_id = $2 - RETURNING version, created_at`, draft.ID, accountID, content).Scan(&draft.Version, &draft.CreatedAt); err != nil { - return Draft{}, publicDatabaseError(err) - } - if err := appendAudit(ctx, tx, "draft_created", "draft_created", accountID, "", 0, "", "", map[string]any{ - "draft_id": draft.ID, "draft_version": draft.Version, - }); err != nil { - return Draft{}, err - } - if err := commit(tx); err != nil { - return Draft{}, err - } - return draft, nil -} - -func (s *Store) ListDrafts(ctx context.Context, accountID string) ([]Draft, error) { - if accountID != "" && !idPattern.MatchString(accountID) { - return nil, ErrInvalid - } - rows, err := s.db.QueryContext(ctx, ` - SELECT id, account_id, version, content, created_at FROM content_draft - WHERE $1 = '' OR account_id = $1 ORDER BY account_id, version DESC, created_at DESC`, accountID) - if err != nil { - return nil, errors.New("read drafts") - } - defer rows.Close() - drafts := []Draft{} - for rows.Next() { - draft, err := scanDraft(rows) - if err != nil { - return nil, err - } - drafts = append(drafts, draft) - } - return drafts, rows.Err() -} - -func (s *Store) GetDraft(ctx context.Context, id string) (Draft, error) { - if !refPattern.MatchString(id) { - return Draft{}, ErrInvalid - } - return scanDraft(s.db.QueryRowContext(ctx, ` - SELECT id, account_id, version, content, created_at FROM content_draft WHERE id = $1`, id)) -} - -type draftScanner interface{ Scan(...any) error } - -func scanDraft(row draftScanner) (Draft, error) { - var draft Draft - if err := row.Scan(&draft.ID, &draft.AccountID, &draft.Version, &draft.Content, &draft.CreatedAt); err != nil { - return Draft{}, rowError(err) - } - return draft, nil -} - -func (s *Store) GetDraftDetail(ctx context.Context, id string) (DraftDetail, error) { - draft, err := s.GetDraft(ctx, id) - if err != nil { - return DraftDetail{}, err - } - account, err := s.GetAccount(ctx, draft.AccountID) - if err != nil { - return DraftDetail{}, err - } - versions, err := s.ListDrafts(ctx, draft.AccountID) - if err != nil { - return DraftDetail{}, err - } - confirmations, err := s.ListConfirmations(ctx, draft.ID) - if err != nil { - return DraftDetail{}, err - } - tasks, err := s.ListTasks(ctx, draft.AccountID, draft.ID) - if err != nil { - return DraftDetail{}, err - } - return DraftDetail{Draft: draft, Account: account, Versions: versions, Confirmations: confirmations, Tasks: tasks}, nil -} - -func (s *Store) ConfirmDraft(ctx context.Context, draftID string, accountVersion, draftVersion int64) (Confirmation, bool, error) { - if !refPattern.MatchString(draftID) || accountVersion < 1 || draftVersion < 1 { - return Confirmation{}, false, ErrInvalid - } - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return Confirmation{}, false, errors.New("begin confirmation transaction") - } - defer tx.Rollback() - var accountID string - var currentAccountVersion, currentDraftVersion, latestDraftVersion int64 - if err := tx.QueryRowContext(ctx, ` - SELECT draft.account_id, account.version, draft.version, - (SELECT max(version) FROM content_draft WHERE account_id = draft.account_id) - FROM content_draft draft JOIN social_account account ON account.id = draft.account_id - WHERE draft.id = $1 FOR UPDATE OF draft, account`, draftID). - Scan(&accountID, ¤tAccountVersion, ¤tDraftVersion, &latestDraftVersion); err != nil { - return Confirmation{}, false, rowError(err) - } - if accountVersion != currentAccountVersion { - return Confirmation{}, false, &ReadinessError{Reason: "account_version_changed"} - } - if draftVersion != currentDraftVersion || draftVersion != latestDraftVersion { - return Confirmation{}, false, &ReadinessError{Reason: "draft_version_changed"} - } - var existing Confirmation - err = tx.QueryRowContext(ctx, ` - SELECT id, account_id, account_version, draft_id, draft_version, version, confirmed_at - FROM confirmation WHERE account_id = $1 AND account_version = $2 AND draft_id = $3 AND draft_version = $4 - ORDER BY version DESC LIMIT 1`, accountID, accountVersion, draftID, draftVersion). - Scan(&existing.ID, &existing.AccountID, &existing.AccountVersion, &existing.DraftID, &existing.DraftVersion, &existing.Version, &existing.ConfirmedAt) - if err == nil { - if err := commit(tx); err != nil { - return Confirmation{}, false, err - } - return existing, false, nil - } - if !errors.Is(err, sql.ErrNoRows) { - return Confirmation{}, false, publicDatabaseError(err) - } - confirmation := Confirmation{ - ID: "confirmation-" + newID()[:20], AccountID: accountID, AccountVersion: accountVersion, - DraftID: draftID, DraftVersion: draftVersion, - } - if err := tx.QueryRowContext(ctx, ` - INSERT INTO confirmation (id, account_id, account_version, draft_id, draft_version, version) - SELECT $1, $2, $3, $4, $5, COALESCE(max(version), 0) + 1 FROM confirmation WHERE draft_id = $4 - RETURNING version, confirmed_at`, confirmation.ID, accountID, accountVersion, draftID, draftVersion). - Scan(&confirmation.Version, &confirmation.ConfirmedAt); err != nil { - return Confirmation{}, false, publicDatabaseError(err) - } - if err := appendAudit(ctx, tx, "draft_confirmed", "draft_confirmed", accountID, confirmation.ID, confirmation.Version, "", "", map[string]any{ - "account_version": accountVersion, "draft_id": draftID, "draft_version": draftVersion, - }); err != nil { - return Confirmation{}, false, err - } - if err := commit(tx); err != nil { - return Confirmation{}, false, err - } - return confirmation, true, nil -} - -func (s *Store) Confirm(ctx context.Context, confirmation Confirmation) error { - if !refPattern.MatchString(confirmation.ID) || !idPattern.MatchString(confirmation.AccountID) || - !refPattern.MatchString(confirmation.DraftID) || confirmation.AccountVersion < 1 || confirmation.DraftVersion < 1 || confirmation.Version < 1 { - return ErrInvalid - } - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return errors.New("begin confirmation transaction") - } - defer tx.Rollback() - var valid bool - if err := tx.QueryRowContext(ctx, ` - SELECT EXISTS ( - SELECT 1 FROM social_account a JOIN content_draft d ON d.account_id = a.id - WHERE a.id = $1 AND a.version = $2 AND d.id = $3 AND d.version = $4 - )`, confirmation.AccountID, confirmation.AccountVersion, confirmation.DraftID, confirmation.DraftVersion).Scan(&valid); err != nil { - return errors.New("validate confirmation versions") - } - if !valid { - return ErrConflict - } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO confirmation (id, account_id, account_version, draft_id, draft_version, version) - VALUES ($1, $2, $3, $4, $5, $6)`, confirmation.ID, confirmation.AccountID, confirmation.AccountVersion, - confirmation.DraftID, confirmation.DraftVersion, confirmation.Version); err != nil { - return publicDatabaseError(err) - } - if err := appendAudit(ctx, tx, "draft_confirmed", "draft_confirmed", confirmation.AccountID, confirmation.ID, confirmation.Version, "", "", map[string]any{ - "account_version": confirmation.AccountVersion, "draft_id": confirmation.DraftID, "draft_version": confirmation.DraftVersion, - }); err != nil { - return err - } - return commit(tx) -} - -func (s *Store) ListConfirmations(ctx context.Context, draftID string) ([]ConfirmationSnapshot, error) { - if draftID != "" && !refPattern.MatchString(draftID) { - return nil, ErrInvalid - } - rows, err := s.db.QueryContext(ctx, ` - SELECT confirmation.id, confirmation.account_id, confirmation.account_version, confirmation.draft_id, - confirmation.draft_version, confirmation.version, confirmation.confirmed_at, - audit.browser_env_alias, audit.network_exit_id, audit.runtime_instance_id, audit.binding_version - FROM confirmation - LEFT JOIN LATERAL ( - SELECT browser_env_alias, network_exit_id, runtime_instance_id, binding_version - FROM audit_event WHERE confirmation_id = confirmation.id AND event_type = 'draft_confirmed' - ORDER BY id DESC LIMIT 1 - ) audit ON true - WHERE $1 = '' OR confirmation.draft_id = $1 - ORDER BY confirmation.confirmed_at DESC, confirmation.version DESC`, draftID) - if err != nil { - return nil, errors.New("read confirmations") - } - defer rows.Close() - confirmations := []ConfirmationSnapshot{} - for rows.Next() { - confirmation, err := scanConfirmation(rows) - if err != nil { - return nil, err - } - confirmations = append(confirmations, confirmation) - } - return confirmations, rows.Err() -} - -func (s *Store) GetConfirmation(ctx context.Context, id string) (ConfirmationSnapshot, error) { - if !refPattern.MatchString(id) { - return ConfirmationSnapshot{}, ErrInvalid - } - return scanConfirmation(s.db.QueryRowContext(ctx, ` - SELECT confirmation.id, confirmation.account_id, confirmation.account_version, confirmation.draft_id, - confirmation.draft_version, confirmation.version, confirmation.confirmed_at, - audit.browser_env_alias, audit.network_exit_id, audit.runtime_instance_id, audit.binding_version - FROM confirmation - LEFT JOIN LATERAL ( - SELECT browser_env_alias, network_exit_id, runtime_instance_id, binding_version - FROM audit_event WHERE confirmation_id = confirmation.id AND event_type = 'draft_confirmed' - ORDER BY id DESC LIMIT 1 - ) audit ON true WHERE confirmation.id = $1`, id)) -} - -type confirmationScanner interface{ Scan(...any) error } - -func scanConfirmation(row confirmationScanner) (ConfirmationSnapshot, error) { - var confirmation ConfirmationSnapshot - var browser, network, runtime sql.NullString - var bindingVersion sql.NullInt64 - if err := row.Scan(&confirmation.ID, &confirmation.AccountID, &confirmation.AccountVersion, &confirmation.DraftID, - &confirmation.DraftVersion, &confirmation.Version, &confirmation.ConfirmedAt, - &browser, &network, &runtime, &bindingVersion); err != nil { - return ConfirmationSnapshot{}, rowError(err) - } - confirmation.BrowserEnvAlias, confirmation.NetworkExitID = browser.String, network.String - confirmation.RuntimeInstanceID, confirmation.BindingVersion = runtime.String, bindingVersion.Int64 - return confirmation, nil -} - -func (s *Store) ListTasks(ctx context.Context, accountID, draftID string) ([]Task, error) { - return s.ListTasksFiltered(ctx, accountID, draftID, "") -} - -func (s *Store) ListTasksFiltered(ctx context.Context, accountID, draftID, state string) ([]Task, error) { - if (accountID != "" && !idPattern.MatchString(accountID)) || (draftID != "" && !refPattern.MatchString(draftID)) || - (state != "" && state != "queued" && state != "executing" && state != "succeeded" && state != "failed" && - state != "needs_confirmation" && state != "policy_hold" && state != "cancelled") { - return nil, ErrInvalid - } - rows, err := s.db.QueryContext(ctx, ` - SELECT id, idempotency_key, account_id, account_version, draft_id, draft_version, - confirmation_id, confirmation_version, state, hold_reason, verification_result, verified_at, verified_by, - created_at, updated_at - FROM operation_task WHERE ($1 = '' OR account_id = $1) AND ($2 = '' OR draft_id = $2) - AND ($3 = '' OR state = $3) - ORDER BY created_at DESC, id`, accountID, draftID, state) - if err != nil { - return nil, errors.New("read tasks") - } - defer rows.Close() - tasks := []Task{} - for rows.Next() { - task, err := scanTask(rows) - if err != nil { - return nil, err - } - tasks = append(tasks, task) - } - return tasks, rows.Err() -} - -func (s *Store) GetTask(ctx context.Context, id string) (Task, error) { - if !refPattern.MatchString(id) { - return Task{}, ErrInvalid - } - return scanTask(s.db.QueryRowContext(ctx, ` - SELECT id, idempotency_key, account_id, account_version, draft_id, draft_version, - confirmation_id, confirmation_version, state, hold_reason, verification_result, verified_at, verified_by, - created_at, updated_at - FROM operation_task WHERE id = $1`, id)) -} - -type taskScanner interface{ Scan(...any) error } - -func scanTask(row taskScanner) (Task, error) { - var task Task - var confirmationID, holdReason, verificationResult, verifiedBy sql.NullString - var confirmationVersion sql.NullInt64 - var verifiedAt sql.NullTime - if err := row.Scan(&task.ID, &task.IdempotencyKey, &task.AccountID, &task.AccountVersion, &task.DraftID, - &task.DraftVersion, &confirmationID, &confirmationVersion, &task.State, &holdReason, &verificationResult, - &verifiedAt, &verifiedBy, &task.CreatedAt, &task.UpdatedAt); err != nil { - return Task{}, rowError(err) - } - task.ConfirmationID, task.ConfirmationVersion = confirmationID.String, confirmationVersion.Int64 - task.HoldReason, task.VerificationResult, task.VerifiedBy = holdReason.String, verificationResult.String, verifiedBy.String - if verifiedAt.Valid { - task.VerifiedAt = &verifiedAt.Time - } - return task, nil -} - -func (s *Store) GetTaskDetail(ctx context.Context, id string) (TaskDetail, error) { - task, err := s.GetTask(ctx, id) - if err != nil { - return TaskDetail{}, err - } - var confirmation ConfirmationSnapshot - if task.ConfirmationID != "" { - confirmation, err = s.GetConfirmation(ctx, task.ConfirmationID) - if err != nil { - return TaskDetail{}, err - } - } - rows, err := s.db.QueryContext(ctx, ` - SELECT id, started_at, finished_at, outcome, result - FROM execution_attempt WHERE task_id = $1 ORDER BY started_at, id`, id) - if err != nil { - return TaskDetail{}, errors.New("read task attempts") - } - defer rows.Close() - attempts := []TaskAttempt{} - for rows.Next() { - var attempt TaskAttempt - var finishedAt sql.NullTime - var outcome sql.NullString - var result json.RawMessage - if err := rows.Scan(&attempt.ID, &attempt.StartedAt, &finishedAt, &outcome, &result); err != nil { - return TaskDetail{}, errors.New("decode task attempt") - } - if finishedAt.Valid { - attempt.FinishedAt = &finishedAt.Time - } - attempt.Outcome, attempt.Evidence = outcome.String, safeEvidence(result) - attempts = append(attempts, attempt) - } - if err := rows.Err(); err != nil { - return TaskDetail{}, errors.New("read task attempts") - } - detail := TaskDetail{Task: task, Confirmation: confirmation, Attempts: attempts, AllowedAction: taskAllowedAction(task)} - if task.VerificationResult == "not_executed" { - reason, _, err := taskReadinessReason(ctx, s.db, task.ID) - if err != nil { - return TaskDetail{}, err - } - detail.ReadinessReason = reason - if reason != "" { - detail.AllowedAction = "" - switch reason { - case "account_version_changed", "draft_version_changed", "confirmation_version_changed", "binding_version_changed": - detail.AllowedAction = "reconfirm" - case "confirmation_missing": - if task.ConfirmationID != "" { - detail.AllowedAction = "reconfirm" - } - } - } - } - var browser, network, runtime sql.NullString - var bindingVersion sql.NullInt64 - err = s.db.QueryRowContext(ctx, ` - SELECT browser_env_alias, network_exit_id, runtime_instance_id, binding_version - FROM audit_event WHERE task_id = $1 AND event_type = 'task_claimed' ORDER BY id DESC LIMIT 1`, id). - Scan(&browser, &network, &runtime, &bindingVersion) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return TaskDetail{}, errors.New("read task correlation") - } - detail.BrowserEnvAlias, detail.NetworkExitID = browser.String, network.String - detail.RuntimeInstanceID, detail.BindingVersion = runtime.String, bindingVersion.Int64 - return detail, nil -} - -func (s *Store) GetTaskAttemptDetail(ctx context.Context, id string) (TaskAttemptDetail, error) { - if !refPattern.MatchString(id) { - return TaskAttemptDetail{}, ErrInvalid - } - var detail TaskAttemptDetail - var finishedAt sql.NullTime - var outcome, browser, network, runtime sql.NullString - var bindingVersion sql.NullInt64 - var result json.RawMessage - err := s.db.QueryRowContext(ctx, ` - SELECT attempt.id, attempt.task_id, attempt.started_at, attempt.finished_at, attempt.outcome, attempt.result, - audit.browser_env_alias, audit.network_exit_id, audit.runtime_instance_id, audit.binding_version - FROM execution_attempt attempt - LEFT JOIN LATERAL ( - SELECT browser_env_alias, network_exit_id, runtime_instance_id, binding_version - FROM audit_event WHERE attempt_id = attempt.id AND event_type = 'task_claimed' ORDER BY id DESC LIMIT 1 - ) audit ON true - WHERE attempt.id = $1`, id).Scan(&detail.ID, &detail.TaskID, &detail.StartedAt, &finishedAt, &outcome, &result, - &browser, &network, &runtime, &bindingVersion) - if err != nil { - return TaskAttemptDetail{}, rowError(err) - } - if finishedAt.Valid { - detail.FinishedAt = &finishedAt.Time - } - detail.Outcome, detail.Evidence = outcome.String, safeEvidence(result) - detail.BrowserEnvAlias, detail.NetworkExitID = browser.String, network.String - detail.RuntimeInstanceID, detail.BindingVersion = runtime.String, bindingVersion.Int64 - return detail, nil -} - -func safeEvidence(raw json.RawMessage) map[string]string { - var values map[string]any - if json.Unmarshal(raw, &values) != nil { - return map[string]string{} - } - evidence := map[string]string{} - if outcome, ok := values["mock_outcome"].(string); ok { - evidence["mock_outcome"] = outcome - } - return evidence -} - -func taskAllowedAction(task Task) string { - if task.State != "policy_hold" && task.State != "needs_confirmation" { - return "" - } - if task.VerificationResult == "not_executed" { - return "resume" - } - if task.VerificationResult == "succeeded" || task.VerificationResult == "failed" { - return "finish" - } - switch task.HoldReason { - case "confirmation_missing": - if task.ConfirmationID == "" { - return "" - } - return "reconfirm" - case "account_version_changed", "draft_version_changed", "confirmation_version_changed", "binding_version_changed": - return "reconfirm" - case "account_paused", "account_revoked", "binding_missing", "environment_missing", "exit_missing", "exit_unhealthy", - "runtime_stop_pending", "runtime_missing", "runtime_lease_expired", "execution_lease_expired", "task_result_uncertain", "task_policy_hold": - return "verify" - default: - return "" - } -} - -func (s *Store) EnqueueConfirmation(ctx context.Context, confirmationID string) (Task, bool, error) { - if !refPattern.MatchString(confirmationID) { - return Task{}, false, ErrInvalid - } - sum := sha256.Sum256([]byte(confirmationID)) - idempotencyKey := "enqueue-" + hex.EncodeToString(sum[:]) - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return Task{}, false, errors.New("begin confirmed enqueue transaction") - } - defer tx.Rollback() - if existing, err := scanTask(tx.QueryRowContext(ctx, ` - SELECT id, idempotency_key, account_id, account_version, draft_id, draft_version, - confirmation_id, confirmation_version, state, hold_reason, verification_result, verified_at, verified_by, - created_at, updated_at - FROM operation_task WHERE idempotency_key = $1`, idempotencyKey)); err == nil { - if err := commit(tx); err != nil { - return Task{}, false, err - } - return existing, false, nil - } else if !errors.Is(err, ErrNotFound) { - return Task{}, false, err - } - - var task Task - var currentAccountVersion, currentDraftVersion, latestDraftVersion int64 - var authorizationStatus, accountStatus string - if err := tx.QueryRowContext(ctx, ` - SELECT confirmation.account_id, confirmation.account_version, confirmation.draft_id, - confirmation.draft_version, confirmation.version, account.version, account.authorization_status, - account.status, draft.version, (SELECT max(version) FROM content_draft WHERE account_id = confirmation.account_id) - FROM confirmation - JOIN social_account account ON account.id = confirmation.account_id - JOIN content_draft draft ON draft.id = confirmation.draft_id - WHERE confirmation.id = $1 FOR UPDATE OF confirmation, account, draft`, confirmationID). - Scan(&task.AccountID, &task.AccountVersion, &task.DraftID, &task.DraftVersion, &task.ConfirmationVersion, - ¤tAccountVersion, &authorizationStatus, &accountStatus, ¤tDraftVersion, &latestDraftVersion); err != nil { - return Task{}, false, rowError(err) - } - task.ConfirmationID, task.IdempotencyKey, task.ID = confirmationID, idempotencyKey, "task-"+newID()[:24] - if task.AccountVersion != currentAccountVersion { - return Task{}, false, &ReadinessError{Reason: "account_version_changed"} - } - if task.DraftVersion != currentDraftVersion || task.DraftVersion != latestDraftVersion { - return Task{}, false, &ReadinessError{Reason: "draft_version_changed"} - } - if authorizationStatus != "authorized" { - return Task{}, false, &ReadinessError{Reason: "account_revoked"} - } - if accountStatus != "active" { - return Task{}, false, &ReadinessError{Reason: "account_paused"} - } - var bindingID string - var networkExitID sql.NullString - var bindingVersion int64 - var cleanupPending bool - if err := tx.QueryRowContext(ctx, ` - SELECT id, network_exit_id, version, runtime_cleanup_pending - FROM environment_binding WHERE account_id = $1 FOR SHARE`, task.AccountID). - Scan(&bindingID, &networkExitID, &bindingVersion, &cleanupPending); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return Task{}, false, &ReadinessError{Reason: "binding_missing", Unavailable: true} - } - return Task{}, false, publicDatabaseError(err) - } - if networkExitID.Valid { - var exitStatus string - if err := tx.QueryRowContext(ctx, `SELECT health_status FROM network_exit WHERE id = $1 FOR SHARE`, networkExitID.String).Scan(&exitStatus); err != nil { - return Task{}, false, publicDatabaseError(err) - } - if exitStatus != "healthy" { - return Task{}, false, &ReadinessError{Reason: "network_exit_unhealthy", Unavailable: true} - } - } - if cleanupPending { - return Task{}, false, &ReadinessError{Reason: "runtime_stop_pending", Unavailable: true} - } - var runtimeID string - var runtimeBindingVersion int64 - var leaseActive bool - if err := tx.QueryRowContext(ctx, ` - SELECT id, binding_version, lease_until > now() FROM runtime_instance - WHERE binding_id = $1 AND released_at IS NULL ORDER BY acquired_at DESC LIMIT 1 FOR SHARE`, bindingID). - Scan(&runtimeID, &runtimeBindingVersion, &leaseActive); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return Task{}, false, &ReadinessError{Reason: "runtime_missing", Unavailable: true} - } - return Task{}, false, publicDatabaseError(err) - } - if !leaseActive { - return Task{}, false, &ReadinessError{Reason: "runtime_lease_expired", Unavailable: true} - } - if runtimeBindingVersion != bindingVersion { - return Task{}, false, &ReadinessError{Reason: "binding_version_changed"} - } - created, inserted, err := enqueueTask(ctx, tx, task) - if err != nil { - return Task{}, false, err - } - if err := commit(tx); err != nil { - return Task{}, false, err - } - return created, inserted, nil -} - -func (s *Store) Enqueue(ctx context.Context, task Task) (Task, bool, error) { - if !refPattern.MatchString(task.ID) || !refPattern.MatchString(task.IdempotencyKey) || !idPattern.MatchString(task.AccountID) || - !refPattern.MatchString(task.DraftID) || task.AccountVersion < 1 || task.DraftVersion < 1 || - ((task.ConfirmationID == "") != (task.ConfirmationVersion == 0)) || - (task.ConfirmationID != "" && (!refPattern.MatchString(task.ConfirmationID) || task.ConfirmationVersion < 1)) { - return Task{}, false, ErrInvalid - } - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return Task{}, false, errors.New("begin task transaction") - } - defer tx.Rollback() - created, inserted, err := enqueueTask(ctx, tx, task) - if err != nil { - return Task{}, false, err - } - if err := commit(tx); err != nil { - return Task{}, false, err - } - return created, inserted, nil -} - -func enqueueTask(ctx context.Context, tx *sql.Tx, task Task) (Task, bool, error) { - var insertedID string - err := tx.QueryRowContext(ctx, ` - INSERT INTO operation_task (id, idempotency_key, account_id, account_version, draft_id, draft_version, confirmation_id, confirmation_version) - VALUES ($1, $2, $3, $4, $5, $6, NULLIF($7, ''), NULLIF($8, 0)) - ON CONFLICT (idempotency_key) DO NOTHING RETURNING id, created_at`, task.ID, task.IdempotencyKey, task.AccountID, task.AccountVersion, - task.DraftID, task.DraftVersion, task.ConfirmationID, task.ConfirmationVersion).Scan(&insertedID, &task.CreatedAt) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return Task{}, false, publicDatabaseError(err) - } - if insertedID != "" { - task.State = "queued" - task.UpdatedAt = task.CreatedAt - if err := appendAudit(ctx, tx, "task_queued", "task_queued", task.AccountID, task.ConfirmationID, task.ConfirmationVersion, "", task.ID, nil); err != nil { - return Task{}, false, err - } - return task, true, nil - } - existing, err := scanTask(tx.QueryRowContext(ctx, ` - SELECT id, idempotency_key, account_id, account_version, draft_id, draft_version, - confirmation_id, confirmation_version, state, hold_reason, verification_result, verified_at, verified_by, - created_at, updated_at - FROM operation_task WHERE idempotency_key = $1`, task.IdempotencyKey)) - if err != nil { - return Task{}, false, err - } - if existing.AccountID != task.AccountID || existing.AccountVersion != task.AccountVersion || existing.DraftID != task.DraftID || - existing.DraftVersion != task.DraftVersion || existing.ConfirmationID != task.ConfirmationID || existing.ConfirmationVersion != task.ConfirmationVersion { - return Task{}, false, ErrConflict - } - return existing, false, nil -} - func (s *Store) PauseAccount(ctx context.Context, accountID string) error { return s.disableAccount(ctx, accountID, false) } @@ -1106,26 +376,18 @@ func (s *Store) disableAccount(ctx context.Context, accountID string, revoke boo if revoke { reason = "account_revoked" } - held, err := holdQueuedTasks(ctx, tx, accountID, reason) - if err != nil { - return err - } - interrupted, err := interruptExecutingTasks(ctx, tx, accountID, reason) - if err != nil { - return err - } if !unchanged { eventType := "account_paused" if revoke { eventType = "account_revoked" } if err := appendAudit(ctx, tx, eventType, reason, accountID, "", 0, "", "", map[string]any{ - "account_version": version, "tasks_held": len(held), "attempts_interrupted": len(interrupted), + "account_version": version, }); err != nil { return err } } - return s.commitTransitions(tx, append(held, interrupted...)) + return commit(tx) } func (s *Store) ResumeAccount(ctx context.Context, accountID string) error { @@ -1177,625 +439,6 @@ func (s *Store) ResumeAccount(ctx context.Context, accountID string) error { return commit(tx) } -func holdQueuedTasks(ctx context.Context, tx *sql.Tx, accountID, reason string) ([]taskstate.Transition, error) { - rows, err := tx.QueryContext(ctx, ` - UPDATE operation_task SET state = 'policy_hold', hold_reason = $2, - verification_result = NULL, verified_at = NULL, verified_by = NULL, updated_at = now() - WHERE account_id = $1 AND state = 'queued' - RETURNING state, hold_reason, account_id, id`, accountID, reason) - if err != nil { - return nil, errors.New("hold queued account tasks") - } - return scanTransitions(rows, "read held account tasks") -} - -func interruptExecutingTasks(ctx context.Context, tx *sql.Tx, accountID, reason string) ([]taskstate.Transition, error) { - rows, err := tx.QueryContext(ctx, ` - UPDATE operation_task SET state = 'needs_confirmation', hold_reason = $2, - verification_result = NULL, verified_at = NULL, verified_by = NULL, - lease_owner = NULL, lease_until = NULL, updated_at = now() - WHERE account_id = $1 AND state = 'executing' - RETURNING id, current_attempt_id, confirmation_id, confirmation_version`, accountID, reason) - if err != nil { - return nil, errors.New("interrupt executing account tasks") - } - type interruptedTask struct { - taskID, attemptID, confirmationID string - confirmationVersion int64 - } - var tasks []interruptedTask - for rows.Next() { - var task interruptedTask - var attemptID, confirmationID sql.NullString - var confirmationVersion sql.NullInt64 - if err := rows.Scan(&task.taskID, &attemptID, &confirmationID, &confirmationVersion); err != nil { - rows.Close() - return nil, errors.New("read interrupted account task") - } - task.attemptID, task.confirmationID, task.confirmationVersion = attemptID.String, confirmationID.String, confirmationVersion.Int64 - tasks = append(tasks, task) - } - if err := rows.Close(); err != nil { - return nil, errors.New("close interrupted account task rows") - } - transitions := make([]taskstate.Transition, 0, len(tasks)) - for _, task := range tasks { - if task.attemptID != "" { - if _, err := tx.ExecContext(ctx, ` - UPDATE execution_attempt SET finished_at = now(), outcome = 'uncertain' - WHERE id = $1 AND finished_at IS NULL`, task.attemptID); err != nil { - return nil, errors.New("mark interrupted attempt uncertain") - } - } - if err := appendAudit(ctx, tx, "task_interrupted", reason, accountID, task.confirmationID, - task.confirmationVersion, task.attemptID, task.taskID, nil); err != nil { - return nil, err - } - transitions = append(transitions, taskstate.Transition{ - State: "needs_confirmation", ReasonCode: reason, AccountID: accountID, TaskID: task.taskID, - }) - } - return transitions, nil -} - -func (s *Store) VerifyTask(ctx context.Context, taskID, result string) error { - if !refPattern.MatchString(taskID) || (result != "not_executed" && result != "succeeded" && result != "failed") { - return ErrInvalid - } - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return errors.New("begin task verification transaction") - } - defer tx.Rollback() - task, err := scanTask(tx.QueryRowContext(ctx, ` - SELECT id, idempotency_key, account_id, account_version, draft_id, draft_version, - confirmation_id, confirmation_version, state, hold_reason, verification_result, verified_at, verified_by, - created_at, updated_at - FROM operation_task WHERE id = $1 FOR UPDATE`, taskID)) - if err != nil { - return err - } - if task.State != "policy_hold" && task.State != "needs_confirmation" { - return ErrConflict - } - if task.VerificationResult != "" { - return ErrConflict - } - switch task.HoldReason { - case "account_paused", "account_revoked", "binding_missing", "environment_missing", "exit_missing", "exit_unhealthy", - "runtime_stop_pending", "runtime_missing", "runtime_lease_expired", "execution_lease_expired", "task_result_uncertain", "task_policy_hold": - default: - return ErrConflict - } - if _, err := tx.ExecContext(ctx, ` - UPDATE operation_task SET verification_result = $2, verified_at = now(), verified_by = 'local-user', updated_at = now() - WHERE id = $1`, taskID, result); err != nil { - return errors.New("record task verification") - } - if err := appendAudit(ctx, tx, "task_verified", "manual_verification_recorded", task.AccountID, task.ConfirmationID, - task.ConfirmationVersion, "", task.ID, map[string]string{"verification_result": result}); err != nil { - return err - } - return commit(tx) -} - -func (s *Store) ResumeTask(ctx context.Context, taskID string) error { - if !refPattern.MatchString(taskID) { - return ErrInvalid - } - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return errors.New("begin task resume transaction") - } - defer tx.Rollback() - var accountID, confirmationID string - var confirmationVersion int64 - err = tx.QueryRowContext(ctx, ` - SELECT task.account_id, task.confirmation_id, task.confirmation_version - FROM operation_task task - JOIN social_account account ON account.id = task.account_id - JOIN content_draft draft ON draft.id = task.draft_id - JOIN confirmation confirmation ON confirmation.id = task.confirmation_id - JOIN environment_binding binding ON binding.account_id = task.account_id - JOIN browser_env environment ON environment.alias = binding.browser_env_alias - LEFT JOIN network_exit network ON network.id = binding.network_exit_id - JOIN runtime_instance runtime ON runtime.binding_id = binding.id AND runtime.released_at IS NULL - WHERE task.id = $1 AND task.state IN ('policy_hold', 'needs_confirmation') - AND task.verification_result = 'not_executed' - AND account.status = 'active' AND account.authorization_status = 'authorized' - AND account.version = task.account_version - AND draft.account_id = task.account_id AND draft.version = task.draft_version - AND confirmation.account_id = task.account_id AND confirmation.account_version = task.account_version - AND confirmation.draft_id = task.draft_id AND confirmation.draft_version = task.draft_version - AND confirmation.version = task.confirmation_version - AND (binding.network_exit_id IS NULL OR network.health_status = 'healthy') AND NOT binding.runtime_cleanup_pending - AND runtime.binding_version = binding.version AND runtime.lease_until > now() - FOR UPDATE OF task, account, draft, confirmation, binding, environment, runtime`, taskID). - Scan(&accountID, &confirmationID, &confirmationVersion) - if errors.Is(err, sql.ErrNoRows) { - task, taskErr := scanTask(tx.QueryRowContext(ctx, ` - SELECT id, idempotency_key, account_id, account_version, draft_id, draft_version, - confirmation_id, confirmation_version, state, hold_reason, verification_result, verified_at, verified_by, - created_at, updated_at - FROM operation_task WHERE id = $1`, taskID)) - if taskErr != nil { - return taskErr - } - if task.VerificationResult != "not_executed" || (task.State != "policy_hold" && task.State != "needs_confirmation") { - return ErrConflict - } - reason, unavailable, reasonErr := taskReadinessReason(ctx, tx, taskID) - if reasonErr != nil { - return reasonErr - } - if reason == "" { - return ErrConflict - } - return &ReadinessError{Reason: reason, Unavailable: unavailable} - } - if err != nil { - return errors.New("validate task resume readiness") - } - if _, err := tx.ExecContext(ctx, ` - UPDATE operation_task SET state = 'queued', hold_reason = NULL, - verification_result = NULL, verified_at = NULL, verified_by = NULL, - lease_owner = NULL, lease_until = NULL, updated_at = now() - WHERE id = $1`, taskID); err != nil { - return errors.New("resume task") - } - if err := appendAudit(ctx, tx, "task_resumed", "manual_verification_not_executed", accountID, confirmationID, - confirmationVersion, "", taskID, nil); err != nil { - return err - } - return commit(tx) -} - -type rowQuerier interface { - QueryRowContext(context.Context, string, ...any) *sql.Row -} - -func taskReadinessReason(ctx context.Context, queryer rowQuerier, taskID string) (string, bool, error) { - var reason string - err := queryer.QueryRowContext(ctx, ` - SELECT CASE - WHEN account.id IS NULL THEN 'account_missing' - WHEN account.authorization_status = 'revoked' THEN 'account_revoked' - WHEN account.status <> 'active' THEN 'account_paused' - WHEN account.version <> task.account_version THEN 'account_version_changed' - WHEN draft.id IS NULL OR draft.account_id <> task.account_id OR draft.version <> task.draft_version THEN 'draft_version_changed' - WHEN confirmation.id IS NULL THEN 'confirmation_missing' - WHEN confirmation.account_id <> task.account_id OR confirmation.account_version <> task.account_version - OR confirmation.draft_id <> task.draft_id OR confirmation.draft_version <> task.draft_version - OR confirmation.version <> task.confirmation_version THEN 'confirmation_version_changed' - WHEN binding.id IS NULL THEN 'binding_missing' - WHEN environment.alias IS NULL THEN 'environment_missing' - WHEN binding.network_exit_id IS NOT NULL AND network.id IS NULL THEN 'exit_missing' - WHEN binding.network_exit_id IS NOT NULL AND network.health_status <> 'healthy' THEN 'exit_unhealthy' - WHEN binding.runtime_cleanup_pending THEN 'runtime_stop_pending' - WHEN runtime.id IS NULL THEN 'runtime_missing' - WHEN runtime.binding_version IS DISTINCT FROM binding.version THEN 'binding_version_changed' - WHEN runtime.lease_until <= now() THEN 'runtime_lease_expired' - ELSE '' - END - FROM operation_task task - LEFT JOIN social_account account ON account.id = task.account_id - LEFT JOIN content_draft draft ON draft.id = task.draft_id - LEFT JOIN confirmation confirmation ON confirmation.id = task.confirmation_id - LEFT JOIN environment_binding binding ON binding.account_id = task.account_id - LEFT JOIN browser_env environment ON environment.alias = binding.browser_env_alias - 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 - WHERE task.id = $1`, taskID).Scan(&reason) - if err != nil { - return "", false, rowError(err) - } - unavailable := reason == "binding_missing" || reason == "environment_missing" || reason == "exit_missing" || - reason == "exit_unhealthy" || reason == "runtime_stop_pending" || reason == "runtime_missing" || reason == "runtime_lease_expired" - return reason, unavailable, nil -} - -func (s *Store) FinishTask(ctx context.Context, taskID string) error { - if !refPattern.MatchString(taskID) { - return ErrInvalid - } - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return errors.New("begin task finish transaction") - } - defer tx.Rollback() - var accountID, result string - var confirmationID sql.NullString - var confirmationVersion sql.NullInt64 - err = tx.QueryRowContext(ctx, ` - UPDATE operation_task SET state = verification_result, hold_reason = NULL, - lease_owner = NULL, lease_until = NULL, updated_at = now() - WHERE id = $1 AND state IN ('policy_hold', 'needs_confirmation') - AND verification_result IN ('succeeded', 'failed') - RETURNING account_id, confirmation_id, confirmation_version, verification_result`, taskID). - Scan(&accountID, &confirmationID, &confirmationVersion, &result) - if err != nil { - return rowError(err) - } - if err := appendAudit(ctx, tx, "task_manually_finished", "manual_verification_"+result, accountID, confirmationID.String, - confirmationVersion.Int64, "", taskID, map[string]string{"state": result}); err != nil { - return err - } - return commit(tx) -} - -func (s *Store) CancelTask(ctx context.Context, taskID string) error { - if !refPattern.MatchString(taskID) { - return ErrInvalid - } - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return errors.New("begin cancel transaction") - } - defer tx.Rollback() - var accountID, state, holdReason string - var attemptID sql.NullString - var confirmationID sql.NullString - var confirmationVersion sql.NullInt64 - err = tx.QueryRowContext(ctx, ` - UPDATE operation_task SET state = CASE WHEN state = 'executing' THEN 'needs_confirmation' ELSE 'cancelled' END, - hold_reason = CASE WHEN state = 'executing' THEN 'task_result_uncertain' END, - verification_result = NULL, verified_at = NULL, verified_by = NULL, - lease_owner = NULL, lease_until = NULL, updated_at = now() - WHERE id = $1 AND state IN ('queued', 'executing', 'needs_confirmation', 'policy_hold') - RETURNING account_id, state, COALESCE(hold_reason, ''), current_attempt_id, confirmation_id, confirmation_version`, taskID).Scan( - &accountID, &state, &holdReason, &attemptID, &confirmationID, &confirmationVersion) - if err != nil { - return rowError(err) - } - if attemptID.Valid { - if _, err := tx.ExecContext(ctx, ` - UPDATE execution_attempt SET finished_at = now(), outcome = 'uncertain' - WHERE id = $1 AND finished_at IS NULL`, attemptID.String); err != nil { - return errors.New("mark cancelled attempt uncertain") - } - } - if err := appendAudit(ctx, tx, "task_cancel_requested", "task_cancel_requested", accountID, confirmationID.String, confirmationVersion.Int64, attemptID.String, taskID, map[string]string{"state": state}); err != nil { - return err - } - return s.commitTransitions(tx, []taskstate.Transition{{State: state, ReasonCode: holdReason, AccountID: accountID, TaskID: taskID}}) -} - -func (s *Store) ExecuteMock(ctx context.Context, workerID, outcome string) (Execution, error) { - if !refPattern.MatchString(workerID) || (outcome != "succeeded" && outcome != "failed" && outcome != "uncertain" && outcome != "policy_hold") { - return Execution{}, ErrInvalid - } - execution, err := s.claim(ctx, workerID) - if err != nil || !execution.WasClaimed { - return execution, err - } - return s.complete(ctx, workerID, execution, outcome) -} - -func (s *Store) claim(ctx context.Context, workerID string) (Execution, error) { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return Execution{}, errors.New("begin claim transaction") - } - defer tx.Rollback() - expired, err := quarantineExpired(ctx, tx) - if err != nil { - return Execution{}, err - } - invalid, err := quarantineInvalid(ctx, tx) - if err != nil { - return Execution{}, err - } - transitions := append(expired, invalid...) - var execution Execution - err = tx.QueryRowContext(ctx, ` - WITH candidate AS ( - SELECT t.id - FROM operation_task t - JOIN social_account a ON a.id = t.account_id - JOIN content_draft d ON d.id = t.draft_id - JOIN confirmation c ON c.id = t.confirmation_id - JOIN environment_binding binding ON binding.account_id = a.id - JOIN browser_env environment ON environment.alias = binding.browser_env_alias - LEFT JOIN network_exit network ON network.id = binding.network_exit_id - JOIN runtime_instance runtime ON runtime.binding_id = binding.id - AND runtime.released_at IS NULL AND runtime.lease_until > now() AND runtime.binding_version = binding.version - WHERE t.state = 'queued' AND a.status = 'active' AND a.authorization_status = 'authorized' - AND a.version = t.account_version - AND d.account_id = t.account_id AND d.version = t.draft_version - AND c.account_id = t.account_id AND c.account_version = t.account_version - AND c.draft_id = t.draft_id AND c.draft_version = t.draft_version - AND c.version = t.confirmation_version - AND (binding.network_exit_id IS NULL OR network.health_status = 'healthy') AND NOT binding.runtime_cleanup_pending - ORDER BY t.created_at, t.id - FOR UPDATE OF t, a, binding, runtime SKIP LOCKED LIMIT 1 - ) - UPDATE operation_task t SET state = 'executing', hold_reason = NULL, - verification_result = NULL, verified_at = NULL, verified_by = NULL, - lease_owner = $1, lease_until = now() + interval '1 minute', updated_at = now() - FROM candidate WHERE t.id = candidate.id - RETURNING t.id, t.account_id, t.confirmation_id, t.confirmation_version`, workerID).Scan( - &execution.TaskID, &execution.AccountID, &execution.ConfirmationID, &execution.ConfirmationVersion) - if errors.Is(err, sql.ErrNoRows) { - if err := s.commitTransitions(tx, transitions); err != nil { - return Execution{}, err - } - return Execution{}, nil - } - if err != nil { - return Execution{}, errors.New("claim task") - } - execution.AttemptID = newID() - execution.State = "executing" - execution.WasClaimed = true - if _, err := tx.ExecContext(ctx, `INSERT INTO execution_attempt (id, task_id) VALUES ($1, $2)`, execution.AttemptID, execution.TaskID); err != nil { - return Execution{}, publicDatabaseError(err) - } - if _, err := tx.ExecContext(ctx, `UPDATE operation_task SET current_attempt_id = $1 WHERE id = $2`, execution.AttemptID, execution.TaskID); err != nil { - return Execution{}, errors.New("attach execution attempt") - } - if err := appendAudit(ctx, tx, "task_claimed", "task_claimed", execution.AccountID, execution.ConfirmationID, execution.ConfirmationVersion, execution.AttemptID, execution.TaskID, map[string]string{"worker_id": workerID}); err != nil { - return Execution{}, err - } - if err := s.commitTransitions(tx, transitions); err != nil { - return Execution{}, err - } - return execution, nil -} - -func (s *Store) complete(ctx context.Context, workerID string, execution Execution, outcome string) (Execution, error) { - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return Execution{}, errors.New("begin completion transaction") - } - defer tx.Rollback() - var leaseUntil time.Time - var holdReason string - err = tx.QueryRowContext(ctx, ` - SELECT task.lease_until, CASE - WHEN account.id IS NULL THEN 'account_missing' - WHEN account.authorization_status <> 'authorized' THEN 'account_revoked' - WHEN account.status <> 'active' THEN 'account_paused' - WHEN account.version <> task.account_version THEN 'account_version_changed' - WHEN draft.id IS NULL OR draft.account_id <> task.account_id OR draft.version <> task.draft_version THEN 'draft_version_changed' - WHEN confirmation.id IS NULL OR confirmation.account_id <> task.account_id - OR confirmation.account_version <> task.account_version OR confirmation.draft_id <> task.draft_id - OR confirmation.draft_version <> task.draft_version OR confirmation.version <> task.confirmation_version THEN 'confirmation_version_changed' - WHEN binding.id IS NULL THEN 'binding_missing' - WHEN environment.alias IS NULL THEN 'environment_missing' - WHEN binding.network_exit_id IS NOT NULL AND network.id IS NULL THEN 'exit_missing' - WHEN binding.network_exit_id IS NOT NULL AND network.health_status <> 'healthy' THEN 'exit_unhealthy' - WHEN binding.runtime_cleanup_pending THEN 'runtime_stop_pending' - WHEN runtime.id IS NULL OR runtime.lease_until <= now() THEN 'runtime_missing' - WHEN claim.binding_version IS DISTINCT FROM binding.version - OR claim.browser_env_alias IS DISTINCT FROM environment.alias - OR claim.network_exit_id IS DISTINCT FROM network.id - OR claim.runtime_instance_id IS DISTINCT FROM runtime.id - OR runtime.binding_version IS DISTINCT FROM binding.version THEN 'task_result_uncertain' - ELSE '' - END - FROM operation_task task - LEFT JOIN LATERAL ( - SELECT * FROM social_account WHERE id = task.account_id FOR UPDATE - ) account ON true - LEFT JOIN LATERAL ( - SELECT * FROM content_draft WHERE id = task.draft_id FOR UPDATE - ) draft ON true - LEFT JOIN LATERAL ( - SELECT * FROM confirmation WHERE id = task.confirmation_id FOR UPDATE - ) confirmation ON true - LEFT JOIN LATERAL ( - SELECT * FROM environment_binding WHERE account_id = task.account_id FOR UPDATE - ) binding ON true - LEFT JOIN LATERAL ( - SELECT * FROM browser_env WHERE alias = binding.browser_env_alias FOR UPDATE - ) environment ON true - LEFT JOIN LATERAL ( - SELECT * FROM network_exit WHERE id = binding.network_exit_id FOR UPDATE - ) network ON true - LEFT JOIN LATERAL ( - SELECT * FROM runtime_instance WHERE binding_id = binding.id AND released_at IS NULL FOR UPDATE - ) runtime ON true - LEFT JOIN LATERAL ( - SELECT browser_env_alias, network_exit_id, runtime_instance_id, binding_version - FROM audit_event WHERE attempt_id = $3 AND event_type = 'task_claimed' ORDER BY id DESC LIMIT 1 - ) claim ON true - WHERE task.id = $1 AND task.state = 'executing' AND task.lease_owner = $2 AND task.current_attempt_id = $3 - FOR UPDATE OF task`, execution.TaskID, workerID, execution.AttemptID).Scan(&leaseUntil, &holdReason) - if err != nil { - return Execution{}, rowError(err) - } - state := map[string]string{"succeeded": "succeeded", "failed": "failed", "uncertain": "needs_confirmation", "policy_hold": "policy_hold"}[outcome] - if leaseUntil.Before(time.Now()) || holdReason != "" { - outcome, state = "uncertain", "needs_confirmation" - } - result, _ := json.Marshal(map[string]string{"mock_outcome": outcome}) - if _, err := tx.ExecContext(ctx, `UPDATE execution_attempt SET finished_at = now(), outcome = $1, result = $2 WHERE id = $3`, outcome, result, execution.AttemptID); err != nil { - return Execution{}, errors.New("finish execution attempt") - } - if state == "needs_confirmation" && holdReason == "" { - holdReason = "task_result_uncertain" - } else if state == "policy_hold" { - holdReason = "task_policy_hold" - } - if _, err := tx.ExecContext(ctx, ` - UPDATE operation_task SET state = $1, hold_reason = NULLIF($2, ''), - verification_result = NULL, verified_at = NULL, verified_by = NULL, - lease_owner = NULL, lease_until = NULL, updated_at = now() WHERE id = $3`, state, holdReason, execution.TaskID); err != nil { - return Execution{}, errors.New("finish task") - } - reason := map[string]string{ - "succeeded": "task_succeeded", "failed": "task_failed", - "needs_confirmation": "task_result_uncertain", "policy_hold": "task_policy_hold", - }[state] - if holdReason != "" && state == "needs_confirmation" { - reason = holdReason - } - if err := appendAudit(ctx, tx, "task_finished", reason, execution.AccountID, execution.ConfirmationID, execution.ConfirmationVersion, execution.AttemptID, execution.TaskID, map[string]string{"state": state}); err != nil { - return Execution{}, err - } - if err := s.commitTransitions(tx, []taskstate.Transition{{ - State: state, ReasonCode: holdReason, AccountID: execution.AccountID, TaskID: execution.TaskID, - }}); err != nil { - return Execution{}, err - } - execution.State = state - return execution, nil -} - -func quarantineExpired(ctx context.Context, tx *sql.Tx) ([]taskstate.Transition, error) { - rows, err := tx.QueryContext(ctx, ` - UPDATE operation_task SET state = 'needs_confirmation', hold_reason = 'execution_lease_expired', - verification_result = NULL, verified_at = NULL, verified_by = NULL, - lease_owner = NULL, lease_until = NULL, updated_at = now() - WHERE state = 'executing' AND lease_until < now() - RETURNING id, account_id, current_attempt_id, confirmation_id, confirmation_version`) - if err != nil { - return nil, errors.New("quarantine expired tasks") - } - type expiredTask struct { - taskID, accountID, attemptID, confirmationID string - confirmationVersion int64 - } - var expired []expiredTask - for rows.Next() { - var taskID, accountID string - var attemptID sql.NullString - var confirmationID sql.NullString - var confirmationVersion sql.NullInt64 - if err := rows.Scan(&taskID, &accountID, &attemptID, &confirmationID, &confirmationVersion); err != nil { - rows.Close() - return nil, errors.New("read expired task") - } - expired = append(expired, expiredTask{taskID, accountID, attemptID.String, confirmationID.String, confirmationVersion.Int64}) - } - if err := rows.Close(); err != nil { - return nil, errors.New("close expired task rows") - } - transitions := make([]taskstate.Transition, 0, len(expired)) - for _, task := range expired { - if task.attemptID != "" { - if _, err := tx.ExecContext(ctx, `UPDATE execution_attempt SET finished_at = now(), outcome = 'uncertain' WHERE id = $1 AND finished_at IS NULL`, task.attemptID); err != nil { - return nil, errors.New("mark expired attempt uncertain") - } - } - if err := appendAudit(ctx, tx, "task_lease_expired", "execution_lease_expired", task.accountID, task.confirmationID, task.confirmationVersion, task.attemptID, task.taskID, nil); err != nil { - return nil, err - } - transitions = append(transitions, taskstate.Transition{ - State: "needs_confirmation", ReasonCode: "execution_lease_expired", AccountID: task.accountID, TaskID: task.taskID, - }) - } - return transitions, nil -} - -func quarantineInvalid(ctx context.Context, tx *sql.Tx) ([]taskstate.Transition, error) { - rows, err := tx.QueryContext(ctx, ` - WITH invalid AS ( - SELECT t.id, - CASE - WHEN account.id IS NULL OR account.status <> 'active' OR account.authorization_status <> 'authorized' THEN 'policy_hold' - WHEN account.version <> t.account_version THEN 'needs_confirmation' - WHEN draft.id IS NULL OR draft.account_id <> t.account_id OR draft.version <> t.draft_version THEN 'needs_confirmation' - WHEN confirmation.id IS NULL OR confirmation.account_id <> t.account_id - OR confirmation.account_version <> t.account_version OR confirmation.draft_id <> t.draft_id - OR confirmation.draft_version <> t.draft_version OR confirmation.version <> t.confirmation_version THEN 'needs_confirmation' - WHEN runtime.id IS NOT NULL AND runtime.binding_version IS DISTINCT FROM binding.version THEN 'needs_confirmation' - ELSE 'policy_hold' - END AS state, - CASE - WHEN account.id IS NULL THEN 'account_missing' - WHEN account.authorization_status = 'revoked' THEN 'account_revoked' - WHEN account.status <> 'active' THEN 'account_paused' - WHEN account.version <> t.account_version THEN 'account_version_changed' - WHEN draft.id IS NULL OR draft.account_id <> t.account_id OR draft.version <> t.draft_version THEN 'draft_version_changed' - WHEN confirmation.id IS NULL THEN 'confirmation_missing' - WHEN confirmation.account_id <> t.account_id OR confirmation.account_version <> t.account_version - OR confirmation.draft_id <> t.draft_id OR confirmation.draft_version <> t.draft_version - OR confirmation.version <> t.confirmation_version THEN 'confirmation_version_changed' - WHEN binding.id IS NULL THEN 'binding_missing' - WHEN environment.alias IS NULL THEN 'environment_missing' - WHEN binding.network_exit_id IS NOT NULL AND network.id IS NULL THEN 'exit_missing' - WHEN binding.network_exit_id IS NOT NULL AND network.health_status <> 'healthy' THEN 'exit_unhealthy' - WHEN binding.runtime_cleanup_pending THEN 'runtime_stop_pending' - WHEN runtime.id IS NULL THEN 'runtime_missing' - WHEN runtime.binding_version IS DISTINCT FROM binding.version THEN 'binding_version_changed' - WHEN runtime.lease_until <= now() THEN 'runtime_lease_expired' - END AS reason_code - FROM operation_task t - LEFT JOIN social_account account ON account.id = t.account_id - LEFT JOIN content_draft draft ON draft.id = t.draft_id - LEFT JOIN confirmation confirmation ON confirmation.id = t.confirmation_id - LEFT JOIN environment_binding binding ON binding.account_id = t.account_id - LEFT JOIN browser_env environment ON environment.alias = binding.browser_env_alias - 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 - WHERE t.state = 'queued' AND ( - account.id IS NULL OR account.status <> 'active' OR account.authorization_status <> 'authorized' - OR account.version <> t.account_version - OR draft.id IS NULL OR draft.account_id <> t.account_id OR draft.version <> t.draft_version - OR confirmation.id IS NULL OR confirmation.account_id <> t.account_id - OR confirmation.account_version <> t.account_version OR confirmation.draft_id <> t.draft_id - OR confirmation.draft_version <> t.draft_version OR confirmation.version <> t.confirmation_version - OR binding.id IS NULL OR environment.alias IS NULL - OR (binding.network_exit_id IS NOT NULL AND (network.id IS NULL OR network.health_status <> 'healthy')) - OR binding.runtime_cleanup_pending OR runtime.id IS NULL - OR runtime.binding_version IS DISTINCT FROM binding.version OR runtime.lease_until <= now() - ) - FOR UPDATE OF t SKIP LOCKED - ) - UPDATE operation_task task SET state = invalid.state, hold_reason = invalid.reason_code, - verification_result = NULL, verified_at = NULL, verified_by = NULL, updated_at = now() - FROM invalid WHERE task.id = invalid.id - RETURNING task.id, task.account_id, task.confirmation_id, task.confirmation_version, task.state, invalid.reason_code`) - if err != nil { - return nil, errors.New("quarantine invalid tasks") - } - type invalidTask struct { - taskID, accountID, confirmationID, state, reasonCode string - confirmationVersion int64 - } - var invalid []invalidTask - for rows.Next() { - var taskID, accountID, state, reasonCode string - var confirmationID sql.NullString - var confirmationVersion sql.NullInt64 - if err := rows.Scan(&taskID, &accountID, &confirmationID, &confirmationVersion, &state, &reasonCode); err != nil { - rows.Close() - return nil, errors.New("read quarantined task") - } - invalid = append(invalid, invalidTask{taskID, accountID, confirmationID.String, state, reasonCode, confirmationVersion.Int64}) - } - if err := rows.Close(); err != nil { - return nil, errors.New("close quarantined task rows") - } - transitions := make([]taskstate.Transition, 0, len(invalid)) - for _, task := range invalid { - if err := appendAudit(ctx, tx, "task_quarantined", task.reasonCode, task.accountID, task.confirmationID, task.confirmationVersion, "", task.taskID, map[string]string{"state": task.state}); err != nil { - return nil, err - } - transitions = append(transitions, taskstate.Transition{ - State: task.state, ReasonCode: task.reasonCode, AccountID: task.accountID, TaskID: task.taskID, - }) - } - return transitions, nil -} - -func scanTransitions(rows *sql.Rows, failure string) ([]taskstate.Transition, error) { - defer rows.Close() - var transitions []taskstate.Transition - for rows.Next() { - var transition taskstate.Transition - if err := rows.Scan(&transition.State, &transition.ReasonCode, &transition.AccountID, &transition.TaskID); err != nil { - return nil, errors.New(failure) - } - transitions = append(transitions, transition) - } - if err := rows.Err(); err != nil { - return nil, errors.New(failure) - } - return transitions, nil -} - func (s *Store) Audit(ctx context.Context) ([]AuditEvent, error) { page, err := s.ListAudit(ctx, AuditFilter{Page: 1, PageSize: 1000}) return page.Data, err @@ -1944,14 +587,6 @@ func commit(tx *sql.Tx) error { return nil } -func (s *Store) commitTransitions(tx *sql.Tx, transitions []taskstate.Transition) error { - if err := commit(tx); err != nil { - return err - } - s.notifyTransitions(transitions) - return nil -} - func rowError(err error) error { if errors.Is(err, sql.ErrNoRows) { return ErrNotFound diff --git a/internal/account/store_test.go b/internal/account/store_test.go index b758a93..ee98333 100644 --- a/internal/account/store_test.go +++ b/internal/account/store_test.go @@ -3,19 +3,12 @@ package account import ( "context" "database/sql" - "encoding/json" "errors" - "fmt" "io" "os" "path/filepath" - "strings" - "sync" - "sync/atomic" "testing" - "time" - "git.ipao.vip/rogee/creator-hub/internal/taskstate" "github.com/jackc/pgx/v5" ) @@ -100,642 +93,40 @@ func TestValidationRejectsInvalidInputsBeforePersistence(t *testing.T) { } }) } - if _, _, err := store.Enqueue(context.Background(), Task{ID: "task-a"}); !errors.Is(err, ErrInvalid) { - t.Fatalf("expected invalid task, got %v", err) - } - if _, _, err := store.EnqueueConfirmation(context.Background(), ""); !errors.Is(err, ErrInvalid) { - t.Fatalf("expected missing confirmation to be rejected, got %v", err) - } - if _, err := store.ExecuteMock(context.Background(), "worker-a", "retry"); !errors.Is(err, ErrInvalid) { - t.Fatalf("expected unsupported outcome to be rejected, got %v", err) - } } -func TestTaskRecoveryActionsAndEvidenceAreFailClosed(t *testing.T) { - for _, test := range []struct { - task Task - want string - }{ - {task: Task{State: "needs_confirmation", HoldReason: "execution_lease_expired"}, want: "verify"}, - {task: Task{State: "policy_hold", HoldReason: "account_version_changed"}, want: "reconfirm"}, - {task: Task{State: "needs_confirmation", HoldReason: "confirmation_missing"}, want: ""}, - {task: Task{State: "policy_hold", HoldReason: "future_reason"}, want: ""}, - {task: Task{State: "needs_confirmation", HoldReason: "task_result_uncertain", VerificationResult: "not_executed"}, want: "resume"}, - {task: Task{State: "needs_confirmation", HoldReason: "task_result_uncertain", VerificationResult: "succeeded"}, want: "finish"}, - } { - if got := taskAllowedAction(test.task); got != test.want { - t.Fatalf("allowed action: got=%q want=%q task=%+v", got, test.want, test.task) +func applyHubMigrationsForPhaseATest(t *testing.T, store *Store) { + t.Helper() + for _, migrationFile := range []struct { + version int + name string + }{{2, "002_hub.sql"}, {3, "003_unified_accounts.sql"}, {4, "004_environment_actions.sql"}, {5, "005_sanitize_legacy_proxy.sql"}, + {6, "006_runtime_cleanup.sql"}, {7, "007_runtime_binding_version.sql"}, {8, "008_runtime_cleanup_generation.sql"}, + {9, "009_runtime_cleanup_compatibility.sql"}, {10, "010_runtime_network_generation.sql"}, {11, "011_task_recovery.sql"}, + {12, "012_task_recovery_compatibility.sql"}, {13, "013_account_creation.sql"}, {14, "014_account_creation_compatibility.sql"}, + {15, "015_gateway_rename_cascade.sql"}, {16, "016_network_exit_plain_credentials.sql"}, {17, "017_native_browser_versions.sql"}, + {33, "033_unique_fingerprint_seed.sql"}, {34, "034_gateway_browser_default.sql"}} { + var applied bool + if err := store.db.QueryRow(`SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migrationFile.version).Scan(&applied); err != nil { + t.Fatal(err) } - } - redacted := safeDetails(json.RawMessage(`{"state":{"state":"safe","api_key":"a","private_key":"b","credential_key":"c","authorization_header":"d","proxy_url":"e"}}`)) - if string(redacted) != `{"state":{"state":"safe"}}` { - t.Fatalf("unexpected redaction: %s", redacted) - } - if evidence := safeEvidence(json.RawMessage(`{"mock_outcome":"uncertain","cookie":"secret"}`)); len(evidence) != 1 || evidence["mock_outcome"] != "uncertain" { - t.Fatalf("unexpected evidence allowlist: %#v", evidence) - } -} - -func TestPhaseAOfflineWorkflow(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, err := Open(ctx, databaseURL) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = store.Close() }) - var notificationMu sync.Mutex - var notifications []taskstate.Transition - store.SetTaskNotifier(func(transition taskstate.Transition) { - notificationMu.Lock() - defer notificationMu.Unlock() - notifications = append(notifications, transition) - }) - assertNotified := func(state, reason, accountID, taskID string) { - t.Helper() - notificationMu.Lock() - defer notificationMu.Unlock() - want := taskstate.Transition{State: state, ReasonCode: reason, AccountID: accountID, TaskID: taskID} - for _, notification := range notifications { - if notification == want { - return - } + if applied { + continue } - t.Fatalf("missing task notification %+v in %+v", want, notifications) - } - applyHubMigrationsForPhaseATest(t, store) - if _, err := store.db.ExecContext(ctx, ` - TRUNCATE audit_event, execution_attempt, operation_task, confirmation, content_draft, - runtime_instance, environment_binding, network_exit, social_account, credential_reference, - browser_env, gateway RESTART IDENTITY CASCADE`); err != nil { - t.Fatal(err) - } - - accounts := []Account{ - {ID: "account-a", Name: "账号 A", Platform: "douyin", PlatformAccountKey: "platform-a", Tags: []string{"主账号"}, Cookies: "sessionid=a", - CredentialReference: CredentialReference{ID: "account-a-cookies", Provider: "os_keyring"}, CredentialKey: "creatorhub/account-a/cookies"}, - {ID: "account-b", Name: "账号 B", Platform: "douyin", PlatformAccountKey: "platform-b", Tags: []string{}, Cookies: "sessionid=b", - CredentialReference: CredentialReference{ID: "account-b-cookies", Provider: "os_keyring"}, CredentialKey: "creatorhub/account-b/cookies"}, - } - credentials := &testCredentialBridge{values: map[string]string{}} - for _, account := range accounts { - if err := store.CreateAccount(ctx, account, credentials); err != nil { + migration, err := os.ReadFile(filepath.Join("..", "environment", "migrations", migrationFile.name)) + if err != nil { + t.Fatal(err) + } + if _, err := store.db.Exec(string(migration)); err != nil { + t.Fatalf("apply test migration %d: %v", migrationFile.version, err) + } + if _, err := store.db.Exec(`INSERT INTO schema_migration (version) VALUES ($1)`, migrationFile.version); err != nil { t.Fatal(err) } } - createdAccount, err := store.GetAccount(ctx, "account-a") - if err != nil || createdAccount.RuntimeStatus != "paused" || createdAccount.Version != 1 || - len(createdAccount.Tags) != 1 || createdAccount.Tags[0] != "主账号" { - t.Fatalf("new account must start paused: %#v %v", createdAccount, err) - } - if credentials.values["creatorhub/account-a/cookies"] != "sessionid=a" || - credentials.values["creatorhub/account-b/cookies"] != "sessionid=b" { - t.Fatalf("cookies were not stored in the credential bridge: %#v", credentials.values) - } - assertCount(t, store, `SELECT count(*) FROM credential_reference WHERE id IN ('account-a-cookies', 'account-b-cookies')`, 2) - assertCount(t, store, `SELECT count(*) FROM information_schema.columns WHERE table_schema = current_schema() - AND table_name = 'social_account' AND column_name = 'cookies'`, 0) - if err := store.ResumeAccount(ctx, "account-a"); !errors.Is(err, ErrConflict) { - t.Fatalf("account without a healthy binding must not resume: %v", err) - } - if err := store.CreateAccount(ctx, Account{ - ID: "account-c", Name: "账号 C", Platform: "douyin", PlatformAccountKey: "platform-a", Tags: []string{}, Cookies: "sessionid=c", - CredentialReference: CredentialReference{ID: "account-c-cookies", Provider: "os_keyring"}, CredentialKey: "creatorhub/account-c/cookies", - }, credentials); !errors.Is(err, ErrConflict) { - t.Fatalf("expected platform accounts to be unique, got %v", err) - } - if _, ok := credentials.values["creatorhub/account-c/cookies"]; ok { - t.Fatal("credential bridge retained a secret after database rejection") - } - if _, err := store.db.ExecContext(ctx, ` - INSERT INTO gateway (name, endpoint, token) VALUES ('test-gateway', 'http://127.0.0.1:8081', 'phase-a-test-gateway-token')`); err != nil { - t.Fatal(err) - } - if _, err := store.db.ExecContext(ctx, ` - INSERT INTO browser_image (version, image_ref) VALUES ('148.0.7778.215', 'git.ipao.vip/rogee/fingerprint-chromium:148.0.7778.215') ON CONFLICT (version) DO NOTHING; - INSERT INTO browser_env (alias, name, gateway_name, fingerprint, image_version) VALUES - ('account-a', 'Account A', 'test-gateway', '{"seed":1}', '148.0.7778.215'), - ('account-b', 'Account B', 'test-gateway', '{"seed":2}', '148.0.7778.215'); - INSERT INTO network_exit (id, protocol, host, port, health_status) - VALUES ('exit-shared', 'socks5', '127.0.0.1', 1080, 'healthy'); - INSERT INTO environment_binding (id, account_id, browser_env_alias, network_exit_id) VALUES - ('binding-a', 'account-a', 'account-a', 'exit-shared'), - ('binding-b', 'account-b', 'account-b', 'exit-shared')`); err != nil { - t.Fatal(err) - } - for _, id := range []string{"account-a", "account-b"} { - if err := store.ResumeAccount(ctx, id); err != nil { - t.Fatalf("resume %s: %v", id, err) - } - } - accountA, err := store.GetAccount(ctx, "account-a") - if err != nil || accountA.RuntimeStatus != "active" || accountA.Version != 2 { - t.Fatalf("unexpected resumed account: %#v %v", accountA, err) - } - exportedAccount, _ := json.Marshal(accountA) - if strings.Contains(string(exportedAccount), "sessionid=a") || strings.Contains(string(exportedAccount), "cookies") { - t.Fatalf("account response leaked cookies: %s", exportedAccount) - } - listedAccounts, err := store.ListAccounts(ctx) - if err != nil || len(listedAccounts) != 2 || len(listedAccounts[0].Tags) != 1 || len(listedAccounts[1].Tags) != 0 { - t.Fatalf("unexpected account list: %#v %v", listedAccounts, err) - } - accountB, err := store.GetAccount(ctx, "account-b") - if err != nil || len(accountB.Tags) != 0 { - t.Fatalf("empty tags did not round-trip: %#v err=%v", accountB, err) - } - - if _, err := store.db.ExecContext(ctx, ` - INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, lease_until) VALUES - ('runtime-instance-a', 'account-a', 'binding-a', 1, 'runtime-a', now() + interval '1 minute'), - ('runtime-instance-b', 'account-b', 'binding-b', 1, 'runtime-b', now() + interval '1 minute')`); err != nil { - t.Fatal(err) - } - - draftV1, err := store.CreateDraftVersion(ctx, "account-a", "first system-generated draft") - if err != nil || draftV1.ID == "" || draftV1.Version != 1 { - t.Fatalf("create generated draft: %#v %v", draftV1, err) - } - confirmation, inserted, err := store.ConfirmDraft(ctx, draftV1.ID, accountA.Version, draftV1.Version) - if err != nil || !inserted || confirmation.ID == "" { - t.Fatalf("confirm generated draft: %#v inserted=%v err=%v", confirmation, inserted, err) - } - if repeated, inserted, err := store.ConfirmDraft(ctx, draftV1.ID, accountA.Version, draftV1.Version); err != nil || inserted || repeated.ID != confirmation.ID { - t.Fatalf("confirmation was not idempotent: %#v inserted=%v err=%v", repeated, inserted, err) - } - queued, inserted, err := store.EnqueueConfirmation(ctx, confirmation.ID) - if err != nil || !inserted || queued.ID == "" { - t.Fatalf("enqueue confirmed draft: %#v inserted=%v err=%v", queued, inserted, err) - } - if repeated, inserted, err := store.EnqueueConfirmation(ctx, confirmation.ID); err != nil || inserted || repeated.ID != queued.ID { - t.Fatalf("enqueue was not idempotent: %#v inserted=%v err=%v", repeated, inserted, err) - } - assertCount(t, store, `SELECT count(*) FROM operation_task WHERE confirmation_id = $1`, 1, confirmation.ID) - detail, err := store.GetDraftDetail(ctx, draftV1.ID) - if err != nil || len(detail.Confirmations) != 1 || detail.Confirmations[0].BrowserEnvAlias != "account-a" || - detail.Confirmations[0].NetworkExitID != "exit-shared" || len(detail.Tasks) != 1 { - t.Fatalf("confirmation snapshot is not traceable: %#v err=%v", detail, err) - } - if err := store.CancelTask(ctx, queued.ID); err != nil { - t.Fatal(err) - } - draftV2, err := store.CreateDraftVersion(ctx, "account-a", "newer draft snapshot") - if err != nil || draftV2.Version != 2 { - t.Fatalf("create second draft version: %#v %v", draftV2, err) - } - if _, _, err := store.ConfirmDraft(ctx, draftV1.ID, accountA.Version, draftV1.Version); readinessReason(err) != "draft_version_changed" { - t.Fatalf("stale draft was confirmed: %v", err) - } - confirmationV2, _, err := store.ConfirmDraft(ctx, draftV2.ID, accountA.Version, draftV2.Version) - if err != nil { - t.Fatal(err) - } - if _, err := store.db.ExecContext(ctx, `UPDATE network_exit SET health_status = 'unhealthy' WHERE id = 'exit-shared'`); err != nil { - t.Fatal(err) - } - if _, _, err := store.EnqueueConfirmation(ctx, confirmationV2.ID); readinessReason(err) != "network_exit_unhealthy" { - t.Fatalf("unhealthy exit accepted for enqueue: %v", err) - } - assertCount(t, store, `SELECT count(*) FROM operation_task WHERE confirmation_id = $1`, 0, confirmationV2.ID) - assertCount(t, store, `SELECT count(*) FROM execution_attempt attempt JOIN operation_task task ON task.id = attempt.task_id WHERE task.confirmation_id = $1`, 0, confirmationV2.ID) - if _, err := store.db.ExecContext(ctx, `UPDATE network_exit SET health_status = 'healthy' WHERE id = 'exit-shared'`); err != nil { - t.Fatal(err) - } - if _, err := store.CreateDraftVersion(ctx, "account-a", "third draft makes confirmation stale"); err != nil { - t.Fatal(err) - } - if _, _, err := store.EnqueueConfirmation(ctx, confirmationV2.ID); readinessReason(err) != "draft_version_changed" { - t.Fatalf("stale confirmation accepted for enqueue: %v", err) - } - assertCount(t, store, `SELECT count(*) FROM operation_task WHERE confirmation_id = $1`, 0, confirmationV2.ID) - assertCount(t, store, `SELECT count(*) FROM execution_attempt attempt JOIN operation_task task ON task.id = attempt.task_id WHERE task.confirmation_id = $1`, 0, confirmationV2.ID) - - createApprovedDraft(t, store, "account-a", accountA.Version, "draft-a", "confirmation-a") - createApprovedDraft(t, store, "account-b", accountB.Version, "draft-b", "confirmation-b") - for index := range 20 { - accountID, accountVersion, draftID, confirmationID := "account-a", accountA.Version, "draft-a", "confirmation-a" - if index%2 == 1 { - accountID, accountVersion, draftID, confirmationID = "account-b", accountB.Version, "draft-b", "confirmation-b" - } - task := approvedTask(index, accountID, accountVersion, draftID, confirmationID) - if _, inserted, err := store.Enqueue(ctx, task); err != nil || !inserted { - t.Fatalf("enqueue task %d: inserted=%v err=%v", index, inserted, err) - } - task.ID = fmt.Sprintf("duplicate-%02d", index) - if existing, inserted, err := store.Enqueue(ctx, task); err != nil || inserted || existing.ID == task.ID { - t.Fatalf("redeliver task %d: existing=%q inserted=%v err=%v", index, existing.ID, inserted, err) - } - } - - var executed atomic.Int64 - var workers sync.WaitGroup - errorsFromWorkers := make(chan error, 4) - for worker := range 4 { - workers.Add(1) - go func() { - defer workers.Done() - for { - execution, err := store.ExecuteMock(ctx, fmt.Sprintf("worker-%d", worker), "succeeded") - if err != nil { - errorsFromWorkers <- err - return - } - if !execution.WasClaimed { - var queued int - if err := store.db.QueryRowContext(ctx, `SELECT count(*) FROM operation_task WHERE state = 'queued'`).Scan(&queued); err != nil { - errorsFromWorkers <- err - return - } - if queued == 0 { - return - } - time.Sleep(5 * time.Millisecond) - continue - } - executed.Add(1) - } - }() - } - workers.Wait() - close(errorsFromWorkers) - for err := range errorsFromWorkers { - t.Fatal(err) - } - if executed.Load() != 20 { - t.Fatalf("expected exactly 20 executions, got %d", executed.Load()) - } - assertCount(t, store, `SELECT count(*) FROM operation_task WHERE state = 'succeeded'`, 20) - assertCount(t, store, `SELECT count(*) FROM execution_attempt`, 20) - - for _, test := range []struct { - name, accountStatus, authorizationStatus, exitStatus, runtimeLease, wantState, wantReason string - binding, versionChanged, confirmationChanged bool - }{ - {name: "binding-missing", accountStatus: "active", authorizationStatus: "authorized", wantState: "policy_hold", wantReason: "binding_missing"}, - {name: "exit-unhealthy", accountStatus: "active", authorizationStatus: "authorized", binding: true, exitStatus: "unhealthy", wantState: "policy_hold", wantReason: "exit_unhealthy"}, - {name: "runtime-missing", accountStatus: "active", authorizationStatus: "authorized", binding: true, exitStatus: "healthy", wantState: "policy_hold", wantReason: "runtime_missing"}, - {name: "runtime-expired", accountStatus: "active", authorizationStatus: "authorized", binding: true, exitStatus: "healthy", runtimeLease: "expired", wantState: "policy_hold", wantReason: "runtime_lease_expired"}, - {name: "binding-version", accountStatus: "active", authorizationStatus: "authorized", binding: true, exitStatus: "healthy", runtimeLease: "active", versionChanged: true, wantState: "needs_confirmation", wantReason: "binding_version_changed"}, - {name: "confirmation-version", accountStatus: "active", authorizationStatus: "authorized", binding: true, exitStatus: "healthy", runtimeLease: "active", confirmationChanged: true, wantState: "needs_confirmation", wantReason: "confirmation_version_changed"}, - {name: "paused", accountStatus: "paused", authorizationStatus: "authorized", binding: true, exitStatus: "healthy", runtimeLease: "active", wantState: "policy_hold", wantReason: "account_paused"}, - {name: "revoked", accountStatus: "paused", authorizationStatus: "revoked", binding: true, exitStatus: "healthy", runtimeLease: "active", wantState: "policy_hold", wantReason: "account_revoked"}, - } { - t.Run("gate "+test.name, func(t *testing.T) { - taskID := seedGateTask(t, store, test.name, test.accountStatus, test.authorizationStatus, test.exitStatus, - test.runtimeLease, test.binding, test.versionChanged, test.confirmationChanged) - if execution, err := store.ExecuteMock(ctx, "worker-gate-"+test.name, "succeeded"); err != nil || execution.WasClaimed { - t.Fatalf("invalid task was claimed: execution=%+v err=%v", execution, err) - } - assertTaskGate(t, store, taskID, test.wantState, test.wantReason) - assertNotified(test.wantState, test.wantReason, "gate-"+test.name, taskID) - }) - } - if err := store.VerifyTask(ctx, "task-gate-exit-unhealthy", "not_executed"); err != nil { - t.Fatal(err) - } - blockedDetail, err := store.GetTaskDetail(ctx, "task-gate-exit-unhealthy") - if err != nil || blockedDetail.AllowedAction != "" || blockedDetail.ReadinessReason != "exit_unhealthy" { - t.Fatalf("unhealthy exit exposed resume after verification: detail=%+v err=%v", blockedDetail, err) - } - - // A held task is never revived in place: only a newly confirmed task with a new idempotency key may run. - if _, err := store.db.ExecContext(ctx, ` - INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, lease_until) - VALUES ('runtime-recovered', 'gate-runtime-missing', 'binding-runtime-missing', 1, 'runtime-recovered', now() + interval '1 minute')`); err != nil { - t.Fatal(err) - } - if execution, err := store.ExecuteMock(ctx, "worker-old-held", "succeeded"); err != nil || execution.WasClaimed { - t.Fatalf("held task was revived after health recovery: execution=%+v err=%v", execution, err) - } - createApprovedDraft(t, store, "gate-runtime-missing", 1, "draft-recovered", "confirmation-recovered") - recovered := Task{ - ID: "task-recovered", IdempotencyKey: "idempotency-recovered", AccountID: "gate-runtime-missing", AccountVersion: 1, - DraftID: "draft-recovered", DraftVersion: 1, ConfirmationID: "confirmation-recovered", ConfirmationVersion: 1, - } - if _, inserted, err := store.Enqueue(ctx, recovered); err != nil || !inserted { - t.Fatalf("enqueue recovered task: inserted=%v err=%v", inserted, err) - } - if execution, err := store.ExecuteMock(ctx, "worker-recovered", "succeeded"); err != nil || !execution.WasClaimed || execution.State != "succeeded" { - t.Fatalf("newly confirmed task did not run after recovery: execution=%+v err=%v", execution, err) - } - assertCount(t, store, `SELECT count(*) FROM operation_task WHERE id = 'task-gate-runtime-missing' AND state = 'policy_hold'`, 1) - - unconfirmed := approvedTask(30, "account-b", accountB.Version, "draft-b", "") - unconfirmed.ConfirmationVersion = 0 - if _, _, err := store.Enqueue(ctx, unconfirmed); err != nil { - t.Fatal(err) - } - mismatch := approvedTask(31, "account-b", accountB.Version, "draft-b", "confirmation-b") - mismatch.AccountVersion++ - if _, _, err := store.Enqueue(ctx, mismatch); err != nil { - t.Fatal(err) - } - if execution, err := store.ExecuteMock(ctx, "worker-validation", "succeeded"); err != nil || execution.WasClaimed { - t.Fatalf("invalid tasks must not execute: execution=%+v err=%v", execution, err) - } - assertCount(t, store, `SELECT count(*) FROM operation_task WHERE id IN ('task-30', 'task-31') AND state = 'needs_confirmation'`, 2) - assertNotified("needs_confirmation", "confirmation_missing", "account-b", "task-30") - assertNotified("needs_confirmation", "account_version_changed", "account-b", "task-31") - assertCount(t, store, `SELECT count(*) FROM execution_attempt WHERE task_id IN ('task-30', 'task-31')`, 0) - nullConfirmation, err := store.GetTaskDetail(ctx, "task-30") - if err != nil || nullConfirmation.Confirmation.ID != "" || nullConfirmation.AllowedAction != "" { - t.Fatalf("NULL confirmation detail was not readable and fail-closed: detail=%+v err=%v", nullConfirmation, err) - } - - uncertain := approvedTask(32, "account-b", accountB.Version, "draft-b", "confirmation-b") - if _, _, err := store.Enqueue(ctx, uncertain); err != nil { - t.Fatal(err) - } - if execution, err := store.ExecuteMock(ctx, "worker-uncertain", "uncertain"); err != nil || execution.State != "needs_confirmation" { - t.Fatalf("uncertain outcome was not held: execution=%+v err=%v", execution, err) - } - assertNotified("needs_confirmation", "task_result_uncertain", "account-b", "task-32") - if execution, err := store.ExecuteMock(ctx, "worker-uncertain", "succeeded"); err != nil || execution.WasClaimed { - t.Fatalf("uncertain task was retried: execution=%+v err=%v", execution, err) - } - assertCount(t, store, `SELECT count(*) FROM execution_attempt WHERE task_id = 'task-32'`, 1) - - policy := approvedTask(33, "account-b", accountB.Version, "draft-b", "confirmation-b") - if _, _, err := store.Enqueue(ctx, policy); err != nil { - t.Fatal(err) - } - if execution, err := store.ExecuteMock(ctx, "worker-policy", "policy_hold"); err != nil || execution.State != "policy_hold" { - t.Fatalf("policy outcome was not held: execution=%+v err=%v", execution, err) - } - assertNotified("policy_hold", "task_policy_hold", "account-b", "task-33") - if execution, err := store.ExecuteMock(ctx, "worker-policy", "succeeded"); err != nil || execution.WasClaimed { - t.Fatalf("policy task was retried: execution=%+v err=%v", execution, err) - } - - paused := approvedTask(34, "account-b", accountB.Version, "draft-b", "confirmation-b") - if _, _, err := store.Enqueue(ctx, paused); err != nil { - t.Fatal(err) - } - pausedQueued := approvedTask(340, "account-b", accountB.Version, "draft-b", "confirmation-b") - pausedQueued.ID, pausedQueued.IdempotencyKey = "task-34-queued", "idempotency-34-queued" - if _, _, err := store.Enqueue(ctx, pausedQueued); err != nil { - t.Fatal(err) - } - if claimed, err := store.claim(ctx, "worker-account-pause"); err != nil || claimed.TaskID != paused.ID { - t.Fatalf("claim executing pause fixture: execution=%+v err=%v", claimed, err) - } - pauseStarted := time.Now() - if err := store.PauseAccount(ctx, "account-b"); err != nil { - t.Fatal(err) - } - if err := store.PauseAccount(ctx, "account-b"); err != nil { - t.Fatalf("pause should be idempotent: %v", err) - } - if execution, err := store.ExecuteMock(ctx, "worker-paused", "succeeded"); err != nil || execution.WasClaimed { - t.Fatalf("paused account task was claimed: execution=%+v err=%v", execution, err) - } - assertCount(t, store, `SELECT count(*) FROM operation_task WHERE id = 'task-34' AND state = 'needs_confirmation'`, 1) - assertCount(t, store, `SELECT count(*) FROM operation_task WHERE id = 'task-34-queued' AND state = 'policy_hold'`, 1) - assertNotified("needs_confirmation", "account_paused", "account-b", "task-34") - assertNotified("policy_hold", "account_paused", "account-b", "task-34-queued") - pausedAccount, err := store.GetAccount(ctx, "account-b") - if err != nil || pausedAccount.Version != accountB.Version+1 { - t.Fatalf("idempotent pause must increment once: %#v %v", pausedAccount, err) - } - var pausedAt time.Time - if err := store.db.QueryRowContext(ctx, `SELECT paused_at FROM social_account WHERE id = 'account-b'`).Scan(&pausedAt); err != nil || pausedAt.Sub(pauseStarted) >= time.Minute { - t.Fatalf("account was not paused within one minute: paused_at=%v err=%v", pausedAt, err) - } - - revokeQueued := approvedTask(341, "account-b", pausedAccount.Version, "draft-b", "confirmation-b") - revokeQueued.ID, revokeQueued.IdempotencyKey = "task-revoke-queued", "idempotency-revoke-queued" - revokeExecuting := approvedTask(342, "account-b", pausedAccount.Version, "draft-b", "confirmation-b") - revokeExecuting.ID, revokeExecuting.IdempotencyKey = "task-revoke-executing", "idempotency-revoke-executing" - for _, task := range []Task{revokeQueued, revokeExecuting} { - if _, _, err := store.Enqueue(ctx, task); err != nil { - t.Fatal(err) - } - } - if _, err := store.db.ExecContext(ctx, `UPDATE operation_task SET state = 'executing', lease_owner = 'worker-revoke', lease_until = now() + interval '1 minute' WHERE id = $1`, revokeExecuting.ID); err != nil { - t.Fatal(err) - } - if err := store.RevokeAccount(ctx, "account-b"); err != nil { - t.Fatal(err) - } - if err := store.RevokeAccount(ctx, "account-b"); err != nil { - t.Fatalf("revoke should be idempotent: %v", err) - } - revokedAccount, err := store.GetAccount(ctx, "account-b") - if err != nil || revokedAccount.AuthorizationStatus != "revoked" || revokedAccount.Version != accountB.Version+2 { - t.Fatalf("unexpected revoked account: %#v %v", revokedAccount, err) - } - assertNotified("policy_hold", "account_revoked", "account-b", revokeQueued.ID) - assertNotified("needs_confirmation", "account_revoked", "account-b", revokeExecuting.ID) - if err := store.ResumeAccount(ctx, "account-b"); !errors.Is(err, ErrConflict) { - t.Fatalf("revoked account must not resume: %v", err) - } - - cancelled := approvedTask(35, "account-a", accountA.Version, "draft-a", "confirmation-a") - if _, _, err := store.Enqueue(ctx, cancelled); err != nil { - t.Fatal(err) - } - if err := store.CancelTask(ctx, cancelled.ID); err != nil { - t.Fatal(err) - } - assertCount(t, store, `SELECT count(*) FROM operation_task WHERE id = 'task-35' AND state = 'cancelled'`, 1) - - expired := approvedTask(36, "account-a", accountA.Version, "draft-a", "confirmation-a") - if _, _, err := store.Enqueue(ctx, expired); err != nil { - t.Fatal(err) - } - claimed, err := store.claim(ctx, "worker-crashed") - if err != nil || !claimed.WasClaimed { - t.Fatalf("claim task for lease test: execution=%+v err=%v", claimed, err) - } - if _, err := store.db.ExecContext(ctx, `UPDATE operation_task SET lease_until = now() - interval '1 second' WHERE id = $1`, claimed.TaskID); err != nil { - t.Fatal(err) - } - if execution, err := store.ExecuteMock(ctx, "worker-after-crash", "succeeded"); err != nil || execution.WasClaimed { - t.Fatalf("expired unknown result was retried: execution=%+v err=%v", execution, err) - } - assertCount(t, store, `SELECT count(*) FROM operation_task WHERE id = 'task-36' AND state = 'needs_confirmation'`, 1) - assertNotified("needs_confirmation", "execution_lease_expired", "account-a", "task-36") - assertCount(t, store, `SELECT count(*) FROM execution_attempt WHERE task_id = 'task-36' AND outcome = 'uncertain'`, 1) - if err := store.ResumeTask(ctx, "task-36"); !errors.Is(err, ErrConflict) { - t.Fatalf("unknown result resumed without verification: %v", err) - } - if err := store.VerifyTask(ctx, "task-36", "not_executed"); err != nil { - t.Fatal(err) - } - if err := store.VerifyTask(ctx, "task-36", "succeeded"); !errors.Is(err, ErrConflict) { - t.Fatalf("repeated verification changed the recorded conclusion: %v", err) - } - verifiedDetail, err := store.GetTaskDetail(ctx, "task-36") - if err != nil || verifiedDetail.AllowedAction != "resume" || verifiedDetail.ReadinessReason != "" { - t.Fatalf("ready verified task did not expose one resume action: detail=%+v err=%v", verifiedDetail, err) - } - if err := store.ResumeTask(ctx, "task-36"); err != nil { - t.Fatal(err) - } - if execution, err := store.ExecuteMock(ctx, "worker-after-verification", "succeeded"); err != nil || !execution.WasClaimed || execution.TaskID != "task-36" { - t.Fatalf("verified task did not resume: execution=%+v err=%v", execution, err) - } - assertCount(t, store, `SELECT count(*) FROM execution_attempt WHERE task_id = 'task-36'`, 2) - attempts, err := store.GetTaskDetail(ctx, "task-36") - if err != nil || len(attempts.Attempts) != 2 { - t.Fatalf("task attempts are not traceable: detail=%+v err=%v", attempts, err) - } - attemptDetail, err := store.GetTaskAttemptDetail(ctx, attempts.Attempts[1].ID) - if err != nil || attemptDetail.TaskID != "task-36" || attemptDetail.BrowserEnvAlias == "" || attemptDetail.NetworkExitID == "" { - t.Fatalf("attempt deep link is incomplete: detail=%+v err=%v", attemptDetail, err) - } - - stale := approvedTask(37, "account-a", accountA.Version, "draft-a", "confirmation-a") - if _, _, err := store.Enqueue(ctx, stale); err != nil { - t.Fatal(err) - } - staleExecution, err := store.claim(ctx, "worker-stale") - if err != nil || staleExecution.TaskID != stale.ID { - t.Fatalf("claim stale completion fixture: execution=%+v err=%v", staleExecution, err) - } - if _, err := store.db.ExecContext(ctx, `UPDATE network_exit SET health_status = 'unhealthy' WHERE id = 'exit-shared'`); err != nil { - t.Fatal(err) - } - completed, err := store.complete(ctx, "worker-stale", staleExecution, "succeeded") - if err != nil || completed.State != "needs_confirmation" { - t.Fatalf("stale worker persisted a successful result: execution=%+v err=%v", completed, err) - } - assertCount(t, store, `SELECT count(*) FROM operation_task WHERE id = 'task-37' AND state = 'needs_confirmation' AND hold_reason = 'exit_unhealthy'`, 1) - assertNotified("needs_confirmation", "exit_unhealthy", "account-a", "task-37") - if _, err := store.db.ExecContext(ctx, `UPDATE network_exit SET health_status = 'healthy' WHERE id = 'exit-shared'`); err != nil { - t.Fatal(err) - } - - concurrent := approvedTask(38, "account-a", accountA.Version, "draft-a", "confirmation-a") - if _, _, err := store.Enqueue(ctx, concurrent); err != nil { - t.Fatal(err) - } - concurrentExecution, err := store.claim(ctx, "worker-concurrent-release") - if err != nil || concurrentExecution.TaskID != concurrent.ID { - t.Fatalf("claim concurrent completion fixture: execution=%+v err=%v", concurrentExecution, err) - } - releaseTx, err := store.db.BeginTx(ctx, nil) - if err != nil { - t.Fatal(err) - } - if _, err := releaseTx.ExecContext(ctx, `UPDATE runtime_instance SET released_at = now() WHERE id = 'runtime-instance-a'`); err != nil { - t.Fatal(err) - } - type completionResult struct { - execution Execution - err error - } - completionStarted := make(chan struct{}) - completionDone := make(chan completionResult, 1) - go func() { - close(completionStarted) - execution, err := store.complete(ctx, "worker-concurrent-release", concurrentExecution, "succeeded") - completionDone <- completionResult{execution: execution, err: err} - }() - <-completionStarted - select { - case result := <-completionDone: - releaseTx.Rollback() - t.Fatalf("completion bypassed an in-flight runtime release: execution=%+v err=%v", result.execution, result.err) - case <-time.After(100 * time.Millisecond): - } - if err := releaseTx.Commit(); err != nil { - t.Fatal(err) - } - select { - case result := <-completionDone: - if result.err != nil || result.execution.State != "needs_confirmation" { - t.Fatalf("completion after runtime release was not quarantined: execution=%+v err=%v", result.execution, result.err) - } - case <-time.After(5 * time.Second): - t.Fatal("completion remained blocked after runtime release committed") - } - assertCount(t, store, `SELECT count(*) FROM operation_task - WHERE id = 'task-38' AND state = 'needs_confirmation' AND hold_reason = 'runtime_missing'`, 1) - assertNotified("needs_confirmation", "runtime_missing", "account-a", "task-38") - concurrentDetail, err := store.GetTaskDetail(ctx, concurrent.ID) - if err != nil || concurrentDetail.RuntimeInstanceID != "runtime-instance-a" { - t.Fatalf("task detail lost its immutable claim runtime: detail=%+v err=%v", concurrentDetail, err) - } - - if _, err := store.db.ExecContext(ctx, ` - INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, lease_until) - VALUES ('runtime-instance-a2', 'account-a', 'binding-a', 1, 'runtime-a2', now() + interval '1 minute')`); err != nil { - t.Fatal(err) - } - replaced := approvedTask(39, "account-a", accountA.Version, "draft-a", "confirmation-a") - if _, _, err := store.Enqueue(ctx, replaced); err != nil { - t.Fatal(err) - } - replacedExecution, err := store.claim(ctx, "worker-replaced-runtime") - if err != nil || replacedExecution.TaskID != replaced.ID { - t.Fatalf("claim replaced runtime fixture: execution=%+v err=%v", replacedExecution, err) - } - if _, err := store.db.ExecContext(ctx, ` - UPDATE runtime_instance SET released_at = now() WHERE id = 'runtime-instance-a2'; - INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, lease_until) - VALUES ('runtime-instance-a3', 'account-a', 'binding-a', 1, 'runtime-a3', now() + interval '1 minute')`); err != nil { - t.Fatal(err) - } - replacedCompletion, err := store.complete(ctx, "worker-replaced-runtime", replacedExecution, "succeeded") - if err != nil || replacedCompletion.State != "needs_confirmation" { - t.Fatalf("old worker completed against a replacement runtime: execution=%+v err=%v", replacedCompletion, err) - } - assertCount(t, store, `SELECT count(*) FROM operation_task - WHERE id = 'task-39' AND state = 'needs_confirmation' AND hold_reason = 'task_result_uncertain'`, 1) - assertNotified("needs_confirmation", "task_result_uncertain", "account-a", "task-39") - replacedDetail, err := store.GetTaskDetail(ctx, replaced.ID) - if err != nil || replacedDetail.AllowedAction != "verify" || replacedDetail.BrowserEnvAlias != "account-a" || - replacedDetail.NetworkExitID != "exit-shared" || replacedDetail.RuntimeInstanceID != "runtime-instance-a2" || replacedDetail.BindingVersion != 1 { - t.Fatalf("replacement task detail did not preserve the claim snapshot: detail=%+v err=%v", replacedDetail, err) - } - replacedAttempt, err := store.GetTaskAttemptDetail(ctx, replacedExecution.AttemptID) - if err != nil || replacedAttempt.BrowserEnvAlias != "account-a" || replacedAttempt.NetworkExitID != "exit-shared" || - replacedAttempt.RuntimeInstanceID != "runtime-instance-a2" || replacedAttempt.BindingVersion != 1 { - t.Fatalf("replacement attempt detail did not preserve the claim snapshot: detail=%+v err=%v", replacedAttempt, err) - } - - if _, err := store.db.ExecContext(ctx, `INSERT INTO audit_event (event_type, details) VALUES ('redaction_test', - '{"state":"safe","api_key":"api-secret","private_key":"private-secret","credential_key":"credential-secret","authorization_header":"auth-secret","proxy_url":"proxy-secret","nested":{"state":"nested-safe","token":"nested-secret"}}')`); err != nil { - t.Fatal(err) - } - - events, err := store.Audit(ctx) - if err != nil { - t.Fatal(err) - } - traceable := false - for _, event := range events { - if event.EventType == "task_finished" && event.ConfirmationID != "" && event.ConfirmationVersion == 1 && event.AttemptID != "" && event.TaskID != "" { - traceable = true - break - } - } - if !traceable { - t.Fatal("audit does not trace confirmation version, task, and attempt") - } - exported, _ := json.Marshal(events) - for _, forbidden := range []string{"password", "cookie", "token", "credential-a", "creatorhub/account-a", "api-secret", "private-secret", "credential-secret", "auth-secret", "proxy-secret", "nested-secret"} { - if strings.Contains(strings.ToLower(string(exported)), forbidden) { - t.Fatalf("audit export contains sensitive field or credential reference %q", forbidden) - } - } - firstPage, err := store.ListAudit(ctx, AuditFilter{Page: 1, PageSize: 1}) - if err != nil || firstPage.Total < 2 || len(firstPage.Data) != 1 { - t.Fatalf("first audit page: page=%+v err=%v", firstPage, err) - } - secondPage, err := store.ListAudit(ctx, AuditFilter{Page: 2, PageSize: 1}) - if err != nil || len(secondPage.Data) != 1 || secondPage.Data[0].ID == firstPage.Data[0].ID { - t.Fatalf("second audit page: page=%+v err=%v", secondPage, err) - } - if _, err := store.db.ExecContext(ctx, `UPDATE audit_event SET event_type = 'rewritten' WHERE id = 1`); err == nil { - t.Fatal("audit events must be append-only") - } } + func TestCreateAccountWithoutCookiesSkipsCredentialStore(t *testing.T) { databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") if databaseURL == "" { @@ -831,161 +222,6 @@ func TestAccountCredentialCommitResult(t *testing.T) { } } -func createApprovedDraft(t *testing.T, store *Store, accountID string, accountVersion int64, draftID, confirmationID string) { - t.Helper() - ctx := context.Background() - if err := store.CreateDraft(ctx, Draft{ID: draftID, AccountID: accountID, Version: 1, Content: "offline mock content"}); err != nil { - t.Fatal(err) - } - if err := store.Confirm(ctx, Confirmation{ - ID: confirmationID, AccountID: accountID, AccountVersion: accountVersion, DraftID: draftID, DraftVersion: 1, Version: 1, - }); err != nil { - t.Fatal(err) - } -} - -func approvedTask(index int, accountID string, accountVersion int64, draftID, confirmationID string) Task { - return Task{ - ID: fmt.Sprintf("task-%02d", index), IdempotencyKey: fmt.Sprintf("idempotency-%02d", index), - AccountID: accountID, AccountVersion: accountVersion, DraftID: draftID, DraftVersion: 1, - ConfirmationID: confirmationID, ConfirmationVersion: 1, - } -} - -func seedGateTask(t *testing.T, store *Store, suffix, accountStatus, authorizationStatus, exitStatus, runtimeLease string, - binding, versionChanged, confirmationChanged bool) string { - t.Helper() - ctx := context.Background() - accountID, taskID := "gate-"+suffix, "task-gate-"+suffix - tx, err := store.db.BeginTx(ctx, nil) - if err != nil { - t.Fatal(err) - } - defer tx.Rollback() - if _, err := tx.ExecContext(ctx, `INSERT INTO credential_reference (id, provider, reference_key) VALUES ($1, 'os_keyring', $2)`, - "credential-"+suffix, "creatorhub/gate-"+suffix); err != nil { - t.Fatal(err) - } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO social_account - (id, credential_reference_id, platform, platform_account_key, authorization_kind, authorization_status, status, version) - VALUES ($1, $2, 'mock', $1, 'owned', $3, $4, 1)`, accountID, "credential-"+suffix, authorizationStatus, accountStatus); err != nil { - t.Fatal(err) - } - if _, err := tx.ExecContext(ctx, `INSERT INTO content_draft (id, account_id, version, content) VALUES ($1, $2, 1, 'gate test')`, - "draft-gate-"+suffix, accountID); err != nil { - t.Fatal(err) - } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO confirmation (id, account_id, account_version, draft_id, draft_version, version) - VALUES ($1, $2, 1, $3, 1, 1)`, "confirmation-gate-"+suffix, accountID, "draft-gate-"+suffix); err != nil { - t.Fatal(err) - } - if binding { - exitID := "exit-gate-" + suffix - if _, err := tx.ExecContext(ctx, `INSERT INTO browser_env (alias, name, gateway_name, fingerprint, image_version) - VALUES ($1, $1, 'test-gateway', jsonb_build_object('seed', hashtext($1)), '148.0.7778.215')`, accountID); err != nil { - t.Fatal(err) - } - if _, err := tx.ExecContext(ctx, `INSERT INTO network_exit (id, protocol, host, port, health_status) - VALUES ($1, 'socks5', '127.0.0.1', 1080, $2)`, exitID, exitStatus); err != nil { - t.Fatal(err) - } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO environment_binding (id, account_id, browser_env_alias, network_exit_id, version) - VALUES ($1, $2, $2, $3, 1)`, "binding-"+suffix, accountID, exitID); err != nil { - t.Fatal(err) - } - if runtimeLease != "" { - interval := "1 minute" - if runtimeLease == "expired" { - interval = "-1 second" - } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO runtime_instance (id, account_id, binding_id, binding_version, runtime_id, lease_until) - VALUES ($1, $2, $3, 1, $4, now() + $5::interval)`, "runtime-gate-"+suffix, accountID, - "binding-"+suffix, "runtime-gate-"+suffix, interval); err != nil { - t.Fatal(err) - } - } - } - if versionChanged { - if _, err := tx.ExecContext(ctx, `UPDATE environment_binding SET version = 2 WHERE account_id = $1`, accountID); err != nil { - t.Fatal(err) - } - } - if err := tx.Commit(); err != nil { - t.Fatal(err) - } - confirmationVersion := int64(1) - if confirmationChanged { - confirmationVersion = 2 - } - if _, _, err := store.Enqueue(ctx, Task{ - ID: taskID, IdempotencyKey: "idempotency-gate-" + suffix, AccountID: accountID, AccountVersion: 1, - DraftID: "draft-gate-" + suffix, DraftVersion: 1, ConfirmationID: "confirmation-gate-" + suffix, - ConfirmationVersion: confirmationVersion, - }); err != nil { - t.Fatal(err) - } - return taskID -} - -func assertTaskGate(t *testing.T, store *Store, taskID, wantState, wantReason string) { - t.Helper() - var state string - if err := store.db.QueryRow(`SELECT state FROM operation_task WHERE id = $1`, taskID).Scan(&state); err != nil || state != wantState { - t.Fatalf("task gate state: got=%q want=%q err=%v", state, wantState, err) - } - var attempts int - if err := store.db.QueryRow(`SELECT count(*) FROM execution_attempt WHERE task_id = $1`, taskID).Scan(&attempts); err != nil || attempts != 0 { - t.Fatalf("gated task executed: attempts=%d err=%v", attempts, err) - } - var reason string - if err := store.db.QueryRow(`SELECT reason_code FROM audit_event WHERE task_id = $1 AND event_type = 'task_quarantined' ORDER BY id DESC LIMIT 1`, taskID).Scan(&reason); err != nil || reason != wantReason { - t.Fatalf("task gate reason: got=%q want=%q err=%v", reason, wantReason, err) - } -} - -func applyHubMigrationsForPhaseATest(t *testing.T, store *Store) { - t.Helper() - for _, migrationFile := range []struct { - version int - name string - }{{2, "002_hub.sql"}, {3, "003_unified_accounts.sql"}, {4, "004_environment_actions.sql"}, {5, "005_sanitize_legacy_proxy.sql"}, - {6, "006_runtime_cleanup.sql"}, {7, "007_runtime_binding_version.sql"}, {8, "008_runtime_cleanup_generation.sql"}, - {9, "009_runtime_cleanup_compatibility.sql"}, {10, "010_runtime_network_generation.sql"}, {11, "011_task_recovery.sql"}, - {12, "012_task_recovery_compatibility.sql"}, {13, "013_account_creation.sql"}, {14, "014_account_creation_compatibility.sql"}, - {15, "015_gateway_rename_cascade.sql"}, {16, "016_network_exit_plain_credentials.sql"}, {17, "017_native_browser_versions.sql"}, - {33, "033_unique_fingerprint_seed.sql"}, {34, "034_gateway_browser_default.sql"}} { - var applied bool - if err := store.db.QueryRow(`SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migrationFile.version).Scan(&applied); err != nil { - t.Fatal(err) - } - if applied { - continue - } - migration, err := os.ReadFile(filepath.Join("..", "environment", "migrations", migrationFile.name)) - if err != nil { - t.Fatal(err) - } - if _, err := store.db.Exec(string(migration)); err != nil { - t.Fatalf("apply test migration %d: %v", migrationFile.version, err) - } - if _, err := store.db.Exec(`INSERT INTO schema_migration (version) VALUES ($1)`, migrationFile.version); err != nil { - t.Fatal(err) - } - } -} - -func readinessReason(err error) string { - var readiness *ReadinessError - if errors.As(err, &readiness) { - return readiness.Reason - } - return "" -} - func assertCount(t *testing.T, store *Store, query string, expected int, args ...any) { t.Helper() var actual int diff --git a/internal/controlplane/api/account_deletion.go b/internal/controlplane/api/account_deletion.go index 21d4126..482d189 100644 --- a/internal/controlplane/api/account_deletion.go +++ b/internal/controlplane/api/account_deletion.go @@ -43,9 +43,6 @@ func registerAccountDeletion(app *fiber.App, phaseAStore *accountdomain.Store, h if gatewayErr != nil { return hubError(c, gatewayErr) } - if err := creatorStore.InvalidateListener(c.Context(), accountID, "账号删除"); err != nil { - return creatorError(c, err) - } if _, err := removeGatewayRuntime(c.Context(), hubStore, gateway, environment); err != nil { return hubError(c, err) } diff --git a/internal/controlplane/api/accounts/register.go b/internal/controlplane/api/accounts/register.go index 053f52a..fc55b03 100644 --- a/internal/controlplane/api/accounts/register.go +++ b/internal/controlplane/api/accounts/register.go @@ -9,7 +9,7 @@ import ( ) func Register(app *fiber.App, store *account.Store, runtimeStore *environment.Store, credentials account.CredentialBridge) { - api.RegisterAccounts(app, store, runtimeStore, credentials) + api.RegisterAccountRoutes(app, store, runtimeStore, credentials) } func RegisterDeletion(app *fiber.App, store *account.Store, runtimeStore *environment.Store, creatorStore *creator.Store, credentials account.CredentialBridge) { diff --git a/internal/controlplane/api/accounts_operations.go b/internal/controlplane/api/accounts_operations.go index 2fbce3a..0df2432 100644 --- a/internal/controlplane/api/accounts_operations.go +++ b/internal/controlplane/api/accounts_operations.go @@ -22,26 +22,9 @@ type accountRequest struct { Cookies string `json:"cookies"` } -type draftRequest struct { - AccountID string `json:"account_id"` - Content string `json:"content"` -} -type confirmationRequest struct { - DraftID string `json:"draft_id"` - AccountVersion int64 `json:"account_version"` - DraftVersion int64 `json:"draft_version"` -} - -type taskRequest struct { - ConfirmationID string `json:"confirmation_id"` -} - -type taskVerificationRequest struct { - Result string `json:"result"` -} - -func registerPhaseA(app *fiber.App, store *accountdomain.Store, runtimeStore RuntimeStopStore, credentials accountdomain.CredentialBridge) { +// 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 { var input accountRequest if err := decodePhaseA(c, &input); err != nil { @@ -138,153 +121,6 @@ func registerPhaseA(app *fiber.App, store *accountdomain.Store, runtimeStore Run return c.SendStatus(fiber.StatusNoContent) }) - app.Post("/api/phase-a/drafts", func(c fiber.Ctx) error { - var input draftRequest - if err := decodePhaseA(c, &input); err != nil { - return phaseAError(c, err) - } - draft, err := store.CreateDraftVersion(c.Context(), input.AccountID, input.Content) - if err != nil { - return phaseAError(c, err) - } - return c.Status(fiber.StatusCreated).JSON(draft) - }) - - app.Get("/api/phase-a/drafts", func(c fiber.Ctx) error { - drafts, err := store.ListDrafts(c.Context(), c.Query("account_id")) - if err != nil { - return phaseAError(c, err) - } - return c.JSON(drafts) - }) - - app.Get("/api/phase-a/drafts/:id", func(c fiber.Ctx) error { - draft, err := store.GetDraftDetail(c.Context(), c.Params("id")) - if err != nil { - return phaseAError(c, err) - } - return c.JSON(draft) - }) - - app.Post("/api/phase-a/confirmations", func(c fiber.Ctx) error { - var input confirmationRequest - if err := decodePhaseA(c, &input); err != nil { - return phaseAError(c, err) - } - confirmation, inserted, err := store.ConfirmDraft(c.Context(), input.DraftID, input.AccountVersion, input.DraftVersion) - if err != nil { - return phaseAError(c, err) - } - status := fiber.StatusOK - if inserted { - status = fiber.StatusCreated - } - return c.Status(status).JSON(confirmation) - }) - - app.Get("/api/phase-a/confirmations", func(c fiber.Ctx) error { - confirmations, err := store.ListConfirmations(c.Context(), c.Query("draft_id")) - if err != nil { - return phaseAError(c, err) - } - return c.JSON(confirmations) - }) - - app.Get("/api/phase-a/confirmations/:id", func(c fiber.Ctx) error { - confirmation, err := store.GetConfirmation(c.Context(), c.Params("id")) - if err != nil { - return phaseAError(c, err) - } - return c.JSON(confirmation) - }) - - app.Post("/api/phase-a/tasks", func(c fiber.Ctx) error { - var input taskRequest - if err := decodePhaseA(c, &input); err != nil { - return phaseAError(c, err) - } - task, inserted, err := store.EnqueueConfirmation(c.Context(), input.ConfirmationID) - if err != nil { - return phaseAError(c, err) - } - status := fiber.StatusOK - if inserted { - status = fiber.StatusCreated - } - return c.Status(status).JSON(task) - }) - - app.Get("/api/phase-a/tasks", func(c fiber.Ctx) error { - tasks, err := store.ListTasksFiltered(c.Context(), c.Query("account_id"), c.Query("draft_id"), c.Query("state")) - if err != nil { - return phaseAError(c, err) - } - return c.JSON(tasks) - }) - - app.Get("/api/phase-a/tasks/:id", func(c fiber.Ctx) error { - task, err := store.GetTaskDetail(c.Context(), c.Params("id")) - if err != nil { - return phaseAError(c, err) - } - return c.JSON(task) - }) - - app.Get("/api/phase-a/attempts/:id", func(c fiber.Ctx) error { - attempt, err := store.GetTaskAttemptDetail(c.Context(), c.Params("id")) - if err != nil { - return phaseAError(c, err) - } - return c.JSON(attempt) - }) - - app.Post("/api/phase-a/tasks/:id/cancel", func(c fiber.Ctx) error { - if err := store.CancelTask(c.Context(), c.Params("id")); err != nil { - return phaseAError(c, err) - } - return c.SendStatus(fiber.StatusNoContent) - }) - - app.Post("/api/phase-a/tasks/:id/verify", func(c fiber.Ctx) error { - var input taskVerificationRequest - if err := decodePhaseA(c, &input); err != nil { - return phaseAError(c, err) - } - if err := store.VerifyTask(c.Context(), c.Params("id"), input.Result); err != nil { - return phaseAError(c, err) - } - return c.SendStatus(fiber.StatusNoContent) - }) - - app.Post("/api/phase-a/tasks/:id/resume", func(c fiber.Ctx) error { - if err := store.ResumeTask(c.Context(), c.Params("id")); err != nil { - return phaseAError(c, err) - } - return c.SendStatus(fiber.StatusNoContent) - }) - - app.Post("/api/phase-a/tasks/:id/finish", func(c fiber.Ctx) error { - if err := store.FinishTask(c.Context(), c.Params("id")); err != nil { - return phaseAError(c, err) - } - return c.SendStatus(fiber.StatusNoContent) - }) - - app.Post("/api/phase-a/mock/execute", func(c fiber.Ctx) error { - var input struct { - WorkerID string `json:"worker_id"` - Outcome string `json:"outcome"` - } - if err := decodePhaseA(c, &input); err != nil { - return phaseAError(c, err) - } - execution, err := store.ExecuteMock(c.Context(), input.WorkerID, input.Outcome) - if err != nil { - return phaseAError(c, err) - } - return c.JSON(execution) - }) - app.Get("/api/phase-a/audit", func(c fiber.Ctx) error { filter, err := auditFilter(c) if err != nil { @@ -404,12 +240,7 @@ func phaseAError(c fiber.Ctx, err error) error { return c.Status(status).JSON(map[string]string{"error": message}) } -// RegisterAccounts exposes account and operation routes to the grouped API package. -func RegisterAccounts(app *fiber.App, store *accountdomain.Store, runtimeStore RuntimeStopStore, credentials accountdomain.CredentialBridge) { - registerPhaseA(app, store, runtimeStore, credentials) -} - -// PhaseAError maps account/operation errors to the existing HTTP contract. +// PhaseAError maps account errors to the existing HTTP contract. func PhaseAError(c fiber.Ctx, err error) error { return phaseAError(c, err) } diff --git a/internal/controlplane/api/app_migrated_test.go b/internal/controlplane/api/app_migrated_test.go index 2f1c24d..326987b 100644 --- a/internal/controlplane/api/app_migrated_test.go +++ b/internal/controlplane/api/app_migrated_test.go @@ -45,11 +45,11 @@ func TestCreatorReadRoutesAgainstPostgres(t *testing.T) { t.Cleanup(func() { _ = creatorStore.Close() }) app := newHandlerWithCreator(t.TempDir(), "operator", "unit-test-password", phaseAStore, hubStore, nil, creatorStore) for _, path := range []string{ - "/api/creator/settings", "/api/creator/accounts", "/api/creator/competitors", "/api/creator/relations", - "/api/creator/accounts/route-account/strategies", "/api/creator/rules", "/api/creator/rule-results", "/api/creator/leads", + "/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", - "/api/creator/listener-boundaries", "/api/creator/events", "/api/creator/events?page=1&page_size=10", - "/api/creator/listeners", "/api/creator/operations", "/api/creator/conversations", + + } { response := do(app, http.MethodGet, path, "", "operator", "unit-test-password") if response.Code != http.StatusOK { @@ -91,7 +91,7 @@ func TestCreatorRouteValidationCoverage(t *testing.T) { } app := newHandlerWithCreator(t.TempDir(), "operator", "unit-test-password", phaseAStore, hubStore, nil, creatorStore) for _, path := range []string{ - "/api/creator/accounts/route-account/profile", "/api/creator/accounts/route-account/strategies", + "/api/creator/accounts/route-account/profile", "/api/creator/accounts/route-account/collection-status", "/api/creator/accounts/route-account/metrics", } { response := do(app, http.MethodGet, path, "", "operator", "unit-test-password") @@ -101,7 +101,6 @@ func TestCreatorRouteValidationCoverage(t *testing.T) { } for _, path := range []string{ "/api/creator/works/missing/metrics", "/api/creator/rule-results?comment_id=missing", - "/api/creator/events/missing/strategy-trace", "/api/creator/conversations/missing/messages", } { response := do(app, http.MethodGet, path, "", "operator", "unit-test-password") if response.Code != http.StatusOK { @@ -111,17 +110,13 @@ func TestCreatorRouteValidationCoverage(t *testing.T) { for _, path := range []string{ "/api/creator/competitors/missing", "/api/creator/works/missing", "/api/creator/comments/missing", "/api/creator/rules/missing", - "/api/creator/operations/missing", "/api/creator/operations/missing/verification", } { response := do(app, http.MethodGet, path, "", "operator", "unit-test-password") if response.Code != http.StatusNotFound { t.Fatalf("GET %s returned %d: %s", path, response.Code, response.Body.String()) } } - response := do(app, http.MethodGet, "/api/creator/works/missing/material", "", "operator", "unit-test-password") - if response.Code != http.StatusBadRequest { - t.Fatalf("GET material for missing work returned %d: %s", response.Code, response.Body.String()) - } + for _, route := range []struct { method string path string @@ -130,23 +125,12 @@ func TestCreatorRouteValidationCoverage(t *testing.T) { {http.MethodPut, "/api/creator/accounts/missing/profile"}, {http.MethodPost, "/api/creator/accounts/missing/login-result"}, {http.MethodPost, "/api/creator/accounts/missing/big-account"}, - {http.MethodPost, "/api/creator/relations"}, - {http.MethodPost, "/api/creator/accounts/missing/strategies"}, - {http.MethodPost, "/api/creator/strategies/missing/enable"}, - {http.MethodPost, "/api/creator/strategies/missing/disable"}, - {http.MethodDelete, "/api/creator/strategies/missing"}, - {http.MethodPut, "/api/creator/strategies/missing"}, {http.MethodPost, "/api/creator/competitor-share-jobs"}, {http.MethodPost, "/api/creator/competitors/missing/pause"}, {http.MethodPost, "/api/creator/competitors/missing/resume"}, {http.MethodPost, "/api/creator/competitors/missing/sync"}, {http.MethodPost, "/api/creator/accounts/missing/sync"}, {http.MethodPost, "/api/creator/works/missing/metrics"}, - {http.MethodPost, "/api/creator/works/missing/material/select"}, - {http.MethodPost, "/api/creator/works/missing/material/process"}, - {http.MethodPost, "/api/creator/works/missing/material/rewrite/confirm"}, - {http.MethodPost, "/api/creator/works/missing/material/rewrite/generate"}, - {http.MethodPut, "/api/creator/works/missing/material/rewrite"}, {http.MethodPost, "/api/creator/rules"}, {http.MethodPut, "/api/creator/rules/missing"}, {http.MethodPost, "/api/creator/rules/missing/enable"}, @@ -235,7 +219,7 @@ func TestCreatorFixtureRoutesPostgres(t *testing.T) { if response := do(app, http.MethodPost, "/api/creator/test/works", workBody, "operator", "unit-test-password"); response.Code != http.StatusOK { t.Fatalf("deduplicate fixture work: %d %s", response.Code, response.Body.String()) } - for _, path := range []string{"/api/creator/works/" + workID, "/api/creator/works/" + workID + "/metrics", "/api/creator/works/" + workID + "/material"} { + for _, path := range []string{"/api/creator/works/" + workID, "/api/creator/works/" + workID + "/metrics"} { if response := do(app, http.MethodGet, path, "", "operator", "unit-test-password"); response.Code != http.StatusOK { t.Fatalf("GET %s: %d %s", path, response.Code, response.Body.String()) } @@ -244,16 +228,6 @@ func TestCreatorFixtureRoutesPostgres(t *testing.T) { if response := do(app, http.MethodPost, "/api/creator/works/"+workID+"/metrics", metricBody, "operator", "unit-test-password"); response.Code != http.StatusOK { t.Fatalf("record fixture metric: %d %s", response.Code, response.Body.String()) } - materialResponse := do(app, http.MethodPost, "/api/creator/works/"+workID+"/material/select", `{}`, "operator", "unit-test-password") - if materialResponse.Code != http.StatusCreated && materialResponse.Code != http.StatusOK { - t.Fatalf("select fixture material: %d %s", materialResponse.Code, materialResponse.Body.String()) - } - if _, claimed, err := creatorStore.ClaimMaterialStep(ctx, workID, "download", "fixture-material-token"); err != nil || !claimed { - t.Fatalf("claim fixture material download: claimed=%v err=%v", claimed, err) - } - if _, err := setMaterialFailure(ctx, creatorStore, workID, "download", "fixture-material-token", errors.New("fixture download failed")); err != nil { - t.Fatalf("record fixture material failure: %v", err) - } commentBody := `{"platform":"douyin","comment_key":"fixture-comment","work_id":"` + workID + `","author_uid":"peer","author_name":"Peer","content":"hello","comment_type":"top_level","published_at":"2024-01-01T00:00:00Z"}` commentResponse := do(app, http.MethodPost, "/api/creator/test/comments", commentBody, "operator", "unit-test-password") if commentResponse.Code != http.StatusCreated { @@ -284,31 +258,6 @@ func TestCreatorFixtureRoutesPostgres(t *testing.T) { if _, err := creatorStore.RecordVerifiedLoginResult(ctx, "fixture-small", "fixture-small-platform"); err != nil { t.Fatal(err) } - if err := creatorStore.SetRelation(ctx, "fixture-account", "fixture-small", true); err != nil { - t.Fatal(err) - } - strategyBody := `{"execution_account_id":"fixture-small","position":1,"enabled":true,"event_types":["comment"],"action":"reply_comment","target_type":"comment","candidate_texts":["已收到"]}` - strategyResponse := do(app, http.MethodPost, "/api/creator/accounts/fixture-account/strategies", strategyBody, "operator", "unit-test-password") - if strategyResponse.Code != http.StatusCreated { - t.Fatalf("create fixture strategy: %d %s", strategyResponse.Code, strategyResponse.Body.String()) - } - strategyID := idFrom(strategyResponse) - for _, route := range []struct { - method string - path string - body string - }{ - {http.MethodPut, "/api/creator/strategies/" + strategyID, strategyBody}, - {http.MethodPost, "/api/creator/strategies/" + strategyID + "/enable", `{}`}, - {http.MethodPost, "/api/creator/strategies/" + strategyID + "/disable", `{}`}, - } { - if response := do(app, route.method, route.path, route.body, "operator", "unit-test-password"); response.Code != http.StatusOK { - t.Fatalf("%s %s: %d %s", route.method, route.path, response.Code, response.Body.String()) - } - } - if response := do(app, http.MethodDelete, "/api/creator/strategies/"+strategyID, "", "operator", "unit-test-password"); response.Code != http.StatusNoContent { - t.Fatalf("delete fixture strategy: %d %s", response.Code, response.Body.String()) - } ruleBody := `{"name":"fixture-rule","enabled":true,"source_type":"owned","topic":"title","include_keywords":["hello"],"ai_requirement":"lead"}` ruleResponse := do(app, http.MethodPost, "/api/creator/rules", ruleBody, "operator", "unit-test-password") if ruleResponse.Code != http.StatusCreated { @@ -419,56 +368,6 @@ func TestCreatorFixtureRoutesPostgres(t *testing.T) { if missingSync.Code != http.StatusNotFound && missingSync.Code != http.StatusConflict && missingSync.Code != http.StatusServiceUnavailable { t.Fatalf("sync missing account: %d %s", missingSync.Code, missingSync.Body.String()) } - eventBody := `{"platform":"douyin","receiving_account_id":"fixture-account","event_key":"fixture-event","event_type":"comment","interactor_uid":"peer","work_id":"` + workID + `","comment_id":"` + commentID + `"}` - eventResponse := do(app, http.MethodPost, "/api/creator/test/events", eventBody, "operator", "unit-test-password") - if eventResponse.Code != http.StatusCreated { - t.Fatalf("create fixture event: %d %s", eventResponse.Code, eventResponse.Body.String()) - } - var eventEnvelope struct { - Event struct { - ID string `json:"id"` - } `json:"event"` - } - if err := json.Unmarshal(eventResponse.Body.Bytes(), &eventEnvelope); err != nil || eventEnvelope.Event.ID == "" { - t.Fatalf("event has no ID: %s (%v)", eventResponse.Body.String(), err) - } - eventID := eventEnvelope.Event.ID - if response := do(app, http.MethodPost, "/api/creator/events/"+eventID+"/display", `{}`, "operator", "unit-test-password"); response.Code != http.StatusOK { - t.Fatalf("display fixture event: %d %s", response.Code, response.Body.String()) - } - operationBody := `{"idempotency_key":"fixture-operation","source":"manual","action":"reply_comment","platform":"douyin","account_id":"fixture-account","target_uid":"peer","target_comment_id":"` + commentID + `","target_work_id":"` + workID + `","text":"reply"}` - operationResponse := do(app, http.MethodPost, "/api/creator/operations", operationBody, "operator", "unit-test-password") - if operationResponse.Code != http.StatusCreated { - t.Fatalf("create fixture operation: %d %s", operationResponse.Code, operationResponse.Body.String()) - } - operationID := idFrom(operationResponse) - for _, path := range []string{"/api/creator/operations/" + operationID, "/api/creator/operations/" + operationID + "/verification"} { - if response := do(app, http.MethodGet, path, "", "operator", "unit-test-password"); response.Code != http.StatusOK { - t.Fatalf("GET %s: %d %s", path, response.Code, response.Body.String()) - } - } - if response := do(app, http.MethodPost, "/api/creator/operations/"+operationID+"/execute", `{}`, "operator", "unit-test-password"); response.Code != http.StatusOK { - t.Fatalf("execute fixture operation without executor: %d %s", response.Code, response.Body.String()) - } - if response := do(app, http.MethodPost, "/api/creator/test/events/process", eventBody, "operator", "unit-test-password"); response.Code != http.StatusOK && response.Code != http.StatusServiceUnavailable { - t.Fatalf("process fixture event without executor: %d %s", response.Code, response.Body.String()) - } - messageBody := `{"platform":"douyin","account_id":"fixture-account","peer_uid":"peer","peer_name":"Peer","platform_message_key":"fixture-message","direction":"inbound","message_type":"text","text":"hello"}` - messageResponse := do(app, http.MethodPost, "/api/creator/test/messages", messageBody, "operator", "unit-test-password") - if messageResponse.Code != http.StatusCreated { - t.Fatalf("create fixture message: %d %s", messageResponse.Code, messageResponse.Body.String()) - } - var message struct { - ConversationID string `json:"conversation_id"` - } - if err := json.Unmarshal(messageResponse.Body.Bytes(), &message); err != nil || message.ConversationID == "" { - t.Fatalf("message has no conversation: %s (%v)", messageResponse.Body.String(), err) - } - for _, path := range []string{"/api/creator/conversations/" + message.ConversationID + "/messages", "/api/creator/conversations/" + message.ConversationID + "/messages?page=1&page_size=10"} { - if response := do(app, http.MethodGet, path, "", "operator", "unit-test-password"); response.Code != http.StatusOK { - t.Fatalf("GET %s: %d %s", path, response.Code, response.Body.String()) - } - } if err := runCreatorScheduleOnce(ctx, creatorStore, phaseAStore, hubStore); err != nil && !errors.Is(err, creator.ErrUnavailable) { t.Fatalf("creator schedule fixture: %v", err) } @@ -539,9 +438,6 @@ func newHandlerWithStores(_ string, _ string, _ string, accountStore *account.St if environmentStore != nil { RegisterEnvironments(appInstance, environmentStore) } - if accountStore != nil { - RegisterAccounts(appInstance, accountStore, environmentStore, nil) - } return appInstance } diff --git a/internal/controlplane/api/creator.go b/internal/controlplane/api/creator.go index 4c07ea1..376d6ac 100644 --- a/internal/controlplane/api/creator.go +++ b/internal/controlplane/api/creator.go @@ -49,14 +49,10 @@ func creatorPageQuery(c fiber.Ctx) (page, pageSize int, enabled bool, err error) } func registerCreator(app *fiber.App, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store) { - var executor creator.ActionExecutor - if store != nil && phaseAStore != nil && hubStore != nil { - executor = creatorGatewayActionExecutor{store: store, phaseAStore: phaseAStore, hubStore: hubStore} - } - registerCreatorWithServices(app, store, phaseAStore, hubStore, executor, nil, nil) + registerCreatorWithServices(app, store, phaseAStore, hubStore, nil) } -func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, executor creator.ActionExecutor, generator creator.TextGenerator, analyzer creator.ThemeAnalyzer) { +func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, analyzer creator.ThemeAnalyzer) { // Platform records enter through the managed collector/listener, not a public // client-supplied write. The explicit test namespace is kept for isolated // contract tests and never participates in the production listener. @@ -239,64 +235,6 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto } return c.JSON(profile) }) - app.Get("/api/creator/relations", func(c fiber.Ctx) error { - relations, err := store.ListRelations(c.Context(), c.Query("big_account_id")) - if err != nil { - return creatorError(c, err) - } - return c.JSON(relations) - }) - app.Post("/api/creator/relations", func(c fiber.Ctx) error { - var input struct { - creator.Relation - Enabled bool `json:"enabled"` - } - if err := decodeCreator(c, &input); err != nil { - return creatorError(c, err) - } - if err := store.SetRelation(c.Context(), input.BigAccountID, input.SmallAccountID, input.Enabled); err != nil { - return creatorError(c, err) - } - return c.SendStatus(fiber.StatusNoContent) - }) - app.Get("/api/creator/accounts/:id/strategies", func(c fiber.Ctx) error { - strategies, err := store.ListStrategies(c.Context(), c.Params("id")) - if err != nil { - return creatorError(c, err) - } - return c.JSON(strategies) - }) - app.Post("/api/creator/accounts/:id/strategies", func(c fiber.Ctx) error { - var input creator.StrategyInput - if err := decodeCreator(c, &input); err != nil { - return creatorError(c, err) - } - strategy, err := store.CreateStrategy(c.Context(), c.Params("id"), input) - if err != nil { - return creatorError(c, err) - } - return c.Status(fiber.StatusCreated).JSON(strategy) - }) - app.Put("/api/creator/strategies/:id", func(c fiber.Ctx) error { - var input creator.StrategyInput - if err := decodeCreator(c, &input); err != nil { - return creatorError(c, err) - } - strategy, err := store.UpdateStrategy(c.Context(), c.Params("id"), input) - if err != nil { - return creatorError(c, err) - } - return c.JSON(strategy) - }) - app.Post("/api/creator/strategies/:id/enable", func(c fiber.Ctx) error { return setStrategyEnabled(c, store, true) }) - app.Post("/api/creator/strategies/:id/disable", func(c fiber.Ctx) error { return setStrategyEnabled(c, store, false) }) - app.Delete("/api/creator/strategies/:id", func(c fiber.Ctx) error { - if err := store.DeleteStrategy(c.Context(), c.Params("id")); err != nil { - return creatorError(c, err) - } - return c.SendStatus(fiber.StatusNoContent) - }) - app.Get("/api/creator/competitors", func(c fiber.Ctx) error { items, err := store.ListCompetitorsWithProfile(c.Context(), c.Query("platform")) if err != nil { @@ -470,66 +408,6 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto c.Set(fiber.HeaderCacheControl, "private, max-age=86400") return c.Send(data) }) - app.Get("/api/creator/works/:id/material", func(c fiber.Ctx) error { - item, err := store.GetMaterial(c.Context(), c.Params("id")) - if err != nil { - return creatorError(c, err) - } - return c.JSON(item) - }) - app.Post("/api/creator/works/:id/material/select", func(c fiber.Ctx) error { - item, inserted, err := store.SelectMaterial(c.Context(), c.Params("id")) - if err != nil { - return creatorError(c, err) - } - status := fiber.StatusOK - if inserted { - status = fiber.StatusCreated - } - return c.Status(status).JSON(item) - }) - app.Post("/api/creator/works/:id/material/process", func(c fiber.Ctx) error { - item, err := processCreatorMaterial(c.Context(), store, phaseAStore, hubStore, c.Params("id")) - if err != nil { - return creatorError(c, err) - } - return c.JSON(item) - }) - app.Post("/api/creator/works/:id/material/rewrite/confirm", func(c fiber.Ctx) error { - var input struct { - Requirement string `json:"requirement"` - } - if err := decodeCreator(c, &input); err != nil { - return creatorError(c, err) - } - item, err := store.ConfirmRewrite(c.Context(), c.Params("id"), input.Requirement) - if err != nil { - return creatorError(c, err) - } - return c.JSON(item) - }) - app.Post("/api/creator/works/:id/material/rewrite/generate", func(c fiber.Ctx) error { - item, err := store.GenerateRewrite(c.Context(), c.Params("id"), generator) - if err != nil { - return creatorError(c, err) - } - return c.JSON(item) - }) - app.Put("/api/creator/works/:id/material/rewrite", func(c fiber.Ctx) error { - var input struct { - Title string `json:"title"` - Script string `json:"script"` - } - if err := decodeCreator(c, &input); err != nil { - return creatorError(c, err) - } - item, err := store.SaveRewrite(c.Context(), c.Params("id"), input.Title, input.Script) - if err != nil { - return creatorError(c, err) - } - return c.JSON(item) - }) - app.Get("/api/creator/comments", func(c fiber.Ctx) error { page, pageSize, paged, err := creatorPageQuery(c) if err != nil { @@ -672,359 +550,6 @@ func registerCreatorWithServices(app *fiber.App, store *creator.Store, phaseASto return c.Status(status).JSON(result) }) - app.Get("/api/creator/listener-boundaries", func(c fiber.Ctx) error { - items, err := store.ListListenerBoundaries(c.Context(), c.Query("account_id")) - if err != nil { - return creatorError(c, err) - } - return c.JSON(items) - }) - app.Get("/api/creator/events", func(c fiber.Ctx) error { - page, pageSize, paged, err := creatorPageQuery(c) - if err != nil { - return creatorError(c, err) - } - if paged { - items, err := store.ListEventsPage(c.Context(), c.Query("account_id"), page, pageSize) - if err != nil { - return creatorError(c, err) - } - return c.JSON(items) - } - items, err := store.ListEvents(c.Context(), c.Query("account_id")) - if err != nil { - return creatorError(c, err) - } - return c.JSON(items) - }) - app.Get("/api/creator/listeners", func(c fiber.Ctx) error { - items, err := store.ListListenerStates(c.Context(), c.Query("account_id")) - if err != nil { - return creatorError(c, err) - } - return c.JSON(items) - }) - app.Post("/api/creator/test/events", func(c fiber.Ctx) error { - var input creator.InteractionEvent - if err := decodeCreator(c, &input); err != nil { - return creatorError(c, err) - } - result, err := store.RecordEvent(c.Context(), input) - if err != nil { - return creatorError(c, err) - } - creatorUpdates.publish() - status := fiber.StatusOK - if !result.Duplicate { - status = fiber.StatusCreated - } - return c.Status(status).JSON(result) - }) - app.Post("/api/creator/test/events/process", func(c fiber.Ctx) error { - var input creator.InteractionEvent - if err := decodeCreator(c, &input); err != nil { - return creatorError(c, err) - } - result, err := store.ProcessAutomaticEvent(c.Context(), input, executor, generator) - if err != nil && !errors.Is(err, creator.ErrUnavailable) { - return creatorError(c, err) - } - status := fiber.StatusOK - if errors.Is(err, creator.ErrUnavailable) { - status = fiber.StatusServiceUnavailable - } - creatorUpdates.publish() - return c.Status(status).JSON(result) - }) - app.Post("/api/creator/events/:id/display", func(c fiber.Ctx) error { - event, err := store.SetEventDisplayed(c.Context(), c.Params("id"), time.Now().UTC()) - if err != nil { - return creatorError(c, err) - } - creatorUpdates.publish() - return c.JSON(event) - }) - app.Get("/api/creator/events/:id/strategy-trace", func(c fiber.Ctx) error { - items, err := store.ListStrategyTraces(c.Context(), c.Params("id")) - if err != nil { - return creatorError(c, err) - } - return c.JSON(items) - }) - app.Get("/api/creator/operations", func(c fiber.Ctx) error { - items, err := store.ListOperations(c.Context(), c.Query("account_id")) - if err != nil { - return creatorError(c, err) - } - return c.JSON(items) - }) - app.Post("/api/creator/operations", func(c fiber.Ctx) error { - var input creator.OperationInput - if err := decodeCreator(c, &input); err != nil { - return creatorError(c, err) - } - item, inserted, err := store.CreateOperation(c.Context(), input) - if err != nil { - return creatorError(c, err) - } - status := fiber.StatusOK - if inserted { - status = fiber.StatusCreated - } - return c.Status(status).JSON(item) - }) - app.Get("/api/creator/operations/:id", func(c fiber.Ctx) error { - item, err := store.GetOperation(c.Context(), c.Params("id")) - if err != nil { - return creatorError(c, err) - } - return c.JSON(item) - }) - app.Get("/api/creator/operations/:id/verification", func(c fiber.Ctx) error { - item, err := store.GetOperation(c.Context(), c.Params("id")) - if err != nil { - return creatorError(c, err) - } - return c.JSON(map[string]any{ - "operation_id": item.ID, - "state": item.State, - "verification_state": item.VerificationState, - "evidence": item.VerificationProof, - "reason": item.Reason, - "verified_at": item.VerifiedAt, - }) - }) - app.Post("/api/creator/operations/:id/execute", func(c fiber.Ctx) error { - item, err := store.ExecuteManualOperation(c.Context(), c.Params("id"), executor) - if err != nil { - return creatorError(c, err) - } - return c.JSON(item) - }) - - app.Get("/api/creator/conversations", func(c fiber.Ctx) error { - items, err := store.ListConversations(c.Context(), c.Query("account_id")) - if err != nil { - return creatorError(c, err) - } - return c.JSON(items) - }) - app.Get("/api/creator/conversations/:id/messages", func(c fiber.Ctx) error { - page, pageSize, paged, err := creatorPageQuery(c) - if err != nil { - return creatorError(c, err) - } - if paged { - items, err := store.ListMessagesPage(c.Context(), c.Params("id"), page, pageSize) - if err != nil { - return creatorError(c, err) - } - return c.JSON(items) - } - items, err := store.ListMessages(c.Context(), c.Params("id")) - if err != nil { - return creatorError(c, err) - } - return c.JSON(items) - }) - app.Post("/api/creator/conversations/:id/sync", func(c fiber.Ctx) error { - conversation, err := store.GetConversation(c.Context(), c.Params("id")) - if err != nil { - return creatorError(c, err) - } - if conversation.Platform != creator.PlatformDouyin { - return creatorError(c, creator.ErrUnavailable) - } - limit := 100 - if value := c.Query("limit"); value != "" { - limit, err = strconv.Atoi(value) - if err != nil || limit < 1 || limit > 200 { - return creatorError(c, creator.ErrInvalid) - } - } - profile, err := store.GetAccountProfile(c.Context(), conversation.AccountID) - if err != nil { - return creatorError(c, err) - } - account, err := phaseAStore.GetAccount(c.Context(), conversation.AccountID) - if err != nil { - return creatorError(c, err) - } - if account.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" || - profile.Platform != creator.PlatformDouyin || (profile.BusinessStatus != "normal" && profile.BusinessStatus != "muted") || profile.LoginStatus != "logged_in" || - profile.PlatformAccountKey == "" || account.PlatformAccountKey != profile.PlatformAccountKey { - return creatorError(c, creator.ErrConflict) - } - environment, err := hubStore.GetEnvironmentContextForAccount(c.Context(), conversation.AccountID) - if err != nil { - return creatorError(c, err) - } - gateway, err := hubStore.GetGateway(c.Context(), environment.Gateway) - if err != nil { - return creatorError(c, err) - } - useCtx, runtimeUse, err := beginRuntimeUseForEnvironment(c.Context(), hubStore, environment, "task", "creator-conversation-"+conversation.ID) - if err != nil { - return creatorError(c, fmt.Errorf("%w: runtime use unavailable: %v", creator.ErrUnavailable, err)) - } - browser := creatorGatewayBrowser{gateway: gateway, environment: environment} - accountUID, err := browser.Identity(useCtx, profile.PlatformAccountKey) - if err != nil { - _ = runtimeUse.Close() - return creatorError(c, fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, err)) - } - history, err := browser.MessageHistory(useCtx, accountUID, conversation.PeerUID, conversation.HistoryCursor, limit) - if err != nil { - _ = runtimeUse.Close() - return creatorError(c, err) - } - if history.AccountUID != accountUID { - _ = runtimeUse.Close() - return creatorError(c, creator.ErrConflict) - } - if err := runtimeUse.Close(); err != nil { - return creatorError(c, err) - } - inserted, err := persistDouyinMessageHistory(c.Context(), store, conversation, accountUID, history.Messages) - if err != nil { - return creatorError(c, err) - } - if err := store.UpdateConversationHistoryCursor(c.Context(), conversation.ID, history.HistoryCursor, history.HistoryHasMore); err != nil { - return creatorError(c, err) - } - creatorUpdates.publish() - return c.JSON(map[string]any{ - "conversation_id": conversation.ID, - "messages": inserted, - "history_source": history.HistorySource, - "history_cursor": history.HistoryCursor, - "history_has_more": history.HistoryHasMore, - }) - }) - app.Post("/api/creator/messages/send", func(c fiber.Ctx) error { - var input creator.MessageInput - if err := decodeCreator(c, &input); err != nil { - return creatorError(c, err) - } - if strings.TrimSpace(input.PlatformMessageKey) == "" || input.Direction != "" && input.Direction != "outbound" || input.MessageType != "text" || input.SentState != "" || input.MessageAt != nil { - return creatorError(c, creator.ErrInvalid) - } - operation, inserted, err := store.CreateOperation(c.Context(), creator.OperationInput{ - IdempotencyKey: "message:" + input.Platform + ":" + input.AccountID + ":" + input.PeerUID + ":" + input.PlatformMessageKey, - Source: "manual", - Action: creator.ActionDM, - Platform: input.Platform, - AccountID: input.AccountID, - TargetUID: input.PeerUID, - Text: input.Text, - }) - if err != nil { - return creatorError(c, err) - } - operation, err = store.ExecuteManualOperation(c.Context(), operation.ID, executor) - if err != nil { - return creatorError(c, err) - } - creatorUpdates.publish() - status := fiber.StatusOK - if inserted { - status = fiber.StatusCreated - } - return c.Status(status).JSON(map[string]any{"operation": operation}) - }) - app.Post("/api/creator/test/messages", func(c fiber.Ctx) error { - var input creator.MessageInput - if err := decodeCreator(c, &input); err != nil { - return creatorError(c, err) - } - if input.Direction != "inbound" { - return creatorError(c, creator.ErrInvalid) - } - item, inserted, err := store.SaveMessage(c.Context(), input) - if err != nil { - return creatorError(c, err) - } - creatorUpdates.publish() - status := fiber.StatusOK - if inserted { - status = fiber.StatusCreated - } - return c.Status(status).JSON(item) - }) -} - -func persistDouyinMessageHistory(ctx context.Context, store *creator.Store, conversation creator.Conversation, accountUID string, messages []douyinHistoryMessage) (int, error) { - if store == nil || conversation.Platform != creator.PlatformDouyin || strings.TrimSpace(accountUID) == "" || strings.TrimSpace(conversation.PeerUID) == "" { - return 0, creator.ErrInvalid - } - inserted := 0 - for _, item := range messages { - if strings.TrimSpace(item.ServerID) == "" || strings.TrimSpace(item.SenderUID) == "" { - return inserted, creator.ErrInvalid - } - messageType := creator.MessageTypeUnknown - text := "" - if len(item.Content) > 0 && string(item.Content) != "null" { - var payload struct { - Text string `json:"text"` - } - var encoded string - if err := json.Unmarshal(item.Content, &encoded); err == nil { - if err := json.Unmarshal([]byte(encoded), &payload); err != nil { - return inserted, creator.ErrInvalid - } - } else if err := json.Unmarshal(item.Content, &payload); err != nil { - return inserted, creator.ErrInvalid - } - text = payload.Text - if text != "" { - messageType = creator.MessageTypeText - } - } - var messageAt *time.Time - if item.CreatedAt != "" { - milliseconds, err := strconv.ParseInt(item.CreatedAt, 10, 64) - if err != nil || milliseconds <= 0 { - return inserted, creator.ErrInvalid - } - value := time.UnixMilli(milliseconds).UTC() - messageAt = &value - } - direction, state := "inbound", "received" - if item.SenderUID == accountUID { - direction, state = "outbound", "succeeded" - } - savedMessage, wasInserted, err := store.SaveMessage(ctx, creator.MessageInput{ - Platform: creator.PlatformDouyin, - AccountID: conversation.AccountID, - PeerUID: conversation.PeerUID, - PeerName: conversation.PeerName, - PlatformMessageKey: item.ServerID, - Direction: direction, - MessageType: messageType, - Text: text, - SentState: state, - MessageAt: messageAt, - }) - if err != nil { - return inserted, err - } - if err := store.LinkMessageOperation(ctx, savedMessage.ID, item.ServerID); err != nil { - return inserted, err - } - if wasInserted { - inserted++ - } - } - return inserted, nil -} - -func setStrategyEnabled(c fiber.Ctx, store *creator.Store, enabled bool) error { - item, err := store.SetStrategyEnabled(c.Context(), c.Params("id"), enabled) - if err != nil { - return creatorError(c, err) - } - return c.JSON(item) } func workFilter(c fiber.Ctx) (creator.WorkFilter, error) { @@ -1117,132 +642,6 @@ type creatorGatewayBrowser struct { environment hub.EnvironmentContext } -type creatorGatewayActionExecutor struct { - store *creator.Store - phaseAStore *accountdomain.Store - hubStore *hub.Store -} - -func (executor creatorGatewayActionExecutor) Execute(ctx context.Context, request creator.ActionRequest) (result creator.ActionResult, resultErr error) { - if request.Platform != creator.PlatformDouyin || executor.store == nil || executor.phaseAStore == nil || executor.hubStore == nil { - return creator.ActionResult{}, creator.ErrUnavailable - } - profile, err := executor.store.GetAccountProfile(ctx, request.AccountID) - if err != nil { - return creator.ActionResult{}, err - } - account, err := executor.phaseAStore.GetAccount(ctx, request.AccountID) - if err != nil { - return creator.ActionResult{}, err - } - if profile.Platform != creator.PlatformDouyin || account.Platform != creator.PlatformDouyin || profile.PlatformAccountKey == "" || profile.PlatformAccountKey != account.PlatformAccountKey { - return creator.ActionResult{}, creator.ErrConflict - } - environment, err := executor.hubStore.GetEnvironmentContextForAccount(ctx, request.AccountID) - if err != nil { - return creator.ActionResult{}, err - } - if environment.RuntimeID == "" || environment.RuntimeNetworkID == "" || environment.BindingVersion <= 0 { - return creator.ActionResult{}, fmt.Errorf("%w: account runtime is not running", creator.ErrUnavailable) - } - gateway, err := executor.hubStore.GetGateway(ctx, environment.Gateway) - if err != nil { - return creator.ActionResult{}, err - } - useCtx, runtimeUse, err := beginRuntimeUseForEnvironment(ctx, executor.hubStore, environment, "task", request.OperationID) - if err != nil { - return creator.ActionResult{}, fmt.Errorf("%w: runtime use unavailable: %v", creator.ErrUnavailable, err) - } - defer func() { resultErr = errors.Join(resultErr, runtimeUse.Close()) }() - browser := creatorGatewayBrowser{gateway: gateway, environment: environment} - uid, identityErr := browser.Identity(useCtx, profile.PlatformAccountKey) - if identityErr != nil { - return creator.ActionResult{}, fmt.Errorf("%w: verify the manually logged-in browser identity: %v", creator.ErrConflict, identityErr) - } - if _, verifyErr := executor.store.RecordVerifiedLoginResult(useCtx, request.AccountID, uid); verifyErr != nil { - return creator.ActionResult{}, fmt.Errorf("persist verified account identity: %w", verifyErr) - } - payload := gatewayGenerationPayload(environment) - payload["expected_uid"] = uid - payload["operation_id"] = request.OperationID - payload["action"] = request.Action - payload["target_uid"] = request.TargetUID - // UI and persistence use opaque internal IDs; the platform gateway receives only - // the verified platform keys and the comment's owning work key. - if request.TargetCommentID != "" { - comment, targetErr := executor.store.GetComment(ctx, request.TargetCommentID) - if errors.Is(targetErr, creator.ErrNotFound) { - comment, targetErr = executor.store.GetCommentByKey(ctx, request.Platform, request.TargetCommentID) - } - if targetErr == nil { - payload["target_comment_id"] = comment.CommentKey - if request.TargetWorkID == "" { - request.TargetWorkID = comment.WorkID - } - } else if errors.Is(targetErr, creator.ErrNotFound) { - // The event may arrive before collection. Keep the opaque platform key; - // the gateway must verify ownership against the logged-in account. - payload["target_comment_id"] = request.TargetCommentID - } else { - return creator.ActionResult{}, targetErr - } - } - if request.TargetWorkID != "" { - work, targetErr := executor.store.GetWork(ctx, request.TargetWorkID) - if errors.Is(targetErr, creator.ErrNotFound) { - work, targetErr = executor.store.GetWorkByKey(ctx, request.Platform, request.TargetWorkID) - } - if targetErr == nil { - payload["target_work_id"] = work.WorkKey - } else if errors.Is(targetErr, creator.ErrNotFound) { - payload["target_work_id"] = request.TargetWorkID - } else { - return creator.ActionResult{}, targetErr - } - } - payload["text"] = request.Text - payload["confirm"] = true - status, body, err := gatewayCall(useCtx, gateway, http.MethodPost, "/v1/browsers/"+url.PathEscape(environment.Alias)+"/douyin/action", payload, 30*time.Second) - if err != nil { - return creator.ActionResult{}, err - } - if status != http.StatusOK { - var gatewayError struct { - Error string `json:"error"` - } - if json.Unmarshal(body, &gatewayError) == nil && strings.EqualFold(strings.TrimSpace(gatewayError.Error), "ACTION_UNAVAILABLE") { - return creator.ActionResult{State: "failed", Reason: "ACTION_UNAVAILABLE", Evidence: map[string]string{"gateway_http_status": strconv.Itoa(status)}}, nil - } - return creator.ActionResult{State: "uncertain", Reason: fmt.Sprintf("gateway returned HTTP %d", status)}, nil - } - var response struct { - Status string `json:"status"` - Code string `json:"code"` - Action string `json:"action"` - Evidence any `json:"evidence"` - } - if err := json.Unmarshal(body, &response); err != nil || (response.Status != "succeeded" && response.Status != "failed" && response.Status != "unknown") { - return creator.ActionResult{}, errors.New("gateway returned an invalid action result") - } - evidence := map[string]string{"gateway_status": response.Status} - if response.Action != "" { - evidence["action"] = response.Action - } - if response.Code != "" { - evidence["code"] = response.Code - } - evidenceCount := flattenActionEvidence(evidence, "evidence", response.Evidence) - state := response.Status - reason := response.Code - if state == "unknown" || state == "succeeded" && evidenceCount == 0 || state == "failed" && strings.TrimSpace(response.Code) == "" { - state = "uncertain" - if reason == "" { - reason = "写后确认证据不足" - } - } - return creator.ActionResult{State: state, Evidence: evidence, Reason: reason}, nil -} - func flattenActionEvidence(destination map[string]string, prefix string, value any) int { switch typed := value.(type) { case string: @@ -1356,18 +755,10 @@ func creatorLoginQRCode(ctx context.Context, store *creator.Store, phaseAStore * if err != nil { return nil, fmt.Errorf("%w: gateway unavailable: %v", creator.ErrUnavailable, err) } - useCtx, runtimeUse, err := beginRuntimeUseForEnvironment(ctx, hubStore, environment, "task", "creator-login-qr-"+accountID) - if err != nil { - return nil, fmt.Errorf("%w: login runtime use unavailable: %v", creator.ErrUnavailable, err) - } - response, callErr := (douyinGatewayBrowser{gateway: gateway, environment: environment}).LoginQR(useCtx) - closeErr := runtimeUse.Close() + response, callErr := (douyinGatewayBrowser{gateway: gateway, environment: environment}).LoginQR(ctx) if callErr != nil { return nil, fmt.Errorf("%w: capture the Douyin login screen: %v", creator.ErrUnavailable, callErr) } - if closeErr != nil { - return nil, fmt.Errorf("%w: release login runtime use: %v", creator.ErrUnavailable, closeErr) - } return map[string]any{ "status": "manual_login", "content_type": response.ContentType, @@ -1403,18 +794,10 @@ func verifyCreatorAccount(ctx context.Context, store *creator.Store, phaseAStore if err != nil { return creator.LoginResult{}, fmt.Errorf("%w: gateway unavailable: %v", creator.ErrUnavailable, err) } - useCtx, runtimeUse, err := beginRuntimeUseForEnvironment(ctx, hubStore, environment, "task", "creator-verify-"+accountID) - if err != nil { - return creator.LoginResult{}, fmt.Errorf("%w: verification runtime use unavailable: %v", creator.ErrUnavailable, err) - } - uid, identityErr := verifyCreatorPlatformIdentity(useCtx, account.Platform, gateway, environment, profile.PlatformAccountKey) - closeErr := runtimeUse.Close() + uid, identityErr := verifyCreatorPlatformIdentity(ctx, account.Platform, gateway, environment, profile.PlatformAccountKey) if identityErr != nil { return creator.LoginResult{}, fmt.Errorf("%w: verify the manually logged-in browser identity: %v", creator.ErrConflict, identityErr) } - if closeErr != nil { - return creator.LoginResult{}, fmt.Errorf("%w: release verification runtime use: %v", creator.ErrUnavailable, closeErr) - } result, err := store.RecordVerifiedLoginResult(ctx, accountID, uid) if err != nil { return creator.LoginResult{}, fmt.Errorf("persist verified account identity: %w", err) @@ -2162,11 +1545,6 @@ func refreshCreatorMetricWork(ctx context.Context, store *creator.Store, phaseAS if err != nil { return fmt.Errorf("%w: gateway unavailable: %v", creator.ErrUnavailable, err) } - useCtx, runtimeUse, err := beginRuntimeUseForEnvironment(ctx, hubStore, environment, "task", "creator-metric-"+work.ID) - if err != nil { - return fmt.Errorf("%w: runtime use unavailable: %v", creator.ErrUnavailable, err) - } - defer func() { resultErr = errors.Join(resultErr, runtimeUse.Close()) }() targetAccountKey, homepageURL := account.PlatformAccountKey, "" if work.SourceType == creator.SourceCompetitor { competitor, competitorErr := store.GetCompetitor(ctx, work.SourceID) @@ -2178,13 +1556,13 @@ func refreshCreatorMetricWork(ctx context.Context, store *creator.Store, phaseAS } targetAccountKey, homepageURL = competitor.PlatformAccountKey, competitor.HomepageURL } - collector, collectionKey, err := newCreatorCollector(useCtx, work.Platform, gateway, environment, account.PlatformAccountKey, targetAccountKey, homepageURL, work.SourceType, work.SourceID) + collector, collectionKey, err := newCreatorCollector(ctx, work.Platform, gateway, environment, account.PlatformAccountKey, targetAccountKey, homepageURL, work.SourceType, work.SourceID) if err != nil { return fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, err) } cursor := "" for page := 0; page < 100; page++ { - result, pageErr := collector.ListWorks(useCtx, collectionKey, cursor) + result, pageErr := collector.ListWorks(ctx, collectionKey, cursor) if pageErr != nil { return pageErr } @@ -2195,7 +1573,7 @@ func refreshCreatorMetricWork(ctx context.Context, store *creator.Store, phaseAS if item.Likes == nil && item.CommentsCount == nil && item.Shares == nil && item.PlayCount == nil { return creator.ErrUnavailable } - _, metricErr := store.RecordMetric(useCtx, creator.MetricInput{WorkID: work.ID, CollectedAt: now, Likes: item.Likes, CommentsCount: item.CommentsCount, Shares: item.Shares, CollectCount: item.CollectCount, PlayCount: item.PlayCount}, settings, now) + _, metricErr := store.RecordMetric(ctx, creator.MetricInput{WorkID: work.ID, CollectedAt: now, Likes: item.Likes, CommentsCount: item.CommentsCount, Shares: item.Shares, CollectCount: item.CollectCount, PlayCount: item.PlayCount}, settings, now) return metricErr } if !result.HasMore { @@ -2251,28 +1629,24 @@ func syncCreatorOwned(ctx context.Context, store *creator.Store, phaseAStore *ac logrus.WithError(releaseErr).WithField("account_id", account.ID).Warn("creator source sync lease release failed") } }() - useCtx, runtimeUse, err := beginRuntimeUseForEnvironment(ctx, hubStore, environment, "task", "creator-owned-"+account.ID) - if err != nil { - return fmt.Errorf("%w: runtime use unavailable: %v", creator.ErrUnavailable, err) - } - collector, _, err := newCreatorCollector(useCtx, account.Platform, gateway, environment, account.PlatformAccountKey, account.PlatformAccountKey, "", creator.SourceOwned, account.ID) + collector, _, err := newCreatorCollector(ctx, account.Platform, gateway, environment, account.PlatformAccountKey, account.PlatformAccountKey, "", creator.SourceOwned, account.ID) if err != nil { blockErr := store.MarkCollectionBlocked(ctx, creator.SourceOwned, account.ID, err.Error(), now, settings.LookbackDays) - return errors.Join(fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, err), blockErr, runtimeUse.Close()) + return errors.Join(fmt.Errorf("%w: account identity verification failed: %v", creator.ErrConflict, err), blockErr) } _, collectionNow, windowErr := store.NextCollectionWindow(ctx, creator.SourceOwned, account.ID, now, time.Duration(settings.NewWorkIntervalSeconds)*time.Second, settings.LookbackDays) if windowErr != nil { - return errors.Join(windowErr, runtimeUse.Close()) + return windowErr } - _, err = store.CollectSource(useCtx, account.Platform, creator.SourceOwned, account.ID, collector, collectionNow) + _, err = store.CollectSource(ctx, account.Platform, creator.SourceOwned, account.ID, collector, collectionNow) collectErr := err // 作品采集成功后顺带拉 self profile 记账号画像快照(粉丝/关注/获赞/作品总数)。 // 失败不吞:join 进返回错误,由调度器日志可见,但不影响已入库的作品数据。 profileErr := error(nil) if collectErr == nil { - profileErr = recordCreatorAccountMetricSnapshot(useCtx, store, creatorGatewayBrowser{gateway: gateway, environment: environment}, account.ID) + profileErr = recordCreatorAccountMetricSnapshot(ctx, store, creatorGatewayBrowser{gateway: gateway, environment: environment}, account.ID) } - return errors.Join(collectErr, profileErr, runtimeUse.Close()) + return errors.Join(collectErr, profileErr) } // recordCreatorAccountMetricSnapshot 拉取登录账号自己的画像并记一次时序快照。 @@ -2333,21 +1707,7 @@ func RegisterCreator(app *fiber.App, store *creator.Store, accountStore *account registerCreator(app, store, accountStore, environmentStore) } -// RegisterCreatorWithAI exposes creator routes with the configured service implementations. -func RegisterCreatorWithAI(app *fiber.App, store *creator.Store, accountStore *accountdomain.Store, environmentStore *hub.Store, generator creator.TextGenerator, analyzer creator.ThemeAnalyzer) { - var executor creator.ActionExecutor - if store != nil && accountStore != nil && environmentStore != nil { - executor = creatorGatewayActionExecutor{store: store, phaseAStore: accountStore, hubStore: environmentStore} - } - registerCreatorWithServices(app, store, accountStore, environmentStore, executor, generator, analyzer) -} - // RunCreatorScheduler runs the creator scheduler until its context is canceled. func RunCreatorScheduler(ctx context.Context, store *creator.Store, accountStore *accountdomain.Store, environmentStore *hub.Store) { runCreatorScheduler(ctx, store, accountStore, environmentStore) } - -// NewCreatorGatewayActionExecutor builds the native gateway-backed action executor. -func NewCreatorGatewayActionExecutor(store *creator.Store, accountStore *accountdomain.Store, environmentStore *hub.Store) creator.ActionExecutor { - return creatorGatewayActionExecutor{store: store, phaseAStore: accountStore, hubStore: environmentStore} -} diff --git a/internal/controlplane/api/creator/register.go b/internal/controlplane/api/creator/register.go index e31ca3f..f70d201 100644 --- a/internal/controlplane/api/creator/register.go +++ b/internal/controlplane/api/creator/register.go @@ -11,7 +11,3 @@ import ( func Register(app *fiber.App, store *creatorDomain.Store, accountStore *account.Store, environmentStore *environment.Store) { api.RegisterCreator(app, store, accountStore, environmentStore) } - -func RegisterWithAI(app *fiber.App, store *creatorDomain.Store, accountStore *account.Store, environmentStore *environment.Store, generator creatorDomain.TextGenerator, analyzer creatorDomain.ThemeAnalyzer) { - api.RegisterCreatorWithAI(app, store, accountStore, environmentStore, generator, analyzer) -} diff --git a/internal/controlplane/api/creator_events.go b/internal/controlplane/api/creator_events.go deleted file mode 100644 index d4e8324..0000000 --- a/internal/controlplane/api/creator_events.go +++ /dev/null @@ -1,607 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strings" - "sync" - "sync/atomic" - "time" - - accountdomain "git.ipao.vip/rogee/creator-hub/internal/account" - "git.ipao.vip/rogee/creator-hub/internal/creator" - hub "git.ipao.vip/rogee/creator-hub/internal/environment" - "github.com/sirupsen/logrus" -) - -const creatorEventReconcileInterval = 10 * time.Second - -type creatorGatewayEvent struct { - DeliveryID string `json:"delivery_id,omitempty"` - Generation string `json:"generation,omitempty"` - Kind string `json:"kind"` - Reason string `json:"reason,omitempty"` - Continuity string `json:"continuity,omitempty"` - BoundaryAt string `json:"boundary_at,omitempty"` - BoundarySource string `json:"boundary_source,omitempty"` - Baseline bool `json:"baseline,omitempty"` - Notice *creatorGatewayEventNotice `json:"notice,omitempty"` -} - -type creatorGatewayEventNotice struct { - EventKey string `json:"event_key"` - EventType string `json:"event_type"` - InteractorUID string `json:"interactor_uid"` - CommentID string `json:"comment_id"` - WorkID string `json:"work_id"` - MessageType string `json:"message_type,omitempty"` - MessageText string `json:"message_text,omitempty"` - PlatformEventAt string `json:"platform_event_at,omitempty"` - GatewayReceivedAt string `json:"gateway_received_at,omitempty"` -} - -type creatorEventBinding struct { - accountID string - uid string - env hub.EnvironmentContext - gateway hub.Gateway - hubStore *hub.Store - sessionToken string -} - -type creatorUpdateHub struct { - mu sync.Mutex - subscribers map[chan struct{}]struct{} -} - -var creatorUpdates = &creatorUpdateHub{subscribers: make(map[chan struct{}]struct{})} - -func (h *creatorUpdateHub) subscribe() (<-chan struct{}, func()) { - channel := make(chan struct{}, 1) - h.mu.Lock() - h.subscribers[channel] = struct{}{} - h.mu.Unlock() - return channel, func() { - h.mu.Lock() - if _, ok := h.subscribers[channel]; ok { - delete(h.subscribers, channel) - close(channel) - } - h.mu.Unlock() - } -} - -func (h *creatorUpdateHub) publish() { - h.mu.Lock() - defer h.mu.Unlock() - for channel := range h.subscribers { - select { - case channel <- struct{}{}: - default: - } - } -} - -func creatorListenerGeneration(env hub.EnvironmentContext) string { - return fmt.Sprintf("%s:%s:%d", env.RuntimeID, env.RuntimeNetworkID, env.BindingVersion) -} - -var creatorListenerSessionNonce atomic.Uint64 - -func creatorListenerSessionToken(env hub.EnvironmentContext) string { - return fmt.Sprintf("%s/%d-%d", creatorListenerGeneration(env), time.Now().UnixNano(), creatorListenerSessionNonce.Add(1)) -} - -func listenerBoundaryPointer(value time.Time) *time.Time { - if value.IsZero() { - return nil - } - value = value.UTC() - return &value -} - -func persistCreatorListenerState(ctx context.Context, store *creator.Store, binding creatorEventBinding, status, reason string, boundaryAt *time.Time, deliveryID string) { - if store == nil { - return - } - if _, err := store.UpsertListenerState(ctx, creator.ListenerState{ - AccountID: binding.accountID, - Platform: creator.PlatformDouyin, - Generation: creatorListenerGeneration(binding.env), - SessionToken: binding.sessionToken, - Status: status, - BoundaryAt: boundaryAt, - LastDeliveryID: deliveryID, - Reason: reason, - }); err != nil { - logrus.WithError(err).WithField("account_id", binding.accountID).Warn("creator listener state persistence failed") - } -} - -func (binding creatorEventBinding) key() string { - return fmt.Sprintf("%s\x00%s\x00%s\x00%d\x00%s\x00%s\x00%s\x00%s", binding.gateway.Name, binding.gateway.Endpoint, binding.gateway.Token, binding.env.BindingVersion, binding.env.RuntimeID, binding.env.RuntimeNetworkID, binding.env.Exit.ID, binding.uid) -} - -type creatorEventListenerHandle struct { - cancel context.CancelFunc - done chan struct{} - key string -} - -type creatorEventListenerManager struct { - mu sync.Mutex - items map[string]creatorEventListenerHandle -} - -func RunCreatorEventListeners(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, executor creator.ActionExecutor, generator creator.TextGenerator) { - manager := &creatorEventListenerManager{items: map[string]creatorEventListenerHandle{}} - ticker := time.NewTicker(creatorEventReconcileInterval) - defer ticker.Stop() - defer manager.close() - for { - if err := manager.reconcile(ctx, store, phaseAStore, hubStore, executor, generator); err != nil && ctx.Err() == nil { - logrus.WithField("service", "control-plane").WithError(err).Warn("creator event listener reconciliation failed") - } - select { - case <-ctx.Done(): - return - case <-ticker.C: - } - } -} - -func (manager *creatorEventListenerManager) reconcile(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, executor creator.ActionExecutor, generator creator.TextGenerator) error { - if store == nil || phaseAStore == nil || hubStore == nil { - return creator.ErrUnavailable - } - if recovered, err := store.RecoverStaleProcessing(ctx, time.Now().UTC()); err != nil { - return err - } else if recovered > 0 { - logrus.WithField("count", recovered).Warn("recovered stale creator operations as uncertain") - } - accounts, err := phaseAStore.ListAccounts(ctx) - if err != nil { - return err - } - desired := make(map[string]creatorEventBinding) - for _, account := range accounts { - if account.Platform != creator.PlatformDouyin || account.AuthorizationStatus != "authorized" { - continue - } - profile, profileErr := store.GetAccountProfile(ctx, account.ID) - if profileErr != nil { - logrus.WithError(profileErr).WithField("account_id", account.ID).Warn("creator event listener account profile unavailable") - continue - } - if profile.LoginStatus != "logged_in" { - continue - } - if !creatorEventUID(profile.PlatformAccountKey) { - logrus.WithField("account_id", account.ID).Warn("creator event listener account UID is unavailable") - continue - } - environment, environmentErr := hubStore.GetEnvironmentContextForAccount(ctx, account.ID) - if environmentErr != nil { - logrus.WithError(environmentErr).WithField("account_id", account.ID).Warn("creator event listener environment unavailable") - continue - } - if environment.RuntimeID == "" || environment.RuntimeNetworkID == "" || environment.BindingVersion <= 0 { - continue - } - gateway, gatewayErr := hubStore.GetGateway(ctx, environment.Gateway) - if gatewayErr != nil { - logrus.WithError(gatewayErr).WithField("account_id", account.ID).Warn("creator event listener gateway unavailable") - continue - } - desired[account.ID] = creatorEventBinding{accountID: account.ID, uid: profile.PlatformAccountKey, env: environment, gateway: gateway, hubStore: hubStore} - } - - var stopping []creatorEventListenerHandle - manager.mu.Lock() - for accountID, current := range manager.items { - binding, ok := desired[accountID] - if ok && current.key == binding.key() { - state, stateErr := store.GetListenerState(ctx, accountID) - if stateErr != nil || !state.Invalidated { - continue - } - } - current.cancel() - delete(manager.items, accountID) - stopping = append(stopping, current) - } - manager.mu.Unlock() - for _, current := range stopping { - <-current.done - } - - manager.mu.Lock() - defer manager.mu.Unlock() - for accountID, binding := range desired { - if _, ok := manager.items[accountID]; ok { - continue - } - listenerContext, cancel := context.WithCancel(ctx) - done := make(chan struct{}) - listenerBinding := binding - manager.items[accountID] = creatorEventListenerHandle{cancel: cancel, done: done, key: binding.key()} - go func() { - defer close(done) - runCreatorEventListener(listenerContext, store, listenerBinding, executor, generator) - }() - } - return nil -} - -func (manager *creatorEventListenerManager) close() { - manager.mu.Lock() - handles := make([]creatorEventListenerHandle, 0, len(manager.items)) - for accountID, current := range manager.items { - current.cancel() - handles = append(handles, current) - delete(manager.items, accountID) - } - manager.mu.Unlock() - for _, current := range handles { - <-current.done - } -} - -func runCreatorEventListener(ctx context.Context, store *creator.Store, binding creatorEventBinding, executor creator.ActionExecutor, generator creator.TextGenerator) { - binding.sessionToken = creatorListenerSessionToken(binding.env) - path := "/v1/browsers/" + url.PathEscape(binding.env.Alias) + "/douyin/events" - generation := gatewayGenerationPayload(binding.env) - useCtx, runtimeUse, err := beginRuntimeUseForEnvironment(ctx, binding.hubStore, binding.env, "listener", "creator-listener-"+binding.accountID) - if err != nil { - persistCreatorListenerState(context.WithoutCancel(ctx), store, binding, "error", "runtime use unavailable: "+err.Error(), nil, "") - return - } - ctx = useCtx - defer func() { - if releaseErr := runtimeUse.Close(); releaseErr != nil { - logrus.WithError(releaseErr).WithField("account_id", binding.accountID).Error("creator listener runtime use release failed") - } - }() - startPayload := make(map[string]any, len(generation)+1) - for key, value := range generation { - startPayload[key] = value - } - startPayload["expected_uid"] = binding.uid - persistCreatorListenerState(ctx, store, binding, "starting", "等待平台边界标记", nil, "") - defer func() { - stopCreatorEventListener(binding.accountID, binding.gateway, path, generation) - persistCreatorListenerState(context.WithoutCancel(ctx), store, binding, "stopped", "监听已停止", nil, "") - }() - - backoff := time.Second - for ctx.Err() == nil { - status, _, err := gatewayCall(ctx, binding.gateway, http.MethodPost, path, startPayload, 30*time.Second) - if err != nil || status != http.StatusOK { - if err == nil { - err = fmt.Errorf("gateway returned HTTP %d", status) - } - logrus.WithError(err).WithField("account_id", binding.accountID).Warn("creator event listener start failed") - persistCreatorListenerState(ctx, store, binding, "error", err.Error(), nil, "") - if !waitCreatorEventBackoff(ctx, backoff) { - return - } - backoff *= 2 - if backoff > 30*time.Second { - backoff = 30 * time.Second - } - continue - } - backoff = time.Second - // Every successful start creates a new boundary. Events observed before - // its marker remain historical even when the previous poll loop was ready. - ready := false - var boundaryAt time.Time - for ctx.Err() == nil { - status, body, err := gatewayCall(ctx, binding.gateway, http.MethodGet, path+"?limit=100&wait=25", generation, 35*time.Second) - if err != nil || status != http.StatusOK { - if err == nil { - err = fmt.Errorf("gateway returned HTTP %d", status) - } - logrus.WithError(err).WithField("account_id", binding.accountID).Warn("creator event listener poll failed") - persistCreatorListenerState(ctx, store, binding, "gap", err.Error(), listenerBoundaryPointer(boundaryAt), "") - break - } - var events []creatorGatewayEvent - if err := json.Unmarshal(body, &events); err != nil { - logrus.WithError(err).WithField("account_id", binding.accountID).Warn("creator event listener response is invalid") - persistCreatorListenerState(ctx, store, binding, "gap", "监听响应无法解析: "+err.Error(), listenerBoundaryPointer(boundaryAt), "") - break - } - for _, event := range events { - if event.Kind == "baseline" { - parsedBoundary, boundaryReady, reason := creatorGatewayBoundary(event) - boundaryAt, ready = parsedBoundary, boundaryReady - status := "gap" - if ready { - status, reason = "ready", "" - } - persistCreatorListenerState(ctx, store, binding, status, reason, listenerBoundaryPointer(boundaryAt), event.DeliveryID) - } else if event.Kind == "open" || event.Kind == "error" || event.Kind == "close" || event.Kind == "reconnected" { - // A transport event never proves continuity. Only the explicit - // boundary marker permits automatic writes again. - ready = false - persistCreatorListenerState(ctx, store, binding, "gap", event.Reason, listenerBoundaryPointer(boundaryAt), event.DeliveryID) - } - if event.Kind == "notice" { - if needsBaseline, reason := creatorGatewayEventNeedsBaseline(event, ready); needsBaseline { - event.Baseline = true - event.Reason = reason - } else if creatorEventBeforeBoundary(event, boundaryAt) { - event.Baseline = true - event.Reason = "平台事件早于监听边界" - } - status := "gap" - reason := event.Reason - if ready && !event.Baseline { - status, reason = "ready", "" - } - persistCreatorListenerState(ctx, store, binding, status, reason, listenerBoundaryPointer(boundaryAt), event.DeliveryID) - } - handleCreatorGatewayEvent(ctx, store, binding, event, executor, generator) - } - } - if !waitCreatorEventBackoff(ctx, backoff) { - return - } - backoff *= 2 - if backoff > 30*time.Second { - backoff = 30 * time.Second - } - } -} - -func stopCreatorEventListener(accountID string, gateway hub.Gateway, path string, generation map[string]any) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if status, _, err := gatewayCall(ctx, gateway, http.MethodDelete, path, generation, 5*time.Second); err != nil || status != http.StatusNoContent { - if err == nil { - err = fmt.Errorf("gateway returned HTTP %d", status) - } - logrus.WithError(err).WithField("account_id", accountID).Warn("creator event listener stop failed") - } -} - -func waitCreatorEventBackoff(ctx context.Context, delay time.Duration) bool { - timer := time.NewTimer(delay) - defer timer.Stop() - select { - case <-ctx.Done(): - return false - case <-timer.C: - return true - } -} - -func pathForCreatorEvent(environment hub.EnvironmentContext) string { - return "/v1/browsers/" + url.PathEscape(environment.Alias) + "/douyin/events" -} - -func handleCreatorGatewayEvent(ctx context.Context, store *creator.Store, binding creatorEventBinding, event creatorGatewayEvent, executor creator.ActionExecutor, generator creator.TextGenerator) { - ack := func() { - if event.DeliveryID == "" { - return - } - // Acknowledgement is deliberately detached from the listener poll loop: - // receipt and event classification must not be serialized behind a slow - // gateway request. The gateway keeps the delivery until this succeeds. - go func(deliveryID string) { - ackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) - defer cancel() - ackPath := pathForCreatorEvent(binding.env) - status, _, ackErr := gatewayCall(ackCtx, binding.gateway, http.MethodGet, ackPath+"?ack="+url.QueryEscape(deliveryID)+"&limit=1&wait=0", gatewayGenerationPayload(binding.env), 5*time.Second) - if ackErr != nil || status != http.StatusOK { - if ackErr == nil { - ackErr = fmt.Errorf("gateway returned HTTP %d", status) - } - logrus.WithError(ackErr).WithField("account_id", binding.accountID).Warn("creator event acknowledgement failed") - } - }(event.DeliveryID) - } - switch event.Kind { - case "error": - logrus.WithFields(logrus.Fields{"account_id": binding.accountID, "reason": event.Reason, "continuity": event.Continuity}).Warn("creator event listener reported an error") - ack() - return - case "reconnected": - logrus.WithField("account_id", binding.accountID).Warn("creator event listener reconnected") - ack() - return - case "open", "baseline": - ack() - return - case "close": - logrus.WithField("account_id", binding.accountID).Warn("creator event listener connection closed") - ack() - return - case "notice": - default: - logrus.WithFields(logrus.Fields{"account_id": binding.accountID, "kind": event.Kind}).Warn("creator event listener returned an unknown event") - ack() - return - } - if event.Notice == nil { - logrus.WithField("account_id", binding.accountID).Warn("creator event listener notice is missing") - ack() - return - } - input, err := creatorEventFromGatewayNotice(binding.accountID, *event.Notice) - if err != nil { - logrus.WithError(err).WithField("account_id", binding.accountID).Warn("creator event listener notice was rejected") - ack() - return - } - input.Baseline = event.Baseline - input.BaselineReason = event.Reason - input.Generation = strings.TrimSpace(event.Generation) - if input.Generation == "" { - input.Generation = creatorListenerGeneration(binding.env) - } - selfEvent := binding.uid != "" && input.InteractorUID == binding.uid - if selfEvent { - input.Baseline = true - input.BaselineReason = "接收账号主动行为" - } - if input.GatewayReceivedAt == nil { - receivedAt := time.Now().UTC() - input.GatewayReceivedAt = &receivedAt - } - if input.PlatformEventAt == nil { - input.Baseline = true - input.BaselineReason = "缺少平台事件时间" - } - if store == nil { - return - } - // Receipt is durable before the potentially slow action. This prevents an - // executor outage from erasing the platform notification and lets polling - // continue while a prior action is still in flight. - received, err := store.RecordEvent(ctx, input) - if err != nil { - logrus.WithError(err).WithFields(logrus.Fields{"account_id": binding.accountID, "event_key": input.EventKey}).Warn("creator event receipt failed") - return - } - creatorUpdates.publish() - if input.EventType == "dm" && input.InteractorUID != "" { - messageAt := input.PlatformEventAt - if messageAt == nil { - receivedAt := input.ReceivedAt - if receivedAt.IsZero() { - receivedAt = time.Now().UTC() - } - messageAt = &receivedAt - } - direction, sentState := "inbound", "received" - if selfEvent { - direction, sentState = "outbound", "succeeded" - } - savedMessage, _, messageErr := store.SaveMessage(ctx, creator.MessageInput{Platform: input.Platform, AccountID: input.ReceivingAccountID, PeerUID: input.InteractorUID, PlatformMessageKey: input.EventKey, Direction: direction, MessageType: input.MessageType, Text: input.MessageText, SentState: sentState, MessageAt: messageAt}) - if messageErr != nil { - logrus.WithError(messageErr).WithFields(logrus.Fields{"account_id": binding.accountID, "event_key": input.EventKey}).Warn("creator direct message persistence failed") - return - } - if selfEvent { - if err := store.LinkMessageOperation(ctx, savedMessage.ID, input.EventKey); err != nil { - logrus.WithError(err).WithFields(logrus.Fields{"account_id": binding.accountID, "event_key": input.EventKey}).Warn("creator outbound direct message correlation failed") - return - } - } - } - // The event is now durable. An action may be slow or unavailable, but that - // must not hold ingestion or cause the same receipt to be fetched forever. - ack() - if input.Baseline || received.Event.State != "received" { - return - } - go func() { - result, processErr := store.ProcessAutomaticEvent(ctx, input, executor, generator) - if processErr != nil { - logrus.WithError(processErr).WithFields(logrus.Fields{"account_id": binding.accountID, "event_key": input.EventKey}).Warn("creator event processing failed") - return - } - logrus.WithFields(logrus.Fields{"account_id": binding.accountID, "event_key": input.EventKey, "event_type": input.EventType, "state": result.Event.State}).Info("creator event processed") - creatorUpdates.publish() - }() -} - -func creatorGatewayBoundary(event creatorGatewayEvent) (time.Time, bool, string) { - if event.BoundarySource != "douyin_identity_extra_now" { - return time.Time{}, false, "平台边界来源未验证" - } - if event.BoundaryAt == "" { - return time.Time{}, false, "平台边界无效" - } - parsedBoundary, err := time.Parse(time.RFC3339Nano, event.BoundaryAt) - if err != nil { - return time.Time{}, false, "平台边界无效" - } - return parsedBoundary.UTC(), true, "" -} - -func creatorEventBeforeBoundary(event creatorGatewayEvent, boundaryAt time.Time) bool { - if boundaryAt.IsZero() || event.Notice == nil { - return false - } - platformAt, err := time.Parse(time.RFC3339Nano, event.Notice.PlatformEventAt) - return err != nil || !platformAt.After(boundaryAt) -} - -func creatorGatewayEventNeedsBaseline(event creatorGatewayEvent, ready bool) (bool, string) { - if event.Baseline { - if event.Reason != "" { - return true, event.Reason - } - return true, "监听基线" - } - if !ready { - return true, "监听边界未确认" - } - if event.Notice == nil || strings.TrimSpace(event.Notice.PlatformEventAt) == "" { - return true, "缺少平台事件时间" - } - return false, "" -} - -func creatorEventFromGatewayNotice(accountID string, notice creatorGatewayEventNotice) (creator.InteractionEvent, error) { - if accountID == "" || !creatorEventID(notice.EventKey) || !creator.ValidEventType(notice.EventType) { - return creator.InteractionEvent{}, creator.ErrInvalid - } - if notice.InteractorUID != "" && !creatorEventUID(notice.InteractorUID) { - return creator.InteractionEvent{}, creator.ErrInvalid - } - if (notice.CommentID != "" && !creatorEventID(notice.CommentID)) || (notice.WorkID != "" && !creatorEventID(notice.WorkID)) { - return creator.InteractionEvent{}, creator.ErrInvalid - } - messageType := strings.TrimSpace(notice.MessageType) - if messageType == "" { - messageType = creator.MessageTypeText - } - if !creator.ValidMessageType(messageType) { - return creator.InteractionEvent{}, creator.ErrInvalid - } - result := creator.InteractionEvent{Platform: creator.PlatformDouyin, ReceivingAccountID: accountID, EventKey: notice.EventKey, EventType: notice.EventType, InteractorUID: notice.InteractorUID, CommentID: notice.CommentID, WorkID: notice.WorkID, MessageType: messageType, MessageText: strings.TrimSpace(notice.MessageText)} - if strings.TrimSpace(notice.PlatformEventAt) != "" { - at, err := time.Parse(time.RFC3339Nano, notice.PlatformEventAt) - if err != nil { - return creator.InteractionEvent{}, fmt.Errorf("invalid platform event time: %w", err) - } - at = at.UTC() - result.PlatformEventAt = &at - } - if strings.TrimSpace(notice.GatewayReceivedAt) != "" { - receivedAt, err := time.Parse(time.RFC3339Nano, notice.GatewayReceivedAt) - if err != nil { - return creator.InteractionEvent{}, fmt.Errorf("invalid gateway receipt time: %w", err) - } - receivedAt = receivedAt.UTC() - result.GatewayReceivedAt = &receivedAt - } - return result, nil -} - -func creatorEventUID(value string) bool { - return creatorEventDigits(value, 20) -} - -func creatorEventID(value string) bool { - return creatorEventDigits(value, 64) -} - -func creatorEventDigits(value string, max int) bool { - if value == "" || len(value) > max || value[0] < '1' || value[0] > '9' { - return false - } - for _, char := range value[1:] { - if char < '0' || char > '9' { - return false - } - } - return true -} diff --git a/internal/controlplane/api/creator_events_test.go b/internal/controlplane/api/creator_events_test.go deleted file mode 100644 index 8f76721..0000000 --- a/internal/controlplane/api/creator_events_test.go +++ /dev/null @@ -1,190 +0,0 @@ -package api - -import ( - "context" - "testing" - "time" - - "git.ipao.vip/rogee/creator-hub/internal/creator" - hub "git.ipao.vip/rogee/creator-hub/internal/environment" -) - -func TestCreatorListenerPrimitives(t *testing.T) { - env := hub.EnvironmentContext{Env: hub.Env{Alias: "browser/a"}, BindingVersion: 3, RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", RuntimeNetworkID: "native-dddddddddddddddddddddddddddddddd", Exit: hub.NetworkExit{ID: "exit"}} - generation := creatorListenerGeneration(env) - if generation != "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd:native-dddddddddddddddddddddddddddddddd:3" { - t.Fatalf("unexpected generation: %q", generation) - } - tokenA, tokenB := creatorListenerSessionToken(env), creatorListenerSessionToken(env) - if tokenA == tokenB { - t.Fatal("listener session tokens must be unique") - } - if listenerBoundaryPointer(time.Time{}) != nil { - t.Fatal("zero boundary must remain nil") - } - boundary := listenerBoundaryPointer(time.Date(2024, 1, 1, 1, 0, 0, 0, time.FixedZone("test", 3600))) - if boundary == nil || !boundary.Equal(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) { - t.Fatalf("boundary was not normalized: %v", boundary) - } - binding := creatorEventBinding{accountID: "account", uid: "uid", env: env, gateway: hub.Gateway{Name: "gateway", Endpoint: "http://gateway", Token: "token"}, sessionToken: "session"} - if binding.key() == "" || pathForCreatorEvent(env) != "/v1/browsers/browser%2Fa/douyin/events" { - t.Fatalf("unexpected listener identity: key=%q path=%q", binding.key(), pathForCreatorEvent(env)) - } -} - -func TestCreatorEventListenerShutdownAndEventKinds(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - if waitCreatorEventBackoff(ctx, time.Hour) { - t.Fatal("cancelled listener backoff must stop") - } - manager := &creatorEventListenerManager{items: map[string]creatorEventListenerHandle{ - "account": {cancel: func() {}, done: func() chan struct{} { ch := make(chan struct{}); close(ch); return ch }()}, - }} - manager.close() - if len(manager.items) != 0 { - t.Fatal("listener manager did not clear handles") - } - for _, kind := range []string{"error", "reconnected", "open", "baseline", "close", "unknown"} { - handleCreatorGatewayEvent(context.Background(), nil, creatorEventBinding{}, creatorGatewayEvent{Kind: kind}, nil, nil) - } - if err := manager.reconcile(context.Background(), nil, nil, nil, nil, nil); err != creator.ErrUnavailable { - t.Fatalf("nil listener dependencies: %v", err) - } -} - -func TestCreatorUpdateHubPublishesAndUnsubscribes(t *testing.T) { - hub := &creatorUpdateHub{subscribers: make(map[chan struct{}]struct{})} - updates, unsubscribe := hub.subscribe() - hub.publish() - select { - case <-updates: - default: - t.Fatal("publish did not notify subscriber") - } - unsubscribe() - hub.publish() - select { - case _, ok := <-updates: - if ok { - t.Fatal("unsubscribed channel received an update") - } - default: - } -} - -func TestGatewayGenerationPayloadIncludesCurrentProxyExit(t *testing.T) { - payload := gatewayGenerationPayload(hub.EnvironmentContext{ - BindingVersion: 3, - RuntimeID: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", - RuntimeNetworkID: "native-dddddddddddddddddddddddddddddddd", - Exit: hub.NetworkExit{ID: "exit-current"}, - }) - if payload["binding_version"] != int64(3) || payload["runtime_id"] != "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" || payload["network_id"] != "native-dddddddddddddddddddddddddddddddd" || payload["network_exit_id"] != "exit-current" { - t.Fatalf("unexpected generation payload: %#v", payload) - } -} - -func TestCreatorGatewayEventNeedsBaseline(t *testing.T) { - if ok, reason := creatorGatewayEventNeedsBaseline(creatorGatewayEvent{Kind: "notice"}, true); !ok || reason != "缺少平台事件时间" { - t.Fatalf("missing platform time must be held at baseline: %v %q", ok, reason) - } - if ok, reason := creatorGatewayEventNeedsBaseline(creatorGatewayEvent{Kind: "notice", Notice: &creatorGatewayEventNotice{PlatformEventAt: "2024-01-01T00:00:00Z"}}, false); !ok || reason != "监听边界未确认" { - t.Fatalf("unconfirmed listener boundary must be held: %v %q", ok, reason) - } - if ok, reason := creatorGatewayEventNeedsBaseline(creatorGatewayEvent{Kind: "notice", Notice: &creatorGatewayEventNotice{PlatformEventAt: "2024-01-01T00:00:00Z"}}, true); ok || reason != "" { - t.Fatalf("confirmed event boundary should be actionable: %v %q", ok, reason) - } -} - -func TestCreatorGatewayBoundary(t *testing.T) { - boundary, ready, reason := creatorGatewayBoundary(creatorGatewayEvent{ - BoundaryAt: "2024-01-01T00:00:00Z", - BoundarySource: "douyin_identity_extra_now", - }) - if !ready || !boundary.Equal(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) || reason != "" { - t.Fatalf("unexpected valid boundary: %v %v %q", boundary, ready, reason) - } - if _, ready, reason := creatorGatewayBoundary(creatorGatewayEvent{BoundaryAt: "2024-01-01T00:00:00Z"}); ready || reason != "平台边界来源未验证" { - t.Fatalf("unverified boundary must stay blocked: %v %q", ready, reason) - } -} - -func TestCreatorEventBeforeBoundary(t *testing.T) { - boundary := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) - before := creatorGatewayEvent{Notice: &creatorGatewayEventNotice{PlatformEventAt: "2023-12-31T23:59:59Z"}} - after := creatorGatewayEvent{Notice: &creatorGatewayEventNotice{PlatformEventAt: "2024-01-01T00:00:01Z"}} - invalid := creatorGatewayEvent{Notice: &creatorGatewayEventNotice{PlatformEventAt: "not-a-time"}} - if !creatorEventBeforeBoundary(before, boundary) || !creatorEventBeforeBoundary(invalid, boundary) || creatorEventBeforeBoundary(after, boundary) { - t.Fatalf("unexpected boundary classification") - } -} - -func TestCreatorEventFromGatewayNotice(t *testing.T) { - event, err := creatorEventFromGatewayNotice("account-1", creatorGatewayEventNotice{ - EventKey: "9007199254740993", - EventType: "comment", - InteractorUID: "7654321", - CommentID: "987654", - WorkID: "123456", - PlatformEventAt: "2023-11-14T22:13:20+00:00", - GatewayReceivedAt: "2023-11-14T22:13:21+00:00", - }) - if err != nil { - t.Fatal(err) - } - if event.Platform != "douyin" || event.ReceivingAccountID != "account-1" || event.EventKey != "9007199254740993" || event.InteractorUID != "7654321" || event.CommentID != "987654" || event.WorkID != "123456" { - t.Fatalf("unexpected event: %+v", event) - } - if event.PlatformEventAt == nil || event.PlatformEventAt.UTC().Format("2006-01-02T15:04:05Z07:00") != "2023-11-14T22:13:20Z" { - t.Fatalf("unexpected event time: %+v", event.PlatformEventAt) - } - if event.GatewayReceivedAt == nil || event.GatewayReceivedAt.UTC().Format("2006-01-02T15:04:05Z07:00") != "2023-11-14T22:13:21Z" { - t.Fatalf("unexpected gateway receipt time: %v", event.GatewayReceivedAt) - } -} - -func TestCreatorEventFromGatewayNoticeRejectsInvalidIdentity(t *testing.T) { - _, err := creatorEventFromGatewayNotice("account-1", creatorGatewayEventNotice{ - EventKey: "1", - EventType: "follow", - InteractorUID: "not-a-uid", - }) - if err == nil { - t.Fatal("expected invalid interactor UID") - } -} - -func TestCreatorEventFromGatewayNoticeRejectsInvalidGatewayReceiptTime(t *testing.T) { - _, err := creatorEventFromGatewayNotice("account-1", creatorGatewayEventNotice{ - EventKey: "1", - EventType: "like", - GatewayReceivedAt: "not-a-time", - }) - if err == nil { - t.Fatal("expected invalid gateway receipt time") - } -} - -func TestCreatorEventFromGatewayNoticePreservesMessageType(t *testing.T) { - result, err := creatorEventFromGatewayNotice("account-1", creatorGatewayEventNotice{ - EventKey: "123", - EventType: "dm", - InteractorUID: "456", - MessageType: creator.MessageTypeImage, - }) - if err != nil || result.MessageType != creator.MessageTypeImage { - t.Fatalf("message type was not preserved: result=%#v err=%v", result, err) - } -} - -func TestCreatorEventFromGatewayNoticeRejectsInvalidTime(t *testing.T) { - _, err := creatorEventFromGatewayNotice("account-1", creatorGatewayEventNotice{ - EventKey: "1", - EventType: "like", - PlatformEventAt: "not-a-time", - }) - if err == nil { - t.Fatal("expected invalid platform event time") - } -} diff --git a/internal/controlplane/api/creator_events_unit_test.go b/internal/controlplane/api/creator_events_unit_test.go deleted file mode 100644 index 5d5372c..0000000 --- a/internal/controlplane/api/creator_events_unit_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package api - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "testing" - "time" - - "git.ipao.vip/rogee/creator-hub/internal/creator" - hub "git.ipao.vip/rogee/creator-hub/internal/environment" -) - -func TestCreatorEventFromGatewayNoticeValidatesAndNormalizesInput(t *testing.T) { - input, err := creatorEventFromGatewayNotice("account-1", creatorGatewayEventNotice{ - EventKey: "1", - EventType: "dm", - InteractorUID: "123", - MessageText: " hello ", - PlatformEventAt: "2026-09-17T08:00:00+08:00", - GatewayReceivedAt: "2026-09-17T00:00:01Z", - }) - if err != nil { - t.Fatal(err) - } - if input.MessageType != creator.MessageTypeText || input.MessageText != "hello" || input.PlatformEventAt == nil || input.GatewayReceivedAt == nil { - t.Fatalf("normalized event = %#v", input) - } - if !input.PlatformEventAt.Equal(time.Date(2026, 9, 17, 0, 0, 0, 0, time.UTC)) { - t.Fatalf("platform event time = %v", input.PlatformEventAt) - } - - invalid := []creatorGatewayEventNotice{ - {EventKey: "", EventType: "comment"}, - {EventKey: "1", EventType: "unknown"}, - {EventKey: "1", EventType: "comment", InteractorUID: "0"}, - {EventKey: "1", EventType: "comment", CommentID: "bad"}, - {EventKey: "1", EventType: "comment", MessageType: "bad"}, - {EventKey: "1", EventType: "comment", PlatformEventAt: "bad"}, - {EventKey: "1", EventType: "comment", GatewayReceivedAt: "bad"}, - } - for index, notice := range invalid { - if _, err := creatorEventFromGatewayNotice("account-1", notice); err == nil { - t.Errorf("invalid notice %d returned no error", index) - } - } - if _, err := creatorEventFromGatewayNotice("", creatorGatewayEventNotice{EventKey: "1", EventType: "comment"}); !errors.Is(err, creator.ErrInvalid) { - t.Fatalf("empty account error = %v", err) - } -} - -func TestCreatorEventListenerControlPathsRemainVisible(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - if waitCreatorEventBackoff(ctx, time.Millisecond) { - t.Fatal("canceled listener backoff was reported as ready") - } - if !waitCreatorEventBackoff(context.Background(), 0) { - t.Fatal("zero listener backoff was not ready") - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodDelete { - w.WriteHeader(http.StatusNoContent) - return - } - w.WriteHeader(http.StatusBadGateway) - })) - stopCreatorEventListener("account-1", hub.Gateway{Endpoint: server.URL}, "/v1/events", map[string]any{}) - server.Close() - server = httptest.NewServer(http.NotFoundHandler()) - stopCreatorEventListener("account-1", hub.Gateway{Endpoint: server.URL}, "/v1/events", map[string]any{}) - server.Close() - - binding := creatorEventBinding{accountID: "account-1", env: testRunnableEnvironment()} - runCreatorEventListener(context.Background(), nil, binding, nil, nil) - manager := &creatorEventListenerManager{items: map[string]creatorEventListenerHandle{}} - if err := manager.reconcile(context.Background(), nil, nil, nil, nil, nil); !errors.Is(err, creator.ErrUnavailable) { - t.Fatalf("nil listener dependencies error = %v", err) - } - listenerCtx, listenerCancel := context.WithCancel(context.Background()) - listenerCancel() - RunCreatorEventListeners(listenerCtx, nil, nil, nil, nil, nil) -} - -func TestHandleCreatorGatewayEventClassifiesNonNoticeEventsWithoutStore(t *testing.T) { - binding := creatorEventBinding{accountID: "account-1"} - for _, kind := range []string{"error", "reconnected", "open", "baseline", "close", "unknown"} { - handleCreatorGatewayEvent(context.Background(), nil, binding, creatorGatewayEvent{Kind: kind}, nil, nil) - } - handleCreatorGatewayEvent(context.Background(), nil, binding, creatorGatewayEvent{Kind: "notice"}, nil, nil) - handleCreatorGatewayEvent(context.Background(), nil, binding, creatorGatewayEvent{ - Kind: "notice", - Notice: &creatorGatewayEventNotice{EventKey: "1", EventType: "comment", InteractorUID: "123"}, - }, nil, nil) - - binding.env = hub.EnvironmentContext{Env: hub.Env{Alias: "account-1"}, RuntimeID: "runtime-1", RuntimeNetworkID: "network-1", BindingVersion: 1} - if got := creatorListenerGeneration(binding.env); got != "runtime-1:network-1:1" { - t.Fatalf("listener generation = %q", got) - } -} diff --git a/internal/controlplane/api/creator_helper_test.go b/internal/controlplane/api/creator_helper_test.go index 2f07b67..e597b2e 100644 --- a/internal/controlplane/api/creator_helper_test.go +++ b/internal/controlplane/api/creator_helper_test.go @@ -28,36 +28,6 @@ func TestCreatorHelperBranches(t *testing.T) { if got := flattenActionEvidence(values, "evidence", []any{"ignored"}); got != 0 { t.Fatalf("flatten unsupported count = %d", got) } - if token, err := materialClaimToken(); err != nil || len(token) != 32 { - t.Fatalf("material claim token = %q, %v", token, err) - } - if _, err := creatorMaterialHasAudio(context.Background(), "/does/not/exist"); err == nil { - t.Fatal("missing media must not report audio") - } - mediaPath := filepath.Join(t.TempDir(), "media.bin") - if err := writeCreatorMedia(mediaPath, []byte("media")); err != nil { - t.Fatal(err) - } - if data, err := os.ReadFile(mediaPath); err != nil || string(data) != "media" || !fileExists(mediaPath) { - t.Fatalf("media write: %q %v", data, err) - } - if err := os.WriteFile(filepath.Join(filepath.Dir(mediaPath), "empty"), nil, 0o600); err != nil { - t.Fatal(err) - } - if fileExists(filepath.Join(filepath.Dir(mediaPath), "empty")) || fileExists("/does/not/exist") { - t.Fatal("empty or missing media reported as existing") - } - for _, input := range [][2]string{{"", ""}, {"unsupported", "model"}, {"whisper", "model"}} { - if _, err := transcribeCreatorAudio(context.Background(), "/does/not/exist", input[0], input[1]); err == nil { - t.Fatalf("invalid transcription config accepted: %v", input) - } - } - if err := validateTranscriptionBinary("/does/not/exist"); err == nil { - t.Fatal("missing transcription binary accepted") - } - if _, err := processCreatorMaterial(context.Background(), nil, nil, nil, "../escape"); !errors.Is(err, creator.ErrInvalid) { - t.Fatalf("invalid material path = %v", err) - } if _, err := verifyCreatorPlatformIdentity(context.Background(), "unsupported", hub.Gateway{}, hub.EnvironmentContext{}, "key"); !errors.Is(err, creator.ErrUnavailable) { t.Fatalf("unsupported identity platform = %v", err) } @@ -112,32 +82,9 @@ func TestCreatorPageQueryValidation(t *testing.T) { func TestCreatorControlPlaneGuards(t *testing.T) { ctx := context.Background() - if _, err := persistDouyinMessageHistory(ctx, nil, creator.Conversation{Platform: "unsupported"}, "uid", nil); !errors.Is(err, creator.ErrInvalid) { - t.Fatalf("invalid history store/platform = %v", err) - } - conversation := creator.Conversation{Platform: creator.PlatformDouyin, AccountID: "account", PeerUID: "peer"} - cases := []douyinHistoryMessage{ - {}, - {ServerID: "id", SenderUID: "sender", Content: []byte("not-json")}, - {ServerID: "id", SenderUID: "sender", CreatedAt: "not-a-time"}, - } - for _, item := range cases { - if _, err := persistDouyinMessageHistory(ctx, nil, conversation, "account", []douyinHistoryMessage{item}); !errors.Is(err, creator.ErrInvalid) { - t.Fatalf("invalid history item %v = %v", item, err) - } - } - if _, err := (creatorGatewayActionExecutor{}).Execute(ctx, creator.ActionRequest{}); !errors.Is(err, creator.ErrUnavailable) { - t.Fatalf("empty action executor = %v", err) - } - if err := (creatorMaterialDownloader{}).Download(ctx, creator.Work{Platform: "unsupported"}, "/tmp/media"); !errors.Is(err, creator.ErrUnavailable) { - t.Fatalf("unsupported material platform = %v", err) - } if _, _, err := newCreatorCollector(ctx, "unsupported", hub.Gateway{}, hub.EnvironmentContext{}, "viewer", "target", "", creator.SourceOwned, "id"); !errors.Is(err, creator.ErrUnavailable) { t.Fatalf("unsupported collector platform = %v", err) } - if listenerBoundaryPointer(time.Time{}) != nil { - t.Fatal("zero listener boundary should be nil") - } } func TestCreatorSchedulerAndPreviewGuards(t *testing.T) { diff --git a/internal/controlplane/api/creator_history_test.go b/internal/controlplane/api/creator_history_test.go deleted file mode 100644 index 13bfa94..0000000 --- a/internal/controlplane/api/creator_history_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "os" - "strconv" - "testing" - "time" - - accountdomain "git.ipao.vip/rogee/creator-hub/internal/account" - "git.ipao.vip/rogee/creator-hub/internal/creator" - hub "git.ipao.vip/rogee/creator-hub/internal/environment" -) - -func TestPersistDouyinMessageHistory(t *testing.T) { - databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") - if databaseURL == "" { - t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run history coverage") - } - ctx := context.Background() - databaseURL = isolatedControlPlaneDatabaseURL(t, databaseURL) - phaseAStore, err := accountdomain.Open(ctx, databaseURL) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = phaseAStore.Close() }) - hubStore, err := hub.Open(ctx, databaseURL) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = hubStore.Close() }) - store, err := creator.Open(ctx, databaseURL) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = store.Close() }) - credentials := &testCredentialBridge{values: make(map[string]string)} - if err := phaseAStore.CreateAccount(ctx, accountdomain.Account{ - ID: "history-account", Name: "History Account", Platform: creator.PlatformDouyin, - PlatformAccountKey: "history-platform", Tags: []string{}, Cookies: "", - CredentialReference: accountdomain.CredentialReference{ID: "history-credential", Provider: "os_keyring"}, - CredentialKey: "creatorhub/history-account/cookies", - }, credentials); err != nil { - t.Fatal(err) - } - if err := store.EnsureAccountProfile(ctx, "history-account"); err != nil { - t.Fatal(err) - } - conversation := creator.Conversation{Platform: creator.PlatformDouyin, AccountID: "history-account", PeerUID: "peer", PeerName: "Peer"} - stamp := time.Now().UTC().UnixMilli() - messages := []douyinHistoryMessage{ - {ServerID: "history-inbound", SenderUID: "peer", Content: json.RawMessage(`{"text":"inbound"}`), CreatedAt: strconv.FormatInt(stamp, 10)}, - {ServerID: "history-outbound", SenderUID: "history-account", Content: json.RawMessage(`{"text":"outbound"}`), CreatedAt: strconv.FormatInt(stamp+1, 10)}, - } - inserted, err := persistDouyinMessageHistory(ctx, store, conversation, "history-account", messages) - if err != nil || inserted != 2 { - t.Fatalf("persist history: inserted=%d err=%v", inserted, err) - } - inserted, err = persistDouyinMessageHistory(ctx, store, conversation, "history-account", messages) - if err != nil || inserted != 0 { - t.Fatalf("deduplicate history: inserted=%d err=%v", inserted, err) - } - badContent := []douyinHistoryMessage{{ServerID: "bad", SenderUID: "peer", Content: json.RawMessage(`{"text":`), CreatedAt: strconv.FormatInt(stamp, 10)}} - if _, err := persistDouyinMessageHistory(ctx, store, conversation, "history-account", badContent); err != creator.ErrInvalid { - t.Fatalf("bad history content: %v", err) - } -} diff --git a/internal/controlplane/api/creator_material.go b/internal/controlplane/api/creator_material.go deleted file mode 100644 index 657b5c0..0000000 --- a/internal/controlplane/api/creator_material.go +++ /dev/null @@ -1,348 +0,0 @@ -package api - -import ( - "context" - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - accountdomain "git.ipao.vip/rogee/creator-hub/internal/account" - "git.ipao.vip/rogee/creator-hub/internal/creator" - hub "git.ipao.vip/rogee/creator-hub/internal/environment" - "github.com/sirupsen/logrus" -) - -type creatorMaterialDownloader struct { - store *creator.Store - phaseAStore *accountdomain.Store - hubStore *hub.Store -} - -func (downloader creatorMaterialDownloader) Download(ctx context.Context, work creator.Work, destination string) (resultErr error) { - if work.Platform != creator.PlatformDouyin || downloader.store == nil || downloader.phaseAStore == nil || downloader.hubStore == nil { - return fmt.Errorf("%w: creator media gateway is unavailable", creator.ErrUnavailable) - } - accountID := work.SourceID - if work.SourceType == creator.SourceCompetitor { - var err error - accountID, err = creatorCollectionAccount(ctx, downloader.store, downloader.phaseAStore, downloader.hubStore, work.Platform) - if err != nil { - return err - } - } - account, err := downloader.phaseAStore.GetAccount(ctx, accountID) - if err != nil { - return err - } - profile, err := downloader.store.GetAccountProfile(ctx, accountID) - if err != nil { - return err - } - if account.Platform != work.Platform || profile.Platform != work.Platform || account.AuthorizationStatus != "authorized" || profile.LoginStatus != "logged_in" || account.PlatformAccountKey != profile.PlatformAccountKey { - return fmt.Errorf("%w: media account identity is not verified", creator.ErrConflict) - } - environment, err := downloader.hubStore.GetEnvironmentContextForAccount(ctx, accountID) - if err != nil { - return fmt.Errorf("%w: media browser environment unavailable: %v", creator.ErrUnavailable, err) - } - if environment.RuntimeID == "" || environment.RuntimeNetworkID == "" || environment.BindingVersion <= 0 { - return fmt.Errorf("%w: media browser runtime is not running", creator.ErrUnavailable) - } - gateway, err := downloader.hubStore.GetGateway(ctx, environment.Gateway) - if err != nil { - return fmt.Errorf("%w: media gateway unavailable: %v", creator.ErrUnavailable, err) - } - useCtx, runtimeUse, err := beginRuntimeUseForEnvironment(ctx, downloader.hubStore, environment, "task", "creator-material-"+work.ID) - if err != nil { - return fmt.Errorf("%w: media runtime use unavailable: %v", creator.ErrUnavailable, err) - } - defer func() { resultErr = errors.Join(resultErr, runtimeUse.Close()) }() - if _, err := verifyCreatorPlatformIdentity(useCtx, work.Platform, gateway, environment, profile.PlatformAccountKey); err != nil { - return fmt.Errorf("%w: media browser identity verification failed: %v", creator.ErrConflict, err) - } - if work.Platform != creator.PlatformDouyin { - return fmt.Errorf("%w: unsupported creator platform %s", creator.ErrUnavailable, work.Platform) - } - return (creatorGatewayBrowser{gateway: gateway, environment: environment}).Media(useCtx, work.OriginalURL, destination) -} - -func processCreatorMaterial(ctx context.Context, store *creator.Store, phaseAStore *accountdomain.Store, hubStore *hub.Store, workID string) (creator.MaterialJob, error) { - if store == nil || workID == "" || workID == "." || workID == ".." || strings.ContainsAny(workID, `/\\`) || filepath.Base(workID) != workID { - return creator.MaterialJob{}, creator.ErrInvalid - } - work, err := store.GetWork(ctx, workID) - if err != nil { - return creator.MaterialJob{}, err - } - job, err := store.GetMaterial(ctx, workID) - if err != nil { - return creator.MaterialJob{}, err - } - if !job.Selected { - return creator.MaterialJob{}, creator.ErrConflict - } - root := os.Getenv("CREATOR_MEDIA_DIR") - if root == "" { - root = "/var/lib/creatorhub/materials" - } - root, err = filepath.Abs(filepath.Clean(root)) - if err != nil { - return creator.MaterialJob{}, fmt.Errorf("resolve material directory: %w", err) - } - dir := filepath.Join(root, workID) - if err := os.MkdirAll(dir, 0o700); err != nil { - return creator.MaterialJob{}, fmt.Errorf("create material directory: %w", err) - } - executionID, err := materialClaimToken() - if err != nil { - return creator.MaterialJob{}, err - } - executionDir := filepath.Join(dir, ".runs", executionID) - if err := os.MkdirAll(executionDir, 0o700); err != nil { - return creator.MaterialJob{}, fmt.Errorf("create material execution directory: %w", err) - } - keepExecutionDir := false - defer func() { - if !keepExecutionDir { - if cleanupErr := os.RemoveAll(executionDir); cleanupErr != nil { - logrus.WithError(cleanupErr).WithField("execution_dir", executionDir).Error("creator material execution cleanup failed") - } - } - }() - - videoPath := "" - if job.DownloadStatus == "succeeded" { - videoPath, err = materialArtifactPath(root, workID, job.VideoReference) - if err != nil || !fileExists(videoPath) { - if _, setErr := store.SetMaterialStep(ctx, workID, "download", "failed", "", "下载产物不存在"); setErr != nil { - return creator.MaterialJob{}, setErr - } - job.DownloadStatus = "failed" - } - } - if job.DownloadStatus != "succeeded" { - token, tokenErr := materialClaimToken() - if tokenErr != nil { - return creator.MaterialJob{}, tokenErr - } - job, claimed, err := store.ClaimMaterialStep(ctx, workID, "download", token) - if err != nil { - return creator.MaterialJob{}, err - } - if !claimed { - return job, fmt.Errorf("%w: download step is already in progress", creator.ErrConflict) - } - videoPath = filepath.Join(executionDir, "source") - if err := (creatorMaterialDownloader{store: store, phaseAStore: phaseAStore, hubStore: hubStore}).Download(ctx, work, videoPath); err != nil { - return setMaterialFailure(ctx, store, workID, "download", token, err) - } - keepExecutionDir = true - videoReference := materialArtifactReference(workID, executionID, "source") - job, err = store.CompleteMaterialStep(ctx, workID, "download", token, "succeeded", videoReference, "") - if err != nil { - return creator.MaterialJob{}, err - } - } - - audioPath := "" - if job.AudioStatus == "succeeded" { - audioPath, err = materialArtifactPath(root, workID, job.AudioReference) - if err != nil || !fileExists(audioPath) { - if _, setErr := store.SetMaterialStep(ctx, workID, "audio", "failed", "", "音频产物不存在"); setErr != nil { - return creator.MaterialJob{}, setErr - } - job.AudioStatus = "failed" - } - } - if job.AudioStatus != "succeeded" && job.AudioStatus != "no_audio" { - token, tokenErr := materialClaimToken() - if tokenErr != nil { - return creator.MaterialJob{}, tokenErr - } - job, claimed, err := store.ClaimMaterialStep(ctx, workID, "audio", token) - if err != nil { - return creator.MaterialJob{}, err - } - if !claimed { - return job, fmt.Errorf("%w: audio step is already in progress", creator.ErrConflict) - } - audioPath = filepath.Join(executionDir, "audio.wav") - hasAudio, err := creatorMaterialHasAudio(ctx, videoPath) - if err != nil { - return setMaterialFailure(ctx, store, workID, "audio", token, err) - } - if !hasAudio { - job, err = store.CompleteMaterialStep(ctx, workID, "audio", token, "no_audio", "", "视频没有音轨") - } else if err := extractCreatorAudio(ctx, videoPath, audioPath); err != nil { - return setMaterialFailure(ctx, store, workID, "audio", token, err) - } else { - keepExecutionDir = true - job, err = store.CompleteMaterialStep(ctx, workID, "audio", token, "succeeded", materialArtifactReference(workID, executionID, "audio.wav"), "") - } - if err != nil { - return creator.MaterialJob{}, err - } - } - - if job.TranscriptionStatus != "succeeded" && job.TranscriptionStatus != "no_speech" { - token, tokenErr := materialClaimToken() - if tokenErr != nil { - return creator.MaterialJob{}, tokenErr - } - job, claimed, claimErr := store.ClaimMaterialStep(ctx, workID, "transcription", token) - if claimErr != nil { - return creator.MaterialJob{}, claimErr - } - if !claimed { - return job, fmt.Errorf("%w: transcription step is already in progress", creator.ErrConflict) - } - if job.AudioStatus == "no_audio" { - job, err = store.CompleteMaterialStep(ctx, workID, "transcription", token, "no_speech", "", "没有可转写的音轨") - } else { - settings, settingsErr := store.GetSettings(ctx) - if settingsErr != nil { - return setMaterialFailure(ctx, store, workID, "transcription", token, settingsErr) - } - if !settings.TranscriptionConfigured || strings.TrimSpace(settings.TranscriptionProvider) == "" || strings.TrimSpace(settings.TranscriptionModel) == "" { - return setMaterialFailure(ctx, store, workID, "transcription", token, fmt.Errorf("transcription provider is not configured")) - } - transcript, transcribeErr := transcribeCreatorAudio(ctx, audioPath, settings.TranscriptionProvider, settings.TranscriptionModel) - if transcribeErr != nil { - job, err = setMaterialFailure(ctx, store, workID, "transcription", token, transcribeErr) - } else if strings.TrimSpace(transcript) == "" { - job, err = store.CompleteMaterialStep(ctx, workID, "transcription", token, "no_speech", "", "转写未检测到语音") - } else { - transcriptPath := filepath.Join(executionDir, "transcript.txt") - if writeErr := os.WriteFile(transcriptPath, []byte(transcript), 0o600); writeErr != nil { - job, err = setMaterialFailure(ctx, store, workID, "transcription", token, writeErr) - } else { - keepExecutionDir = true - job, err = store.CompleteMaterialStep(ctx, workID, "transcription", token, "succeeded", materialArtifactReference(workID, executionID, "transcript.txt"), "") - } - } - } - if err != nil { - return creator.MaterialJob{}, err - } - } - return job, nil -} - -func materialArtifactReference(workID, executionID, name string) string { - return filepath.ToSlash(filepath.Join(workID, ".runs", executionID, name)) -} - -func materialArtifactPath(root, workID, reference string) (string, error) { - if reference == "" || filepath.IsAbs(reference) { - return "", creator.ErrInvalid - } - clean := filepath.Clean(filepath.FromSlash(reference)) - prefix := workID + string(filepath.Separator) - if clean == "." || !strings.HasPrefix(clean, prefix) || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { - return "", creator.ErrInvalid - } - root, err := filepath.Abs(root) - if err != nil { - return "", err - } - path := filepath.Join(root, clean) - relative, err := filepath.Rel(root, path) - if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - return "", creator.ErrInvalid - } - return path, nil -} - -func materialClaimToken() (string, error) { - var data [16]byte - if _, err := rand.Read(data[:]); err != nil { - return "", fmt.Errorf("create material claim token: %w", err) - } - return hex.EncodeToString(data[:]), nil -} - -func creatorMaterialHasAudio(ctx context.Context, videoPath string) (bool, error) { - if _, err := exec.LookPath("ffprobe"); err != nil { - return false, fmt.Errorf("ffprobe is unavailable: %w", err) - } - command := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=index", "-of", "csv=p=0", videoPath) - output, err := command.Output() - if err != nil { - return false, fmt.Errorf("inspect audio stream: %w", err) - } - return strings.TrimSpace(string(output)) != "", nil -} - -func extractCreatorAudio(ctx context.Context, videoPath, audioPath string) error { - if _, err := exec.LookPath("ffmpeg"); err != nil { - return fmt.Errorf("ffmpeg is unavailable: %w", err) - } - temporary := audioPath + ".tmp" - defer os.Remove(temporary) - command := exec.CommandContext(ctx, "ffmpeg", "-nostdin", "-v", "error", "-y", "-i", videoPath, "-vn", "-ac", "1", "-ar", "16000", "-f", "wav", temporary) - if output, err := command.CombinedOutput(); err != nil { - return fmt.Errorf("extract audio: %w: %s", err, strings.TrimSpace(string(output))) - } - if !fileExists(temporary) { - return fmt.Errorf("extract audio produced no file") - } - if err := os.Rename(temporary, audioPath); err != nil { - return fmt.Errorf("publish audio: %w", err) - } - return nil -} - -func validateTranscriptionBinary(binary string) error { - path, err := exec.LookPath(binary) - if err != nil { - return fmt.Errorf("lookup transcription provider %q: %w", binary, err) - } - if path == "" { - return fmt.Errorf("binary path is empty") - } - return nil -} - -func transcribeCreatorAudio(ctx context.Context, audioPath, provider, model string) (string, error) { - provider, model = strings.TrimSpace(provider), strings.TrimSpace(model) - if provider == "" || model == "" { - return "", fmt.Errorf("transcription provider and model are not configured") - } - if provider != "whisper" && provider != "faster-whisper" { - return "", fmt.Errorf("unsupported transcription provider %q", provider) - } - configured := strings.TrimSpace(os.Getenv("CREATOR_TRANSCRIPTION_BIN")) - if configured == "" || filepath.Base(configured) != provider { - return "", fmt.Errorf("transcription binary does not match configured provider %q", provider) - } - if err := validateTranscriptionBinary(configured); err != nil { - return "", fmt.Errorf("transcription provider is unavailable: %w", err) - } - command := exec.CommandContext(ctx, configured, audioPath, "--model", model) - output, err := command.Output() - if err != nil { - return "", fmt.Errorf("transcribe audio: %w", err) - } - if len(output) > 1<<20 { - return "", fmt.Errorf("transcript exceeds size limit") - } - return string(output), nil -} - -func setMaterialFailure(ctx context.Context, store *creator.Store, workID, step, token string, cause error) (creator.MaterialJob, error) { - job, err := store.CompleteMaterialStep(ctx, workID, step, token, "failed", "", cause.Error()) - if err != nil { - return creator.MaterialJob{}, fmt.Errorf("record %s failure: %w", step, err) - } - return job, nil -} - -func fileExists(path string) bool { - info, err := os.Stat(path) - return err == nil && info.Mode().IsRegular() && info.Size() > 0 -} diff --git a/internal/controlplane/api/creator_material_test.go b/internal/controlplane/api/creator_material_test.go deleted file mode 100644 index 31eb7d7..0000000 --- a/internal/controlplane/api/creator_material_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package api - -import ( - "context" - "errors" - "os" - "path/filepath" - "testing" - - "git.ipao.vip/rogee/creator-hub/internal/creator" -) - -func TestCreatorMaterialAudioAndTranscriptionFailuresAreVisible(t *testing.T) { - binDir := t.TempDir() - fakeFFmpeg := filepath.Join(binDir, "ffmpeg") - if err := os.WriteFile(fakeFFmpeg, []byte("#!/bin/sh\nfor arg in \"$@\"; do last=\"$arg\"; done\nprintf x > \"$last\"\n"), 0o700); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) - audioPath := filepath.Join(t.TempDir(), "audio.wav") - if err := extractCreatorAudio(context.Background(), "/missing/video.mp4", audioPath); err != nil { - t.Fatalf("extractCreatorAudio returned %v", err) - } - if _, err := os.Stat(audioPath); err != nil { - t.Fatalf("extracted audio was not published: %v", err) - } - - if err := os.WriteFile(fakeFFmpeg, []byte("#!/bin/sh\necho ffmpeg failed >&2\nexit 1\n"), 0o700); err != nil { - t.Fatal(err) - } - if err := extractCreatorAudio(context.Background(), "/missing/video.mp4", filepath.Join(t.TempDir(), "failed.wav")); err == nil { - t.Fatal("failed ffmpeg command was reported as successful") - } - if err := validateTranscriptionBinary("missing-transcription-binary"); err == nil { - t.Fatal("missing transcription binary was reported as available") - } - if _, err := transcribeCreatorAudio(context.Background(), "audio.wav", "", "model"); err == nil { - t.Fatal("missing transcription provider was reported as successful") - } - if _, err := transcribeCreatorAudio(context.Background(), "audio.wav", "unknown", "model"); err == nil { - t.Fatal("unsupported transcription provider was reported as successful") - } - t.Setenv("CREATOR_TRANSCRIPTION_BIN", filepath.Join(binDir, "not-whisper")) - if _, err := transcribeCreatorAudio(context.Background(), "audio.wav", "whisper", "model"); err == nil { - t.Fatal("mismatched transcription binary was reported as successful") - } - - whisper := filepath.Join(binDir, "whisper") - if err := os.WriteFile(whisper, []byte("#!/bin/sh\nprintf 'transcript'\n"), 0o700); err != nil { - t.Fatal(err) - } - t.Setenv("CREATOR_TRANSCRIPTION_BIN", whisper) - text, err := transcribeCreatorAudio(context.Background(), audioPath, "whisper", "small") - if err != nil || text != "transcript" { - t.Fatalf("transcription = %q, err = %v", text, err) - } - if err := os.WriteFile(whisper, []byte("#!/bin/sh\nexit 1\n"), 0o700); err != nil { - t.Fatal(err) - } - if _, err := transcribeCreatorAudio(context.Background(), audioPath, "whisper", "small"); err == nil { - t.Fatal("failed transcription command was reported as successful") - } -} - -func TestMaterialArtifactReferenceAndPathStayWithinWorkRoot(t *testing.T) { - if got := materialArtifactReference("work-1", "execution-1", "cover.jpg"); got != "work-1/.runs/execution-1/cover.jpg" { - t.Fatalf("artifact reference = %q", got) - } - - root := t.TempDir() - want := filepath.Join(root, "work-1", ".runs", "execution-1", "cover.jpg") - got, err := materialArtifactPath(root, "work-1", "work-1/.runs/execution-1/cover.jpg") - if err != nil || got != want { - t.Fatalf("artifact path = %q, err = %v, want %q", got, err, want) - } - - for _, reference := range []string{ - "", - "/tmp/cover.jpg", - "other/.runs/execution-1/cover.jpg", - "work-1/../other/cover.jpg", - "work-1/../../outside.jpg", - "work-1", - } { - if _, err := materialArtifactPath(root, "work-1", reference); !errors.Is(err, creator.ErrInvalid) { - t.Errorf("materialArtifactPath(%q) error = %v, want creator.ErrInvalid", reference, err) - } - } -} diff --git a/internal/controlplane/api/creator_pure_unit_test.go b/internal/controlplane/api/creator_pure_unit_test.go index 12d2270..26180be 100644 --- a/internal/controlplane/api/creator_pure_unit_test.go +++ b/internal/controlplane/api/creator_pure_unit_test.go @@ -165,9 +165,6 @@ func TestCreatorPreviewAndLifecycleHelpersRejectUnavailableDependencies(t *testi if _, err := creatorLoginQRCode(context.Background(), nil, nil, nil, "account-1"); err == nil { t.Fatal("missing login QR dependencies were accepted") } - if _, err := processCreatorMaterial(context.Background(), nil, nil, nil, "work-1"); err == nil { - t.Fatal("missing material dependencies were accepted") - } if err := startCreatorEnvironment(context.Background(), nil, hub.EnvironmentContext{}); err == nil { t.Fatal("missing runtime store was accepted") } diff --git a/internal/controlplane/api/creator_route_validation_test.go b/internal/controlplane/api/creator_route_validation_test.go index a24a7e8..30609c6 100644 --- a/internal/controlplane/api/creator_route_validation_test.go +++ b/internal/controlplane/api/creator_route_validation_test.go @@ -13,7 +13,7 @@ import ( func TestCreatorUpdatesStreamStopsOnRequestCancellation(t *testing.T) { app := fiber.New() - registerCreatorWithServices(app, nil, nil, nil, nil, nil, nil) + registerCreatorWithServices(app, nil, nil, nil, nil) request := httptest.NewRequest(http.MethodGet, "/api/creator/updates", http.NoBody) response, err := app.Test(request, fiber.TestConfig{ @@ -39,7 +39,7 @@ func TestCreatorUpdatesStreamStopsOnRequestCancellation(t *testing.T) { func TestCreatorWriteRoutesRejectMalformedInputBeforeStoreAccess(t *testing.T) { app := fiber.New() - registerCreatorWithServices(app, nil, nil, nil, nil, nil, nil) + registerCreatorWithServices(app, nil, nil, nil, nil) routes := []struct { method string path string @@ -49,24 +49,16 @@ func TestCreatorWriteRoutesRejectMalformedInputBeforeStoreAccess(t *testing.T) { {method: http.MethodPut, path: "/api/creator/accounts/account-1/tags"}, {method: http.MethodPost, path: "/api/creator/accounts/account-1/login-result"}, {method: http.MethodPost, path: "/api/creator/accounts/account-1/big-account"}, - {method: http.MethodPost, path: "/api/creator/relations"}, - {method: http.MethodPost, path: "/api/creator/accounts/account-1/strategies"}, - {method: http.MethodPut, path: "/api/creator/strategies/strategy-1"}, {method: http.MethodPost, path: "/api/creator/competitor-share-jobs"}, {method: http.MethodPut, path: "/api/creator/competitors/competitor-1"}, // sync 路由不解析请求体(强制同步走匿名浏览器,无输入字段),不适用畸形 body 校验。 {method: http.MethodPost, path: "/api/creator/test/works"}, {method: http.MethodPost, path: "/api/creator/works/work-1/metrics"}, - {method: http.MethodPost, path: "/api/creator/works/work-1/material/rewrite/confirm"}, - {method: http.MethodPut, path: "/api/creator/works/work-1/material/rewrite"}, {method: http.MethodPost, path: "/api/creator/test/comments"}, {method: http.MethodPost, path: "/api/creator/rules"}, {method: http.MethodPut, path: "/api/creator/rules/rule-1"}, {method: http.MethodPost, path: "/api/creator/comments/analyze"}, {method: http.MethodPost, path: "/api/creator/comments/comment-1/analyze"}, - {method: http.MethodPost, path: "/api/creator/test/events"}, - {method: http.MethodPost, path: "/api/creator/events/process"}, - {method: http.MethodPost, path: "/api/creator/messages"}, } for _, route := range routes { route := route diff --git a/internal/controlplane/api/creator_updates.go b/internal/controlplane/api/creator_updates.go new file mode 100644 index 0000000..d4fd6ac --- /dev/null +++ b/internal/controlplane/api/creator_updates.go @@ -0,0 +1,39 @@ +package api + +import ( + "sync" +) + +// creatorUpdateHub 向 SSE 订阅者广播运营数据变更通知。 +type creatorUpdateHub struct { + mu sync.Mutex + subscribers map[chan struct{}]struct{} +} + +var creatorUpdates = &creatorUpdateHub{subscribers: make(map[chan struct{}]struct{})} + +func (h *creatorUpdateHub) subscribe() (<-chan struct{}, func()) { + channel := make(chan struct{}, 1) + h.mu.Lock() + h.subscribers[channel] = struct{}{} + h.mu.Unlock() + return channel, func() { + h.mu.Lock() + if _, ok := h.subscribers[channel]; ok { + delete(h.subscribers, channel) + close(channel) + } + h.mu.Unlock() + } +} + +func (h *creatorUpdateHub) publish() { + h.mu.Lock() + defer h.mu.Unlock() + for channel := range h.subscribers { + select { + case channel <- struct{}{}: + default: + } + } +} diff --git a/internal/controlplane/api/hub_test.go b/internal/controlplane/api/hub_test.go index f367e33..f9b9eae 100644 --- a/internal/controlplane/api/hub_test.go +++ b/internal/controlplane/api/hub_test.go @@ -90,28 +90,6 @@ func TestResumeBlockReasonIsStable(t *testing.T) { } } -func TestPhaseAReadinessErrorsAreStructured(t *testing.T) { - for _, test := range []struct { - name, reason string - unavailable bool - status int - }{ - {name: "version conflict", reason: "draft_version_changed", status: http.StatusConflict}, - {name: "resource unavailable", reason: "network_exit_unhealthy", unavailable: true, status: http.StatusServiceUnavailable}, - } { - t.Run(test.name, func(t *testing.T) { - app := fiber.New() - app.Get("/", func(c fiber.Ctx) error { - return phaseAError(c, &accountdomain.ReadinessError{Reason: test.reason, Unavailable: test.unavailable}) - }) - response := do(app, http.MethodGet, "/", "") - if response.Code != test.status || !strings.Contains(response.Body.String(), `"reason_code":"`+test.reason+`"`) { - t.Fatalf("unexpected response: %d %s", response.Code, response.Body.String()) - } - }) - } -} - func (s *memoryStore) LockResources(_ context.Context, aliases, exitIDs []string, imageVersionSets ...[]string) (func(), error) { imageVersions := []string(nil) if len(imageVersionSets) > 0 { @@ -474,21 +452,6 @@ func (s *memoryStore) AppendEnvironmentAction(_ context.Context, _ string, actio return nil } -func TestPhaseARuntimeLeaseRoutesAreInternal(t *testing.T) { - app := fiber.New() - registerPhaseA(app, nil, nil, nil) - for _, request := range []struct { - method, path string - }{ - {http.MethodPost, "/api/phase-a/runtimes"}, - {http.MethodDelete, "/api/phase-a/runtimes/runtime-a"}, - } { - if response := do(app, request.method, request.path, `{}`); response.Code != http.StatusNotFound { - t.Fatalf("%s %s remained public: %d", request.method, request.path, response.Code) - } - } -} - func TestPauseSerializesResumeAndRetainsClaimGateOnUnknownStop(t *testing.T) { databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") if databaseURL == "" { @@ -509,7 +472,7 @@ func TestPauseSerializesResumeAndRetainsClaimGateOnUnknownStop(t *testing.T) { } blocking := &blockingRuntimeStopStore{Store: fixture.store, entered: make(chan struct{}, 1), release: make(chan struct{})} app := fiber.New() - registerPhaseA(app, accountStore, blocking, nil) + RegisterAccountRoutes(app, accountStore, blocking, nil) pauseDone := make(chan *httptest.ResponseRecorder, 1) resumeDone := make(chan *httptest.ResponseRecorder, 1) go func() { pauseDone <- do(app, http.MethodPost, "/api/phase-a/accounts/account-a/pause", "") }() @@ -1572,98 +1535,6 @@ func newPostgresRebindFixture(t *testing.T, databaseURL string) postgresRebindFi return postgresRebindFixture{store: store, db: db, databaseURL: databaseURL, gateway: gateway, bound: bound, exit: exit} } -func TestPauseClosesClaimGateBeforeStoppingRuntime(t *testing.T) { - databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") - if databaseURL == "" { - t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage") - } - for _, test := range []struct { - name string - gatewayEndpoint string - wantStatus int - wantOutcome string - wantReason string - }{ - {name: "confirmed stop", wantStatus: http.StatusNoContent, wantOutcome: "succeeded", wantReason: "environment_stopped"}, - {name: "gateway unreachable", gatewayEndpoint: "http://127.0.0.1:1", wantStatus: http.StatusBadGateway, wantOutcome: "unknown", wantReason: "gateway_result_unknown"}, - } { - t.Run(test.name, func(t *testing.T) { - ctx := context.Background() - fixture := newPostgresRebindFixture(t, databaseURL) - accountStore, err := accountdomain.Open(ctx, fixture.databaseURL) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = accountStore.Close() }) - if err := accountStore.ResumeAccount(ctx, "account-a"); err != nil { - t.Fatal(err) - } - account, err := accountStore.GetAccount(ctx, "account-a") - if err != nil { - t.Fatal(err) - } - fixture.bound, err = fixture.store.ActivateRuntime(ctx, "account-a", "active-container", fixture.bound.BindingVersion, fixture.bound.Exit.ID, "native-33333333333333333333333333333333") - if err != nil { - t.Fatal(err) - } - fixture.gateway.runtimes = []runtimeStatus{{ - ID: "active-container", Alias: "account-a", State: "running", ProxyReady: true, - BindingVersion: fixture.bound.BindingVersion, NetworkExitID: fixture.bound.Exit.ID, - }} - if test.gatewayEndpoint != "" { - if _, err := fixture.db.ExecContext(ctx, `UPDATE gateway SET endpoint = $1 WHERE name = 'gw-1'`, test.gatewayEndpoint); err != nil { - t.Fatal(err) - } - } - if err := accountStore.CreateDraft(ctx, accountdomain.Draft{ID: "pause-draft", AccountID: "account-a", Version: 1, Content: "pause"}); err != nil { - t.Fatal(err) - } - if err := accountStore.Confirm(ctx, accountdomain.Confirmation{ - ID: "pause-confirmation", AccountID: "account-a", AccountVersion: account.Version, - DraftID: "pause-draft", DraftVersion: 1, Version: 1, - }); err != nil { - t.Fatal(err) - } - if _, inserted, err := accountStore.Enqueue(ctx, accountdomain.Task{ - ID: "pause-task", IdempotencyKey: "pause-idempotency", AccountID: "account-a", AccountVersion: account.Version, - DraftID: "pause-draft", DraftVersion: 1, ConfirmationID: "pause-confirmation", ConfirmationVersion: 1, - }); err != nil || !inserted { - t.Fatalf("enqueue pause task: inserted=%v err=%v", inserted, err) - } - if _, err := fixture.db.ExecContext(ctx, ` - INSERT INTO execution_attempt (id, task_id) VALUES ('pause-attempt', 'pause-task'); - UPDATE operation_task SET state = 'executing', current_attempt_id = 'pause-attempt', - lease_owner = 'worker-pause', lease_until = now() + interval '1 minute' - WHERE id = 'pause-task'`); err != nil { - t.Fatal(err) - } - - app := fiber.New() - registerPhaseA(app, accountStore, fixture.store, nil) - response := do(app, http.MethodPost, "/api/phase-a/accounts/account-a/pause", "") - if response.Code != test.wantStatus { - t.Fatalf("pause returned %d, want %d: %s", response.Code, test.wantStatus, response.Body.String()) - } - paused, err := accountStore.GetAccount(ctx, "account-a") - if err != nil || paused.RuntimeStatus != "paused" { - t.Fatalf("claim gate remained open: account=%#v err=%v", paused, err) - } - assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM operation_task WHERE id = 'pause-task' AND state = 'needs_confirmation'`, 1) - assertControlPlaneDatabaseCount(t, fixture.db, `SELECT count(*) FROM execution_attempt WHERE id = 'pause-attempt' AND outcome = 'uncertain'`, 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 audit_event WHERE task_id = 'pause-task' AND attempt_id = 'pause-attempt' - AND browser_env_alias = 'account-a' AND network_exit_id IS NOT NULL AND runtime_instance_id IS NOT NULL - AND binding_version = 1 AND reason_code = 'account_paused'`, 1) - var outcome, reason string - if err := fixture.db.QueryRowContext(ctx, `SELECT outcome, reason_code FROM audit_event - WHERE account_id = 'account-a' AND action = 'stop' AND event_type = 'environment_action_finished' - ORDER BY id DESC LIMIT 1`).Scan(&outcome, &reason); err != nil || outcome != test.wantOutcome || reason != test.wantReason { - t.Fatalf("stop audit: outcome=%q reason=%q err=%v", outcome, reason, err) - } - }) - } -} - func TestPauseAndRevokeStopContainerWithoutRuntimeLease(t *testing.T) { databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") if databaseURL == "" { @@ -1681,7 +1552,7 @@ func TestPauseAndRevokeStopContainerWithoutRuntimeLease(t *testing.T) { BindingVersion: fixture.bound.BindingVersion, NetworkExitID: fixture.bound.Exit.ID, }} app := fiber.New() - registerPhaseA(app, accountStore, fixture.store, nil) + RegisterAccountRoutes(app, accountStore, fixture.store, nil) if err := accountStore.ResumeAccount(ctx, fixture.bound.AccountID); err != nil { t.Fatal(err) diff --git a/internal/controlplane/api/runtime.go b/internal/controlplane/api/runtime.go deleted file mode 100644 index 1174229..0000000 --- a/internal/controlplane/api/runtime.go +++ /dev/null @@ -1,102 +0,0 @@ -package api - -import ( - "context" - "errors" - "reflect" - "sync" - "time" - - hub "git.ipao.vip/rogee/creator-hub/internal/environment" - "github.com/sirupsen/logrus" -) - -var runtimeUseRenewInterval = 20 * time.Second - -type runtimeUseStore interface { - AcquireRuntimeUse(context.Context, string, string, string, string) (hub.RuntimeUseLease, error) - RenewRuntimeUse(context.Context, string) (hub.RuntimeUseLease, error) - ReleaseRuntimeUse(context.Context, string) error -} - -type runtimeUseHandle struct { - store runtimeUseStore - lease hub.RuntimeUseLease - ctx context.Context - cancel context.CancelFunc - done chan struct{} - mu sync.Mutex - renewErr error - closeOnce sync.Once - closeErr error -} - -func beginRuntimeUse(ctx context.Context, store any, alias, purpose, ownerID string) (context.Context, *runtimeUseHandle, error) { - useStore, ok := store.(runtimeUseStore) - if !ok || useStore == nil || (reflect.ValueOf(useStore).Kind() == reflect.Ptr && reflect.ValueOf(useStore).IsNil()) { - return ctx, nil, errors.New("runtime use lease store is unavailable") - } - lease, err := useStore.AcquireRuntimeUse(ctx, alias, purpose, ownerID, "") - if err != nil { - return ctx, nil, err - } - useCtx, cancel := context.WithCancel(ctx) - handle := &runtimeUseHandle{store: useStore, lease: lease, ctx: useCtx, cancel: cancel, done: make(chan struct{})} - go handle.renew() - return useCtx, handle, nil -} - -func beginRuntimeUseForEnvironment(ctx context.Context, store any, environment hub.EnvironmentContext, purpose, ownerID string) (context.Context, *runtimeUseHandle, error) { - if environment.Alias == "" || environment.RuntimeInstanceID == "" { - return ctx, nil, hub.ErrConflict - } - useCtx, handle, err := beginRuntimeUse(ctx, store, environment.Alias, purpose, ownerID) - if err != nil { - return ctx, nil, err - } - if handle.lease.RuntimeInstanceID != environment.RuntimeInstanceID { - return ctx, nil, errors.Join(hub.ErrConflict, handle.Close()) - } - return useCtx, handle, nil -} - -func (h *runtimeUseHandle) renew() { - ticker := time.NewTicker(runtimeUseRenewInterval) - defer ticker.Stop() - defer close(h.done) - for { - select { - case <-h.ctx.Done(): - return - case <-ticker.C: - if _, err := h.store.RenewRuntimeUse(h.ctx, h.lease.Token); err != nil { - h.mu.Lock() - h.renewErr = err - h.mu.Unlock() - logrus.WithError(err).WithFields(logrus.Fields{ - "runtime_use_token": h.lease.Token, - "runtime_use_purpose": h.lease.Purpose, - }).Error("runtime use lease renewal failed") - h.cancel() - return - } - } - } -} - -func (h *runtimeUseHandle) Close() error { - if h == nil { - return nil - } - h.closeOnce.Do(func() { - h.cancel() - <-h.done - releaseCtx, cancel := context.WithTimeout(context.WithoutCancel(h.ctx), 5*time.Second) - defer cancel() - releaseErr := h.store.ReleaseRuntimeUse(releaseCtx, h.lease.Token) - h.mu.Lock() - defer h.mu.Unlock() - h.closeErr = errors.Join(h.renewErr, releaseErr) - }) - return h.closeErr -} diff --git a/internal/controlplane/api/runtime_use_test.go b/internal/controlplane/api/runtime_use_test.go deleted file mode 100644 index 7ecb8fc..0000000 --- a/internal/controlplane/api/runtime_use_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package api - -import ( - "context" - "errors" - "testing" - "time" - - hub "git.ipao.vip/rogee/creator-hub/internal/environment" -) - -type runtimeUseTestStore struct { - lease hub.RuntimeUseLease - acquireErr error - renewErr error - releaseErr error - renewCalls int - releaseCount int -} - -func (s *runtimeUseTestStore) AcquireRuntimeUse(context.Context, string, string, string, string) (hub.RuntimeUseLease, error) { - return s.lease, s.acquireErr -} - -func (s *runtimeUseTestStore) RenewRuntimeUse(context.Context, string) (hub.RuntimeUseLease, error) { - s.renewCalls++ - return s.lease, s.renewErr -} - -func (s *runtimeUseTestStore) ReleaseRuntimeUse(context.Context, string) error { - s.releaseCount++ - return s.releaseErr -} - -func TestBeginRuntimeUseForEnvironmentFencesGenerationAndClosesIdempotently(t *testing.T) { - store := &runtimeUseTestStore{lease: hub.RuntimeUseLease{ - Token: "runtime-use-token", - RuntimeInstanceID: "runtime-instance", - OwnerID: "owner", - Purpose: "task", - LeaseUntil: time.Now().Add(time.Minute), - }} - environment := hub.EnvironmentContext{Env: hub.Env{Alias: "browser"}, RuntimeInstanceID: "runtime-instance"} - useCtx, handle, err := beginRuntimeUseForEnvironment(context.Background(), store, environment, "task", "owner") - if err != nil || handle == nil || useCtx == nil { - t.Fatalf("begin runtime use: handle=%#v err=%v", handle, err) - } - if err := handle.Close(); err != nil { - t.Fatal(err) - } - if err := handle.Close(); err != nil { - t.Fatal(err) - } - if store.releaseCount != 1 { - t.Fatalf("runtime-use close released %d times", store.releaseCount) - } - - store.lease.RuntimeInstanceID = "successor-runtime" - if _, handle, err := beginRuntimeUseForEnvironment(context.Background(), store, environment, "task", "owner"); !errors.Is(err, hub.ErrConflict) || handle != nil { - t.Fatalf("stale runtime lease was accepted: handle=%#v err=%v", handle, err) - } - if store.releaseCount != 2 { - t.Fatalf("mismatched lease was not released: %d", store.releaseCount) - } - - if _, handle, err := beginRuntimeUseForEnvironment(context.Background(), store, hub.EnvironmentContext{}, "task", "owner"); !errors.Is(err, hub.ErrConflict) || handle != nil { - t.Fatalf("missing runtime generation was accepted: handle=%#v err=%v", handle, err) - } - - var nilHandle *runtimeUseHandle - if err := nilHandle.Close(); err != nil { - t.Fatal(err) - } -} - -func TestRuntimeUseRenewalCancelsWorkWhenRenewalFails(t *testing.T) { - previousInterval := runtimeUseRenewInterval - runtimeUseRenewInterval = time.Millisecond - defer func() { runtimeUseRenewInterval = previousInterval }() - - renewErr := errors.New("renewal failed") - store := &runtimeUseTestStore{ - lease: hub.RuntimeUseLease{ - Token: "runtime-use-token", - RuntimeInstanceID: "runtime-instance", - Purpose: "task", - }, - renewErr: renewErr, - } - useCtx, handle, err := beginRuntimeUse(context.Background(), store, "browser", "task", "owner") - if err != nil { - t.Fatal(err) - } - select { - case <-useCtx.Done(): - case <-time.After(time.Second): - t.Fatal("runtime-use context was not canceled after renewal failure") - } - if err := handle.Close(); !errors.Is(err, renewErr) { - t.Fatalf("close error = %v, want %v", err, renewErr) - } - if store.renewCalls == 0 { - t.Fatal("runtime-use renewal was not attempted") - } -} - -func TestRuntimeUseCloseReportsReleaseFailure(t *testing.T) { - releaseErr := errors.New("release failed") - store := &runtimeUseTestStore{ - lease: hub.RuntimeUseLease{Token: "token", RuntimeInstanceID: "runtime"}, - releaseErr: releaseErr, - } - _, handle, err := beginRuntimeUse(context.Background(), store, "browser", "task", "owner") - if err != nil { - t.Fatal(err) - } - if err := handle.Close(); !errors.Is(err, releaseErr) { - t.Fatalf("close error = %v, want %v", err, releaseErr) - } -} - -func TestBeginRuntimeUseReportsUnavailableStoreAndAcquireFailure(t *testing.T) { - if _, handle, err := beginRuntimeUse(context.Background(), struct{}{}, "browser", "task", "owner"); err == nil || handle != nil { - t.Fatalf("unavailable lease store: handle=%#v err=%v", handle, err) - } - acquireErr := errors.New("acquire failed") - store := &runtimeUseTestStore{acquireErr: acquireErr} - if _, handle, err := beginRuntimeUse(context.Background(), store, "browser", "task", "owner"); !errors.Is(err, acquireErr) || handle != nil { - t.Fatalf("acquire error: handle=%#v err=%v", handle, err) - } -} diff --git a/internal/controlplane/app/app.go b/internal/controlplane/app/app.go index e03823b..b0a061d 100644 --- a/internal/controlplane/app/app.go +++ b/internal/controlplane/app/app.go @@ -6,7 +6,6 @@ import ( "crypto/subtle" "encoding/base64" "errors" - "io" "net" "net/url" "os" @@ -27,7 +26,6 @@ import ( "git.ipao.vip/rogee/creator-hub/internal/creator" "git.ipao.vip/rogee/creator-hub/internal/credentials" hub "git.ipao.vip/rogee/creator-hub/internal/environment" - "git.ipao.vip/rogee/creator-hub/internal/taskstate" "github.com/gofiber/fiber/v3" "github.com/sirupsen/logrus" "github.com/spf13/cobra" @@ -86,9 +84,6 @@ func newCommand() *cobra.Command { } defer creatorStore.Close() creatorStore.SetSecretBridge(creatorSecretBridge{bridge: credentials}) - notify := newAttentionNotifier(os.Stderr) - phaseAStore.SetTaskNotifier(notify) - hubStore.SetTaskNotifier(notify) logStartup(cfg) heartbeatContext, stopHeartbeat := context.WithCancel(command.Context()) heartbeatDone := make(chan struct{}) @@ -102,23 +97,13 @@ func newCommand() *cobra.Command { defer close(creatorScheduleDone) workers.RunCreatorScheduler(creatorScheduleContext, creatorStore, phaseAStore, hubStore) }() - creatorEventContext, stopCreatorEvents := context.WithCancel(command.Context()) - creatorEventDone := make(chan struct{}) - creatorEventExecutor := api.NewCreatorGatewayActionExecutor(creatorStore, phaseAStore, hubStore) - creatorAI := &creator.ConfiguredBailian{Store: creatorStore, APIKey: cfg.aiAPIKey, BaseURL: cfg.aiBaseURL} - go func() { - defer close(creatorEventDone) - workers.RunCreatorEventListeners(creatorEventContext, creatorStore, phaseAStore, hubStore, creatorEventExecutor, creatorAI) - }() - listenErr := newHandlerWithCreatorAndAI(cfg.webDir, cfg.username, cfg.password, phaseAStore, hubStore, credentials, creatorStore, creatorAI, creatorAI).Listen(cfg.listenAddr, fiber.ListenConfig{ + listenErr := newHandler(cfg.webDir, cfg.username, cfg.password, phaseAStore, hubStore, credentials, creatorStore).Listen(cfg.listenAddr, fiber.ListenConfig{ GracefulContext: command.Context(), DisableStartupMessage: true, }) stopCreatorScheduler() - stopCreatorEvents() stopHeartbeat() <-creatorScheduleDone - <-creatorEventDone <-heartbeatDone return listenErr }, @@ -126,25 +111,6 @@ func newCommand() *cobra.Command { return command } -func newAttentionNotifier(output io.Writer) taskstate.Notifier { - logger := logrus.New() - logger.SetFormatter(&logrus.JSONFormatter{}) - logger.SetOutput(output) - return func(transition taskstate.Transition) { - if transition.State != "policy_hold" && transition.State != "needs_confirmation" { - return - } - logger.WithFields(logrus.Fields{ - "service": "control-plane", "notification_channel": "structured_log", "event_type": transition.State, - "reason_code": transition.ReasonCode, "account_id": transition.AccountID, "task_id": transition.TaskID, - }).Warn("operator attention required") - } -} - -func runtimeLeaseHeartbeat(ctx context.Context, store *hub.Store) { - workers.RunRuntimeLeaseHeartbeat(ctx, store) -} - func logStartup(cfg config) { logrus.WithFields(logrus.Fields{ "service": "control-plane", @@ -233,10 +199,6 @@ func validateListenAddr(addr string) error { return nil } -func newHandler(webDirectory, username, password string) *fiber.App { - return newHandlerWithStores(webDirectory, username, password, nil, nil) -} - func newHandlerWithStores(webDirectory, username, password string, phaseAStore *account.Store, hubStore *hub.Store) *fiber.App { return newHandlerWithCredentialBridge(webDirectory, username, password, phaseAStore, hubStore, nil) } @@ -264,10 +226,10 @@ func newHandlerWithCredentialBridge(webDirectory, username, password string, pha } func newHandlerWithCreator(webDirectory, username, password string, phaseAStore *account.Store, hubStore *hub.Store, credentials account.CredentialBridge, creatorStore *creator.Store) *fiber.App { - return newHandlerWithCreatorAndAI(webDirectory, username, password, phaseAStore, hubStore, credentials, creatorStore, nil, nil) + return newHandler(webDirectory, username, password, phaseAStore, hubStore, credentials, creatorStore) } -func newHandlerWithCreatorAndAI(webDirectory, username, password string, phaseAStore *account.Store, hubStore *hub.Store, credentials account.CredentialBridge, creatorStore *creator.Store, generator creator.TextGenerator, analyzer creator.ThemeAnalyzer) *fiber.App { +func newHandler(webDirectory, username, password string, phaseAStore *account.Store, hubStore *hub.Store, credentials account.CredentialBridge, creatorStore *creator.Store) *fiber.App { app := fiber.New(fiber.Config{ AppName: "CreatorHub control plane", BodyLimit: 1 << 20, @@ -284,11 +246,7 @@ func newHandlerWithCreatorAndAI(webDirectory, username, password string, phaseAS accountsapi.Register(app, phaseAStore, hubStore, credentials) } if creatorStore != nil { - if generator == nil && analyzer == nil { - creatorapi.Register(app, creatorStore, phaseAStore, hubStore) - } else { - creatorapi.RegisterWithAI(app, creatorStore, phaseAStore, hubStore, generator, analyzer) - } + creatorapi.Register(app, creatorStore, phaseAStore, hubStore) if phaseAStore != nil && hubStore != nil { accountsapi.RegisterDeletion(app, phaseAStore, hubStore, creatorStore, credentials) } @@ -343,10 +301,6 @@ func spaHandler(directory string) fiber.Handler { } } -func registerPhaseA(appInstance *fiber.App, store *account.Store, runtimeStore api.RuntimeStopStore, credentials account.CredentialBridge) { - api.RegisterAccounts(appInstance, store, runtimeStore, credentials) -} - func registerHubWithNetwork(appInstance *fiber.App, store api.HubStore, probe api.NetworkExitProbe, resolve func(hub.NetworkExitAccess) (string, error)) { api.RegisterHubWithNetwork(appInstance, store, probe, resolve) } diff --git a/internal/controlplane/app/app_test.go b/internal/controlplane/app/app_test.go index d1c0ba7..44cc34b 100644 --- a/internal/controlplane/app/app_test.go +++ b/internal/controlplane/app/app_test.go @@ -20,8 +20,8 @@ import ( "time" "git.ipao.vip/rogee/creator-hub/internal/account" + accountsapi "git.ipao.vip/rogee/creator-hub/internal/controlplane/api/accounts" hub "git.ipao.vip/rogee/creator-hub/internal/environment" - "git.ipao.vip/rogee/creator-hub/internal/taskstate" "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/adaptor" "github.com/sirupsen/logrus" @@ -128,7 +128,7 @@ func TestControlPlaneAuthentication(t *testing.T) { if err := os.WriteFile(filepath.Join(directory, "index.html"), []byte("index"), 0o600); err != nil { t.Fatal(err) } - app := newHandler(directory, "operator", "unit-test-password") + app := newHandler(directory, "operator", "unit-test-password", nil, nil, nil, nil) health, err := app.Test(httptest.NewRequest(http.MethodGet, "/healthz", nil)) if err != nil || health.StatusCode != http.StatusNoContent { @@ -154,7 +154,7 @@ func TestControlPlaneAuthentication(t *testing.T) { unknownResponse.Body.Close() for _, path := range []string{ - "/", "/api/phase-a/accounts", "/api/browsers", "/api/network-exits", "/api/phase-a/tasks", "/api/phase-a/audit", + "/", "/api/phase-a/accounts", "/api/browsers", "/api/network-exits", "/api/phase-a/audit", } { request := httptest.NewRequest(http.MethodGet, path, nil) response, err := app.Test(request) @@ -222,7 +222,7 @@ func TestControlPlaneRouteRegistrationMatrix(t *testing.T) { app := fiber.New() app.Use(authenticate("operator", "unit-test-password")) registerHubWithNetwork(app, nil, nil, nil) - registerPhaseA(app, nil, nil, nil) + accountsapi.Register(app, nil, nil, nil) routes := controlPlaneRouteMatrix() assertControlPlaneRouteMatrix(t, app, routes) @@ -365,63 +365,10 @@ func controlPlaneRouteMatrix() []controlPlaneRouteCase { {http.MethodPost, "/api/phase-a/accounts/:id/resume", "/api/phase-a/accounts/missing/resume", "", http.StatusNotFound}, {http.MethodPost, "/api/phase-a/accounts/:id/revoke", "/api/phase-a/accounts/missing/revoke", "", http.StatusNotFound}, - {http.MethodPost, "/api/phase-a/drafts", "/api/phase-a/drafts", "", http.StatusBadRequest}, - {http.MethodGet, "/api/phase-a/drafts", "/api/phase-a/drafts", "", http.StatusOK}, - {http.MethodGet, "/api/phase-a/drafts/:id", "/api/phase-a/drafts/missing", "", http.StatusNotFound}, - - {http.MethodPost, "/api/phase-a/confirmations", "/api/phase-a/confirmations", "", http.StatusBadRequest}, - {http.MethodGet, "/api/phase-a/confirmations", "/api/phase-a/confirmations", "", http.StatusOK}, - {http.MethodGet, "/api/phase-a/confirmations/:id", "/api/phase-a/confirmations/missing", "", http.StatusNotFound}, - - {http.MethodPost, "/api/phase-a/tasks", "/api/phase-a/tasks", "", http.StatusBadRequest}, - {http.MethodGet, "/api/phase-a/tasks", "/api/phase-a/tasks", "", http.StatusOK}, - {http.MethodGet, "/api/phase-a/tasks/:id", "/api/phase-a/tasks/missing", "", http.StatusNotFound}, - {http.MethodGet, "/api/phase-a/attempts/:id", "/api/phase-a/attempts/missing", "", http.StatusNotFound}, - {http.MethodPost, "/api/phase-a/tasks/:id/cancel", "/api/phase-a/tasks/missing/cancel", "", http.StatusNotFound}, - {http.MethodPost, "/api/phase-a/tasks/:id/verify", "/api/phase-a/tasks/missing/verify", "", http.StatusBadRequest}, - {http.MethodPost, "/api/phase-a/tasks/:id/resume", "/api/phase-a/tasks/missing/resume", "", http.StatusNotFound}, - {http.MethodPost, "/api/phase-a/tasks/:id/finish", "/api/phase-a/tasks/missing/finish", "", http.StatusNotFound}, - - {http.MethodPost, "/api/phase-a/mock/execute", "/api/phase-a/mock/execute", "", http.StatusBadRequest}, {http.MethodGet, "/api/phase-a/audit", "/api/phase-a/audit", "", http.StatusOK}, } } -func TestOperatorNotificationFiltersAndRedacts(t *testing.T) { - previousLevel := logrus.GetLevel() - t.Cleanup(func() { logrus.SetLevel(previousLevel) }) - var output bytes.Buffer - notify := newAttentionNotifier(&output) - - notify(taskstate.Transition{State: "succeeded", ReasonCode: "task_succeeded", AccountID: "account-a", TaskID: "task-a"}) - if output.Len() != 0 { - t.Fatalf("successful task emitted a notification: %s", output.String()) - } - - for _, level := range []logrus.Level{logrus.ErrorLevel, logrus.FatalLevel, logrus.PanicLevel} { - for _, test := range []struct{ state, reason string }{ - {"policy_hold", "account_paused"}, {"needs_confirmation", "exit_unhealthy"}, - } { - output.Reset() - logrus.SetLevel(level) - notify(taskstate.Transition{State: test.state, ReasonCode: test.reason, AccountID: "account-a", TaskID: "task-a"}) - var entry map[string]any - if err := json.Unmarshal(output.Bytes(), &entry); err != nil { - t.Fatal(err) - } - if entry["event_type"] != test.state || entry["reason_code"] != test.reason || - entry["notification_channel"] != "structured_log" || entry["account_id"] != "account-a" || entry["task_id"] != "task-a" { - t.Fatalf("unexpected notification at LOG_LEVEL=%s: %#v", level, entry) - } - for _, forbidden := range []string{"password", "authorization", "credential", "token", "secret"} { - if strings.Contains(strings.ToLower(output.String()), forbidden) { - t.Fatalf("notification contained sensitive field %q: %s", forbidden, output.String()) - } - } - } - } -} - func TestSPAServesFileAndIndexFallback(t *testing.T) { directory := t.TempDir() files := map[string]string{ @@ -438,7 +385,7 @@ func TestSPAServesFileAndIndexFallback(t *testing.T) { if err := os.WriteFile(filepath.Join(directory, "..", "outside-secret.txt"), []byte("secret"), 0o600); err != nil { t.Fatal(err) } - app := newHandler(directory, "operator", "unit-test-password") + app := newHandler(directory, "operator", "unit-test-password", nil, nil, nil, nil) for path, want := range map[string]string{ "/app.js": "asset", "/hello%20world.js": "space", diff --git a/internal/controlplane/workers/listeners.go b/internal/controlplane/workers/listeners.go deleted file mode 100644 index daca34f..0000000 --- a/internal/controlplane/workers/listeners.go +++ /dev/null @@ -1,13 +0,0 @@ -package workers - -import ( - "context" - "git.ipao.vip/rogee/creator-hub/internal/account" - "git.ipao.vip/rogee/creator-hub/internal/controlplane/api" - creatorDomain "git.ipao.vip/rogee/creator-hub/internal/creator" - "git.ipao.vip/rogee/creator-hub/internal/environment" -) - -func RunCreatorEventListeners(ctx context.Context, store *creatorDomain.Store, accountStore *account.Store, environmentStore *environment.Store, executor creatorDomain.ActionExecutor, generator creatorDomain.TextGenerator) { - api.RunCreatorEventListeners(ctx, store, accountStore, environmentStore, executor, generator) -} diff --git a/internal/creator/accounts.go b/internal/creator/accounts.go index 566b68e..183cf52 100644 --- a/internal/creator/accounts.go +++ b/internal/creator/accounts.go @@ -288,15 +288,6 @@ func (s *Store) UpdateAccountProfile(ctx context.Context, accountID string, inpu return AccountProfile{}, databaseError(err) } } - if input.BigAccount { - var isSmall bool - if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM creator_relation WHERE small_account_id=$1)`, accountID).Scan(&isSmall); err != nil { - return AccountProfile{}, databaseError(err) - } - if isSmall { - return AccountProfile{}, ErrConflict - } - } _, err = tx.ExecContext(ctx, ` UPDATE creator_account_profile SET login_username = $2, password_configured = CASE WHEN $3 THEN true ELSE password_configured END, @@ -333,9 +324,6 @@ func (s *Store) UpdateAccountProfile(ctx context.Context, accountID string, inpu return AccountProfile{}, fmt.Errorf("replace account password: remove old secret: %w", err) } } - if err := s.InvalidateListener(ctx, accountID, "账号配置变更"); err != nil { - return AccountProfile{}, err - } return s.GetAccountProfile(ctx, accountID) } @@ -379,14 +367,6 @@ func (s *Store) recordLoginResult(ctx context.Context, accountID, status, reason if err != nil { return LoginResult{}, databaseError(err) } - if status != "logged_in" { - if _, err := s.db.ExecContext(ctx, `UPDATE creator_strategy SET enabled=false, updated_at=now() WHERE big_account_id=$1 OR execution_account_id=$1`, accountID); err != nil { - return LoginResult{}, databaseError(err) - } - } - if err := s.InvalidateListener(ctx, accountID, "登录状态变更"); err != nil { - return LoginResult{}, err - } return LoginResult{AccountID: accountID, Status: status, Reason: reason, ActualKey: actualKey, CheckedAt: now}, nil } @@ -402,150 +382,15 @@ func (s *Store) SetBigAccount(ctx context.Context, accountID string, enabled boo return AccountProfile{}, fmt.Errorf("begin creator big-account update: %w", err) } defer tx.Rollback() - if enabled { - var isSmall bool - if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM creator_relation WHERE small_account_id=$1)`, accountID).Scan(&isSmall); err != nil { - return AccountProfile{}, databaseError(err) - } - if isSmall { - return AccountProfile{}, ErrConflict - } - } if _, err := tx.ExecContext(ctx, `UPDATE creator_account_profile SET big_account = $2, updated_at = now() WHERE account_id = $1`, accountID, enabled); err != nil { return AccountProfile{}, databaseError(err) } - if !enabled { - if _, err := tx.ExecContext(ctx, `UPDATE creator_strategy SET enabled=false, updated_at=now() WHERE big_account_id=$1 OR execution_account_id=$1`, accountID); err != nil { - return AccountProfile{}, databaseError(err) - } - } if err := tx.Commit(); err != nil { return AccountProfile{}, fmt.Errorf("commit creator big-account update: %w", err) } - if err := s.InvalidateListener(ctx, accountID, "大小号模式变更"); err != nil { - return AccountProfile{}, err - } return s.GetAccountProfile(ctx, accountID) } -func (s *Store) ListRelations(ctx context.Context, bigAccountID string) ([]Relation, error) { - query := `SELECT big_account_id, small_account_id FROM creator_relation ORDER BY created_at, small_account_id` - args := []any{} - if bigAccountID != "" { - query = `SELECT big_account_id, small_account_id FROM creator_relation WHERE big_account_id = $1 ORDER BY created_at, small_account_id` - args = append(args, bigAccountID) - } - rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, databaseError(err) - } - defer rows.Close() - result := make([]Relation, 0) - for rows.Next() { - var relation Relation - if err := rows.Scan(&relation.BigAccountID, &relation.SmallAccountID); err != nil { - return nil, err - } - result = append(result, relation) - } - return result, rows.Err() -} - -func (s *Store) SetRelation(ctx context.Context, bigAccountID, smallAccountID string, enabled bool) error { - if bigAccountID == "" || smallAccountID == "" || bigAccountID == smallAccountID { - return ErrInvalid - } - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("begin creator relation: %w", err) - } - defer tx.Rollback() - // Lock both account rows in a stable order so relationship checks and role changes - // cannot observe a half-updated account pair. - first, second := bigAccountID, smallAccountID - if first > second { - first, second = second, first - } - rows, err := tx.QueryContext(ctx, `SELECT id FROM social_account WHERE id IN ($1,$2) ORDER BY id FOR UPDATE`, first, second) - if err != nil { - return databaseError(err) - } - defer rows.Close() - count := 0 - for rows.Next() { - count++ - } - if err := rows.Err(); err != nil { - return databaseError(err) - } - if count != 2 { - return ErrNotFound - } - if err := rows.Close(); err != nil { - return err - } - var bigPlatform, smallPlatform, bigAuthorizationKind, smallAuthorizationKind string - if err := tx.QueryRowContext(ctx, `SELECT platform, authorization_kind FROM social_account WHERE id = $1`, bigAccountID).Scan(&bigPlatform, &bigAuthorizationKind); err != nil { - return rowError(err) - } - if err := tx.QueryRowContext(ctx, `SELECT platform, authorization_kind FROM social_account WHERE id = $1`, smallAccountID).Scan(&smallPlatform, &smallAuthorizationKind); err != nil { - return rowError(err) - } - if !ValidatePlatform(bigPlatform) || bigPlatform != smallPlatform { - return ErrInvalid - } - if enabled && (bigAuthorizationKind != SourceOwned || smallAuthorizationKind != SourceOwned) { - return ErrInvalid - } - var bigEnabled bool - if err := tx.QueryRowContext(ctx, `SELECT COALESCE((SELECT big_account FROM creator_account_profile WHERE account_id=$1), false)`, bigAccountID).Scan(&bigEnabled); err != nil { - return databaseError(err) - } - if enabled && !bigEnabled { - return ErrConflict - } - if enabled { - var smallIsBig, bigIsSmall bool - if err := tx.QueryRowContext(ctx, `SELECT COALESCE((SELECT big_account FROM creator_account_profile WHERE account_id = $1), false)`, smallAccountID).Scan(&smallIsBig); err != nil { - return rowError(err) - } - if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM creator_relation WHERE small_account_id=$1)`, bigAccountID).Scan(&bigIsSmall); err != nil { - return databaseError(err) - } - if smallIsBig || bigIsSmall { - return ErrConflict - } - result, err := tx.ExecContext(ctx, `INSERT INTO creator_relation (big_account_id, small_account_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, bigAccountID, smallAccountID) - if err != nil { - return databaseError(err) - } - affected, err := result.RowsAffected() - if err != nil { - return err - } - if affected == 0 { - var existingBig string - if err := tx.QueryRowContext(ctx, `SELECT big_account_id FROM creator_relation WHERE small_account_id=$1`, smallAccountID).Scan(&existingBig); err != nil { - return rowError(err) - } - if existingBig != bigAccountID { - return ErrConflict - } - } - } else { - if _, err := tx.ExecContext(ctx, `DELETE FROM creator_relation WHERE big_account_id = $1 AND small_account_id = $2`, bigAccountID, smallAccountID); err != nil { - return databaseError(err) - } - if _, err := tx.ExecContext(ctx, `UPDATE creator_strategy SET enabled=false, updated_at=now() WHERE big_account_id=$1 AND execution_account_id=$2`, bigAccountID, smallAccountID); err != nil { - return databaseError(err) - } - } - if err := tx.Commit(); err != nil { - return fmt.Errorf("commit creator relation: %w", err) - } - return s.InvalidateListener(ctx, bigAccountID, "账号关系变更") -} - func (s *Store) AccountWriteCheck(ctx context.Context, accountID string, automatic bool, action string) (AccountProfile, error) { profile, err := s.GetAccountProfile(ctx, accountID) if err != nil { diff --git a/internal/creator/actions.go b/internal/creator/actions.go deleted file mode 100644 index 551df6f..0000000 --- a/internal/creator/actions.go +++ /dev/null @@ -1,1299 +0,0 @@ -package creator - -import ( - "context" - "crypto/rand" - "crypto/sha256" - "database/sql" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "math/big" - "strings" - "time" -) - -func validateStrategyInput(input StrategyInput) (StrategyInput, error) { - input.Action = strings.TrimSpace(input.Action) - input.TargetType = strings.TrimSpace(input.TargetType) - if input.Position <= 0 || !ValidAction(input.Action) || len(input.EventTypes) == 0 { - return StrategyInput{}, ErrInvalid - } - for i, eventType := range input.EventTypes { - input.EventTypes[i] = strings.TrimSpace(eventType) - if !ValidEventType(input.EventTypes[i]) || input.EventTypes[i] == "dm" { - return StrategyInput{}, ErrInvalid - } - } - for i, text := range input.CandidateTexts { - input.CandidateTexts[i] = strings.TrimSpace(text) - if input.CandidateTexts[i] == "" { - return StrategyInput{}, ErrInvalid - } - } - if input.Action == ActionLikeComment && input.TargetType != "comment" { - return StrategyInput{}, ErrInvalid - } - if (input.Action == ActionLikeWork || input.Action == ActionRepost) && input.TargetType != "work" { - return StrategyInput{}, ErrInvalid - } - if input.Action != ActionLikeComment && input.Action != ActionLikeWork && input.Action != ActionRepost { - input.TargetType = "user" - } - if len(input.CandidateTexts) > 20 { - return StrategyInput{}, ErrInvalid - } - return input, nil -} - -func encodeStrategyLists(input StrategyInput) (string, string, error) { - events, err := jsonText(input.EventTypes) - if err != nil { - return "", "", err - } - texts, err := jsonText(input.CandidateTexts) - if err != nil { - return "", "", err - } - return events, texts, nil -} - -func scanStrategy(scanner interface{ Scan(...any) error }) (Strategy, error) { - var result Strategy - var events, texts []byte - if err := scanner.Scan(&result.ID, &result.BigAccountID, &result.ExecutionAccountID, &result.Position, &result.Enabled, &events, &result.Action, &result.TargetType, &texts, &result.CreatedAt, &result.UpdatedAt); err != nil { - return Strategy{}, err - } - var err error - result.EventTypes, err = decodeStringList(events) - if err != nil { - return Strategy{}, err - } - result.CandidateTexts, err = decodeStringList(texts) - if err != nil { - return Strategy{}, err - } - return result, nil -} - -const strategySelect = `SELECT id,big_account_id,execution_account_id,position,enabled,event_types,action,target_type,candidate_texts,created_at,updated_at FROM creator_strategy` - -func (s *Store) validateEnabledStrategy(ctx context.Context, bigAccountID string, input StrategyInput) error { - if !input.Enabled || !ActionRequiresText(input.Action) || len(input.CandidateTexts) > 0 { - return nil - } - if strings.TrimSpace(input.Action) == "" { - return ErrInvalid - } - if strings.TrimSpace(bigAccountID) == "" { - return ErrInvalid - } - big, err := s.GetAccountProfile(ctx, bigAccountID) - if err != nil { - return err - } - if strings.TrimSpace(big.ReplyRequirements) == "" { - return ErrInvalid - } - settings, err := s.GetSettings(ctx) - if err != nil { - return err - } - if !settings.AIConfigured || settings.AIProvider != "bailian" || strings.TrimSpace(settings.AIModel) == "" { - return ErrUnavailable - } - return nil -} - -func (s *Store) CreateStrategy(ctx context.Context, bigAccountID string, input StrategyInput) (Strategy, error) { - input, err := validateStrategyInput(input) - if err != nil { - return Strategy{}, err - } - if err := s.validateEnabledStrategy(ctx, bigAccountID, input); err != nil { - return Strategy{}, err - } - big, err := s.GetAccountProfile(ctx, bigAccountID) - if err != nil { - return Strategy{}, err - } - if !big.BigAccount { - return Strategy{}, ErrConflict - } - small, err := s.GetAccountProfile(ctx, input.ExecutionAccountID) - if err != nil { - return Strategy{}, err - } - if big.Platform != small.Platform { - return Strategy{}, ErrConflict - } - if big.AuthorizationKind != SourceOwned || small.AuthorizationKind != SourceOwned { - return Strategy{}, ErrInvalid - } - if err := s.requireRelation(ctx, bigAccountID, input.ExecutionAccountID); err != nil { - return Strategy{}, err - } - events, texts, err := encodeStrategyLists(input) - if err != nil { - return Strategy{}, err - } - id := newID("strategy") - if _, err := s.db.ExecContext(ctx, `INSERT INTO creator_strategy (id,big_account_id,execution_account_id,position,enabled,event_types,action,target_type,candidate_texts) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9::jsonb)`, id, bigAccountID, input.ExecutionAccountID, input.Position, input.Enabled, events, input.Action, input.TargetType, texts); err != nil { - return Strategy{}, databaseError(err) - } - if input.Enabled { - if err := s.InvalidateListener(ctx, bigAccountID, "策略新增"); err != nil { - return Strategy{}, err - } - } - return s.GetStrategy(ctx, id) -} - -func (s *Store) requireRelation(ctx context.Context, bigID, smallID string) error { - var exists bool - if err := s.db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM creator_relation WHERE big_account_id=$1 AND small_account_id=$2)`, bigID, smallID).Scan(&exists); err != nil { - return databaseError(err) - } - if !exists { - return ErrConflict - } - return nil -} - -func (s *Store) GetStrategy(ctx context.Context, id string) (Strategy, error) { - result, err := scanStrategy(s.db.QueryRowContext(ctx, strategySelect+` WHERE id=$1`, id)) - return result, rowError(err) -} -func (s *Store) ListStrategyTraces(ctx context.Context, eventID string) ([]StrategyTrace, error) { - eventID = strings.TrimSpace(eventID) - if eventID == "" { - return nil, ErrInvalid - } - rows, err := s.db.QueryContext(ctx, `SELECT event_id,strategy_id,position,outcome,reason,created_at FROM creator_event_strategy_trace WHERE event_id=$1 ORDER BY position,id`, eventID) - if err != nil { - return nil, databaseError(err) - } - defer rows.Close() - result := make([]StrategyTrace, 0) - for rows.Next() { - var trace StrategyTrace - if err := rows.Scan(&trace.EventID, &trace.StrategyID, &trace.Position, &trace.Outcome, &trace.Reason, &trace.CreatedAt); err != nil { - return nil, err - } - result = append(result, trace) - } - return result, rows.Err() -} - -func (s *Store) ListStrategies(ctx context.Context, bigID string) ([]Strategy, error) { - query := strategySelect - args := []any{} - if bigID != "" { - query += ` WHERE big_account_id=$1` - args = append(args, bigID) - } - query += ` ORDER BY position,id` - rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, databaseError(err) - } - defer rows.Close() - result := make([]Strategy, 0) - for rows.Next() { - item, err := scanStrategy(rows) - if err != nil { - return nil, err - } - result = append(result, item) - } - return result, rows.Err() -} -func (s *Store) UpdateStrategy(ctx context.Context, id string, input StrategyInput) (Strategy, error) { - strategy, err := s.GetStrategy(ctx, id) - if err != nil { - return Strategy{}, err - } - input, err = validateStrategyInput(input) - if err != nil { - return Strategy{}, err - } - if err := s.validateEnabledStrategy(ctx, strategy.BigAccountID, input); err != nil { - return Strategy{}, err - } - big, err := s.GetAccountProfile(ctx, strategy.BigAccountID) - if err != nil { - return Strategy{}, err - } - small, err := s.GetAccountProfile(ctx, input.ExecutionAccountID) - if err != nil { - return Strategy{}, err - } - if big.Platform != small.Platform { - return Strategy{}, ErrConflict - } - if big.AuthorizationKind != SourceOwned || small.AuthorizationKind != SourceOwned { - return Strategy{}, ErrInvalid - } - events, texts, err := encodeStrategyLists(input) - if err != nil { - return Strategy{}, err - } - if err := s.requireRelation(ctx, strategy.BigAccountID, input.ExecutionAccountID); err != nil { - return Strategy{}, err - } - if _, err := s.db.ExecContext(ctx, `UPDATE creator_strategy SET execution_account_id=$2,position=$3,enabled=$4,event_types=$5::jsonb,action=$6,target_type=$7,candidate_texts=$8::jsonb,updated_at=now() WHERE id=$1`, id, input.ExecutionAccountID, input.Position, input.Enabled, events, input.Action, input.TargetType, texts); err != nil { - return Strategy{}, databaseError(err) - } - if err := s.InvalidateListener(ctx, strategy.BigAccountID, "策略修改"); err != nil { - return Strategy{}, err - } - return s.GetStrategy(ctx, id) -} -func (s *Store) SetStrategyEnabled(ctx context.Context, id string, enabled bool) (Strategy, error) { - strategy, err := s.GetStrategy(ctx, id) - if err != nil { - return Strategy{}, err - } - if enabled { - if err := s.validateEnabledStrategy(ctx, strategy.BigAccountID, StrategyInput{Enabled: true, Action: strategy.Action, CandidateTexts: strategy.CandidateTexts}); err != nil { - return Strategy{}, err - } - } - if _, err := s.db.ExecContext(ctx, `UPDATE creator_strategy SET enabled=$2,updated_at=now() WHERE id=$1`, id, enabled); err != nil { - return Strategy{}, databaseError(err) - } - if err := s.InvalidateListener(ctx, strategy.BigAccountID, "策略启停变更"); err != nil { - return Strategy{}, err - } - return s.GetStrategy(ctx, id) -} -func (s *Store) DeleteStrategy(ctx context.Context, id string) error { - _, err := s.db.ExecContext(ctx, `DELETE FROM creator_strategy WHERE id=$1`, id) - return databaseError(err) -} - -func scanEvent(scanner interface{ Scan(...any) error }) (InteractionEvent, error) { - var result InteractionEvent - var platformAt, gatewayReceivedAt, receivedAt, startedAt, finishedAt, displayedAt sql.NullTime - var commentID, workID, messageType, messageText, strategyID, executionID sql.NullString - if err := scanner.Scan(&result.ID, &result.Platform, &result.Generation, &result.ReceivingAccountID, &result.EventKey, &result.EventType, &result.InteractorUID, &commentID, &workID, &messageType, &messageText, &platformAt, &gatewayReceivedAt, &receivedAt, &startedAt, &finishedAt, &displayedAt, &result.State, &result.Reason, &strategyID, &executionID); err != nil { - return InteractionEvent{}, err - } - result.CommentID, result.WorkID, result.MessageType, result.MessageText = commentID.String, workID.String, messageType.String, messageText.String - result.StrategyID, result.ExecutionAccountID = strategyID.String, executionID.String - result.PlatformEventAt, result.GatewayReceivedAt, result.ProcessingStartedAt, result.ProcessingFinishedAt, result.DisplayedAt = nullableTime(platformAt), nullableTime(gatewayReceivedAt), nullableTime(startedAt), nullableTime(finishedAt), nullableTime(displayedAt) - if receivedAt.Valid { - result.ReceivedAt = receivedAt.Time.UTC() - } - return result, nil -} - -const eventSelect = `SELECT id,platform,generation,receiving_account_id,event_key,event_type,interactor_uid,comment_id,work_id,message_type,message_text,platform_event_at,gateway_received_at,received_at,processing_started_at,processing_finished_at,displayed_at,state,reason,strategy_id,execution_account_id FROM creator_event` - -func (s *Store) GetEvent(ctx context.Context, id string) (InteractionEvent, error) { - result, err := scanEvent(s.db.QueryRowContext(ctx, eventSelect+` WHERE id=$1`, id)) - return result, rowError(err) -} -func (s *Store) ListEvents(ctx context.Context, accountID string) ([]InteractionEvent, error) { - query := eventSelect - args := []any{} - if accountID != "" { - query += ` WHERE receiving_account_id=$1` - args = append(args, accountID) - } - query += ` ORDER BY received_at DESC,id` - rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, databaseError(err) - } - defer rows.Close() - result := make([]InteractionEvent, 0) - for rows.Next() { - item, err := scanEvent(rows) - if err != nil { - return nil, err - } - result = append(result, item) - } - return result, rows.Err() -} - -func (s *Store) RecordEvent(ctx context.Context, input InteractionEvent) (AutomaticResult, error) { - input.Platform, input.Generation, input.ReceivingAccountID, input.EventKey, input.EventType, input.InteractorUID, input.CommentID, input.WorkID, input.MessageType, input.MessageText = strings.TrimSpace(input.Platform), strings.TrimSpace(input.Generation), strings.TrimSpace(input.ReceivingAccountID), strings.TrimSpace(input.EventKey), strings.TrimSpace(input.EventType), strings.TrimSpace(input.InteractorUID), strings.TrimSpace(input.CommentID), strings.TrimSpace(input.WorkID), strings.TrimSpace(input.MessageType), strings.TrimSpace(input.MessageText) - if input.MessageType == "" { - input.MessageType = MessageTypeText - } - if !ValidatePlatform(input.Platform) || len(input.Generation) > 500 || input.ReceivingAccountID == "" || input.EventKey == "" || !ValidEventType(input.EventType) || !ValidMessageType(input.MessageType) || len(input.MessageText) > 100000 { - return AutomaticResult{}, ErrInvalid - } - profile, err := s.GetAccountProfile(ctx, input.ReceivingAccountID) - if err != nil { - return AutomaticResult{}, err - } - if profile.Platform != input.Platform { - return AutomaticResult{}, ErrInvalid - } - id := newID("event") - state := "received" - reason := "" - if input.Baseline { - state = "baseline" - reason = input.BaselineReason - if reason == "" { - reason = "监听基线" - } - } - if !input.Baseline && input.EventType != "dm" && input.InteractorUID == "" { - state = "blocked" - reason = "缺少互动用户 UID" - } - receivedAt := input.ReceivedAt.UTC() - if receivedAt.IsZero() { - receivedAt = time.Now().UTC() - } - var returnedID string - var inserted bool - err = s.db.QueryRowContext(ctx, `INSERT INTO creator_event (id,platform,generation,receiving_account_id,event_key,event_type,interactor_uid,comment_id,work_id,message_type,message_text,platform_event_at,gateway_received_at,received_at,state,reason) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) ON CONFLICT (platform,receiving_account_id,event_key) DO NOTHING RETURNING id,(xmax=0)`, id, input.Platform, input.Generation, input.ReceivingAccountID, input.EventKey, input.EventType, input.InteractorUID, input.CommentID, input.WorkID, input.MessageType, input.MessageText, input.PlatformEventAt, input.GatewayReceivedAt, receivedAt, state, reason).Scan(&returnedID, &inserted) - if errors.Is(err, sql.ErrNoRows) { - existingErr := s.db.QueryRowContext(ctx, `SELECT id FROM creator_event WHERE platform=$1 AND receiving_account_id=$2 AND event_key=$3`, input.Platform, input.ReceivingAccountID, input.EventKey).Scan(&returnedID) - if existingErr != nil { - return AutomaticResult{}, rowError(existingErr) - } - event, getErr := s.GetEvent(ctx, returnedID) - return AutomaticResult{Event: event, Duplicate: true}, getErr - } - if err != nil { - return AutomaticResult{}, databaseError(err) - } - event, err := s.GetEvent(ctx, returnedID) - return AutomaticResult{Event: event, Duplicate: !inserted}, err -} - -func (s *Store) markEvent(ctx context.Context, eventID, state, reason, strategyID, executionID string, started, finished, displayed *time.Time) error { - if state != "received" && state != "baseline" && state != "ignored" && state != "unmatched" && state != "blocked" && state != "processing" && state != "succeeded" && state != "failed" && state != "uncertain" { - return ErrInvalid - } - _, err := s.db.ExecContext(ctx, `UPDATE creator_event SET state=$2,reason=$3,strategy_id=NULLIF($4,''),execution_account_id=NULLIF($5,''),processing_started_at=COALESCE($6,processing_started_at),processing_finished_at=COALESCE($7,processing_finished_at),displayed_at=COALESCE($8,displayed_at) WHERE id=$1`, eventID, state, reason, strategyID, executionID, started, finished, displayed) - return databaseError(err) -} - -func selectCandidate(values []string) (string, error) { - if len(values) == 0 { - return "", nil - } - if len(values) == 1 { - return values[0], nil - } - n, err := rand.Int(rand.Reader, big.NewInt(int64(len(values)))) - if err != nil { - return "", fmt.Errorf("choose automatic response candidate: %w", err) - } - return values[n.Int64()], nil -} - -func (s *Store) ProcessAutomaticEvent(ctx context.Context, input InteractionEvent, executor ActionExecutor, generator TextGenerator) (AutomaticResult, error) { - recorded, err := s.RecordEvent(ctx, input) - if err != nil { - return recorded, err - } - eventID := recorded.Event.ID - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return AutomaticResult{}, fmt.Errorf("begin automatic event: %w", err) - } - defer tx.Rollback() - var storedPlatform, storedGeneration, storedReceivingAccountID, storedEventKey, storedEventType, storedInteractorUID, storedCommentID, storedWorkID, eventState string - if err := tx.QueryRowContext(ctx, `SELECT platform,generation,receiving_account_id,event_key,event_type,interactor_uid,comment_id,work_id,state FROM creator_event WHERE id=$1 FOR UPDATE`, eventID).Scan(&storedPlatform, &storedGeneration, &storedReceivingAccountID, &storedEventKey, &storedEventType, &storedInteractorUID, &storedCommentID, &storedWorkID, &eventState); err != nil { - return AutomaticResult{}, rowError(err) - } - if eventState != "received" { - tx.Rollback() - event, getErr := s.GetEvent(ctx, eventID) - return AutomaticResult{Event: event, Duplicate: recorded.Duplicate}, getErr - } - input.Platform = storedPlatform - input.Generation = storedGeneration - input.ReceivingAccountID = storedReceivingAccountID - input.EventKey = storedEventKey - input.EventType = storedEventType - input.InteractorUID = storedInteractorUID - input.CommentID = storedCommentID - input.WorkID = storedWorkID - var bigProfile AccountProfile - if err := s.scanAccountTx(ctx, tx, input.ReceivingAccountID, &bigProfile); err != nil { - return AutomaticResult{}, err - } - if storedGeneration != "" { - var listenerStatus, listenerGeneration string - var listenerInvalidated bool - listenerErr := tx.QueryRowContext(ctx, `SELECT status,generation,invalidated FROM creator_listener_state WHERE account_id=$1`, input.ReceivingAccountID).Scan(&listenerStatus, &listenerGeneration, &listenerInvalidated) - if listenerErr != nil && !errors.Is(listenerErr, sql.ErrNoRows) { - return AutomaticResult{}, databaseError(listenerErr) - } - if listenerErr == nil && (listenerStatus != "ready" || listenerGeneration != storedGeneration || listenerInvalidated) { - if _, updateErr := tx.ExecContext(ctx, `UPDATE creator_event SET state='blocked',reason='监听代际未就绪' WHERE id=$1 AND state='received'`, eventID); updateErr != nil { - return AutomaticResult{}, databaseError(updateErr) - } - if err := tx.Commit(); err != nil { - return AutomaticResult{}, err - } - event, getErr := s.GetEvent(ctx, eventID) - return AutomaticResult{Event: event}, getErr - } - } - if !bigProfile.BigAccount { - if _, err := tx.ExecContext(ctx, `UPDATE creator_event SET state='ignored', reason='大号模式未开启' WHERE id=$1`, eventID); err != nil { - return AutomaticResult{}, databaseError(err) - } - if err := tx.Commit(); err != nil { - return AutomaticResult{}, fmt.Errorf("commit ignored event: %w", err) - } - event, err := s.GetEvent(ctx, eventID) - return AutomaticResult{Event: event}, err - } - var chosen Strategy - var execution AccountProfile - reason := "" - strategies, err := s.listStrategiesTx(ctx, tx, input.ReceivingAccountID) - if err != nil { - return AutomaticResult{}, err - } - tracePosition := 0 - trace := func(strategyID, outcome, traceReason string) error { - tracePosition++ - _, traceErr := tx.ExecContext(ctx, `INSERT INTO creator_event_strategy_trace (event_id,strategy_id,position,outcome,reason) VALUES ($1,$2,$3,$4,$5)`, eventID, strategyID, tracePosition, outcome, traceReason) - return databaseError(traceErr) - } - for _, strategy := range strategies { - if !strategy.Enabled { - if err := trace(strategy.ID, "skipped", "策略未启用"); err != nil { - return AutomaticResult{}, err - } - continue - } - if !contains(strategy.EventTypes, input.EventType) { - if err := trace(strategy.ID, "skipped", "事件类型不匹配"); err != nil { - return AutomaticResult{}, err - } - continue - } - var profile AccountProfile - if err := s.scanAccountTx(ctx, tx, strategy.ExecutionAccountID, &profile); err != nil { - reason = "执行账号不可用" - if traceErr := trace(strategy.ID, "skipped", reason); traceErr != nil { - return AutomaticResult{}, traceErr - } - continue - } - var related bool - if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM creator_relation WHERE big_account_id=$1 AND small_account_id=$2)`, input.ReceivingAccountID, strategy.ExecutionAccountID).Scan(&related); err != nil { - return AutomaticResult{}, databaseError(err) - } - if !related { - reason = "执行账号未绑定" - if traceErr := trace(strategy.ID, "skipped", reason); traceErr != nil { - return AutomaticResult{}, traceErr - } - continue - } - if err := CanWrite(profile, true, strategy.Action); err != nil { - reason = "执行账号不可用" - if traceErr := trace(strategy.ID, "skipped", reason); traceErr != nil { - return AutomaticResult{}, traceErr - } - continue - } - if !ActionTargetValid(strategy.Action, input.InteractorUID, input.CommentID, input.WorkID, strategy.TargetType) { - reason = "事件缺少动作目标" - if traceErr := trace(strategy.ID, "skipped", reason); traceErr != nil { - return AutomaticResult{}, traceErr - } - continue - } - if ActionRequiresText(strategy.Action) && len(strategy.CandidateTexts) == 0 && strings.TrimSpace(bigProfile.ReplyRequirements) == "" { - reason = "缺少候选文本和大号 AI 回复要求" - if traceErr := trace(strategy.ID, "skipped", reason); traceErr != nil { - return AutomaticResult{}, traceErr - } - continue - } - chosen, execution = strategy, profile - if err := trace(strategy.ID, "selected", ""); err != nil { - return AutomaticResult{}, err - } - break - } - if chosen.ID == "" { - if reason == "" { - reason = "没有匹配策略" - } - if _, err := tx.ExecContext(ctx, `UPDATE creator_event SET state='unmatched',reason=$2 WHERE id=$1`, eventID, reason); err != nil { - return AutomaticResult{}, databaseError(err) - } - if err := tx.Commit(); err != nil { - return AutomaticResult{}, err - } - event, getErr := s.GetEvent(ctx, eventID) - if getErr != nil { - return AutomaticResult{}, getErr - } - return AutomaticResult{Event: event}, nil - } - now := time.Now().UTC() - cooldownSeconds := bigProfile.CooldownSeconds - if !validCooldownSeconds(cooldownSeconds) { - return AutomaticResult{}, ErrInvalid - } - expires := now.Add(time.Duration(cooldownSeconds) * time.Second) - var cooldownID string - scanErr := tx.QueryRowContext(ctx, `INSERT INTO creator_cooldown (big_account_id,interactor_uid,event_id,strategy_id,execution_account_id,started_at,expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (big_account_id,interactor_uid) DO UPDATE SET event_id=EXCLUDED.event_id,strategy_id=EXCLUDED.strategy_id,execution_account_id=EXCLUDED.execution_account_id,started_at=EXCLUDED.started_at,expires_at=EXCLUDED.expires_at WHERE creator_cooldown.expires_at <= $6 RETURNING event_id`, input.ReceivingAccountID, input.InteractorUID, eventID, chosen.ID, execution.ID, now, expires).Scan(&cooldownID) - if errors.Is(scanErr, sql.ErrNoRows) { - if _, err := tx.ExecContext(ctx, `UPDATE creator_event SET state='blocked',reason='自动响应冷却中' WHERE id=$1`, eventID); err != nil { - return AutomaticResult{}, databaseError(err) - } - if err := tx.Commit(); err != nil { - return AutomaticResult{}, err - } - event, getErr := s.GetEvent(ctx, eventID) - if getErr != nil { - return AutomaticResult{}, getErr - } - return AutomaticResult{Event: event}, nil - } - if scanErr != nil { - return AutomaticResult{}, databaseError(scanErr) - } - text, selectionErr := selectCandidate(chosen.CandidateTexts) - if selectionErr != nil { - if _, updateErr := tx.ExecContext(ctx, `UPDATE creator_event SET state='failed', reason=$2, strategy_id=$3, execution_account_id=$4 WHERE id=$1`, eventID, selectionErr.Error(), chosen.ID, execution.ID); updateErr != nil { - return AutomaticResult{}, databaseError(updateErr) - } - if commitErr := tx.Commit(); commitErr != nil { - return AutomaticResult{}, commitErr - } - event, getErr := s.GetEvent(ctx, eventID) - if getErr != nil { - return AutomaticResult{}, getErr - } - return AutomaticResult{Event: event}, selectionErr - } - opInput := OperationInput{IdempotencyKey: "event:" + input.Platform + ":" + input.ReceivingAccountID + ":" + input.EventKey, Source: "automatic", Action: chosen.Action, Platform: input.Platform, AccountID: execution.ID, TargetUID: input.InteractorUID, TargetCommentID: input.CommentID, TargetWorkID: input.WorkID, Text: text, EventID: eventID, StrategyID: chosen.ID} - opID := newID("operation") - hash := operationHash(opInput) - if _, err := tx.ExecContext(ctx, `INSERT INTO creator_operation (id,idempotency_key,source,action,platform,account_id,target_uid,target_comment_id,target_work_id,text,event_id,strategy_id,request_hash,state) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'processing')`, opID, opInput.IdempotencyKey, opInput.Source, opInput.Action, opInput.Platform, opInput.AccountID, opInput.TargetUID, opInput.TargetCommentID, opInput.TargetWorkID, opInput.Text, opInput.EventID, opInput.StrategyID, hash); err != nil { - return AutomaticResult{}, databaseError(err) - } - if _, err := tx.ExecContext(ctx, `UPDATE creator_event SET state='processing',reason='',strategy_id=$2,execution_account_id=$3,processing_started_at=$4 WHERE id=$1`, eventID, chosen.ID, execution.ID, now); err != nil { - return AutomaticResult{}, databaseError(err) - } - if err := tx.Commit(); err != nil { - return AutomaticResult{}, fmt.Errorf("commit automatic event: %w", err) - } - if ActionRequiresText(chosen.Action) && strings.TrimSpace(text) == "" { - if generator == nil { - result := ActionResult{State: "blocked", Reason: "AI 生成不可用"} - if err := s.UpdateOperationResult(ctx, opID, result); err != nil { - return AutomaticResult{}, fmt.Errorf("save unavailable automatic operation: %w", err) - } - if err := s.markEvent(ctx, eventID, result.State, result.Reason, chosen.ID, execution.ID, nil, ptrTime(time.Now().UTC()), nil); err != nil { - return AutomaticResult{}, fmt.Errorf("save unavailable automatic event: %w", err) - } - event, getErr := s.GetEvent(ctx, eventID) - if getErr != nil { - return AutomaticResult{}, getErr - } - op, getErr := s.GetOperation(ctx, opID) - if getErr != nil { - return AutomaticResult{}, getErr - } - return AutomaticResult{Event: event, Operation: &op}, ErrUnavailable - } - generated, generateErr := generator.Generate(ctx, bigProfile.ReplyRequirements, input.EventType) - if generateErr != nil { - result := ActionResult{State: "failed", Reason: generateErr.Error()} - if err := s.UpdateOperationResult(ctx, opID, result); err != nil { - return AutomaticResult{}, fmt.Errorf("save failed automatic operation: %w", err) - } - if err := s.markEvent(ctx, eventID, result.State, result.Reason, chosen.ID, execution.ID, nil, ptrTime(time.Now().UTC()), nil); err != nil { - return AutomaticResult{}, fmt.Errorf("save failed automatic event: %w", err) - } - event, getErr := s.GetEvent(ctx, eventID) - if getErr != nil { - return AutomaticResult{}, getErr - } - op, getErr := s.GetOperation(ctx, opID) - if getErr != nil { - return AutomaticResult{}, getErr - } - return AutomaticResult{Event: event, Operation: &op}, generateErr - } - text = strings.TrimSpace(generated) - if text == "" { - result := ActionResult{State: "failed", Reason: "AI 返回空内容"} - if err := s.UpdateOperationResult(ctx, opID, result); err != nil { - return AutomaticResult{}, fmt.Errorf("save empty automatic operation: %w", err) - } - if err := s.markEvent(ctx, eventID, result.State, result.Reason, chosen.ID, execution.ID, nil, ptrTime(time.Now().UTC()), nil); err != nil { - return AutomaticResult{}, fmt.Errorf("save empty automatic event: %w", err) - } - event, getErr := s.GetEvent(ctx, eventID) - if getErr != nil { - return AutomaticResult{}, getErr - } - op, getErr := s.GetOperation(ctx, opID) - if getErr != nil { - return AutomaticResult{}, getErr - } - return AutomaticResult{Event: event, Operation: &op}, ErrInvalid - } - hash = operationHash(OperationInput{IdempotencyKey: opInput.IdempotencyKey, Source: opInput.Source, Action: opInput.Action, Platform: opInput.Platform, AccountID: opInput.AccountID, TargetUID: opInput.TargetUID, TargetCommentID: opInput.TargetCommentID, TargetWorkID: opInput.TargetWorkID, Text: text, EventID: opInput.EventID, StrategyID: opInput.StrategyID}) - updated, updateErr := s.db.ExecContext(ctx, `UPDATE creator_operation SET text=$2,request_hash=$3,updated_at=now() WHERE id=$1 AND state='processing'`, opID, text, hash) - if updateErr != nil { - return AutomaticResult{}, databaseError(updateErr) - } - if affected, affectedErr := updated.RowsAffected(); affectedErr != nil { - return AutomaticResult{}, affectedErr - } else if affected != 1 { - event, getErr := s.GetEvent(ctx, eventID) - if getErr != nil { - return AutomaticResult{}, getErr - } - op, getErr := s.GetOperation(ctx, opID) - if getErr != nil { - return AutomaticResult{}, getErr - } - return AutomaticResult{Event: event, Operation: &op}, nil - } - } - started := now - // Conditions may change while the operation waits for the account executor. - // Re-check immediately before the platform write; a stale queued operation is blocked, never sent. - // The lock is deliberately acquired after receipt/operation persistence so ingestion - // never waits on a slow platform write, while one execution account remains serial. - executionLock := s.automaticExecutionLock(execution.ID) - executionLock.Lock() - defer executionLock.Unlock() - releaseExecutionLock, err := s.acquireAutomaticExecutionLock(ctx, execution.ID) - if err != nil { - return AutomaticResult{}, err - } - defer releaseExecutionLock() - var liveOperationID string - if err := s.db.QueryRowContext(ctx, `UPDATE creator_operation SET updated_at=now() WHERE id=$1 AND state='processing' RETURNING id`, opID).Scan(&liveOperationID); errors.Is(err, sql.ErrNoRows) { - event, getErr := s.GetEvent(ctx, eventID) - if getErr != nil { - return AutomaticResult{}, getErr - } - op, getErr := s.GetOperation(ctx, opID) - if getErr != nil { - return AutomaticResult{}, getErr - } - return AutomaticResult{Event: event, Operation: &op}, nil - } else if err != nil { - return AutomaticResult{}, databaseError(err) - } - result := ActionResult{} - _, checkErr := s.AccountWriteCheck(ctx, execution.ID, true, chosen.Action) - if checkErr == nil { - currentBig, bigErr := s.GetAccountProfile(ctx, input.ReceivingAccountID) - if bigErr != nil { - checkErr = bigErr - } else if !currentBig.BigAccount || currentBig.BusinessStatus != "normal" || currentBig.LoginStatus != "logged_in" || currentBig.AuthorizationStatus != "authorized" { - checkErr = ErrConflict - } - } - if checkErr == nil { - var related bool - checkErr = s.db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM creator_relation WHERE big_account_id=$1 AND small_account_id=$2)`, input.ReceivingAccountID, execution.ID).Scan(&related) - if checkErr == nil && !related { - checkErr = ErrConflict - } - } - if checkErr == nil { - latestStrategy, strategyErr := s.GetStrategy(ctx, chosen.ID) - if strategyErr != nil { - checkErr = strategyErr - } else if !latestStrategy.Enabled || latestStrategy.Action != chosen.Action || latestStrategy.ExecutionAccountID != execution.ID || !contains(latestStrategy.EventTypes, input.EventType) { - checkErr = ErrConflict - } - } - if checkErr == nil && storedGeneration != "" { - var listenerStatus, listenerGeneration string - var listenerInvalidated bool - listenerErr := s.db.QueryRowContext(ctx, `SELECT status,generation,invalidated FROM creator_listener_state WHERE account_id=$1`, input.ReceivingAccountID).Scan(&listenerStatus, &listenerGeneration, &listenerInvalidated) - if listenerErr != nil && !errors.Is(listenerErr, sql.ErrNoRows) { - checkErr = databaseError(listenerErr) - } else if listenerErr == nil && (listenerStatus != "ready" || listenerGeneration != storedGeneration || listenerInvalidated) { - checkErr = ErrConflict - } - } - if checkErr != nil { - result = actionPreconditionResult(checkErr, "写入前条件已变化") - } else if executor == nil { - result = ActionResult{State: "uncertain", Reason: "平台执行器不可用"} - } else { - result, err = executor.Execute(ctx, ActionRequest{OperationID: opID, Action: chosen.Action, Platform: input.Platform, AccountID: execution.ID, TargetUID: input.InteractorUID, TargetCommentID: input.CommentID, TargetWorkID: input.WorkID, Text: text}) - result = normalizeActionResult(result, err) - } - if result.State != "succeeded" && result.State != "failed" && result.State != "uncertain" && result.State != "blocked" { - result.State = "uncertain" - if result.Reason == "" { - result.Reason = "执行器未返回明确结果" - } - } - if err := s.UpdateOperationResult(ctx, opID, result); err != nil { - return AutomaticResult{}, fmt.Errorf("save automatic operation result: %w", err) - } - finished := time.Now().UTC() - if err := s.markEvent(ctx, eventID, result.State, result.Reason, chosen.ID, execution.ID, &started, &finished, nil); err != nil { - return AutomaticResult{}, fmt.Errorf("save automatic event result: %w", err) - } - event, err := s.GetEvent(ctx, eventID) - if err != nil { - return AutomaticResult{}, err - } - op, err := s.GetOperation(ctx, opID) - if err != nil { - return AutomaticResult{}, err - } - return AutomaticResult{Event: event, Operation: &op}, nil -} - -const staleProcessingAfter = 2 * time.Minute - -func (s *Store) RecoverStaleProcessing(ctx context.Context, now time.Time) (int, error) { - if now.IsZero() { - return 0, ErrInvalid - } - cutoff := now.UTC().Add(-staleProcessingAfter) - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return 0, databaseError(err) - } - defer tx.Rollback() - rows, err := tx.QueryContext(ctx, ` - SELECT id,event_id - FROM creator_operation - WHERE state='processing' AND updated_at < $1 - FOR UPDATE SKIP LOCKED`, cutoff) - if err != nil { - return 0, databaseError(err) - } - const reason = "处理者失联,平台写入结果不明" - stale := make([]struct{ operationID, eventID string }, 0) - for rows.Next() { - var item struct{ operationID, eventID string } - if err := rows.Scan(&item.operationID, &item.eventID); err != nil { - rows.Close() - return 0, databaseError(err) - } - stale = append(stale, item) - } - if err := rows.Err(); err != nil { - rows.Close() - return 0, databaseError(err) - } - if err := rows.Close(); err != nil { - return 0, databaseError(err) - } - for _, item := range stale { - if _, err := tx.ExecContext(ctx, `UPDATE creator_operation SET state='uncertain',reason=$2,updated_at=$3 WHERE id=$1 AND state='processing'`, item.operationID, reason, now.UTC()); err != nil { - return 0, databaseError(err) - } - if item.eventID != "" { - if _, err := tx.ExecContext(ctx, `UPDATE creator_event SET state='uncertain',reason=$2,processing_finished_at=COALESCE(processing_finished_at,$3) WHERE id=$1 AND state='processing'`, item.eventID, reason, now.UTC()); err != nil { - return 0, databaseError(err) - } - } - } - if _, err := tx.ExecContext(ctx, `UPDATE creator_event SET state='uncertain',reason=$2,processing_finished_at=COALESCE(processing_finished_at,$3) WHERE state='processing' AND processing_started_at IS NOT NULL AND processing_started_at < $1 AND NOT EXISTS (SELECT 1 FROM creator_operation WHERE event_id=creator_event.id AND state='processing')`, cutoff, reason, now.UTC()); err != nil { - return 0, databaseError(err) - } - receivedRecovered, err := tx.ExecContext(ctx, `UPDATE creator_event SET state='uncertain',reason='事件已收到但未开始处理,未补发',processing_finished_at=COALESCE(processing_finished_at,$2) WHERE state='received' AND received_at < $1`, cutoff, now.UTC()) - if err != nil { - return 0, databaseError(err) - } - receivedCount, err := receivedRecovered.RowsAffected() - if err != nil { - return 0, err - } - if err := tx.Commit(); err != nil { - return 0, databaseError(err) - } - return len(stale) + int(receivedCount), nil -} - -func contains(values []string, want string) bool { - for _, value := range values { - if value == want { - return true - } - } - return false -} -func (s *Store) scanAccountTx(ctx context.Context, tx *sql.Tx, id string, result *AccountProfile) error { - var checkedAt sql.NullTime - if err := tx.QueryRowContext(ctx, accountProfileQuery(), id).Scan(&result.ID, &result.Name, &result.Platform, &result.PlatformAccountKey, &result.AuthorizationKind, &result.AuthorizationStatus, &result.RuntimeStatus, &result.LoginUsername, &result.PasswordConfigured, &result.RealNameStatus, &result.RealName, &result.IdentityNumber, &result.Note, &result.BusinessStatus, &result.BigAccount, &result.ReplyRequirements, &result.LoginStatus, &result.LoginReason, &checkedAt, &result.CooldownSeconds, &result.UpdatedAt); err != nil { - return rowError(err) - } - result.LoginCheckedAt = nullableTime(checkedAt) - return nil -} -func (s *Store) listStrategiesTx(ctx context.Context, tx *sql.Tx, bigID string) ([]Strategy, error) { - rows, err := tx.QueryContext(ctx, strategySelect+` WHERE big_account_id=$1 ORDER BY position,id`, bigID) - if err != nil { - return nil, databaseError(err) - } - defer rows.Close() - result := make([]Strategy, 0) - for rows.Next() { - item, err := scanStrategy(rows) - if err != nil { - return nil, err - } - result = append(result, item) - } - return result, rows.Err() -} - -func operationHash(input OperationInput) string { - encoded, _ := json.Marshal(input) - sum := sha256.Sum256(encoded) - return hex.EncodeToString(sum[:]) -} -func scanOperation(scanner interface{ Scan(...any) error }) (Operation, error) { - var result Operation - var evidence, verificationEvidence []byte - var eventID, strategyID sql.NullString - if err := scanner.Scan(&result.ID, &result.IdempotencyKey, &result.Source, &result.Action, &result.Platform, &result.AccountID, &result.TargetUID, &result.TargetCommentID, &result.TargetWorkID, &result.Text, &eventID, &strategyID, &result.State, &evidence, &result.Reason, &result.VerificationState, &verificationEvidence, &result.VerifiedAt, &result.CreatedAt, &result.UpdatedAt); err != nil { - return Operation{}, err - } - result.EventID, result.StrategyID = eventID.String, strategyID.String - result.Evidence = map[string]string{} - if len(evidence) > 0 { - if err := json.Unmarshal(evidence, &result.Evidence); err != nil { - return Operation{}, err - } - } - if result.VerificationState == "" { - result.VerificationState = "not_verified" - } - result.VerificationProof = map[string]string{} - if len(verificationEvidence) > 0 { - if err := json.Unmarshal(verificationEvidence, &result.VerificationProof); err != nil { - return Operation{}, err - } - } - return result, nil -} - -const operationSelect = `SELECT id,idempotency_key,source,action,platform,account_id,target_uid,target_comment_id,target_work_id,text,event_id,strategy_id,state,evidence,reason,verification_state,verification_evidence,verified_at,created_at,updated_at FROM creator_operation` - -func (s *Store) GetOperation(ctx context.Context, id string) (Operation, error) { - result, err := scanOperation(s.db.QueryRowContext(ctx, operationSelect+` WHERE id=$1`, id)) - return result, rowError(err) -} -func (s *Store) ListOperations(ctx context.Context, accountID string) ([]Operation, error) { - query := operationSelect - args := []any{} - if accountID != "" { - query += ` WHERE account_id=$1` - args = append(args, accountID) - } - query += ` ORDER BY created_at DESC,id` - rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, databaseError(err) - } - defer rows.Close() - result := make([]Operation, 0) - for rows.Next() { - item, err := scanOperation(rows) - if err != nil { - return nil, err - } - result = append(result, item) - } - return result, rows.Err() -} -func actionPreconditionResult(err error, reason string) ActionResult { - if errors.Is(err, ErrInvalid) || errors.Is(err, ErrConflict) || errors.Is(err, ErrNotFound) { - return ActionResult{State: "blocked", Reason: reason} - } - return ActionResult{State: "uncertain", Reason: "写入前检查失败: " + err.Error()} -} - -func normalizeActionResult(result ActionResult, execErr error) ActionResult { - if execErr != nil { - if result.State != "failed" && result.State != "blocked" { - result.State = "uncertain" - } - if result.Reason == "" { - result.Reason = execErr.Error() - } - } - if result.State != "succeeded" && result.State != "failed" && result.State != "uncertain" && result.State != "blocked" { - result.State = "uncertain" - if result.Reason == "" { - result.Reason = "执行器未返回明确结果" - } - } - return result -} - -func (s *Store) validateOperationTarget(ctx context.Context, input *OperationInput) error { - if input.TargetCommentID != "" { - var platform, authorUID, commentWorkID string - if err := s.db.QueryRowContext(ctx, `SELECT platform,author_uid,work_id FROM creator_comment WHERE id=$1`, input.TargetCommentID).Scan(&platform, &authorUID, &commentWorkID); err != nil { - return rowError(err) - } - if platform != input.Platform || authorUID != input.TargetUID || input.TargetWorkID != "" && commentWorkID != input.TargetWorkID { - return ErrInvalid - } - if input.TargetWorkID == "" { - input.TargetWorkID = commentWorkID - } - } - if input.TargetWorkID != "" { - var platform string - if err := s.db.QueryRowContext(ctx, `SELECT platform FROM creator_work WHERE id=$1`, input.TargetWorkID).Scan(&platform); err != nil { - return rowError(err) - } - if platform != input.Platform { - return ErrInvalid - } - } - return nil -} - -func (s *Store) CreateOperation(ctx context.Context, input OperationInput) (Operation, bool, error) { - input.IdempotencyKey = strings.TrimSpace(input.IdempotencyKey) - input.Source = strings.TrimSpace(input.Source) - input.Action = strings.TrimSpace(input.Action) - input.Platform = strings.TrimSpace(input.Platform) - input.AccountID = strings.TrimSpace(input.AccountID) - input.TargetUID = strings.TrimSpace(input.TargetUID) - input.TargetCommentID = strings.TrimSpace(input.TargetCommentID) - input.TargetWorkID = strings.TrimSpace(input.TargetWorkID) - input.Text = strings.TrimSpace(input.Text) - if input.IdempotencyKey == "" || input.Source != "manual" || !ValidAction(input.Action) || !ValidatePlatform(input.Platform) || input.AccountID == "" || !ActionTargetValid(input.Action, input.TargetUID, input.TargetCommentID, input.TargetWorkID, "") || ActionRequiresText(input.Action) && input.Text == "" { - return Operation{}, false, ErrInvalid - } - hash := operationHash(input) - var returnedID, existingHash string - lookupErr := s.db.QueryRowContext(ctx, `SELECT id,request_hash FROM creator_operation WHERE idempotency_key=$1`, input.IdempotencyKey).Scan(&returnedID, &existingHash) - if lookupErr == nil { - if existingHash != hash { - return Operation{}, false, ErrConflict - } - op, err := s.GetOperation(ctx, returnedID) - return op, false, err - } - if !errors.Is(lookupErr, sql.ErrNoRows) { - return Operation{}, false, databaseError(lookupErr) - } - profile, err := s.AccountWriteCheck(ctx, input.AccountID, false, input.Action) - if err != nil { - return Operation{}, false, err - } - if profile.Platform != input.Platform { - return Operation{}, false, ErrInvalid - } - if err := s.validateOperationTarget(ctx, &input); err != nil { - return Operation{}, false, err - } - id := newID("operation") - var inserted bool - err = s.db.QueryRowContext(ctx, `INSERT INTO creator_operation (id,idempotency_key,source,action,platform,account_id,target_uid,target_comment_id,target_work_id,text,event_id,strategy_id,request_hash) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT (idempotency_key) DO NOTHING RETURNING id,(xmax=0)`, id, input.IdempotencyKey, input.Source, input.Action, input.Platform, input.AccountID, input.TargetUID, input.TargetCommentID, input.TargetWorkID, input.Text, input.EventID, input.StrategyID, hash).Scan(&returnedID, &inserted) - if errors.Is(err, sql.ErrNoRows) { - if err := s.db.QueryRowContext(ctx, `SELECT id,request_hash FROM creator_operation WHERE idempotency_key=$1`, input.IdempotencyKey).Scan(&returnedID, &existingHash); err != nil { - return Operation{}, false, rowError(err) - } - if existingHash != hash { - return Operation{}, false, ErrConflict - } - op, err := s.GetOperation(ctx, returnedID) - return op, false, err - } - if err != nil { - return Operation{}, false, databaseError(err) - } - op, err := s.GetOperation(ctx, returnedID) - return op, inserted, err -} -func (s *Store) UpdateOperationResult(ctx context.Context, id string, result ActionResult) error { - if result.State != "succeeded" && result.State != "failed" && result.State != "uncertain" && result.State != "blocked" { - return ErrInvalid - } - evidence, err := jsonText(result.Evidence) - if err != nil { - return err - } - verificationState := "uncertain" - if result.State == "succeeded" || result.State == "failed" || result.State == "blocked" { - verificationState = result.State - } - updated, err := s.db.ExecContext(ctx, `UPDATE creator_operation SET state=$2,evidence=$3::jsonb,reason=$4,verification_state=$5,verification_evidence=$3::jsonb,verified_at=CASE WHEN $5 IN ('succeeded','failed','blocked') THEN now() ELSE NULL END,updated_at=now() WHERE id=$1 AND state='processing'`, id, result.State, evidence, result.Reason, verificationState) - if err != nil { - return databaseError(err) - } - affected, err := updated.RowsAffected() - if err != nil { - return err - } - if affected != 1 { - return ErrConflict - } - return nil -} -func (s *Store) persistDirectMessageResult(ctx context.Context, op Operation, result ActionResult) error { - if op.Action != ActionDM { - return nil - } - messageState := result.State - if messageState == "blocked" { - messageState = "failed" - } - messageAt := time.Now().UTC() - platformMessageKey := result.Evidence["message_server_id"] - if strings.TrimSpace(platformMessageKey) == "" { - platformMessageKey = "operation:" + op.ID - } - _, _, err := s.SaveMessage(ctx, MessageInput{Platform: op.Platform, AccountID: op.AccountID, PeerUID: op.TargetUID, PlatformMessageKey: platformMessageKey, OperationID: op.ID, Direction: "outbound", MessageType: "text", Text: op.Text, SentState: messageState, MessageAt: &messageAt}) - return err -} - -func (s *Store) ExecuteManualOperation(ctx context.Context, id string, executor ActionExecutor) (Operation, error) { - op, err := s.GetOperation(ctx, id) - if err != nil { - return Operation{}, err - } - if op.Source != "manual" { - return Operation{}, ErrInvalid - } - if op.State != "created" { - return op, nil - } - var claimedID string - if err := s.db.QueryRowContext(ctx, `UPDATE creator_operation SET state='processing',updated_at=now() WHERE id=$1 AND state='created' RETURNING id`, id).Scan(&claimedID); errors.Is(err, sql.ErrNoRows) { - return s.GetOperation(ctx, id) - } else if err != nil { - return Operation{}, databaseError(err) - } - executionLock := s.automaticExecutionLock(op.AccountID) - executionLock.Lock() - defer executionLock.Unlock() - releaseExecutionLock, err := s.acquireAutomaticExecutionLock(ctx, op.AccountID) - if err != nil { - return Operation{}, err - } - defer releaseExecutionLock() - if err := s.db.QueryRowContext(ctx, `UPDATE creator_operation SET updated_at=now() WHERE id=$1 AND state='processing' RETURNING id`, id).Scan(&claimedID); errors.Is(err, sql.ErrNoRows) { - return s.GetOperation(ctx, id) - } else if err != nil { - return Operation{}, databaseError(err) - } - if _, checkErr := s.AccountWriteCheck(ctx, op.AccountID, false, op.Action); checkErr != nil { - result := actionPreconditionResult(checkErr, "写入前条件已变化") - if updateErr := s.UpdateOperationResult(ctx, id, result); updateErr != nil { - return Operation{}, updateErr - } - if messageErr := s.persistDirectMessageResult(ctx, op, result); messageErr != nil { - return Operation{}, fmt.Errorf("persist direct message result: %w", messageErr) - } - return s.GetOperation(ctx, id) - } - if checkErr := s.validateOperationTarget(ctx, &OperationInput{Platform: op.Platform, AccountID: op.AccountID, Action: op.Action, TargetUID: op.TargetUID, TargetCommentID: op.TargetCommentID, TargetWorkID: op.TargetWorkID}); checkErr != nil { - result := actionPreconditionResult(checkErr, "写入目标已变化") - if updateErr := s.UpdateOperationResult(ctx, id, result); updateErr != nil { - return Operation{}, updateErr - } - if messageErr := s.persistDirectMessageResult(ctx, op, result); messageErr != nil { - return Operation{}, fmt.Errorf("persist direct message result: %w", messageErr) - } - return s.GetOperation(ctx, id) - } - if executor == nil { - result := ActionResult{State: "uncertain", Reason: "平台执行器不可用"} - if updateErr := s.UpdateOperationResult(ctx, id, result); updateErr != nil { - return Operation{}, updateErr - } - if messageErr := s.persistDirectMessageResult(ctx, op, result); messageErr != nil { - return Operation{}, fmt.Errorf("persist direct message result: %w", messageErr) - } - return s.GetOperation(ctx, id) - } - result, execErr := executor.Execute(ctx, ActionRequest{OperationID: claimedID, Action: op.Action, Platform: op.Platform, AccountID: op.AccountID, TargetUID: op.TargetUID, TargetCommentID: op.TargetCommentID, TargetWorkID: op.TargetWorkID, Text: op.Text}) - result = normalizeActionResult(result, execErr) - if err := s.UpdateOperationResult(ctx, id, result); err != nil { - return Operation{}, err - } - if err := s.persistDirectMessageResult(ctx, op, result); err != nil { - return Operation{}, fmt.Errorf("persist direct message result: %w", err) - } - return s.GetOperation(ctx, id) -} - -func scanConversation(scanner interface{ Scan(...any) error }) (Conversation, error) { - var c Conversation - var last, synced sql.NullTime - if err := scanner.Scan(&c.ID, &c.Platform, &c.AccountID, &c.PeerUID, &c.PeerName, &last, &c.HistoryCursor, &c.HistoryHasMore, &synced); err != nil { - return Conversation{}, err - } - c.LastMessageAt, c.HistorySyncedAt = nullableTime(last), nullableTime(synced) - return c, nil -} - -const conversationSelect = `SELECT id,platform,account_id,peer_uid,peer_name,last_message_at,history_cursor,history_has_more,history_synced_at FROM creator_conversation` - -func (s *Store) UpsertConversation(ctx context.Context, input MessageInput) (Conversation, error) { - if !ValidatePlatform(input.Platform) || input.AccountID == "" || input.PeerUID == "" { - return Conversation{}, ErrInvalid - } - var accountPlatform string - if err := s.db.QueryRowContext(ctx, `SELECT platform FROM social_account WHERE id=$1`, input.AccountID).Scan(&accountPlatform); err != nil { - return Conversation{}, rowError(err) - } - if accountPlatform != input.Platform { - return Conversation{}, ErrInvalid - } - id := newID("conversation") - var returned string - if err := s.db.QueryRowContext(ctx, `INSERT INTO creator_conversation (id,platform,account_id,peer_uid,peer_name,last_message_at) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (account_id,peer_uid) DO UPDATE SET peer_name=CASE WHEN EXCLUDED.peer_name='' THEN creator_conversation.peer_name ELSE EXCLUDED.peer_name END, last_message_at=CASE WHEN EXCLUDED.last_message_at IS NULL THEN creator_conversation.last_message_at WHEN creator_conversation.last_message_at IS NULL OR EXCLUDED.last_message_at > creator_conversation.last_message_at THEN EXCLUDED.last_message_at ELSE creator_conversation.last_message_at END RETURNING id`, id, input.Platform, input.AccountID, input.PeerUID, input.PeerName, input.MessageAt).Scan(&returned); err != nil { - return Conversation{}, databaseError(err) - } - return s.GetConversation(ctx, returned) -} -func (s *Store) GetConversation(ctx context.Context, id string) (Conversation, error) { - result, err := scanConversation(s.db.QueryRowContext(ctx, conversationSelect+` WHERE id=$1`, id)) - return result, rowError(err) -} - -func (s *Store) UpdateConversationHistoryCursor(ctx context.Context, id, cursor string, hasMore bool) error { - id, cursor = strings.TrimSpace(id), strings.TrimSpace(cursor) - if id == "" || len(cursor) > 500 { - return ErrInvalid - } - if _, err := s.db.ExecContext(ctx, `UPDATE creator_conversation SET history_cursor=$2,history_has_more=$3,history_synced_at=now() WHERE id=$1`, id, cursor, hasMore); err != nil { - return databaseError(err) - } - return nil -} -func (s *Store) ListConversations(ctx context.Context, accountID string) ([]Conversation, error) { - query := conversationSelect - args := []any{} - if accountID != "" { - query += ` WHERE account_id=$1` - args = append(args, accountID) - } - query += ` ORDER BY last_message_at DESC NULLS LAST,id` - rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, databaseError(err) - } - defer rows.Close() - result := make([]Conversation, 0) - for rows.Next() { - item, err := scanConversation(rows) - if err != nil { - return nil, err - } - result = append(result, item) - } - return result, rows.Err() -} -func (s *Store) SaveMessage(ctx context.Context, input MessageInput) (Message, bool, error) { - input.PlatformMessageKey = strings.TrimSpace(input.PlatformMessageKey) - input.Direction = strings.TrimSpace(input.Direction) - input.MessageType = strings.TrimSpace(input.MessageType) - if input.PlatformMessageKey == "" || len(input.PlatformMessageKey) > 500 || input.Direction != "inbound" && input.Direction != "outbound" || input.MessageType == "" || len(input.MessageType) > 100 || len(input.Text) > 100000 { - return Message{}, false, ErrInvalid - } - state := input.SentState - if state == "" { - state = "received" - } - if state != "received" && state != "pending" && state != "succeeded" && state != "failed" && state != "uncertain" { - return Message{}, false, ErrInvalid - } - input.SentState = state - conversation, err := s.UpsertConversation(ctx, input) - if err != nil { - return Message{}, false, err - } - id := newID("message") - var returned string - var inserted bool - err = s.db.QueryRowContext(ctx, `INSERT INTO creator_message (id,conversation_id,platform_message_key,operation_id,direction,message_type,text,sent_state,message_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) ON CONFLICT (conversation_id,platform_message_key) DO NOTHING RETURNING id,(xmax=0)`, id, conversation.ID, input.PlatformMessageKey, nullableString(input.OperationID), input.Direction, input.MessageType, input.Text, state, input.MessageAt).Scan(&returned, &inserted) - if errors.Is(err, sql.ErrNoRows) { - if err := s.db.QueryRowContext(ctx, `SELECT id FROM creator_message WHERE conversation_id=$1 AND platform_message_key=$2`, conversation.ID, input.PlatformMessageKey).Scan(&returned); err != nil { - return Message{}, false, rowError(err) - } - message, err := s.GetMessage(ctx, returned) - return message, false, err - } - if err != nil { - return Message{}, false, databaseError(err) - } - message, err := s.GetMessage(ctx, returned) - return message, inserted, err -} -func scanMessage(scanner interface{ Scan(...any) error }) (Message, error) { - var m Message - var operationID sql.NullString - var at sql.NullTime - if err := scanner.Scan(&m.ID, &m.ConversationID, &m.PlatformMessageKey, &operationID, &m.Direction, &m.MessageType, &m.Text, &m.SentState, &at, &m.CreatedAt); err != nil { - return Message{}, err - } - m.OperationID, m.MessageAt = operationID.String, nullableTime(at) - return m, nil -} - -const messageSelect = `SELECT id,conversation_id,platform_message_key,operation_id,direction,message_type,text,sent_state,message_at,created_at FROM creator_message` - -func (s *Store) GetMessage(ctx context.Context, id string) (Message, error) { - result, err := scanMessage(s.db.QueryRowContext(ctx, messageSelect+` WHERE id=$1`, id)) - return result, rowError(err) -} -func (s *Store) ListEventsPage(ctx context.Context, accountID string, page, pageSize int) (Page[InteractionEvent], error) { - items, err := s.ListEvents(ctx, accountID) - if err != nil { - return Page[InteractionEvent]{}, err - } - return slicePage(items, page, pageSize) -} - -func (s *Store) ListMessagesPage(ctx context.Context, conversationID string, page, pageSize int) (Page[Message], error) { - items, err := s.ListMessages(ctx, conversationID) - if err != nil { - return Page[Message]{}, err - } - return slicePage(items, page, pageSize) -} - -func (s *Store) LinkMessageOperation(ctx context.Context, messageID, platformMessageKey string) error { - messageID, platformMessageKey = strings.TrimSpace(messageID), strings.TrimSpace(platformMessageKey) - if messageID == "" || platformMessageKey == "" { - return ErrInvalid - } - _, err := s.db.ExecContext(ctx, `UPDATE creator_message message SET operation_id=(SELECT operation.id FROM creator_operation operation WHERE operation.evidence->>'message_server_id'=$2 ORDER BY operation.updated_at DESC LIMIT 1) WHERE message.id=$1 AND message.operation_id IS NULL`, messageID, platformMessageKey) - return databaseError(err) -} - -func (s *Store) ListMessages(ctx context.Context, conversationID string) ([]Message, error) { - rows, err := s.db.QueryContext(ctx, messageSelect+` WHERE conversation_id=$1 ORDER BY message_at NULLS LAST,created_at,id`, conversationID) - if err != nil { - return nil, databaseError(err) - } - defer rows.Close() - result := make([]Message, 0) - for rows.Next() { - item, err := scanMessage(rows) - if err != nil { - return nil, err - } - result = append(result, item) - } - return result, rows.Err() -} diff --git a/internal/creator/content.go b/internal/creator/content.go index 3dce4a7..1109a72 100644 --- a/internal/creator/content.go +++ b/internal/creator/content.go @@ -704,274 +704,6 @@ func (s *Store) ListMetrics(ctx context.Context, workID string) ([]MetricPoint, return result, rows.Err() } -func (s *Store) GetMaterial(ctx context.Context, workID string) (MaterialJob, error) { - if _, err := s.db.ExecContext(ctx, `INSERT INTO creator_material_job (work_id) VALUES ($1) ON CONFLICT (work_id) DO NOTHING`, workID); err != nil { - return MaterialJob{}, databaseError(err) - } - return s.scanMaterial(s.db.QueryRowContext(ctx, `SELECT work_id, selected, select_confirmed_at, download_status, video_reference, audio_status, audio_reference, transcription_status, transcript, failed_step, failure_reason, rewrite_confirmed_at, rewrite_requirement, generated_title, generated_script, processing_step, processing_token, processing_started_at, created_at, updated_at FROM creator_material_job WHERE work_id = $1`, workID)) -} - -func (s *Store) scanMaterial(scanner interface{ Scan(...any) error }) (MaterialJob, error) { - var result MaterialJob - var selectedAt, rewriteAt, processingStartedAt sql.NullTime - if err := scanner.Scan(&result.WorkID, &result.Selected, &selectedAt, &result.DownloadStatus, &result.VideoReference, &result.AudioStatus, &result.AudioReference, &result.TranscriptionStatus, &result.Transcript, &result.FailedStep, &result.FailureReason, &rewriteAt, &result.RewriteRequirement, &result.GeneratedTitle, &result.GeneratedScript, &result.ProcessingStep, &result.ProcessingToken, &processingStartedAt, &result.CreatedAt, &result.UpdatedAt); err != nil { - return MaterialJob{}, rowError(err) - } - result.SelectConfirmedAt, result.RewriteConfirmedAt, result.ProcessingStartedAt = nullableTime(selectedAt), nullableTime(rewriteAt), nullableTime(processingStartedAt) - return result, nil -} - -func (s *Store) ClaimMaterialStep(ctx context.Context, workID, step, token string) (MaterialJob, bool, error) { - workID, step, token = strings.TrimSpace(workID), strings.TrimSpace(step), strings.TrimSpace(token) - if workID == "" || token == "" || len(token) > 200 || (step != "download" && step != "audio" && step != "transcription") { - return MaterialJob{}, false, ErrInvalid - } - stale, err := s.recoverStaleMaterialStep(ctx, workID, time.Now().UTC().Add(-30*time.Minute)) - if err != nil { - return MaterialJob{}, false, err - } - job, err := s.GetMaterial(ctx, workID) - if err != nil { - return MaterialJob{}, false, err - } - if !job.Selected { - return MaterialJob{}, false, ErrConflict - } - status := job.DownloadStatus - if step == "audio" { - status = job.AudioStatus - } - if step == "transcription" { - status = job.TranscriptionStatus - } - if stale { - return job, false, nil - } - if status == "succeeded" || status == "no_audio" || status == "no_speech" { - return job, false, nil - } - if job.ProcessingStep != "" || (status != "not_started" && status != "failed") { - return job, false, nil - } - var query string - switch step { - case "download": - query = `UPDATE creator_material_job SET download_status='running',processing_step=$2,processing_token=$3,processing_started_at=now(),failed_step='',failure_reason='',updated_at=now() WHERE work_id=$1 AND selected=true AND processing_step='' AND download_status IN ('not_started','failed')` - case "audio": - query = `UPDATE creator_material_job SET audio_status='running',processing_step=$2,processing_token=$3,processing_started_at=now(),failed_step='',failure_reason='',updated_at=now() WHERE work_id=$1 AND selected=true AND processing_step='' AND audio_status IN ('not_started','failed')` - case "transcription": - query = `UPDATE creator_material_job SET transcription_status='running',processing_step=$2,processing_token=$3,processing_started_at=now(),failed_step='',failure_reason='',updated_at=now() WHERE work_id=$1 AND selected=true AND processing_step='' AND transcription_status IN ('not_started','failed')` - } - updated, err := s.db.ExecContext(ctx, query, workID, step, token) - if err != nil { - return MaterialJob{}, false, databaseError(err) - } - affected, err := updated.RowsAffected() - if err != nil { - return MaterialJob{}, false, err - } - job, err = s.GetMaterial(ctx, workID) - return job, affected == 1, err -} - -func (s *Store) recoverStaleMaterialStep(ctx context.Context, workID string, cutoff time.Time) (bool, error) { - result, err := s.db.ExecContext(ctx, ` - UPDATE creator_material_job - SET download_status = CASE WHEN processing_step='download' THEN 'failed' ELSE download_status END, - audio_status = CASE WHEN processing_step='audio' THEN 'failed' ELSE audio_status END, - transcription_status = CASE WHEN processing_step='transcription' THEN 'failed' ELSE transcription_status END, - failed_step = processing_step, - failure_reason = '上次处理结果不明,未自动重试', processing_step='', processing_token='', - processing_started_at=NULL, updated_at=now() - WHERE work_id=$1 AND selected=true AND processing_step <> '' - AND processing_started_at IS NOT NULL AND processing_started_at < $2`, workID, cutoff.UTC()) - if err != nil { - return false, databaseError(err) - } - affected, err := result.RowsAffected() - return affected == 1, err -} - -func (s *Store) SelectMaterial(ctx context.Context, workID string) (MaterialJob, bool, error) { - if _, err := s.GetWork(ctx, workID); err != nil { - return MaterialJob{}, false, err - } - var inserted bool - if err := s.db.QueryRowContext(ctx, `INSERT INTO creator_material_job (work_id, selected, select_confirmed_at) VALUES ($1, true, now()) ON CONFLICT (work_id) DO UPDATE SET selected = true, select_confirmed_at = COALESCE(creator_material_job.select_confirmed_at, now()), updated_at = now() RETURNING (xmax = 0)`, workID).Scan(&inserted); err != nil { - return MaterialJob{}, false, databaseError(err) - } - job, err := s.GetMaterial(ctx, workID) - return job, inserted, err -} - -func (s *Store) SetMaterialStep(ctx context.Context, workID, step, status, reference, reason string) (MaterialJob, error) { - if step != "download" && step != "audio" && step != "transcription" { - return MaterialJob{}, ErrInvalid - } - valid := map[string]map[string]bool{ - "download": {"not_started": true, "succeeded": true, "failed": true}, - "audio": {"not_started": true, "succeeded": true, "no_audio": true, "failed": true}, - "transcription": {"not_started": true, "succeeded": true, "no_speech": true, "failed": true}, - } - if !valid[step][status] || len(reference) > 2000 || len(reason) > 2000 { - return MaterialJob{}, ErrInvalid - } - job, err := s.GetMaterial(ctx, workID) - if err != nil { - return MaterialJob{}, err - } - if !job.Selected { - return MaterialJob{}, ErrConflict - } - var query string - var args []any - switch step { - case "download": - query = `UPDATE creator_material_job SET download_status = $2, video_reference = $3, failed_step = CASE WHEN $2 = 'failed' THEN 'download' ELSE failed_step END, failure_reason = CASE WHEN $2 = 'failed' THEN $4 ELSE failure_reason END, updated_at = now() WHERE work_id = $1 AND selected=true AND processing_step=''` - args = []any{workID, status, reference, reason} - case "audio": - query = `UPDATE creator_material_job SET audio_status = $2, audio_reference = $3, failed_step = CASE WHEN $2 = 'failed' THEN 'audio' ELSE failed_step END, failure_reason = CASE WHEN $2 = 'failed' THEN $4 ELSE failure_reason END, updated_at = now() WHERE work_id = $1 AND selected=true AND processing_step=''` - args = []any{workID, status, reference, reason} - case "transcription": - query = `UPDATE creator_material_job SET transcription_status = $2, transcript = $3, failed_step = CASE WHEN $2 = 'failed' THEN 'transcription' ELSE failed_step END, failure_reason = CASE WHEN $2 = 'failed' THEN $4 ELSE failure_reason END, updated_at = now() WHERE work_id = $1 AND selected=true AND processing_step=''` - args = []any{workID, status, reference, reason} - } - updated, err := s.db.ExecContext(ctx, query, args...) - if err != nil { - return MaterialJob{}, databaseError(err) - } - if affected, err := updated.RowsAffected(); err != nil { - return MaterialJob{}, err - } else if affected != 1 { - return MaterialJob{}, ErrConflict - } - return s.GetMaterial(ctx, workID) -} - -func (s *Store) CompleteMaterialStep(ctx context.Context, workID, step, token, status, reference, reason string) (MaterialJob, error) { - workID, step, token = strings.TrimSpace(workID), strings.TrimSpace(step), strings.TrimSpace(token) - if workID == "" || token == "" || len(token) > 200 || (step != "download" && step != "audio" && step != "transcription") || len(reference) > 2000 || len(reason) > 2000 { - return MaterialJob{}, ErrInvalid - } - valid := map[string]map[string]bool{ - "download": {"succeeded": true, "failed": true}, - "audio": {"succeeded": true, "no_audio": true, "failed": true}, - "transcription": {"succeeded": true, "no_speech": true, "failed": true}, - } - if !valid[step][status] { - return MaterialJob{}, ErrInvalid - } - var column string - switch step { - case "download": - column = "download_status" - case "audio": - column = "audio_status" - case "transcription": - column = "transcription_status" - } - query := fmt.Sprintf(`UPDATE creator_material_job SET %s=$4, %s_reference=$5, failed_step=CASE WHEN $4='failed' THEN $2 ELSE failed_step END, failure_reason=CASE WHEN $4='failed' THEN $6 ELSE failure_reason END, processing_step='', processing_token='', processing_started_at=NULL, updated_at=now() WHERE work_id=$1 AND selected=true AND processing_step=$2 AND processing_token=$3`, column, map[string]string{"download": "video", "audio": "audio", "transcription": "transcript"}[step]) - updated, err := s.db.ExecContext(ctx, query, workID, step, token, status, reference, reason) - if err != nil { - return MaterialJob{}, databaseError(err) - } - if affected, err := updated.RowsAffected(); err != nil { - return MaterialJob{}, err - } else if affected != 1 { - return MaterialJob{}, ErrConflict - } - return s.GetMaterial(ctx, workID) -} - -func (s *Store) ConfirmRewrite(ctx context.Context, workID, requirement string) (MaterialJob, error) { - requirement = strings.TrimSpace(requirement) - if utf8.RuneCountInString(requirement) > 4000 { - return MaterialJob{}, ErrInvalid - } - job, err := s.GetMaterial(ctx, workID) - if err != nil { - return MaterialJob{}, err - } - if !job.Selected || job.DownloadStatus != "succeeded" || job.AudioStatus == "failed" || job.TranscriptionStatus == "failed" || job.AudioStatus == "running" || job.TranscriptionStatus == "running" || job.AudioStatus == "not_started" || job.TranscriptionStatus == "not_started" { - return MaterialJob{}, ErrConflict - } - if _, err := s.db.ExecContext(ctx, `UPDATE creator_material_job SET rewrite_confirmed_at = now(), rewrite_requirement = $2, updated_at = now() WHERE work_id = $1`, workID, requirement); err != nil { - return MaterialJob{}, databaseError(err) - } - return s.GetMaterial(ctx, workID) -} - -func parseGeneratedRewrite(content string) (string, string, error) { - var result struct { - Title string `json:"title"` - Script string `json:"script"` - } - if err := json.Unmarshal([]byte(strings.TrimSpace(content)), &result); err != nil { - return "", "", fmt.Errorf("decode generated rewrite: %w", err) - } - result.Title, result.Script = strings.TrimSpace(result.Title), strings.TrimSpace(result.Script) - if result.Title == "" || result.Script == "" || utf8.RuneCountInString(result.Title) > 1000 || utf8.RuneCountInString(result.Script) > 100000 { - return "", "", ErrInvalid - } - return result.Title, result.Script, nil -} - -func (s *Store) GenerateRewrite(ctx context.Context, workID string, generator TextGenerator) (MaterialJob, error) { - if generator == nil { - return MaterialJob{}, fmt.Errorf("%w: text generator is not configured", ErrUnavailable) - } - settings, err := s.GetSettings(ctx) - if err != nil { - return MaterialJob{}, err - } - if !settings.AIConfigured || strings.TrimSpace(settings.AIProvider) == "" || strings.TrimSpace(settings.AIModel) == "" { - return MaterialJob{}, fmt.Errorf("%w: AI settings are not approved", ErrUnavailable) - } - job, err := s.GetMaterial(ctx, workID) - if err != nil { - return MaterialJob{}, err - } - if job.RewriteConfirmedAt == nil { - return MaterialJob{}, ErrConflict - } - work, err := s.GetWork(ctx, workID) - if err != nil { - return MaterialJob{}, err - } - content, err := generator.Generate(ctx, - "根据原作品和转写内容生成仿写草稿。只返回 JSON,不要 Markdown 或额外文字,格式必须是 {\"title\":\"标题\",\"script\":\"脚本\"}。不得编造原文没有的事实。", - fmt.Sprintf("改写要求:%s\n原标题:%s\n原文案:%s\n转写:%s", job.RewriteRequirement, work.Title, work.Body, job.Transcript)) - if err != nil { - return MaterialJob{}, err - } - title, script, err := parseGeneratedRewrite(content) - if err != nil { - return MaterialJob{}, err - } - if _, err := s.db.ExecContext(ctx, `UPDATE creator_material_job SET generated_title = $2, generated_script = $3, updated_at = now() WHERE work_id = $1`, workID, title, script); err != nil { - return MaterialJob{}, databaseError(err) - } - return s.GetMaterial(ctx, workID) -} - -func (s *Store) SaveRewrite(ctx context.Context, workID, title, script string) (MaterialJob, error) { - title, script = strings.TrimSpace(title), strings.TrimSpace(script) - if title == "" || script == "" || utf8.RuneCountInString(title) > 1000 || utf8.RuneCountInString(script) > 100000 { - return MaterialJob{}, ErrInvalid - } - job, err := s.GetMaterial(ctx, workID) - if err != nil { - return MaterialJob{}, err - } - if job.RewriteConfirmedAt == nil { - return MaterialJob{}, ErrConflict - } - if _, err := s.db.ExecContext(ctx, `UPDATE creator_material_job SET generated_title = $2, generated_script = $3, updated_at = now() WHERE work_id = $1`, workID, title, script); err != nil { - return MaterialJob{}, databaseError(err) - } - return s.GetMaterial(ctx, workID) -} - func (s *Store) SaveComment(ctx context.Context, input CommentInput) (Comment, bool, error) { input.Platform, input.CommentKey, input.WorkID, input.AuthorUID, input.AuthorName, input.CommentType = strings.TrimSpace(input.Platform), strings.TrimSpace(input.CommentKey), strings.TrimSpace(input.WorkID), strings.TrimSpace(input.AuthorUID), strings.TrimSpace(input.AuthorName), strings.TrimSpace(input.CommentType) if !ValidatePlatform(input.Platform) || input.CommentKey == "" || input.WorkID == "" || strings.TrimSpace(input.Content) == "" || (input.CommentType != "top_level" && input.CommentType != "unknown") || utf8.RuneCountInString(input.Content) > 10000 || len(input.RawPayload) > maxRawPayloadBytes || input.RawPayload != "" && !json.Valid([]byte(input.RawPayload)) { diff --git a/internal/creator/content_test.go b/internal/creator/content_test.go index 1fbc7f8..f29cdd0 100644 --- a/internal/creator/content_test.go +++ b/internal/creator/content_test.go @@ -19,23 +19,6 @@ func TestSlicePageUsesEmptyDataArray(t *testing.T) { } } -func TestParseGeneratedRewriteRequiresBoundedJSON(t *testing.T) { - title, script, err := parseGeneratedRewrite(`{"title":"新标题","script":"新脚本"}`) - if err != nil || title != "新标题" || script != "新脚本" { - t.Fatalf("parseGeneratedRewrite() = %q, %q, %v", title, script, err) - } - for _, content := range []string{ - "", - "不是 JSON", - `{"title":"有标题"}`, - `{"title":"标题","script":""}`, - } { - if _, _, err := parseGeneratedRewrite(content); err == nil { - t.Fatalf("parseGeneratedRewrite(%q) accepted invalid output", content) - } - } -} - func TestValidateCreatorTags(t *testing.T) { valid := make([]string, 20) for i := range valid { diff --git a/internal/creator/coverage_integration_test.go b/internal/creator/coverage_integration_test.go index 98f4031..8a39a42 100644 --- a/internal/creator/coverage_integration_test.go +++ b/internal/creator/coverage_integration_test.go @@ -97,41 +97,6 @@ func TestCreatorPostgresPageAndConversationState(t *testing.T) { if err != nil || len(commentPage.Data) != 1 || commentPage.Total != 1 { t.Fatalf("list comments page: page=%+v err=%v", commentPage, err) } - eventResult, err := store.RecordEvent(ctx, InteractionEvent{ - Platform: PlatformDouyin, ReceivingAccountID: accountID, EventKey: "page-event", - EventType: "dm", InteractorUID: "peer", MessageType: MessageTypeText, MessageText: "incoming", - ReceivedAt: now, - }) - if err != nil { - t.Fatal(err) - } - if _, err := store.ListEventsPage(ctx, accountID, 1, 1); err != nil { - t.Fatal(err) - } - if _, err := store.SetEventDisplayed(ctx, eventResult.Event.ID, time.Time{}); err != nil { - t.Fatal(err) - } - messageAt := now.Add(time.Minute) - message, inserted, err := store.SaveMessage(ctx, MessageInput{ - Platform: PlatformDouyin, AccountID: accountID, PeerUID: "peer", PeerName: "Peer", - PlatformMessageKey: "page-message", Direction: "inbound", MessageType: MessageTypeText, - Text: "incoming", MessageAt: &messageAt, - }) - if err != nil || !inserted { - t.Fatalf("save message: inserted=%v err=%v", inserted, err) - } - if _, err := store.ListMessagesPage(ctx, message.ConversationID, 1, 1); err != nil { - t.Fatal(err) - } - if err := store.UpdateConversationHistoryCursor(ctx, message.ConversationID, "cursor-1", true); err != nil { - t.Fatal(err) - } - if err := store.LinkMessageOperation(ctx, message.ID, "missing-platform-message"); err != nil { - t.Fatal(err) - } - if traces, err := store.ListStrategyTraces(ctx, eventResult.Event.ID); err != nil || traces == nil { - t.Fatalf("list strategy traces: traces=%v err=%v", traces, err) - } if _, _, err := store.SaveComment(ctx, CommentInput{Platform: PlatformDouyin, CommentKey: "page-comment", WorkID: work.ID, Content: "updated", CommentType: "top_level"}); err != nil { t.Fatal(err) } @@ -144,12 +109,6 @@ func TestCreatorPostgresPageAndConversationState(t *testing.T) { if _, _, err := store.NextCollectionWindow(ctx, SourceOwned, accountID, now, time.Hour, 1); err != nil { t.Fatal(err) } - if _, err := store.ListListenerBoundaries(ctx, accountID); err != nil { - t.Fatal(err) - } - if _, err := store.ListListenerStates(ctx, accountID); err != nil { - t.Fatal(err) - } if works, err := store.ListDueMetricWorks(ctx, now); err != nil || works == nil { t.Fatalf("list due metric works: works=%v err=%v", works, err) } @@ -182,12 +141,6 @@ func TestCreatorPostgresPageAndConversationState(t *testing.T) { if _, err := store.RecordVerifiedLoginResult(ctx, accountID, "sec_uid_"+accountID); err != nil { t.Fatal(err) } - if _, err := store.GenerateRewrite(ctx, work.ID, nil); !errors.Is(err, ErrUnavailable) { - t.Fatalf("generate rewrite without generator: %v", err) - } - if _, err := store.ConfirmRewrite(ctx, work.ID, "coverage rewrite"); !errors.Is(err, ErrConflict) { - t.Fatalf("confirm incomplete rewrite: %v", err) - } rule, err := store.CreateRule(ctx, LeadRuleInput{Name: "coverage rule", Enabled: true, SourceType: SourceOwned, Topic: "title", IncludeKeywords: []string{"hello"}, AIRequirement: "business lead"}) if err != nil { t.Fatal(err) @@ -196,16 +149,6 @@ func TestCreatorPostgresPageAndConversationState(t *testing.T) { if len(analysis) != 1 || !errors.Is(err, ErrUnavailable) { t.Fatalf("analyze comments without AI: analysis=%v err=%v", analysis, err) } - strategyInput := StrategyInput{Enabled: true, Action: ActionReplyComment, EventTypes: []string{"comment"}} - if err := store.validateEnabledStrategy(ctx, accountID, strategyInput); !errors.Is(err, ErrInvalid) { - t.Fatalf("strategy without reply requirements: %v", err) - } - if _, err := store.UpdateAccountProfile(ctx, accountID, AccountProfileUpdate{RealNameStatus: "unknown", BusinessStatus: "normal", ReplyRequirements: "answer", CooldownSeconds: 60}); err != nil { - t.Fatal(err) - } - if err := store.validateEnabledStrategy(ctx, accountID, strategyInput); !errors.Is(err, ErrUnavailable) { - t.Fatalf("strategy without AI: %v", err) - } missing := []struct { name string get func() error @@ -213,13 +156,7 @@ func TestCreatorPostgresPageAndConversationState(t *testing.T) { {"comment", func() error { _, err := store.GetComment(ctx, "missing"); return err }}, {"comment by key", func() error { _, err := store.GetCommentByKey(ctx, PlatformDouyin, "missing"); return err }}, {"competitor", func() error { _, err := store.GetCompetitor(ctx, "missing"); return err }}, - {"conversation", func() error { _, err := store.GetConversation(ctx, "missing"); return err }}, - {"event", func() error { _, err := store.GetEvent(ctx, "missing"); return err }}, - {"listener", func() error { _, err := store.GetListenerState(ctx, accountID); return err }}, - {"message", func() error { _, err := store.GetMessage(ctx, "missing"); return err }}, - {"operation", func() error { _, err := store.GetOperation(ctx, "missing"); return err }}, {"rule", func() error { _, err := store.GetRule(ctx, "missing"); return err }}, - {"strategy", func() error { _, err := store.GetStrategy(ctx, "missing"); return err }}, {"work", func() error { _, err := store.GetWork(ctx, "missing"); return err }}, {"work by key", func() error { _, err := store.GetWorkByKey(ctx, PlatformDouyin, "missing"); return err }}, } @@ -233,15 +170,10 @@ func TestCreatorPostgresPageAndConversationState(t *testing.T) { set func() error }{ {"competitor", func() error { _, err := store.SetCompetitorEnabled(ctx, "missing", true); return err }}, - {"event", func() error { _, err := store.SetEventDisplayed(ctx, "missing", now); return err }}, {"rule", func() error { _, err := store.SetRuleEnabled(ctx, "missing", true); return err }}, - {"strategy", func() error { _, err := store.SetStrategyEnabled(ctx, "missing", true); return err }}, } { if err := item.set(); !errors.Is(err, ErrNotFound) { t.Fatalf("set missing %s: %v", item.name, err) } } - if err := store.DeleteStrategy(ctx, "missing"); err != nil { - t.Fatalf("delete missing strategy: %v", err) - } } diff --git a/internal/creator/coverage_unit_test.go b/internal/creator/coverage_unit_test.go index 28aa150..43076d0 100644 --- a/internal/creator/coverage_unit_test.go +++ b/internal/creator/coverage_unit_test.go @@ -8,55 +8,6 @@ import ( ) func TestCreatorPureCoverageGuards(t *testing.T) { - if got, err := selectCandidate(nil); err != nil || got != "" { - t.Fatalf("empty candidate: %q %v", got, err) - } - if got, err := selectCandidate([]string{"one"}); err != nil || got != "one" { - t.Fatalf("single candidate: %q %v", got, err) - } - got, err := selectCandidate([]string{"one", "two"}) - if err != nil || (got != "one" && got != "two") { - t.Fatalf("multiple candidates: %q %v", got, err) - } - for _, input := range []StrategyInput{ - {}, - {Position: 1, Action: "invalid", EventTypes: []string{"comment"}}, - {Position: 1, Action: ActionLikeComment, TargetType: "work", EventTypes: []string{"comment"}}, - {Position: 1, Action: ActionLikeWork, TargetType: "comment", EventTypes: []string{"comment"}}, - {Position: 1, Action: ActionReplyComment, EventTypes: []string{"dm"}}, - {Position: 1, Action: ActionReplyComment, EventTypes: []string{"comment"}, CandidateTexts: []string{""}}, - } { - if _, err := validateStrategyInput(input); !errors.Is(err, ErrInvalid) { - t.Fatalf("invalid strategy input accepted: %+v -> %v", input, err) - } - } - valid, err := validateStrategyInput(StrategyInput{Position: 1, Action: ActionReplyComment, EventTypes: []string{" comment "}, CandidateTexts: []string{" reply "}}) - if err != nil || valid.TargetType != "user" || valid.EventTypes[0] != "comment" || valid.CandidateTexts[0] != "reply" { - t.Fatalf("valid strategy normalization: %+v %v", valid, err) - } - if result := actionPreconditionResult(ErrConflict, "blocked"); result.State != "blocked" || result.Reason != "blocked" { - t.Fatalf("blocked precondition: %+v", result) - } - if result := actionPreconditionResult(errors.New("database down"), "ignored"); result.State != "uncertain" { - t.Fatalf("uncertain precondition: %+v", result) - } - for _, result := range []ActionResult{{State: "succeeded"}, {State: "failed"}, {State: "blocked"}, {State: "uncertain"}, {}} { - normalized := normalizeActionResult(result, nil) - if normalized.State == "" { - t.Fatalf("empty action result was not normalized: %+v", normalized) - } - } - if result := normalizeActionResult(ActionResult{}, errors.New("executor failed")); result.State != "uncertain" || result.Reason != "executor failed" { - t.Fatalf("executor error normalization: %+v", result) - } - if title, script, err := parseGeneratedRewrite(`{"title":" title ","script":" script "}`); err != nil || title != "title" || script != "script" { - t.Fatalf("generated rewrite parse: %q %q %v", title, script, err) - } - for _, value := range []string{"not-json", `{"title":"","script":"script"}`, `{"title":"title","script":""}`} { - if _, _, err := parseGeneratedRewrite(value); err == nil { - t.Fatalf("invalid generated rewrite accepted: %s", value) - } - } if _, err := decodeCommentCheckpoint(`{"cursor":"missing-work-key"}`); !errors.Is(err, ErrInvalid) { t.Fatalf("invalid empty checkpoint: %v", err) } @@ -78,23 +29,6 @@ func TestCreatorSchedulingAndActionPredicates(t *testing.T) { if coalesceReason("value", "fallback") != "value" || coalesceReason("", "fallback") != "fallback" { t.Fatal("coalesceReason did not choose the expected value") } - for _, test := range []struct { - action, interactor, comment, work string - valid bool - }{ - {ActionDM, "peer", "", "", true}, - {ActionFollow, "peer", "", "", true}, - {ActionReplyComment, "peer", "comment", "", true}, - {ActionLikeComment, "peer", "", "", false}, - {ActionLikeWork, "peer", "", "work", true}, - {ActionRepost, "peer", "", "", false}, - {ActionDM, "", "comment", "work", false}, - {"unknown", "peer", "comment", "work", false}, - } { - if got := ActionTargetValid(test.action, test.interactor, test.comment, test.work, ""); got != test.valid { - t.Fatalf("ActionTargetValid(%+v) = %v", test, got) - } - } } func TestCreatorPaginationGuards(t *testing.T) { diff --git a/internal/creator/deletion.go b/internal/creator/deletion.go index ec15015..20144e0 100644 --- a/internal/creator/deletion.go +++ b/internal/creator/deletion.go @@ -69,42 +69,6 @@ func (s *Store) DeleteOwnedAccountData(ctx context.Context, accountID string) er if hasSecret && s.secrets == nil { return errors.New("creator secret bridge is unavailable") } - if _, err := tx.ExecContext(ctx, ` - DELETE FROM creator_operation - WHERE account_id = $1 - OR strategy_id IN (SELECT id FROM creator_strategy WHERE big_account_id = $1 OR execution_account_id = $1) - OR event_id IN (SELECT id FROM creator_event WHERE receiving_account_id = $1 OR execution_account_id = $1)`, accountID); err != nil { - return databaseError(err) - } - if _, err := tx.ExecContext(ctx, ` - DELETE FROM creator_event - WHERE receiving_account_id = $1 OR execution_account_id = $1`, accountID); err != nil { - return databaseError(err) - } - if _, err := tx.ExecContext(ctx, ` - DELETE FROM creator_cooldown - WHERE big_account_id = $1 OR execution_account_id = $1`, accountID); err != nil { - return databaseError(err) - } - if _, err := tx.ExecContext(ctx, ` - DELETE FROM creator_strategy - WHERE big_account_id = $1 OR execution_account_id = $1`, accountID); err != nil { - return databaseError(err) - } - if _, err := tx.ExecContext(ctx, ` - DELETE FROM creator_relation - WHERE big_account_id = $1 OR small_account_id = $1`, accountID); err != nil { - return databaseError(err) - } - if _, err := tx.ExecContext(ctx, `DELETE FROM creator_conversation WHERE account_id = $1`, accountID); err != nil { - return databaseError(err) - } - if _, err := tx.ExecContext(ctx, `DELETE FROM creator_listener_boundary WHERE account_id = $1`, accountID); err != nil { - return databaseError(err) - } - if _, err := tx.ExecContext(ctx, `DELETE FROM creator_listener_state WHERE account_id = $1`, accountID); err != nil { - return databaseError(err) - } if err := deleteCreatorSourceTx(ctx, tx, SourceOwned, accountID); err != nil { return err } diff --git a/internal/creator/integration_test.go b/internal/creator/integration_test.go index 5f46dbe..1f0eecd 100644 --- a/internal/creator/integration_test.go +++ b/internal/creator/integration_test.go @@ -227,24 +227,6 @@ func TestCreatorPostgresContentAndWorkflow(t *testing.T) { if _, err := store.RecordVerifiedLoginResult(ctx, smallID, "sec_uid_"+smallID); err != nil { t.Fatal(err) } - if err := store.SetRelation(ctx, bigID, smallID, true); err != nil { - t.Fatal(err) - } - relations, err := store.ListRelations(ctx, bigID) - if err != nil || len(relations) != 1 { - t.Fatalf("list relations: relations=%+v err=%v", relations, err) - } - if _, err := store.SetBigAccount(ctx, smallID, true); !errors.Is(err, ErrConflict) { - t.Fatalf("expected small-account promotion to be rejected, got %v", err) - } - authorizedID := createIntegrationAccount(t, ctx, phaseAStore, "authorized"+stamp) - if _, err := store.db.ExecContext(ctx, `UPDATE social_account SET authorization_kind = 'authorized' WHERE id = $1`, authorizedID); err != nil { - t.Fatal(err) - } - if err := store.SetRelation(ctx, bigID, authorizedID, true); !errors.Is(err, ErrInvalid) { - t.Fatalf("expected authorized account relation to be rejected, got %v", err) - } - now := time.Now().UTC().Truncate(time.Microsecond) ownedDue, err := store.ListDueOwnedAccounts(ctx, now, 1800) if err != nil || len(ownedDue) != 2 || ownedDue[0] != bigID || ownedDue[1] != smallID { @@ -447,22 +429,6 @@ func TestCreatorPostgresContentAndWorkflow(t *testing.T) { t.Fatal(err) } - material, selected, err := store.SelectMaterial(ctx, work.ID) - if err != nil || !selected || !material.Selected { - t.Fatalf("select material: material=%+v selected=%v err=%v", material, selected, err) - } - for step, status := range map[string]string{"download": "succeeded", "audio": "no_audio", "transcription": "no_speech"} { - if _, err := store.SetMaterialStep(ctx, work.ID, step, status, "ref-"+step, ""); err != nil { - t.Fatal(err) - } - } - if _, err := store.ConfirmRewrite(ctx, work.ID, "仿写同主题但不复制原文"); err != nil { - t.Fatal(err) - } - if _, err := store.SaveRewrite(ctx, work.ID, "新标题", "新口播"); err != nil { - t.Fatal(err) - } - collector := integrationCollector{work: WorkInput{Platform: PlatformDouyin, WorkKey: "creator-it-collected-" + stamp, SourceType: SourceOwned, SourceID: bigID, Title: "Collected", Body: "body", PublishedAt: &published, PublishedAtStatus: "verified", Likes: &likes}, comment: CommentInput{Platform: PlatformDouyin, CommentKey: "creator-it-collected-comment-" + stamp, WorkID: "", Content: "hello", CommentType: "top_level"}} collector.comment.WorkID = "" report, err := store.CollectSource(ctx, PlatformDouyin, SourceOwned, bigID, collector, now) @@ -709,149 +675,3 @@ func TestCreatorPostgresMetricPlanFollowsPublishedAt(t *testing.T) { t.Fatalf("second metric plan drifted: next=%s interval=%d", next, interval) } } - -func prepareIntegrationActionFixture(t *testing.T, store *Store, phaseAStore *account.Store, ctx context.Context, stamp string) (string, string, Work, Comment, Strategy) { - t.Helper() - bigID := createIntegrationAccount(t, ctx, phaseAStore, "big"+stamp) - smallID := createIntegrationAccount(t, ctx, phaseAStore, "small"+stamp) - for _, accountID := range []string{bigID, smallID} { - if err := store.EnsureAccountProfile(ctx, accountID); err != nil { - t.Fatal(err) - } - if _, err := store.UpdateAccountProfile(ctx, accountID, AccountProfileUpdate{RealNameStatus: "unknown", BusinessStatus: "normal", CooldownSeconds: 86400}); err != nil { - t.Fatal(err) - } - if _, err := store.RecordVerifiedLoginResult(ctx, accountID, "sec_uid_"+accountID); err != nil { - t.Fatal(err) - } - } - if _, err := store.SetBigAccount(ctx, bigID, true); err != nil { - t.Fatal(err) - } - if err := store.SetRelation(ctx, bigID, smallID, true); err != nil { - t.Fatal(err) - } - now := time.Now().UTC().Truncate(time.Microsecond) - published := now.Add(-time.Hour) - likes, comments, shares := int64(1), int64(1), int64(1) - work, _, err := store.UpsertWork(ctx, WorkInput{Platform: PlatformDouyin, WorkKey: "creator-it-action-work-" + stamp, SourceType: SourceOwned, SourceID: bigID, Title: "Action", Body: "body", PublishedAt: &published, PublishedAtStatus: "verified", Likes: &likes, CommentsCount: &comments, Shares: &shares}, now) - if err != nil { - t.Fatal(err) - } - comment, _, err := store.SaveComment(ctx, CommentInput{Platform: PlatformDouyin, CommentKey: "creator-it-action-comment-" + stamp, WorkID: work.ID, AuthorUID: "interactor-" + stamp, Content: "hello", CommentType: "top_level"}) - if err != nil { - t.Fatal(err) - } - strategy, err := store.CreateStrategy(ctx, bigID, StrategyInput{ExecutionAccountID: smallID, Position: 1, Enabled: true, EventTypes: []string{"comment"}, Action: ActionReplyComment, TargetType: "comment", CandidateTexts: []string{"已收到"}}) - if err != nil { - t.Fatal(err) - } - strategies, err := store.ListStrategies(ctx, bigID) - if err != nil || len(strategies) != 1 { - t.Fatalf("list strategies: strategies=%+v err=%v", strategies, err) - } - return bigID, smallID, work, comment, strategy -} - -func TestCreatorPostgresActionsAndMessaging(t *testing.T) { - store, phaseAStore, ctx := openCreatorIntegrationStore(t) - stamp := fmt.Sprintf("%d", time.Now().UnixNano()) - bigID, smallID, work, comment, strategy := prepareIntegrationActionFixture(t, store, phaseAStore, ctx, stamp) - event := InteractionEvent{Platform: PlatformDouyin, ReceivingAccountID: bigID, EventKey: "creator-it-event-" + stamp, EventType: "comment", InteractorUID: comment.AuthorUID, CommentID: comment.ID, WorkID: work.ID} - automatic, err := store.ProcessAutomaticEvent(ctx, event, integrationExecutor{}, nil) - if err != nil || automatic.Operation == nil || automatic.Operation.State != "succeeded" || automatic.Event.State != "succeeded" { - t.Fatalf("automatic action failed: result=%+v err=%v", automatic, err) - } - events, err := store.ListEvents(ctx, bigID) - if err != nil || len(events) != 1 { - t.Fatalf("list events: events=%+v err=%v", events, err) - } - duplicate, err := store.ProcessAutomaticEvent(ctx, event, integrationExecutor{}, nil) - if err != nil || !duplicate.Duplicate || duplicate.Event.ID != automatic.Event.ID { - t.Fatalf("automatic event deduplication failed: result=%+v err=%v", duplicate, err) - } - cooldownEvent := event - cooldownEvent.EventKey += "-cooldown" - blocked, err := store.ProcessAutomaticEvent(ctx, cooldownEvent, integrationExecutor{}, nil) - if err != nil || blocked.Event.State != "blocked" { - t.Fatalf("cooldown did not block second event: result=%+v err=%v", blocked, err) - } - manualInput := OperationInput{IdempotencyKey: "creator-it-manual-" + stamp, Source: "manual", Action: ActionReplyComment, Platform: PlatformDouyin, AccountID: smallID, TargetUID: comment.AuthorUID, TargetCommentID: comment.ID, Text: "人工回复"} - manual, inserted, err := store.CreateOperation(ctx, manualInput) - if err != nil || !inserted { - t.Fatalf("create manual operation: operation=%+v inserted=%v err=%v", manual, inserted, err) - } - manualDuplicate, inserted, err := store.CreateOperation(ctx, manualInput) - if err != nil || inserted || manualDuplicate.ID != manual.ID { - t.Fatalf("manual operation idempotency failed: operation=%+v inserted=%v err=%v", manualDuplicate, inserted, err) - } - operations, err := store.ListOperations(ctx, smallID) - if err != nil || len(operations) != 2 { - t.Fatalf("list operations: operations=%+v err=%v", operations, err) - } - manual, err = store.ExecuteManualOperation(ctx, manual.ID, integrationExecutor{}) - if err != nil || manual.State != "succeeded" { - t.Fatalf("execute manual operation: operation=%+v err=%v", manual, err) - } - dmOperation, inserted, err := store.CreateOperation(ctx, OperationInput{IdempotencyKey: "creator-it-dm-" + stamp, Source: "manual", Action: ActionDM, Platform: PlatformDouyin, AccountID: smallID, TargetUID: "peer-" + stamp, Text: "人工私信"}) - if err != nil || !inserted { - t.Fatalf("create direct-message operation: operation=%+v inserted=%v err=%v", dmOperation, inserted, err) - } - dmOperation, err = store.ExecuteManualOperation(ctx, dmOperation.ID, integrationExecutor{}) - if err != nil || dmOperation.State != "succeeded" { - t.Fatalf("execute direct-message operation: operation=%+v err=%v", dmOperation, err) - } - messageAt := time.Now().UTC().Truncate(time.Microsecond) - message, inserted, err := store.SaveMessage(ctx, MessageInput{Platform: PlatformDouyin, AccountID: smallID, PeerUID: "peer-" + stamp, PeerName: "Peer", PlatformMessageKey: "creator-it-message-" + stamp, Direction: "inbound", MessageType: "text", Text: "hello", MessageAt: &messageAt}) - if err != nil || !inserted { - t.Fatalf("save message: message=%+v inserted=%v err=%v", message, inserted, err) - } - if _, inserted, err := store.SaveMessage(ctx, MessageInput{Platform: PlatformDouyin, AccountID: smallID, PeerUID: "peer-" + stamp, PlatformMessageKey: message.PlatformMessageKey, Direction: "inbound", MessageType: "text", Text: "hello", MessageAt: &messageAt}); err != nil || inserted { - t.Fatalf("message deduplication failed: inserted=%v err=%v", inserted, err) - } - conversations, err := store.ListConversations(ctx, smallID) - if err != nil || len(conversations) != 1 { - t.Fatalf("list conversations: conversations=%+v err=%v", conversations, err) - } - messages, err := store.ListMessages(ctx, conversations[0].ID) - if err != nil || len(messages) != 2 { - t.Fatalf("list messages: messages=%+v err=%v", messages, err) - } - if _, err := store.SetEventDisplayed(ctx, automatic.Event.ID, time.Now().UTC()); err != nil { - t.Fatal(err) - } - updated, err := store.UpdateStrategy(ctx, strategy.ID, StrategyInput{ExecutionAccountID: smallID, Position: 2, Enabled: true, EventTypes: []string{"comment", "like"}, Action: ActionReplyComment, TargetType: "comment", CandidateTexts: []string{"已更新"}}) - if err != nil || updated.Position != 2 || len(updated.EventTypes) != 2 { - t.Fatalf("update strategy: strategy=%+v err=%v", updated, err) - } - if _, err := store.SetStrategyEnabled(ctx, strategy.ID, false); err != nil { - t.Fatal(err) - } - if err := store.DeleteStrategy(ctx, strategy.ID); err != nil { - t.Fatal(err) - } - if err := store.SetRelation(ctx, bigID, smallID, false); err != nil { - t.Fatal(err) - } - if _, err := store.SetBigAccount(ctx, bigID, false); err != nil { - t.Fatal(err) - } - - start, end := time.Now().UTC().Add(-time.Hour), time.Now().UTC() - lease, err := store.beginCheckpoint(ctx, SourceOwned, bigID, "works", start, end) - if err != nil { - t.Fatal(err) - } - if _, err := store.beginCheckpoint(ctx, SourceOwned, bigID, "works", start, end); !errors.Is(err, ErrConflict) { - t.Fatalf("expected concurrent checkpoint claim conflict, got %v", err) - } - if err := store.finishCheckpoint(ctx, SourceOwned, bigID, "works", "stale-lease", "succeeded", ""); !errors.Is(err, ErrConflict) { - t.Fatalf("expected stale checkpoint completion conflict, got %v", err) - } - if err := store.saveCheckpointCursor(ctx, SourceOwned, bigID, "works", lease, "1"); err != nil { - t.Fatal(err) - } - if err := store.finishCheckpoint(ctx, SourceOwned, bigID, "works", lease, "succeeded", ""); err != nil { - t.Fatal(err) - } -} diff --git a/internal/creator/listener.go b/internal/creator/listener.go deleted file mode 100644 index 8cb7ca8..0000000 --- a/internal/creator/listener.go +++ /dev/null @@ -1,170 +0,0 @@ -package creator - -import ( - "context" - "database/sql" - "errors" - "strings" -) - -const listenerStateSelect = `SELECT account_id,platform,generation,status,boundary_at,last_delivery_id,reason,updated_at,session_token,invalidated FROM creator_listener_state` - -func validListenerState(input ListenerState) bool { - if input.AccountID == "" || !ValidatePlatform(input.Platform) || strings.TrimSpace(input.Generation) == "" { - return false - } - switch input.Status { - case "starting", "gap", "stopped", "error": - return len(input.LastDeliveryID) <= 500 && len(input.Reason) <= 2000 - case "ready": - return input.BoundaryAt != nil && !input.BoundaryAt.IsZero() && len(input.LastDeliveryID) <= 500 && len(input.Reason) <= 2000 - default: - return false - } -} - -func scanListenerState(scanner interface{ Scan(...any) error }) (ListenerState, error) { - var result ListenerState - var boundaryAt sql.NullTime - if err := scanner.Scan(&result.AccountID, &result.Platform, &result.Generation, &result.Status, &boundaryAt, &result.LastDeliveryID, &result.Reason, &result.UpdatedAt, &result.SessionToken, &result.Invalidated); err != nil { - return ListenerState{}, err - } - result.BoundaryAt = nullableTime(boundaryAt) - result.UpdatedAt = result.UpdatedAt.UTC() - return result, nil -} - -func (s *Store) UpsertListenerState(ctx context.Context, input ListenerState) (ListenerState, error) { - input.Generation = strings.TrimSpace(input.Generation) - input.SessionToken = strings.TrimSpace(input.SessionToken) - input.LastDeliveryID = strings.TrimSpace(input.LastDeliveryID) - input.Reason = strings.TrimSpace(input.Reason) - if input.SessionToken == "" { - input.SessionToken = input.Generation - } - if !validListenerState(input) || len(input.SessionToken) > 500 { - return ListenerState{}, ErrInvalid - } - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return ListenerState{}, databaseError(err) - } - defer tx.Rollback() - result, err := tx.ExecContext(ctx, ` - INSERT INTO creator_listener_state (account_id,platform,generation,status,boundary_at,last_delivery_id,reason,session_token,invalidated,updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,false,now()) - ON CONFLICT (account_id) DO UPDATE SET platform=EXCLUDED.platform,generation=EXCLUDED.generation,status=EXCLUDED.status, - boundary_at=COALESCE(EXCLUDED.boundary_at, creator_listener_state.boundary_at), - last_delivery_id=CASE WHEN EXCLUDED.last_delivery_id = '' THEN creator_listener_state.last_delivery_id ELSE EXCLUDED.last_delivery_id END, - reason=EXCLUDED.reason,session_token=EXCLUDED.session_token,invalidated=false,updated_at=now() - WHERE NOT creator_listener_state.invalidated OR creator_listener_state.session_token <> EXCLUDED.session_token`, - input.AccountID, input.Platform, input.Generation, input.Status, input.BoundaryAt, input.LastDeliveryID, input.Reason, input.SessionToken) - if err != nil { - return ListenerState{}, databaseError(err) - } - affected, err := result.RowsAffected() - if err != nil { - return ListenerState{}, err - } - if affected != 1 { - _ = tx.Rollback() - state, stateErr := s.GetListenerState(ctx, input.AccountID) - if stateErr != nil { - return ListenerState{}, stateErr - } - return state, ErrConflict - } - if _, err := tx.ExecContext(ctx, ` - INSERT INTO creator_listener_boundary (account_id,platform,generation,status,boundary_at,last_delivery_id,reason) - VALUES ($1,$2,$3,$4,$5,$6,$7)`, - input.AccountID, input.Platform, input.Generation, input.Status, input.BoundaryAt, input.LastDeliveryID, input.Reason); err != nil { - return ListenerState{}, databaseError(err) - } - if err := tx.Commit(); err != nil { - return ListenerState{}, databaseError(err) - } - return s.GetListenerState(ctx, input.AccountID) -} - -func (s *Store) InvalidateListener(ctx context.Context, accountID, reason string) error { - accountID = strings.TrimSpace(accountID) - reason = strings.TrimSpace(reason) - if accountID == "" || reason == "" { - return ErrInvalid - } - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return databaseError(err) - } - defer tx.Rollback() - var state ListenerState - var boundaryAt sql.NullTime - if err := tx.QueryRowContext(ctx, listenerStateSelect+` WHERE account_id=$1 FOR UPDATE`, accountID).Scan(&state.AccountID, &state.Platform, &state.Generation, &state.Status, &boundaryAt, &state.LastDeliveryID, &state.Reason, &state.UpdatedAt, &state.SessionToken, &state.Invalidated); errors.Is(err, sql.ErrNoRows) { - return nil - } else if err != nil { - return databaseError(err) - } - state.BoundaryAt = nullableTime(boundaryAt) - if _, err := tx.ExecContext(ctx, `UPDATE creator_listener_state SET status='gap', reason=$2, invalidated=true, updated_at=now() WHERE account_id=$1`, accountID, reason); err != nil { - return databaseError(err) - } - if _, err := tx.ExecContext(ctx, `INSERT INTO creator_listener_boundary (account_id,platform,generation,status,boundary_at,last_delivery_id,reason) VALUES ($1,$2,$3,'gap',$4,$5,$6)`, state.AccountID, state.Platform, state.Generation, state.BoundaryAt, state.LastDeliveryID, reason); err != nil { - return databaseError(err) - } - return tx.Commit() -} - -func (s *Store) ListListenerBoundaries(ctx context.Context, accountID string) ([]ListenerState, error) { - query := `SELECT account_id,platform,generation,status,boundary_at,last_delivery_id,reason,recorded_at,''::text AS session_token,false AS invalidated FROM creator_listener_boundary` - args := []any{} - if strings.TrimSpace(accountID) != "" { - query += ` WHERE account_id=$1` - args = append(args, accountID) - } - query += ` ORDER BY recorded_at,id` - rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, databaseError(err) - } - defer rows.Close() - result := make([]ListenerState, 0) - for rows.Next() { - item, err := scanListenerState(rows) - if err != nil { - return nil, err - } - result = append(result, item) - } - return result, rows.Err() -} - -func (s *Store) GetListenerState(ctx context.Context, accountID string) (ListenerState, error) { - if strings.TrimSpace(accountID) == "" { - return ListenerState{}, ErrInvalid - } - result, err := scanListenerState(s.db.QueryRowContext(ctx, listenerStateSelect+` WHERE account_id=$1`, accountID)) - return result, rowError(err) -} - -func (s *Store) ListListenerStates(ctx context.Context, accountID string) ([]ListenerState, error) { - query, args := listenerStateSelect, []any{} - if strings.TrimSpace(accountID) != "" { - query += ` WHERE account_id=$1` - args = append(args, accountID) - } - query += ` ORDER BY account_id` - rows, err := s.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, databaseError(err) - } - defer rows.Close() - result := make([]ListenerState, 0) - for rows.Next() { - item, err := scanListenerState(rows) - if err != nil { - return nil, err - } - result = append(result, item) - } - return result, rows.Err() -} diff --git a/internal/creator/listener_test.go b/internal/creator/listener_test.go deleted file mode 100644 index fbf27bd..0000000 --- a/internal/creator/listener_test.go +++ /dev/null @@ -1,29 +0,0 @@ -package creator - -import "testing" - -func TestValidListenerStateRequiresVerifiedGeneration(t *testing.T) { - base := ListenerState{AccountID: "account", Platform: PlatformDouyin, Generation: "runtime:network:1", Status: "starting"} - if !validListenerState(base) { - t.Fatal("expected a complete listener state to be valid") - } - for _, mutate := range []func(*ListenerState){ - func(state *ListenerState) { state.Generation = "" }, - func(state *ListenerState) { state.Platform = "unknown" }, - func(state *ListenerState) { state.Status = "ready"; state.BoundaryAt = nil }, - func(state *ListenerState) { state.Status = "lost" }, - } { - state := base - mutate(&state) - if state.Status == "ready" && state.BoundaryAt == nil { - // A ready state without a boundary is never a valid continuity claim. - if validListenerState(state) { - t.Fatalf("accepted ready state without boundary: %+v", state) - } - continue - } - if validListenerState(state) { - t.Fatalf("accepted invalid listener state: %+v", state) - } - } -} diff --git a/internal/creator/recovery_integration_test.go b/internal/creator/recovery_integration_test.go deleted file mode 100644 index c16af82..0000000 --- a/internal/creator/recovery_integration_test.go +++ /dev/null @@ -1,202 +0,0 @@ -package creator - -import ( - "context" - "errors" - "fmt" - "testing" - "time" -) - -func TestCreatorPostgresRecoversReceivedEventWithoutReplay(t *testing.T) { - store, phaseAStore, ctx := openCreatorIntegrationStore(t) - stamp := time.Now().UnixNano() - bigID, _, work, comment, _ := prepareIntegrationActionFixture(t, store, phaseAStore, ctx, fmt.Sprintf("%d", stamp)) - platformAt := time.Now().UTC().Add(-2 * time.Minute) - recorded, err := store.RecordEvent(ctx, InteractionEvent{Platform: PlatformDouyin, ReceivingAccountID: bigID, EventKey: fmt.Sprintf("received-event-%d", stamp), EventType: "comment", InteractorUID: comment.AuthorUID, CommentID: comment.ID, WorkID: work.ID, PlatformEventAt: &platformAt}) - if err != nil { - t.Fatalf("record event: result=%+v err=%v", recorded, err) - } - old := time.Now().UTC().Add(-3 * time.Minute) - if _, err := store.db.ExecContext(ctx, `UPDATE creator_event SET state='received',received_at=$2 WHERE id=$1`, recorded.Event.ID, old); err != nil { - t.Fatal(err) - } - count, err := store.RecoverStaleProcessing(ctx, time.Now().UTC()) - if err != nil || count != 1 { - t.Fatalf("recover received event: count=%d err=%v", count, err) - } - recovered, err := store.GetEvent(ctx, recorded.Event.ID) - if err != nil || recovered.State != "uncertain" || recovered.Reason != "事件已收到但未开始处理,未补发" { - t.Fatalf("received event was not held without replay: event=%+v err=%v", recovered, err) - } -} - -type integrationActionExecutor struct { - called chan struct{} -} - -func (e integrationActionExecutor) Execute(context.Context, ActionRequest) (ActionResult, error) { - e.called <- struct{}{} - return ActionResult{State: "succeeded"}, nil -} - -func TestCreatorPostgresRecoveryDoesNotReleaseQueuedAutomaticOperation(t *testing.T) { - store, phaseAStore, ctx := openCreatorIntegrationStore(t) - stamp := time.Now().UnixNano() - bigID, smallID, work, comment, _ := prepareIntegrationActionFixture(t, store, phaseAStore, ctx, fmt.Sprintf("%d", stamp)) - input := InteractionEvent{Platform: PlatformDouyin, ReceivingAccountID: bigID, EventKey: fmt.Sprintf("queued-event-%d", stamp), EventType: "comment", InteractorUID: comment.AuthorUID, CommentID: comment.ID, WorkID: work.ID} - lock := store.automaticExecutionLock(smallID) - lock.Lock() - executor := integrationActionExecutor{called: make(chan struct{}, 1)} - done := make(chan struct{}) - go func() { - _, _ = store.ProcessAutomaticEvent(ctx, input, executor, nil) - close(done) - }() - var operationID string - deadline := time.Now().Add(10 * time.Second) - for time.Now().Before(deadline) { - err := store.db.QueryRowContext(ctx, `SELECT id FROM creator_operation WHERE idempotency_key=$1 AND state='processing'`, "event:douyin:"+bigID+":"+input.EventKey).Scan(&operationID) - if err == nil { - break - } - time.Sleep(20 * time.Millisecond) - } - if operationID == "" { - lock.Unlock() - t.Fatal("automatic operation did not reach the execution lock") - } - if _, err := store.db.ExecContext(ctx, `UPDATE creator_operation SET updated_at=$2 WHERE id=$1`, operationID, time.Now().UTC().Add(-10*time.Minute)); err != nil { - lock.Unlock() - t.Fatal(err) - } - count, err := store.RecoverStaleProcessing(ctx, time.Now().UTC()) - if err != nil || count != 1 { - lock.Unlock() - t.Fatalf("recover queued operation: count=%d err=%v", count, err) - } - lock.Unlock() - select { - case <-done: - case <-time.After(10 * time.Second): - t.Fatal("automatic operation did not observe recovery") - } - select { - case <-executor.called: - t.Fatal("recovered automatic operation reached the platform executor") - default: - } - op, err := store.GetOperation(ctx, operationID) - if err != nil || op.State != "uncertain" { - t.Fatalf("recovered operation state: operation=%+v err=%v", op, err) - } -} - -func TestCreatorPostgresInvalidatedListenerBlocksAutomaticWrite(t *testing.T) { - store, phaseAStore, ctx := openCreatorIntegrationStore(t) - stamp := time.Now().UnixNano() - bigID, _, work, comment, _ := prepareIntegrationActionFixture(t, store, phaseAStore, ctx, fmt.Sprintf("%d", stamp)) - boundary := time.Now().UTC() - generation := "runtime:network:1" - if _, err := store.UpsertListenerState(ctx, ListenerState{AccountID: bigID, Platform: PlatformDouyin, Generation: generation, SessionToken: "session-1", Status: "ready", BoundaryAt: &boundary}); err != nil { - t.Fatal(err) - } - if err := store.InvalidateListener(ctx, bigID, "strategy changed"); err != nil { - t.Fatal(err) - } - result, err := store.ProcessAutomaticEvent(ctx, InteractionEvent{Platform: PlatformDouyin, ReceivingAccountID: bigID, Generation: generation, EventKey: fmt.Sprintf("invalidated-event-%d", stamp), EventType: "comment", InteractorUID: comment.AuthorUID, CommentID: comment.ID, WorkID: work.ID}, integrationActionExecutor{called: make(chan struct{}, 1)}, nil) - if err != nil { - t.Fatalf("invalidated listener processing failed: result=%+v err=%v", result, err) - } - if result.Operation != nil || result.Event.State != "blocked" || result.Event.Reason != "监听代际未就绪" { - t.Fatalf("invalidated listener was allowed to process: result=%+v", result) - } -} - -func TestCreatorPostgresInvalidatedListenerRejectsStaleSession(t *testing.T) { - store, phaseAStore, ctx := openCreatorIntegrationStore(t) - stamp := time.Now().UnixNano() - accountID, _, _, _, _ := prepareIntegrationActionFixture(t, store, phaseAStore, ctx, fmt.Sprintf("%d", stamp)) - boundary := time.Now().UTC() - initial := ListenerState{AccountID: accountID, Platform: PlatformDouyin, Generation: "runtime:network:1", SessionToken: "session-1", Status: "ready", BoundaryAt: &boundary} - if _, err := store.UpsertListenerState(ctx, initial); err != nil { - t.Fatalf("upsert initial listener: %v", err) - } - if err := store.InvalidateListener(ctx, accountID, "strategy changed"); err != nil { - t.Fatalf("invalidate listener: %v", err) - } - if _, err := store.UpsertListenerState(ctx, initial); !errors.Is(err, ErrConflict) { - t.Fatalf("stale listener session was accepted: err=%v", err) - } - invalidated, err := store.GetListenerState(ctx, accountID) - if err != nil || invalidated.Status != "gap" || !invalidated.Invalidated || invalidated.SessionToken != "session-1" { - t.Fatalf("invalidated listener state changed unexpectedly: state=%+v err=%v", invalidated, err) - } - fresh := initial - fresh.SessionToken = "session-2" - fresh.Status = "ready" - if _, err := store.UpsertListenerState(ctx, fresh); err != nil { - t.Fatalf("upsert fresh listener: %v", err) - } - active, err := store.GetListenerState(ctx, accountID) - if err != nil || active.Status != "ready" || active.Invalidated || active.SessionToken != "session-2" { - t.Fatalf("fresh listener session was not activated: state=%+v err=%v", active, err) - } -} - -func TestCreatorPostgresMaterialClaimDoesNotAcceptStaleCompletion(t *testing.T) { - store, phaseAStore, ctx := openCreatorIntegrationStore(t) - stamp := time.Now().UnixNano() - _, _, work, _, _ := prepareIntegrationActionFixture(t, store, phaseAStore, ctx, fmt.Sprintf("%d", stamp)) - if _, _, err := store.SelectMaterial(ctx, work.ID); err != nil { - t.Fatalf("select material: %v", err) - } - claimed, ok, err := store.ClaimMaterialStep(ctx, work.ID, "download", "old-token") - if err != nil || !ok || claimed.ProcessingToken != "old-token" { - t.Fatalf("claim material: job=%+v claimed=%v err=%v", claimed, ok, err) - } - if _, err := store.db.ExecContext(ctx, `UPDATE creator_material_job SET processing_started_at=$2 WHERE work_id=$1`, work.ID, time.Now().UTC().Add(-time.Hour)); err != nil { - t.Fatal(err) - } - recovered, ok, err := store.ClaimMaterialStep(ctx, work.ID, "download", "new-token") - if err != nil || ok || recovered.DownloadStatus != "failed" || recovered.ProcessingToken != "" || recovered.FailureReason != "上次处理结果不明,未自动重试" { - t.Fatalf("stale claim was retried or not recovered: job=%+v claimed=%v err=%v", recovered, ok, err) - } - if _, err := store.CompleteMaterialStep(ctx, work.ID, "download", "old-token", "succeeded", "late-video", ""); !errors.Is(err, ErrConflict) { - t.Fatalf("stale completion was accepted: err=%v", err) - } -} - -func TestCreatorPostgresRecoversStaleProcessingWithoutRetry(t *testing.T) { - store, phaseAStore, ctx := openCreatorIntegrationStore(t) - stamp := time.Now().UnixNano() - bigID, smallID, work, comment, _ := prepareIntegrationActionFixture(t, store, phaseAStore, ctx, fmt.Sprintf("%d", stamp)) - recorded, err := store.RecordEvent(ctx, InteractionEvent{Platform: PlatformDouyin, ReceivingAccountID: bigID, EventKey: fmt.Sprintf("recovery-event-%d", stamp), EventType: "comment", InteractorUID: comment.AuthorUID, CommentID: comment.ID, WorkID: work.ID}) - if err != nil { - t.Fatalf("record event: result=%+v err=%v", recorded, err) - } - event := recorded.Event - op, inserted, err := store.CreateOperation(ctx, OperationInput{IdempotencyKey: fmt.Sprintf("recovery-op-%d", stamp), Source: "manual", Action: ActionReplyComment, Platform: PlatformDouyin, AccountID: smallID, TargetUID: comment.AuthorUID, TargetCommentID: comment.ID, TargetWorkID: work.ID, Text: "reply", EventID: event.ID}) - if err != nil || !inserted { - t.Fatalf("create operation: operation=%+v inserted=%v err=%v", op, inserted, err) - } - old := time.Now().UTC().Add(-3 * time.Minute) - if _, err := store.db.ExecContext(ctx, `UPDATE creator_operation SET source='automatic',state='processing',updated_at=$2 WHERE id=$1`, op.ID, old); err != nil { - t.Fatal(err) - } - if _, err := store.db.ExecContext(ctx, `UPDATE creator_event SET state='processing',processing_started_at=$2 WHERE id=$1`, event.ID, old); err != nil { - t.Fatal(err) - } - count, err := store.RecoverStaleProcessing(ctx, time.Now().UTC()) - if err != nil || count != 1 { - t.Fatalf("recover stale processing: count=%d err=%v", count, err) - } - recoveredOperation, err := store.GetOperation(ctx, op.ID) - if err != nil || recoveredOperation.State != "uncertain" { - t.Fatalf("operation was not made uncertain: operation=%+v err=%v", recoveredOperation, err) - } - recoveredEvent, err := store.GetEvent(ctx, event.ID) - if err != nil || recoveredEvent.State != "uncertain" { - t.Fatalf("event was not made uncertain: event=%+v err=%v", recoveredEvent, err) - } -} diff --git a/internal/creator/settings.go b/internal/creator/settings.go index 3ca07f2..4c7349f 100644 --- a/internal/creator/settings.go +++ b/internal/creator/settings.go @@ -139,16 +139,6 @@ func NextMetricAtValue(publishedAt *time.Time, now time.Time, input SettingsUpda input.MetricMultiplier, time.Duration(input.MetricAgeSeconds)*time.Second) } -func (s *Store) SetEventDisplayed(ctx context.Context, eventID string, displayedAt time.Time) (InteractionEvent, error) { - if displayedAt.IsZero() { - displayedAt = time.Now().UTC() - } - if _, err := s.db.ExecContext(ctx, `UPDATE creator_event SET displayed_at=$2 WHERE id=$1`, eventID, displayedAt.UTC()); err != nil { - return InteractionEvent{}, databaseError(err) - } - return s.GetEvent(ctx, eventID) -} - func (s *Store) EnsureSchema(ctx context.Context) error { if _, err := s.db.ExecContext(ctx, `SELECT 1 FROM creator_settings WHERE id=true`); err != nil { return fmt.Errorf("check creator schema: %w", err) diff --git a/internal/environment/environment.go b/internal/environment/environment.go index 1636dcb..1fd21ef 100644 --- a/internal/environment/environment.go +++ b/internal/environment/environment.go @@ -12,7 +12,6 @@ import ( "strings" "time" - "git.ipao.vip/rogee/creator-hub/internal/taskstate" ) var exitIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`) @@ -66,15 +65,6 @@ type EnvironmentContext struct { RuntimeNodeID string `json:"runtime_node_id,omitempty"` } -type RuntimeUseLease struct { - Token string `json:"token"` - RuntimeInstanceID string `json:"runtime_instance_id"` - OwnerID string `json:"owner_id"` - Purpose string `json:"purpose"` - TaskID string `json:"task_id,omitempty"` - LeaseUntil time.Time `json:"lease_until"` -} - type EnvironmentAction struct { OperationID string Action string @@ -180,7 +170,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()) OR EXISTS (SELECT 1 FROM environment_binding binding JOIN operation_task task ON task.account_id=binding.account_id WHERE binding.network_exit_id=$1 AND task.state='executing')`, id).Scan(&active); err != nil { + 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 { return NetworkExit{}, errors.New("check network exit activity") } if active { @@ -331,14 +321,7 @@ func (s *Store) RecordNetworkExitCheck(ctx context.Context, id string, observati WHERE id = $1`, id, observation.PublicIP, observation.Region, status, reason, version); err != nil { return NetworkExit{}, "persistence_failed", errors.New("record network exit check") } - var transitions []taskstate.Transition - if changed { - transitions, err = invalidateAccountsForExit(ctx, tx, id) - if err != nil { - return NetworkExit{}, "persistence_failed", err - } - } - if err := s.commitTransitions(tx, transitions); err != nil { + if err := commitHub(tx); err != nil { return NetworkExit{}, "persistence_failed", err } exit, err := s.GetNetworkExit(ctx, id) @@ -379,17 +362,12 @@ func (s *Store) DisableNetworkExit(ctx context.Context, id string) (NetworkExit, 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() - ) OR EXISTS ( - SELECT 1 FROM environment_binding binding - JOIN operation_task task ON task.account_id = binding.account_id - WHERE binding.network_exit_id = $1 AND task.state = 'executing' )`, id).Scan(&active); err != nil { return NetworkExit{}, errors.New("check network exit activity") } if active { return NetworkExit{}, ErrConflict } - var transitions []taskstate.Transition if oldStatus != "disabled" { if _, err := tx.ExecContext(ctx, ` UPDATE network_exit SET health_status = 'disabled', last_check_reason = 'exit_disabled', @@ -397,43 +375,18 @@ func (s *Store) DisableNetworkExit(ctx context.Context, id string) (NetworkExit, WHERE id = $1`, id); err != nil { return NetworkExit{}, errors.New("disable network exit") } - transitions, err = invalidateAccountsForExit(ctx, tx, id) - if err != nil { - return NetworkExit{}, err - } - } - if err := s.commitTransitions(tx, transitions); err != nil { - return NetworkExit{}, err - } - return s.GetNetworkExit(ctx, id) -} - -func invalidateAccountsForExit(ctx context.Context, tx *sql.Tx, exitID string) ([]taskstate.Transition, error) { - rows, err := tx.QueryContext(ctx, ` - WITH changed AS ( + 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 - RETURNING account.id - ), held AS ( - UPDATE operation_task task SET - state = CASE task.state WHEN 'executing' THEN 'needs_confirmation' ELSE 'policy_hold' END, - hold_reason = 'exit_unhealthy', verification_result = NULL, verified_at = NULL, verified_by = NULL, - lease_owner = NULL, lease_until = NULL, updated_at = now() - FROM changed WHERE task.account_id = changed.id AND task.state IN ('queued', 'executing') - RETURNING task.current_attempt_id, task.state, task.hold_reason, task.account_id, task.id - ), finished AS ( - UPDATE execution_attempt attempt SET finished_at = now(), outcome = 'uncertain' - FROM held WHERE held.state = 'needs_confirmation' AND attempt.id = held.current_attempt_id - AND attempt.finished_at IS NULL - RETURNING attempt.id - ) - SELECT state, hold_reason, account_id, id FROM held`, exitID) - if err != nil { - return nil, errors.New("invalidate network exit accounts") + WHERE binding.network_exit_id = $1 AND binding.account_id = account.id`, id); err != nil { + return NetworkExit{}, errors.New("invalidate network exit accounts") + } } - return scanTransitions(rows, "read invalidated network exit tasks") + if err := commitHub(tx); err != nil { + return NetworkExit{}, err + } + return s.GetNetworkExit(ctx, id) } func (s *Store) CreateBoundEnv(ctx context.Context, env Env, accountID, exitID string) (EnvironmentContext, bool, error) { @@ -576,111 +529,6 @@ func (s *Store) GetEnvironmentContextForAccount(ctx context.Context, accountID s } const runtimeUseLeaseDuration = time.Minute - -func validRuntimeUse(purpose, ownerID, taskID string) bool { - if purpose != "task" && purpose != "listener" || ownerID == "" || len(ownerID) > 128 || !exitIDPattern.MatchString(ownerID) { - return false - } - return taskID == "" || exitIDPattern.MatchString(taskID) -} - -// AcquireRuntimeUse reserves a short-lived task or long-lived listener right on -// an already running browser. It deliberately does not own the browser runtime -// itself; stopping that runtime revokes active use leases and callers observe -// the loss on their next renewal. -func (s *Store) AcquireRuntimeUse(ctx context.Context, alias, purpose, ownerID, taskID string) (RuntimeUseLease, error) { - if !aliasPattern.MatchString(alias) || !validRuntimeUse(purpose, ownerID, taskID) { - return RuntimeUseLease{}, ErrInvalid - } - tx, err := s.db.BeginTx(ctx, nil) - if err != nil { - return RuntimeUseLease{}, errors.New("begin runtime use acquisition") - } - defer tx.Rollback() - var runtimeInstanceID string - err = tx.QueryRowContext(ctx, ` - SELECT runtime.id - FROM environment_binding binding - JOIN runtime_instance runtime ON runtime.binding_id = binding.id - JOIN social_account account ON account.id = binding.account_id - WHERE binding.browser_env_alias = $1 AND runtime.released_at IS NULL - AND runtime.lease_until > now() AND NOT binding.runtime_cleanup_pending - AND account.status = 'active' AND account.authorization_status = 'authorized' - FOR UPDATE OF binding, runtime`, alias).Scan(&runtimeInstanceID) - if errors.Is(err, sql.ErrNoRows) { - return RuntimeUseLease{}, ErrConflict - } - if err != nil { - return RuntimeUseLease{}, publicDatabaseError(err) - } - token := "runtime-use-" + newHubID() - var lease RuntimeUseLease - err = tx.QueryRowContext(ctx, ` - INSERT INTO runtime_use_lease (token, runtime_instance_id, owner_id, purpose, task_id, lease_until) - VALUES ($1, $2, $3, $4, NULLIF($5, ''), now() + $6::interval) - RETURNING token, runtime_instance_id, owner_id, purpose, COALESCE(task_id, ''), lease_until`, - token, runtimeInstanceID, ownerID, purpose, taskID, runtimeUseLeaseDuration.String()).Scan( - &lease.Token, &lease.RuntimeInstanceID, &lease.OwnerID, &lease.Purpose, &lease.TaskID, &lease.LeaseUntil) - if err != nil { - return RuntimeUseLease{}, publicDatabaseError(err) - } - if err := commitHub(tx); err != nil { - return RuntimeUseLease{}, err - } - return lease, nil -} - -func (s *Store) RenewRuntimeUse(ctx context.Context, token string) (RuntimeUseLease, error) { - if !exitIDPattern.MatchString(token) { - return RuntimeUseLease{}, ErrInvalid - } - var lease RuntimeUseLease - err := s.db.QueryRowContext(ctx, ` - UPDATE runtime_use_lease use_lease - SET lease_until = now() + interval '1 minute' - FROM runtime_instance runtime - WHERE use_lease.token = $1 AND use_lease.released_at IS NULL - AND use_lease.lease_until > now() AND runtime.id = use_lease.runtime_instance_id - AND runtime.released_at IS NULL AND runtime.lease_until > now() - RETURNING use_lease.token, use_lease.runtime_instance_id, use_lease.owner_id, - use_lease.purpose, COALESCE(use_lease.task_id, ''), use_lease.lease_until`, token).Scan( - &lease.Token, &lease.RuntimeInstanceID, &lease.OwnerID, &lease.Purpose, &lease.TaskID, &lease.LeaseUntil) - if errors.Is(err, sql.ErrNoRows) { - return RuntimeUseLease{}, ErrConflict - } - if err != nil { - return RuntimeUseLease{}, publicDatabaseError(err) - } - return lease, nil -} - -func (s *Store) ReleaseRuntimeUse(ctx context.Context, token string) error { - if !exitIDPattern.MatchString(token) { - return ErrInvalid - } - result, err := s.db.ExecContext(ctx, ` - UPDATE runtime_use_lease SET released_at = now() - WHERE token = $1 AND released_at IS NULL`, token) - if err != nil { - return publicDatabaseError(err) - } - affected, err := result.RowsAffected() - if err != nil { - return err - } - if affected == 1 { - return nil - } - var exists bool - if err := s.db.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM runtime_use_lease WHERE token = $1)`, token).Scan(&exists); err != nil { - return publicDatabaseError(err) - } - if !exists { - return ErrNotFound - } - return nil -} - func (s *Store) SetRuntimeNode(ctx context.Context, alias, runtimeID, nodeID string) error { if !aliasPattern.MatchString(alias) || !exitIDPattern.MatchString(runtimeID) || !nodeIDPattern.MatchString(nodeID) { return ErrInvalid @@ -703,15 +551,6 @@ func (s *Store) SetRuntimeNode(ctx context.Context, alias, runtimeID, nodeID str } return nil } - -func releaseRuntimeUseLeases(ctx context.Context, tx *sql.Tx, runtimeInstanceID string) error { - if runtimeInstanceID == "" { - return nil - } - _, err := tx.ExecContext(ctx, `UPDATE runtime_use_lease SET released_at = now() WHERE runtime_instance_id = $1 AND released_at IS NULL`, runtimeInstanceID) - return err -} - func releaseExpiredRuntime(ctx context.Context, tx *sql.Tx, bindingID string) error { var accountID, alias, runtimeInstanceID string var exitID sql.NullString @@ -730,9 +569,6 @@ func releaseExpiredRuntime(ctx context.Context, tx *sql.Tx, bindingID string) er if err != nil { return err } - if err := releaseRuntimeUseLeases(ctx, tx, runtimeInstanceID); err != nil { - return errors.New("release runtime use leases") - } return appendRuntimeAudit(ctx, tx, "runtime_released", accountID, alias, exitID.String, runtimeInstanceID, bindingVersion) } @@ -762,9 +598,8 @@ func validateEnvironmentRebind(ctx context.Context, tx *sql.Tx, alias, exitID st 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 operation_task WHERE account_id = $2 AND state = 'executing') - AND NOT EXISTS (SELECT 1 FROM runtime_instance WHERE binding_id = $3 AND released_at IS NULL)`, - exitID, accountID, bindingID).Scan(&allowed); err != nil { + 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") } if !allowed { @@ -929,9 +764,6 @@ func (s *Store) ReleaseRuntime(ctx context.Context, environment EnvironmentConte if err != nil { return errors.New("release environment runtime") } - if err := releaseRuntimeUseLeases(ctx, tx, runtimeInstanceID); err != nil { - return errors.New("release runtime use leases") - } if err := appendRuntimeAudit(ctx, tx, "runtime_released", accountID, alias, exitID.String, runtimeInstanceID, bindingVersion); err != nil { return err } @@ -1007,9 +839,6 @@ func (s *Store) SetRuntimeCleanupPending(ctx context.Context, environment Enviro if affected, err := result.RowsAffected(); err != nil || affected != 1 { return ErrConflict } - if err := releaseRuntimeUseLeases(ctx, tx, runtimeInstanceID); err != nil { - return errors.New("release runtime use leases") - } if err := appendRuntimeAudit(ctx, tx, "runtime_released", accountID, alias, exitID.String, runtimeInstanceID, environment.BindingVersion); err != nil { return err } diff --git a/internal/environment/migration_test.go b/internal/environment/migration_test.go index 5de0d2c..cfca258 100644 --- a/internal/environment/migration_test.go +++ b/internal/environment/migration_test.go @@ -3,7 +3,6 @@ package environment import ( "context" "database/sql" - "errors" "fmt" "net/url" "os" @@ -12,7 +11,6 @@ import ( "time" "git.ipao.vip/rogee/creator-hub/internal/account" - "git.ipao.vip/rogee/creator-hub/internal/taskstate" ) func TestUnifiedAccountMigration(t *testing.T) { @@ -85,56 +83,6 @@ func TestUnifiedAccountMigration(t *testing.T) { assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version = 14`, 0) }) - t.Run("previous migration 011 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-recovery-upgrade', 'os_keyring', 'creatorhub/recovery-upgrade'); - INSERT INTO social_account - (id, credential_reference_id, platform, platform_account_key, authorization_kind, authorization_status) - VALUES ('recovery-upgrade', 'credential-recovery-upgrade', 'mock', 'recovery-upgrade', 'owned', 'authorized'); - INSERT INTO content_draft (id, account_id, version, content) - VALUES ('draft-recovery-upgrade', 'recovery-upgrade', 1, 'legacy'); - INSERT INTO confirmation (id, account_id, account_version, draft_id, draft_version, version) - VALUES ('confirmation-recovery-upgrade', 'recovery-upgrade', 1, 'draft-recovery-upgrade', 1, 1); - INSERT INTO operation_task - (id, idempotency_key, account_id, account_version, draft_id, draft_version, - confirmation_id, confirmation_version, state, hold_reason) - VALUES ('task-recovery-upgrade', 'task-recovery-upgrade-key', 'recovery-upgrade', 1, - 'draft-recovery-upgrade', 1, 'confirmation-recovery-upgrade', 1, - 'needs_confirmation', 'legacy_confirmation_required'); - DELETE FROM schema_migration WHERE version = 12`); err != nil { - t.Fatal(err) - } - - store, err = Open(ctx, testURL) - if err != nil { - t.Fatalf("apply task recovery compatibility migration: %v", err) - } - store.Close() - assertDatabaseCount(t, db, `SELECT count(*) FROM schema_migration WHERE version = 12`, 1) - assertDatabaseCount(t, db, `SELECT count(*) FROM operation_task - WHERE id = 'task-recovery-upgrade' AND state = 'needs_confirmation' - AND hold_reason = 'task_result_uncertain'`, 1) - phaseAStore, err := account.Open(ctx, testURL) - if err != nil { - t.Fatal(err) - } - defer phaseAStore.Close() - detail, err := phaseAStore.GetTaskDetail(ctx, "task-recovery-upgrade") - if err != nil || detail.AllowedAction != "verify" { - t.Fatalf("migrated unknown result did not require manual verification: detail=%+v err=%v", detail, err) - } - }) - t.Run("previous migration 008 already applied", func(t *testing.T) { ctx := context.Background() testURL := isolatedDatabaseURL(t, databaseURL) @@ -224,14 +172,6 @@ func TestUnifiedAccountMigration(t *testing.T) { INSERT INTO social_account (id, credential_reference_id, profile_id, status) VALUES ('mapped', 'credential-mapped', 'legacy-profile-mapped', 'active'), ('unbound', 'credential-unbound', 'legacy-profile-unbound', 'active'); - INSERT INTO content_draft (id, account_id, version, content) VALUES ('legacy-draft', 'mapped', 1, 'legacy'); - INSERT INTO confirmation (id, account_id, account_version, draft_id, draft_version, version) - VALUES ('legacy-confirmation', 'mapped', 1, 'legacy-draft', 1, 1); - INSERT INTO operation_task - (id, idempotency_key, account_id, account_version, draft_id, draft_version, confirmation_id, confirmation_version, state) - VALUES - ('legacy-task', 'legacy-task-key', 'mapped', 1, 'legacy-draft', 1, 'legacy-confirmation', 1, 'queued'), - ('legacy-unknown-task', 'legacy-unknown-task-key', 'mapped', 1, 'legacy-draft', 1, 'legacy-confirmation', 1, 'needs_confirmation'); INSERT INTO gateway (name, endpoint, token) VALUES ('legacy-gateway', 'http://127.0.0.1:8081', 'legacy-gateway-token'); INSERT INTO browser_image (version, image_ref) VALUES ('1', '/opt/creatorhub/browsers/1'); INSERT INTO browser_env (alias, name, gateway_name, image_version, fingerprint) VALUES @@ -259,8 +199,6 @@ func TestUnifiedAccountMigration(t *testing.T) { 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 operation_task WHERE id = 'legacy-task' AND state = 'policy_hold'`, 1) - assertDatabaseCount(t, db, `SELECT count(*) FROM operation_task WHERE id = 'legacy-unknown-task' AND state = 'needs_confirmation' AND hold_reason = 'task_result_uncertain'`, 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) @@ -317,72 +255,6 @@ func TestUnifiedAccountMigration(t *testing.T) { VALUES ('instance-conflict', 'mapped', 'mapped', 'runtime-new', now() + interval '1 hour')`); err == nil { t.Fatal("one binding must not have two active runtime instances") } - if _, err := db.Exec(` - UPDATE social_account SET status = 'active' WHERE id = 'mapped'; - INSERT INTO content_draft (id, account_id, version, content) VALUES ('upgrade-draft', 'mapped', 1, 'test'); - INSERT INTO confirmation (id, account_id, account_version, draft_id, draft_version, version) - VALUES ('upgrade-confirmation', 'mapped', 1, 'upgrade-draft', 1, 1); - INSERT INTO operation_task - (id, idempotency_key, account_id, account_version, draft_id, draft_version, confirmation_id, confirmation_version) - VALUES ('upgrade-task', 'upgrade-task-key', 'mapped', 1, 'upgrade-draft', 1, 'upgrade-confirmation', 1); - INSERT INTO operation_task - (id, idempotency_key, account_id, account_version, draft_id, draft_version, confirmation_id, confirmation_version, state, lease_owner, lease_until) - VALUES ('upgrade-executing', 'upgrade-executing-key', 'mapped', 1, 'upgrade-draft', 1, 'upgrade-confirmation', 1, - 'executing', 'worker-old', now() + interval '1 minute'); - INSERT INTO execution_attempt (id, task_id) VALUES ('upgrade-attempt', 'upgrade-executing'); - UPDATE operation_task SET current_attempt_id = 'upgrade-attempt' WHERE id = 'upgrade-executing'`); err != nil { - t.Fatal(err) - } - store, err = Open(ctx, testURL) - if err != nil { - t.Fatal(err) - } - var notifications []taskstate.Transition - store.SetTaskNotifier(func(transition taskstate.Transition) { notifications = append(notifications, transition) }) - if _, err := db.Exec(`UPDATE browser_env SET version = 2 WHERE alias = 'mapped'`); err != nil { - t.Fatal(err) - } - if _, err := db.Exec(`UPDATE environment_binding SET version = 2 WHERE id = 'mapped'`); err != nil { - t.Fatal(err) - } - if _, err := db.Exec(`UPDATE social_account SET version = 2, status = 'paused', paused_at = now() WHERE id = 'mapped'`); err != nil { - t.Fatal(err) - } - if _, err := db.Exec(`UPDATE operation_task SET state = 'policy_hold', hold_reason = 'binding_version_changed' WHERE id = 'upgrade-task'`); err != nil { - t.Fatal(err) - } - if _, err := db.Exec(`UPDATE operation_task SET state = 'needs_confirmation', hold_reason = 'task_result_uncertain', lease_owner = NULL, lease_until = NULL WHERE id = 'upgrade-executing'`); err != nil { - t.Fatal(err) - } - if _, err := db.Exec(`UPDATE execution_attempt SET outcome = 'uncertain', finished_at = now() WHERE id = 'upgrade-attempt'`); err != nil { - t.Fatal(err) - } - store.Close() - assertDatabaseCount(t, db, `SELECT count(*) FROM browser_env WHERE alias = 'mapped' AND version = 2`, 1) - assertDatabaseCount(t, db, `SELECT count(*) FROM environment_binding WHERE id = 'mapped' AND version = 2`, 1) - assertDatabaseCount(t, db, `SELECT count(*) FROM social_account WHERE id = 'mapped' AND version = 2 AND status = 'paused'`, 1) - assertDatabaseCount(t, db, `SELECT count(*) FROM operation_task WHERE id = 'upgrade-task' AND state = 'policy_hold' AND hold_reason = 'binding_version_changed'`, 1) - assertDatabaseCount(t, db, `SELECT count(*) FROM operation_task WHERE id = 'upgrade-executing' AND state = 'needs_confirmation' AND hold_reason = 'task_result_uncertain' AND lease_owner IS NULL`, 1) - assertDatabaseCount(t, db, `SELECT count(*) FROM execution_attempt WHERE id = 'upgrade-attempt' AND outcome = 'uncertain' AND finished_at IS NOT NULL`, 1) - _ = notifications - phaseAStore, err = account.Open(ctx, testURL) - if err != nil { - t.Fatal(err) - } - defer phaseAStore.Close() - detail, err := phaseAStore.GetTaskDetail(ctx, "upgrade-executing") - if err != nil || detail.AllowedAction != "verify" { - t.Fatalf("upgraded executing task did not require manual verification: detail=%+v err=%v", detail, err) - } - if err := phaseAStore.VerifyTask(ctx, "upgrade-executing", "failed"); err != nil { - t.Fatalf("verify upgraded executing task: %v", err) - } - assertDatabaseCount(t, db, `SELECT count(*) FROM operation_task WHERE id = 'upgrade-executing' AND verification_result = 'failed' AND verified_by = 'local-user'`, 1) - if err := phaseAStore.VerifyTask(ctx, "upgrade-executing", "succeeded"); !errors.Is(err, account.ErrConflict) { - t.Fatalf("repeated verification changed the recorded conclusion: %v", err) - } - assertDatabaseCount(t, db, `SELECT count(*) FROM operation_task WHERE confirmation_id = 'upgrade-confirmation'`, 2) - store, err = Open(ctx, testURL) if err != nil { t.Fatal(err) diff --git a/internal/environment/store.go b/internal/environment/store.go index 9f00349..d63cc31 100644 --- a/internal/environment/store.go +++ b/internal/environment/store.go @@ -16,7 +16,6 @@ import ( "time" "unicode/utf8" - "git.ipao.vip/rogee/creator-hub/internal/taskstate" "github.com/jackc/pgx/v5/pgconn" _ "github.com/jackc/pgx/v5/stdlib" ) @@ -96,7 +95,6 @@ var ( type Store struct { db *sql.DB lockAdmission chan struct{} - notify taskstate.Notifier } // Gateway 是平台注册的 native browser gateway 节点;Token 由平台生成,明文存储供页面复制(开发阶段约定)。 @@ -144,17 +142,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) SetTaskNotifier(notify taskstate.Notifier) { s.notify = notify } - -func (s *Store) notifyTransitions(transitions []taskstate.Transition) { - if s.notify == nil { - return - } - for _, transition := range transitions { - s.notify(transition) - } -} - // LockResources serializes lifecycle state across control-plane replicas. The // transaction carries no data changes; rolling it back only releases the locks. func (s *Store) LockResources(ctx context.Context, aliases, exitIDs []string, _ ...[]string) (func(), error) { @@ -476,30 +463,6 @@ func commitHub(tx *sql.Tx) error { return nil } -func (s *Store) commitTransitions(tx *sql.Tx, transitions []taskstate.Transition) error { - if err := commitHub(tx); err != nil { - return err - } - s.notifyTransitions(transitions) - return nil -} - -func scanTransitions(rows *sql.Rows, failure string) ([]taskstate.Transition, error) { - defer rows.Close() - var transitions []taskstate.Transition - for rows.Next() { - var transition taskstate.Transition - if err := rows.Scan(&transition.State, &transition.ReasonCode, &transition.AccountID, &transition.TaskID); err != nil { - return nil, errors.New(failure) - } - transitions = append(transitions, transition) - } - if err := rows.Err(); err != nil { - return nil, errors.New(failure) - } - return transitions, nil -} - func rowError(err error) error { if errors.Is(err, sql.ErrNoRows) { return ErrNotFound diff --git a/internal/environment/store_test.go b/internal/environment/store_test.go index 1eec1db..da1fa58 100644 --- a/internal/environment/store_test.go +++ b/internal/environment/store_test.go @@ -7,13 +7,11 @@ import ( "fmt" "os" "reflect" - "slices" "strings" "sync/atomic" "testing" "time" - "git.ipao.vip/rogee/creator-hub/internal/taskstate" ) func TestNetworkExitCredentialValidation(t *testing.T) { @@ -403,84 +401,6 @@ func TestHubWorkflow(t *testing.T) { } } -func TestRuntimeUseLeaseLifecycle(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, `TRUNCATE runtime_use_lease, runtime_instance, environment_binding, browser_env, - gateway, social_account, credential_reference CASCADE`); err != nil { - t.Fatal(err) - } - if _, err := store.db.ExecContext(ctx, ` - INSERT INTO credential_reference (id, provider, reference_key) - VALUES ('lease-credential', 'os_keyring', 'creatorhub/lease-account'); - INSERT INTO social_account - (id, credential_reference_id, platform, platform_account_key, authorization_kind, authorization_status) - VALUES ('lease-account', 'lease-credential', 'mock', 'lease-account', 'owned', 'authorized')`); err != nil { - t.Fatal(err) - } - if _, err := store.CreateGateway(ctx, "lease-gateway", "http://127.0.0.1:8081", "lease-gateway-token"); err != nil { - t.Fatal(err) - } - environment, created, err := store.CreateBoundEnv(ctx, Env{ - Alias: "lease-env", Name: "Lease environment", Gateway: "lease-gateway", - Fingerprint: Fingerprint{Seed: 1}, - }, "lease-account", "") - if err != nil || !created { - t.Fatalf("create lease environment: %#v created=%v err=%v", environment, created, err) - } - if _, err := store.db.ExecContext(ctx, `UPDATE social_account SET status = 'active' WHERE id = 'lease-account'`); err != nil { - t.Fatal(err) - } - active, err := store.ActivateRuntime(ctx, environment.Alias, "runtime-lease-generation", environment.BindingVersion, "", "native-lease-network") - if err != nil { - t.Fatal(err) - } - lease, err := store.AcquireRuntimeUse(ctx, environment.Alias, "task", "lease-owner", "") - if err != nil || lease.RuntimeInstanceID != active.RuntimeInstanceID || lease.Purpose != "task" { - t.Fatalf("acquire runtime-use lease: %#v err=%v", lease, err) - } - if renewed, renewErr := store.RenewRuntimeUse(ctx, lease.Token); renewErr != nil || !renewed.LeaseUntil.After(lease.LeaseUntil) { - t.Fatalf("renew runtime-use lease: %#v err=%v", renewed, renewErr) - } - if err := store.ReleaseRuntimeUse(ctx, lease.Token); err != nil { - t.Fatal(err) - } - if err := store.ReleaseRuntimeUse(ctx, lease.Token); err != nil { - t.Fatalf("releasing an already released lease must be idempotent: %v", err) - } - listener, err := store.AcquireRuntimeUse(ctx, environment.Alias, "listener", "listener-owner", "") - if err != nil { - t.Fatal(err) - } - if _, err := store.db.ExecContext(ctx, `UPDATE runtime_use_lease SET lease_until = now() - interval '1 second' WHERE token = $1`, listener.Token); err != nil { - t.Fatal(err) - } - if _, err := store.RenewRuntimeUse(ctx, listener.Token); !errors.Is(err, ErrConflict) { - t.Fatalf("expired runtime-use lease renewed: %v", err) - } - if err := store.ReleaseRuntime(ctx, active); err != nil { - t.Fatal(err) - } - var released, runtimeReleased bool - if err := store.db.QueryRowContext(ctx, `SELECT released_at IS NOT NULL FROM runtime_use_lease WHERE token = $1`, listener.Token).Scan(&released); err != nil { - t.Fatal(err) - } - if err := store.db.QueryRowContext(ctx, `SELECT released_at IS NOT NULL FROM runtime_instance WHERE id = $1`, active.RuntimeInstanceID).Scan(&runtimeReleased); err != nil { - t.Fatal(err) - } - if !released || !runtimeReleased { - t.Fatalf("runtime cleanup did not revoke use leases: lease_released=%v runtime_released=%v", released, runtimeReleased) - } - if _, err := store.RenewRuntimeUse(ctx, listener.Token); !errors.Is(err, ErrConflict) { - t.Fatalf("runtime cleanup left an old lease renewable: %v", err) - } -} - func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) { databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") if databaseURL == "" { @@ -489,8 +409,6 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) { ctx := context.Background() store := openFullyMigratedHub(t, ctx, isolatedDatabaseURL(t, databaseURL)) t.Cleanup(func() { _ = store.Close() }) - var notifications []taskstate.Transition - store.SetTaskNotifier(func(transition taskstate.Transition) { notifications = append(notifications, transition) }) 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 { t.Fatal(err) @@ -733,72 +651,8 @@ func TestNetworkExitBindingRuntimeAndAuditWorkflow(t *testing.T) { t.Fatal(err) } assertDatabaseCount(t, store.db, `SELECT count(*) FROM audit_event WHERE operation_id = '`+action.OperationID+`'`, 2) - var accountVersion int64 - if err := store.db.QueryRowContext(ctx, `SELECT version FROM social_account WHERE id = 'account-a'`).Scan(&accountVersion); err != nil { - t.Fatal(err) - } - if _, err := store.db.ExecContext(ctx, `INSERT INTO content_draft (id, account_id, version, content) VALUES ('exit-hold-draft', 'account-a', 1, 'test')`); err != nil { - t.Fatal(err) - } - if _, err := store.db.ExecContext(ctx, `INSERT INTO confirmation (id, account_id, account_version, draft_id, draft_version, version) - VALUES ('exit-hold-confirmation', 'account-a', $1, 'exit-hold-draft', 1, 1)`, accountVersion); err != nil { - t.Fatal(err) - } - if _, err := store.db.ExecContext(ctx, `INSERT INTO operation_task - (id, idempotency_key, account_id, account_version, draft_id, draft_version, confirmation_id, confirmation_version, state, lease_owner, lease_until) - VALUES - ('exit-hold-queued', 'exit-hold-queued-key', 'account-a', $1, 'exit-hold-draft', 1, 'exit-hold-confirmation', 1, 'queued', NULL, NULL), - ('exit-hold-executing', 'exit-hold-executing-key', 'account-a', $1, 'exit-hold-draft', 1, 'exit-hold-confirmation', 1, 'executing', 'worker-old', now() + interval '1 minute')`, accountVersion); err != nil { - t.Fatal(err) - } - if _, err := store.db.ExecContext(ctx, `INSERT INTO execution_attempt (id, task_id) VALUES ('exit-hold-attempt', 'exit-hold-executing')`); err != nil { - t.Fatal(err) - } - if _, err := store.db.ExecContext(ctx, `UPDATE operation_task SET current_attempt_id = 'exit-hold-attempt' WHERE id = 'exit-hold-executing'`); err != nil { - t.Fatal(err) - } - if _, _, err := store.RecordNetworkExitCheck(ctx, newGeneration.Exit.ID, ExitObservation{}, "proxy_check_failed"); err != nil { - t.Fatal(err) - } - assertDatabaseCount(t, store.db, `SELECT count(*) FROM operation_task WHERE id = 'exit-hold-queued' AND state = 'policy_hold' AND hold_reason = 'exit_unhealthy'`, 1) - assertDatabaseCount(t, store.db, `SELECT count(*) FROM operation_task WHERE id = 'exit-hold-executing' AND state = 'needs_confirmation' AND hold_reason = 'exit_unhealthy' AND lease_owner IS NULL`, 1) - assertDatabaseCount(t, store.db, `SELECT count(*) FROM execution_attempt WHERE id = 'exit-hold-attempt' AND outcome = 'uncertain' AND finished_at IS NOT NULL`, 1) - for _, want := range []taskstate.Transition{ - {State: "policy_hold", ReasonCode: "exit_unhealthy", AccountID: "account-a", TaskID: "exit-hold-queued"}, - {State: "needs_confirmation", ReasonCode: "exit_unhealthy", AccountID: "account-a", TaskID: "exit-hold-executing"}, - } { - if !slices.Contains(notifications, want) { - t.Fatalf("missing network exit transition %+v in %+v", want, notifications) - } - } - if err := store.db.QueryRowContext(ctx, `SELECT version FROM social_account WHERE id = 'account-a'`).Scan(&accountVersion); err != nil { - t.Fatal(err) - } - if _, err := store.db.ExecContext(ctx, `INSERT INTO content_draft (id, account_id, version, content) VALUES ('exit-disable-draft', 'account-a', 1, 'test')`); err != nil { - t.Fatal(err) - } - if _, err := store.db.ExecContext(ctx, `INSERT INTO confirmation (id, account_id, account_version, draft_id, draft_version, version) - VALUES ('exit-disable-confirmation', 'account-a', $1, 'exit-disable-draft', 1, 1)`, accountVersion); err != nil { - t.Fatal(err) - } - if _, err := store.db.ExecContext(ctx, `INSERT INTO operation_task - (id, idempotency_key, account_id, account_version, draft_id, draft_version, confirmation_id, confirmation_version, state, lease_owner, lease_until) - VALUES - ('exit-disable-queued', 'exit-disable-queued-key', 'account-a', $1, 'exit-disable-draft', 1, 'exit-disable-confirmation', 1, 'queued', NULL, NULL), - ('exit-disable-executing', 'exit-disable-executing-key', 'account-a', $1, 'exit-disable-draft', 1, 'exit-disable-confirmation', 1, 'executing', 'worker-disabled', now() + interval '1 minute')`, accountVersion); err != nil { - t.Fatal(err) - } - if _, err := store.DisableNetworkExit(ctx, newGeneration.Exit.ID); !errors.Is(err, ErrConflict) { - t.Fatalf("running exit must not be disabled: %v", err) - } - if _, err := store.db.ExecContext(ctx, `UPDATE operation_task SET state='cancelled', lease_owner=NULL, lease_until=NULL WHERE id IN ('exit-disable-queued','exit-disable-executing')`); err != nil { - t.Fatal(err) - } - if _, err := store.db.ExecContext(ctx, `UPDATE runtime_instance SET released_at=now() WHERE binding_id IN (SELECT id FROM environment_binding WHERE network_exit_id=$1)`, newGeneration.Exit.ID); err != nil { - t.Fatal(err) - } if _, err := store.DisableNetworkExit(ctx, newGeneration.Exit.ID); err != nil { - t.Fatalf("stopped exit should be disableable: %v", err) + t.Fatalf("exit without active runtime should be disableable: %v", err) } var auditText string if err := store.db.QueryRowContext(ctx, `SELECT string_agg(row_to_json(event)::text, '') FROM audit_event event`).Scan(&auditText); err != nil { diff --git a/internal/taskstate/transition.go b/internal/taskstate/transition.go deleted file mode 100644 index c14548c..0000000 --- a/internal/taskstate/transition.go +++ /dev/null @@ -1,7 +0,0 @@ -package taskstate - -type Transition struct { - State, ReasonCode, AccountID, TaskID string -} - -type Notifier func(Transition) diff --git a/web/src/pages/accounts/$id/edit.tsx b/web/src/pages/accounts/$id/edit.tsx index 865376b..cec5e3d 100644 --- a/web/src/pages/accounts/$id/edit.tsx +++ b/web/src/pages/accounts/$id/edit.tsx @@ -1,18 +1,11 @@ -// 编辑社媒账号:语义对齐 web.archived AccountEditPage。 -// 布局改用 antd Tabs(账号资料 / 自动响应策略)——归档版三区块信息量超过单页栅格,Tabs 是 antd 标准组织方式。 +// 编辑社媒账号:账号资料维护 + 登录二维码/身份核验。 import { useCallback, useEffect, useState } from 'react'; import { history, useParams } from '@umijs/max'; -import { Alert, App, Button, Card, Checkbox, Descriptions, Flex, Form, Input, InputNumber, Popconfirm, Select, Space, Tabs, Tag, Typography } from 'antd'; -import { creatorAction, creatorCreate, creatorGet, creatorRequest, creatorUpdate, getList } from '@/services/api'; +import { Alert, App, Button, Card, Descriptions, Flex, Form, Input, InputNumber, Select, Space, Typography } from 'antd'; +import { creatorAction, creatorGet, creatorUpdate, getList } from '@/services/api'; import { conflictMessage, dateTime } from '@/utils/helpers'; const platformLabelMap: Record = { douyin: '抖音' }; -const eventTypeOptions = [ - { value: 'comment', label: '评论' }, - { value: 'like', label: '点赞' }, - { value: 'repost', label: '转发' }, - { value: 'follow', label: '关注' }, -]; function profileForm(profile: any) { return { @@ -22,9 +15,7 @@ function profileForm(profile: any) { real_name: profile.real_name || '', identity_number: profile.identity_number || '', note: profile.note || '', - business_status: profile.business_status || 'normal', - reply_requirements: profile.reply_requirements || '', - cooldown_seconds: profile.cooldown_seconds || 86400 }; + business_status: profile.business_status || 'normal' }; } export default function Page() { @@ -35,14 +26,6 @@ export default function Page() { const [busy, setBusy] = useState(false); const [loginQR, setLoginQR] = useState(null); const [form] = Form.useForm(); - const [strategyForm] = Form.useForm(); - const [strategies, setStrategies] = useState([]); - const [strategyError, setStrategyError] = useState(null); - const [editingStrategyID, setEditingStrategyID] = useState(''); - const [relations, setRelations] = useState([]); - const [relationError, setRelationError] = useState(null); - const [listener, setListener] = useState(null); - const [listenerError, setListenerError] = useState(null); const { message: messageApi } = App.useApp(); const selected = profiles.find((item) => item.id === selectedID); @@ -70,27 +53,6 @@ export default function Page() { load(); }, [load]); - useEffect(() => { - if (!selectedID) return; - creatorGet(`/creator/accounts/${encodeURIComponent(selectedID)}/strategies`) - .then((result) => setStrategies(result)) - .catch((loadError) => setStrategyError(loadError)); - }, [selectedID]); - - useEffect(() => { - if (!selectedID) return; - creatorGet(`/creator/listeners?account_id=${encodeURIComponent(selectedID)}`) - .then((result) => setListener(result[0] || null)) - .catch((loadError) => setListenerError(loadError)); - }, [selectedID]); - - useEffect(() => { - if (!selectedID) return; - creatorGet(`/creator/relations?big_account_id=${encodeURIComponent(selectedID)}`) - .then((result) => setRelations(result)) - .catch((loadError) => setRelationError(loadError)); - }, [selectedID]); - async function save(values: any) { if (!selected) return; setBusy(true); @@ -146,96 +108,6 @@ export default function Page() { } } - async function toggleBig() { - if (!selected) return; - setBusy(true); - try { - const result = await creatorAction(`/creator/accounts/${encodeURIComponent(selected.id)}/big-account`, { - enabled: !selected.big_account }); - setProfiles((items) => items.map((item) => (item.id === selected.id ? result : item))); - form.setFieldsValue(profileForm(result)); - messageApi.success(result.big_account ? '已开启大号模式。' : '已关闭大号模式。'); - } catch (actionError) { - messageApi.error(conflictMessage(actionError, '大号模式更新失败')); - } finally { - setBusy(false); - } - } - - async function createStrategy(values: any) { - if (!selected) return; - setBusy(true); - try { - const payload = { - ...values, - position: Number(values.position), - candidate_texts: String(values.candidate_texts || '') - .split(',') - .map((item: string) => item.trim()) - .filter(Boolean) }; - await creatorCreate('/creator/relations', { - big_account_id: selected.id, - small_account_id: values.execution_account_id, - enabled: true }); - const result = editingStrategyID - ? await creatorUpdate(`/creator/strategies/${encodeURIComponent(editingStrategyID)}`, payload) - : await creatorCreate(`/creator/accounts/${encodeURIComponent(selected.id)}/strategies`, payload); - setStrategies((items) => - (editingStrategyID ? items.map((item) => (item.id === editingStrategyID ? result : item)) : [...items, result]).sort( - (left: any, right: any) => left.position - right.position, - ), - ); - strategyForm.resetFields(); - setEditingStrategyID(''); - messageApi.success('自动响应策略已保存。'); - } catch (createError) { - messageApi.error(conflictMessage(createError, '策略保存失败;请确认执行账号已绑定且平台一致')); - } finally { - setBusy(false); - } - } - - async function setStrategyEnabled(strategy: any, enabled: boolean) { - setBusy(true); - try { - const result = await creatorAction(`/creator/strategies/${encodeURIComponent(strategy.id)}/${enabled ? 'enable' : 'disable'}`); - setStrategies((items) => items.map((item) => (item.id === strategy.id ? result : item))); - } catch (actionError) { - messageApi.error(conflictMessage(actionError, '策略状态更新失败')); - } finally { - setBusy(false); - } - } - - async function unlinkRelation(relation: any) { - setBusy(true); - try { - await creatorCreate('/creator/relations', { - big_account_id: relation.big_account_id, - small_account_id: relation.small_account_id, - enabled: false }); - setRelations((items) => items.filter((item) => item.small_account_id !== relation.small_account_id)); - messageApi.success('账号关系已解除;已有策略不会自动恢复。'); - } catch (actionError) { - messageApi.error(conflictMessage(actionError, '账号关系解除失败')); - } finally { - setBusy(false); - } - } - - async function deleteStrategy(strategy: any) { - setBusy(true); - try { - await creatorRequest(`/creator/strategies/${encodeURIComponent(strategy.id)}`, { method: 'DELETE' }); - setStrategies((items) => items.filter((item) => item.id !== strategy.id)); - messageApi.success('策略已删除。'); - } catch (actionError) { - messageApi.error(conflictMessage(actionError, '策略删除失败')); - } finally { - setBusy(false); - } - } - if (pending) return ; if (error && !selected) return ( @@ -248,14 +120,21 @@ export default function Page() { ); if (!selected) return ; - const smallAccounts = profiles.filter( - (profile) => profile.id !== selected.id && profile.authorization_kind === 'owned' && profile.platform === selected.platform, - ); - - const profileTab = ( + return (
- -
+
+
+ + {selected.name || selected.platform_account_key} + + + {platformLabelMap[selected.platform] || selected.platform} · {selected.platform_account_key} + +
+ +
+ + {selected.login_status === 'logged_in' ? '已登录' : '需人工确认'} @@ -263,12 +142,6 @@ export default function Page() { {selected.login_reason ? ` · ${selected.login_reason}` : ''} {selected.password_configured ? '已配置' : '未配置'} - - {listener ? listener.status : '尚未启动'} - {listener?.boundary_at ? ` · 边界 ${dateTime(listener.boundary_at)}` : ' · 等待平台边界'} - {listener?.reason ? ` · ${listener.reason}` : ''} - - {listenerError ? {conflictMessage(listenerError, '请重试')} : null} - -
+
{loginQR ? ( 刷新登录画面}> @@ -332,16 +202,10 @@ export default function Page() { - - - - - -
最近更新:{selected.updated_at ? dateTime(selected.updated_at) : '—'}
); - - const strategyTab = selected.big_account ? ( -
- - 先绑定同平台小号,再保存单动作策略;没有平台执行证据时只记录为不确定。 -
- - - - - - - - - - - - - - {editingStrategyID ? ( - - ) : null} - -
- {strategyError ? : null} - {relationError ? : null} - {relations.map((relation) => ( - -
- 执行账号:{relation.small_account_id} - -
-
- ))} - {strategies.map((strategy) => ( - -
- - - #{strategy.position} · {strategy.action} · {strategy.execution_account_id} · {(strategy.event_types || []).join('、')} - - {strategy.enabled ? '启用' : '停用'} - - - - - deleteStrategy(strategy)}> - - - -
-
- ))} - {!strategies.length && !strategyError ? 尚未配置策略。 : null} -
-
- ) : ( - 开启大号模式后可配置有序自动响应策略。 - ); - - return ( -
-
-
- - {selected.name || selected.platform_account_key} - - - {platformLabelMap[selected.platform] || selected.platform} · {selected.platform_account_key} - -
- -
- -
- ); } diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 87fb5c9..3434bd4 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -135,10 +135,6 @@ export function creatorUpdate(path: string, data: unknown) { return request(path, jsonOptions('PUT', data)); } -export function creatorSyncConversation(id: string, limit = 200) { - return request(`/creator/conversations/${encodeURIComponent(id)}/sync?limit=${encodeURIComponent(limit)}`, { method: 'POST' }); -} - // SSE 订阅:语义对齐归档版 dataProvider.creatorSubscribe(fetch 流式读取,按 data: 帧分发)。 export async function creatorSubscribe( path: string,