HH-847 / HH-799: add draft review and idempotent enqueue (#24)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
+437
-39
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <Box sx={{ display: 'grid', placeItems: 'center', minHeight: 300 }}><CircularProgress aria-label="正在加载账号详情" /></Box>
|
||||
if (error || !account) return <Alert severity="error">{error?.message || '账号不存在'}</Alert>
|
||||
return (
|
||||
@@ -219,6 +236,17 @@ export function AccountDetail() {
|
||||
<Paper variant="outlined" sx={{ p: 3, minWidth: 0 }}><Stack spacing={2} sx={{ minWidth: 0 }}><Typography variant="h6">账号状态</Typography><AccountState account={account} binding={binding} bindingError={browsersError} /><Typography>授权:{account.authorization_status === 'authorized' ? '已授权' : '已撤销'}({account.authorization_kind})</Typography><Typography>运行:{account.runtime_status === 'active' ? '启用' : '暂停'} · 版本 {account.version}</Typography><Typography sx={wrapAnywhere}>凭据引用:{account.credential_reference?.id} · {account.credential_reference?.provider}</Typography><Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}><Button variant="outlined" color="warning" disabled={busy || account.runtime_status === 'paused'} onClick={() => act('pause')}>暂停账号</Button><Button variant="contained" disabled={busy || !readiness.canResume} onClick={() => act('resume')}>恢复账号</Button></Stack></Stack></Paper>
|
||||
<Paper variant="outlined" sx={{ p: 3, minWidth: 0 }}><Stack spacing={2} sx={{ minWidth: 0 }}><Typography variant="h6">固定资源</Typography>{browsersError ? <Typography color="text.secondary">运行环境、固定出口与 readiness 状态未知;重试成功后再执行依赖资源状态的操作。</Typography> : binding ? <><Typography sx={wrapAnywhere}>运行环境:{binding.name}({binding.alias})</Typography><Typography sx={wrapAnywhere}>固定出口:{binding.network_exit_id || '未绑定'} · {binding.network_exit_health || '未知状态'}</Typography><Typography>绑定版本:{binding.binding_version}</Typography><Typography sx={wrapAnywhere}>不可调度原因:{binding.schedule_block_reason ? (reasonLabels[binding.schedule_block_reason] || binding.schedule_block_reason) : '无'}</Typography><Button component={RouterLink} to="/browsers" variant="outlined">查看运行环境</Button></> : <><Typography color="text.secondary">尚未绑定运行环境与固定出口,因此不能恢复或排队。</Typography><Button component={RouterLink} to="/browsers" variant="contained">绑定运行环境与出口</Button></>}</Stack></Paper>
|
||||
</Box>
|
||||
<Paper component="section" variant="outlined" sx={{ p: { xs: 2.5, md: 3 }, mt: 2.5, minWidth: 0 }}>
|
||||
<Typography variant="h6">文本草稿</Typography>
|
||||
<Typography color="text.secondary" sx={{ mt: 0.5, mb: 2 }}>仅支持单用户 Phase A Mock 文本;内部 ID 与版本由系统生成。</Typography>
|
||||
<Box component="form" onSubmit={createDraft} sx={{ display: 'grid', gap: 1.5 }}>
|
||||
<TextField multiline minRows={4} label="草稿内容" value={draftContent} onChange={event => setDraftContent(event.target.value)} disabled={!readiness?.ready || draftBusy} helperText={readiness?.ready ? '创建后进入只读快照核对并显式确认' : `资源未就绪:${readiness?.label || '正在加载'}`} />
|
||||
<Button type="submit" variant="contained" disabled={!readiness?.ready || !draftContent.trim() || draftBusy} sx={{ justifySelf: { sm: 'start' }, minHeight: 44 }}>{draftBusy ? '创建中…' : '创建草稿'}</Button>
|
||||
</Box>
|
||||
{draftsError ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => refetchDrafts()}>重试草稿</Button>} sx={{ mt: 2 }}>{draftsError.message}</Alert> : null}
|
||||
{!draftsError && drafts.length === 0 ? <Typography color="text.secondary" sx={{ mt: 2.5 }}>尚无草稿。</Typography> : null}
|
||||
{drafts.length > 0 ? <Stack spacing={1.5} sx={{ mt: 2.5 }}>{drafts.map(draft => <Paper key={draft.id} variant="outlined" sx={{ p: 2, minWidth: 0 }}><Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} sx={{ justifyContent: 'space-between', alignItems: { sm: 'center' }, minWidth: 0 }}><Box sx={{ minWidth: 0 }}><Typography fontWeight={700}>草稿版本 {draft.version}</Typography><Typography variant="body2" color="text.secondary" sx={{ ...wrapAnywhere, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{draft.content}</Typography></Box><Button component={RouterLink} to={`/drafts/${draft.id}`} variant="outlined">核对草稿</Button></Stack></Paper>)}</Stack> : null}
|
||||
</Paper>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(<MemoryRouter initialEntries={['/accounts/account-a']}><CoreAdminContext dataProvider={dataProvider}><Routes><Route path="/accounts/:id" element={<AccountDetail />} /></Routes></CoreAdminContext></MemoryRouter>)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 <Box sx={{ display: 'grid', placeItems: 'center', minHeight: 300 }}><CircularProgress aria-label="正在加载草稿" /></Box>
|
||||
if (error || !draft) return <Alert severity="error">{error?.message || '草稿不存在'}</Alert>
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Box component="header" sx={{ mb: 4, minWidth: 0 }}>
|
||||
<Button component={RouterLink} to={`/accounts/${draft.account_id}`} sx={{ mb: 1 }}>← 返回账号详情</Button>
|
||||
<Stack direction="row" spacing={1.5} sx={{ alignItems: 'center' }}><DescriptionOutlined color="primary" fontSize="large" /><Typography variant="h1">草稿核对</Typography></Stack>
|
||||
<Typography color="text.secondary" sx={{ ...wrapAnywhere, mt: 1 }}>{draft.account.platform_account_key} · 草稿版本 {draft.version}</Typography>
|
||||
</Box>
|
||||
|
||||
{message ? <Alert severity={message.severity} aria-live="polite" sx={{ mb: 2.5 }}>{message.text}</Alert> : null}
|
||||
{browsersError ? <Alert severity="error" action={<Button color="inherit" size="small" onClick={() => refetchBrowsers()}>重试环境状态</Button>} sx={{ mb: 2.5 }}>环境不可用:当前运行环境与固定出口状态未知,不能确认或排队。</Alert> : null}
|
||||
{!snapshotCurrent ? <Alert severity="warning" action={<Button component={RouterLink} to={`/drafts/${versions[0]?.id}`}>打开最新版本</Button>} sx={{ mb: 2.5 }}>当前只读快照已不是最新草稿版本,请刷新到版本 {latestVersion} 后重新核对。</Alert> : null}
|
||||
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'minmax(0, 1fr)', lg: 'minmax(0, 1.5fr) minmax(0, 1fr)' }, gap: 2.5 }}>
|
||||
<Paper component="section" variant="outlined" sx={{ p: { xs: 2.5, md: 3 }, minWidth: 0 }}>
|
||||
<Typography variant="h6">只读内容快照</Typography>
|
||||
<Typography component="p" sx={{ ...wrapAnywhere, whiteSpace: 'pre-wrap', mt: 2, mb: 0 }}>{draft.content}</Typography>
|
||||
</Paper>
|
||||
<Stack spacing={2.5} sx={{ minWidth: 0 }}>
|
||||
<Paper component="section" variant="outlined" sx={{ p: 3, minWidth: 0 }}>
|
||||
<Typography variant="h6">版本与当前资源</Typography>
|
||||
<Stack spacing={1.25} sx={{ mt: 2, minWidth: 0 }}>
|
||||
<Typography>账号版本:{draft.account.version}</Typography>
|
||||
<Typography>草稿版本:{draft.version}</Typography>
|
||||
<Typography sx={wrapAnywhere}>运行环境:{browsersError ? '状态未知' : (binding?.name || '未绑定')}</Typography>
|
||||
<Typography sx={wrapAnywhere}>固定出口:{browsersError ? '状态未知' : (binding?.network_exit_id || '未绑定')}</Typography>
|
||||
<Stack direction="row" spacing={1} sx={{ alignItems: 'center', color: readiness.ready ? 'success.main' : 'warning.main' }}>{readiness.ready ? <CheckCircleOutlined /> : <ReportProblemOutlined />}<Typography fontWeight={650}>{readiness.label}</Typography></Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
<Paper component="section" variant="outlined" sx={{ p: 3, minWidth: 0 }}>
|
||||
<Typography variant="h6">版本链</Typography>
|
||||
<Stack spacing={1} sx={{ mt: 1.5 }}>{versions.map(version => version.id === draft.id
|
||||
? <Typography key={version.id} fontWeight={700}>版本 {version.version}(当前快照)</Typography>
|
||||
: <Button key={version.id} component={RouterLink} to={`/drafts/${version.id}`} variant="text" sx={{ justifyContent: 'flex-start' }}>查看版本 {version.version}</Button>)}</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Paper component="section" variant="outlined" sx={{ p: { xs: 2.5, md: 3 }, mt: 2.5, minWidth: 0 }}>
|
||||
<Typography variant="h6">显式核对与入队</Typography>
|
||||
<FormControlLabel sx={{ mt: 1.5, alignItems: 'flex-start' }} control={<Checkbox checked={checked} onChange={event => setChecked(event.target.checked)} />} label="我已核对当前账号、草稿内容、运行环境和固定出口" />
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5} sx={{ mt: 2 }}>
|
||||
<Button ref={confirmTrigger} variant="outlined" disabled={!checked || !canConfirm || busy} onClick={openDialog}>确认当前快照</Button>
|
||||
<Button variant="contained" disabled={!canEnqueue || busy} onClick={enqueue}>{busy ? '处理中…' : '加入队列'}</Button>
|
||||
</Stack>
|
||||
{!currentConfirmation ? <Typography color="text.secondary" sx={{ mt: 1.5 }}>保存有效确认后才可加入队列。</Typography> : null}
|
||||
{currentConfirmation ? <Paper variant="outlined" sx={{ p: 2, mt: 2.5, minWidth: 0 }}><Typography fontWeight={700}>确认快照 v{currentConfirmation.version}</Typography><Typography variant="body2">账号版本 {currentConfirmation.account_version} · 草稿版本 {currentConfirmation.draft_version}</Typography><Typography variant="body2" sx={wrapAnywhere}>环境:{currentConfirmation.browser_env_alias || '未记录'} · 出口:{currentConfirmation.network_exit_id || '未记录'} · 绑定版本:{currentConfirmation.binding_version || '未记录'}</Typography></Paper> : null}
|
||||
</Paper>
|
||||
|
||||
<Paper component="section" variant="outlined" sx={{ p: { xs: 2.5, md: 3 }, mt: 2.5, minWidth: 0 }}>
|
||||
<Typography variant="h6">关联任务</Typography>
|
||||
{tasks.length === 0 ? <Typography color="text.secondary" sx={{ mt: 1.5 }}>尚未入队。</Typography> : <Stack spacing={1.25} sx={{ mt: 1.5 }}>{tasks.map(task => <Paper key={task.id} variant="outlined" sx={{ p: 2, minWidth: 0 }}><Typography fontWeight={700} sx={wrapAnywhere}>{task.id}</Typography><Typography variant="body2">{taskLabels[task.state] || `未知状态:${task.state}`}</Typography></Paper>)}</Stack>}
|
||||
</Paper>
|
||||
|
||||
<Dialog open={dialogOpen} disableRestoreFocus onClose={() => busy ? undefined : closeDialog()} aria-labelledby="confirm-draft-title" slotProps={{ transition: { onExited: () => confirmTrigger.current?.focus() } }}>
|
||||
<DialogTitle id="confirm-draft-title">确认草稿版本</DialogTitle>
|
||||
<DialogContent><DialogContentText>将保存账号版本 {draft.account.version}、草稿版本 {draft.version}、运行环境 {binding?.alias} 与固定出口 {binding?.network_exit_id} 的只读确认快照。版本变化后必须重新确认。</DialogContentText></DialogContent>
|
||||
<DialogActions><Button autoFocus disabled={busy} onClick={closeDialog}>返回核对</Button><Button variant="contained" disabled={busy} onClick={confirm}>{busy ? '确认中…' : '保存确认'}</Button></DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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(<MemoryRouter initialEntries={['/drafts/draft-a']}><CoreAdminContext dataProvider={dataProvider}><Routes><Route path="/drafts/:id" element={<DraftDetail />} /></Routes></CoreAdminContext></MemoryRouter>)
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
+21
-3
@@ -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' })
|
||||
|
||||
@@ -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'],
|
||||
|
||||
+5
-1
@@ -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(
|
||||
<Resource name="browsers" list={BrowserList} options={{ label: '运行环境' }} />
|
||||
<Resource name="browser-images" list={BrowserImageList} options={{ label: '镜像版本' }} />
|
||||
<Resource name="gateways" list={GatewayList} options={{ label: '网关管理' }} />
|
||||
<CustomRoutes><Route path="/accounts/:id" element={<AccountDetail />} /></CustomRoutes>
|
||||
<CustomRoutes>
|
||||
<Route path="/accounts/:id" element={<AccountDetail />} />
|
||||
<Route path="/drafts/:id" element={<DraftDetail />} />
|
||||
</CustomRoutes>
|
||||
</CoreAdmin>
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user