Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87591e84f9 | ||
|
|
8be6d206dd | ||
|
|
bcec012c8b | ||
|
|
2ab5baa776 | ||
|
|
cf293d2382 |
@@ -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 (
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/mattn/go-sqlite3 v1.14.49
|
||||
github.com/pressly/goose/v3 v3.24.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -22,14 +23,17 @@ require (
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
|
||||
@@ -7,6 +7,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
@@ -28,6 +30,10 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
@@ -38,18 +44,28 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
|
||||
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.24.0 h1:sFbNms7Bd++2VMq6HSgDHDLWa7kHz1qXzPb3ZIU72VU=
|
||||
github.com/pressly/goose/v3 v3.24.0/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -65,6 +81,8 @@ github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
@@ -88,3 +106,17 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk=
|
||||
modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -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 (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// Open 打开 SQLite 数据源;调用方负责其 schema 与生命周期。
|
||||
const driverName = "cmbuyer-sqlite3"
|
||||
|
||||
func init() {
|
||||
sql.Register(driverName, &sqlite3.SQLiteDriver{
|
||||
ConnectHook: func(connection *sqlite3.SQLiteConn) error {
|
||||
_, err := connection.Exec("PRAGMA foreign_keys = ON", nil)
|
||||
return err
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Open 打开 SQLite 数据源并逐连接启用外键,避免连接池配置遗漏而绕过授权与任务的引用约束。
|
||||
func Open(dataSourceName string) (*sql.DB, error) {
|
||||
return sql.Open("sqlite3", dataSourceName)
|
||||
database, err := sql.Open(driverName, dataSourceName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := database.Ping(); err != nil {
|
||||
_ = database.Close()
|
||||
return nil, fmt.Errorf("ping SQLite database: %w", err)
|
||||
}
|
||||
|
||||
return database, nil
|
||||
}
|
||||
|
||||
@@ -21,4 +21,15 @@ func TestOpen(t *testing.T) {
|
||||
if err := database.PingContext(context.Background()); err != nil {
|
||||
t.Fatalf("ping SQLite database: %v", err)
|
||||
}
|
||||
|
||||
database.SetMaxIdleConns(0)
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
var foreignKeysEnabled int
|
||||
if err := database.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeysEnabled); err != nil {
|
||||
t.Fatalf("read SQLite foreign key setting: %v", err)
|
||||
}
|
||||
if foreignKeysEnabled != 1 {
|
||||
t.Fatalf("SQLite foreign_keys = %d, want 1", foreignKeysEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,23 @@
|
||||
# 本机开发环境与解释器缓存
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# 运行时生成的日志、截图和其他证据产物不得进入版本库。
|
||||
logs/
|
||||
artifacts/
|
||||
runtime/
|
||||
*.log
|
||||
|
||||
# 本机凭据或环境覆盖仅可保存在未跟踪文件中。
|
||||
.env
|
||||
.env.*
|
||||
secrets/
|
||||
|
||||
# 打包工具生成的本机产物
|
||||
build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
@@ -0,0 +1,19 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "cmbuyer-client"
|
||||
version = "0.1.0"
|
||||
description = "cmbuyer 采购工具桌面端"
|
||||
requires-python = ">=3.11"
|
||||
dynamic = ["dependencies"]
|
||||
|
||||
[project.scripts]
|
||||
cmbuyer-client = "cmbuyer_client.app:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
dependencies = { file = ["requirements.txt"] }
|
||||
@@ -0,0 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# 桌面界面(Qt 官方 Python 绑定)。
|
||||
PySide6
|
||||
# 后续真机取证会使用;本阶段不导入或连接设备。
|
||||
uiautomator2
|
||||
# 后续截图完整性检查会使用。
|
||||
Pillow
|
||||
@@ -0,0 +1,80 @@
|
||||
"""验证 wheel 元数据从 requirements.txt 声明了全部运行时依赖。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
|
||||
def normalize_project_name(name: str) -> str:
|
||||
"""使用足以比较 requirements 与 Core Metadata 的项目名规范化规则。"""
|
||||
|
||||
return re.sub(r"[-_.]+", "-", name).lower()
|
||||
|
||||
|
||||
def dependencies_from_requirements(requirements_file: Path) -> set[str]:
|
||||
"""从唯一依赖来源读取项目名;当前 requirements 不允许间接或可编辑依赖。"""
|
||||
|
||||
dependencies: set[str] = set()
|
||||
for line in requirements_file.read_text(encoding="utf-8").splitlines():
|
||||
requirement = line.partition("#")[0].strip()
|
||||
if not requirement:
|
||||
continue
|
||||
match = re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*", requirement)
|
||||
if match is None:
|
||||
raise ValueError(f"requirements.txt 包含不支持的依赖声明:{requirement}")
|
||||
dependencies.add(normalize_project_name(match.group()))
|
||||
return dependencies
|
||||
|
||||
|
||||
def dependencies_from_wheel(wheel_file: Path) -> set[str]:
|
||||
"""读取 wheel 的 Core Metadata 中声明的 Requires-Dist 项目名。"""
|
||||
|
||||
with zipfile.ZipFile(wheel_file) as wheel:
|
||||
metadata_members = [name for name in wheel.namelist() if name.endswith(".dist-info/METADATA")]
|
||||
if len(metadata_members) != 1:
|
||||
raise ValueError("wheel 中必须恰有一个 .dist-info/METADATA 文件")
|
||||
metadata = BytesParser(policy=policy.default).parsebytes(wheel.read(metadata_members[0]))
|
||||
|
||||
dependencies = set()
|
||||
for requirement in metadata.get_all("Requires-Dist", []):
|
||||
match = re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*", requirement)
|
||||
if match is None:
|
||||
raise ValueError(f"wheel METADATA 包含无效的 Requires-Dist:{requirement}")
|
||||
dependencies.add(normalize_project_name(match.group()))
|
||||
return dependencies
|
||||
|
||||
|
||||
def verify_wheel_metadata(wheel_file: Path, requirements_file: Path) -> set[str]:
|
||||
"""返回没有被 wheel 元数据声明的 requirements 项目名。"""
|
||||
|
||||
return dependencies_from_requirements(requirements_file) - dependencies_from_wheel(wheel_file)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="检查 wheel 是否包含 requirements.txt 的依赖元数据")
|
||||
parser.add_argument("wheel", type=Path, help="待检查的 wheel 文件")
|
||||
parser.add_argument(
|
||||
"--requirements",
|
||||
type=Path,
|
||||
default=Path(__file__).resolve().parents[1] / "requirements.txt",
|
||||
help="唯一依赖来源 requirements.txt 的路径",
|
||||
)
|
||||
arguments = parser.parse_args(argv)
|
||||
|
||||
missing = verify_wheel_metadata(arguments.wheel, arguments.requirements)
|
||||
if missing:
|
||||
print(f"wheel METADATA 缺少依赖:{', '.join(sorted(missing))}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print("wheel METADATA 已声明 requirements.txt 中的全部依赖。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,6 @@
|
||||
"""采购工具桌面端包。
|
||||
|
||||
本包当前只提供应用骨架和安全的本地运行基础设施;不包含真机操作或采购流程。
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,7 @@
|
||||
"""支持通过 ``python -m cmbuyer_client`` 启动应用。"""
|
||||
|
||||
from .app import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,57 @@
|
||||
"""采购工具的最小桌面应用入口。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
|
||||
from .logging_policy import configure_application_logger
|
||||
from .runtime import RuntimePaths
|
||||
|
||||
|
||||
def select_application_argv(argv: Sequence[str] | None) -> list[str]:
|
||||
"""保留调用方明确给出的空参数列表,避免改变测试或打包入口的语义。"""
|
||||
|
||||
return list(sys.argv if argv is None else argv)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""启动只表达当前工程状态的桌面外壳。
|
||||
|
||||
真机控制和采购执行必须在完成取证并实现后才可接入,因此此入口不导入
|
||||
uiautomator2,也不提供任何会影响采购或支付状态的命令。
|
||||
"""
|
||||
|
||||
try:
|
||||
paths = RuntimePaths.default()
|
||||
logger = configure_application_logger(paths)
|
||||
except OSError as error:
|
||||
print(f"无法创建采购工具运行目录:{error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QMainWindow
|
||||
except ImportError:
|
||||
logger.error("缺少 PySide6,无法启动桌面界面。")
|
||||
print("无法启动采购工具:缺少 PySide6。请先安装 requirements.txt 中的依赖。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
application = QApplication.instance() or QApplication(select_application_argv(argv))
|
||||
application.setApplicationName("采购工具")
|
||||
|
||||
window = QMainWindow()
|
||||
window.setWindowTitle("采购工具")
|
||||
window.setAccessibleName("采购工具")
|
||||
window.setMinimumSize(420, 240)
|
||||
window.resize(560, 320)
|
||||
|
||||
message = QLabel("应用骨架已初始化。\n采购执行功能尚未启用。")
|
||||
message.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
message.setWordWrap(True)
|
||||
message.setAccessibleName("当前状态")
|
||||
window.setCentralWidget(message)
|
||||
|
||||
logger.info("应用已启动;采购执行功能尚未启用。")
|
||||
window.show()
|
||||
return application.exec()
|
||||
@@ -0,0 +1,66 @@
|
||||
"""采购工具日志的最小脱敏策略。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from .runtime import RuntimePaths
|
||||
|
||||
|
||||
LOGGER_NAME = "cmbuyer_client"
|
||||
REDACTED = "[已隐藏]"
|
||||
|
||||
_SENSITIVE_KEY_PATTERN = (
|
||||
r"token|authorization|password|secret|api[_-]?key|"
|
||||
r"address|phone|mobile|payment|pay|card|bank[_-]?account|"
|
||||
r"令牌|授权|密码|密钥|地址|手机号|电话|支付|银行卡"
|
||||
)
|
||||
_KEY_VALUE_PATTERN = re.compile(
|
||||
rf"(?P<key>{_SENSITIVE_KEY_PATTERN})\s*(?P<separator>[:=])\s*"
|
||||
r"(?P<value>\"[^\"]*\"|'[^']*'|[^\s,;]+)",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
_PHONE_PATTERN = re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")
|
||||
|
||||
|
||||
def redact_text(message: str) -> str:
|
||||
"""移除日志文本中的凭据、地址、手机号和支付字段值。"""
|
||||
|
||||
def replace_key_value(match: re.Match[str]) -> str:
|
||||
return f"{match.group('key')}{match.group('separator')}{REDACTED}"
|
||||
|
||||
redacted = _KEY_VALUE_PATTERN.sub(replace_key_value, message)
|
||||
return _PHONE_PATTERN.sub(REDACTED, redacted)
|
||||
|
||||
|
||||
class SensitiveDataFilter(logging.Filter):
|
||||
"""在任何 handler 格式化记录前,清除敏感字段。
|
||||
|
||||
该过滤器在日志写入前替换 ``msg`` 与 ``args``,确保文件 handler 不会得到原始值。
|
||||
"""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.msg = redact_text(record.getMessage())
|
||||
record.args = ()
|
||||
return True
|
||||
|
||||
|
||||
def configure_application_logger(paths: RuntimePaths) -> logging.Logger:
|
||||
"""配置唯一的 UTF-8 文件日志,并确保其先经过脱敏过滤。"""
|
||||
|
||||
paths.ensure_exists()
|
||||
logger = logging.getLogger(LOGGER_NAME)
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
|
||||
for handler in tuple(logger.handlers):
|
||||
logger.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
handler = logging.FileHandler(Path(paths.logs) / "client.log", encoding="utf-8")
|
||||
handler.addFilter(SensitiveDataFilter())
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||
logger.addHandler(handler)
|
||||
return logger
|
||||
@@ -0,0 +1,42 @@
|
||||
"""采购工具的本地运行目录策略。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimePaths:
|
||||
"""仅包含应用生成物的本地目录。
|
||||
|
||||
日志和证据产物不落在源码树中,避免把含有现场信息的运行数据误提交到 Git。
|
||||
"""
|
||||
|
||||
root: Path
|
||||
logs: Path
|
||||
artifacts: Path
|
||||
|
||||
@classmethod
|
||||
def from_root(cls, root: Path) -> "RuntimePaths":
|
||||
resolved_root = root.expanduser()
|
||||
return cls(
|
||||
root=resolved_root,
|
||||
logs=resolved_root / "logs",
|
||||
artifacts=resolved_root / "artifacts",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def default(cls) -> "RuntimePaths":
|
||||
local_app_data = os.environ.get("LOCALAPPDATA")
|
||||
if local_app_data:
|
||||
return cls.from_root(Path(local_app_data) / "cmbuyer")
|
||||
|
||||
return cls.from_root(Path.home() / ".local" / "share" / "cmbuyer")
|
||||
|
||||
def ensure_exists(self) -> None:
|
||||
"""创建运行目录;调用方负责向用户呈现无法创建目录的错误。"""
|
||||
|
||||
self.logs.mkdir(parents=True, exist_ok=True)
|
||||
self.artifacts.mkdir(parents=True, exist_ok=True)
|
||||
@@ -0,0 +1 @@
|
||||
"""采购工具的离线单元测试。"""
|
||||
@@ -0,0 +1,24 @@
|
||||
"""验证桌面入口的纯参数处理,不启动 PySide6。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.app import select_application_argv
|
||||
|
||||
|
||||
class ApplicationArgumentsTests(unittest.TestCase):
|
||||
def test_empty_argument_list_is_not_replaced_with_process_arguments(self) -> None:
|
||||
with mock.patch("cmbuyer_client.app.sys.argv", ["process-name", "--process-option"]):
|
||||
self.assertEqual([], select_application_argv([]))
|
||||
|
||||
def test_none_uses_process_arguments(self) -> None:
|
||||
with mock.patch("cmbuyer_client.app.sys.argv", ["process-name", "--process-option"]):
|
||||
self.assertEqual(["process-name", "--process-option"], select_application_argv(None))
|
||||
@@ -0,0 +1,49 @@
|
||||
"""验证日志脱敏边界,不需要 PySide6 或真机。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.logging_policy import configure_application_logger, redact_text
|
||||
from cmbuyer_client.runtime import RuntimePaths
|
||||
|
||||
|
||||
class LoggingPolicyTests(unittest.TestCase):
|
||||
def test_redact_text_hides_required_sensitive_values(self) -> None:
|
||||
message = (
|
||||
"token=secret-value authorization: Bearer-value "
|
||||
"address='浙江省杭州市' phone=13800138000 payment=card-value"
|
||||
)
|
||||
|
||||
redacted = redact_text(message)
|
||||
|
||||
for raw_value in ("secret-value", "Bearer-value", "浙江省杭州市", "13800138000", "card-value"):
|
||||
self.assertNotIn(raw_value, redacted)
|
||||
self.assertIn("token=[已隐藏]", redacted)
|
||||
|
||||
def test_file_handler_writes_only_redacted_text(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
paths = RuntimePaths.from_root(Path(directory))
|
||||
logger = configure_application_logger(paths)
|
||||
try:
|
||||
logger.info("token=not-for-log phone=13900139000 payment=not-for-log")
|
||||
|
||||
for handler in logger.handlers:
|
||||
handler.flush()
|
||||
content = (paths.logs / "client.log").read_text(encoding="utf-8")
|
||||
finally:
|
||||
# Windows 不允许在 FileHandler 持有文件时删除临时目录。
|
||||
for handler in tuple(logger.handlers):
|
||||
logger.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
self.assertNotIn("not-for-log", content)
|
||||
self.assertNotIn("13900139000", content)
|
||||
self.assertIn("[已隐藏]", content)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""验证本地运行目录策略,不需要连接设备。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.runtime import RuntimePaths
|
||||
|
||||
|
||||
class RuntimePathsTests(unittest.TestCase):
|
||||
def test_ensure_exists_creates_only_runtime_directories(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
paths = RuntimePaths.from_root(Path(directory) / "runtime")
|
||||
|
||||
paths.ensure_exists()
|
||||
|
||||
self.assertTrue(paths.logs.is_dir())
|
||||
self.assertTrue(paths.artifacts.is_dir())
|
||||
@@ -0,0 +1,29 @@
|
||||
"""验证 wheel 元数据检查脚本,不构建 wheel 或安装运行时依赖。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "scripts"))
|
||||
|
||||
from verify_wheel_metadata import dependencies_from_requirements, verify_wheel_metadata
|
||||
|
||||
|
||||
class WheelMetadataTests(unittest.TestCase):
|
||||
def test_metadata_checker_accepts_dependencies_from_the_single_requirements_file(self) -> None:
|
||||
expected_dependencies = dependencies_from_requirements(CLIENT_ROOT / "requirements.txt")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
wheel_file = Path(directory) / "cmbuyer_client-0.1.0-py3-none-any.whl"
|
||||
metadata = "Metadata-Version: 2.3\n" + "".join(
|
||||
f"Requires-Dist: {dependency}\n" for dependency in sorted(expected_dependencies)
|
||||
)
|
||||
with zipfile.ZipFile(wheel_file, "w") as wheel:
|
||||
wheel.writestr("cmbuyer_client-0.1.0.dist-info/METADATA", metadata)
|
||||
|
||||
self.assertEqual(set(), verify_wheel_metadata(wheel_file, CLIENT_ROOT / "requirements.txt"))
|
||||
@@ -85,12 +85,12 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
|
||||
## 当前阶段
|
||||
|
||||
**Phase 0 · 地基。** 采购服务骨架已初始化,采购工具骨架仍在进行;尚无采购业务代码。
|
||||
**Phase 0 · 地基。** 采购服务与采购工具骨架均已初始化,尚无采购业务代码。
|
||||
|
||||
执行按任务依赖驱动,**不按 Phase 整段串行等待**。当前优先路径:
|
||||
|
||||
1. 继续完成 T-002 采购工具骨架;采购服务方向可并行推进 T-004 数据模型。
|
||||
2. T-002 完成后立即推进 **T-101 → T-102 → T-103 真机取证**,并可并行补 T-003 统一入口。
|
||||
1. T-004 核心数据模型正在进行;T-003 统一入口与 T-101 真机环境盘点可开始。
|
||||
2. T-002 已完成,立即推进 **T-101 → T-102 → T-103 真机取证**,并可并行补 T-003 统一入口。
|
||||
3. T-103 结论确认后,才开始依赖真机可读字段的 Phase 2 生产页面;采购服务核心与
|
||||
T-104 → T-107 后续真机安全判据按依赖并行推进。
|
||||
4. Phase 3:双端打通与**第一趟试选**端到端。
|
||||
@@ -172,22 +172,31 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
|
||||
## 验证命令
|
||||
|
||||
> 采购服务命令已由 T-001 实际验证;采购工具命令在 T-002 完成前仍是占位路径。
|
||||
> 采购服务命令已由 T-001 实际验证;采购工具的离线测试、编译和 wheel 元数据命令已由
|
||||
> Python 3.12 实际验证。当前默认 `python` 是 Python 3.10.11,统一入口尚未正确选择
|
||||
> Python 3.11+,此缺口由 T-003 修复。
|
||||
|
||||
```bash
|
||||
```powershell
|
||||
# 采购服务 admin/(改了 Go 代码后)
|
||||
cd admin
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
|
||||
# 采购工具 client/(T-002 完成后启用)
|
||||
# 采购工具 client/(使用 Python 3.11+;本机已验证 3.12)
|
||||
cd client
|
||||
python -m unittest discover -s tests -t .
|
||||
python -m compileall -q src tests
|
||||
py -3.12 -m unittest discover -s tests -t .
|
||||
py -3.12 -m compileall -q src tests scripts
|
||||
$wheelDir = Join-Path $env:TEMP ('cmbuyer-client-wheel-' + [guid]::NewGuid())
|
||||
New-Item -ItemType Directory -Path $wheelDir | Out-Null
|
||||
py -3.12 -m pip wheel --no-deps . --wheel-dir $wheelDir
|
||||
py -3.12 scripts/verify_wheel_metadata.py (Get-ChildItem $wheelDir -Filter '*.whl').FullName
|
||||
|
||||
# 跨端契约改动:两端全跑
|
||||
```
|
||||
|
||||
验证层级何时触发见 [`03-tech-stack.md`](03-tech-stack.md) 第六节验证矩阵。
|
||||
如果命令当前不可运行,必须在回复里如实说明原因。
|
||||
`requirements.txt` 是采购工具唯一的运行时依赖来源,`pyproject.toml` 动态读取它写入 wheel
|
||||
元数据。规范安装是 Python 3.11+ 虚拟环境中的 `python -m pip install -e .`,已由 Python 3.12
|
||||
验证通过;桌面 GUI 和真机流程不属于本次验收。`init.ps1` 仍未正确选择 Python 3.11+,统一入口
|
||||
由 T-003 修复。若命令当前不可运行,必须在回复里如实说明原因。
|
||||
|
||||
+17
-7
@@ -59,15 +59,25 @@
|
||||
|
||||
## 五、构建与运行命令
|
||||
|
||||
> 采购服务命令已由 T-001 实际验证;采购工具命令由 T-002 完成后替换剩余占位项。
|
||||
> 采购服务命令已由 T-001 实际验证。采购工具以下离线测试、编译和 wheel 元数据命令已由
|
||||
> Python 3.12 实际验证;桌面 GUI 和真机流程不属于 T-002 验收范围。
|
||||
|
||||
| 用途 | 采购服务(`admin/`) | 采购工具(`client/`) |
|
||||
| --- | --- | --- |
|
||||
| 安装依赖 | `go mod download` | `python -m venv .venv` + `pip install -r requirements.txt` |
|
||||
| 本地开发 | `go run ./cmd/server` | 【T-002 填写】 |
|
||||
| 构建 | `go build ./...` | 【T-002 填写】 |
|
||||
| 测试 | `go test ./...` | `python -m unittest discover -s tests -t .` |
|
||||
| 静态检查 | `go vet ./...` | `python -m compileall -q src tests` |
|
||||
| 安装依赖 | `go mod download` | `py -3.12 -m venv .venv` 后运行 `.\.venv\Scripts\python.exe -m pip install -e .` |
|
||||
| 本地开发 | `go run ./cmd/server` | `.\.venv\Scripts\python.exe -m cmbuyer_client` |
|
||||
| 构建 | `go build ./...` | `py -3.12 -m pip wheel --no-deps . --wheel-dir <输出目录>`,再运行 `py -3.12 scripts/verify_wheel_metadata.py <wheel 路径>` |
|
||||
| 测试 | `go test ./...` | `py -3.12 -m unittest discover -s tests -t .` |
|
||||
| 静态检查 | `go vet ./...` | `py -3.12 -m compileall -q src tests scripts` |
|
||||
|
||||
采购工具的运行时依赖只维护在 `client/requirements.txt`。`client/pyproject.toml` 通过 setuptools
|
||||
动态读取该文件生成 wheel 的 `Requires-Dist`,避免两份依赖列表漂移;
|
||||
`scripts/verify_wheel_metadata.py` 会验证生成 wheel 已声明全部这些依赖。
|
||||
|
||||
当前 Windows 默认 `python` 指向 Python 3.10.11,不满足采购工具的 Python 3.11+ 下限,
|
||||
不得把未加版本选择器的 `python` 当作采购工具的已验证命令。T-003 负责统一入口与解释器选择;
|
||||
在此之前使用 `py -3.12` 或 Python 3.11+ 虚拟环境。Python 3.12 虚拟环境中的完整
|
||||
`pip install -e .` 已验证通过;桌面 GUI 与真机流程不属于 T-002 验收范围。
|
||||
|
||||
Windows PowerShell 差异:
|
||||
|
||||
@@ -80,7 +90,7 @@ $env:GOTOOLCHAIN = "local"
|
||||
|
||||
| 层级 | 触发条件 | 命令 / 操作 | 通过证据 |
|
||||
| --- | --- | --- | --- |
|
||||
| 任务相关验证 | 每个任务必跑 | 改 `admin/` 跑 `go test ./...` + `go vet ./...`;改 `client/` 跑 `python -m unittest discover -s tests -t .` + `python -m compileall -q src tests` | 退出码 0、测试数 |
|
||||
| 任务相关验证 | 每个任务必跑 | 改 `admin/` 跑 `go test ./...` + `go vet ./...`;改 `client/` 跑 `py -3.12 -m unittest discover -s tests -t .` + `py -3.12 -m compileall -q src tests scripts` | 退出码 0、测试数 |
|
||||
| 完整门禁 | 发布前;修改 HTTP 契约、数据库 schema、依赖或构建配置时;跨端改动时 | 两端全部测试 + 静态检查 + 两端构建 | 退出码 0、测试数、产物路径 |
|
||||
| 人工 / 设备验收 | 任何涉及真机页面判据、下单动作或付款路径的任务 | 连接真机执行,记录设备型号、Android 版本、拼多多版本、goods_id、截图与页面 XML 路径 | 人工结论 + 证据文件路径 |
|
||||
|
||||
|
||||
+39
-12
@@ -12,30 +12,33 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-08-03
|
||||
- 阶段:**Phase 0 · 地基(采购服务骨架完成,采购工具骨架进行中)**
|
||||
- 阶段:**Phase 0 · 地基(采购服务与采购工具骨架已初始化,尚无采购业务代码)**
|
||||
- MVP 形态:手工填链接建单 → 批量开始试选 → 定时轮询 → **第一趟试选** → 人工确认 → **第二趟下单** → 待付款
|
||||
- 技术栈:已定。采购服务(`admin/`)使用 Go 1.23+ / gin / SQLite;采购工具(`client/`)
|
||||
使用 Python 3.11+ / uiautomator2 / PySide6。
|
||||
详见 [`03-tech-stack.md`](03-tech-stack.md)
|
||||
- 生产代码:`admin/` 已有最小 Go 服务、`GET /healthz` 与 SQLite 驱动封装;尚无采购业务代码
|
||||
- 测试:采购服务 2 项离线单元测试
|
||||
- 生产代码:`admin/` 已有最小 Go 服务、`GET /healthz` 与 SQLite 驱动封装;`client/` 已有
|
||||
Python 包、PySide6 最小入口、运行目录与日志脱敏策略;两端均尚无采购业务代码
|
||||
- 测试:采购服务 2 项离线单元测试;采购工具 6 项离线单元测试(不连接真机)
|
||||
- 数据:**无**
|
||||
- 标准启动路径:`./init.ps1`(Windows)/ `./init.sh`。当前会验证采购服务,再因
|
||||
`client/requirements.txt` 尚不存在以退出码 3 提示 T-002——这是预期行为
|
||||
- 标准验证路径:`admin/` 下运行 `go test ./...`、`go vet ./...`、`go build ./...`;仓库根运行
|
||||
- 标准启动路径:`./init.ps1`(Windows)/ `./init.sh`。两端骨架现已存在,但 `init.ps1` 仍使用
|
||||
默认 Python 3.10,不能正确验证要求 Python 3.11+ 的采购工具;T-003 负责统一入口与解释器选择。
|
||||
- 标准验证路径:`admin/` 下运行 `go test ./...`、`go vet ./...`、`go build ./...`;`client/` 下使用
|
||||
`py -3.12 -m unittest discover -s tests -t .`、`py -3.12 -m compileall -q src tests scripts`;仓库根运行
|
||||
`python scripts/validate_agent_context.py`
|
||||
- 当前 blocker:无外部 blocker。T-002 正在实现;T-004 已因 T-001 完成而可领取。
|
||||
- 当前 blocker:无外部 blocker。T-002 已完成;T-004 核心数据模型正在进行;T-003 统一入口与
|
||||
T-101 真机环境盘点已就绪。当前不宣称统一入口或桌面 GUI 已验收。
|
||||
|
||||
## 当前目录要点
|
||||
|
||||
| 路径 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
|
||||
| `docs/tasks/` | 已有(T-001、T-002、T-005~T-009) | T-001 已完成;T-002 正在进行;其余既有任务已完成 |
|
||||
| `docs/tasks/` | 已有(T-001、T-002、T-004~T-009) | T-001、T-002 已完成;T-004 正在进行;其余既有任务已完成 |
|
||||
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
||||
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
||||
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite 骨架与健康检查;无采购业务路由 |
|
||||
| `client/` | **待建** | “采购工具”Python 桌面端(T-002) |
|
||||
| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、离线测试与 wheel 元数据检查;无真机或采购流程 |
|
||||
| `init.ps1` / `init.sh` | 已有(骨架) | 统一入口。两端目录建好后由 T-003 补全并验证 |
|
||||
|
||||
## 任务状态
|
||||
@@ -45,8 +48,8 @@
|
||||
- 已完成:T-005(采购服务交互原型)、T-006(采购工具交互原型)、T-007(产品名称与
|
||||
源码目录契约)、T-008(Vikunja 任务权威与单向导出)、T-009(MVP 关键路径与并行波次),
|
||||
以及 T-001(采购服务 Go 骨架)。
|
||||
- 正在进行:T-002(采购工具 Python 骨架)。采购服务方向可并行落成 T-004;T-002 完成后
|
||||
立即推进 T-101 → T-102 → T-103,并可补 T-003 统一入口。
|
||||
- 已完成:T-002(采购工具 Python 骨架)。正在进行:T-004(核心数据模型)。
|
||||
T-003(统一入口)与 T-101(真机环境盘点)已就绪,立即推进 T-101 → T-102 → T-103。
|
||||
- T-103 是当前最高优先级和 MVP 生死线。通过前不开发依赖真机可读字段的 Phase 2 生产页面。
|
||||
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
|
||||
可读字段时必须先回修原型与交互清单。
|
||||
@@ -73,7 +76,31 @@ cd ..
|
||||
python scripts/validate_agent_context.py
|
||||
```
|
||||
|
||||
采购工具与统一入口的真实命令分别等待 T-002、T-003 完成后补齐。
|
||||
采购工具当前可运行的离线验证与 wheel 元数据检查:
|
||||
|
||||
```powershell
|
||||
cd client
|
||||
py -3.12 -m unittest discover -s tests -t .
|
||||
py -3.12 -m compileall -q src tests scripts
|
||||
$wheelDir = Join-Path $env:TEMP ('cmbuyer-client-wheel-' + [guid]::NewGuid())
|
||||
New-Item -ItemType Directory -Path $wheelDir | Out-Null
|
||||
py -3.12 -m pip wheel --no-deps . --wheel-dir $wheelDir
|
||||
py -3.12 scripts/verify_wheel_metadata.py (Get-ChildItem $wheelDir -Filter '*.whl').FullName
|
||||
```
|
||||
|
||||
采购工具规范安装与本地开发入口:
|
||||
|
||||
```powershell
|
||||
cd client
|
||||
py -3.12 -m venv .venv
|
||||
.\.venv\Scripts\python.exe -m pip install -e .
|
||||
.\.venv\Scripts\python.exe -m cmbuyer_client
|
||||
```
|
||||
|
||||
`client/requirements.txt` 是唯一依赖来源,`client/pyproject.toml` 动态读取它生成 wheel 的
|
||||
`Requires-Dist`。Python 3.12 已验证 6 项离线测试、编译与 wheel 元数据;完整运行时依赖安装
|
||||
(`pip install -e .`)已通过。`init.ps1` 当前仍选择 Python 3.10,因此不能声称统一入口已正确;
|
||||
桌面 GUI 与真机流程未作为 T-002 验收执行。
|
||||
|
||||
## 关键背景
|
||||
|
||||
|
||||
+10
-3
@@ -3,7 +3,7 @@ id: T-002
|
||||
title: 初始化采购工具 client Python 骨架
|
||||
phase: 0
|
||||
deps: []
|
||||
status: DOING
|
||||
status: DONE
|
||||
created: 2026-08-03
|
||||
vikunja_task_id: 17
|
||||
context_ref: a63bdb8
|
||||
@@ -13,9 +13,12 @@ needs_human_review: false
|
||||
write_paths:
|
||||
- docs/tasks/T-002.md
|
||||
- client/**
|
||||
- docs/03-tech-stack.md
|
||||
- docs/00-ai-start-here.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=17 synced=2026-08-03T09:57:46Z sha256=14061aa9aca2b854afd2b657c051f593e29272ed67327d0c840d499708f8c821 -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=17 synced=2026-08-03T10:31:33Z sha256=6f7f0738647530406d746c46f898df8f572153cbce0180fe18fcc07c10e9e9d3 -->
|
||||
## 问题 / 背景
|
||||
|
||||
仓库尚无 client/requirements.txt 和可运行的采购工具代码,init.ps1 因此按预期以退出码 3 停止。没有稳定的 Python 包、测试入口、日志与产物策略,后续 ADB/uiautomator2 真机取证无法开始。
|
||||
@@ -44,7 +47,11 @@ write_paths:
|
||||
|
||||
## 执行记录
|
||||
|
||||
(暂无)
|
||||
### 2026-08-03T10:30:56Z · ila
|
||||
|
||||
主 agent 终审通过:采购工具 Python 3.11+ 骨架、PySide6 最小入口、运行目录和敏感日志脱敏策略均已落成;无真机、页面判据、下单或付款逻辑。
|
||||
独立验证:6 项 unittest 通过;compileall 通过;wheel 构建及 requirements.txt→Requires-Dist 元数据核对通过;Python 3.12 editable install 通过;离屏窗口构造通过;采购服务 race/vet/build 回归及 agent-context 门禁通过。
|
||||
共享文档已更新为 T-002 完成快照,并如实保留 init.ps1 默认 Python 3.10 风险给 T-003。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
@@ -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` 第四节安全边界,入口脚本不得触发任何业务动作。
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
id: T-004
|
||||
title: 建立核心数据模型与状态机
|
||||
phase: 0
|
||||
deps: [T-001]
|
||||
status: DONE
|
||||
created: 2026-08-03
|
||||
vikunja_task_id: 19
|
||||
context_ref: c72371c
|
||||
work_branch: task/t-004-core-data-state
|
||||
needs_device: false
|
||||
needs_human_review: false
|
||||
write_paths:
|
||||
- docs/tasks/T-004.md
|
||||
- admin/go.mod
|
||||
- admin/go.sum
|
||||
- admin/cmd/migrate/**
|
||||
- admin/internal/domain/**
|
||||
- admin/internal/migrations/**
|
||||
- admin/internal/storage/sqlite/**
|
||||
- admin/migrations/**
|
||||
- docs/04-architecture.md
|
||||
- docs/api.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=19 synced=2026-08-03T10:37:34Z sha256=a848b2881154932ac8dd7ac46abbbd8a58da1664c50a0901dccd905eb00db5c2 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-001 已建立可运行的采购服务骨架,但尚无业务实体、数据库 schema 或可验证的状态机。后续登录、建单、领取、授权和提交围栏都依赖一致的数据模型;若先写 HTTP 页面再补状态约束,会把资金安全边界分散到处理器中。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:Phase 0 核心数据模型地基。
|
||||
- 用户故事 / 交互:不适用,不实现生产页面。
|
||||
- 架构 / API:以 docs/04-architecture.md 第四、第五节和 docs/api.md 为权威;schema 变化必须同步两者。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 从现有架构/API逐字段提取 tasks、order_authorizations、order_submissions,不臆造接口字段。
|
||||
2. 使用 goose 与 SQLite 建立可重复迁移及约束;金额只存十进制字符串,禁止 float。
|
||||
3. 在 admin/internal/domain 建立显式状态与合法转换,非法转换 fail closed,并用表驱动测试覆盖。
|
||||
4. 对一次性授权、提交围栏和不可重试结果只建立数据约束与纯状态规则,不实现 HTTP、真机点击或下单函数。
|
||||
5. T-002 为 DOING 时不修改 docs/current-state.md;代码与架构/API完成后先记录验证,待 T-002 关闭再扩展 write_paths 并收口状态快照。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- tasks、order_authorizations、order_submissions 表和约束与架构文档一致,迁移可重复向上/向下验证。
|
||||
- 核心状态合法/非法流转有单元测试;第一趟状态路径不引用任何下单函数。
|
||||
- 金额字段是十进制字符串,代码和 schema 不使用浮点数。
|
||||
- go test ./...、go vet ./...、go build ./... 通过。
|
||||
- 不实现 HTTP 业务端点、页面、真机判据、提交订单点击或付款。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 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 -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 不实现 HTTP 业务端点、管理页面、设备领取、真机自动化或 Phase 2+ 服务。
|
||||
- 不编写或引用任何真机下单函数,不点击提交订单,不涉及付款。
|
||||
- 不放宽 `docs/04-architecture.md` 第四节任何边界;代码与文档不一致时先停止并由主 agent
|
||||
复核,不以“跑通”为理由改弱约束。
|
||||
- 不修改 `client/`、需求、交互原型、启动入口或 T-002 当前占用的 `docs/current-state.md`。
|
||||
- 如完成代码后仍需更新 `docs/current-state.md`,必须等待 T-002 关闭并先提交 `write_paths`
|
||||
扩展;不得在两个 `DOING` 任务间制造共享路径冲突。
|
||||
- 不使用前序项目 schema 作为事实来源;所有字段和状态只取自本仓库架构/API。
|
||||
Reference in New Issue
Block a user