feat(admin): migrate core schema to single-pass model

This commit is contained in:
QiuSW
2026-08-04 17:01:35 +08:00
parent 64a7468cab
commit e04f05b20b
12 changed files with 798 additions and 274 deletions
+27 -41
View File
@@ -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: {},
+9 -11
View File
@@ -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 {
+74
View File
@@ -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: {},
},
}
@@ -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)
}
}
}
-16
View File
@@ -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
}
+11 -11
View File
@@ -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 只允许围栏记录向最终观察结果调和,拒绝回退以防触发第二次真实动作。
+1
View File
@@ -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 {
+15 -21
View File
@@ -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: {},
},
}
+14 -12
View File
@@ -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},
}
+279 -161
View File
@@ -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")
}
}
@@ -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)
);
+1 -1
View File
@@ -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