Compare commits

..
Author SHA1 Message Date
QiuSW d38cfb61af feat(admin): add draft task creation 2026-08-04 16:33:22 +08:00
QiuSW cea27ff7ef docs(tasks): define T-202 draft creation 2026-08-04 15:08:13 +08:00
QiuSW 1c35155c2d merge: T-201 administrator sessions 2026-08-04 15:04:38 +08:00
QiuSW babb530b99 docs(tasks): complete T-201 admin session 2026-08-04 15:04:25 +08:00
QiuSW 55d9e09bda merge: main into T-201 2026-08-04 14:59:28 +08:00
QiuSW 47c0844f9c feat(admin): add administrator sessions 2026-08-04 14:57:40 +08:00
QiuSW 88d8f77417 merge: T-103 safe price diagnostics 2026-08-04 14:56:50 +08:00
QiuSW f7efa4a161 docs(tasks): record T-103 safe diagnostics 2026-08-04 14:56:41 +08:00
QiuSW ef1ac60ac5 fix(client): diagnose T-103 price text safely 2026-08-04 14:55:14 +08:00
QiuSW 5332d67b5e merge: T-103 sanitizer v4 2026-08-04 14:48:44 +08:00
QiuSW 5f6b3ce01a docs(tasks): record T-103 sanitizer v4 2026-08-04 14:48:36 +08:00
QiuSW 824a628733 Merge branch 'main' into task/t-103-sku-panel 2026-08-04 14:42:57 +08:00
QiuSW 1e69d274b8 feat(client): project sanitized SKU price candidates 2026-08-04 14:42:49 +08:00
QiuSW 8ee26be95a docs(tasks): define T-201 admin session 2026-08-04 14:40:59 +08:00
QiuSW 80ed9b71e8 docs(tasks): complete T-010 parallel gate 2026-08-04 14:39:27 +08:00
QiuSW 3f2e0e5598 docs: allow safe admin parallelism 2026-08-04 14:38:20 +08:00
QiuSW a45a1b5afc docs(tasks): review first T-103 derived evidence 2026-08-04 14:20:23 +08:00
QiuSW d27fda6be3 docs(tasks): record T-103 sanitizer v3 2026-08-04 14:08:10 +08:00
QiuSW acf7e11114 fix(client): bind T-103 XML coordinates 2026-08-04 14:04:49 +08:00
QiuSW 946b064470 docs(tasks): record T-103 sanitizer v2 2026-08-04 11:59:48 +08:00
QiuSW 44c027a18f fix(client): separate screenshot and XML coordinates 2026-08-04 11:57:45 +08:00
QiuSW cf3692112c docs(tasks): record preselected SKU evidence flow 2026-08-04 11:41:54 +08:00
QiuSW dbb69a7d5e fix(client): reflect preselected SKU evidence states 2026-08-04 11:38:15 +08:00
QiuSW 3d063b9785 docs(tasks): revise T-103 evidence states 2026-08-04 11:35:18 +08:00
QiuSW 932ca8c7f3 docs(tasks): record T-103 sanitizer milestone 2026-08-04 11:25:40 +08:00
QiuSW 9b2eb74478 feat(client): sanitize T-103 device evidence 2026-08-04 11:16:11 +08:00
QiuSW 17e295e99d docs(tasks): authorize T-103 sanitizer CLI 2026-08-04 11:03:39 +08:00
QiuSW d80e4393b4 docs(tasks): record T-110 architecture validation 2026-08-04 11:00:20 +08:00
QiuSW 82f57c7233 docs(architecture): approve controlled trial SKU entry 2026-08-04 10:59:40 +08:00
QiuSW d81fc722dc docs(tasks): define controlled SKU entry boundary 2026-08-04 10:49:05 +08:00
QiuSW abeefbbea8 docs(architecture): record T-103 purchase-entry no-go 2026-08-04 10:42:28 +08:00
QiuSW b66417d80b docs(tasks): reject unsafe T-103 evidence set 2026-08-04 10:36:53 +08:00
QiuSW 5b56161fd7 docs(tasks): record T-103 evidence scaffold audit 2026-08-04 10:10:11 +08:00
QiuSW a92b8f6be0 feat(client): add T-103 manual SKU evidence capture 2026-08-04 10:08:49 +08:00
QiuSW ec8257ba8b docs(tasks): start T-103 SKU panel spike 2026-08-04 09:58:13 +08:00
QiuSW 27999c8c85 docs(tasks): define T-103 SKU panel spike 2026-08-04 09:57:07 +08:00
35 changed files with 4586 additions and 71 deletions
+25
View File
@@ -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
```
采购服务会话仅保存在当前进程内;进程重启后既有登录会话会安全失效。
+29 -1
View File
@@ -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
}
+1 -1
View File
@@ -6,6 +6,7 @@ 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 (
@@ -35,7 +36,6 @@ require (
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
+198
View File
@@ -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
}
+49
View File
@@ -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:]
}
+91
View File
@@ -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
}
+85
View File
@@ -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
}
+282 -7
View File
@@ -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
}
+457 -4
View File
@@ -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(), `&lt;script&gt;alert(1)&lt;/script&gt;`) {
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{`任务已创建,已显示在列表首行。`, `&lt;b&gt;夏季上衣&lt;/b&gt;`, `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)
}
+129
View File
@@ -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
}
+159
View File
@@ -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
}
+300
View File
@@ -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}}
+45
View File
@@ -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)
}
+103
View File
@@ -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,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,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,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", "快卖光&#10;¥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", "快卖光&#10;¥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")
+263
View File
@@ -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")
+9 -7
View File
@@ -85,22 +85,24 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
## 当前阶段
**Phase 0 · 地基。** 采购服务与采购工具骨架均已初始化,尚无采购业务代码。
**Phase 1 · 真机可行性,并行启动采购服务基础能力。** 两端骨架与核心数据模型已完成,T-103
仍是当前真机关键路径;与真机可读字段无关的采购服务能力不再空等。
执行按任务依赖驱动,**不按 Phase 整段串行等待**。当前优先路径:
1. T-003 统一入口与 T-004 核心数据模型已完成;T-101 真机环境盘点已就绪,但尚未真机验收。
2. T-002 已完成,立即推进 **T-101 → T-102 → T-103 真机取证**。
3. T-103 结论确认后,才开始依赖真机可读字段的 Phase 2 生产页面;采购服务核心与
T-104 → T-107 后续真机安全判据按依赖并行推进。
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 可以并行。原型只确认流程与信息架构,实际可读字段仍以真机证据为准。
## 领取任务规则
+19 -7
View File
@@ -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 必须一并取证。**
若规格面板上无法可靠读到单价,闸门一要改为「只截图不判价」,确认页设计随之调整。
+61 -1
View File
@@ -43,7 +43,7 @@
| 调用位置 | 采购工具 | 已定 | PC 有算力;改 prompt 不需要重新打包 |
| provider | 待定 | **待定** | 需先确认预算与合规;不得由 agent 自行选定 |
| 凭据存储 | 采购工具本机配置文件,不入库、不上传 | 已定 | 采购服务不保存、不代理、不下发任何模型凭据 |
| 输入 | 完整节点树 XML + 页面截图 | 已定 | `dump_hierarchy(compressed=False)` 不丢节点 |
| 输入 | 自动复检通过的脱敏派生 XML + 页面截图 | 已定 | 原始证据只允许本机确定性脱敏器消费,AI/agent 不读取原始地址或手机号 |
## 四、决策记录与演进
@@ -125,6 +125,66 @@ D:\Portable\adb\adb.exe devices -l
证据不得提交 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`。
+39 -10
View File
@@ -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,20 +387,25 @@ 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 看截图筛同款 |
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 再实现。
## 四、待实现时确认
+53 -10
View File
@@ -12,15 +12,16 @@
## 当前快照
- 日期:2026-08-04
- 阶段:**Phase 1 · 真机可行性(T-101、T-102 已完成人工真机验收,下一步 T-103)**
- 阶段:**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 服务、健康检查、核心领域模型、SQLite 迁移与任务状态机;
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
连接边界、本地基线取证 CLI 和受限商品链接打开取证 CLI;尚无规格选择、价格读取或下单流程
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 46 项离线单元测试
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板状态的只读取证 CLI,
以及绑定 PKG110 / Android 16 / 拼多多 8.17.0 的规格证据确定性脱敏 CLI;尚无规格选择、价格读取或下单流程
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 78 项离线单元测试
(全部 mock,不连接真机)
- 数据:SQLite 核心表与迁移已落成;无业务实例数据
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
@@ -28,19 +29,27 @@
并且不覆盖低版本环境;成功后打印真实启动命令。
- 标准验证路径:`./init.ps1` 已实际跑通 admin 的 mod download / test / vet / build、client 的
editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。
- 当前 blocker:无外部 blocker。T-102 已证明 canonical 链接可在拼多多 8.17.0 打开到目标商品并完成
人工隐私验收;下一步落成 T-103 的规格面板证据与试选验证。T-103 通过前 Phase 2 仍不能抢跑。
- 当前设备门禁:人工已确认拼多多 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-004、T-005~T-009、T-101~T-102) | T-001~T-004、T-101~T-102 已完成;下一任务为 T-103 |
| `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 最小入口、显式 serial 的基线/商品打开取证、离线测试与 wheel 元数据检查;无规格选择、价格读取或下单流程 |
| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、显式 serial 的基线/商品打开/规格面板只读取证、确定性证据脱敏、离线测试与 wheel 元数据检查;无规格选择、价格读取或下单流程 |
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
## 任务状态
@@ -52,9 +61,12 @@
以及 T-001(采购服务 Go 骨架)。
- 已完成:T-002(采购工具 Python 骨架)、T-003(双端统一初始化与验证入口)、
T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)、T-102(canonical
链接打开与目标商品/隐私人工验收)。下一步推进 T-103。
- T-103 是当前最高优先级和 MVP 生死线。通过前不开发依赖真机可读字段的
Phase 2 生产页面。
链接打开与目标商品/隐私人工验收)。
- 已完成 T-010(安全并行门禁);T-201(管理员登录与会话)已在独立写路径并行开发。T-202 可在
T-201 完成后继续,但只能创建和展示 `DRAFT`,不得启动试选或引入未经 T-103 证实的真机字段。
- 已完成 T-110(第一趟受控规格入口与隐私脱敏边界)。T-103 是当前最高优先级和 MVP 生死线,
已补充 T-110 依赖并恢复 `DOING`;v4 脱敏器离线实现已完成,三态派生证据和新真机验收完成前不开发
T-203 及后续依赖真机可读字段或会启动试选的 Phase 2 生产页面。
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
可读字段时必须先回修原型与交互清单。
@@ -133,6 +145,37 @@ D:\Portable\adb\adb.exe devices -l
检查无地址、手机号、支付信息或其他无关隐私;只回报 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、编写判据。
人工确认中还必须记录中间态实际选择的非目标颜色和尺码,不能只写“已切换”。
## 关键背景
本项目是 `cmroubao`(Go 后端 + Android AccessibilityService)与 `cmpdd`
+52
View File
@@ -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 阻塞;不得用
假字段、原型假数据或前序项目结论提前固化生产契约。
- 不付款、第一趟不可达下单、价格读取位置、真机判据先取证、原始证据本机隔离与派生物消费边界
均保持不变。
+179
View File
@@ -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`。
+87
View File
@@ -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 继续实现脱敏器、判据、离线测试和人工真机验收。
+69
View File
@@ -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、`//`、反斜杠、编码绕过和其他站内路径均拒绝。
+75
View File
@@ -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`。导入按钮只作
禁用占位;不得加载外部资源或把原型假数据、真机数据、地址、手机号带进生产页面。
- 不实现或引用试选、数量设置、订单确认、提交围栏、提交订单、付款、免密支付或先用后付能力。