Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efeb2d958a | ||
|
|
549099ad24 | ||
|
|
76a522f13f | ||
|
|
5088d544f2 | ||
|
|
6e001b2729 | ||
|
|
1c35155c2d | ||
|
|
babb530b99 | ||
|
|
55d9e09bda | ||
|
|
47c0844f9c | ||
|
|
88d8f77417 | ||
|
|
f7efa4a161 | ||
|
|
ef1ac60ac5 | ||
|
|
5332d67b5e | ||
|
|
5f6b3ce01a | ||
|
|
824a628733 | ||
|
|
1e69d274b8 | ||
|
|
8ee26be95a | ||
|
|
a45a1b5afc |
@@ -0,0 +1,22 @@
|
|||||||
|
# 采购服务
|
||||||
|
|
||||||
|
启动前必须显式设置下列环境变量;服务不提供默认管理员账号、密码或会话密钥。
|
||||||
|
|
||||||
|
| 变量 | 要求 |
|
||||||
|
| --- | --- |
|
||||||
|
| `CMBUYER_ADMIN_USERNAME` | 非空管理员账号。 |
|
||||||
|
| `CMBUYER_ADMIN_PASSWORD_BCRYPT` | 非空 bcrypt 密码哈希,不接受明文密码。 |
|
||||||
|
| `CMBUYER_SESSION_SECRET` | 至少 32 字节的会话签名密钥。 |
|
||||||
|
| `CMBUYER_COOKIE_SECURE` | 可选;存在时只能精确为 `true` 或 `false`。HTTPS 部署应设为 `true`。 |
|
||||||
|
|
||||||
|
示例仅展示变量名,不提供可运行凭据:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:CMBUYER_ADMIN_USERNAME = '<管理员账号>'
|
||||||
|
$env:CMBUYER_ADMIN_PASSWORD_BCRYPT = '<bcrypt 密码哈希>'
|
||||||
|
$env:CMBUYER_SESSION_SECRET = '<至少 32 字节的随机密钥>'
|
||||||
|
$env:CMBUYER_COOKIE_SECURE = 'true'
|
||||||
|
go run ./cmd/server
|
||||||
|
```
|
||||||
|
|
||||||
|
采购服务会话仅保存在当前进程内;进程重启后既有登录会话会安全失效。
|
||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"cmbuyer/admin/internal/auth"
|
||||||
|
"cmbuyer/admin/internal/config"
|
||||||
"cmbuyer/admin/internal/server"
|
"cmbuyer/admin/internal/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,7 +19,21 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func run() error {
|
func run() error {
|
||||||
err := http.ListenAndServe(listenAddress, server.NewRouter())
|
configuration, err := config.LoadFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
router, err := server.NewRouter(server.Options{
|
||||||
|
AdminUsername: configuration.AdminUsername,
|
||||||
|
AdminPasswordBcrypt: configuration.AdminPasswordBcrypt,
|
||||||
|
Sessions: auth.NewManager(configuration.SessionSecret, configuration.CookieSecure),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = http.ListenAndServe(listenAddress, router)
|
||||||
if errors.Is(err, http.ErrServerClosed) {
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -6,6 +6,7 @@ require (
|
|||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.49
|
github.com/mattn/go-sqlite3 v1.14.49
|
||||||
github.com/pressly/goose/v3 v3.24.0
|
github.com/pressly/goose/v3 v3.24.0
|
||||||
|
golang.org/x/crypto v0.40.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -35,7 +36,6 @@ require (
|
|||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/arch v0.20.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/mod v0.25.0 // indirect
|
||||||
golang.org/x/net v0.42.0 // indirect
|
golang.org/x/net v0.42.0 // indirect
|
||||||
golang.org/x/sync v0.16.0 // indirect
|
golang.org/x/sync v0.16.0 // indirect
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
// Package auth 提供内存会话与 CSRF 防护。会话不落库,服务重启会安全地使所有登录失效。
|
||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
CookieName = "cmbuyer_session"
|
||||||
|
SessionLifetime = 8 * time.Hour
|
||||||
|
csrfTokenByteSize = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
type session struct {
|
||||||
|
csrfToken string
|
||||||
|
authenticated bool
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager 签发、验证并撤销进程内会话。cookie 仅承载经过 HMAC 签名的随机 session ID。
|
||||||
|
type Manager struct {
|
||||||
|
secret []byte
|
||||||
|
cookieSecure bool
|
||||||
|
now func() time.Time
|
||||||
|
random io.Reader
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[string]session
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager 创建会话管理器。secret 在启动时已由 config 验证为足够长度。
|
||||||
|
func NewManager(secret []byte, cookieSecure bool) *Manager {
|
||||||
|
return &Manager{
|
||||||
|
secret: append([]byte(nil), secret...),
|
||||||
|
cookieSecure: cookieSecure,
|
||||||
|
now: time.Now,
|
||||||
|
random: rand.Reader,
|
||||||
|
sessions: make(map[string]session),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure 返回当前有效会话;不存在或过期时签发匿名会话,以保护登录表单本身的 POST。
|
||||||
|
func (manager *Manager) Ensure(writer http.ResponseWriter, request *http.Request) (csrfToken string, authenticated bool) {
|
||||||
|
if id, current, ok := manager.current(request); ok {
|
||||||
|
return current.csrfToken, current.authenticated
|
||||||
|
} else if id != "" {
|
||||||
|
manager.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, current := manager.create(false)
|
||||||
|
manager.writeCookie(writer, id, current.expiresAt)
|
||||||
|
return current.csrfToken, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyCSRF 只接受当前未过期会话中以恒定时间比较匹配的 token。
|
||||||
|
func (manager *Manager) VerifyCSRF(request *http.Request, token string) (authenticated bool, ok bool) {
|
||||||
|
_, current, found := manager.current(request)
|
||||||
|
if !found || token == "" {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if subtle.ConstantTimeCompare([]byte(current.csrfToken), []byte(token)) != 1 {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return current.authenticated, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RotateAuthenticated 在登录成功后撤销旧会话并签发全新认证会话,避免 session fixation 与 CSRF 复用。
|
||||||
|
func (manager *Manager) RotateAuthenticated(writer http.ResponseWriter, request *http.Request) string {
|
||||||
|
if id, _, ok := manager.current(request); ok {
|
||||||
|
manager.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, current := manager.create(true)
|
||||||
|
manager.writeCookie(writer, id, current.expiresAt)
|
||||||
|
return current.csrfToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout 撤销当前会话并立即清除浏览器 cookie。
|
||||||
|
func (manager *Manager) Logout(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if id, _, ok := manager.current(request); ok {
|
||||||
|
manager.delete(id)
|
||||||
|
}
|
||||||
|
http.SetCookie(writer, &http.Cookie{
|
||||||
|
Name: CookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: manager.cookieSecure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *Manager) current(request *http.Request) (string, session, bool) {
|
||||||
|
cookie, err := request.Cookie(CookieName)
|
||||||
|
if err != nil {
|
||||||
|
return "", session{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
id, expiresAt, ok := manager.verifyCookie(cookie.Value)
|
||||||
|
if !ok || !manager.now().Before(expiresAt) {
|
||||||
|
return id, session{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
defer manager.mu.Unlock()
|
||||||
|
current, found := manager.sessions[id]
|
||||||
|
if !found || !manager.now().Before(current.expiresAt) {
|
||||||
|
return id, session{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return id, current, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *Manager) create(authenticated bool) (string, session) {
|
||||||
|
id := manager.randomToken()
|
||||||
|
current := session{
|
||||||
|
csrfToken: manager.randomToken(),
|
||||||
|
authenticated: authenticated,
|
||||||
|
expiresAt: manager.now().Add(SessionLifetime),
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.mu.Lock()
|
||||||
|
manager.sessions[id] = current
|
||||||
|
manager.mu.Unlock()
|
||||||
|
return id, current
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *Manager) delete(id string) {
|
||||||
|
manager.mu.Lock()
|
||||||
|
delete(manager.sessions, id)
|
||||||
|
manager.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *Manager) randomToken() string {
|
||||||
|
bytes := make([]byte, csrfTokenByteSize)
|
||||||
|
if _, err := io.ReadFull(manager.random, bytes); err != nil {
|
||||||
|
panic("crypto/rand failed while creating a session token")
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *Manager) writeCookie(writer http.ResponseWriter, id string, expiresAt time.Time) {
|
||||||
|
http.SetCookie(writer, &http.Cookie{
|
||||||
|
Name: CookieName,
|
||||||
|
Value: manager.signCookie(id, expiresAt),
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: int(expiresAt.Sub(manager.now()).Seconds()),
|
||||||
|
Expires: expiresAt,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: manager.cookieSecure,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *Manager) signCookie(id string, expiresAt time.Time) string {
|
||||||
|
payload := id + "." + strconv.FormatInt(expiresAt.Unix(), 10)
|
||||||
|
mac := hmac.New(sha256.New, manager.secret)
|
||||||
|
_, _ = mac.Write([]byte(payload))
|
||||||
|
return payload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *Manager) verifyCookie(value string) (string, time.Time, bool) {
|
||||||
|
parts := strings.Split(value, ".")
|
||||||
|
if len(parts) != 3 || parts[0] == "" {
|
||||||
|
return "", time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresUnix, err := strconv.ParseInt(parts[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, false
|
||||||
|
}
|
||||||
|
provided, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := parts[0] + "." + parts[1]
|
||||||
|
mac := hmac.New(sha256.New, manager.secret)
|
||||||
|
_, _ = mac.Write([]byte(payload))
|
||||||
|
if !hmac.Equal(provided, mac.Sum(nil)) {
|
||||||
|
return "", time.Time{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts[0], time.Unix(expiresUnix, 0), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManagerRejectsTamperedAndExpiredCookies(t *testing.T) {
|
||||||
|
manager := NewManager([]byte(strings.Repeat("s", 32)), true)
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||||
|
response := httptest.NewRecorder()
|
||||||
|
csrf, authenticated := manager.Ensure(response, request)
|
||||||
|
if csrf == "" || authenticated {
|
||||||
|
t.Fatalf("Ensure = (%q, %t), want anonymous CSRF session", csrf, authenticated)
|
||||||
|
}
|
||||||
|
cookie := response.Result().Cookies()[0]
|
||||||
|
if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteLaxMode || cookie.Path != "/" {
|
||||||
|
t.Fatalf("session cookie is missing security attributes: %#v", cookie)
|
||||||
|
}
|
||||||
|
|
||||||
|
tampered := *cookie
|
||||||
|
tampered.Value = flipCookieValue(t, cookie.Value)
|
||||||
|
tamperedRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||||
|
tamperedRequest.AddCookie(&tampered)
|
||||||
|
if _, ok := manager.VerifyCSRF(tamperedRequest, csrf); ok {
|
||||||
|
t.Fatal("tampered signed cookie passed CSRF verification")
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.now = func() time.Time { return time.Now().Add(9 * time.Hour) }
|
||||||
|
expiredRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||||
|
expiredRequest.AddCookie(cookie)
|
||||||
|
if _, ok := manager.VerifyCSRF(expiredRequest, csrf); ok {
|
||||||
|
t.Fatal("expired cookie passed CSRF verification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func flipCookieValue(t *testing.T, value string) string {
|
||||||
|
t.Helper()
|
||||||
|
if value == "" {
|
||||||
|
t.Fatal("cannot tamper with an empty cookie")
|
||||||
|
}
|
||||||
|
if value[0] == 'A' {
|
||||||
|
return "B" + value[1:]
|
||||||
|
}
|
||||||
|
return "A" + value[1:]
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// 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"
|
||||||
|
minimumSecretLength = 32
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config 是启动采购服务所需的最小安全配置。
|
||||||
|
type Config struct {
|
||||||
|
AdminUsername string
|
||||||
|
AdminPasswordBcrypt string
|
||||||
|
SessionSecret []byte
|
||||||
|
CookieSecure bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Config{
|
||||||
|
AdminUsername: username,
|
||||||
|
AdminPasswordBcrypt: passwordHash,
|
||||||
|
SessionSecret: []byte(secret),
|
||||||
|
CookieSecure: cookieSecure,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func required(lookup func(string) (string, bool), name string) (string, error) {
|
||||||
|
value, present := lookup(name)
|
||||||
|
if !present || strings.TrimSpace(value) == "" {
|
||||||
|
return "", errors.New(name + " must be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
|
||||||
|
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"},
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -2,18 +2,164 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"cmbuyer/admin/internal/auth"
|
||||||
|
"cmbuyer/admin/internal/transport/webui"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
|
const maxFormBytes = 8 << 10
|
||||||
func NewRouter() *gin.Engine {
|
|
||||||
router := gin.New()
|
|
||||||
|
|
||||||
router.GET("/healthz", func(context *gin.Context) {
|
// Options 是路由层需要的安全依赖。凭据由启动配置注入,不能在路由中设置默认值。
|
||||||
|
type Options struct {
|
||||||
|
AdminUsername string
|
||||||
|
AdminPasswordBcrypt string
|
||||||
|
Sessions *auth.Manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
|
||||||
|
func NewRouter(options Options) (*gin.Engine, error) {
|
||||||
|
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == 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))
|
||||||
|
|
||||||
|
return router, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func healthz(context *gin.Context) {
|
||||||
context.JSON(http.StatusOK, gin.H{"status": "ok"})
|
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) {
|
||||||
|
limitFormBody(context)
|
||||||
|
csrfToken := context.PostForm("csrf_token")
|
||||||
|
returnPath := returnTo(context.PostForm("return_to"))
|
||||||
|
username := context.PostForm("username")
|
||||||
|
password := context.PostForm("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) {
|
||||||
|
limitFormBody(context)
|
||||||
|
authenticated, ok := options.Sessions.VerifyCSRF(context.Request, context.PostForm("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
|
||||||
|
}
|
||||||
|
|
||||||
|
context.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := webui.RenderTasks(context.Writer, webui.TasksData{CSRFToken: csrfToken}); 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 limitFormBody(context *gin.Context) {
|
||||||
|
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxFormBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
return router
|
func hasSafeTaskPath(path string) bool {
|
||||||
|
for _, segment := range strings.Split(path, "/") {
|
||||||
|
if segment == "." || segment == ".." {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,26 +3,291 @@ package server_test
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"cmbuyer/admin/internal/auth"
|
||||||
"cmbuyer/admin/internal/server"
|
"cmbuyer/admin/internal/server"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHealthz(t *testing.T) {
|
var csrfPattern = regexp.MustCompile(`name="csrf_token" value="([^"]+)"`)
|
||||||
|
|
||||||
|
func TestHealthzIsPublic(t *testing.T) {
|
||||||
|
router, _ := newRouter(t)
|
||||||
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
|
|
||||||
server.NewRouter().ServeHTTP(response, request)
|
router.ServeHTTP(response, request)
|
||||||
|
|
||||||
if response.Code != http.StatusOK {
|
if response.Code != http.StatusOK {
|
||||||
t.Fatalf("healthz status = %d, want %d", 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" {
|
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)
|
t.Fatalf("healthz content type = %q, want application/json; charset=utf-8", contentType)
|
||||||
}
|
}
|
||||||
|
|
||||||
if body := response.Body.String(); body != "{\"status\":\"ok\"}" {
|
if body := response.Body.String(); body != "{\"status\":\"ok\"}" {
|
||||||
t.Fatalf("healthz body = %q, want {\"status\":\"ok\"}", body)
|
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 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,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRouter: %v", err)
|
||||||
|
}
|
||||||
|
return router, manager
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,27 @@
|
|||||||
|
{{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 { color-scheme:light; --bg:#f4f7fb; --surface:#fff; --text:#172033; --muted:#526079; --border:#cfd8e6; --primary:#155eef; --focus:#ffbf47; font-family:"Segoe UI","Microsoft YaHei UI",system-ui,sans-serif; }
|
||||||
|
* { box-sizing:border-box; } body { min-height:100dvh; margin:0; color:var(--text); background:var(--bg); font-size:16px; line-height:1.55; } button { 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); }
|
||||||
|
header { display:flex; min-height:64px; align-items:center; justify-content:space-between; gap:16px; padding:10px clamp(16px,4vw,40px); border-bottom:1px solid var(--border); background:var(--surface); }
|
||||||
|
.brand { display:flex; align-items:center; gap:10px; 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; }
|
||||||
|
.logout { min-height:44px; padding:8px 14px; border:1px solid var(--border); border-radius:8px; color:var(--text); background:var(--surface); font-weight:700; cursor:pointer; }
|
||||||
|
main { width:min(100% - 32px,760px); margin:48px auto; padding:32px; border:1px solid var(--border); border-radius:14px; background:var(--surface); }
|
||||||
|
h1 { margin:0; font-size:clamp(1.5rem,5vw,2rem); } p { color:var(--muted); } .notice { margin-top:24px; padding:14px; border-left:4px solid var(--primary); border-radius:6px; background:#eaf1ff; color:#29466f; }
|
||||||
|
@media (max-width:420px) { main { width:calc(100% - 24px); margin:24px auto; 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>
|
||||||
|
<header><div class="brand"><span class="brand-mark" aria-hidden="true">采</span><span>采购服务</span></div><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button class="logout" type="submit">退出登录</button></form></header>
|
||||||
|
<main id="main"><h1>采购任务</h1><p>任务功能正在准备中。</p><p class="notice">当前页面仅用于验证管理员会话。</p></main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Package webui 渲染采购服务当前可用的服务端页面。
|
||||||
|
package webui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates/*.html
|
||||||
|
var templateFiles embed.FS
|
||||||
|
|
||||||
|
var templates = template.Must(template.New("webui").ParseFS(templateFiles, "templates/*.html"))
|
||||||
|
|
||||||
|
// LoginData 是登录页面所需的非敏感展示数据。
|
||||||
|
type LoginData struct {
|
||||||
|
CSRFToken string
|
||||||
|
ReturnTo string
|
||||||
|
Username string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TasksData 是当前受保护任务空壳所需的数据。任务字段将在后续任务实现。
|
||||||
|
type TasksData struct {
|
||||||
|
CSRFToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""T-103 原始规格面板证据的本机确定性隐私脱敏。
|
"""T-103 原始规格面板证据的本机确定性隐私脱敏。
|
||||||
|
|
||||||
此模块只处理人工采集的本地文件:不连接设备、不理解拼多多页面,也不识别规格或价格。
|
此模块只处理人工采集的本地文件:不连接设备、不识别规格;仅可按已取证的固定
|
||||||
|
几何和严格格式,将跨隐私边界的价格叶节点投影到派生 XML。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -21,7 +22,7 @@ from ..pdd.product_url import ProductUrl, ProductUrlError, parse_product_url
|
|||||||
from ..pdd.sku_panel_state import HUMAN_DECLARED_STATES
|
from ..pdd.sku_panel_state import HUMAN_DECLARED_STATES
|
||||||
|
|
||||||
|
|
||||||
SANITIZER_VERSION = "t103-privacy-v3"
|
SANITIZER_VERSION = "t103-privacy-v5"
|
||||||
EXPECTED_GOODS_ID = "937122477375"
|
EXPECTED_GOODS_ID = "937122477375"
|
||||||
EXPECTED_PDD_VERSION = "8.17.0"
|
EXPECTED_PDD_VERSION = "8.17.0"
|
||||||
EXPECTED_DEVICE_MODEL = "PKG110"
|
EXPECTED_DEVICE_MODEL = "PKG110"
|
||||||
@@ -37,6 +38,29 @@ _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}")
|
_MASKED_PHONE_RE = re.compile(r"1[3-9]\d\*{4}\d{4}")
|
||||||
_MASK_TRANSLATION = str.maketrans({"*": "*", "•": "*", "·": "*", "×": "*", "x": "*", "X": "*"})
|
_MASK_TRANSLATION = str.maketrans({"*": "*", "•": "*", "·": "*", "×": "*", "x": "*", "X": "*"})
|
||||||
_SEPARATOR_RE = re.compile(r"[\s\-‐‑‒–—―()()]+")
|
_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 空格,且每个可分隔位置最多一个;禁止换行、折扣、支付/提交文案和
|
||||||
|
# 任何其它字符。前缀捕获组用于区分当前价与至多一个划线/原价候选。
|
||||||
|
# T-103 人工在 live 规格面板确认当前价槽的完整非敏感前缀仅为“快卖完”;不得兼容
|
||||||
|
# 未取证的“快卖光”或其它相近文案。
|
||||||
|
_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):
|
class SkuEvidenceSanitizationError(RuntimeError):
|
||||||
@@ -49,9 +73,12 @@ class _CleanupStats:
|
|||||||
|
|
||||||
removed_nodes: int = 0
|
removed_nodes: int = 0
|
||||||
cleared_crossing_nodes: int = 0
|
cleared_crossing_nodes: int = 0
|
||||||
|
preserved_crossing_price_nodes: int = 0
|
||||||
retained_below_nodes: int = 0
|
retained_below_nodes: int = 0
|
||||||
max_right: int = 0
|
max_right: int = 0
|
||||||
max_bottom: int = 0
|
max_bottom: int = 0
|
||||||
|
current_price_candidates: int = 0
|
||||||
|
original_price_candidates: int = 0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -271,6 +298,7 @@ def _sanitize_hierarchy(source: Path, target: Path) -> _CleanupStats:
|
|||||||
_require_expected_xml_coordinate_space(stats)
|
_require_expected_xml_coordinate_space(stats)
|
||||||
if stats.removed_nodes < 1 or stats.retained_below_nodes < 1:
|
if stats.removed_nodes < 1 or stats.retained_below_nodes < 1:
|
||||||
raise SkuEvidenceSanitizationError("原始节点树未满足隐私几何结构。")
|
raise SkuEvidenceSanitizationError("原始节点树未满足隐私几何结构。")
|
||||||
|
_require_safe_crossing_price_projection(stats)
|
||||||
if _contains_phone(root):
|
if _contains_phone(root):
|
||||||
raise SkuEvidenceSanitizationError("派生节点树仍包含手机号,拒绝发布。")
|
raise SkuEvidenceSanitizationError("派生节点树仍包含手机号,拒绝发布。")
|
||||||
ElementTree.ElementTree(root).write(target, encoding="utf-8", xml_declaration=True)
|
ElementTree.ElementTree(root).write(target, encoding="utf-8", xml_declaration=True)
|
||||||
@@ -294,6 +322,9 @@ def _sanitize_node(parent: ElementTree.Element, node: ElementTree.Element, stats
|
|||||||
parent.remove(node)
|
parent.remove(node)
|
||||||
return
|
return
|
||||||
if position == "crossing":
|
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)
|
_clear_node_text(node)
|
||||||
stats.cleared_crossing_nodes += 1
|
stats.cleared_crossing_nodes += 1
|
||||||
@@ -340,6 +371,85 @@ def _vertical_position(bounds: tuple[int, int, int, int]) -> str:
|
|||||||
return "crossing"
|
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_one_leading_space = text[1:] if text.startswith(" ") else text
|
||||||
|
if without_one_leading_space.startswith("快要抢光"):
|
||||||
|
return "observed_prefix_kuaiyaoqiangguang"
|
||||||
|
if without_one_leading_space.startswith("快") and not without_one_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:
|
def _clear_node_text(node: ElementTree.Element) -> None:
|
||||||
node.attrib = {"bounds": node.attrib["bounds"]} if "bounds" in node.attrib else {}
|
node.attrib = {"bounds": node.attrib["bounds"]} if "bounds" in node.attrib else {}
|
||||||
node.text = None
|
node.text = None
|
||||||
@@ -415,6 +525,7 @@ def _derived_manifest(
|
|||||||
"privacy_cleanup": {
|
"privacy_cleanup": {
|
||||||
"removed_nodes": cleanup_stats.removed_nodes,
|
"removed_nodes": cleanup_stats.removed_nodes,
|
||||||
"cleared_crossing_nodes": cleanup_stats.cleared_crossing_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,
|
"retained_below_nodes": cleanup_stats.retained_below_nodes,
|
||||||
"max_right": cleanup_stats.max_right,
|
"max_right": cleanup_stats.max_right,
|
||||||
"max_bottom": cleanup_stats.max_bottom,
|
"max_bottom": cleanup_stats.max_bottom,
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ TEST_ADDRESS = "SYNTHETIC_ADDRESS_NEVER_PUBLISH"
|
|||||||
FULL_PHONE = "13800138000"
|
FULL_PHONE = "13800138000"
|
||||||
MASKED_PHONE = "138****0000"
|
MASKED_PHONE = "138****0000"
|
||||||
SAFE_TEXT = "synthetic-safe-lower-content"
|
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:
|
def _hash(path: Path) -> str:
|
||||||
@@ -48,11 +52,56 @@ def _default_xml() -> str:
|
|||||||
return (
|
return (
|
||||||
"<hierarchy rotation='0'>"
|
"<hierarchy rotation='0'>"
|
||||||
f"<node bounds='[0,0][1080,540]' text='{TEST_ADDRESS}' content-desc='{MASKED_PHONE} {FULL_PHONE}' />"
|
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}' />"
|
f"<node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' />"
|
||||||
"</hierarchy>"
|
"</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(
|
def _write_raw(
|
||||||
root: Path,
|
root: Path,
|
||||||
*,
|
*,
|
||||||
@@ -117,7 +166,7 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
|
|||||||
self.assertNotIn(MASKED_PHONE, derived_xml)
|
self.assertNotIn(MASKED_PHONE, derived_xml)
|
||||||
self.assertIn(f'"human_declared_state": "{state}"', manifest)
|
self.assertIn(f'"human_declared_state": "{state}"', manifest)
|
||||||
self.assertIn('"privacy_tier": "SANITIZED"', manifest)
|
self.assertIn('"privacy_tier": "SANITIZED"', manifest)
|
||||||
self.assertIn('"sanitizer_version": "t103-privacy-v3"', manifest)
|
self.assertIn('"sanitizer_version": "t103-privacy-v5"', manifest)
|
||||||
self.assertIn('"screenshot_space": {', manifest)
|
self.assertIn('"screenshot_space": {', manifest)
|
||||||
self.assertIn('"xml_coordinate_space": {', manifest)
|
self.assertIn('"xml_coordinate_space": {', manifest)
|
||||||
self.assertIn('"height": 2376', manifest)
|
self.assertIn('"height": 2376', manifest)
|
||||||
@@ -125,6 +174,7 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
|
|||||||
self.assertIn('"privacy_mask_rectangle": [', manifest)
|
self.assertIn('"privacy_mask_rectangle": [', manifest)
|
||||||
self.assertIn('"removed_nodes": 1', manifest)
|
self.assertIn('"removed_nodes": 1', manifest)
|
||||||
self.assertIn('"cleared_crossing_nodes": 0', 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('"retained_below_nodes": 1', manifest)
|
||||||
self.assertIn('"max_right": 1080', manifest)
|
self.assertIn('"max_right": 1080', manifest)
|
||||||
self.assertIn('"max_bottom": 2376', manifest)
|
self.assertIn('"max_bottom": 2376', manifest)
|
||||||
@@ -140,6 +190,8 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
|
|||||||
"<hierarchy>"
|
"<hierarchy>"
|
||||||
f"<node bounds='[0,0][1080,2376]' text='{TEST_ADDRESS}' content-desc='{MASKED_PHONE}'>"
|
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"<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}' />"
|
f"<node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' />"
|
||||||
"</node></hierarchy>"
|
"</node></hierarchy>"
|
||||||
)
|
)
|
||||||
@@ -152,20 +204,281 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
|
|||||||
self.assertIsNotNone(crossing)
|
self.assertIsNotNone(crossing)
|
||||||
assert crossing is not None
|
assert crossing is not None
|
||||||
self.assertEqual(crossing.attrib, {"bounds": "[0,0][1080,2376]"})
|
self.assertEqual(crossing.attrib, {"bounds": "[0,0][1080,2376]"})
|
||||||
self.assertEqual(len(list(crossing)), 1)
|
self.assertEqual(len(list(crossing)), 3)
|
||||||
self.assertEqual(list(crossing)[0].get("text"), SAFE_TEXT)
|
self.assertEqual(list(crossing)[-1].get("text"), SAFE_TEXT)
|
||||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
manifest["privacy_cleanup"],
|
manifest["privacy_cleanup"],
|
||||||
{
|
{
|
||||||
"removed_nodes": 1,
|
"removed_nodes": 1,
|
||||||
"cleared_crossing_nodes": 1,
|
"cleared_crossing_nodes": 1,
|
||||||
|
"preserved_crossing_price_nodes": 2,
|
||||||
"retained_below_nodes": 1,
|
"retained_below_nodes": 1,
|
||||||
"max_right": 1080,
|
"max_right": 1080,
|
||||||
"max_bottom": 2376,
|
"max_bottom": 2376,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_only_strict_crossing_price_leaves_are_projected_with_whitelisted_attributes(self) -> None:
|
||||||
|
xml = _xml_with_prices(
|
||||||
|
current_node=_price_node(
|
||||||
|
CURRENT_PRICE,
|
||||||
|
PRICE_CURRENT_BOUNDS,
|
||||||
|
extra_attributes=" content-desc='discard' resource-id='discard' focused='true'",
|
||||||
|
),
|
||||||
|
original_node=_price_node(
|
||||||
|
ORIGINAL_PRICE,
|
||||||
|
PRICE_ORIGINAL_BOUNDS,
|
||||||
|
extra_attributes=" content-desc='discard-too' resource-id='discard-too'",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), xml=xml)
|
||||||
|
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
root = ElementTree.parse(result.hierarchy_path).getroot()
|
||||||
|
prices = [node for node in root.findall("node") if node.get("text") in {CURRENT_PRICE, ORIGINAL_PRICE}]
|
||||||
|
|
||||||
|
self.assertEqual(len(prices), 2)
|
||||||
|
for node in prices:
|
||||||
|
self.assertEqual(
|
||||||
|
set(node.attrib),
|
||||||
|
{"bounds", "text", "package", "class", "clickable", "enabled", "visible-to-user"},
|
||||||
|
)
|
||||||
|
self.assertEqual(node.get("package"), "com.xunmeng.pinduoduo")
|
||||||
|
self.assertEqual(node.get("class"), "android.widget.TextView")
|
||||||
|
self.assertEqual(node.get("clickable"), "false")
|
||||||
|
self.assertEqual(node.get("enabled"), "true")
|
||||||
|
self.assertEqual(node.get("visible-to-user"), "true")
|
||||||
|
|
||||||
|
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||||
|
self.assertEqual(manifest["privacy_cleanup"]["preserved_crossing_price_nodes"], 2)
|
||||||
|
self.assertNotIn("discard", result.hierarchy_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def test_crossing_price_window_rejects_text_and_structure_drift(self) -> None:
|
||||||
|
bad_texts = (
|
||||||
|
f"快卖完 ¥12.88 {TEST_ADDRESS}",
|
||||||
|
f"快卖完 ¥12.88 {FULL_PHONE}",
|
||||||
|
"快卖完 ¥12.88 使用微信支付",
|
||||||
|
"快卖完 ¥12.88 提交订单",
|
||||||
|
"快卖完 ¥12.88 优惠-11元",
|
||||||
|
"快要抢光 ¥12.88",
|
||||||
|
"快卖完 ¥0.00",
|
||||||
|
"快卖完 ¥12.8",
|
||||||
|
"快卖完 ¥12.880",
|
||||||
|
"快卖完 ¥12.88",
|
||||||
|
)
|
||||||
|
for text in bad_texts:
|
||||||
|
with self.subTest(text=text), TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=text))
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
self.assertFalse((raw.parent / "derived").exists())
|
||||||
|
|
||||||
|
def test_crossing_price_text_mismatch_reports_only_fixed_slot_and_reason(self) -> None:
|
||||||
|
newline_node = _price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS).replace(
|
||||||
|
"快卖完 ¥12.88", "快卖完 ¥12.88"
|
||||||
|
)
|
||||||
|
cases = (
|
||||||
|
("newline", _xml_with_prices(current_node=newline_node), "newline", PRICE_CURRENT_BOUNDS),
|
||||||
|
(
|
||||||
|
"non-ascii-whitespace",
|
||||||
|
_xml_with_prices(current="快卖完 ¥12.88"),
|
||||||
|
"non_ascii_whitespace",
|
||||||
|
PRICE_CURRENT_BOUNDS,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"known-prefix-missing",
|
||||||
|
_xml_with_prices(current="快要抢光 ¥12.88"),
|
||||||
|
"observed_prefix_kuaiyaoqiangguang",
|
||||||
|
PRICE_CURRENT_BOUNDS,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"other-kuai-prefix-remains-generic",
|
||||||
|
_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_observed_prefix_diagnostic_does_not_echo_its_suffix(self) -> None:
|
||||||
|
suffix = "地址和金额都不得回显"
|
||||||
|
text = f"快要抢光 ¥12.88 {suffix}"
|
||||||
|
with 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.assertEqual(
|
||||||
|
message,
|
||||||
|
"跨界价格节点文本不匹配:slot=[396,503][712,570];"
|
||||||
|
"reason=observed_prefix_kuaiyaoqiangguang。",
|
||||||
|
)
|
||||||
|
self.assertNotIn(suffix, message)
|
||||||
|
self.assertNotIn("¥12.88", 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_unverified_or_missing_current_price_prefixes_fail_closed(self) -> None:
|
||||||
|
cases = (
|
||||||
|
("old-prefix", "快卖光 ¥12.88"),
|
||||||
|
("bottom-button-prefix", "快要抢光 ¥12.88"),
|
||||||
|
("missing-prefix", "¥12.88"),
|
||||||
|
)
|
||||||
|
for name, current in cases:
|
||||||
|
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=current))
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
self.assertFalse((raw.parent / "derived").exists())
|
||||||
|
|
||||||
|
def test_unique_current_price_without_original_price_is_published(self) -> None:
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), xml=_xml_with_prices(include_original=False))
|
||||||
|
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
hierarchy = result.hierarchy_path.read_text(encoding="utf-8")
|
||||||
|
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
self.assertIn(CURRENT_PRICE, hierarchy)
|
||||||
|
self.assertNotIn(ORIGINAL_PRICE, hierarchy)
|
||||||
|
self.assertEqual(manifest["privacy_cleanup"]["preserved_crossing_price_nodes"], 1)
|
||||||
|
|
||||||
|
def test_crossing_price_projection_rejects_newline_and_structure_drift(self) -> None:
|
||||||
|
encoded_newline = _price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS).replace(
|
||||||
|
"快卖完 ¥12.88", "快卖完 ¥12.88"
|
||||||
|
)
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current_node=encoded_newline))
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
self.assertFalse((raw.parent / "derived").exists())
|
||||||
|
|
||||||
|
bad_structure = (
|
||||||
|
("package", {"package": "com.android.systemui"}),
|
||||||
|
("class", {"node_class": "android.view.View"}),
|
||||||
|
("clickable", {"clickable": "true"}),
|
||||||
|
("disabled", {"enabled": "false"}),
|
||||||
|
("hidden", {"visible": "false"}),
|
||||||
|
("children", {"children": "<node bounds='[400,510][500,520]' />"}),
|
||||||
|
)
|
||||||
|
for name, kwargs in bad_structure:
|
||||||
|
with self.subTest(structure=name), TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(
|
||||||
|
Path(temporary),
|
||||||
|
xml=_xml_with_prices(current_node=_price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS, **kwargs)),
|
||||||
|
)
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
self.assertFalse((raw.parent / "derived").exists())
|
||||||
|
|
||||||
|
def test_crossing_price_candidates_require_unique_current_and_at_most_one_original(self) -> None:
|
||||||
|
scenarios = (
|
||||||
|
(
|
||||||
|
"missing-current",
|
||||||
|
_xml_with_prices(current="¥12.88", original=ORIGINAL_PRICE),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"duplicate-current",
|
||||||
|
_xml_with_prices(current=CURRENT_PRICE, original=f"快卖完 {ORIGINAL_PRICE}"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"multiple-original",
|
||||||
|
_xml_with_prices(
|
||||||
|
extra_nodes=_price_node("¥39.88", PRICE_ORIGINAL_BOUNDS),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for name, xml in scenarios:
|
||||||
|
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), xml=xml)
|
||||||
|
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||||
|
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
self.assertFalse((raw.parent / "derived").exists())
|
||||||
|
|
||||||
|
def test_crossing_price_outside_fixed_windows_is_cleared_and_submit_price_is_not_candidate(self) -> None:
|
||||||
|
outside_crossing = _price_node("快卖光 ¥99.99", "[396,498][712,570]")
|
||||||
|
submit = (
|
||||||
|
"<node bounds='[369,2225][710,2284]' text='提交订单 ¥12.88' "
|
||||||
|
"package='com.xunmeng.pinduoduo' class='android.widget.TextView' clickable='false' "
|
||||||
|
"enabled='true' visible-to-user='true' resource-id='submit-button' />"
|
||||||
|
)
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
raw = _write_raw(Path(temporary), xml=_xml_with_prices(extra_nodes=outside_crossing + submit))
|
||||||
|
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||||
|
hierarchy = result.hierarchy_path.read_text(encoding="utf-8")
|
||||||
|
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
self.assertNotIn("快卖光 ¥99.99", hierarchy)
|
||||||
|
self.assertIn("提交订单 ¥12.88", hierarchy)
|
||||||
|
self.assertIn("submit-button", hierarchy)
|
||||||
|
self.assertEqual(manifest["privacy_cleanup"]["preserved_crossing_price_nodes"], 2)
|
||||||
|
|
||||||
def test_same_raw_and_config_produce_identical_derived_files(self) -> None:
|
def test_same_raw_and_config_produce_identical_derived_files(self) -> None:
|
||||||
with TemporaryDirectory() as temporary:
|
with TemporaryDirectory() as temporary:
|
||||||
root = Path(temporary)
|
root = Path(temporary)
|
||||||
|
|||||||
+14
-24
@@ -149,33 +149,23 @@ T-103 已证明当前衣服商品没有独立「规格/已选」入口。T-110
|
|||||||
.\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
|
.\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 只能留在
|
PDD 规格面板不可避免显示收货区域和手机号。项目所有者确认 cmbuyer 是内部系统,正式流程允许把
|
||||||
`%LOCALAPPDATA%\cmbuyer\artifacts\...\raw` 隔离目录,不供 agent、fixture、业务或 HTTP 上传读取;
|
页面原始截图上传采购服务供已登录管理员查看,不做遮罩或裁剪。完整 XML 仍只留在
|
||||||
T-103 已在 `client/src/cmbuyer_client/device/sku_evidence_sanitizer.py` 实现本机确定性脱敏器,并由
|
`%LOCALAPPDATA%\cmbuyer\artifacts\...\raw`;采购工具运行时在内存中读取当前页面树,但只返回规格、
|
||||||
`client/scripts/sanitize_sku_panel_evidence.py` 提供离线 CLI。第一组新 raw 的 PNG 头部由人确认实际为
|
选中态、价格和页面状态摘要,不把地址或手机号解析为业务字段,也不写入日志、Git 或 Vikunja。
|
||||||
1080×2376,推翻了 v1 将截图和 XML 坐标空间都写成 1080×2400 的假设;v1 正确拒绝且原始证据未重采。
|
|
||||||
随后 v2 在同一份 raw 上安全报告 XML `observed 1080x2376` 并拒绝发布,证明其独立配置的
|
|
||||||
1080×2400 假设同样不成立。当前 `t103-privacy-v3` 配置精确绑定 PKG110 / Android 16 / 拼多多
|
|
||||||
8.17.0 / goods_id `937122477375`;screenshot space 与 XML coordinate space 仍分别建模、分别校验,
|
|
||||||
当前都精确为 1080×2376,且都把
|
|
||||||
`[0,0,1080,540)` 作为整宽隐私带。截图覆盖该区域;XML 递归移除区域内节点,跨界容器只清空自身
|
|
||||||
敏感属性并保留下方子节点。地址依靠已确认的整块几何隔离而不是易漏的关键词表;完整、掩码、带分隔符
|
|
||||||
或跨节点手机号残留会被自动复检拒绝。
|
|
||||||
|
|
||||||
```powershell
|
T-103 已完成 `t103-privacy-v5` 的一次真实派生:人确认 XML 无实际地址/手机号、目标颜色
|
||||||
.\client\.venv\Scripts\python.exe client\scripts\sanitize_sku_panel_evidence.py --raw-dir "<证据目录>\raw" --output-dir "<证据目录>\derived"
|
“黑色CHA(纯棉)”与尺码“M(建议100-115)”保留;安全节点显示顶部当前价“快卖完 ¥12.88”与原价
|
||||||
```
|
“¥29.88”,底部“提交订单 ¥12.88”明确属于第一趟硬拒绝区。派生截图的整宽黑色隐私带遮住了顶部
|
||||||
|
价格文字的一半,这只说明该截图不适合上传展示,不否定 XML 中已经确认的规格面板价格事实。
|
||||||
|
|
||||||
脱敏器校验源 manifest 与文件哈希、设备/App/商品/人工状态、截图分辨率和 XML 隐私结构;XML 所有
|
为加快 MVP,T-103 从此不再修改或调用截图遮罩器。现有 v5 派生 XML 只用于提取不含页面个人信息的
|
||||||
节点观察到的最大 right/bottom 必须精确为 1080×2376,否则只报告非敏感 observed 尺寸并拒绝。它只允许发布到
|
最小 fixture;正式 `TrialSkuFlow` 在本机实时页面树上按拼多多 8.17.0 与面板结构读取允许字段,完整
|
||||||
同级且尚不存在的 `derived`;失败或发布竞态不覆盖已有目录、不留下 staging。派生 manifest 记录
|
页面树不落日志、不上传。T-204 直接接入原始截图上传,资产标记 `privacy_tier=INTERNAL_RAW`,只允许
|
||||||
`privacy_tier=SANITIZED`、sanitizer 版本、两个坐标空间、清理计数及本机 source/derived 哈希,不记录
|
已认证设备写入、已登录管理员读取;不上传 XML,不允许外部支付页或支付凭据。
|
||||||
原始路径、serial 或页面正文。只有自动复检通过并经人确认状态对应性的派生物,agent 才能读取并提取
|
|
||||||
最小 fixture、编写判据。
|
|
||||||
|
|
||||||
2026-08-04 的首轮旧证据只用于确认上述入口事实;其中旧 `initial` 不是规格面板,另两张原始截图含隐私
|
这个范围调整不授权第一趟调整数量、进入确认页、点击“提交订单”或触碰支付控件。最终仍由人把两个
|
||||||
区域,因此 XML 未读取、fixture 与选择器未生成。T-110 完成受控入口与脱敏契约后,T-103 重新取证;
|
维度改成明确的非目标值,再运行受控入口、精确选择、读价和安全退出脚本完成真机验收。
|
||||||
该边界调整不授权第一趟调整数量、进入确认页、点击“提交订单”或触碰任何支付控件。
|
|
||||||
|
|
||||||
Windows 的标准入口是仓库根 `./init.ps1`。它要求 Go、两端目录及其哨兵文件存在;已有合规
|
Windows 的标准入口是仓库根 `./init.ps1`。它要求 Go、两端目录及其哨兵文件存在;已有合规
|
||||||
`client/.venv` 时,所有采购工具检查与 validator 都使用该解释器。只有 venv 不存在时,才从 `py -0p`
|
`client/.venv` 时,所有采购工具检查与 validator 都使用该解释器。只有 venv 不存在时,才从 `py -0p`
|
||||||
|
|||||||
+20
-13
@@ -150,7 +150,8 @@ T-103 的真机证据推翻了“商品详情页存在独立规格入口”的
|
|||||||
1. 打开 canonical 商品链接。
|
1. 打开 canonical 商品链接。
|
||||||
2. 点击与证据哈希、拼多多版本和页面状态绑定的精确唯一 `快要抢光` 入口。
|
2. 点击与证据哈希、拼多多版本和页面状态绑定的精确唯一 `快要抢光` 入口。
|
||||||
3. 在已确认的维度容器中精确选择规格并读回选中态。
|
3. 在已确认的维度容器中精确选择规格并读回选中态。
|
||||||
4. 从规格面板读取单价、生成脱敏派生证据、关闭面板并退出商品页。
|
4. 从规格面板读取单价、保存原始截图、关闭面板并退出商品页。T-103 真机 spike 先在本机验证;
|
||||||
|
T-204 再把原始截图接入采购服务供管理员查看。
|
||||||
|
|
||||||
第一趟 capability **不得包含**通用 `click`、数量增减、进入订单确认页、提交订单或付款能力。
|
第一趟 capability **不得包含**通用 `click`、数量增减、进入订单确认页、提交订单或付款能力。
|
||||||
“免拼购买 / 单独购买 / 直接拼成”等其他文案即使人工认为行为相同,也必须各自重新取证后才能评审;
|
“免拼购买 / 单独购买 / 直接拼成”等其他文案即使人工认为行为相同,也必须各自重新取证后才能评审;
|
||||||
@@ -194,10 +195,10 @@ V2 实现时仍遵守:**图搜的唯一产出是 goods_id**,不在搜索结
|
|||||||
| 提交订单控件唯一 | 文本精确等于「提交订单」且可点击祖先唯一,否则停 | 点到未知控件 |
|
| 提交订单控件唯一 | 文本精确等于「提交订单」且可点击祖先唯一,否则停 | 点到未知控件 |
|
||||||
| 数量必须复核 | 设置后读回确认精确等于要求值,否则停 | 买错数量 |
|
| 数量必须复核 | 设置后读回确认精确等于要求值,否则停 | 买错数量 |
|
||||||
| 价格三道闸门 | 见第三节。任一道读不到或不通过即停,**不用其他位置的数字凑合** | 超预算采购 |
|
| 价格三道闸门 | 见第三节。任一道读不到或不通过即停,**不用其他位置的数字凑合** | 超预算采购 |
|
||||||
| 第一趟不下单 | 只允许点击证据/版本绑定的精确唯一受控入口打开规格面板,当前仅为 `快要抢光`;随后只选规格、读价、脱敏取证和返回。数量、确认页、`提交订单`、付款与通用点击能力均不可达 | 无授权下单 |
|
| 第一趟不下单 | 只允许点击证据/版本绑定的精确唯一受控入口打开规格面板,当前仅为 `快要抢光`;随后只选规格、读价、截图和返回。数量、确认页、`提交订单`、付款与通用点击能力均不可达 | 无授权下单 |
|
||||||
| 外部支付页 | 检测到微信等外部支付交接立即停止、转人工、保留证据 | 凭据泄露 |
|
| 外部支付页 | 检测到微信等外部支付交接立即停止、转人工、保留证据 | 凭据泄露 |
|
||||||
| 安全校验 | 检测到验证码、风控、人脸、短信校验立即停止,不尝试绕过 | 封号 / 违规 |
|
| 安全校验 | 检测到验证码、风控、人脸、短信校验立即停止,不尝试绕过 | 封号 / 违规 |
|
||||||
| 敏感信息 | 只读非敏感摘要,不提取收货地址原文、手机号、支付凭据 | 隐私泄露 |
|
| 页面个人信息 | 内部原始截图可包含并上传页面已显示的地址、手机号,供已登录管理员查看;不得把这些内容解析成业务字段或写入普通日志、Git、Vikunja。支付凭据仍不得读取或上传 | 非必要扩散 / 凭据泄露 |
|
||||||
| 授权一次性 | 一笔授权只能产生一笔订单,重复提交幂等 | 重复采购 |
|
| 授权一次性 | 一笔授权只能产生一笔订单,重复提交幂等 | 重复采购 |
|
||||||
| 服务端提交围栏 | 真机点击前必须由采购服务原子冻结授权并创建唯一提交记录;失败或响应不明不得点击 | 并发 / 断网导致重复下单 |
|
| 服务端提交围栏 | 真机点击前必须由采购服务原子冻结授权并创建唯一提交记录;失败或响应不明不得点击 | 并发 / 断网导致重复下单 |
|
||||||
| App 版本失配即停 | 运行版本与本项目已取证版本不一致时停止领取真机任务,先重新取证 | 旧判据误点新页面 |
|
| App 版本失配即停 | 运行版本与本项目已取证版本不一致时停止领取真机任务,先重新取证 | 旧判据误点新页面 |
|
||||||
@@ -387,18 +388,23 @@ PENDING_RETRIAL ─────────────┴─claim→ CLAIMED
|
|||||||
|
|
||||||
| 数据 | 位置 | 理由 |
|
| 数据 | 位置 | 理由 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 原始商品页 / 规格页 screenshot/XML | **仅采购工具本机隔离目录** | PDD 页面不可避免包含收货区域和掩码手机号;只允许确定性脱敏器读取,不供业务、agent、fixture 或上传消费 |
|
| 原始商品页 / 规格页 screenshot | 采购工具本机 + 采购服务内部证据存储(T-204) | 内部系统允许保留页面中已显示的地址和手机号;设备鉴权后上传,只有已登录管理员可查看,不做遮罩或裁剪 |
|
||||||
| 脱敏派生商品页 / 规格页截图 | 上传采购服务 | 管理员做授权决策必须看;服务端只接收派生哈希和 sanitizer 版本,原始/派生哈希映射仅留本机 manifest |
|
| 原始商品页 / 规格页完整 XML | **仅采购工具本机隔离目录** | 自动化运行时在内存中读取规格/价格,完整 XML 不上传、不进入日志、Git、Vikunja 或 fixture |
|
||||||
|
| T-103 真机 spike 证据 | 先在采购工具本机;T-204 接入上传 | T-103 只验证选择与读价,不实现 HTTP 证据链;这是一项任务拆分,不是禁止原始截图上传 |
|
||||||
| 最小脱敏 XML fixture | 采购工具测试 / 可提交 Git | 只保留页面判据所需结构;自动复检无地址、手机号、支付凭据后才可发布 |
|
| 最小脱敏 XML fixture | 采购工具测试 / 可提交 Git | 只保留页面判据所需结构;自动复检无地址、手机号、支付凭据后才可发布 |
|
||||||
| 脱敏派生订单确认页截图 | 上传采购服务 | 授权后核对与审计必须留;原始物仍只在本机隔离目录 |
|
| 原始订单确认页截图 | 采购服务内部证据存储 | 授权后核对与审计必须留;允许页面已显示的地址/手机号,禁止外部支付凭据 |
|
||||||
| 脱敏派生订单核对截图 | 上传采购服务 | 资金核对证据;原始物仍只在本机隔离目录 |
|
| 原始订单核对截图 | 采购服务内部证据存储 | 资金核对证据;只有已认证设备上传、已登录管理员查看 |
|
||||||
| AI 调用记录(P1) | **仅采购工具本地** | 含 prompt / 响应全文,脱敏成本高 |
|
| AI 调用记录(P1) | **仅采购工具本地** | 含 prompt / 响应全文,脱敏成本高 |
|
||||||
| 失败现场快照 | 原始物仅采购工具本机;只能手工导出脱敏派生物 | 同上 |
|
| 失败现场快照 | 原始物仅采购工具本机;只能手工导出脱敏派生物 | 同上 |
|
||||||
|
|
||||||
原始目录不得被 HTTP sink、Vikunja 导出、日志或 fixture 构建器读取。脱敏器必须先验证设备分辨率、
|
截图上传器只能接收调用方显式传入的截图文件,不能枚举整个原始目录,也不能上传 XML、manifest 中的
|
||||||
页面/App 版本和预期隐私区域,再同时生成派生 screenshot/XML;派生 XML 仍命中手机号模式、截图隐私
|
本机路径或其他文件。采购工具可以在真机流程内存中读取当前页面树,但只返回规格、选中态、价格和页面
|
||||||
区域无法确定、sanitizer 异常或任一哈希不一致时,不发布派生目录。上传端只接受 manifest 明确标记
|
状态摘要;不得把地址或手机号解析成结构化业务字段。原始 XML 不进入 HTTP、日志、Vikunja、Git 或
|
||||||
`privacy_tier=SANITIZED` 的派生截图,**不上传含收货地址、手机号、支付凭据的区域或文本。**
|
fixture;仓库只允许进入与页面判据有关的最小 XML fixture。
|
||||||
|
|
||||||
|
T-204 负责原始截图上传、SHA-256 校验、内部访问控制和保留策略,不再实现遮罩或裁剪。证据响应使用
|
||||||
|
`Cache-Control: no-store`,不得暴露为无需登录的静态目录。外部支付页及任何支付凭据不属于“内部原图
|
||||||
|
可上传”的范围,遇到支付交接仍立即停止。
|
||||||
|
|
||||||
## 六、关键技术难点
|
## 六、关键技术难点
|
||||||
|
|
||||||
@@ -420,7 +426,7 @@ PENDING_RETRIAL ─────────────┴─claim→ CLAIMED
|
|||||||
|
|
||||||
1. **Phase 0 地基**:两端骨架、测试命令、`init` 脚本可运行。
|
1. **Phase 0 地基**:两端骨架、测试命令、`init` 脚本可运行。
|
||||||
2. **Phase 1 真机取证**:WiFi ADB 连通;先验证第一趟的打开商品 → 受控打开规格面板 → 按维度
|
2. **Phase 1 真机取证**:WiFi ADB 连通;先验证第一趟的打开商品 → 受控打开规格面板 → 按维度
|
||||||
精确勾选颜色分类和尺码 → **读到该 SKU 单价** → 脱敏取证 → 退出;再以独立的第二趟 spike
|
精确勾选颜色分类和尺码 → **读到该 SKU 单价** → 原始截图取证 → 退出;再以独立的第二趟 spike
|
||||||
验证设数量 → 进订单确认页 → 读「实付款」。第一趟 capability 不含后三项。
|
验证设数量 → 进订单确认页 → 读「实付款」。第一趟 capability 不含后三项。
|
||||||
**结论写入文档,判据带拼多多 App 版本。**
|
**结论写入文档,判据带拼多多 App 版本。**
|
||||||
3. **Phase 2 采购服务核心**:数据模型与状态机、手工建单、任务查询、试选结果接收、
|
3. **Phase 2 采购服务核心**:数据模型与状态机、手工建单、任务查询、试选结果接收、
|
||||||
@@ -435,7 +441,8 @@ PENDING_RETRIAL ─────────────┴─claim→ CLAIMED
|
|||||||
|
|
||||||
> 2026-08-04:Phase 1 证明已验证衣服商品没有独立规格入口。项目所有者随后批准 T-110 的受控入口
|
> 2026-08-04:Phase 1 证明已验证衣服商品没有独立规格入口。项目所有者随后批准 T-110 的受控入口
|
||||||
> 方案:当前只允许证据绑定的精确唯一 `快要抢光` 打开规格面板,并以 capability 隔离保证数量、确认页、
|
> 方案:当前只允许证据绑定的精确唯一 `快要抢光` 打开规格面板,并以 capability 隔离保证数量、确认页、
|
||||||
> 提交订单和付款在第一趟不可达。T-103 仍须先实现自动脱敏与新证据门禁,完成前 Phase 2 保持冻结。
|
> 提交订单和付款在第一趟不可达。项目所有者随后决定加速 MVP:停止截图遮罩器开发,T-103 使用现有
|
||||||
|
> 安全最小 XML 继续实现选择与读价;T-204 直接把内部原始截图上传采购服务,不遮罩地址或手机号。
|
||||||
|
|
||||||
## 八、项目结构
|
## 八、项目结构
|
||||||
|
|
||||||
|
|||||||
@@ -54,12 +54,13 @@
|
|||||||
|
|
||||||
- 检测到验证码、风控、人脸、短信校验时立即停止,**不尝试绕过**。
|
- 检测到验证码、风控、人脸、短信校验时立即停止,**不尝试绕过**。
|
||||||
- 检测到外部支付交接立即停止,**不读取、不保存、不输入任何凭据**。
|
- 检测到外部支付交接立即停止,**不读取、不保存、不输入任何凭据**。
|
||||||
- 只读非敏感摘要,**不提取收货地址原文、手机号、支付凭据**。
|
- 页面已显示的地址和手机号允许随**内部原始截图**上传采购服务,供已登录管理员核对;不做遮罩或裁剪。
|
||||||
- 上传服务端的证据必须先脱敏。
|
- 地址和手机号不得被解析为结构化业务字段,也不得写入普通日志、Git、Vikunja、fixture 或错误消息。
|
||||||
- PDD 页面不可避免显示地址/掩码手机号时,原始 screenshot/XML 只允许写入采购工具本机隔离目录,
|
原始完整 XML 只留采购工具本机;真机流程可在内存中读取,但只能返回规格、选中态、价格和页面状态摘要。
|
||||||
只由确定性脱敏器消费;业务代码、agent、fixture、日志和 HTTP sink 只能读取自动复检通过且 manifest
|
- 截图上传器只能接收显式文件,不得枚举原始目录或顺带上传 XML/manifest/其他文件;服务端必须校验
|
||||||
标记 `privacy_tier=SANITIZED` 的派生物。脱敏失败、分辨率/版本不符或派生 XML 仍命中手机号模式时
|
设备身份和 SHA-256,证据只允许已登录管理员访问,不得提供公开静态目录。
|
||||||
必须拒绝发布,不得用人工口头确认绕过。
|
- **支付凭据仍不得读取、保存或上传。** 外部支付交接不在“内部原图允许上传”的例外范围内。
|
||||||
|
- T-103 停止遮罩器开发并专注真机选择/读价;生产原始截图上传由 T-204 实现。
|
||||||
|
|
||||||
### 1.5 页面判据
|
### 1.5 页面判据
|
||||||
|
|
||||||
|
|||||||
+18
-15
@@ -168,26 +168,29 @@
|
|||||||
- 无可领任务返回 `200` 且 `task` 为 `null`,**不是 404**。
|
- 无可领任务返回 `200` 且 `task` 为 `null`,**不是 404**。
|
||||||
- 后续所有该任务的调用必须携带 `X-Claim-Token` 与匹配的 `claim_generation`。
|
- 后续所有该任务的调用必须携带 `X-Claim-Token` 与匹配的 `claim_generation`。
|
||||||
|
|
||||||
### `POST /api/v1/tasks/{id}/evidence`(只接收脱敏派生物)
|
### `POST /api/v1/tasks/{id}/evidence`(内部原始截图)
|
||||||
|
|
||||||
规格面板和订单确认页可能固定展示地址与掩码手机号。采购工具必须先在本机隔离目录保存原始证据,
|
> 本接口属于生产第一趟证据链,由 T-204 实现。T-103 只做本机真机可行性验证,不调用本接口,
|
||||||
再由确定性脱敏器生成派生 screenshot/XML;本接口只接受派生截图及如下审计元数据:
|
> 也不为上传目的继续扩展截图遮罩器。正式流程直接上传原始截图;T-103 与 T-204 的区分只是任务拆分。
|
||||||
|
|
||||||
|
这是内部系统。规格面板和订单确认页固定展示的地址与手机号允许保留在原始截图中。本接口只接受截图
|
||||||
|
文件及如下审计元数据;完整 XML、manifest、本机路径和目录中的其他文件一律不接收:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"kind": "SKU_PANEL_SCREENSHOT",
|
"kind": "SKU_PANEL_SCREENSHOT",
|
||||||
"privacy_tier": "SANITIZED",
|
"privacy_tier": "INTERNAL_RAW",
|
||||||
"artifact_sha256": "<派生截图 SHA-256>",
|
"artifact_sha256": "<原始截图 SHA-256>",
|
||||||
"sanitizer_version": "sku-panel-pdd-8.17.0-v1"
|
"pdd_version": "8.17.0"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `privacy_tier` 必须精确为 `SANITIZED`;缺失、其他值或 sanitizer 元数据不完整均拒绝。
|
- `privacy_tier` 必须精确为 `INTERNAL_RAW`,明确告知服务端该资产可能包含页面个人信息。
|
||||||
- 上传内容的 SHA-256 必须等于 `artifact_sha256`。服务端不接收原始文件、原始哈希、原始路径、
|
- 上传截图的 SHA-256 必须等于 `artifact_sha256`。服务端不接收截图以外的原始文件、完整 XML、
|
||||||
原始 XML、地址、手机号或支付凭据;原始/派生哈希映射只存在于采购工具本机 manifest。
|
本机路径、源 manifest 或地址/手机号的结构化字段。支付凭据和外部支付页截图始终拒绝。
|
||||||
- 原始目录不得被 `HttpResultSink` 或证据上传器枚举;调用方必须显式传入已原子发布的派生目录。
|
- 原始目录不得被 `HttpResultSink` 或证据上传器枚举;调用方必须显式传入单个截图文件。
|
||||||
- 完整 XML 永不上传。经自动复检的最小脱敏 XML 只用于采购工具离线 fixture;仍命中手机号模式或
|
- 完整 XML 永不上传。最小 XML 只用于采购工具离线 fixture;fixture 不得包含地址、手机号或支付凭据。
|
||||||
脱敏结果不确定时,客户端调用 `/needs-manual`,不得上传截图或继续试选。
|
- 资产不得放在公开静态目录;读取要求管理会话,响应使用 `Cache-Control: no-store`。
|
||||||
|
|
||||||
### `POST /api/v1/tasks/{id}/spec-trial`(第一趟回传)
|
### `POST /api/v1/tasks/{id}/spec-trial`(第一趟回传)
|
||||||
|
|
||||||
@@ -208,7 +211,7 @@
|
|||||||
- `unit_price` 来自闸门一(规格面板)。**读不到时不要发这个接口**,改发
|
- `unit_price` 来自闸门一(规格面板)。**读不到时不要发这个接口**,改发
|
||||||
`/needs-manual` 并带原因码 `UNIT_PRICE_UNREADABLE`。
|
`/needs-manual` 并带原因码 `UNIT_PRICE_UNREADABLE`。
|
||||||
- `total_price` = `unit_price` × 任务数量,服务端会重算校验。
|
- `total_price` = `unit_price` × 任务数量,服务端会重算校验。
|
||||||
- 证据须先经 `/evidence` 上传,且对应资产必须为 `privacy_tier=SANITIZED`。
|
- 原始截图须先经 `/evidence` 上传,且对应资产必须为 `privacy_tier=INTERNAL_RAW`。
|
||||||
- 服务端接收后创建 `spec_trials` 记录,任务转 `WAITING_CONFIRMATION`。
|
- 服务端接收后创建 `spec_trials` 记录,任务转 `WAITING_CONFIRMATION`。
|
||||||
|
|
||||||
### dry-run 与真实提交协议
|
### dry-run 与真实提交协议
|
||||||
@@ -310,8 +313,8 @@ class ResultSink(ABC):
|
|||||||
| `open_product(url)` | TRIAL / ORDER | 商品 URL | 页面快照路径 | 只打开页面,不点击控件 |
|
| `open_product(url)` | TRIAL / ORDER | 商品 URL | 页面快照路径 | 只打开页面,不点击控件 |
|
||||||
| `open_trial_sku_panel(evidence_key)` | **仅 TRIAL** | 版本与证据绑定键 | 面板快照 | 只点击精确唯一、已取证的受控入口;当前仅 `快要抢光`,无通用 click |
|
| `open_trial_sku_panel(evidence_key)` | **仅 TRIAL** | 版本与证据绑定键 | 面板快照 | 只点击精确唯一、已取证的受控入口;当前仅 `快要抢光`,无通用 click |
|
||||||
| `select_sku_options(items)` | TRIAL / ORDER | `{维度: 值}` | 选中证据 | 按维度精确匹配,找不到抛错 |
|
| `select_sku_options(items)` | TRIAL / ORDER | `{维度: 值}` | 选中证据 | 按维度精确匹配,找不到抛错 |
|
||||||
| `sanitize_evidence(raw_manifest)` | TRIAL / ORDER | 本机隔离目录 manifest | 派生 manifest | 原子发布脱敏派生物;失败不发布,原始内容不进入日志/上传 |
|
| `capture_evidence(screenshot)` | TRIAL / ORDER | 单个原始截图路径 | 截图 SHA-256 | 只处理显式截图文件;不得枚举目录或读取/上传 XML,T-204 接入内部证据 API |
|
||||||
| `read_sku_unit_price(xml)` | TRIAL / ORDER | 脱敏规格面板 XML | 单价或 `None` | **闸门一 / 二**;读不到返回 `None`,不猜 |
|
| `read_sku_unit_price(xml)` | TRIAL / ORDER | 当前规格面板 XML 或安全最小 fixture | 单价或 `None` | **闸门一 / 二**;只返回金额摘要,读不到返回 `None`,不猜、不返回页面其他正文 |
|
||||||
| `leave_product()` | TRIAL / ORDER | - | - | 第一趟结束时退出并释放手机 |
|
| `leave_product()` | TRIAL / ORDER | - | - | 第一趟结束时退出并释放手机 |
|
||||||
| `set_quantity(n)` | **仅 ORDER** | 数量 | 读回值 | 必须复核等于 n;TRIAL capability 不暴露 |
|
| `set_quantity(n)` | **仅 ORDER** | 数量 | 读回值 | 必须复核等于 n;TRIAL capability 不暴露 |
|
||||||
| `go_to_order_confirm()` | **仅 ORDER** | - | 确认页摘要 | 需显式授权;TRIAL capability 不暴露 |
|
| `go_to_order_confirm()` | **仅 ORDER** | - | 确认页摘要 | 需显式授权;TRIAL capability 不暴露 |
|
||||||
|
|||||||
+21
-34
@@ -21,7 +21,7 @@
|
|||||||
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
|
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
|
||||||
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板状态的只读取证 CLI,
|
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板状态的只读取证 CLI,
|
||||||
以及绑定 PKG110 / Android 16 / 拼多多 8.17.0 的规格证据确定性脱敏 CLI;尚无规格选择、价格读取或下单流程
|
以及绑定 PKG110 / Android 16 / 拼多多 8.17.0 的规格证据确定性脱敏 CLI;尚无规格选择、价格读取或下单流程
|
||||||
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 69 项离线单元测试
|
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 80 项离线单元测试
|
||||||
(全部 mock,不连接真机)
|
(全部 mock,不连接真机)
|
||||||
- 数据:SQLite 核心表与迁移已落成;无业务实例数据
|
- 数据:SQLite 核心表与迁移已落成;无业务实例数据
|
||||||
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
|
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
|
||||||
@@ -31,11 +31,12 @@
|
|||||||
editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。
|
editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。
|
||||||
- 当前设备门禁:人工已确认拼多多 8.17.0、goods_id `937122477375` 的衣服商品只能通过“快要抢光”
|
- 当前设备门禁:人工已确认拼多多 8.17.0、goods_id `937122477375` 的衣服商品只能通过“快要抢光”
|
||||||
打开规格面板;T-110 已获项目所有者批准,只把该证据/版本绑定的精确唯一入口作为第一趟可逆导航,
|
打开规格面板;T-110 已获项目所有者批准,只把该证据/版本绑定的精确唯一入口作为第一趟可逆导航,
|
||||||
数量、确认页、提交订单、付款与通用点击能力仍不可达。T-103 的原始证据本机隔离与确定性脱敏器
|
数量、确认页、提交订单、付款与通用点击能力仍不可达。T-103 的 v5 派生 XML 已由人确认无实际地址/
|
||||||
已完成离线实现;人工确认面板刚打开时目标颜色和尺码已经自动选中,取证三态已据此修正。下一步
|
手机号,并保留目标预选规格、顶部当前价“快卖完 ¥12.88”和原价“¥29.88”;底部“提交订单 ¥12.88”
|
||||||
用 v3 对已采第一态 raw 重新脱敏;截图与 XML 已分别安全观测为 1080×2376,仍作为两个独立坐标
|
继续属于硬拒绝区。派生截图虽然把顶部价格遮住一半,但项目已停止遮罩器开发,视觉完整性不再阻塞
|
||||||
空间严格校验。第一态 derived 经人验收后,再采集“两个维度改为非目标 / 两个维度恢复目标”的 raw,
|
规格选择与读价;T-204 将直接上传内部原始截图供管理员查看。T-010 已允许不依赖真机字段的 T-201 和只创建
|
||||||
并由人只核对 derived。在派生证据验收前不写页面判据,Phase 2 仍不能抢跑。
|
`DRAFT` 的 T-202 并行;
|
||||||
|
T-203 及后续会启动试选或依赖真机字段的 Phase 2 功能继续等待 T-103。
|
||||||
|
|
||||||
## 当前目录要点
|
## 当前目录要点
|
||||||
|
|
||||||
@@ -59,9 +60,12 @@
|
|||||||
- 已完成:T-002(采购工具 Python 骨架)、T-003(双端统一初始化与验证入口)、
|
- 已完成:T-002(采购工具 Python 骨架)、T-003(双端统一初始化与验证入口)、
|
||||||
T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)、T-102(canonical
|
T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)、T-102(canonical
|
||||||
链接打开与目标商品/隐私人工验收)。
|
链接打开与目标商品/隐私人工验收)。
|
||||||
|
- 已完成 T-010(安全并行门禁);T-201(管理员登录与会话)已在独立写路径并行开发。T-202 可在
|
||||||
|
T-201 完成后继续,但只能创建和展示 `DRAFT`,不得启动试选或引入未经 T-103 证实的真机字段。
|
||||||
- 已完成 T-110(第一趟受控规格入口与隐私脱敏边界)。T-103 是当前最高优先级和 MVP 生死线,
|
- 已完成 T-110(第一趟受控规格入口与隐私脱敏边界)。T-103 是当前最高优先级和 MVP 生死线,
|
||||||
已补充 T-110 依赖并恢复 `DOING`;脱敏器已完成,三态派生证据和新真机验收完成前不开发依赖真机可读字段的
|
已补充 T-110 依赖并保持 `DOING`;现有安全派生 XML 足以开始最小 fixture、规格选择与读价实现,
|
||||||
Phase 2 生产页面。
|
内部原始截图上传与展示交给 T-204;T-103 最终真机验收完成前不开发
|
||||||
|
T-203 及后续依赖真机可读字段或会启动试选的 Phase 2 生产页面。
|
||||||
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
|
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
|
||||||
可读字段时必须先回修原型与交互清单。
|
可读字段时必须先回修原型与交互清单。
|
||||||
|
|
||||||
@@ -141,33 +145,16 @@ D:\Portable\adb\adb.exe devices -l
|
|||||||
不得提交 Git。T-102 已由人确认 goods_id `958756616606` 的目标商品及截图/XML 隐私,并完成验收。
|
不得提交 Git。T-102 已由人确认 goods_id `958756616606` 的目标商品及截图/XML 隐私,并完成验收。
|
||||||
|
|
||||||
T-103 已确认当前衣服商品只能从精确文案“快要抢光”进入规格面板;T-110 只批准该证据/版本绑定入口,
|
T-103 已确认当前衣服商品只能从精确文案“快要抢光”进入规格面板;T-110 只批准该证据/版本绑定入口,
|
||||||
不授权“免拼购买 / 单独购买 / 直接拼成”等其他文案。现有只读取证脚本本身仍不打开面板、不点击或选择
|
不授权“免拼购买 / 单独购买 / 直接拼成”等其他文案。面板刚打开时目标颜色“黑色CHA(纯棉)”与尺码
|
||||||
规格,也不读取价格;`--state` 只是人工声明,不能作为自动判据。面板刚打开时目标颜色
|
“M(建议100-115)”已经自动选中。v5 派生 XML 与人工观察已证明顶部当前价为“快卖完 ¥12.88”、
|
||||||
“黑色CHA(纯棉)”与尺码“M(建议100-115)”已经自动选中,不强行取消。人依次准备刚打开目标预选、
|
并列原价为“¥29.88”;促销前缀会变化,正式读价不能把该文案作为主判据,必须以面板结构、价格角色、
|
||||||
颜色和尺码均为非目标值、再恢复目标值三个状态,并分别采集到全新 `raw` 目录:
|
严格金额格式和唯一性组合判断,冲突时返回 unreadable。
|
||||||
|
|
||||||
```powershell
|
本轮不再运行或修改截图遮罩器。T-103 先用本机证据验证真机能力;T-204 再把原始规格页截图上传采购
|
||||||
# 仓库根目录;三次采集使用三个全新的 output-dir
|
服务,允许页面中已显示的地址和手机号,完整 XML 不上传。代码从已确认的派生 XML 提取最小 fixture,
|
||||||
.\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
|
并在真机运行时只返回获准摘要。下一步实现 `TrialSkuFlow` 的受控入口、
|
||||||
.\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
|
按维度精确选择、选中态读回、SKU 单价读取与安全退出;完成离线主审后,由人先把两个维度改成明确
|
||||||
.\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
|
记录的非目标值,再执行真机脚本确认恢复目标规格与 `12.88` 单价。生产原始截图展示/上传由 T-204 处理。
|
||||||
```
|
|
||||||
|
|
||||||
每组 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"
|
|
||||||
```
|
|
||||||
|
|
||||||
原始 screenshot/XML 可能包含 PDD 固定展示的地址和掩码手机号,只能留在本机 `raw` 目录,不能由
|
|
||||||
agent、fixture、业务或上传端消费。`t103-privacy-v3` 把截图与 XML 保持为两个独立坐标空间,
|
|
||||||
并根据同一第一态 raw 的两次 fail-closed 安全观测将二者分别精确固定为 1080×2376;它校验源哈希、
|
|
||||||
设备/App/商品/人工状态与隐私结构,在 sibling
|
|
||||||
`derived` 目录原子发布 screenshot/XML 与 manifest;任何不匹配、手机号残留或已有目标均拒绝发布。
|
|
||||||
自动复检通过且人确认三态对应性后,agent 才能读取派生物并提取最小 fixture、编写判据。
|
|
||||||
人工确认中还必须记录中间态实际选择的非目标颜色和尺码,不能只写“已切换”。
|
|
||||||
|
|
||||||
## 关键背景
|
## 关键背景
|
||||||
|
|
||||||
|
|||||||
+63
-31
@@ -25,38 +25,39 @@ write_paths:
|
|||||||
- docs/current-state.md
|
- docs/current-state.md
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-04T06:06:05Z sha256=9de2a35beba4cca7a29cf4dc6b5ff643d829f6467ff576da976589a8d1f3dcad -->
|
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-04T07:46:08Z sha256=48f9e0a02fd330550ae8d749b142a05cb9d92b16c8ee795c6289ccaedde6bca9 -->
|
||||||
## 问题 / 背景
|
## 问题 / 背景
|
||||||
|
|
||||||
T-102 已证明 canonical 链接可进入目标商品。T-103 随后在 PKG110 / Android 16 / 拼多多 8.17.0、衣服商品 goods_id `937122477375` 上确认:规格面板只能从详情页右下角精确文案“快要抢光”进入,面板固定显示收货区域和掩码手机号。T-110 经项目所有者批准,将该已取证点击定义为可逆、能力受限的规格面板导航;这不是通用购买入口豁免,不授权其他文案、数量、确认页、提交订单或支付。 项目所有者随后确认,面板刚打开时已自动选中目标颜色“黑色CHA(纯棉)”和尺码“M(建议100-115)”;因此本任务不再假设存在“未选择”或“只选择一个维度”的初始状态。
|
T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:规格面板只能由详情页精确唯一的“快要抢光”打开;T-110 已把该入口批准为可逆、能力受限的第一趟导航。面板刚打开时目标颜色“黑色CHA(纯棉)”和尺码“M(建议100-115)”自动选中。
|
||||||
|
|
||||||
|
真实 `t103-privacy-v5` 派生曾成功用于安全分析:顶部价格节点为“快卖完 ¥12.88”,并列原价为“¥29.88”;派生截图的整宽遮罩把顶部价格文字遮住一半。项目所有者确认 cmbuyer 是内部系统,要求停止遮罩返工:正式流程可以把包含页面地址/手机号的原始截图上传采购服务供已登录管理员查看。完整 XML 不上传,地址/手机号不解析为业务字段,支付凭据仍禁止处理。
|
||||||
|
|
||||||
## 关联需求与交互
|
## 关联需求与交互
|
||||||
|
|
||||||
- 功能:F-006 第一趟试选;覆盖自动脱敏、`open_trial_sku_panel()`、`select_sku_options()`、`read_sku_unit_price()` 的真机 spike。
|
- 功能:F-006 第一趟试选;覆盖 `open_trial_sku_panel()`、`select_sku_options()`、`read_sku_unit_price()` 和安全退出的真机 spike。
|
||||||
- 用户故事:US-003、US-004、US-008;本任务无生产 GUI。
|
- 用户故事:US-003、US-004、US-008;本任务无生产 GUI。T-103 先验证本机真机能力,T-204 接入原始截图 HTTP 上传与管理员查看。
|
||||||
- 架构 / API:`docs/04-architecture.md` 第三、四、5.4、六节;`docs/api.md` 证据上传和 `TrialSkuFlow`;T-104、T-105 与 Phase 2 的前置。
|
- 架构 / 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 均不得读取/上传原始内容。
|
1. 停止修改或调用截图遮罩器。T-103 的截图先保存在 `%LOCALAPPDATA%\cmbuyer\artifacts\T-103\...` 完成本机验收;T-204 正式接入原始截图上传,资产标记 `privacy_tier=INTERNAL_RAW`,允许包含页面已显示的地址和手机号。
|
||||||
2. 脱敏器绑定 PKG110 分辨率、拼多多 8.17.0、goods_id、人工声明状态和本项目证据配置,同时从 screenshot/XML 移除收货区域、地址与手机号内容,在 sibling `derived` 目录原子发布派生物。派生 XML 仍命中手机号模式、隐私区域无法确认、结构/分辨率/版本失配、哈希不一致或目标目录存在时 fail closed,不发布半成品。服务端只接收派生截图哈希和 sanitizer 版本,不接收原始文件/哈希/路径/XML。
|
2. 完整 XML 只留采购工具本机,不上传、不进日志、Vikunja 或 Git。以已确认的 v5 派生 XML 提取只含页面判据节点的最小 fixture;fixture 不含地址、手机号或支付凭据。
|
||||||
3. 由人运行脱敏器并本地确认派生 screenshot/XML 的三种真实状态:panel-opened-target-preselected(刚打开且目标规格已自动选中)、alternate-all-dimensions-selected(人工把颜色和尺码都改成非目标值)、target-selection-restored(再把两个维度恢复为目标值)。主 agent 只在派生 manifest、每态实际选中值与人工确认齐全后读取派生证据,提取最小 fixture。首轮旧证据不得复用为 fixture。
|
3. 实现 `TrialSkuFlow` 窄 capability。`open_trial_sku_panel()` 只允许拼多多 8.17.0、goods_id `937122477375` 上证据绑定、精确唯一的 `快要抢光`;缺失、重复、版本失配或面板判据不唯一时零后续点击。其他购买文案不得复用。
|
||||||
4. 实现 `TrialSkuFlow` 窄 capability。`open_trial_sku_panel()` 只允许拼多多 8.17.0、goods_id `937122477375` 上证据绑定、精确唯一的 `快要抢光`;缺失、重复、版本失配或打开后面板判据不唯一时零后续点击。`免拼购买 / 单独购买 / 直接拼成` 等其他文案必须分别取证,不能包含、前缀、同义、OCR 或坐标兜底。
|
4. 第一趟只含打开商品、受控打开面板、在维度容器内精确选择、读回选中态、读取 SKU 单价、原始截图和退出。不得暴露通用 `click`、数量、确认页、提交订单或支付能力;“提交订单”、微信支付、先用后付和 0 元下单只作为硬拒绝判据。
|
||||||
5. 第一趟 capability 只含打开商品、受控打开面板、在维度容器内精确选择、读面板单价、脱敏取证和返回。接口不得暴露通用 `click`、`set_quantity()`、`go_to_order_confirm()`、`submit_order()` 或支付能力;规格面板中的“提交订单”、微信支付、先用后付和 0 元下单只作为硬拒绝判据,静态调用链测试证明不可达。
|
5. 规格选项按维度容器隔离后文本精确唯一匹配,防 `红`/`粉红`、`1`/`10` 前缀碰撞;缺失、重复、禁用、维度不明或选中态不唯一均停止。
|
||||||
6. 在脱敏派生 XML 上按维度容器隔离候选,文本等值匹配并读回选中态;缺失、重复、禁用、维度不明或选中态不唯一均停止。纯函数测试覆盖 `红`/`粉红`、`1`/`10` 等前缀碰撞。
|
6. 单价只从已取证规格面板结构中的当前价角色读取,严格解析人民币两位小数字符串。促销前缀会变化,不能把“快卖完”作为主判据;必须限定 PDD package、面板结构/相对位置、非点击价格叶节点、唯一当前价,并排除原价及“提交订单/支付/优惠/预估”等语义。任一歧义返回 unreadable,不从详情页或底部提交区补值。
|
||||||
7. 单价只从规格面板证据确定的唯一节点读取,以十进制字符串返回,禁止浮点。记录真实文本是否含优惠前缀、货币符号是否拆节点、是否并列原价/区间价;语义不唯一、节点缺失、候选冲突或只能从详情页读取时返回 unreadable。
|
7. 真机运行时在内存中读取当前页面树,只返回规格、选中态、价格和页面状态摘要;地址/手机号不解析为结构化字段。原始截图可由 T-204 显式上传,但截图上传器不得枚举目录或顺带上传 XML/manifest。
|
||||||
8. 人最终在真机先把两个维度改成一组明确记录的非目标值,再执行受控入口、精确选择和读价脚本恢复目标规格,确认实际选择颜色分类=黑色 CHA(纯棉)、尺码=M(建议100-115)(以派生 XML 精确文本为准)、面板单价语义、派生证据隐私与退出行为;agent 保持 DOING 直到人工验收。
|
8. 离线主审通过后,由人先把颜色和尺码改成明确记录的非目标值,再运行脚本恢复目标规格、读取 `12.88`、保存原始截图并安全退出。`needs_device=true`,agent 保持 DOING,等待人工验收。
|
||||||
|
|
||||||
## 验收要点
|
## 验收要点
|
||||||
|
|
||||||
- 三组原始证据分别对应 panel-opened-target-preselected、alternate-all-dimensions-selected、target-selection-restored,且仅在本机 raw 目录;脱敏器离线测试覆盖地址/手机号移除、截图与 XML 同步处理、状态/版本/分辨率/结构失配、手机号残留、原子性、异常脱敏和已有目录不覆盖。
|
- 最小 fixture 只来自本项目拼多多 8.17.0、goods_id `937122477375` 的已验收 v5 派生 XML,不含地址、手机号或支付凭据。
|
||||||
- 派生 manifest 记录 source/derived 本机哈希、sanitizer 版本、设备/App/goods_id/人工状态;原始页面正文、serial、地址和手机号不进入 manifest、日志、错误或上传。
|
- `快要抢光` 精确唯一时才打开面板;其他入口、版本失配、面板不唯一、规格碰撞/禁用、选中态或价格不唯一均 fail closed。
|
||||||
- 只从人工验收通过的派生证据编写页面判据与最小 fixture;判据绑定拼多多 8.17.0 和证据哈希。
|
- 价格读取不依赖可变促销文案,能区分当前价 `12.88`、原价 `29.88` 和硬拒绝区“提交订单 ¥12.88”;金额只用十进制字符串。
|
||||||
- `快要抢光` 精确唯一时才打开面板;其他文案、入口缺失/重复、面板不唯一、规格缺失/碰撞/禁用、价格不唯一均 fail closed。
|
- 静态 API/导入测试证明第一趟无通用点击、数量、确认页、提交订单或支付能力。
|
||||||
- 静态 API/导入测试证明 T-103 第一趟无通用点击、数量、确认页、提交订单或支付能力,“提交订单”及资金控件不可达。
|
- T-103 不再开发遮罩;T-204 原始截图上传必须限制为显式截图文件、设备鉴权、SHA-256 校验和管理员会话读取,不上传完整 XML 或支付凭据。
|
||||||
- 从规格面板可靠读取选中 SKU 单价为十进制字符串;无法唯一解释优惠价/原价/区间价时返回 unreadable。
|
|
||||||
- client 全部单测、compileall、wheel metadata、上下文校验、完整 `init.ps1` 与 diff-check 通过。
|
- client 全部单测、compileall、wheel metadata、上下文校验、完整 `init.ps1` 与 diff-check 通过。
|
||||||
- `needs_device=true`:只有人完成受控入口、精确选择、单价读取、派生证据对应性/隐私和安全退出验收后才能 DONE。
|
- 只有人完成受控入口、精确规格选择、单价读取、页面对应性、原始截图与安全退出验收后才能 DONE。
|
||||||
|
|
||||||
## 执行记录
|
## 执行记录
|
||||||
|
|
||||||
@@ -131,14 +132,45 @@ T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space
|
|||||||
### 2026-08-04T06:05:53Z · ila
|
### 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-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。
|
||||||
|
|
||||||
|
### 2026-08-04T07:17:10Z · ila
|
||||||
|
|
||||||
|
2026-08-04 项目所有者重跑安全诊断,固定当前价槽 [396,503][712,570] 返回 reason=known_prefix_missing,证明 raw 文本以“快”开头但不是 v4 假设的精确“快卖光”;该原因不足以证明具体前缀,成功白名单未放宽。提交 6e001b2 仅细分一个已人工观察过的安全枚举:若该槽精确以“快要抢光”开头则返回 observed_prefix_kuaiyaoqiangguang,其余“快”开头仍为 generic;不回显后缀、金额、长度、码点或 raw 属性。主 agent 独立复跑 79 项 client 单测、compileall 与 diff-check 通过。等待项目所有者用同一 raw 再次重跑;T-103 保持 DOING。
|
||||||
|
|
||||||
|
### 2026-08-04T07:19:42Z · ila
|
||||||
|
|
||||||
|
2026-08-04 项目所有者在手机 live 规格面板人工确认:固定顶部当前价槽的完整非敏感文字为“快卖完 ¥12.88”;底部购买按钮“快要抢光”是另一控件。主 agent 未读取 raw。该事实解释了 v4 对未经验证“快卖光”假设的两次安全拒绝。下一步升级 v5:删除“快卖光”成功假设,只允许证据绑定的精确“快卖完”当前价;坐标、遮罩、结构、PDD package、非点击叶节点、唯一性与原价限制全部不变。T-103 保持 DOING。
|
||||||
|
|
||||||
|
### 2026-08-04T07:42:53Z · ila
|
||||||
|
|
||||||
|
2026-08-04 范围调整:`t103-privacy-v5` 已在真实第一态成功生成,人工确认派生 XML 无实际地址/手机号、目标规格保留;顶部当前价为“快卖完 ¥12.88”、原价为“¥29.88”,底部“提交订单 ¥12.88”继续属于第一趟硬拒绝区。派生截图的整宽遮罩把顶部价格遮住一半。项目所有者要求停止遮罩返工并加速 MVP:T-103 不上传规格页截图,以安全最小 XML fixture 继续选择/读价;正式管理员证据截图归 T-204,上传端仍不得接收地址、手机号或支付凭据。未完成真机选择/读价验收,T-103 保持 Doing。
|
||||||
|
|
||||||
|
### 2026-08-04T07:45:57Z · ila
|
||||||
|
|
||||||
|
2026-08-04 最终范围澄清:上一条“正式截图仍需脱敏/裁剪”的记录已被项目所有者新决定取代。cmbuyer 是内部系统,正式第一趟允许把包含页面已显示地址/手机号的原始截图上传采购服务,不做遮罩或裁剪;T-204 负责设备鉴权上传、SHA-256 校验与管理员会话访问。完整 XML、普通日志、Git、Vikunja 和支付凭据仍不接收。T-103 立即停止遮罩开发,继续受控规格选择与 SKU 读价,任务保持 Doing。
|
||||||
<!-- END VIKUNJA EXPORT -->
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
- 任何规格面板入口、维度容器、选项、选中态和价格判据都必须来自本项目新采集、自动复检通过的
|
- 任何规格面板入口、维度容器、选项、选中态和价格判据都必须来自本项目拼多多 8.17.0 真机证据;
|
||||||
拼多多 8.17.0 **脱敏派生** screenshot/XML;T-102 详情页证据只能证明已到目标商品,不能证明
|
T-102 详情页证据只能证明已到目标商品,不能证明规格面板结构。T-103 只使用已由人确认无实际地址/
|
||||||
规格面板结构。派生证据路径、SHA-256、goods_id、设备、Android、App 与 sanitizer 版本写入
|
手机号的 v5 派生 XML 提取最小 fixture;派生截图的上传适用性不再是本任务的代码门禁。
|
||||||
执行记录之前,不得提交页面判据代码。
|
|
||||||
- 第一趟只允许 T-110 批准的 `open_trial_sku_panel()` 窄能力点击证据/版本绑定、精确唯一的
|
- 第一趟只允许 T-110 批准的 `open_trial_sku_panel()` 窄能力点击证据/版本绑定、精确唯一的
|
||||||
`快要抢光`;当前事实仅覆盖 goods_id `937122477375`、拼多多 `8.17.0`。不得把“免拼购买 /
|
`快要抢光`;当前事实仅覆盖 goods_id `937122477375`、拼多多 `8.17.0`。不得把“免拼购买 /
|
||||||
单独购买 / 直接拼成”等其他文案加入包含、前缀、同义或坐标兜底,分别取证前一律拒绝。
|
单独购买 / 直接拼成”等其他文案加入包含、前缀、同义或坐标兜底,分别取证前一律拒绝。
|
||||||
@@ -153,11 +185,11 @@ T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space
|
|||||||
规格面板中的“提交订单”、微信支付、先用后付、0 元下单只可作为硬拒绝判据,不能返回可点击对象。
|
规格面板中的“提交订单”、微信支付、先用后付、0 元下单只可作为硬拒绝判据,不能返回可点击对象。
|
||||||
- 不把 `/mnt/d/chengma/cmroubao`、`/mnt/d/chengma/cmpdd` 或任何旧版本的节点、Activity、选择器、
|
- 不把 `/mnt/d/chengma/cmroubao`、`/mnt/d/chengma/cmpdd` 或任何旧版本的节点、Activity、选择器、
|
||||||
坐标、文本形态和页面结论当作事实;前序项目只可用于理解为什么必须 fail closed。
|
坐标、文本形态和页面结论当作事实;前序项目只可用于理解为什么必须 fail closed。
|
||||||
- 原始 screenshot/XML 只保存在 `%LOCALAPPDATA%\cmbuyer\artifacts\T-103\...\raw`,只允许本机
|
- 原始完整 XML 只保存在 `%LOCALAPPDATA%\cmbuyer\artifacts\T-103\...\raw`,不得由 HTTP sink、
|
||||||
确定性脱敏器消费,不得由 agent、业务、fixture、HTTP sink、Vikunja 或 Git 读取/上传。脱敏器必须
|
Vikunja 或 Git 读取/上传。真机流程可在内存中读取当前页面树,但只返回规格、选中态、价格和页面
|
||||||
校验设备分辨率、页面/App 版本和预期隐私区域,同时移除 screenshot/XML 中的地址与手机号内容;
|
状态摘要;不得把地址、手机号或支付凭据解析成业务字段或写入日志。
|
||||||
派生 XML 仍命中手机号模式、结构/版本/分辨率失配或哈希不一致时不得发布 `derived`。
|
- T-103 不再扩展截图遮罩器;本任务先以本机原始截图完成真机验收。生产第一趟原始截图上传由 T-204
|
||||||
- 只有自动复检通过的最小脱敏 fixture、派生路径/哈希、sanitizer 版本与非敏感结论可入库;服务端
|
接入,允许截图包含页面已显示的地址和手机号,但只允许已认证设备上传、已登录管理员查看;完整 XML、
|
||||||
不接收原始文件、原始路径或原始哈希。
|
外部支付页和支付凭据不得上传。
|
||||||
- `needs_device: true`:agent 只能完成离线实现并保持 `DOING`;只有人完成安全入口、精确规格选择、
|
- `needs_device: true`:agent 只能完成离线实现并保持 `DOING`;只有人完成安全入口、精确规格选择、
|
||||||
SKU 单价语义、页面对应性与派生证据验收后才能标 `DONE`。
|
SKU 单价语义、页面对应性与安全退出验收后才能标 `DONE`。HTTP 截图上传实现不属于 T-103 验收。
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
id: T-201
|
||||||
|
title: 管理员登录与会话
|
||||||
|
phase: 2
|
||||||
|
deps: [T-004, T-005]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-08-04
|
||||||
|
vikunja_task_id: 26
|
||||||
|
context_ref: 80ed9b7
|
||||||
|
work_branch: task/t-201-admin-session
|
||||||
|
needs_device: false
|
||||||
|
needs_human_review: false
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-201.md
|
||||||
|
- admin/cmd/server/main.go
|
||||||
|
- admin/internal/config/**
|
||||||
|
- admin/internal/server/**
|
||||||
|
- admin/internal/auth/**
|
||||||
|
- admin/internal/transport/webui/**
|
||||||
|
- admin/go.mod
|
||||||
|
- admin/go.sum
|
||||||
|
- admin/README.md
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=26 synced=2026-08-04T07:04:10Z sha256=25607da2b9c35781f56fdea1f399bdfb65b76fa9a5ca8210693714793f5ef5f4 -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
采购服务已有 Go/Gin/SQLite 骨架和核心模型,但没有管理员会话。T-201 与真机规格字段无关,可在 T-103 进行时并行。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
F-013、US-007;路由 GET/POST /login、POST /logout、受保护的 GET /tasks 空壳;沿用已确认采购服务原型。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. 启动时从环境变量读取管理员用户名、bcrypt 密码哈希、至少 32 字节 session secret;缺失或无效时明确失败,不提供默认凭据,不打印秘密。
|
||||||
|
2. 使用签名会话和 CSRF;登录成功轮换会话,Cookie 为 HttpOnly、SameSite,Secure 由显式配置控制。
|
||||||
|
3. return_to 仅接受 /tasks 及其子路径,拒绝绝对 URL、双斜杠、反斜杠和其他站内路径。
|
||||||
|
4. 实现登录、登出与受保护的任务空壳;/healthz 保持公开。
|
||||||
|
5. 不改数据库 schema,不实现设备 Bearer、建单、试选、授权、提交或付款。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 覆盖未登录跳转、成功登录与会话轮换、统一失败错误、CSRF 拒绝、登出失效和开放重定向拦截。
|
||||||
|
- 无默认账号/密码/密钥,日志与响应不泄露凭据。
|
||||||
|
- go test ./...、go vet ./...、go build ./...、上下文校验与 diff-check 通过。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
### 2026-08-04T06:58:30Z · ila
|
||||||
|
|
||||||
|
2026-08-04 T-201 实现与主审完成:提交 47c0844 新增强制环境配置、bcrypt 校验、HMAC-SHA256 签名进程内 session、随机 CSRF、登录轮换、登出撤销、受保护 /tasks 空壳和无外部资源的 SSR 登录页。主 agent 首轮退回 /tasks/.. 返回路径绕过、含 CSRF 页面缺安全响应头、Cookie 篡改测试概率失效三项;修正后独立复跑 go test ./...、go test -race ./...、go vet ./...、go build ./...、上下文校验与 diff-check,全部通过。实现未包含建单、试选、真机字段、授权、提交或付款;T-201 验收通过。
|
||||||
|
|
||||||
|
### 2026-08-04T07:03:54Z · ila
|
||||||
|
|
||||||
|
集成门禁补充:T-201 分支已合入 main@88d8f77(含 T-103 最新安全诊断),随后在独立 Windows worktree 运行完整 init.ps1,admin test/vet/build、client 78 项测试/compileall、editable install 与 agent-context 校验全部通过。首次运行因 120 秒工具超时中断在大体积 PySide6 依赖安装,未产生代码或证据问题;复用已建 venv 后完整成功。
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 启动配置只接受显式环境变量中的管理员用户名、bcrypt 密码哈希和至少 32 字节 session secret;
|
||||||
|
不得内置开发默认值、明文密码或密钥,不得打印、回显或提交凭据。
|
||||||
|
- 本任务只实现 `/login`、`/logout`、受保护的 `/tasks` 空壳和保持公开的 `/healthz`;不实现建单、
|
||||||
|
查询、批量开始试选、设备 Bearer、真机自动化或任务状态流转。
|
||||||
|
- 不增加或修改数据库 schema,不读写 `spec_trials`、授权或 `order_submissions`,不实现任何提交订单、
|
||||||
|
付款、免密支付、先用后付或资金控件代码。
|
||||||
|
- 页面和测试不得夹带机器实际规格、规格面板价格、截图、证据哈希或任何 PDD 页面判据;这些仍等待
|
||||||
|
T-103 真机结论。
|
||||||
|
- `return_to` 必须是 `/tasks` 或其子路径;绝对 URL、`//`、反斜杠、编码绕过和其他站内路径均拒绝。
|
||||||
Reference in New Issue
Block a user