Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87591e84f9 | ||
|
|
8be6d206dd |
@@ -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 <sqlite-data-source> [-dir <migration-directory>] <up|down|status>")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ go 1.23.0
|
|||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.49
|
github.com/mattn/go-sqlite3 v1.14.49
|
||||||
|
github.com/pressly/goose/v3 v3.24.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -22,14 +23,17 @@ require (
|
|||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // 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/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.54.0 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
go.uber.org/mock v0.5.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/arch v0.20.0 // indirect
|
||||||
golang.org/x/crypto v0.40.0 // indirect
|
golang.org/x/crypto v0.40.0 // indirect
|
||||||
golang.org/x/mod v0.25.0 // indirect
|
golang.org/x/mod v0.25.0 // indirect
|
||||||
|
|||||||
@@ -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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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/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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
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-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 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
|
||||||
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
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 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
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 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
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=
|
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 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
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 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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=
|
||||||
|
|||||||
@@ -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: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,33 @@ package sqlite
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"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) {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,4 +21,15 @@ func TestOpen(t *testing.T) {
|
|||||||
if err := database.PingContext(context.Background()); err != nil {
|
if err := database.PingContext(context.Background()); err != nil {
|
||||||
t.Fatalf("ping SQLite database: %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
---
|
||||||
|
id: T-003
|
||||||
|
title: 建立双端统一初始化与验证入口
|
||||||
|
phase: 0
|
||||||
|
deps: [T-001, T-002]
|
||||||
|
status: DOING
|
||||||
|
created: 2026-08-03
|
||||||
|
vikunja_task_id: 20
|
||||||
|
context_ref: bcec012
|
||||||
|
work_branch: task/t-003-unified-entry
|
||||||
|
needs_device: false
|
||||||
|
needs_human_review: false
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-003.md
|
||||||
|
- init.ps1
|
||||||
|
- init.sh
|
||||||
|
- docs/03-tech-stack.md
|
||||||
|
- docs/00-ai-start-here.md
|
||||||
|
- docs/current-state.md
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=20 synced=2026-08-03T10:35:06Z sha256=24a880be86ebfda9b3176ed3fdbfae289c750427baa67eaa5207e4eb69a1841e -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-001 与 T-002 已建立两端可运行骨架,但仓库根 init.ps1 / init.sh 仍沿用占位命令:Windows 默认 Python 3.10 不满足 client 的 Python 3.11+ 下限,桌面端仍按 requirements.txt 安装而不是规范的 editable package,启动命令也指向不存在的 src/main.py。统一入口若不能主动验证工具链和两端基线,后续任务会在错误环境上叠加实现。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
- 功能:Phase 0 工程地基,只建立开发安装、验证与启动提示。
|
||||||
|
- 用户故事 / 交互:不改采购服务或采购工具产品界面。
|
||||||
|
- 架构 / API:不改业务接口、数据模型或任何采购安全边界。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 完善 init.ps1:显式选择并验证 Python 3.11+,创建或校验 client/.venv,以 editable 方式安装 client,再运行两端离线测试、静态检查和构建。
|
||||||
|
2. 完善 init.sh 的等价流程与缺失工具提示;在无 Windows 工具链的 WSL 环境只允许明确失败,不得把跳过当成功。
|
||||||
|
3. 两端目录、Go、Python、venv 或必要文件缺失时输出可操作原因并返回非零;任何门禁失败立即停止。
|
||||||
|
4. 成功后只打印真实启动命令:采购服务 go run ./cmd/server;采购工具虚拟环境 Python -m cmbuyer_client。
|
||||||
|
5. 同步入口、技术栈与当前状态文档;不修改 admin/client 业务代码。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- Windows 一条命令完成两端安装、admin test/vet/build、client unittest/compileall 与上下文门禁。
|
||||||
|
- 不会使用 Python 3.10 创建采购工具环境;已有低版本 venv 会明确失败并给出修复提示。
|
||||||
|
- editable install 与启动模块和 T-002 已验证契约一致。
|
||||||
|
- init.sh 至少通过 bash 语法检查,且与 PowerShell 入口保持同一边界。
|
||||||
|
- 不连接真机,不实现采购、下单或付款逻辑。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
(暂无)
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 不修改 `admin/`、`client/` 生产代码、数据库迁移、业务 API、产品界面或真机流程。
|
||||||
|
- 不连接设备,不编写拼多多页面判据、下单、提交订单、付款或支付逻辑。
|
||||||
|
- 统一入口只能执行安装、离线验证、构建和打印启动命令;不得在失败时静默跳过任一已初始化端。
|
||||||
|
- 采购工具必须使用 Python 3.11+ 虚拟环境和 `pip install -e .`;已有低版本虚拟环境必须明确失败,
|
||||||
|
不得继续运行或自动覆盖用户环境。
|
||||||
|
- `init.sh` 在缺少 Windows 侧工具链的环境应明确非零退出;语法通过不等于在 WSL 已完成产品验收。
|
||||||
|
- 不放宽 `docs/04-architecture.md` 第四节安全边界,入口脚本不得触发任何业务动作。
|
||||||
+7
-3
@@ -3,7 +3,7 @@ id: T-004
|
|||||||
title: 建立核心数据模型与状态机
|
title: 建立核心数据模型与状态机
|
||||||
phase: 0
|
phase: 0
|
||||||
deps: [T-001]
|
deps: [T-001]
|
||||||
status: DOING
|
status: DONE
|
||||||
created: 2026-08-03
|
created: 2026-08-03
|
||||||
vikunja_task_id: 19
|
vikunja_task_id: 19
|
||||||
context_ref: c72371c
|
context_ref: c72371c
|
||||||
@@ -23,7 +23,7 @@ write_paths:
|
|||||||
- docs/api.md
|
- docs/api.md
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- BEGIN VIKUNJA EXPORT id=19 synced=2026-08-03T10:16:07Z sha256=dc6e71b81abdf95ef5942603c31944dd3eb76141e9d2edc8edef56d71a1b1d4c -->
|
<!-- BEGIN VIKUNJA EXPORT id=19 synced=2026-08-03T10:37:34Z sha256=a848b2881154932ac8dd7ac46abbbd8a58da1664c50a0901dccd905eb00db5c2 -->
|
||||||
## 问题 / 背景
|
## 问题 / 背景
|
||||||
|
|
||||||
T-001 已建立可运行的采购服务骨架,但尚无业务实体、数据库 schema 或可验证的状态机。后续登录、建单、领取、授权和提交围栏都依赖一致的数据模型;若先写 HTTP 页面再补状态约束,会把资金安全边界分散到处理器中。
|
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 和越界扫描通过。
|
||||||
<!-- END VIKUNJA EXPORT -->
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|||||||
Reference in New Issue
Block a user