Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f4a95a55e | ||
|
|
637c341b95 | ||
|
|
87591e84f9 | ||
|
|
8be6d206dd | ||
|
|
bcec012c8b | ||
|
|
2ab5baa776 | ||
|
|
cf293d2382 | ||
|
|
c72371ce90 | ||
|
|
b7559a9451 | ||
|
|
a63bdb8c37 | ||
|
|
4948afc393 |
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"cmbuyer/admin/internal/server"
|
||||
)
|
||||
|
||||
const listenAddress = ":8080"
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
err := http.ListenAndServe(listenAddress, server.NewRouter())
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
module cmbuyer/admin
|
||||
|
||||
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 (
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
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
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
)
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
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=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
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=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
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=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
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=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Package server 定义采购服务当前拥有的 HTTP 端点。
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
|
||||
func NewRouter() *gin.Engine {
|
||||
router := gin.New()
|
||||
|
||||
router.GET("/healthz", func(context *gin.Context) {
|
||||
context.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/server"
|
||||
)
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
server.NewRouter().ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("healthz status = %d, want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
if contentType := response.Header().Get("Content-Type"); contentType != "application/json; charset=utf-8" {
|
||||
t.Fatalf("healthz content type = %q, want application/json; charset=utf-8", contentType)
|
||||
}
|
||||
|
||||
if body := response.Body.String(); body != "{\"status\":\"ok\"}" {
|
||||
t.Fatalf("healthz body = %q, want {\"status\":\"ok\"}", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Package sqlite 提供采购服务的 SQLite 驱动注册。
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
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) {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package sqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
func TestOpen(t *testing.T) {
|
||||
database, err := sqlite.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open SQLite database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := database.Close(); err != nil {
|
||||
t.Errorf("close SQLite database: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
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"))
|
||||
+28
-12
@@ -85,13 +85,14 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
|
||||
## 当前阶段
|
||||
|
||||
**Phase 0 · 地基(尚未开始编码)。** 仓库目前只有文档,没有生产代码。
|
||||
**Phase 0 · 地基。** 采购服务与采购工具骨架均已初始化,尚无采购业务代码。
|
||||
|
||||
优先路径:
|
||||
执行按任务依赖驱动,**不按 Phase 整段串行等待**。当前优先路径:
|
||||
|
||||
1. Phase 0:网页端 / 桌面端 MVP 原型人工确认、两端骨架与数据模型。
|
||||
2. **Phase 1:真机取证(最高风险,生死线)。**
|
||||
3. Phase 2:采购服务核心(含**授权超时**,不得推后)。
|
||||
1. T-003 统一入口与 T-004 核心数据模型已完成;T-101 真机环境盘点已就绪,但尚未真机验收。
|
||||
2. T-002 已完成,立即推进 **T-101 → T-102 → T-103 真机取证**。
|
||||
3. T-103 结论确认后,才开始依赖真机可读字段的 Phase 2 生产页面;采购服务核心与
|
||||
T-104 → T-107 后续真机安全判据按依赖并行推进。
|
||||
4. Phase 3:双端打通与**第一趟试选**端到端。
|
||||
5. Phase 4:**第二趟下单**与收尾。
|
||||
6. V2 及以后:图搜、Excel、ERP、订单自动核对、AI 辅助。
|
||||
@@ -171,20 +172,35 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
|
||||
## 验证命令
|
||||
|
||||
> **占位符。** T-001 / T-002 落地后由该任务替换为真实命令,并同步
|
||||
> [`03-tech-stack.md`](03-tech-stack.md) 和 [`current-state.md`](current-state.md)。
|
||||
> Windows 标准入口 `./init.ps1` 已由 T-003 实际验证。它优先使用合规的既有 venv(本机实际为
|
||||
> Python 3.12),仅在 venv 缺失时才从 Python Launcher 自动选择最高的 Python 3.11+,避免回退到
|
||||
> 默认 Python 3.10;通过后会输出两端真实启动命令。
|
||||
|
||||
```bash
|
||||
```powershell
|
||||
# Windows 统一安装、离线验证与启动命令提示
|
||||
.\init.ps1
|
||||
|
||||
# 以下为诊断或单独验证时使用的等价命令
|
||||
# 采购服务 admin/(改了 Go 代码后)
|
||||
cd admin
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
|
||||
# 采购工具 client/(改了 Python 代码后)
|
||||
python -m unittest discover -s tests -t .
|
||||
python -m compileall -q src tests
|
||||
# 采购工具 client/(由 init.ps1 创建的 Python 3.11+ 虚拟环境)
|
||||
cd client
|
||||
.\.venv\Scripts\python.exe -m unittest discover -s tests -t .
|
||||
.\.venv\Scripts\python.exe -m compileall -q src tests scripts
|
||||
$wheelDir = Join-Path $env:TEMP ('cmbuyer-client-wheel-' + [guid]::NewGuid())
|
||||
New-Item -ItemType Directory -Path $wheelDir | Out-Null
|
||||
.\.venv\Scripts\python.exe -m pip wheel --no-deps . --wheel-dir $wheelDir
|
||||
.\.venv\Scripts\python.exe 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` 使用合规既有 venv,或在缺失时自动选择
|
||||
Python 3.11+ 创建它;本机现有 venv 实际验证为 3.12。若命令当前不可运行,必须在回复里如实说明原因。
|
||||
|
||||
+25
-8
@@ -59,16 +59,33 @@
|
||||
|
||||
## 五、构建与运行命令
|
||||
|
||||
> **占位符。** 代码尚未初始化,以下命令分别在 T-001 / T-002 落地后由对应任务替换为真实可运行命令,
|
||||
> 并同步到 [`00-ai-start-here.md`](00-ai-start-here.md) 和 [`current-state.md`](current-state.md)。
|
||||
> `init.ps1` 已由 T-003 在 Windows PowerShell 实际验证:它优先复用合规的既有采购工具虚拟环境
|
||||
> (本机实际为 Python 3.12);仅在虚拟环境不存在时,才从 Python Launcher 的已安装版本中确定性选择
|
||||
> 最高的 Python 3.11+ 创建它,以 editable 方式安装采购工具,并跑两端
|
||||
> 离线门禁。桌面 GUI 和真机流程不属于该入口的验收范围。
|
||||
|
||||
| 用途 | 采购服务(`admin/`) | 采购工具(`client/`) |
|
||||
| --- | --- | --- |
|
||||
| 安装依赖 | `go mod download` | `python -m venv .venv` + `pip install -r requirements.txt` |
|
||||
| 本地开发 | 【T-001 填写】 | 【T-002 填写】 |
|
||||
| 构建 | 【T-001 填写】 | 【T-002 填写】 |
|
||||
| 测试 | `go test ./...` | `python -m unittest discover -s tests -t .` |
|
||||
| 静态检查 | `go vet ./...` | `python -m compileall -q src tests` |
|
||||
| 安装依赖 | `go mod download` | Windows 运行 `./init.ps1`;它复用合规 venv,或选择 Python Launcher 中最高的 Python 3.11+ 创建 venv 后执行 `pip install -e .` |
|
||||
| 本地开发 | `go run ./cmd/server` | `.\.venv\Scripts\python.exe -m cmbuyer_client` |
|
||||
| 构建 | `go build ./...` | `.\.venv\Scripts\python.exe -m pip wheel --no-deps . --wheel-dir <输出目录>`,再运行 `.\.venv\Scripts\python.exe scripts/verify_wheel_metadata.py <wheel 路径>` |
|
||||
| 测试 | `go test ./...` | `.\.venv\Scripts\python.exe -m unittest discover -s tests -t .` |
|
||||
| 静态检查 | `go vet ./...` | `.\.venv\Scripts\python.exe -m compileall -q src tests scripts` |
|
||||
|
||||
Windows 的标准入口是仓库根 `./init.ps1`。它要求 Go、两端目录及其哨兵文件存在;已有合规
|
||||
`client/.venv` 时,所有采购工具检查与 validator 都使用该解释器。只有 venv 不存在时,才从 `py -0p`
|
||||
枚举的版本中确定性选择最高的 Python 3.11+ 创建它;没有合规版本时明确失败,绝不回退默认 `python`。
|
||||
它执行 admin 的 `go mod download` / test / vet / build、client 的 editable install / 包导入 / unittest /
|
||||
compileall。既有 `client/.venv` 若不是 Python 3.11+ 会明确失败,不会自动覆盖用户环境。`init.sh` 保持等价门禁语义;WSL 或 Unix 环境缺少所需 Go、Python 3.11+
|
||||
或项目文件时必须非零退出,语法通过或 Unix 成功不构成 Windows / 真机验收。
|
||||
|
||||
采购工具的运行时依赖只维护在 `client/requirements.txt`。`client/pyproject.toml` 通过 setuptools
|
||||
动态读取该文件生成 wheel 的 `Requires-Dist`,避免两份依赖列表漂移;
|
||||
`scripts/verify_wheel_metadata.py` 会验证生成 wheel 已声明全部这些依赖。
|
||||
|
||||
当前 Windows 默认 `python` 仍可能指向 Python 3.10,不满足采购工具的 Python 3.11+ 下限;
|
||||
不得把未加版本选择器的 `python` 当作采购工具命令。统一入口优先使用既有合规 venv,仅在需要创建时
|
||||
自动选择 Launcher 中最高的合规版本;本机现有 venv 实际为 Python 3.12。桌面 GUI 与真机流程不属于 T-003 验收范围。
|
||||
|
||||
Windows PowerShell 差异:
|
||||
|
||||
@@ -81,7 +98,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/` 跑 `.venv\Scripts\python.exe -m unittest discover -s tests -t .` + `.venv\Scripts\python.exe -m compileall -q src tests scripts` | 退出码 0、测试数 |
|
||||
| 完整门禁 | 发布前;修改 HTTP 契约、数据库 schema、依赖或构建配置时;跨端改动时 | 两端全部测试 + 静态检查 + 两端构建 | 退出码 0、测试数、产物路径 |
|
||||
| 人工 / 设备验收 | 任何涉及真机页面判据、下单动作或付款路径的任务 | 连接真机执行,记录设备型号、Android 版本、拼多多版本、goods_id、截图与页面 XML 路径 | 人工结论 + 证据文件路径 |
|
||||
|
||||
|
||||
@@ -15,6 +15,27 @@
|
||||
4. **本文只在规划变化时修改**:单个任务开工或完成**不**修改本文。
|
||||
5. **动手前**先读 `00-ai-start-here.md`、`05-coding-rules.md` 和 `current-state.md`。
|
||||
|
||||
## 并行执行与关键路径
|
||||
|
||||
执行顺序以任务表中的 `依赖` 为准,Phase 是里程碑分组,**不是要求整段串行的栅栏**。
|
||||
在 `write_paths` 不重叠且依赖已完成的前提下,按以下波次推进:
|
||||
|
||||
| 波次 | 采购服务方向 | 采购工具 / 真机方向 | 集成方向 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | T-001 初始化 `admin/` | T-002 初始化 `client/` | - |
|
||||
| 2 | T-001 后立即做 T-004 | T-002 后立即做 T-101 → T-102 → **T-103** | T-001、T-002 都完成后做 T-003 |
|
||||
| 3 | T-103 通过后推进 T-201 → T-207 | T-103 后推进 T-104 → T-107 | T-208 等待 T-207 与 T-107 均完成 |
|
||||
| 4 | T-301 → T-302 | T-302 后交接 T-303 → T-304 → T-306 | T-306 与 T-104 完成后做 T-305 |
|
||||
| 5 | - | T-305、T-208、T-306 后做 T-401 | T-401 后并行 T-402 / T-403,再做 T-404 → T-405 |
|
||||
|
||||
**T-103 是当前最高优先级和 MVP 生死线。** T-002 一完成就启动 T-101,不等待 T-003、
|
||||
T-004 或整个 Phase 0 收尾。并行只优化等待关系,不改变下列门禁:
|
||||
|
||||
- T-103 的真机结论出来前,不写依赖真机可读字段的 Phase 2 生产页面。
|
||||
- `needs_device: true` 的任务仍只能由人完成验收。
|
||||
- 不复用前序项目页面判据,不放宽三道价格闸门,不让第一趟引用任何下单函数。
|
||||
- 第三个 agent 优先做写路径独立的集成任务或只读复核,不与两端任务争写共享文档。
|
||||
|
||||
## 建议拆分清单
|
||||
|
||||
### Phase 0 · 地基
|
||||
|
||||
+61
-35
@@ -12,65 +12,91 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期: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)
|
||||
- 生产代码:**无**。仓库目前只有文档
|
||||
- 测试:**无**
|
||||
- 数据:**无**
|
||||
- 标准启动路径:`./init.ps1`(Windows)/ `./init.sh`。已存在,以 `admin/go.mod` 和
|
||||
`client/requirements.txt` 判断两端是否初始化;当前会以退出码 3 结束并列出 T-001 / T-002
|
||||
——这是预期行为
|
||||
- 标准验证路径:目前只有 `python scripts/validate_agent_context.py` 可跑通
|
||||
- 当前 blocker:T-005“采购服务”Web 表格工作台和 T-006 第三轮“采购工具”桌面工作区均已通过
|
||||
自动检查;两者经人工确认前不开始对应生产 UI。
|
||||
- 生产代码:`admin/` 已有最小 Go 服务、健康检查、核心领域模型、SQLite 迁移与任务状态机;
|
||||
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略;尚无真机采购流程
|
||||
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 6 项离线单元测试
|
||||
(不连接真机)
|
||||
- 数据:SQLite 核心表与迁移已落成;无业务实例数据
|
||||
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
|
||||
优先使用合规的既有 venv;仅在其缺失时才从 Python Launcher 已安装版本中选择最高的 Python 3.11+,
|
||||
并且不覆盖低版本环境;成功后打印真实启动命令。
|
||||
- 标准验证路径:`./init.ps1` 已实际跑通 admin 的 mod download / test / vet / build、client 的
|
||||
editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。
|
||||
- 当前 blocker:无外部 blocker。T-002、T-003、T-004 已完成;T-101 真机环境盘点已就绪,
|
||||
但尚未真机验收。桌面 GUI 与真机流程尚未验收。
|
||||
|
||||
## 当前目录要点
|
||||
|
||||
| 路径 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
|
||||
| `docs/tasks/` | 已有(2 个活动任务) | T-005 / T-006 均为 `DOING`,含 `README.md` 与 `_template.md` |
|
||||
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;待人工确认 |
|
||||
| `scripts/` | 已有 | `validate_agent_context.py` |
|
||||
| `admin/` | **待建** | “采购服务”Go 后端与管理页面(T-001) |
|
||||
| `client/` | **待建** | “采购工具”Python 桌面端(T-002) |
|
||||
| `init.ps1` / `init.sh` | 已有(骨架) | 统一入口。两端目录建好后由 T-003 补全并验证 |
|
||||
| `docs/tasks/` | 已有(T-001~T-004、T-005~T-009、T-101) | T-001~T-004 已完成;T-101 已就绪、待真机验收 |
|
||||
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
||||
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
||||
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 |
|
||||
| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、离线测试与 wheel 元数据检查;无真机或采购流程 |
|
||||
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
|
||||
|
||||
## 任务状态
|
||||
|
||||
任务状态以 `docs/tasks/` 各任务文件 frontmatter 的 `status` 为准。本节只写项目级摘要:
|
||||
|
||||
- 已完成:T-007(项目 / 产品名称与 `admin/`、`client/` 源码目录契约)。
|
||||
- 正在进行:T-005(“采购服务”网页端 MVP 交互原型)、T-006(桌面端 MVP 交互原型)。
|
||||
T-005 已将三泳道改为传统表格并统一网页端品牌名称;T-006 第三轮已把执行记录详情从模态框
|
||||
改为左侧内联双视图,支持连续选行、可见返回按钮、`Esc` 层级、异常读取状态和紧凑宽度
|
||||
单视图。两项自动验证均已完成,都因 `needs_human_review: true` 保持 `DOING`。
|
||||
- **下一步:人工浏览 6 个原型,重点确认 Web 表格与详情抽屉,以及桌面端顶部状态区、图片
|
||||
证据、两列执行记录和左侧内联详情 / 返回路径。** 明确确认后才能把 T-005 / T-006 改为
|
||||
`DONE`。
|
||||
- 原型使用假数据、不调用真实接口、不驱动真机;T-001 / T-002 骨架可在确认后并行,
|
||||
Phase 2 生产页面还必须等待相关真机结论。
|
||||
- 已完成:T-005(采购服务交互原型)、T-006(采购工具交互原型)、T-007(产品名称与
|
||||
源码目录契约)、T-008(Vikunja 任务权威与单向导出)、T-009(MVP 关键路径与并行波次),
|
||||
以及 T-001(采购服务 Go 骨架)。
|
||||
- 已完成:T-002(采购工具 Python 骨架)、T-003(双端统一初始化与验证入口)、
|
||||
T-004(核心数据模型)。T-101(真机环境盘点)已建档并就绪,尚未真机验收,接续推进
|
||||
T-101 → T-102 → T-103。
|
||||
- T-103 是当前最高优先级和 MVP 生死线。通过前不开发依赖真机可读字段的 Phase 2 生产页面。
|
||||
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
|
||||
可读字段时必须先回修原型与交互清单。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
**目前没有可运行的代码。** 唯一可运行的检查:
|
||||
Windows 统一安装、离线验证与启动命令提示已可运行:
|
||||
|
||||
```bash
|
||||
# 校验 agent 上下文清单
|
||||
python scripts/validate_agent_context.py
|
||||
```powershell
|
||||
.\init.ps1
|
||||
```
|
||||
|
||||
T-001 / T-002 完成后,本节替换为真实命令:
|
||||
它会在成功后输出以下真实启动命令,而不自动启动或连接设备:
|
||||
|
||||
```bash
|
||||
# 采购服务 admin/(T-001 后填写)
|
||||
# 采购工具 client/(T-002 后填写)
|
||||
# 统一入口(T-003 后填写)
|
||||
```text
|
||||
采购服务:cd admin; go run ./cmd/server
|
||||
采购工具:cd client; .\.venv\Scripts\python.exe -m cmbuyer_client
|
||||
```
|
||||
|
||||
采购工具当前可运行的离线验证与 wheel 元数据检查:
|
||||
|
||||
```powershell
|
||||
cd client
|
||||
.\.venv\Scripts\python.exe -m unittest discover -s tests -t .
|
||||
.\.venv\Scripts\python.exe -m compileall -q src tests scripts
|
||||
$wheelDir = Join-Path $env:TEMP ('cmbuyer-client-wheel-' + [guid]::NewGuid())
|
||||
New-Item -ItemType Directory -Path $wheelDir | Out-Null
|
||||
.\.venv\Scripts\python.exe -m pip wheel --no-deps . --wheel-dir $wheelDir
|
||||
.\.venv\Scripts\python.exe scripts/verify_wheel_metadata.py (Get-ChildItem $wheelDir -Filter '*.whl').FullName
|
||||
```
|
||||
|
||||
采购工具规范安装与本地开发入口:
|
||||
|
||||
```powershell
|
||||
# 在仓库根执行统一初始化;成功后再进入 client/
|
||||
.\init.ps1
|
||||
cd client
|
||||
.\.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` 优先使用合规既有 venv,缺失时自动选择最高的 Python 3.11+;
|
||||
本机现有 venv 实际验证为 Python 3.12。桌面 GUI 与真机流程未作为 T-003 验收执行。
|
||||
|
||||
## 关键背景
|
||||
|
||||
本项目是 `cmroubao`(Go 后端 + Android AccessibilityService)与 `cmpdd`
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
id: T-001
|
||||
title: 初始化采购服务 admin Go 骨架
|
||||
phase: 0
|
||||
deps: []
|
||||
status: DONE
|
||||
created: 2026-08-03
|
||||
vikunja_task_id: 18
|
||||
context_ref: a63bdb8
|
||||
work_branch: task/t-001-admin-skeleton
|
||||
needs_device: false
|
||||
needs_human_review: false
|
||||
write_paths:
|
||||
- docs/tasks/T-001.md
|
||||
- admin/**
|
||||
- docs/03-tech-stack.md
|
||||
- docs/00-ai-start-here.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=18 synced=2026-08-03T10:13:08Z sha256=81e7657246ae88d8b8c8c9eeb370989c11a524a7ed2e7bd25d647dc77efd747e -->
|
||||
## 问题 / 背景
|
||||
|
||||
仓库尚无 admin/go.mod 和可运行的采购服务代码,init.ps1 因此按预期以退出码 3 停止。没有稳定的 Go 骨架、健康检查和真实验证命令,后续数据模型与服务端任务无法开始。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:Phase 0 工程地基,不新增业务功能。
|
||||
- 用户故事 / 交互:不适用,不实现生产页面。
|
||||
- 架构 / API:遵循 docs/03-tech-stack.md;只提供健康检查,不新增采购业务接口。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 在 admin/ 初始化 Go 1.23+ 模块,采用既定 gin / SQLite 技术栈和可测试的应用入口。
|
||||
2. 提供健康检查端点及对应单元测试;启动时错误必须显式返回,不吞错。
|
||||
3. 建立后续可扩展但保持最小的目录结构,不提前实现登录、任务、授权、提交订单或支付逻辑。
|
||||
4. 运行 go test ./... 与 go vet ./...,并记录真实命令。
|
||||
5. T-001 作为本轮共享文档所有者,只更新 admin 相关的 docs/03-tech-stack.md、docs/00-ai-start-here.md、docs/current-state.md 段落;不得代写尚未验证的 client 命令。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- admin/go.mod 存在,Go 模块与依赖可解析。
|
||||
- 健康检查端点可通过 httptest 验证状态码和稳定响应。
|
||||
- go test ./...、go vet ./... 在 admin/ 通过。
|
||||
- 共享文档中的 admin 占位命令替换为经实际运行的命令,client 未完成部分仍明确标为未初始化。
|
||||
- 不包含采购业务、真机页面判据、下单或付款代码。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-03T10:11:42Z · ila
|
||||
|
||||
T-001 实施与主审完成(2026-08-03):新增 admin Go 1.23 模块、gin 路由、GET /healthz、mattn/go-sqlite3 封装及 2 项离线测试;未实现业务路由、真机判据、下单或付款。主 agent 逐文件审查并独立运行:go mod tidy;gofmt -d .(无差异);go test -count=1 -race ./...(2 项通过);go vet ./...;go build ./...;python scripts/validate_agent_context.py;init.ps1(按 T-002 未完成预期退出 3)。已更新 admin 的真实运行/验证文档。风险:go-sqlite3 依赖 CGO;init.ps1 的“仓库只有文档”旧提示留给 T-003 修正。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 不实现管理员登录、任务 CRUD、授权、围栏、真机自动化或任何 Phase 1+ 功能。
|
||||
- 不新增或修改采购业务 API;健康检查不承载业务数据。
|
||||
- 不编写任何下单、提交订单、付款或支付相关代码。
|
||||
- 不修改 `client/`、`docs/api.md`、`docs/04-architecture.md`、需求或交互原型。
|
||||
- 共享文档只写已经实际运行验证的 admin 命令,不代写尚未完成的 client 命令。
|
||||
@@ -0,0 +1,65 @@
|
||||
---
|
||||
id: T-002
|
||||
title: 初始化采购工具 client Python 骨架
|
||||
phase: 0
|
||||
deps: []
|
||||
status: DONE
|
||||
created: 2026-08-03
|
||||
vikunja_task_id: 17
|
||||
context_ref: a63bdb8
|
||||
work_branch: task/t-002-client-skeleton
|
||||
needs_device: false
|
||||
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-03T10:31:33Z sha256=6f7f0738647530406d746c46f898df8f572153cbce0180fe18fcc07c10e9e9d3 -->
|
||||
## 问题 / 背景
|
||||
|
||||
仓库尚无 client/requirements.txt 和可运行的采购工具代码,init.ps1 因此按预期以退出码 3 停止。没有稳定的 Python 包、测试入口、日志与产物策略,后续 ADB/uiautomator2 真机取证无法开始。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:Phase 0 工程地基,不实现采购执行流程。
|
||||
- 用户故事 / 交互:不适用,本任务只建立应用骨架,不实现已确认原型的生产界面。
|
||||
- 架构 / API:遵循 docs/03-tech-stack.md 与 TaskSource / ResultSink 边界;不新增跨端接口。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 在 client/ 建立 Python 3.11+ 包、requirements.txt、应用入口和最小测试结构。
|
||||
2. 声明既定 PySide6 / uiautomator2 依赖,但基础单元测试不得要求连接真机。
|
||||
3. 建立日志和运行产物目录策略;本地虚拟环境与运行产物必须被 client 范围内的忽略规则排除,日志不得记录 token、地址、手机号或支付信息。
|
||||
4. 保证 python -m unittest discover -s tests -t . 与 python -m compileall -q src tests 可运行。
|
||||
5. T-001 关闭前不修改共享文档。先在 Vikunja 评论记录已验证的 client 命令;T-001 完成后由任务所有者先提交 write_paths 扩展,再更新共享文档并关闭 T-002。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- client/requirements.txt、src 包、应用入口和 tests 存在。
|
||||
- unittest 与 compileall 在 client/ 通过,且不需要真机。
|
||||
- 日志 / 产物路径和脱敏边界有测试或可验证配置。
|
||||
- 不包含拼多多页面判据、真机自动化实现、下单或付款代码。
|
||||
- 关闭 T-002 前,共享文档中的 client 占位命令已由扩展后的独占 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 -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 不实现拼多多页面判据、ADB/uiautomator2 真机流程、采购执行 UI 或任何 Phase 1+ 功能。
|
||||
- 不编写任何进入订单确认、提交订单、付款或支付相关代码。
|
||||
- 不修改 `admin/`、`docs/api.md`、`docs/04-architecture.md`、需求或交互原型。
|
||||
- T-001 为 `DOING` 时不修改共享文档;先在 Vikunja 记录验证命令。T-001 关闭后,必须先提交
|
||||
`write_paths` 扩展,再更新 `docs/03-tech-stack.md`、`docs/00-ai-start-here.md`、
|
||||
`docs/current-state.md`,不得绕过路径门禁。
|
||||
- 本地虚拟环境、缓存、日志和运行产物不得提交 Git;凭据和敏感信息不得进入日志。
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
id: T-003
|
||||
title: 建立双端统一初始化与验证入口
|
||||
phase: 0
|
||||
deps: [T-001, T-002]
|
||||
status: DONE
|
||||
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:55:26Z sha256=ded66098a74140a3738f1afd64299eb429325a72fdd018aeb1dabbedc89cca56 -->
|
||||
## 问题 / 背景
|
||||
|
||||
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 入口保持同一边界。
|
||||
- 不连接真机,不实现采购、下单或付款逻辑。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-03T10:54:57Z · ila
|
||||
|
||||
主 agent 终审通过:init.ps1/init.sh 已成为双端统一安装与离线门禁入口;不启动产品、不连接设备、不触发采购、下单或付款动作。
|
||||
Windows 入口优先复用并验证现有 Python 3.11+ venv,仅缺失时从 Launcher 合规版本确定性创建;安装、包导入、unittest、compileall、validator 全部使用同一 venv Python。当前实测 venv 为 Python 3.12。
|
||||
独立验证:在已合入 T-004 的最新组合基线上连续两次运行 init.ps1 成功;admin test/vet/build、client 6 tests/compileall、validator 通过。Python 3.10 venv fixture 与仅 3.10/3.9 Launcher fixture 均 fail closed;bash -n 通过,WSL 缺 Go 时明确非零退出。
|
||||
<!-- 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。
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
id: T-009
|
||||
title: 明确 MVP 关键路径与并行开发波次
|
||||
phase: 0
|
||||
deps: [T-008]
|
||||
status: DONE
|
||||
created: 2026-08-03
|
||||
vikunja_task_id: 16
|
||||
context_ref: 31b08d7
|
||||
work_branch: task/t-009-mvp-critical-path
|
||||
needs_device: false
|
||||
needs_human_review: false
|
||||
write_paths:
|
||||
- docs/tasks/T-009.md
|
||||
- docs/00-ai-start-here.md
|
||||
- docs/06-tasks.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=16 synced=2026-08-03T09:46:54Z sha256=e2541b909a42349c6459a241adbe4776dc518d93c599348846ee9f3a6413250c -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-005 至 T-008 已完成,但当前状态快照仍把 T-005、T-006 写成等待人工确认;入口文档的阶段顺序也容易被理解为必须完成整个 Phase 0 后才能开始 Phase 1。现有依赖实际上允许 T-002 完成后立即进入 T-101,并让 T-004 与真机取证并行。若不把这条关键路径写入仓库,后续 agent 会按阶段串行执行,延迟 MVP 生死线 T-103。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:不适用,本任务只调整开发执行计划。
|
||||
- 用户故事:不适用。
|
||||
- 交互:不适用,不修改产品界面。
|
||||
- 架构 / API:不修改架构、API、数据模型或安全边界。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 更新 docs/current-state.md:同步 T-005 至 T-008 已完成,明确 T-001 与 T-002 为下一波并行任务,并注明 T-002 完成后立即启动 T-101。
|
||||
2. 更新 docs/06-tasks.md:新增依赖驱动的并行波次和关键路径,明确 T-003、T-004、T-101 的可并行关系,并把 T-103 标为最高优先级生死线。
|
||||
3. 更新 docs/00-ai-start-here.md:把笼统的阶段串行表述改为依赖驱动;保留 T-103 结论出来前不写依赖真机字段的 Phase 2 生产页面。
|
||||
4. 所有共享文档由 T-009 所有者单独写入,不创建 T-001/T-002,不修改既有任务依赖与产品范围。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 三份文档对当前状态、下一步和关键路径的表述一致。
|
||||
- 明确 T-001/T-002 并行;T-002 后立即 T-101→T-103;T-001 后可做 T-004;T-001/T-002 后可做 T-003。
|
||||
- 明确 Phase 2 生产页面仍受 T-103 真机结论门禁约束。
|
||||
- 不修改 docs/02-requirements.md、docs/04-architecture.md、docs/api.md、docs/05-coding-rules.md。
|
||||
- python scripts/validate_agent_context.py 与 git diff --check 通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-03T09:42:43Z · ila
|
||||
|
||||
T-009 已落成:Vikunja 任务 #16 与 Git 本地任务文件已建立,T-008 precedes T-009 依赖已配置。任务定义提交:4948afc。基线结果:validate_agent_context.py 通过;init.ps1 按预期以退出码 3 提示 T-001/T-002 尚未初始化。
|
||||
|
||||
### 2026-08-03T09:44:49Z · ila
|
||||
|
||||
T-009 实施完成(2026-08-03):更新 docs/00-ai-start-here.md、docs/06-tasks.md、docs/current-state.md,明确 T-001/T-002 并行、T-002 后立即 T-101→T-103、T-001 后 T-004、两端骨架后 T-003,并保留 T-103 前禁止开发依赖真机字段的 Phase 2 生产页面。验证:文档断言通过;python scripts/validate_agent_context.py 通过;git diff --check 通过;requirements/architecture/api/coding-rules 均未修改。
|
||||
|
||||
### 2026-08-03T09:46:42Z · ila
|
||||
|
||||
复核发现 @0xk3vin/vikunja-mcp@1.1.1 的 tasks_update 在仅提交 done/percentDone 时会清空 description。已使用包含原 description 的完整更新载荷恢复正文并保持 done=true;后续通过此 MCP 更新任务时必须携带现有 description,不能把部分更新当作 PATCH。该发现不改变本任务产品范围。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 不修改 `docs/02-requirements.md`、`docs/04-architecture.md`、`docs/api.md`、
|
||||
`docs/05-coding-rules.md`、交互原型或生产代码。
|
||||
- 不改变既有任务依赖、MVP 产品范围、两趟执行流程、三道价格闸门或提交订单四条件。
|
||||
- 不以并行提速为理由跳过 T-103 真机取证,或在 T-103 结论前开发依赖真机字段的
|
||||
Phase 2 生产页面。
|
||||
- 不在本任务创建或领取 T-001、T-002;本任务只把已确认的执行策略写入权威文档。
|
||||
- 不让受托执行者修改本任务列出的共享文档;这些文件只由 T-009 所有者写入。
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
id: T-101
|
||||
title: 验证 ADB 与 uiautomator2 双通道连接
|
||||
phase: 1
|
||||
deps: [T-002]
|
||||
status: TODO
|
||||
created: 2026-08-03
|
||||
vikunja_task_id: 21
|
||||
context_ref: 87591e8
|
||||
work_branch: task/t-101-device-connectivity
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-101.md
|
||||
- client/.gitignore
|
||||
- client/src/cmbuyer_client/device/**
|
||||
- client/tests/device/**
|
||||
- client/scripts/capture_device_baseline.py
|
||||
- docs/evidence/T-101/**
|
||||
- docs/04-architecture.md
|
||||
- docs/03-tech-stack.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=21 synced=2026-08-03T10:40:11Z sha256=e6a7a6c24df85d52435692fc120d3d3ddea3148b46ea3cf1e99c3debfe53321e -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-002 已建立采购工具骨架,但本项目还没有对实际 Android 手机、ADB 通道或 uiautomator2 做过取证。后续所有拼多多页面判据都依赖稳定、显式且可审计的设备连接;若复用前序项目结论或自动猜设备,会把错误设备和旧页面事实带入生产流程。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:Phase 1 真机环境盘点,是 T-102/T-103 的前置。
|
||||
- 用户故事 / 交互:采购工具配置页的显式设备 serial、连接检查与错误反馈。
|
||||
- 架构 / API:只验证 ADB/uiautomator2 基础能力,不编写拼多多页面判据,不调用采购服务业务接口。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 实现显式 serial 的设备连接边界与可替换命令执行器;禁止留空自动选择,离线测试覆盖无设备、多设备、unauthorized/offline 和同一手机 USB+WiFi 双通道冲突。
|
||||
2. 提供只读取证命令:对指定通道连接 uiautomator2,获取设备型号/Android 版本/拼多多 App 版本,执行截图与 dump_hierarchy(compressed=False),所有超时可配置。
|
||||
3. 产物按稳定目录保存并记录 SHA-256;日志只记录设备标识与元数据,不记录 token、地址、手机号或页面全文。
|
||||
4. 先完成 mock/fixture 离线测试;随后由人分别用 USB 与 WiFi 真机执行并把设备、Android、拼多多版本、截图/XML 路径和结论写入任务执行记录。
|
||||
5. 同一手机两个通道同时在线必须明确拒绝,不能随机选一个继续。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- USB 和 WiFi 均能显式指定并完成截图、compressed=False XML dump。
|
||||
- 设备未授权、离线、超时、serial 不存在和双通道冲突有可区分错误。
|
||||
- 取证记录含设备型号、Android 版本、拼多多 App 版本、通道、时间、截图/XML 路径及 SHA-256。
|
||||
- 离线单测与 compileall 通过,不要求真机。
|
||||
- needs_device=true:agent 完成代码后保持 DOING,只有人完成两通道真机验收后才能 DONE。
|
||||
|
||||
## 执行记录
|
||||
|
||||
(暂无)
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 本任务只建立并验证设备连接、元数据读取、截图和 `dump_hierarchy(compressed=False)`;不打开
|
||||
拼多多商品、不编写任何页面判据,不选择规格,不进入购买或订单页面。
|
||||
- 设备 serial 必须显式提供;无设备、多设备、未授权、离线或同一手机 USB + WiFi 双通道并存时
|
||||
必须明确失败,不得随机选择或静默降级。
|
||||
- 不使用 `/mnt/d/chengma/cmroubao`、`/mnt/d/chengma/cmpdd` 的设备常量、页面 XML 或结论作为
|
||||
本项目事实;所有真机结论都必须在本项目重新取证。
|
||||
- 产物与执行记录不得包含 token、地址、手机号、支付信息或无关页面全文;页面 XML 原始文件只
|
||||
保存于明确的 T-101 取证目录,日志只记录路径、哈希与非敏感元数据。
|
||||
- 不修改 `admin/`、业务 API、数据模型或产品 UI,不实现任务领取、采购执行、下单、提交订单、
|
||||
付款或支付逻辑。
|
||||
- `needs_device: true`:agent 完成代码和离线门禁后仍必须保持 `DOING`;只有人完成 USB、WiFi
|
||||
两通道真机验收并记录设备型号、Android 版本、拼多多 App 版本、截图/XML 路径后才能标 `DONE`。
|
||||
@@ -1,84 +1,149 @@
|
||||
#!/usr/bin/env pwsh
|
||||
|
||||
# cmbuyer 标准启动与验证入口(Windows PowerShell 版),与 init.sh 等价,二选一:
|
||||
# - Windows 原生 PowerShell:./init.ps1
|
||||
# - WSL / Git Bash:./init.sh
|
||||
#
|
||||
# 本项目是双产品结构:admin/(采购服务,Go)+ client/(采购工具,Python)。脚本按
|
||||
# admin/go.mod 与 client/requirements.txt 两个初始化哨兵分别处理;未初始化的一端会跳过并
|
||||
# 提示对应任务,不会把空目录误判为可运行项目,也不会静默通过。
|
||||
#
|
||||
# T-001 / T-002 / T-003 落地后,把下面的命令补全,并同步:
|
||||
# docs/03-tech-stack.md、docs/00-ai-start-here.md、docs/current-state.md
|
||||
# cmbuyer Windows 统一安装、验证与启动入口。
|
||||
# 这个入口只处理开发环境和离线门禁,不连接设备,也不触发采购、下单或付款动作。
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-Location -Path $PSScriptRoot
|
||||
|
||||
# Go 工具链固定用本机版本,避免自动下载
|
||||
# Go 工具链固定用本机版本,避免自动下载造成不可复现的环境漂移。
|
||||
$env:GOTOOLCHAIN = "local"
|
||||
|
||||
$AdminDir = Join-Path $PSScriptRoot "admin"
|
||||
$AdminDir = Join-Path $PSScriptRoot "admin"
|
||||
$ClientDir = Join-Path $PSScriptRoot "client"
|
||||
$Pending = @()
|
||||
$ClientVenv = Join-Path $ClientDir ".venv"
|
||||
$ClientPython = Join-Path $ClientVenv "Scripts\python.exe"
|
||||
|
||||
function Fail([string]$Message) {
|
||||
throw $Message
|
||||
}
|
||||
|
||||
function Require-Directory([string]$Path, [string]$Name) {
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Container)) {
|
||||
Fail "$Name 目录不存在:$Path。请先完成对应初始化任务。"
|
||||
}
|
||||
}
|
||||
|
||||
function Require-File([string]$Path, [string]$Name) {
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
||||
Fail "$Name 不存在:$Path。请先完成对应初始化任务。"
|
||||
}
|
||||
}
|
||||
|
||||
function Require-Command([string]$Name, [string]$Hint) {
|
||||
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
|
||||
Fail "缺少 $Name。$Hint"
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Step([string]$Description, [scriptblock]$Action) {
|
||||
Write-Host "==> $Description"
|
||||
& $Action
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail "$Description 失败,退出码:$LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
function Confirm-Python311OrNewer([string]$PythonExecutable, [string]$Context) {
|
||||
& $PythonExecutable -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail "$Context 必须使用 Python 3.11+。不会自动覆盖既有虚拟环境;请手工删除 client/.venv 后重试。"
|
||||
}
|
||||
}
|
||||
|
||||
function Select-PythonLauncherSelector() {
|
||||
$launcherLines = @(& py -0p)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail "无法从 Python Launcher 枚举已安装版本。请安装 Python 3.11+ 并确认 py -0p 可运行。"
|
||||
}
|
||||
|
||||
$minimumVersion = [version]::new(3, 11)
|
||||
$candidates = @()
|
||||
foreach ($line in $launcherLines) {
|
||||
$selectorMatch = [regex]::Match([string]$line, '^\s*-V:(?<selector>\S+)(?:\s+\*)?\s+(?<path>.+?)\s*$')
|
||||
if (-not $selectorMatch.Success) {
|
||||
continue
|
||||
}
|
||||
|
||||
$selector = $selectorMatch.Groups['selector'].Value
|
||||
$pythonPath = $selectorMatch.Groups['path'].Value
|
||||
$versionMatch = [regex]::Match($selector, '(?<major>\d+)\.(?<minor>\d+)(?:\.(?<patch>\d+))?')
|
||||
if (-not $versionMatch.Success) {
|
||||
continue
|
||||
}
|
||||
|
||||
$patch = if ($versionMatch.Groups['patch'].Success) { [int]$versionMatch.Groups['patch'].Value } else { 0 }
|
||||
$version = [version]::new(
|
||||
[int]$versionMatch.Groups['major'].Value,
|
||||
[int]$versionMatch.Groups['minor'].Value,
|
||||
$patch
|
||||
)
|
||||
if ($version -ge $minimumVersion) {
|
||||
$candidates += [PSCustomObject]@{ Selector = $selector; Version = $version; Path = $pythonPath }
|
||||
}
|
||||
}
|
||||
|
||||
if ($candidates.Count -eq 0) {
|
||||
Fail "Python Launcher 未发现 Python 3.11+。当前默认 python 不会作为回退;请安装合规版本后重试。"
|
||||
}
|
||||
|
||||
$selected = $candidates | Sort-Object @{ Expression = 'Version'; Descending = $true }, @{ Expression = 'Selector'; Descending = $false } | Select-Object -First 1
|
||||
Write-Host "==> 选择 Python Launcher 解释器:-V:$($selected.Selector)($($selected.Version))"
|
||||
return $selected
|
||||
}
|
||||
|
||||
Write-Host "==> 当前目录: $($PWD.Path)"
|
||||
|
||||
# ------------------------------------------------------- 采购服务(admin/,Go)
|
||||
if (Test-Path (Join-Path $AdminDir "go.mod") -PathType Leaf) {
|
||||
Write-Host "==> 采购服务:admin/ 同步依赖"
|
||||
Push-Location $AdminDir
|
||||
try {
|
||||
go mod download
|
||||
Write-Host "==> 采购服务:admin/ 静态检查"
|
||||
go vet ./...
|
||||
Write-Host "==> 采购服务:admin/ 测试"
|
||||
go test ./...
|
||||
} finally {
|
||||
Pop-Location
|
||||
Require-Directory $AdminDir "采购服务 admin"
|
||||
Require-File (Join-Path $AdminDir "go.mod") "采购服务哨兵 admin/go.mod"
|
||||
Require-Directory $ClientDir "采购工具 client"
|
||||
Require-File (Join-Path $ClientDir "requirements.txt") "采购工具哨兵 client/requirements.txt"
|
||||
Require-File (Join-Path $ClientDir "pyproject.toml") "采购工具打包配置 client/pyproject.toml"
|
||||
Require-Command "go" "请安装 Go 1.23+ 并重新打开 PowerShell。"
|
||||
if (Test-Path -LiteralPath $ClientVenv) {
|
||||
if (-not (Test-Path -LiteralPath $ClientVenv -PathType Container)) {
|
||||
Fail "client/.venv 不是目录。不会覆盖该路径,请人工处理后重试。"
|
||||
}
|
||||
Require-File $ClientPython "既有 client/.venv Python"
|
||||
Confirm-Python311OrNewer $ClientPython "既有 client/.venv"
|
||||
} else {
|
||||
Write-Host "==> 采购服务:admin/go.mod 不存在,跳过。先做 T-001(初始化 admin/ Go 骨架)。"
|
||||
$Pending += "T-001 初始化采购服务 admin/ Go 骨架"
|
||||
# 默认 python 在本机可能仍指向 3.10;仅在需要新建 venv 时,从 Launcher 中确定性选择最高的合规版本。
|
||||
Require-Command "py" "请安装 Python Launcher 和 Python 3.11+。"
|
||||
$PythonRuntime = Select-PythonLauncherSelector
|
||||
Require-File $PythonRuntime.Path "Python Launcher 选择的解释器"
|
||||
Invoke-Step "验证所选 Python Launcher 解释器" { & $PythonRuntime.Path -c "import sys; print(sys.version); raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" }
|
||||
Invoke-Step "创建所选 Python 3.11+ 采购工具虚拟环境" { & $PythonRuntime.Path -m venv $ClientVenv }
|
||||
Require-File $ClientPython "新建 client/.venv Python"
|
||||
Confirm-Python311OrNewer $ClientPython "新建 client/.venv"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------- 采购工具(client/,Python)
|
||||
if (Test-Path (Join-Path $ClientDir "requirements.txt") -PathType Leaf) {
|
||||
Write-Host "==> 采购工具:client/ 同步依赖"
|
||||
Push-Location $ClientDir
|
||||
try {
|
||||
if (-not (Test-Path ".venv")) {
|
||||
python -m venv .venv
|
||||
}
|
||||
& ".venv\Scripts\python.exe" -m pip install -q -r requirements.txt
|
||||
Write-Host "==> 采购工具:client/ 语法检查"
|
||||
& ".venv\Scripts\python.exe" -m compileall -q src tests
|
||||
Write-Host "==> 采购工具:client/ 测试"
|
||||
& ".venv\Scripts\python.exe" -m unittest discover -s tests -t .
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} else {
|
||||
Write-Host "==> 采购工具:client/requirements.txt 不存在,跳过。先做 T-002(初始化 client/ Python 骨架)。"
|
||||
$Pending += "T-002 初始化采购工具 client/ Python 骨架"
|
||||
# 后续所有采购工具检查与仓库 validator 均使用同一个合规 venv 解释器,避免混用宿主 Python。
|
||||
Invoke-Step "验证采购工具虚拟环境 Python" { & $ClientPython -c "import sys; print(sys.version); raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" }
|
||||
|
||||
Push-Location $AdminDir
|
||||
try {
|
||||
Invoke-Step "采购服务:admin/ 同步依赖" { go mod download }
|
||||
Invoke-Step "采购服务:admin/ 测试" { go test ./... }
|
||||
Invoke-Step "采购服务:admin/ 静态检查" { go vet ./... }
|
||||
Invoke-Step "采购服务:admin/ 构建" { go build ./... }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 文档自检
|
||||
Write-Host "==> 校验 agent 上下文清单"
|
||||
python scripts/validate_agent_context.py
|
||||
Push-Location $ClientDir
|
||||
try {
|
||||
Invoke-Step "采购工具:以 editable 方式安装" { & $ClientPython -m pip install -e . }
|
||||
Invoke-Step "采购工具:验证已安装包" { & $ClientPython -c "import cmbuyer_client; print(cmbuyer_client.__version__)" }
|
||||
Invoke-Step "采购工具:测试" { & $ClientPython -m unittest discover -s tests -t . }
|
||||
Invoke-Step "采购工具:语法检查" { & $ClientPython -m compileall -q src tests scripts }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Invoke-Step "校验 agent 上下文清单" { & $ClientPython scripts/validate_agent_context.py }
|
||||
|
||||
# -------------------------------------------------------------------- 汇总
|
||||
Write-Host ""
|
||||
if ($Pending.Count -gt 0) {
|
||||
Write-Host "==> 尚未初始化的部分:"
|
||||
foreach ($item in $Pending) { Write-Host " - $item" }
|
||||
Write-Host ""
|
||||
Write-Host "当前仓库只有文档。按 docs/06-tasks.md 领取上述任务后再运行本脚本。"
|
||||
exit 3
|
||||
}
|
||||
|
||||
Write-Host "==> 启动命令"
|
||||
Write-Host " 采购服务:cd admin ; go run ./cmd/server"
|
||||
Write-Host " 采购工具:cd client ; .venv\Scripts\python.exe src/main.py"
|
||||
Write-Host "==> 基线验证通过。启动命令:"
|
||||
Write-Host " 采购服务:cd admin; go run ./cmd/server"
|
||||
Write-Host " 采购工具:cd client; .\.venv\Scripts\python.exe -m cmbuyer_client"
|
||||
Write-Host ""
|
||||
Write-Host "基础验证失败时先修基线,不要在坏的起点上继续叠新功能。"
|
||||
Write-Host "真机连接与设备验收只能由人工完成,agent 不得据此把任务标为 DONE。"
|
||||
Write-Host "真机连接与设备验收只能由人工完成;本入口不执行任何采购、下单或付款动作。"
|
||||
|
||||
@@ -1,69 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# cmbuyer 标准启动与验证入口(Unix shell 版),与 init.ps1 等价,二选一。
|
||||
#
|
||||
# 注意:本项目的构建与验证工具链只在 Windows 侧(Go SDK、Python 环境、Android SDK)。
|
||||
# 在 WSL 中运行本脚本通常会因缺少工具链而失败——这是预期行为,不是 bug。
|
||||
# 正式验证请在 Windows PowerShell 运行 ./init.ps1。
|
||||
# 双产品目录为 admin/(采购服务,Go)与 client/(采购工具,Python);分别以 go.mod 与
|
||||
# requirements.txt 作为初始化哨兵,不能只按空目录存在判断。
|
||||
#
|
||||
# T-001 / T-002 / T-003 落地后补全命令,并同步:
|
||||
# docs/03-tech-stack.md、docs/00-ai-start-here.md、docs/current-state.md
|
||||
# cmbuyer Unix shell 统一安装、验证与启动入口。
|
||||
# 本脚本与 init.ps1 保持同一门禁语义;在 WSL 缺少 Windows 侧工具链时必须明确失败,
|
||||
# 不得将跳过或 bash 语法通过误报为 Windows 产品验收。
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
export GOTOOLCHAIN=local
|
||||
|
||||
pending=()
|
||||
fail() {
|
||||
echo "错误:$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_directory() {
|
||||
local path="$1"
|
||||
local name="$2"
|
||||
[[ -d "$path" ]] || fail "$name 目录不存在:$path。请先完成对应初始化任务。"
|
||||
}
|
||||
|
||||
require_file() {
|
||||
local path="$1"
|
||||
local name="$2"
|
||||
[[ -f "$path" ]] || fail "$name 不存在:$path。请先完成对应初始化任务。"
|
||||
}
|
||||
|
||||
require_command() {
|
||||
local name="$1"
|
||||
local hint="$2"
|
||||
command -v "$name" >/dev/null 2>&1 || fail "缺少 $name。$hint"
|
||||
}
|
||||
|
||||
run_step() {
|
||||
local description="$1"
|
||||
shift
|
||||
echo "==> $description"
|
||||
"$@" || fail "$description 失败。"
|
||||
}
|
||||
|
||||
confirm_python311_or_newer() {
|
||||
local executable="$1"
|
||||
local context="$2"
|
||||
"$executable" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' \
|
||||
|| fail "$context 必须使用 Python 3.11+。不会自动覆盖既有虚拟环境;请手工删除 client/.venv 后重试。"
|
||||
}
|
||||
|
||||
echo "==> 当前目录: $(pwd)"
|
||||
require_directory admin "采购服务 admin"
|
||||
require_file admin/go.mod "采购服务哨兵 admin/go.mod"
|
||||
require_directory client "采购工具 client"
|
||||
require_file client/requirements.txt "采购工具哨兵 client/requirements.txt"
|
||||
require_file client/pyproject.toml "采购工具打包配置 client/pyproject.toml"
|
||||
require_command go "请安装 Go 1.23+ 后重试。"
|
||||
|
||||
# ------------------------------------------------------- 采购服务(admin/,Go)
|
||||
if [ -f admin/go.mod ]; then
|
||||
echo "==> 采购服务:admin/ 同步依赖"
|
||||
( cd admin && go mod download )
|
||||
echo "==> 采购服务:admin/ 静态检查"
|
||||
( cd admin && go vet ./... )
|
||||
echo "==> 采购服务:admin/ 测试"
|
||||
( cd admin && go test ./... )
|
||||
client_python="client/.venv/bin/python"
|
||||
if [[ -e client/.venv ]]; then
|
||||
[[ -d client/.venv ]] || fail "client/.venv 不是目录。不会覆盖该路径,请人工处理后重试。"
|
||||
require_file "$client_python" "既有 client/.venv Python"
|
||||
confirm_python311_or_newer "$client_python" "既有 client/.venv"
|
||||
else
|
||||
echo "==> 采购服务:admin/go.mod 不存在,跳过。先做 T-001(初始化 admin/ Go 骨架)。"
|
||||
pending+=("T-001 初始化采购服务 admin/ Go 骨架")
|
||||
require_command python3 "请安装 Python 3.11+ 后重试。"
|
||||
confirm_python311_or_newer python3 "python3"
|
||||
run_step "创建 Python 3.11+ 采购工具虚拟环境" python3 -m venv client/.venv
|
||||
require_file "$client_python" "新建 client/.venv Python"
|
||||
confirm_python311_or_newer "$client_python" "新建 client/.venv"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------- 采购工具(client/,Python)
|
||||
if [ -f client/requirements.txt ]; then
|
||||
echo "==> 采购工具:client/ 同步依赖"
|
||||
( cd client \
|
||||
&& { [ -d .venv ] || python3 -m venv .venv; } \
|
||||
&& .venv/bin/python -m pip install -q -r requirements.txt )
|
||||
echo "==> 采购工具:client/ 语法检查"
|
||||
( cd client && .venv/bin/python -m compileall -q src tests )
|
||||
echo "==> 采购工具:client/ 测试"
|
||||
( cd client && .venv/bin/python -m unittest discover -s tests -t . )
|
||||
else
|
||||
echo "==> 采购工具:client/requirements.txt 不存在,跳过。先做 T-002(初始化 client/ Python 骨架)。"
|
||||
pending+=("T-002 初始化采购工具 client/ Python 骨架")
|
||||
fi
|
||||
run_step "验证采购工具虚拟环境 Python" "$client_python" -c 'import sys; print(sys.version); raise SystemExit(0 if sys.version_info >= (3, 11) else 1)'
|
||||
|
||||
# ------------------------------------------------------------------ 文档自检
|
||||
echo "==> 校验 agent 上下文清单"
|
||||
python3 scripts/validate_agent_context.py
|
||||
( cd admin && run_step "采购服务:admin/ 同步依赖" go mod download )
|
||||
( cd admin && run_step "采购服务:admin/ 测试" go test ./... )
|
||||
( cd admin && run_step "采购服务:admin/ 静态检查" go vet ./... )
|
||||
( cd admin && run_step "采购服务:admin/ 构建" go build ./... )
|
||||
|
||||
( cd client && run_step "采购工具:以 editable 方式安装" .venv/bin/python -m pip install -e . )
|
||||
( cd client && run_step "采购工具:验证已安装包" .venv/bin/python -c 'import cmbuyer_client; print(cmbuyer_client.__version__)' )
|
||||
( cd client && run_step "采购工具:测试" .venv/bin/python -m unittest discover -s tests -t . )
|
||||
( cd client && run_step "采购工具:语法检查" .venv/bin/python -m compileall -q src tests scripts )
|
||||
|
||||
run_step "校验 agent 上下文清单" "$client_python" scripts/validate_agent_context.py
|
||||
|
||||
# -------------------------------------------------------------------- 汇总
|
||||
echo
|
||||
if [ ${#pending[@]} -gt 0 ]; then
|
||||
echo "==> 尚未初始化的部分:"
|
||||
for item in "${pending[@]}"; do echo " - $item"; done
|
||||
echo
|
||||
echo "当前仓库只有文档。按 docs/06-tasks.md 领取上述任务后再运行本脚本。"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
echo "==> 启动命令"
|
||||
echo " 采购服务:cd admin && go run ./cmd/server"
|
||||
echo " 采购工具:cd client && .venv/bin/python src/main.py"
|
||||
echo "==> 基线验证通过。启动命令:"
|
||||
echo " 采购服务:cd admin && go run ./cmd/server"
|
||||
echo " 采购工具:cd client && .venv/bin/python -m cmbuyer_client"
|
||||
echo
|
||||
echo "基础验证失败时先修基线,不要在坏的起点上继续叠新功能。"
|
||||
echo "真机连接与设备验收只能由人工完成,agent 不得据此把任务标为 DONE。"
|
||||
echo "WSL / Unix shell 仅在本机工具链齐备时能通过;这不构成 Windows 或真机验收。"
|
||||
|
||||
Reference in New Issue
Block a user