Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d38cfb61af | ||
|
|
cea27ff7ef | ||
|
|
1c35155c2d | ||
|
|
babb530b99 | ||
|
|
55d9e09bda | ||
|
|
47c0844f9c | ||
|
|
88d8f77417 | ||
|
|
f7efa4a161 | ||
|
|
ef1ac60ac5 | ||
|
|
5332d67b5e | ||
|
|
5f6b3ce01a | ||
|
|
824a628733 | ||
|
|
1e69d274b8 | ||
|
|
8ee26be95a | ||
|
|
80ed9b71e8 | ||
|
|
3f2e0e5598 | ||
|
|
a45a1b5afc | ||
|
|
d27fda6be3 | ||
|
|
acf7e11114 | ||
|
|
946b064470 | ||
|
|
44c027a18f | ||
|
|
cf3692112c | ||
|
|
dbb69a7d5e | ||
|
|
3d063b9785 | ||
|
|
932ca8c7f3 | ||
|
|
9b2eb74478 | ||
|
|
17e295e99d | ||
|
|
d80e4393b4 | ||
|
|
82f57c7233 | ||
|
|
d81fc722dc | ||
|
|
abeefbbea8 | ||
|
|
b66417d80b | ||
|
|
5b56161fd7 | ||
|
|
a92b8f6be0 | ||
|
|
ec8257ba8b | ||
|
|
27999c8c85 | ||
|
|
1dc83086a0 | ||
|
|
ad55e77bda | ||
|
|
652eca7953 | ||
|
|
cb646b4974 | ||
|
|
9452debf66 | ||
|
|
4adeb1b37f | ||
|
|
2ea28c2626 | ||
|
|
7040bb61d8 | ||
|
|
e7a4be1b9b | ||
|
|
4281b06711 | ||
|
|
393f26de53 | ||
|
|
71c66a074a | ||
|
|
7905fa0b70 | ||
|
|
c4cf19fd55 | ||
|
|
de8187eb5b | ||
|
|
f3294633c2 | ||
|
|
0fbf66836b | ||
|
|
4f4a95a55e | ||
|
|
637c341b95 | ||
|
|
87591e84f9 | ||
|
|
8be6d206dd |
@@ -0,0 +1,25 @@
|
||||
# 采购服务
|
||||
|
||||
启动前必须显式设置下列环境变量;服务不提供默认管理员账号、密码或会话密钥。
|
||||
|
||||
| 变量 | 要求 |
|
||||
| --- | --- |
|
||||
| `CMBUYER_ADMIN_USERNAME` | 非空管理员账号。 |
|
||||
| `CMBUYER_ADMIN_PASSWORD_BCRYPT` | 非空 bcrypt 密码哈希,不接受明文密码。 |
|
||||
| `CMBUYER_SESSION_SECRET` | 至少 32 字节的会话签名密钥。 |
|
||||
| `CMBUYER_COOKIE_SECURE` | 可选;存在时只能精确为 `true` 或 `false`。HTTPS 部署应设为 `true`。 |
|
||||
| `CMBUYER_DATABASE_SOURCE` | 已迁移 SQLite 的显式 data source。 |
|
||||
|
||||
示例仅展示变量名,不提供可运行凭据:
|
||||
|
||||
```powershell
|
||||
$env:CMBUYER_ADMIN_USERNAME = '<管理员账号>'
|
||||
$env:CMBUYER_ADMIN_PASSWORD_BCRYPT = '<bcrypt 密码哈希>'
|
||||
$env:CMBUYER_SESSION_SECRET = '<至少 32 字节的随机密钥>'
|
||||
$env:CMBUYER_COOKIE_SECURE = 'true'
|
||||
$env:CMBUYER_DATABASE_SOURCE = '<SQLite data source>'
|
||||
go run ./cmd/migrate -database $env:CMBUYER_DATABASE_SOURCE up
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
采购服务会话仅保存在当前进程内;进程重启后既有登录会话会安全失效。
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"cmbuyer/admin/internal/migrations"
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(context.Background(), os.Args[1:], os.Stderr); err != nil {
|
||||
log.Print(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string, stderr io.Writer) error {
|
||||
flags := flag.NewFlagSet("migrate", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
databaseSource := flags.String("database", "", "SQLite data source")
|
||||
migrationDirectory := flags.String("dir", "migrations", "migration directory")
|
||||
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *databaseSource == "" {
|
||||
return errors.New("-database is required")
|
||||
}
|
||||
if flags.NArg() != 1 {
|
||||
return fmt.Errorf("usage: migrate -database <sqlite-data-source> [-dir <migration-directory>] <up|down|status>")
|
||||
}
|
||||
command := flags.Arg(0)
|
||||
if command != "up" && command != "down" && command != "status" {
|
||||
return fmt.Errorf("unsupported migration command %q", command)
|
||||
}
|
||||
|
||||
database, err := sqlite.Open(*databaseSource)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open SQLite database: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := database.Close(); err != nil {
|
||||
log.Printf("close SQLite database: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := migrations.Run(ctx, database, *migrationDirectory, command); err != nil {
|
||||
return fmt.Errorf("run migrations: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunUp(t *testing.T) {
|
||||
databaseSource := filepath.Join(t.TempDir(), "migrate.db")
|
||||
if err := run(context.Background(), []string{
|
||||
"-database", databaseSource,
|
||||
"-dir", migrationDirectory(t),
|
||||
"up",
|
||||
}, io.Discard); err != nil {
|
||||
t.Fatalf("run up migration command: %v", err)
|
||||
}
|
||||
|
||||
database, err := sql.Open("sqlite3", databaseSource)
|
||||
if err != nil {
|
||||
t.Fatalf("open migrated database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := database.Close(); err != nil {
|
||||
t.Errorf("close migrated database: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
var count int
|
||||
if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'tasks'`).Scan(&count); err != nil {
|
||||
t.Fatalf("look up tasks table: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("tasks table count = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRequiresDatabase(t *testing.T) {
|
||||
if err := run(context.Background(), []string{"up"}, io.Discard); err == nil {
|
||||
t.Fatal("run without database source succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsUndeclaredCommand(t *testing.T) {
|
||||
databaseSource := filepath.Join(t.TempDir(), "migrate.db")
|
||||
err := run(context.Background(), []string{"-database", databaseSource, "reset"}, io.Discard)
|
||||
if err == nil {
|
||||
t.Fatal("run with undeclared command succeeded")
|
||||
}
|
||||
if _, err := os.Stat(databaseSource); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("undeclared command opened database source: stat error = %v, want not exist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func migrationDirectory(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("locate migration command test source")
|
||||
}
|
||||
|
||||
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
|
||||
}
|
||||
@@ -5,7 +5,11 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"cmbuyer/admin/internal/auth"
|
||||
"cmbuyer/admin/internal/config"
|
||||
"cmbuyer/admin/internal/server"
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
)
|
||||
|
||||
const listenAddress = ":8080"
|
||||
@@ -17,7 +21,31 @@ func main() {
|
||||
}
|
||||
|
||||
func run() error {
|
||||
err := http.ListenAndServe(listenAddress, server.NewRouter())
|
||||
configuration, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
database, err := sqlite.Open(configuration.DatabaseSource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer database.Close()
|
||||
taskStore, err := tasks.NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(server.Options{
|
||||
AdminUsername: configuration.AdminUsername,
|
||||
AdminPasswordBcrypt: configuration.AdminPasswordBcrypt,
|
||||
Sessions: auth.NewManager(configuration.SessionSecret, configuration.CookieSecure),
|
||||
Tasks: taskStore,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = http.ListenAndServe(listenAddress, router)
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
|
||||
+5
-1
@@ -5,6 +5,8 @@ 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
|
||||
golang.org/x/crypto v0.40.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -22,16 +24,18 @@ require (
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
|
||||
@@ -7,6 +7,8 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
@@ -28,6 +30,10 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
@@ -38,18 +44,28 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
|
||||
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.24.0 h1:sFbNms7Bd++2VMq6HSgDHDLWa7kHz1qXzPb3ZIU72VU=
|
||||
github.com/pressly/goose/v3 v3.24.0/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -65,6 +81,8 @@ github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
@@ -88,3 +106,17 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||
modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk=
|
||||
modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// Package auth 提供内存会话与 CSRF 防护。会话不落库,服务重启会安全地使所有登录失效。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
CookieName = "cmbuyer_session"
|
||||
SessionLifetime = 8 * time.Hour
|
||||
csrfTokenByteSize = 32
|
||||
)
|
||||
|
||||
type session struct {
|
||||
csrfToken string
|
||||
authenticated bool
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// Manager 签发、验证并撤销进程内会话。cookie 仅承载经过 HMAC 签名的随机 session ID。
|
||||
type Manager struct {
|
||||
secret []byte
|
||||
cookieSecure bool
|
||||
now func() time.Time
|
||||
random io.Reader
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]session
|
||||
}
|
||||
|
||||
// NewManager 创建会话管理器。secret 在启动时已由 config 验证为足够长度。
|
||||
func NewManager(secret []byte, cookieSecure bool) *Manager {
|
||||
return &Manager{
|
||||
secret: append([]byte(nil), secret...),
|
||||
cookieSecure: cookieSecure,
|
||||
now: time.Now,
|
||||
random: rand.Reader,
|
||||
sessions: make(map[string]session),
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure 返回当前有效会话;不存在或过期时签发匿名会话,以保护登录表单本身的 POST。
|
||||
func (manager *Manager) Ensure(writer http.ResponseWriter, request *http.Request) (csrfToken string, authenticated bool) {
|
||||
if id, current, ok := manager.current(request); ok {
|
||||
return current.csrfToken, current.authenticated
|
||||
} else if id != "" {
|
||||
manager.delete(id)
|
||||
}
|
||||
|
||||
id, current := manager.create(false)
|
||||
manager.writeCookie(writer, id, current.expiresAt)
|
||||
return current.csrfToken, false
|
||||
}
|
||||
|
||||
// VerifyCSRF 只接受当前未过期会话中以恒定时间比较匹配的 token。
|
||||
func (manager *Manager) VerifyCSRF(request *http.Request, token string) (authenticated bool, ok bool) {
|
||||
_, current, found := manager.current(request)
|
||||
if !found || token == "" {
|
||||
return false, false
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(current.csrfToken), []byte(token)) != 1 {
|
||||
return false, false
|
||||
}
|
||||
|
||||
return current.authenticated, true
|
||||
}
|
||||
|
||||
// RotateAuthenticated 在登录成功后撤销旧会话并签发全新认证会话,避免 session fixation 与 CSRF 复用。
|
||||
func (manager *Manager) RotateAuthenticated(writer http.ResponseWriter, request *http.Request) string {
|
||||
if id, _, ok := manager.current(request); ok {
|
||||
manager.delete(id)
|
||||
}
|
||||
|
||||
id, current := manager.create(true)
|
||||
manager.writeCookie(writer, id, current.expiresAt)
|
||||
return current.csrfToken
|
||||
}
|
||||
|
||||
// Logout 撤销当前会话并立即清除浏览器 cookie。
|
||||
func (manager *Manager) Logout(writer http.ResponseWriter, request *http.Request) {
|
||||
if id, _, ok := manager.current(request); ok {
|
||||
manager.delete(id)
|
||||
}
|
||||
http.SetCookie(writer, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: manager.cookieSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *Manager) current(request *http.Request) (string, session, bool) {
|
||||
cookie, err := request.Cookie(CookieName)
|
||||
if err != nil {
|
||||
return "", session{}, false
|
||||
}
|
||||
|
||||
id, expiresAt, ok := manager.verifyCookie(cookie.Value)
|
||||
if !ok || !manager.now().Before(expiresAt) {
|
||||
return id, session{}, false
|
||||
}
|
||||
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
current, found := manager.sessions[id]
|
||||
if !found || !manager.now().Before(current.expiresAt) {
|
||||
return id, session{}, false
|
||||
}
|
||||
|
||||
return id, current, true
|
||||
}
|
||||
|
||||
func (manager *Manager) create(authenticated bool) (string, session) {
|
||||
id := manager.randomToken()
|
||||
current := session{
|
||||
csrfToken: manager.randomToken(),
|
||||
authenticated: authenticated,
|
||||
expiresAt: manager.now().Add(SessionLifetime),
|
||||
}
|
||||
|
||||
manager.mu.Lock()
|
||||
manager.sessions[id] = current
|
||||
manager.mu.Unlock()
|
||||
return id, current
|
||||
}
|
||||
|
||||
func (manager *Manager) delete(id string) {
|
||||
manager.mu.Lock()
|
||||
delete(manager.sessions, id)
|
||||
manager.mu.Unlock()
|
||||
}
|
||||
|
||||
func (manager *Manager) randomToken() string {
|
||||
bytes := make([]byte, csrfTokenByteSize)
|
||||
if _, err := io.ReadFull(manager.random, bytes); err != nil {
|
||||
panic("crypto/rand failed while creating a session token")
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
func (manager *Manager) writeCookie(writer http.ResponseWriter, id string, expiresAt time.Time) {
|
||||
http.SetCookie(writer, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: manager.signCookie(id, expiresAt),
|
||||
Path: "/",
|
||||
MaxAge: int(expiresAt.Sub(manager.now()).Seconds()),
|
||||
Expires: expiresAt,
|
||||
HttpOnly: true,
|
||||
Secure: manager.cookieSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *Manager) signCookie(id string, expiresAt time.Time) string {
|
||||
payload := id + "." + strconv.FormatInt(expiresAt.Unix(), 10)
|
||||
mac := hmac.New(sha256.New, manager.secret)
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
return payload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (manager *Manager) verifyCookie(value string) (string, time.Time, bool) {
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) != 3 || parts[0] == "" {
|
||||
return "", time.Time{}, false
|
||||
}
|
||||
|
||||
expiresUnix, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil {
|
||||
return "", time.Time{}, false
|
||||
}
|
||||
provided, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return "", time.Time{}, false
|
||||
}
|
||||
|
||||
payload := parts[0] + "." + parts[1]
|
||||
mac := hmac.New(sha256.New, manager.secret)
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
if !hmac.Equal(provided, mac.Sum(nil)) {
|
||||
return "", time.Time{}, false
|
||||
}
|
||||
|
||||
return parts[0], time.Unix(expiresUnix, 0), true
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestManagerRejectsTamperedAndExpiredCookies(t *testing.T) {
|
||||
manager := NewManager([]byte(strings.Repeat("s", 32)), true)
|
||||
request := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
response := httptest.NewRecorder()
|
||||
csrf, authenticated := manager.Ensure(response, request)
|
||||
if csrf == "" || authenticated {
|
||||
t.Fatalf("Ensure = (%q, %t), want anonymous CSRF session", csrf, authenticated)
|
||||
}
|
||||
cookie := response.Result().Cookies()[0]
|
||||
if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteLaxMode || cookie.Path != "/" {
|
||||
t.Fatalf("session cookie is missing security attributes: %#v", cookie)
|
||||
}
|
||||
|
||||
tampered := *cookie
|
||||
tampered.Value = flipCookieValue(t, cookie.Value)
|
||||
tamperedRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||
tamperedRequest.AddCookie(&tampered)
|
||||
if _, ok := manager.VerifyCSRF(tamperedRequest, csrf); ok {
|
||||
t.Fatal("tampered signed cookie passed CSRF verification")
|
||||
}
|
||||
|
||||
manager.now = func() time.Time { return time.Now().Add(9 * time.Hour) }
|
||||
expiredRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||
expiredRequest.AddCookie(cookie)
|
||||
if _, ok := manager.VerifyCSRF(expiredRequest, csrf); ok {
|
||||
t.Fatal("expired cookie passed CSRF verification")
|
||||
}
|
||||
}
|
||||
|
||||
func flipCookieValue(t *testing.T, value string) string {
|
||||
t.Helper()
|
||||
if value == "" {
|
||||
t.Fatal("cannot tamper with an empty cookie")
|
||||
}
|
||||
if value[0] == 'A' {
|
||||
return "B" + value[1:]
|
||||
}
|
||||
return "A" + value[1:]
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Package config 读取采购服务的启动配置。凭据只允许来自显式环境变量,避免把秘密写入代码或仓库。
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
adminUsernameEnv = "CMBUYER_ADMIN_USERNAME"
|
||||
adminPasswordBcryptEnv = "CMBUYER_ADMIN_PASSWORD_BCRYPT"
|
||||
sessionSecretEnv = "CMBUYER_SESSION_SECRET"
|
||||
cookieSecureEnv = "CMBUYER_COOKIE_SECURE"
|
||||
databaseSourceEnv = "CMBUYER_DATABASE_SOURCE"
|
||||
minimumSecretLength = 32
|
||||
)
|
||||
|
||||
// Config 是启动采购服务所需的最小安全配置。
|
||||
type Config struct {
|
||||
AdminUsername string
|
||||
AdminPasswordBcrypt string
|
||||
SessionSecret []byte
|
||||
CookieSecure bool
|
||||
DatabaseSource string
|
||||
}
|
||||
|
||||
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
|
||||
func LoadFromEnv() (Config, error) {
|
||||
return Load(os.LookupEnv)
|
||||
}
|
||||
|
||||
// Load 使用 lookup 读取配置,以便在不污染进程环境的情况下测试启动边界。
|
||||
func Load(lookup func(string) (string, bool)) (Config, error) {
|
||||
username, err := required(lookup, adminUsernameEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
passwordHash, err := required(lookup, adminPasswordBcryptEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if _, err := bcrypt.Cost([]byte(passwordHash)); err != nil {
|
||||
return Config{}, fmt.Errorf("%s is not a valid bcrypt hash", adminPasswordBcryptEnv)
|
||||
}
|
||||
|
||||
secret, err := required(lookup, sessionSecretEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if len([]byte(secret)) < minimumSecretLength {
|
||||
return Config{}, fmt.Errorf("%s must be at least %d bytes", sessionSecretEnv, minimumSecretLength)
|
||||
}
|
||||
|
||||
cookieSecure := false
|
||||
if value, present := lookup(cookieSecureEnv); present {
|
||||
switch value {
|
||||
case "true":
|
||||
cookieSecure = true
|
||||
case "false":
|
||||
cookieSecure = false
|
||||
default:
|
||||
return Config{}, fmt.Errorf("%s must be exactly true or false", cookieSecureEnv)
|
||||
}
|
||||
}
|
||||
databaseSource, err := required(lookup, databaseSourceEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return Config{
|
||||
AdminUsername: username,
|
||||
AdminPasswordBcrypt: passwordHash,
|
||||
SessionSecret: []byte(secret),
|
||||
CookieSecure: cookieSecure,
|
||||
DatabaseSource: databaseSource,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func required(lookup func(string) (string, bool), name string) (string, error) {
|
||||
value, present := lookup(name)
|
||||
if !present || strings.TrimSpace(value) == "" {
|
||||
return "", errors.New(name + " must be set")
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/config"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("generate bcrypt hash: %v", err)
|
||||
}
|
||||
|
||||
values := map[string]string{
|
||||
"CMBUYER_ADMIN_USERNAME": "admin",
|
||||
"CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash),
|
||||
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
|
||||
"CMBUYER_COOKIE_SECURE": "true",
|
||||
"CMBUYER_DATABASE_SOURCE": ":memory:",
|
||||
}
|
||||
|
||||
got, err := config.Load(lookup(values))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if got.AdminUsername != "admin" || !got.CookieSecure {
|
||||
t.Fatalf("Load returned unexpected public configuration: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("generate bcrypt hash: %v", err)
|
||||
}
|
||||
|
||||
base := map[string]string{
|
||||
"CMBUYER_ADMIN_USERNAME": "admin",
|
||||
"CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash),
|
||||
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
|
||||
"CMBUYER_DATABASE_SOURCE": ":memory:",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(map[string]string)
|
||||
want string
|
||||
}{
|
||||
{"missing username", func(values map[string]string) { delete(values, "CMBUYER_ADMIN_USERNAME") }, "CMBUYER_ADMIN_USERNAME"},
|
||||
{"invalid bcrypt", func(values map[string]string) { values["CMBUYER_ADMIN_PASSWORD_BCRYPT"] = "not-a-bcrypt-hash" }, "CMBUYER_ADMIN_PASSWORD_BCRYPT"},
|
||||
{"short secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = "short" }, "CMBUYER_SESSION_SECRET"},
|
||||
{"invalid secure flag", func(values map[string]string) { values["CMBUYER_COOKIE_SECURE"] = "1" }, "CMBUYER_COOKIE_SECURE"},
|
||||
{"missing database", func(values map[string]string) { delete(values, "CMBUYER_DATABASE_SOURCE") }, "CMBUYER_DATABASE_SOURCE"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
values := copyValues(base)
|
||||
test.mutate(values)
|
||||
_, err := config.Load(lookup(values))
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Load error = %v, want mention of %s", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func lookup(values map[string]string) func(string) (string, bool) {
|
||||
return func(key string) (string, bool) {
|
||||
value, ok := values[key]
|
||||
return value, ok
|
||||
}
|
||||
}
|
||||
|
||||
func copyValues(values map[string]string) map[string]string {
|
||||
copy := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
copy[key] = value
|
||||
}
|
||||
return copy
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrInvalidAuthorizationTransition = errors.New("invalid authorization status transition")
|
||||
|
||||
type AuthorizationStatus string
|
||||
|
||||
const (
|
||||
AuthorizationStatusPendingDelivery AuthorizationStatus = "PENDING_DELIVERY"
|
||||
AuthorizationStatusDelivered AuthorizationStatus = "DELIVERED"
|
||||
AuthorizationStatusAcknowledged AuthorizationStatus = "ACKNOWLEDGED"
|
||||
AuthorizationStatusExecuting AuthorizationStatus = "EXECUTING"
|
||||
AuthorizationStatusFenced AuthorizationStatus = "FENCED"
|
||||
AuthorizationStatusConsumed AuthorizationStatus = "CONSUMED"
|
||||
AuthorizationStatusSuperseded AuthorizationStatus = "SUPERSEDED"
|
||||
AuthorizationStatusExpired AuthorizationStatus = "EXPIRED"
|
||||
)
|
||||
|
||||
type OrderAuthorization struct {
|
||||
ID string
|
||||
TaskID string
|
||||
SpecTrialID string
|
||||
Version int
|
||||
GoodsID string
|
||||
SKUColor string
|
||||
SKUSize string
|
||||
Quantity int
|
||||
AuthorizedUnitPrice string
|
||||
TotalPriceCap string
|
||||
Note *string
|
||||
Status AuthorizationStatus
|
||||
CreatedBy string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// CanTransitionTo 围栏后的授权只能消费,不能回到可领取或可过期状态,以防重复采购。
|
||||
func (status AuthorizationStatus) CanTransitionTo(next AuthorizationStatus) bool {
|
||||
_, allowed := authorizationTransitions[status][next]
|
||||
return allowed
|
||||
}
|
||||
|
||||
// TransitionAuthorization 返回下一状态;未定义的授权状态转移一律失败。
|
||||
func TransitionAuthorization(current, next AuthorizationStatus) (AuthorizationStatus, error) {
|
||||
if !current.CanTransitionTo(next) {
|
||||
return current, ErrInvalidAuthorizationTransition
|
||||
}
|
||||
|
||||
return next, nil
|
||||
}
|
||||
|
||||
var authorizationTransitions = map[AuthorizationStatus]map[AuthorizationStatus]struct{}{
|
||||
AuthorizationStatusPendingDelivery: {
|
||||
AuthorizationStatusDelivered: {},
|
||||
AuthorizationStatusSuperseded: {},
|
||||
AuthorizationStatusExpired: {},
|
||||
},
|
||||
AuthorizationStatusDelivered: {
|
||||
AuthorizationStatusAcknowledged: {},
|
||||
AuthorizationStatusSuperseded: {},
|
||||
AuthorizationStatusExpired: {},
|
||||
},
|
||||
AuthorizationStatusAcknowledged: {
|
||||
AuthorizationStatusExecuting: {},
|
||||
AuthorizationStatusSuperseded: {},
|
||||
AuthorizationStatusExpired: {},
|
||||
},
|
||||
AuthorizationStatusExecuting: {
|
||||
AuthorizationStatusFenced: {},
|
||||
AuthorizationStatusSuperseded: {},
|
||||
AuthorizationStatusExpired: {},
|
||||
},
|
||||
AuthorizationStatusFenced: {
|
||||
AuthorizationStatusConsumed: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/domain"
|
||||
)
|
||||
|
||||
func TestAuthorizationTransitions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current domain.AuthorizationStatus
|
||||
next domain.AuthorizationStatus
|
||||
allowed bool
|
||||
}{
|
||||
{"deliver", domain.AuthorizationStatusPendingDelivery, domain.AuthorizationStatusDelivered, true},
|
||||
{"acknowledge", domain.AuthorizationStatusDelivered, domain.AuthorizationStatusAcknowledged, true},
|
||||
{"execute", domain.AuthorizationStatusAcknowledged, domain.AuthorizationStatusExecuting, true},
|
||||
{"fence", domain.AuthorizationStatusExecuting, domain.AuthorizationStatusFenced, true},
|
||||
{"consume fenced authorization", domain.AuthorizationStatusFenced, domain.AuthorizationStatusConsumed, true},
|
||||
{"expire pending delivery", domain.AuthorizationStatusPendingDelivery, domain.AuthorizationStatusExpired, true},
|
||||
{"supersede pending delivery", domain.AuthorizationStatusPendingDelivery, domain.AuthorizationStatusSuperseded, true},
|
||||
{"expire before fence", domain.AuthorizationStatusExecuting, domain.AuthorizationStatusExpired, true},
|
||||
{"supersede before fence", domain.AuthorizationStatusDelivered, domain.AuthorizationStatusSuperseded, true},
|
||||
{"fenced authorization cannot expire", domain.AuthorizationStatusFenced, domain.AuthorizationStatusExpired, false},
|
||||
{"fenced authorization cannot be superseded", domain.AuthorizationStatusFenced, domain.AuthorizationStatusSuperseded, false},
|
||||
{"fenced authorization cannot be delivered again", domain.AuthorizationStatusFenced, domain.AuthorizationStatusDelivered, false},
|
||||
{"consumed authorization cannot restart", domain.AuthorizationStatusConsumed, domain.AuthorizationStatusDelivered, false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := test.current.CanTransitionTo(test.next); got != test.allowed {
|
||||
t.Fatalf("CanTransitionTo(%s, %s) = %t, want %t", test.current, test.next, got, test.allowed)
|
||||
}
|
||||
|
||||
result, err := domain.TransitionAuthorization(test.current, test.next)
|
||||
if test.allowed {
|
||||
if err != nil {
|
||||
t.Fatalf("TransitionAuthorization(%s, %s): %v", test.current, test.next, err)
|
||||
}
|
||||
if result != test.next {
|
||||
t.Fatalf("TransitionAuthorization(%s, %s) = %s, want %s", test.current, test.next, result, test.next)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !errors.Is(err, domain.ErrInvalidAuthorizationTransition) {
|
||||
t.Fatalf("TransitionAuthorization(%s, %s) error = %v, want ErrInvalidAuthorizationTransition", test.current, test.next, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type SpecTrial struct {
|
||||
ID string
|
||||
TaskID string
|
||||
Attempt int
|
||||
ProductTitle string
|
||||
SelectedColor string
|
||||
SelectedSize string
|
||||
UnitPrice string
|
||||
TotalPrice string
|
||||
EvidenceSHA256 string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrInvalidSubmissionTransition = errors.New("invalid submission status transition")
|
||||
|
||||
type SubmissionStatus string
|
||||
|
||||
const (
|
||||
SubmissionStatusFenced SubmissionStatus = "FENCED"
|
||||
SubmissionStatusSubmitted SubmissionStatus = "SUBMITTED"
|
||||
SubmissionStatusReconciliationRequired SubmissionStatus = "RECONCILIATION_REQUIRED"
|
||||
SubmissionStatusManualResolved SubmissionStatus = "MANUAL_RESOLVED"
|
||||
)
|
||||
|
||||
type OrderSubmission struct {
|
||||
ID string
|
||||
TaskID string
|
||||
AuthorizationID string
|
||||
CommandID string
|
||||
DryRunID string
|
||||
Status SubmissionStatus
|
||||
VerifiedUnitPrice string
|
||||
QuantityRead int
|
||||
ConfirmPageAmount string
|
||||
CreatedAt time.Time
|
||||
ResolvedAt *time.Time
|
||||
}
|
||||
|
||||
// CanTransitionTo 只允许围栏记录向最终观察结果调和,拒绝回退以防触发第二次真实动作。
|
||||
func (status SubmissionStatus) CanTransitionTo(next SubmissionStatus) bool {
|
||||
_, allowed := submissionTransitions[status][next]
|
||||
return allowed
|
||||
}
|
||||
|
||||
// TransitionSubmission 返回下一状态;未定义的提交记录状态转移一律失败。
|
||||
func TransitionSubmission(current, next SubmissionStatus) (SubmissionStatus, error) {
|
||||
if !current.CanTransitionTo(next) {
|
||||
return current, ErrInvalidSubmissionTransition
|
||||
}
|
||||
|
||||
return next, nil
|
||||
}
|
||||
|
||||
var submissionTransitions = map[SubmissionStatus]map[SubmissionStatus]struct{}{
|
||||
SubmissionStatusFenced: {
|
||||
SubmissionStatusSubmitted: {},
|
||||
SubmissionStatusReconciliationRequired: {},
|
||||
},
|
||||
SubmissionStatusReconciliationRequired: {
|
||||
SubmissionStatusManualResolved: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/domain"
|
||||
)
|
||||
|
||||
func TestSubmissionTransitions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current domain.SubmissionStatus
|
||||
next domain.SubmissionStatus
|
||||
allowed bool
|
||||
}{
|
||||
{"submitted", domain.SubmissionStatusFenced, domain.SubmissionStatusSubmitted, true},
|
||||
{"uncertain requires reconciliation", domain.SubmissionStatusFenced, domain.SubmissionStatusReconciliationRequired, true},
|
||||
{"manual review resolves reconciliation", domain.SubmissionStatusReconciliationRequired, domain.SubmissionStatusManualResolved, true},
|
||||
{"cannot reopen fenced submission", domain.SubmissionStatusSubmitted, domain.SubmissionStatusFenced, false},
|
||||
{"submitted cannot require reconciliation", domain.SubmissionStatusSubmitted, domain.SubmissionStatusReconciliationRequired, false},
|
||||
{"cannot skip reconciliation", domain.SubmissionStatusFenced, domain.SubmissionStatusManualResolved, false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := test.current.CanTransitionTo(test.next); got != test.allowed {
|
||||
t.Fatalf("CanTransitionTo(%s, %s) = %t, want %t", test.current, test.next, got, test.allowed)
|
||||
}
|
||||
|
||||
result, err := domain.TransitionSubmission(test.current, test.next)
|
||||
if test.allowed {
|
||||
if err != nil {
|
||||
t.Fatalf("TransitionSubmission(%s, %s): %v", test.current, test.next, err)
|
||||
}
|
||||
if result != test.next {
|
||||
t.Fatalf("TransitionSubmission(%s, %s) = %s, want %s", test.current, test.next, result, test.next)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !errors.Is(err, domain.ErrInvalidSubmissionTransition) {
|
||||
t.Fatalf("TransitionSubmission(%s, %s) error = %v, want ErrInvalidSubmissionTransition", test.current, test.next, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Package domain 定义采购服务的业务实体与不依赖外部系统的状态规则。
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrInvalidTaskTransition = errors.New("invalid task status transition")
|
||||
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
TaskStatusDraft TaskStatus = "DRAFT"
|
||||
TaskStatusPending TaskStatus = "PENDING"
|
||||
TaskStatusClaimed TaskStatus = "CLAIMED"
|
||||
TaskStatusRunning TaskStatus = "RUNNING"
|
||||
TaskStatusWaitingConfirmation TaskStatus = "WAITING_CONFIRMATION"
|
||||
TaskStatusPendingRetrial TaskStatus = "PENDING_RETRIAL"
|
||||
TaskStatusAuthorized TaskStatus = "AUTHORIZED"
|
||||
TaskStatusOrdering TaskStatus = "ORDERING"
|
||||
TaskStatusWaitingPayment TaskStatus = "WAITING_PAYMENT"
|
||||
TaskStatusReconciliationRequired TaskStatus = "RECONCILIATION_REQUIRED"
|
||||
TaskStatusNeedsManual TaskStatus = "NEEDS_MANUAL"
|
||||
TaskStatusSucceeded TaskStatus = "SUCCEEDED"
|
||||
TaskStatusCanceled TaskStatus = "CANCELED"
|
||||
)
|
||||
|
||||
type Source string
|
||||
|
||||
const (
|
||||
SourceManual Source = "MANUAL"
|
||||
SourceExcel Source = "EXCEL"
|
||||
SourceERP Source = "ERP"
|
||||
)
|
||||
|
||||
type Task struct {
|
||||
ID string
|
||||
Source Source
|
||||
SourceRef *string
|
||||
Title string
|
||||
GoodsID string
|
||||
SKUColor string
|
||||
SKUSize string
|
||||
Quantity int
|
||||
MaxTotalPrice string
|
||||
ReferenceAssetID *string
|
||||
Status TaskStatus
|
||||
Version int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// CanTransitionTo 只接受架构已定义的任务状态转移,未知状态或未列出的动作均拒绝。
|
||||
func (status TaskStatus) CanTransitionTo(next TaskStatus) bool {
|
||||
_, allowed := taskTransitions[status][next]
|
||||
return allowed
|
||||
}
|
||||
|
||||
// TransitionTask 返回下一状态;非法转移必须在写库前失败,不能由调用方猜测补救路径。
|
||||
func TransitionTask(current, next TaskStatus) (TaskStatus, error) {
|
||||
if !current.CanTransitionTo(next) {
|
||||
return current, ErrInvalidTaskTransition
|
||||
}
|
||||
|
||||
return next, nil
|
||||
}
|
||||
|
||||
var taskTransitions = map[TaskStatus]map[TaskStatus]struct{}{
|
||||
TaskStatusDraft: {
|
||||
TaskStatusPending: {},
|
||||
},
|
||||
TaskStatusPending: {
|
||||
TaskStatusClaimed: {},
|
||||
},
|
||||
TaskStatusPendingRetrial: {
|
||||
TaskStatusClaimed: {},
|
||||
},
|
||||
TaskStatusClaimed: {
|
||||
TaskStatusRunning: {},
|
||||
TaskStatusPending: {},
|
||||
},
|
||||
TaskStatusRunning: {
|
||||
TaskStatusWaitingConfirmation: {},
|
||||
TaskStatusNeedsManual: {},
|
||||
},
|
||||
TaskStatusWaitingConfirmation: {
|
||||
TaskStatusCanceled: {},
|
||||
TaskStatusAuthorized: {},
|
||||
},
|
||||
TaskStatusAuthorized: {
|
||||
TaskStatusOrdering: {},
|
||||
},
|
||||
TaskStatusOrdering: {
|
||||
TaskStatusNeedsManual: {},
|
||||
TaskStatusWaitingPayment: {},
|
||||
TaskStatusReconciliationRequired: {},
|
||||
},
|
||||
TaskStatusWaitingPayment: {
|
||||
TaskStatusSucceeded: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/domain"
|
||||
)
|
||||
|
||||
func TestTaskTransitions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
current domain.TaskStatus
|
||||
next domain.TaskStatus
|
||||
allowed bool
|
||||
}{
|
||||
{"start trial", domain.TaskStatusDraft, domain.TaskStatusPending, true},
|
||||
{"claim trial", domain.TaskStatusPending, domain.TaskStatusClaimed, true},
|
||||
{"claim retrial", domain.TaskStatusPendingRetrial, domain.TaskStatusClaimed, true},
|
||||
{"start trial execution", domain.TaskStatusClaimed, domain.TaskStatusRunning, true},
|
||||
{"release unstarted claim", domain.TaskStatusClaimed, domain.TaskStatusPending, true},
|
||||
{"trial completes", domain.TaskStatusRunning, domain.TaskStatusWaitingConfirmation, true},
|
||||
{"trial needs manual review", domain.TaskStatusRunning, domain.TaskStatusNeedsManual, true},
|
||||
{"authorize confirmed trial", domain.TaskStatusWaitingConfirmation, domain.TaskStatusAuthorized, true},
|
||||
{"reject confirmed trial", domain.TaskStatusWaitingConfirmation, domain.TaskStatusCanceled, true},
|
||||
{"start authorized order leg", domain.TaskStatusAuthorized, domain.TaskStatusOrdering, true},
|
||||
{"order reaches payment", domain.TaskStatusOrdering, domain.TaskStatusWaitingPayment, true},
|
||||
{"order needs manual review before fence", domain.TaskStatusOrdering, domain.TaskStatusNeedsManual, true},
|
||||
{"order needs reconciliation", domain.TaskStatusOrdering, domain.TaskStatusReconciliationRequired, true},
|
||||
{"payment verified", domain.TaskStatusWaitingPayment, domain.TaskStatusSucceeded, true},
|
||||
{"cannot skip trial", domain.TaskStatusDraft, domain.TaskStatusAuthorized, false},
|
||||
{"trial cannot enter order leg", domain.TaskStatusRunning, domain.TaskStatusOrdering, false},
|
||||
{"terminal task cannot restart", domain.TaskStatusSucceeded, domain.TaskStatusPending, false},
|
||||
{"unknown status is rejected", domain.TaskStatus("UNKNOWN"), domain.TaskStatusPending, false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := test.current.CanTransitionTo(test.next); got != test.allowed {
|
||||
t.Fatalf("CanTransitionTo(%s, %s) = %t, want %t", test.current, test.next, got, test.allowed)
|
||||
}
|
||||
|
||||
result, err := domain.TransitionTask(test.current, test.next)
|
||||
if test.allowed {
|
||||
if err != nil {
|
||||
t.Fatalf("TransitionTask(%s, %s): %v", test.current, test.next, err)
|
||||
}
|
||||
if result != test.next {
|
||||
t.Fatalf("TransitionTask(%s, %s) = %s, want %s", test.current, test.next, result, test.next)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !errors.Is(err, domain.ErrInvalidTaskTransition) {
|
||||
t.Fatalf("TransitionTask(%s, %s) error = %v, want ErrInvalidTaskTransition", test.current, test.next, err)
|
||||
}
|
||||
if result != test.current {
|
||||
t.Fatalf("TransitionTask(%s, %s) result = %s, want unchanged status", test.current, test.next, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Package migrations 通过 goose 执行采购服务的版本化数据库迁移。
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
// Run 执行指定的 goose 命令。迁移目录由调用方显式传入,避免把运行目录当作隐式配置。
|
||||
func Run(ctx context.Context, database *sql.DB, directory, command string) error {
|
||||
if err := goose.SetDialect("sqlite3"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return goose.RunContext(ctx, command, database, directory)
|
||||
}
|
||||
|
||||
// Up 将数据库迁移到当前版本。
|
||||
func Up(ctx context.Context, database *sql.DB, directory string) error {
|
||||
return Run(ctx, database, directory, "up")
|
||||
}
|
||||
|
||||
// Down 回退一个已应用的迁移版本。
|
||||
func Down(ctx context.Context, database *sql.DB, directory string) error {
|
||||
return Run(ctx, database, directory, "down")
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/migrations"
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
func TestUpDownAndIdempotence(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
directory := migrationDirectory(t)
|
||||
context := context.Background()
|
||||
|
||||
if err := migrations.Up(context, database, directory); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 1)
|
||||
assertTableExists(t, database, "tasks", true)
|
||||
assertTableExists(t, database, "spec_trials", true)
|
||||
assertTableExists(t, database, "order_authorizations", true)
|
||||
assertTableExists(t, database, "order_submissions", true)
|
||||
|
||||
if err := migrations.Up(context, database, directory); err != nil {
|
||||
t.Fatalf("reapply migrations: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 1)
|
||||
|
||||
if err := migrations.Down(context, database, directory); err != nil {
|
||||
t.Fatalf("roll back migration: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 0)
|
||||
assertTableExists(t, database, "tasks", false)
|
||||
assertTableExists(t, database, "spec_trials", false)
|
||||
assertTableExists(t, database, "order_authorizations", false)
|
||||
assertTableExists(t, database, "order_submissions", false)
|
||||
|
||||
if err := migrations.Up(context, database, directory); err != nil {
|
||||
t.Fatalf("apply migration after rollback: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 1)
|
||||
}
|
||||
|
||||
func TestSchemaConstraints(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
|
||||
for _, column := range []struct {
|
||||
table string
|
||||
name string
|
||||
}{
|
||||
{"tasks", "max_total_price"},
|
||||
{"spec_trials", "unit_price"},
|
||||
{"spec_trials", "total_price"},
|
||||
{"order_authorizations", "authorized_unit_price"},
|
||||
{"order_authorizations", "total_price_cap"},
|
||||
{"order_submissions", "verified_unit_price"},
|
||||
{"order_submissions", "confirm_page_amount"},
|
||||
} {
|
||||
assertColumnType(t, database, column.table, column.name, "TEXT")
|
||||
}
|
||||
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO tasks (
|
||||
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||
status, created_at, updated_at
|
||||
) VALUES ('bad-quantity', 'MANUAL', 'title', 'goods', 'white', 'XL', 0, '80.00', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
|
||||
`); err == nil {
|
||||
t.Fatal("insert task with quantity 0 succeeded")
|
||||
}
|
||||
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO tasks (
|
||||
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||
status, created_at, updated_at
|
||||
) VALUES ('bad-price', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '80..00', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
|
||||
`); err == nil {
|
||||
t.Fatal("insert task with malformed decimal price succeeded")
|
||||
}
|
||||
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO tasks (
|
||||
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||
status, created_at, updated_at
|
||||
) VALUES ('fractional-quantity', 'MANUAL', 'title', 'goods', 'white', 'XL', 1.5, '80.00', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
|
||||
`); err == nil {
|
||||
t.Fatal("insert task with fractional quantity succeeded")
|
||||
}
|
||||
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO tasks (
|
||||
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||
status, created_at, updated_at
|
||||
) VALUES ('trailing-decimal', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '80.', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
|
||||
`); err == nil {
|
||||
t.Fatal("insert task with trailing decimal point succeeded")
|
||||
}
|
||||
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO tasks (
|
||||
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||
status, created_at, updated_at
|
||||
) VALUES ('bad-status', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '80.00', 'UNKNOWN', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
|
||||
`); err == nil {
|
||||
t.Fatal("insert task with invalid status succeeded")
|
||||
}
|
||||
|
||||
insertTask(t, database, "task-one")
|
||||
insertTask(t, database, "task-two")
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO spec_trials (
|
||||
id, task_id, attempt, product_title, selected_color, selected_size, unit_price,
|
||||
total_price, evidence_sha256, created_at
|
||||
) VALUES ('orphan-trial', 'missing-task', 1, 'title', 'white', 'XL', '32.50', '65.00', 'hash', '2026-08-03T00:00:00Z')
|
||||
`); err == nil {
|
||||
t.Fatal("insert spec trial without task succeeded")
|
||||
}
|
||||
|
||||
insertSpecTrial(t, database, "trial-one", "task-one")
|
||||
insertSpecTrial(t, database, "trial-two", "task-two")
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO order_authorizations (
|
||||
id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity,
|
||||
authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at
|
||||
) VALUES ('authorization-cross-task', 'task-one', 'trial-two', 1, 'goods', 'white', 'XL', 2, '32.50', '80.00', 'PENDING_DELIVERY', 'admin-one', '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z')
|
||||
`); err == nil {
|
||||
t.Fatal("insert authorization with a spec trial from another task succeeded")
|
||||
}
|
||||
|
||||
insertAuthorization(t, database, "authorization-one", "task-one", "trial-one", 1)
|
||||
insertAuthorization(t, database, "authorization-task-two", "task-two", "trial-two", 1)
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO order_submissions (
|
||||
id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price,
|
||||
quantity_read, confirm_page_amount, created_at
|
||||
) VALUES ('submission-cross-task', 'task-one', 'authorization-task-two', 'command-cross-task', 'dry-run-cross-task', 'FENCED', '32.50', 2, '65.00', '2026-08-03T00:00:00Z')
|
||||
`); err == nil {
|
||||
t.Fatal("insert submission with an authorization from another task succeeded")
|
||||
}
|
||||
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO order_authorizations (
|
||||
id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity,
|
||||
authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at
|
||||
) VALUES ('authorization-duplicate', 'task-one', 'trial-one', 1, 'goods', 'white', 'XL', 2, '32.50', '80.00', 'PENDING_DELIVERY', 'admin-one', '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z')
|
||||
`); err == nil {
|
||||
t.Fatal("insert authorization with duplicate task version succeeded")
|
||||
}
|
||||
|
||||
insertSubmission(t, database, "submission-one", "authorization-one", "command-one")
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO order_submissions (
|
||||
id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price,
|
||||
quantity_read, confirm_page_amount, created_at
|
||||
) VALUES ('submission-duplicate-auth', 'task-one', 'authorization-one', 'command-two', 'dry-run-two', 'FENCED', '32.50', 2, '65.00', '2026-08-03T00:00:00Z')
|
||||
`); err == nil {
|
||||
t.Fatal("insert submission with duplicate authorization succeeded")
|
||||
}
|
||||
|
||||
insertAuthorization(t, database, "authorization-two", "task-one", "trial-one", 2)
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO order_submissions (
|
||||
id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price,
|
||||
quantity_read, confirm_page_amount, created_at
|
||||
) VALUES ('submission-duplicate-command', 'task-one', 'authorization-two', 'command-one', 'dry-run-three', 'FENCED', '32.50', 2, '65.00', '2026-08-03T00:00:00Z')
|
||||
`); err == nil {
|
||||
t.Fatal("insert submission with duplicate command succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func openTestDatabase(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
database, err := sqlite.Open(filepath.Join(t.TempDir(), "migrations.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open test database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := database.Close(); err != nil {
|
||||
t.Errorf("close test database: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
return database
|
||||
}
|
||||
|
||||
func migrationDirectory(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("locate migration test source")
|
||||
}
|
||||
|
||||
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
|
||||
}
|
||||
|
||||
func assertVersion(t *testing.T, database *sql.DB, want int64) {
|
||||
t.Helper()
|
||||
got, err := goose.GetDBVersion(database)
|
||||
if err != nil {
|
||||
t.Fatalf("read migration version: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("migration version = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertTableExists(t *testing.T, database *sql.DB, table string, want bool) {
|
||||
t.Helper()
|
||||
var count int
|
||||
if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&count); err != nil {
|
||||
t.Fatalf("look up table %s: %v", table, err)
|
||||
}
|
||||
if got := count == 1; got != want {
|
||||
t.Fatalf("table %s exists = %t, want %t", table, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertColumnType(t *testing.T, database *sql.DB, table, column, want string) {
|
||||
t.Helper()
|
||||
var got string
|
||||
if err := database.QueryRow(`SELECT type FROM pragma_table_info(?) WHERE name = ?`, table, column).Scan(&got); err != nil {
|
||||
t.Fatalf("read %s.%s type: %v", table, column, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("%s.%s type = %s, want %s", table, column, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func insertTask(t *testing.T, database *sql.DB, id string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO tasks (
|
||||
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||
status, created_at, updated_at
|
||||
) VALUES (?, 'MANUAL', 'title', 'goods', 'white', 'XL', 2, '80.00', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
|
||||
`, id); err != nil {
|
||||
t.Fatalf("insert task: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertSpecTrial(t *testing.T, database *sql.DB, id, taskID string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO spec_trials (
|
||||
id, task_id, attempt, product_title, selected_color, selected_size, unit_price,
|
||||
total_price, evidence_sha256, created_at
|
||||
) VALUES (?, ?, 1, 'title', 'white', 'XL', '32.50', '65.00', 'hash', '2026-08-03T00:00:00Z')
|
||||
`, id, taskID); err != nil {
|
||||
t.Fatalf("insert spec trial: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertAuthorization(t *testing.T, database *sql.DB, id, taskID, specTrialID string, version int) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO order_authorizations (
|
||||
id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity,
|
||||
authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at
|
||||
) VALUES (?, ?, ?, ?, 'goods', 'white', 'XL', 2, '32.50', '80.00', 'PENDING_DELIVERY', 'admin-one', '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z')
|
||||
`, id, taskID, specTrialID, version); err != nil {
|
||||
t.Fatalf("insert authorization: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertSubmission(t *testing.T, database *sql.DB, id, authorizationID, commandID string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO order_submissions (
|
||||
id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price,
|
||||
quantity_read, confirm_page_amount, created_at
|
||||
) VALUES (?, 'task-one', ?, ?, 'dry-run-one', 'FENCED', '32.50', 2, '65.00', '2026-08-03T00:00:00Z')
|
||||
`, id, authorizationID, commandID); err != nil {
|
||||
t.Fatalf("insert submission: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,293 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"cmbuyer/admin/internal/auth"
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
"cmbuyer/admin/internal/transport/webui"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
|
||||
func NewRouter() *gin.Engine {
|
||||
router := gin.New()
|
||||
const maxFormBytes = 8 << 10
|
||||
|
||||
router.GET("/healthz", func(context *gin.Context) {
|
||||
context.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
// Options 是路由层需要的安全依赖。凭据由启动配置注入,不能在路由中设置默认值。
|
||||
type Options struct {
|
||||
AdminUsername string
|
||||
AdminPasswordBcrypt string
|
||||
Sessions *auth.Manager
|
||||
Tasks tasks.Store
|
||||
}
|
||||
|
||||
return router
|
||||
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
|
||||
func NewRouter(options Options) (*gin.Engine, error) {
|
||||
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil {
|
||||
return nil, errors.New("server authentication options are incomplete")
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
router.Use(securityHeaders())
|
||||
router.GET("/healthz", healthz)
|
||||
router.GET("/login", loginPage(options))
|
||||
router.POST("/login", login(options))
|
||||
router.POST("/logout", logout(options))
|
||||
router.GET("/tasks", tasksPage(options))
|
||||
router.GET("/tasks/new", newTaskPage(options))
|
||||
router.POST("/tasks", createTask(options))
|
||||
|
||||
return router, nil
|
||||
}
|
||||
|
||||
func healthz(context *gin.Context) {
|
||||
context.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func securityHeaders() gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
context.Header("Cache-Control", "no-store")
|
||||
context.Header("X-Content-Type-Options", "nosniff")
|
||||
context.Header("Referrer-Policy", "no-referrer")
|
||||
context.Header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
|
||||
context.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func loginPage(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
csrfToken, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
|
||||
if authenticated {
|
||||
context.Redirect(http.StatusSeeOther, "/tasks")
|
||||
return
|
||||
}
|
||||
|
||||
renderLogin(context, http.StatusOK, csrfToken, returnTo(context.Query("return_to")), "", "")
|
||||
}
|
||||
}
|
||||
|
||||
func login(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
if !parseForm(context) {
|
||||
return
|
||||
}
|
||||
form := context.Request.PostForm
|
||||
csrfToken := form.Get("csrf_token")
|
||||
returnPath := returnTo(form.Get("return_to"))
|
||||
username := form.Get("username")
|
||||
password := form.Get("password")
|
||||
|
||||
if _, ok := options.Sessions.VerifyCSRF(context.Request, csrfToken); !ok {
|
||||
newCSRF, _ := options.Sessions.Ensure(context.Writer, context.Request)
|
||||
renderLogin(context, http.StatusForbidden, newCSRF, returnPath, "", "请求已过期,请重新登录。")
|
||||
return
|
||||
}
|
||||
|
||||
usernameMatches := subtle.ConstantTimeCompare([]byte(options.AdminUsername), []byte(username)) == 1
|
||||
passwordMatches := bcrypt.CompareHashAndPassword([]byte(options.AdminPasswordBcrypt), []byte(password)) == nil
|
||||
if !usernameMatches || !passwordMatches {
|
||||
csrf, _ := options.Sessions.Ensure(context.Writer, context.Request)
|
||||
renderLogin(context, http.StatusUnauthorized, csrf, returnPath, "", "账号或密码不正确,请检查后重试。")
|
||||
return
|
||||
}
|
||||
|
||||
options.Sessions.RotateAuthenticated(context.Writer, context.Request)
|
||||
context.Redirect(http.StatusSeeOther, returnPath)
|
||||
}
|
||||
}
|
||||
|
||||
func logout(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
if !parseForm(context) {
|
||||
return
|
||||
}
|
||||
authenticated, ok := options.Sessions.VerifyCSRF(context.Request, context.Request.PostForm.Get("csrf_token"))
|
||||
if !ok || !authenticated {
|
||||
context.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
options.Sessions.Logout(context.Writer, context.Request)
|
||||
context.Redirect(http.StatusSeeOther, "/login")
|
||||
}
|
||||
}
|
||||
|
||||
func tasksPage(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
csrfToken, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
|
||||
if !authenticated {
|
||||
context.Redirect(http.StatusSeeOther, "/login?return_to="+url.QueryEscape(context.Request.URL.RequestURI()))
|
||||
return
|
||||
}
|
||||
|
||||
drafts, err := options.Tasks.ListDrafts(context.Request.Context())
|
||||
if err != nil {
|
||||
context.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := webui.TasksData{CSRFToken: csrfToken, Drafts: drafts}
|
||||
for _, draft := range drafts {
|
||||
if draft.ID == context.Query("created") {
|
||||
data.Success = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if context.Query("create") == "1" {
|
||||
form, err := newTaskForm()
|
||||
if err != nil {
|
||||
context.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data.OpenForm = true
|
||||
data.Form = form
|
||||
data.FocusField = "title"
|
||||
}
|
||||
renderTasks(context, http.StatusOK, data)
|
||||
}
|
||||
}
|
||||
|
||||
func newTaskPage(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
csrf, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
|
||||
if !authenticated {
|
||||
context.Redirect(http.StatusSeeOther, "/login?return_to=%2Ftasks%2Fnew")
|
||||
return
|
||||
}
|
||||
form, err := newTaskForm()
|
||||
if err != nil {
|
||||
context.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
renderTasks(context, http.StatusOK, webui.TasksData{CSRFToken: csrf, Form: form, FullPage: true, FocusField: "title"})
|
||||
}
|
||||
}
|
||||
func createTask(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
if !parseForm(context) {
|
||||
return
|
||||
}
|
||||
requestForm := context.Request.PostForm
|
||||
authenticated, csrfOK := options.Sessions.VerifyCSRF(context.Request, requestForm.Get("csrf_token"))
|
||||
if !csrfOK || !authenticated {
|
||||
context.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
form := taskForm(requestForm)
|
||||
draft, validation := tasks.Validate(form)
|
||||
if draft.GoodsID != "" {
|
||||
form.ProductURL = tasks.CanonicalURL(draft.GoodsID)
|
||||
}
|
||||
fullPage := requestForm.Get("form_mode") == "full"
|
||||
if !validation.Valid() {
|
||||
drafts, err := options.Tasks.ListDrafts(context.Request.Context())
|
||||
if err != nil {
|
||||
context.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
renderTasks(context, http.StatusBadRequest, webui.TasksData{CSRFToken: csrfFor(context, options), Drafts: drafts, Form: form, Errors: validation, OpenForm: !fullPage, FullPage: fullPage, FocusField: firstError(validation)})
|
||||
return
|
||||
}
|
||||
created, err := options.Tasks.CreateDraft(context.Request.Context(), draft)
|
||||
if err != nil {
|
||||
if errors.Is(err, tasks.ErrCreateKeyConflict) {
|
||||
validation["create_key"] = "该创建请求已用于另一条任务,请重新打开表单。"
|
||||
drafts, listErr := options.Tasks.ListDrafts(context.Request.Context())
|
||||
if listErr != nil {
|
||||
context.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
renderTasks(context, http.StatusConflict, webui.TasksData{CSRFToken: csrfFor(context, options), Drafts: drafts, Form: form, Errors: validation, OpenForm: !fullPage, FullPage: fullPage, FocusField: firstError(validation)})
|
||||
return
|
||||
}
|
||||
context.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
context.Redirect(http.StatusSeeOther, "/tasks?created="+url.QueryEscape(created.ID))
|
||||
}
|
||||
}
|
||||
|
||||
func newTaskForm() (tasks.Form, error) {
|
||||
key, err := tasks.NewCreateKey()
|
||||
if err != nil {
|
||||
return tasks.Form{}, err
|
||||
}
|
||||
return tasks.Form{CreateKey: key}, nil
|
||||
}
|
||||
func taskForm(form url.Values) tasks.Form {
|
||||
return tasks.Form{CreateKey: form.Get("create_key"), Title: form.Get("title"), ProductURL: form.Get("product_url"), SKUColor: form.Get("sku_color"), SKUSize: form.Get("sku_size"), Quantity: form.Get("quantity"), MaxTotalPrice: form.Get("max_total_price")}
|
||||
}
|
||||
func csrfFor(context *gin.Context, options Options) string {
|
||||
csrf, _ := options.Sessions.Ensure(context.Writer, context.Request)
|
||||
return csrf
|
||||
}
|
||||
func renderTasks(context *gin.Context, status int, data webui.TasksData) {
|
||||
context.Header("Content-Type", "text/html; charset=utf-8")
|
||||
context.Status(status)
|
||||
if err := webui.RenderTasks(context.Writer, data); err != nil {
|
||||
_ = context.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func renderLogin(context *gin.Context, status int, csrfToken, returnPath, username, message string) {
|
||||
context.Header("Content-Type", "text/html; charset=utf-8")
|
||||
context.Status(status)
|
||||
if err := webui.RenderLogin(context.Writer, webui.LoginData{
|
||||
CSRFToken: csrfToken,
|
||||
ReturnTo: returnPath,
|
||||
Username: username,
|
||||
Error: message,
|
||||
}); err != nil {
|
||||
_ = context.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func parseForm(context *gin.Context) bool {
|
||||
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxFormBytes)
|
||||
if err := context.Request.ParseForm(); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
context.Status(http.StatusRequestEntityTooLarge)
|
||||
} else {
|
||||
context.Status(http.StatusBadRequest)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func firstError(validation tasks.Errors) string {
|
||||
for _, field := range []string{"title", "product_url", "sku_color", "sku_size", "quantity", "max_total_price"} {
|
||||
if _, ok := validation[field]; ok {
|
||||
return field
|
||||
}
|
||||
}
|
||||
return "title"
|
||||
}
|
||||
|
||||
func returnTo(value string) string {
|
||||
if value == "/tasks" || strings.HasPrefix(value, "/tasks/") || strings.HasPrefix(value, "/tasks?") {
|
||||
if strings.Contains(value, "\\") || strings.Contains(value, "%") || strings.HasPrefix(value, "//") {
|
||||
return "/tasks"
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(value)
|
||||
if err == nil && parsed.IsAbs() == false && parsed.Host == "" && hasSafeTaskPath(parsed.Path) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return "/tasks"
|
||||
}
|
||||
|
||||
func hasSafeTaskPath(path string) bool {
|
||||
for _, segment := range strings.Split(path, "/") {
|
||||
if segment == "." || segment == ".." {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,28 +1,481 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/auth"
|
||||
"cmbuyer/admin/internal/server"
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
var csrfPattern = regexp.MustCompile(`name="csrf_token" value="([^"]+)"`)
|
||||
var createKeyPattern = regexp.MustCompile(`name="create_key" value="([^"]+)"`)
|
||||
|
||||
func TestHealthzIsPublic(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
server.NewRouter().ServeHTTP(response, request)
|
||||
router.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)
|
||||
}
|
||||
assertSecurityHeaders(t, response)
|
||||
}
|
||||
|
||||
func TestTasksRequiresLoginAndBlocksOpenRedirects(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
|
||||
tasks := serve(router, http.MethodGet, "/tasks", nil, nil)
|
||||
if tasks.Code != http.StatusSeeOther {
|
||||
t.Fatalf("GET /tasks status = %d, want %d", tasks.Code, http.StatusSeeOther)
|
||||
}
|
||||
if location := tasks.Header().Get("Location"); location != "/login?return_to=%2Ftasks" {
|
||||
t.Fatalf("GET /tasks location = %q, want login return path", location)
|
||||
}
|
||||
|
||||
for _, target := range []string{"https://example.invalid", "//example.invalid", `\\example.invalid`, "/other", "/tasks/..", "/tasks/../other", "/tasks/%2e%2e", "%2F%2Fevil.invalid", "%252F%252Fevil.invalid"} {
|
||||
response := serve(router, http.MethodGet, "/login?return_to="+url.QueryEscape(target), nil, nil)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("GET /login return_to=%q status = %d, want 200", target, response.Code)
|
||||
}
|
||||
if strings.Contains(response.Body.String(), target) || !strings.Contains(response.Body.String(), `name="return_to" value="/tasks"`) {
|
||||
t.Fatalf("GET /login accepted unsafe return_to %q", target)
|
||||
}
|
||||
}
|
||||
|
||||
encodedPath := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%252F..", nil, nil)
|
||||
if !strings.Contains(encodedPath.Body.String(), `name="return_to" value="/tasks"`) {
|
||||
t.Fatal("encoded parent path was accepted as return_to")
|
||||
}
|
||||
encodedQuery := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%3Fnext%3D%252Ftasks%252F..", nil, nil)
|
||||
if !strings.Contains(encodedQuery.Body.String(), `name="return_to" value="/tasks"`) {
|
||||
t.Fatal("encoded query bypass was accepted as return_to")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRotatesSessionAndCSRF(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
initial := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%3Fview%3Dmine", nil, nil)
|
||||
oldCookie := sessionCookie(t, initial)
|
||||
oldCSRF := csrfToken(t, initial.Body.String())
|
||||
|
||||
login := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"csrf_token": {oldCSRF},
|
||||
"return_to": {"/tasks?view=mine"},
|
||||
"username": {"admin"},
|
||||
"password": {"test-password"},
|
||||
}, oldCookie)
|
||||
if login.Code != http.StatusSeeOther || login.Header().Get("Location") != "/tasks?view=mine" {
|
||||
t.Fatalf("successful login = (%d, %q), want 303 /tasks?view=mine", login.Code, login.Header().Get("Location"))
|
||||
}
|
||||
newCookie := sessionCookie(t, login)
|
||||
if newCookie.Value == oldCookie.Value {
|
||||
t.Fatal("successful login reused the anonymous session cookie")
|
||||
}
|
||||
|
||||
tasks := serve(router, http.MethodGet, "/tasks", nil, newCookie)
|
||||
if tasks.Code != http.StatusOK {
|
||||
t.Fatalf("GET /tasks after login status = %d, want 200", tasks.Code)
|
||||
}
|
||||
if newCSRF := csrfToken(t, tasks.Body.String()); newCSRF == oldCSRF {
|
||||
t.Fatal("successful login reused the anonymous CSRF token")
|
||||
}
|
||||
for _, forbidden := range []string{"建单", "试选", "拼多多", "规格", "单价", "证据"} {
|
||||
if strings.Contains(tasks.Body.String(), forbidden) {
|
||||
t.Fatalf("task shell must not expose deferred feature content %q", forbidden)
|
||||
}
|
||||
}
|
||||
assertSecurityHeaders(t, initial)
|
||||
assertSecurityHeaders(t, tasks)
|
||||
}
|
||||
|
||||
func TestLoginPageIncludesAccessibleFormBasics(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
page := serve(router, http.MethodGet, "/login", nil, nil)
|
||||
body := page.Body.String()
|
||||
for _, want := range []string{
|
||||
`<label for="username">`,
|
||||
`<label for="password">`,
|
||||
`autocomplete="username"`,
|
||||
`autocomplete="current-password"`,
|
||||
`min-height:44px`,
|
||||
`:focus-visible`,
|
||||
`prefers-reduced-motion`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("login page is missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "http://") || strings.Contains(body, "https://") || strings.Contains(body, "<script") {
|
||||
t.Fatal("login page must not load external resources or require client-side JavaScript")
|
||||
}
|
||||
|
||||
failure := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"csrf_token": {csrfToken(t, body)},
|
||||
"username": {"admin"},
|
||||
"password": {"wrong"},
|
||||
}, sessionCookie(t, page))
|
||||
if !strings.Contains(failure.Body.String(), `role="alert"`) {
|
||||
t.Fatal("login failure must announce its error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCSRFAndCredentialFailuresAreSafe(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
page := serve(router, http.MethodGet, "/login", nil, nil)
|
||||
cookie := sessionCookie(t, page)
|
||||
|
||||
withoutCSRF := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"username": {"admin"},
|
||||
"password": {"test-password"},
|
||||
}, cookie)
|
||||
if withoutCSRF.Code != http.StatusForbidden || !strings.Contains(withoutCSRF.Body.String(), "请求已过期") {
|
||||
t.Fatalf("login without CSRF = (%d, %q), want rejected form", withoutCSRF.Code, withoutCSRF.Body.String())
|
||||
}
|
||||
|
||||
page = serve(router, http.MethodGet, "/login", nil, cookie)
|
||||
badCredentials := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"csrf_token": {csrfToken(t, page.Body.String())},
|
||||
"username": {"unknown"},
|
||||
"password": {"wrong"},
|
||||
}, cookie)
|
||||
if badCredentials.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("login with invalid credentials status = %d, want 401", badCredentials.Code)
|
||||
}
|
||||
if body := badCredentials.Body.String(); !strings.Contains(body, "账号或密码不正确") || strings.Contains(body, "unknown") {
|
||||
t.Fatalf("invalid login leaked account detail: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTamperedCookieCannotAccessTasks(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
page := serve(router, http.MethodGet, "/login", nil, nil)
|
||||
cookie := sessionCookie(t, page)
|
||||
|
||||
tampered := *cookie
|
||||
tampered.Value = flipCookieValue(t, cookie.Value)
|
||||
response := serve(router, http.MethodGet, "/tasks", nil, &tampered)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("tampered cookie status = %d, want 303", response.Code)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestTaskCreationRendersSharedFormsAndPersistsOnlyDraft(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
cookie := authenticate(t, router)
|
||||
|
||||
modal := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
|
||||
if modal.Code != http.StatusOK {
|
||||
t.Fatalf("GET dialog form status = %d, want 200", modal.Code)
|
||||
}
|
||||
fullPage := serve(router, http.MethodGet, "/tasks/new", nil, cookie)
|
||||
if fullPage.Code != http.StatusOK {
|
||||
t.Fatalf("GET full form status = %d, want 200", fullPage.Code)
|
||||
}
|
||||
for _, want := range []string{`<div class="modal-scrim"`, `<dialog open`, `aria-modal="true"`, `name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `type="url" inputmode="url" maxlength="2048"`, `type="number" inputmode="numeric" min="1" step="1"`, `inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?"`, `maxlength="120"`, `maxlength="80"`, `required`, `autofocus`, `导入</button><a class="button primary"`, `type="search" disabled`, `disabled>筛选</button>`, `disabled>清除</button>`, `min-height:44px`, `overflow-x:auto`, `prefers-reduced-motion`} {
|
||||
if !strings.Contains(modal.Body.String(), want) {
|
||||
t.Fatalf("dialog form is missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{`name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `name="form_mode" value="full"`} {
|
||||
if !strings.Contains(fullPage.Body.String(), want) {
|
||||
t.Fatalf("full-page form is missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := serve(router, http.MethodPost, "/tasks", url.Values{
|
||||
"csrf_token": {csrfToken(t, modal.Body.String())},
|
||||
"create_key": {createKey(t, modal.Body.String())},
|
||||
"title": {`<script>alert(1)</script>`},
|
||||
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&uin=discard"},
|
||||
"sku_color": {"black"},
|
||||
"sku_size": {"M"},
|
||||
"quantity": {"0"},
|
||||
"max_total_price": {"12.80"},
|
||||
"form_mode": {"dialog"},
|
||||
}, cookie)
|
||||
if invalid.Code != http.StatusBadRequest || !strings.Contains(invalid.Body.String(), `<dialog open`) || !strings.Contains(invalid.Body.String(), "数量必须是正整数") || !strings.Contains(invalid.Body.String(), `role="alert"`) || !strings.Contains(invalid.Body.String(), `href="#quantity"`) || !strings.Contains(invalid.Body.String(), `aria-describedby="quantity-error"`) || !strings.Contains(invalid.Body.String(), `autofocus`) {
|
||||
t.Fatalf("invalid create = (%d, %q), want dialog validation response", invalid.Code, invalid.Body.String())
|
||||
}
|
||||
if strings.Contains(invalid.Body.String(), `<script>alert(1)</script>`) || !strings.Contains(invalid.Body.String(), `<script>alert(1)</script>`) {
|
||||
t.Fatalf("invalid create did not safely preserve title: %q", invalid.Body.String())
|
||||
}
|
||||
if strings.Contains(invalid.Body.String(), "uin=discard") || !strings.Contains(invalid.Body.String(), `value="https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"`) {
|
||||
t.Fatalf("invalid create did not canonicalize product URL: %q", invalid.Body.String())
|
||||
}
|
||||
|
||||
createPage := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
|
||||
key := createKey(t, createPage.Body.String())
|
||||
created := serve(router, http.MethodPost, "/tasks", url.Values{
|
||||
"csrf_token": {csrfToken(t, createPage.Body.String())},
|
||||
"create_key": {key},
|
||||
"title": {"<b>夏季上衣</b>"},
|
||||
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=discard"},
|
||||
"sku_color": {"black"},
|
||||
"sku_size": {"M"},
|
||||
"quantity": {"2"},
|
||||
"max_total_price": {"12.8"},
|
||||
"form_mode": {"dialog"},
|
||||
}, cookie)
|
||||
if created.Code != http.StatusSeeOther || !strings.HasPrefix(created.Header().Get("Location"), "/tasks?created=") {
|
||||
t.Fatalf("valid create = (%d, %q), want 303 to a created-task acknowledgement", created.Code, created.Header().Get("Location"))
|
||||
}
|
||||
replay := serve(router, http.MethodPost, "/tasks", url.Values{
|
||||
"csrf_token": {csrfToken(t, createPage.Body.String())},
|
||||
"create_key": {key},
|
||||
"title": {"<b>夏季上衣</b>"},
|
||||
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=discard"},
|
||||
"sku_color": {"black"},
|
||||
"sku_size": {"M"},
|
||||
"quantity": {"2"},
|
||||
"max_total_price": {"12.8"},
|
||||
"form_mode": {"dialog"},
|
||||
}, cookie)
|
||||
if replay.Code != http.StatusSeeOther {
|
||||
t.Fatalf("idempotent replay status = %d, want 303", replay.Code)
|
||||
}
|
||||
conflict := serve(router, http.MethodPost, "/tasks", url.Values{
|
||||
"csrf_token": {csrfToken(t, createPage.Body.String())},
|
||||
"create_key": {key},
|
||||
"title": {"different task"},
|
||||
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"},
|
||||
"sku_color": {"black"},
|
||||
"sku_size": {"M"},
|
||||
"quantity": {"2"},
|
||||
"max_total_price": {"12.80"},
|
||||
"form_mode": {"dialog"},
|
||||
}, cookie)
|
||||
if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "该创建请求已用于另一条任务") {
|
||||
t.Fatalf("conflicting create = (%d, %q), want a 409 form error", conflict.Code, conflict.Body.String())
|
||||
}
|
||||
|
||||
list := serve(router, http.MethodGet, created.Header().Get("Location"), nil, cookie)
|
||||
if list.Code != http.StatusOK {
|
||||
t.Fatalf("GET /tasks status = %d, want 200", list.Code)
|
||||
}
|
||||
body := list.Body.String()
|
||||
for _, want := range []string{`任务已创建,已显示在列表首行。`, `<b>夏季上衣</b>`, `https://mobile.yangkeduo.com/goods.html?goods_id=937122477375`, `target="_blank"`, `rel="noopener noreferrer"`, `¥12.80`, `待开始`, `选择全部任务`, `选择任务`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("task list is missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"utm_source", "试选", "PENDING", "支付", "订单确认", "真机", "提交订单"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("task list exposed deferred scope %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCreationRequiresAuthenticationAndCSRF(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, nil); response.Code != http.StatusForbidden {
|
||||
t.Fatalf("anonymous POST /tasks = %d, want 403", response.Code)
|
||||
}
|
||||
cookie := authenticate(t, router)
|
||||
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, cookie); response.Code != http.StatusForbidden {
|
||||
t.Fatalf("POST /tasks without CSRF = %d, want 403", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCreationFailsClosedForMalformedOrOversizedForms(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
cookie := authenticate(t, router)
|
||||
page := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
|
||||
base := url.Values{
|
||||
"csrf_token": {csrfToken(t, page.Body.String())},
|
||||
"create_key": {createKey(t, page.Body.String())},
|
||||
"title": {"title"},
|
||||
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=malformed"},
|
||||
"sku_color": {"black"},
|
||||
"sku_size": {"M"},
|
||||
"quantity": {"1"},
|
||||
"max_total_price": {"1.00"},
|
||||
"form_mode": {"dialog"},
|
||||
}
|
||||
malformed := serve(router, http.MethodPost, "/tasks", base, cookie)
|
||||
if malformed.Code != http.StatusBadRequest || !strings.Contains(malformed.Body.String(), "canonical 商品链接") {
|
||||
t.Fatalf("malformed URL create = (%d, %q), want validation failure", malformed.Code, malformed.Body.String())
|
||||
}
|
||||
|
||||
oversized := url.Values{"csrf_token": {csrfToken(t, page.Body.String())}, "title": {strings.Repeat("x", 9<<10)}}
|
||||
if response := serve(router, http.MethodPost, "/tasks", oversized, cookie); response.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("oversized form status = %d, want 413", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSecurityHeaders(t *testing.T, response *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
want := map[string]string{
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"Content-Security-Policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
||||
}
|
||||
for name, expected := range want {
|
||||
if got := response.Header().Get(name); got != expected {
|
||||
t.Fatalf("%s = %q, want %q", name, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func flipCookieValue(t *testing.T, value string) string {
|
||||
t.Helper()
|
||||
if value == "" {
|
||||
t.Fatal("cannot tamper with an empty cookie")
|
||||
}
|
||||
if value[0] == 'A' {
|
||||
return "B" + value[1:]
|
||||
}
|
||||
return "A" + value[1:]
|
||||
}
|
||||
|
||||
func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
loginPage := serve(router, http.MethodGet, "/login", nil, nil)
|
||||
loginCookie := sessionCookie(t, loginPage)
|
||||
login := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"csrf_token": {csrfToken(t, loginPage.Body.String())},
|
||||
"username": {"admin"},
|
||||
"password": {"test-password"},
|
||||
}, loginCookie)
|
||||
authenticatedCookie := sessionCookie(t, login)
|
||||
|
||||
missingCSRF := serve(router, http.MethodPost, "/logout", url.Values{}, authenticatedCookie)
|
||||
if missingCSRF.Code != http.StatusForbidden {
|
||||
t.Fatalf("logout without CSRF status = %d, want 403", missingCSRF.Code)
|
||||
}
|
||||
|
||||
tasks := serve(router, http.MethodGet, "/tasks", nil, authenticatedCookie)
|
||||
logout := serve(router, http.MethodPost, "/logout", url.Values{
|
||||
"csrf_token": {csrfToken(t, tasks.Body.String())},
|
||||
}, authenticatedCookie)
|
||||
if logout.Code != http.StatusSeeOther || logout.Header().Get("Location") != "/login" {
|
||||
t.Fatalf("logout = (%d, %q), want 303 /login", logout.Code, logout.Header().Get("Location"))
|
||||
}
|
||||
if cookie := sessionCookie(t, logout); cookie.MaxAge >= 0 {
|
||||
t.Fatalf("logout cookie MaxAge = %d, want a deletion cookie", cookie.MaxAge)
|
||||
}
|
||||
|
||||
reused := serve(router, http.MethodGet, "/tasks", nil, authenticatedCookie)
|
||||
if reused.Code != http.StatusSeeOther {
|
||||
t.Fatalf("revoked session status = %d, want 303", reused.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("generate bcrypt hash: %v", err)
|
||||
}
|
||||
manager := auth.NewManager([]byte(strings.Repeat("s", 32)), false)
|
||||
router, err := server.NewRouter(server.Options{
|
||||
AdminUsername: "admin",
|
||||
AdminPasswordBcrypt: string(hash),
|
||||
Sessions: manager,
|
||||
Tasks: &memoryStore{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter: %v", err)
|
||||
}
|
||||
return router, manager
|
||||
}
|
||||
|
||||
type memoryStore struct{ drafts []tasks.Draft }
|
||||
|
||||
func (store *memoryStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) {
|
||||
for _, existing := range store.drafts {
|
||||
if existing.ID == draft.ID {
|
||||
if existing.Title != draft.Title || existing.GoodsID != draft.GoodsID || existing.SKUColor != draft.SKUColor || existing.SKUSize != draft.SKUSize || existing.Quantity != draft.Quantity || existing.MaxTotalPrice != draft.MaxTotalPrice {
|
||||
return tasks.Draft{}, tasks.ErrCreateKeyConflict
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
store.drafts = append(store.drafts, draft)
|
||||
return draft, nil
|
||||
}
|
||||
func (store *memoryStore) ListDrafts(_ context.Context) ([]tasks.Draft, error) {
|
||||
return append([]tasks.Draft(nil), store.drafts...), nil
|
||||
}
|
||||
|
||||
func serve(router http.Handler, method, target string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
var body *strings.Reader
|
||||
if form == nil {
|
||||
body = strings.NewReader("")
|
||||
} else {
|
||||
body = strings.NewReader(form.Encode())
|
||||
}
|
||||
request := httptest.NewRequest(method, target, body)
|
||||
if form != nil {
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
if cookie != nil {
|
||||
request.AddCookie(cookie)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func sessionCookie(t *testing.T, response *httptest.ResponseRecorder) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, cookie := range response.Result().Cookies() {
|
||||
if cookie.Name == auth.CookieName {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
t.Fatalf("response did not set %s cookie", auth.CookieName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func csrfToken(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
matches := csrfPattern.FindStringSubmatch(body)
|
||||
if len(matches) != 2 || matches[1] == "" {
|
||||
t.Fatalf("no CSRF token in response body: %q", body)
|
||||
}
|
||||
return matches[1]
|
||||
}
|
||||
|
||||
func createKey(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
matches := createKeyPattern.FindStringSubmatch(body)
|
||||
if len(matches) != 2 || matches[1] == "" {
|
||||
t.Fatalf("no create key in response body: %q", body)
|
||||
}
|
||||
return matches[1]
|
||||
}
|
||||
|
||||
func authenticate(t *testing.T, router http.Handler) *http.Cookie {
|
||||
t.Helper()
|
||||
page := serve(router, http.MethodGet, "/login", nil, nil)
|
||||
login := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"csrf_token": {csrfToken(t, page.Body.String())},
|
||||
"username": {"admin"},
|
||||
"password": {"test-password"},
|
||||
}, sessionCookie(t, page))
|
||||
if login.Code != http.StatusSeeOther {
|
||||
t.Fatalf("authenticate status = %d, want 303", login.Code)
|
||||
}
|
||||
return sessionCookie(t, login)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,33 @@ package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// Open 打开 SQLite 数据源;调用方负责其 schema 与生命周期。
|
||||
const driverName = "cmbuyer-sqlite3"
|
||||
|
||||
func init() {
|
||||
sql.Register(driverName, &sqlite3.SQLiteDriver{
|
||||
ConnectHook: func(connection *sqlite3.SQLiteConn) error {
|
||||
_, err := connection.Exec("PRAGMA foreign_keys = ON", nil)
|
||||
return err
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Open 打开 SQLite 数据源并逐连接启用外键,避免连接池配置遗漏而绕过授权与任务的引用约束。
|
||||
func Open(dataSourceName string) (*sql.DB, error) {
|
||||
return sql.Open("sqlite3", dataSourceName)
|
||||
database, err := sql.Open(driverName, dataSourceName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := database.Ping(); err != nil {
|
||||
_ = database.Close()
|
||||
return nil, fmt.Errorf("ping SQLite database: %w", err)
|
||||
}
|
||||
|
||||
return database, nil
|
||||
}
|
||||
|
||||
@@ -21,4 +21,15 @@ func TestOpen(t *testing.T) {
|
||||
if err := database.PingContext(context.Background()); err != nil {
|
||||
t.Fatalf("ping SQLite database: %v", err)
|
||||
}
|
||||
|
||||
database.SetMaxIdleConns(0)
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
var foreignKeysEnabled int
|
||||
if err := database.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeysEnabled); err != nil {
|
||||
t.Fatalf("read SQLite foreign key setting: %v", err)
|
||||
}
|
||||
if foreignKeysEnabled != 1 {
|
||||
t.Fatalf("SQLite foreign_keys = %d, want 1", foreignKeysEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const sqliteWriteTimeout = 2 * time.Second
|
||||
|
||||
type Store interface {
|
||||
CreateDraft(context.Context, Draft) (Draft, error)
|
||||
ListDrafts(context.Context) ([]Draft, error)
|
||||
}
|
||||
type SQLiteStore struct {
|
||||
database *sql.DB
|
||||
now func() time.Time
|
||||
createGate chan struct{}
|
||||
}
|
||||
|
||||
func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
|
||||
if database == nil {
|
||||
return nil, errors.New("database is required")
|
||||
}
|
||||
if _, err := database.Exec("SELECT 1 FROM tasks LIMIT 1"); err != nil {
|
||||
return nil, fmt.Errorf("tasks migration is not available: %w", err)
|
||||
}
|
||||
return &SQLiteStore{database: database, now: time.Now, createGate: make(chan struct{}, 1)}, nil
|
||||
}
|
||||
|
||||
func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) {
|
||||
writeContext, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
|
||||
defer cancel()
|
||||
// SQLite permits one writer at a time. Serializing this store's short create
|
||||
// transaction prevents concurrent retries of one create key from surfacing as busy.
|
||||
select {
|
||||
case store.createGate <- struct{}{}:
|
||||
defer func() { <-store.createGate }()
|
||||
case <-writeContext.Done():
|
||||
return Draft{}, writeContext.Err()
|
||||
}
|
||||
draft.CreatedAt = store.now().UTC()
|
||||
transaction, err := store.database.BeginTx(writeContext, nil)
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
defer transaction.Rollback()
|
||||
_, err = transaction.ExecContext(writeContext, `INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', ?, ?, ?, ?, ?, ?, 'DRAFT', 1, ?, ?)`, draft.ID, draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.Quantity, draft.MaxTotalPrice, draft.CreatedAt.Format(time.RFC3339Nano), draft.CreatedAt.Format(time.RFC3339Nano))
|
||||
if err == nil {
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
return draft, nil
|
||||
}
|
||||
existing, found, currentPhase, lookupErr := findDraft(writeContext, transaction, draft.ID)
|
||||
if lookupErr != nil {
|
||||
return Draft{}, lookupErr
|
||||
}
|
||||
if found && currentPhase && samePayload(existing, draft) {
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
if found {
|
||||
return Draft{}, ErrCreateKeyConflict
|
||||
}
|
||||
return Draft{}, err
|
||||
}
|
||||
|
||||
func (store *SQLiteStore) ListDrafts(ctx context.Context) ([]Draft, error) {
|
||||
// rowid makes equal timestamps deterministic: SQLite assigns it in insertion order,
|
||||
// whereas UUID v4 is deliberately not time-sortable.
|
||||
rows, err := store.database.QueryContext(ctx, `SELECT id, title, goods_id, sku_color, sku_size, quantity, max_total_price, created_at FROM tasks WHERE source = 'MANUAL' AND status = 'DRAFT' ORDER BY created_at DESC, rowid DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []Draft{}
|
||||
for rows.Next() {
|
||||
draft, err := scanDraft(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, draft)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func findDraft(ctx context.Context, transaction *sql.Tx, id string) (Draft, bool, bool, error) {
|
||||
row := transaction.QueryRowContext(ctx, `SELECT id, title, goods_id, sku_color, sku_size, quantity, max_total_price, created_at, source, status, version FROM tasks WHERE id = ?`, id)
|
||||
var draft Draft
|
||||
var created, source, status string
|
||||
var version int
|
||||
err := row.Scan(&draft.ID, &draft.Title, &draft.GoodsID, &draft.SKUColor, &draft.SKUSize, &draft.Quantity, &draft.MaxTotalPrice, &created, &source, &status, &version)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Draft{}, false, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Draft{}, false, false, err
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339Nano, created)
|
||||
if err != nil {
|
||||
return Draft{}, false, false, err
|
||||
}
|
||||
draft.CreatedAt = parsed
|
||||
return draft, true, source == "MANUAL" && status == "DRAFT" && version == 1, nil
|
||||
}
|
||||
|
||||
type scanner interface{ Scan(...any) error }
|
||||
|
||||
func scanDraft(row scanner) (Draft, error) {
|
||||
var draft Draft
|
||||
var created string
|
||||
if err := row.Scan(&draft.ID, &draft.Title, &draft.GoodsID, &draft.SKUColor, &draft.SKUSize, &draft.Quantity, &draft.MaxTotalPrice, &created); err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339Nano, created)
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
draft.CreatedAt = parsed
|
||||
return draft, nil
|
||||
}
|
||||
func samePayload(left, right Draft) bool {
|
||||
return left.ID == right.ID && left.Title == right.Title && left.GoodsID == right.GoodsID && left.SKUColor == right.SKUColor && left.SKUSize == right.SKUSize && left.Quantity == right.Quantity && left.MaxTotalPrice == right.MaxTotalPrice
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Package tasks 定义手工 DRAFT 任务的校验与窄仓储边界。
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxTitleLength = 120
|
||||
maxSKUText = 80
|
||||
)
|
||||
|
||||
var ErrCreateKeyConflict = errors.New("create key conflicts with a different task")
|
||||
|
||||
type Draft struct {
|
||||
ID string
|
||||
Title string
|
||||
GoodsID string
|
||||
SKUColor string
|
||||
SKUSize string
|
||||
Quantity int
|
||||
MaxTotalPrice string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Form struct{ CreateKey, Title, ProductURL, SKUColor, SKUSize, Quantity, MaxTotalPrice string }
|
||||
type Errors map[string]string
|
||||
|
||||
func (errors Errors) Valid() bool { return len(errors) == 0 }
|
||||
|
||||
// Validate trims and normalizes a user form. It never reads a product page or derives price data.
|
||||
func Validate(form Form) (Draft, Errors) {
|
||||
draft := Draft{ID: strings.TrimSpace(form.CreateKey), Title: strings.TrimSpace(form.Title), SKUColor: strings.TrimSpace(form.SKUColor), SKUSize: strings.TrimSpace(form.SKUSize)}
|
||||
errors := Errors{}
|
||||
if !validUUID(draft.ID) {
|
||||
errors["create_key"] = "创建请求已过期,请重新打开表单。"
|
||||
}
|
||||
if draft.Title == "" || len([]rune(draft.Title)) > maxTitleLength {
|
||||
errors["title"] = "任务名称不能为空,且不能超过 120 个字符。"
|
||||
}
|
||||
if draft.SKUColor == "" || len([]rune(draft.SKUColor)) > maxSKUText {
|
||||
errors["sku_color"] = "颜色分类不能为空,且不能超过 80 个字符。"
|
||||
}
|
||||
if draft.SKUSize == "" || len([]rune(draft.SKUSize)) > maxSKUText {
|
||||
errors["sku_size"] = "尺码不能为空,且不能超过 80 个字符。"
|
||||
}
|
||||
goodsID, ok := CanonicalGoodsID(strings.TrimSpace(form.ProductURL))
|
||||
if !ok {
|
||||
errors["product_url"] = "请输入唯一的 canonical 商品链接。"
|
||||
} else {
|
||||
draft.GoodsID = goodsID
|
||||
}
|
||||
quantity, err := strconv.ParseInt(strings.TrimSpace(form.Quantity), 10, 0)
|
||||
if err != nil || quantity < 1 {
|
||||
errors["quantity"] = "数量必须是正整数。"
|
||||
} else {
|
||||
draft.Quantity = int(quantity)
|
||||
}
|
||||
money, ok := normalizeMoney(strings.TrimSpace(form.MaxTotalPrice))
|
||||
if !ok {
|
||||
errors["max_total_price"] = "价格上限必须大于零,且最多两位小数。"
|
||||
} else {
|
||||
draft.MaxTotalPrice = money
|
||||
}
|
||||
return draft, errors
|
||||
}
|
||||
|
||||
// CanonicalGoodsID only accepts the one verified manual-entry URL shape; untrusted query data is discarded.
|
||||
func CanonicalGoodsID(value string) (string, bool) {
|
||||
if value == "" || strings.Contains(value, "\\") || strings.Contains(value, "%") {
|
||||
return "", false
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(value)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host != "mobile.yangkeduo.com" || parsed.User != nil || parsed.Port() != "" || parsed.Path != "/goods.html" || parsed.Fragment != "" {
|
||||
return "", false
|
||||
}
|
||||
values, err := url.ParseQuery(parsed.RawQuery)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
goodsIDs := values["goods_id"]
|
||||
if len(goodsIDs) != 1 || goodsIDs[0] == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, character := range goodsIDs[0] {
|
||||
if character < '0' || character > '9' {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return goodsIDs[0], true
|
||||
}
|
||||
|
||||
func CanonicalURL(goodsID string) string {
|
||||
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID
|
||||
}
|
||||
|
||||
func NewCreateKey() (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
hexValue := hex.EncodeToString(bytes)
|
||||
return hexValue[0:8] + "-" + hexValue[8:12] + "-" + hexValue[12:16] + "-" + hexValue[16:20] + "-" + hexValue[20:32], nil
|
||||
}
|
||||
|
||||
func validUUID(value string) bool {
|
||||
if len(value) != 36 {
|
||||
return false
|
||||
}
|
||||
for index, character := range value {
|
||||
if index == 8 || index == 13 || index == 18 || index == 23 {
|
||||
if character != '-' {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b')
|
||||
}
|
||||
|
||||
func normalizeMoney(value string) (string, bool) {
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) > 2 || parts[0] == "" || len(parts) == 2 && (len(parts[1]) == 0 || len(parts[1]) > 2) {
|
||||
return "", false
|
||||
}
|
||||
for _, character := range parts[0] {
|
||||
if character < '0' || character > '9' {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
fraction := ""
|
||||
if len(parts) == 2 {
|
||||
fraction = parts[1]
|
||||
for _, character := range fraction {
|
||||
if character < '0' || character > '9' {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
}
|
||||
whole := strings.TrimLeft(parts[0], "0")
|
||||
if whole == "" {
|
||||
whole = "0"
|
||||
}
|
||||
if whole == "0" && strings.Trim(fraction, "0") == "" {
|
||||
return "", false
|
||||
}
|
||||
return whole + "." + (fraction + "00")[:2], true
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/migrations"
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
const testKey = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
|
||||
func TestValidateNormalizesManualDraft(t *testing.T) {
|
||||
draft, validation := Validate(Form{
|
||||
CreateKey: " " + testKey + " ",
|
||||
Title: " 夏季上衣 ",
|
||||
ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=untrusted",
|
||||
SKUColor: " 黑色CHA(纯棉) ",
|
||||
SKUSize: " M(建议100-115) ",
|
||||
Quantity: "2",
|
||||
MaxTotalPrice: "00012.8",
|
||||
})
|
||||
if !validation.Valid() {
|
||||
t.Fatalf("Validate errors = %#v", validation)
|
||||
}
|
||||
if draft.ID != testKey || draft.GoodsID != "937122477375" || draft.Title != "夏季上衣" || draft.SKUColor != "黑色CHA(纯棉)" || draft.SKUSize != "M(建议100-115)" || draft.Quantity != 2 || draft.MaxTotalPrice != "12.80" {
|
||||
t.Fatalf("normalized draft = %#v", draft)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
|
||||
base := Form{CreateKey: testKey, Title: "title", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", SKUColor: "black", SKUSize: "M", Quantity: "1", MaxTotalPrice: "1"}
|
||||
for name, update := range map[string]func(*Form){
|
||||
"empty title": func(form *Form) { form.Title = " " },
|
||||
"long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) },
|
||||
"fraction quantity": func(form *Form) { form.Quantity = "1.5" },
|
||||
"zero quantity": func(form *Form) { form.Quantity = "0" },
|
||||
"too many decimals": func(form *Form) { form.MaxTotalPrice = "1.234" },
|
||||
"trailing decimal": func(form *Form) { form.MaxTotalPrice = "1." },
|
||||
"zero money": func(form *Form) { form.MaxTotalPrice = "0.00" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
form := base
|
||||
update(&form)
|
||||
if _, validation := Validate(form); validation.Valid() {
|
||||
t.Fatal("invalid form was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, value := range []string{
|
||||
"http://mobile.yangkeduo.com/goods.html?goods_id=1",
|
||||
"https://yangkeduo.com/goods.html?goods_id=1",
|
||||
"https://mobile.yangkeduo.com:443/goods.html?goods_id=1",
|
||||
"https://user@mobile.yangkeduo.com/goods.html?goods_id=1",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=1#fragment",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=1&goods_id=2",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=one",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=%31",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=1%26goods_id%3D2",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=bad",
|
||||
"https://mobile.yangkeduo.com/other.html?goods_id=1",
|
||||
} {
|
||||
if _, ok := CanonicalGoodsID(value); ok {
|
||||
t.Fatalf("CanonicalGoodsID accepted %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMoneyBoundaries(t *testing.T) {
|
||||
for value, want := range map[string]string{"1": "1.00", "1.2": "1.20", "000.01": "0.01", "999999999999999999": "999999999999999999.00"} {
|
||||
got, ok := normalizeMoney(value)
|
||||
if !ok || got != want {
|
||||
t.Fatalf("normalizeMoney(%q) = (%q, %t), want (%q, true)", value, got, ok, want)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"0", "0.0", "0.00", "1.", ".1", "1.000", "-1", "1e2", " 1"} {
|
||||
if got, ok := normalizeMoney(value); ok {
|
||||
t.Fatalf("normalizeMoney(%q) = %q, want rejection", value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCreateKeyIsUUIDv4(t *testing.T) {
|
||||
key, err := NewCreateKey()
|
||||
if err != nil {
|
||||
t.Fatalf("NewCreateKey: %v", err)
|
||||
}
|
||||
if !regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`).MatchString(key) {
|
||||
t.Fatalf("create key %q is not UUID v4", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreRequiresMigratedDatabase(t *testing.T) {
|
||||
database := openDatabase(t)
|
||||
if _, err := NewSQLiteStore(database); err == nil {
|
||||
t.Fatal("NewSQLiteStore accepted an unmigrated database")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreCreatesListsAndHandlesIdempotency(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store, err := NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStore: %v", err)
|
||||
}
|
||||
baseTime := time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC)
|
||||
call := 0
|
||||
store.now = func() time.Time {
|
||||
result := baseTime.Add(time.Duration(call) * time.Minute)
|
||||
call++
|
||||
return result
|
||||
}
|
||||
first := testDraft(testKey, "first")
|
||||
created, err := store.CreateDraft(context.Background(), first)
|
||||
if err != nil {
|
||||
t.Fatalf("create first draft: %v", err)
|
||||
}
|
||||
replayed, err := store.CreateDraft(context.Background(), first)
|
||||
if err != nil {
|
||||
t.Fatalf("replay first draft: %v", err)
|
||||
}
|
||||
if replayed.CreatedAt != created.CreatedAt {
|
||||
t.Fatalf("replayed CreatedAt = %s, want original %s", replayed.CreatedAt, created.CreatedAt)
|
||||
}
|
||||
second := testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "second")
|
||||
if _, err := store.CreateDraft(context.Background(), second); err != nil {
|
||||
t.Fatalf("create second draft: %v", err)
|
||||
}
|
||||
drafts, err := store.ListDrafts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list drafts: %v", err)
|
||||
}
|
||||
if len(drafts) != 2 || drafts[0].ID != second.ID || drafts[1].ID != first.ID {
|
||||
t.Fatalf("draft order = %#v, want second then first", drafts)
|
||||
}
|
||||
var source, status string
|
||||
var version int
|
||||
if err := database.QueryRow(`SELECT source, status, version FROM tasks WHERE id = ?`, first.ID).Scan(&source, &status, &version); err != nil {
|
||||
t.Fatalf("read stored task: %v", err)
|
||||
}
|
||||
if source != "MANUAL" || status != "DRAFT" || version != 1 {
|
||||
t.Fatalf("stored metadata = (%q, %q, %d)", source, status, version)
|
||||
}
|
||||
|
||||
conflicting := first
|
||||
conflicting.Title = "different"
|
||||
if _, err := store.CreateDraft(context.Background(), conflicting); !errors.Is(err, ErrCreateKeyConflict) {
|
||||
t.Fatalf("conflicting create error = %v, want ErrCreateKeyConflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreRollsBackFailedCreate(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store, err := NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStore: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`CREATE TRIGGER reject_task BEFORE INSERT ON tasks BEGIN SELECT RAISE(ABORT, 'reject test insert'); END`); err != nil {
|
||||
t.Fatalf("create trigger: %v", err)
|
||||
}
|
||||
if _, err := store.CreateDraft(context.Background(), testDraft(testKey, "blocked")); err == nil {
|
||||
t.Fatal("CreateDraft succeeded despite rejecting trigger")
|
||||
}
|
||||
drafts, err := store.ListDrafts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list after failed create: %v", err)
|
||||
}
|
||||
if len(drafts) != 0 {
|
||||
t.Fatalf("failed create persisted drafts: %#v", drafts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreUsesInsertionOrderForEqualTimesAndFiltersPhase(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store, err := NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStore: %v", err)
|
||||
}
|
||||
store.now = func() time.Time { return time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) }
|
||||
first := testDraft(testKey, "first")
|
||||
second := testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "second")
|
||||
for _, draft := range []Draft{first, second} {
|
||||
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
|
||||
t.Fatalf("create %s: %v", draft.Title, err)
|
||||
}
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES ('excel-draft', 'EXCEL', 'other', '1', 'black', 'M', 1, '1.00', 'DRAFT', 1, '2026-08-04T10:00:00Z', '2026-08-04T10:00:00Z'), ('manual-pending', 'MANUAL', 'other', '2', 'black', 'M', 1, '1.00', 'PENDING', 1, '2026-08-04T10:00:00Z', '2026-08-04T10:00:00Z')`); err != nil {
|
||||
t.Fatalf("insert out-of-scope tasks: %v", err)
|
||||
}
|
||||
drafts, err := store.ListDrafts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list drafts: %v", err)
|
||||
}
|
||||
if len(drafts) != 2 || drafts[0].ID != second.ID || drafts[1].ID != first.ID {
|
||||
t.Fatalf("equal-time draft order/filter = %#v, want second then first only", drafts)
|
||||
}
|
||||
if _, err := database.Exec(`UPDATE tasks SET status = 'PENDING' WHERE id = ?`, first.ID); err != nil {
|
||||
t.Fatalf("move draft outside current phase: %v", err)
|
||||
}
|
||||
if _, err := store.CreateDraft(context.Background(), first); !errors.Is(err, ErrCreateKeyConflict) {
|
||||
t.Fatalf("replay of non-DRAFT record error = %v, want conflict", err)
|
||||
}
|
||||
third := testDraft("c3c9f507-7473-4fa6-8d71-8786c34c6301", "third")
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'EXCEL', ?, ?, ?, ?, ?, ?, 'DRAFT', 1, '2026-08-04T09:00:00Z', '2026-08-04T09:00:00Z')`, third.ID, third.Title, third.GoodsID, third.SKUColor, third.SKUSize, third.Quantity, third.MaxTotalPrice); err != nil {
|
||||
t.Fatalf("insert same-payload EXCEL record: %v", err)
|
||||
}
|
||||
if _, err := store.CreateDraft(context.Background(), third); !errors.Is(err, ErrCreateKeyConflict) {
|
||||
t.Fatalf("replay of non-MANUAL record error = %v, want conflict", err)
|
||||
}
|
||||
if _, err := database.Exec(`UPDATE tasks SET version = 2, source = 'MANUAL' WHERE id = ?`, third.ID); err != nil {
|
||||
t.Fatalf("change replay record version: %v", err)
|
||||
}
|
||||
if _, err := store.CreateDraft(context.Background(), third); !errors.Is(err, ErrCreateKeyConflict) {
|
||||
t.Fatalf("replay of non-v1 record error = %v, want conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreConcurrentIdenticalCreateIsOneDraft(t *testing.T) {
|
||||
store, err := NewSQLiteStore(migratedDatabase(t))
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStore: %v", err)
|
||||
}
|
||||
const callers = 20
|
||||
start := make(chan struct{})
|
||||
errors := make(chan error, callers)
|
||||
results := make(chan Draft, callers)
|
||||
var group sync.WaitGroup
|
||||
for range callers {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
<-start
|
||||
draft, err := store.CreateDraft(context.Background(), testDraft(testKey, "same"))
|
||||
if err != nil {
|
||||
errors <- err
|
||||
return
|
||||
}
|
||||
results <- draft
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
group.Wait()
|
||||
close(errors)
|
||||
close(results)
|
||||
for err := range errors {
|
||||
t.Fatalf("concurrent create: %v", err)
|
||||
}
|
||||
for result := range results {
|
||||
if result.ID != testKey {
|
||||
t.Fatalf("concurrent result = %#v", result)
|
||||
}
|
||||
}
|
||||
drafts, err := store.ListDrafts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list after concurrent create: %v", err)
|
||||
}
|
||||
if len(drafts) != 1 || drafts[0].ID != testKey {
|
||||
t.Fatalf("concurrent creates persisted %#v, want exactly one", drafts)
|
||||
}
|
||||
}
|
||||
|
||||
func testDraft(id, title string) Draft {
|
||||
return Draft{ID: id, Title: title, GoodsID: "937122477375", SKUColor: "black", SKUSize: "M", Quantity: 2, MaxTotalPrice: "12.80"}
|
||||
}
|
||||
|
||||
func openDatabase(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
database, err := sqlite.Open(filepath.Join(t.TempDir(), "tasks.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
return database
|
||||
}
|
||||
|
||||
func migratedDatabase(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
database := openDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("migrate database: %v", err)
|
||||
}
|
||||
return database
|
||||
}
|
||||
|
||||
func migrationDirectory(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("locate test source")
|
||||
}
|
||||
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{{define "login.html"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>登录 · 采购服务</title>
|
||||
<style>
|
||||
:root { color-scheme: light; --bg:#f4f7fb; --surface:#fff; --text:#172033; --muted:#526079; --border:#cfd8e6; --primary:#155eef; --primary-hover:#0b4ed1; --primary-soft:#eaf1ff; --danger:#b42318; --danger-soft:#fef3f2; --focus:#ffbf47; --shadow:0 12px 30px rgba(23,32,51,.1); font-family:"Segoe UI","Microsoft YaHei UI",system-ui,sans-serif; }
|
||||
* { box-sizing:border-box; }
|
||||
html { min-width:320px; background:var(--bg); }
|
||||
body { min-height:100dvh; margin:0; color:var(--text); background:var(--bg); font-size:16px; line-height:1.55; }
|
||||
button,input { font:inherit; }
|
||||
:focus-visible { outline:3px solid var(--focus); outline-offset:3px; }
|
||||
.skip-link { position:fixed; z-index:10; top:8px; left:8px; padding:10px 14px; color:#fff; background:var(--text); transform:translateY(-160%); }
|
||||
.skip-link:focus { transform:translateY(0); }
|
||||
main { display:grid; min-height:100dvh; place-items:center; padding:24px 16px; }
|
||||
.card { width:min(100%,440px); padding:32px; border:1px solid var(--border); border-radius:14px; background:var(--surface); box-shadow:var(--shadow); }
|
||||
.brand { display:flex; align-items:center; gap:10px; margin:0 0 24px; font-size:1rem; font-weight:700; }
|
||||
.brand-mark { display:grid; width:32px; height:32px; place-items:center; border-radius:8px; color:#fff; background:var(--primary); font-size:.82rem; }
|
||||
h1 { margin:0; font-size:clamp(1.6rem,5vw,2rem); line-height:1.25; }
|
||||
.intro { margin:8px 0 24px; color:var(--muted); }
|
||||
.field { margin-top:16px; }
|
||||
label { display:block; margin-bottom:6px; font-weight:700; }
|
||||
input { width:100%; min-height:44px; padding:10px 12px; border:1px solid #9ba9bc; border-radius:8px; color:var(--text); background:#fff; }
|
||||
input[aria-invalid="true"] { border-color:var(--danger); box-shadow:0 0 0 1px var(--danger); }
|
||||
.hint { margin:5px 0 0; color:var(--muted); font-size:.875rem; }
|
||||
.error { margin:0 0 18px; padding:12px 14px; border-left:4px solid var(--danger); border-radius:6px; color:var(--danger); background:var(--danger-soft); font-weight:650; }
|
||||
.submit { width:100%; min-height:44px; margin-top:24px; padding:10px 16px; border:1px solid transparent; border-radius:8px; color:#fff; background:var(--primary); font-weight:700; cursor:pointer; transition:background-color 180ms ease-out; }
|
||||
.submit:hover { background:var(--primary-hover); }
|
||||
.notice { margin:20px 0 0; padding:12px 14px; border:1px solid #b9cffc; border-radius:8px; color:#29466f; background:var(--primary-soft); font-size:.9rem; }
|
||||
@media (max-width:420px) { main { padding-inline:12px; } .card { padding:24px 16px; } }
|
||||
@media (prefers-reduced-motion:reduce) { *,*::before,*::after { transition-duration:.01ms !important; animation-duration:.01ms !important; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">跳到主要内容</a>
|
||||
<main id="main">
|
||||
<section class="card" aria-labelledby="login-title">
|
||||
<p class="brand"><span class="brand-mark" aria-hidden="true">采</span><span>采购服务</span></p>
|
||||
<h1 id="login-title">管理端登录</h1>
|
||||
<p class="intro">登录后进入采购任务工作台。设备身份不能使用此入口。</p>
|
||||
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="return_to" value="{{.ReturnTo}}">
|
||||
<div class="field">
|
||||
<label for="username">账号</label>
|
||||
<input id="username" name="username" type="text" value="{{.Username}}" autocomplete="username" required aria-invalid="{{if .Error}}true{{else}}false{{end}}" aria-describedby="username-hint">
|
||||
<p class="hint" id="username-hint">使用采购管理员账号登录。</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">密码</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required aria-invalid="{{if .Error}}true{{else}}false{{end}}">
|
||||
</div>
|
||||
<button class="submit" type="submit">登录并继续</button>
|
||||
</form>
|
||||
<p class="notice">系统只创建待付款订单,付款始终由人完成。</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,19 @@
|
||||
{{define "tasks.html"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>采购任务 · 采购服务</title>
|
||||
<style>
|
||||
:root{--bg:#f4f7fb;--surface:#fff;--text:#172033;--muted:#526079;--border:#cfd8e6;--primary:#155eef;--danger:#b42318;--success:#067647;--focus:#ffbf47;font-family:"Segoe UI","Microsoft YaHei UI",system-ui,sans-serif}*{box-sizing:border-box}html{min-width:320px;background:var(--bg)}body{min-height:100dvh;margin:0;color:var(--text);background:var(--bg);font-size:16px;line-height:1.55}button,input{font:inherit}:focus-visible{outline:3px solid var(--focus);outline-offset:3px}.skip{position:fixed;z-index:100;top:8px;left:8px;padding:10px;color:#fff;background:#172033;transform:translateY(-160%)}.skip:focus{transform:translateY(0)}header{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:64px;padding:10px clamp(16px,4vw,40px);border-bottom:1px solid var(--border);background:var(--surface)}.brand{font-weight:700}.brand b{display:inline-grid;place-items:center;width:32px;height:32px;margin-right:8px;border-radius:8px;background:var(--primary);color:#fff;font-size:.82rem}.logout,.button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:9px 14px;border:1px solid var(--border);border-radius:8px;color:var(--text);background:#fff;font-weight:700;text-decoration:none;cursor:pointer}.button.primary{border-color:var(--primary);background:var(--primary);color:#fff}.button:disabled,.filter input:disabled{opacity:.5;cursor:not-allowed}main{width:min(100% - 32px,1200px);margin:32px auto}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:16px}.toolbar-actions,.filters,.actions{display:flex;flex-wrap:wrap;gap:10px}.muted,.placeholder{color:var(--muted)}.filters{align-items:end;margin:0 0 16px}.filters label{display:grid;gap:4px;font-weight:700}.filters input{min-height:44px;min-width:180px;padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:#fff}.table-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:12px;background:var(--surface)}table{width:100%;min-width:880px;border-collapse:collapse}th,td{padding:12px 14px;border-bottom:1px solid var(--border);text-align:left;vertical-align:top}th{background:#f8fafc;font-size:.88rem}td a{color:#124cc5;font-weight:700;text-underline-offset:3px}.status{display:inline-block;padding:3px 8px;border-radius:999px;background:#eaf1ff;color:#173d8f;font-size:.85rem;font-weight:700}.empty,.success{padding:20px;border:1px solid var(--border);border-radius:12px;background:var(--surface)}.success{margin:0 0 16px;border-color:#9dd9b8;background:#ecfdf3;color:var(--success)}.modal-scrim{position:fixed;z-index:20;inset:0;background:rgba(23,32,51,.52)}dialog[open]{position:fixed;z-index:30;top:50%;left:50%;width:min(calc(100% - 24px),640px);max-height:calc(100dvh - 24px);margin:0;padding:28px;overflow-y:auto;border:1px solid var(--border);border-radius:14px;box-shadow:0 18px 48px rgba(23,32,51,.24);transform:translate(-50%,-50%);background:var(--surface)}.form-page{width:min(100% - 32px,640px);margin:32px auto;padding:28px;border:1px solid var(--border);border-radius:14px;background:var(--surface)}.form-grid{display:grid;gap:16px}.field label{display:block;margin-bottom:6px;font-weight:700}.required{color:var(--danger)}.field input{width:100%;min-height:44px;padding:10px 12px;border:1px solid #9ba9bc;border-radius:8px}.field input[aria-invalid=true]{border-color:var(--danger)}.error{margin:5px 0 0;color:var(--danger);font-size:.9rem}.summary{margin:0 0 16px;padding:12px;border-left:4px solid var(--danger);background:#fef3f2;color:var(--danger)}.summary p{margin:0}.summary ul{margin:8px 0 0;padding-left:20px}.summary a{color:inherit}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:420px){main,.form-page{width:calc(100% - 24px);margin:24px auto}.toolbar{align-items:stretch;flex-direction:column}.toolbar-actions,.toolbar .button{width:100%}.toolbar-actions .button{flex:1}.filters{align-items:stretch;flex-direction:column}.filters input,.filters .button{width:100%}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition-duration:.01ms!important;animation-duration:.01ms!important}}</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip" href="#main">跳到主要内容</a>
|
||||
<header><div class="brand"><b aria-hidden="true">采</b>采购服务</div><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button class="logout" type="submit">退出登录</button></form></header>
|
||||
{{if .FullPage}}<main class="form-page" id="main">{{template "form" .}}</main>{{else}}<main id="main"><div class="toolbar"><div><h1>采购任务</h1><p class="muted">只显示待开始的手工任务。</p></div><div class="toolbar-actions"><button class="button" type="button" disabled>导入</button><a class="button primary" href="/tasks?create=1">创建任务</a></div></div><div class="filters" aria-label="暂不可用的列表条件"><label>关键词<input type="search" disabled></label><button class="button" type="button" disabled>筛选</button><button class="button" type="button" disabled>清除</button></div>{{if .Success}}<p class="success" role="status">任务已创建,已显示在列表首行。</p>{{end}}{{if .Drafts}}<div class="table-wrap"><table><thead><tr><th scope="col"><input type="checkbox" disabled aria-label="选择全部任务"></th><th scope="col">标题</th><th scope="col">颜色分类</th><th scope="col">尺码</th><th scope="col">价格上限</th><th scope="col">数量</th><th scope="col">采购结果</th><th scope="col">状态</th><th scope="col">创建时间</th></tr></thead><tbody>{{range .Drafts}}<tr><td><input type="checkbox" disabled aria-label="选择任务 {{.Title}}"></td><td><a href="https://mobile.yangkeduo.com/goods.html?goods_id={{.GoodsID}}" target="_blank" rel="noopener noreferrer">{{.Title}}</a></td><td>{{.SKUColor}}</td><td>{{.SKUSize}}</td><td>¥{{.MaxTotalPrice}}</td><td>{{.Quantity}}</td><td>—</td><td><span class="status">待开始</span></td><td><time datetime="{{.CreatedAt.Format "2006-01-02T15:04:05Z07:00"}}">{{.CreatedAt.Format "2006-01-02 15:04 UTC"}}</time></td></tr>{{end}}</tbody></table></div>{{else}}<section class="empty"><h2>还没有待开始任务</h2><p>创建一条手工任务后会显示在这里。</p></section>{{end}}</main>{{if .OpenForm}}<div class="modal-scrim" aria-hidden="true"></div><dialog open aria-modal="true" aria-labelledby="form-title">{{template "form" .}}</dialog>{{end}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
{{define "form"}}<h1 id="form-title">创建采购任务</h1><p class="muted">保存后仅生成待开始任务,不会执行其他动作。</p>{{if .Errors}}<div class="summary" role="alert" aria-live="assertive"><p>请修正下列字段后再保存。</p><ul>{{with index .Errors "title"}}<li><a href="#title">任务名称:{{.}}</a></li>{{end}}{{with index .Errors "product_url"}}<li><a href="#product_url">商品链接:{{.}}</a></li>{{end}}{{with index .Errors "sku_color"}}<li><a href="#sku_color">颜色分类:{{.}}</a></li>{{end}}{{with index .Errors "sku_size"}}<li><a href="#sku_size">尺码:{{.}}</a></li>{{end}}{{with index .Errors "quantity"}}<li><a href="#quantity">数量:{{.}}</a></li>{{end}}{{with index .Errors "max_total_price"}}<li><a href="#max_total_price">价格上限:{{.}}</a></li>{{end}}{{with index .Errors "create_key"}}<li>{{.}}</li>{{end}}</ul></div>{{end}}<form method="post" action="/tasks" class="form-grid"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><input type="hidden" name="create_key" value="{{.Form.CreateKey}}"><input type="hidden" name="form_mode" value="{{if .FullPage}}full{{else}}dialog{{end}}">{{template "field" (list "title" "任务名称" .Form.Title .Errors .FocusField)}}{{template "field" (list "product_url" "商品链接" .Form.ProductURL .Errors .FocusField)}}{{template "field" (list "sku_color" "颜色分类" .Form.SKUColor .Errors .FocusField)}}{{template "field" (list "sku_size" "尺码" .Form.SKUSize .Errors .FocusField)}}{{template "field" (list "quantity" "数量" .Form.Quantity .Errors .FocusField)}}{{template "field" (list "max_total_price" "价格上限" .Form.MaxTotalPrice .Errors .FocusField)}}<div class="actions"><button class="button primary" type="submit">保存任务</button><a class="button" href="/tasks">取消</a></div></form>{{end}}
|
||||
{{define "field"}}{{$name:=index . 0}}{{$label:=index . 1}}{{$value:=index . 2}}{{$errors:=index . 3}}{{$focus:=index . 4}}<div class="field"><label for="{{$name}}">{{$label}} <span class="required" aria-hidden="true">*</span><span class="sr-only">(必填)</span></label><input id="{{$name}}" name="{{$name}}" value="{{$value}}" required {{if eq $focus $name}}autofocus{{end}} aria-invalid="{{if index $errors $name}}true{{else}}false{{end}}"{{with index $errors $name}} aria-describedby="{{$name}}-error"{{end}} {{if eq $name "product_url"}}type="url" inputmode="url" maxlength="2048"{{else if eq $name "quantity"}}type="number" inputmode="numeric" min="1" step="1"{{else if eq $name "max_total_price"}}type="text" inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?" maxlength="64"{{else if eq $name "title"}}type="text" maxlength="120"{{else}}type="text" maxlength="80"{{end}}>{{with index $errors $name}}<p class="error" id="{{$name}}-error">{{.}}</p>{{end}}</div>{{end}}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Package webui 渲染采购服务当前可用的服务端页面。
|
||||
package webui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"io"
|
||||
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
var templateFiles embed.FS
|
||||
|
||||
var templates = template.Must(template.New("webui").Funcs(template.FuncMap{"list": func(values ...any) []any { return values }}).ParseFS(templateFiles, "templates/*.html"))
|
||||
|
||||
// LoginData 是登录页面所需的非敏感展示数据。
|
||||
type LoginData struct {
|
||||
CSRFToken string
|
||||
ReturnTo string
|
||||
Username string
|
||||
Error string
|
||||
}
|
||||
|
||||
// TasksData 是受保护的 DRAFT 建单与列表页面所需数据。
|
||||
type TasksData struct {
|
||||
CSRFToken string
|
||||
Drafts []tasks.Draft
|
||||
Form tasks.Form
|
||||
Errors tasks.Errors
|
||||
OpenForm bool
|
||||
FullPage bool
|
||||
FocusField string
|
||||
Success bool
|
||||
}
|
||||
|
||||
// RenderLogin 写入登录页。
|
||||
func RenderLogin(writer io.Writer, data LoginData) error {
|
||||
return templates.ExecuteTemplate(writer, "login.html", data)
|
||||
}
|
||||
|
||||
// RenderTasks 写入登录后的受保护任务页。
|
||||
func RenderTasks(writer io.Writer, data TasksData) error {
|
||||
return templates.ExecuteTemplate(writer, "tasks.html", data)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,7 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# 桌面界面(Qt 官方 Python 绑定)。
|
||||
PySide6
|
||||
# 后续真机取证会使用;本阶段不导入或连接设备。
|
||||
# T-101 基线取证使用;只连接显式 serial,不打开或操作拼多多页面。
|
||||
uiautomator2
|
||||
# T-101 直接使用当前 ADB server 的已列出设备对象交给 uiautomator2,禁止 WiFi 自动重连。
|
||||
adbutils>=2.11,<3
|
||||
# 后续截图完整性检查会使用。
|
||||
Pillow
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1,75 @@
|
||||
"""打开已验证的拼多多商品直链并采集只读本地证据。"""
|
||||
|
||||
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 NoReconnectUiautomatorConnector
|
||||
from cmbuyer_client.pdd.product_open import ProductOpenCapturer, ProductOpenError
|
||||
from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="打开 canonical 拼多多商品链接并采集只读证据。")
|
||||
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||
parser.add_argument("--url", required=True, help="唯一允许的 goods.html?goods_id= 直链。")
|
||||
parser.add_argument("--output-dir", required=True, type=Path, help="新建的本地证据目录;不得覆盖已有目录。")
|
||||
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 和只读 RPC 超时(秒)。")
|
||||
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。")
|
||||
parse_product_url(arguments.url)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
validate_arguments(arguments)
|
||||
link = parse_product_url(arguments.url)
|
||||
except (ValueError, ProductUrlError) 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 = ProductOpenCapturer(client, connector, timeout_seconds=arguments.timeout)
|
||||
try:
|
||||
result = capturer.open_and_capture(arguments.serial, link.canonical_url, arguments.output_dir)
|
||||
except (DeviceConnectionError, ProductOpenError) as error:
|
||||
# 不打印 ADB 输出、serial、Activity、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())
|
||||
@@ -0,0 +1,103 @@
|
||||
"""采集人工已停在规格面板的三种状态证据;不执行任何页面操作。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from math import isfinite
|
||||
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 NoReconnectUiautomatorConnector
|
||||
from cmbuyer_client.pdd.product_url import ProductUrl, ProductUrlError, parse_product_url
|
||||
from cmbuyer_client.pdd.sku_panel_spike import (
|
||||
HUMAN_DECLARED_STATES,
|
||||
SkuPanelEvidenceCapturer,
|
||||
SkuPanelEvidenceError,
|
||||
)
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="采集人工已打开的拼多多规格面板状态证据。")
|
||||
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||
product_source = parser.add_mutually_exclusive_group(required=True)
|
||||
product_source.add_argument("--url", help="唯一 canonical goods.html?goods_id= 直链。")
|
||||
product_source.add_argument("--goods-id", help="纯数字商品标识;仅用于记录证据归属。")
|
||||
parser.add_argument("--state", required=True, choices=sorted(HUMAN_DECLARED_STATES), help="人工声明的面板状态。")
|
||||
parser.add_argument("--output-dir", required=True, type=Path, help="新建的本地证据目录;不得覆盖已有目录。")
|
||||
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 和只读 RPC 超时(秒)。")
|
||||
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def resolve_product_url(arguments: argparse.Namespace) -> ProductUrl:
|
||||
if isinstance(arguments.url, str):
|
||||
return parse_product_url(arguments.url)
|
||||
if isinstance(arguments.goods_id, str):
|
||||
# 仅使用严格 parser 重新验证并构建,不把输入交给 ADB 或页面。
|
||||
return parse_product_url(f"https://mobile.yangkeduo.com/goods.html?goods_id={arguments.goods_id}")
|
||||
raise ValueError("必须提供 --url 或 --goods-id。")
|
||||
|
||||
|
||||
def validate_arguments(arguments: argparse.Namespace) -> ProductUrl:
|
||||
if not isinstance(arguments.serial, str) or not arguments.serial.strip():
|
||||
raise ValueError("必须显式提供非空 --serial。")
|
||||
if (
|
||||
not isinstance(arguments.timeout, (int, float))
|
||||
or isinstance(arguments.timeout, bool)
|
||||
or arguments.timeout <= 0
|
||||
or not isfinite(arguments.timeout)
|
||||
):
|
||||
raise ValueError("--timeout 必须是大于 0 的有限数值。")
|
||||
if arguments.state not in HUMAN_DECLARED_STATES:
|
||||
raise ValueError("--state 必须是允许的人工声明状态。")
|
||||
return resolve_product_url(arguments)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
link = validate_arguments(arguments)
|
||||
except (ValueError, ProductUrlError) 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 = SkuPanelEvidenceCapturer(client, connector, timeout_seconds=arguments.timeout)
|
||||
try:
|
||||
result = capturer.capture(
|
||||
arguments.serial,
|
||||
link.canonical_url,
|
||||
arguments.state,
|
||||
arguments.output_dir,
|
||||
)
|
||||
except (DeviceConnectionError, SkuPanelEvidenceError) as error:
|
||||
# 不打印 ADB 输出、serial、Activity、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())
|
||||
@@ -0,0 +1,41 @@
|
||||
"""本机脱敏 T-103 raw 证据到同级 derived;不连接设备或解析页面语义。"""
|
||||
|
||||
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.sku_evidence_sanitizer import (
|
||||
SkuEvidenceSanitizationError,
|
||||
sanitize_sku_panel_evidence,
|
||||
)
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="将本机 raw 规格面板证据确定性脱敏到同级 derived。")
|
||||
parser.add_argument("--raw-dir", required=True, type=Path, help="仅允许名为 raw 的本机原始证据目录。")
|
||||
parser.add_argument("--output-dir", required=True, type=Path, help="仅允许 raw 同级且名为 derived 的新目录。")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
result = sanitize_sku_panel_evidence(arguments.raw_dir, arguments.output_dir)
|
||||
except SkuEvidenceSanitizationError as error:
|
||||
# 错误不回显 raw 路径、manifest/XML、地址、手机号或 serial。
|
||||
print(f"证据脱敏失败:{error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"派生证据脱敏完成:{result.output_directory}")
|
||||
print(f"manifest:{result.manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,15 @@
|
||||
"""设备连接与基线取证边界。
|
||||
|
||||
本包只提供显式设备选择、非敏感身份核验和本地基线采集;不包含任何采购页面或订单操作。
|
||||
"""
|
||||
|
||||
from .adb import AdbClient, AdbDevice, CommandResult
|
||||
from .baseline import BaselineCaptureResult, DeviceBaselineCapturer
|
||||
|
||||
__all__ = [
|
||||
"AdbClient",
|
||||
"AdbDevice",
|
||||
"BaselineCaptureResult",
|
||||
"CommandResult",
|
||||
"DeviceBaselineCapturer",
|
||||
]
|
||||
@@ -0,0 +1,282 @@
|
||||
"""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 通道同时在线。"""
|
||||
|
||||
|
||||
class IntentLaunchUnconfirmedError(DeviceConnectionError):
|
||||
"""`am start -W` 没有给出可确认的启动成功结果。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandResult:
|
||||
"""可注入命令执行器的最小、可离线构造结果。"""
|
||||
|
||||
stdout: str
|
||||
stderr: str = ""
|
||||
returncode: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntentLaunchSummary:
|
||||
"""不含 Activity、页面内容或 ADB 输出的受限启动摘要。"""
|
||||
|
||||
status: str
|
||||
returncode: int
|
||||
|
||||
|
||||
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 start_pdd_view_intent(self, serial: str, goods_id: str) -> IntentLaunchSummary:
|
||||
"""以参数数组启动唯一允许的拼多多 ACTION_VIEW Intent。
|
||||
|
||||
这里刻意不提供任意 shell 或任意 package 的执行接口。调用方必须先完成
|
||||
``inspect`` 和应用版本核验;本方法在本层从纯数字 ``goods_id`` 重建 URL,调用方不能
|
||||
把另一个 URL 直接交给 ADB。本方法既不点击控件,也不解析 Activity 或页面文本。
|
||||
"""
|
||||
|
||||
selected_serial = _require_serial(serial)
|
||||
if (
|
||||
not isinstance(goods_id, str)
|
||||
or not goods_id
|
||||
or any(character < "0" or character > "9" for character in goods_id)
|
||||
):
|
||||
raise ValueError("goods_id 必须是纯数字")
|
||||
canonical_url = f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}"
|
||||
result = self._run_checked(
|
||||
(
|
||||
"-s",
|
||||
selected_serial,
|
||||
"shell",
|
||||
"am",
|
||||
"start",
|
||||
"-W",
|
||||
"-a",
|
||||
"android.intent.action.VIEW",
|
||||
"-d",
|
||||
canonical_url,
|
||||
"-p",
|
||||
"com.xunmeng.pinduoduo",
|
||||
)
|
||||
)
|
||||
if not any(line.strip() == "Status: ok" for line in result.stdout.splitlines()):
|
||||
raise IntentLaunchUnconfirmedError("商品链接启动结果无法确认,已停止后续取证。")
|
||||
return IntentLaunchSummary(status="ok", returncode=result.returncode)
|
||||
|
||||
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,576 @@
|
||||
"""T-103 原始规格面板证据的本机确定性隐私脱敏。
|
||||
|
||||
此模块只处理人工采集的本地文件:不连接设备、不识别规格;仅可按已取证的固定
|
||||
几何和严格格式,将跨隐私边界的价格叶节点投影到派生 XML。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from PIL import Image, ImageDraw, UnidentifiedImageError
|
||||
|
||||
from ..pdd.product_url import ProductUrl, ProductUrlError, parse_product_url
|
||||
from ..pdd.sku_panel_state import HUMAN_DECLARED_STATES
|
||||
|
||||
|
||||
SANITIZER_VERSION = "t103-privacy-v4"
|
||||
EXPECTED_GOODS_ID = "937122477375"
|
||||
EXPECTED_PDD_VERSION = "8.17.0"
|
||||
EXPECTED_DEVICE_MODEL = "PKG110"
|
||||
EXPECTED_ANDROID_VERSION = "16"
|
||||
EXPECTED_SCREENSHOT_WIDTH = 1080
|
||||
EXPECTED_SCREENSHOT_HEIGHT = 2376
|
||||
EXPECTED_XML_WIDTH = 1080
|
||||
EXPECTED_XML_HEIGHT = 2376
|
||||
_ARTIFACT_FILES = ("screenshot.png", "hierarchy.xml")
|
||||
_SHA256_RE = re.compile(r"[0-9a-f]{64}\Z")
|
||||
_BOUNDS_RE = re.compile(r"\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]\Z")
|
||||
_FULL_PHONE_RE = re.compile(r"(?:\+?86)?1[3-9]\d{9}")
|
||||
_MASKED_PHONE_RE = re.compile(r"1[3-9]\d\*{4}\d{4}")
|
||||
_MASK_TRANSLATION = str.maketrans({"*": "*", "•": "*", "·": "*", "×": "*", "x": "*", "X": "*"})
|
||||
_SEPARATOR_RE = re.compile(r"[\s\-‐‑‒–—―()()]+")
|
||||
# 这两个槽位来自 T-103 当前第一态、1080×2376 XML 坐标的人工审查。它们不是通用
|
||||
# 页面判据;坐标、文本或结构任何变化都停止发布,交由人重新取证。
|
||||
_CROSSING_PRICE_SLOTS = {
|
||||
(396, 503, 712, 570): "[396,503][712,570]",
|
||||
(730, 503, 895, 570): "[730,503][895,570]",
|
||||
}
|
||||
_CROSSING_PRICE_BOUNDS = frozenset(_CROSSING_PRICE_SLOTS)
|
||||
_PRICE_PROJECTION_ATTRIBUTES = (
|
||||
"bounds",
|
||||
"text",
|
||||
"package",
|
||||
"class",
|
||||
"clickable",
|
||||
"enabled",
|
||||
"visible-to-user",
|
||||
)
|
||||
# 仅接受普通 ASCII 空格,且每个可分隔位置最多一个;禁止换行、折扣、支付/提交文案和
|
||||
# 任何其它字符。前缀捕获组用于区分当前价与至多一个划线/原价候选。
|
||||
_CROSSING_PRICE_TEXT_RE = re.compile(r" {0,1}(?:(快卖光) {0,1})?[¥¥] {0,1}[1-9]\d*\.\d{2} {0,1}\Z")
|
||||
_CROSSING_PRICE_PREFIX_RE = re.compile(r" {0,1}(?:快卖光 {0,1})?[¥¥] {0,1}[1-9]\d*\.\d{2} {0,1}")
|
||||
_CROSSING_PRICE_ALLOWED_CHARACTERS = frozenset(" 快卖光¥¥0123456789.")
|
||||
|
||||
|
||||
class SkuEvidenceSanitizationError(RuntimeError):
|
||||
"""原始证据不能被安全地发布为派生证据。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CleanupStats:
|
||||
"""仅记录节点数量,供派生 manifest 审计;不记录任何页面文本。"""
|
||||
|
||||
removed_nodes: int = 0
|
||||
cleared_crossing_nodes: int = 0
|
||||
preserved_crossing_price_nodes: int = 0
|
||||
retained_below_nodes: int = 0
|
||||
max_right: int = 0
|
||||
max_bottom: int = 0
|
||||
current_price_candidates: int = 0
|
||||
original_price_candidates: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PrivacyMaskConfig:
|
||||
"""仅描述已人工确认的隐私几何区域,绝不承担页面或规格判据。"""
|
||||
|
||||
version: str
|
||||
screenshot_width: int
|
||||
screenshot_height: int
|
||||
xml_width: int
|
||||
xml_height: int
|
||||
privacy_top: int
|
||||
|
||||
|
||||
PRIVACY_MASK_CONFIG = PrivacyMaskConfig(
|
||||
version=SANITIZER_VERSION,
|
||||
screenshot_width=EXPECTED_SCREENSHOT_WIDTH,
|
||||
screenshot_height=EXPECTED_SCREENSHOT_HEIGHT,
|
||||
xml_width=EXPECTED_XML_WIDTH,
|
||||
xml_height=EXPECTED_XML_HEIGHT,
|
||||
# 主审在原始截图确认 y < 540 为收货/手机号区域;整宽遮罩优先保护隐私而非保留版面。
|
||||
privacy_top=540,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkuEvidenceSanitizationResult:
|
||||
"""已经原子发布的派生证据位置。"""
|
||||
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
screenshot_path: Path
|
||||
hierarchy_path: Path
|
||||
|
||||
|
||||
def sanitize_sku_panel_evidence(raw_directory: Path, output_directory: Path) -> SkuEvidenceSanitizationResult:
|
||||
"""校验 raw 三文件,并发布同级 ``derived`` 的脱敏副本。
|
||||
|
||||
目标已存在时在读取任何输入前拒绝,避免混入旧派生物或覆盖人工保留文件。
|
||||
"""
|
||||
|
||||
raw = Path(raw_directory)
|
||||
target = Path(output_directory)
|
||||
_validate_directories(raw, target)
|
||||
if target.exists():
|
||||
raise SkuEvidenceSanitizationError("派生证据目录已存在,拒绝覆盖。")
|
||||
|
||||
staging: Path | None = None
|
||||
try:
|
||||
source_manifest_path = _required_file(raw, "manifest.json")
|
||||
source_screenshot_path = _required_file(raw, "screenshot.png")
|
||||
source_hierarchy_path = _required_file(raw, "hierarchy.xml")
|
||||
manifest = _read_source_manifest(source_manifest_path)
|
||||
link, state, source_hashes = _validate_source_manifest(manifest)
|
||||
_verify_source_hashes(source_screenshot_path, source_hierarchy_path, source_hashes)
|
||||
|
||||
staging = raw.parent / f".derived.staging-{uuid4().hex}"
|
||||
staging.mkdir()
|
||||
derived_screenshot_path = staging / "screenshot.png"
|
||||
_sanitize_screenshot(source_screenshot_path, derived_screenshot_path)
|
||||
derived_hierarchy_path = staging / "hierarchy.xml"
|
||||
cleanup_stats = _sanitize_hierarchy(source_hierarchy_path, derived_hierarchy_path)
|
||||
|
||||
derived_manifest_path = staging / "manifest.json"
|
||||
derived_manifest_path.write_text(
|
||||
json.dumps(
|
||||
_derived_manifest(
|
||||
manifest,
|
||||
link,
|
||||
state,
|
||||
source_manifest_path,
|
||||
source_screenshot_path,
|
||||
source_hierarchy_path,
|
||||
derived_screenshot_path,
|
||||
derived_hierarchy_path,
|
||||
cleanup_stats,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
_publish_staging(staging, target)
|
||||
except SkuEvidenceSanitizationError:
|
||||
_clean_staging(staging)
|
||||
raise
|
||||
except (OSError, ValueError, ElementTree.ParseError, UnidentifiedImageError) as error:
|
||||
_clean_staging(staging)
|
||||
# 原始异常可能含文件路径、JSON/XML 文本或其他敏感内容,不能向 CLI/日志传播。
|
||||
raise SkuEvidenceSanitizationError("原始证据无法安全脱敏,未发布任何派生产物。") from error
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuEvidenceSanitizationError("原始证据脱敏未完成,未发布任何派生产物。") from error
|
||||
|
||||
return SkuEvidenceSanitizationResult(
|
||||
output_directory=target,
|
||||
manifest_path=target / "manifest.json",
|
||||
screenshot_path=target / "screenshot.png",
|
||||
hierarchy_path=target / "hierarchy.xml",
|
||||
)
|
||||
|
||||
|
||||
def _validate_directories(raw: Path, target: Path) -> None:
|
||||
if raw.name != "raw" or not raw.is_dir():
|
||||
raise SkuEvidenceSanitizationError("原始证据目录必须是存在的 raw 目录。")
|
||||
if target.name != "derived" or target.parent != raw.parent:
|
||||
raise SkuEvidenceSanitizationError("派生证据目录必须是 raw 同级的 derived 目录。")
|
||||
|
||||
|
||||
def _required_file(raw: Path, filename: str) -> Path:
|
||||
candidate = raw / filename
|
||||
if not candidate.is_file():
|
||||
raise SkuEvidenceSanitizationError("原始证据文件集合不完整。")
|
||||
return candidate
|
||||
|
||||
|
||||
def _read_source_manifest(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise SkuEvidenceSanitizationError("原始证据 manifest 无效。") from error
|
||||
if not isinstance(value, dict):
|
||||
raise SkuEvidenceSanitizationError("原始证据 manifest 结构无效。")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_source_manifest(manifest: dict[str, Any]) -> tuple[ProductUrl, str, dict[str, str]]:
|
||||
product = manifest.get("product")
|
||||
device = manifest.get("device")
|
||||
state = manifest.get("human_declared_state")
|
||||
if manifest.get("schema_version") != 1 or not isinstance(product, dict) or not isinstance(device, dict):
|
||||
raise SkuEvidenceSanitizationError("原始证据 manifest 缺少必要元数据。")
|
||||
canonical_url = product.get("canonical_url")
|
||||
goods_id = product.get("goods_id")
|
||||
try:
|
||||
link = parse_product_url(canonical_url)
|
||||
except ProductUrlError as error:
|
||||
raise SkuEvidenceSanitizationError("原始证据商品元数据不匹配。") from error
|
||||
if (
|
||||
link.goods_id != EXPECTED_GOODS_ID
|
||||
or goods_id != EXPECTED_GOODS_ID
|
||||
or device.get("model") != EXPECTED_DEVICE_MODEL
|
||||
or device.get("android_version") != EXPECTED_ANDROID_VERSION
|
||||
or device.get("pdd_version") != EXPECTED_PDD_VERSION
|
||||
or device.get("pdd_package") != "com.xunmeng.pinduoduo"
|
||||
or not isinstance(state, str)
|
||||
or state not in HUMAN_DECLARED_STATES
|
||||
):
|
||||
raise SkuEvidenceSanitizationError("原始证据元数据与脱敏配置不匹配。")
|
||||
return link, state, _artifact_hashes(manifest)
|
||||
|
||||
|
||||
def _artifact_hashes(manifest: dict[str, Any]) -> dict[str, str]:
|
||||
artifacts = manifest.get("artifacts")
|
||||
if not isinstance(artifacts, list):
|
||||
raise SkuEvidenceSanitizationError("原始证据 manifest 缺少文件校验信息。")
|
||||
hashes: dict[str, str] = {}
|
||||
for artifact in artifacts:
|
||||
if not isinstance(artifact, dict):
|
||||
raise SkuEvidenceSanitizationError("原始证据 manifest 文件校验信息无效。")
|
||||
path = artifact.get("path")
|
||||
digest = artifact.get("sha256")
|
||||
if path not in _ARTIFACT_FILES or path in hashes or not isinstance(digest, str) or not _SHA256_RE.fullmatch(digest):
|
||||
raise SkuEvidenceSanitizationError("原始证据 manifest 文件校验信息无效。")
|
||||
hashes[path] = digest
|
||||
if set(hashes) != set(_ARTIFACT_FILES):
|
||||
raise SkuEvidenceSanitizationError("原始证据 manifest 文件校验信息不完整。")
|
||||
return hashes
|
||||
|
||||
|
||||
def _verify_source_hashes(screenshot_path: Path, hierarchy_path: Path, expected: dict[str, str]) -> None:
|
||||
if (
|
||||
_sha256_file(screenshot_path) != expected["screenshot.png"]
|
||||
or _sha256_file(hierarchy_path) != expected["hierarchy.xml"]
|
||||
):
|
||||
raise SkuEvidenceSanitizationError("原始证据文件校验失败。")
|
||||
|
||||
|
||||
def _sanitize_screenshot(source: Path, target: Path) -> None:
|
||||
try:
|
||||
with Image.open(source) as image:
|
||||
image.load()
|
||||
if image.format != "PNG" or image.size != (
|
||||
PRIVACY_MASK_CONFIG.screenshot_width,
|
||||
PRIVACY_MASK_CONFIG.screenshot_height,
|
||||
):
|
||||
raise SkuEvidenceSanitizationError("原始截图分辨率或格式与脱敏配置不匹配。")
|
||||
sanitized = image.convert("RGBA")
|
||||
except SkuEvidenceSanitizationError:
|
||||
raise
|
||||
except (OSError, UnidentifiedImageError) as error:
|
||||
raise SkuEvidenceSanitizationError("原始截图无效。") from error
|
||||
|
||||
# 用不透明黑色覆盖 y < 540,保证截图与 XML 使用相同的隐私几何边界。
|
||||
ImageDraw.Draw(sanitized).rectangle(
|
||||
(0, 0, PRIVACY_MASK_CONFIG.screenshot_width - 1, PRIVACY_MASK_CONFIG.privacy_top - 1),
|
||||
fill=(0, 0, 0, 255),
|
||||
)
|
||||
sanitized.save(target, format="PNG", optimize=False, compress_level=9)
|
||||
|
||||
|
||||
def _sanitize_hierarchy(source: Path, target: Path) -> _CleanupStats:
|
||||
try:
|
||||
root = ElementTree.parse(source).getroot()
|
||||
except (OSError, ElementTree.ParseError) as error:
|
||||
raise SkuEvidenceSanitizationError("原始节点树无效。") from error
|
||||
if root.tag != "hierarchy":
|
||||
raise SkuEvidenceSanitizationError("原始节点树结构不匹配。")
|
||||
if not list(root):
|
||||
raise SkuEvidenceSanitizationError("原始节点树结构不匹配。")
|
||||
stats = _CleanupStats()
|
||||
_clear_node_text(root)
|
||||
for child in list(root):
|
||||
_sanitize_node(root, child, stats)
|
||||
_require_expected_xml_coordinate_space(stats)
|
||||
if stats.removed_nodes < 1 or stats.retained_below_nodes < 1:
|
||||
raise SkuEvidenceSanitizationError("原始节点树未满足隐私几何结构。")
|
||||
_require_safe_crossing_price_projection(stats)
|
||||
if _contains_phone(root):
|
||||
raise SkuEvidenceSanitizationError("派生节点树仍包含手机号,拒绝发布。")
|
||||
ElementTree.ElementTree(root).write(target, encoding="utf-8", xml_declaration=True)
|
||||
return stats
|
||||
|
||||
|
||||
def _sanitize_node(parent: ElementTree.Element, node: ElementTree.Element, stats: _CleanupStats) -> None:
|
||||
if node.tag != "node":
|
||||
raise SkuEvidenceSanitizationError("原始节点树结构不匹配。")
|
||||
bounds = _parse_bounds(node.get("bounds"))
|
||||
_observe_bounds(stats, bounds)
|
||||
position = _vertical_position(bounds)
|
||||
if position == "private":
|
||||
# 私有带内的父节点不可以悄然包含下方子节点,否则会把仍需审计的下方内容一起丢失。
|
||||
for descendant in node.iter("node"):
|
||||
descendant_bounds = _parse_bounds(descendant.get("bounds"))
|
||||
_observe_bounds(stats, descendant_bounds)
|
||||
if _vertical_position(descendant_bounds) != "private":
|
||||
raise SkuEvidenceSanitizationError("原始节点树 bounds 结构不匹配。")
|
||||
stats.removed_nodes += sum(1 for _ in node.iter("node"))
|
||||
parent.remove(node)
|
||||
return
|
||||
if position == "crossing":
|
||||
if bounds in _CROSSING_PRICE_BOUNDS and node.get("text"):
|
||||
_project_crossing_price_node(node, bounds, stats)
|
||||
else:
|
||||
# 全屏/跨界容器可保留其下方子节点,但自身所有属性和文本都可能含地址或手机号。
|
||||
_clear_node_text(node)
|
||||
stats.cleared_crossing_nodes += 1
|
||||
else:
|
||||
stats.retained_below_nodes += 1
|
||||
for child in list(node):
|
||||
_sanitize_node(node, child, stats)
|
||||
|
||||
|
||||
def _parse_bounds(value: object) -> tuple[int, int, int, int]:
|
||||
if not isinstance(value, str):
|
||||
raise SkuEvidenceSanitizationError("原始节点树 bounds 缺失或无效。")
|
||||
match = _BOUNDS_RE.fullmatch(value)
|
||||
if match is None:
|
||||
raise SkuEvidenceSanitizationError("原始节点树 bounds 缺失或无效。")
|
||||
left, top, right, bottom = (int(group) for group in match.groups())
|
||||
if not (0 <= left < right and 0 <= top < bottom):
|
||||
raise SkuEvidenceSanitizationError("原始节点树 bounds 缺失或无效。")
|
||||
return left, top, right, bottom
|
||||
|
||||
|
||||
def _observe_bounds(stats: _CleanupStats, bounds: tuple[int, int, int, int]) -> None:
|
||||
_, _, right, bottom = bounds
|
||||
stats.max_right = max(stats.max_right, right)
|
||||
stats.max_bottom = max(stats.max_bottom, bottom)
|
||||
|
||||
|
||||
def _require_expected_xml_coordinate_space(stats: _CleanupStats) -> None:
|
||||
if (
|
||||
stats.max_right != PRIVACY_MASK_CONFIG.xml_width
|
||||
or stats.max_bottom != PRIVACY_MASK_CONFIG.xml_height
|
||||
):
|
||||
raise SkuEvidenceSanitizationError(
|
||||
f"原始节点树坐标范围不匹配(observed {stats.max_right}x{stats.max_bottom})。"
|
||||
)
|
||||
|
||||
|
||||
def _vertical_position(bounds: tuple[int, int, int, int]) -> str:
|
||||
_, top, _, bottom = bounds
|
||||
if bottom <= PRIVACY_MASK_CONFIG.privacy_top:
|
||||
return "private"
|
||||
if top >= PRIVACY_MASK_CONFIG.privacy_top:
|
||||
return "below"
|
||||
return "crossing"
|
||||
|
||||
|
||||
def _project_crossing_price_node(
|
||||
node: ElementTree.Element,
|
||||
bounds: tuple[int, int, int, int],
|
||||
stats: _CleanupStats,
|
||||
) -> None:
|
||||
"""投影唯一允许的跨界价格叶节点;任何结构漂移一律拒绝发布。"""
|
||||
|
||||
if (
|
||||
len(node) != 0
|
||||
or node.get("package") != "com.xunmeng.pinduoduo"
|
||||
or node.get("class") != "android.widget.TextView"
|
||||
or node.get("clickable") != "false"
|
||||
or node.get("enabled") != "true"
|
||||
or node.get("visible-to-user") != "true"
|
||||
):
|
||||
raise SkuEvidenceSanitizationError("跨界价格节点结构不匹配,拒绝发布。")
|
||||
text = node.get("text")
|
||||
if text is None:
|
||||
raise SkuEvidenceSanitizationError("跨界价格节点文本不匹配,拒绝发布。")
|
||||
match = _CROSSING_PRICE_TEXT_RE.fullmatch(text)
|
||||
if match is None:
|
||||
raise _crossing_price_text_mismatch_error(bounds, text)
|
||||
|
||||
# 只有这七项经上述检查后可进入派生 XML;尤其不复制 content-desc、resource-id 等原始属性。
|
||||
node.attrib = {attribute: node.attrib[attribute] for attribute in _PRICE_PROJECTION_ATTRIBUTES}
|
||||
node.text = None
|
||||
node.tail = None
|
||||
stats.preserved_crossing_price_nodes += 1
|
||||
if match.group(1) is not None:
|
||||
stats.current_price_candidates += 1
|
||||
else:
|
||||
stats.original_price_candidates += 1
|
||||
|
||||
|
||||
def _crossing_price_text_mismatch_error(
|
||||
bounds: tuple[int, int, int, int],
|
||||
text: str,
|
||||
) -> SkuEvidenceSanitizationError:
|
||||
"""仅输出固定槽位与 reason,避免将任意 raw 正文带入 CLI 或日志。"""
|
||||
|
||||
reason = _crossing_price_text_mismatch_reason(text)
|
||||
slot = _CROSSING_PRICE_SLOTS[bounds]
|
||||
return SkuEvidenceSanitizationError(f"跨界价格节点文本不匹配:slot={slot};reason={reason}。")
|
||||
|
||||
|
||||
def _crossing_price_text_mismatch_reason(text: str) -> str:
|
||||
"""将未匹配文本归类为受控枚举;返回值绝不包含原始片段。"""
|
||||
|
||||
if "\r" in text or "\n" in text:
|
||||
return "newline"
|
||||
if any(character.isspace() and character != " " for character in text):
|
||||
return "non_ascii_whitespace"
|
||||
if any(marker in text for marker in ("提交订单", "支付", "下单", "优惠")):
|
||||
return "extra_or_order"
|
||||
without_leading_space = text.lstrip(" ")
|
||||
if without_leading_space.startswith("快") and not without_leading_space.startswith("快卖光"):
|
||||
return "known_prefix_missing"
|
||||
if "¥" not in text and "¥" not in text:
|
||||
return "currency_missing"
|
||||
if _CROSSING_PRICE_PREFIX_RE.match(text) is not None:
|
||||
return "extra_or_order"
|
||||
if any(character not in _CROSSING_PRICE_ALLOWED_CHARACTERS for character in text):
|
||||
return "forbidden_characters"
|
||||
return "amount_shape"
|
||||
|
||||
|
||||
def _require_safe_crossing_price_projection(stats: _CleanupStats) -> None:
|
||||
"""当前价必须唯一;原价仅可选且唯一,避免把任意金额释放为价格证据。"""
|
||||
|
||||
if (
|
||||
stats.current_price_candidates != 1
|
||||
or stats.original_price_candidates > 1
|
||||
or stats.preserved_crossing_price_nodes != stats.current_price_candidates + stats.original_price_candidates
|
||||
):
|
||||
raise SkuEvidenceSanitizationError("跨界价格候选不唯一或缺失,拒绝发布。")
|
||||
|
||||
|
||||
def _clear_node_text(node: ElementTree.Element) -> None:
|
||||
node.attrib = {"bounds": node.attrib["bounds"]} if "bounds" in node.attrib else {}
|
||||
node.text = None
|
||||
node.tail = None
|
||||
|
||||
|
||||
def _contains_phone(root: ElementTree.Element) -> bool:
|
||||
"""逐项与跨节点复检电话,避免分隔符、遮罩字符或节点切分绕过。"""
|
||||
|
||||
all_values: list[str] = []
|
||||
content_values: list[str] = []
|
||||
for element in root.iter():
|
||||
if element.text:
|
||||
all_values.append(element.text)
|
||||
content_values.append(element.text)
|
||||
for attribute, value in element.attrib.items():
|
||||
all_values.append(value)
|
||||
if attribute != "bounds":
|
||||
content_values.append(value)
|
||||
if element.tail:
|
||||
all_values.append(element.tail)
|
||||
content_values.append(element.tail)
|
||||
normalized_values = [_normalize_phone_value(value) for value in all_values]
|
||||
normalized_all_document = "".join(normalized_values)
|
||||
normalized_document = "".join(_normalize_phone_value(value) for value in content_values)
|
||||
return (
|
||||
any(_matches_phone(value) for value in normalized_values)
|
||||
or _matches_phone(normalized_all_document)
|
||||
or _matches_phone(normalized_document)
|
||||
)
|
||||
|
||||
|
||||
def _normalize_phone_value(value: str) -> str:
|
||||
return _SEPARATOR_RE.sub("", value.translate(_MASK_TRANSLATION))
|
||||
|
||||
|
||||
def _matches_phone(value: str) -> bool:
|
||||
return _FULL_PHONE_RE.search(value) is not None or _MASKED_PHONE_RE.search(value) is not None
|
||||
|
||||
|
||||
def _derived_manifest(
|
||||
source_manifest: dict[str, Any],
|
||||
link: ProductUrl,
|
||||
state: str,
|
||||
source_manifest_path: Path,
|
||||
source_screenshot_path: Path,
|
||||
source_hierarchy_path: Path,
|
||||
derived_screenshot_path: Path,
|
||||
derived_hierarchy_path: Path,
|
||||
cleanup_stats: _CleanupStats,
|
||||
) -> dict[str, Any]:
|
||||
device = source_manifest["device"]
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"privacy_tier": "SANITIZED",
|
||||
"sanitizer_version": PRIVACY_MASK_CONFIG.version,
|
||||
"screenshot_space": {
|
||||
"width": PRIVACY_MASK_CONFIG.screenshot_width,
|
||||
"height": PRIVACY_MASK_CONFIG.screenshot_height,
|
||||
"privacy_mask_rectangle": [
|
||||
0,
|
||||
0,
|
||||
PRIVACY_MASK_CONFIG.screenshot_width,
|
||||
PRIVACY_MASK_CONFIG.privacy_top,
|
||||
],
|
||||
},
|
||||
"xml_coordinate_space": {
|
||||
"width": PRIVACY_MASK_CONFIG.xml_width,
|
||||
"height": PRIVACY_MASK_CONFIG.xml_height,
|
||||
"privacy_mask_rectangle": [0, 0, PRIVACY_MASK_CONFIG.xml_width, PRIVACY_MASK_CONFIG.privacy_top],
|
||||
"observed_max": {"right": cleanup_stats.max_right, "bottom": cleanup_stats.max_bottom},
|
||||
},
|
||||
"privacy_cleanup": {
|
||||
"removed_nodes": cleanup_stats.removed_nodes,
|
||||
"cleared_crossing_nodes": cleanup_stats.cleared_crossing_nodes,
|
||||
"preserved_crossing_price_nodes": cleanup_stats.preserved_crossing_price_nodes,
|
||||
"retained_below_nodes": cleanup_stats.retained_below_nodes,
|
||||
"max_right": cleanup_stats.max_right,
|
||||
"max_bottom": cleanup_stats.max_bottom,
|
||||
},
|
||||
"product": {"goods_id": link.goods_id},
|
||||
"human_declared_state": state,
|
||||
"device": {
|
||||
"model": device["model"],
|
||||
"android_version": device.get("android_version"),
|
||||
"pdd_package": device["pdd_package"],
|
||||
"pdd_version": device["pdd_version"],
|
||||
},
|
||||
# source hashes stay only in the local derived manifest; no raw path, serial or body is retained.
|
||||
"source": {
|
||||
"manifest_sha256": _sha256_file(source_manifest_path),
|
||||
"artifacts": [
|
||||
{"path": "screenshot.png", "sha256": _sha256_file(source_screenshot_path)},
|
||||
{"path": "hierarchy.xml", "sha256": _sha256_file(source_hierarchy_path)},
|
||||
],
|
||||
},
|
||||
"derived": {
|
||||
"artifacts": [
|
||||
{"path": "screenshot.png", "sha256": _sha256_file(derived_screenshot_path)},
|
||||
{"path": "hierarchy.xml", "sha256": _sha256_file(derived_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()
|
||||
|
||||
|
||||
def _clean_staging(staging: Path | None) -> None:
|
||||
if staging is not None and staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
|
||||
|
||||
def _publish_staging(staging: Path, target: Path) -> None:
|
||||
"""发布前二次检查,并使用目录 rename 而不是会覆盖目标的 replace。"""
|
||||
|
||||
if target.exists():
|
||||
raise SkuEvidenceSanitizationError("派生证据目录已存在,拒绝覆盖。")
|
||||
try:
|
||||
staging.rename(target)
|
||||
except OSError as error:
|
||||
# 竞态中新目标出现或文件系统拒绝 rename 时一律不尝试覆盖或重试。
|
||||
raise SkuEvidenceSanitizationError("派生证据目录发布失败,未覆盖已有目录。") from error
|
||||
@@ -0,0 +1,15 @@
|
||||
"""拼多多链接的受限打开与只读取证。
|
||||
|
||||
此包不提供页面选择器、输入、滑动、下单或支付能力。
|
||||
"""
|
||||
|
||||
from .product_open import ProductOpenCapturer, ProductOpenResult
|
||||
from .product_url import ProductUrl, ProductUrlError, parse_product_url
|
||||
|
||||
__all__ = [
|
||||
"ProductOpenCapturer",
|
||||
"ProductOpenResult",
|
||||
"ProductUrl",
|
||||
"ProductUrlError",
|
||||
"parse_product_url",
|
||||
]
|
||||
@@ -0,0 +1,262 @@
|
||||
"""安全打开 canonical 商品链接后的只读取证。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from math import isfinite
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from time import monotonic, sleep
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from adbutils.errors import AdbTimeout
|
||||
from uiautomator2.exceptions import HTTPTimeoutError
|
||||
|
||||
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection, IntentLaunchSummary
|
||||
from ..device.baseline import (
|
||||
HIERARCHY_PARAMS,
|
||||
PDD_PACKAGE,
|
||||
SCREENSHOT_PARAMS,
|
||||
_save_base64_screenshot,
|
||||
_sha256_file,
|
||||
_validate_hierarchy,
|
||||
)
|
||||
from .product_url import ProductUrl, parse_product_url
|
||||
|
||||
|
||||
EXPECTED_PDD_VERSION = "8.17.0"
|
||||
|
||||
|
||||
class ProductOpenError(RuntimeError):
|
||||
"""商品打开或证据发布未完整完成。"""
|
||||
|
||||
|
||||
class ProductVersionMismatchError(ProductOpenError):
|
||||
"""运行时拼多多版本不是经取证允许的版本。"""
|
||||
|
||||
|
||||
class ProductPackageMismatchError(ProductOpenError):
|
||||
"""Intent 后在有限时间内未观察到拼多多前台包。"""
|
||||
|
||||
|
||||
class ProductOpenTimeoutError(ProductOpenError):
|
||||
"""商品打开后的只读取证超时。"""
|
||||
|
||||
|
||||
class ProductScreenshotCaptureError(ProductOpenError):
|
||||
"""Intent 后截图不能作为完整 PNG 证据保存。"""
|
||||
|
||||
|
||||
class ProductHierarchyCaptureError(ProductOpenError):
|
||||
"""Intent 后完整节点树不能作为有效 XML 证据保存。"""
|
||||
|
||||
|
||||
class ProductOpenUiDevice(Protocol):
|
||||
"""本任务所需的只读 uiautomator2 接口;故意没有任何 UI 操作方法。"""
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]:
|
||||
"""读取应用元数据。"""
|
||||
|
||||
def app_current(self) -> dict[str, Any]:
|
||||
"""读取当前前台应用元数据。"""
|
||||
|
||||
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any:
|
||||
"""调用只读取证所需的公开 JSON-RPC 方法。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProductOpenResult:
|
||||
"""已原子发布的商品打开证据位置。"""
|
||||
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
screenshot_path: Path
|
||||
hierarchy_path: Path
|
||||
|
||||
|
||||
class ProductOpenCapturer:
|
||||
"""以 fail-closed 顺序打开已重建链接,并在打开后只读留证。
|
||||
|
||||
本类不判断商品页、Activity、文案或控件;打开后只确认当前 package,随后采集截图与
|
||||
完整节点树。任何失败都不会发布半成品证据目录。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adb_client: AdbClient,
|
||||
connector: Callable[[str], ProductOpenUiDevice],
|
||||
timeout_seconds: float,
|
||||
foreground_poll_interval_seconds: float = 0.2,
|
||||
monotonic_clock: Callable[[], float] = monotonic,
|
||||
sleep_function: Callable[[float], None] = sleep,
|
||||
) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
if not _is_positive_finite(foreground_poll_interval_seconds):
|
||||
raise ValueError("foreground_poll_interval_seconds 必须是大于 0 的有限数值")
|
||||
self._adb_client = adb_client
|
||||
self._connector = connector
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._foreground_poll_interval_seconds = foreground_poll_interval_seconds
|
||||
self._monotonic_clock = monotonic_clock
|
||||
self._sleep_function = sleep_function
|
||||
|
||||
def open_and_capture(self, serial: str, product_url: str, output_directory: Path) -> ProductOpenResult:
|
||||
"""完成唯一允许的 Intent 打开及其后的只读取证。"""
|
||||
|
||||
# 公共入口只接收原始字符串并每次重新解析,不能由调用方构造不一致的值对象伪造 manifest。
|
||||
link = parse_product_url(product_url)
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
|
||||
staging: Path | None = None
|
||||
try:
|
||||
# inspect 必须先于连接和 Intent,复用 T-101 的显式 serial、重复物理设备拒绝逻辑。
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
device = self._connector(serial)
|
||||
pdd_version = _require_expected_version(device.app_info(PDD_PACKAGE))
|
||||
|
||||
# 版本精确匹配是 Intent 的前置条件,失败时绝不调用 start_pdd_view_intent。
|
||||
intent = self._adb_client.start_pdd_view_intent(serial, link.goods_id)
|
||||
self._wait_for_pdd_foreground(device)
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
||||
staging.mkdir()
|
||||
screenshot_path = staging / "screenshot.png"
|
||||
try:
|
||||
_save_base64_screenshot(
|
||||
device.jsonrpc_call("takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds),
|
||||
screenshot_path,
|
||||
)
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError):
|
||||
raise
|
||||
except Exception as error:
|
||||
raise ProductScreenshotCaptureError("商品打开后截图取证失败,未发布任何证据产物。") from error
|
||||
|
||||
try:
|
||||
hierarchy = device.jsonrpc_call(
|
||||
"dumpWindowHierarchy",
|
||||
HIERARCHY_PARAMS,
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
_validate_hierarchy(hierarchy)
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError):
|
||||
raise
|
||||
except Exception as error:
|
||||
raise ProductHierarchyCaptureError("商品打开后节点树取证失败,未发布任何证据产物。") from error
|
||||
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, link, pdd_version, intent, screenshot_path, hierarchy_path),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(staging, target)
|
||||
except (ProductOpenError, DeviceConnectionError):
|
||||
_clean_staging(staging)
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
_clean_staging(staging)
|
||||
raise ProductOpenTimeoutError("商品打开后的只读取证超时,未发布任何证据产物。") from error
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
# 底层异常可能含 serial、路径或远端页面内容,不能直接向 CLI 或日志传播。
|
||||
raise ProductOpenError("商品打开或只读取证未完成,未发布任何证据产物。") from error
|
||||
|
||||
return ProductOpenResult(
|
||||
output_directory=target,
|
||||
manifest_path=target / "manifest.json",
|
||||
screenshot_path=target / "screenshot.png",
|
||||
hierarchy_path=target / "hierarchy.xml",
|
||||
)
|
||||
|
||||
def _wait_for_pdd_foreground(self, device: ProductOpenUiDevice) -> None:
|
||||
"""只轮询当前 package,直到 deadline;Activity 和节点树均不参与本判据。"""
|
||||
|
||||
deadline = self._monotonic_clock() + self._timeout_seconds
|
||||
while True:
|
||||
if _is_pdd_foreground(device.app_current()):
|
||||
return
|
||||
remaining = deadline - self._monotonic_clock()
|
||||
if remaining <= 0:
|
||||
raise ProductPackageMismatchError(
|
||||
"商品链接打开后未在限定时间内进入拼多多,已停止后续取证。"
|
||||
)
|
||||
# 每个失败观察后都等待正的、受 deadline 约束的时长,避免 busy-loop。
|
||||
self._sleep_function(min(self._foreground_poll_interval_seconds, remaining))
|
||||
|
||||
|
||||
def _validate_new_target(target: Path) -> None:
|
||||
if target.exists():
|
||||
raise ProductOpenError("输出目录已存在;为防止混入旧证据,拒绝覆盖。")
|
||||
if not target.name:
|
||||
raise ProductOpenError("输出目录必须是明确的新目录。")
|
||||
|
||||
|
||||
def _clean_staging(staging: Path | None) -> None:
|
||||
if staging is not None and staging.exists():
|
||||
# staging 仅在本次调用中创建,删除前不解析或扩展任何调用方提供的路径。
|
||||
shutil.rmtree(staging)
|
||||
|
||||
|
||||
def _require_expected_version(app_info: dict[str, Any]) -> str:
|
||||
if not isinstance(app_info, dict):
|
||||
raise ProductVersionMismatchError("拼多多版本与已取证版本不一致,已停止打开商品链接。")
|
||||
version = app_info.get("versionName") or app_info.get("version_name")
|
||||
if not isinstance(version, str) or version != EXPECTED_PDD_VERSION:
|
||||
raise ProductVersionMismatchError("拼多多版本与已取证版本不一致,已停止打开商品链接。")
|
||||
return version
|
||||
|
||||
|
||||
def _is_positive_finite(value: object) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
|
||||
|
||||
|
||||
def _is_pdd_foreground(current: object) -> bool:
|
||||
return isinstance(current, dict) and current.get("package") == PDD_PACKAGE
|
||||
|
||||
|
||||
def _manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
link: ProductUrl,
|
||||
pdd_version: str,
|
||||
intent: IntentLaunchSummary,
|
||||
screenshot_path: Path,
|
||||
hierarchy_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
"""只写审计摘要;原始 serial、Activity、ADB 输出和页面正文均不进入 manifest。"""
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
|
||||
"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,
|
||||
},
|
||||
"intent": {"status": intent.status, "returncode": intent.returncode},
|
||||
"current_package": PDD_PACKAGE,
|
||||
"artifacts": [
|
||||
{"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)},
|
||||
{"path": hierarchy_path.name, "sha256": _sha256_file(hierarchy_path)},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"""唯一允许交给 Android Intent 的商品链接。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import parse_qsl, urlsplit
|
||||
|
||||
|
||||
_SCHEME = "https"
|
||||
_HOST = "mobile.yangkeduo.com"
|
||||
_PATH = "/goods.html"
|
||||
|
||||
|
||||
class ProductUrlError(ValueError):
|
||||
"""输入不是可安全重建的 canonical 商品链接。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProductUrl:
|
||||
"""经验证的商品标识及由它重建的 canonical URL。"""
|
||||
|
||||
goods_id: str
|
||||
canonical_url: str
|
||||
|
||||
|
||||
def parse_product_url(value: str) -> ProductUrl:
|
||||
"""只接受一个 ASCII 数字 ``goods_id`` 的拼多多商品直链。
|
||||
|
||||
解析结果绝不原样透传:Intent 使用的 URL 必须从 ``goods_id`` 重新构建,以排除
|
||||
短链、额外参数、userinfo、fragment 和 URL 解析器的边缘表示。
|
||||
"""
|
||||
|
||||
if not isinstance(value, str):
|
||||
raise ProductUrlError("商品链接必须是字符串。")
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
port = parsed.port
|
||||
query_pairs = parse_qsl(parsed.query, keep_blank_values=True, strict_parsing=True)
|
||||
except ValueError as error:
|
||||
raise ProductUrlError("商品链接格式无效。") from error
|
||||
|
||||
if (
|
||||
parsed.scheme != _SCHEME
|
||||
or parsed.hostname != _HOST
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or port is not None
|
||||
or parsed.path != _PATH
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ProductUrlError("商品链接不是允许的拼多多商品直链。")
|
||||
if len(query_pairs) != 1 or query_pairs[0][0] != "goods_id":
|
||||
raise ProductUrlError("商品链接必须且只能包含一个 goods_id 参数。")
|
||||
|
||||
goods_id = query_pairs[0][1]
|
||||
if not goods_id or any(character < "0" or character > "9" for character in goods_id):
|
||||
raise ProductUrlError("goods_id 必须是纯数字。")
|
||||
canonical_url = f"{_SCHEME}://{_HOST}{_PATH}?goods_id={goods_id}"
|
||||
if value != canonical_url:
|
||||
raise ProductUrlError("商品链接必须使用唯一 canonical 表示。")
|
||||
return ProductUrl(goods_id=goods_id, canonical_url=canonical_url)
|
||||
@@ -0,0 +1,252 @@
|
||||
"""人工停留在规格面板后的只读取证。
|
||||
|
||||
本模块不识别规格面板,不打开商品链接,也不读取价格;三种面板状态完全由现场人员声明。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from math import isfinite
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from adbutils.errors import AdbTimeout
|
||||
from uiautomator2.exceptions import HTTPTimeoutError
|
||||
|
||||
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
|
||||
from ..device.baseline import (
|
||||
HIERARCHY_PARAMS,
|
||||
PDD_PACKAGE,
|
||||
SCREENSHOT_PARAMS,
|
||||
_save_base64_screenshot,
|
||||
_sha256_file,
|
||||
_validate_hierarchy,
|
||||
)
|
||||
from .product_open import EXPECTED_PDD_VERSION
|
||||
from .product_url import ProductUrl, parse_product_url
|
||||
from .sku_panel_state import HUMAN_DECLARED_STATES
|
||||
|
||||
|
||||
class SkuPanelEvidenceError(RuntimeError):
|
||||
"""人工规格面板证据无法完整发布。"""
|
||||
|
||||
|
||||
class SkuPanelDeclaredStateError(SkuPanelEvidenceError):
|
||||
"""调用方没有提供允许的人工声明状态。"""
|
||||
|
||||
|
||||
class SkuPanelVersionMismatchError(SkuPanelEvidenceError):
|
||||
"""运行时拼多多版本不是已取证版本。"""
|
||||
|
||||
|
||||
class SkuPanelPackageMismatchError(SkuPanelEvidenceError):
|
||||
"""人工声明前台不是拼多多时仍试图留证。"""
|
||||
|
||||
|
||||
class SkuPanelEvidenceTimeoutError(SkuPanelEvidenceError):
|
||||
"""只读截图或节点树取证超时。"""
|
||||
|
||||
|
||||
class SkuPanelScreenshotError(SkuPanelEvidenceError):
|
||||
"""截图不能保存为严格有效的 PNG。"""
|
||||
|
||||
|
||||
class SkuPanelHierarchyError(SkuPanelEvidenceError):
|
||||
"""节点树不能保存为严格有效的 XML。"""
|
||||
|
||||
|
||||
class SkuPanelUiDevice(Protocol):
|
||||
"""人工面板证据所需的只读接口,故意没有任何页面操作方法。"""
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]:
|
||||
"""读取应用元数据。"""
|
||||
|
||||
def app_current(self) -> dict[str, Any]:
|
||||
"""读取当前前台应用元数据。"""
|
||||
|
||||
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any:
|
||||
"""调用公开 JSON-RPC 的只读取证方法。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkuPanelEvidenceResult:
|
||||
"""已原子发布的本地证据目录。"""
|
||||
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
screenshot_path: Path
|
||||
hierarchy_path: Path
|
||||
|
||||
|
||||
class SkuPanelEvidenceCapturer:
|
||||
"""把人工已停留的面板状态留证,不对页面作任何自动结论。
|
||||
|
||||
状态字段命名为 ``human_declared_state``,防止消费者把本模块误解为自动面板/规格/价格识别。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adb_client: AdbClient,
|
||||
connector: Callable[[str], SkuPanelUiDevice],
|
||||
timeout_seconds: float,
|
||||
) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._adb_client = adb_client
|
||||
self._connector = connector
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
def capture(
|
||||
self,
|
||||
serial: str,
|
||||
product_url: str,
|
||||
human_declared_state: str,
|
||||
output_directory: Path,
|
||||
) -> SkuPanelEvidenceResult:
|
||||
"""采集人工已准备的状态;不会打开链接、面板或执行任何 UI 操作。"""
|
||||
|
||||
link = parse_product_url(product_url)
|
||||
state = _validate_human_declared_state(human_declared_state)
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
|
||||
staging: Path | None = None
|
||||
try:
|
||||
# 沿用 T-101 的显式 serial、在线状态与重复物理设备 fail-closed 核验。
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
device = self._connector(serial)
|
||||
pdd_version = _require_expected_version(device.app_info(PDD_PACKAGE))
|
||||
_require_pdd_foreground(device.app_current())
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
||||
staging.mkdir()
|
||||
screenshot_path = staging / "screenshot.png"
|
||||
try:
|
||||
_save_base64_screenshot(
|
||||
device.jsonrpc_call("takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds),
|
||||
screenshot_path,
|
||||
)
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError):
|
||||
raise
|
||||
except Exception as error:
|
||||
raise SkuPanelScreenshotError("规格面板截图取证失败,未发布任何证据产物。") from error
|
||||
|
||||
try:
|
||||
hierarchy = device.jsonrpc_call(
|
||||
"dumpWindowHierarchy",
|
||||
HIERARCHY_PARAMS,
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
_validate_hierarchy(hierarchy)
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError):
|
||||
raise
|
||||
except Exception as error:
|
||||
raise SkuPanelHierarchyError("规格面板节点树取证失败,未发布任何证据产物。") from error
|
||||
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, link, state, pdd_version, screenshot_path, hierarchy_path),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(staging, target)
|
||||
except (SkuPanelEvidenceError, DeviceConnectionError):
|
||||
_clean_staging(staging)
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuPanelEvidenceTimeoutError("规格面板只读取证超时,未发布任何证据产物。") from error
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
# 第三方异常可能含 serial、Activity 或页面正文,不能直接向 CLI/日志传播。
|
||||
raise SkuPanelEvidenceError("规格面板只读取证未完成,未发布任何证据产物。") from error
|
||||
|
||||
return SkuPanelEvidenceResult(
|
||||
output_directory=target,
|
||||
manifest_path=target / "manifest.json",
|
||||
screenshot_path=target / "screenshot.png",
|
||||
hierarchy_path=target / "hierarchy.xml",
|
||||
)
|
||||
|
||||
|
||||
def _is_positive_finite(value: object) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
|
||||
|
||||
|
||||
def _validate_human_declared_state(value: object) -> str:
|
||||
if not isinstance(value, str) or value not in HUMAN_DECLARED_STATES:
|
||||
raise SkuPanelDeclaredStateError("必须提供允许的人工声明规格面板状态。")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_new_target(target: Path) -> None:
|
||||
if target.exists():
|
||||
raise SkuPanelEvidenceError("输出目录已存在;为防止混入旧证据,拒绝覆盖。")
|
||||
if not target.name:
|
||||
raise SkuPanelEvidenceError("输出目录必须是明确的新目录。")
|
||||
|
||||
|
||||
def _clean_staging(staging: Path | None) -> None:
|
||||
if staging is not None and staging.exists():
|
||||
# staging 仅在本次调用中创建,绝不删除调用方已存在的目录。
|
||||
shutil.rmtree(staging)
|
||||
|
||||
|
||||
def _require_expected_version(app_info: object) -> str:
|
||||
if not isinstance(app_info, dict):
|
||||
raise SkuPanelVersionMismatchError("拼多多版本与已取证版本不一致,已停止取证。")
|
||||
version = app_info.get("versionName") or app_info.get("version_name")
|
||||
if not isinstance(version, str) or version != EXPECTED_PDD_VERSION:
|
||||
raise SkuPanelVersionMismatchError("拼多多版本与已取证版本不一致,已停止取证。")
|
||||
return version
|
||||
|
||||
|
||||
def _require_pdd_foreground(current: object) -> None:
|
||||
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
|
||||
raise SkuPanelPackageMismatchError("当前前台应用不是拼多多,已停止取证。")
|
||||
|
||||
|
||||
def _manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
link: ProductUrl,
|
||||
human_declared_state: str,
|
||||
pdd_version: str,
|
||||
screenshot_path: Path,
|
||||
hierarchy_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
"""仅记录人工声明与非敏感审计摘要,不写入 Activity 或页面内容。"""
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
|
||||
"human_declared_state": human_declared_state,
|
||||
"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)},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"""T-103 的人工声明证据状态;不含任何页面识别或规格语义。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
# 这些值由现场人员填写到 manifest,不能被解释为自动检测出的页面或选择状态。
|
||||
HUMAN_DECLARED_STATES = frozenset(
|
||||
{
|
||||
"panel-opened-target-preselected",
|
||||
"alternate-all-dimensions-selected",
|
||||
"target-selection-restored",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""设备连接与基线取证的离线测试。"""
|
||||
@@ -0,0 +1,273 @@
|
||||
"""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,
|
||||
DeviceCommandError,
|
||||
DeviceCommandTimeoutError,
|
||||
DeviceNotFoundError,
|
||||
DeviceOfflineError,
|
||||
DeviceStateError,
|
||||
DeviceUnauthorizedError,
|
||||
DuplicatePhysicalDeviceError,
|
||||
IntentLaunchUnconfirmedError,
|
||||
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)
|
||||
|
||||
with self.assertRaises(DeviceCommandTimeoutError):
|
||||
AdbClient(TimeoutRunner()).inspect(USB_SERIAL)
|
||||
|
||||
def test_product_intent_is_fixed_to_action_view_and_pdd_package(self) -> None:
|
||||
class IntentRunner:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, ...]] = []
|
||||
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
self.calls.append(tuple(arguments))
|
||||
return CommandResult(stdout="Status: ok\n")
|
||||
|
||||
runner = IntentRunner()
|
||||
summary = AdbClient(runner).start_pdd_view_intent(
|
||||
USB_SERIAL,
|
||||
"123",
|
||||
)
|
||||
|
||||
self.assertEqual(summary.status, "ok")
|
||||
self.assertEqual(
|
||||
runner.calls,
|
||||
[
|
||||
(
|
||||
"-s",
|
||||
USB_SERIAL,
|
||||
"shell",
|
||||
"am",
|
||||
"start",
|
||||
"-W",
|
||||
"-a",
|
||||
"android.intent.action.VIEW",
|
||||
"-d",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||
"-p",
|
||||
"com.xunmeng.pinduoduo",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_product_intent_without_explicit_success_is_rejected(self) -> None:
|
||||
class UnknownIntentRunner:
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
return CommandResult(stdout="Starting: Intent { ... }\n")
|
||||
|
||||
with self.assertRaises(IntentLaunchUnconfirmedError):
|
||||
AdbClient(UnknownIntentRunner()).start_pdd_view_intent(
|
||||
USB_SERIAL,
|
||||
"123",
|
||||
)
|
||||
|
||||
def test_product_intent_rejects_invalid_goods_id_before_runner(self) -> None:
|
||||
class RecordingRunner:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, ...]] = []
|
||||
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
self.calls.append(tuple(arguments))
|
||||
return CommandResult(stdout="Status: ok\n")
|
||||
|
||||
invalid_values: tuple[object, ...] = (
|
||||
"",
|
||||
"12a",
|
||||
"123",
|
||||
" 123",
|
||||
"123 ",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||
"am start -W -d anything",
|
||||
123,
|
||||
None,
|
||||
)
|
||||
for value in invalid_values:
|
||||
with self.subTest(value=repr(value)):
|
||||
runner = RecordingRunner()
|
||||
with self.assertRaises(ValueError):
|
||||
AdbClient(runner).start_pdd_view_intent(USB_SERIAL, value) # type: ignore[arg-type]
|
||||
self.assertEqual(runner.calls, [])
|
||||
|
||||
def test_product_intent_nonzero_and_timeout_remain_distinct(self) -> None:
|
||||
class FailedIntentRunner:
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
return CommandResult(stdout="sensitive command output", returncode=1)
|
||||
|
||||
class TimeoutIntentRunner:
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
raise subprocess.TimeoutExpired(arguments, timeout_seconds)
|
||||
|
||||
with self.assertRaises(DeviceCommandError) as command_error:
|
||||
AdbClient(FailedIntentRunner()).start_pdd_view_intent(USB_SERIAL, "123")
|
||||
self.assertNotIn("sensitive command output", str(command_error.exception))
|
||||
|
||||
with self.assertRaises(DeviceCommandTimeoutError):
|
||||
AdbClient(TimeoutIntentRunner()).start_pdd_view_intent(USB_SERIAL, "123")
|
||||
@@ -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)
|
||||
@@ -0,0 +1,699 @@
|
||||
"""T-103 脱敏器测试:全部证据为合成数据,绝不读取真实 raw 目录。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.sku_evidence_sanitizer import (
|
||||
EXPECTED_GOODS_ID,
|
||||
EXPECTED_SCREENSHOT_HEIGHT,
|
||||
EXPECTED_SCREENSHOT_WIDTH,
|
||||
EXPECTED_XML_HEIGHT,
|
||||
EXPECTED_XML_WIDTH,
|
||||
HUMAN_DECLARED_STATES,
|
||||
SkuEvidenceSanitizationError,
|
||||
sanitize_sku_panel_evidence,
|
||||
)
|
||||
|
||||
|
||||
TEST_SERIAL = "synthetic-serial-never-publish"
|
||||
TEST_ADDRESS = "SYNTHETIC_ADDRESS_NEVER_PUBLISH"
|
||||
FULL_PHONE = "13800138000"
|
||||
MASKED_PHONE = "138****0000"
|
||||
SAFE_TEXT = "synthetic-safe-lower-content"
|
||||
CURRENT_PRICE = "快卖光 ¥12.88"
|
||||
ORIGINAL_PRICE = "¥29.00"
|
||||
PRICE_CURRENT_BOUNDS = "[396,503][712,570]"
|
||||
PRICE_ORIGINAL_BOUNDS = "[730,503][895,570]"
|
||||
|
||||
|
||||
def _hash(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()
|
||||
|
||||
|
||||
def _default_xml() -> str:
|
||||
return (
|
||||
"<hierarchy rotation='0'>"
|
||||
f"<node bounds='[0,0][1080,540]' text='{TEST_ADDRESS}' content-desc='{MASKED_PHONE} {FULL_PHONE}' />"
|
||||
f"{_price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS)}"
|
||||
f"{_price_node(ORIGINAL_PRICE, PRICE_ORIGINAL_BOUNDS)}"
|
||||
f"<node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' />"
|
||||
"</hierarchy>"
|
||||
)
|
||||
|
||||
|
||||
def _price_node(
|
||||
text: str,
|
||||
bounds: str,
|
||||
*,
|
||||
package: str = "com.xunmeng.pinduoduo",
|
||||
node_class: str = "android.widget.TextView",
|
||||
clickable: str = "false",
|
||||
enabled: str = "true",
|
||||
visible: str = "true",
|
||||
extra_attributes: str = "",
|
||||
children: str = "",
|
||||
) -> str:
|
||||
attributes = (
|
||||
f"bounds='{bounds}' text='{text}' package='{package}' class='{node_class}' "
|
||||
f"clickable='{clickable}' enabled='{enabled}' visible-to-user='{visible}'{extra_attributes}"
|
||||
)
|
||||
return f"<node {attributes}>{children}</node>"
|
||||
|
||||
|
||||
def _xml_with_prices(
|
||||
current: str = CURRENT_PRICE,
|
||||
original: str = ORIGINAL_PRICE,
|
||||
*,
|
||||
current_node: str | None = None,
|
||||
original_node: str | None = None,
|
||||
include_original: bool = True,
|
||||
extra_nodes: str = "",
|
||||
) -> str:
|
||||
current_markup = current_node if current_node is not None else _price_node(current, PRICE_CURRENT_BOUNDS)
|
||||
original_markup = (
|
||||
original_node if original_node is not None else _price_node(original, PRICE_ORIGINAL_BOUNDS)
|
||||
) if include_original else ""
|
||||
return (
|
||||
"<hierarchy>"
|
||||
f"<node bounds='[0,0][1080,540]' text='{TEST_ADDRESS}' />"
|
||||
f"{current_markup}"
|
||||
f"{original_markup}"
|
||||
f"{extra_nodes}"
|
||||
f"<node bounds='[0,570][1080,2376]' text='{SAFE_TEXT}' />"
|
||||
"</hierarchy>"
|
||||
)
|
||||
|
||||
|
||||
def _write_raw(
|
||||
root: Path,
|
||||
*,
|
||||
state: str = "panel-opened-target-preselected",
|
||||
goods_id: str = EXPECTED_GOODS_ID,
|
||||
model: str = "PKG110",
|
||||
android_version: str = "16",
|
||||
pdd_version: str = "8.17.0",
|
||||
size: tuple[int, int] = (EXPECTED_SCREENSHOT_WIDTH, EXPECTED_SCREENSHOT_HEIGHT),
|
||||
xml: str | None = None,
|
||||
) -> Path:
|
||||
raw = root / "raw"
|
||||
raw.mkdir(parents=True)
|
||||
screenshot = raw / "screenshot.png"
|
||||
image = Image.new("RGB", size, color=(0, 180, 0))
|
||||
if size == (EXPECTED_SCREENSHOT_WIDTH, EXPECTED_SCREENSHOT_HEIGHT):
|
||||
for y in range(540):
|
||||
for x in range(8):
|
||||
image.putpixel((x, y), (255, 0, 0))
|
||||
image.save(screenshot, format="PNG")
|
||||
hierarchy = raw / "hierarchy.xml"
|
||||
hierarchy.write_text(_default_xml() if xml is None else xml, encoding="utf-8")
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"product": {
|
||||
"goods_id": goods_id,
|
||||
"canonical_url": f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}",
|
||||
},
|
||||
"human_declared_state": state,
|
||||
"serial": TEST_SERIAL,
|
||||
"channel": "wifi",
|
||||
"device": {
|
||||
"model": model,
|
||||
"android_version": android_version,
|
||||
"pdd_package": "com.xunmeng.pinduoduo",
|
||||
"pdd_version": pdd_version,
|
||||
},
|
||||
"artifacts": [
|
||||
{"path": "screenshot.png", "sha256": _hash(screenshot)},
|
||||
{"path": "hierarchy.xml", "sha256": _hash(hierarchy)},
|
||||
],
|
||||
}
|
||||
(raw / "manifest.json").write_text(json.dumps(manifest, sort_keys=True), encoding="utf-8")
|
||||
return raw
|
||||
|
||||
|
||||
class SkuEvidenceSanitizerTests(unittest.TestCase):
|
||||
def test_all_declared_states_mask_screenshot_and_xml_without_raw_metadata(self) -> None:
|
||||
for state in sorted(HUMAN_DECLARED_STATES):
|
||||
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), state=state)
|
||||
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
with Image.open(result.screenshot_path) as image:
|
||||
self.assertEqual(image.getpixel((0, 0)), (0, 0, 0, 255))
|
||||
self.assertEqual(image.getpixel((100, 600)), (0, 180, 0, 255))
|
||||
derived_xml = result.hierarchy_path.read_text(encoding="utf-8")
|
||||
manifest = result.manifest_path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn(SAFE_TEXT, derived_xml)
|
||||
self.assertNotIn(TEST_ADDRESS, derived_xml)
|
||||
self.assertNotIn(FULL_PHONE, derived_xml)
|
||||
self.assertNotIn(MASKED_PHONE, derived_xml)
|
||||
self.assertIn(f'"human_declared_state": "{state}"', manifest)
|
||||
self.assertIn('"privacy_tier": "SANITIZED"', manifest)
|
||||
self.assertIn('"sanitizer_version": "t103-privacy-v4"', manifest)
|
||||
self.assertIn('"screenshot_space": {', manifest)
|
||||
self.assertIn('"xml_coordinate_space": {', manifest)
|
||||
self.assertIn('"height": 2376', manifest)
|
||||
self.assertIn('"height": 2376', manifest)
|
||||
self.assertIn('"privacy_mask_rectangle": [', manifest)
|
||||
self.assertIn('"removed_nodes": 1', manifest)
|
||||
self.assertIn('"cleared_crossing_nodes": 0', manifest)
|
||||
self.assertIn('"preserved_crossing_price_nodes": 2', manifest)
|
||||
self.assertIn('"retained_below_nodes": 1', manifest)
|
||||
self.assertIn('"max_right": 1080', manifest)
|
||||
self.assertIn('"max_bottom": 2376', manifest)
|
||||
self.assertNotIn("canonical_url", manifest)
|
||||
self.assertNotIn(TEST_SERIAL, manifest)
|
||||
self.assertNotIn("serial", manifest)
|
||||
self.assertNotIn("channel", manifest)
|
||||
self.assertNotIn(TEST_ADDRESS, manifest)
|
||||
self.assertNotIn(FULL_PHONE, manifest)
|
||||
|
||||
def test_crossing_container_keeps_lower_children_but_clears_its_text(self) -> None:
|
||||
xml = (
|
||||
"<hierarchy>"
|
||||
f"<node bounds='[0,0][1080,2376]' text='{TEST_ADDRESS}' content-desc='{MASKED_PHONE}'>"
|
||||
f"<node bounds='[0,0][1080,540]' text='{FULL_PHONE}' />"
|
||||
f"{_price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS)}"
|
||||
f"{_price_node(ORIGINAL_PRICE, PRICE_ORIGINAL_BOUNDS)}"
|
||||
f"<node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' />"
|
||||
"</node></hierarchy>"
|
||||
)
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=xml)
|
||||
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
root = ElementTree.parse(result.hierarchy_path).getroot()
|
||||
crossing = root.find("node")
|
||||
|
||||
self.assertIsNotNone(crossing)
|
||||
assert crossing is not None
|
||||
self.assertEqual(crossing.attrib, {"bounds": "[0,0][1080,2376]"})
|
||||
self.assertEqual(len(list(crossing)), 3)
|
||||
self.assertEqual(list(crossing)[-1].get("text"), SAFE_TEXT)
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(
|
||||
manifest["privacy_cleanup"],
|
||||
{
|
||||
"removed_nodes": 1,
|
||||
"cleared_crossing_nodes": 1,
|
||||
"preserved_crossing_price_nodes": 2,
|
||||
"retained_below_nodes": 1,
|
||||
"max_right": 1080,
|
||||
"max_bottom": 2376,
|
||||
},
|
||||
)
|
||||
|
||||
def test_only_strict_crossing_price_leaves_are_projected_with_whitelisted_attributes(self) -> None:
|
||||
xml = _xml_with_prices(
|
||||
current_node=_price_node(
|
||||
CURRENT_PRICE,
|
||||
PRICE_CURRENT_BOUNDS,
|
||||
extra_attributes=" content-desc='discard' resource-id='discard' focused='true'",
|
||||
),
|
||||
original_node=_price_node(
|
||||
ORIGINAL_PRICE,
|
||||
PRICE_ORIGINAL_BOUNDS,
|
||||
extra_attributes=" content-desc='discard-too' resource-id='discard-too'",
|
||||
),
|
||||
)
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=xml)
|
||||
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
root = ElementTree.parse(result.hierarchy_path).getroot()
|
||||
prices = [node for node in root.findall("node") if node.get("text") in {CURRENT_PRICE, ORIGINAL_PRICE}]
|
||||
|
||||
self.assertEqual(len(prices), 2)
|
||||
for node in prices:
|
||||
self.assertEqual(
|
||||
set(node.attrib),
|
||||
{"bounds", "text", "package", "class", "clickable", "enabled", "visible-to-user"},
|
||||
)
|
||||
self.assertEqual(node.get("package"), "com.xunmeng.pinduoduo")
|
||||
self.assertEqual(node.get("class"), "android.widget.TextView")
|
||||
self.assertEqual(node.get("clickable"), "false")
|
||||
self.assertEqual(node.get("enabled"), "true")
|
||||
self.assertEqual(node.get("visible-to-user"), "true")
|
||||
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(manifest["privacy_cleanup"]["preserved_crossing_price_nodes"], 2)
|
||||
self.assertNotIn("discard", result.hierarchy_path.read_text(encoding="utf-8"))
|
||||
|
||||
def test_crossing_price_window_rejects_text_and_structure_drift(self) -> None:
|
||||
bad_texts = (
|
||||
f"快卖光 ¥12.88 {TEST_ADDRESS}",
|
||||
f"快卖光 ¥12.88 {FULL_PHONE}",
|
||||
"快卖光 ¥12.88 使用微信支付",
|
||||
"快卖光 ¥12.88 提交订单",
|
||||
"快卖光 ¥12.88 优惠-11元",
|
||||
"快要抢光 ¥12.88",
|
||||
"快卖光 ¥0.00",
|
||||
"快卖光 ¥12.8",
|
||||
"快卖光 ¥12.880",
|
||||
"快卖光 ¥12.88",
|
||||
)
|
||||
for text in bad_texts:
|
||||
with self.subTest(text=text), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=text))
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
def test_crossing_price_text_mismatch_reports_only_fixed_slot_and_reason(self) -> None:
|
||||
newline_node = _price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS).replace(
|
||||
"快卖光 ¥12.88", "快卖光 ¥12.88"
|
||||
)
|
||||
cases = (
|
||||
("newline", _xml_with_prices(current_node=newline_node), "newline", PRICE_CURRENT_BOUNDS),
|
||||
(
|
||||
"non-ascii-whitespace",
|
||||
_xml_with_prices(current="快卖光 ¥12.88"),
|
||||
"non_ascii_whitespace",
|
||||
PRICE_CURRENT_BOUNDS,
|
||||
),
|
||||
(
|
||||
"known-prefix-missing",
|
||||
_xml_with_prices(current="快要抢光 ¥12.88"),
|
||||
"known_prefix_missing",
|
||||
PRICE_CURRENT_BOUNDS,
|
||||
),
|
||||
(
|
||||
"currency-missing",
|
||||
_xml_with_prices(current="快卖光 12.88"),
|
||||
"currency_missing",
|
||||
PRICE_CURRENT_BOUNDS,
|
||||
),
|
||||
(
|
||||
"amount-shape",
|
||||
_xml_with_prices(current="快卖光 ¥12.8"),
|
||||
"amount_shape",
|
||||
PRICE_CURRENT_BOUNDS,
|
||||
),
|
||||
(
|
||||
"extra-or-order",
|
||||
_xml_with_prices(current="提交订单 ¥12.88"),
|
||||
"extra_or_order",
|
||||
PRICE_CURRENT_BOUNDS,
|
||||
),
|
||||
(
|
||||
"forbidden-characters",
|
||||
_xml_with_prices(current="商品 ¥12.88"),
|
||||
"forbidden_characters",
|
||||
PRICE_CURRENT_BOUNDS,
|
||||
),
|
||||
(
|
||||
"right-slot-amount-shape",
|
||||
_xml_with_prices(original="¥29.0"),
|
||||
"amount_shape",
|
||||
PRICE_ORIGINAL_BOUNDS,
|
||||
),
|
||||
)
|
||||
for name, xml, reason, bounds in cases:
|
||||
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=xml)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
|
||||
self.assertEqual(
|
||||
str(raised.exception),
|
||||
f"跨界价格节点文本不匹配:slot={bounds};reason={reason}。",
|
||||
)
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
def test_crossing_price_text_mismatch_never_echoes_sensitive_or_order_text(self) -> None:
|
||||
cases = (
|
||||
f"快卖光 ¥12.88 {TEST_ADDRESS}",
|
||||
f"快卖光 ¥12.88 {FULL_PHONE}",
|
||||
"快卖光 ¥12.88 使用微信支付",
|
||||
"快卖光 ¥12.88 提交订单",
|
||||
)
|
||||
for text in cases:
|
||||
with self.subTest(text=text), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=text))
|
||||
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
|
||||
message = str(raised.exception)
|
||||
self.assertIn("slot=[396,503][712,570]", message)
|
||||
self.assertIn("reason=extra_or_order", message)
|
||||
for raw_fragment in (TEST_ADDRESS, FULL_PHONE, "使用微信支付", "提交订单", "¥12.88"):
|
||||
self.assertNotIn(raw_fragment, message)
|
||||
|
||||
def test_crossing_price_projection_allows_only_limited_ascii_spaces_and_yen_variants(self) -> None:
|
||||
for current in ("快卖光 ¥12.88", " 快卖光 ¥ 12.88 ", "快卖光 ¥12.88"):
|
||||
with self.subTest(current=current), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=current))
|
||||
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
hierarchy = result.hierarchy_path.read_text(encoding="utf-8")
|
||||
self.assertIn(current, hierarchy)
|
||||
|
||||
def test_unique_current_price_without_original_price_is_published(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=_xml_with_prices(include_original=False))
|
||||
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
hierarchy = result.hierarchy_path.read_text(encoding="utf-8")
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
self.assertIn(CURRENT_PRICE, hierarchy)
|
||||
self.assertNotIn(ORIGINAL_PRICE, hierarchy)
|
||||
self.assertEqual(manifest["privacy_cleanup"]["preserved_crossing_price_nodes"], 1)
|
||||
|
||||
def test_crossing_price_projection_rejects_newline_and_structure_drift(self) -> None:
|
||||
encoded_newline = _price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS).replace(
|
||||
"快卖光 ¥12.88", "快卖光 ¥12.88"
|
||||
)
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current_node=encoded_newline))
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
bad_structure = (
|
||||
("package", {"package": "com.android.systemui"}),
|
||||
("class", {"node_class": "android.view.View"}),
|
||||
("clickable", {"clickable": "true"}),
|
||||
("disabled", {"enabled": "false"}),
|
||||
("hidden", {"visible": "false"}),
|
||||
("children", {"children": "<node bounds='[400,510][500,520]' />"}),
|
||||
)
|
||||
for name, kwargs in bad_structure:
|
||||
with self.subTest(structure=name), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(
|
||||
Path(temporary),
|
||||
xml=_xml_with_prices(current_node=_price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS, **kwargs)),
|
||||
)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
def test_crossing_price_candidates_require_unique_current_and_at_most_one_original(self) -> None:
|
||||
scenarios = (
|
||||
(
|
||||
"missing-current",
|
||||
_xml_with_prices(current="¥12.88", original=ORIGINAL_PRICE),
|
||||
),
|
||||
(
|
||||
"duplicate-current",
|
||||
_xml_with_prices(current=CURRENT_PRICE, original=f"快卖光 {ORIGINAL_PRICE}"),
|
||||
),
|
||||
(
|
||||
"multiple-original",
|
||||
_xml_with_prices(
|
||||
extra_nodes=_price_node("¥39.88", PRICE_ORIGINAL_BOUNDS),
|
||||
),
|
||||
),
|
||||
)
|
||||
for name, xml in scenarios:
|
||||
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=xml)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
def test_crossing_price_outside_fixed_windows_is_cleared_and_submit_price_is_not_candidate(self) -> None:
|
||||
outside_crossing = _price_node("快卖光 ¥99.99", "[396,498][712,570]")
|
||||
submit = (
|
||||
"<node bounds='[369,2225][710,2284]' text='提交订单 ¥12.88' "
|
||||
"package='com.xunmeng.pinduoduo' class='android.widget.TextView' clickable='false' "
|
||||
"enabled='true' visible-to-user='true' resource-id='submit-button' />"
|
||||
)
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=_xml_with_prices(extra_nodes=outside_crossing + submit))
|
||||
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
hierarchy = result.hierarchy_path.read_text(encoding="utf-8")
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
self.assertNotIn("快卖光 ¥99.99", hierarchy)
|
||||
self.assertIn("提交订单 ¥12.88", hierarchy)
|
||||
self.assertIn("submit-button", hierarchy)
|
||||
self.assertEqual(manifest["privacy_cleanup"]["preserved_crossing_price_nodes"], 2)
|
||||
|
||||
def test_same_raw_and_config_produce_identical_derived_files(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
left_raw = _write_raw(root / "left")
|
||||
right_raw = _write_raw(root / "right")
|
||||
left = sanitize_sku_panel_evidence(left_raw, left_raw.parent / "derived")
|
||||
right = sanitize_sku_panel_evidence(right_raw, right_raw.parent / "derived")
|
||||
|
||||
for left_path, right_path in (
|
||||
(left.screenshot_path, right.screenshot_path),
|
||||
(left.hierarchy_path, right.hierarchy_path),
|
||||
(left.manifest_path, right.manifest_path),
|
||||
):
|
||||
self.assertEqual(left_path.read_bytes(), right_path.read_bytes())
|
||||
|
||||
def test_manifest_records_distinct_screenshot_and_xml_coordinate_spaces(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary))
|
||||
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
self.assertEqual(
|
||||
manifest["screenshot_space"],
|
||||
{"width": 1080, "height": 2376, "privacy_mask_rectangle": [0, 0, 1080, 540]},
|
||||
)
|
||||
self.assertEqual(
|
||||
manifest["xml_coordinate_space"],
|
||||
{
|
||||
"width": 1080,
|
||||
"height": 2376,
|
||||
"privacy_mask_rectangle": [0, 0, 1080, 540],
|
||||
"observed_max": {"right": 1080, "bottom": 2376},
|
||||
},
|
||||
)
|
||||
|
||||
def test_hash_and_metadata_mismatch_fail_closed_without_leak(self) -> None:
|
||||
scenarios = (
|
||||
("hash", {}, "screenshot"),
|
||||
("model", {"model": "other"}, None),
|
||||
("android", {"android_version": "15"}, None),
|
||||
("version", {"pdd_version": "8.17.1"}, None),
|
||||
("goods", {"goods_id": "123"}, None),
|
||||
("state", {"state": "guessed"}, None),
|
||||
("old-screenshot-space", {"size": (1080, 2400)}, None),
|
||||
("other-screenshot-space", {"size": (100, 100)}, None),
|
||||
)
|
||||
for name, kwargs, corrupt_file in scenarios:
|
||||
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), **kwargs)
|
||||
if corrupt_file is not None:
|
||||
(raw / f"{corrupt_file}.png").write_bytes(b"changed")
|
||||
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
|
||||
message = str(raised.exception)
|
||||
self.assertNotIn(TEST_SERIAL, message)
|
||||
self.assertNotIn(TEST_ADDRESS, message)
|
||||
self.assertNotIn(FULL_PHONE, message)
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||
|
||||
def test_old_and_unknown_human_states_are_rejected(self) -> None:
|
||||
for state in ("initial", "one-dimension-selected", "all-dimensions-selected", "guessed"):
|
||||
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), state=state)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
def test_malformed_inputs_and_bounds_or_phone_residue_fail_closed(self) -> None:
|
||||
malformed = (
|
||||
("manifest", None),
|
||||
("png", None),
|
||||
("xml", None),
|
||||
("bounds", "<hierarchy><node text='missing bounds' /></hierarchy>"),
|
||||
(
|
||||
"private-parent-with-lower-child",
|
||||
"<hierarchy><node bounds='[0,0][1080,540]'><node bounds='[0,540][1080,2376]' text='x' /></node></hierarchy>",
|
||||
),
|
||||
("full-phone-below", f"<hierarchy><node bounds='[0,540][1080,2376]' text='{FULL_PHONE}' /></hierarchy>"),
|
||||
("masked-phone-below", f"<hierarchy><node bounds='[0,540][1080,2376]' text='{MASKED_PHONE}' /></hierarchy>"),
|
||||
)
|
||||
for kind, xml in malformed:
|
||||
with self.subTest(kind=kind), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=xml)
|
||||
if kind == "manifest":
|
||||
(raw / "manifest.json").write_text("{invalid", encoding="utf-8")
|
||||
elif kind == "png":
|
||||
(raw / "screenshot.png").write_bytes(b"not a png")
|
||||
manifest = json.loads((raw / "manifest.json").read_text(encoding="utf-8"))
|
||||
manifest["artifacts"][0]["sha256"] = _hash(raw / "screenshot.png")
|
||||
(raw / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
elif kind == "xml":
|
||||
(raw / "hierarchy.xml").write_text("<hierarchy>", encoding="utf-8")
|
||||
manifest = json.loads((raw / "manifest.json").read_text(encoding="utf-8"))
|
||||
manifest["artifacts"][1]["sha256"] = _hash(raw / "hierarchy.xml")
|
||||
(raw / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||
|
||||
def test_phone_recheck_rejects_separator_mask_and_cross_node_bypasses(self) -> None:
|
||||
variants = (
|
||||
"138 0013-8000",
|
||||
"138****0000",
|
||||
"138••••0000",
|
||||
"138xxxx0000",
|
||||
"138XXXX0000",
|
||||
)
|
||||
for value in variants:
|
||||
xml = (
|
||||
"<hierarchy>"
|
||||
"<node bounds='[0,0][1080,540]' text='private' />"
|
||||
f"<node bounds='[0,540][1080,2376]' text='{value}' />"
|
||||
"</hierarchy>"
|
||||
)
|
||||
with self.subTest(value=value), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=xml)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
split_xml = (
|
||||
"<hierarchy>"
|
||||
"<node bounds='[0,0][1080,540]' text='private' />"
|
||||
"<node bounds='[0,540][1080,1000]' text='138' content-desc='0013' />"
|
||||
"<node bounds='[0,1000][1080,2376]' text='8000' />"
|
||||
"</hierarchy>"
|
||||
)
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=split_xml)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
def test_privacy_geometry_requires_removed_and_retained_nodes(self) -> None:
|
||||
scenarios = (
|
||||
("no-private", f"<hierarchy><node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' /></hierarchy>"),
|
||||
("no-below", "<hierarchy><node bounds='[0,0][1080,540]' text='private' /></hierarchy>"),
|
||||
)
|
||||
for name, xml in scenarios:
|
||||
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=xml)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||
|
||||
def test_xml_coordinate_space_must_have_exact_configured_maximums(self) -> None:
|
||||
scenarios = (
|
||||
(
|
||||
"short-width",
|
||||
"<hierarchy><node bounds='[0,0][1079,540]' text='private' />"
|
||||
"<node bounds='[0,540][1079,2376]' text='safe' /></hierarchy>",
|
||||
"1079x2376",
|
||||
),
|
||||
(
|
||||
"short-height",
|
||||
"<hierarchy><node bounds='[0,0][1080,540]' text='private' />"
|
||||
"<node bounds='[0,540][1080,2375]' text='safe' /></hierarchy>",
|
||||
"1080x2375",
|
||||
),
|
||||
(
|
||||
"wide-width",
|
||||
"<hierarchy><node bounds='[0,0][1081,540]' text='private' />"
|
||||
"<node bounds='[0,540][1081,2376]' text='safe' /></hierarchy>",
|
||||
"1081x2376",
|
||||
),
|
||||
(
|
||||
"old-v2-xml-height",
|
||||
"<hierarchy><node bounds='[0,0][1080,540]' text='private' />"
|
||||
"<node bounds='[0,540][1080,2400]' text='safe' /></hierarchy>",
|
||||
"1080x2400",
|
||||
),
|
||||
)
|
||||
for name, xml, observed in scenarios:
|
||||
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=xml)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
|
||||
self.assertIn(observed, str(raised.exception))
|
||||
self.assertNotIn(TEST_SERIAL, str(raised.exception))
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||
|
||||
def test_manifest_schema_package_and_artifact_structure_are_required(self) -> None:
|
||||
def mutate(manifest: dict[str, object], kind: str) -> None:
|
||||
if kind == "schema":
|
||||
manifest["schema_version"] = 2
|
||||
elif kind == "package":
|
||||
manifest["device"]["pdd_package"] = "com.example.other" # type: ignore[index]
|
||||
elif kind == "missing":
|
||||
manifest.pop("artifacts")
|
||||
elif kind == "duplicate":
|
||||
manifest["artifacts"].append(manifest["artifacts"][0]) # type: ignore[index]
|
||||
elif kind == "bad-hash":
|
||||
manifest["artifacts"][0]["sha256"] = "g" * 64 # type: ignore[index]
|
||||
|
||||
for kind in ("schema", "package", "missing", "duplicate", "bad-hash"):
|
||||
with self.subTest(kind=kind), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary))
|
||||
manifest_path = raw / "manifest.json"
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
mutate(manifest, kind)
|
||||
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
|
||||
self.assertNotIn(TEST_SERIAL, str(raised.exception))
|
||||
self.assertNotIn(TEST_ADDRESS, str(raised.exception))
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
def test_target_created_during_publish_is_preserved_and_staging_is_removed(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary))
|
||||
target = raw.parent / "derived"
|
||||
|
||||
def race_rename(destination: Path) -> None:
|
||||
destination.mkdir()
|
||||
(destination / "sentinel.txt").write_text("keep", encoding="utf-8")
|
||||
raise FileExistsError("simulated publish race")
|
||||
|
||||
with patch("cmbuyer_client.device.sku_evidence_sanitizer.Path.rename", side_effect=race_rename):
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, target)
|
||||
|
||||
self.assertEqual((target / "sentinel.txt").read_text(encoding="utf-8"), "keep")
|
||||
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||
|
||||
def test_existing_derived_is_preserved_without_reading_or_writing_raw(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary))
|
||||
target = raw.parent / "derived"
|
||||
target.mkdir()
|
||||
sentinel = target / "sentinel.txt"
|
||||
sentinel.write_text("keep", encoding="utf-8")
|
||||
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, target)
|
||||
|
||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
||||
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||
|
||||
def test_directory_contract_rejects_non_sibling_paths(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
raw = _write_raw(root)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, root / "not-derived")
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(root / "not-raw", root / "derived")
|
||||
@@ -0,0 +1 @@
|
||||
"""拼多多受限打开模块的离线测试。"""
|
||||
@@ -0,0 +1,266 @@
|
||||
"""商品打开围栏的离线测试;所有设备和命令均为 fake。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection, IntentLaunchSummary
|
||||
from cmbuyer_client.pdd.product_open import (
|
||||
ProductOpenCapturer,
|
||||
ProductOpenTimeoutError,
|
||||
ProductOpenUiDevice,
|
||||
ProductHierarchyCaptureError,
|
||||
ProductPackageMismatchError,
|
||||
ProductScreenshotCaptureError,
|
||||
ProductVersionMismatchError,
|
||||
)
|
||||
from cmbuyer_client.pdd.product_url import ProductUrl, ProductUrlError
|
||||
|
||||
|
||||
SERIAL = "192.168.0.173:5555"
|
||||
URL = "https://mobile.yangkeduo.com/goods.html?goods_id=123"
|
||||
HIERARCHY = "<?xml version='1.0' encoding='UTF-8'?><hierarchy rotation='0'><node /></hierarchy>"
|
||||
|
||||
|
||||
def _png_base64() -> str:
|
||||
image_data = BytesIO()
|
||||
Image.new("RGB", (1, 1), color="white").save(image_data, format="PNG")
|
||||
return base64.b64encode(image_data.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
class FakeAdbClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str | None]] = []
|
||||
self.inspection = DeviceInspection(
|
||||
device=AdbDevice(serial=SERIAL, state="device", model="PKG110"),
|
||||
model="PKG110",
|
||||
android_version="16",
|
||||
)
|
||||
|
||||
def inspect(self, serial: str) -> DeviceInspection:
|
||||
self.calls.append(("inspect", serial))
|
||||
return self.inspection
|
||||
|
||||
def start_pdd_view_intent(self, serial: str, goods_id: str) -> IntentLaunchSummary:
|
||||
self.calls.append(("intent", goods_id))
|
||||
return IntentLaunchSummary(status="ok", returncode=0)
|
||||
|
||||
|
||||
class FakeUiDevice:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
version: str = "8.17.0",
|
||||
current_package: str = "com.xunmeng.pinduoduo",
|
||||
current_packages: list[str] | None = None,
|
||||
hierarchy: str = HIERARCHY,
|
||||
timeout_on_screenshot: bool = False,
|
||||
) -> None:
|
||||
self.version = version
|
||||
self.current_package = current_package
|
||||
self.current_packages = list(current_packages) if current_packages is not None else None
|
||||
self.hierarchy = hierarchy
|
||||
self.timeout_on_screenshot = timeout_on_screenshot
|
||||
self.calls: list[str] = []
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, str]:
|
||||
self.calls.append("app_info")
|
||||
return {"versionName": self.version}
|
||||
|
||||
def app_current(self) -> dict[str, str]:
|
||||
self.calls.append("app_current")
|
||||
if self.current_packages:
|
||||
package = self.current_packages.pop(0)
|
||||
self.current_package = package
|
||||
return {"package": self.current_package, "activity": "sensitive.activity.name"}
|
||||
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
self.calls.append(method)
|
||||
if method == "takeScreenshot":
|
||||
if self.timeout_on_screenshot:
|
||||
raise TimeoutError("raw remote detail")
|
||||
return _png_base64()
|
||||
if method == "dumpWindowHierarchy":
|
||||
return self.hierarchy
|
||||
raise AssertionError(f"unexpected RPC {method}")
|
||||
|
||||
|
||||
class ProductOpenTests(unittest.TestCase):
|
||||
def _capturer(self, adb: FakeAdbClient, device: FakeUiDevice, **kwargs: object) -> ProductOpenCapturer:
|
||||
return ProductOpenCapturer(adb, lambda serial: device, timeout_seconds=2, **kwargs)
|
||||
|
||||
def test_success_uses_canonical_url_and_redacted_atomic_manifest(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeUiDevice()
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
result = self._capturer(adb, device).open_and_capture(SERIAL, URL, target)
|
||||
manifest = result.manifest_path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertTrue(result.screenshot_path.exists())
|
||||
self.assertTrue(result.hierarchy_path.exists())
|
||||
self.assertEqual(adb.calls, [("inspect", SERIAL), ("intent", "123")])
|
||||
self.assertEqual(device.calls, ["app_info", "app_current", "takeScreenshot", "dumpWindowHierarchy"])
|
||||
self.assertIn('"goods_id": "123"', manifest)
|
||||
self.assertIn('"canonical_url": "https://mobile.yangkeduo.com/goods.html?goods_id=123"', manifest)
|
||||
self.assertNotIn(SERIAL, manifest)
|
||||
self.assertNotIn("sensitive.activity.name", manifest)
|
||||
self.assertNotIn(HIERARCHY, manifest)
|
||||
|
||||
def test_version_mismatch_halts_before_intent(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
for version in ("8.17.1", " 8.17.0 "):
|
||||
with self.subTest(version=version), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(ProductVersionMismatchError):
|
||||
self._capturer(adb, FakeUiDevice(version=version)).open_and_capture(SERIAL, URL, target)
|
||||
|
||||
self.assertEqual(adb.calls[-1:], [("inspect", SERIAL)])
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
def test_public_entry_rejects_caller_constructed_url_value_object(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
with TemporaryDirectory() as temporary:
|
||||
with self.assertRaises(ProductUrlError):
|
||||
self._capturer(adb, FakeUiDevice()).open_and_capture(
|
||||
SERIAL,
|
||||
ProductUrl(goods_id="123", canonical_url="https://example.invalid/"), # type: ignore[arg-type]
|
||||
Path(temporary) / "evidence",
|
||||
)
|
||||
|
||||
self.assertEqual(adb.calls, [])
|
||||
|
||||
def test_foreground_package_mismatch_halts_before_capture(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeUiDevice(current_package="com.example.other")
|
||||
clock = FakeClock()
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(ProductPackageMismatchError):
|
||||
self._capturer(
|
||||
adb,
|
||||
device,
|
||||
foreground_poll_interval_seconds=0.5,
|
||||
monotonic_clock=clock.monotonic,
|
||||
sleep_function=clock.sleep,
|
||||
).open_and_capture(SERIAL, URL, target)
|
||||
|
||||
self.assertEqual(adb.calls, [("inspect", SERIAL), ("intent", "123")])
|
||||
self.assertEqual(device.calls, ["app_info", "app_current", "app_current", "app_current", "app_current", "app_current"])
|
||||
self.assertEqual(clock.sleeps, [0.5, 0.5, 0.5, 0.5])
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
def test_foreground_package_poll_waits_for_pdd_before_reading_evidence(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeUiDevice(current_packages=["com.example.other", "com.xunmeng.pinduoduo"])
|
||||
clock = FakeClock()
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
result = self._capturer(
|
||||
adb,
|
||||
device,
|
||||
foreground_poll_interval_seconds=0.25,
|
||||
monotonic_clock=clock.monotonic,
|
||||
sleep_function=clock.sleep,
|
||||
).open_and_capture(SERIAL, URL, target)
|
||||
|
||||
self.assertTrue(result.manifest_path.exists())
|
||||
self.assertEqual(clock.sleeps, [0.25])
|
||||
self.assertEqual(
|
||||
device.calls,
|
||||
["app_info", "app_current", "app_current", "takeScreenshot", "dumpWindowHierarchy"],
|
||||
)
|
||||
|
||||
def test_foreground_package_poll_stops_at_deadline_without_evidence(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeUiDevice(current_packages=["com.example.other", "", "com.example.other"])
|
||||
clock = FakeClock()
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(ProductPackageMismatchError):
|
||||
self._capturer(
|
||||
adb,
|
||||
device,
|
||||
foreground_poll_interval_seconds=0.8,
|
||||
monotonic_clock=clock.monotonic,
|
||||
sleep_function=clock.sleep,
|
||||
).open_and_capture(SERIAL, URL, target)
|
||||
|
||||
self.assertEqual(len(clock.sleeps), 3)
|
||||
for actual, expected in zip(clock.sleeps, (0.8, 0.8, 0.4), strict=True):
|
||||
self.assertAlmostEqual(actual, expected)
|
||||
self.assertEqual(device.calls, ["app_info", "app_current", "app_current", "app_current", "app_current"])
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_foreground_poll_interval_must_be_positive_and_finite(self) -> None:
|
||||
for interval in (0, -0.1, float("inf"), float("nan"), True):
|
||||
with self.subTest(interval=interval):
|
||||
with self.assertRaises(ValueError):
|
||||
ProductOpenCapturer(
|
||||
FakeAdbClient(),
|
||||
lambda serial: FakeUiDevice(),
|
||||
timeout_seconds=2,
|
||||
foreground_poll_interval_seconds=interval, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def test_timeout_and_invalid_hierarchy_leave_no_partial_evidence(self) -> None:
|
||||
scenarios = (
|
||||
(FakeUiDevice(timeout_on_screenshot=True), ProductOpenTimeoutError),
|
||||
(FakeUiDevice(hierarchy="<not-hierarchy />"), ProductHierarchyCaptureError),
|
||||
)
|
||||
for device, error_type in scenarios:
|
||||
with self.subTest(error_type=error_type.__name__), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(error_type):
|
||||
self._capturer(FakeAdbClient(), device).open_and_capture(SERIAL, URL, target)
|
||||
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_invalid_screenshot_is_a_distinct_redacted_failure(self) -> None:
|
||||
class InvalidScreenshotDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "takeScreenshot":
|
||||
self.calls.append(method)
|
||||
return "not valid base64!"
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(ProductScreenshotCaptureError) as raised:
|
||||
self._capturer(FakeAdbClient(), InvalidScreenshotDevice()).open_and_capture(SERIAL, URL, target)
|
||||
|
||||
self.assertNotIn("base64", str(raised.exception).lower())
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_read_only_protocol_has_no_ui_operation_methods(self) -> None:
|
||||
forbidden = {"click", "swipe", "send_keys", "set_text", "press", "long_click"}
|
||||
|
||||
self.assertTrue(forbidden.isdisjoint(ProductOpenUiDevice.__dict__))
|
||||
self.assertEqual(base64.b64decode(_png_base64())[:8], b"\x89PNG\r\n\x1a\n")
|
||||
|
||||
|
||||
class FakeClock:
|
||||
def __init__(self) -> None:
|
||||
self.value = 0.0
|
||||
self.sleeps: list[float] = []
|
||||
|
||||
def monotonic(self) -> float:
|
||||
return self.value
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
self.sleeps.append(seconds)
|
||||
self.value += seconds
|
||||
@@ -0,0 +1,49 @@
|
||||
"""canonical 商品 URL 的离线解析测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
|
||||
|
||||
|
||||
class ProductUrlTests(unittest.TestCase):
|
||||
def test_rebuilds_url_from_goods_id(self) -> None:
|
||||
link = parse_product_url("https://mobile.yangkeduo.com/goods.html?goods_id=00123")
|
||||
|
||||
self.assertEqual(link.goods_id, "00123")
|
||||
self.assertEqual(
|
||||
link.canonical_url,
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=00123",
|
||||
)
|
||||
|
||||
def test_rejects_noncanonical_and_ambiguous_urls(self) -> None:
|
||||
rejected = (
|
||||
"http://mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||
"https://other.example/goods.html?goods_id=123",
|
||||
"https://mobile.yangkeduo.com/other.html?goods_id=123",
|
||||
"https://user@mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||
"https://mobile.yangkeduo.com:8443/goods.html?goods_id=123",
|
||||
"https://mobile.yangkeduo.com:443/goods.html?goods_id=123",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=123#fragment",
|
||||
"https://mobile.yangkeduo.com/goods.html",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=123&goods_id=456",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=123&source=share",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=12a",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=%EF%BC%91%EF%BC%92%EF%BC%93",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=",
|
||||
" https://mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||
"https://MOBILE.YANGKEDUO.COM/goods.html?goods_id=123",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=%31%32%33",
|
||||
)
|
||||
|
||||
for value in rejected:
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(ProductUrlError):
|
||||
parse_product_url(value)
|
||||
@@ -0,0 +1,263 @@
|
||||
"""人工声明规格面板状态的离线只读取证测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
|
||||
from cmbuyer_client.pdd.product_url import ProductUrlError
|
||||
from cmbuyer_client.pdd.sku_panel_spike import (
|
||||
HUMAN_DECLARED_STATES,
|
||||
SkuPanelDeclaredStateError,
|
||||
SkuPanelEvidenceCapturer,
|
||||
SkuPanelEvidenceError,
|
||||
SkuPanelEvidenceTimeoutError,
|
||||
SkuPanelHierarchyError,
|
||||
SkuPanelPackageMismatchError,
|
||||
SkuPanelScreenshotError,
|
||||
SkuPanelUiDevice,
|
||||
SkuPanelVersionMismatchError,
|
||||
)
|
||||
|
||||
|
||||
SERIAL = "192.168.0.173:5555"
|
||||
URL = "https://mobile.yangkeduo.com/goods.html?goods_id=123"
|
||||
HIERARCHY = "<?xml version='1.0' encoding='UTF-8'?><hierarchy><node text='sensitive page text' /></hierarchy>"
|
||||
|
||||
|
||||
def _png_base64() -> str:
|
||||
image_data = BytesIO()
|
||||
Image.new("RGB", (1, 1), color="white").save(image_data, format="PNG")
|
||||
return base64.b64encode(image_data.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
class FakeAdbClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[str] = []
|
||||
self.inspection = DeviceInspection(
|
||||
device=AdbDevice(serial=SERIAL, state="device", model="PKG110"),
|
||||
model="PKG110",
|
||||
android_version="16",
|
||||
)
|
||||
|
||||
def inspect(self, serial: str) -> DeviceInspection:
|
||||
self.calls.append(serial)
|
||||
return self.inspection
|
||||
|
||||
|
||||
class FakeUiDevice:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
version: str = "8.17.0",
|
||||
package: str = "com.xunmeng.pinduoduo",
|
||||
screenshot: str | None = None,
|
||||
hierarchy: str = HIERARCHY,
|
||||
) -> None:
|
||||
self.version = version
|
||||
self.package = package
|
||||
self.screenshot = screenshot if screenshot is not None else _png_base64()
|
||||
self.hierarchy = hierarchy
|
||||
self.calls: list[str] = []
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, str]:
|
||||
self.calls.append("app_info")
|
||||
return {"versionName": self.version}
|
||||
|
||||
def app_current(self) -> dict[str, str]:
|
||||
self.calls.append("app_current")
|
||||
return {"package": self.package, "activity": "sensitive.activity.name"}
|
||||
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
self.calls.append(method)
|
||||
if method == "takeScreenshot":
|
||||
return self.screenshot
|
||||
if method == "dumpWindowHierarchy":
|
||||
return self.hierarchy
|
||||
raise AssertionError(f"unexpected RPC {method}")
|
||||
|
||||
|
||||
def _load_spike_script() -> object:
|
||||
script_path = CLIENT_ROOT / "scripts" / "capture_sku_panel_spike.py"
|
||||
spec = spec_from_file_location("capture_sku_panel_spike_for_test", script_path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class SkuPanelEvidenceTests(unittest.TestCase):
|
||||
def _capturer(self, adb: FakeAdbClient, device: FakeUiDevice) -> SkuPanelEvidenceCapturer:
|
||||
return SkuPanelEvidenceCapturer(adb, lambda serial: device, timeout_seconds=2)
|
||||
|
||||
def test_all_human_declared_states_publish_redacted_manifest(self) -> None:
|
||||
for state in sorted(HUMAN_DECLARED_STATES):
|
||||
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeUiDevice()
|
||||
target = Path(temporary) / "evidence"
|
||||
result = self._capturer(adb, device).capture(SERIAL, URL, state, target)
|
||||
manifest = result.manifest_path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertEqual(adb.calls, [SERIAL])
|
||||
self.assertEqual(device.calls, ["app_info", "app_current", "takeScreenshot", "dumpWindowHierarchy"])
|
||||
self.assertIn(f'"human_declared_state": "{state}"', manifest)
|
||||
self.assertIn('"goods_id": "123"', manifest)
|
||||
self.assertIn('"canonical_url": "https://mobile.yangkeduo.com/goods.html?goods_id=123"', manifest)
|
||||
self.assertNotIn("detected_state", manifest)
|
||||
self.assertNotIn(SERIAL, manifest)
|
||||
self.assertNotIn("sensitive.activity.name", manifest)
|
||||
self.assertNotIn(HIERARCHY, manifest)
|
||||
|
||||
def test_invalid_state_and_url_fail_before_device_access(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
rejected_states = ("initial", "one-dimension-selected", "all-dimensions-selected", "guessed")
|
||||
with TemporaryDirectory() as temporary:
|
||||
for state in rejected_states:
|
||||
with self.subTest(state=state), self.assertRaises(SkuPanelDeclaredStateError):
|
||||
self._capturer(adb, FakeUiDevice()).capture(SERIAL, URL, state, Path(temporary) / "state")
|
||||
with self.assertRaises(ProductUrlError):
|
||||
self._capturer(adb, FakeUiDevice()).capture(
|
||||
SERIAL,
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=12x",
|
||||
"panel-opened-target-preselected",
|
||||
Path(temporary) / "url",
|
||||
)
|
||||
|
||||
self.assertEqual(adb.calls, [])
|
||||
|
||||
def test_version_or_foreground_package_mismatch_stops_before_artifacts(self) -> None:
|
||||
scenarios = (
|
||||
(FakeUiDevice(version="8.17.1"), SkuPanelVersionMismatchError, ["app_info"]),
|
||||
(FakeUiDevice(package="com.example.other"), SkuPanelPackageMismatchError, ["app_info", "app_current"]),
|
||||
)
|
||||
for device, error_type, expected_calls in scenarios:
|
||||
with self.subTest(error_type=error_type.__name__), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(error_type):
|
||||
self._capturer(FakeAdbClient(), device).capture(SERIAL, URL, "panel-opened-target-preselected", target)
|
||||
|
||||
self.assertEqual(device.calls, expected_calls)
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
def test_screenshot_and_xml_failure_leave_no_partial_evidence(self) -> None:
|
||||
scenarios = (
|
||||
(FakeUiDevice(screenshot="not valid base64!"), SkuPanelScreenshotError),
|
||||
(FakeUiDevice(hierarchy="<not-hierarchy />"), SkuPanelHierarchyError),
|
||||
)
|
||||
for device, error_type in scenarios:
|
||||
with self.subTest(error_type=error_type.__name__), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(error_type):
|
||||
self._capturer(FakeAdbClient(), device).capture(SERIAL, URL, "panel-opened-target-preselected", target)
|
||||
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_screenshot_timeout_is_redacted_and_leaves_no_partial_evidence(self) -> None:
|
||||
class TimeoutScreenshotDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "takeScreenshot":
|
||||
self.calls.append(method)
|
||||
raise TimeoutError("adb 192.168.0.173:5555 raw detail")
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(SkuPanelEvidenceTimeoutError) as raised:
|
||||
self._capturer(FakeAdbClient(), TimeoutScreenshotDevice()).capture(SERIAL, URL, "panel-opened-target-preselected", target)
|
||||
|
||||
self.assertNotIn(SERIAL, str(raised.exception))
|
||||
self.assertNotIn("adb", str(raised.exception).lower())
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_existing_output_directory_is_not_overwritten_or_connected(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeUiDevice()
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
target.mkdir()
|
||||
sentinel = target / "sentinel.txt"
|
||||
sentinel.write_text("keep", encoding="utf-8")
|
||||
|
||||
with self.assertRaises(SkuPanelEvidenceError):
|
||||
self._capturer(adb, device).capture(SERIAL, URL, "panel-opened-target-preselected", target)
|
||||
|
||||
self.assertEqual(adb.calls, [])
|
||||
self.assertEqual(device.calls, [])
|
||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
||||
|
||||
def test_protocol_has_no_ui_or_purchase_operation_methods(self) -> None:
|
||||
forbidden = {
|
||||
"click",
|
||||
"swipe",
|
||||
"send_keys",
|
||||
"set_text",
|
||||
"press",
|
||||
"open_product",
|
||||
"open_sku_panel",
|
||||
"set_quantity",
|
||||
"go_to_order_confirm",
|
||||
"submit_order",
|
||||
"pay",
|
||||
}
|
||||
self.assertTrue(forbidden.isdisjoint(SkuPanelUiDevice.__dict__))
|
||||
self.assertEqual({name for name in SkuPanelEvidenceCapturer.__dict__ if not name.startswith("_")}, {"capture"})
|
||||
|
||||
|
||||
class SkuPanelSpikeCliTests(unittest.TestCase):
|
||||
def test_validate_arguments_rejects_invalid_serial_timeout_state_and_url(self) -> None:
|
||||
script = _load_spike_script()
|
||||
valid = {
|
||||
"serial": SERIAL,
|
||||
"url": URL,
|
||||
"goods_id": None,
|
||||
"state": "panel-opened-target-preselected",
|
||||
"output_dir": Path("evidence"),
|
||||
"timeout": 10.0,
|
||||
"adb": "adb",
|
||||
}
|
||||
invalid_values = (
|
||||
("serial", ""),
|
||||
("timeout", 0),
|
||||
("timeout", float("inf")),
|
||||
("state", "not-declared"),
|
||||
("state", "initial"),
|
||||
("state", "one-dimension-selected"),
|
||||
("state", "all-dimensions-selected"),
|
||||
("url", "https://mobile.yangkeduo.com/goods.html?goods_id=bad"),
|
||||
)
|
||||
for field, value in invalid_values:
|
||||
with self.subTest(field=field, value=value):
|
||||
arguments = argparse.Namespace(**(valid | {field: value}))
|
||||
with self.assertRaises((ValueError, ProductUrlError)):
|
||||
script.validate_arguments(arguments) # type: ignore[attr-defined]
|
||||
|
||||
def test_goods_id_is_rebuilt_as_canonical_url(self) -> None:
|
||||
script = _load_spike_script()
|
||||
arguments = argparse.Namespace(
|
||||
serial=SERIAL,
|
||||
url=None,
|
||||
goods_id="00123",
|
||||
state="target-selection-restored",
|
||||
output_dir=Path("evidence"),
|
||||
timeout=10.0,
|
||||
adb="adb",
|
||||
)
|
||||
|
||||
link = script.validate_arguments(arguments) # type: ignore[attr-defined]
|
||||
self.assertEqual(link.canonical_url, "https://mobile.yangkeduo.com/goods.html?goods_id=00123")
|
||||
+23
-17
@@ -85,22 +85,24 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
|
||||
## 当前阶段
|
||||
|
||||
**Phase 0 · 地基。** 采购服务与采购工具骨架均已初始化,尚无采购业务代码。
|
||||
**Phase 1 · 真机可行性,并行启动采购服务基础能力。** 两端骨架与核心数据模型已完成,T-103
|
||||
仍是当前真机关键路径;与真机可读字段无关的采购服务能力不再空等。
|
||||
|
||||
执行按任务依赖驱动,**不按 Phase 整段串行等待**。当前优先路径:
|
||||
|
||||
1. T-004 核心数据模型正在进行;T-003 统一入口与 T-101 真机环境盘点可开始。
|
||||
2. T-002 已完成,立即推进 **T-101 → T-102 → T-103 真机取证**,并可并行补 T-003 统一入口。
|
||||
3. T-103 结论确认后,才开始依赖真机可读字段的 Phase 2 生产页面;采购服务核心与
|
||||
T-104 → T-107 后续真机安全判据按依赖并行推进。
|
||||
1. T-001~T-004 与 T-101~T-102 已完成,继续推进 **T-103 真机取证**。
|
||||
2. T-103 进行时并行推进 T-201 管理会话 → T-202 `DRAFT` 建单与基础列表;两者不得启动试选,
|
||||
也不得引入机器结果、规格面板单价或证据字段。
|
||||
3. T-103 结论确认后,再推进 T-203 批量开始试选、T-204 试选证据详情、T-205~T-207,
|
||||
并按依赖推进 T-104 → T-107 后续真机安全判据。
|
||||
4. Phase 3:双端打通与**第一趟试选**端到端。
|
||||
5. Phase 4:**第二趟下单**与收尾。
|
||||
6. V2 及以后:图搜、Excel、ERP、订单自动核对、AI 辅助。
|
||||
|
||||
> **M2 是本项目的生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、
|
||||
> **读到该 SKU 的单价**(T-103)。前序项目正是卡在选规格和读价。
|
||||
> M2 不通过之前不要写 Phase 2 的生产页面;Phase 0 原型只确认流程与信息架构,实际可读
|
||||
> 字段仍以真机证据为准。
|
||||
> M2 不通过之前不要写**依赖真机可读字段或会启动试选**的 Phase 2 功能;T-201 与仅创建
|
||||
> `DRAFT` 的 T-202 可以并行。原型只确认流程与信息架构,实际可读字段仍以真机证据为准。
|
||||
|
||||
## 领取任务规则
|
||||
|
||||
@@ -172,25 +174,29 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
|
||||
## 验证命令
|
||||
|
||||
> 采购服务命令已由 T-001 实际验证;采购工具的离线测试、编译和 wheel 元数据命令已由
|
||||
> Python 3.12 实际验证。当前默认 `python` 是 Python 3.10.11,统一入口尚未正确选择
|
||||
> Python 3.11+,此缺口由 T-003 修复。
|
||||
> Windows 标准入口 `./init.ps1` 已由 T-003 实际验证。它优先使用合规的既有 venv(本机实际为
|
||||
> Python 3.12),仅在 venv 缺失时才从 Python Launcher 自动选择最高的 Python 3.11+,避免回退到
|
||||
> 默认 Python 3.10;通过后会输出两端真实启动命令。
|
||||
|
||||
```powershell
|
||||
# Windows 统一安装、离线验证与启动命令提示
|
||||
.\init.ps1
|
||||
|
||||
# 以下为诊断或单独验证时使用的等价命令
|
||||
# 采购服务 admin/(改了 Go 代码后)
|
||||
cd admin
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
|
||||
# 采购工具 client/(使用 Python 3.11+;本机已验证 3.12)
|
||||
# 采购工具 client/(由 init.ps1 创建的 Python 3.11+ 虚拟环境)
|
||||
cd client
|
||||
py -3.12 -m unittest discover -s tests -t .
|
||||
py -3.12 -m compileall -q src tests scripts
|
||||
.\.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
|
||||
py -3.12 -m pip wheel --no-deps . --wheel-dir $wheelDir
|
||||
py -3.12 scripts/verify_wheel_metadata.py (Get-ChildItem $wheelDir -Filter '*.whl').FullName
|
||||
.\.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
|
||||
|
||||
# 跨端契约改动:两端全跑
|
||||
```
|
||||
@@ -198,5 +204,5 @@ py -3.12 scripts/verify_wheel_metadata.py (Get-ChildItem $wheelDir -Filter '*.wh
|
||||
验证层级何时触发见 [`03-tech-stack.md`](03-tech-stack.md) 第六节验证矩阵。
|
||||
`requirements.txt` 是采购工具唯一的运行时依赖来源,`pyproject.toml` 动态读取它写入 wheel
|
||||
元数据。规范安装是 Python 3.11+ 虚拟环境中的 `python -m pip install -e .`,已由 Python 3.12
|
||||
验证通过;桌面 GUI 和真机流程不属于本次验收。`init.ps1` 仍未正确选择 Python 3.11+,统一入口
|
||||
由 T-003 修复。若命令当前不可运行,必须在回复里如实说明原因。
|
||||
验证通过;桌面 GUI 和真机流程不属于本次验收。`init.ps1` 使用合规既有 venv,或在缺失时自动选择
|
||||
Python 3.11+ 创建它;本机现有 venv 实际验证为 3.12。若命令当前不可运行,必须在回复里如实说明原因。
|
||||
|
||||
+19
-7
@@ -99,14 +99,20 @@ US-006(付款前核对)在 MVP 降级为:系统展示订单截图与授权
|
||||
- **F-005**:执行员启动会话后桌面端定时轮询,同时领取待试选与已授权两类任务。
|
||||
两个实例并发领取同一条时只有一个成功,另一个得到明确的「无可领任务」而不是报错。
|
||||
**关闭会话即停止轮询;连续失败达到阈值自动停止并提示原因。**
|
||||
- **F-006**:手机打开对应商品详情页,打开规格面板,按维度精确匹配颜色分类和尺码。
|
||||
- **F-006**:手机打开对应商品详情页,只点击与当前拼多多版本及本项目真机证据绑定的精确唯一
|
||||
**受控规格面板入口**,打开规格面板后按维度精确匹配颜色分类和尺码。当前只确认拼多多
|
||||
`8.17.0`、goods_id `937122477375` 上的“快要抢光”;其他入口文案必须分别取证,不能按购买
|
||||
语义泛化。
|
||||
**任一维度找不到精确值即停止并转人工,不选相近选项。** 勾选后读取该 SKU 单价
|
||||
(闸门一),读不到即转人工,**不用商品详情页正文或搜索页的数字凑合**。
|
||||
- **F-006 释放要求**:试选完成后**必须退出商品页释放手机**,不得停在规格面板等待人工。
|
||||
- **F-006 硬边界**:试选阶段**绝不点击「现在买」或任何进入下单流程的入口**。
|
||||
必须有测试证明该路径不调用任何下单语义动作。
|
||||
- **F-006 硬边界**:第一趟只把上述精确入口点击视为可逆的“打开规格面板”能力;进入面板后
|
||||
不得调整数量、进入订单确认页、点击“提交订单”或任何支付/资金控件,也不得暴露通用任意点击能力。
|
||||
必须有静态调用链和测试证明上述下单语义动作不可达。入口缺失、重复、版本失配或打开后不是已取证
|
||||
面板时立即停止,不尝试“免拼购买 / 单独购买 / 直接拼成”等相似文案。
|
||||
- **F-007**:回传商品标题、实际勾选到的颜色分类与尺码、单价、合计(单价 × 数量)和
|
||||
规格面板截图;任务转「等你确认」。
|
||||
**自动脱敏后的**规格面板截图;任务转「等你确认」。原始 screenshot/XML 只能留在采购工具本机
|
||||
隔离目录,不能上传、写日志或提交 Git。
|
||||
|
||||
### 决策与资金
|
||||
|
||||
@@ -138,7 +144,8 @@ US-006(付款前核对)在 MVP 降级为:系统展示订单截图与授权
|
||||
|
||||
- **F-011**:失败必须可区分至少这些原因:设备未连接、商品页打不开、规格面板打不开、
|
||||
规格不匹配、单价读不到、单价与授权价不符、数量设置失败、金额超上限、提交控件不唯一、
|
||||
页面识别失败、安全校验、外部支付交接、超时。每种都保留截图和页面快照。
|
||||
页面识别失败、安全校验、外部支付交接、超时。原始截图/页面快照只留采购工具本机隔离目录;
|
||||
脱敏成功时保留派生物,任何上传、远程审阅或服务端展示都只能使用自动复检通过的派生证据。
|
||||
- **版本失配**:运行时读取到的拼多多 App 版本与当前已取证版本不一致时,桌面端必须停止
|
||||
领取真机任务并提示重新取证;不得继续使用旧页面判据。
|
||||
- **付款收口(MVP 简化版)**:订单创建后任务转「待付款」,页面展示订单截图、商品、
|
||||
@@ -177,8 +184,13 @@ US-006(付款前核对)在 MVP 降级为:系统展示订单截图与授权
|
||||
可配置的动作节奏,并在检测到安全校验时立即停止。
|
||||
- **自动化边界风险**:会自动点击并创建订单,属不可逆操作。必须支持 dry-run(跑到
|
||||
订单确认页停止)、服务端提交围栏和点击后调和;任一环节状态不明都不得继续点击。
|
||||
- **隐私风险**:订单确认页含收货地址和掩码手机号。**只读取非敏感摘要,不提取地址
|
||||
原文、手机号或支付凭据**;上传服务端的证据需先脱敏。
|
||||
- **隐私风险**:规格面板和订单确认页都会显示收货地址和掩码手机号。允许原始 screenshot/XML
|
||||
仅在采购工具本机隔离目录短链路落盘供确定性脱敏器消费;业务逻辑、日志、agent、fixture 与采购
|
||||
服务**不得提取或接收地址原文、手机号或支付凭据**。只有脱敏成功且自动复检通过的派生证据才能
|
||||
上传或进入开发材料,无法确认脱敏完整即 fail closed。
|
||||
- **受控规格入口风险**:T-103 已证明当前衣服商品只有购买语义按钮能打开规格面板。项目所有者批准
|
||||
仅把经真机取证的精确唯一入口作为第一趟可逆导航;该批准不覆盖其他文案,不授权调整数量、提交订单、
|
||||
进入确认页或付款。T-103 必须用 capability 隔离和不可达测试证明边界没有扩散。
|
||||
- **规格面板上的单价位置未取证(阻塞 F-006 闸门一)**:选中 SKU 后价格显示在哪个节点、
|
||||
是否带「券后」前缀、是否与原价并列,尚无本项目的真机证据。**T-103 必须一并取证。**
|
||||
若规格面板上无法可靠读到单价,闸门一要改为「只截图不判价」,确认页设计随之调整。
|
||||
|
||||
+134
-12
@@ -43,7 +43,7 @@
|
||||
| 调用位置 | 采购工具 | 已定 | PC 有算力;改 prompt 不需要重新打包 |
|
||||
| provider | 待定 | **待定** | 需先确认预算与合规;不得由 agent 自行选定 |
|
||||
| 凭据存储 | 采购工具本机配置文件,不入库、不上传 | 已定 | 采购服务不保存、不代理、不下发任何模型凭据 |
|
||||
| 输入 | 完整节点树 XML + 页面截图 | 已定 | `dump_hierarchy(compressed=False)` 不丢节点 |
|
||||
| 输入 | 自动复检通过的脱敏派生 XML + 页面截图 | 已定 | 原始证据只允许本机确定性脱敏器消费,AI/agent 不读取原始地址或手机号 |
|
||||
|
||||
## 四、决策记录与演进
|
||||
|
||||
@@ -59,25 +59,147 @@
|
||||
|
||||
## 五、构建与运行命令
|
||||
|
||||
> 采购服务命令已由 T-001 实际验证。采购工具以下离线测试、编译和 wheel 元数据命令已由
|
||||
> Python 3.12 实际验证;桌面 GUI 和真机流程不属于 T-002 验收范围。
|
||||
> `init.ps1` 已由 T-003 在 Windows PowerShell 实际验证:它优先复用合规的既有采购工具虚拟环境
|
||||
> (本机实际为 Python 3.12);仅在虚拟环境不存在时,才从 Python Launcher 的已安装版本中确定性选择
|
||||
> 最高的 Python 3.11+ 创建它,以 editable 方式安装采购工具,并跑两端
|
||||
> 离线门禁。桌面 GUI 和真机流程不属于该入口的验收范围。
|
||||
|
||||
| 用途 | 采购服务(`admin/`) | 采购工具(`client/`) |
|
||||
| --- | --- | --- |
|
||||
| 安装依赖 | `go mod download` | `py -3.12 -m venv .venv` 后运行 `.\.venv\Scripts\python.exe -m pip install -e .` |
|
||||
| 安装依赖 | `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 ./...` | `py -3.12 -m pip wheel --no-deps . --wheel-dir <输出目录>`,再运行 `py -3.12 scripts/verify_wheel_metadata.py <wheel 路径>` |
|
||||
| 测试 | `go test ./...` | `py -3.12 -m unittest discover -s tests -t .` |
|
||||
| 静态检查 | `go vet ./...` | `py -3.12 -m compileall -q src tests scripts` |
|
||||
| 构建 | `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;上述命令保留用于可审计的复现与排障。
|
||||
|
||||
### T-102 按链接打开商品取证(2026-08-04 已完成人工真机验收)
|
||||
|
||||
`client/scripts/capture_product_open.py` 只接受
|
||||
`https://mobile.yangkeduo.com/goods.html?goods_id=<纯数字>` 的唯一 canonical 表示。解析后由
|
||||
`goods_id` 再次重建 URL,并以参数数组执行显式限定 `com.xunmeng.pinduoduo` 的 Android `VIEW`
|
||||
intent;没有任意 URL、任意 shell 或其他 App 控件操作入口。运行拼多多版本必须精确等于 T-101 已
|
||||
取证的 `8.17.0`,版本失配会在 intent 前停止。
|
||||
|
||||
```powershell
|
||||
# 仓库根目录;USB 或 WiFi 每次只保留一个通道在线,再手工复制该次 serial
|
||||
D:\Portable\adb\adb.exe devices -l
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_product_open.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-102\product-open-<GOODS_ID>" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
```
|
||||
|
||||
脚本在 intent 成功后,以 `--timeout` 为明确上限只读轮询前台 package;只有观察到拼多多才采集截图与
|
||||
完整 XML,超时仍 fail closed。这一等待只解决 App 异步切换,不根据 Activity、节点文本或旧项目常量
|
||||
声称已到详情页。成功目录以原子方式发布,manifest 仅记录 `goods_id`、canonical URL、
|
||||
设备/App 非敏感元数据、受限命令摘要、文件路径和 SHA-256,不含原始 serial、Activity 或页面正文。
|
||||
必须由人本地确认截图对应目标商品并检查截图/XML 无地址、手机号、支付信息或其他无关隐私;原始
|
||||
证据不得提交 Git。T-102 已由人确认 goods_id `958756616606` 对应目标商品并完成截图/XML 隐私检查,
|
||||
设备、版本、证据路径与 SHA-256 已记录到任务执行记录。
|
||||
|
||||
### T-103 规格面板三状态只读取证(T-110 边界调整后待重新验证)
|
||||
|
||||
`client/scripts/capture_sku_panel_spike.py` 只采集人已在手机上准备好的规格面板截图和完整 XML。
|
||||
它不打开链接或规格面板,不识别面板,不点击、滑动、输入或选择规格,也不读取价格;接口只暴露
|
||||
`app_info`、`app_current` 和两个只读 JSON-RPC 方法。`panel-opened-target-preselected`、
|
||||
`alternate-all-dimensions-selected`、`target-selection-restored` 三种值写入 manifest 的字段名是
|
||||
`human_declared_state`,明确表示人工声明,不得将其当作自动识别结果。旧的“未选 / 单维度 / 全选”
|
||||
状态假设已被真机事实推翻,旧枚举会被采集器和脱敏器明确拒绝。运行拼多多版本必须精确为 `8.17.0`,
|
||||
前台 package 必须是拼多多,否则 fail closed;目标目录已存在、截图/XML 无效或超时均不得覆盖已有内容
|
||||
或发布半成品。
|
||||
|
||||
T-103 已证明当前衣服商品没有独立「规格/已选」入口。T-110 只批准拼多多 `8.17.0`、goods_id
|
||||
`937122477375` 上经真机确认的精确唯一 `快要抢光` 作为可逆的受控规格面板入口;“免拼购买 / 单独购买 /
|
||||
直接拼成”等其他文案不能凭人工经验复用,必须分别重新取证。项目所有者已确认面板刚打开时目标颜色
|
||||
“黑色CHA(纯棉)”和尺码“M(建议100-115)”均已自动选中;取证不强行取消选择,而是记录刚打开状态、
|
||||
人工把两个维度都改成非目标值、再恢复目标值三个真实状态。只读取证 CLI 本身仍不执行点击;三种状态
|
||||
分别使用一个全新原始证据目录:
|
||||
|
||||
```powershell
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state panel-opened-target-preselected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-opened-target-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state alternate-all-dimensions-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-alternate-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state target-selection-restored --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
```
|
||||
|
||||
PDD 规格面板不可避免显示收货区域和掩码手机号。原始截图/XML 只能留在
|
||||
`%LOCALAPPDATA%\cmbuyer\artifacts\...\raw` 隔离目录,不供 agent、fixture、业务或 HTTP 上传读取;
|
||||
T-103 已在 `client/src/cmbuyer_client/device/sku_evidence_sanitizer.py` 实现本机确定性脱敏器,并由
|
||||
`client/scripts/sanitize_sku_panel_evidence.py` 提供离线 CLI。第一组新 raw 的 PNG 头部由人确认实际为
|
||||
1080×2376,推翻了 v1 将截图和 XML 坐标空间都写成 1080×2400 的假设;v1 正确拒绝且原始证据未重采。
|
||||
随后 v2 在同一份 raw 上安全报告 XML `observed 1080x2376` 并拒绝发布,证明其独立配置的
|
||||
1080×2400 假设同样不成立。v3 用同一 raw 成功发布后,人已确认截图隐私带完整遮挡、派生 XML
|
||||
不含地址或手机号且目标颜色和尺码仍为预选;但顶部当前价节点横跨隐私带边界,v3 只留下断片,
|
||||
而下方完整的“提交订单 ¥12.88”属于第一趟硬拒绝区,不能作为 SKU 单价证据。
|
||||
|
||||
当前 `t103-privacy-v4` 继续精确绑定 PKG110 / Android 16 / 拼多多 8.17.0 / goods_id
|
||||
`937122477375`;screenshot space 与 XML coordinate space 仍分别建模、分别校验,当前都精确为
|
||||
1080×2376,且都把
|
||||
`[0,0,1080,540)` 作为整宽隐私带。截图覆盖该区域;XML 递归移除区域内节点,跨界容器只清空自身
|
||||
敏感属性并保留下方子节点。v4 **不改变或缩小截图遮罩**,只允许交界带两个已取证精确 bounds 中,
|
||||
属于拼多多包、不可点击、可见且启用的 `TextView` 叶节点投影最小价格属性;当前价必须唯一匹配
|
||||
可选“快卖光”前缀加人民币符号和两位小数,原价同格式且最多一个。包外、可点击、结构漂移、重复、
|
||||
零值、混杂文本或坐标变化一律拒绝。地址依靠已确认的整块几何隔离而不是易漏的关键词表;完整、
|
||||
掩码、带分隔符或跨节点手机号残留会被自动复检拒绝。v4 离线实现已通过合成测试,仍须由人用同一
|
||||
第一态 raw 重跑并确认派生结果,才构成真实价格证据。
|
||||
|
||||
```powershell
|
||||
.\client\.venv\Scripts\python.exe client\scripts\sanitize_sku_panel_evidence.py --raw-dir "<证据目录>\raw" --output-dir "<证据目录>\derived"
|
||||
```
|
||||
|
||||
脱敏器校验源 manifest 与文件哈希、设备/App/商品/人工状态、截图分辨率和 XML 隐私结构;XML 所有
|
||||
节点观察到的最大 right/bottom 必须精确为 1080×2376,否则只报告非敏感 observed 尺寸并拒绝。它只允许发布到
|
||||
同级且尚不存在的 `derived`;失败或发布竞态不覆盖已有目录、不留下 staging。派生 manifest 记录
|
||||
`privacy_tier=SANITIZED`、sanitizer 版本、两个坐标空间、清理计数及本机 source/derived 哈希,不记录
|
||||
原始路径、serial 或页面正文;v4 另记录 `preserved_crossing_price_nodes`,用于核对严格价格投影
|
||||
数量。只有自动复检通过并经人确认状态对应性的派生物,agent 才能读取并提取最小 fixture、编写判据。
|
||||
|
||||
2026-08-04 的首轮旧证据只用于确认上述入口事实;其中旧 `initial` 不是规格面板,另两张原始截图含隐私
|
||||
区域,因此 XML 未读取、fixture 与选择器未生成。T-110 完成受控入口与脱敏契约后,T-103 重新取证;
|
||||
该边界调整不授权第一趟调整数量、进入确认页、点击“提交订单”或触碰任何支付控件。
|
||||
|
||||
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.11,不满足采购工具的 Python 3.11+ 下限,
|
||||
不得把未加版本选择器的 `python` 当作采购工具的已验证命令。T-003 负责统一入口与解释器选择;
|
||||
在此之前使用 `py -3.12` 或 Python 3.11+ 虚拟环境。Python 3.12 虚拟环境中的完整
|
||||
`pip install -e .` 已验证通过;桌面 GUI 与真机流程不属于 T-002 验收范围。
|
||||
当前 Windows 默认 `python` 仍可能指向 Python 3.10,不满足采购工具的 Python 3.11+ 下限;
|
||||
不得把未加版本选择器的 `python` 当作采购工具命令。统一入口优先使用既有合规 venv,仅在需要创建时
|
||||
自动选择 Launcher 中最高的合规版本;本机 Python 3.12 与 3.14 均已验证,主工作区当前选择 Python
|
||||
3.14。桌面 GUI 与真机流程不属于 T-003 验收范围。
|
||||
|
||||
Windows PowerShell 差异:
|
||||
|
||||
@@ -90,7 +212,7 @@ $env:GOTOOLCHAIN = "local"
|
||||
|
||||
| 层级 | 触发条件 | 命令 / 操作 | 通过证据 |
|
||||
| --- | --- | --- | --- |
|
||||
| 任务相关验证 | 每个任务必跑 | 改 `admin/` 跑 `go test ./...` + `go vet ./...`;改 `client/` 跑 `py -3.12 -m unittest discover -s tests -t .` + `py -3.12 -m compileall -q src tests scripts` | 退出码 0、测试数 |
|
||||
| 任务相关验证 | 每个任务必跑 | 改 `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 路径 | 人工结论 + 证据文件路径 |
|
||||
|
||||
|
||||
+40
-11
@@ -97,7 +97,7 @@
|
||||
┌──────────────── 第一趟:试选 ────────────────┐
|
||||
│ 采购工具轮询领取 PENDING 任务 │
|
||||
│ 1. open_product(url) │
|
||||
│ 2. 打开规格面板 │
|
||||
│ 2. 经版本绑定、精确唯一的受控入口打开规格面板 │
|
||||
│ 3. 按维度精确勾选颜色分类、尺码 │
|
||||
│ 4. 【闸门一】读该 SKU 单价,算合计 │
|
||||
│ 5. 截图 │
|
||||
@@ -139,6 +139,25 @@
|
||||
代价是同一商品走两遍,但第二趟很快,而且换来两个好处:手机可以在人思考时继续跑别的
|
||||
任务的试选;价格变动能被第二趟抓住。
|
||||
|
||||
### 第一趟受控规格面板入口
|
||||
|
||||
T-103 的真机证据推翻了“商品详情页存在独立规格入口”的假设:拼多多 8.17.0、goods_id
|
||||
`937122477375` 只能从“快要抢光”打开规格面板。项目所有者于 2026-08-04 批准把这一点击定义为
|
||||
**可逆且能力受限的规格面板导航**,不把它当作下单授权,也不把购买语义文案整体加入白名单。
|
||||
|
||||
第一趟执行器只能持有以下 capability:
|
||||
|
||||
1. 打开 canonical 商品链接。
|
||||
2. 点击与证据哈希、拼多多版本和页面状态绑定的精确唯一 `快要抢光` 入口。
|
||||
3. 在已确认的维度容器中精确选择规格并读回选中态。
|
||||
4. 从规格面板读取单价、生成脱敏派生证据、关闭面板并退出商品页。
|
||||
|
||||
第一趟 capability **不得包含**通用 `click`、数量增减、进入订单确认页、提交订单或付款能力。
|
||||
“免拼购买 / 单独购买 / 直接拼成”等其他文案即使人工认为行为相同,也必须各自重新取证后才能评审;
|
||||
入口缺失、重复、版本失配、打开后面板判据不唯一,或出现“提交订单”以外的未知终态按钮时立即停止。
|
||||
第一趟的静态依赖检查必须证明 `set_quantity()`、`go_to_order_confirm()`、`submit_order()` 和任何
|
||||
支付函数不可达。
|
||||
|
||||
### 三道价格闸门
|
||||
|
||||
| 闸门 | 位置 | 作用 | 不通过时 |
|
||||
@@ -175,7 +194,7 @@ V2 实现时仍遵守:**图搜的唯一产出是 goods_id**,不在搜索结
|
||||
| 提交订单控件唯一 | 文本精确等于「提交订单」且可点击祖先唯一,否则停 | 点到未知控件 |
|
||||
| 数量必须复核 | 设置后读回确认精确等于要求值,否则停 | 买错数量 |
|
||||
| 价格三道闸门 | 见第三节。任一道读不到或不通过即停,**不用其他位置的数字凑合** | 超预算采购 |
|
||||
| 第一趟不下单 | 试选阶段只勾选规格和读价,**绝不点击「现在买」或任何进入下单流程的入口** | 无授权下单 |
|
||||
| 第一趟不下单 | 只允许点击证据/版本绑定的精确唯一受控入口打开规格面板,当前仅为 `快要抢光`;随后只选规格、读价、脱敏取证和返回。数量、确认页、`提交订单`、付款与通用点击能力均不可达 | 无授权下单 |
|
||||
| 外部支付页 | 检测到微信等外部支付交接立即停止、转人工、保留证据 | 凭据泄露 |
|
||||
| 安全校验 | 检测到验证码、风控、人脸、短信校验立即停止,不尝试绕过 | 封号 / 违规 |
|
||||
| 敏感信息 | 只读非敏感摘要,不提取收货地址原文、手机号、支付凭据 | 隐私泄露 |
|
||||
@@ -368,25 +387,30 @@ PENDING_RETRIAL ─────────────┴─claim→ CLAIMED
|
||||
|
||||
| 数据 | 位置 | 理由 |
|
||||
| --- | --- | --- |
|
||||
| 候选商品页 / 规格页截图 | 上传采购服务 | 管理员做授权决策必须看 |
|
||||
| 订单确认页截图 | 上传采购服务 | 授权后核对与审计必须留 |
|
||||
| 订单核对截图 | 上传采购服务 | 资金核对证据 |
|
||||
| 完整节点树 XML | **仅采购工具本地** | 体积大、含页面全文、只用于排障 |
|
||||
| 原始商品页 / 规格页 screenshot/XML | **仅采购工具本机隔离目录** | PDD 页面不可避免包含收货区域和掩码手机号;只允许确定性脱敏器读取,不供业务、agent、fixture 或上传消费 |
|
||||
| 脱敏派生商品页 / 规格页截图 | 上传采购服务 | 管理员做授权决策必须看;服务端只接收派生哈希和 sanitizer 版本,原始/派生哈希映射仅留本机 manifest |
|
||||
| 最小脱敏 XML fixture | 采购工具测试 / 可提交 Git | 只保留页面判据所需结构;自动复检无地址、手机号、支付凭据后才可发布 |
|
||||
| 脱敏派生订单确认页截图 | 上传采购服务 | 授权后核对与审计必须留;原始物仍只在本机隔离目录 |
|
||||
| 脱敏派生订单核对截图 | 上传采购服务 | 资金核对证据;原始物仍只在本机隔离目录 |
|
||||
| AI 调用记录(P1) | **仅采购工具本地** | 含 prompt / 响应全文,脱敏成本高 |
|
||||
| 失败现场快照 | 仅采购工具本地,可按需手工导出 | 同上 |
|
||||
| 失败现场快照 | 原始物仅采购工具本机;只能手工导出脱敏派生物 | 同上 |
|
||||
|
||||
上传前必须脱敏:**不上传含收货地址、手机号、支付凭据的截图区域或文本。**
|
||||
原始目录不得被 HTTP sink、Vikunja 导出、日志或 fixture 构建器读取。脱敏器必须先验证设备分辨率、
|
||||
页面/App 版本和预期隐私区域,再同时生成派生 screenshot/XML;派生 XML 仍命中手机号模式、截图隐私
|
||||
区域无法确定、sanitizer 异常或任一哈希不一致时,不发布派生目录。上传端只接受 manifest 明确标记
|
||||
`privacy_tier=SANITIZED` 的派生截图,**不上传含收货地址、手机号、支付凭据的区域或文本。**
|
||||
|
||||
## 六、关键技术难点
|
||||
|
||||
| 难点 | 说明 | 应对 |
|
||||
| --- | --- | --- |
|
||||
| 拼多多页面结构随版本变化 | 前序项目已观察到详情页无独立规格入口、价格节点拆分等变化 | **每条判据先做真机 spike 取证再写代码**;判据与 App 版本一并记录 |
|
||||
| 规格面板安全入口 | T-103 已在拼多多 8.17.0 真机确认衣服商品只能通过购买语义入口打开规格面板 | T-110 已批准仅使用当前证据证明的精确唯一 `快要抢光` 作为受控导航;其他文案不泛化,第一趟下单能力保持不可达 |
|
||||
| 规格面板上的价格位置 | 选中 SKU 后价格显示在哪、是否含券后前缀,未取证 | **T-103 必须一并取证**,闸门一依赖它;读不到就转人工,不用详情页数字凑合 |
|
||||
| 同一商品两趟结果不一致 | 第二趟价格变了、规格选项变了或商品下架 | 闸门二拦截;一律转人工,不自动放弃也不自动继续 |
|
||||
| 图搜结果含跨类目商品(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) 是唯一权威 |
|
||||
|
||||
@@ -395,8 +419,9 @@ PENDING_RETRIAL ─────────────┴─claim→ CLAIMED
|
||||
## 七、推荐开发顺序
|
||||
|
||||
1. **Phase 0 地基**:两端骨架、测试命令、`init` 脚本可运行。
|
||||
2. **Phase 1 真机取证**:WiFi ADB 连通;打开商品 → 打开规格面板 → 按维度精确勾选颜色
|
||||
分类和尺码 → **读到该 SKU 单价** → 设数量 → 进订单确认页 → 读「实付款」。
|
||||
2. **Phase 1 真机取证**:WiFi ADB 连通;先验证第一趟的打开商品 → 受控打开规格面板 → 按维度
|
||||
精确勾选颜色分类和尺码 → **读到该 SKU 单价** → 脱敏取证 → 退出;再以独立的第二趟 spike
|
||||
验证设数量 → 进订单确认页 → 读「实付款」。第一趟 capability 不含后三项。
|
||||
**结论写入文档,判据带拼多多 App 版本。**
|
||||
3. **Phase 2 采购服务核心**:数据模型与状态机、手工建单、任务查询、试选结果接收、
|
||||
确认页与授权签发、**授权超时与放弃**。
|
||||
@@ -408,6 +433,10 @@ PENDING_RETRIAL ─────────────┴─claim→ CLAIMED
|
||||
**不要在 Phase 1 结论出来之前写 Phase 2 的页面**——确认页要显示什么,取决于真机上
|
||||
究竟能读到什么。尤其是闸门一的单价,如果规格面板上读不可靠,整个确认页的设计要改。
|
||||
|
||||
> 2026-08-04:Phase 1 证明已验证衣服商品没有独立规格入口。项目所有者随后批准 T-110 的受控入口
|
||||
> 方案:当前只允许证据绑定的精确唯一 `快要抢光` 打开规格面板,并以 capability 隔离保证数量、确认页、
|
||||
> 提交订单和付款在第一趟不可达。T-103 仍须先实现自动脱敏与新证据门禁,完成前 Phase 2 保持冻结。
|
||||
|
||||
## 八、项目结构
|
||||
|
||||
```text
|
||||
|
||||
+15
-2
@@ -28,6 +28,11 @@
|
||||
已消费。
|
||||
- **第一趟试选的代码路径不得引用 `go_to_order_confirm()` 与 `submit_order()`**,
|
||||
必须有测试证明不可达。
|
||||
- 第一趟只可通过独立的 `open_trial_sku_panel()` capability 点击本项目真机证据与 App 版本绑定的
|
||||
精确唯一入口;当前仅允许拼多多 `8.17.0` 上已取证的 `快要抢光`。不得向第一趟暴露通用 `click`、
|
||||
`set_quantity()`、订单确认、提交或付款能力,不得把其他购买文案作为包含/同义匹配兜底。
|
||||
- 规格面板内即使可见“提交订单”、微信支付、先用后付或 0 元下单,第一趟也只能把它们作为硬拒绝
|
||||
判据,绝不能返回可点击对象或尝试继续。
|
||||
|
||||
### 1.2 匹配纪律
|
||||
|
||||
@@ -51,11 +56,17 @@
|
||||
- 检测到外部支付交接立即停止,**不读取、不保存、不输入任何凭据**。
|
||||
- 只读非敏感摘要,**不提取收货地址原文、手机号、支付凭据**。
|
||||
- 上传服务端的证据必须先脱敏。
|
||||
- PDD 页面不可避免显示地址/掩码手机号时,原始 screenshot/XML 只允许写入采购工具本机隔离目录,
|
||||
只由确定性脱敏器消费;业务代码、agent、fixture、日志和 HTTP sink 只能读取自动复检通过且 manifest
|
||||
标记 `privacy_tier=SANITIZED` 的派生物。脱敏失败、分辨率/版本不符或派生 XML 仍命中手机号模式时
|
||||
必须拒绝发布,不得用人工口头确认绕过。
|
||||
|
||||
### 1.5 页面判据
|
||||
|
||||
- **不得从前序项目、旧文档或推理直接写页面判据。** 必须有本项目的真机取证。
|
||||
- 每条判据必须记录取证时的**拼多多 App 版本**。
|
||||
- 购买语义按钮不能按“作用相同”共享判据。`快要抢光`、`免拼购买`、`单独购买`、`直接拼成` 等
|
||||
每个入口文案都必须分别取证;只允许精确唯一匹配,不允许包含、前缀、相似或坐标兜底。
|
||||
- 判据失效时先重新取证,不要靠加兜底分支硬扛。
|
||||
|
||||
> 这些红线不是建议。[`04-architecture.md`](04-architecture.md) 第四节列出的每一条都必须有
|
||||
@@ -85,8 +96,10 @@
|
||||
- V2 / V3 只记录,不实现。**图搜、Excel、ERP、订单自动核对、AI 辅助全部不在 MVP。**
|
||||
- 需求明确排除的非目标不得实现。
|
||||
- 不为「将来可能用到」提前抽象。
|
||||
- **Phase 1 真机结论出来之前不写 Phase 2 的页面**——确认页显示什么,取决于真机上
|
||||
能读到什么,尤其是规格面板上的单价。
|
||||
- **Phase 1 真机结论出来之前,不写依赖真机可读字段或会启动试选的 Phase 2 功能。** T-201
|
||||
管理会话与只创建 `DRAFT` 的 T-202 基础建单 / 列表可以并行;T-203 的批量开始试选、T-204
|
||||
的试选证据详情以及 T-205 以后仍等待 T-103。T-201 / T-202 不得夹带机器实际规格、规格面板
|
||||
单价、证据、授权、提交或付款字段。
|
||||
|
||||
## 5. 架构纪律
|
||||
|
||||
|
||||
+9
-6
@@ -10,8 +10,9 @@
|
||||
把验收要点展开成可执行、可观察的步骤,再开始实现。
|
||||
2. **每个 agent 一次只做一个任务**:领取、状态流转、执行记录、完成定义遵循
|
||||
[`tasks/README.md`](tasks/README.md) 和[编码规则](05-coding-rules.md)。
|
||||
3. **不跳步**:依赖未完成的任务不能开工。Phase 0 可先做使用假数据的低保真交互原型;
|
||||
**Phase 1 的真机结论出来之前不写 Phase 2 的生产页面**,T-103 若改变可读字段则先修订原型与 IX。
|
||||
3. **不跳步**:依赖未完成的任务不能开工。T-103 完成前可并行 T-201 管理会话与只创建 `DRAFT`
|
||||
的 T-202 基础建单 / 列表;**依赖真机可读字段或会启动试选的 Phase 2 功能仍不得抢跑**,
|
||||
T-103 若改变可读字段则先修订原型与 IX。
|
||||
4. **本文只在规划变化时修改**:单个任务开工或完成**不**修改本文。
|
||||
5. **动手前**先读 `00-ai-start-here.md`、`05-coding-rules.md` 和 `current-state.md`。
|
||||
|
||||
@@ -24,14 +25,15 @@
|
||||
| --- | --- | --- | --- |
|
||||
| 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 均完成 |
|
||||
| 3 | 与 T-103 并行做 T-201 → T-202;T-103 后做 T-203 → 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 生产页面。
|
||||
- T-103 的真机结论出来前,T-201 / T-202 只能落管理会话、`DRAFT` 建单与基础列表;不启动试选,
|
||||
不展示机器规格、规格面板单价或证据。T-203~T-207 继续等待 T-103。
|
||||
- `needs_device: true` 的任务仍只能由人完成验收。
|
||||
- 不复用前序项目页面判据,不放宽三道价格闸门,不让第一趟引用任何下单函数。
|
||||
- 第三个 agent 优先做写路径独立的集成任务或只读复核,不与两端任务争写共享文档。
|
||||
@@ -126,8 +128,9 @@ T-004 或整个 Phase 0 收尾。并行只优化等待关系,不改变下列
|
||||
- **M5**:第一趟试选端到端跑通,任务能停在「等你确认」。(T-305)
|
||||
- **M6**:MVP 闭环——第二趟下单成功,任务停在「待付款」。(T-401)
|
||||
|
||||
**M2 是本项目的生死线。** 前序项目正是卡在选规格和读价;M2 不通过之前不要写 Phase 2
|
||||
的生产页面。Phase 0 原型只用于确认信息架构,T-103 若改变可读字段必须先回修原型与 IX。
|
||||
**M2 是本项目的生死线。** 前序项目正是卡在选规格和读价;M2 不通过之前只允许 T-201 与
|
||||
不启动试选的 T-202,不写依赖真机字段或会推进任务执行的 Phase 2 功能。原型只用于确认信息架构,
|
||||
T-103 若改变可读字段必须先回修原型与 IX。
|
||||
|
||||
## 待办池(Backlog)
|
||||
|
||||
|
||||
+40
-15
@@ -168,6 +168,27 @@
|
||||
- 无可领任务返回 `200` 且 `task` 为 `null`,**不是 404**。
|
||||
- 后续所有该任务的调用必须携带 `X-Claim-Token` 与匹配的 `claim_generation`。
|
||||
|
||||
### `POST /api/v1/tasks/{id}/evidence`(只接收脱敏派生物)
|
||||
|
||||
规格面板和订单确认页可能固定展示地址与掩码手机号。采购工具必须先在本机隔离目录保存原始证据,
|
||||
再由确定性脱敏器生成派生 screenshot/XML;本接口只接受派生截图及如下审计元数据:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "SKU_PANEL_SCREENSHOT",
|
||||
"privacy_tier": "SANITIZED",
|
||||
"artifact_sha256": "<派生截图 SHA-256>",
|
||||
"sanitizer_version": "sku-panel-pdd-8.17.0-v1"
|
||||
}
|
||||
```
|
||||
|
||||
- `privacy_tier` 必须精确为 `SANITIZED`;缺失、其他值或 sanitizer 元数据不完整均拒绝。
|
||||
- 上传内容的 SHA-256 必须等于 `artifact_sha256`。服务端不接收原始文件、原始哈希、原始路径、
|
||||
原始 XML、地址、手机号或支付凭据;原始/派生哈希映射只存在于采购工具本机 manifest。
|
||||
- 原始目录不得被 `HttpResultSink` 或证据上传器枚举;调用方必须显式传入已原子发布的派生目录。
|
||||
- 完整 XML 永不上传。经自动复检的最小脱敏 XML 只用于采购工具离线 fixture;仍命中手机号模式或
|
||||
脱敏结果不确定时,客户端调用 `/needs-manual`,不得上传截图或继续试选。
|
||||
|
||||
### `POST /api/v1/tasks/{id}/spec-trial`(第一趟回传)
|
||||
|
||||
```json
|
||||
@@ -187,7 +208,7 @@
|
||||
- `unit_price` 来自闸门一(规格面板)。**读不到时不要发这个接口**,改发
|
||||
`/needs-manual` 并带原因码 `UNIT_PRICE_UNREADABLE`。
|
||||
- `total_price` = `unit_price` × 任务数量,服务端会重算校验。
|
||||
- 证据须先经 `/evidence` 上传。
|
||||
- 证据须先经 `/evidence` 上传,且对应资产必须为 `privacy_tier=SANITIZED`。
|
||||
- 服务端接收后创建 `spec_trials` 记录,任务转 `WAITING_CONFIRMATION`。
|
||||
|
||||
### dry-run 与真实提交协议
|
||||
@@ -282,25 +303,29 @@ class ResultSink(ABC):
|
||||
|
||||
### 真机流程模块
|
||||
|
||||
`client/src/android/pdd_flow.py` 的公开入口,每个都不得越界:
|
||||
`client/src/android/pdd_flow.py` 的公开入口按 capability 分离,每个都不得越界:
|
||||
|
||||
| 函数 | 输入 | 输出 | 副作用边界 |
|
||||
| --- | --- | --- | --- |
|
||||
| `open_product(url)` | 商品 URL | 页面快照路径 | 只打开页面,不点击购买 |
|
||||
| `open_sku_panel()` | - | 面板快照 | 只点规格入口,不提交 |
|
||||
| `select_sku_options(items)` | `{维度: 值}` | 选中证据 | 按维度精确匹配,找不到抛错 |
|
||||
| `set_quantity(n)` | 数量 | 读回值 | 必须复核等于 n |
|
||||
| `read_sku_unit_price(xml)` | 规格面板 XML | 单价或 `None` | **闸门一**;读不到返回 `None`,不猜 |
|
||||
| `leave_product()` | - | - | 第一趟结束时退出并释放手机 |
|
||||
| `go_to_order_confirm()` | - | 确认页摘要 | **可能创建订单**,需显式授权 |
|
||||
| `read_order_confirm_info(xml)` | 页面 XML | 非敏感摘要 | **闸门三**;不提取地址原文、手机号 |
|
||||
| `submit_order(auth, submission)` | 授权 + 已建立的提交围栏 | 提交结果 | **唯一创建真实订单入口**,四条件与围栏全通过后只点一次 |
|
||||
| 函数 | 可用趟次 | 输入 | 输出 | 副作用边界 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `open_product(url)` | TRIAL / ORDER | 商品 URL | 页面快照路径 | 只打开页面,不点击控件 |
|
||||
| `open_trial_sku_panel(evidence_key)` | **仅 TRIAL** | 版本与证据绑定键 | 面板快照 | 只点击精确唯一、已取证的受控入口;当前仅 `快要抢光`,无通用 click |
|
||||
| `select_sku_options(items)` | TRIAL / ORDER | `{维度: 值}` | 选中证据 | 按维度精确匹配,找不到抛错 |
|
||||
| `sanitize_evidence(raw_manifest)` | TRIAL / ORDER | 本机隔离目录 manifest | 派生 manifest | 原子发布脱敏派生物;失败不发布,原始内容不进入日志/上传 |
|
||||
| `read_sku_unit_price(xml)` | TRIAL / ORDER | 脱敏规格面板 XML | 单价或 `None` | **闸门一 / 二**;读不到返回 `None`,不猜 |
|
||||
| `leave_product()` | TRIAL / ORDER | - | - | 第一趟结束时退出并释放手机 |
|
||||
| `set_quantity(n)` | **仅 ORDER** | 数量 | 读回值 | 必须复核等于 n;TRIAL capability 不暴露 |
|
||||
| `go_to_order_confirm()` | **仅 ORDER** | - | 确认页摘要 | 需显式授权;TRIAL capability 不暴露 |
|
||||
| `read_order_confirm_info(xml)` | **仅 ORDER** | 脱敏页面 XML | 非敏感摘要 | **闸门三**;不提取地址原文、手机号 |
|
||||
| `submit_order(auth, submission)` | **仅 ORDER** | 授权 + 已建立的提交围栏 | 提交结果 | **唯一创建真实订单入口**,四条件与围栏全通过后只点一次 |
|
||||
|
||||
两个不可逆入口:
|
||||
能力隔离规则:
|
||||
|
||||
- `go_to_order_confirm()` 必须校验授权存在;`submit_order()` 还必须校验授权已由服务端围栏
|
||||
且 `submission` 与当前任务、命令、授权完全一致。
|
||||
- **第一趟的代码路径不得引用这两个函数。** 必须有测试证明试选流程不可达它们。
|
||||
- 第一趟只能拿到 `TrialSkuFlow` 窄接口,接口中不得出现通用 `click`、`set_quantity()`、
|
||||
`go_to_order_confirm()`、`submit_order()` 或支付能力;静态依赖测试必须证明试选流程不可达它们。
|
||||
- `open_trial_sku_panel()` 的点击是唯一批准的购买语义控件例外,只用于打开已取证规格面板;入口
|
||||
缺失/重复、App 版本不符、面板判据不唯一或出现未知终态控件时停止。其他入口文案不得推断复用。
|
||||
- `search_by_image()` 属 B 路径,V2 再实现。
|
||||
|
||||
## 四、待实现时确认
|
||||
|
||||
+117
-43
@@ -11,35 +11,46 @@
|
||||
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-08-03
|
||||
- 阶段:**Phase 0 · 地基(采购服务与采购工具骨架已初始化,尚无采购业务代码)**
|
||||
- 日期:2026-08-04
|
||||
- 阶段:**Phase 1 · 真机可行性(T-110 已批准受控规格入口,T-103 重新执行)**
|
||||
- MVP 形态:手工填链接建单 → 批量开始试选 → 定时轮询 → **第一趟试选** → 人工确认 → **第二趟下单** → 待付款
|
||||
- 技术栈:已定。采购服务(`admin/`)使用 Go 1.23+ / gin / SQLite;采购工具(`client/`)
|
||||
使用 Python 3.11+ / uiautomator2 / PySide6。
|
||||
详见 [`03-tech-stack.md`](03-tech-stack.md)
|
||||
- 生产代码:`admin/` 已有最小 Go 服务、`GET /healthz` 与 SQLite 驱动封装;`client/` 已有
|
||||
Python 包、PySide6 最小入口、运行目录与日志脱敏策略;两端均尚无采购业务代码
|
||||
- 测试:采购服务 2 项离线单元测试;采购工具 6 项离线单元测试(不连接真机)
|
||||
- 数据:**无**
|
||||
- 标准启动路径:`./init.ps1`(Windows)/ `./init.sh`。两端骨架现已存在,但 `init.ps1` 仍使用
|
||||
默认 Python 3.10,不能正确验证要求 Python 3.11+ 的采购工具;T-003 负责统一入口与解释器选择。
|
||||
- 标准验证路径:`admin/` 下运行 `go test ./...`、`go vet ./...`、`go build ./...`;`client/` 下使用
|
||||
`py -3.12 -m unittest discover -s tests -t .`、`py -3.12 -m compileall -q src tests scripts`;仓库根运行
|
||||
`python scripts/validate_agent_context.py`
|
||||
- 当前 blocker:无外部 blocker。T-002 已完成;T-004 核心数据模型正在进行;T-003 统一入口与
|
||||
T-101 真机环境盘点已就绪。当前不宣称统一入口或桌面 GUI 已验收。
|
||||
- 生产代码:`admin/` 已有最小 Go 服务、健康检查、核心领域模型、SQLite 迁移与任务状态机;
|
||||
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
|
||||
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板状态的只读取证 CLI,
|
||||
以及绑定 PKG110 / Android 16 / 拼多多 8.17.0 的规格证据确定性脱敏 CLI;尚无规格选择、价格读取或下单流程
|
||||
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 78 项离线单元测试
|
||||
(全部 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,以及仓库上下文校验。可单独运行两端命令诊断。
|
||||
- 当前设备门禁:人工已确认拼多多 8.17.0、goods_id `937122477375` 的衣服商品只能通过“快要抢光”
|
||||
打开规格面板;T-110 已获项目所有者批准,只把该证据/版本绑定的精确唯一入口作为第一趟可逆导航,
|
||||
数量、确认页、提交订单、付款与通用点击能力仍不可达。T-103 的原始证据本机隔离与确定性脱敏器
|
||||
已完成离线实现;第一态 v3 derived 已由人确认隐私安全,并证明目标颜色和尺码自动预选,派生哈希
|
||||
复算一致。顶部规格价格横跨隐私遮罩边界,当前只保留了不可读断片;唯一完整金额位于禁触的
|
||||
“提交订单 ¥12.88”区域,不能作为第一趟价格证据。v4 已在保持截图遮罩不变的前提下,只允许顶部
|
||||
交界带中满足精确坐标、拼多多包、非点击叶节点和严格金额格式的文本投影到派生 XML,离线门禁已通过;
|
||||
下一步由人保留已验收 v3 derived,并用同一 raw 重跑 v4。价格证据通过后才采集“两个维度改为非目标 /
|
||||
两个维度恢复目标”两态。T-010 已允许不依赖真机字段的 T-201 和只创建 `DRAFT` 的 T-202 并行;
|
||||
T-203 及后续会启动试选或依赖真机字段的 Phase 2 功能继续等待 T-103。
|
||||
|
||||
## 当前目录要点
|
||||
|
||||
| 路径 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
|
||||
| `docs/tasks/` | 已有(T-001、T-002、T-004~T-009) | T-001、T-002 已完成;T-004 正在进行;其余既有任务已完成 |
|
||||
| `docs/tasks/` | 已有(T-001~T-004、T-005~T-009、T-101~T-110) | T-001~T-004、T-101~T-102、T-110 已完成;T-103 重新执行 |
|
||||
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
||||
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
||||
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite 骨架与健康检查;无采购业务路由 |
|
||||
| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、离线测试与 wheel 元数据检查;无真机或采购流程 |
|
||||
| `init.ps1` / `init.sh` | 已有(骨架) | 统一入口。两端目录建好后由 T-003 补全并验证 |
|
||||
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 |
|
||||
| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、显式 serial 的基线/商品打开/规格面板只读取证、确定性证据脱敏、离线测试与 wheel 元数据检查;无规格选择、价格读取或下单流程 |
|
||||
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
|
||||
|
||||
## 任务状态
|
||||
|
||||
@@ -48,59 +59,122 @@
|
||||
- 已完成:T-005(采购服务交互原型)、T-006(采购工具交互原型)、T-007(产品名称与
|
||||
源码目录契约)、T-008(Vikunja 任务权威与单向导出)、T-009(MVP 关键路径与并行波次),
|
||||
以及 T-001(采购服务 Go 骨架)。
|
||||
- 已完成:T-002(采购工具 Python 骨架)。正在进行:T-004(核心数据模型)。
|
||||
T-003(统一入口)与 T-101(真机环境盘点)已就绪,立即推进 T-101 → T-102 → T-103。
|
||||
- T-103 是当前最高优先级和 MVP 生死线。通过前不开发依赖真机可读字段的 Phase 2 生产页面。
|
||||
- 已完成:T-002(采购工具 Python 骨架)、T-003(双端统一初始化与验证入口)、
|
||||
T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)、T-102(canonical
|
||||
链接打开与目标商品/隐私人工验收)。
|
||||
- 已完成 T-010(安全并行门禁);T-201(管理员登录与会话)已在独立写路径并行开发。T-202 可在
|
||||
T-201 完成后继续,但只能创建和展示 `DRAFT`,不得启动试选或引入未经 T-103 证实的真机字段。
|
||||
- 已完成 T-110(第一趟受控规格入口与隐私脱敏边界)。T-103 是当前最高优先级和 MVP 生死线,
|
||||
已补充 T-110 依赖并恢复 `DOING`;v4 脱敏器离线实现已完成,三态派生证据和新真机验收完成前不开发
|
||||
T-203 及后续依赖真机可读字段或会启动试选的 Phase 2 生产页面。
|
||||
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
|
||||
可读字段时必须先回修原型与交互清单。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
采购服务当前可运行:
|
||||
Windows 统一安装、离线验证与启动命令提示已可运行:
|
||||
|
||||
```bash
|
||||
cd admin
|
||||
go mod download
|
||||
go run ./cmd/server
|
||||
```powershell
|
||||
.\init.ps1
|
||||
```
|
||||
|
||||
采购服务验证与仓库级上下文门禁:
|
||||
它会在成功后输出以下真实启动命令,而不自动启动或连接设备:
|
||||
|
||||
```bash
|
||||
cd admin
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
|
||||
cd ..
|
||||
python scripts/validate_agent_context.py
|
||||
```text
|
||||
采购服务:cd admin; go run ./cmd/server
|
||||
采购工具:cd client; .\.venv\Scripts\python.exe -m cmbuyer_client
|
||||
```
|
||||
|
||||
采购工具当前可运行的离线验证与 wheel 元数据检查:
|
||||
|
||||
```powershell
|
||||
cd client
|
||||
py -3.12 -m unittest discover -s tests -t .
|
||||
py -3.12 -m compileall -q src tests scripts
|
||||
.\.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
|
||||
py -3.12 -m pip wheel --no-deps . --wheel-dir $wheelDir
|
||||
py -3.12 scripts/verify_wheel_metadata.py (Get-ChildItem $wheelDir -Filter '*.whl').FullName
|
||||
.\.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
|
||||
py -3.12 -m venv .venv
|
||||
.\.venv\Scripts\python.exe -m pip install -e .
|
||||
.\.venv\Scripts\python.exe -m cmbuyer_client
|
||||
```
|
||||
|
||||
`client/requirements.txt` 是唯一依赖来源,`client/pyproject.toml` 动态读取它生成 wheel 的
|
||||
`Requires-Dist`。Python 3.12 已验证 6 项离线测试、编译与 wheel 元数据;完整运行时依赖安装
|
||||
(`pip install -e .`)已通过。`init.ps1` 当前仍选择 Python 3.10,因此不能声称统一入口已正确;
|
||||
桌面 GUI 与真机流程未作为 T-002 验收执行。
|
||||
`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。
|
||||
|
||||
T-102 的人工真机验收命令(只接受唯一 canonical 链接;USB 或 WiFi 每次只保留一个通道在线):
|
||||
|
||||
```powershell
|
||||
# 仓库根目录;从输出中手工复制本次在线 serial
|
||||
D:\Portable\adb\adb.exe devices -l
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_product_open.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-102\product-open-<GOODS_ID>" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
```
|
||||
|
||||
脚本只允许 Android `VIEW` intent,并把 package 固定为 `com.xunmeng.pinduoduo`;intent 后会在
|
||||
`--timeout` 的有限窗口内只读轮询前台 package,解决 App 异步切换造成的一次性误判,超时仍会停止。
|
||||
它不点击、滑动、输入或判断商品页节点,也不打开规格、读取价格、进入下单或支付。运行拼多多版本必须精确为
|
||||
`8.17.0`,否则在 intent 前停止。成功后由人本地查看截图/XML,确认页面确为该 `goods_id` 对应商品并
|
||||
检查无地址、手机号、支付信息或其他无关隐私;只回报 manifest 路径及截图/XML SHA-256,原始证据
|
||||
不得提交 Git。T-102 已由人确认 goods_id `958756616606` 的目标商品及截图/XML 隐私,并完成验收。
|
||||
|
||||
T-103 已确认当前衣服商品只能从精确文案“快要抢光”进入规格面板;T-110 只批准该证据/版本绑定入口,
|
||||
不授权“免拼购买 / 单独购买 / 直接拼成”等其他文案。现有只读取证脚本本身仍不打开面板、不点击或选择
|
||||
规格,也不读取价格;`--state` 只是人工声明,不能作为自动判据。面板刚打开时目标颜色
|
||||
“黑色CHA(纯棉)”与尺码“M(建议100-115)”已经自动选中,不强行取消。人依次准备刚打开目标预选、
|
||||
颜色和尺码均为非目标值、再恢复目标值三个状态,并分别采集到全新 `raw` 目录:
|
||||
|
||||
```powershell
|
||||
# 仓库根目录;三次采集使用三个全新的 output-dir
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state panel-opened-target-preselected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-opened-target-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state alternate-all-dimensions-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-alternate-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state target-selection-restored --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
```
|
||||
|
||||
每组 raw 采集成功后分别运行离线脱敏器;`derived` 必须尚不存在:
|
||||
|
||||
```powershell
|
||||
.\client\.venv\Scripts\python.exe client\scripts\sanitize_sku_panel_evidence.py --raw-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-opened-target-<GOODS_ID>-v2\raw" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-opened-target-<GOODS_ID>-v2\derived"
|
||||
.\client\.venv\Scripts\python.exe client\scripts\sanitize_sku_panel_evidence.py --raw-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-alternate-<GOODS_ID>-v2\raw" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-alternate-<GOODS_ID>-v2\derived"
|
||||
.\client\.venv\Scripts\python.exe client\scripts\sanitize_sku_panel_evidence.py --raw-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored-<GOODS_ID>-v2\raw" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored-<GOODS_ID>-v2\derived"
|
||||
```
|
||||
|
||||
PDD 现场显示地址和掩码手机号不阻塞真机流程;但原始 screenshot/XML 只能留在本机 `raw` 隔离目录,
|
||||
不能由 agent、fixture、业务或上传端消费。`t103-privacy-v4` 把截图与 XML 保持为两个独立坐标空间,
|
||||
并根据同一第一态 raw 的两次 fail-closed 安全观测将二者分别精确固定为 1080×2376;截图隐私带沿用
|
||||
v3,不扩大、不缩小。v4 只从两个精确交界 bounds 投影属于拼多多包、不可点击且格式严格唯一的价格
|
||||
叶节点,任何点击节点、包外节点、坐标/结构/文本漂移、重复或零值都 fail closed。它校验源哈希、
|
||||
设备/App/商品/人工状态与隐私结构,在 sibling `derived` 目录原子发布 screenshot/XML 与 manifest;
|
||||
任何不匹配、手机号残留或已有目标均拒绝发布。
|
||||
自动复检通过且人确认三态对应性后,agent 才能读取派生物并提取最小 fixture、编写判据。
|
||||
人工确认中还必须记录中间态实际选择的非目标颜色和尺码,不能只写“已切换”。
|
||||
|
||||
## 关键背景
|
||||
|
||||
|
||||
@@ -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` 第四节安全边界,入口脚本不得触发任何业务动作。
|
||||
+7
-3
@@ -3,7 +3,7 @@ id: T-004
|
||||
title: 建立核心数据模型与状态机
|
||||
phase: 0
|
||||
deps: [T-001]
|
||||
status: DOING
|
||||
status: DONE
|
||||
created: 2026-08-03
|
||||
vikunja_task_id: 19
|
||||
context_ref: c72371c
|
||||
@@ -23,7 +23,7 @@ write_paths:
|
||||
- docs/api.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=19 synced=2026-08-03T10:16:07Z sha256=dc6e71b81abdf95ef5942603c31944dd3eb76141e9d2edc8edef56d71a1b1d4c -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=19 synced=2026-08-03T10:37:34Z sha256=a848b2881154932ac8dd7ac46abbbd8a58da1664c50a0901dccd905eb00db5c2 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-001 已建立可运行的采购服务骨架,但尚无业务实体、数据库 schema 或可验证的状态机。后续登录、建单、领取、授权和提交围栏都依赖一致的数据模型;若先写 HTTP 页面再补状态约束,会把资金安全边界分散到处理器中。
|
||||
@@ -52,7 +52,11 @@ T-001 已建立可运行的采购服务骨架,但尚无业务实体、数据
|
||||
|
||||
## 执行记录
|
||||
|
||||
(暂无)
|
||||
### 2026-08-03T10:37:05Z · ila
|
||||
|
||||
主 agent 二次终审通过:完成 tasks、spec_trials、order_authorizations、order_submissions 的 Goose/SQLite 迁移、纯状态机与迁移 CLI;金额字段均为 TEXT 十进制字符串,无 HTTP、真机、下单点击或付款代码。
|
||||
返修后迁移 CLI 仅允许 up/down/status;复合外键禁止授权引用其他任务试选记录、提交记录引用其他任务授权;围栏后状态不可回退,第一趟 RUNNING 不可进入 ORDERING。
|
||||
独立验证:gofmt 无差异,go mod tidy 后干净;go test ./...、go test -race ./...、go vet ./...、go build ./... 通过;真实临时库迁移 up/status 通过;agent-context、diff check 和越界扫描通过。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
id: T-010
|
||||
title: 收窄 Phase 2 门禁并启动安全并行
|
||||
phase: 0
|
||||
deps: [T-009]
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 25
|
||||
context_ref: d27fda6
|
||||
work_branch: task/t-010-parallel-gate
|
||||
needs_device: false
|
||||
needs_human_review: false
|
||||
write_paths:
|
||||
- docs/tasks/T-010.md
|
||||
- docs/00-ai-start-here.md
|
||||
- docs/05-coding-rules.md
|
||||
- docs/06-tasks.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=25 synced=2026-08-04T06:39:13Z sha256=b3485476184414158cac243aa3f2d08567372179c2e91c2cf4bb5552e7191510 -->
|
||||
## 问题 / 背景
|
||||
|
||||
客户要求加快 MVP。现有全局门禁把全部 Phase 2 页面都冻结到 T-103 完成,范围过宽;管理员登录与只创建 DRAFT 的基础建单不读取真机页面字段,也不会启动试选或触发下单。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 把门禁收窄为:T-103 前允许 T-201 管理会话与 T-202 DRAFT 建单/基础列表;T-203 的批量开始试选、T-204 试选证据详情和 T-205 以后继续冻结。
|
||||
2. 同步 AI 入口、编码规则和路线图;不修改 T-103 安全边界,不放宽不付款、第一趟禁下单、真机判据先取证或隐私证据分层。
|
||||
3. 并行启动 admin T-201;共享文档由本任务所有者统一修改。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 门禁文字不存在“冻结全部 Phase 2”的歧义。
|
||||
- T-201/T-202 不得出现 PDD 页面判据、试选结果、证据、授权、提交或支付能力。
|
||||
- 上下文校验、Vikunja 导出检查与 diff-check 通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T06:38:51Z · ila
|
||||
|
||||
2026-08-04 完成并行门禁收窄:提交 3f2e0e5 更新 AI 入口、编码规则和路线图。T-103 期间只放行 T-201 管理会话与不启动试选的 T-202 DRAFT 建单/基础列表;T-203~T-207 继续等待 T-103。不付款、第一趟禁下单、真机判据与证据边界均未放宽。主 agent 复跑 agent-context validator、冲突文案检索和 git diff --check,全部通过。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 本任务只调整依赖门禁和并行顺序,不实现任何采购服务、真机自动化或页面判据代码。
|
||||
- T-103 完成前只放行与真机可读字段无关的 T-201 管理会话,以及只创建 `DRAFT`、不启动试选的
|
||||
T-202 基础建单与列表壳。
|
||||
- T-203 的批量 `DRAFT → PENDING`、T-204 的试选证据详情以及 T-205 以后仍由 T-103 阻塞;不得用
|
||||
假字段、原型假数据或前序项目结论提前固化生产契约。
|
||||
- 不付款、第一趟不可达下单、价格读取位置、真机判据先取证、原始证据本机隔离与派生物消费边界
|
||||
均保持不变。
|
||||
@@ -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`。
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
id: T-102
|
||||
title: 验证按链接打开商品详情页
|
||||
phase: 1
|
||||
deps: [T-101]
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 22
|
||||
context_ref: 393f26d
|
||||
work_branch: task/t-102-open-product
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-102.md
|
||||
- client/src/cmbuyer_client/pdd/**
|
||||
- client/src/cmbuyer_client/device/**
|
||||
- client/tests/pdd/**
|
||||
- client/tests/device/**
|
||||
- client/scripts/capture_product_open.py
|
||||
- docs/03-tech-stack.md
|
||||
- docs/04-architecture.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=22 synced=2026-08-04T01:50:40Z sha256=ee2405865a36aa01a3503e6d24d62a3dddd0f19b451845e1e2c3937734c31442 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-101 已证明同一台 PKG110(Android 16、拼多多 8.17.0)可通过 USB/WiFi 显式 serial 完成只读截图与完整 XML 取证。MVP 下一风险是任务自带的合法拼多多链接能否由 Android intent 打开到对应商品详情页。此前项目和旧文档不能证明当前 App 的行为;若先写详情页判据,会把未经本项目真机验证的假设带入 T-103。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-006 的第一步 `open_product(url)`;仅覆盖链接打开,不覆盖规格面板、规格选择或价格。
|
||||
- 用户故事 / 交互:采购工具执行链接任务前的只读真机 spike;无生产 GUI。
|
||||
- 架构 / API:`docs/04-architecture.md` 第三节 A 路径;`docs/api.md` 的 canonical `product_url` / `goods_id` 示例;T-103 前置。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 只接受 HTTPS canonical 链接 `https://mobile.yangkeduo.com/goods.html?goods_id=<纯数字>`;拒绝 userinfo、非默认端口、fragment、重复/缺失/非数字 goods_id、其他 host/path 和额外业务参数。解析后由代码按 goods_id 重建 canonical URL,绝不把任意输入拼入 shell。
|
||||
2. 复用 T-101 的显式 serial、同机多通道 fail-closed、no-reconnect、类型化超时和原子证据发布边界;运行拼多多版本必须精确等于本项目已取证的 8.17.0,否则在打开链接前停止。
|
||||
3. 通过参数数组执行只含 Android `VIEW` intent 语义的 `adb shell am start -W`,显式限定包 `com.xunmeng.pinduoduo`;不使用 shell 字符串、不点击/滑动/输入任何控件。Android 命令失败、超时、未解析 intent、未停留在拼多多包、截图/XML 失败分别给出脱敏错误。
|
||||
4. 启动后只读取 current app/package、截图和 `dumpWindowHierarchy(compressed=False)`,manifest 记录 goods_id、canonical URL、设备/App 元数据、命令结果摘要、路径与 SHA-256,不记录页面全文或原始 serial。原始截图/XML 仅存 `%LOCALAPPDATA%`,不提交 Git。
|
||||
5. 离线 mock 测试覆盖 URL 正反例、参数数组与超时、版本失配在 intent 前停止、包不匹配、无 UI 点击 API、证据原子性和异常脱敏。代码完成后由人使用一个明确 goods_id 在仅单通道在线的真机执行,人工确认截图确为对应商品详情页并检查 XML/截图无敏感信息;再把设备、Android、拼多多版本、goods_id、路径、SHA-256 与结论写入执行记录。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 合法 canonical 链接可在拼多多 8.17.0 上打开;人工确认到达与 goods_id 对应的商品详情页。
|
||||
- 非法链接、设备状态、App 版本失配、intent 失败/超时、落在非拼多多包、证据失败均有可区分且不泄露页面内容的错误。
|
||||
- 取证记录包含 PKG110、Android 16、连接方式、拼多多 8.17.0、goods_id、canonical URL、截图/XML 本地路径与 SHA-256。
|
||||
- client 全部单测、compileall、wheel metadata、上下文校验及完整 `init.ps1` 通过。
|
||||
- `needs_device=true`:agent 只能完成离线实现并保持 DOING;只有人完成真机链接打开、页面对应性与隐私检查后才能 DONE。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T01:09:47Z · ila
|
||||
|
||||
2026-08-04:已认领 T-102,工作分支 `task/t-102-open-product`,从任务定义提交 `4281b06` 开始实现。离线阶段只实现严格 canonical URL 校验、Android VIEW intent、版本/设备 fail-closed 与原子证据采集;不写商品详情页节点判据,不操作任何 App 控件。`needs_device=true`,离线实现完成后仍保持 DOING,等待人工提供明确商品链接并完成真机页面对应性和隐私验收。
|
||||
|
||||
### 2026-08-04T01:27:08Z · ila
|
||||
|
||||
2026-08-04:T-102 离线实现已由子 agent 完成并经主 agent 独立审查、两轮退回修正后通过,代码提交 `7040bb6`。实现包含:唯一 canonical 商品 URL 校验与重建、ADB 内层纯数字 goods_id 二次围栏、固定 package 的 Android VIEW intent、拼多多 8.17.0 intent 前版本围栏、前台 package 核验,以及截图/XML 原子取证与脱敏 manifest;无点击、滑动、输入、规格、价格、下单或支付能力,也未写商品详情页节点/Activity 判据。主审验证:43 项 client 离线测试、compileall、完整 init.ps1(含 admin test/vet/build)、wheel metadata、agent-context 与 diff-check 均通过;当前 wheel SHA-256 为 c675cd527849149ec25f213a0d67da31d0bb497cd8c5ed255d71154c103659ed(审计临时产物已清理)。未连接或操作真机,T-102 继续保持 DOING,等待人工使用明确 canonical 商品链接执行取证,并确认页面对应性与隐私。
|
||||
|
||||
### 2026-08-04T01:32:31Z · ila
|
||||
|
||||
2026-08-04 人工真机首轮:使用 WiFi serial `192.168.0.173:5555` 与 canonical 商品链接 `https://mobile.yangkeduo.com/goods.html?goods_id=958756616606` 执行 T-102 脚本。脚本通过 URL/intent 前置围栏,但在 intent 后返回“商品链接打开后前台应用不是拼多多”,按 fail-closed 规则停止,未发布完整取证。人在 Chrome 中可打开该 URL,只能证明网页链接有效,不能证明拼多多 App 已接管。T-102 保持 DOING;下一步由人只读取当前前台 package,区分瞬时切换/检查过早与实际落到 Chrome、系统解析器或其他包,在得到事实前不移除 package 围栏、不增加页面判据或 UI 兜底。
|
||||
|
||||
### 2026-08-04T01:34:39Z · ila
|
||||
|
||||
2026-08-04 人工追加诊断:T-102 首轮返回前台包不符后,人在未切换 App 的情况下立即执行只读 `dumpsys window`,得到 `mCurrentFocus` 与 `mFocusedApp` 均为 `com.xunmeng.pinduoduo/com.xunmeng.pinduoduo.activity.NewPageActivity`。该事实证明失败返回后前台已稳定到拼多多,支持“一次性 app_current 检查过早/存在异步切换窗口”的诊断;Activity 名只记录为诊断证据,不作为商品详情页判据。修复保持 package 围栏:改为有限时长、只读 package 的轮询,超时仍 fail closed,期间不点击、滑动、输入或读取页面节点。
|
||||
|
||||
### 2026-08-04T01:40:42Z · ila
|
||||
|
||||
2026-08-04:已按人工 foreground 证据完成最小修复,代码提交 `cb646b4`。保留 PDD package 围栏,将 intent 后的一次性判断改为以 CLI `--timeout` 为 deadline、默认 0.2 秒间隔的只读 package 轮询;只有观察到 `com.xunmeng.pinduoduo` 才采集截图/XML,超时仍 fail closed。轮询不读取 Activity/节点,不点击、滑动或输入。主 agent 已独立验证 46 项 client 测试、compileall、完整 init.ps1、wheel metadata、agent-context、diff-check;wheel SHA-256 `80fbc73785bb12b40856c6b1ef503bdb5cbc350fadd2b23cc6cc58358499bfe1`,临时产物已清理。未由 agent 连接真机,T-102 保持 DOING,等待人以 goods_id `958756616606` 重跑原命令并检查页面对应性与隐私。
|
||||
|
||||
### 2026-08-04T01:46:35Z · ila
|
||||
|
||||
2026-08-04 人工真机重跑已成功:人先退出到拼多多首页,再用 WiFi serial `192.168.0.173:5555`、goods_id `958756616606` 执行同一命令,证据原子发布到 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-102\product-open-958756616606`。manifest 记录 PKG110 / Android 16 / PDD 8.17.0 / current package `com.xunmeng.pinduoduo` / intent status ok;截图 SHA-256 `be9ea1f53b13fec82fd716875567870132ef0a1480cfbec5ad603436d774d81c`,XML SHA-256 `1449e78a154fddf7108e63a14a2e85bf74f162f4689c1fe95f2cb67fdb7b4fc5`。主 agent 只读取 manifest 并重新计算哈希,二者均匹配;未打开原始截图/XML。仍等待人明确确认截图对应目标商品且截图/XML 无地址、手机号、支付信息或其他无关隐私,确认前 T-102 保持 DOING。
|
||||
|
||||
### 2026-08-04T01:49:55Z · ila
|
||||
|
||||
2026-08-04 人工最终验收:人已查看本地 screenshot.png,确认对应 goods_id 958756616606;并确认截图与 hierarchy.xml 不含地址、手机号、支付信息或其他无关隐私。T-102 的 canonical 链接打开、PDD 8.17.0 前台 package、证据原子发布与人工页面对应性验收全部通过,允许关闭。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 本任务只验证由显式 canonical 链接启动拼多多并采集本地证据;不编写或声称商品详情页节点判据,
|
||||
页面是否为对应商品必须由人查看本项目新产物确认。
|
||||
- 只允许 Android `VIEW` intent;不点击、滑动、长按、输入任何 App 控件,不打开规格面板,不选择
|
||||
颜色或尺码,不读取价格,不进入购买或订单页面。
|
||||
- 不引用或实现 `go_to_order_confirm()`、`submit_order()`、提交订单、付款、支付、授权或采购服务接口;
|
||||
不把前序项目的 URL、activity、节点文本或页面结论当作事实。
|
||||
- 输入 URL 必须先严格解析并按纯数字 `goods_id` 重建,再以参数数组传给 ADB;禁止 shell 拼接、
|
||||
任意 scheme/host/path、短链跳转、额外参数或自动猜测链接。
|
||||
- 运行拼多多版本与本项目已取证版本不一致时必须在 intent 前停止;证据失败、当前包不符或结果无法
|
||||
由人确认时均不得声称成功,不增加自动点击或其他兜底路径。
|
||||
- 截图和完整 XML 只保存在明确的本地 T-102 目录,人工检查后只把路径、SHA-256 与非敏感元数据
|
||||
写入执行记录;不得提交原始证据,不记录地址、手机号、支付信息或无关页面正文。
|
||||
- 不修改 `admin/`、业务 API、数据模型、生产 GUI 或 T-103 规格面板逻辑。
|
||||
- `needs_device: true`:agent 完成离线实现后保持 `DOING`;只有人使用明确 goods_id 完成真机打开、
|
||||
确认对应商品详情页并完成隐私检查后才能标 `DONE`。
|
||||
@@ -0,0 +1,179 @@
|
||||
---
|
||||
id: T-103
|
||||
title: 验证规格面板精确选择与 SKU 单价
|
||||
phase: 1
|
||||
deps: [T-102, T-110]
|
||||
status: DOING
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 23
|
||||
context_ref: 1dc8308
|
||||
work_branch: task/t-103-sku-panel
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-103.md
|
||||
- client/src/cmbuyer_client/pdd/**
|
||||
- client/src/cmbuyer_client/device/**
|
||||
- client/tests/pdd/**
|
||||
- client/tests/device/**
|
||||
- client/scripts/capture_sku_panel_spike.py
|
||||
- client/scripts/sanitize_sku_panel_evidence.py
|
||||
- docs/02-requirements.md
|
||||
- docs/03-tech-stack.md
|
||||
- docs/04-architecture.md
|
||||
- docs/api.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-04T06:56:08Z sha256=1f5f8b30076531a859c0cada576c91206cbe20471694a53690231fbb031e816e -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-102 已证明 canonical 链接可进入目标商品。T-103 随后在 PKG110 / Android 16 / 拼多多 8.17.0、衣服商品 goods_id `937122477375` 上确认:规格面板只能从详情页右下角精确文案“快要抢光”进入,面板固定显示收货区域和掩码手机号。T-110 经项目所有者批准,将该已取证点击定义为可逆、能力受限的规格面板导航;这不是通用购买入口豁免,不授权其他文案、数量、确认页、提交订单或支付。 项目所有者随后确认,面板刚打开时已自动选中目标颜色“黑色CHA(纯棉)”和尺码“M(建议100-115)”;因此本任务不再假设存在“未选择”或“只选择一个维度”的初始状态。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-006 第一趟试选;覆盖自动脱敏、`open_trial_sku_panel()`、`select_sku_options()`、`read_sku_unit_price()` 的真机 spike。
|
||||
- 用户故事:US-003、US-004、US-008;本任务无生产 GUI。
|
||||
- 架构 / API:`docs/04-architecture.md` 第三、四、5.4、六节;`docs/api.md` 证据上传和 `TrialSkuFlow`;T-104、T-105 与 Phase 2 的前置。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 先实现本机确定性脱敏器,不先写页面判据。原始 screenshot/XML 只保存到 `%LOCALAPPDATA%\cmbuyer\artifacts\T-103\...\raw`,只允许脱敏器消费;agent、fixture、业务、日志、HTTP sink、Vikunja 与 Git 均不得读取/上传原始内容。
|
||||
2. 脱敏器绑定 PKG110 分辨率、拼多多 8.17.0、goods_id、人工声明状态和本项目证据配置,同时从 screenshot/XML 移除收货区域、地址与手机号内容,在 sibling `derived` 目录原子发布派生物。派生 XML 仍命中手机号模式、隐私区域无法确认、结构/分辨率/版本失配、哈希不一致或目标目录存在时 fail closed,不发布半成品。服务端只接收派生截图哈希和 sanitizer 版本,不接收原始文件/哈希/路径/XML。
|
||||
3. 由人运行脱敏器并本地确认派生 screenshot/XML 的三种真实状态:panel-opened-target-preselected(刚打开且目标规格已自动选中)、alternate-all-dimensions-selected(人工把颜色和尺码都改成非目标值)、target-selection-restored(再把两个维度恢复为目标值)。主 agent 只在派生 manifest、每态实际选中值与人工确认齐全后读取派生证据,提取最小 fixture。首轮旧证据不得复用为 fixture。
|
||||
4. 实现 `TrialSkuFlow` 窄 capability。`open_trial_sku_panel()` 只允许拼多多 8.17.0、goods_id `937122477375` 上证据绑定、精确唯一的 `快要抢光`;缺失、重复、版本失配或打开后面板判据不唯一时零后续点击。`免拼购买 / 单独购买 / 直接拼成` 等其他文案必须分别取证,不能包含、前缀、同义、OCR 或坐标兜底。
|
||||
5. 第一趟 capability 只含打开商品、受控打开面板、在维度容器内精确选择、读面板单价、脱敏取证和返回。接口不得暴露通用 `click`、`set_quantity()`、`go_to_order_confirm()`、`submit_order()` 或支付能力;规格面板中的“提交订单”、微信支付、先用后付和 0 元下单只作为硬拒绝判据,静态调用链测试证明不可达。
|
||||
6. 在脱敏派生 XML 上按维度容器隔离候选,文本等值匹配并读回选中态;缺失、重复、禁用、维度不明或选中态不唯一均停止。纯函数测试覆盖 `红`/`粉红`、`1`/`10` 等前缀碰撞。
|
||||
7. 单价只从规格面板证据确定的唯一节点读取,以十进制字符串返回,禁止浮点。记录真实文本是否含优惠前缀、货币符号是否拆节点、是否并列原价/区间价;语义不唯一、节点缺失、候选冲突或只能从详情页读取时返回 unreadable。
|
||||
8. 人最终在真机先把两个维度改成一组明确记录的非目标值,再执行受控入口、精确选择和读价脚本恢复目标规格,确认实际选择颜色分类=黑色 CHA(纯棉)、尺码=M(建议100-115)(以派生 XML 精确文本为准)、面板单价语义、派生证据隐私与退出行为;agent 保持 DOING 直到人工验收。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 三组原始证据分别对应 panel-opened-target-preselected、alternate-all-dimensions-selected、target-selection-restored,且仅在本机 raw 目录;脱敏器离线测试覆盖地址/手机号移除、截图与 XML 同步处理、状态/版本/分辨率/结构失配、手机号残留、原子性、异常脱敏和已有目录不覆盖。
|
||||
- 派生 manifest 记录 source/derived 本机哈希、sanitizer 版本、设备/App/goods_id/人工状态;原始页面正文、serial、地址和手机号不进入 manifest、日志、错误或上传。
|
||||
- 只从人工验收通过的派生证据编写页面判据与最小 fixture;判据绑定拼多多 8.17.0 和证据哈希。
|
||||
- `快要抢光` 精确唯一时才打开面板;其他文案、入口缺失/重复、面板不唯一、规格缺失/碰撞/禁用、价格不唯一均 fail closed。
|
||||
- 静态 API/导入测试证明 T-103 第一趟无通用点击、数量、确认页、提交订单或支付能力,“提交订单”及资金控件不可达。
|
||||
- 从规格面板可靠读取选中 SKU 单价为十进制字符串;无法唯一解释优惠价/原价/区间价时返回 unreadable。
|
||||
- client 全部单测、compileall、wheel metadata、上下文校验、完整 `init.ps1` 与 diff-check 通过。
|
||||
- `needs_device=true`:只有人完成受控入口、精确选择、单价读取、派生证据对应性/隐私和安全退出验收后才能 DONE。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T01:58:01Z · ila
|
||||
|
||||
2026-08-04:已认领 T-103,工作分支 `task/t-103-sku-panel`,任务定义基线 `27999c8`。先执行人工证据门禁,不立即编写页面判据:T-102 现有详情截图只证明目标商品,且当前视口可见“单独购买 / 免拼购买”“先用后付 / 0元下单”等禁触入口,没有证明独立安全规格入口。下一步由人手工寻找非购买的“规格 / 已选”入口;若不存在就记录 no-go,不点击购买按钮兜底。规格面板三态证据与隐私确认完成前,受托 agent 只允许做证据采集脚手架、纯函数边界和只读审计,T-103 保持 DOING。
|
||||
|
||||
### 2026-08-04T02:09:48Z · ila
|
||||
|
||||
2026-08-04 离线实现与主审记录:
|
||||
|
||||
- 提交 `a92b8f6` 新增 T-103 人工声明三状态的只读取证 CLI、捕获模块与离线测试;脚本不打开链接/面板,不点击、滑动、选择规格或读取价格。
|
||||
- 失败闭合覆盖:严格 URL/状态/版本/前台包校验、截图/XML 有效性、超时脱敏、原子发布、已有输出目录不覆盖;协议无购买、下单、支付或数量操作。
|
||||
- 主 agent 独立复跑:client 55 项单测、compileall、wheel metadata、agent-context validator、diff-check 与完整 `init.ps1` 全部通过。
|
||||
- `needs_device=true`,任务保持 DOING;等待人通过独立安全规格入口分别采集 initial、one-dimension-selected、all-dimensions-selected 三组证据并完成对应性与隐私检查。若只有购买/下单/资金入口可打开面板,则记录 no-go,不放宽边界。
|
||||
|
||||
### 2026-08-04T02:36:33Z · ila
|
||||
|
||||
2026-08-04 真机证据主审退回:
|
||||
|
||||
- goods_id `937122477375` 的三组 manifest、设备/App 元数据与文件 SHA-256 均一致。
|
||||
- 人工给出隐私确认后,主 agent 只查看三张 screenshot,未读取 XML。
|
||||
- `initial` 截图实际仍为商品详情页,不是规格面板初始态;`one-dimension-selected` 与 `all-dimensions-selected` 截图顶部含收货区域和掩码手机号,不满足任务的无地址/手机号证据边界。
|
||||
- 本组证据判定不合格:不提取 fixture、不编写页面判据、不提交原始证据;T-103 保持 DOING。
|
||||
- 下一步使用全新输出目录重采:人先在规格面板取消全部规格选择,并滚动到收货区域和手机号完全离开可见视口,同时保留规格面板价格与规格区域;三态逐次人工核对后再交主审。
|
||||
|
||||
### 2026-08-04T02:41:12Z · ila
|
||||
|
||||
2026-08-04 T-103 真机 no-go 结论:
|
||||
|
||||
- 人工确认 goods_id `937122477375` 的规格面板是通过商品详情页右下角“快要抢光”进入;通常“免拼购买 / 快要抢光 / 单独购买 / 直接拼成”等购买语义按钮才会打开规格面板。
|
||||
- 这直接触发任务方案与边界中的 no-go 条件:第一趟不得点击任何购买、下单、提交或资金入口,也不得把购买按钮包装成规格入口。
|
||||
- 因此不读取本轮 XML、不提取 fixture、不实现选择器;已有截图仅用于判定入口不可行,原始证据不提交 Git。
|
||||
- 当前 MVP 的自动第一趟试选在现有安全边界下不可实现,T-103 转 BLOCKED。解除阻塞需要项目所有者选择新的架构方向,并先更新架构/任务边界;不得由 agent 擅自放宽。
|
||||
|
||||
### 2026-08-04T02:56:36Z · ila
|
||||
|
||||
2026-08-04:T-110 已获项目所有者授权并同步架构契约。T-103 方案已重写为“先自动脱敏派生证据,再实现受控 `快要抢光` 入口与精确规格/价格判据”;T-110 完成并转 Done 后,本任务恢复 Doing。
|
||||
|
||||
### 2026-08-04T03:20:51Z · ila
|
||||
|
||||
2026-08-04 T-103 自动脱敏第一阶段实现与主审:
|
||||
|
||||
- 提交 `9b2eb74` 新增离线确定性脱敏 CLI、实现模块和纯合成测试;仅允许 `raw` 到同级全新 `derived`,不连接设备。
|
||||
- 绑定 PKG110 / Android 16 / 拼多多 8.17.0 / goods_id `937122477375` / 1080×2400 / 人工声明三态;校验源 manifest 与文件哈希。
|
||||
- 截图整宽遮罩 `[0,0,1080,540)`;XML 递归删除隐私带节点、清空跨界容器并保留下方规格节点,完整/掩码/分隔/跨节点手机号残留均 fail closed。
|
||||
- 主审首轮发现手机号跨节点绕过、隐私结构未确认和发布竞态风险后退回;第二轮修复并补测,目标目录已存在或竞态出现均不覆盖且不留 staging。
|
||||
- 主 agent 独立验证:66 项 client 单测、compileall、CLI help、wheel metadata、完整 `init.ps1`、上下文校验与 diff-check 全部通过。
|
||||
- 实现与审查过程未读取、列举或打开任何真实 raw artifacts,也未实现 PDD 页面判据、点击、规格选择、价格、数量、确认页、提交或付款能力。
|
||||
- `needs_device=true`,T-103 保持 DOING;等待人重新采集三态 raw、运行脱敏器并只核对 derived 后,才能继续页面判据与真机选择/读价。
|
||||
|
||||
### 2026-08-04T03:34:47Z · ila
|
||||
|
||||
项目所有者确认:规格面板刚打开时已自动选中目标颜色“黑色CHA(纯棉)”与尺码“M(建议100-115)”。旧的未选择/单维度/全选三态假设失效;T-103 改为记录刚打开目标预选、两个维度改为非目标值、两个维度恢复目标值三态。修改 CLI 状态枚举与测试前不再采集旧状态。
|
||||
|
||||
### 2026-08-04T03:40:39Z · ila
|
||||
|
||||
T-103 新三态离线实现与主审:提交 dbb69a7 将人工声明状态集中到 sku_panel_state.py,采集器和脱敏器仅接受 panel-opened-target-preselected、alternate-all-dimensions-selected、target-selection-restored;旧 initial / one-dimension-selected / all-dimensions-selected 与未知值均 fail closed。主 agent 独立复跑 67 项 client 单测、compileall、两个 CLI help、静态禁用能力检索与完整 init.ps1,全部通过。过程未连接设备、未读取真实 raw、未新增页面判据或点击/数量/确认/提交/付款能力。T-103 保持 DOING,等待新三态 derived 人工验收。
|
||||
|
||||
### 2026-08-04T03:54:21Z · ila
|
||||
|
||||
第一组新三态 raw 已由人成功采集;脱敏器 v1 按设计拒绝。人只读取 PNG 头部元数据并确认实际 format=PNG、size=1080x2376,证明 v1 将截图尺寸与 Android XML 坐标空间统一设为 1080x2400 的假设错误。原始证据不重采、不删除、不读取正文;下一步把 screenshot 与 XML coordinate space 分离配置,升级 sanitizer 版本并继续 fail closed。
|
||||
|
||||
### 2026-08-04T03:58:54Z · ila
|
||||
|
||||
T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space 固定为人确认的 1080x2376,并把 XML coordinate space 独立严格配置为待验证的 1080x2400;sanitizer_version 升为 t103-privacy-v2。XML 会统计所有 bounds 的 max right/bottom,任何小于或大于配置的偏差均 fail closed,错误只含非敏感 observed 尺寸。派生 manifest 记录两个坐标空间和各自遮罩。主 agent 独立复跑 69 项 client 单测、compileall、CLI help 与 diff-check,全部通过;未读取真实 raw 正文、未连接设备。等待人用同一第一态 raw 重跑 v2 脱敏。
|
||||
|
||||
### 2026-08-04T06:01:02Z · ila
|
||||
|
||||
人使用 sanitizer v2 对同一第一态 raw 重跑,安全诊断返回 XML observed 1080x2376;未发布 derived。由此确认本次 screenshot space 与 XML coordinate space 均为 1080x2376,v2 对 XML=1080x2400 的待验证假设被真机证据否定。原始证据继续保留且不重采;下一步升级 v3,仍分离建模两个空间但分别精确绑定当前相同尺寸。
|
||||
|
||||
### 2026-08-04T06:05:53Z · ila
|
||||
|
||||
2026-08-04 T-103 sanitizer v3 坐标修正与主审:人用同一第一态 raw 运行 v2,脱敏器安全拒绝并仅报告 observed 1080x2376,证明 XML 实际坐标与截图相同;原始证据仍有效、未重采、未读取正文。提交 acf7e11 将 sanitizer_version 升为 t103-privacy-v3;截图与 XML 仍分别建模并分别严格校验,但当前均精确绑定 1080x2376,隐私带保持 [0,0,1080,540)。合成测试明确拒绝旧截图 1080x2400、旧 v2 XML 1080x2400,以及较小/较大坐标;手机号残留、哈希/状态/版本、确定性、原子发布和不覆盖门禁保持不变。主 agent 独立复跑 69 项 client 单测、compileall、CLI help、静态禁用能力检索与 diff-check,全部通过;未连接设备、未读取真实 raw。T-103 保持 DOING,等待人用同一第一态 raw 重跑 v3 并只核对 derived。
|
||||
|
||||
### 2026-08-04T06:17:05Z · ila
|
||||
|
||||
2026-08-04 第一态 derived 人工隐私验收与主审:项目所有者确认截图顶部隐私区完全遮黑、派生 XML 不含地址/手机号、目标颜色和尺码仍为预选;顶部价格因隐私带只显示一部分,最底部价格完整。人工确认后主 agent 才读取 derived,未访问 raw。路径 C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-panel-opened-target- 937122477375-v2\derived;manifest 为 privacy_tier=SANITIZED、sanitizer_version=t103-privacy-v3、state=panel-opened-target-preselected,派生 screenshot/XML SHA-256 分别为 f9e370747cbef4facf6ec4a20d5af72b36c7615e7be7038144b1834632cdbf57 / f2024b7bcc69a03b05f5e95610708c0dbd33001698f40a011ddc895cf0410fb1,复算一致。XML 中“黑色 CHA (纯棉)”与“M(建议100-115)”各有明确 selected=true,汇总文本一致。完整金额候选仅见“提交订单 ¥12.88”;该文本只能在不可点击的派生快照中用于离线价格语义验证,第一趟绝不能获得该控件或其可点击父节点。另观察到 com.android.systemui 的“肉包采购辅助”浮层节点,后续最小 fixture/判据必须限定 PDD package,并建议后两态采集前关闭该浮层。第一态隐私与状态证据通过,但价格语义仍需另两态交叉验证,T-103 保持 DOING。
|
||||
|
||||
### 2026-08-04T06:20:03Z · ila
|
||||
|
||||
主审补充澄清:上一条所述“提交订单 ¥12.88”只能证明面板中存在该禁触文本,不得作为第一趟 SKU 单价证据,也不得驱动任何控件解析或交互。原因是它属于可点击“提交订单”父区域,违反 T-110 硬拒绝区边界。v3 第一态因此只通过隐私与预选状态验收,价格证据仍不通过。下一步不缩小 [0,0,1080,540) 截图遮罩,而由 v4 脱敏器在顶部交界带仅保留严格白名单、PDD package、非点击叶节点的价格文本到派生 XML;任何混杂文本、点击节点、包外节点或非唯一候选均 fail closed。
|
||||
|
||||
### 2026-08-04T06:43:53Z · ila
|
||||
|
||||
2026-08-04 客户确认加速方案:手机规格面板显示地址/手机号不再作为真机流程阻塞条件;不再扩大或调整截图黑色遮罩。原始证据仍只留本机,agent/Git/服务端仍只消费派生物。提交 1e69d27 实现 t103-privacy-v4:截图遮罩与 1080x2376 双坐标校验完全不变,只把已取证的两个顶部交界价格槽中,PDD package、TextView、非点击、可见启用、叶节点且严格匹配“唯一快卖光当前价 + 至多一个原价”的七属性安全文本投影到派生 XML;结构/文本/候选数漂移均原子拒绝,底部“提交订单 ¥12.88”仍不是价格候选。主 agent 两轮退回测试职责问题后独立复跑 76 项 client 单测、compileall、CLI help、禁用能力检索与 diff-check,全部通过。下一步保留已验收 v3 derived,用同一 raw 生成 v4 derived;T-103 保持 DOING。
|
||||
|
||||
### 2026-08-04T06:55:52Z · ila
|
||||
|
||||
2026-08-04 项目所有者用同一第一态 raw 运行 v4,脱敏器按设计 fail closed:跨界价格节点文本不匹配,未发布 derived;raw 与已保留的 derived-v3-reviewed 未受损。提交 ef1ac60 增加最小安全诊断:失败只输出两个固定价格槽位和受控 reason 枚举,不回显原文、金额、数字串、字符码点、长度或原始属性,v4 成功白名单与原子不发布语义不变。主 agent 独立复跑 78 项 client 单测、compileall、CLI help 与 diff-check,全部通过。等待项目所有者用同一 raw 重跑并反馈 slot/reason;T-103 保持 DOING。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 任何规格面板入口、维度容器、选项、选中态和价格判据都必须来自本项目新采集、自动复检通过的
|
||||
拼多多 8.17.0 **脱敏派生** screenshot/XML;T-102 详情页证据只能证明已到目标商品,不能证明
|
||||
规格面板结构。派生证据路径、SHA-256、goods_id、设备、Android、App 与 sanitizer 版本写入
|
||||
执行记录之前,不得提交页面判据代码。
|
||||
- 第一趟只允许 T-110 批准的 `open_trial_sku_panel()` 窄能力点击证据/版本绑定、精确唯一的
|
||||
`快要抢光`;当前事实仅覆盖 goods_id `937122477375`、拼多多 `8.17.0`。不得把“免拼购买 /
|
||||
单独购买 / 直接拼成”等其他文案加入包含、前缀、同义或坐标兜底,分别取证前一律拒绝。
|
||||
- 规格选项只能在已确认的面板及对应维度容器内按文本精确唯一匹配;缺失、重复、禁用、维度不明、
|
||||
选中态无法唯一读回或页面版本不符均 fail closed。不得前缀、包含、模糊、相似或跨维度匹配,
|
||||
不得用 OCR / 坐标兜底猜选项。
|
||||
- 单价只允许从规格面板证据确认的节点读取,并以十进制字符串表达;不得使用浮点,不得从详情页、
|
||||
搜索卡片或其他页面的数字补值。券后价、原价、区间价、货币符号拆分或多个候选的语义无法唯一
|
||||
证明时必须返回 unreadable 并转人工。
|
||||
- T-103 代码路径不得引用或实现 `set_quantity()`、`go_to_order_confirm()`、`submit_order()`、
|
||||
通用 `click`、创建订单、授权、提交围栏或任何支付能力;不得进入订单确认页,不得创建待付款订单。
|
||||
规格面板中的“提交订单”、微信支付、先用后付、0 元下单只可作为硬拒绝判据,不能返回可点击对象。
|
||||
- 不把 `/mnt/d/chengma/cmroubao`、`/mnt/d/chengma/cmpdd` 或任何旧版本的节点、Activity、选择器、
|
||||
坐标、文本形态和页面结论当作事实;前序项目只可用于理解为什么必须 fail closed。
|
||||
- 原始 screenshot/XML 只保存在 `%LOCALAPPDATA%\cmbuyer\artifacts\T-103\...\raw`,只允许本机
|
||||
确定性脱敏器消费,不得由 agent、业务、fixture、HTTP sink、Vikunja 或 Git 读取/上传。脱敏器必须
|
||||
校验设备分辨率、页面/App 版本和预期隐私区域,同时移除 screenshot/XML 中的地址与手机号内容;
|
||||
派生 XML 仍命中手机号模式、结构/版本/分辨率失配或哈希不一致时不得发布 `derived`。
|
||||
- 只有自动复检通过的最小脱敏 fixture、派生路径/哈希、sanitizer 版本与非敏感结论可入库;服务端
|
||||
不接收原始文件、原始路径或原始哈希。
|
||||
- `needs_device: true`:agent 只能完成离线实现并保持 `DOING`;只有人完成安全入口、精确规格选择、
|
||||
SKU 单价语义、页面对应性与派生证据验收后才能标 `DONE`。
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
id: T-110
|
||||
title: 调整第一趟受控规格入口与隐私脱敏边界
|
||||
phase: 1
|
||||
deps: [T-102]
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 24
|
||||
context_ref: abeefbb
|
||||
work_branch: task/t-110-trial-sku-boundary
|
||||
needs_device: false
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-110.md
|
||||
- docs/tasks/T-103.md
|
||||
- docs/02-requirements.md
|
||||
- docs/03-tech-stack.md
|
||||
- docs/04-architecture.md
|
||||
- docs/05-coding-rules.md
|
||||
- docs/api.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=24 synced=2026-08-04T03:00:16Z sha256=dd7209dda3769397f96882556864c1836955507fd5ee293103295d4fd9f9a840 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-103 在 PKG110 / Android 16 / 拼多多 8.17.0 真机确认:衣服商品 goods_id `937122477375` 的规格面板只能从详情页右下角“快要抢光”等购买语义按钮进入,不存在原架构假设的独立“规格 / 已选”入口。与此同时,规格面板固定展示收货区域与掩码手机号。项目所有者已批准调整第一趟入口边界,但“不付款、第一趟不可达提交订单、价格只从规格面板读取、敏感信息不进入业务数据/日志/上传/Git”等边界不变。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-006 第一趟试选;为 T-103 解阻,不实现页面选择器或真机点击代码。
|
||||
- 架构:`docs/04-architecture.md` 第一趟流程、安全边界、证据分层;`docs/05-coding-rules.md` 不可逆动作和敏感信息纪律。
|
||||
- API:设备试选证据只允许上传脱敏派生物;原始截图/XML 仅留采购工具本机隔离目录。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 把“独立规格入口”改为“受控规格面板入口”:第一趟只可点击本项目真机证据证明、与拼多多版本绑定的唯一精确入口;当前仅确认 `快要抢光`。其他文案即使语义相近也不得推断复用,必须重新取证。
|
||||
2. 将点击入口与下单能力隔离。第一趟 capability 只允许打开受控面板、在维度内精确选择、读规格面板单价、生成脱敏证据和返回商品页;接口不得出现设置数量、进入确认页、提交订单、支付或通用任意点击能力。静态调用链和测试必须证明这些能力不可达。
|
||||
3. 规格面板内的“提交订单”以及微信支付、先用后付、0 元下单等控件全部列入硬拒绝区。入口缺失/重复、版本失配、打开后不是已取证面板、选择或价格不唯一时立即返回,不尝试相近按钮。
|
||||
4. 接受 PDD 页面会显示收货区域与掩码手机号这一事实,但不允许业务系统提取或传播。原始 screenshot/XML 仅写入 `%LOCALAPPDATA%` 隔离目录,不打印页面正文、不上传、不提交 Git;本地确定性脱敏器生成派生 screenshot/XML 和哈希,开发、fixture、服务端证据只消费派生物。
|
||||
5. 脱敏必须 fail closed:无法确认地址/手机号区域已移除、派生 XML 仍命中手机号模式、分辨率/结构不符合已取证版本,均拒绝发布派生证据。不得依赖人工口头“无需处理”绕过自动检查。
|
||||
6. 同步需求、架构、技术栈、编码规则、API、current-state 与 T-103。T-110 完成后把 T-103 依赖补为 T-110 并重新转 DOING;真正的脱敏器、入口判据和真机实现仍由 T-103 完成。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 文档明确区分“可逆的受控规格面板入口”与“不可逆的提交订单动作”,不把购买文案泛化为通用可点击入口。
|
||||
- 第一趟的 capability 列表与硬拒绝列表完整;提交订单、数量、确认页、付款和任意点击能力不可达。
|
||||
- 原始证据与脱敏派生证据的目录、生命周期、消费者和 fail-closed 条件清楚;服务端和 Git 永不接收原始隐私证据。
|
||||
- `docs/tasks/T-103.md` 在 T-110 完成后恢复 DOING,并要求先产出自动脱敏证据再写页面判据。
|
||||
- `python scripts/validate_agent_context.py`、Vikunja 导出检查与 `git diff --check` 通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T02:48:25Z · ila
|
||||
|
||||
2026-08-04:项目所有者明确批准受控规格面板入口方案;PDD 页面不可避免显示地址/手机号的事实被接受,但系统仍按本机原始证据隔离、自动脱敏派生物消费的方式收紧传播边界。任务已认领,分支 `task/t-110-trial-sku-boundary`,基线 `abeefbb`。
|
||||
|
||||
### 2026-08-04T02:58:44Z · ila
|
||||
|
||||
2026-08-04 完成记录:
|
||||
|
||||
- 已同步 `docs/02-requirements.md`、`03-tech-stack.md`、`04-architecture.md`、`05-coding-rules.md`、`api.md`、`current-state.md` 与 T-103。
|
||||
- 第一趟只批准证据/版本绑定、精确唯一的 `快要抢光`;其他购买文案不泛化。`TrialSkuFlow` 不暴露通用点击、数量、确认页、提交订单或支付能力。
|
||||
- 原始 screenshot/XML 只留本机隔离目录;服务端只接收自动复检通过的派生截图哈希与 sanitizer 版本,不接收原始文件、哈希、路径或 XML。
|
||||
- Phase 1 已拆分第一趟规格 spike 与第二趟数量/确认页 spike;T-103 已增加 T-110 依赖并按新边界恢复 DOING。
|
||||
- `validate_agent_context.py` 与 `git diff --check` 通过;本任务没有实现或运行任何真机点击代码。
|
||||
|
||||
### 2026-08-04T03:00:01Z · ila
|
||||
|
||||
最终提交 `82f57c7`。主 agent 独立复跑完整 `init.ps1`:admin go test/vet/build、client editable install/55 项 unittest/compileall、agent-context validator 全部通过;`git diff --check` 通过。T-110 已完成且未实现或执行任何真机点击。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 本任务只调整架构、需求、API、编码规则与后续任务契约,不实现或运行任何真机点击代码。
|
||||
- 仍然绝不点击支付、免密支付、先用后付、0 元下单或扣款控件;不创建订单,不进入订单确认页。
|
||||
- 第一趟只允许未来实现点击**本项目真机证据证明、与拼多多版本绑定、精确唯一**的规格面板入口;
|
||||
当前只确认 goods_id `937122477375`、拼多多 `8.17.0` 上的“快要抢光”。不得把人工经验中的
|
||||
“免拼购买 / 单独购买 / 直接拼成”等文案直接加入白名单,分别取证前一律拒绝。
|
||||
- 第一趟 capability 不得包含通用 `click`、数量调整、订单确认、提交订单或付款能力;规格面板中的
|
||||
“提交订单”及任何支付提示始终属于硬拒绝区,静态调用链必须保持不可达。
|
||||
- 接受原始规格面板证据在手机本地可能包含收货区域和掩码手机号,但不得从中提取业务字段、打印、
|
||||
上传或提交 Git。只有确定性脱敏并通过自动检查的派生 screenshot/XML 才能供 agent、fixture、
|
||||
采购服务或人工远程评审使用;脱敏不确定即 fail closed。
|
||||
- 价格仍只允许从规格面板和订单确认页读取,使用十进制字符串;不得用详情页价格补齐。
|
||||
- 本任务不得把 T-103 标为 `DONE`。文档边界完成并获得本次用户明确授权后,只能把 T-103 重新置为
|
||||
`DOING`,由 T-103 继续实现脱敏器、判据、离线测试和人工真机验收。
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
id: T-201
|
||||
title: 管理员登录与会话
|
||||
phase: 2
|
||||
deps: [T-004, T-005]
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 26
|
||||
context_ref: 80ed9b7
|
||||
work_branch: task/t-201-admin-session
|
||||
needs_device: false
|
||||
needs_human_review: false
|
||||
write_paths:
|
||||
- docs/tasks/T-201.md
|
||||
- admin/cmd/server/main.go
|
||||
- admin/internal/config/**
|
||||
- admin/internal/server/**
|
||||
- admin/internal/auth/**
|
||||
- admin/internal/transport/webui/**
|
||||
- admin/go.mod
|
||||
- admin/go.sum
|
||||
- admin/README.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=26 synced=2026-08-04T07:04:10Z sha256=25607da2b9c35781f56fdea1f399bdfb65b76fa9a5ca8210693714793f5ef5f4 -->
|
||||
## 问题 / 背景
|
||||
|
||||
采购服务已有 Go/Gin/SQLite 骨架和核心模型,但没有管理员会话。T-201 与真机规格字段无关,可在 T-103 进行时并行。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
F-013、US-007;路由 GET/POST /login、POST /logout、受保护的 GET /tasks 空壳;沿用已确认采购服务原型。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 启动时从环境变量读取管理员用户名、bcrypt 密码哈希、至少 32 字节 session secret;缺失或无效时明确失败,不提供默认凭据,不打印秘密。
|
||||
2. 使用签名会话和 CSRF;登录成功轮换会话,Cookie 为 HttpOnly、SameSite,Secure 由显式配置控制。
|
||||
3. return_to 仅接受 /tasks 及其子路径,拒绝绝对 URL、双斜杠、反斜杠和其他站内路径。
|
||||
4. 实现登录、登出与受保护的任务空壳;/healthz 保持公开。
|
||||
5. 不改数据库 schema,不实现设备 Bearer、建单、试选、授权、提交或付款。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 覆盖未登录跳转、成功登录与会话轮换、统一失败错误、CSRF 拒绝、登出失效和开放重定向拦截。
|
||||
- 无默认账号/密码/密钥,日志与响应不泄露凭据。
|
||||
- go test ./...、go vet ./...、go build ./...、上下文校验与 diff-check 通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T06:58:30Z · ila
|
||||
|
||||
2026-08-04 T-201 实现与主审完成:提交 47c0844 新增强制环境配置、bcrypt 校验、HMAC-SHA256 签名进程内 session、随机 CSRF、登录轮换、登出撤销、受保护 /tasks 空壳和无外部资源的 SSR 登录页。主 agent 首轮退回 /tasks/.. 返回路径绕过、含 CSRF 页面缺安全响应头、Cookie 篡改测试概率失效三项;修正后独立复跑 go test ./...、go test -race ./...、go vet ./...、go build ./...、上下文校验与 diff-check,全部通过。实现未包含建单、试选、真机字段、授权、提交或付款;T-201 验收通过。
|
||||
|
||||
### 2026-08-04T07:03:54Z · ila
|
||||
|
||||
集成门禁补充:T-201 分支已合入 main@88d8f77(含 T-103 最新安全诊断),随后在独立 Windows worktree 运行完整 init.ps1,admin test/vet/build、client 78 项测试/compileall、editable install 与 agent-context 校验全部通过。首次运行因 120 秒工具超时中断在大体积 PySide6 依赖安装,未产生代码或证据问题;复用已建 venv 后完整成功。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 启动配置只接受显式环境变量中的管理员用户名、bcrypt 密码哈希和至少 32 字节 session secret;
|
||||
不得内置开发默认值、明文密码或密钥,不得打印、回显或提交凭据。
|
||||
- 本任务只实现 `/login`、`/logout`、受保护的 `/tasks` 空壳和保持公开的 `/healthz`;不实现建单、
|
||||
查询、批量开始试选、设备 Bearer、真机自动化或任务状态流转。
|
||||
- 不增加或修改数据库 schema,不读写 `spec_trials`、授权或 `order_submissions`,不实现任何提交订单、
|
||||
付款、免密支付、先用后付或资金控件代码。
|
||||
- 页面和测试不得夹带机器实际规格、规格面板价格、截图、证据哈希或任何 PDD 页面判据;这些仍等待
|
||||
T-103 真机结论。
|
||||
- `return_to` 必须是 `/tasks` 或其子路径;绝对 URL、`//`、反斜杠、编码绕过和其他站内路径均拒绝。
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
id: T-202
|
||||
title: 手工建单与 DRAFT 基础列表
|
||||
phase: 2
|
||||
deps: [T-201, T-004, T-005]
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 27
|
||||
context_ref: 1c35155
|
||||
work_branch: task/t-202-admin-draft
|
||||
needs_device: false
|
||||
needs_human_review: false
|
||||
write_paths:
|
||||
- docs/tasks/T-202.md
|
||||
- admin/cmd/server/main.go
|
||||
- admin/internal/config/**
|
||||
- admin/internal/server/**
|
||||
- admin/internal/tasks/**
|
||||
- admin/internal/storage/sqlite/**
|
||||
- admin/internal/transport/webui/**
|
||||
- admin/README.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=27 synced=2026-08-04T08:32:16Z sha256=39e3b06bab4ca86e97b961a4eb6bb0a4f1e88b29dae4d50f916779f7e761424c -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-201 已提供管理员会话;T-004 已提供 tasks 表。根据 T-010 加速门禁,T-103 尚未完成时只允许实现不启动试选的 DRAFT 手工建单与基础列表。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
F-001、US-001、IX-002;GET /tasks、GET /tasks/new、POST /tasks;沿用已确认的传统表格与创建弹窗/直达页。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 显式数据库配置并打开已迁移 SQLite;以仓储接口隔离 HTTP 和 SQL,创建事务只写 MANUAL、DRAFT、version=1。
|
||||
2. 表单校验任务名称、canonical 拼多多链接、颜色分类、尺码、正整数数量和正十进制总额上限;金额只用字符串并规范为两位小数。链接只接受 HTTPS mobile.yangkeduo.com/goods.html 且 goods_id 为唯一纯数字参数,额外查询参数不进入数据库。
|
||||
3. 以服务端生成的 create_key 同时作为任务 ID;重复相同 key 和相同内容返回原结果,不创建第二条,内容不同则冲突。
|
||||
4. GET /tasks 默认 created_at DESC 显示 DRAFT 基础表格;创建入口用服务端渲染的 modal 状态,/tasks/new 复用同一表单作为无脚本兜底;失败保留非密码输入并显示字段错误,成功 303 回列表且新任务第一行。
|
||||
5. 页面只显示需求字段、采购结果占位、DRAFT 状态与创建时间;不读取或伪造规格面板价格/证据,不提供勾选开始试选、状态推进、详情或设备接口。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 覆盖创建成功、倒序第一行、严格链接/goods_id、数量、金额、空白/长度、CSRF/未登录、幂等重放与冲突、SQL 错误 fail closed。
|
||||
- 弹窗与 /tasks/new 共享校验;错误保留输入并可访问;标题只链接到由 goods_id 重建的 canonical PDD URL并使用安全新标签属性。
|
||||
- go test ./...、go test -race ./...、go vet ./...、go build ./...、完整 init.ps1、上下文校验和 diff-check 通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T08:30:47Z · ila
|
||||
|
||||
已完成:DRAFT 手工建单与基础列表;已验证链接、金额、CSRF、幂等、SQLite 并发和 SSR 无障碍,Go 与上下文门禁均通过。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 本任务只创建 `source=MANUAL`、`status=DRAFT`、`version=1` 的任务并显示 DRAFT 基础列表;不得
|
||||
实现勾选、批量开始试选、`DRAFT → PENDING` 或任何其他状态流转,也不得新增设备领取接口。
|
||||
- 不增加或修改数据库 schema,不读写 `spec_trials`、`order_authorizations`、`order_submissions`,
|
||||
不生成或展示机器实际规格、规格面板单价、截图、证据哈希或 PDD 页面判据。
|
||||
- 启动服务必须从显式 `CMBUYER_DATABASE_SOURCE` 读取 SQLite data source;缺失时明确失败,不提供
|
||||
隐式内存库或仓库内默认数据库。服务不自动猜迁移目录;README 必须先给出显式迁移命令。
|
||||
- 商品链接只接受 `https://mobile.yangkeduo.com/goods.html`,且必须恰有一个纯数字 `goods_id`;
|
||||
拒绝 userinfo、端口、fragment、重复参数、其他 host/scheme/path 和编码绕过。数据库只保存 goods_id,
|
||||
展示链接由 goods_id 重建 canonical URL;`uin` 等额外查询参数既不保存也不回显。
|
||||
- 标题、颜色分类、尺码必须去除首尾空白后非空并受明确长度上限约束;数量必须是可表示的正整数;
|
||||
总额上限必须是大于零、最多两位小数的十进制字符串并规范为两位小数。金额校验、保存与展示均不得
|
||||
使用浮点数或从其他数字推测。
|
||||
- `create_key` 由服务端用 `crypto/rand` 生成并验证格式,同时作为任务 ID;相同 key 与相同规范化内容
|
||||
重放只能返回原任务,不得二次 INSERT,相同 key 携带不同内容必须冲突。SQL 必须参数化,创建失败
|
||||
不得留下半条或未知状态记录。
|
||||
- `GET /tasks`、`GET /tasks/new`、`POST /tasks` 都必须复用 T-201 管理会话;POST 必须验证 CSRF。
|
||||
校验失败保留非敏感输入并逐字段提示,数据库内部错误只给通用响应,不泄露 SQL、路径或凭据。
|
||||
- 页面只使用服务端模板转义;标题商品链接在新标签打开时必须带 `noopener noreferrer`。导入按钮只作
|
||||
禁用占位;不得加载外部资源或把原型假数据、真机数据、地址、手机号带进生产页面。
|
||||
- 不实现或引用试选、数量设置、订单确认、提交围栏、提交订单、付款、免密支付或先用后付能力。
|
||||
@@ -1,84 +1,149 @@
|
||||
#!/usr/bin/env pwsh
|
||||
|
||||
# cmbuyer 标准启动与验证入口(Windows PowerShell 版),与 init.sh 等价,二选一:
|
||||
# - Windows 原生 PowerShell:./init.ps1
|
||||
# - WSL / Git Bash:./init.sh
|
||||
#
|
||||
# 本项目是双产品结构:admin/(采购服务,Go)+ client/(采购工具,Python)。脚本按
|
||||
# admin/go.mod 与 client/requirements.txt 两个初始化哨兵分别处理;未初始化的一端会跳过并
|
||||
# 提示对应任务,不会把空目录误判为可运行项目,也不会静默通过。
|
||||
#
|
||||
# T-001 / T-002 / T-003 落地后,把下面的命令补全,并同步:
|
||||
# docs/03-tech-stack.md、docs/00-ai-start-here.md、docs/current-state.md
|
||||
# cmbuyer Windows 统一安装、验证与启动入口。
|
||||
# 这个入口只处理开发环境和离线门禁,不连接设备,也不触发采购、下单或付款动作。
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-Location -Path $PSScriptRoot
|
||||
|
||||
# Go 工具链固定用本机版本,避免自动下载
|
||||
# Go 工具链固定用本机版本,避免自动下载造成不可复现的环境漂移。
|
||||
$env:GOTOOLCHAIN = "local"
|
||||
|
||||
$AdminDir = Join-Path $PSScriptRoot "admin"
|
||||
$AdminDir = Join-Path $PSScriptRoot "admin"
|
||||
$ClientDir = Join-Path $PSScriptRoot "client"
|
||||
$Pending = @()
|
||||
$ClientVenv = Join-Path $ClientDir ".venv"
|
||||
$ClientPython = Join-Path $ClientVenv "Scripts\python.exe"
|
||||
|
||||
function Fail([string]$Message) {
|
||||
throw $Message
|
||||
}
|
||||
|
||||
function Require-Directory([string]$Path, [string]$Name) {
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Container)) {
|
||||
Fail "$Name 目录不存在:$Path。请先完成对应初始化任务。"
|
||||
}
|
||||
}
|
||||
|
||||
function Require-File([string]$Path, [string]$Name) {
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
||||
Fail "$Name 不存在:$Path。请先完成对应初始化任务。"
|
||||
}
|
||||
}
|
||||
|
||||
function Require-Command([string]$Name, [string]$Hint) {
|
||||
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
|
||||
Fail "缺少 $Name。$Hint"
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-Step([string]$Description, [scriptblock]$Action) {
|
||||
Write-Host "==> $Description"
|
||||
& $Action
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail "$Description 失败,退出码:$LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
function Confirm-Python311OrNewer([string]$PythonExecutable, [string]$Context) {
|
||||
& $PythonExecutable -c "import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)"
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail "$Context 必须使用 Python 3.11+。不会自动覆盖既有虚拟环境;请手工删除 client/.venv 后重试。"
|
||||
}
|
||||
}
|
||||
|
||||
function Select-PythonLauncherSelector() {
|
||||
$launcherLines = @(& py -0p)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail "无法从 Python Launcher 枚举已安装版本。请安装 Python 3.11+ 并确认 py -0p 可运行。"
|
||||
}
|
||||
|
||||
$minimumVersion = [version]::new(3, 11)
|
||||
$candidates = @()
|
||||
foreach ($line in $launcherLines) {
|
||||
$selectorMatch = [regex]::Match([string]$line, '^\s*-V:(?<selector>\S+)(?:\s+\*)?\s+(?<path>.+?)\s*$')
|
||||
if (-not $selectorMatch.Success) {
|
||||
continue
|
||||
}
|
||||
|
||||
$selector = $selectorMatch.Groups['selector'].Value
|
||||
$pythonPath = $selectorMatch.Groups['path'].Value
|
||||
$versionMatch = [regex]::Match($selector, '(?<major>\d+)\.(?<minor>\d+)(?:\.(?<patch>\d+))?')
|
||||
if (-not $versionMatch.Success) {
|
||||
continue
|
||||
}
|
||||
|
||||
$patch = if ($versionMatch.Groups['patch'].Success) { [int]$versionMatch.Groups['patch'].Value } else { 0 }
|
||||
$version = [version]::new(
|
||||
[int]$versionMatch.Groups['major'].Value,
|
||||
[int]$versionMatch.Groups['minor'].Value,
|
||||
$patch
|
||||
)
|
||||
if ($version -ge $minimumVersion) {
|
||||
$candidates += [PSCustomObject]@{ Selector = $selector; Version = $version; Path = $pythonPath }
|
||||
}
|
||||
}
|
||||
|
||||
if ($candidates.Count -eq 0) {
|
||||
Fail "Python Launcher 未发现 Python 3.11+。当前默认 python 不会作为回退;请安装合规版本后重试。"
|
||||
}
|
||||
|
||||
$selected = $candidates | Sort-Object @{ Expression = 'Version'; Descending = $true }, @{ Expression = 'Selector'; Descending = $false } | Select-Object -First 1
|
||||
Write-Host "==> 选择 Python Launcher 解释器:-V:$($selected.Selector)($($selected.Version))"
|
||||
return $selected
|
||||
}
|
||||
|
||||
Write-Host "==> 当前目录: $($PWD.Path)"
|
||||
|
||||
# ------------------------------------------------------- 采购服务(admin/,Go)
|
||||
if (Test-Path (Join-Path $AdminDir "go.mod") -PathType Leaf) {
|
||||
Write-Host "==> 采购服务:admin/ 同步依赖"
|
||||
Push-Location $AdminDir
|
||||
try {
|
||||
go mod download
|
||||
Write-Host "==> 采购服务:admin/ 静态检查"
|
||||
go vet ./...
|
||||
Write-Host "==> 采购服务:admin/ 测试"
|
||||
go test ./...
|
||||
} finally {
|
||||
Pop-Location
|
||||
Require-Directory $AdminDir "采购服务 admin"
|
||||
Require-File (Join-Path $AdminDir "go.mod") "采购服务哨兵 admin/go.mod"
|
||||
Require-Directory $ClientDir "采购工具 client"
|
||||
Require-File (Join-Path $ClientDir "requirements.txt") "采购工具哨兵 client/requirements.txt"
|
||||
Require-File (Join-Path $ClientDir "pyproject.toml") "采购工具打包配置 client/pyproject.toml"
|
||||
Require-Command "go" "请安装 Go 1.23+ 并重新打开 PowerShell。"
|
||||
if (Test-Path -LiteralPath $ClientVenv) {
|
||||
if (-not (Test-Path -LiteralPath $ClientVenv -PathType Container)) {
|
||||
Fail "client/.venv 不是目录。不会覆盖该路径,请人工处理后重试。"
|
||||
}
|
||||
Require-File $ClientPython "既有 client/.venv Python"
|
||||
Confirm-Python311OrNewer $ClientPython "既有 client/.venv"
|
||||
} else {
|
||||
Write-Host "==> 采购服务:admin/go.mod 不存在,跳过。先做 T-001(初始化 admin/ Go 骨架)。"
|
||||
$Pending += "T-001 初始化采购服务 admin/ Go 骨架"
|
||||
# 默认 python 在本机可能仍指向 3.10;仅在需要新建 venv 时,从 Launcher 中确定性选择最高的合规版本。
|
||||
Require-Command "py" "请安装 Python Launcher 和 Python 3.11+。"
|
||||
$PythonRuntime = Select-PythonLauncherSelector
|
||||
Require-File $PythonRuntime.Path "Python Launcher 选择的解释器"
|
||||
Invoke-Step "验证所选 Python Launcher 解释器" { & $PythonRuntime.Path -c "import sys; print(sys.version); raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" }
|
||||
Invoke-Step "创建所选 Python 3.11+ 采购工具虚拟环境" { & $PythonRuntime.Path -m venv $ClientVenv }
|
||||
Require-File $ClientPython "新建 client/.venv Python"
|
||||
Confirm-Python311OrNewer $ClientPython "新建 client/.venv"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------- 采购工具(client/,Python)
|
||||
if (Test-Path (Join-Path $ClientDir "requirements.txt") -PathType Leaf) {
|
||||
Write-Host "==> 采购工具:client/ 同步依赖"
|
||||
Push-Location $ClientDir
|
||||
try {
|
||||
if (-not (Test-Path ".venv")) {
|
||||
python -m venv .venv
|
||||
}
|
||||
& ".venv\Scripts\python.exe" -m pip install -q -r requirements.txt
|
||||
Write-Host "==> 采购工具:client/ 语法检查"
|
||||
& ".venv\Scripts\python.exe" -m compileall -q src tests
|
||||
Write-Host "==> 采购工具:client/ 测试"
|
||||
& ".venv\Scripts\python.exe" -m unittest discover -s tests -t .
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} else {
|
||||
Write-Host "==> 采购工具:client/requirements.txt 不存在,跳过。先做 T-002(初始化 client/ Python 骨架)。"
|
||||
$Pending += "T-002 初始化采购工具 client/ Python 骨架"
|
||||
# 后续所有采购工具检查与仓库 validator 均使用同一个合规 venv 解释器,避免混用宿主 Python。
|
||||
Invoke-Step "验证采购工具虚拟环境 Python" { & $ClientPython -c "import sys; print(sys.version); raise SystemExit(0 if sys.version_info >= (3, 11) else 1)" }
|
||||
|
||||
Push-Location $AdminDir
|
||||
try {
|
||||
Invoke-Step "采购服务:admin/ 同步依赖" { go mod download }
|
||||
Invoke-Step "采购服务:admin/ 测试" { go test ./... }
|
||||
Invoke-Step "采购服务:admin/ 静态检查" { go vet ./... }
|
||||
Invoke-Step "采购服务:admin/ 构建" { go build ./... }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 文档自检
|
||||
Write-Host "==> 校验 agent 上下文清单"
|
||||
python scripts/validate_agent_context.py
|
||||
Push-Location $ClientDir
|
||||
try {
|
||||
Invoke-Step "采购工具:以 editable 方式安装" { & $ClientPython -m pip install -e . }
|
||||
Invoke-Step "采购工具:验证已安装包" { & $ClientPython -c "import cmbuyer_client; print(cmbuyer_client.__version__)" }
|
||||
Invoke-Step "采购工具:测试" { & $ClientPython -m unittest discover -s tests -t . }
|
||||
Invoke-Step "采购工具:语法检查" { & $ClientPython -m compileall -q src tests scripts }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Invoke-Step "校验 agent 上下文清单" { & $ClientPython scripts/validate_agent_context.py }
|
||||
|
||||
# -------------------------------------------------------------------- 汇总
|
||||
Write-Host ""
|
||||
if ($Pending.Count -gt 0) {
|
||||
Write-Host "==> 尚未初始化的部分:"
|
||||
foreach ($item in $Pending) { Write-Host " - $item" }
|
||||
Write-Host ""
|
||||
Write-Host "当前仓库只有文档。按 docs/06-tasks.md 领取上述任务后再运行本脚本。"
|
||||
exit 3
|
||||
}
|
||||
|
||||
Write-Host "==> 启动命令"
|
||||
Write-Host " 采购服务:cd admin ; go run ./cmd/server"
|
||||
Write-Host " 采购工具:cd client ; .venv\Scripts\python.exe src/main.py"
|
||||
Write-Host "==> 基线验证通过。启动命令:"
|
||||
Write-Host " 采购服务:cd admin; go run ./cmd/server"
|
||||
Write-Host " 采购工具:cd client; .\.venv\Scripts\python.exe -m cmbuyer_client"
|
||||
Write-Host ""
|
||||
Write-Host "基础验证失败时先修基线,不要在坏的起点上继续叠新功能。"
|
||||
Write-Host "真机连接与设备验收只能由人工完成,agent 不得据此把任务标为 DONE。"
|
||||
Write-Host "真机连接与设备验收只能由人工完成;本入口不执行任何采购、下单或付款动作。"
|
||||
|
||||
@@ -1,69 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
# cmbuyer 标准启动与验证入口(Unix shell 版),与 init.ps1 等价,二选一。
|
||||
#
|
||||
# 注意:本项目的构建与验证工具链只在 Windows 侧(Go SDK、Python 环境、Android SDK)。
|
||||
# 在 WSL 中运行本脚本通常会因缺少工具链而失败——这是预期行为,不是 bug。
|
||||
# 正式验证请在 Windows PowerShell 运行 ./init.ps1。
|
||||
# 双产品目录为 admin/(采购服务,Go)与 client/(采购工具,Python);分别以 go.mod 与
|
||||
# requirements.txt 作为初始化哨兵,不能只按空目录存在判断。
|
||||
#
|
||||
# T-001 / T-002 / T-003 落地后补全命令,并同步:
|
||||
# docs/03-tech-stack.md、docs/00-ai-start-here.md、docs/current-state.md
|
||||
# cmbuyer Unix shell 统一安装、验证与启动入口。
|
||||
# 本脚本与 init.ps1 保持同一门禁语义;在 WSL 缺少 Windows 侧工具链时必须明确失败,
|
||||
# 不得将跳过或 bash 语法通过误报为 Windows 产品验收。
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
export GOTOOLCHAIN=local
|
||||
|
||||
pending=()
|
||||
fail() {
|
||||
echo "错误:$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_directory() {
|
||||
local path="$1"
|
||||
local name="$2"
|
||||
[[ -d "$path" ]] || fail "$name 目录不存在:$path。请先完成对应初始化任务。"
|
||||
}
|
||||
|
||||
require_file() {
|
||||
local path="$1"
|
||||
local name="$2"
|
||||
[[ -f "$path" ]] || fail "$name 不存在:$path。请先完成对应初始化任务。"
|
||||
}
|
||||
|
||||
require_command() {
|
||||
local name="$1"
|
||||
local hint="$2"
|
||||
command -v "$name" >/dev/null 2>&1 || fail "缺少 $name。$hint"
|
||||
}
|
||||
|
||||
run_step() {
|
||||
local description="$1"
|
||||
shift
|
||||
echo "==> $description"
|
||||
"$@" || fail "$description 失败。"
|
||||
}
|
||||
|
||||
confirm_python311_or_newer() {
|
||||
local executable="$1"
|
||||
local context="$2"
|
||||
"$executable" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)' \
|
||||
|| fail "$context 必须使用 Python 3.11+。不会自动覆盖既有虚拟环境;请手工删除 client/.venv 后重试。"
|
||||
}
|
||||
|
||||
echo "==> 当前目录: $(pwd)"
|
||||
require_directory admin "采购服务 admin"
|
||||
require_file admin/go.mod "采购服务哨兵 admin/go.mod"
|
||||
require_directory client "采购工具 client"
|
||||
require_file client/requirements.txt "采购工具哨兵 client/requirements.txt"
|
||||
require_file client/pyproject.toml "采购工具打包配置 client/pyproject.toml"
|
||||
require_command go "请安装 Go 1.23+ 后重试。"
|
||||
|
||||
# ------------------------------------------------------- 采购服务(admin/,Go)
|
||||
if [ -f admin/go.mod ]; then
|
||||
echo "==> 采购服务:admin/ 同步依赖"
|
||||
( cd admin && go mod download )
|
||||
echo "==> 采购服务:admin/ 静态检查"
|
||||
( cd admin && go vet ./... )
|
||||
echo "==> 采购服务:admin/ 测试"
|
||||
( cd admin && go test ./... )
|
||||
client_python="client/.venv/bin/python"
|
||||
if [[ -e client/.venv ]]; then
|
||||
[[ -d client/.venv ]] || fail "client/.venv 不是目录。不会覆盖该路径,请人工处理后重试。"
|
||||
require_file "$client_python" "既有 client/.venv Python"
|
||||
confirm_python311_or_newer "$client_python" "既有 client/.venv"
|
||||
else
|
||||
echo "==> 采购服务:admin/go.mod 不存在,跳过。先做 T-001(初始化 admin/ Go 骨架)。"
|
||||
pending+=("T-001 初始化采购服务 admin/ Go 骨架")
|
||||
require_command python3 "请安装 Python 3.11+ 后重试。"
|
||||
confirm_python311_or_newer python3 "python3"
|
||||
run_step "创建 Python 3.11+ 采购工具虚拟环境" python3 -m venv client/.venv
|
||||
require_file "$client_python" "新建 client/.venv Python"
|
||||
confirm_python311_or_newer "$client_python" "新建 client/.venv"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------- 采购工具(client/,Python)
|
||||
if [ -f client/requirements.txt ]; then
|
||||
echo "==> 采购工具:client/ 同步依赖"
|
||||
( cd client \
|
||||
&& { [ -d .venv ] || python3 -m venv .venv; } \
|
||||
&& .venv/bin/python -m pip install -q -r requirements.txt )
|
||||
echo "==> 采购工具:client/ 语法检查"
|
||||
( cd client && .venv/bin/python -m compileall -q src tests )
|
||||
echo "==> 采购工具:client/ 测试"
|
||||
( cd client && .venv/bin/python -m unittest discover -s tests -t . )
|
||||
else
|
||||
echo "==> 采购工具:client/requirements.txt 不存在,跳过。先做 T-002(初始化 client/ Python 骨架)。"
|
||||
pending+=("T-002 初始化采购工具 client/ Python 骨架")
|
||||
fi
|
||||
run_step "验证采购工具虚拟环境 Python" "$client_python" -c 'import sys; print(sys.version); raise SystemExit(0 if sys.version_info >= (3, 11) else 1)'
|
||||
|
||||
# ------------------------------------------------------------------ 文档自检
|
||||
echo "==> 校验 agent 上下文清单"
|
||||
python3 scripts/validate_agent_context.py
|
||||
( cd admin && run_step "采购服务:admin/ 同步依赖" go mod download )
|
||||
( cd admin && run_step "采购服务:admin/ 测试" go test ./... )
|
||||
( cd admin && run_step "采购服务:admin/ 静态检查" go vet ./... )
|
||||
( cd admin && run_step "采购服务:admin/ 构建" go build ./... )
|
||||
|
||||
( cd client && run_step "采购工具:以 editable 方式安装" .venv/bin/python -m pip install -e . )
|
||||
( cd client && run_step "采购工具:验证已安装包" .venv/bin/python -c 'import cmbuyer_client; print(cmbuyer_client.__version__)' )
|
||||
( cd client && run_step "采购工具:测试" .venv/bin/python -m unittest discover -s tests -t . )
|
||||
( cd client && run_step "采购工具:语法检查" .venv/bin/python -m compileall -q src tests scripts )
|
||||
|
||||
run_step "校验 agent 上下文清单" "$client_python" scripts/validate_agent_context.py
|
||||
|
||||
# -------------------------------------------------------------------- 汇总
|
||||
echo
|
||||
if [ ${#pending[@]} -gt 0 ]; then
|
||||
echo "==> 尚未初始化的部分:"
|
||||
for item in "${pending[@]}"; do echo " - $item"; done
|
||||
echo
|
||||
echo "当前仓库只有文档。按 docs/06-tasks.md 领取上述任务后再运行本脚本。"
|
||||
exit 3
|
||||
fi
|
||||
|
||||
echo "==> 启动命令"
|
||||
echo " 采购服务:cd admin && go run ./cmd/server"
|
||||
echo " 采购工具:cd client && .venv/bin/python src/main.py"
|
||||
echo "==> 基线验证通过。启动命令:"
|
||||
echo " 采购服务:cd admin && go run ./cmd/server"
|
||||
echo " 采购工具:cd client && .venv/bin/python -m cmbuyer_client"
|
||||
echo
|
||||
echo "基础验证失败时先修基线,不要在坏的起点上继续叠新功能。"
|
||||
echo "真机连接与设备验收只能由人工完成,agent 不得据此把任务标为 DONE。"
|
||||
echo "WSL / Unix shell 仅在本机工具链齐备时能通过;这不构成 Windows 或真机验收。"
|
||||
|
||||
Reference in New Issue
Block a user