Compare commits

..
Author SHA1 Message Date
QiuSW 393f26de53 docs(tasks): complete T-101 device validation 2026-08-04 09:03:26 +08:00
QiuSW 71c66a074a docs(tasks): record T-101 wifi evidence 2026-08-04 08:55:31 +08:00
QiuSW 7905fa0b70 fix(client): accept whitespace in screenshot base64 2026-08-04 08:51:00 +08:00
QiuSW c4cf19fd55 docs(tasks): record T-101 wifi connectivity 2026-08-04 08:36:26 +08:00
QiuSW de8187eb5b feat(client): add T-101 device baseline 2026-08-03 19:28:39 +08:00
QiuSW f3294633c2 docs(tasks): allow T-101 adb dependency update 2026-08-03 19:07:01 +08:00
QiuSW 0fbf66836b docs(tasks): start T-101 device connectivity spike 2026-08-03 18:56:39 +08:00
QiuSW 4f4a95a55e build: establish unified project entry 2026-08-03 18:55:32 +08:00
QiuSW 637c341b95 docs(tasks): define T-101 device connectivity spike 2026-08-03 18:40:19 +08:00
QiuSW 87591e84f9 feat(admin): establish core data model 2026-08-03 18:37:48 +08:00
QiuSW 8be6d206dd docs(tasks): define T-003 unified entry 2026-08-03 18:35:12 +08:00
QiuSW bcec012c8b feat(client): initialize procurement tool skeleton 2026-08-03 18:32:00 +08:00
QiuSW 2ab5baa776 docs(tasks): expand T-002 shared document paths 2026-08-03 18:16:48 +08:00
QiuSW cf293d2382 docs(tasks): define T-004 core data model 2026-08-03 18:16:08 +08:00
QiuSW c72371ce90 feat(admin): initialize procurement service skeleton 2026-08-03 18:13:30 +08:00
QiuSW b7559a9451 docs(tasks): define parallel T-001 and T-002 2026-08-03 17:57:53 +08:00
QiuSW a63bdb8c37 docs(plan): prioritize MVP critical path 2026-08-03 17:47:22 +08:00
QiuSW 4948afc393 docs(tasks): define T-009 MVP critical path plan 2026-08-03 17:41:58 +08:00
53 changed files with 3595 additions and 172 deletions
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
)
func main() {
if err := run(context.Background(), os.Args[1:], os.Stderr); err != nil {
log.Print(err)
os.Exit(1)
}
}
func run(ctx context.Context, args []string, stderr io.Writer) error {
flags := flag.NewFlagSet("migrate", flag.ContinueOnError)
flags.SetOutput(stderr)
databaseSource := flags.String("database", "", "SQLite data source")
migrationDirectory := flags.String("dir", "migrations", "migration directory")
if err := flags.Parse(args); err != nil {
return err
}
if *databaseSource == "" {
return errors.New("-database is required")
}
if flags.NArg() != 1 {
return fmt.Errorf("usage: migrate -database <sqlite-data-source> [-dir <migration-directory>] <up|down|status>")
}
command := flags.Arg(0)
if command != "up" && command != "down" && command != "status" {
return fmt.Errorf("unsupported migration command %q", command)
}
database, err := sqlite.Open(*databaseSource)
if err != nil {
return fmt.Errorf("open SQLite database: %w", err)
}
defer func() {
if err := database.Close(); err != nil {
log.Printf("close SQLite database: %v", err)
}
}()
if err := migrations.Run(ctx, database, *migrationDirectory, command); err != nil {
return fmt.Errorf("run migrations: %w", err)
}
return nil
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"context"
"database/sql"
"errors"
"io"
"os"
"path/filepath"
"runtime"
"testing"
)
func TestRunUp(t *testing.T) {
databaseSource := filepath.Join(t.TempDir(), "migrate.db")
if err := run(context.Background(), []string{
"-database", databaseSource,
"-dir", migrationDirectory(t),
"up",
}, io.Discard); err != nil {
t.Fatalf("run up migration command: %v", err)
}
database, err := sql.Open("sqlite3", databaseSource)
if err != nil {
t.Fatalf("open migrated database: %v", err)
}
t.Cleanup(func() {
if err := database.Close(); err != nil {
t.Errorf("close migrated database: %v", err)
}
})
var count int
if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'tasks'`).Scan(&count); err != nil {
t.Fatalf("look up tasks table: %v", err)
}
if count != 1 {
t.Fatalf("tasks table count = %d, want 1", count)
}
}
func TestRunRequiresDatabase(t *testing.T) {
if err := run(context.Background(), []string{"up"}, io.Discard); err == nil {
t.Fatal("run without database source succeeded")
}
}
func TestRunRejectsUndeclaredCommand(t *testing.T) {
databaseSource := filepath.Join(t.TempDir(), "migrate.db")
err := run(context.Background(), []string{"-database", databaseSource, "reset"}, io.Discard)
if err == nil {
t.Fatal("run with undeclared command succeeded")
}
if _, err := os.Stat(databaseSource); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("undeclared command opened database source: stat error = %v, want not exist", err)
}
}
func migrationDirectory(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate migration command test source")
}
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
}
+26
View File
@@ -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
}
+46
View File
@@ -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
View File
@@ -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=
+80
View File
@@ -0,0 +1,80 @@
package domain
import (
"errors"
"time"
)
var ErrInvalidAuthorizationTransition = errors.New("invalid authorization status transition")
type AuthorizationStatus string
const (
AuthorizationStatusPendingDelivery AuthorizationStatus = "PENDING_DELIVERY"
AuthorizationStatusDelivered AuthorizationStatus = "DELIVERED"
AuthorizationStatusAcknowledged AuthorizationStatus = "ACKNOWLEDGED"
AuthorizationStatusExecuting AuthorizationStatus = "EXECUTING"
AuthorizationStatusFenced AuthorizationStatus = "FENCED"
AuthorizationStatusConsumed AuthorizationStatus = "CONSUMED"
AuthorizationStatusSuperseded AuthorizationStatus = "SUPERSEDED"
AuthorizationStatusExpired AuthorizationStatus = "EXPIRED"
)
type OrderAuthorization struct {
ID string
TaskID string
SpecTrialID string
Version int
GoodsID string
SKUColor string
SKUSize string
Quantity int
AuthorizedUnitPrice string
TotalPriceCap string
Note *string
Status AuthorizationStatus
CreatedBy string
CreatedAt time.Time
ExpiresAt time.Time
}
// CanTransitionTo 围栏后的授权只能消费,不能回到可领取或可过期状态,以防重复采购。
func (status AuthorizationStatus) CanTransitionTo(next AuthorizationStatus) bool {
_, allowed := authorizationTransitions[status][next]
return allowed
}
// TransitionAuthorization 返回下一状态;未定义的授权状态转移一律失败。
func TransitionAuthorization(current, next AuthorizationStatus) (AuthorizationStatus, error) {
if !current.CanTransitionTo(next) {
return current, ErrInvalidAuthorizationTransition
}
return next, nil
}
var authorizationTransitions = map[AuthorizationStatus]map[AuthorizationStatus]struct{}{
AuthorizationStatusPendingDelivery: {
AuthorizationStatusDelivered: {},
AuthorizationStatusSuperseded: {},
AuthorizationStatusExpired: {},
},
AuthorizationStatusDelivered: {
AuthorizationStatusAcknowledged: {},
AuthorizationStatusSuperseded: {},
AuthorizationStatusExpired: {},
},
AuthorizationStatusAcknowledged: {
AuthorizationStatusExecuting: {},
AuthorizationStatusSuperseded: {},
AuthorizationStatusExpired: {},
},
AuthorizationStatusExecuting: {
AuthorizationStatusFenced: {},
AuthorizationStatusSuperseded: {},
AuthorizationStatusExpired: {},
},
AuthorizationStatusFenced: {
AuthorizationStatusConsumed: {},
},
}
@@ -0,0 +1,54 @@
package domain_test
import (
"errors"
"testing"
"cmbuyer/admin/internal/domain"
)
func TestAuthorizationTransitions(t *testing.T) {
tests := []struct {
name string
current domain.AuthorizationStatus
next domain.AuthorizationStatus
allowed bool
}{
{"deliver", domain.AuthorizationStatusPendingDelivery, domain.AuthorizationStatusDelivered, true},
{"acknowledge", domain.AuthorizationStatusDelivered, domain.AuthorizationStatusAcknowledged, true},
{"execute", domain.AuthorizationStatusAcknowledged, domain.AuthorizationStatusExecuting, true},
{"fence", domain.AuthorizationStatusExecuting, domain.AuthorizationStatusFenced, true},
{"consume fenced authorization", domain.AuthorizationStatusFenced, domain.AuthorizationStatusConsumed, true},
{"expire pending delivery", domain.AuthorizationStatusPendingDelivery, domain.AuthorizationStatusExpired, true},
{"supersede pending delivery", domain.AuthorizationStatusPendingDelivery, domain.AuthorizationStatusSuperseded, true},
{"expire before fence", domain.AuthorizationStatusExecuting, domain.AuthorizationStatusExpired, true},
{"supersede before fence", domain.AuthorizationStatusDelivered, domain.AuthorizationStatusSuperseded, true},
{"fenced authorization cannot expire", domain.AuthorizationStatusFenced, domain.AuthorizationStatusExpired, false},
{"fenced authorization cannot be superseded", domain.AuthorizationStatusFenced, domain.AuthorizationStatusSuperseded, false},
{"fenced authorization cannot be delivered again", domain.AuthorizationStatusFenced, domain.AuthorizationStatusDelivered, false},
{"consumed authorization cannot restart", domain.AuthorizationStatusConsumed, domain.AuthorizationStatusDelivered, false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := test.current.CanTransitionTo(test.next); got != test.allowed {
t.Fatalf("CanTransitionTo(%s, %s) = %t, want %t", test.current, test.next, got, test.allowed)
}
result, err := domain.TransitionAuthorization(test.current, test.next)
if test.allowed {
if err != nil {
t.Fatalf("TransitionAuthorization(%s, %s): %v", test.current, test.next, err)
}
if result != test.next {
t.Fatalf("TransitionAuthorization(%s, %s) = %s, want %s", test.current, test.next, result, test.next)
}
return
}
if !errors.Is(err, domain.ErrInvalidAuthorizationTransition) {
t.Fatalf("TransitionAuthorization(%s, %s) error = %v, want ErrInvalidAuthorizationTransition", test.current, test.next, err)
}
})
}
}
+16
View File
@@ -0,0 +1,16 @@
package domain
import "time"
type SpecTrial struct {
ID string
TaskID string
Attempt int
ProductTitle string
SelectedColor string
SelectedSize string
UnitPrice string
TotalPrice string
EvidenceSHA256 string
CreatedAt time.Time
}
+56
View File
@@ -0,0 +1,56 @@
package domain
import (
"errors"
"time"
)
var ErrInvalidSubmissionTransition = errors.New("invalid submission status transition")
type SubmissionStatus string
const (
SubmissionStatusFenced SubmissionStatus = "FENCED"
SubmissionStatusSubmitted SubmissionStatus = "SUBMITTED"
SubmissionStatusReconciliationRequired SubmissionStatus = "RECONCILIATION_REQUIRED"
SubmissionStatusManualResolved SubmissionStatus = "MANUAL_RESOLVED"
)
type OrderSubmission struct {
ID string
TaskID string
AuthorizationID string
CommandID string
DryRunID string
Status SubmissionStatus
VerifiedUnitPrice string
QuantityRead int
ConfirmPageAmount string
CreatedAt time.Time
ResolvedAt *time.Time
}
// CanTransitionTo 只允许围栏记录向最终观察结果调和,拒绝回退以防触发第二次真实动作。
func (status SubmissionStatus) CanTransitionTo(next SubmissionStatus) bool {
_, allowed := submissionTransitions[status][next]
return allowed
}
// TransitionSubmission 返回下一状态;未定义的提交记录状态转移一律失败。
func TransitionSubmission(current, next SubmissionStatus) (SubmissionStatus, error) {
if !current.CanTransitionTo(next) {
return current, ErrInvalidSubmissionTransition
}
return next, nil
}
var submissionTransitions = map[SubmissionStatus]map[SubmissionStatus]struct{}{
SubmissionStatusFenced: {
SubmissionStatusSubmitted: {},
SubmissionStatusReconciliationRequired: {},
},
SubmissionStatusReconciliationRequired: {
SubmissionStatusManualResolved: {},
},
}
+47
View File
@@ -0,0 +1,47 @@
package domain_test
import (
"errors"
"testing"
"cmbuyer/admin/internal/domain"
)
func TestSubmissionTransitions(t *testing.T) {
tests := []struct {
name string
current domain.SubmissionStatus
next domain.SubmissionStatus
allowed bool
}{
{"submitted", domain.SubmissionStatusFenced, domain.SubmissionStatusSubmitted, true},
{"uncertain requires reconciliation", domain.SubmissionStatusFenced, domain.SubmissionStatusReconciliationRequired, true},
{"manual review resolves reconciliation", domain.SubmissionStatusReconciliationRequired, domain.SubmissionStatusManualResolved, true},
{"cannot reopen fenced submission", domain.SubmissionStatusSubmitted, domain.SubmissionStatusFenced, false},
{"submitted cannot require reconciliation", domain.SubmissionStatusSubmitted, domain.SubmissionStatusReconciliationRequired, false},
{"cannot skip reconciliation", domain.SubmissionStatusFenced, domain.SubmissionStatusManualResolved, false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := test.current.CanTransitionTo(test.next); got != test.allowed {
t.Fatalf("CanTransitionTo(%s, %s) = %t, want %t", test.current, test.next, got, test.allowed)
}
result, err := domain.TransitionSubmission(test.current, test.next)
if test.allowed {
if err != nil {
t.Fatalf("TransitionSubmission(%s, %s): %v", test.current, test.next, err)
}
if result != test.next {
t.Fatalf("TransitionSubmission(%s, %s) = %s, want %s", test.current, test.next, result, test.next)
}
return
}
if !errors.Is(err, domain.ErrInvalidSubmissionTransition) {
t.Fatalf("TransitionSubmission(%s, %s) error = %v, want ErrInvalidSubmissionTransition", test.current, test.next, err)
}
})
}
}
+102
View File
@@ -0,0 +1,102 @@
// Package domain 定义采购服务的业务实体与不依赖外部系统的状态规则。
package domain
import (
"errors"
"time"
)
var ErrInvalidTaskTransition = errors.New("invalid task status transition")
type TaskStatus string
const (
TaskStatusDraft TaskStatus = "DRAFT"
TaskStatusPending TaskStatus = "PENDING"
TaskStatusClaimed TaskStatus = "CLAIMED"
TaskStatusRunning TaskStatus = "RUNNING"
TaskStatusWaitingConfirmation TaskStatus = "WAITING_CONFIRMATION"
TaskStatusPendingRetrial TaskStatus = "PENDING_RETRIAL"
TaskStatusAuthorized TaskStatus = "AUTHORIZED"
TaskStatusOrdering TaskStatus = "ORDERING"
TaskStatusWaitingPayment TaskStatus = "WAITING_PAYMENT"
TaskStatusReconciliationRequired TaskStatus = "RECONCILIATION_REQUIRED"
TaskStatusNeedsManual TaskStatus = "NEEDS_MANUAL"
TaskStatusSucceeded TaskStatus = "SUCCEEDED"
TaskStatusCanceled TaskStatus = "CANCELED"
)
type Source string
const (
SourceManual Source = "MANUAL"
SourceExcel Source = "EXCEL"
SourceERP Source = "ERP"
)
type Task struct {
ID string
Source Source
SourceRef *string
Title string
GoodsID string
SKUColor string
SKUSize string
Quantity int
MaxTotalPrice string
ReferenceAssetID *string
Status TaskStatus
Version int
CreatedAt time.Time
UpdatedAt time.Time
}
// CanTransitionTo 只接受架构已定义的任务状态转移,未知状态或未列出的动作均拒绝。
func (status TaskStatus) CanTransitionTo(next TaskStatus) bool {
_, allowed := taskTransitions[status][next]
return allowed
}
// TransitionTask 返回下一状态;非法转移必须在写库前失败,不能由调用方猜测补救路径。
func TransitionTask(current, next TaskStatus) (TaskStatus, error) {
if !current.CanTransitionTo(next) {
return current, ErrInvalidTaskTransition
}
return next, nil
}
var taskTransitions = map[TaskStatus]map[TaskStatus]struct{}{
TaskStatusDraft: {
TaskStatusPending: {},
},
TaskStatusPending: {
TaskStatusClaimed: {},
},
TaskStatusPendingRetrial: {
TaskStatusClaimed: {},
},
TaskStatusClaimed: {
TaskStatusRunning: {},
TaskStatusPending: {},
},
TaskStatusRunning: {
TaskStatusWaitingConfirmation: {},
TaskStatusNeedsManual: {},
},
TaskStatusWaitingConfirmation: {
TaskStatusCanceled: {},
TaskStatusAuthorized: {},
},
TaskStatusAuthorized: {
TaskStatusOrdering: {},
},
TaskStatusOrdering: {
TaskStatusNeedsManual: {},
TaskStatusWaitingPayment: {},
TaskStatusReconciliationRequired: {},
},
TaskStatusWaitingPayment: {
TaskStatusSucceeded: {},
},
}
+62
View File
@@ -0,0 +1,62 @@
package domain_test
import (
"errors"
"testing"
"cmbuyer/admin/internal/domain"
)
func TestTaskTransitions(t *testing.T) {
tests := []struct {
name string
current domain.TaskStatus
next domain.TaskStatus
allowed bool
}{
{"start trial", domain.TaskStatusDraft, domain.TaskStatusPending, true},
{"claim trial", domain.TaskStatusPending, domain.TaskStatusClaimed, true},
{"claim retrial", domain.TaskStatusPendingRetrial, domain.TaskStatusClaimed, true},
{"start trial execution", domain.TaskStatusClaimed, domain.TaskStatusRunning, true},
{"release unstarted claim", domain.TaskStatusClaimed, domain.TaskStatusPending, true},
{"trial completes", domain.TaskStatusRunning, domain.TaskStatusWaitingConfirmation, true},
{"trial needs manual review", domain.TaskStatusRunning, domain.TaskStatusNeedsManual, true},
{"authorize confirmed trial", domain.TaskStatusWaitingConfirmation, domain.TaskStatusAuthorized, true},
{"reject confirmed trial", domain.TaskStatusWaitingConfirmation, domain.TaskStatusCanceled, true},
{"start authorized order leg", domain.TaskStatusAuthorized, domain.TaskStatusOrdering, true},
{"order reaches payment", domain.TaskStatusOrdering, domain.TaskStatusWaitingPayment, true},
{"order needs manual review before fence", domain.TaskStatusOrdering, domain.TaskStatusNeedsManual, true},
{"order needs reconciliation", domain.TaskStatusOrdering, domain.TaskStatusReconciliationRequired, true},
{"payment verified", domain.TaskStatusWaitingPayment, domain.TaskStatusSucceeded, true},
{"cannot skip trial", domain.TaskStatusDraft, domain.TaskStatusAuthorized, false},
{"trial cannot enter order leg", domain.TaskStatusRunning, domain.TaskStatusOrdering, false},
{"terminal task cannot restart", domain.TaskStatusSucceeded, domain.TaskStatusPending, false},
{"unknown status is rejected", domain.TaskStatus("UNKNOWN"), domain.TaskStatusPending, false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := test.current.CanTransitionTo(test.next); got != test.allowed {
t.Fatalf("CanTransitionTo(%s, %s) = %t, want %t", test.current, test.next, got, test.allowed)
}
result, err := domain.TransitionTask(test.current, test.next)
if test.allowed {
if err != nil {
t.Fatalf("TransitionTask(%s, %s): %v", test.current, test.next, err)
}
if result != test.next {
t.Fatalf("TransitionTask(%s, %s) = %s, want %s", test.current, test.next, result, test.next)
}
return
}
if !errors.Is(err, domain.ErrInvalidTaskTransition) {
t.Fatalf("TransitionTask(%s, %s) error = %v, want ErrInvalidTaskTransition", test.current, test.next, err)
}
if result != test.current {
t.Fatalf("TransitionTask(%s, %s) result = %s, want unchanged status", test.current, test.next, result)
}
})
}
}
+28
View File
@@ -0,0 +1,28 @@
// Package migrations 通过 goose 执行采购服务的版本化数据库迁移。
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
// Run 执行指定的 goose 命令。迁移目录由调用方显式传入,避免把运行目录当作隐式配置。
func Run(ctx context.Context, database *sql.DB, directory, command string) error {
if err := goose.SetDialect("sqlite3"); err != nil {
return err
}
return goose.RunContext(ctx, command, database, directory)
}
// Up 将数据库迁移到当前版本。
func Up(ctx context.Context, database *sql.DB, directory string) error {
return Run(ctx, database, directory, "up")
}
// Down 回退一个已应用的迁移版本。
func Down(ctx context.Context, database *sql.DB, directory string) error {
return Run(ctx, database, directory, "down")
}
@@ -0,0 +1,283 @@
package migrations_test
import (
"context"
"database/sql"
"path/filepath"
"runtime"
"testing"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
"github.com/pressly/goose/v3"
)
func TestUpDownAndIdempotence(t *testing.T) {
database := openTestDatabase(t)
directory := migrationDirectory(t)
context := context.Background()
if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("apply migrations: %v", err)
}
assertVersion(t, database, 1)
assertTableExists(t, database, "tasks", true)
assertTableExists(t, database, "spec_trials", true)
assertTableExists(t, database, "order_authorizations", true)
assertTableExists(t, database, "order_submissions", true)
if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("reapply migrations: %v", err)
}
assertVersion(t, database, 1)
if err := migrations.Down(context, database, directory); err != nil {
t.Fatalf("roll back migration: %v", err)
}
assertVersion(t, database, 0)
assertTableExists(t, database, "tasks", false)
assertTableExists(t, database, "spec_trials", false)
assertTableExists(t, database, "order_authorizations", false)
assertTableExists(t, database, "order_submissions", false)
if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("apply migration after rollback: %v", err)
}
assertVersion(t, database, 1)
}
func TestSchemaConstraints(t *testing.T) {
database := openTestDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("apply migrations: %v", err)
}
for _, column := range []struct {
table string
name string
}{
{"tasks", "max_total_price"},
{"spec_trials", "unit_price"},
{"spec_trials", "total_price"},
{"order_authorizations", "authorized_unit_price"},
{"order_authorizations", "total_price_cap"},
{"order_submissions", "verified_unit_price"},
{"order_submissions", "confirm_page_amount"},
} {
assertColumnType(t, database, column.table, column.name, "TEXT")
}
if _, err := database.Exec(`
INSERT INTO tasks (
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
status, created_at, updated_at
) VALUES ('bad-quantity', 'MANUAL', 'title', 'goods', 'white', 'XL', 0, '80.00', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
`); err == nil {
t.Fatal("insert task with quantity 0 succeeded")
}
if _, err := database.Exec(`
INSERT INTO tasks (
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
status, created_at, updated_at
) VALUES ('bad-price', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '80..00', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
`); err == nil {
t.Fatal("insert task with malformed decimal price succeeded")
}
if _, err := database.Exec(`
INSERT INTO tasks (
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
status, created_at, updated_at
) VALUES ('fractional-quantity', 'MANUAL', 'title', 'goods', 'white', 'XL', 1.5, '80.00', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
`); err == nil {
t.Fatal("insert task with fractional quantity succeeded")
}
if _, err := database.Exec(`
INSERT INTO tasks (
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
status, created_at, updated_at
) VALUES ('trailing-decimal', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '80.', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
`); err == nil {
t.Fatal("insert task with trailing decimal point succeeded")
}
if _, err := database.Exec(`
INSERT INTO tasks (
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
status, created_at, updated_at
) VALUES ('bad-status', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '80.00', 'UNKNOWN', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
`); err == nil {
t.Fatal("insert task with invalid status succeeded")
}
insertTask(t, database, "task-one")
insertTask(t, database, "task-two")
if _, err := database.Exec(`
INSERT INTO spec_trials (
id, task_id, attempt, product_title, selected_color, selected_size, unit_price,
total_price, evidence_sha256, created_at
) VALUES ('orphan-trial', 'missing-task', 1, 'title', 'white', 'XL', '32.50', '65.00', 'hash', '2026-08-03T00:00:00Z')
`); err == nil {
t.Fatal("insert spec trial without task succeeded")
}
insertSpecTrial(t, database, "trial-one", "task-one")
insertSpecTrial(t, database, "trial-two", "task-two")
if _, err := database.Exec(`
INSERT INTO order_authorizations (
id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity,
authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at
) VALUES ('authorization-cross-task', 'task-one', 'trial-two', 1, 'goods', 'white', 'XL', 2, '32.50', '80.00', 'PENDING_DELIVERY', 'admin-one', '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z')
`); err == nil {
t.Fatal("insert authorization with a spec trial from another task succeeded")
}
insertAuthorization(t, database, "authorization-one", "task-one", "trial-one", 1)
insertAuthorization(t, database, "authorization-task-two", "task-two", "trial-two", 1)
if _, err := database.Exec(`
INSERT INTO order_submissions (
id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price,
quantity_read, confirm_page_amount, created_at
) VALUES ('submission-cross-task', 'task-one', 'authorization-task-two', 'command-cross-task', 'dry-run-cross-task', 'FENCED', '32.50', 2, '65.00', '2026-08-03T00:00:00Z')
`); err == nil {
t.Fatal("insert submission with an authorization from another task succeeded")
}
if _, err := database.Exec(`
INSERT INTO order_authorizations (
id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity,
authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at
) VALUES ('authorization-duplicate', 'task-one', 'trial-one', 1, 'goods', 'white', 'XL', 2, '32.50', '80.00', 'PENDING_DELIVERY', 'admin-one', '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z')
`); err == nil {
t.Fatal("insert authorization with duplicate task version succeeded")
}
insertSubmission(t, database, "submission-one", "authorization-one", "command-one")
if _, err := database.Exec(`
INSERT INTO order_submissions (
id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price,
quantity_read, confirm_page_amount, created_at
) VALUES ('submission-duplicate-auth', 'task-one', 'authorization-one', 'command-two', 'dry-run-two', 'FENCED', '32.50', 2, '65.00', '2026-08-03T00:00:00Z')
`); err == nil {
t.Fatal("insert submission with duplicate authorization succeeded")
}
insertAuthorization(t, database, "authorization-two", "task-one", "trial-one", 2)
if _, err := database.Exec(`
INSERT INTO order_submissions (
id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price,
quantity_read, confirm_page_amount, created_at
) VALUES ('submission-duplicate-command', 'task-one', 'authorization-two', 'command-one', 'dry-run-three', 'FENCED', '32.50', 2, '65.00', '2026-08-03T00:00:00Z')
`); err == nil {
t.Fatal("insert submission with duplicate command succeeded")
}
}
func openTestDatabase(t *testing.T) *sql.DB {
t.Helper()
database, err := sqlite.Open(filepath.Join(t.TempDir(), "migrations.db"))
if err != nil {
t.Fatalf("open test database: %v", err)
}
t.Cleanup(func() {
if err := database.Close(); err != nil {
t.Errorf("close test database: %v", err)
}
})
return database
}
func migrationDirectory(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate migration test source")
}
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
}
func assertVersion(t *testing.T, database *sql.DB, want int64) {
t.Helper()
got, err := goose.GetDBVersion(database)
if err != nil {
t.Fatalf("read migration version: %v", err)
}
if got != want {
t.Fatalf("migration version = %d, want %d", got, want)
}
}
func assertTableExists(t *testing.T, database *sql.DB, table string, want bool) {
t.Helper()
var count int
if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&count); err != nil {
t.Fatalf("look up table %s: %v", table, err)
}
if got := count == 1; got != want {
t.Fatalf("table %s exists = %t, want %t", table, got, want)
}
}
func assertColumnType(t *testing.T, database *sql.DB, table, column, want string) {
t.Helper()
var got string
if err := database.QueryRow(`SELECT type FROM pragma_table_info(?) WHERE name = ?`, table, column).Scan(&got); err != nil {
t.Fatalf("read %s.%s type: %v", table, column, err)
}
if got != want {
t.Fatalf("%s.%s type = %s, want %s", table, column, got, want)
}
}
func insertTask(t *testing.T, database *sql.DB, id string) {
t.Helper()
if _, err := database.Exec(`
INSERT INTO tasks (
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
status, created_at, updated_at
) VALUES (?, 'MANUAL', 'title', 'goods', 'white', 'XL', 2, '80.00', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
`, id); err != nil {
t.Fatalf("insert task: %v", err)
}
}
func insertSpecTrial(t *testing.T, database *sql.DB, id, taskID string) {
t.Helper()
if _, err := database.Exec(`
INSERT INTO spec_trials (
id, task_id, attempt, product_title, selected_color, selected_size, unit_price,
total_price, evidence_sha256, created_at
) VALUES (?, ?, 1, 'title', 'white', 'XL', '32.50', '65.00', 'hash', '2026-08-03T00:00:00Z')
`, id, taskID); err != nil {
t.Fatalf("insert spec trial: %v", err)
}
}
func insertAuthorization(t *testing.T, database *sql.DB, id, taskID, specTrialID string, version int) {
t.Helper()
if _, err := database.Exec(`
INSERT INTO order_authorizations (
id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity,
authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at
) VALUES (?, ?, ?, ?, 'goods', 'white', 'XL', 2, '32.50', '80.00', 'PENDING_DELIVERY', 'admin-one', '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z')
`, id, taskID, specTrialID, version); err != nil {
t.Fatalf("insert authorization: %v", err)
}
}
func insertSubmission(t *testing.T, database *sql.DB, id, authorizationID, commandID string) {
t.Helper()
if _, err := database.Exec(`
INSERT INTO order_submissions (
id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price,
quantity_read, confirm_page_amount, created_at
) VALUES (?, 'task-one', ?, ?, 'dry-run-one', 'FENCED', '32.50', 2, '65.00', '2026-08-03T00:00:00Z')
`, id, authorizationID, commandID); err != nil {
t.Fatalf("insert submission: %v", err)
}
}
+19
View File
@@ -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
}
+28
View File
@@ -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)
}
}
+35
View File
@@ -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)
}
}
}
+148
View File
@@ -0,0 +1,148 @@
-- +goose Up
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
source TEXT NOT NULL CHECK (source IN ('MANUAL', 'EXCEL', 'ERP')),
source_ref TEXT,
title TEXT NOT NULL,
goods_id TEXT NOT NULL,
sku_color TEXT NOT NULL,
sku_size TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
max_total_price TEXT NOT NULL CHECK (
max_total_price <> ''
AND max_total_price NOT GLOB '*[^0-9.]*'
AND length(max_total_price) - length(replace(max_total_price, '.', '')) <= 1
AND max_total_price <> '.'
AND (instr(max_total_price, '.') = 0 OR (
instr(max_total_price, '.') > 1
AND length(max_total_price) > instr(max_total_price, '.')
))
),
reference_asset_id TEXT,
status TEXT NOT NULL CHECK (status IN (
'DRAFT', 'PENDING', 'CLAIMED', 'RUNNING', 'WAITING_CONFIRMATION',
'PENDING_RETRIAL', 'AUTHORIZED', 'ORDERING', 'WAITING_PAYMENT',
'RECONCILIATION_REQUIRED', 'NEEDS_MANUAL', 'SUCCEEDED', 'CANCELED'
)),
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0 AND typeof(version) = 'integer'),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE spec_trials (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
attempt INTEGER NOT NULL CHECK (attempt > 0 AND typeof(attempt) = 'integer'),
product_title TEXT NOT NULL,
selected_color TEXT NOT NULL,
selected_size TEXT NOT NULL,
unit_price TEXT NOT NULL CHECK (
unit_price <> ''
AND unit_price NOT GLOB '*[^0-9.]*'
AND length(unit_price) - length(replace(unit_price, '.', '')) <= 1
AND unit_price <> '.'
AND (instr(unit_price, '.') = 0 OR (
instr(unit_price, '.') > 1
AND length(unit_price) > instr(unit_price, '.')
))
),
total_price TEXT NOT NULL CHECK (
total_price <> ''
AND total_price NOT GLOB '*[^0-9.]*'
AND length(total_price) - length(replace(total_price, '.', '')) <= 1
AND total_price <> '.'
AND (instr(total_price, '.') = 0 OR (
instr(total_price, '.') > 1
AND length(total_price) > instr(total_price, '.')
))
),
evidence_sha256 TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (task_id, attempt),
UNIQUE (task_id, id)
);
CREATE TABLE order_authorizations (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
spec_trial_id TEXT NOT NULL REFERENCES spec_trials(id),
version INTEGER NOT NULL CHECK (version > 0 AND typeof(version) = 'integer'),
goods_id TEXT NOT NULL,
sku_color TEXT NOT NULL,
sku_size TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
authorized_unit_price TEXT NOT NULL CHECK (
authorized_unit_price <> ''
AND authorized_unit_price NOT GLOB '*[^0-9.]*'
AND length(authorized_unit_price) - length(replace(authorized_unit_price, '.', '')) <= 1
AND authorized_unit_price <> '.'
AND (instr(authorized_unit_price, '.') = 0 OR (
instr(authorized_unit_price, '.') > 1
AND length(authorized_unit_price) > instr(authorized_unit_price, '.')
))
),
total_price_cap TEXT NOT NULL CHECK (
total_price_cap <> ''
AND total_price_cap NOT GLOB '*[^0-9.]*'
AND length(total_price_cap) - length(replace(total_price_cap, '.', '')) <= 1
AND total_price_cap <> '.'
AND (instr(total_price_cap, '.') = 0 OR (
instr(total_price_cap, '.') > 1
AND length(total_price_cap) > instr(total_price_cap, '.')
))
),
note TEXT,
status TEXT NOT NULL CHECK (status IN (
'PENDING_DELIVERY', 'DELIVERED', 'ACKNOWLEDGED', 'EXECUTING', 'FENCED',
'CONSUMED', 'SUPERSEDED', 'EXPIRED'
)),
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
UNIQUE (task_id, version),
UNIQUE (task_id, id),
FOREIGN KEY (task_id, spec_trial_id) REFERENCES spec_trials(task_id, id)
);
CREATE TABLE order_submissions (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
authorization_id TEXT NOT NULL REFERENCES order_authorizations(id),
command_id TEXT NOT NULL,
dry_run_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN (
'FENCED', 'SUBMITTED', 'RECONCILIATION_REQUIRED', 'MANUAL_RESOLVED'
)),
verified_unit_price TEXT NOT NULL CHECK (
verified_unit_price <> ''
AND verified_unit_price NOT GLOB '*[^0-9.]*'
AND length(verified_unit_price) - length(replace(verified_unit_price, '.', '')) <= 1
AND verified_unit_price <> '.'
AND (instr(verified_unit_price, '.') = 0 OR (
instr(verified_unit_price, '.') > 1
AND length(verified_unit_price) > instr(verified_unit_price, '.')
))
),
quantity_read INTEGER NOT NULL CHECK (quantity_read > 0 AND typeof(quantity_read) = 'integer'),
confirm_page_amount TEXT NOT NULL CHECK (
confirm_page_amount <> ''
AND confirm_page_amount NOT GLOB '*[^0-9.]*'
AND length(confirm_page_amount) - length(replace(confirm_page_amount, '.', '')) <= 1
AND confirm_page_amount <> '.'
AND (instr(confirm_page_amount, '.') = 0 OR (
instr(confirm_page_amount, '.') > 1
AND length(confirm_page_amount) > instr(confirm_page_amount, '.')
))
),
created_at TEXT NOT NULL,
resolved_at TEXT,
UNIQUE (authorization_id),
UNIQUE (command_id),
FOREIGN KEY (task_id, authorization_id) REFERENCES order_authorizations(task_id, id)
);
-- +goose Down
DROP TABLE order_submissions;
DROP TABLE order_authorizations;
DROP TABLE spec_trials;
DROP TABLE tasks;
+23
View File
@@ -0,0 +1,23 @@
# 本机开发环境与解释器缓存
.venv/
venv/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
# 运行时生成的日志、截图和其他证据产物不得进入版本库。
logs/
artifacts/
runtime/
*.log
# 本机凭据或环境覆盖仅可保存在未跟踪文件中。
.env
.env.*
secrets/
# 打包工具生成的本机产物
build/
dist/
*.egg-info/
+19
View File
@@ -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"] }
+9
View File
@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# 桌面界面(Qt 官方 Python 绑定)。
PySide6
# T-101 基线取证使用;只连接显式 serial,不打开或操作拼多多页面。
uiautomator2
# T-101 直接使用当前 ADB server 的已列出设备对象交给 uiautomator2,禁止 WiFi 自动重连。
adbutils>=2.11,<3
# 后续截图完整性检查会使用。
Pillow
+76
View File
@@ -0,0 +1,76 @@
"""采集指定 Android 设备的本地基线证据;不打开或操作拼多多页面。"""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import (
BaselineCaptureError,
DeviceBaselineCapturer,
NoReconnectUiautomatorConnector,
)
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="采集显式指定 Android 设备的本地基线证据。")
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
parser.add_argument("--output-dir", required=True, type=Path, help="新建的本地证据目录;不得覆盖已有目录。")
parser.add_argument("--timeout", type=float, default=10.0, help="ADB、uiautomator2 RPC 与 ADB socket 超时(秒)。")
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
return parser.parse_args(argv)
def validate_arguments(arguments: argparse.Namespace) -> None:
"""在导入设备库前拒绝危险或无效输入,便于离线测试。"""
if not arguments.serial.strip():
raise ValueError("必须显式提供非空 --serial。")
if arguments.timeout <= 0:
raise ValueError("--timeout 必须大于 0。")
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
validate_arguments(arguments)
except ValueError as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
return 2
client = AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout)
connector = NoReconnectUiautomatorConnector(
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
u2.connect,
)
capturer = DeviceBaselineCapturer(client, connector, timeout_seconds=arguments.timeout)
try:
result = capturer.capture(arguments.serial, arguments.output_dir)
except (DeviceConnectionError, BaselineCaptureError) as error:
# 错误类型只表达状态,不打印 ADB 输出、serial、XML 或页面正文。
print(f"基线取证失败:{error}", file=sys.stderr)
return 1
except OSError:
print("基线取证失败:无法创建或发布本地证据目录。", file=sys.stderr)
return 1
print(f"基线取证完成:{result.output_directory}")
print(f"manifest:{result.manifest_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+80
View File
@@ -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())
+6
View File
@@ -0,0 +1,6 @@
"""采购工具桌面端包。
本包当前只提供应用骨架和安全的本地运行基础设施;不包含真机操作或采购流程。
"""
__version__ = "0.1.0"
+7
View File
@@ -0,0 +1,7 @@
"""支持通过 ``python -m cmbuyer_client`` 启动应用。"""
from .app import main
if __name__ == "__main__":
raise SystemExit(main())
+57
View File
@@ -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,15 @@
"""设备连接与基线取证边界。
本包只提供显式设备选择、非敏感身份核验和本地基线采集;不包含任何采购页面或订单操作。
"""
from .adb import AdbClient, AdbDevice, CommandResult
from .baseline import BaselineCaptureResult, DeviceBaselineCapturer
__all__ = [
"AdbClient",
"AdbDevice",
"BaselineCaptureResult",
"CommandResult",
"DeviceBaselineCapturer",
]
+234
View File
@@ -0,0 +1,234 @@
"""ADB 设备清单与物理设备冲突的 fail-closed 边界。"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import subprocess
from typing import Protocol, Sequence
class DeviceConnectionError(RuntimeError):
"""显式设备连接边界的基础错误,不携带命令输出或设备敏感内容。"""
class SerialRequiredError(DeviceConnectionError):
"""调用方没有明确指定设备 serial。"""
class DeviceNotFoundError(DeviceConnectionError):
"""指定 serial 不在 ADB 当前清单中。"""
class DeviceOfflineError(DeviceConnectionError):
"""指定设备处于 offline 状态。"""
class DeviceUnauthorizedError(DeviceConnectionError):
"""指定设备尚未授权此电脑。"""
class DeviceStateError(DeviceConnectionError):
"""指定设备处于其他不可用状态。"""
class DeviceCommandTimeoutError(DeviceConnectionError):
"""ADB 命令超过调用方指定的超时。"""
class DeviceCommandError(DeviceConnectionError):
"""ADB 命令失败;错误文本刻意不回显设备输出。"""
class DeviceIdentityUnconfirmedError(DeviceConnectionError):
"""多条在线通道无法完成同机身份判断,必须由人处理。"""
class DuplicatePhysicalDeviceError(DeviceConnectionError):
"""同一物理手机通过多个 ADB 通道同时在线。"""
@dataclass(frozen=True)
class CommandResult:
"""可注入命令执行器的最小、可离线构造结果。"""
stdout: str
stderr: str = ""
returncode: int = 0
class CommandRunner(Protocol):
"""运行 ADB 子命令的可替换边界。"""
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
"""运行参数,不得通过 shell 拼接。"""
class SubprocessAdbRunner:
"""使用 subprocess 的生产执行器,所有调用必须带超时。"""
def __init__(self, executable: str | Path = "adb") -> None:
self._executable = str(executable)
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
try:
completed = subprocess.run(
[self._executable, *arguments],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout_seconds,
)
except subprocess.TimeoutExpired as error:
raise DeviceCommandTimeoutError("ADB 命令超时,请检查设备连接后由人工重试。") from error
except OSError as error:
raise DeviceCommandError("无法启动 ADB,请检查 adb 路径与本机工具链。") from error
return CommandResult(
stdout=completed.stdout,
stderr=completed.stderr,
returncode=completed.returncode,
)
@dataclass(frozen=True)
class AdbDevice:
"""`adb devices -l` 的单行非敏感传输元数据。"""
serial: str
state: str
product: str | None = None
model: str | None = None
device: str | None = None
@dataclass(frozen=True)
class DeviceInspection:
"""选定通道的只读身份结果,原始硬件标识只在内存中参与比较。"""
device: AdbDevice
model: str
android_version: str
def parse_adb_devices(output: str) -> list[AdbDevice]:
"""解析 `adb devices -l`,忽略标题、空行和 adb 附加提示。"""
devices: list[AdbDevice] = []
for raw_line in output.splitlines():
line = raw_line.strip()
if not line or line.startswith("List of devices attached") or line.startswith("*"):
continue
fields = line.split()
if len(fields) < 2:
continue
details = {
key: value
for field in fields[2:]
if ":" in field
for key, value in [field.split(":", 1)]
}
devices.append(
AdbDevice(
serial=fields[0],
state=fields[1],
product=details.get("product"),
model=details.get("model"),
device=details.get("device"),
)
)
return devices
class AdbClient:
"""显式 serial 的 ADB 只读查询。
多个在线通道必须完成硬件身份比对。比对失败时不能用相同 model/product 猜测同一台手机,
因为那会把不确定性隐藏成错误的安全结论。
"""
def __init__(self, runner: CommandRunner, timeout_seconds: float = 10.0) -> None:
if timeout_seconds <= 0:
raise ValueError("timeout_seconds 必须大于 0")
self._runner = runner
self._timeout_seconds = timeout_seconds
def inspect(self, serial: str) -> DeviceInspection:
"""确认指定通道在线且不与另一在线通道指向同一物理设备。"""
selected_serial = _require_serial(serial)
devices = self.devices()
selected = next((device for device in devices if device.serial == selected_serial), None)
if selected is None:
raise DeviceNotFoundError("指定设备不在 ADB 清单中,请显式检查 serial。")
_raise_for_state(selected.state)
online_devices = [device for device in devices if device.state == "device"]
if len(online_devices) > 1:
identities: dict[str, frozenset[str]] = {}
for candidate in online_devices:
try:
identities[candidate.serial] = self._physical_identity(candidate)
except DeviceConnectionError as error:
raise DeviceIdentityUnconfirmedError(
"存在多个在线 ADB 通道且身份无法确认,已拒绝选择设备。"
) from error
selected_identity = identities[selected.serial]
if any(
candidate_serial != selected.serial and selected_identity.intersection(candidate_identity)
for candidate_serial, candidate_identity in identities.items()
):
raise DuplicatePhysicalDeviceError(
"同一物理手机的多个 ADB 通道同时在线,已拒绝继续;请仅保留一个通道。"
)
model = self._getprop(selected.serial, "ro.product.model") or selected.model or "unknown"
android_version = self._getprop(selected.serial, "ro.build.version.release") or "unknown"
return DeviceInspection(device=selected, model=model, android_version=android_version)
def devices(self) -> list[AdbDevice]:
"""读取并解析 ADB 设备清单。"""
result = self._run_checked(("devices", "-l"))
return parse_adb_devices(result.stdout)
def _physical_identity(self, device: AdbDevice) -> frozenset[str]:
serialno = self._getprop(device.serial, "ro.serialno")
boot_serialno = self._getprop(device.serial, "ro.boot.serialno")
identifiers = frozenset(value for value in (serialno, boot_serialno) if value)
if identifiers:
return identifiers
# model/product/device 只能作为展示元数据,不能证明两台同型号设备是同一物理机。
raise DeviceIdentityUnconfirmedError("无法读取设备硬件身份摘要。")
def _getprop(self, serial: str, property_name: str) -> str:
result = self._run_checked(("-s", serial, "shell", "getprop", property_name))
return result.stdout.strip()
def _run_checked(self, arguments: Sequence[str]) -> CommandResult:
try:
result = self._runner.run(arguments, self._timeout_seconds)
except subprocess.TimeoutExpired as error:
raise DeviceCommandTimeoutError("ADB 命令超时,请检查设备连接后由人工重试。") from error
if result.returncode != 0:
raise DeviceCommandError("ADB 命令失败,请检查设备连接或授权状态。")
return result
def _require_serial(serial: str) -> str:
if not isinstance(serial, str) or not serial.strip():
raise SerialRequiredError("必须显式提供设备 serial,禁止自动选择设备。")
return serial.strip()
def _raise_for_state(state: str) -> None:
if state == "device":
return
if state == "offline":
raise DeviceOfflineError("指定设备处于 offline 状态。")
if state == "unauthorized":
raise DeviceUnauthorizedError("指定设备尚未授权此电脑。")
raise DeviceStateError("指定设备不处于可用状态。")
@@ -0,0 +1,238 @@
"""只读设备基线取证,严格限制在元数据、截图和完整节点树。"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from hashlib import sha256
import base64
import binascii
from io import BytesIO
import json
import os
from pathlib import Path
import shutil
from typing import Any, Protocol
from uuid import uuid4
from xml.etree import ElementTree
from adbutils.errors import AdbTimeout
from PIL import Image, UnidentifiedImageError
from uiautomator2.exceptions import HTTPTimeoutError
from .adb import AdbClient, DeviceInspection
PDD_PACKAGE = "com.xunmeng.pinduoduo"
SCREENSHOT_PARAMS = [1, 80]
HIERARCHY_PARAMS = [False, 50]
_BASE64_ASCII_WHITESPACE = " \t\r\n"
class BaselineCaptureError(RuntimeError):
"""基线取证无法完整落盘时的失败,不会伪造成功产物。"""
class BaselineCaptureTimeoutError(BaselineCaptureError):
"""设备基线取证超时;底层异常文本不向 CLI 或日志泄露。"""
class UiAutomatorDevice(Protocol):
"""本任务唯一需要的 uiautomator2 只读能力。"""
def app_info(self, package_name: str) -> dict[str, Any]:
"""读取已安装应用元数据。"""
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any:
"""调用公开 uiautomator2 JSON-RPC 接口。"""
class NoReconnectUiautomatorConnector:
"""只把当前 ADB server 已列出的设备对象交给 uiautomator2。
uiautomator2 直接接收 IP serial 时会在内部尝试 adb disconnect/connect。这里先从已列出设备中
取对象再调用 ``u2.connect(device_object)``,避免连接阶段隐式重连已经掉线的 WiFi 通道。
"""
def __init__(self, list_devices: Callable[[], list[Any]], connect: Callable[[Any], UiAutomatorDevice]) -> None:
self._list_devices = list_devices
self._connect = connect
def __call__(self, serial: str) -> UiAutomatorDevice:
device = next((item for item in self._list_devices() if item.serial == serial), None)
if device is None:
raise BaselineCaptureError("设备在连接前已从 ADB 清单消失,已拒绝自动重连。")
return self._connect(device)
@dataclass(frozen=True)
class BaselineCaptureResult:
"""已原子发布的基线取证摘要,不包含页面正文或原始 serial。"""
output_directory: Path
manifest_path: Path
screenshot_path: Path
hierarchy_path: Path
class DeviceBaselineCapturer:
"""以先校验通道、后连接、最后原子发布的顺序采集基线。
截图和 XML 可能包含页面敏感内容,因此仅落在调用方明确指定的本地目录;manifest 只写
哈希、设备非敏感元数据和脱敏后的 serial 摘要,绝不嵌入 XML 或页面文本。
"""
def __init__(
self,
adb_client: AdbClient,
connector: Callable[[str], UiAutomatorDevice],
timeout_seconds: float,
) -> None:
if timeout_seconds <= 0:
raise ValueError("timeout_seconds 必须大于 0")
self._adb_client = adb_client
self._connector = connector
self._timeout_seconds = timeout_seconds
def capture(self, serial: str, output_directory: Path) -> BaselineCaptureResult:
"""采集完整基线,任何一步失败均不发布 output_directory。"""
inspection = self._adb_client.inspect(serial)
target = Path(output_directory)
if target.exists():
raise BaselineCaptureError("输出目录已存在;为防止混入旧证据,拒绝覆盖。")
if not target.name:
raise BaselineCaptureError("输出目录必须是明确的新目录。")
target.parent.mkdir(parents=True, exist_ok=True)
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
staging.mkdir()
try:
device = self._connector(serial)
app_info = device.app_info(PDD_PACKAGE)
version = _extract_version(app_info)
screenshot_path = staging / "screenshot.png"
screenshot_base64 = device.jsonrpc_call(
"takeScreenshot",
SCREENSHOT_PARAMS,
timeout=self._timeout_seconds,
)
_save_base64_screenshot(screenshot_base64, screenshot_path)
hierarchy = device.jsonrpc_call(
"dumpWindowHierarchy",
HIERARCHY_PARAMS,
timeout=self._timeout_seconds,
)
_validate_hierarchy(hierarchy)
hierarchy_path = staging / "hierarchy.xml"
hierarchy_path.write_text(hierarchy, encoding="utf-8")
manifest_path = staging / "manifest.json"
manifest_path.write_text(
json.dumps(
_manifest(inspection, serial, version, screenshot_path, hierarchy_path),
ensure_ascii=False,
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
os.replace(staging, target)
except BaselineCaptureError:
# 仅删除本次创建、名称带随机标识的暂存目录,绝不触碰调用方原有输出目录。
if staging.exists():
shutil.rmtree(staging)
raise
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
if staging.exists():
shutil.rmtree(staging)
raise BaselineCaptureTimeoutError("设备基线取证超时,未发布任何证据产物。") from error
except Exception as error:
if staging.exists():
shutil.rmtree(staging)
# uiautomator2/adbutils 可能把 serial、路径或远端响应放入异常文本,不能直接传播到 CLI。
raise BaselineCaptureError("设备基线取证未完成,未发布任何证据产物。") from error
return BaselineCaptureResult(
output_directory=target,
manifest_path=target / "manifest.json",
screenshot_path=target / "screenshot.png",
hierarchy_path=target / "hierarchy.xml",
)
def _extract_version(app_info: dict[str, Any]) -> str:
version = app_info.get("versionName") or app_info.get("version_name")
if not isinstance(version, str) or not version.strip():
raise BaselineCaptureError("无法读取拼多多版本,拒绝发布不完整取证。")
return version.strip()
def _save_base64_screenshot(value: Any, target: Path) -> None:
"""规范化常见 ASCII Base64 空白后严格解码;没有 adb screenshot fallback。"""
if not isinstance(value, str) or not value:
raise BaselineCaptureError("截图 RPC 未返回 base64 数据,拒绝发布不完整取证。")
try:
normalized = value.translate({ord(character): None for character in _BASE64_ASCII_WHITESPACE})
raw_image = base64.b64decode(normalized.encode("ascii"), validate=True)
except (UnicodeEncodeError, ValueError, binascii.Error) as error:
raise BaselineCaptureError("截图 RPC Base64 语法无效,拒绝发布不完整取证。") from error
try:
with Image.open(BytesIO(raw_image)) as image:
image.load()
image.save(target, format="PNG")
except (UnidentifiedImageError, OSError) as error:
raise BaselineCaptureError("截图 RPC 图像数据无效,拒绝发布不完整取证。") from error
def _validate_hierarchy(value: Any) -> None:
"""确认 RPC 返回的是完整节点树,不把原始 XML 放进错误或日志。"""
if not isinstance(value, str) or not value:
raise BaselineCaptureError("节点树导出为空,拒绝发布不完整取证。")
try:
root = ElementTree.fromstring(value)
except ElementTree.ParseError as error:
raise BaselineCaptureError("节点树不是有效 XML,拒绝发布不完整取证。") from error
if root.tag != "hierarchy":
raise BaselineCaptureError("节点树根节点无效,拒绝发布不完整取证。")
def _manifest(
inspection: DeviceInspection,
serial: str,
pdd_version: str,
screenshot_path: Path,
hierarchy_path: Path,
) -> dict[str, Any]:
"""只序列化审计摘要;页面内容留在 XML 文件,不进入日志或 manifest。"""
return {
"schema_version": 1,
"captured_at": datetime.now(UTC).isoformat(),
"channel": "wifi" if ":" in serial else "usb",
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
"device": {
"model": inspection.model,
"android_version": inspection.android_version,
"pdd_package": PDD_PACKAGE,
"pdd_version": pdd_version,
},
"artifacts": [
{"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)},
{"path": hierarchy_path.name, "sha256": _sha256_file(hierarchy_path)},
],
}
def _sha256_file(path: Path) -> str:
digest = sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
@@ -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
+42
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""采购工具的离线单元测试。"""
+1
View File
@@ -0,0 +1 @@
"""设备连接与基线取证的离线测试。"""
+182
View File
@@ -0,0 +1,182 @@
"""ADB 设备边界测试:所有命令执行器均为 mock,不连接真机。"""
from __future__ import annotations
from collections.abc import Sequence
from pathlib import Path
import subprocess
import sys
import unittest
CLIENT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import (
AdbClient,
CommandResult,
DeviceIdentityUnconfirmedError,
DeviceNotFoundError,
DeviceOfflineError,
DeviceStateError,
DeviceUnauthorizedError,
DuplicatePhysicalDeviceError,
SerialRequiredError,
)
USB_SERIAL = "3B65BD02H7F00000"
WIFI_SERIAL = "192.168.0.173:5555"
class FakeRunner:
def __init__(self, devices_output: str, properties: dict[tuple[str, str], CommandResult | str]) -> None:
self.devices_output = devices_output
self.properties = properties
self.calls: list[tuple[str, ...]] = []
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
self.calls.append(tuple(arguments))
if tuple(arguments) == ("devices", "-l"):
return CommandResult(stdout=self.devices_output)
key = (arguments[1], arguments[-1])
value = self.properties.get(key, "")
return value if isinstance(value, CommandResult) else CommandResult(stdout=value)
def _properties(serials: tuple[str, ...]) -> dict[tuple[str, str], str]:
values: dict[tuple[str, str], str] = {}
for serial in serials:
values[(serial, "ro.serialno")] = "physical-phone-1"
values[(serial, "ro.boot.serialno")] = "physical-phone-1"
values[(serial, "ro.product.model")] = "PKG110"
values[(serial, "ro.product.name")] = "PKG110"
values[(serial, "ro.product.device")] = "OP5D2BL1"
values[(serial, "ro.build.version.release")] = "16"
return values
class AdbClientTests(unittest.TestCase):
def test_requires_explicit_serial(self) -> None:
runner = FakeRunner("List of devices attached\n", {})
with self.assertRaises(SerialRequiredError):
AdbClient(runner).inspect(" ")
self.assertEqual(runner.calls, [])
def test_missing_offline_and_unauthorized_are_distinct(self) -> None:
missing = AdbClient(FakeRunner("List of devices attached\n", {}))
with self.assertRaises(DeviceNotFoundError):
missing.inspect(USB_SERIAL)
offline = AdbClient(FakeRunner(f"List of devices attached\n{USB_SERIAL}\toffline\n", {}))
with self.assertRaises(DeviceOfflineError):
offline.inspect(USB_SERIAL)
unauthorized = AdbClient(FakeRunner(f"List of devices attached\n{USB_SERIAL}\tunauthorized\n", {}))
with self.assertRaises(DeviceUnauthorizedError):
unauthorized.inspect(USB_SERIAL)
def test_two_channels_with_same_physical_identity_fail_closed(self) -> None:
output = (
"List of devices attached\n"
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
)
runner = FakeRunner(output, _properties((WIFI_SERIAL, USB_SERIAL)))
with self.assertRaises(DuplicatePhysicalDeviceError):
AdbClient(runner).inspect(USB_SERIAL)
self.assertIn(("-s", WIFI_SERIAL, "shell", "getprop", "ro.serialno"), runner.calls)
self.assertIn(("-s", USB_SERIAL, "shell", "getprop", "ro.serialno"), runner.calls)
def test_multiple_online_devices_with_failed_identity_fail_closed(self) -> None:
output = (
"List of devices attached\n"
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
)
properties = _properties((WIFI_SERIAL, USB_SERIAL))
properties[(WIFI_SERIAL, "ro.serialno")] = CommandResult(stdout="", returncode=1)
runner = FakeRunner(output, properties)
with self.assertRaises(DeviceIdentityUnconfirmedError):
AdbClient(runner).inspect(USB_SERIAL)
def test_online_explicit_serial_reads_non_sensitive_metadata(self) -> None:
output = f"List of devices attached\n{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
inspection = AdbClient(FakeRunner(output, _properties((USB_SERIAL,)))).inspect(USB_SERIAL)
self.assertEqual(inspection.device.serial, USB_SERIAL)
self.assertEqual(inspection.model, "PKG110")
self.assertEqual(inspection.android_version, "16")
def test_single_online_device_does_not_require_hardware_identity(self) -> None:
output = f"List of devices attached\n{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
properties = _properties((USB_SERIAL,))
properties[(USB_SERIAL, "ro.serialno")] = ""
properties[(USB_SERIAL, "ro.boot.serialno")] = ""
inspection = AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
self.assertEqual(inspection.model, "PKG110")
def test_multiple_online_devices_without_hardware_identity_are_unconfirmed(self) -> None:
output = (
"List of devices attached\n"
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
)
properties = _properties((WIFI_SERIAL, USB_SERIAL))
properties[(WIFI_SERIAL, "ro.serialno")] = ""
properties[(WIFI_SERIAL, "ro.boot.serialno")] = ""
with self.assertRaises(DeviceIdentityUnconfirmedError):
AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
def test_multiple_online_devices_with_different_identity_keep_explicit_selection(self) -> None:
output = (
"List of devices attached\n"
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
)
properties = _properties((WIFI_SERIAL, USB_SERIAL))
properties[(WIFI_SERIAL, "ro.serialno")] = "physical-phone-2"
properties[(WIFI_SERIAL, "ro.boot.serialno")] = "physical-phone-2"
inspection = AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
self.assertEqual(inspection.device.serial, USB_SERIAL)
def test_shared_boot_serial_is_duplicate_even_when_ro_serial_differs(self) -> None:
output = (
"List of devices attached\n"
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
)
properties = _properties((WIFI_SERIAL, USB_SERIAL))
properties[(WIFI_SERIAL, "ro.serialno")] = "wifi-transport-serial"
properties[(USB_SERIAL, "ro.serialno")] = "usb-transport-serial"
properties[(WIFI_SERIAL, "ro.boot.serialno")] = "shared-hardware-serial"
properties[(USB_SERIAL, "ro.boot.serialno")] = "shared-hardware-serial"
with self.assertRaises(DuplicatePhysicalDeviceError):
AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
def test_unknown_adb_state_is_rejected(self) -> None:
client = AdbClient(FakeRunner(f"List of devices attached\n{USB_SERIAL}\trecovery\n", {}))
with self.assertRaises(DeviceStateError):
client.inspect(USB_SERIAL)
def test_runner_timeout_is_a_distinct_connection_error(self) -> None:
class TimeoutRunner:
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
raise subprocess.TimeoutExpired(arguments, timeout_seconds)
from cmbuyer_client.device.adb import DeviceCommandTimeoutError
with self.assertRaises(DeviceCommandTimeoutError):
AdbClient(TimeoutRunner()).inspect(USB_SERIAL)
+281
View File
@@ -0,0 +1,281 @@
"""基线取证测试:mock ADB/uiautomator2,不连接手机。"""
from __future__ import annotations
from pathlib import Path
import base64
from io import BytesIO
import sys
import tempfile
import unittest
from PIL import Image
from uiautomator2.exceptions import HTTPTimeoutError
CLIENT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
sys.path.insert(0, str(CLIENT_ROOT / "scripts"))
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
from cmbuyer_client.device.baseline import (
BaselineCaptureError,
BaselineCaptureTimeoutError,
DeviceBaselineCapturer,
NoReconnectUiautomatorConnector,
PDD_PACKAGE,
)
from capture_device_baseline import parse_arguments, validate_arguments
SERIAL = "USB-serial-for-test"
class StaticAdbClient:
def __init__(self) -> None:
self.serials: list[str] = []
def inspect(self, serial: str) -> DeviceInspection:
self.serials.append(serial)
return DeviceInspection(
device=AdbDevice(serial=serial, state="device", model="Test Model"),
model="Test Model",
android_version="16",
)
class FakeUiDevice:
def __init__(self, fail_dump: bool = False) -> None:
self.fail_dump = fail_dump
self.rpc_calls: list[tuple[str, object, float]] = []
self.app_info_calls: list[str] = []
def app_info(self, package_name: str) -> dict[str, str]:
self.app_info_calls.append(package_name)
return {"versionName": "8.17.0"}
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
self.rpc_calls.append((method, params, timeout))
if method == "takeScreenshot":
image_data = BytesIO()
Image.new("RGB", (1, 1), color="white").save(image_data, format="PNG")
return base64.b64encode(image_data.getvalue()).decode("ascii")
if method != "dumpWindowHierarchy":
raise AssertionError(f"unexpected method: {method}")
if self.fail_dump:
raise RuntimeError("mock dump failed")
return "<hierarchy><node text='page body must stay out of manifest'/></hierarchy>"
class BaselineCaptureTests(unittest.TestCase):
def test_capture_writes_hashes_without_xml_or_raw_serial_in_manifest(self) -> None:
adb = StaticAdbClient()
device = FakeUiDevice()
capturer = DeviceBaselineCapturer(adb, lambda serial: device, timeout_seconds=7.5)
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "baseline"
result = capturer.capture(SERIAL, output)
manifest = result.manifest_path.read_text(encoding="utf-8")
self.assertEqual(adb.serials, [SERIAL])
self.assertEqual(device.app_info_calls, [PDD_PACKAGE])
self.assertEqual(
device.rpc_calls,
[
("takeScreenshot", [1, 80], 7.5),
("dumpWindowHierarchy", [False, 50], 7.5),
],
)
self.assertTrue(result.screenshot_path.is_file())
self.assertTrue(result.hierarchy_path.is_file())
self.assertIn('"sha256"', manifest)
self.assertNotIn("page body must stay out of manifest", manifest)
self.assertNotIn(SERIAL, manifest)
self.assertIn('"channel": "usb"', manifest)
def test_capture_failure_cleans_staging_and_does_not_publish_partial_output(self) -> None:
device = FakeUiDevice(fail_dump=True)
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: device, timeout_seconds=5)
with tempfile.TemporaryDirectory() as directory:
parent = Path(directory)
output = parent / "baseline"
with self.assertRaises(BaselineCaptureError) as raised:
capturer.capture(SERIAL, output)
self.assertFalse(output.exists())
self.assertEqual(list(parent.iterdir()), [])
self.assertNotIn("mock dump failed", str(raised.exception))
def test_existing_output_is_never_overwritten(self) -> None:
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "baseline"
output.mkdir()
sentinel = output / "keep.txt"
sentinel.write_text("preserve", encoding="utf-8")
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: FakeUiDevice(), timeout_seconds=5)
with self.assertRaises(BaselineCaptureError):
capturer.capture(SERIAL, output)
self.assertEqual(sentinel.read_text(encoding="utf-8"), "preserve")
def test_no_reconnect_connector_passes_only_current_adb_device_object(self) -> None:
class ListedDevice:
serial = SERIAL
listed = ListedDevice()
connected: list[object] = []
connector = NoReconnectUiautomatorConnector(lambda: [listed], lambda device: connected.append(device) or FakeUiDevice())
connector(SERIAL)
self.assertEqual(connected, [listed])
def test_no_reconnect_connector_refuses_disappeared_serial(self) -> None:
connector = NoReconnectUiautomatorConnector(lambda: [], lambda device: FakeUiDevice())
with self.assertRaises(BaselineCaptureError) as raised:
connector(SERIAL)
self.assertIn("拒绝自动重连", str(raised.exception))
def test_connector_exception_is_redacted_and_publishes_no_partial_output(self) -> None:
def failing_connector(serial: str) -> FakeUiDevice:
raise RuntimeError(f"third party leaked {serial}")
capturer = DeviceBaselineCapturer(StaticAdbClient(), failing_connector, timeout_seconds=5)
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "baseline"
with self.assertRaises(BaselineCaptureError) as raised:
capturer.capture(SERIAL, output)
self.assertNotIn(SERIAL, str(raised.exception))
self.assertFalse(output.exists())
self.assertEqual(list(Path(directory).iterdir()), [])
def test_invalid_screenshot_base64_syntax_fails_closed_without_partial_output(self) -> None:
class InvalidScreenshotDevice(FakeUiDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "takeScreenshot":
valid = super().jsonrpc_call(method, params, timeout)
return valid[:12] + "!" + valid[12:]
return super().jsonrpc_call(method, params, timeout)
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: InvalidScreenshotDevice(), timeout_seconds=5)
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "baseline"
with self.assertRaises(BaselineCaptureError) as raised:
capturer.capture(SERIAL, output)
self.assertIn("Base64 语法无效", str(raised.exception))
self.assertNotIn("!", str(raised.exception))
self.assertFalse(output.exists())
self.assertEqual(list(Path(directory).iterdir()), [])
def test_invalid_padding_and_unapproved_ascii_whitespace_fail_closed(self) -> None:
invalid_insertions = {
"padding": lambda value: value[:-1],
"vertical-tab": lambda value: value[:12] + "\v" + value[12:],
"form-feed": lambda value: value[:12] + "\f" + value[12:],
}
for name, make_invalid in invalid_insertions.items():
with self.subTest(name=name), tempfile.TemporaryDirectory() as directory:
class InvalidScreenshotDevice(FakeUiDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
value = super().jsonrpc_call(method, params, timeout)
if method == "takeScreenshot":
return make_invalid(value)
return value
output = Path(directory) / "baseline"
capturer = DeviceBaselineCapturer(
StaticAdbClient(),
lambda serial: InvalidScreenshotDevice(),
timeout_seconds=5,
)
with self.assertRaises(BaselineCaptureError) as raised:
capturer.capture(SERIAL, output)
self.assertIn("Base64 语法无效", str(raised.exception))
self.assertNotIn(SERIAL, str(raised.exception))
self.assertFalse(output.exists())
self.assertEqual(list(Path(directory).iterdir()), [])
def test_base64_decoded_nonimage_fails_closed_without_partial_output(self) -> None:
class NonImageScreenshotDevice(FakeUiDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "takeScreenshot":
return base64.b64encode(b"not an image").decode("ascii")
return super().jsonrpc_call(method, params, timeout)
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: NonImageScreenshotDevice(), timeout_seconds=5)
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "baseline"
with self.assertRaises(BaselineCaptureError) as raised:
capturer.capture(SERIAL, output)
self.assertIn("图像数据无效", str(raised.exception))
self.assertNotIn("not an image", str(raised.exception))
self.assertFalse(output.exists())
self.assertEqual(list(Path(directory).iterdir()), [])
def test_ascii_base64_whitespace_is_normalized_before_strict_decode(self) -> None:
class WhitespaceScreenshotDevice(FakeUiDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
value = super().jsonrpc_call(method, params, timeout)
if method == "takeScreenshot":
return value[:10] + " \t\r\n" + value[10:30] + "\n" + value[30:]
return value
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: WhitespaceScreenshotDevice(), timeout_seconds=5)
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "baseline"
result = capturer.capture(SERIAL, output)
self.assertTrue(result.screenshot_path.is_file())
with Image.open(result.screenshot_path) as image:
self.assertEqual(image.size, (1, 1))
def test_invalid_or_non_hierarchy_xml_fails_closed_without_partial_output(self) -> None:
class InvalidHierarchyDevice(FakeUiDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "dumpWindowHierarchy":
return "<not-hierarchy/>"
return super().jsonrpc_call(method, params, timeout)
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: InvalidHierarchyDevice(), timeout_seconds=5)
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "baseline"
with self.assertRaises(BaselineCaptureError) as raised:
capturer.capture(SERIAL, output)
self.assertNotIn("not-hierarchy", str(raised.exception))
self.assertFalse(output.exists())
self.assertEqual(list(Path(directory).iterdir()), [])
def test_rpc_timeout_is_distinct_redacted_and_does_not_publish_partial_output(self) -> None:
class TimeoutRpcDevice(FakeUiDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
raise HTTPTimeoutError(f"raw serial={SERIAL} xml=<hierarchy/>")
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: TimeoutRpcDevice(), timeout_seconds=5)
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "baseline"
with self.assertRaises(BaselineCaptureTimeoutError) as raised:
capturer.capture(SERIAL, output)
self.assertIn("超时", str(raised.exception))
self.assertNotIn(SERIAL, str(raised.exception))
self.assertNotIn("hierarchy", str(raised.exception))
self.assertFalse(output.exists())
self.assertEqual(list(Path(directory).iterdir()), [])
def test_cli_validation_rejects_empty_serial_and_nonpositive_timeout(self) -> None:
empty_serial = parse_arguments(["--serial", "", "--output-dir", "baseline"])
with self.assertRaisesRegex(ValueError, "非空 --serial"):
validate_arguments(empty_serial)
nonpositive_timeout = parse_arguments(["--serial", SERIAL, "--output-dir", "baseline", "--timeout", "0"])
with self.assertRaisesRegex(ValueError, "必须大于 0"):
validate_arguments(nonpositive_timeout)
+24
View File
@@ -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))
+49
View File
@@ -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)
+25
View File
@@ -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())
+29
View File
@@ -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
View File
@@ -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。若命令当前不可运行,必须在回复里如实说明原因。
+57 -8
View File
@@ -59,16 +59,65 @@
## 五、构建与运行命令
> **占位符。** 代码尚未初始化,以下命令分别在 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` |
### T-101 设备基线取证(2026-08-04 已完成人工双通道验收)
`client/scripts/capture_device_baseline.py` 只允许对**手工明确填写**的 ADB serial 做连接前核验、
设备型号 / Android / 拼多多版本读取、截图和 `dump_hierarchy(compressed=False)`。它不打开商品、
不读取页面判据,也不执行采购、下单或付款动作。多个在线通道必须完成 `getprop` 物理身份比对:
同一手机 USB + WiFi 同时在线,或任一在线通道的身份读取失败,都会 fail closed,不能随机继续。
人工验收前,先由人把手机切换到不含收货地址、手机号、支付信息或其他无关隐私的安全页面,再在
`adb devices -l` 中**手工复制**一个在线 serial;USB 和 WiFi 分别验收,且每次只保留一个通道在线。
WiFi 通道必须由人先行建立;脚本禁止 `adb connect`、`adb disconnect` 或自动重连。以下命令中的尖括号
必须替换为该次人工确认的实际 serial,不能省略或改成自动选择:
```powershell
# 仓库根目录;先手工确认设备状态,命令本身只读 ADB 清单
D:\Portable\adb\adb.exe devices -l
# USB:粘贴该次 devices -l 显示的 USB serial
.\client\.venv\Scripts\python.exe client\scripts\capture_device_baseline.py --serial <USB_SERIAL> --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-101\usb-baseline" --timeout 10 --adb D:\Portable\adb\adb.exe
# WiFi:由人先建立 WiFi ADB 通道、断开 USB 后,粘贴该次 devices -l 显示的 WiFi serial
.\client\.venv\Scripts\python.exe client\scripts\capture_device_baseline.py --serial <WIFI_SERIAL> --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-101\wifi-baseline" --timeout 10 --adb D:\Portable\adb\adb.exe
```
成功时输出目录仅包含截图、完整 XML 与不含页面正文的 `manifest.json`(设备元数据、通道、时间、
文件 SHA-256 和 serial 哈希)。`--timeout` 约束 ADB 命令、ADB socket 及 `takeScreenshot` /
`dumpWindowHierarchy(compressed=False, max_depth=50)` 的公开 JSON-RPC 调用;uiautomator2 初始化仍有
上游固定启动上限。截图 Base64 仅兼容 RPC 返回值中的空格、TAB、CR、LF,其余字符
仍严格拒绝。XML 仅留在本机明确指定的证据目录;人工必须先在本地检查截图/XML,再只记录路径
和哈希,不得把原始证据提交 Git。USB 与 WiFi 已分别由人完成取证和隐私检查,设备型号、Android、
拼多多版本、产物路径及 SHA-256 已记录到 T-101;上述命令保留用于可审计的复现与排障。
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 中最高的合规版本;本机 Python 3.12 与 3.14 均已验证,主工作区当前选择 Python
3.14。桌面 GUI 与真机流程不属于 T-003 验收范围。
Windows PowerShell 差异:
@@ -81,7 +130,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 路径 | 人工结论 + 证据文件路径 |
+1 -1
View File
@@ -386,7 +386,7 @@ PENDING_RETRIAL ─────────────┴─claim→ CLAIMED
| 同一商品两趟结果不一致 | 第二趟价格变了、规格选项变了或商品下架 | 闸门二拦截;一律转人工,不自动放弃也不自动继续 |
| 图搜结果含跨类目商品(V2) | 搜服装出现纸巾 | B 路径只产 goods_id 且限 5 个;后续用 VLM 看截图筛同款 |
| WiFi ADB 稳定性 | 息屏、换网、DHCP 续租会断连 | 超时可配置;断连视为技术失败并保留现场,不重试点击 |
| 同一手机 USB + WiFi 同时在线 | `adb devices` 列出两条,自动选设备会失败 | 设备档案必须显式指定 serial,不允许留空自动选 |
| 同一手机 USB + WiFi 同时在线 | `adb devices` 列出两条,自动选设备会失败 | serial 必填;多在线通道必须读到 `ro.serialno` 或 `ro.boot.serialno` 才能比对。身份一致或任一身份读取失败时都 fail closed,不能以相同 model/product 猜测后继续 |
| 不可逆动作的重试 | 点击「现在买」后超时,无法判断订单是否已创建 | 一律转人工并预留金额额度,**禁止自动重试点击** |
| 双端契约漂移 | 两端独立演进会静默不兼容 | 契约改动必跑完整门禁;[api.md](api.md) 是唯一权威 |
+21
View File
@@ -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 · 地基
+81 -35
View File
@@ -12,65 +12,111 @@
## 当前快照
- 日期: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 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
连接边界与本地基线取证 CLI;尚无真机采购流程
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 30 项离线单元测试
(全部 mock,不连接真机)
- 数据: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-101 已完成人工 USB/WiFi 双通道验收;下一步落成并领取 T-102,
验证按链接打开商品详情页。桌面 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 已完成;下一任务为 T-102 |
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 |
| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、显式 serial 的设备基线取证、离线测试与 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(真机环境盘点与 USB/WiFi 双通道人工验收)。下一步推进
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 与 3.14 均已验证离线测试、编译与 wheel 元数据;完整运行时依赖安装
(`pip install -e .`)已通过。`init.ps1` 优先使用合规既有 venv,缺失时自动选择最高的 Python 3.11+;
本机 Python 3.12 与 3.14 均已验证,主工作区当前选择 Python 3.14。桌面 GUI 与真机流程未作为 T-003
验收执行。
T-101 的人工真机验收命令(先把手机切到不含收货地址、手机号、支付信息或其他无关隐私的安全页面;
必须从 `adb devices -l` 手工复制在线 serial,不能留空或自动选择):
```powershell
# 仓库根目录;USB 和 WiFi 分开执行,每次只保留一个通道在线
D:\Portable\adb\adb.exe devices -l
.\client\.venv\Scripts\python.exe client\scripts\capture_device_baseline.py --serial <USB_SERIAL> --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-101\usb-baseline" --timeout 10 --adb D:\Portable\adb\adb.exe
# WiFi 必须由人先建立通道、断开 USB 后再手工粘贴在线 WiFi serial;脚本不自动 connect/reconnect
.\client\.venv\Scripts\python.exe client\scripts\capture_device_baseline.py --serial <WIFI_SERIAL> --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-101\wifi-baseline" --timeout 10 --adb D:\Portable\adb\adb.exe
```
脚本只读取非敏感设备元数据、拼多多版本、截图和完整 XML;不会打开商品或写页面判据。它对同机双
通道、身份读取失败、offline、unauthorized、超时或 serial 不存在均 fail closed。成功目录的
`manifest.json` 只记录元数据、产物路径和 SHA-256,不记录 XML 页面正文或原始 serial。T-101 已由人
完成两次验收并确认原始证据不含敏感信息;`--timeout` 约束 ADB 命令、ADB socket 以及截图/节点树的
公开 JSON-RPC 调用,uiautomator2 初始化仍有上游固定启动上限。截图/XML 只保留在本地,执行记录只写
路径和 SHA-256,原始证据不得提交 Git。
## 关键背景
本项目是 `cmroubao`(Go 后端 + Android AccessibilityService)与 `cmpdd`
+61
View File
@@ -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 命令。
+65
View File
@@ -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;凭据和敏感信息不得进入日志。
+66
View File
@@ -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` 第四节安全边界,入口脚本不得触发任何业务动作。
+71
View File
@@ -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。
+70
View File
@@ -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 所有者写入。
+97
View File
@@ -0,0 +1,97 @@
---
id: T-101
title: 验证 ADB 与 uiautomator2 双通道连接
phase: 1
deps: [T-002]
status: DONE
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/requirements.txt
- 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-04T01:02:36Z sha256=b6458effd879f2e258c861b1b7b99d1162fdbea586e3fa467d3a65178c900453 -->
## 问题 / 背景
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。
## 执行记录
### 2026-08-03T10:56:19Z · ila
T-101 已领取,Git 状态将提交为 DOING。只读环境盘点:adb 位于 D:\Portable\adb\adb.exe;当前同时列出 WiFi serial 192.168.0.173:5555 与 USB serial 3B65BD02H7F00000,二者 product/model/device 均为 PKG110/PKG110/OP5D2BL1。该现场必须由实现识别为同机双通道并 fail closed。尚未截图、dump 或操作手机,本记录不是人工真机验收;needs_device 规则继续生效。
### 2026-08-03T11:27:08Z · ila
2026-08-03 离线实现与主审完成:新增显式 serial 的 ADB 边界、USB/WiFi 同机多通道 fail-closed、禁止 uiautomator2 隐式重连的连接器,以及只读基线 CLI(型号、Android、拼多多版本、截图、compressed=false XML、manifest SHA-256)。主 agent 三轮审查后补齐:截图/XML JSON-RPC 可配置超时、adbutils/uiautomator2 类型化超时、完整硬件身份集合比对、非法截图/XML fail-closed、异常脱敏、暂存清理,以及人工安全页面/原始证据不入 Git 的操作要求。独立验证:init.ps1 通过;admin go test/vet/build 通过;client 27 项 mock 单测、compileall、wheel METADATA、validator、bash -n 与 diff check 通过。未连接或操作真机,T-101 继续保持 DOING;等待人分别完成 USB 与 WiFi 取证并记录 manifest 中的设备型号、Android、拼多多版本、路径与 SHA-256。已知边界:ADB/ADB socket/截图与节点树 RPC 超时可配置,uiautomator2 初始化仍受上游固定启动上限约束。
### 2026-08-04T00:36:03Z · ila
2026-08-04 人工进展:用户确认 WiFi ADB 已连接,serial 为 192.168.0.173:5555。主 agent 随后仅执行 `adb devices -l` 复核,WiFi 与 USB 3B65BD02H7F00000 当前均为 device,product/model/device 均显示 PKG110/PKG110/OP5D2BL1;本机尚无 `%LOCALAPPDATA%\cmbuyer\artifacts\T-101` 取证产物。该结果只证明 WiFi 通道在线,不是 T-101 完整人工验收。按 fail-closed 边界,人工进行 WiFi 取证前须先断开 USB,只保留 WiFi;USB 取证时须断开 WiFi,只保留 USB。仍等待两次截图、compressed=false XML 与 manifest 路径/SHA-256,任务保持 DOING。
### 2026-08-04T00:43:38Z · ila
2026-08-04 人工 WiFi 取证失败:用户在仅 WiFi serial 192.168.0.173:5555 在线时运行基线 CLI,工具返回“截图 RPC 返回的数据不是有效图像,拒绝发布不完整取证”。主 agent 只读复核:ADB 仅列出该 WiFi 通道为 device,T-101 产物目录为空,说明暂存清理与 fail-closed 生效。核对本机 uiautomator2 3.7.0 源码后发现其高层 screenshot 使用允许 Base64 空白的 `base64.b64decode`,而当前实现直接 `validate=True`,可能错误拒绝真实 RPC 返回中的 CR/LF/空格。已退回 client agent 修复为“只规范化 ASCII Base64 空白,再严格 validate=True;其他非法字符与非图像继续拒绝”,并补回归测试。该失败不是验收通过,T-101 继续 DOING;修复、主审、提交后由人重新执行 WiFi 取证。
### 2026-08-04T00:49:31Z · ila
2026-08-04 WiFi 截图兼容性修复已完成主审:实现只删除 RPC Base64 返回值中的空格、TAB、CR、LF,再使用 `validate=True` 严格解码;非法字符、错误 padding、vertical-tab、form-feed、data URI 与解码后非图像仍 fail closed,不增加 adb screenshot fallback。错误安全地区分为“Base64 语法无效”和“图像数据无效”,均不输出原始内容,失败继续清理暂存目录。新增真实 PNG Base64 空白兼容、非法字符、错误 padding、未白名单空白及非图像测试;client 共 30 项离线测试。主 agent 独立复核并运行 client 测试/compileall、admin test/vet/build、完整 init.ps1、validator、bash 语法与 diff check,均通过;Python 3.12 和主工作区 3.14 均已验证。未由 agent 连接或截图真机,T-101 保持 DOING,等待人工重新执行 WiFi 取证确认现场问题是否解决。
### 2026-08-04T00:55:14Z · ila
2026-08-04 WiFi 人工取证技术验收通过:用户报告基线命令成功,manifest 位于 `%LOCALAPPDATA%\cmbuyer\artifacts\T-101\wifi-baseline\manifest.json`。主 agent 仅读取 manifest 元数据并计算文件哈希,未打开或展示截图/XML 内容。manifest schema=1、channel=wifi、设备型号 PKG110、Android 16、拼多多包 com.xunmeng.pinduoduo、版本 8.17.0;serial 仅保存 64 位 SHA-256。`screenshot.png` 存在,SHA-256=`8f86c94700d93380805b275469123a31749d592d99c0c4d2bcfd540784d1620b`;`hierarchy.xml` 存在,SHA-256=`708182200e25085c07cbb546c9b0adbc7310022bb75a7285c5581bd262661573`;两者均与 manifest 一致。仍等待人工确认原始截图/XML 不含敏感信息,并完成仅 USB 通道的同等取证;T-101 保持 DOING。
### 2026-08-04T01:02:03Z · ila
2026-08-04 USB 人工取证与隐私验收通过:用户报告仅 USB 通道基线命令成功,manifest 位于 `%LOCALAPPDATA%\cmbuyer\artifacts\T-101\usb-baseline\manifest.json`。主 agent 仅读取 manifest 元数据并计算哈希,未打开或展示截图/XML:schema=1、channel=usb、设备 PKG110、Android 16、拼多多 8.17.0;`screenshot.png` SHA-256=`da66e27ef6df1d4b99a64590e9aa383069c8c3995eb9b3695e5daec3a0e7bcf6`,`hierarchy.xml` SHA-256=`e0817f26c07e6861f2ab80052995aff2622ce7cdb61f18689d55c5591bb2045a`,均与 manifest 一致。用户随后明确确认 USB 与 WiFi 两组截图/XML 均不含地址、手机号、支付信息或其他无关隐私。至此 USB/WiFi 显式 serial、截图、compressed=false XML、设备/Android/拼多多版本、路径和 SHA-256 的人工验收全部满足;T-101 可关闭,后续进入 T-102。
<!-- 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`。
+129 -64
View File
@@ -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 "真机连接与设备验收只能由人工完成;本入口不执行任何采购、下单或付款动作。"
+71 -52
View File
@@ -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 或真机验收。"