Compare commits

...
Author SHA1 Message Date
QiuSW 87591e84f9 feat(admin): establish core data model 2026-08-03 18:37:48 +08:00
QiuSW 8be6d206dd docs(tasks): define T-003 unified entry 2026-08-03 18:35:12 +08:00
18 changed files with 1143 additions and 6 deletions
+58
View File
@@ -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
}
+68
View File
@@ -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")
}
+4
View File
@@ -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
+32
View File
@@ -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=
+80
View File
@@ -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)
}
})
}
}
+16
View File
@@ -0,0 +1,16 @@
package domain
import "time"
type SpecTrial struct {
ID string
TaskID string
Attempt int
ProductTitle string
SelectedColor string
SelectedSize string
UnitPrice string
TotalPrice string
EvidenceSHA256 string
CreatedAt time.Time
}
+56
View File
@@ -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: {},
},
}
+47
View File
@@ -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)
}
})
}
}
+102
View File
@@ -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: {},
},
}
+62
View File
@@ -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)
}
})
}
}
+28
View File
@@ -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)
}
}
+25 -3
View File
@@ -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)
}
}
} }
+148
View File
@@ -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;
+62
View File
@@ -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
View File
@@ -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 -->
## 边界 ## 边界