diff --git a/admin/cmd/migrate/main.go b/admin/cmd/migrate/main.go new file mode 100644 index 0000000..37bc071 --- /dev/null +++ b/admin/cmd/migrate/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "log" + "os" + + "cmbuyer/admin/internal/migrations" + "cmbuyer/admin/internal/storage/sqlite" +) + +func main() { + if err := run(context.Background(), os.Args[1:], os.Stderr); err != nil { + log.Print(err) + os.Exit(1) + } +} + +func run(ctx context.Context, args []string, stderr io.Writer) error { + flags := flag.NewFlagSet("migrate", flag.ContinueOnError) + flags.SetOutput(stderr) + databaseSource := flags.String("database", "", "SQLite data source") + migrationDirectory := flags.String("dir", "migrations", "migration directory") + + if err := flags.Parse(args); err != nil { + return err + } + if *databaseSource == "" { + return errors.New("-database is required") + } + if flags.NArg() != 1 { + return fmt.Errorf("usage: migrate -database [-dir ] ") + } + command := flags.Arg(0) + if command != "up" && command != "down" && command != "status" { + return fmt.Errorf("unsupported migration command %q", command) + } + + database, err := sqlite.Open(*databaseSource) + if err != nil { + return fmt.Errorf("open SQLite database: %w", err) + } + defer func() { + if err := database.Close(); err != nil { + log.Printf("close SQLite database: %v", err) + } + }() + + if err := migrations.Run(ctx, database, *migrationDirectory, command); err != nil { + return fmt.Errorf("run migrations: %w", err) + } + + return nil +} diff --git a/admin/cmd/migrate/main_test.go b/admin/cmd/migrate/main_test.go new file mode 100644 index 0000000..384b0f7 --- /dev/null +++ b/admin/cmd/migrate/main_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "database/sql" + "errors" + "io" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestRunUp(t *testing.T) { + databaseSource := filepath.Join(t.TempDir(), "migrate.db") + if err := run(context.Background(), []string{ + "-database", databaseSource, + "-dir", migrationDirectory(t), + "up", + }, io.Discard); err != nil { + t.Fatalf("run up migration command: %v", err) + } + + database, err := sql.Open("sqlite3", databaseSource) + if err != nil { + t.Fatalf("open migrated database: %v", err) + } + t.Cleanup(func() { + if err := database.Close(); err != nil { + t.Errorf("close migrated database: %v", err) + } + }) + + var count int + if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'tasks'`).Scan(&count); err != nil { + t.Fatalf("look up tasks table: %v", err) + } + if count != 1 { + t.Fatalf("tasks table count = %d, want 1", count) + } +} + +func TestRunRequiresDatabase(t *testing.T) { + if err := run(context.Background(), []string{"up"}, io.Discard); err == nil { + t.Fatal("run without database source succeeded") + } +} + +func TestRunRejectsUndeclaredCommand(t *testing.T) { + databaseSource := filepath.Join(t.TempDir(), "migrate.db") + err := run(context.Background(), []string{"-database", databaseSource, "reset"}, io.Discard) + if err == nil { + t.Fatal("run with undeclared command succeeded") + } + if _, err := os.Stat(databaseSource); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("undeclared command opened database source: stat error = %v, want not exist", err) + } +} + +func migrationDirectory(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate migration command test source") + } + + return filepath.Join(filepath.Dir(file), "..", "..", "migrations") +} diff --git a/admin/go.mod b/admin/go.mod index 52f14d9..cfe986f 100644 --- a/admin/go.mod +++ b/admin/go.mod @@ -5,6 +5,7 @@ go 1.23.0 require ( github.com/gin-gonic/gin v1.11.0 github.com/mattn/go-sqlite3 v1.14.49 + github.com/pressly/goose/v3 v3.24.0 ) require ( @@ -22,14 +23,17 @@ require ( github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect + go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.20.0 // indirect golang.org/x/crypto v0.40.0 // indirect golang.org/x/mod v0.25.0 // indirect diff --git a/admin/go.sum b/admin/go.sum index 8e399c0..52f9a44 100644 --- a/admin/go.sum +++ b/admin/go.sum @@ -7,6 +7,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= @@ -28,6 +30,10 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= @@ -38,18 +44,28 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w= github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.24.0 h1:sFbNms7Bd++2VMq6HSgDHDLWa7kHz1qXzPb3ZIU72VU= +github.com/pressly/goose/v3 v3.24.0/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -65,6 +81,8 @@ github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= @@ -88,3 +106,17 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk= +modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/admin/internal/domain/authorization.go b/admin/internal/domain/authorization.go new file mode 100644 index 0000000..90d9754 --- /dev/null +++ b/admin/internal/domain/authorization.go @@ -0,0 +1,80 @@ +package domain + +import ( + "errors" + "time" +) + +var ErrInvalidAuthorizationTransition = errors.New("invalid authorization status transition") + +type AuthorizationStatus string + +const ( + AuthorizationStatusPendingDelivery AuthorizationStatus = "PENDING_DELIVERY" + AuthorizationStatusDelivered AuthorizationStatus = "DELIVERED" + AuthorizationStatusAcknowledged AuthorizationStatus = "ACKNOWLEDGED" + AuthorizationStatusExecuting AuthorizationStatus = "EXECUTING" + AuthorizationStatusFenced AuthorizationStatus = "FENCED" + AuthorizationStatusConsumed AuthorizationStatus = "CONSUMED" + AuthorizationStatusSuperseded AuthorizationStatus = "SUPERSEDED" + AuthorizationStatusExpired AuthorizationStatus = "EXPIRED" +) + +type OrderAuthorization struct { + ID string + TaskID string + SpecTrialID string + Version int + GoodsID string + SKUColor string + SKUSize string + Quantity int + AuthorizedUnitPrice string + TotalPriceCap string + Note *string + Status AuthorizationStatus + CreatedBy string + CreatedAt time.Time + ExpiresAt time.Time +} + +// CanTransitionTo 围栏后的授权只能消费,不能回到可领取或可过期状态,以防重复采购。 +func (status AuthorizationStatus) CanTransitionTo(next AuthorizationStatus) bool { + _, allowed := authorizationTransitions[status][next] + return allowed +} + +// TransitionAuthorization 返回下一状态;未定义的授权状态转移一律失败。 +func TransitionAuthorization(current, next AuthorizationStatus) (AuthorizationStatus, error) { + if !current.CanTransitionTo(next) { + return current, ErrInvalidAuthorizationTransition + } + + return next, nil +} + +var authorizationTransitions = map[AuthorizationStatus]map[AuthorizationStatus]struct{}{ + AuthorizationStatusPendingDelivery: { + AuthorizationStatusDelivered: {}, + AuthorizationStatusSuperseded: {}, + AuthorizationStatusExpired: {}, + }, + AuthorizationStatusDelivered: { + AuthorizationStatusAcknowledged: {}, + AuthorizationStatusSuperseded: {}, + AuthorizationStatusExpired: {}, + }, + AuthorizationStatusAcknowledged: { + AuthorizationStatusExecuting: {}, + AuthorizationStatusSuperseded: {}, + AuthorizationStatusExpired: {}, + }, + AuthorizationStatusExecuting: { + AuthorizationStatusFenced: {}, + AuthorizationStatusSuperseded: {}, + AuthorizationStatusExpired: {}, + }, + AuthorizationStatusFenced: { + AuthorizationStatusConsumed: {}, + }, +} diff --git a/admin/internal/domain/authorization_test.go b/admin/internal/domain/authorization_test.go new file mode 100644 index 0000000..3a8ca25 --- /dev/null +++ b/admin/internal/domain/authorization_test.go @@ -0,0 +1,54 @@ +package domain_test + +import ( + "errors" + "testing" + + "cmbuyer/admin/internal/domain" +) + +func TestAuthorizationTransitions(t *testing.T) { + tests := []struct { + name string + current domain.AuthorizationStatus + 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}, + {"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}, + {"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}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := test.current.CanTransitionTo(test.next); got != test.allowed { + t.Fatalf("CanTransitionTo(%s, %s) = %t, want %t", test.current, test.next, got, test.allowed) + } + + result, err := domain.TransitionAuthorization(test.current, test.next) + if test.allowed { + if err != nil { + t.Fatalf("TransitionAuthorization(%s, %s): %v", test.current, test.next, err) + } + if result != test.next { + t.Fatalf("TransitionAuthorization(%s, %s) = %s, want %s", test.current, test.next, result, test.next) + } + return + } + + if !errors.Is(err, domain.ErrInvalidAuthorizationTransition) { + t.Fatalf("TransitionAuthorization(%s, %s) error = %v, want ErrInvalidAuthorizationTransition", test.current, test.next, err) + } + }) + } +} diff --git a/admin/internal/domain/spec_trial.go b/admin/internal/domain/spec_trial.go new file mode 100644 index 0000000..69465b0 --- /dev/null +++ b/admin/internal/domain/spec_trial.go @@ -0,0 +1,16 @@ +package domain + +import "time" + +type SpecTrial struct { + ID string + TaskID string + Attempt int + ProductTitle string + SelectedColor string + SelectedSize string + UnitPrice string + TotalPrice string + EvidenceSHA256 string + CreatedAt time.Time +} diff --git a/admin/internal/domain/submission.go b/admin/internal/domain/submission.go new file mode 100644 index 0000000..7d14027 --- /dev/null +++ b/admin/internal/domain/submission.go @@ -0,0 +1,56 @@ +package domain + +import ( + "errors" + "time" +) + +var ErrInvalidSubmissionTransition = errors.New("invalid submission status transition") + +type SubmissionStatus string + +const ( + SubmissionStatusFenced SubmissionStatus = "FENCED" + SubmissionStatusSubmitted SubmissionStatus = "SUBMITTED" + SubmissionStatusReconciliationRequired SubmissionStatus = "RECONCILIATION_REQUIRED" + SubmissionStatusManualResolved SubmissionStatus = "MANUAL_RESOLVED" +) + +type OrderSubmission struct { + ID string + TaskID string + AuthorizationID string + CommandID string + DryRunID string + Status SubmissionStatus + VerifiedUnitPrice string + QuantityRead int + ConfirmPageAmount string + CreatedAt time.Time + ResolvedAt *time.Time +} + +// CanTransitionTo 只允许围栏记录向最终观察结果调和,拒绝回退以防触发第二次真实动作。 +func (status SubmissionStatus) CanTransitionTo(next SubmissionStatus) bool { + _, allowed := submissionTransitions[status][next] + return allowed +} + +// TransitionSubmission 返回下一状态;未定义的提交记录状态转移一律失败。 +func TransitionSubmission(current, next SubmissionStatus) (SubmissionStatus, error) { + if !current.CanTransitionTo(next) { + return current, ErrInvalidSubmissionTransition + } + + return next, nil +} + +var submissionTransitions = map[SubmissionStatus]map[SubmissionStatus]struct{}{ + SubmissionStatusFenced: { + SubmissionStatusSubmitted: {}, + SubmissionStatusReconciliationRequired: {}, + }, + SubmissionStatusReconciliationRequired: { + SubmissionStatusManualResolved: {}, + }, +} diff --git a/admin/internal/domain/submission_test.go b/admin/internal/domain/submission_test.go new file mode 100644 index 0000000..adebe36 --- /dev/null +++ b/admin/internal/domain/submission_test.go @@ -0,0 +1,47 @@ +package domain_test + +import ( + "errors" + "testing" + + "cmbuyer/admin/internal/domain" +) + +func TestSubmissionTransitions(t *testing.T) { + tests := []struct { + name string + current domain.SubmissionStatus + next domain.SubmissionStatus + allowed bool + }{ + {"submitted", domain.SubmissionStatusFenced, domain.SubmissionStatusSubmitted, true}, + {"uncertain requires reconciliation", domain.SubmissionStatusFenced, domain.SubmissionStatusReconciliationRequired, true}, + {"manual review resolves reconciliation", domain.SubmissionStatusReconciliationRequired, domain.SubmissionStatusManualResolved, true}, + {"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}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := test.current.CanTransitionTo(test.next); got != test.allowed { + t.Fatalf("CanTransitionTo(%s, %s) = %t, want %t", test.current, test.next, got, test.allowed) + } + + result, err := domain.TransitionSubmission(test.current, test.next) + if test.allowed { + if err != nil { + t.Fatalf("TransitionSubmission(%s, %s): %v", test.current, test.next, err) + } + if result != test.next { + t.Fatalf("TransitionSubmission(%s, %s) = %s, want %s", test.current, test.next, result, test.next) + } + return + } + + if !errors.Is(err, domain.ErrInvalidSubmissionTransition) { + t.Fatalf("TransitionSubmission(%s, %s) error = %v, want ErrInvalidSubmissionTransition", test.current, test.next, err) + } + }) + } +} diff --git a/admin/internal/domain/task.go b/admin/internal/domain/task.go new file mode 100644 index 0000000..eac0579 --- /dev/null +++ b/admin/internal/domain/task.go @@ -0,0 +1,102 @@ +// Package domain 定义采购服务的业务实体与不依赖外部系统的状态规则。 +package domain + +import ( + "errors" + "time" +) + +var ErrInvalidTaskTransition = errors.New("invalid task status transition") + +type TaskStatus string + +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" + TaskStatusCanceled TaskStatus = "CANCELED" +) + +type Source string + +const ( + SourceManual Source = "MANUAL" + SourceExcel Source = "EXCEL" + SourceERP Source = "ERP" +) + +type Task struct { + ID string + Source Source + SourceRef *string + Title string + GoodsID string + SKUColor string + SKUSize string + Quantity int + MaxTotalPrice string + ReferenceAssetID *string + Status TaskStatus + Version int + CreatedAt time.Time + UpdatedAt time.Time +} + +// CanTransitionTo 只接受架构已定义的任务状态转移,未知状态或未列出的动作均拒绝。 +func (status TaskStatus) CanTransitionTo(next TaskStatus) bool { + _, allowed := taskTransitions[status][next] + return allowed +} + +// TransitionTask 返回下一状态;非法转移必须在写库前失败,不能由调用方猜测补救路径。 +func TransitionTask(current, next TaskStatus) (TaskStatus, error) { + if !current.CanTransitionTo(next) { + return current, ErrInvalidTaskTransition + } + + return next, nil +} + +var taskTransitions = map[TaskStatus]map[TaskStatus]struct{}{ + TaskStatusDraft: { + TaskStatusPending: {}, + }, + TaskStatusPending: { + TaskStatusClaimed: {}, + }, + TaskStatusPendingRetrial: { + TaskStatusClaimed: {}, + }, + TaskStatusClaimed: { + TaskStatusRunning: {}, + TaskStatusPending: {}, + }, + TaskStatusRunning: { + TaskStatusWaitingConfirmation: {}, + TaskStatusNeedsManual: {}, + }, + TaskStatusWaitingConfirmation: { + TaskStatusCanceled: {}, + TaskStatusAuthorized: {}, + }, + TaskStatusAuthorized: { + TaskStatusOrdering: {}, + }, + TaskStatusOrdering: { + TaskStatusNeedsManual: {}, + TaskStatusWaitingPayment: {}, + TaskStatusReconciliationRequired: {}, + }, + TaskStatusWaitingPayment: { + TaskStatusSucceeded: {}, + }, +} diff --git a/admin/internal/domain/task_test.go b/admin/internal/domain/task_test.go new file mode 100644 index 0000000..7ba6fb2 --- /dev/null +++ b/admin/internal/domain/task_test.go @@ -0,0 +1,62 @@ +package domain_test + +import ( + "errors" + "testing" + + "cmbuyer/admin/internal/domain" +) + +func TestTaskTransitions(t *testing.T) { + tests := []struct { + name string + current domain.TaskStatus + 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}, + {"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}, + {"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}, + {"terminal task cannot restart", domain.TaskStatusSucceeded, domain.TaskStatusPending, false}, + {"unknown status is rejected", domain.TaskStatus("UNKNOWN"), domain.TaskStatusPending, false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := test.current.CanTransitionTo(test.next); got != test.allowed { + t.Fatalf("CanTransitionTo(%s, %s) = %t, want %t", test.current, test.next, got, test.allowed) + } + + result, err := domain.TransitionTask(test.current, test.next) + if test.allowed { + if err != nil { + t.Fatalf("TransitionTask(%s, %s): %v", test.current, test.next, err) + } + if result != test.next { + t.Fatalf("TransitionTask(%s, %s) = %s, want %s", test.current, test.next, result, test.next) + } + return + } + + if !errors.Is(err, domain.ErrInvalidTaskTransition) { + t.Fatalf("TransitionTask(%s, %s) error = %v, want ErrInvalidTaskTransition", test.current, test.next, err) + } + if result != test.current { + t.Fatalf("TransitionTask(%s, %s) result = %s, want unchanged status", test.current, test.next, result) + } + }) + } +} diff --git a/admin/internal/migrations/migrations.go b/admin/internal/migrations/migrations.go new file mode 100644 index 0000000..e87d3d5 --- /dev/null +++ b/admin/internal/migrations/migrations.go @@ -0,0 +1,28 @@ +// Package migrations 通过 goose 执行采购服务的版本化数据库迁移。 +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +// Run 执行指定的 goose 命令。迁移目录由调用方显式传入,避免把运行目录当作隐式配置。 +func Run(ctx context.Context, database *sql.DB, directory, command string) error { + if err := goose.SetDialect("sqlite3"); err != nil { + return err + } + + return goose.RunContext(ctx, command, database, directory) +} + +// Up 将数据库迁移到当前版本。 +func Up(ctx context.Context, database *sql.DB, directory string) error { + return Run(ctx, database, directory, "up") +} + +// Down 回退一个已应用的迁移版本。 +func Down(ctx context.Context, database *sql.DB, directory string) error { + return Run(ctx, database, directory, "down") +} diff --git a/admin/internal/migrations/migrations_test.go b/admin/internal/migrations/migrations_test.go new file mode 100644 index 0000000..2b5ac4e --- /dev/null +++ b/admin/internal/migrations/migrations_test.go @@ -0,0 +1,283 @@ +package migrations_test + +import ( + "context" + "database/sql" + "path/filepath" + "runtime" + "testing" + + "cmbuyer/admin/internal/migrations" + "cmbuyer/admin/internal/storage/sqlite" + + "github.com/pressly/goose/v3" +) + +func TestUpDownAndIdempotence(t *testing.T) { + database := openTestDatabase(t) + directory := migrationDirectory(t) + context := context.Background() + + if err := migrations.Up(context, database, directory); err != nil { + t.Fatalf("apply migrations: %v", err) + } + assertVersion(t, database, 1) + assertTableExists(t, database, "tasks", true) + assertTableExists(t, database, "spec_trials", true) + assertTableExists(t, database, "order_authorizations", true) + assertTableExists(t, database, "order_submissions", true) + + if err := migrations.Up(context, database, directory); err != nil { + t.Fatalf("reapply migrations: %v", err) + } + assertVersion(t, database, 1) + + 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) + } + assertVersion(t, database, 1) +} + +func TestSchemaConstraints(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 + }{ + {"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"}, + } { + 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") + } + + 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") + } + + 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") + } + + 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") + } + + 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") + } + + 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") + } + + 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") + } + + 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") + } + + 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") + } + + 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") + } + + 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 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) + } + }) + + return database +} + +func migrationDirectory(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate migration test source") + } + + return filepath.Join(filepath.Dir(file), "..", "..", "migrations") +} + +func assertVersion(t *testing.T, database *sql.DB, want int64) { + t.Helper() + got, err := goose.GetDBVersion(database) + if err != nil { + t.Fatalf("read migration version: %v", err) + } + if got != want { + t.Fatalf("migration version = %d, want %d", got, want) + } +} + +func assertTableExists(t *testing.T, database *sql.DB, table string, want bool) { + t.Helper() + var count int + if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&count); err != nil { + t.Fatalf("look up table %s: %v", table, err) + } + if got := count == 1; got != want { + t.Fatalf("table %s exists = %t, want %t", table, got, want) + } +} + +func assertColumnType(t *testing.T, database *sql.DB, table, column, want string) { + t.Helper() + var got string + if err := database.QueryRow(`SELECT type FROM pragma_table_info(?) WHERE name = ?`, table, column).Scan(&got); err != nil { + t.Fatalf("read %s.%s type: %v", table, column, err) + } + if got != want { + t.Fatalf("%s.%s type = %s, want %s", table, column, got, want) + } +} + +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 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) + } +} diff --git a/admin/internal/storage/sqlite/sqlite.go b/admin/internal/storage/sqlite/sqlite.go index 7bd0739..ed9f1d4 100644 --- a/admin/internal/storage/sqlite/sqlite.go +++ b/admin/internal/storage/sqlite/sqlite.go @@ -3,11 +3,33 @@ package sqlite import ( "database/sql" + "fmt" - _ "github.com/mattn/go-sqlite3" + "github.com/mattn/go-sqlite3" ) -// Open 打开 SQLite 数据源;调用方负责其 schema 与生命周期。 +const driverName = "cmbuyer-sqlite3" + +func init() { + sql.Register(driverName, &sqlite3.SQLiteDriver{ + ConnectHook: func(connection *sqlite3.SQLiteConn) error { + _, err := connection.Exec("PRAGMA foreign_keys = ON", nil) + return err + }, + }) +} + +// Open 打开 SQLite 数据源并逐连接启用外键,避免连接池配置遗漏而绕过授权与任务的引用约束。 func Open(dataSourceName string) (*sql.DB, error) { - return sql.Open("sqlite3", dataSourceName) + database, err := sql.Open(driverName, dataSourceName) + if err != nil { + return nil, err + } + + if err := database.Ping(); err != nil { + _ = database.Close() + return nil, fmt.Errorf("ping SQLite database: %w", err) + } + + return database, nil } diff --git a/admin/internal/storage/sqlite/sqlite_test.go b/admin/internal/storage/sqlite/sqlite_test.go index 791a1f1..f6bf2be 100644 --- a/admin/internal/storage/sqlite/sqlite_test.go +++ b/admin/internal/storage/sqlite/sqlite_test.go @@ -21,4 +21,15 @@ func TestOpen(t *testing.T) { if err := database.PingContext(context.Background()); err != nil { t.Fatalf("ping SQLite database: %v", err) } + + database.SetMaxIdleConns(0) + for attempt := 0; attempt < 2; attempt++ { + var foreignKeysEnabled int + if err := database.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeysEnabled); err != nil { + t.Fatalf("read SQLite foreign key setting: %v", err) + } + if foreignKeysEnabled != 1 { + t.Fatalf("SQLite foreign_keys = %d, want 1", foreignKeysEnabled) + } + } } diff --git a/admin/migrations/00001_core_data.sql b/admin/migrations/00001_core_data.sql new file mode 100644 index 0000000..e1b8501 --- /dev/null +++ b/admin/migrations/00001_core_data.sql @@ -0,0 +1,148 @@ +-- +goose Up +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, '.') + )) + ), + 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 +); + +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, '.') + )) + ), + 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, '.') + )) + ), + 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, '.') + )) + ), + 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, '.') + )) + ), + 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, '.') + )) + ), + 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, '.') + )) + ), + 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) +); + +-- +goose Down +DROP TABLE order_submissions; +DROP TABLE order_authorizations; +DROP TABLE spec_trials; +DROP TABLE tasks; diff --git a/docs/tasks/T-004.md b/docs/tasks/T-004.md index 32bf39b..939d0d5 100644 --- a/docs/tasks/T-004.md +++ b/docs/tasks/T-004.md @@ -3,7 +3,7 @@ id: T-004 title: 建立核心数据模型与状态机 phase: 0 deps: [T-001] -status: DOING +status: DONE created: 2026-08-03 vikunja_task_id: 19 context_ref: c72371c @@ -23,7 +23,7 @@ write_paths: - docs/api.md --- - + ## 问题 / 背景 T-001 已建立可运行的采购服务骨架,但尚无业务实体、数据库 schema 或可验证的状态机。后续登录、建单、领取、授权和提交围栏都依赖一致的数据模型;若先写 HTTP 页面再补状态约束,会把资金安全边界分散到处理器中。 @@ -52,7 +52,11 @@ T-001 已建立可运行的采购服务骨架,但尚无业务实体、数据 ## 执行记录 -(暂无) +### 2026-08-03T10:37:05Z · ila + +主 agent 二次终审通过:完成 tasks、spec_trials、order_authorizations、order_submissions 的 Goose/SQLite 迁移、纯状态机与迁移 CLI;金额字段均为 TEXT 十进制字符串,无 HTTP、真机、下单点击或付款代码。 +返修后迁移 CLI 仅允许 up/down/status;复合外键禁止授权引用其他任务试选记录、提交记录引用其他任务授权;围栏后状态不可回退,第一趟 RUNNING 不可进入 ORDERING。 +独立验证:gofmt 无差异,go mod tidy 后干净;go test ./...、go test -race ./...、go vet ./...、go build ./... 通过;真实临时库迁移 up/status 通过;agent-context、diff check 和越界扫描通过。 ## 边界