From 59c30d887c6d057995b56d96ea4aa73477b6babb Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 31 Aug 2026 09:09:47 +0800 Subject: [PATCH] HH-847 / HH-799: add draft review and idempotent enqueue (#24) --- cmd/control-plane/hub_test.go | 22 ++ cmd/control-plane/phasea.go | 92 ++++++- internal/phasea/store.go | 476 +++++++++++++++++++++++++++++++--- internal/phasea/store_test.go | 73 +++++- web/src/AccountList.jsx | 28 ++ web/src/AccountList.test.jsx | 19 ++ web/src/DraftDetail.jsx | 168 ++++++++++++ web/src/DraftDetail.test.jsx | 72 +++++ web/src/dataProvider.js | 24 +- web/src/dataProvider.test.js | 26 ++ web/src/main.jsx | 6 +- web/tests/responsive.e2e.js | 36 +++ 12 files changed, 989 insertions(+), 53 deletions(-) create mode 100644 web/src/DraftDetail.jsx create mode 100644 web/src/DraftDetail.test.jsx diff --git a/cmd/control-plane/hub_test.go b/cmd/control-plane/hub_test.go index dc887e8..8918b55 100644 --- a/cmd/control-plane/hub_test.go +++ b/cmd/control-plane/hub_test.go @@ -87,6 +87,28 @@ 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, &phasea.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) CreateGateway(_ context.Context, _, _, _ string) (hub.Gateway, error) { return hub.Gateway{}, nil } diff --git a/cmd/control-plane/phasea.go b/cmd/control-plane/phasea.go index 6fb1ad2..43670dc 100644 --- a/cmd/control-plane/phasea.go +++ b/cmd/control-plane/phasea.go @@ -23,6 +23,21 @@ type accountRequest struct { } `json:"credential_reference"` } +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"` +} + func registerPhaseA(app *fiber.App, store *phasea.Store, runtimeStore runtimeStopStore) { app.Post("/api/phase-a/accounts", func(c fiber.Ctx) error { var input accountRequest @@ -102,33 +117,71 @@ func registerPhaseA(app *fiber.App, store *phasea.Store, runtimeStore runtimeSto }) app.Post("/api/phase-a/drafts", func(c fiber.Ctx) error { - var input phasea.Draft + var input draftRequest if err := decodePhaseA(c, &input); err != nil { return phaseAError(c, err) } - if err := store.CreateDraft(c.Context(), input); err != nil { + draft, err := store.CreateDraftVersion(c.Context(), input.AccountID, input.Content) + if err != nil { return phaseAError(c, err) } - return c.Status(fiber.StatusCreated).JSON(map[string]string{"id": input.ID}) + 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 phasea.Confirmation + var input confirmationRequest if err := decodePhaseA(c, &input); err != nil { return phaseAError(c, err) } - if err := store.Confirm(c.Context(), input); err != nil { + confirmation, inserted, err := store.ConfirmDraft(c.Context(), input.DraftID, input.AccountVersion, input.DraftVersion) + if err != nil { return phaseAError(c, err) } - return c.Status(fiber.StatusCreated).JSON(map[string]string{"id": input.ID}) + 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 phasea.Task + var input taskRequest if err := decodePhaseA(c, &input); err != nil { return phaseAError(c, err) } - task, inserted, err := store.Enqueue(c.Context(), input) + task, inserted, err := store.EnqueueConfirmation(c.Context(), input.ConfirmationID) if err != nil { return phaseAError(c, err) } @@ -139,6 +192,22 @@ func registerPhaseA(app *fiber.App, store *phasea.Store, runtimeStore runtimeSto return c.Status(status).JSON(task) }) + app.Get("/api/phase-a/tasks", func(c fiber.Ctx) error { + tasks, err := store.ListTasks(c.Context(), c.Query("account_id"), c.Query("draft_id")) + 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.GetTask(c.Context(), c.Params("id")) + if err != nil { + return phaseAError(c, err) + } + return c.JSON(task) + }) + 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) @@ -223,7 +292,14 @@ func decodePhaseA(c fiber.Ctx, destination any) error { func phaseAError(c fiber.Ctx, err error) error { status := fiber.StatusInternalServerError message := "phase A operation failed" + var readiness *phasea.ReadinessError switch { + case errors.As(err, &readiness): + status, message = fiber.StatusConflict, "phase A version or account state changed" + if readiness.Unavailable { + status, message = fiber.StatusServiceUnavailable, "phase A resources are not ready" + } + return c.Status(status).JSON(map[string]string{"error": message, "reason_code": readiness.Reason}) case errors.Is(err, phasea.ErrInvalid): status, message = fiber.StatusBadRequest, phasea.ErrInvalid.Error() case errors.Is(err, phasea.ErrConflict): diff --git a/internal/phasea/store.go b/internal/phasea/store.go index 8e69788..90c5d49 100644 --- a/internal/phasea/store.go +++ b/internal/phasea/store.go @@ -3,6 +3,7 @@ package phasea import ( "context" "crypto/rand" + "crypto/sha256" "database/sql" _ "embed" "encoding/hex" @@ -50,33 +51,59 @@ type CredentialReference struct { } type Draft struct { - ID string `json:"id"` - AccountID string `json:"account_id"` - Version int64 `json:"version"` - Content string `json:"content"` + 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"` + 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"` + 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"` + CreatedAt time.Time `json:"created_at"` } +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 +} + +func (e *ReadinessError) Error() string { return e.Reason } + type Execution struct { TaskID string `json:"task_id"` AttemptID string `json:"attempt_id"` @@ -247,6 +274,162 @@ func (s *Store) CreateDraft(ctx context.Context, draft Draft) error { 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 { @@ -282,6 +465,222 @@ func (s *Store) Confirm(ctx context.Context, confirmation Confirmation) error { 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) { + if (accountID != "" && !idPattern.MatchString(accountID)) || (draftID != "" && !refPattern.MatchString(draftID)) { + 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, created_at + FROM operation_task WHERE ($1 = '' OR account_id = $1) AND ($2 = '' OR draft_id = $2) + ORDER BY created_at DESC, id`, accountID, draftID) + 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, created_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 sql.NullString + var confirmationVersion sql.NullInt64 + if err := row.Scan(&task.ID, &task.IdempotencyKey, &task.AccountID, &task.AccountVersion, &task.DraftID, + &task.DraftVersion, &confirmationID, &confirmationVersion, &task.State, &task.CreatedAt); err != nil { + return Task{}, rowError(err) + } + task.ConfirmationID, task.ConfirmationVersion = confirmationID.String, confirmationVersion.Int64 + return task, nil +} + +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, created_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 { + return Task{}, false, &ReadinessError{Reason: "network_exit_missing", Unavailable: true} + } + 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 || @@ -294,12 +693,23 @@ func (s *Store) Enqueue(ctx context.Context, task Task) (Task, bool, error) { 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, ` + 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`, task.ID, task.IdempotencyKey, task.AccountID, task.AccountVersion, - task.DraftID, task.DraftVersion, task.ConfirmationID, task.ConfirmationVersion).Scan(&insertedID) + 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) } @@ -308,31 +718,19 @@ func (s *Store) Enqueue(ctx context.Context, task Task) (Task, bool, error) { if err := appendAudit(ctx, tx, "task_queued", "task_queued", task.AccountID, task.ConfirmationID, task.ConfirmationVersion, "", task.ID, nil); err != nil { return Task{}, false, err } - if err := commit(tx); err != nil { - return Task{}, false, err - } return task, true, nil } - - var existing Task - var confirmationID sql.NullString - var confirmationVersion sql.NullInt64 - err = tx.QueryRowContext(ctx, ` - SELECT id, idempotency_key, account_id, account_version, draft_id, draft_version, confirmation_id, confirmation_version, state - FROM operation_task WHERE idempotency_key = $1`, task.IdempotencyKey).Scan(&existing.ID, &existing.IdempotencyKey, - &existing.AccountID, &existing.AccountVersion, &existing.DraftID, &existing.DraftVersion, &confirmationID, &confirmationVersion, &existing.State) + existing, err := scanTask(tx.QueryRowContext(ctx, ` + SELECT id, idempotency_key, account_id, account_version, draft_id, draft_version, + confirmation_id, confirmation_version, state, created_at + FROM operation_task WHERE idempotency_key = $1`, task.IdempotencyKey)) if err != nil { - return Task{}, false, rowError(err) + return Task{}, false, err } - existing.ConfirmationID = confirmationID.String - existing.ConfirmationVersion = confirmationVersion.Int64 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 } - if err := commit(tx); err != nil { - return Task{}, false, err - } return existing, false, nil } diff --git a/internal/phasea/store_test.go b/internal/phasea/store_test.go index 22c3014..a3ac050 100644 --- a/internal/phasea/store_test.go +++ b/internal/phasea/store_test.go @@ -51,6 +51,9 @@ 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) } @@ -145,6 +148,64 @@ func TestPhaseAOfflineWorkflow(t *testing.T) { 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 { @@ -508,10 +569,18 @@ func applyHubMigrationsForPhaseATest(t *testing.T, store *Store) { } } -func assertCount(t *testing.T, store *Store, query string, expected int) { +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 - if err := store.db.QueryRowContext(context.Background(), query).Scan(&actual); err != nil || actual != expected { + if err := store.db.QueryRowContext(context.Background(), query, args...).Scan(&actual); err != nil || actual != expected { t.Fatalf("count mismatch: expected=%d actual=%d err=%v query=%s", expected, actual, err, query) } } diff --git a/web/src/AccountList.jsx b/web/src/AccountList.jsx index 8939934..3d3854e 100644 --- a/web/src/AccountList.jsx +++ b/web/src/AccountList.jsx @@ -188,8 +188,11 @@ export function AccountDetail() { const dataProvider = useDataProvider() const [busy, setBusy] = useState(false) const [message, setMessage] = useState(null) + const [draftContent, setDraftContent] = useState('') + const [draftBusy, setDraftBusy] = useState(false) const { data: account, error, isPending, refetch } = useGetOne('accounts', { id }) const { data: browsers = [], error: browsersError, refetch: refetchBrowsers } = useGetList('browsers', undefined, { retry: false }) + const { data: drafts = [], error: draftsError, refetch: refetchDrafts } = useGetList('drafts', { filter: { account_id: id } }, { retry: false }) const binding = browsersError ? undefined : browsers.find(browser => browser.account_id === id) const readiness = account ? accountReadiness(account, binding, browsersError) : null @@ -207,6 +210,20 @@ export function AccountDetail() { } finally { setBusy(false) } } + async function createDraft(event) { + event.preventDefault() + if (!draftContent.trim() || !readiness?.ready) return + setDraftBusy(true); setMessage(null) + try { + const draft = await dataProvider.createDraft(account.id, draftContent) + await refetchDrafts() + setDraftContent('') + setMessage({ severity: 'success', text: `草稿版本 ${draft.version} 已创建,请进入只读快照核对。` }) + } catch (reason) { + setMessage({ severity: 'error', text: actionError(reason, '账号或草稿状态已变化;输入内容已保留。') }) + } finally { setDraftBusy(false) } + } + if (isPending) return if (error || !account) return {error?.message || '账号不存在'} return ( @@ -219,6 +236,17 @@ export function AccountDetail() { 账号状态授权:{account.authorization_status === 'authorized' ? '已授权' : '已撤销'}({account.authorization_kind})运行:{account.runtime_status === 'active' ? '启用' : '暂停'} · 版本 {account.version}凭据引用:{account.credential_reference?.id} · {account.credential_reference?.provider} 固定资源{browsersError ? 运行环境、固定出口与 readiness 状态未知;重试成功后再执行依赖资源状态的操作。 : binding ? <>运行环境:{binding.name}({binding.alias})固定出口:{binding.network_exit_id || '未绑定'} · {binding.network_exit_health || '未知状态'}绑定版本:{binding.binding_version}不可调度原因:{binding.schedule_block_reason ? (reasonLabels[binding.schedule_block_reason] || binding.schedule_block_reason) : '无'} : <>尚未绑定运行环境与固定出口,因此不能恢复或排队。} + + 文本草稿 + 仅支持单用户 Phase A Mock 文本;内部 ID 与版本由系统生成。 + + setDraftContent(event.target.value)} disabled={!readiness?.ready || draftBusy} helperText={readiness?.ready ? '创建后进入只读快照核对并显式确认' : `资源未就绪:${readiness?.label || '正在加载'}`} /> + + + {draftsError ? refetchDrafts()}>重试草稿} sx={{ mt: 2 }}>{draftsError.message} : null} + {!draftsError && drafts.length === 0 ? 尚无草稿。 : null} + {drafts.length > 0 ? {drafts.map(draft => 草稿版本 {draft.version}{draft.content})} : null} + ) } diff --git a/web/src/AccountList.test.jsx b/web/src/AccountList.test.jsx index b27c503..a9b7b21 100644 --- a/web/src/AccountList.test.jsx +++ b/web/src/AccountList.test.jsx @@ -82,4 +82,23 @@ describe('AccountDetail', () => { fireEvent.click(screen.getByRole('button', { name: '重试环境状态' })) await waitFor(() => expect(dataProvider.getList.mock.calls.filter(([resource]) => resource === 'browsers').length).toBeGreaterThan(1)) }) + + it('preserves draft text when creation returns 503', async () => { + const active = { ...account, runtime_status: 'active' } + const readyBinding = { ...binding, schedule_status: 'ready', schedule_block_reason: '' } + const dataProvider = provider({ + getOne: vi.fn().mockResolvedValue({ data: active }), + getList: vi.fn(resource => Promise.resolve(resource === 'browsers' ? { data: [readyBinding], total: 1 } : { data: [], total: 0 })), + createDraft: vi.fn().mockRejectedValue(new HttpError('unavailable', 503, { reason_code: 'runtime_missing' })), + }) + render(} />) + + const input = await screen.findByRole('textbox', { name: '草稿内容' }) + fireEvent.change(input, { target: { value: 'keep this text' } }) + fireEvent.click(screen.getByRole('button', { name: '创建草稿' })) + + await waitFor(() => expect(dataProvider.createDraft).toHaveBeenCalledWith('account-a', 'keep this text')) + expect(screen.getByRole('textbox', { name: '草稿内容' }).value).toBe('keep this text') + expect(await screen.findByText('环境不可用(503):unavailable')).toBeTruthy() + }) }) diff --git a/web/src/DraftDetail.jsx b/web/src/DraftDetail.jsx new file mode 100644 index 0000000..aa2afb5 --- /dev/null +++ b/web/src/DraftDetail.jsx @@ -0,0 +1,168 @@ +import { useEffect, useRef, useState } from 'react' +import { useDataProvider, useGetList, useGetOne } from 'ra-core' +import { Link as RouterLink, useParams } from 'react-router-dom' +import { + Alert, + Box, + Button, + Checkbox, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + FormControlLabel, + Paper, + Stack, + Typography, +} from '@mui/material' +import CheckCircleOutlined from '@mui/icons-material/CheckCircleOutlined' +import DescriptionOutlined from '@mui/icons-material/DescriptionOutlined' +import ReportProblemOutlined from '@mui/icons-material/ReportProblemOutlined' +import { accountReadiness } from './AccountList' + +const wrapAnywhere = { overflowWrap: 'anywhere', minWidth: 0 } + +const taskLabels = { + queued: '已排队', + executing: '执行中', + succeeded: '已成功', + failed: '失败', + needs_confirmation: '需要重新确认', + policy_hold: '策略暂停', + cancelled: '已取消', +} + +function requestMessage(error) { + const reason = error?.body?.reason_code + if (error?.status === 409) return `版本或账号状态冲突(409):${reason || error.message}` + if (error?.status === 503) return `资源未就绪(503):${reason || error.message}` + return error?.message || '操作失败' +} + +export function DraftDetail() { + const { id } = useParams() + const dataProvider = useDataProvider() + const [checked, setChecked] = useState(false) + const [dialogOpen, setDialogOpen] = useState(false) + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState(null) + const confirmTrigger = useRef(null) + const { data: draft, error, isPending, refetch } = useGetOne('drafts', { id }, { retry: false }) + const { data: browsers = [], error: browsersError, refetch: refetchBrowsers } = useGetList('browsers', undefined, { retry: false }) + + useEffect(() => { document.title = 'CreatorHub · 草稿核对' }, []) + + if (isPending) return + if (error || !draft) return {error?.message || '草稿不存在'} + + const versions = draft.versions || [] + const confirmations = draft.confirmations || [] + const tasks = draft.tasks || [] + const latestVersion = versions[0]?.version || draft.version + const snapshotCurrent = draft.version === latestVersion + const currentConfirmation = confirmations.find(confirmation => + confirmation.account_version === draft.account.version && confirmation.draft_version === draft.version) + const binding = browsersError ? undefined : browsers.find(browser => browser.account_id === draft.account_id) + const readiness = accountReadiness(draft.account, binding, Boolean(browsersError)) + const canConfirm = snapshotCurrent && Boolean(binding?.network_exit_id) && !browsersError + const canEnqueue = Boolean(currentConfirmation) && snapshotCurrent && readiness.ready + + function openDialog() { + confirmTrigger.current?.blur() + setDialogOpen(true) + } + + function closeDialog() { + setDialogOpen(false) + } + + async function confirm() { + setBusy(true); setMessage(null) + try { + await dataProvider.confirmDraft(draft.id, draft.account.version, draft.version) + await refetch() + closeDialog() + setMessage({ severity: 'success', text: '确认快照已保存,可回溯账号、草稿与当前固定资源版本。' }) + } catch (reason) { + closeDialog() + setMessage({ severity: 'error', text: requestMessage(reason) }) + await Promise.all([refetch(), refetchBrowsers()]) + } finally { setBusy(false) } + } + + async function enqueue() { + if (!currentConfirmation) return + setBusy(true); setMessage(null) + try { + const task = await dataProvider.enqueueConfirmation(currentConfirmation.id) + await refetch() + setMessage({ severity: 'success', text: `任务已排队:${task.id}。重复提交会返回同一任务。` }) + } catch (reason) { + setMessage({ severity: 'error', text: requestMessage(reason) }) + await Promise.all([refetch(), refetchBrowsers()]) + } finally { setBusy(false) } + } + + return ( + <> + + + 草稿核对 + {draft.account.platform_account_key} · 草稿版本 {draft.version} + + + {message ? {message.text} : null} + {browsersError ? refetchBrowsers()}>重试环境状态} sx={{ mb: 2.5 }}>环境不可用:当前运行环境与固定出口状态未知,不能确认或排队。 : null} + {!snapshotCurrent ? 打开最新版本} sx={{ mb: 2.5 }}>当前只读快照已不是最新草稿版本,请刷新到版本 {latestVersion} 后重新核对。 : null} + + + + 只读内容快照 + {draft.content} + + + + 版本与当前资源 + + 账号版本:{draft.account.version} + 草稿版本:{draft.version} + 运行环境:{browsersError ? '状态未知' : (binding?.name || '未绑定')} + 固定出口:{browsersError ? '状态未知' : (binding?.network_exit_id || '未绑定')} + {readiness.ready ? : }{readiness.label} + + + + 版本链 + {versions.map(version => version.id === draft.id + ? 版本 {version.version}(当前快照) + : )} + + + + + + 显式核对与入队 + setChecked(event.target.checked)} />} label="我已核对当前账号、草稿内容、运行环境和固定出口" /> + + + + + {!currentConfirmation ? 保存有效确认后才可加入队列。 : null} + {currentConfirmation ? 确认快照 v{currentConfirmation.version}账号版本 {currentConfirmation.account_version} · 草稿版本 {currentConfirmation.draft_version}环境:{currentConfirmation.browser_env_alias || '未记录'} · 出口:{currentConfirmation.network_exit_id || '未记录'} · 绑定版本:{currentConfirmation.binding_version || '未记录'} : null} + + + + 关联任务 + {tasks.length === 0 ? 尚未入队。 : {tasks.map(task => {task.id}{taskLabels[task.state] || `未知状态:${task.state}`})}} + + + busy ? undefined : closeDialog()} aria-labelledby="confirm-draft-title" slotProps={{ transition: { onExited: () => confirmTrigger.current?.focus() } }}> + 确认草稿版本 + 将保存账号版本 {draft.account.version}、草稿版本 {draft.version}、运行环境 {binding?.alias} 与固定出口 {binding?.network_exit_id} 的只读确认快照。版本变化后必须重新确认。 + + + + ) +} diff --git a/web/src/DraftDetail.test.jsx b/web/src/DraftDetail.test.jsx new file mode 100644 index 0000000..4c319d4 --- /dev/null +++ b/web/src/DraftDetail.test.jsx @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { CoreAdminContext } from 'ra-core' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { DraftDetail } from './DraftDetail' + +afterEach(() => { cleanup(); vi.restoreAllMocks() }) + +const account = { id: 'account-a', platform: 'mock', platform_account_key: 'shop-a', authorization_status: 'authorized', runtime_status: 'active', version: 2 } +const draft = { + id: 'draft-a', account_id: 'account-a', version: 1, content: 'checked content', account, + versions: [{ id: 'draft-a', account_id: 'account-a', version: 1, content: 'checked content' }], + confirmations: [], tasks: [], +} +const binding = { id: 'env-a', alias: 'env-a', name: '店铺环境', account_id: 'account-a', network_exit_id: 'exit-a', network_exit_health: 'healthy', binding_version: 4, schedule_status: 'ready', schedule_block_reason: '' } + +function provider(record = draft, overrides = {}) { + return { + getOne: vi.fn().mockResolvedValue({ data: record }), + getList: vi.fn().mockResolvedValue({ data: [binding], total: 1 }), + confirmDraft: vi.fn().mockResolvedValue({ id: 'confirmation-a' }), + enqueueConfirmation: vi.fn().mockResolvedValue({ id: 'task-a', state: 'queued' }), + getMany: vi.fn(), getManyReference: vi.fn(), create: vi.fn(), update: vi.fn(), updateMany: vi.fn(), delete: vi.fn(), deleteMany: vi.fn(), + ...overrides, + } +} + +function renderDraft(dataProvider) { + return render(} />) +} + +describe('DraftDetail', () => { + it('opens a keyboard-accessible confirmation dialog with the reviewed versions', async () => { + const dataProvider = provider() + renderDraft(dataProvider) + + fireEvent.click(await screen.findByRole('checkbox', { name: /我已核对当前账号/ })) + fireEvent.click(screen.getByRole('button', { name: '确认当前快照' })) + + const dialog = screen.getByRole('dialog', { name: '确认草稿版本' }) + expect(dialog.textContent).toContain('账号版本 2、草稿版本 1') + await waitFor(() => expect(screen.getByRole('button', { name: '返回核对' })).toBe(document.activeElement)) + fireEvent.click(screen.getByRole('button', { name: '保存确认' })) + await waitFor(() => expect(dataProvider.confirmDraft).toHaveBeenCalledWith('draft-a', 2, 1)) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + expect(screen.getByRole('button', { name: '确认当前快照' })).toBe(document.activeElement) + }) + + it('submits only once while a repeated enqueue click is in flight', async () => { + let finish + const pending = new Promise(resolve => { finish = resolve }) + const confirmation = { id: 'confirmation-a', account_version: 2, draft_version: 1, version: 1, browser_env_alias: 'env-a', network_exit_id: 'exit-a', binding_version: 4 } + const dataProvider = provider({ ...draft, confirmations: [confirmation] }, { enqueueConfirmation: vi.fn().mockReturnValue(pending) }) + renderDraft(dataProvider) + + const button = await screen.findByRole('button', { name: '加入队列' }) + fireEvent.click(button) + fireEvent.click(button) + expect(dataProvider.enqueueConfirmation).toHaveBeenCalledTimes(1) + finish({ id: 'task-a', state: 'queued' }) + await screen.findByText(/重复提交会返回同一任务/) + }) + + it('disables confirmation for a stale snapshot and links to the latest version', async () => { + const stale = { ...draft, versions: [{ id: 'draft-b', version: 2, content: 'latest' }, ...draft.versions] } + renderDraft(provider(stale)) + + expect(await screen.findByText(/当前只读快照已不是最新草稿版本/)).toBeTruthy() + expect(screen.getByRole('button', { name: '确认当前快照' }).disabled).toBe(true) + expect(screen.getByRole('link', { name: '打开最新版本' }).getAttribute('href')).toBe('/drafts/draft-b') + }) +}) diff --git a/web/src/dataProvider.js b/web/src/dataProvider.js index 63d743e..9656bf6 100644 --- a/web/src/dataProvider.js +++ b/web/src/dataProvider.js @@ -22,14 +22,19 @@ const resourcePaths = { 'browser-images': '/browser-images', gateways: '/gateways', accounts: '/phase-a/accounts', + drafts: '/phase-a/drafts', + confirmations: '/phase-a/confirmations', + tasks: '/phase-a/tasks', 'network-exits': '/network-exits', } export const dataProvider = { - async getList(resource) { + async getList(resource, params = {}) { const path = resourcePaths[resource] if (!path) return unsupported(resource, 'getList') - const records = await request(path) + const filterKeys = { drafts: ['account_id'], confirmations: ['draft_id'], tasks: ['account_id', 'draft_id'] }[resource] || [] + const query = new URLSearchParams(filterKeys.flatMap(key => params.filter?.[key] ? [[key, params.filter[key]]] : [])) + const records = await request(`${path}${query.size ? `?${query}` : ''}`) return { data: records.map(record => ({ ...record, id: record.id ?? record.alias ?? record.version ?? record.name })), total: records.length } }, async create(resource, { data }) { @@ -54,7 +59,7 @@ export const dataProvider = { }, async getOne(resource, { id }) { const path = resourcePaths[resource] - if (!path || (resource !== 'accounts' && resource !== 'network-exits')) return unsupported(resource, 'getOne') + if (!path || !['accounts', 'network-exits', 'drafts', 'confirmations', 'tasks'].includes(resource)) return unsupported(resource, 'getOne') const record = await request(`${path}/${encodeURIComponent(id)}`) return { data: { ...record, id: record.id ?? id } } }, @@ -81,6 +86,19 @@ export const dataProvider = { if (action !== 'pause' && action !== 'resume') throw new Error(`未知账号操作: ${action}`) await request(`/phase-a/accounts/${encodeURIComponent(id)}/${action}`, { method: 'POST' }) }, + createDraft(accountID, content) { + return request('/phase-a/drafts', jsonOptions('POST', { account_id: accountID, content })) + }, + confirmDraft(draftID, accountVersion, draftVersion) { + return request('/phase-a/confirmations', jsonOptions('POST', { + draft_id: draftID, + account_version: accountVersion, + draft_version: draftVersion, + })) + }, + enqueueConfirmation(confirmationID) { + return request('/phase-a/tasks', jsonOptions('POST', { confirmation_id: confirmationID })) + }, async networkExitAction(id, action) { if (action !== 'check' && action !== 'disable') throw new Error(`未知网络出口操作: ${action}`) return request(`/network-exits/${encodeURIComponent(id)}/${action}`, { method: 'POST' }) diff --git a/web/src/dataProvider.test.js b/web/src/dataProvider.test.js index ef31b55..f2b518a 100644 --- a/web/src/dataProvider.test.js +++ b/web/src/dataProvider.test.js @@ -29,6 +29,9 @@ describe('dataProvider', () => { it.each([ ['accounts', 'account-a', '/api/phase-a/accounts/account-a'], ['network-exits', 'exit/one', '/api/network-exits/exit%2Fone'], + ['drafts', 'draft/one', '/api/phase-a/drafts/draft%2Fone'], + ['confirmations', 'confirmation/one', '/api/phase-a/confirmations/confirmation%2Fone'], + ['tasks', 'task/one', '/api/phase-a/tasks/task%2Fone'], ])('loads %s detail through its stable API path', async (resource, id, path) => { const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ id }), { status: 200 })) vi.stubGlobal('fetch', fetch) @@ -46,6 +49,29 @@ describe('dataProvider', () => { expect(fetch).toHaveBeenCalledWith('/api/phase-a/accounts', expect.objectContaining({ method: 'POST' })) }) + it('filters drafts by account through the server list contract', async () => { + const fetch = vi.fn().mockResolvedValue(new Response('[]', { status: 200 })) + vi.stubGlobal('fetch', fetch) + + await dataProvider.getList('drafts', { filter: { account_id: 'account-a' } }) + expect(fetch).toHaveBeenCalledWith('/api/phase-a/drafts?account_id=account-a', undefined) + }) + + it('keeps generated draft, confirmation and enqueue identifiers out of user input', async () => { + const fetch = vi.fn().mockImplementation(() => Promise.resolve(new Response('{"id":"generated"}', { status: 201 }))) + vi.stubGlobal('fetch', fetch) + + await dataProvider.createDraft('account-a', 'hello') + await dataProvider.confirmDraft('draft-a', 2, 3) + await dataProvider.enqueueConfirmation('confirmation-a') + + expect(fetch.mock.calls.map(([path, options]) => [path, JSON.parse(options.body)])).toEqual([ + ['/api/phase-a/drafts', { account_id: 'account-a', content: 'hello' }], + ['/api/phase-a/confirmations', { draft_id: 'draft-a', account_version: 2, draft_version: 3 }], + ['/api/phase-a/tasks', { confirmation_id: 'confirmation-a' }], + ]) + }) + it.each([ ['start', '/api/browsers/account-a/start', 'POST'], ['stop', '/api/browsers/account-a/stop', 'POST'], diff --git a/web/src/main.jsx b/web/src/main.jsx index 281a897..6234aac 100644 --- a/web/src/main.jsx +++ b/web/src/main.jsx @@ -8,6 +8,7 @@ import { AccountDetail, AccountList } from './AccountList' import { BrowserImageList } from './BrowserImageList' import { BrowserList } from './BrowserList' import { dataProvider } from './dataProvider' +import { DraftDetail } from './DraftDetail' import { GatewayList } from './GatewayList' import { CreatorHubLayout } from './layout' import { NetworkExitList } from './NetworkExitList' @@ -24,7 +25,10 @@ createRoot(document.getElementById('root')).render( - } /> + + } /> + } /> + , diff --git a/web/tests/responsive.e2e.js b/web/tests/responsive.e2e.js index e1b5839..269ac76 100644 --- a/web/tests/responsive.e2e.js +++ b/web/tests/responsive.e2e.js @@ -57,6 +57,7 @@ test('keeps account and network-exit pages inside 599px, 900px and 1280px', asyn const networkExit = { id: 'exit-a', protocol: 'socks5', host: hostname, port: 1080, health_status: 'healthy', credential_reference: { id: credentialID, provider: 'os_keyring' } } await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [account] })) await page.route('**/api/phase-a/accounts/account-a', route => route.fulfill({ json: account })) + await page.route('**/api/phase-a/drafts?account_id=account-a', route => route.fulfill({ json: [] })) await page.route('**/api/browsers', route => route.fulfill({ json: [binding] })) await page.route('**/api/network-exits', route => route.fulfill({ json: [networkExit] })) @@ -75,6 +76,7 @@ test('opens account detail at the phase A route', async ({ page }) => { await page.route('**/api/phase-a/accounts', route => route.fulfill({ json: [account] })) await page.route('**/api/phase-a/accounts/account-a', route => route.fulfill({ json: account })) await page.route('**/api/browsers', route => route.fulfill({ json: [] })) + await page.route('**/api/phase-a/drafts?account_id=account-a', route => route.fulfill({ json: [] })) await page.setViewportSize({ width: 599, height: 900 }) await page.goto('/#/accounts') @@ -83,3 +85,37 @@ test('opens account detail at the phase A route', async ({ page }) => { await expect(page.getByRole('heading', { level: 1, name: 'shop-a' })).toBeVisible() expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(599) }) + +test('keeps draft review responsive and restores focus after dialog close and successful save', async ({ page }) => { + const account = { id: 'account-a', platform: 'mock', platform_account_key: 'shop-a', authorization_status: 'authorized', runtime_status: 'active', version: 2 } + const draft = { + id: 'draft-a', account_id: 'account-a', version: 1, content: 'x'.repeat(1000), account, + versions: [{ id: 'draft-a', account_id: 'account-a', version: 1, content: 'x'.repeat(1000) }], + confirmations: [], tasks: [], + } + const binding = { alias: 'env-a', name: '店铺环境', account_id: 'account-a', network_exit_id: 'exit-a', network_exit_health: 'healthy', binding_version: 4, schedule_status: 'ready', schedule_block_reason: '' } + await page.route('**/api/phase-a/drafts/draft-a', route => route.fulfill({ json: draft })) + await page.route('**/api/browsers', route => route.fulfill({ json: [binding] })) + await page.route('**/api/phase-a/confirmations', route => route.fulfill({ json: { id: 'confirmation-a' } })) + + for (const width of [599, 900, 1280]) { + await page.setViewportSize({ width, height: 900 }) + await page.goto('/#/drafts/draft-a') + await expect(page.getByRole('heading', { level: 1, name: '草稿核对' })).toBeVisible() + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width) + } + + await page.setViewportSize({ width: 599, height: 900 }) + await page.getByRole('checkbox', { name: /我已核对当前账号/ }).check() + const trigger = page.getByRole('button', { name: '确认当前快照' }) + await trigger.click() + await expect(page.getByRole('dialog', { name: '确认草稿版本' })).toBeVisible() + await page.keyboard.press('Escape') + await expect(page.getByRole('dialog')).toBeHidden() + await expect(trigger).toBeFocused() + + await trigger.click() + await page.getByRole('button', { name: '保存确认' }).click() + await expect(page.getByRole('dialog')).toBeHidden() + await expect(trigger).toBeFocused() +})