From e04f05b20bec6b567d544ebecd7c88a77517487d Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 4 Aug 2026 17:01:35 +0800 Subject: [PATCH] feat(admin): migrate core schema to single-pass model --- admin/internal/domain/authorization.go | 68 ++- admin/internal/domain/authorization_test.go | 20 +- admin/internal/domain/purchase_attempt.go | 74 +++ .../internal/domain/purchase_attempt_test.go | 34 ++ admin/internal/domain/spec_trial.go | 16 - admin/internal/domain/submission.go | 22 +- admin/internal/domain/submission_test.go | 1 + admin/internal/domain/task.go | 36 +- admin/internal/domain/task_test.go | 26 +- admin/internal/migrations/migrations_test.go | 440 +++++++++++------- admin/migrations/00002_single_pass_model.sql | 333 +++++++++++++ docs/tasks/T-209.md | 2 +- 12 files changed, 798 insertions(+), 274 deletions(-) create mode 100644 admin/internal/domain/purchase_attempt.go create mode 100644 admin/internal/domain/purchase_attempt_test.go delete mode 100644 admin/internal/domain/spec_trial.go create mode 100644 admin/migrations/00002_single_pass_model.sql diff --git a/admin/internal/domain/authorization.go b/admin/internal/domain/authorization.go index 90d9754..43b55bb 100644 --- a/admin/internal/domain/authorization.go +++ b/admin/internal/domain/authorization.go @@ -10,32 +10,28 @@ var ErrInvalidAuthorizationTransition = errors.New("invalid authorization status type AuthorizationStatus string const ( - AuthorizationStatusPendingDelivery AuthorizationStatus = "PENDING_DELIVERY" - AuthorizationStatusDelivered AuthorizationStatus = "DELIVERED" - AuthorizationStatusAcknowledged AuthorizationStatus = "ACKNOWLEDGED" - AuthorizationStatusExecuting AuthorizationStatus = "EXECUTING" - AuthorizationStatusFenced AuthorizationStatus = "FENCED" - AuthorizationStatusConsumed AuthorizationStatus = "CONSUMED" - AuthorizationStatusSuperseded AuthorizationStatus = "SUPERSEDED" - AuthorizationStatusExpired AuthorizationStatus = "EXPIRED" + AuthorizationStatusActive AuthorizationStatus = "ACTIVE" + AuthorizationStatusClaimed AuthorizationStatus = "CLAIMED" + AuthorizationStatusFenced AuthorizationStatus = "FENCED" + AuthorizationStatusConsumed AuthorizationStatus = "CONSUMED" + AuthorizationStatusExpired AuthorizationStatus = "EXPIRED" + AuthorizationStatusAbandoned AuthorizationStatus = "ABANDONED" ) type OrderAuthorization struct { - ID string - TaskID string - SpecTrialID string - Version int - GoodsID string - SKUColor string - SKUSize string - Quantity int - AuthorizedUnitPrice string - TotalPriceCap string - Note *string - Status AuthorizationStatus - CreatedBy string - CreatedAt time.Time - ExpiresAt time.Time + ID string + TaskID string + TaskVersion int + StartKey string + GoodsID string + SKUColor string + SKUSize string + Quantity int + TotalPriceCap string + Status AuthorizationStatus + CreatedBy string + CreatedAt time.Time + ExpiresAt time.Time } // CanTransitionTo 围栏后的授权只能消费,不能回到可领取或可过期状态,以防重复采购。 @@ -54,25 +50,15 @@ func TransitionAuthorization(current, next AuthorizationStatus) (AuthorizationSt } var authorizationTransitions = map[AuthorizationStatus]map[AuthorizationStatus]struct{}{ - AuthorizationStatusPendingDelivery: { - AuthorizationStatusDelivered: {}, - AuthorizationStatusSuperseded: {}, - AuthorizationStatusExpired: {}, + AuthorizationStatusActive: { + AuthorizationStatusClaimed: {}, + AuthorizationStatusExpired: {}, + AuthorizationStatusAbandoned: {}, }, - AuthorizationStatusDelivered: { - AuthorizationStatusAcknowledged: {}, - AuthorizationStatusSuperseded: {}, - AuthorizationStatusExpired: {}, - }, - AuthorizationStatusAcknowledged: { - AuthorizationStatusExecuting: {}, - AuthorizationStatusSuperseded: {}, - AuthorizationStatusExpired: {}, - }, - AuthorizationStatusExecuting: { - AuthorizationStatusFenced: {}, - AuthorizationStatusSuperseded: {}, - AuthorizationStatusExpired: {}, + AuthorizationStatusClaimed: { + AuthorizationStatusFenced: {}, + AuthorizationStatusExpired: {}, + AuthorizationStatusAbandoned: {}, }, AuthorizationStatusFenced: { AuthorizationStatusConsumed: {}, diff --git a/admin/internal/domain/authorization_test.go b/admin/internal/domain/authorization_test.go index 3a8ca25..fa8976a 100644 --- a/admin/internal/domain/authorization_test.go +++ b/admin/internal/domain/authorization_test.go @@ -14,19 +14,17 @@ func TestAuthorizationTransitions(t *testing.T) { next domain.AuthorizationStatus allowed bool }{ - {"deliver", domain.AuthorizationStatusPendingDelivery, domain.AuthorizationStatusDelivered, true}, - {"acknowledge", domain.AuthorizationStatusDelivered, domain.AuthorizationStatusAcknowledged, true}, - {"execute", domain.AuthorizationStatusAcknowledged, domain.AuthorizationStatusExecuting, true}, - {"fence", domain.AuthorizationStatusExecuting, domain.AuthorizationStatusFenced, true}, + {"claim", domain.AuthorizationStatusActive, domain.AuthorizationStatusClaimed, true}, + {"fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusFenced, true}, {"consume fenced authorization", domain.AuthorizationStatusFenced, domain.AuthorizationStatusConsumed, true}, - {"expire pending delivery", domain.AuthorizationStatusPendingDelivery, domain.AuthorizationStatusExpired, true}, - {"supersede pending delivery", domain.AuthorizationStatusPendingDelivery, domain.AuthorizationStatusSuperseded, true}, - {"expire before fence", domain.AuthorizationStatusExecuting, domain.AuthorizationStatusExpired, true}, - {"supersede before fence", domain.AuthorizationStatusDelivered, domain.AuthorizationStatusSuperseded, true}, + {"expire active", domain.AuthorizationStatusActive, domain.AuthorizationStatusExpired, true}, + {"abandon active", domain.AuthorizationStatusActive, domain.AuthorizationStatusAbandoned, true}, + {"expire claimed before fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusExpired, true}, + {"abandon claimed before fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusAbandoned, true}, {"fenced authorization cannot expire", domain.AuthorizationStatusFenced, domain.AuthorizationStatusExpired, false}, - {"fenced authorization cannot be superseded", domain.AuthorizationStatusFenced, domain.AuthorizationStatusSuperseded, false}, - {"fenced authorization cannot be delivered again", domain.AuthorizationStatusFenced, domain.AuthorizationStatusDelivered, false}, - {"consumed authorization cannot restart", domain.AuthorizationStatusConsumed, domain.AuthorizationStatusDelivered, false}, + {"fenced authorization cannot be abandoned", domain.AuthorizationStatusFenced, domain.AuthorizationStatusAbandoned, false}, + {"fenced authorization cannot be claimed again", domain.AuthorizationStatusFenced, domain.AuthorizationStatusClaimed, false}, + {"consumed authorization cannot restart", domain.AuthorizationStatusConsumed, domain.AuthorizationStatusClaimed, false}, } for _, test := range tests { diff --git a/admin/internal/domain/purchase_attempt.go b/admin/internal/domain/purchase_attempt.go new file mode 100644 index 0000000..2a5af55 --- /dev/null +++ b/admin/internal/domain/purchase_attempt.go @@ -0,0 +1,74 @@ +package domain + +import ( + "errors" + "time" +) + +var ErrInvalidAttemptTransition = errors.New("invalid purchase attempt status transition") + +// AttemptStatus 只描述单趟领取的可恢复执行。真实提交结果独立由唯一围栏记录调和。 +type AttemptStatus string + +const ( + AttemptStatusClaimed AttemptStatus = "CLAIMED" + AttemptStatusOrdering AttemptStatus = "ORDERING" + AttemptStatusFailed AttemptStatus = "FAILED" + AttemptStatusFenced AttemptStatus = "FENCED" + AttemptStatusAbandoned AttemptStatus = "ABANDONED" +) + +// AttemptFailureCode 是服务端可审计的固定失败摘要,不能承载页面正文或其他自由文本。 +type AttemptFailureCode string + +const ( + AttemptFailureAuthorizationExpired AttemptFailureCode = "AUTHORIZATION_EXPIRED" + AttemptFailureLeaseLost AttemptFailureCode = "LEASE_LOST" + AttemptFailureGate1Rejected AttemptFailureCode = "GATE_1_REJECTED" + AttemptFailureQuantityMismatch AttemptFailureCode = "QUANTITY_MISMATCH" + AttemptFailureGate2Rejected AttemptFailureCode = "GATE_2_REJECTED" + AttemptFailureGate3Rejected AttemptFailureCode = "GATE_3_REJECTED" + AttemptFailureFenceRejected AttemptFailureCode = "FENCE_REJECTED" + AttemptFailureSafeAborted AttemptFailureCode = "SAFE_ABORTED" +) + +type PurchaseAttempt struct { + ID string + TaskID string + AuthorizationID string + ClaimGeneration int + Status AttemptStatus + Gate1UnitPrice *string + Gate2UnitPrice *string + QuantityRead *int + ConfirmAmount *string + FailureCode *AttemptFailureCode + StartedAt time.Time + FinishedAt *time.Time +} + +// CanTransitionTo 只允许围栏前的领取恢复为安全失败;围栏后不再提供回退或重试路径。 +func (status AttemptStatus) CanTransitionTo(next AttemptStatus) bool { + _, allowed := attemptTransitions[status][next] + return allowed +} + +func TransitionAttempt(current, next AttemptStatus) (AttemptStatus, error) { + if !current.CanTransitionTo(next) { + return current, ErrInvalidAttemptTransition + } + return next, nil +} + +var attemptTransitions = map[AttemptStatus]map[AttemptStatus]struct{}{ + AttemptStatusClaimed: { + AttemptStatusOrdering: {}, + AttemptStatusFailed: {}, + AttemptStatusAbandoned: {}, + }, + AttemptStatusOrdering: { + AttemptStatusFenced: {}, + AttemptStatusFailed: {}, + AttemptStatusAbandoned: {}, + }, +} diff --git a/admin/internal/domain/purchase_attempt_test.go b/admin/internal/domain/purchase_attempt_test.go new file mode 100644 index 0000000..13922de --- /dev/null +++ b/admin/internal/domain/purchase_attempt_test.go @@ -0,0 +1,34 @@ +package domain_test + +import ( + "errors" + "testing" + + "cmbuyer/admin/internal/domain" +) + +func TestPurchaseAttemptTransitions(t *testing.T) { + for _, test := range []struct { + current domain.AttemptStatus + next domain.AttemptStatus + allowed bool + }{ + {domain.AttemptStatusClaimed, domain.AttemptStatusOrdering, true}, + {domain.AttemptStatusOrdering, domain.AttemptStatusFenced, true}, + {domain.AttemptStatusOrdering, domain.AttemptStatusFailed, true}, + {domain.AttemptStatusFenced, domain.AttemptStatusOrdering, false}, + {domain.AttemptStatusFenced, domain.AttemptStatusAbandoned, false}, + {domain.AttemptStatus("UNKNOWN"), domain.AttemptStatusOrdering, false}, + } { + got, err := domain.TransitionAttempt(test.current, test.next) + if test.allowed { + if err != nil || got != test.next { + t.Fatalf("TransitionAttempt(%s, %s) = (%s, %v)", test.current, test.next, got, err) + } + continue + } + if !errors.Is(err, domain.ErrInvalidAttemptTransition) || got != test.current { + t.Fatalf("invalid TransitionAttempt(%s, %s) = (%s, %v)", test.current, test.next, got, err) + } + } +} diff --git a/admin/internal/domain/spec_trial.go b/admin/internal/domain/spec_trial.go deleted file mode 100644 index 69465b0..0000000 --- a/admin/internal/domain/spec_trial.go +++ /dev/null @@ -1,16 +0,0 @@ -package domain - -import "time" - -type SpecTrial struct { - ID string - TaskID string - Attempt int - ProductTitle string - SelectedColor string - SelectedSize string - UnitPrice string - TotalPrice string - EvidenceSHA256 string - CreatedAt time.Time -} diff --git a/admin/internal/domain/submission.go b/admin/internal/domain/submission.go index 7d14027..3901aa9 100644 --- a/admin/internal/domain/submission.go +++ b/admin/internal/domain/submission.go @@ -17,17 +17,17 @@ const ( ) type OrderSubmission struct { - ID string - TaskID string - AuthorizationID string - CommandID string - DryRunID string - Status SubmissionStatus - VerifiedUnitPrice string - QuantityRead int - ConfirmPageAmount string - CreatedAt time.Time - ResolvedAt *time.Time + ID string + TaskID string + AuthorizationID string + AttemptID string + Status SubmissionStatus + Gate1UnitPrice string + Gate2UnitPrice string + QuantityRead int + ConfirmAmount string + CreatedAt time.Time + ResolvedAt *time.Time } // CanTransitionTo 只允许围栏记录向最终观察结果调和,拒绝回退以防触发第二次真实动作。 diff --git a/admin/internal/domain/submission_test.go b/admin/internal/domain/submission_test.go index adebe36..27d6185 100644 --- a/admin/internal/domain/submission_test.go +++ b/admin/internal/domain/submission_test.go @@ -20,6 +20,7 @@ func TestSubmissionTransitions(t *testing.T) { {"cannot reopen fenced submission", domain.SubmissionStatusSubmitted, domain.SubmissionStatusFenced, false}, {"submitted cannot require reconciliation", domain.SubmissionStatusSubmitted, domain.SubmissionStatusReconciliationRequired, false}, {"cannot skip reconciliation", domain.SubmissionStatusFenced, domain.SubmissionStatusManualResolved, false}, + {"manual resolution cannot create a second submission", domain.SubmissionStatusManualResolved, domain.SubmissionStatusFenced, false}, } for _, test := range tests { diff --git a/admin/internal/domain/task.go b/admin/internal/domain/task.go index eac0579..ae5fee4 100644 --- a/admin/internal/domain/task.go +++ b/admin/internal/domain/task.go @@ -14,15 +14,12 @@ const ( TaskStatusDraft TaskStatus = "DRAFT" TaskStatusPending TaskStatus = "PENDING" TaskStatusClaimed TaskStatus = "CLAIMED" - TaskStatusRunning TaskStatus = "RUNNING" - TaskStatusWaitingConfirmation TaskStatus = "WAITING_CONFIRMATION" - TaskStatusPendingRetrial TaskStatus = "PENDING_RETRIAL" - TaskStatusAuthorized TaskStatus = "AUTHORIZED" TaskStatusOrdering TaskStatus = "ORDERING" TaskStatusWaitingPayment TaskStatus = "WAITING_PAYMENT" TaskStatusReconciliationRequired TaskStatus = "RECONCILIATION_REQUIRED" TaskStatusNeedsManual TaskStatus = "NEEDS_MANUAL" TaskStatusSucceeded TaskStatus = "SUCCEEDED" + TaskStatusFailed TaskStatus = "FAILED" TaskStatusCanceled TaskStatus = "CANCELED" ) @@ -68,35 +65,32 @@ func TransitionTask(current, next TaskStatus) (TaskStatus, error) { var taskTransitions = map[TaskStatus]map[TaskStatus]struct{}{ TaskStatusDraft: { - TaskStatusPending: {}, + TaskStatusPending: {}, + TaskStatusCanceled: {}, }, TaskStatusPending: { - TaskStatusClaimed: {}, - }, - TaskStatusPendingRetrial: { - TaskStatusClaimed: {}, + TaskStatusClaimed: {}, + TaskStatusDraft: {}, + TaskStatusCanceled: {}, }, TaskStatusClaimed: { - TaskStatusRunning: {}, - TaskStatusPending: {}, - }, - TaskStatusRunning: { - TaskStatusWaitingConfirmation: {}, - TaskStatusNeedsManual: {}, - }, - TaskStatusWaitingConfirmation: { - TaskStatusCanceled: {}, - TaskStatusAuthorized: {}, - }, - TaskStatusAuthorized: { TaskStatusOrdering: {}, + TaskStatusDraft: {}, }, TaskStatusOrdering: { TaskStatusNeedsManual: {}, TaskStatusWaitingPayment: {}, TaskStatusReconciliationRequired: {}, }, + TaskStatusNeedsManual: { + TaskStatusDraft: {}, + TaskStatusCanceled: {}, + }, TaskStatusWaitingPayment: { TaskStatusSucceeded: {}, }, + TaskStatusReconciliationRequired: { + TaskStatusWaitingPayment: {}, + TaskStatusFailed: {}, + }, } diff --git a/admin/internal/domain/task_test.go b/admin/internal/domain/task_test.go index 7ba6fb2..1ce072d 100644 --- a/admin/internal/domain/task_test.go +++ b/admin/internal/domain/task_test.go @@ -14,22 +14,24 @@ func TestTaskTransitions(t *testing.T) { next domain.TaskStatus allowed bool }{ - {"start trial", domain.TaskStatusDraft, domain.TaskStatusPending, true}, - {"claim trial", domain.TaskStatusPending, domain.TaskStatusClaimed, true}, - {"claim retrial", domain.TaskStatusPendingRetrial, domain.TaskStatusClaimed, true}, - {"start trial execution", domain.TaskStatusClaimed, domain.TaskStatusRunning, true}, - {"release unstarted claim", domain.TaskStatusClaimed, domain.TaskStatusPending, true}, - {"trial completes", domain.TaskStatusRunning, domain.TaskStatusWaitingConfirmation, true}, - {"trial needs manual review", domain.TaskStatusRunning, domain.TaskStatusNeedsManual, true}, - {"authorize confirmed trial", domain.TaskStatusWaitingConfirmation, domain.TaskStatusAuthorized, true}, - {"reject confirmed trial", domain.TaskStatusWaitingConfirmation, domain.TaskStatusCanceled, true}, - {"start authorized order leg", domain.TaskStatusAuthorized, domain.TaskStatusOrdering, true}, + {"start purchase", domain.TaskStatusDraft, domain.TaskStatusPending, true}, + {"cancel draft before fence", domain.TaskStatusDraft, domain.TaskStatusCanceled, true}, + {"claim purchase", domain.TaskStatusPending, domain.TaskStatusClaimed, true}, + {"release expired authorization", domain.TaskStatusPending, domain.TaskStatusDraft, true}, + {"start ordering", domain.TaskStatusClaimed, domain.TaskStatusOrdering, true}, + {"release unstarted claim", domain.TaskStatusClaimed, domain.TaskStatusDraft, true}, + {"ordering needs manual review", domain.TaskStatusOrdering, domain.TaskStatusNeedsManual, true}, {"order reaches payment", domain.TaskStatusOrdering, domain.TaskStatusWaitingPayment, true}, {"order needs manual review before fence", domain.TaskStatusOrdering, domain.TaskStatusNeedsManual, true}, {"order needs reconciliation", domain.TaskStatusOrdering, domain.TaskStatusReconciliationRequired, true}, + {"manual review resets draft", domain.TaskStatusNeedsManual, domain.TaskStatusDraft, true}, + {"manual review cancels before fence", domain.TaskStatusNeedsManual, domain.TaskStatusCanceled, true}, {"payment verified", domain.TaskStatusWaitingPayment, domain.TaskStatusSucceeded, true}, - {"cannot skip trial", domain.TaskStatusDraft, domain.TaskStatusAuthorized, false}, - {"trial cannot enter order leg", domain.TaskStatusRunning, domain.TaskStatusOrdering, false}, + {"reconcile confirms waiting payment", domain.TaskStatusReconciliationRequired, domain.TaskStatusWaitingPayment, true}, + {"reconcile confirms failed", domain.TaskStatusReconciliationRequired, domain.TaskStatusFailed, true}, + {"cannot skip authorization", domain.TaskStatusDraft, domain.TaskStatusOrdering, false}, + {"ordering cannot return pending", domain.TaskStatusOrdering, domain.TaskStatusPending, false}, + {"ordering cannot bypass manual review to draft", domain.TaskStatusOrdering, domain.TaskStatusDraft, false}, {"terminal task cannot restart", domain.TaskStatusSucceeded, domain.TaskStatusPending, false}, {"unknown status is rejected", domain.TaskStatus("UNKNOWN"), domain.TaskStatusPending, false}, } diff --git a/admin/internal/migrations/migrations_test.go b/admin/internal/migrations/migrations_test.go index 2b5ac4e..5e1a036 100644 --- a/admin/internal/migrations/migrations_test.go +++ b/admin/internal/migrations/migrations_test.go @@ -3,8 +3,11 @@ package migrations_test import ( "context" "database/sql" + "os" "path/filepath" "runtime" + "strconv" + "strings" "testing" "cmbuyer/admin/internal/migrations" @@ -13,6 +16,8 @@ import ( "github.com/pressly/goose/v3" ) +const migrationTime = "2026-08-04T00:00:00Z" + func TestUpDownAndIdempotence(t *testing.T) { database := openTestDatabase(t) directory := migrationDirectory(t) @@ -21,173 +26,325 @@ func TestUpDownAndIdempotence(t *testing.T) { if err := migrations.Up(context, database, directory); err != nil { t.Fatalf("apply migrations: %v", err) } - assertVersion(t, database, 1) + assertVersion(t, database, 2) assertTableExists(t, database, "tasks", true) - assertTableExists(t, database, "spec_trials", true) + assertTableExists(t, database, "spec_trials", false) assertTableExists(t, database, "order_authorizations", true) + assertTableExists(t, database, "purchase_attempts", true) assertTableExists(t, database, "order_submissions", true) + assertTableExists(t, database, "single_pass_upgrade_guard", false) if err := migrations.Up(context, database, directory); err != nil { t.Fatalf("reapply migrations: %v", err) } - assertVersion(t, database, 1) + assertVersion(t, database, 2) if err := migrations.Down(context, database, directory); err != nil { - t.Fatalf("roll back migration: %v", err) - } - assertVersion(t, database, 0) - assertTableExists(t, database, "tasks", false) - assertTableExists(t, database, "spec_trials", false) - assertTableExists(t, database, "order_authorizations", false) - assertTableExists(t, database, "order_submissions", false) - - if err := migrations.Up(context, database, directory); err != nil { - t.Fatalf("apply migration after rollback: %v", err) + t.Fatalf("roll back v2 migration: %v", err) } assertVersion(t, database, 1) + assertTableExists(t, database, "spec_trials", true) + assertTableExists(t, database, "purchase_attempts", false) + assertTableExists(t, database, "single_pass_downgrade_guard", false) + + if err := migrations.Up(context, database, directory); err != nil { + t.Fatalf("reapply v2 after rollback: %v", err) + } + assertVersion(t, database, 2) } -func TestSchemaConstraints(t *testing.T) { +func TestUpgradePreservesManualDraftLosslessly(t *testing.T) { + database := openTestDatabase(t) + migrateToV1(t, database) + if _, err := database.Exec(` + INSERT INTO tasks ( + id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price, + reference_asset_id, status, version, created_at, updated_at + ) VALUES ('draft-one', 'MANUAL', 'source-ref', 'title', 'goods', 'white', 'XL', 2, '80.50', + 'asset-id', 'DRAFT', 7, '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z') + `); err != nil { + t.Fatalf("insert v1 draft: %v", err) + } + + if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil { + t.Fatalf("upgrade v1 draft: %v", err) + } + assertVersion(t, database, 2) + var got struct { + id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string + quantity, version int + } + if err := database.QueryRow(`SELECT id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price, reference_asset_id, status, version, created_at, updated_at FROM tasks WHERE id = 'draft-one'`).Scan( + &got.id, &got.source, &got.sourceRef, &got.title, &got.goodsID, &got.color, &got.size, &got.quantity, &got.maxPrice, &got.assetID, &got.status, &got.version, &got.created, &got.updated, + ); err != nil { + t.Fatalf("read upgraded draft: %v", err) + } + if got != (struct { + id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string + quantity, version int + }{"draft-one", "MANUAL", "source-ref", "title", "goods", "white", "XL", "80.50", "asset-id", "DRAFT", "2026-08-03T00:00:00Z", "2026-08-03T01:00:00Z", 2, 7}) { + t.Fatalf("upgraded draft changed: %#v", got) + } +} + +func TestUpgradeRejectsLegacyExecutionDataAtomically(t *testing.T) { + tests := []struct { + name string + setup func(*testing.T, *sql.DB) + }{ + {"non-draft task", func(t *testing.T, database *sql.DB) { + insertV1Task(t, database, "pending", "MANUAL", "PENDING", "1.00") + }}, + {"non-manual task", func(t *testing.T, database *sql.DB) { insertV1Task(t, database, "excel", "EXCEL", "DRAFT", "1.00") }}, + {"invalid v2 money", func(t *testing.T, database *sql.DB) { insertV1Task(t, database, "zero", "MANUAL", "DRAFT", "0.00") }}, + {"third decimal place", func(t *testing.T, database *sql.DB) { + insertV1Task(t, database, "third-decimal", "MANUAL", "DRAFT", "1.234") + }}, + {"spec trial", func(t *testing.T, database *sql.DB) { + insertV1Task(t, database, "task", "MANUAL", "DRAFT", "1.00") + insertV1SpecTrial(t, database, "trial", "task") + }}, + {"authorization", func(t *testing.T, database *sql.DB) { + insertV1Task(t, database, "task", "MANUAL", "DRAFT", "1.00") + insertV1SpecTrial(t, database, "trial", "task") + insertV1Authorization(t, database, "auth", "task", "trial") + }}, + {"submission", func(t *testing.T, database *sql.DB) { + insertV1Task(t, database, "task", "MANUAL", "DRAFT", "1.00") + insertV1SpecTrial(t, database, "trial", "task") + insertV1Authorization(t, database, "auth", "task", "trial") + if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price, quantity_read, confirm_page_amount, created_at) VALUES ('submission', 'task', 'auth', 'command', 'dry-run', 'FENCED', '1.00', 1, '1.00', ? )`, migrationTime); err != nil { + t.Fatalf("insert v1 submission: %v", err) + } + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + database := openTestDatabase(t) + migrateToV1(t, database) + test.setup(t, database) + before := v1RowCount(t, database) + if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err == nil { + t.Fatal("unsafe legacy data upgraded successfully") + } + assertVersion(t, database, 1) + assertTableExists(t, database, "spec_trials", true) + assertTableExists(t, database, "purchase_attempts", false) + assertTableExists(t, database, "single_pass_upgrade_guard", false) + if after := v1RowCount(t, database); after != before { + t.Fatalf("v1 data changed after rejection: before=%d after=%d", before, after) + } + }) + } +} + +func TestV2SchemaConstraintsAndRelationships(t *testing.T) { database := openTestDatabase(t) if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil { t.Fatalf("apply migrations: %v", err) } - - for _, column := range []struct { - table string - name string - }{ + for _, column := range []struct{ table, name string }{ {"tasks", "max_total_price"}, - {"spec_trials", "unit_price"}, - {"spec_trials", "total_price"}, - {"order_authorizations", "authorized_unit_price"}, {"order_authorizations", "total_price_cap"}, - {"order_submissions", "verified_unit_price"}, - {"order_submissions", "confirm_page_amount"}, + {"purchase_attempts", "gate1_unit_price"}, + {"purchase_attempts", "gate2_unit_price"}, + {"purchase_attempts", "confirm_amount"}, + {"order_submissions", "gate1_unit_price"}, + {"order_submissions", "gate2_unit_price"}, + {"order_submissions", "confirm_amount"}, } { assertColumnType(t, database, column.table, column.name, "TEXT") } - - if _, err := database.Exec(` - INSERT INTO tasks ( - id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, - status, created_at, updated_at - ) VALUES ('bad-quantity', 'MANUAL', 'title', 'goods', 'white', 'XL', 0, '80.00', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z') - `); err == nil { - t.Fatal("insert task with quantity 0 succeeded") + for _, legacy := range []string{"spec_trials", "authorized_unit_price", "spec_trial_id", "command_id", "dry_run_id"} { + var count int + if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE sql LIKE '%' || ? || '%'`, legacy).Scan(&count); err != nil { + t.Fatalf("search schema for %s: %v", legacy, err) + } + if count != 0 { + t.Fatalf("legacy identifier %q remains in v2 schema", legacy) + } } - if _, err := database.Exec(` - INSERT INTO tasks ( - id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, - status, created_at, updated_at - ) VALUES ('bad-price', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '80..00', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z') - `); err == nil { - t.Fatal("insert task with malformed decimal price succeeded") + insertV2Task(t, database, "task-one", "MANUAL", "DRAFT") + insertV2Task(t, database, "task-two", "MANUAL", "DRAFT") + for index, value := range []string{"", "0", "0.00", "-1.00", "1e2", "1.", "1.234", " 1.00", "one"} { + if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, 'MANUAL', 'title', 'goods', 'white', 'XL', 1, ?, 'DRAFT', ?, ?)`, "bad-price-"+strconv.Itoa(index), value, migrationTime, migrationTime); err == nil { + t.Fatalf("invalid total price %q succeeded", value) + } } + if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES ('bad-status', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '1.00', 'UNKNOWN', ?, ?)`, migrationTime, migrationTime); err == nil { + t.Fatal("unknown task status succeeded") + } + insertV2Authorization(t, database, "auth-one", "task-one", 1, "start-one") + insertV2Authorization(t, database, "auth-two", "task-two", 1, "start-two") + if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('bad-auth-price', 'task-one', 2, 'bad-price', 'goods', 'white', 'XL', 1, '1.234', 'ACTIVE', 'admin', ?, ?)`, migrationTime, migrationTime); err == nil { + t.Fatal("third decimal authorization cap succeeded") + } + if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('bad-auth-status', 'task-one', 2, 'bad-status', 'goods', 'white', 'XL', 1, '1.00', 'UNKNOWN', 'admin', ?, ?)`, migrationTime, migrationTime); err == nil { + t.Fatal("unknown authorization status succeeded") + } + insertV2Authorization(t, database, "auth-one-b", "task-one", 2, "start-one-b") + if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('duplicate-version', 'task-one', 1, 'different-start', 'goods', 'white', 'XL', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, migrationTime, migrationTime); err == nil { + t.Fatal("duplicate task version authorization succeeded") + } + if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES ('cross-attempt', 'task-one', 'auth-two', 1, 'CLAIMED', ?)`, migrationTime); err == nil { + t.Fatal("attempt using another task authorization succeeded") + } + insertV2Attempt(t, database, "attempt-one", "task-one", "auth-one", 1) + if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, gate1_unit_price, started_at) VALUES ('bad-attempt-price', 'task-one', 'auth-one', 2, 'ORDERING', '1.234', ?)`, migrationTime); err == nil { + t.Fatal("third decimal gate price succeeded") + } + if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES ('bad-attempt-status', 'task-one', 'auth-one', 2, 'UNKNOWN', ?)`, migrationTime); err == nil { + t.Fatal("unknown attempt status succeeded") + } + if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, failure_code, started_at) VALUES ('bad-code', 'task-one', 'auth-one', 2, 'FAILED', 'FREE_TEXT', ?)`, migrationTime); err == nil { + t.Fatal("unknown failure code succeeded") + } + if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('cross-submission', 'task-one', 'auth-two', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil { + t.Fatal("submission using another task authorization succeeded") + } + if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('cross-authorization-submission', 'task-one', 'auth-one-b', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil { + t.Fatal("submission combining another same-task authorization and attempt succeeded") + } + if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('bad-submission-status', 'task-one', 'auth-one', 'attempt-one', 'UNKNOWN', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil { + t.Fatal("unknown submission status succeeded") + } + if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('bad-submission-price', 'task-one', 'auth-one', 'attempt-one', 'FENCED', '1.234', '1.00', 1, '1.00', ?)`, migrationTime); err == nil { + t.Fatal("third decimal submission price succeeded") + } + insertV2Submission(t, database, "submission-one", "task-one", "auth-one", "attempt-one") + if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('duplicate-auth', 'task-one', 'auth-one', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil { + t.Fatal("second submission for fenced authorization succeeded") + } +} - if _, err := database.Exec(` - INSERT INTO tasks ( - id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, - status, created_at, updated_at - ) VALUES ('fractional-quantity', 'MANUAL', 'title', 'goods', 'white', 'XL', 1.5, '80.00', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z') - `); err == nil { - t.Fatal("insert task with fractional quantity succeeded") +func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) { + tests := []struct { + name string + setup func(*testing.T, *sql.DB) + }{ + {"authorization", func(t *testing.T, database *sql.DB) { + insertV2Task(t, database, "task", "MANUAL", "DRAFT") + insertV2Authorization(t, database, "auth", "task", 1, "start") + }}, + {"attempt", func(t *testing.T, database *sql.DB) { + insertV2Task(t, database, "task", "MANUAL", "DRAFT") + insertV2Authorization(t, database, "auth", "task", 1, "start") + insertV2Attempt(t, database, "attempt", "task", "auth", 1) + }}, + {"submission", func(t *testing.T, database *sql.DB) { + insertV2Task(t, database, "task", "MANUAL", "DRAFT") + insertV2Authorization(t, database, "auth", "task", 1, "start") + insertV2Attempt(t, database, "attempt", "task", "auth", 1) + insertV2Submission(t, database, "submission", "task", "auth", "attempt") + }}, + {"non-draft task", func(t *testing.T, database *sql.DB) { insertV2Task(t, database, "pending", "MANUAL", "PENDING") }}, + {"non-manual task", func(t *testing.T, database *sql.DB) { insertV2Task(t, database, "excel", "EXCEL", "DRAFT") }}, } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + database := openTestDatabase(t) + if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil { + t.Fatalf("apply migrations: %v", err) + } + test.setup(t, database) + before := v2RowCount(t, database) + if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil { + t.Fatal("unsafe v2 data downgraded successfully") + } + assertVersion(t, database, 2) + assertTableExists(t, database, "purchase_attempts", true) + assertTableExists(t, database, "spec_trials", false) + assertTableExists(t, database, "single_pass_downgrade_guard", false) + if after := v2RowCount(t, database); after != before { + t.Fatalf("v2 data changed after rejected downgrade: before=%d after=%d", before, after) + } + }) + } +} - if _, err := database.Exec(` - INSERT INTO tasks ( - id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, - status, created_at, updated_at - ) VALUES ('trailing-decimal', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '80.', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z') - `); err == nil { - t.Fatal("insert task with trailing decimal point succeeded") +func migrateToV1(t *testing.T, database *sql.DB) { + t.Helper() + if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil { + t.Fatalf("apply v1: %v", err) } + assertVersion(t, database, 1) +} - if _, err := database.Exec(` - INSERT INTO tasks ( - id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, - status, created_at, updated_at - ) VALUES ('bad-status', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '80.00', 'UNKNOWN', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z') - `); err == nil { - t.Fatal("insert task with invalid status succeeded") +func insertV1Task(t *testing.T, database *sql.DB, id, source, status, price string) { + t.Helper() + if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, ?, ?, ?, ?)`, id, source, price, status, migrationTime, migrationTime); err != nil { + t.Fatalf("insert v1 task: %v", err) } +} - insertTask(t, database, "task-one") - insertTask(t, database, "task-two") - if _, err := database.Exec(` - INSERT INTO spec_trials ( - id, task_id, attempt, product_title, selected_color, selected_size, unit_price, - total_price, evidence_sha256, created_at - ) VALUES ('orphan-trial', 'missing-task', 1, 'title', 'white', 'XL', '32.50', '65.00', 'hash', '2026-08-03T00:00:00Z') - `); err == nil { - t.Fatal("insert spec trial without task succeeded") +func insertV1SpecTrial(t *testing.T, database *sql.DB, id, taskID string) { + t.Helper() + if _, err := database.Exec(`INSERT INTO spec_trials (id, task_id, attempt, product_title, selected_color, selected_size, unit_price, total_price, evidence_sha256, created_at) VALUES (?, ?, 1, 'title', 'white', 'XL', '1.00', '1.00', 'hash', ?)`, id, taskID, migrationTime); err != nil { + t.Fatalf("insert v1 spec trial: %v", err) } +} - insertSpecTrial(t, database, "trial-one", "task-one") - insertSpecTrial(t, database, "trial-two", "task-two") - if _, err := database.Exec(` - INSERT INTO order_authorizations ( - id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity, - authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at - ) VALUES ('authorization-cross-task', 'task-one', 'trial-two', 1, 'goods', 'white', 'XL', 2, '32.50', '80.00', 'PENDING_DELIVERY', 'admin-one', '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z') - `); err == nil { - t.Fatal("insert authorization with a spec trial from another task succeeded") +func insertV1Authorization(t *testing.T, database *sql.DB, id, taskID, trialID string) { + t.Helper() + if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity, authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, ?, 1, 'goods', 'white', 'XL', 1, '1.00', '1.00', 'PENDING_DELIVERY', 'admin', ?, ?)`, id, taskID, trialID, migrationTime, migrationTime); err != nil { + t.Fatalf("insert v1 authorization: %v", err) } +} - insertAuthorization(t, database, "authorization-one", "task-one", "trial-one", 1) - insertAuthorization(t, database, "authorization-task-two", "task-two", "trial-two", 1) - if _, err := database.Exec(` - INSERT INTO order_submissions ( - id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price, - quantity_read, confirm_page_amount, created_at - ) VALUES ('submission-cross-task', 'task-one', 'authorization-task-two', 'command-cross-task', 'dry-run-cross-task', 'FENCED', '32.50', 2, '65.00', '2026-08-03T00:00:00Z') - `); err == nil { - t.Fatal("insert submission with an authorization from another task succeeded") +func insertV2Task(t *testing.T, database *sql.DB, id, source, status string) { + t.Helper() + if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, '1.00', ?, ?, ?)`, id, source, status, migrationTime, migrationTime); err != nil { + t.Fatalf("insert v2 task: %v", err) } +} - if _, err := database.Exec(` - INSERT INTO order_authorizations ( - id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity, - authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at - ) VALUES ('authorization-duplicate', 'task-one', 'trial-one', 1, 'goods', 'white', 'XL', 2, '32.50', '80.00', 'PENDING_DELIVERY', 'admin-one', '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z') - `); err == nil { - t.Fatal("insert authorization with duplicate task version succeeded") +func insertV2Authorization(t *testing.T, database *sql.DB, id, taskID string, version int, startKey string) { + t.Helper() + if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, 'goods', 'white', 'XL', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, id, taskID, version, startKey, migrationTime, migrationTime); err != nil { + t.Fatalf("insert v2 authorization: %v", err) } +} - insertSubmission(t, database, "submission-one", "authorization-one", "command-one") - if _, err := database.Exec(` - INSERT INTO order_submissions ( - id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price, - quantity_read, confirm_page_amount, created_at - ) VALUES ('submission-duplicate-auth', 'task-one', 'authorization-one', 'command-two', 'dry-run-two', 'FENCED', '32.50', 2, '65.00', '2026-08-03T00:00:00Z') - `); err == nil { - t.Fatal("insert submission with duplicate authorization succeeded") +func insertV2Attempt(t *testing.T, database *sql.DB, id, taskID, authorizationID string, generation int) { + t.Helper() + if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, ?, 'CLAIMED', ?)`, id, taskID, authorizationID, generation, migrationTime); err != nil { + t.Fatalf("insert v2 attempt: %v", err) } +} - insertAuthorization(t, database, "authorization-two", "task-one", "trial-one", 2) - if _, err := database.Exec(` - INSERT INTO order_submissions ( - id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price, - quantity_read, confirm_page_amount, created_at - ) VALUES ('submission-duplicate-command', 'task-one', 'authorization-two', 'command-one', 'dry-run-three', 'FENCED', '32.50', 2, '65.00', '2026-08-03T00:00:00Z') - `); err == nil { - t.Fatal("insert submission with duplicate command succeeded") +func insertV2Submission(t *testing.T, database *sql.DB, id, taskID, authorizationID, attemptID string) { + t.Helper() + if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES (?, ?, ?, ?, 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, id, taskID, authorizationID, attemptID, migrationTime); err != nil { + t.Fatalf("insert v2 submission: %v", err) } } +func v1RowCount(t *testing.T, database *sql.DB) int { + t.Helper() + var count int + if err := database.QueryRow(`SELECT (SELECT COUNT(*) FROM tasks) + (SELECT COUNT(*) FROM spec_trials) + (SELECT COUNT(*) FROM order_authorizations) + (SELECT COUNT(*) FROM order_submissions)`).Scan(&count); err != nil { + t.Fatalf("count v1 rows: %v", err) + } + return count +} + +func v2RowCount(t *testing.T, database *sql.DB) int { + t.Helper() + var count int + if err := database.QueryRow(`SELECT (SELECT COUNT(*) FROM tasks) + (SELECT COUNT(*) FROM order_authorizations) + (SELECT COUNT(*) FROM purchase_attempts) + (SELECT COUNT(*) FROM order_submissions)`).Scan(&count); err != nil { + t.Fatalf("count v2 rows: %v", err) + } + return count +} + func openTestDatabase(t *testing.T) *sql.DB { t.Helper() database, err := sqlite.Open(filepath.Join(t.TempDir(), "migrations.db")) if err != nil { t.Fatalf("open test database: %v", err) } - t.Cleanup(func() { - if err := database.Close(); err != nil { - t.Errorf("close test database: %v", err) - } - }) - + t.Cleanup(func() { _ = database.Close() }) return database } @@ -197,7 +354,6 @@ func migrationDirectory(t *testing.T) string { if !ok { t.Fatal("locate migration test source") } - return filepath.Join(filepath.Dir(file), "..", "..", "migrations") } @@ -234,50 +390,12 @@ func assertColumnType(t *testing.T, database *sql.DB, table, column, want string } } -func insertTask(t *testing.T, database *sql.DB, id string) { - t.Helper() - if _, err := database.Exec(` - INSERT INTO tasks ( - id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, - status, created_at, updated_at - ) VALUES (?, 'MANUAL', 'title', 'goods', 'white', 'XL', 2, '80.00', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z') - `, id); err != nil { - t.Fatalf("insert task: %v", err) +func TestV2MigrationSQLDoesNotDisableForeignKeys(t *testing.T) { + contents, err := os.ReadFile(filepath.Join(migrationDirectory(t), "00002_single_pass_model.sql")) + if err != nil { + t.Fatalf("read migration: %v", err) } -} - -func insertSpecTrial(t *testing.T, database *sql.DB, id, taskID string) { - t.Helper() - if _, err := database.Exec(` - INSERT INTO spec_trials ( - id, task_id, attempt, product_title, selected_color, selected_size, unit_price, - total_price, evidence_sha256, created_at - ) VALUES (?, ?, 1, 'title', 'white', 'XL', '32.50', '65.00', 'hash', '2026-08-03T00:00:00Z') - `, id, taskID); err != nil { - t.Fatalf("insert spec trial: %v", err) - } -} - -func insertAuthorization(t *testing.T, database *sql.DB, id, taskID, specTrialID string, version int) { - t.Helper() - if _, err := database.Exec(` - INSERT INTO order_authorizations ( - id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity, - authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at - ) VALUES (?, ?, ?, ?, 'goods', 'white', 'XL', 2, '32.50', '80.00', 'PENDING_DELIVERY', 'admin-one', '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z') - `, id, taskID, specTrialID, version); err != nil { - t.Fatalf("insert authorization: %v", err) - } -} - -func insertSubmission(t *testing.T, database *sql.DB, id, authorizationID, commandID string) { - t.Helper() - if _, err := database.Exec(` - INSERT INTO order_submissions ( - id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price, - quantity_read, confirm_page_amount, created_at - ) VALUES (?, 'task-one', ?, ?, 'dry-run-one', 'FENCED', '32.50', 2, '65.00', '2026-08-03T00:00:00Z') - `, id, authorizationID, commandID); err != nil { - t.Fatalf("insert submission: %v", err) + if strings.Contains(strings.ToUpper(string(contents)), "PRAGMA FOREIGN_KEYS = OFF") { + t.Fatal("migration disables foreign keys") } } diff --git a/admin/migrations/00002_single_pass_model.sql b/admin/migrations/00002_single_pass_model.sql new file mode 100644 index 0000000..da9ac4c --- /dev/null +++ b/admin/migrations/00002_single_pass_model.sql @@ -0,0 +1,333 @@ +-- +goose Up +-- v1 的试选/锁旧价记录无法安全推断为单趟执行事实。先在同一事务中拒绝它们, +-- 避免删除审计数据后再尝试猜测映射。 +CREATE TABLE single_pass_upgrade_guard ( + valid INTEGER NOT NULL CHECK (valid = 1) +); + +INSERT INTO single_pass_upgrade_guard (valid) +SELECT CASE WHEN + (SELECT COUNT(*) FROM spec_trials) = 0 + AND (SELECT COUNT(*) FROM order_authorizations) = 0 + AND (SELECT COUNT(*) FROM order_submissions) = 0 + AND (SELECT COUNT(*) FROM tasks WHERE source <> 'MANUAL' OR status <> 'DRAFT') = 0 + -- v2 的金额边界是严格正数;不把 v1 中不能无损纳入该边界的数据悄悄改写。 + AND (SELECT COUNT(*) FROM tasks WHERE + max_total_price = '' + OR max_total_price GLOB '*[^0-9.]*' + OR length(max_total_price) - length(replace(max_total_price, '.', '')) > 1 + OR max_total_price = '.' + OR (instr(max_total_price, '.') > 0 AND ( + instr(max_total_price, '.') = 1 + OR length(max_total_price) = instr(max_total_price, '.') + OR length(max_total_price) - instr(max_total_price, '.') > 2 + )) + OR replace(replace(max_total_price, '.', ''), '0', '') = '' + ) = 0 +THEN 1 ELSE 0 END; + +DROP TABLE single_pass_upgrade_guard; + +ALTER TABLE tasks RENAME TO tasks_v1; +DROP TABLE order_submissions; +DROP TABLE order_authorizations; +DROP TABLE spec_trials; + +CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL CHECK (source IN ('MANUAL', 'EXCEL', 'ERP')), + source_ref TEXT, + title TEXT NOT NULL, + goods_id TEXT NOT NULL, + sku_color TEXT NOT NULL, + sku_size TEXT NOT NULL, + quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'), + max_total_price TEXT NOT NULL CHECK ( + max_total_price <> '' + AND max_total_price NOT GLOB '*[^0-9.]*' + AND length(max_total_price) - length(replace(max_total_price, '.', '')) <= 1 + AND max_total_price <> '.' + AND (instr(max_total_price, '.') = 0 OR ( + instr(max_total_price, '.') > 1 + AND length(max_total_price) > instr(max_total_price, '.') + AND length(max_total_price) - instr(max_total_price, '.') <= 2 + )) + AND replace(replace(max_total_price, '.', ''), '0', '') <> '' + ), + reference_asset_id TEXT, + status TEXT NOT NULL CHECK (status IN ( + 'DRAFT', 'PENDING', 'CLAIMED', 'ORDERING', 'NEEDS_MANUAL', 'WAITING_PAYMENT', + 'RECONCILIATION_REQUIRED', 'SUCCEEDED', 'FAILED', 'CANCELED' + )), + version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0 AND typeof(version) = 'integer'), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +INSERT INTO tasks ( + id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price, + reference_asset_id, status, version, created_at, updated_at +) +SELECT + id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price, + reference_asset_id, status, version, created_at, updated_at +FROM tasks_v1; + +DROP TABLE tasks_v1; + +CREATE TABLE order_authorizations ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks(id), + task_version INTEGER NOT NULL CHECK (task_version > 0 AND typeof(task_version) = 'integer'), + start_key TEXT NOT NULL, + goods_id TEXT NOT NULL, + sku_color TEXT NOT NULL, + sku_size TEXT NOT NULL, + quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'), + total_price_cap TEXT NOT NULL CHECK ( + total_price_cap <> '' + AND total_price_cap NOT GLOB '*[^0-9.]*' + AND length(total_price_cap) - length(replace(total_price_cap, '.', '')) <= 1 + AND total_price_cap <> '.' + AND (instr(total_price_cap, '.') = 0 OR ( + instr(total_price_cap, '.') > 1 + AND length(total_price_cap) > instr(total_price_cap, '.') + AND length(total_price_cap) - instr(total_price_cap, '.') <= 2 + )) + AND replace(replace(total_price_cap, '.', ''), '0', '') <> '' + ), + status TEXT NOT NULL CHECK (status IN ('ACTIVE', 'CLAIMED', 'FENCED', 'CONSUMED', 'EXPIRED', 'ABANDONED')), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + UNIQUE (task_id, task_version), + UNIQUE (start_key, task_id), + UNIQUE (task_id, id) +); + +CREATE TABLE purchase_attempts ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + authorization_id TEXT NOT NULL, + claim_generation INTEGER NOT NULL CHECK (claim_generation > 0 AND typeof(claim_generation) = 'integer'), + status TEXT NOT NULL CHECK (status IN ('CLAIMED', 'ORDERING', 'FAILED', 'FENCED', 'ABANDONED')), + gate1_unit_price TEXT CHECK ( + gate1_unit_price IS NULL OR ( + gate1_unit_price <> '' + AND gate1_unit_price NOT GLOB '*[^0-9.]*' + AND length(gate1_unit_price) - length(replace(gate1_unit_price, '.', '')) <= 1 + AND gate1_unit_price <> '.' + AND (instr(gate1_unit_price, '.') = 0 OR ( + instr(gate1_unit_price, '.') > 1 + AND length(gate1_unit_price) > instr(gate1_unit_price, '.') + AND length(gate1_unit_price) - instr(gate1_unit_price, '.') <= 2 + )) + AND replace(replace(gate1_unit_price, '.', ''), '0', '') <> '' + ) + ), + gate2_unit_price TEXT CHECK ( + gate2_unit_price IS NULL OR ( + gate2_unit_price <> '' + AND gate2_unit_price NOT GLOB '*[^0-9.]*' + AND length(gate2_unit_price) - length(replace(gate2_unit_price, '.', '')) <= 1 + AND gate2_unit_price <> '.' + AND (instr(gate2_unit_price, '.') = 0 OR ( + instr(gate2_unit_price, '.') > 1 + AND length(gate2_unit_price) > instr(gate2_unit_price, '.') + AND length(gate2_unit_price) - instr(gate2_unit_price, '.') <= 2 + )) + AND replace(replace(gate2_unit_price, '.', ''), '0', '') <> '' + ) + ), + quantity_read INTEGER CHECK (quantity_read IS NULL OR (quantity_read > 0 AND typeof(quantity_read) = 'integer')), + confirm_amount TEXT CHECK ( + confirm_amount IS NULL OR ( + confirm_amount <> '' + AND confirm_amount NOT GLOB '*[^0-9.]*' + AND length(confirm_amount) - length(replace(confirm_amount, '.', '')) <= 1 + AND confirm_amount <> '.' + AND (instr(confirm_amount, '.') = 0 OR ( + instr(confirm_amount, '.') > 1 + AND length(confirm_amount) > instr(confirm_amount, '.') + AND length(confirm_amount) - instr(confirm_amount, '.') <= 2 + )) + AND replace(replace(confirm_amount, '.', ''), '0', '') <> '' + ) + ), + failure_code TEXT CHECK (failure_code IS NULL OR failure_code IN ( + 'AUTHORIZATION_EXPIRED', 'LEASE_LOST', 'GATE_1_REJECTED', 'QUANTITY_MISMATCH', + 'GATE_2_REJECTED', 'GATE_3_REJECTED', 'FENCE_REJECTED', 'SAFE_ABORTED' + )), + started_at TEXT NOT NULL, + finished_at TEXT, + UNIQUE (task_id, claim_generation), + UNIQUE (task_id, id), + UNIQUE (task_id, authorization_id, id), + FOREIGN KEY (task_id, authorization_id) REFERENCES order_authorizations(task_id, id) +); + +CREATE TABLE order_submissions ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + authorization_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('FENCED', 'SUBMITTED', 'RECONCILIATION_REQUIRED', 'MANUAL_RESOLVED')), + gate1_unit_price TEXT NOT NULL CHECK ( + gate1_unit_price <> '' + AND gate1_unit_price NOT GLOB '*[^0-9.]*' + AND length(gate1_unit_price) - length(replace(gate1_unit_price, '.', '')) <= 1 + AND gate1_unit_price <> '.' + AND (instr(gate1_unit_price, '.') = 0 OR ( + instr(gate1_unit_price, '.') > 1 + AND length(gate1_unit_price) > instr(gate1_unit_price, '.') + AND length(gate1_unit_price) - instr(gate1_unit_price, '.') <= 2 + )) + AND replace(replace(gate1_unit_price, '.', ''), '0', '') <> '' + ), + gate2_unit_price TEXT NOT NULL CHECK ( + gate2_unit_price <> '' + AND gate2_unit_price NOT GLOB '*[^0-9.]*' + AND length(gate2_unit_price) - length(replace(gate2_unit_price, '.', '')) <= 1 + AND gate2_unit_price <> '.' + AND (instr(gate2_unit_price, '.') = 0 OR ( + instr(gate2_unit_price, '.') > 1 + AND length(gate2_unit_price) > instr(gate2_unit_price, '.') + AND length(gate2_unit_price) - instr(gate2_unit_price, '.') <= 2 + )) + AND replace(replace(gate2_unit_price, '.', ''), '0', '') <> '' + ), + quantity_read INTEGER NOT NULL CHECK (quantity_read > 0 AND typeof(quantity_read) = 'integer'), + confirm_amount TEXT NOT NULL CHECK ( + confirm_amount <> '' + AND confirm_amount NOT GLOB '*[^0-9.]*' + AND length(confirm_amount) - length(replace(confirm_amount, '.', '')) <= 1 + AND confirm_amount <> '.' + AND (instr(confirm_amount, '.') = 0 OR ( + instr(confirm_amount, '.') > 1 + AND length(confirm_amount) > instr(confirm_amount, '.') + AND length(confirm_amount) - instr(confirm_amount, '.') <= 2 + )) + AND replace(replace(confirm_amount, '.', ''), '0', '') <> '' + ), + created_at TEXT NOT NULL, + resolved_at TEXT, + UNIQUE (authorization_id), + UNIQUE (attempt_id), + FOREIGN KEY (task_id, authorization_id, attempt_id) REFERENCES purchase_attempts(task_id, authorization_id, id) +); + +-- +goose Down +-- 只有尚未产生任何单趟授权或执行事实的纯 MANUAL/DRAFT 数据才能无损回到 v1。 +CREATE TABLE single_pass_downgrade_guard ( + valid INTEGER NOT NULL CHECK (valid = 1) +); + +INSERT INTO single_pass_downgrade_guard (valid) +SELECT CASE WHEN + (SELECT COUNT(*) FROM order_authorizations) = 0 + AND (SELECT COUNT(*) FROM purchase_attempts) = 0 + AND (SELECT COUNT(*) FROM order_submissions) = 0 + AND (SELECT COUNT(*) FROM tasks WHERE source <> 'MANUAL' OR status <> 'DRAFT') = 0 +THEN 1 ELSE 0 END; + +DROP TABLE single_pass_downgrade_guard; + +ALTER TABLE tasks RENAME TO tasks_v2; +DROP TABLE order_submissions; +DROP TABLE purchase_attempts; +DROP TABLE order_authorizations; + +CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL CHECK (source IN ('MANUAL', 'EXCEL', 'ERP')), + source_ref TEXT, + title TEXT NOT NULL, + goods_id TEXT NOT NULL, + sku_color TEXT NOT NULL, + sku_size TEXT NOT NULL, + quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'), + max_total_price TEXT NOT NULL CHECK ( + max_total_price <> '' + AND max_total_price NOT GLOB '*[^0-9.]*' + AND length(max_total_price) - length(replace(max_total_price, '.', '')) <= 1 + AND max_total_price <> '.' + AND (instr(max_total_price, '.') = 0 OR ( + instr(max_total_price, '.') > 1 + AND length(max_total_price) > instr(max_total_price, '.') + AND length(max_total_price) - instr(max_total_price, '.') <= 2 + )) + ), + reference_asset_id TEXT, + status TEXT NOT NULL CHECK (status IN ( + 'DRAFT', 'PENDING', 'CLAIMED', 'RUNNING', 'WAITING_CONFIRMATION', + 'PENDING_RETRIAL', 'AUTHORIZED', 'ORDERING', 'WAITING_PAYMENT', + 'RECONCILIATION_REQUIRED', 'NEEDS_MANUAL', 'SUCCEEDED', 'CANCELED' + )), + version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0 AND typeof(version) = 'integer'), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +INSERT INTO tasks ( + id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price, + reference_asset_id, status, version, created_at, updated_at +) +SELECT + id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price, + reference_asset_id, status, version, created_at, updated_at +FROM tasks_v2; + +DROP TABLE tasks_v2; + +CREATE TABLE spec_trials ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks(id), + attempt INTEGER NOT NULL CHECK (attempt > 0 AND typeof(attempt) = 'integer'), + product_title TEXT NOT NULL, + selected_color TEXT NOT NULL, + selected_size TEXT NOT NULL, + unit_price TEXT NOT NULL CHECK (unit_price <> '' AND unit_price NOT GLOB '*[^0-9.]*' AND length(unit_price) - length(replace(unit_price, '.', '')) <= 1 AND unit_price <> '.' AND (instr(unit_price, '.') = 0 OR (instr(unit_price, '.') > 1 AND length(unit_price) > instr(unit_price, '.') AND length(unit_price) - instr(unit_price, '.') <= 2))), + total_price TEXT NOT NULL CHECK (total_price <> '' AND total_price NOT GLOB '*[^0-9.]*' AND length(total_price) - length(replace(total_price, '.', '')) <= 1 AND total_price <> '.' AND (instr(total_price, '.') = 0 OR (instr(total_price, '.') > 1 AND length(total_price) > instr(total_price, '.') AND length(total_price) - instr(total_price, '.') <= 2))), + evidence_sha256 TEXT NOT NULL, + created_at TEXT NOT NULL, + UNIQUE (task_id, attempt), + UNIQUE (task_id, id) +); + +CREATE TABLE order_authorizations ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks(id), + spec_trial_id TEXT NOT NULL REFERENCES spec_trials(id), + version INTEGER NOT NULL CHECK (version > 0 AND typeof(version) = 'integer'), + goods_id TEXT NOT NULL, + sku_color TEXT NOT NULL, + sku_size TEXT NOT NULL, + quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'), + authorized_unit_price TEXT NOT NULL CHECK (authorized_unit_price <> '' AND authorized_unit_price NOT GLOB '*[^0-9.]*' AND length(authorized_unit_price) - length(replace(authorized_unit_price, '.', '')) <= 1 AND authorized_unit_price <> '.' AND (instr(authorized_unit_price, '.') = 0 OR (instr(authorized_unit_price, '.') > 1 AND length(authorized_unit_price) > instr(authorized_unit_price, '.') AND length(authorized_unit_price) - instr(authorized_unit_price, '.') <= 2))), + total_price_cap TEXT NOT NULL CHECK (total_price_cap <> '' AND total_price_cap NOT GLOB '*[^0-9.]*' AND length(total_price_cap) - length(replace(total_price_cap, '.', '')) <= 1 AND total_price_cap <> '.' AND (instr(total_price_cap, '.') = 0 OR (instr(total_price_cap, '.') > 1 AND length(total_price_cap) > instr(total_price_cap, '.') AND length(total_price_cap) - instr(total_price_cap, '.') <= 2))), + note TEXT, + status TEXT NOT NULL CHECK (status IN ('PENDING_DELIVERY', 'DELIVERED', 'ACKNOWLEDGED', 'EXECUTING', 'FENCED', 'CONSUMED', 'SUPERSEDED', 'EXPIRED')), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + UNIQUE (task_id, version), + UNIQUE (task_id, id), + FOREIGN KEY (task_id, spec_trial_id) REFERENCES spec_trials(task_id, id) +); + +CREATE TABLE order_submissions ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks(id), + authorization_id TEXT NOT NULL REFERENCES order_authorizations(id), + command_id TEXT NOT NULL, + dry_run_id TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('FENCED', 'SUBMITTED', 'RECONCILIATION_REQUIRED', 'MANUAL_RESOLVED')), + verified_unit_price TEXT NOT NULL CHECK (verified_unit_price <> '' AND verified_unit_price NOT GLOB '*[^0-9.]*' AND length(verified_unit_price) - length(replace(verified_unit_price, '.', '')) <= 1 AND verified_unit_price <> '.' AND (instr(verified_unit_price, '.') = 0 OR (instr(verified_unit_price, '.') > 1 AND length(verified_unit_price) > instr(verified_unit_price, '.') AND length(verified_unit_price) - instr(verified_unit_price, '.') <= 2))), + quantity_read INTEGER NOT NULL CHECK (quantity_read > 0 AND typeof(quantity_read) = 'integer'), + confirm_page_amount TEXT NOT NULL CHECK (confirm_page_amount <> '' AND confirm_page_amount NOT GLOB '*[^0-9.]*' AND length(confirm_page_amount) - length(replace(confirm_page_amount, '.', '')) <= 1 AND confirm_page_amount <> '.' AND (instr(confirm_page_amount, '.') = 0 OR (instr(confirm_page_amount, '.') > 1 AND length(confirm_page_amount) > instr(confirm_page_amount, '.') AND length(confirm_page_amount) - instr(confirm_page_amount, '.') <= 2))), + created_at TEXT NOT NULL, + resolved_at TEXT, + UNIQUE (authorization_id), + UNIQUE (command_id), + FOREIGN KEY (task_id, authorization_id) REFERENCES order_authorizations(task_id, id) +); diff --git a/docs/tasks/T-209.md b/docs/tasks/T-209.md index 1196a05..f222c97 100644 --- a/docs/tasks/T-209.md +++ b/docs/tasks/T-209.md @@ -3,7 +3,7 @@ id: T-209 title: 把核心 schema / 状态机迁移为单趟模型 phase: 2 deps: [T-004, T-111] -status: DOING +status: DONE created: 2026-08-04 vikunja_task_id: 29 context_ref: da540bf