Compare commits

..
Author SHA1 Message Date
QiuSW e20457b6db docs(tasks): define T-203 purchase authorization 2026-08-04 16:58:37 +08:00
QiuSW b5f45b87a5 docs(state): record T-202 completion 2026-08-04 16:41:22 +08:00
14 changed files with 351 additions and 800 deletions
+24 -10
View File
@@ -10,24 +10,28 @@ var ErrInvalidAuthorizationTransition = errors.New("invalid authorization status
type AuthorizationStatus string type AuthorizationStatus string
const ( const (
AuthorizationStatusActive AuthorizationStatus = "ACTIVE" AuthorizationStatusPendingDelivery AuthorizationStatus = "PENDING_DELIVERY"
AuthorizationStatusClaimed AuthorizationStatus = "CLAIMED" AuthorizationStatusDelivered AuthorizationStatus = "DELIVERED"
AuthorizationStatusAcknowledged AuthorizationStatus = "ACKNOWLEDGED"
AuthorizationStatusExecuting AuthorizationStatus = "EXECUTING"
AuthorizationStatusFenced AuthorizationStatus = "FENCED" AuthorizationStatusFenced AuthorizationStatus = "FENCED"
AuthorizationStatusConsumed AuthorizationStatus = "CONSUMED" AuthorizationStatusConsumed AuthorizationStatus = "CONSUMED"
AuthorizationStatusSuperseded AuthorizationStatus = "SUPERSEDED"
AuthorizationStatusExpired AuthorizationStatus = "EXPIRED" AuthorizationStatusExpired AuthorizationStatus = "EXPIRED"
AuthorizationStatusAbandoned AuthorizationStatus = "ABANDONED"
) )
type OrderAuthorization struct { type OrderAuthorization struct {
ID string ID string
TaskID string TaskID string
TaskVersion int SpecTrialID string
StartKey string Version int
GoodsID string GoodsID string
SKUColor string SKUColor string
SKUSize string SKUSize string
Quantity int Quantity int
AuthorizedUnitPrice string
TotalPriceCap string TotalPriceCap string
Note *string
Status AuthorizationStatus Status AuthorizationStatus
CreatedBy string CreatedBy string
CreatedAt time.Time CreatedAt time.Time
@@ -50,15 +54,25 @@ func TransitionAuthorization(current, next AuthorizationStatus) (AuthorizationSt
} }
var authorizationTransitions = map[AuthorizationStatus]map[AuthorizationStatus]struct{}{ var authorizationTransitions = map[AuthorizationStatus]map[AuthorizationStatus]struct{}{
AuthorizationStatusActive: { AuthorizationStatusPendingDelivery: {
AuthorizationStatusClaimed: {}, AuthorizationStatusDelivered: {},
AuthorizationStatusSuperseded: {},
AuthorizationStatusExpired: {}, AuthorizationStatusExpired: {},
AuthorizationStatusAbandoned: {},
}, },
AuthorizationStatusClaimed: { AuthorizationStatusDelivered: {
AuthorizationStatusAcknowledged: {},
AuthorizationStatusSuperseded: {},
AuthorizationStatusExpired: {},
},
AuthorizationStatusAcknowledged: {
AuthorizationStatusExecuting: {},
AuthorizationStatusSuperseded: {},
AuthorizationStatusExpired: {},
},
AuthorizationStatusExecuting: {
AuthorizationStatusFenced: {}, AuthorizationStatusFenced: {},
AuthorizationStatusSuperseded: {},
AuthorizationStatusExpired: {}, AuthorizationStatusExpired: {},
AuthorizationStatusAbandoned: {},
}, },
AuthorizationStatusFenced: { AuthorizationStatusFenced: {
AuthorizationStatusConsumed: {}, AuthorizationStatusConsumed: {},
+11 -9
View File
@@ -14,17 +14,19 @@ func TestAuthorizationTransitions(t *testing.T) {
next domain.AuthorizationStatus next domain.AuthorizationStatus
allowed bool allowed bool
}{ }{
{"claim", domain.AuthorizationStatusActive, domain.AuthorizationStatusClaimed, true}, {"deliver", domain.AuthorizationStatusPendingDelivery, domain.AuthorizationStatusDelivered, true},
{"fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusFenced, true}, {"acknowledge", domain.AuthorizationStatusDelivered, domain.AuthorizationStatusAcknowledged, true},
{"execute", domain.AuthorizationStatusAcknowledged, domain.AuthorizationStatusExecuting, true},
{"fence", domain.AuthorizationStatusExecuting, domain.AuthorizationStatusFenced, true},
{"consume fenced authorization", domain.AuthorizationStatusFenced, domain.AuthorizationStatusConsumed, true}, {"consume fenced authorization", domain.AuthorizationStatusFenced, domain.AuthorizationStatusConsumed, true},
{"expire active", domain.AuthorizationStatusActive, domain.AuthorizationStatusExpired, true}, {"expire pending delivery", domain.AuthorizationStatusPendingDelivery, domain.AuthorizationStatusExpired, true},
{"abandon active", domain.AuthorizationStatusActive, domain.AuthorizationStatusAbandoned, true}, {"supersede pending delivery", domain.AuthorizationStatusPendingDelivery, domain.AuthorizationStatusSuperseded, true},
{"expire claimed before fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusExpired, true}, {"expire before fence", domain.AuthorizationStatusExecuting, domain.AuthorizationStatusExpired, true},
{"abandon claimed before fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusAbandoned, true}, {"supersede before fence", domain.AuthorizationStatusDelivered, domain.AuthorizationStatusSuperseded, true},
{"fenced authorization cannot expire", domain.AuthorizationStatusFenced, domain.AuthorizationStatusExpired, false}, {"fenced authorization cannot expire", domain.AuthorizationStatusFenced, domain.AuthorizationStatusExpired, false},
{"fenced authorization cannot be abandoned", domain.AuthorizationStatusFenced, domain.AuthorizationStatusAbandoned, false}, {"fenced authorization cannot be superseded", domain.AuthorizationStatusFenced, domain.AuthorizationStatusSuperseded, false},
{"fenced authorization cannot be claimed again", domain.AuthorizationStatusFenced, domain.AuthorizationStatusClaimed, false}, {"fenced authorization cannot be delivered again", domain.AuthorizationStatusFenced, domain.AuthorizationStatusDelivered, false},
{"consumed authorization cannot restart", domain.AuthorizationStatusConsumed, domain.AuthorizationStatusClaimed, false}, {"consumed authorization cannot restart", domain.AuthorizationStatusConsumed, domain.AuthorizationStatusDelivered, false},
} }
for _, test := range tests { for _, test := range tests {
-74
View File
@@ -1,74 +0,0 @@
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: {},
},
}
@@ -1,34 +0,0 @@
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
@@ -0,0 +1,16 @@
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
}
+4 -4
View File
@@ -20,12 +20,12 @@ type OrderSubmission struct {
ID string ID string
TaskID string TaskID string
AuthorizationID string AuthorizationID string
AttemptID string CommandID string
DryRunID string
Status SubmissionStatus Status SubmissionStatus
Gate1UnitPrice string VerifiedUnitPrice string
Gate2UnitPrice string
QuantityRead int QuantityRead int
ConfirmAmount string ConfirmPageAmount string
CreatedAt time.Time CreatedAt time.Time
ResolvedAt *time.Time ResolvedAt *time.Time
} }
-1
View File
@@ -20,7 +20,6 @@ func TestSubmissionTransitions(t *testing.T) {
{"cannot reopen fenced submission", domain.SubmissionStatusSubmitted, domain.SubmissionStatusFenced, false}, {"cannot reopen fenced submission", domain.SubmissionStatusSubmitted, domain.SubmissionStatusFenced, false},
{"submitted cannot require reconciliation", domain.SubmissionStatusSubmitted, domain.SubmissionStatusReconciliationRequired, false}, {"submitted cannot require reconciliation", domain.SubmissionStatusSubmitted, domain.SubmissionStatusReconciliationRequired, false},
{"cannot skip reconciliation", domain.SubmissionStatusFenced, domain.SubmissionStatusManualResolved, 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 { for _, test := range tests {
+19 -13
View File
@@ -14,12 +14,15 @@ const (
TaskStatusDraft TaskStatus = "DRAFT" TaskStatusDraft TaskStatus = "DRAFT"
TaskStatusPending TaskStatus = "PENDING" TaskStatusPending TaskStatus = "PENDING"
TaskStatusClaimed TaskStatus = "CLAIMED" TaskStatusClaimed TaskStatus = "CLAIMED"
TaskStatusRunning TaskStatus = "RUNNING"
TaskStatusWaitingConfirmation TaskStatus = "WAITING_CONFIRMATION"
TaskStatusPendingRetrial TaskStatus = "PENDING_RETRIAL"
TaskStatusAuthorized TaskStatus = "AUTHORIZED"
TaskStatusOrdering TaskStatus = "ORDERING" TaskStatusOrdering TaskStatus = "ORDERING"
TaskStatusWaitingPayment TaskStatus = "WAITING_PAYMENT" TaskStatusWaitingPayment TaskStatus = "WAITING_PAYMENT"
TaskStatusReconciliationRequired TaskStatus = "RECONCILIATION_REQUIRED" TaskStatusReconciliationRequired TaskStatus = "RECONCILIATION_REQUIRED"
TaskStatusNeedsManual TaskStatus = "NEEDS_MANUAL" TaskStatusNeedsManual TaskStatus = "NEEDS_MANUAL"
TaskStatusSucceeded TaskStatus = "SUCCEEDED" TaskStatusSucceeded TaskStatus = "SUCCEEDED"
TaskStatusFailed TaskStatus = "FAILED"
TaskStatusCanceled TaskStatus = "CANCELED" TaskStatusCanceled TaskStatus = "CANCELED"
) )
@@ -66,31 +69,34 @@ func TransitionTask(current, next TaskStatus) (TaskStatus, error) {
var taskTransitions = map[TaskStatus]map[TaskStatus]struct{}{ var taskTransitions = map[TaskStatus]map[TaskStatus]struct{}{
TaskStatusDraft: { TaskStatusDraft: {
TaskStatusPending: {}, TaskStatusPending: {},
TaskStatusCanceled: {},
}, },
TaskStatusPending: { TaskStatusPending: {
TaskStatusClaimed: {}, TaskStatusClaimed: {},
TaskStatusDraft: {}, },
TaskStatusCanceled: {}, TaskStatusPendingRetrial: {
TaskStatusClaimed: {},
}, },
TaskStatusClaimed: { TaskStatusClaimed: {
TaskStatusRunning: {},
TaskStatusPending: {},
},
TaskStatusRunning: {
TaskStatusWaitingConfirmation: {},
TaskStatusNeedsManual: {},
},
TaskStatusWaitingConfirmation: {
TaskStatusCanceled: {},
TaskStatusAuthorized: {},
},
TaskStatusAuthorized: {
TaskStatusOrdering: {}, TaskStatusOrdering: {},
TaskStatusDraft: {},
}, },
TaskStatusOrdering: { TaskStatusOrdering: {
TaskStatusNeedsManual: {}, TaskStatusNeedsManual: {},
TaskStatusWaitingPayment: {}, TaskStatusWaitingPayment: {},
TaskStatusReconciliationRequired: {}, TaskStatusReconciliationRequired: {},
}, },
TaskStatusNeedsManual: {
TaskStatusDraft: {},
TaskStatusCanceled: {},
},
TaskStatusWaitingPayment: { TaskStatusWaitingPayment: {
TaskStatusSucceeded: {}, TaskStatusSucceeded: {},
}, },
TaskStatusReconciliationRequired: {
TaskStatusWaitingPayment: {},
TaskStatusFailed: {},
},
} }
+12 -14
View File
@@ -14,24 +14,22 @@ func TestTaskTransitions(t *testing.T) {
next domain.TaskStatus next domain.TaskStatus
allowed bool allowed bool
}{ }{
{"start purchase", domain.TaskStatusDraft, domain.TaskStatusPending, true}, {"start trial", domain.TaskStatusDraft, domain.TaskStatusPending, true},
{"cancel draft before fence", domain.TaskStatusDraft, domain.TaskStatusCanceled, true}, {"claim trial", domain.TaskStatusPending, domain.TaskStatusClaimed, true},
{"claim purchase", domain.TaskStatusPending, domain.TaskStatusClaimed, true}, {"claim retrial", domain.TaskStatusPendingRetrial, domain.TaskStatusClaimed, true},
{"release expired authorization", domain.TaskStatusPending, domain.TaskStatusDraft, true}, {"start trial execution", domain.TaskStatusClaimed, domain.TaskStatusRunning, true},
{"start ordering", domain.TaskStatusClaimed, domain.TaskStatusOrdering, true}, {"release unstarted claim", domain.TaskStatusClaimed, domain.TaskStatusPending, true},
{"release unstarted claim", domain.TaskStatusClaimed, domain.TaskStatusDraft, true}, {"trial completes", domain.TaskStatusRunning, domain.TaskStatusWaitingConfirmation, true},
{"ordering needs manual review", domain.TaskStatusOrdering, domain.TaskStatusNeedsManual, 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},
{"order reaches payment", domain.TaskStatusOrdering, domain.TaskStatusWaitingPayment, true}, {"order reaches payment", domain.TaskStatusOrdering, domain.TaskStatusWaitingPayment, true},
{"order needs manual review before fence", domain.TaskStatusOrdering, domain.TaskStatusNeedsManual, true}, {"order needs manual review before fence", domain.TaskStatusOrdering, domain.TaskStatusNeedsManual, true},
{"order needs reconciliation", domain.TaskStatusOrdering, domain.TaskStatusReconciliationRequired, 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}, {"payment verified", domain.TaskStatusWaitingPayment, domain.TaskStatusSucceeded, true},
{"reconcile confirms waiting payment", domain.TaskStatusReconciliationRequired, domain.TaskStatusWaitingPayment, true}, {"cannot skip trial", domain.TaskStatusDraft, domain.TaskStatusAuthorized, false},
{"reconcile confirms failed", domain.TaskStatusReconciliationRequired, domain.TaskStatusFailed, true}, {"trial cannot enter order leg", domain.TaskStatusRunning, domain.TaskStatusOrdering, false},
{"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}, {"terminal task cannot restart", domain.TaskStatusSucceeded, domain.TaskStatusPending, false},
{"unknown status is rejected", domain.TaskStatus("UNKNOWN"), domain.TaskStatusPending, false}, {"unknown status is rejected", domain.TaskStatus("UNKNOWN"), domain.TaskStatusPending, false},
} }
+158 -276
View File
@@ -3,11 +3,8 @@ package migrations_test
import ( import (
"context" "context"
"database/sql" "database/sql"
"os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv"
"strings"
"testing" "testing"
"cmbuyer/admin/internal/migrations" "cmbuyer/admin/internal/migrations"
@@ -16,8 +13,6 @@ import (
"github.com/pressly/goose/v3" "github.com/pressly/goose/v3"
) )
const migrationTime = "2026-08-04T00:00:00Z"
func TestUpDownAndIdempotence(t *testing.T) { func TestUpDownAndIdempotence(t *testing.T) {
database := openTestDatabase(t) database := openTestDatabase(t)
directory := migrationDirectory(t) directory := migrationDirectory(t)
@@ -26,325 +21,173 @@ func TestUpDownAndIdempotence(t *testing.T) {
if err := migrations.Up(context, database, directory); err != nil { if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("apply migrations: %v", err) t.Fatalf("apply migrations: %v", err)
} }
assertVersion(t, database, 2) assertVersion(t, database, 1)
assertTableExists(t, database, "tasks", true) assertTableExists(t, database, "tasks", true)
assertTableExists(t, database, "spec_trials", false) assertTableExists(t, database, "spec_trials", true)
assertTableExists(t, database, "order_authorizations", true) assertTableExists(t, database, "order_authorizations", true)
assertTableExists(t, database, "purchase_attempts", true)
assertTableExists(t, database, "order_submissions", true) assertTableExists(t, database, "order_submissions", true)
assertTableExists(t, database, "single_pass_upgrade_guard", false)
if err := migrations.Up(context, database, directory); err != nil { if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("reapply migrations: %v", err) t.Fatalf("reapply migrations: %v", err)
} }
assertVersion(t, database, 2) assertVersion(t, database, 1)
if err := migrations.Down(context, database, directory); err != nil { if err := migrations.Down(context, database, directory); err != nil {
t.Fatalf("roll back v2 migration: %v", err) t.Fatalf("roll back migration: %v", err)
} }
assertVersion(t, database, 1) assertVersion(t, database, 0)
assertTableExists(t, database, "spec_trials", true) assertTableExists(t, database, "tasks", false)
assertTableExists(t, database, "purchase_attempts", false) assertTableExists(t, database, "spec_trials", false)
assertTableExists(t, database, "single_pass_downgrade_guard", false) assertTableExists(t, database, "order_authorizations", false)
assertTableExists(t, database, "order_submissions", false)
if err := migrations.Up(context, database, directory); err != nil { if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("reapply v2 after rollback: %v", err) t.Fatalf("apply migration after rollback: %v", err)
}
assertVersion(t, database, 2)
}
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) 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) { func TestSchemaConstraints(t *testing.T) {
database := openTestDatabase(t) database := openTestDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil { if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("apply migrations: %v", err) t.Fatalf("apply migrations: %v", err)
} }
for _, column := range []struct{ table, name string }{
for _, column := range []struct {
table string
name string
}{
{"tasks", "max_total_price"}, {"tasks", "max_total_price"},
{"spec_trials", "unit_price"},
{"spec_trials", "total_price"},
{"order_authorizations", "authorized_unit_price"},
{"order_authorizations", "total_price_cap"}, {"order_authorizations", "total_price_cap"},
{"purchase_attempts", "gate1_unit_price"}, {"order_submissions", "verified_unit_price"},
{"purchase_attempts", "gate2_unit_price"}, {"order_submissions", "confirm_page_amount"},
{"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") assertColumnType(t, database, column.table, column.name, "TEXT")
} }
for _, legacy := range []string{"spec_trials", "authorized_unit_price", "spec_trial_id", "command_id", "dry_run_id"} {
var count int if _, err := database.Exec(`
if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE sql LIKE '%' || ? || '%'`, legacy).Scan(&count); err != nil { INSERT INTO tasks (
t.Fatalf("search schema for %s: %v", legacy, err) id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
} status, created_at, updated_at
if count != 0 { ) VALUES ('bad-quantity', 'MANUAL', 'title', 'goods', 'white', 'XL', 0, '80.00', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
t.Fatalf("legacy identifier %q remains in v2 schema", legacy) `); err == nil {
} t.Fatal("insert task with quantity 0 succeeded")
} }
insertV2Task(t, database, "task-one", "MANUAL", "DRAFT") if _, err := database.Exec(`
insertV2Task(t, database, "task-two", "MANUAL", "DRAFT") INSERT INTO tasks (
for index, value := range []string{"", "0", "0.00", "-1.00", "1e2", "1.", "1.234", " 1.00", "one"} { id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
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 { status, created_at, updated_at
t.Fatalf("invalid total price %q succeeded", value) ) 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")
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")
}
} }
func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) { if _, err := database.Exec(`
tests := []struct { INSERT INTO tasks (
name string id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
setup func(*testing.T, *sql.DB) 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')
{"authorization", func(t *testing.T, database *sql.DB) { `); err == nil {
insertV2Task(t, database, "task", "MANUAL", "DRAFT") t.Fatal("insert task with fractional quantity succeeded")
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)
}
})
}
} }
func migrateToV1(t *testing.T, database *sql.DB) { if _, err := database.Exec(`
t.Helper() INSERT INTO tasks (
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil { id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
t.Fatalf("apply v1: %v", err) 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')
assertVersion(t, database, 1) `); err == nil {
t.Fatal("insert task with trailing decimal point succeeded")
} }
func insertV1Task(t *testing.T, database *sql.DB, id, source, status, price string) { if _, err := database.Exec(`
t.Helper() INSERT INTO tasks (
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 { id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
t.Fatalf("insert v1 task: %v", err) 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 insertV1SpecTrial(t *testing.T, database *sql.DB, id, taskID string) { insertTask(t, database, "task-one")
t.Helper() 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 (?, ?, 1, 'title', 'white', 'XL', '1.00', '1.00', 'hash', ?)`, id, taskID, migrationTime); err != nil { if _, err := database.Exec(`
t.Fatalf("insert v1 spec trial: %v", err) 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 insertV1Authorization(t *testing.T, database *sql.DB, id, taskID, trialID string) { insertSpecTrial(t, database, "trial-one", "task-one")
t.Helper() 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 (?, ?, ?, 1, 'goods', 'white', 'XL', 1, '1.00', '1.00', 'PENDING_DELIVERY', 'admin', ?, ?)`, id, taskID, trialID, migrationTime, migrationTime); err != nil { if _, err := database.Exec(`
t.Fatalf("insert v1 authorization: %v", err) 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 insertV2Task(t *testing.T, database *sql.DB, id, source, status string) { insertAuthorization(t, database, "authorization-one", "task-one", "trial-one", 1)
t.Helper() insertAuthorization(t, database, "authorization-task-two", "task-two", "trial-two", 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 (?, ?, 'title', 'goods', 'white', 'XL', 1, '1.00', ?, ?, ?)`, id, source, status, migrationTime, migrationTime); err != nil { if _, err := database.Exec(`
t.Fatalf("insert v2 task: %v", err) 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 insertV2Authorization(t *testing.T, database *sql.DB, id, taskID string, version int, startKey string) { if _, err := database.Exec(`
t.Helper() INSERT INTO order_authorizations (
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 { id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity,
t.Fatalf("insert v2 authorization: %v", err) 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 insertV2Attempt(t *testing.T, database *sql.DB, id, taskID, authorizationID string, generation int) { insertSubmission(t, database, "submission-one", "authorization-one", "command-one")
t.Helper() if _, err := database.Exec(`
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 { INSERT INTO order_submissions (
t.Fatalf("insert v2 attempt: %v", err) 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 insertV2Submission(t *testing.T, database *sql.DB, id, taskID, authorizationID, attemptID string) { insertAuthorization(t, database, "authorization-two", "task-one", "trial-one", 2)
t.Helper() if _, err := database.Exec(`
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 { INSERT INTO order_submissions (
t.Fatalf("insert v2 submission: %v", err) 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 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 { func openTestDatabase(t *testing.T) *sql.DB {
t.Helper() t.Helper()
database, err := sqlite.Open(filepath.Join(t.TempDir(), "migrations.db")) database, err := sqlite.Open(filepath.Join(t.TempDir(), "migrations.db"))
if err != nil { if err != nil {
t.Fatalf("open test database: %v", err) t.Fatalf("open test database: %v", err)
} }
t.Cleanup(func() { _ = database.Close() }) t.Cleanup(func() {
if err := database.Close(); err != nil {
t.Errorf("close test database: %v", err)
}
})
return database return database
} }
@@ -354,6 +197,7 @@ func migrationDirectory(t *testing.T) string {
if !ok { if !ok {
t.Fatal("locate migration test source") t.Fatal("locate migration test source")
} }
return filepath.Join(filepath.Dir(file), "..", "..", "migrations") return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
} }
@@ -390,12 +234,50 @@ func assertColumnType(t *testing.T, database *sql.DB, table, column, want string
} }
} }
func TestV2MigrationSQLDoesNotDisableForeignKeys(t *testing.T) { func insertTask(t *testing.T, database *sql.DB, id string) {
contents, err := os.ReadFile(filepath.Join(migrationDirectory(t), "00002_single_pass_model.sql")) t.Helper()
if err != nil { if _, err := database.Exec(`
t.Fatalf("read migration: %v", err) 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)
} }
if strings.Contains(strings.ToUpper(string(contents)), "PRAGMA FOREIGN_KEYS = OFF") { }
t.Fatal("migration disables foreign keys")
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)
} }
} }
@@ -1,333 +0,0 @@
-- +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)
);
+6 -4
View File
@@ -48,7 +48,7 @@
| 路径 | 状态 | 说明 | | 路径 | 状态 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 | | `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-202) | T-111 单趟契约已完成;T-103 已恢复;T-202 在独立工作树待主审提交 | | `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-202、T-209) | T-111 单趟契约与 T-202 手工 DRAFT 建单已完成;T-103、T-209 并行推进 |
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 | | `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 | | `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 | | `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 |
@@ -65,11 +65,13 @@
- 已完成:T-002(采购工具 Python 骨架)、T-003(双端统一初始化与验证入口)、 - 已完成:T-002(采购工具 Python 骨架)、T-003(双端统一初始化与验证入口)、
T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)、T-102(canonical T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)、T-102(canonical
链接打开与目标商品/隐私人工验收)。 链接打开与目标商品/隐私人工验收)。
- 已完成 T-010(安全并行门禁)与 T-201(管理员登录与会话)。T-202 已由 admin agent 实现且 - 已完成 T-010(安全并行门禁)、T-201(管理员登录与会话)与 T-202(手工 DRAFT 建单和
测试通过,仍只创建/展示 `DRAFT`;当前在独立工作树等待主 agent 审阅、提交和推送。 基础列表)。T-202 已通过主 agent 独立审查、竞态测试与完整门禁并合入主线,仍只创建/展示
`DRAFT`,未实现授权、设备领取或采购执行。
- 已完成 T-110(受控规格入口边界)与 T-111(开始采购授权的单趟契约)。T-103 已恢复为 - 已完成 T-110(受控规格入口边界)与 T-111(开始采购授权的单趟契约)。T-103 已恢复为
`DOING`,以 `SkuSelectionFlow` 继续最小 fixture、精确规格和读价,不实现数量、确认页或提交; `DOING`,以 `SkuSelectionFlow` 继续最小 fixture、精确规格和读价,不实现数量、确认页或提交;
内部原始截图上传交给 T-204。admin 方向在 T-202 主审合入后,先落 T-209 迁移旧 schema/状态机。 内部原始截图上传交给 T-204。admin 方向已启动 T-209,先迁移旧 schema/状态机,再实现 T-203
“开始采购”授权事务。
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变 - 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
可读字段时必须先回修原型与交互清单。 可读字段时必须先回修原型与交互清单。
+73
View File
@@ -0,0 +1,73 @@
---
id: T-203
title: 表格查询与批量开始采购授权
phase: 2
deps: [T-202, T-209]
status: TODO
created: 2026-08-04
vikunja_task_id: 30
context_ref: b5f45b8
work_branch: task/t-203-start-purchases
needs_device: false
needs_human_review: false
write_paths:
- docs/tasks/T-203.md
- admin/internal/tasks/**
- admin/internal/server/**
- admin/internal/transport/webui/**
- admin/internal/config/**
- admin/cmd/server/**
- admin/README.md
---
<!-- BEGIN VIKUNJA EXPORT id=30 synced=2026-08-04T08:58:34Z sha256=bf954b6f5e557ca4cf0e50e77c27a4cd10b71f4b92036bdd02ea236a17c6156a -->
## 问题 / 背景
T-202 已完成手工 DRAFT 建单;T-209 将生产 schema/领域状态机迁移到单趟模型。项目所有者明确:管理员点击“开始采购(只创建待付款订单)”本身就是授权,不再增加试选后确认。T-203 负责采购服务查询和批量授权事务,使设备后续只能领取显式授权的 PENDING 任务。
## 关联需求与交互
F-004、F-008、F-018;US-003、US-005;IX-005;GET /tasks、POST /tasks/start-purchases;依赖 T-202、T-209。
## 方案
1. GET /tasks 支持 keyword、status、created_from、created_to;日期按 Asia/Shanghai 转为 UTC 半开区间,非法筛选返回可访问字段错误;默认全部状态并按 created_at DESC,rowid DESC。
2. 页面只让 DRAFT 行可勾选;表格上方显示选中数量、最高总额字符串合计、“系统不会付款”和唯一主按钮“开始采购(只创建待付款订单)”,不加逐行操作或重复确认弹窗。JS 只用同源静态文件,金额以分/BigInt 累计,不用浮点。
3. POST 使用服务端生成并渲染的 UUID v4 start_key,接收非空去重任务 id + expected_task_version;批量上限 100。created_by 只取已认证管理员,不接受请求字段。
4. 在一个有界 SQLite 写事务内先按 start_key 检查重放,再按 task_id 稳定排序读取并复核全部任务:存在、DRAFT、版本相等、锁定字段完整、数量/总额上限满足显式配置。任一失败整批不变。
5. 新请求为整批使用同一 created_at/expires_at;逐条创建 ACTIVE 一次性授权,锁定新 task_version、goods_id、颜色、尺码、数量和 total_price_cap;条件更新每条 DRAFT/version 为 PENDING/version+1,任一 RowsAffected != 1 则全批回滚。
6. 相同 start_key + 相同规范集合(输入顺序无关)返回原 authorization ids/版本/有效期,不再次改任务;同 key 子集、超集、不同版本或残缺集合返回 409。网络结果不明时前端冻结原 key/载荷,只允许原样重放。
7. 显式配置并启动时校验授权 TTL、最大任务数量、最大总额;金额只用规范十进制字符串。T-207 才关闭过期授权,T-203 只写 expires_at。
## 验收要点
- 覆盖单条/100条成功,任务版本仅加一次、快照逐字段相等、管理员来自服务端、UTC 过期时间正确。
- 任一缺失/非 DRAFT/版本冲突/字段不完整/数量或金额超限/中途 SQL 失败均整批零修改。
- 同 key 同集合、倒序集合、并发重放返回同一结果;同 key 不同集合冲突;不同 key 并发抢同一版本仅一个成功。
- 未登录、CSRF、空/重复/畸形/超大请求、配置边界和数据库故障 fail closed,不泄露内部错误。
- UI 只有 DRAFT 可选;筛选、全选当前可见项、选择反馈、网络不明重放、焦点/aria-live/横向滚动与不付款文案有测试;无同义确认弹窗。
- go test ./...、go test -race ./...、go vet ./...、go build ./...、node --check 静态 JS、完整 init.ps1、上下文校验和 diff-check 通过。
## 执行记录
(暂无)
<!-- END VIKUNJA EXPORT -->
## 边界
- 本任务只实现管理员任务查询与批量“开始采购”授权;不实现任务详情/截图、设备 Bearer 身份、领取/
租约、purchase attempt API、提交围栏、结果调和、真机自动化、创建订单点击或付款。
- “开始采购(只创建待付款订单)”按钮本身就是明确授权,不得再增加同义确认弹窗,也不得把它拆回
试选后确认。按钮附近必须持续显示系统不付款;页面不得提供逐行“开始采购”操作列。
- 只有 `DRAFT` 行可以勾选;批量事务必须全有或全无。任一任务缺失、状态/版本变化、锁定字段非法、
配置超限、授权插入失败或条件更新未命中,都不得留下部分授权或部分 `PENDING`。
- 幂等重放必须先于 DRAFT 状态检查:相同 `start_key` 和相同规范任务集合只返回原结果,任务版本不得
再增加;同 key 的子集、超集、不同 expected version 或残缺授权集合一律冲突。网络结果不明只能
原样重放同一个 key 和载荷,不能生成新 key。
- 授权只锁定任务的新版本、goods_id、颜色、尺码、数量和 `total_price_cap`;不得写入观察单价或
`authorized_unit_price`。金额校验、配置比较和浏览器合计均使用十进制字符串/整数分,不用浮点。
- `created_by` 只能来自已认证管理员会话;POST 必须验证 CSRF。设备凭据不能调用本接口,本任务也不
新增设备接口。内部数据库错误不得回显 SQL、路径、配置值或凭据。
- 过期授权的关闭/重置属于 T-207;本任务只创建 `expires_at`。授权一旦进入 `FENCED`,本任务没有
释放、取消、重新授权或重试入口。
- 本任务不实现、不引用通用真机点击、`submit_order_once()` 或任何支付、免密支付、先用后付能力。
+1 -1
View File
@@ -3,7 +3,7 @@ id: T-209
title: 把核心 schema / 状态机迁移为单趟模型 title: 把核心 schema / 状态机迁移为单趟模型
phase: 2 phase: 2
deps: [T-004, T-111] deps: [T-004, T-111]
status: DONE status: DOING
created: 2026-08-04 created: 2026-08-04
vikunja_task_id: 29 vikunja_task_id: 29
context_ref: da540bf context_ref: da540bf