Compare commits

...
24 changed files with 1899 additions and 303 deletions
+3
View File
@@ -8,6 +8,7 @@
| `CMBUYER_ADMIN_PASSWORD_BCRYPT` | 非空 bcrypt 密码哈希,不接受明文密码。 |
| `CMBUYER_SESSION_SECRET` | 至少 32 字节的会话签名密钥。 |
| `CMBUYER_COOKIE_SECURE` | 可选;存在时只能精确为 `true` 或 `false`。HTTPS 部署应设为 `true`。 |
| `CMBUYER_DATABASE_SOURCE` | 已迁移 SQLite 的显式 data source。 |
示例仅展示变量名,不提供可运行凭据:
@@ -16,6 +17,8 @@ $env:CMBUYER_ADMIN_USERNAME = '<管理员账号>'
$env:CMBUYER_ADMIN_PASSWORD_BCRYPT = '<bcrypt 密码哈希>'
$env:CMBUYER_SESSION_SECRET = '<至少 32 字节的随机密钥>'
$env:CMBUYER_COOKIE_SECURE = 'true'
$env:CMBUYER_DATABASE_SOURCE = '<SQLite data source>'
go run ./cmd/migrate -database $env:CMBUYER_DATABASE_SOURCE up
go run ./cmd/server
```
+12
View File
@@ -8,6 +8,8 @@ import (
"cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/config"
"cmbuyer/admin/internal/server"
"cmbuyer/admin/internal/storage/sqlite"
"cmbuyer/admin/internal/tasks"
)
const listenAddress = ":8080"
@@ -23,11 +25,21 @@ func run() error {
if err != nil {
return err
}
database, err := sqlite.Open(configuration.DatabaseSource)
if err != nil {
return err
}
defer database.Close()
taskStore, err := tasks.NewSQLiteStore(database)
if err != nil {
return err
}
router, err := server.NewRouter(server.Options{
AdminUsername: configuration.AdminUsername,
AdminPasswordBcrypt: configuration.AdminPasswordBcrypt,
Sessions: auth.NewManager(configuration.SessionSecret, configuration.CookieSecure),
Tasks: taskStore,
})
if err != nil {
return err
+7
View File
@@ -15,6 +15,7 @@ const (
adminPasswordBcryptEnv = "CMBUYER_ADMIN_PASSWORD_BCRYPT"
sessionSecretEnv = "CMBUYER_SESSION_SECRET"
cookieSecureEnv = "CMBUYER_COOKIE_SECURE"
databaseSourceEnv = "CMBUYER_DATABASE_SOURCE"
minimumSecretLength = 32
)
@@ -24,6 +25,7 @@ type Config struct {
AdminPasswordBcrypt string
SessionSecret []byte
CookieSecure bool
DatabaseSource string
}
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
@@ -65,12 +67,17 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
return Config{}, fmt.Errorf("%s must be exactly true or false", cookieSecureEnv)
}
}
databaseSource, err := required(lookup, databaseSourceEnv)
if err != nil {
return Config{}, err
}
return Config{
AdminUsername: username,
AdminPasswordBcrypt: passwordHash,
SessionSecret: []byte(secret),
CookieSecure: cookieSecure,
DatabaseSource: databaseSource,
}, nil
}
+3
View File
@@ -20,6 +20,7 @@ func TestLoad(t *testing.T) {
"CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash),
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
"CMBUYER_COOKIE_SECURE": "true",
"CMBUYER_DATABASE_SOURCE": ":memory:",
}
got, err := config.Load(lookup(values))
@@ -41,6 +42,7 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
"CMBUYER_ADMIN_USERNAME": "admin",
"CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash),
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
"CMBUYER_DATABASE_SOURCE": ":memory:",
}
tests := []struct {
@@ -52,6 +54,7 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
{"invalid bcrypt", func(values map[string]string) { values["CMBUYER_ADMIN_PASSWORD_BCRYPT"] = "not-a-bcrypt-hash" }, "CMBUYER_ADMIN_PASSWORD_BCRYPT"},
{"short secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = "short" }, "CMBUYER_SESSION_SECRET"},
{"invalid secure flag", func(values map[string]string) { values["CMBUYER_COOKIE_SECURE"] = "1" }, "CMBUYER_COOKIE_SECURE"},
{"missing database", func(values map[string]string) { delete(values, "CMBUYER_DATABASE_SOURCE") }, "CMBUYER_DATABASE_SOURCE"},
}
for _, test := range tests {
+10 -24
View File
@@ -10,28 +10,24 @@ var ErrInvalidAuthorizationTransition = errors.New("invalid authorization status
type AuthorizationStatus string
const (
AuthorizationStatusPendingDelivery AuthorizationStatus = "PENDING_DELIVERY"
AuthorizationStatusDelivered AuthorizationStatus = "DELIVERED"
AuthorizationStatusAcknowledged AuthorizationStatus = "ACKNOWLEDGED"
AuthorizationStatusExecuting AuthorizationStatus = "EXECUTING"
AuthorizationStatusActive AuthorizationStatus = "ACTIVE"
AuthorizationStatusClaimed AuthorizationStatus = "CLAIMED"
AuthorizationStatusFenced AuthorizationStatus = "FENCED"
AuthorizationStatusConsumed AuthorizationStatus = "CONSUMED"
AuthorizationStatusSuperseded AuthorizationStatus = "SUPERSEDED"
AuthorizationStatusExpired AuthorizationStatus = "EXPIRED"
AuthorizationStatusAbandoned AuthorizationStatus = "ABANDONED"
)
type OrderAuthorization struct {
ID string
TaskID string
SpecTrialID string
Version int
TaskVersion int
StartKey string
GoodsID string
SKUColor string
SKUSize string
Quantity int
AuthorizedUnitPrice string
TotalPriceCap string
Note *string
Status AuthorizationStatus
CreatedBy string
CreatedAt time.Time
@@ -54,25 +50,15 @@ func TransitionAuthorization(current, next AuthorizationStatus) (AuthorizationSt
}
var authorizationTransitions = map[AuthorizationStatus]map[AuthorizationStatus]struct{}{
AuthorizationStatusPendingDelivery: {
AuthorizationStatusDelivered: {},
AuthorizationStatusSuperseded: {},
AuthorizationStatusActive: {
AuthorizationStatusClaimed: {},
AuthorizationStatusExpired: {},
AuthorizationStatusAbandoned: {},
},
AuthorizationStatusDelivered: {
AuthorizationStatusAcknowledged: {},
AuthorizationStatusSuperseded: {},
AuthorizationStatusExpired: {},
},
AuthorizationStatusAcknowledged: {
AuthorizationStatusExecuting: {},
AuthorizationStatusSuperseded: {},
AuthorizationStatusExpired: {},
},
AuthorizationStatusExecuting: {
AuthorizationStatusClaimed: {
AuthorizationStatusFenced: {},
AuthorizationStatusSuperseded: {},
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
}
+4 -4
View File
@@ -20,12 +20,12 @@ type OrderSubmission struct {
ID string
TaskID string
AuthorizationID string
CommandID string
DryRunID string
AttemptID string
Status SubmissionStatus
VerifiedUnitPrice string
Gate1UnitPrice string
Gate2UnitPrice string
QuantityRead int
ConfirmPageAmount string
ConfirmAmount string
CreatedAt time.Time
ResolvedAt *time.Time
}
+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 {
+13 -19
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"
)
@@ -69,34 +66,31 @@ func TransitionTask(current, next TaskStatus) (TaskStatus, error) {
var taskTransitions = map[TaskStatus]map[TaskStatus]struct{}{
TaskStatusDraft: {
TaskStatusPending: {},
TaskStatusCanceled: {},
},
TaskStatusPending: {
TaskStatusClaimed: {},
},
TaskStatusPendingRetrial: {
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")
}
}
+141 -12
View File
@@ -9,6 +9,7 @@ import (
"strings"
"cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/tasks"
"cmbuyer/admin/internal/transport/webui"
"github.com/gin-gonic/gin"
@@ -22,11 +23,12 @@ type Options struct {
AdminUsername string
AdminPasswordBcrypt string
Sessions *auth.Manager
Tasks tasks.Store
}
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
func NewRouter(options Options) (*gin.Engine, error) {
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil {
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil {
return nil, errors.New("server authentication options are incomplete")
}
@@ -38,6 +40,8 @@ func NewRouter(options Options) (*gin.Engine, error) {
router.POST("/login", login(options))
router.POST("/logout", logout(options))
router.GET("/tasks", tasksPage(options))
router.GET("/tasks/new", newTaskPage(options))
router.POST("/tasks", createTask(options))
return router, nil
}
@@ -70,11 +74,14 @@ func loginPage(options Options) gin.HandlerFunc {
func login(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
limitFormBody(context)
csrfToken := context.PostForm("csrf_token")
returnPath := returnTo(context.PostForm("return_to"))
username := context.PostForm("username")
password := context.PostForm("password")
if !parseForm(context) {
return
}
form := context.Request.PostForm
csrfToken := form.Get("csrf_token")
returnPath := returnTo(form.Get("return_to"))
username := form.Get("username")
password := form.Get("password")
if _, ok := options.Sessions.VerifyCSRF(context.Request, csrfToken); !ok {
newCSRF, _ := options.Sessions.Ensure(context.Writer, context.Request)
@@ -97,8 +104,10 @@ func login(options Options) gin.HandlerFunc {
func logout(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
limitFormBody(context)
authenticated, ok := options.Sessions.VerifyCSRF(context.Request, context.PostForm("csrf_token"))
if !parseForm(context) {
return
}
authenticated, ok := options.Sessions.VerifyCSRF(context.Request, context.Request.PostForm.Get("csrf_token"))
if !ok || !authenticated {
context.Status(http.StatusForbidden)
return
@@ -117,10 +126,111 @@ func tasksPage(options Options) gin.HandlerFunc {
return
}
context.Header("Content-Type", "text/html; charset=utf-8")
if err := webui.RenderTasks(context.Writer, webui.TasksData{CSRFToken: csrfToken}); err != nil {
_ = context.Error(err)
drafts, err := options.Tasks.ListDrafts(context.Request.Context())
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
data := webui.TasksData{CSRFToken: csrfToken, Drafts: drafts}
for _, draft := range drafts {
if draft.ID == context.Query("created") {
data.Success = true
break
}
}
if context.Query("create") == "1" {
form, err := newTaskForm()
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
data.OpenForm = true
data.Form = form
data.FocusField = "title"
}
renderTasks(context, http.StatusOK, data)
}
}
func newTaskPage(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
csrf, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
if !authenticated {
context.Redirect(http.StatusSeeOther, "/login?return_to=%2Ftasks%2Fnew")
return
}
form, err := newTaskForm()
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
renderTasks(context, http.StatusOK, webui.TasksData{CSRFToken: csrf, Form: form, FullPage: true, FocusField: "title"})
}
}
func createTask(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
if !parseForm(context) {
return
}
requestForm := context.Request.PostForm
authenticated, csrfOK := options.Sessions.VerifyCSRF(context.Request, requestForm.Get("csrf_token"))
if !csrfOK || !authenticated {
context.Status(http.StatusForbidden)
return
}
form := taskForm(requestForm)
draft, validation := tasks.Validate(form)
if draft.GoodsID != "" {
form.ProductURL = tasks.CanonicalURL(draft.GoodsID)
}
fullPage := requestForm.Get("form_mode") == "full"
if !validation.Valid() {
drafts, err := options.Tasks.ListDrafts(context.Request.Context())
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
renderTasks(context, http.StatusBadRequest, webui.TasksData{CSRFToken: csrfFor(context, options), Drafts: drafts, Form: form, Errors: validation, OpenForm: !fullPage, FullPage: fullPage, FocusField: firstError(validation)})
return
}
created, err := options.Tasks.CreateDraft(context.Request.Context(), draft)
if err != nil {
if errors.Is(err, tasks.ErrCreateKeyConflict) {
validation["create_key"] = "该创建请求已用于另一条任务,请重新打开表单。"
drafts, listErr := options.Tasks.ListDrafts(context.Request.Context())
if listErr != nil {
context.Status(http.StatusInternalServerError)
return
}
renderTasks(context, http.StatusConflict, webui.TasksData{CSRFToken: csrfFor(context, options), Drafts: drafts, Form: form, Errors: validation, OpenForm: !fullPage, FullPage: fullPage, FocusField: firstError(validation)})
return
}
context.Status(http.StatusInternalServerError)
return
}
context.Redirect(http.StatusSeeOther, "/tasks?created="+url.QueryEscape(created.ID))
}
}
func newTaskForm() (tasks.Form, error) {
key, err := tasks.NewCreateKey()
if err != nil {
return tasks.Form{}, err
}
return tasks.Form{CreateKey: key}, nil
}
func taskForm(form url.Values) tasks.Form {
return tasks.Form{CreateKey: form.Get("create_key"), Title: form.Get("title"), ProductURL: form.Get("product_url"), SKUColor: form.Get("sku_color"), SKUSize: form.Get("sku_size"), Quantity: form.Get("quantity"), MaxTotalPrice: form.Get("max_total_price")}
}
func csrfFor(context *gin.Context, options Options) string {
csrf, _ := options.Sessions.Ensure(context.Writer, context.Request)
return csrf
}
func renderTasks(context *gin.Context, status int, data webui.TasksData) {
context.Header("Content-Type", "text/html; charset=utf-8")
context.Status(status)
if err := webui.RenderTasks(context.Writer, data); err != nil {
_ = context.Error(err)
}
}
@@ -137,8 +247,27 @@ func renderLogin(context *gin.Context, status int, csrfToken, returnPath, userna
}
}
func limitFormBody(context *gin.Context) {
func parseForm(context *gin.Context) bool {
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxFormBytes)
if err := context.Request.ParseForm(); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
context.Status(http.StatusRequestEntityTooLarge)
} else {
context.Status(http.StatusBadRequest)
}
return false
}
return true
}
func firstError(validation tasks.Errors) string {
for _, field := range []string{"title", "product_url", "sku_color", "sku_size", "quantity", "max_total_price"} {
if _, ok := validation[field]; ok {
return field
}
}
return "title"
}
func returnTo(value string) string {
+188
View File
@@ -1,6 +1,7 @@
package server_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
@@ -10,12 +11,14 @@ import (
"cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/server"
"cmbuyer/admin/internal/tasks"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
var csrfPattern = regexp.MustCompile(`name="csrf_token" value="([^"]+)"`)
var createKeyPattern = regexp.MustCompile(`name="create_key" value="([^"]+)"`)
func TestHealthzIsPublic(t *testing.T) {
router, _ := newRouter(t)
@@ -175,6 +178,149 @@ func TestTamperedCookieCannotAccessTasks(t *testing.T) {
}
func TestTaskCreationRendersSharedFormsAndPersistsOnlyDraft(t *testing.T) {
router, _ := newRouter(t)
cookie := authenticate(t, router)
modal := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
if modal.Code != http.StatusOK {
t.Fatalf("GET dialog form status = %d, want 200", modal.Code)
}
fullPage := serve(router, http.MethodGet, "/tasks/new", nil, cookie)
if fullPage.Code != http.StatusOK {
t.Fatalf("GET full form status = %d, want 200", fullPage.Code)
}
for _, want := range []string{`<div class="modal-scrim"`, `<dialog open`, `aria-modal="true"`, `name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `type="url" inputmode="url" maxlength="2048"`, `type="number" inputmode="numeric" min="1" step="1"`, `inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?"`, `maxlength="120"`, `maxlength="80"`, `required`, `autofocus`, `导入</button><a class="button primary"`, `type="search" disabled`, `disabled>筛选</button>`, `disabled>清除</button>`, `min-height:44px`, `overflow-x:auto`, `prefers-reduced-motion`} {
if !strings.Contains(modal.Body.String(), want) {
t.Fatalf("dialog form is missing %q", want)
}
}
for _, want := range []string{`name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `name="form_mode" value="full"`} {
if !strings.Contains(fullPage.Body.String(), want) {
t.Fatalf("full-page form is missing %q", want)
}
}
invalid := serve(router, http.MethodPost, "/tasks", url.Values{
"csrf_token": {csrfToken(t, modal.Body.String())},
"create_key": {createKey(t, modal.Body.String())},
"title": {`<script>alert(1)</script>`},
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&uin=discard"},
"sku_color": {"black"},
"sku_size": {"M"},
"quantity": {"0"},
"max_total_price": {"12.80"},
"form_mode": {"dialog"},
}, cookie)
if invalid.Code != http.StatusBadRequest || !strings.Contains(invalid.Body.String(), `<dialog open`) || !strings.Contains(invalid.Body.String(), "数量必须是正整数") || !strings.Contains(invalid.Body.String(), `role="alert"`) || !strings.Contains(invalid.Body.String(), `href="#quantity"`) || !strings.Contains(invalid.Body.String(), `aria-describedby="quantity-error"`) || !strings.Contains(invalid.Body.String(), `autofocus`) {
t.Fatalf("invalid create = (%d, %q), want dialog validation response", invalid.Code, invalid.Body.String())
}
if strings.Contains(invalid.Body.String(), `<script>alert(1)</script>`) || !strings.Contains(invalid.Body.String(), `&lt;script&gt;alert(1)&lt;/script&gt;`) {
t.Fatalf("invalid create did not safely preserve title: %q", invalid.Body.String())
}
if strings.Contains(invalid.Body.String(), "uin=discard") || !strings.Contains(invalid.Body.String(), `value="https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"`) {
t.Fatalf("invalid create did not canonicalize product URL: %q", invalid.Body.String())
}
createPage := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
key := createKey(t, createPage.Body.String())
created := serve(router, http.MethodPost, "/tasks", url.Values{
"csrf_token": {csrfToken(t, createPage.Body.String())},
"create_key": {key},
"title": {"<b>夏季上衣</b>"},
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=discard"},
"sku_color": {"black"},
"sku_size": {"M"},
"quantity": {"2"},
"max_total_price": {"12.8"},
"form_mode": {"dialog"},
}, cookie)
if created.Code != http.StatusSeeOther || !strings.HasPrefix(created.Header().Get("Location"), "/tasks?created=") {
t.Fatalf("valid create = (%d, %q), want 303 to a created-task acknowledgement", created.Code, created.Header().Get("Location"))
}
replay := serve(router, http.MethodPost, "/tasks", url.Values{
"csrf_token": {csrfToken(t, createPage.Body.String())},
"create_key": {key},
"title": {"<b>夏季上衣</b>"},
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=discard"},
"sku_color": {"black"},
"sku_size": {"M"},
"quantity": {"2"},
"max_total_price": {"12.8"},
"form_mode": {"dialog"},
}, cookie)
if replay.Code != http.StatusSeeOther {
t.Fatalf("idempotent replay status = %d, want 303", replay.Code)
}
conflict := serve(router, http.MethodPost, "/tasks", url.Values{
"csrf_token": {csrfToken(t, createPage.Body.String())},
"create_key": {key},
"title": {"different task"},
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"},
"sku_color": {"black"},
"sku_size": {"M"},
"quantity": {"2"},
"max_total_price": {"12.80"},
"form_mode": {"dialog"},
}, cookie)
if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "该创建请求已用于另一条任务") {
t.Fatalf("conflicting create = (%d, %q), want a 409 form error", conflict.Code, conflict.Body.String())
}
list := serve(router, http.MethodGet, created.Header().Get("Location"), nil, cookie)
if list.Code != http.StatusOK {
t.Fatalf("GET /tasks status = %d, want 200", list.Code)
}
body := list.Body.String()
for _, want := range []string{`任务已创建,已显示在列表首行。`, `&lt;b&gt;夏季上衣&lt;/b&gt;`, `https://mobile.yangkeduo.com/goods.html?goods_id=937122477375`, `target="_blank"`, `rel="noopener noreferrer"`, `¥12.80`, `待开始`, `选择全部任务`, `选择任务`} {
if !strings.Contains(body, want) {
t.Fatalf("task list is missing %q", want)
}
}
for _, forbidden := range []string{"utm_source", "试选", "PENDING", "支付", "订单确认", "真机", "提交订单"} {
if strings.Contains(body, forbidden) {
t.Fatalf("task list exposed deferred scope %q", forbidden)
}
}
}
func TestTaskCreationRequiresAuthenticationAndCSRF(t *testing.T) {
router, _ := newRouter(t)
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, nil); response.Code != http.StatusForbidden {
t.Fatalf("anonymous POST /tasks = %d, want 403", response.Code)
}
cookie := authenticate(t, router)
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, cookie); response.Code != http.StatusForbidden {
t.Fatalf("POST /tasks without CSRF = %d, want 403", response.Code)
}
}
func TestTaskCreationFailsClosedForMalformedOrOversizedForms(t *testing.T) {
router, _ := newRouter(t)
cookie := authenticate(t, router)
page := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
base := url.Values{
"csrf_token": {csrfToken(t, page.Body.String())},
"create_key": {createKey(t, page.Body.String())},
"title": {"title"},
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=malformed"},
"sku_color": {"black"},
"sku_size": {"M"},
"quantity": {"1"},
"max_total_price": {"1.00"},
"form_mode": {"dialog"},
}
malformed := serve(router, http.MethodPost, "/tasks", base, cookie)
if malformed.Code != http.StatusBadRequest || !strings.Contains(malformed.Body.String(), "canonical 商品链接") {
t.Fatalf("malformed URL create = (%d, %q), want validation failure", malformed.Code, malformed.Body.String())
}
oversized := url.Values{"csrf_token": {csrfToken(t, page.Body.String())}, "title": {strings.Repeat("x", 9<<10)}}
if response := serve(router, http.MethodPost, "/tasks", oversized, cookie); response.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized form status = %d, want 413", response.Code)
}
}
func assertSecurityHeaders(t *testing.T, response *httptest.ResponseRecorder) {
t.Helper()
want := map[string]string{
@@ -246,6 +392,7 @@ func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
AdminUsername: "admin",
AdminPasswordBcrypt: string(hash),
Sessions: manager,
Tasks: &memoryStore{},
})
if err != nil {
t.Fatalf("NewRouter: %v", err)
@@ -253,6 +400,24 @@ func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
return router, manager
}
type memoryStore struct{ drafts []tasks.Draft }
func (store *memoryStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) {
for _, existing := range store.drafts {
if existing.ID == draft.ID {
if existing.Title != draft.Title || existing.GoodsID != draft.GoodsID || existing.SKUColor != draft.SKUColor || existing.SKUSize != draft.SKUSize || existing.Quantity != draft.Quantity || existing.MaxTotalPrice != draft.MaxTotalPrice {
return tasks.Draft{}, tasks.ErrCreateKeyConflict
}
return existing, nil
}
}
store.drafts = append(store.drafts, draft)
return draft, nil
}
func (store *memoryStore) ListDrafts(_ context.Context) ([]tasks.Draft, error) {
return append([]tasks.Draft(nil), store.drafts...), nil
}
func serve(router http.Handler, method, target string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
var body *strings.Reader
if form == nil {
@@ -291,3 +456,26 @@ func csrfToken(t *testing.T, body string) string {
}
return matches[1]
}
func createKey(t *testing.T, body string) string {
t.Helper()
matches := createKeyPattern.FindStringSubmatch(body)
if len(matches) != 2 || matches[1] == "" {
t.Fatalf("no create key in response body: %q", body)
}
return matches[1]
}
func authenticate(t *testing.T, router http.Handler) *http.Cookie {
t.Helper()
page := serve(router, http.MethodGet, "/login", nil, nil)
login := serve(router, http.MethodPost, "/login", url.Values{
"csrf_token": {csrfToken(t, page.Body.String())},
"username": {"admin"},
"password": {"test-password"},
}, sessionCookie(t, page))
if login.Code != http.StatusSeeOther {
t.Fatalf("authenticate status = %d, want 303", login.Code)
}
return sessionCookie(t, login)
}
+129
View File
@@ -0,0 +1,129 @@
package tasks
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
const sqliteWriteTimeout = 2 * time.Second
type Store interface {
CreateDraft(context.Context, Draft) (Draft, error)
ListDrafts(context.Context) ([]Draft, error)
}
type SQLiteStore struct {
database *sql.DB
now func() time.Time
createGate chan struct{}
}
func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
if database == nil {
return nil, errors.New("database is required")
}
if _, err := database.Exec("SELECT 1 FROM tasks LIMIT 1"); err != nil {
return nil, fmt.Errorf("tasks migration is not available: %w", err)
}
return &SQLiteStore{database: database, now: time.Now, createGate: make(chan struct{}, 1)}, nil
}
func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) {
writeContext, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
defer cancel()
// SQLite permits one writer at a time. Serializing this store's short create
// transaction prevents concurrent retries of one create key from surfacing as busy.
select {
case store.createGate <- struct{}{}:
defer func() { <-store.createGate }()
case <-writeContext.Done():
return Draft{}, writeContext.Err()
}
draft.CreatedAt = store.now().UTC()
transaction, err := store.database.BeginTx(writeContext, nil)
if err != nil {
return Draft{}, err
}
defer transaction.Rollback()
_, err = transaction.ExecContext(writeContext, `INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', ?, ?, ?, ?, ?, ?, 'DRAFT', 1, ?, ?)`, draft.ID, draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.Quantity, draft.MaxTotalPrice, draft.CreatedAt.Format(time.RFC3339Nano), draft.CreatedAt.Format(time.RFC3339Nano))
if err == nil {
if err := transaction.Commit(); err != nil {
return Draft{}, err
}
return draft, nil
}
existing, found, currentPhase, lookupErr := findDraft(writeContext, transaction, draft.ID)
if lookupErr != nil {
return Draft{}, lookupErr
}
if found && currentPhase && samePayload(existing, draft) {
if err := transaction.Commit(); err != nil {
return Draft{}, err
}
return existing, nil
}
if found {
return Draft{}, ErrCreateKeyConflict
}
return Draft{}, err
}
func (store *SQLiteStore) ListDrafts(ctx context.Context) ([]Draft, error) {
// rowid makes equal timestamps deterministic: SQLite assigns it in insertion order,
// whereas UUID v4 is deliberately not time-sortable.
rows, err := store.database.QueryContext(ctx, `SELECT id, title, goods_id, sku_color, sku_size, quantity, max_total_price, created_at FROM tasks WHERE source = 'MANUAL' AND status = 'DRAFT' ORDER BY created_at DESC, rowid DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
result := []Draft{}
for rows.Next() {
draft, err := scanDraft(rows)
if err != nil {
return nil, err
}
result = append(result, draft)
}
return result, rows.Err()
}
func findDraft(ctx context.Context, transaction *sql.Tx, id string) (Draft, bool, bool, error) {
row := transaction.QueryRowContext(ctx, `SELECT id, title, goods_id, sku_color, sku_size, quantity, max_total_price, created_at, source, status, version FROM tasks WHERE id = ?`, id)
var draft Draft
var created, source, status string
var version int
err := row.Scan(&draft.ID, &draft.Title, &draft.GoodsID, &draft.SKUColor, &draft.SKUSize, &draft.Quantity, &draft.MaxTotalPrice, &created, &source, &status, &version)
if errors.Is(err, sql.ErrNoRows) {
return Draft{}, false, false, nil
}
if err != nil {
return Draft{}, false, false, err
}
parsed, err := time.Parse(time.RFC3339Nano, created)
if err != nil {
return Draft{}, false, false, err
}
draft.CreatedAt = parsed
return draft, true, source == "MANUAL" && status == "DRAFT" && version == 1, nil
}
type scanner interface{ Scan(...any) error }
func scanDraft(row scanner) (Draft, error) {
var draft Draft
var created string
if err := row.Scan(&draft.ID, &draft.Title, &draft.GoodsID, &draft.SKUColor, &draft.SKUSize, &draft.Quantity, &draft.MaxTotalPrice, &created); err != nil {
return Draft{}, err
}
parsed, err := time.Parse(time.RFC3339Nano, created)
if err != nil {
return Draft{}, err
}
draft.CreatedAt = parsed
return draft, nil
}
func samePayload(left, right Draft) bool {
return left.ID == right.ID && left.Title == right.Title && left.GoodsID == right.GoodsID && left.SKUColor == right.SKUColor && left.SKUSize == right.SKUSize && left.Quantity == right.Quantity && left.MaxTotalPrice == right.MaxTotalPrice
}
+159
View File
@@ -0,0 +1,159 @@
// Package tasks 定义手工 DRAFT 任务的校验与窄仓储边界。
package tasks
import (
"crypto/rand"
"encoding/hex"
"errors"
"net/url"
"strconv"
"strings"
"time"
)
const (
maxTitleLength = 120
maxSKUText = 80
)
var ErrCreateKeyConflict = errors.New("create key conflicts with a different task")
type Draft struct {
ID string
Title string
GoodsID string
SKUColor string
SKUSize string
Quantity int
MaxTotalPrice string
CreatedAt time.Time
}
type Form struct{ CreateKey, Title, ProductURL, SKUColor, SKUSize, Quantity, MaxTotalPrice string }
type Errors map[string]string
func (errors Errors) Valid() bool { return len(errors) == 0 }
// Validate trims and normalizes a user form. It never reads a product page or derives price data.
func Validate(form Form) (Draft, Errors) {
draft := Draft{ID: strings.TrimSpace(form.CreateKey), Title: strings.TrimSpace(form.Title), SKUColor: strings.TrimSpace(form.SKUColor), SKUSize: strings.TrimSpace(form.SKUSize)}
errors := Errors{}
if !validUUID(draft.ID) {
errors["create_key"] = "创建请求已过期,请重新打开表单。"
}
if draft.Title == "" || len([]rune(draft.Title)) > maxTitleLength {
errors["title"] = "任务名称不能为空,且不能超过 120 个字符。"
}
if draft.SKUColor == "" || len([]rune(draft.SKUColor)) > maxSKUText {
errors["sku_color"] = "颜色分类不能为空,且不能超过 80 个字符。"
}
if draft.SKUSize == "" || len([]rune(draft.SKUSize)) > maxSKUText {
errors["sku_size"] = "尺码不能为空,且不能超过 80 个字符。"
}
goodsID, ok := CanonicalGoodsID(strings.TrimSpace(form.ProductURL))
if !ok {
errors["product_url"] = "请输入唯一的 canonical 商品链接。"
} else {
draft.GoodsID = goodsID
}
quantity, err := strconv.ParseInt(strings.TrimSpace(form.Quantity), 10, 0)
if err != nil || quantity < 1 {
errors["quantity"] = "数量必须是正整数。"
} else {
draft.Quantity = int(quantity)
}
money, ok := normalizeMoney(strings.TrimSpace(form.MaxTotalPrice))
if !ok {
errors["max_total_price"] = "价格上限必须大于零,且最多两位小数。"
} else {
draft.MaxTotalPrice = money
}
return draft, errors
}
// CanonicalGoodsID only accepts the one verified manual-entry URL shape; untrusted query data is discarded.
func CanonicalGoodsID(value string) (string, bool) {
if value == "" || strings.Contains(value, "\\") || strings.Contains(value, "%") {
return "", false
}
parsed, err := url.ParseRequestURI(value)
if err != nil || parsed.Scheme != "https" || parsed.Host != "mobile.yangkeduo.com" || parsed.User != nil || parsed.Port() != "" || parsed.Path != "/goods.html" || parsed.Fragment != "" {
return "", false
}
values, err := url.ParseQuery(parsed.RawQuery)
if err != nil {
return "", false
}
goodsIDs := values["goods_id"]
if len(goodsIDs) != 1 || goodsIDs[0] == "" {
return "", false
}
for _, character := range goodsIDs[0] {
if character < '0' || character > '9' {
return "", false
}
}
return goodsIDs[0], true
}
func CanonicalURL(goodsID string) string {
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID
}
func NewCreateKey() (string, error) {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
hexValue := hex.EncodeToString(bytes)
return hexValue[0:8] + "-" + hexValue[8:12] + "-" + hexValue[12:16] + "-" + hexValue[16:20] + "-" + hexValue[20:32], nil
}
func validUUID(value string) bool {
if len(value) != 36 {
return false
}
for index, character := range value {
if index == 8 || index == 13 || index == 18 || index == 23 {
if character != '-' {
return false
}
continue
}
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
return false
}
}
return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b')
}
func normalizeMoney(value string) (string, bool) {
parts := strings.Split(value, ".")
if len(parts) > 2 || parts[0] == "" || len(parts) == 2 && (len(parts[1]) == 0 || len(parts[1]) > 2) {
return "", false
}
for _, character := range parts[0] {
if character < '0' || character > '9' {
return "", false
}
}
fraction := ""
if len(parts) == 2 {
fraction = parts[1]
for _, character := range fraction {
if character < '0' || character > '9' {
return "", false
}
}
}
whole := strings.TrimLeft(parts[0], "0")
if whole == "" {
whole = "0"
}
if whole == "0" && strings.Trim(fraction, "0") == "" {
return "", false
}
return whole + "." + (fraction + "00")[:2], true
}
+300
View File
@@ -0,0 +1,300 @@
package tasks
import (
"context"
"database/sql"
"errors"
"path/filepath"
"regexp"
"runtime"
"sync"
"testing"
"time"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
)
const testKey = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
func TestValidateNormalizesManualDraft(t *testing.T) {
draft, validation := Validate(Form{
CreateKey: " " + testKey + " ",
Title: " 夏季上衣 ",
ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=untrusted",
SKUColor: " 黑色CHA(纯棉) ",
SKUSize: " M(建议100-115) ",
Quantity: "2",
MaxTotalPrice: "00012.8",
})
if !validation.Valid() {
t.Fatalf("Validate errors = %#v", validation)
}
if draft.ID != testKey || draft.GoodsID != "937122477375" || draft.Title != "夏季上衣" || draft.SKUColor != "黑色CHA(纯棉)" || draft.SKUSize != "M(建议100-115)" || draft.Quantity != 2 || draft.MaxTotalPrice != "12.80" {
t.Fatalf("normalized draft = %#v", draft)
}
}
func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
base := Form{CreateKey: testKey, Title: "title", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", SKUColor: "black", SKUSize: "M", Quantity: "1", MaxTotalPrice: "1"}
for name, update := range map[string]func(*Form){
"empty title": func(form *Form) { form.Title = " " },
"long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) },
"fraction quantity": func(form *Form) { form.Quantity = "1.5" },
"zero quantity": func(form *Form) { form.Quantity = "0" },
"too many decimals": func(form *Form) { form.MaxTotalPrice = "1.234" },
"trailing decimal": func(form *Form) { form.MaxTotalPrice = "1." },
"zero money": func(form *Form) { form.MaxTotalPrice = "0.00" },
} {
t.Run(name, func(t *testing.T) {
form := base
update(&form)
if _, validation := Validate(form); validation.Valid() {
t.Fatal("invalid form was accepted")
}
})
}
for _, value := range []string{
"http://mobile.yangkeduo.com/goods.html?goods_id=1",
"https://yangkeduo.com/goods.html?goods_id=1",
"https://mobile.yangkeduo.com:443/goods.html?goods_id=1",
"https://user@mobile.yangkeduo.com/goods.html?goods_id=1",
"https://mobile.yangkeduo.com/goods.html?goods_id=1#fragment",
"https://mobile.yangkeduo.com/goods.html?goods_id=1&goods_id=2",
"https://mobile.yangkeduo.com/goods.html?goods_id=one",
"https://mobile.yangkeduo.com/goods.html?goods_id=%31",
"https://mobile.yangkeduo.com/goods.html?goods_id=1%26goods_id%3D2",
"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=bad",
"https://mobile.yangkeduo.com/other.html?goods_id=1",
} {
if _, ok := CanonicalGoodsID(value); ok {
t.Fatalf("CanonicalGoodsID accepted %q", value)
}
}
}
func TestNormalizeMoneyBoundaries(t *testing.T) {
for value, want := range map[string]string{"1": "1.00", "1.2": "1.20", "000.01": "0.01", "999999999999999999": "999999999999999999.00"} {
got, ok := normalizeMoney(value)
if !ok || got != want {
t.Fatalf("normalizeMoney(%q) = (%q, %t), want (%q, true)", value, got, ok, want)
}
}
for _, value := range []string{"0", "0.0", "0.00", "1.", ".1", "1.000", "-1", "1e2", " 1"} {
if got, ok := normalizeMoney(value); ok {
t.Fatalf("normalizeMoney(%q) = %q, want rejection", value, got)
}
}
}
func TestNewCreateKeyIsUUIDv4(t *testing.T) {
key, err := NewCreateKey()
if err != nil {
t.Fatalf("NewCreateKey: %v", err)
}
if !regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`).MatchString(key) {
t.Fatalf("create key %q is not UUID v4", key)
}
}
func TestSQLiteStoreRequiresMigratedDatabase(t *testing.T) {
database := openDatabase(t)
if _, err := NewSQLiteStore(database); err == nil {
t.Fatal("NewSQLiteStore accepted an unmigrated database")
}
}
func TestSQLiteStoreCreatesListsAndHandlesIdempotency(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
baseTime := time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC)
call := 0
store.now = func() time.Time {
result := baseTime.Add(time.Duration(call) * time.Minute)
call++
return result
}
first := testDraft(testKey, "first")
created, err := store.CreateDraft(context.Background(), first)
if err != nil {
t.Fatalf("create first draft: %v", err)
}
replayed, err := store.CreateDraft(context.Background(), first)
if err != nil {
t.Fatalf("replay first draft: %v", err)
}
if replayed.CreatedAt != created.CreatedAt {
t.Fatalf("replayed CreatedAt = %s, want original %s", replayed.CreatedAt, created.CreatedAt)
}
second := testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "second")
if _, err := store.CreateDraft(context.Background(), second); err != nil {
t.Fatalf("create second draft: %v", err)
}
drafts, err := store.ListDrafts(context.Background())
if err != nil {
t.Fatalf("list drafts: %v", err)
}
if len(drafts) != 2 || drafts[0].ID != second.ID || drafts[1].ID != first.ID {
t.Fatalf("draft order = %#v, want second then first", drafts)
}
var source, status string
var version int
if err := database.QueryRow(`SELECT source, status, version FROM tasks WHERE id = ?`, first.ID).Scan(&source, &status, &version); err != nil {
t.Fatalf("read stored task: %v", err)
}
if source != "MANUAL" || status != "DRAFT" || version != 1 {
t.Fatalf("stored metadata = (%q, %q, %d)", source, status, version)
}
conflicting := first
conflicting.Title = "different"
if _, err := store.CreateDraft(context.Background(), conflicting); !errors.Is(err, ErrCreateKeyConflict) {
t.Fatalf("conflicting create error = %v, want ErrCreateKeyConflict", err)
}
}
func TestSQLiteStoreRollsBackFailedCreate(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
if _, err := database.Exec(`CREATE TRIGGER reject_task BEFORE INSERT ON tasks BEGIN SELECT RAISE(ABORT, 'reject test insert'); END`); err != nil {
t.Fatalf("create trigger: %v", err)
}
if _, err := store.CreateDraft(context.Background(), testDraft(testKey, "blocked")); err == nil {
t.Fatal("CreateDraft succeeded despite rejecting trigger")
}
drafts, err := store.ListDrafts(context.Background())
if err != nil {
t.Fatalf("list after failed create: %v", err)
}
if len(drafts) != 0 {
t.Fatalf("failed create persisted drafts: %#v", drafts)
}
}
func TestSQLiteStoreUsesInsertionOrderForEqualTimesAndFiltersPhase(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
store.now = func() time.Time { return time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) }
first := testDraft(testKey, "first")
second := testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "second")
for _, draft := range []Draft{first, second} {
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
t.Fatalf("create %s: %v", draft.Title, err)
}
}
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES ('excel-draft', 'EXCEL', 'other', '1', 'black', 'M', 1, '1.00', 'DRAFT', 1, '2026-08-04T10:00:00Z', '2026-08-04T10:00:00Z'), ('manual-pending', 'MANUAL', 'other', '2', 'black', 'M', 1, '1.00', 'PENDING', 1, '2026-08-04T10:00:00Z', '2026-08-04T10:00:00Z')`); err != nil {
t.Fatalf("insert out-of-scope tasks: %v", err)
}
drafts, err := store.ListDrafts(context.Background())
if err != nil {
t.Fatalf("list drafts: %v", err)
}
if len(drafts) != 2 || drafts[0].ID != second.ID || drafts[1].ID != first.ID {
t.Fatalf("equal-time draft order/filter = %#v, want second then first only", drafts)
}
if _, err := database.Exec(`UPDATE tasks SET status = 'PENDING' WHERE id = ?`, first.ID); err != nil {
t.Fatalf("move draft outside current phase: %v", err)
}
if _, err := store.CreateDraft(context.Background(), first); !errors.Is(err, ErrCreateKeyConflict) {
t.Fatalf("replay of non-DRAFT record error = %v, want conflict", err)
}
third := testDraft("c3c9f507-7473-4fa6-8d71-8786c34c6301", "third")
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'EXCEL', ?, ?, ?, ?, ?, ?, 'DRAFT', 1, '2026-08-04T09:00:00Z', '2026-08-04T09:00:00Z')`, third.ID, third.Title, third.GoodsID, third.SKUColor, third.SKUSize, third.Quantity, third.MaxTotalPrice); err != nil {
t.Fatalf("insert same-payload EXCEL record: %v", err)
}
if _, err := store.CreateDraft(context.Background(), third); !errors.Is(err, ErrCreateKeyConflict) {
t.Fatalf("replay of non-MANUAL record error = %v, want conflict", err)
}
if _, err := database.Exec(`UPDATE tasks SET version = 2, source = 'MANUAL' WHERE id = ?`, third.ID); err != nil {
t.Fatalf("change replay record version: %v", err)
}
if _, err := store.CreateDraft(context.Background(), third); !errors.Is(err, ErrCreateKeyConflict) {
t.Fatalf("replay of non-v1 record error = %v, want conflict", err)
}
}
func TestSQLiteStoreConcurrentIdenticalCreateIsOneDraft(t *testing.T) {
store, err := NewSQLiteStore(migratedDatabase(t))
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
const callers = 20
start := make(chan struct{})
errors := make(chan error, callers)
results := make(chan Draft, callers)
var group sync.WaitGroup
for range callers {
group.Add(1)
go func() {
defer group.Done()
<-start
draft, err := store.CreateDraft(context.Background(), testDraft(testKey, "same"))
if err != nil {
errors <- err
return
}
results <- draft
}()
}
close(start)
group.Wait()
close(errors)
close(results)
for err := range errors {
t.Fatalf("concurrent create: %v", err)
}
for result := range results {
if result.ID != testKey {
t.Fatalf("concurrent result = %#v", result)
}
}
drafts, err := store.ListDrafts(context.Background())
if err != nil {
t.Fatalf("list after concurrent create: %v", err)
}
if len(drafts) != 1 || drafts[0].ID != testKey {
t.Fatalf("concurrent creates persisted %#v, want exactly one", drafts)
}
}
func testDraft(id, title string) Draft {
return Draft{ID: id, Title: title, GoodsID: "937122477375", SKUColor: "black", SKUSize: "M", Quantity: 2, MaxTotalPrice: "12.80"}
}
func openDatabase(t *testing.T) *sql.DB {
t.Helper()
database, err := sqlite.Open(filepath.Join(t.TempDir(), "tasks.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
return database
}
func migratedDatabase(t *testing.T) *sql.DB {
t.Helper()
database := openDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("migrate database: %v", err)
}
return database
}
func migrationDirectory(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate test source")
}
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
}
@@ -6,22 +6,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>采购任务 · 采购服务</title>
<style>
:root { color-scheme:light; --bg:#f4f7fb; --surface:#fff; --text:#172033; --muted:#526079; --border:#cfd8e6; --primary:#155eef; --focus:#ffbf47; font-family:"Segoe UI","Microsoft YaHei UI",system-ui,sans-serif; }
* { box-sizing:border-box; } body { min-height:100dvh; margin:0; color:var(--text); background:var(--bg); font-size:16px; line-height:1.55; } button { font:inherit; } :focus-visible { outline:3px solid var(--focus); outline-offset:3px; }
.skip-link { position:fixed; z-index:10; top:8px; left:8px; padding:10px 14px; color:#fff; background:var(--text); transform:translateY(-160%); } .skip-link:focus { transform:translateY(0); }
header { display:flex; min-height:64px; align-items:center; justify-content:space-between; gap:16px; padding:10px clamp(16px,4vw,40px); border-bottom:1px solid var(--border); background:var(--surface); }
.brand { display:flex; align-items:center; gap:10px; font-weight:700; } .brand-mark { display:grid; width:32px; height:32px; place-items:center; border-radius:8px; color:#fff; background:var(--primary); font-size:.82rem; }
.logout { min-height:44px; padding:8px 14px; border:1px solid var(--border); border-radius:8px; color:var(--text); background:var(--surface); font-weight:700; cursor:pointer; }
main { width:min(100% - 32px,760px); margin:48px auto; padding:32px; border:1px solid var(--border); border-radius:14px; background:var(--surface); }
h1 { margin:0; font-size:clamp(1.5rem,5vw,2rem); } p { color:var(--muted); } .notice { margin-top:24px; padding:14px; border-left:4px solid var(--primary); border-radius:6px; background:#eaf1ff; color:#29466f; }
@media (max-width:420px) { main { width:calc(100% - 24px); margin:24px auto; padding:24px 16px; } }
@media (prefers-reduced-motion:reduce) { *,*::before,*::after { transition-duration:.01ms !important; animation-duration:.01ms !important; } }
</style>
:root{--bg:#f4f7fb;--surface:#fff;--text:#172033;--muted:#526079;--border:#cfd8e6;--primary:#155eef;--danger:#b42318;--success:#067647;--focus:#ffbf47;font-family:"Segoe UI","Microsoft YaHei UI",system-ui,sans-serif}*{box-sizing:border-box}html{min-width:320px;background:var(--bg)}body{min-height:100dvh;margin:0;color:var(--text);background:var(--bg);font-size:16px;line-height:1.55}button,input{font:inherit}:focus-visible{outline:3px solid var(--focus);outline-offset:3px}.skip{position:fixed;z-index:100;top:8px;left:8px;padding:10px;color:#fff;background:#172033;transform:translateY(-160%)}.skip:focus{transform:translateY(0)}header{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:64px;padding:10px clamp(16px,4vw,40px);border-bottom:1px solid var(--border);background:var(--surface)}.brand{font-weight:700}.brand b{display:inline-grid;place-items:center;width:32px;height:32px;margin-right:8px;border-radius:8px;background:var(--primary);color:#fff;font-size:.82rem}.logout,.button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:9px 14px;border:1px solid var(--border);border-radius:8px;color:var(--text);background:#fff;font-weight:700;text-decoration:none;cursor:pointer}.button.primary{border-color:var(--primary);background:var(--primary);color:#fff}.button:disabled,.filter input:disabled{opacity:.5;cursor:not-allowed}main{width:min(100% - 32px,1200px);margin:32px auto}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:16px}.toolbar-actions,.filters,.actions{display:flex;flex-wrap:wrap;gap:10px}.muted,.placeholder{color:var(--muted)}.filters{align-items:end;margin:0 0 16px}.filters label{display:grid;gap:4px;font-weight:700}.filters input{min-height:44px;min-width:180px;padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:#fff}.table-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:12px;background:var(--surface)}table{width:100%;min-width:880px;border-collapse:collapse}th,td{padding:12px 14px;border-bottom:1px solid var(--border);text-align:left;vertical-align:top}th{background:#f8fafc;font-size:.88rem}td a{color:#124cc5;font-weight:700;text-underline-offset:3px}.status{display:inline-block;padding:3px 8px;border-radius:999px;background:#eaf1ff;color:#173d8f;font-size:.85rem;font-weight:700}.empty,.success{padding:20px;border:1px solid var(--border);border-radius:12px;background:var(--surface)}.success{margin:0 0 16px;border-color:#9dd9b8;background:#ecfdf3;color:var(--success)}.modal-scrim{position:fixed;z-index:20;inset:0;background:rgba(23,32,51,.52)}dialog[open]{position:fixed;z-index:30;top:50%;left:50%;width:min(calc(100% - 24px),640px);max-height:calc(100dvh - 24px);margin:0;padding:28px;overflow-y:auto;border:1px solid var(--border);border-radius:14px;box-shadow:0 18px 48px rgba(23,32,51,.24);transform:translate(-50%,-50%);background:var(--surface)}.form-page{width:min(100% - 32px,640px);margin:32px auto;padding:28px;border:1px solid var(--border);border-radius:14px;background:var(--surface)}.form-grid{display:grid;gap:16px}.field label{display:block;margin-bottom:6px;font-weight:700}.required{color:var(--danger)}.field input{width:100%;min-height:44px;padding:10px 12px;border:1px solid #9ba9bc;border-radius:8px}.field input[aria-invalid=true]{border-color:var(--danger)}.error{margin:5px 0 0;color:var(--danger);font-size:.9rem}.summary{margin:0 0 16px;padding:12px;border-left:4px solid var(--danger);background:#fef3f2;color:var(--danger)}.summary p{margin:0}.summary ul{margin:8px 0 0;padding-left:20px}.summary a{color:inherit}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:420px){main,.form-page{width:calc(100% - 24px);margin:24px auto}.toolbar{align-items:stretch;flex-direction:column}.toolbar-actions,.toolbar .button{width:100%}.toolbar-actions .button{flex:1}.filters{align-items:stretch;flex-direction:column}.filters input,.filters .button{width:100%}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition-duration:.01ms!important;animation-duration:.01ms!important}}</style>
</head>
<body>
<a class="skip-link" href="#main">跳到主要内容</a>
<header><div class="brand"><span class="brand-mark" aria-hidden="true">采</span><span>采购服务</span></div><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button class="logout" type="submit">退出登录</button></form></header>
<main id="main"><h1>采购任务</h1><p>任务功能正在准备中。</p><p class="notice">当前页面仅用于验证管理员会话。</p></main>
<a class="skip" href="#main">跳到主要内容</a>
<header><div class="brand"><b aria-hidden="true">采</b>采购服务</div><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button class="logout" type="submit">退出登录</button></form></header>
{{if .FullPage}}<main class="form-page" id="main">{{template "form" .}}</main>{{else}}<main id="main"><div class="toolbar"><div><h1>采购任务</h1><p class="muted">只显示待开始的手工任务。</p></div><div class="toolbar-actions"><button class="button" type="button" disabled>导入</button><a class="button primary" href="/tasks?create=1">创建任务</a></div></div><div class="filters" aria-label="暂不可用的列表条件"><label>关键词<input type="search" disabled></label><button class="button" type="button" disabled>筛选</button><button class="button" type="button" disabled>清除</button></div>{{if .Success}}<p class="success" role="status">任务已创建,已显示在列表首行。</p>{{end}}{{if .Drafts}}<div class="table-wrap"><table><thead><tr><th scope="col"><input type="checkbox" disabled aria-label="选择全部任务"></th><th scope="col">标题</th><th scope="col">颜色分类</th><th scope="col">尺码</th><th scope="col">价格上限</th><th scope="col">数量</th><th scope="col">采购结果</th><th scope="col">状态</th><th scope="col">创建时间</th></tr></thead><tbody>{{range .Drafts}}<tr><td><input type="checkbox" disabled aria-label="选择任务 {{.Title}}"></td><td><a href="https://mobile.yangkeduo.com/goods.html?goods_id={{.GoodsID}}" target="_blank" rel="noopener noreferrer">{{.Title}}</a></td><td>{{.SKUColor}}</td><td>{{.SKUSize}}</td><td>¥{{.MaxTotalPrice}}</td><td>{{.Quantity}}</td><td>—</td><td><span class="status">待开始</span></td><td><time datetime="{{.CreatedAt.Format "2006-01-02T15:04:05Z07:00"}}">{{.CreatedAt.Format "2006-01-02 15:04 UTC"}}</time></td></tr>{{end}}</tbody></table></div>{{else}}<section class="empty"><h2>还没有待开始任务</h2><p>创建一条手工任务后会显示在这里。</p></section>{{end}}</main>{{if .OpenForm}}<div class="modal-scrim" aria-hidden="true"></div><dialog open aria-modal="true" aria-labelledby="form-title">{{template "form" .}}</dialog>{{end}}{{end}}
</body>
</html>
{{end}}
{{define "form"}}<h1 id="form-title">创建采购任务</h1><p class="muted">保存后仅生成待开始任务,不会执行其他动作。</p>{{if .Errors}}<div class="summary" role="alert" aria-live="assertive"><p>请修正下列字段后再保存。</p><ul>{{with index .Errors "title"}}<li><a href="#title">任务名称:{{.}}</a></li>{{end}}{{with index .Errors "product_url"}}<li><a href="#product_url">商品链接:{{.}}</a></li>{{end}}{{with index .Errors "sku_color"}}<li><a href="#sku_color">颜色分类:{{.}}</a></li>{{end}}{{with index .Errors "sku_size"}}<li><a href="#sku_size">尺码:{{.}}</a></li>{{end}}{{with index .Errors "quantity"}}<li><a href="#quantity">数量:{{.}}</a></li>{{end}}{{with index .Errors "max_total_price"}}<li><a href="#max_total_price">价格上限:{{.}}</a></li>{{end}}{{with index .Errors "create_key"}}<li>{{.}}</li>{{end}}</ul></div>{{end}}<form method="post" action="/tasks" class="form-grid"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><input type="hidden" name="create_key" value="{{.Form.CreateKey}}"><input type="hidden" name="form_mode" value="{{if .FullPage}}full{{else}}dialog{{end}}">{{template "field" (list "title" "任务名称" .Form.Title .Errors .FocusField)}}{{template "field" (list "product_url" "商品链接" .Form.ProductURL .Errors .FocusField)}}{{template "field" (list "sku_color" "颜色分类" .Form.SKUColor .Errors .FocusField)}}{{template "field" (list "sku_size" "尺码" .Form.SKUSize .Errors .FocusField)}}{{template "field" (list "quantity" "数量" .Form.Quantity .Errors .FocusField)}}{{template "field" (list "max_total_price" "价格上限" .Form.MaxTotalPrice .Errors .FocusField)}}<div class="actions"><button class="button primary" type="submit">保存任务</button><a class="button" href="/tasks">取消</a></div></form>{{end}}
{{define "field"}}{{$name:=index . 0}}{{$label:=index . 1}}{{$value:=index . 2}}{{$errors:=index . 3}}{{$focus:=index . 4}}<div class="field"><label for="{{$name}}">{{$label}} <span class="required" aria-hidden="true">*</span><span class="sr-only">(必填)</span></label><input id="{{$name}}" name="{{$name}}" value="{{$value}}" required {{if eq $focus $name}}autofocus{{end}} aria-invalid="{{if index $errors $name}}true{{else}}false{{end}}"{{with index $errors $name}} aria-describedby="{{$name}}-error"{{end}} {{if eq $name "product_url"}}type="url" inputmode="url" maxlength="2048"{{else if eq $name "quantity"}}type="number" inputmode="numeric" min="1" step="1"{{else if eq $name "max_total_price"}}type="text" inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?" maxlength="64"{{else if eq $name "title"}}type="text" maxlength="120"{{else}}type="text" maxlength="80"{{end}}>{{with index $errors $name}}<p class="error" id="{{$name}}-error">{{.}}</p>{{end}}</div>{{end}}
+12 -3
View File
@@ -5,12 +5,14 @@ import (
"embed"
"html/template"
"io"
"cmbuyer/admin/internal/tasks"
)
//go:embed templates/*.html
var templateFiles embed.FS
var templates = template.Must(template.New("webui").ParseFS(templateFiles, "templates/*.html"))
var templates = template.Must(template.New("webui").Funcs(template.FuncMap{"list": func(values ...any) []any { return values }}).ParseFS(templateFiles, "templates/*.html"))
// LoginData 是登录页面所需的非敏感展示数据。
type LoginData struct {
@@ -20,9 +22,16 @@ type LoginData struct {
Error string
}
// TasksData 是当前受保护任务空壳所需的数据。任务字段将在后续任务实现。
// TasksData 是受保护的 DRAFT 建单与列表页面所需数据。
type TasksData struct {
CSRFToken string
Drafts []tasks.Draft
Form tasks.Form
Errors tasks.Errors
OpenForm bool
FullPage bool
FocusField string
Success bool
}
// RenderLogin 写入登录页。
@@ -30,7 +39,7 @@ func RenderLogin(writer io.Writer, data LoginData) error {
return templates.ExecuteTemplate(writer, "login.html", data)
}
// RenderTasks 写入登录后的受保护空壳。
// RenderTasks 写入登录后的受保护任务页。
func RenderTasks(writer io.Writer, data TasksData) error {
return templates.ExecuteTemplate(writer, "tasks.html", data)
}
@@ -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)
);
+75
View File
@@ -0,0 +1,75 @@
---
id: T-202
title: 手工建单与 DRAFT 基础列表
phase: 2
deps: [T-201, T-004, T-005]
status: DONE
created: 2026-08-04
vikunja_task_id: 27
context_ref: 1c35155
work_branch: task/t-202-admin-draft
needs_device: false
needs_human_review: false
write_paths:
- docs/tasks/T-202.md
- admin/cmd/server/main.go
- admin/internal/config/**
- admin/internal/server/**
- admin/internal/tasks/**
- admin/internal/storage/sqlite/**
- admin/internal/transport/webui/**
- admin/README.md
---
<!-- BEGIN VIKUNJA EXPORT id=27 synced=2026-08-04T08:32:16Z sha256=39e3b06bab4ca86e97b961a4eb6bb0a4f1e88b29dae4d50f916779f7e761424c -->
## 问题 / 背景
T-201 已提供管理员会话;T-004 已提供 tasks 表。根据 T-010 加速门禁,T-103 尚未完成时只允许实现不启动试选的 DRAFT 手工建单与基础列表。
## 关联需求与交互
F-001、US-001、IX-002;GET /tasks、GET /tasks/new、POST /tasks;沿用已确认的传统表格与创建弹窗/直达页。
## 方案
1. 显式数据库配置并打开已迁移 SQLite;以仓储接口隔离 HTTP 和 SQL,创建事务只写 MANUAL、DRAFT、version=1。
2. 表单校验任务名称、canonical 拼多多链接、颜色分类、尺码、正整数数量和正十进制总额上限;金额只用字符串并规范为两位小数。链接只接受 HTTPS mobile.yangkeduo.com/goods.html 且 goods_id 为唯一纯数字参数,额外查询参数不进入数据库。
3. 以服务端生成的 create_key 同时作为任务 ID;重复相同 key 和相同内容返回原结果,不创建第二条,内容不同则冲突。
4. GET /tasks 默认 created_at DESC 显示 DRAFT 基础表格;创建入口用服务端渲染的 modal 状态,/tasks/new 复用同一表单作为无脚本兜底;失败保留非密码输入并显示字段错误,成功 303 回列表且新任务第一行。
5. 页面只显示需求字段、采购结果占位、DRAFT 状态与创建时间;不读取或伪造规格面板价格/证据,不提供勾选开始试选、状态推进、详情或设备接口。
## 验收要点
- 覆盖创建成功、倒序第一行、严格链接/goods_id、数量、金额、空白/长度、CSRF/未登录、幂等重放与冲突、SQL 错误 fail closed。
- 弹窗与 /tasks/new 共享校验;错误保留输入并可访问;标题只链接到由 goods_id 重建的 canonical PDD URL并使用安全新标签属性。
- go test ./...、go test -race ./...、go vet ./...、go build ./...、完整 init.ps1、上下文校验和 diff-check 通过。
## 执行记录
### 2026-08-04T08:30:47Z · ila
已完成:DRAFT 手工建单与基础列表;已验证链接、金额、CSRF、幂等、SQLite 并发和 SSR 无障碍,Go 与上下文门禁均通过。
<!-- END VIKUNJA EXPORT -->
## 边界
- 本任务只创建 `source=MANUAL`、`status=DRAFT`、`version=1` 的任务并显示 DRAFT 基础列表;不得
实现勾选、批量开始试选、`DRAFT → PENDING` 或任何其他状态流转,也不得新增设备领取接口。
- 不增加或修改数据库 schema,不读写 `spec_trials`、`order_authorizations`、`order_submissions`,
不生成或展示机器实际规格、规格面板单价、截图、证据哈希或 PDD 页面判据。
- 启动服务必须从显式 `CMBUYER_DATABASE_SOURCE` 读取 SQLite data source;缺失时明确失败,不提供
隐式内存库或仓库内默认数据库。服务不自动猜迁移目录;README 必须先给出显式迁移命令。
- 商品链接只接受 `https://mobile.yangkeduo.com/goods.html`,且必须恰有一个纯数字 `goods_id`;
拒绝 userinfo、端口、fragment、重复参数、其他 host/scheme/path 和编码绕过。数据库只保存 goods_id,
展示链接由 goods_id 重建 canonical URL;`uin` 等额外查询参数既不保存也不回显。
- 标题、颜色分类、尺码必须去除首尾空白后非空并受明确长度上限约束;数量必须是可表示的正整数;
总额上限必须是大于零、最多两位小数的十进制字符串并规范为两位小数。金额校验、保存与展示均不得
使用浮点数或从其他数字推测。
- `create_key` 由服务端用 `crypto/rand` 生成并验证格式,同时作为任务 ID;相同 key 与相同规范化内容
重放只能返回原任务,不得二次 INSERT,相同 key 携带不同内容必须冲突。SQL 必须参数化,创建失败
不得留下半条或未知状态记录。
- `GET /tasks`、`GET /tasks/new`、`POST /tasks` 都必须复用 T-201 管理会话;POST 必须验证 CSRF。
校验失败保留非敏感输入并逐字段提示,数据库内部错误只给通用响应,不泄露 SQL、路径或凭据。
- 页面只使用服务端模板转义;标题商品链接在新标签打开时必须带 `noopener noreferrer`。导入按钮只作
禁用占位;不得加载外部资源或把原型假数据、真机数据、地址、手机号带进生产页面。
- 不实现或引用试选、数量设置、订单确认、提交围栏、提交订单、付款、免密支付或先用后付能力。
+66
View File
@@ -0,0 +1,66 @@
---
id: T-209
title: 把核心 schema / 状态机迁移为单趟模型
phase: 2
deps: [T-004, T-111]
status: DONE
created: 2026-08-04
vikunja_task_id: 29
context_ref: da540bf
work_branch: task/t-209-single-pass-schema
needs_device: false
needs_human_review: false
write_paths:
- docs/tasks/T-209.md
- admin/migrations/**
- admin/internal/migrations/**
- admin/internal/domain/**
---
<!-- BEGIN VIKUNJA EXPORT id=29 synced=2026-08-04T08:38:26Z sha256=2cac52971eda168b1d17bc4b6930f97876324d5685af26dc26495de07b44cb7d -->
## 问题 / 背景
T-111 已把业务契约改为管理员点击“开始采购”即授权桌面端在同一趟创建待付款订单;现有 SQLite v1 与领域模型仍是旧两趟结构,包含 spec_trials、authorized_unit_price 及 WAITING_CONFIRMATION/PENDING_RETRIAL/AUTHORIZED/RUNNING 等旧状态。T-203 不能在旧结构上继续实现。
## 关联需求与交互
T-111;F-004、F-005、F-008、F-017、F-018;docs/04-architecture.md 第四、五节;不包含页面选择器或真机操作。
## 方案
1. 保留 00001_core_data.sql 作为不可变迁移历史,新增 00002 单趟模型迁移;迁移只允许保留既有 MANUAL+DRAFT 任务,发现任一旧 spec_trials/authorization/submission 数据或非 DRAFT 任务即整体失败并回滚,不能猜测映射。
2. 重建 tasks 的状态约束为 DRAFT、PENDING、CLAIMED、ORDERING、NEEDS_MANUAL、WAITING_PAYMENT、RECONCILIATION_REQUIRED、SUCCEEDED、FAILED、CANCELED;保留 DRAFT 内容、版本与时间。
3. 删除旧 spec_trials 结构;重建 order_authorizations,锁定 task_version/start_key/goods_id/颜色/尺码/数量/total_price_cap,状态仅 ACTIVE/CLAIMED/FENCED/CONSUMED/EXPIRED/ABANDONED,不保存 observed/authorized unit price。
4. 新增 purchase_attempts 保存 claim generation、三闸门摘要和固定 failure code;重建 order_submissions,原子关联同一 task/authorization/attempt,保存 gate1、gate2、quantity、confirm amount,并限制一份授权/attempt 最多一条围栏。
5. 同步 Go 领域实体和 fail-closed 状态机;围栏前允许安全失败/重置,FENCED 后授权只能 CONSUMED,提交结果不明只能调和同一记录,任何未列出转移拒绝。
6. Down 迁移同样只在没有新业务执行数据且任务仍可无损回退时执行,否则失败并保持 v2;覆盖 up/down、幂等、DRAFT 保留、未知旧数据回滚、外键/唯一约束、金额 TEXT、状态转移与旧标识符消失测试。
## 验收要点
- 新 migration 不修改 00001;从空 v1 和仅含 DRAFT 的 v1 升级成功,DRAFT 字段逐项不变。
- 任一旧执行/授权/提交记录或非 DRAFT 状态都使升级失败,版本和原数据保持 v1;没有半迁移。
- 新 schema 无 spec_trials、authorized_unit_price、spec_trial_id、command_id、dry_run_id;包含 purchase_attempts 与架构规定的关系、唯一性、金额字符串和状态 CHECK。
- 领域模型不再暴露旧两趟状态;未知状态或未列出的转移全部失败。第一趟试选/下单函数均不在本任务范围。
- go test ./...、go test -race ./...、go vet ./...、go build ./...、完整 init.ps1、上下文校验和 diff-check 通过。
## 执行记录
(暂无)
<!-- END VIKUNJA EXPORT -->
## 边界
- 本任务只迁移数据结构和纯领域状态机,不实现管理员“开始采购”HTTP/usecase、设备领取、租约、
attempt 写入 API、截图上传、提交围栏 API、页面自动化或任何真机动作;这些能力仍分别属于
T-203、T-205、T-208 与 Phase 3/4 任务。
- 不修改已经发布的 `00001_core_data.sql`;只能追加 `00002`。Up/Down 都必须置于单个事务,前置
检查失败时版本、schema 和数据原样保留,不能删除、转换或猜测任何旧执行记录。
- Up 只允许空库或仅含可无损保留的 `MANUAL + DRAFT` 任务。任一 `spec_trials`、旧
`order_authorizations`、旧 `order_submissions` 数据,或任一非 DRAFT / 非 MANUAL 任务均拒绝升级。
- Down 只允许没有授权、attempt、submission 且全部任务都能无损回到 v1 DRAFT 的 v2 数据库;否则
拒绝回退。迁移测试不得为通过而临时关闭外键后漏恢复,也不得留下临时表或 guard 表。
- 金额继续只用严格正十进制 `TEXT`;不得引入浮点数。新授权只锁定 `total_price_cap`,不得重新加入
`authorized_unit_price`、观察价格或 `spec_trial_id`。
- `FENCED` 授权不得回到可领取、可过期或可放弃状态;围栏后的提交只能记录明确提交或进入同一记录
调和,不能提供重试、释放或第二次点击的状态转移。
- 本任务不实现、不引用点击“提交订单”的函数,更不涉及支付、免密支付、先用后付或任何扣款动作。