Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e04f05b20b | ||
|
|
64a7468cab | ||
|
|
442ab88fd7 | ||
|
|
d38cfb61af | ||
|
|
da540bfdf6 | ||
|
|
cfd5440ac0 | ||
|
|
8ba9b231f4 | ||
|
|
efeb2d958a | ||
|
|
549099ad24 | ||
|
|
76a522f13f | ||
|
|
5088d544f2 | ||
|
|
6e001b2729 | ||
|
|
cea27ff7ef | ||
|
|
1c35155c2d | ||
|
|
babb530b99 | ||
|
|
55d9e09bda | ||
|
|
47c0844f9c | ||
|
|
88d8f77417 | ||
|
|
f7efa4a161 | ||
|
|
ef1ac60ac5 | ||
|
|
5332d67b5e | ||
|
|
5f6b3ce01a | ||
|
|
824a628733 | ||
|
|
1e69d274b8 | ||
|
|
8ee26be95a | ||
|
|
80ed9b71e8 | ||
|
|
3f2e0e5598 | ||
|
|
a45a1b5afc | ||
|
|
d27fda6be3 | ||
|
|
acf7e11114 | ||
|
|
946b064470 | ||
|
|
44c027a18f | ||
|
|
cf3692112c | ||
|
|
dbb69a7d5e | ||
|
|
3d063b9785 | ||
|
|
932ca8c7f3 | ||
|
|
9b2eb74478 | ||
|
|
17e295e99d | ||
|
|
d80e4393b4 | ||
|
|
82f57c7233 | ||
|
|
d81fc722dc | ||
|
|
abeefbbea8 | ||
|
|
b66417d80b | ||
|
|
5b56161fd7 | ||
|
|
a92b8f6be0 | ||
|
|
ec8257ba8b | ||
|
|
27999c8c85 | ||
|
|
1dc83086a0 | ||
|
|
ad55e77bda | ||
|
|
652eca7953 | ||
|
|
cb646b4974 | ||
|
|
9452debf66 | ||
|
|
4adeb1b37f | ||
|
|
2ea28c2626 | ||
|
|
7040bb61d8 | ||
|
|
e7a4be1b9b | ||
|
|
4281b06711 | ||
|
|
393f26de53 | ||
|
|
71c66a074a | ||
|
|
7905fa0b70 | ||
|
|
c4cf19fd55 | ||
|
|
de8187eb5b | ||
|
|
f3294633c2 | ||
|
|
0fbf66836b |
@@ -10,7 +10,8 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
|
||||
**系统只创建待付款订单,任何情况下都不自动付款。**
|
||||
|
||||
当前状态:仓库只有文档,尚未开始编码。阶段为 Phase 0。
|
||||
当前状态:两端骨架、基础模型、登录和真机取证脚手架已落地;Phase 1 真机取证与不依赖页面判据的
|
||||
Phase 2 服务端任务并行。实时快照见 [`docs/current-state.md`](docs/current-state.md)。
|
||||
|
||||
## 必读顺序
|
||||
|
||||
@@ -33,7 +34,9 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
系统**会**点击「提交订单」创建待付款订单,但必须满足四个前置条件(授权未消费且服务端
|
||||
提交围栏已建立、闸门二通过、闸门三通过、控件唯一),且**只点一次**、点击后无论结果
|
||||
都不重试。围栏申请失败或响应不明时不得点击;围栏建立后只能调和同一提交记录。
|
||||
**第一趟试选的代码路径不得引用任何下单函数**,必须有测试证明不可达。
|
||||
管理员点击“开始采购(只创建待付款订单)”是唯一的人类授权动作。T-103 的规格选择/读价隔离
|
||||
验证路径不得引用数量、确认页、提交或付款函数;后续能力必须按真机取证任务逐段开放,并有静态
|
||||
调用链测试证明未获准能力不可达。
|
||||
|
||||
### 2. 安全边界只能收紧
|
||||
|
||||
@@ -141,8 +144,8 @@ python scripts/validate_agent_context.py
|
||||
|
||||
**跨端契约改动必跑完整门禁**——两端会同时坏。
|
||||
|
||||
代码尚未初始化,上述命令在 T-001 / T-002 完成前不可运行;届时由对应任务替换为真实命令
|
||||
并同步文档。
|
||||
两端已初始化;跨端契约改动还需在仓库根运行 `./init.ps1`(或 `./init.sh`)完成安装、测试、
|
||||
vet/build、compileall 和上下文门禁。
|
||||
|
||||
## 风格
|
||||
|
||||
|
||||
@@ -8,13 +8,14 @@
|
||||
## 它做什么
|
||||
|
||||
一笔外部订单进来,采购人员需要去拼多多找到同款、选对颜色尺码、下单、把订单号抄回系统。
|
||||
cmbuyer 把这个过程自动化,人只在两个点介入:**机器选对了吗**和**付不付款**。
|
||||
cmbuyer 把这个过程自动化:管理员明确“买什么、买多少、最多多少钱”并点击开始采购,系统只
|
||||
创建待付款订单,**是否付款始终由人决定**。
|
||||
|
||||
```text
|
||||
手工填链接(MVP) / Excel · ERP(V2)
|
||||
│
|
||||
v
|
||||
采购服务(admin/,Go) 建单 · 试选确认 · 下单授权 · 审计
|
||||
采购服务(admin/,Go) 建单 · 开始采购授权 · 围栏 · 审计
|
||||
│ HTTP
|
||||
v
|
||||
采购工具(client/,Python) 领任务 · 跑流程 · 回传
|
||||
@@ -23,31 +24,22 @@ cmbuyer 把这个过程自动化,人只在两个点介入:**机器选对了
|
||||
Android 手机(拼多多 App)
|
||||
```
|
||||
|
||||
## 两趟执行
|
||||
## 单趟执行
|
||||
|
||||
MVP 只做**任务自带商品链接**的情形,分两趟跑完:
|
||||
MVP 只做**任务自带商品链接**的情形:管理员先把任务保存为 `DRAFT`,再在表格中勾选并点击
|
||||
“开始采购(只创建待付款订单)”。该点击创建一次性授权,锁定商品、颜色、尺码、数量和最高总价。
|
||||
|
||||
| 趟次 | 做什么 |
|
||||
| --- | --- |
|
||||
| **第一趟 · 试选** | 开商品 → 精确勾选颜色分类和尺码 → 读单价 → 截图 → **退出释放手机** → 回传 |
|
||||
| **人工确认** | 人在网页端看「机器选对了吗」→ 确认并**锁定单价** |
|
||||
| **第二趟 · 下单** | 重新开商品 → 重新选同一规格 → **三道价格闸门** → 提交订单一次 → 转「待付款」 |
|
||||
|
||||
为什么分两趟:一台手机是瓶颈,不能停在规格面板上等人。代价是走两遍,换来手机不空闲,
|
||||
且第二趟能抓住价格变动。
|
||||
|
||||
**三道价格闸门**:① 第一趟规格面板读价 ② 第二趟重读必须与授权价一致
|
||||
③ 订单确认页「实付款」不超上限。任一道读不到或不通过即停,转人工。
|
||||
采购工具领取后在同一次设备会话中完成:打开商品 → 精确选择规格 → 闸门一读 SKU 单价并校验
|
||||
上限 → 设置并复核数量 → 闸门二重读规格与同价 → 进入确认页 → 闸门三校验应付总额 → 服务端
|
||||
原子建立提交围栏 → 精确点击一次“提交订单” → 转待付款。中间不再回网页端等“机器选对了吗”。
|
||||
|
||||
价格**只在规格面板和订单确认页读**——别处的价格文本被拆成多个节点、带券后前缀、
|
||||
实付价与原价混在一起,不可靠。
|
||||
|
||||
## 现在处于什么阶段
|
||||
|
||||
**Phase 0 · 地基。仓库目前只有文档,尚未开始编码。**
|
||||
|
||||
下一步:T-005(网页端 MVP 原型)与 T-006(桌面端 MVP 原型)的文件和自动检查已完成,
|
||||
请先人工确认 6 个 HTML 原型,再进入 T-001 / T-002 骨架和生产实现。
|
||||
**Phase 1 真机取证与 Phase 2 的安全服务端工作并行。** 两端骨架、基础模型、登录和真机基线已
|
||||
落地;T-111 正在冻结单趟采购契约,T-103 随后继续规格精确选择与读价,T-202 等待主审合入。
|
||||
详见 [`docs/current-state.md`](docs/current-state.md)。
|
||||
|
||||
**生死线是 M2**:真机能按链接打开商品、精确勾选颜色分类和尺码、**读到该 SKU 单价**。
|
||||
@@ -74,7 +66,7 @@ MVP 只做**任务自带商品链接**的情形,分两趟跑完:
|
||||
4. 规格按维度精确匹配,防前缀碰撞,找不到即停
|
||||
5. 数量设置后必须读回复核
|
||||
6. **三道价格闸门**,任一道读不到或不通过即停
|
||||
7. **第一趟绝不下单**——试选路径不得引用下单函数
|
||||
7. **能力分层**——T-103 规格验证路径不得引用数量、确认页或下单函数;后续能力逐段取证
|
||||
8. 检测到外部支付交接立即停止,不读取不保存凭据
|
||||
9. 检测到验证码 / 风控 / 人脸 / 短信校验立即停止,不绕过
|
||||
10. 只读非敏感摘要,不提取收货地址原文、手机号、支付凭据
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# 采购服务
|
||||
|
||||
启动前必须显式设置下列环境变量;服务不提供默认管理员账号、密码或会话密钥。
|
||||
|
||||
| 变量 | 要求 |
|
||||
| --- | --- |
|
||||
| `CMBUYER_ADMIN_USERNAME` | 非空管理员账号。 |
|
||||
| `CMBUYER_ADMIN_PASSWORD_BCRYPT` | 非空 bcrypt 密码哈希,不接受明文密码。 |
|
||||
| `CMBUYER_SESSION_SECRET` | 至少 32 字节的会话签名密钥。 |
|
||||
| `CMBUYER_COOKIE_SECURE` | 可选;存在时只能精确为 `true` 或 `false`。HTTPS 部署应设为 `true`。 |
|
||||
| `CMBUYER_DATABASE_SOURCE` | 已迁移 SQLite 的显式 data source。 |
|
||||
|
||||
示例仅展示变量名,不提供可运行凭据:
|
||||
|
||||
```powershell
|
||||
$env:CMBUYER_ADMIN_USERNAME = '<管理员账号>'
|
||||
$env:CMBUYER_ADMIN_PASSWORD_BCRYPT = '<bcrypt 密码哈希>'
|
||||
$env:CMBUYER_SESSION_SECRET = '<至少 32 字节的随机密钥>'
|
||||
$env:CMBUYER_COOKIE_SECURE = 'true'
|
||||
$env:CMBUYER_DATABASE_SOURCE = '<SQLite data source>'
|
||||
go run ./cmd/migrate -database $env:CMBUYER_DATABASE_SOURCE up
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
采购服务会话仅保存在当前进程内;进程重启后既有登录会话会安全失效。
|
||||
@@ -5,7 +5,11 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"cmbuyer/admin/internal/auth"
|
||||
"cmbuyer/admin/internal/config"
|
||||
"cmbuyer/admin/internal/server"
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
)
|
||||
|
||||
const listenAddress = ":8080"
|
||||
@@ -17,7 +21,31 @@ func main() {
|
||||
}
|
||||
|
||||
func run() error {
|
||||
err := http.ListenAndServe(listenAddress, server.NewRouter())
|
||||
configuration, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
database, err := sqlite.Open(configuration.DatabaseSource)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer database.Close()
|
||||
taskStore, err := tasks.NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(server.Options{
|
||||
AdminUsername: configuration.AdminUsername,
|
||||
AdminPasswordBcrypt: configuration.AdminPasswordBcrypt,
|
||||
Sessions: auth.NewManager(configuration.SessionSecret, configuration.CookieSecure),
|
||||
Tasks: taskStore,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = http.ListenAndServe(listenAddress, router)
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,6 +6,7 @@ require (
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/mattn/go-sqlite3 v1.14.49
|
||||
github.com/pressly/goose/v3 v3.24.0
|
||||
golang.org/x/crypto v0.40.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -35,7 +36,6 @@ require (
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// Package auth 提供内存会话与 CSRF 防护。会话不落库,服务重启会安全地使所有登录失效。
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
CookieName = "cmbuyer_session"
|
||||
SessionLifetime = 8 * time.Hour
|
||||
csrfTokenByteSize = 32
|
||||
)
|
||||
|
||||
type session struct {
|
||||
csrfToken string
|
||||
authenticated bool
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// Manager 签发、验证并撤销进程内会话。cookie 仅承载经过 HMAC 签名的随机 session ID。
|
||||
type Manager struct {
|
||||
secret []byte
|
||||
cookieSecure bool
|
||||
now func() time.Time
|
||||
random io.Reader
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]session
|
||||
}
|
||||
|
||||
// NewManager 创建会话管理器。secret 在启动时已由 config 验证为足够长度。
|
||||
func NewManager(secret []byte, cookieSecure bool) *Manager {
|
||||
return &Manager{
|
||||
secret: append([]byte(nil), secret...),
|
||||
cookieSecure: cookieSecure,
|
||||
now: time.Now,
|
||||
random: rand.Reader,
|
||||
sessions: make(map[string]session),
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure 返回当前有效会话;不存在或过期时签发匿名会话,以保护登录表单本身的 POST。
|
||||
func (manager *Manager) Ensure(writer http.ResponseWriter, request *http.Request) (csrfToken string, authenticated bool) {
|
||||
if id, current, ok := manager.current(request); ok {
|
||||
return current.csrfToken, current.authenticated
|
||||
} else if id != "" {
|
||||
manager.delete(id)
|
||||
}
|
||||
|
||||
id, current := manager.create(false)
|
||||
manager.writeCookie(writer, id, current.expiresAt)
|
||||
return current.csrfToken, false
|
||||
}
|
||||
|
||||
// VerifyCSRF 只接受当前未过期会话中以恒定时间比较匹配的 token。
|
||||
func (manager *Manager) VerifyCSRF(request *http.Request, token string) (authenticated bool, ok bool) {
|
||||
_, current, found := manager.current(request)
|
||||
if !found || token == "" {
|
||||
return false, false
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(current.csrfToken), []byte(token)) != 1 {
|
||||
return false, false
|
||||
}
|
||||
|
||||
return current.authenticated, true
|
||||
}
|
||||
|
||||
// RotateAuthenticated 在登录成功后撤销旧会话并签发全新认证会话,避免 session fixation 与 CSRF 复用。
|
||||
func (manager *Manager) RotateAuthenticated(writer http.ResponseWriter, request *http.Request) string {
|
||||
if id, _, ok := manager.current(request); ok {
|
||||
manager.delete(id)
|
||||
}
|
||||
|
||||
id, current := manager.create(true)
|
||||
manager.writeCookie(writer, id, current.expiresAt)
|
||||
return current.csrfToken
|
||||
}
|
||||
|
||||
// Logout 撤销当前会话并立即清除浏览器 cookie。
|
||||
func (manager *Manager) Logout(writer http.ResponseWriter, request *http.Request) {
|
||||
if id, _, ok := manager.current(request); ok {
|
||||
manager.delete(id)
|
||||
}
|
||||
http.SetCookie(writer, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: manager.cookieSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *Manager) current(request *http.Request) (string, session, bool) {
|
||||
cookie, err := request.Cookie(CookieName)
|
||||
if err != nil {
|
||||
return "", session{}, false
|
||||
}
|
||||
|
||||
id, expiresAt, ok := manager.verifyCookie(cookie.Value)
|
||||
if !ok || !manager.now().Before(expiresAt) {
|
||||
return id, session{}, false
|
||||
}
|
||||
|
||||
manager.mu.Lock()
|
||||
defer manager.mu.Unlock()
|
||||
current, found := manager.sessions[id]
|
||||
if !found || !manager.now().Before(current.expiresAt) {
|
||||
return id, session{}, false
|
||||
}
|
||||
|
||||
return id, current, true
|
||||
}
|
||||
|
||||
func (manager *Manager) create(authenticated bool) (string, session) {
|
||||
id := manager.randomToken()
|
||||
current := session{
|
||||
csrfToken: manager.randomToken(),
|
||||
authenticated: authenticated,
|
||||
expiresAt: manager.now().Add(SessionLifetime),
|
||||
}
|
||||
|
||||
manager.mu.Lock()
|
||||
manager.sessions[id] = current
|
||||
manager.mu.Unlock()
|
||||
return id, current
|
||||
}
|
||||
|
||||
func (manager *Manager) delete(id string) {
|
||||
manager.mu.Lock()
|
||||
delete(manager.sessions, id)
|
||||
manager.mu.Unlock()
|
||||
}
|
||||
|
||||
func (manager *Manager) randomToken() string {
|
||||
bytes := make([]byte, csrfTokenByteSize)
|
||||
if _, err := io.ReadFull(manager.random, bytes); err != nil {
|
||||
panic("crypto/rand failed while creating a session token")
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(bytes)
|
||||
}
|
||||
|
||||
func (manager *Manager) writeCookie(writer http.ResponseWriter, id string, expiresAt time.Time) {
|
||||
http.SetCookie(writer, &http.Cookie{
|
||||
Name: CookieName,
|
||||
Value: manager.signCookie(id, expiresAt),
|
||||
Path: "/",
|
||||
MaxAge: int(expiresAt.Sub(manager.now()).Seconds()),
|
||||
Expires: expiresAt,
|
||||
HttpOnly: true,
|
||||
Secure: manager.cookieSecure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (manager *Manager) signCookie(id string, expiresAt time.Time) string {
|
||||
payload := id + "." + strconv.FormatInt(expiresAt.Unix(), 10)
|
||||
mac := hmac.New(sha256.New, manager.secret)
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
return payload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func (manager *Manager) verifyCookie(value string) (string, time.Time, bool) {
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) != 3 || parts[0] == "" {
|
||||
return "", time.Time{}, false
|
||||
}
|
||||
|
||||
expiresUnix, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil {
|
||||
return "", time.Time{}, false
|
||||
}
|
||||
provided, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return "", time.Time{}, false
|
||||
}
|
||||
|
||||
payload := parts[0] + "." + parts[1]
|
||||
mac := hmac.New(sha256.New, manager.secret)
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
if !hmac.Equal(provided, mac.Sum(nil)) {
|
||||
return "", time.Time{}, false
|
||||
}
|
||||
|
||||
return parts[0], time.Unix(expiresUnix, 0), true
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestManagerRejectsTamperedAndExpiredCookies(t *testing.T) {
|
||||
manager := NewManager([]byte(strings.Repeat("s", 32)), true)
|
||||
request := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
response := httptest.NewRecorder()
|
||||
csrf, authenticated := manager.Ensure(response, request)
|
||||
if csrf == "" || authenticated {
|
||||
t.Fatalf("Ensure = (%q, %t), want anonymous CSRF session", csrf, authenticated)
|
||||
}
|
||||
cookie := response.Result().Cookies()[0]
|
||||
if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteLaxMode || cookie.Path != "/" {
|
||||
t.Fatalf("session cookie is missing security attributes: %#v", cookie)
|
||||
}
|
||||
|
||||
tampered := *cookie
|
||||
tampered.Value = flipCookieValue(t, cookie.Value)
|
||||
tamperedRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||
tamperedRequest.AddCookie(&tampered)
|
||||
if _, ok := manager.VerifyCSRF(tamperedRequest, csrf); ok {
|
||||
t.Fatal("tampered signed cookie passed CSRF verification")
|
||||
}
|
||||
|
||||
manager.now = func() time.Time { return time.Now().Add(9 * time.Hour) }
|
||||
expiredRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||
expiredRequest.AddCookie(cookie)
|
||||
if _, ok := manager.VerifyCSRF(expiredRequest, csrf); ok {
|
||||
t.Fatal("expired cookie passed CSRF verification")
|
||||
}
|
||||
}
|
||||
|
||||
func flipCookieValue(t *testing.T, value string) string {
|
||||
t.Helper()
|
||||
if value == "" {
|
||||
t.Fatal("cannot tamper with an empty cookie")
|
||||
}
|
||||
if value[0] == 'A' {
|
||||
return "B" + value[1:]
|
||||
}
|
||||
return "A" + value[1:]
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Package config 读取采购服务的启动配置。凭据只允许来自显式环境变量,避免把秘密写入代码或仓库。
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
adminUsernameEnv = "CMBUYER_ADMIN_USERNAME"
|
||||
adminPasswordBcryptEnv = "CMBUYER_ADMIN_PASSWORD_BCRYPT"
|
||||
sessionSecretEnv = "CMBUYER_SESSION_SECRET"
|
||||
cookieSecureEnv = "CMBUYER_COOKIE_SECURE"
|
||||
databaseSourceEnv = "CMBUYER_DATABASE_SOURCE"
|
||||
minimumSecretLength = 32
|
||||
)
|
||||
|
||||
// Config 是启动采购服务所需的最小安全配置。
|
||||
type Config struct {
|
||||
AdminUsername string
|
||||
AdminPasswordBcrypt string
|
||||
SessionSecret []byte
|
||||
CookieSecure bool
|
||||
DatabaseSource string
|
||||
}
|
||||
|
||||
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
|
||||
func LoadFromEnv() (Config, error) {
|
||||
return Load(os.LookupEnv)
|
||||
}
|
||||
|
||||
// Load 使用 lookup 读取配置,以便在不污染进程环境的情况下测试启动边界。
|
||||
func Load(lookup func(string) (string, bool)) (Config, error) {
|
||||
username, err := required(lookup, adminUsernameEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
passwordHash, err := required(lookup, adminPasswordBcryptEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if _, err := bcrypt.Cost([]byte(passwordHash)); err != nil {
|
||||
return Config{}, fmt.Errorf("%s is not a valid bcrypt hash", adminPasswordBcryptEnv)
|
||||
}
|
||||
|
||||
secret, err := required(lookup, sessionSecretEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if len([]byte(secret)) < minimumSecretLength {
|
||||
return Config{}, fmt.Errorf("%s must be at least %d bytes", sessionSecretEnv, minimumSecretLength)
|
||||
}
|
||||
|
||||
cookieSecure := false
|
||||
if value, present := lookup(cookieSecureEnv); present {
|
||||
switch value {
|
||||
case "true":
|
||||
cookieSecure = true
|
||||
case "false":
|
||||
cookieSecure = false
|
||||
default:
|
||||
return Config{}, fmt.Errorf("%s must be exactly true or false", cookieSecureEnv)
|
||||
}
|
||||
}
|
||||
databaseSource, err := required(lookup, databaseSourceEnv)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
return Config{
|
||||
AdminUsername: username,
|
||||
AdminPasswordBcrypt: passwordHash,
|
||||
SessionSecret: []byte(secret),
|
||||
CookieSecure: cookieSecure,
|
||||
DatabaseSource: databaseSource,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func required(lookup func(string) (string, bool), name string) (string, error) {
|
||||
value, present := lookup(name)
|
||||
if !present || strings.TrimSpace(value) == "" {
|
||||
return "", errors.New(name + " must be set")
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/config"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("generate bcrypt hash: %v", err)
|
||||
}
|
||||
|
||||
values := map[string]string{
|
||||
"CMBUYER_ADMIN_USERNAME": "admin",
|
||||
"CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash),
|
||||
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
|
||||
"CMBUYER_COOKIE_SECURE": "true",
|
||||
"CMBUYER_DATABASE_SOURCE": ":memory:",
|
||||
}
|
||||
|
||||
got, err := config.Load(lookup(values))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if got.AdminUsername != "admin" || !got.CookieSecure {
|
||||
t.Fatalf("Load returned unexpected public configuration: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("generate bcrypt hash: %v", err)
|
||||
}
|
||||
|
||||
base := map[string]string{
|
||||
"CMBUYER_ADMIN_USERNAME": "admin",
|
||||
"CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash),
|
||||
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
|
||||
"CMBUYER_DATABASE_SOURCE": ":memory:",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(map[string]string)
|
||||
want string
|
||||
}{
|
||||
{"missing username", func(values map[string]string) { delete(values, "CMBUYER_ADMIN_USERNAME") }, "CMBUYER_ADMIN_USERNAME"},
|
||||
{"invalid bcrypt", func(values map[string]string) { values["CMBUYER_ADMIN_PASSWORD_BCRYPT"] = "not-a-bcrypt-hash" }, "CMBUYER_ADMIN_PASSWORD_BCRYPT"},
|
||||
{"short secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = "short" }, "CMBUYER_SESSION_SECRET"},
|
||||
{"invalid secure flag", func(values map[string]string) { values["CMBUYER_COOKIE_SECURE"] = "1" }, "CMBUYER_COOKIE_SECURE"},
|
||||
{"missing database", func(values map[string]string) { delete(values, "CMBUYER_DATABASE_SOURCE") }, "CMBUYER_DATABASE_SOURCE"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
values := copyValues(base)
|
||||
test.mutate(values)
|
||||
_, err := config.Load(lookup(values))
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Load error = %v, want mention of %s", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func lookup(values map[string]string) func(string) (string, bool) {
|
||||
return func(key string) (string, bool) {
|
||||
value, ok := values[key]
|
||||
return value, ok
|
||||
}
|
||||
}
|
||||
|
||||
func copyValues(values map[string]string) map[string]string {
|
||||
copy := make(map[string]string, len(values))
|
||||
for key, value := range values {
|
||||
copy[key] = value
|
||||
}
|
||||
return copy
|
||||
}
|
||||
@@ -10,32 +10,28 @@ var ErrInvalidAuthorizationTransition = errors.New("invalid authorization status
|
||||
type AuthorizationStatus string
|
||||
|
||||
const (
|
||||
AuthorizationStatusPendingDelivery AuthorizationStatus = "PENDING_DELIVERY"
|
||||
AuthorizationStatusDelivered AuthorizationStatus = "DELIVERED"
|
||||
AuthorizationStatusAcknowledged AuthorizationStatus = "ACKNOWLEDGED"
|
||||
AuthorizationStatusExecuting AuthorizationStatus = "EXECUTING"
|
||||
AuthorizationStatusFenced AuthorizationStatus = "FENCED"
|
||||
AuthorizationStatusConsumed AuthorizationStatus = "CONSUMED"
|
||||
AuthorizationStatusSuperseded AuthorizationStatus = "SUPERSEDED"
|
||||
AuthorizationStatusExpired AuthorizationStatus = "EXPIRED"
|
||||
AuthorizationStatusActive AuthorizationStatus = "ACTIVE"
|
||||
AuthorizationStatusClaimed AuthorizationStatus = "CLAIMED"
|
||||
AuthorizationStatusFenced AuthorizationStatus = "FENCED"
|
||||
AuthorizationStatusConsumed AuthorizationStatus = "CONSUMED"
|
||||
AuthorizationStatusExpired AuthorizationStatus = "EXPIRED"
|
||||
AuthorizationStatusAbandoned AuthorizationStatus = "ABANDONED"
|
||||
)
|
||||
|
||||
type OrderAuthorization struct {
|
||||
ID string
|
||||
TaskID string
|
||||
SpecTrialID string
|
||||
Version int
|
||||
GoodsID string
|
||||
SKUColor string
|
||||
SKUSize string
|
||||
Quantity int
|
||||
AuthorizedUnitPrice string
|
||||
TotalPriceCap string
|
||||
Note *string
|
||||
Status AuthorizationStatus
|
||||
CreatedBy string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
ID string
|
||||
TaskID string
|
||||
TaskVersion int
|
||||
StartKey string
|
||||
GoodsID string
|
||||
SKUColor string
|
||||
SKUSize string
|
||||
Quantity int
|
||||
TotalPriceCap string
|
||||
Status AuthorizationStatus
|
||||
CreatedBy string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// CanTransitionTo 围栏后的授权只能消费,不能回到可领取或可过期状态,以防重复采购。
|
||||
@@ -54,25 +50,15 @@ func TransitionAuthorization(current, next AuthorizationStatus) (AuthorizationSt
|
||||
}
|
||||
|
||||
var authorizationTransitions = map[AuthorizationStatus]map[AuthorizationStatus]struct{}{
|
||||
AuthorizationStatusPendingDelivery: {
|
||||
AuthorizationStatusDelivered: {},
|
||||
AuthorizationStatusSuperseded: {},
|
||||
AuthorizationStatusExpired: {},
|
||||
AuthorizationStatusActive: {
|
||||
AuthorizationStatusClaimed: {},
|
||||
AuthorizationStatusExpired: {},
|
||||
AuthorizationStatusAbandoned: {},
|
||||
},
|
||||
AuthorizationStatusDelivered: {
|
||||
AuthorizationStatusAcknowledged: {},
|
||||
AuthorizationStatusSuperseded: {},
|
||||
AuthorizationStatusExpired: {},
|
||||
},
|
||||
AuthorizationStatusAcknowledged: {
|
||||
AuthorizationStatusExecuting: {},
|
||||
AuthorizationStatusSuperseded: {},
|
||||
AuthorizationStatusExpired: {},
|
||||
},
|
||||
AuthorizationStatusExecuting: {
|
||||
AuthorizationStatusFenced: {},
|
||||
AuthorizationStatusSuperseded: {},
|
||||
AuthorizationStatusExpired: {},
|
||||
AuthorizationStatusClaimed: {
|
||||
AuthorizationStatusFenced: {},
|
||||
AuthorizationStatusExpired: {},
|
||||
AuthorizationStatusAbandoned: {},
|
||||
},
|
||||
AuthorizationStatusFenced: {
|
||||
AuthorizationStatusConsumed: {},
|
||||
|
||||
@@ -14,19 +14,17 @@ func TestAuthorizationTransitions(t *testing.T) {
|
||||
next domain.AuthorizationStatus
|
||||
allowed bool
|
||||
}{
|
||||
{"deliver", domain.AuthorizationStatusPendingDelivery, domain.AuthorizationStatusDelivered, true},
|
||||
{"acknowledge", domain.AuthorizationStatusDelivered, domain.AuthorizationStatusAcknowledged, true},
|
||||
{"execute", domain.AuthorizationStatusAcknowledged, domain.AuthorizationStatusExecuting, true},
|
||||
{"fence", domain.AuthorizationStatusExecuting, domain.AuthorizationStatusFenced, true},
|
||||
{"claim", domain.AuthorizationStatusActive, domain.AuthorizationStatusClaimed, true},
|
||||
{"fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusFenced, true},
|
||||
{"consume fenced authorization", domain.AuthorizationStatusFenced, domain.AuthorizationStatusConsumed, true},
|
||||
{"expire pending delivery", domain.AuthorizationStatusPendingDelivery, domain.AuthorizationStatusExpired, true},
|
||||
{"supersede pending delivery", domain.AuthorizationStatusPendingDelivery, domain.AuthorizationStatusSuperseded, true},
|
||||
{"expire before fence", domain.AuthorizationStatusExecuting, domain.AuthorizationStatusExpired, true},
|
||||
{"supersede before fence", domain.AuthorizationStatusDelivered, domain.AuthorizationStatusSuperseded, true},
|
||||
{"expire active", domain.AuthorizationStatusActive, domain.AuthorizationStatusExpired, true},
|
||||
{"abandon active", domain.AuthorizationStatusActive, domain.AuthorizationStatusAbandoned, true},
|
||||
{"expire claimed before fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusExpired, true},
|
||||
{"abandon claimed before fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusAbandoned, true},
|
||||
{"fenced authorization cannot expire", domain.AuthorizationStatusFenced, domain.AuthorizationStatusExpired, false},
|
||||
{"fenced authorization cannot be superseded", domain.AuthorizationStatusFenced, domain.AuthorizationStatusSuperseded, false},
|
||||
{"fenced authorization cannot be delivered again", domain.AuthorizationStatusFenced, domain.AuthorizationStatusDelivered, false},
|
||||
{"consumed authorization cannot restart", domain.AuthorizationStatusConsumed, domain.AuthorizationStatusDelivered, false},
|
||||
{"fenced authorization cannot be abandoned", domain.AuthorizationStatusFenced, domain.AuthorizationStatusAbandoned, false},
|
||||
{"fenced authorization cannot be claimed again", domain.AuthorizationStatusFenced, domain.AuthorizationStatusClaimed, false},
|
||||
{"consumed authorization cannot restart", domain.AuthorizationStatusConsumed, domain.AuthorizationStatusClaimed, false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrInvalidAttemptTransition = errors.New("invalid purchase attempt status transition")
|
||||
|
||||
// AttemptStatus 只描述单趟领取的可恢复执行。真实提交结果独立由唯一围栏记录调和。
|
||||
type AttemptStatus string
|
||||
|
||||
const (
|
||||
AttemptStatusClaimed AttemptStatus = "CLAIMED"
|
||||
AttemptStatusOrdering AttemptStatus = "ORDERING"
|
||||
AttemptStatusFailed AttemptStatus = "FAILED"
|
||||
AttemptStatusFenced AttemptStatus = "FENCED"
|
||||
AttemptStatusAbandoned AttemptStatus = "ABANDONED"
|
||||
)
|
||||
|
||||
// AttemptFailureCode 是服务端可审计的固定失败摘要,不能承载页面正文或其他自由文本。
|
||||
type AttemptFailureCode string
|
||||
|
||||
const (
|
||||
AttemptFailureAuthorizationExpired AttemptFailureCode = "AUTHORIZATION_EXPIRED"
|
||||
AttemptFailureLeaseLost AttemptFailureCode = "LEASE_LOST"
|
||||
AttemptFailureGate1Rejected AttemptFailureCode = "GATE_1_REJECTED"
|
||||
AttemptFailureQuantityMismatch AttemptFailureCode = "QUANTITY_MISMATCH"
|
||||
AttemptFailureGate2Rejected AttemptFailureCode = "GATE_2_REJECTED"
|
||||
AttemptFailureGate3Rejected AttemptFailureCode = "GATE_3_REJECTED"
|
||||
AttemptFailureFenceRejected AttemptFailureCode = "FENCE_REJECTED"
|
||||
AttemptFailureSafeAborted AttemptFailureCode = "SAFE_ABORTED"
|
||||
)
|
||||
|
||||
type PurchaseAttempt struct {
|
||||
ID string
|
||||
TaskID string
|
||||
AuthorizationID string
|
||||
ClaimGeneration int
|
||||
Status AttemptStatus
|
||||
Gate1UnitPrice *string
|
||||
Gate2UnitPrice *string
|
||||
QuantityRead *int
|
||||
ConfirmAmount *string
|
||||
FailureCode *AttemptFailureCode
|
||||
StartedAt time.Time
|
||||
FinishedAt *time.Time
|
||||
}
|
||||
|
||||
// CanTransitionTo 只允许围栏前的领取恢复为安全失败;围栏后不再提供回退或重试路径。
|
||||
func (status AttemptStatus) CanTransitionTo(next AttemptStatus) bool {
|
||||
_, allowed := attemptTransitions[status][next]
|
||||
return allowed
|
||||
}
|
||||
|
||||
func TransitionAttempt(current, next AttemptStatus) (AttemptStatus, error) {
|
||||
if !current.CanTransitionTo(next) {
|
||||
return current, ErrInvalidAttemptTransition
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
var attemptTransitions = map[AttemptStatus]map[AttemptStatus]struct{}{
|
||||
AttemptStatusClaimed: {
|
||||
AttemptStatusOrdering: {},
|
||||
AttemptStatusFailed: {},
|
||||
AttemptStatusAbandoned: {},
|
||||
},
|
||||
AttemptStatusOrdering: {
|
||||
AttemptStatusFenced: {},
|
||||
AttemptStatusFailed: {},
|
||||
AttemptStatusAbandoned: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/domain"
|
||||
)
|
||||
|
||||
func TestPurchaseAttemptTransitions(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
current domain.AttemptStatus
|
||||
next domain.AttemptStatus
|
||||
allowed bool
|
||||
}{
|
||||
{domain.AttemptStatusClaimed, domain.AttemptStatusOrdering, true},
|
||||
{domain.AttemptStatusOrdering, domain.AttemptStatusFenced, true},
|
||||
{domain.AttemptStatusOrdering, domain.AttemptStatusFailed, true},
|
||||
{domain.AttemptStatusFenced, domain.AttemptStatusOrdering, false},
|
||||
{domain.AttemptStatusFenced, domain.AttemptStatusAbandoned, false},
|
||||
{domain.AttemptStatus("UNKNOWN"), domain.AttemptStatusOrdering, false},
|
||||
} {
|
||||
got, err := domain.TransitionAttempt(test.current, test.next)
|
||||
if test.allowed {
|
||||
if err != nil || got != test.next {
|
||||
t.Fatalf("TransitionAttempt(%s, %s) = (%s, %v)", test.current, test.next, got, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !errors.Is(err, domain.ErrInvalidAttemptTransition) || got != test.current {
|
||||
t.Fatalf("invalid TransitionAttempt(%s, %s) = (%s, %v)", test.current, test.next, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type SpecTrial struct {
|
||||
ID string
|
||||
TaskID string
|
||||
Attempt int
|
||||
ProductTitle string
|
||||
SelectedColor string
|
||||
SelectedSize string
|
||||
UnitPrice string
|
||||
TotalPrice string
|
||||
EvidenceSHA256 string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -17,17 +17,17 @@ const (
|
||||
)
|
||||
|
||||
type OrderSubmission struct {
|
||||
ID string
|
||||
TaskID string
|
||||
AuthorizationID string
|
||||
CommandID string
|
||||
DryRunID string
|
||||
Status SubmissionStatus
|
||||
VerifiedUnitPrice string
|
||||
QuantityRead int
|
||||
ConfirmPageAmount string
|
||||
CreatedAt time.Time
|
||||
ResolvedAt *time.Time
|
||||
ID string
|
||||
TaskID string
|
||||
AuthorizationID string
|
||||
AttemptID string
|
||||
Status SubmissionStatus
|
||||
Gate1UnitPrice string
|
||||
Gate2UnitPrice string
|
||||
QuantityRead int
|
||||
ConfirmAmount string
|
||||
CreatedAt time.Time
|
||||
ResolvedAt *time.Time
|
||||
}
|
||||
|
||||
// CanTransitionTo 只允许围栏记录向最终观察结果调和,拒绝回退以防触发第二次真实动作。
|
||||
|
||||
@@ -20,6 +20,7 @@ func TestSubmissionTransitions(t *testing.T) {
|
||||
{"cannot reopen fenced submission", domain.SubmissionStatusSubmitted, domain.SubmissionStatusFenced, false},
|
||||
{"submitted cannot require reconciliation", domain.SubmissionStatusSubmitted, domain.SubmissionStatusReconciliationRequired, false},
|
||||
{"cannot skip reconciliation", domain.SubmissionStatusFenced, domain.SubmissionStatusManualResolved, false},
|
||||
{"manual resolution cannot create a second submission", domain.SubmissionStatusManualResolved, domain.SubmissionStatusFenced, false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
|
||||
@@ -14,15 +14,12 @@ const (
|
||||
TaskStatusDraft TaskStatus = "DRAFT"
|
||||
TaskStatusPending TaskStatus = "PENDING"
|
||||
TaskStatusClaimed TaskStatus = "CLAIMED"
|
||||
TaskStatusRunning TaskStatus = "RUNNING"
|
||||
TaskStatusWaitingConfirmation TaskStatus = "WAITING_CONFIRMATION"
|
||||
TaskStatusPendingRetrial TaskStatus = "PENDING_RETRIAL"
|
||||
TaskStatusAuthorized TaskStatus = "AUTHORIZED"
|
||||
TaskStatusOrdering TaskStatus = "ORDERING"
|
||||
TaskStatusWaitingPayment TaskStatus = "WAITING_PAYMENT"
|
||||
TaskStatusReconciliationRequired TaskStatus = "RECONCILIATION_REQUIRED"
|
||||
TaskStatusNeedsManual TaskStatus = "NEEDS_MANUAL"
|
||||
TaskStatusSucceeded TaskStatus = "SUCCEEDED"
|
||||
TaskStatusFailed TaskStatus = "FAILED"
|
||||
TaskStatusCanceled TaskStatus = "CANCELED"
|
||||
)
|
||||
|
||||
@@ -68,35 +65,32 @@ func TransitionTask(current, next TaskStatus) (TaskStatus, error) {
|
||||
|
||||
var taskTransitions = map[TaskStatus]map[TaskStatus]struct{}{
|
||||
TaskStatusDraft: {
|
||||
TaskStatusPending: {},
|
||||
TaskStatusPending: {},
|
||||
TaskStatusCanceled: {},
|
||||
},
|
||||
TaskStatusPending: {
|
||||
TaskStatusClaimed: {},
|
||||
},
|
||||
TaskStatusPendingRetrial: {
|
||||
TaskStatusClaimed: {},
|
||||
TaskStatusClaimed: {},
|
||||
TaskStatusDraft: {},
|
||||
TaskStatusCanceled: {},
|
||||
},
|
||||
TaskStatusClaimed: {
|
||||
TaskStatusRunning: {},
|
||||
TaskStatusPending: {},
|
||||
},
|
||||
TaskStatusRunning: {
|
||||
TaskStatusWaitingConfirmation: {},
|
||||
TaskStatusNeedsManual: {},
|
||||
},
|
||||
TaskStatusWaitingConfirmation: {
|
||||
TaskStatusCanceled: {},
|
||||
TaskStatusAuthorized: {},
|
||||
},
|
||||
TaskStatusAuthorized: {
|
||||
TaskStatusOrdering: {},
|
||||
TaskStatusDraft: {},
|
||||
},
|
||||
TaskStatusOrdering: {
|
||||
TaskStatusNeedsManual: {},
|
||||
TaskStatusWaitingPayment: {},
|
||||
TaskStatusReconciliationRequired: {},
|
||||
},
|
||||
TaskStatusNeedsManual: {
|
||||
TaskStatusDraft: {},
|
||||
TaskStatusCanceled: {},
|
||||
},
|
||||
TaskStatusWaitingPayment: {
|
||||
TaskStatusSucceeded: {},
|
||||
},
|
||||
TaskStatusReconciliationRequired: {
|
||||
TaskStatusWaitingPayment: {},
|
||||
TaskStatusFailed: {},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -14,22 +14,24 @@ func TestTaskTransitions(t *testing.T) {
|
||||
next domain.TaskStatus
|
||||
allowed bool
|
||||
}{
|
||||
{"start trial", domain.TaskStatusDraft, domain.TaskStatusPending, true},
|
||||
{"claim trial", domain.TaskStatusPending, domain.TaskStatusClaimed, true},
|
||||
{"claim retrial", domain.TaskStatusPendingRetrial, domain.TaskStatusClaimed, true},
|
||||
{"start trial execution", domain.TaskStatusClaimed, domain.TaskStatusRunning, true},
|
||||
{"release unstarted claim", domain.TaskStatusClaimed, domain.TaskStatusPending, true},
|
||||
{"trial completes", domain.TaskStatusRunning, domain.TaskStatusWaitingConfirmation, true},
|
||||
{"trial needs manual review", domain.TaskStatusRunning, domain.TaskStatusNeedsManual, true},
|
||||
{"authorize confirmed trial", domain.TaskStatusWaitingConfirmation, domain.TaskStatusAuthorized, true},
|
||||
{"reject confirmed trial", domain.TaskStatusWaitingConfirmation, domain.TaskStatusCanceled, true},
|
||||
{"start authorized order leg", domain.TaskStatusAuthorized, domain.TaskStatusOrdering, true},
|
||||
{"start purchase", domain.TaskStatusDraft, domain.TaskStatusPending, true},
|
||||
{"cancel draft before fence", domain.TaskStatusDraft, domain.TaskStatusCanceled, true},
|
||||
{"claim purchase", domain.TaskStatusPending, domain.TaskStatusClaimed, true},
|
||||
{"release expired authorization", domain.TaskStatusPending, domain.TaskStatusDraft, true},
|
||||
{"start ordering", domain.TaskStatusClaimed, domain.TaskStatusOrdering, true},
|
||||
{"release unstarted claim", domain.TaskStatusClaimed, domain.TaskStatusDraft, true},
|
||||
{"ordering needs manual review", domain.TaskStatusOrdering, domain.TaskStatusNeedsManual, true},
|
||||
{"order reaches payment", domain.TaskStatusOrdering, domain.TaskStatusWaitingPayment, true},
|
||||
{"order needs manual review before fence", domain.TaskStatusOrdering, domain.TaskStatusNeedsManual, true},
|
||||
{"order needs reconciliation", domain.TaskStatusOrdering, domain.TaskStatusReconciliationRequired, true},
|
||||
{"manual review resets draft", domain.TaskStatusNeedsManual, domain.TaskStatusDraft, true},
|
||||
{"manual review cancels before fence", domain.TaskStatusNeedsManual, domain.TaskStatusCanceled, true},
|
||||
{"payment verified", domain.TaskStatusWaitingPayment, domain.TaskStatusSucceeded, true},
|
||||
{"cannot skip trial", domain.TaskStatusDraft, domain.TaskStatusAuthorized, false},
|
||||
{"trial cannot enter order leg", domain.TaskStatusRunning, domain.TaskStatusOrdering, false},
|
||||
{"reconcile confirms waiting payment", domain.TaskStatusReconciliationRequired, domain.TaskStatusWaitingPayment, true},
|
||||
{"reconcile confirms failed", domain.TaskStatusReconciliationRequired, domain.TaskStatusFailed, true},
|
||||
{"cannot skip authorization", domain.TaskStatusDraft, domain.TaskStatusOrdering, false},
|
||||
{"ordering cannot return pending", domain.TaskStatusOrdering, domain.TaskStatusPending, false},
|
||||
{"ordering cannot bypass manual review to draft", domain.TaskStatusOrdering, domain.TaskStatusDraft, false},
|
||||
{"terminal task cannot restart", domain.TaskStatusSucceeded, domain.TaskStatusPending, false},
|
||||
{"unknown status is rejected", domain.TaskStatus("UNKNOWN"), domain.TaskStatusPending, false},
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ package migrations_test
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/migrations"
|
||||
@@ -13,6 +16,8 @@ import (
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
const migrationTime = "2026-08-04T00:00:00Z"
|
||||
|
||||
func TestUpDownAndIdempotence(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
directory := migrationDirectory(t)
|
||||
@@ -21,173 +26,325 @@ func TestUpDownAndIdempotence(t *testing.T) {
|
||||
if err := migrations.Up(context, database, directory); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 1)
|
||||
assertVersion(t, database, 2)
|
||||
assertTableExists(t, database, "tasks", true)
|
||||
assertTableExists(t, database, "spec_trials", true)
|
||||
assertTableExists(t, database, "spec_trials", false)
|
||||
assertTableExists(t, database, "order_authorizations", true)
|
||||
assertTableExists(t, database, "purchase_attempts", true)
|
||||
assertTableExists(t, database, "order_submissions", true)
|
||||
assertTableExists(t, database, "single_pass_upgrade_guard", false)
|
||||
|
||||
if err := migrations.Up(context, database, directory); err != nil {
|
||||
t.Fatalf("reapply migrations: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 1)
|
||||
assertVersion(t, database, 2)
|
||||
|
||||
if err := migrations.Down(context, database, directory); err != nil {
|
||||
t.Fatalf("roll back migration: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 0)
|
||||
assertTableExists(t, database, "tasks", false)
|
||||
assertTableExists(t, database, "spec_trials", false)
|
||||
assertTableExists(t, database, "order_authorizations", false)
|
||||
assertTableExists(t, database, "order_submissions", false)
|
||||
|
||||
if err := migrations.Up(context, database, directory); err != nil {
|
||||
t.Fatalf("apply migration after rollback: %v", err)
|
||||
t.Fatalf("roll back v2 migration: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 1)
|
||||
assertTableExists(t, database, "spec_trials", true)
|
||||
assertTableExists(t, database, "purchase_attempts", false)
|
||||
assertTableExists(t, database, "single_pass_downgrade_guard", false)
|
||||
|
||||
if err := migrations.Up(context, database, directory); err != nil {
|
||||
t.Fatalf("reapply v2 after rollback: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 2)
|
||||
}
|
||||
|
||||
func TestSchemaConstraints(t *testing.T) {
|
||||
func TestUpgradePreservesManualDraftLosslessly(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
migrateToV1(t, database)
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO tasks (
|
||||
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||
reference_asset_id, status, version, created_at, updated_at
|
||||
) VALUES ('draft-one', 'MANUAL', 'source-ref', 'title', 'goods', 'white', 'XL', 2, '80.50',
|
||||
'asset-id', 'DRAFT', 7, '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z')
|
||||
`); err != nil {
|
||||
t.Fatalf("insert v1 draft: %v", err)
|
||||
}
|
||||
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("upgrade v1 draft: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 2)
|
||||
var got struct {
|
||||
id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string
|
||||
quantity, version int
|
||||
}
|
||||
if err := database.QueryRow(`SELECT id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price, reference_asset_id, status, version, created_at, updated_at FROM tasks WHERE id = 'draft-one'`).Scan(
|
||||
&got.id, &got.source, &got.sourceRef, &got.title, &got.goodsID, &got.color, &got.size, &got.quantity, &got.maxPrice, &got.assetID, &got.status, &got.version, &got.created, &got.updated,
|
||||
); err != nil {
|
||||
t.Fatalf("read upgraded draft: %v", err)
|
||||
}
|
||||
if got != (struct {
|
||||
id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string
|
||||
quantity, version int
|
||||
}{"draft-one", "MANUAL", "source-ref", "title", "goods", "white", "XL", "80.50", "asset-id", "DRAFT", "2026-08-03T00:00:00Z", "2026-08-03T01:00:00Z", 2, 7}) {
|
||||
t.Fatalf("upgraded draft changed: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeRejectsLegacyExecutionDataAtomically(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*testing.T, *sql.DB)
|
||||
}{
|
||||
{"non-draft task", func(t *testing.T, database *sql.DB) {
|
||||
insertV1Task(t, database, "pending", "MANUAL", "PENDING", "1.00")
|
||||
}},
|
||||
{"non-manual task", func(t *testing.T, database *sql.DB) { insertV1Task(t, database, "excel", "EXCEL", "DRAFT", "1.00") }},
|
||||
{"invalid v2 money", func(t *testing.T, database *sql.DB) { insertV1Task(t, database, "zero", "MANUAL", "DRAFT", "0.00") }},
|
||||
{"third decimal place", func(t *testing.T, database *sql.DB) {
|
||||
insertV1Task(t, database, "third-decimal", "MANUAL", "DRAFT", "1.234")
|
||||
}},
|
||||
{"spec trial", func(t *testing.T, database *sql.DB) {
|
||||
insertV1Task(t, database, "task", "MANUAL", "DRAFT", "1.00")
|
||||
insertV1SpecTrial(t, database, "trial", "task")
|
||||
}},
|
||||
{"authorization", func(t *testing.T, database *sql.DB) {
|
||||
insertV1Task(t, database, "task", "MANUAL", "DRAFT", "1.00")
|
||||
insertV1SpecTrial(t, database, "trial", "task")
|
||||
insertV1Authorization(t, database, "auth", "task", "trial")
|
||||
}},
|
||||
{"submission", func(t *testing.T, database *sql.DB) {
|
||||
insertV1Task(t, database, "task", "MANUAL", "DRAFT", "1.00")
|
||||
insertV1SpecTrial(t, database, "trial", "task")
|
||||
insertV1Authorization(t, database, "auth", "task", "trial")
|
||||
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price, quantity_read, confirm_page_amount, created_at) VALUES ('submission', 'task', 'auth', 'command', 'dry-run', 'FENCED', '1.00', 1, '1.00', ? )`, migrationTime); err != nil {
|
||||
t.Fatalf("insert v1 submission: %v", err)
|
||||
}
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
migrateToV1(t, database)
|
||||
test.setup(t, database)
|
||||
before := v1RowCount(t, database)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||
t.Fatal("unsafe legacy data upgraded successfully")
|
||||
}
|
||||
assertVersion(t, database, 1)
|
||||
assertTableExists(t, database, "spec_trials", true)
|
||||
assertTableExists(t, database, "purchase_attempts", false)
|
||||
assertTableExists(t, database, "single_pass_upgrade_guard", false)
|
||||
if after := v1RowCount(t, database); after != before {
|
||||
t.Fatalf("v1 data changed after rejection: before=%d after=%d", before, after)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestV2SchemaConstraintsAndRelationships(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
|
||||
for _, column := range []struct {
|
||||
table string
|
||||
name string
|
||||
}{
|
||||
for _, column := range []struct{ table, name string }{
|
||||
{"tasks", "max_total_price"},
|
||||
{"spec_trials", "unit_price"},
|
||||
{"spec_trials", "total_price"},
|
||||
{"order_authorizations", "authorized_unit_price"},
|
||||
{"order_authorizations", "total_price_cap"},
|
||||
{"order_submissions", "verified_unit_price"},
|
||||
{"order_submissions", "confirm_page_amount"},
|
||||
{"purchase_attempts", "gate1_unit_price"},
|
||||
{"purchase_attempts", "gate2_unit_price"},
|
||||
{"purchase_attempts", "confirm_amount"},
|
||||
{"order_submissions", "gate1_unit_price"},
|
||||
{"order_submissions", "gate2_unit_price"},
|
||||
{"order_submissions", "confirm_amount"},
|
||||
} {
|
||||
assertColumnType(t, database, column.table, column.name, "TEXT")
|
||||
}
|
||||
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO tasks (
|
||||
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||
status, created_at, updated_at
|
||||
) VALUES ('bad-quantity', 'MANUAL', 'title', 'goods', 'white', 'XL', 0, '80.00', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
|
||||
`); err == nil {
|
||||
t.Fatal("insert task with quantity 0 succeeded")
|
||||
for _, legacy := range []string{"spec_trials", "authorized_unit_price", "spec_trial_id", "command_id", "dry_run_id"} {
|
||||
var count int
|
||||
if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE sql LIKE '%' || ? || '%'`, legacy).Scan(&count); err != nil {
|
||||
t.Fatalf("search schema for %s: %v", legacy, err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("legacy identifier %q remains in v2 schema", legacy)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO tasks (
|
||||
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||
status, created_at, updated_at
|
||||
) VALUES ('bad-price', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '80..00', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
|
||||
`); err == nil {
|
||||
t.Fatal("insert task with malformed decimal price succeeded")
|
||||
insertV2Task(t, database, "task-one", "MANUAL", "DRAFT")
|
||||
insertV2Task(t, database, "task-two", "MANUAL", "DRAFT")
|
||||
for index, value := range []string{"", "0", "0.00", "-1.00", "1e2", "1.", "1.234", " 1.00", "one"} {
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, 'MANUAL', 'title', 'goods', 'white', 'XL', 1, ?, 'DRAFT', ?, ?)`, "bad-price-"+strconv.Itoa(index), value, migrationTime, migrationTime); err == nil {
|
||||
t.Fatalf("invalid total price %q succeeded", value)
|
||||
}
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES ('bad-status', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '1.00', 'UNKNOWN', ?, ?)`, migrationTime, migrationTime); err == nil {
|
||||
t.Fatal("unknown task status succeeded")
|
||||
}
|
||||
insertV2Authorization(t, database, "auth-one", "task-one", 1, "start-one")
|
||||
insertV2Authorization(t, database, "auth-two", "task-two", 1, "start-two")
|
||||
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('bad-auth-price', 'task-one', 2, 'bad-price', 'goods', 'white', 'XL', 1, '1.234', 'ACTIVE', 'admin', ?, ?)`, migrationTime, migrationTime); err == nil {
|
||||
t.Fatal("third decimal authorization cap succeeded")
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('bad-auth-status', 'task-one', 2, 'bad-status', 'goods', 'white', 'XL', 1, '1.00', 'UNKNOWN', 'admin', ?, ?)`, migrationTime, migrationTime); err == nil {
|
||||
t.Fatal("unknown authorization status succeeded")
|
||||
}
|
||||
insertV2Authorization(t, database, "auth-one-b", "task-one", 2, "start-one-b")
|
||||
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('duplicate-version', 'task-one', 1, 'different-start', 'goods', 'white', 'XL', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, migrationTime, migrationTime); err == nil {
|
||||
t.Fatal("duplicate task version authorization succeeded")
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES ('cross-attempt', 'task-one', 'auth-two', 1, 'CLAIMED', ?)`, migrationTime); err == nil {
|
||||
t.Fatal("attempt using another task authorization succeeded")
|
||||
}
|
||||
insertV2Attempt(t, database, "attempt-one", "task-one", "auth-one", 1)
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, gate1_unit_price, started_at) VALUES ('bad-attempt-price', 'task-one', 'auth-one', 2, 'ORDERING', '1.234', ?)`, migrationTime); err == nil {
|
||||
t.Fatal("third decimal gate price succeeded")
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES ('bad-attempt-status', 'task-one', 'auth-one', 2, 'UNKNOWN', ?)`, migrationTime); err == nil {
|
||||
t.Fatal("unknown attempt status succeeded")
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, failure_code, started_at) VALUES ('bad-code', 'task-one', 'auth-one', 2, 'FAILED', 'FREE_TEXT', ?)`, migrationTime); err == nil {
|
||||
t.Fatal("unknown failure code succeeded")
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('cross-submission', 'task-one', 'auth-two', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
|
||||
t.Fatal("submission using another task authorization succeeded")
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('cross-authorization-submission', 'task-one', 'auth-one-b', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
|
||||
t.Fatal("submission combining another same-task authorization and attempt succeeded")
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('bad-submission-status', 'task-one', 'auth-one', 'attempt-one', 'UNKNOWN', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
|
||||
t.Fatal("unknown submission status succeeded")
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('bad-submission-price', 'task-one', 'auth-one', 'attempt-one', 'FENCED', '1.234', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
|
||||
t.Fatal("third decimal submission price succeeded")
|
||||
}
|
||||
insertV2Submission(t, database, "submission-one", "task-one", "auth-one", "attempt-one")
|
||||
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('duplicate-auth', 'task-one', 'auth-one', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
|
||||
t.Fatal("second submission for fenced authorization succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO tasks (
|
||||
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||
status, created_at, updated_at
|
||||
) VALUES ('fractional-quantity', 'MANUAL', 'title', 'goods', 'white', 'XL', 1.5, '80.00', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
|
||||
`); err == nil {
|
||||
t.Fatal("insert task with fractional quantity succeeded")
|
||||
func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*testing.T, *sql.DB)
|
||||
}{
|
||||
{"authorization", func(t *testing.T, database *sql.DB) {
|
||||
insertV2Task(t, database, "task", "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, "auth", "task", 1, "start")
|
||||
}},
|
||||
{"attempt", func(t *testing.T, database *sql.DB) {
|
||||
insertV2Task(t, database, "task", "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, "auth", "task", 1, "start")
|
||||
insertV2Attempt(t, database, "attempt", "task", "auth", 1)
|
||||
}},
|
||||
{"submission", func(t *testing.T, database *sql.DB) {
|
||||
insertV2Task(t, database, "task", "MANUAL", "DRAFT")
|
||||
insertV2Authorization(t, database, "auth", "task", 1, "start")
|
||||
insertV2Attempt(t, database, "attempt", "task", "auth", 1)
|
||||
insertV2Submission(t, database, "submission", "task", "auth", "attempt")
|
||||
}},
|
||||
{"non-draft task", func(t *testing.T, database *sql.DB) { insertV2Task(t, database, "pending", "MANUAL", "PENDING") }},
|
||||
{"non-manual task", func(t *testing.T, database *sql.DB) { insertV2Task(t, database, "excel", "EXCEL", "DRAFT") }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
database := openTestDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
test.setup(t, database)
|
||||
before := v2RowCount(t, database)
|
||||
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
|
||||
t.Fatal("unsafe v2 data downgraded successfully")
|
||||
}
|
||||
assertVersion(t, database, 2)
|
||||
assertTableExists(t, database, "purchase_attempts", true)
|
||||
assertTableExists(t, database, "spec_trials", false)
|
||||
assertTableExists(t, database, "single_pass_downgrade_guard", false)
|
||||
if after := v2RowCount(t, database); after != before {
|
||||
t.Fatalf("v2 data changed after rejected downgrade: before=%d after=%d", before, after)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO tasks (
|
||||
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||
status, created_at, updated_at
|
||||
) VALUES ('trailing-decimal', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '80.', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
|
||||
`); err == nil {
|
||||
t.Fatal("insert task with trailing decimal point succeeded")
|
||||
func migrateToV1(t *testing.T, database *sql.DB) {
|
||||
t.Helper()
|
||||
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
|
||||
t.Fatalf("apply v1: %v", err)
|
||||
}
|
||||
assertVersion(t, database, 1)
|
||||
}
|
||||
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO tasks (
|
||||
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||
status, created_at, updated_at
|
||||
) VALUES ('bad-status', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '80.00', 'UNKNOWN', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
|
||||
`); err == nil {
|
||||
t.Fatal("insert task with invalid status succeeded")
|
||||
func insertV1Task(t *testing.T, database *sql.DB, id, source, status, price string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, ?, ?, ?, ?)`, id, source, price, status, migrationTime, migrationTime); err != nil {
|
||||
t.Fatalf("insert v1 task: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
insertTask(t, database, "task-one")
|
||||
insertTask(t, database, "task-two")
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO spec_trials (
|
||||
id, task_id, attempt, product_title, selected_color, selected_size, unit_price,
|
||||
total_price, evidence_sha256, created_at
|
||||
) VALUES ('orphan-trial', 'missing-task', 1, 'title', 'white', 'XL', '32.50', '65.00', 'hash', '2026-08-03T00:00:00Z')
|
||||
`); err == nil {
|
||||
t.Fatal("insert spec trial without task succeeded")
|
||||
func insertV1SpecTrial(t *testing.T, database *sql.DB, id, taskID string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`INSERT INTO spec_trials (id, task_id, attempt, product_title, selected_color, selected_size, unit_price, total_price, evidence_sha256, created_at) VALUES (?, ?, 1, 'title', 'white', 'XL', '1.00', '1.00', 'hash', ?)`, id, taskID, migrationTime); err != nil {
|
||||
t.Fatalf("insert v1 spec trial: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
insertSpecTrial(t, database, "trial-one", "task-one")
|
||||
insertSpecTrial(t, database, "trial-two", "task-two")
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO order_authorizations (
|
||||
id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity,
|
||||
authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at
|
||||
) VALUES ('authorization-cross-task', 'task-one', 'trial-two', 1, 'goods', 'white', 'XL', 2, '32.50', '80.00', 'PENDING_DELIVERY', 'admin-one', '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z')
|
||||
`); err == nil {
|
||||
t.Fatal("insert authorization with a spec trial from another task succeeded")
|
||||
func insertV1Authorization(t *testing.T, database *sql.DB, id, taskID, trialID string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity, authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, ?, 1, 'goods', 'white', 'XL', 1, '1.00', '1.00', 'PENDING_DELIVERY', 'admin', ?, ?)`, id, taskID, trialID, migrationTime, migrationTime); err != nil {
|
||||
t.Fatalf("insert v1 authorization: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
insertAuthorization(t, database, "authorization-one", "task-one", "trial-one", 1)
|
||||
insertAuthorization(t, database, "authorization-task-two", "task-two", "trial-two", 1)
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO order_submissions (
|
||||
id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price,
|
||||
quantity_read, confirm_page_amount, created_at
|
||||
) VALUES ('submission-cross-task', 'task-one', 'authorization-task-two', 'command-cross-task', 'dry-run-cross-task', 'FENCED', '32.50', 2, '65.00', '2026-08-03T00:00:00Z')
|
||||
`); err == nil {
|
||||
t.Fatal("insert submission with an authorization from another task succeeded")
|
||||
func insertV2Task(t *testing.T, database *sql.DB, id, source, status string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, '1.00', ?, ?, ?)`, id, source, status, migrationTime, migrationTime); err != nil {
|
||||
t.Fatalf("insert v2 task: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO order_authorizations (
|
||||
id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity,
|
||||
authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at
|
||||
) VALUES ('authorization-duplicate', 'task-one', 'trial-one', 1, 'goods', 'white', 'XL', 2, '32.50', '80.00', 'PENDING_DELIVERY', 'admin-one', '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z')
|
||||
`); err == nil {
|
||||
t.Fatal("insert authorization with duplicate task version succeeded")
|
||||
func insertV2Authorization(t *testing.T, database *sql.DB, id, taskID string, version int, startKey string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, 'goods', 'white', 'XL', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, id, taskID, version, startKey, migrationTime, migrationTime); err != nil {
|
||||
t.Fatalf("insert v2 authorization: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
insertSubmission(t, database, "submission-one", "authorization-one", "command-one")
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO order_submissions (
|
||||
id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price,
|
||||
quantity_read, confirm_page_amount, created_at
|
||||
) VALUES ('submission-duplicate-auth', 'task-one', 'authorization-one', 'command-two', 'dry-run-two', 'FENCED', '32.50', 2, '65.00', '2026-08-03T00:00:00Z')
|
||||
`); err == nil {
|
||||
t.Fatal("insert submission with duplicate authorization succeeded")
|
||||
func insertV2Attempt(t *testing.T, database *sql.DB, id, taskID, authorizationID string, generation int) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, ?, 'CLAIMED', ?)`, id, taskID, authorizationID, generation, migrationTime); err != nil {
|
||||
t.Fatalf("insert v2 attempt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
insertAuthorization(t, database, "authorization-two", "task-one", "trial-one", 2)
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO order_submissions (
|
||||
id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price,
|
||||
quantity_read, confirm_page_amount, created_at
|
||||
) VALUES ('submission-duplicate-command', 'task-one', 'authorization-two', 'command-one', 'dry-run-three', 'FENCED', '32.50', 2, '65.00', '2026-08-03T00:00:00Z')
|
||||
`); err == nil {
|
||||
t.Fatal("insert submission with duplicate command succeeded")
|
||||
func insertV2Submission(t *testing.T, database *sql.DB, id, taskID, authorizationID, attemptID string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES (?, ?, ?, ?, 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, id, taskID, authorizationID, attemptID, migrationTime); err != nil {
|
||||
t.Fatalf("insert v2 submission: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func v1RowCount(t *testing.T, database *sql.DB) int {
|
||||
t.Helper()
|
||||
var count int
|
||||
if err := database.QueryRow(`SELECT (SELECT COUNT(*) FROM tasks) + (SELECT COUNT(*) FROM spec_trials) + (SELECT COUNT(*) FROM order_authorizations) + (SELECT COUNT(*) FROM order_submissions)`).Scan(&count); err != nil {
|
||||
t.Fatalf("count v1 rows: %v", err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func v2RowCount(t *testing.T, database *sql.DB) int {
|
||||
t.Helper()
|
||||
var count int
|
||||
if err := database.QueryRow(`SELECT (SELECT COUNT(*) FROM tasks) + (SELECT COUNT(*) FROM order_authorizations) + (SELECT COUNT(*) FROM purchase_attempts) + (SELECT COUNT(*) FROM order_submissions)`).Scan(&count); err != nil {
|
||||
t.Fatalf("count v2 rows: %v", err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func openTestDatabase(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
database, err := sqlite.Open(filepath.Join(t.TempDir(), "migrations.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open test database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := database.Close(); err != nil {
|
||||
t.Errorf("close test database: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
return database
|
||||
}
|
||||
|
||||
@@ -197,7 +354,6 @@ func migrationDirectory(t *testing.T) string {
|
||||
if !ok {
|
||||
t.Fatal("locate migration test source")
|
||||
}
|
||||
|
||||
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
|
||||
}
|
||||
|
||||
@@ -234,50 +390,12 @@ func assertColumnType(t *testing.T, database *sql.DB, table, column, want string
|
||||
}
|
||||
}
|
||||
|
||||
func insertTask(t *testing.T, database *sql.DB, id string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO tasks (
|
||||
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||
status, created_at, updated_at
|
||||
) VALUES (?, 'MANUAL', 'title', 'goods', 'white', 'XL', 2, '80.00', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
|
||||
`, id); err != nil {
|
||||
t.Fatalf("insert task: %v", err)
|
||||
func TestV2MigrationSQLDoesNotDisableForeignKeys(t *testing.T) {
|
||||
contents, err := os.ReadFile(filepath.Join(migrationDirectory(t), "00002_single_pass_model.sql"))
|
||||
if err != nil {
|
||||
t.Fatalf("read migration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertSpecTrial(t *testing.T, database *sql.DB, id, taskID string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO spec_trials (
|
||||
id, task_id, attempt, product_title, selected_color, selected_size, unit_price,
|
||||
total_price, evidence_sha256, created_at
|
||||
) VALUES (?, ?, 1, 'title', 'white', 'XL', '32.50', '65.00', 'hash', '2026-08-03T00:00:00Z')
|
||||
`, id, taskID); err != nil {
|
||||
t.Fatalf("insert spec trial: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertAuthorization(t *testing.T, database *sql.DB, id, taskID, specTrialID string, version int) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO order_authorizations (
|
||||
id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity,
|
||||
authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at
|
||||
) VALUES (?, ?, ?, ?, 'goods', 'white', 'XL', 2, '32.50', '80.00', 'PENDING_DELIVERY', 'admin-one', '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z')
|
||||
`, id, taskID, specTrialID, version); err != nil {
|
||||
t.Fatalf("insert authorization: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func insertSubmission(t *testing.T, database *sql.DB, id, authorizationID, commandID string) {
|
||||
t.Helper()
|
||||
if _, err := database.Exec(`
|
||||
INSERT INTO order_submissions (
|
||||
id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price,
|
||||
quantity_read, confirm_page_amount, created_at
|
||||
) VALUES (?, 'task-one', ?, ?, 'dry-run-one', 'FENCED', '32.50', 2, '65.00', '2026-08-03T00:00:00Z')
|
||||
`, id, authorizationID, commandID); err != nil {
|
||||
t.Fatalf("insert submission: %v", err)
|
||||
if strings.Contains(strings.ToUpper(string(contents)), "PRAGMA FOREIGN_KEYS = OFF") {
|
||||
t.Fatal("migration disables foreign keys")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,18 +2,293 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"cmbuyer/admin/internal/auth"
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
"cmbuyer/admin/internal/transport/webui"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
|
||||
func NewRouter() *gin.Engine {
|
||||
router := gin.New()
|
||||
const maxFormBytes = 8 << 10
|
||||
|
||||
router.GET("/healthz", func(context *gin.Context) {
|
||||
context.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
// Options 是路由层需要的安全依赖。凭据由启动配置注入,不能在路由中设置默认值。
|
||||
type Options struct {
|
||||
AdminUsername string
|
||||
AdminPasswordBcrypt string
|
||||
Sessions *auth.Manager
|
||||
Tasks tasks.Store
|
||||
}
|
||||
|
||||
return router
|
||||
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
|
||||
func NewRouter(options Options) (*gin.Engine, error) {
|
||||
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil {
|
||||
return nil, errors.New("server authentication options are incomplete")
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
router.Use(securityHeaders())
|
||||
router.GET("/healthz", healthz)
|
||||
router.GET("/login", loginPage(options))
|
||||
router.POST("/login", login(options))
|
||||
router.POST("/logout", logout(options))
|
||||
router.GET("/tasks", tasksPage(options))
|
||||
router.GET("/tasks/new", newTaskPage(options))
|
||||
router.POST("/tasks", createTask(options))
|
||||
|
||||
return router, nil
|
||||
}
|
||||
|
||||
func healthz(context *gin.Context) {
|
||||
context.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func securityHeaders() gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
context.Header("Cache-Control", "no-store")
|
||||
context.Header("X-Content-Type-Options", "nosniff")
|
||||
context.Header("Referrer-Policy", "no-referrer")
|
||||
context.Header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
|
||||
context.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func loginPage(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
csrfToken, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
|
||||
if authenticated {
|
||||
context.Redirect(http.StatusSeeOther, "/tasks")
|
||||
return
|
||||
}
|
||||
|
||||
renderLogin(context, http.StatusOK, csrfToken, returnTo(context.Query("return_to")), "", "")
|
||||
}
|
||||
}
|
||||
|
||||
func login(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
if !parseForm(context) {
|
||||
return
|
||||
}
|
||||
form := context.Request.PostForm
|
||||
csrfToken := form.Get("csrf_token")
|
||||
returnPath := returnTo(form.Get("return_to"))
|
||||
username := form.Get("username")
|
||||
password := form.Get("password")
|
||||
|
||||
if _, ok := options.Sessions.VerifyCSRF(context.Request, csrfToken); !ok {
|
||||
newCSRF, _ := options.Sessions.Ensure(context.Writer, context.Request)
|
||||
renderLogin(context, http.StatusForbidden, newCSRF, returnPath, "", "请求已过期,请重新登录。")
|
||||
return
|
||||
}
|
||||
|
||||
usernameMatches := subtle.ConstantTimeCompare([]byte(options.AdminUsername), []byte(username)) == 1
|
||||
passwordMatches := bcrypt.CompareHashAndPassword([]byte(options.AdminPasswordBcrypt), []byte(password)) == nil
|
||||
if !usernameMatches || !passwordMatches {
|
||||
csrf, _ := options.Sessions.Ensure(context.Writer, context.Request)
|
||||
renderLogin(context, http.StatusUnauthorized, csrf, returnPath, "", "账号或密码不正确,请检查后重试。")
|
||||
return
|
||||
}
|
||||
|
||||
options.Sessions.RotateAuthenticated(context.Writer, context.Request)
|
||||
context.Redirect(http.StatusSeeOther, returnPath)
|
||||
}
|
||||
}
|
||||
|
||||
func logout(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
if !parseForm(context) {
|
||||
return
|
||||
}
|
||||
authenticated, ok := options.Sessions.VerifyCSRF(context.Request, context.Request.PostForm.Get("csrf_token"))
|
||||
if !ok || !authenticated {
|
||||
context.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
options.Sessions.Logout(context.Writer, context.Request)
|
||||
context.Redirect(http.StatusSeeOther, "/login")
|
||||
}
|
||||
}
|
||||
|
||||
func tasksPage(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
csrfToken, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
|
||||
if !authenticated {
|
||||
context.Redirect(http.StatusSeeOther, "/login?return_to="+url.QueryEscape(context.Request.URL.RequestURI()))
|
||||
return
|
||||
}
|
||||
|
||||
drafts, err := options.Tasks.ListDrafts(context.Request.Context())
|
||||
if err != nil {
|
||||
context.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data := webui.TasksData{CSRFToken: csrfToken, Drafts: drafts}
|
||||
for _, draft := range drafts {
|
||||
if draft.ID == context.Query("created") {
|
||||
data.Success = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if context.Query("create") == "1" {
|
||||
form, err := newTaskForm()
|
||||
if err != nil {
|
||||
context.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data.OpenForm = true
|
||||
data.Form = form
|
||||
data.FocusField = "title"
|
||||
}
|
||||
renderTasks(context, http.StatusOK, data)
|
||||
}
|
||||
}
|
||||
|
||||
func newTaskPage(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
csrf, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
|
||||
if !authenticated {
|
||||
context.Redirect(http.StatusSeeOther, "/login?return_to=%2Ftasks%2Fnew")
|
||||
return
|
||||
}
|
||||
form, err := newTaskForm()
|
||||
if err != nil {
|
||||
context.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
renderTasks(context, http.StatusOK, webui.TasksData{CSRFToken: csrf, Form: form, FullPage: true, FocusField: "title"})
|
||||
}
|
||||
}
|
||||
func createTask(options Options) gin.HandlerFunc {
|
||||
return func(context *gin.Context) {
|
||||
if !parseForm(context) {
|
||||
return
|
||||
}
|
||||
requestForm := context.Request.PostForm
|
||||
authenticated, csrfOK := options.Sessions.VerifyCSRF(context.Request, requestForm.Get("csrf_token"))
|
||||
if !csrfOK || !authenticated {
|
||||
context.Status(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
form := taskForm(requestForm)
|
||||
draft, validation := tasks.Validate(form)
|
||||
if draft.GoodsID != "" {
|
||||
form.ProductURL = tasks.CanonicalURL(draft.GoodsID)
|
||||
}
|
||||
fullPage := requestForm.Get("form_mode") == "full"
|
||||
if !validation.Valid() {
|
||||
drafts, err := options.Tasks.ListDrafts(context.Request.Context())
|
||||
if err != nil {
|
||||
context.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
renderTasks(context, http.StatusBadRequest, webui.TasksData{CSRFToken: csrfFor(context, options), Drafts: drafts, Form: form, Errors: validation, OpenForm: !fullPage, FullPage: fullPage, FocusField: firstError(validation)})
|
||||
return
|
||||
}
|
||||
created, err := options.Tasks.CreateDraft(context.Request.Context(), draft)
|
||||
if err != nil {
|
||||
if errors.Is(err, tasks.ErrCreateKeyConflict) {
|
||||
validation["create_key"] = "该创建请求已用于另一条任务,请重新打开表单。"
|
||||
drafts, listErr := options.Tasks.ListDrafts(context.Request.Context())
|
||||
if listErr != nil {
|
||||
context.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
renderTasks(context, http.StatusConflict, webui.TasksData{CSRFToken: csrfFor(context, options), Drafts: drafts, Form: form, Errors: validation, OpenForm: !fullPage, FullPage: fullPage, FocusField: firstError(validation)})
|
||||
return
|
||||
}
|
||||
context.Status(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
context.Redirect(http.StatusSeeOther, "/tasks?created="+url.QueryEscape(created.ID))
|
||||
}
|
||||
}
|
||||
|
||||
func newTaskForm() (tasks.Form, error) {
|
||||
key, err := tasks.NewCreateKey()
|
||||
if err != nil {
|
||||
return tasks.Form{}, err
|
||||
}
|
||||
return tasks.Form{CreateKey: key}, nil
|
||||
}
|
||||
func taskForm(form url.Values) tasks.Form {
|
||||
return tasks.Form{CreateKey: form.Get("create_key"), Title: form.Get("title"), ProductURL: form.Get("product_url"), SKUColor: form.Get("sku_color"), SKUSize: form.Get("sku_size"), Quantity: form.Get("quantity"), MaxTotalPrice: form.Get("max_total_price")}
|
||||
}
|
||||
func csrfFor(context *gin.Context, options Options) string {
|
||||
csrf, _ := options.Sessions.Ensure(context.Writer, context.Request)
|
||||
return csrf
|
||||
}
|
||||
func renderTasks(context *gin.Context, status int, data webui.TasksData) {
|
||||
context.Header("Content-Type", "text/html; charset=utf-8")
|
||||
context.Status(status)
|
||||
if err := webui.RenderTasks(context.Writer, data); err != nil {
|
||||
_ = context.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func renderLogin(context *gin.Context, status int, csrfToken, returnPath, username, message string) {
|
||||
context.Header("Content-Type", "text/html; charset=utf-8")
|
||||
context.Status(status)
|
||||
if err := webui.RenderLogin(context.Writer, webui.LoginData{
|
||||
CSRFToken: csrfToken,
|
||||
ReturnTo: returnPath,
|
||||
Username: username,
|
||||
Error: message,
|
||||
}); err != nil {
|
||||
_ = context.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func parseForm(context *gin.Context) bool {
|
||||
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxFormBytes)
|
||||
if err := context.Request.ParseForm(); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
context.Status(http.StatusRequestEntityTooLarge)
|
||||
} else {
|
||||
context.Status(http.StatusBadRequest)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func firstError(validation tasks.Errors) string {
|
||||
for _, field := range []string{"title", "product_url", "sku_color", "sku_size", "quantity", "max_total_price"} {
|
||||
if _, ok := validation[field]; ok {
|
||||
return field
|
||||
}
|
||||
}
|
||||
return "title"
|
||||
}
|
||||
|
||||
func returnTo(value string) string {
|
||||
if value == "/tasks" || strings.HasPrefix(value, "/tasks/") || strings.HasPrefix(value, "/tasks?") {
|
||||
if strings.Contains(value, "\\") || strings.Contains(value, "%") || strings.HasPrefix(value, "//") {
|
||||
return "/tasks"
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(value)
|
||||
if err == nil && parsed.IsAbs() == false && parsed.Host == "" && hasSafeTaskPath(parsed.Path) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
return "/tasks"
|
||||
}
|
||||
|
||||
func hasSafeTaskPath(path string) bool {
|
||||
for _, segment := range strings.Split(path, "/") {
|
||||
if segment == "." || segment == ".." {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,28 +1,481 @@
|
||||
package server_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmbuyer/admin/internal/auth"
|
||||
"cmbuyer/admin/internal/server"
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
var csrfPattern = regexp.MustCompile(`name="csrf_token" value="([^"]+)"`)
|
||||
var createKeyPattern = regexp.MustCompile(`name="create_key" value="([^"]+)"`)
|
||||
|
||||
func TestHealthzIsPublic(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
server.NewRouter().ServeHTTP(response, request)
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("healthz status = %d, want %d", response.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
if contentType := response.Header().Get("Content-Type"); contentType != "application/json; charset=utf-8" {
|
||||
t.Fatalf("healthz content type = %q, want application/json; charset=utf-8", contentType)
|
||||
}
|
||||
|
||||
if body := response.Body.String(); body != "{\"status\":\"ok\"}" {
|
||||
t.Fatalf("healthz body = %q, want {\"status\":\"ok\"}", body)
|
||||
}
|
||||
assertSecurityHeaders(t, response)
|
||||
}
|
||||
|
||||
func TestTasksRequiresLoginAndBlocksOpenRedirects(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
|
||||
tasks := serve(router, http.MethodGet, "/tasks", nil, nil)
|
||||
if tasks.Code != http.StatusSeeOther {
|
||||
t.Fatalf("GET /tasks status = %d, want %d", tasks.Code, http.StatusSeeOther)
|
||||
}
|
||||
if location := tasks.Header().Get("Location"); location != "/login?return_to=%2Ftasks" {
|
||||
t.Fatalf("GET /tasks location = %q, want login return path", location)
|
||||
}
|
||||
|
||||
for _, target := range []string{"https://example.invalid", "//example.invalid", `\\example.invalid`, "/other", "/tasks/..", "/tasks/../other", "/tasks/%2e%2e", "%2F%2Fevil.invalid", "%252F%252Fevil.invalid"} {
|
||||
response := serve(router, http.MethodGet, "/login?return_to="+url.QueryEscape(target), nil, nil)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("GET /login return_to=%q status = %d, want 200", target, response.Code)
|
||||
}
|
||||
if strings.Contains(response.Body.String(), target) || !strings.Contains(response.Body.String(), `name="return_to" value="/tasks"`) {
|
||||
t.Fatalf("GET /login accepted unsafe return_to %q", target)
|
||||
}
|
||||
}
|
||||
|
||||
encodedPath := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%252F..", nil, nil)
|
||||
if !strings.Contains(encodedPath.Body.String(), `name="return_to" value="/tasks"`) {
|
||||
t.Fatal("encoded parent path was accepted as return_to")
|
||||
}
|
||||
encodedQuery := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%3Fnext%3D%252Ftasks%252F..", nil, nil)
|
||||
if !strings.Contains(encodedQuery.Body.String(), `name="return_to" value="/tasks"`) {
|
||||
t.Fatal("encoded query bypass was accepted as return_to")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRotatesSessionAndCSRF(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
initial := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%3Fview%3Dmine", nil, nil)
|
||||
oldCookie := sessionCookie(t, initial)
|
||||
oldCSRF := csrfToken(t, initial.Body.String())
|
||||
|
||||
login := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"csrf_token": {oldCSRF},
|
||||
"return_to": {"/tasks?view=mine"},
|
||||
"username": {"admin"},
|
||||
"password": {"test-password"},
|
||||
}, oldCookie)
|
||||
if login.Code != http.StatusSeeOther || login.Header().Get("Location") != "/tasks?view=mine" {
|
||||
t.Fatalf("successful login = (%d, %q), want 303 /tasks?view=mine", login.Code, login.Header().Get("Location"))
|
||||
}
|
||||
newCookie := sessionCookie(t, login)
|
||||
if newCookie.Value == oldCookie.Value {
|
||||
t.Fatal("successful login reused the anonymous session cookie")
|
||||
}
|
||||
|
||||
tasks := serve(router, http.MethodGet, "/tasks", nil, newCookie)
|
||||
if tasks.Code != http.StatusOK {
|
||||
t.Fatalf("GET /tasks after login status = %d, want 200", tasks.Code)
|
||||
}
|
||||
if newCSRF := csrfToken(t, tasks.Body.String()); newCSRF == oldCSRF {
|
||||
t.Fatal("successful login reused the anonymous CSRF token")
|
||||
}
|
||||
for _, forbidden := range []string{"建单", "试选", "拼多多", "规格", "单价", "证据"} {
|
||||
if strings.Contains(tasks.Body.String(), forbidden) {
|
||||
t.Fatalf("task shell must not expose deferred feature content %q", forbidden)
|
||||
}
|
||||
}
|
||||
assertSecurityHeaders(t, initial)
|
||||
assertSecurityHeaders(t, tasks)
|
||||
}
|
||||
|
||||
func TestLoginPageIncludesAccessibleFormBasics(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
page := serve(router, http.MethodGet, "/login", nil, nil)
|
||||
body := page.Body.String()
|
||||
for _, want := range []string{
|
||||
`<label for="username">`,
|
||||
`<label for="password">`,
|
||||
`autocomplete="username"`,
|
||||
`autocomplete="current-password"`,
|
||||
`min-height:44px`,
|
||||
`:focus-visible`,
|
||||
`prefers-reduced-motion`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("login page is missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "http://") || strings.Contains(body, "https://") || strings.Contains(body, "<script") {
|
||||
t.Fatal("login page must not load external resources or require client-side JavaScript")
|
||||
}
|
||||
|
||||
failure := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"csrf_token": {csrfToken(t, body)},
|
||||
"username": {"admin"},
|
||||
"password": {"wrong"},
|
||||
}, sessionCookie(t, page))
|
||||
if !strings.Contains(failure.Body.String(), `role="alert"`) {
|
||||
t.Fatal("login failure must announce its error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginCSRFAndCredentialFailuresAreSafe(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
page := serve(router, http.MethodGet, "/login", nil, nil)
|
||||
cookie := sessionCookie(t, page)
|
||||
|
||||
withoutCSRF := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"username": {"admin"},
|
||||
"password": {"test-password"},
|
||||
}, cookie)
|
||||
if withoutCSRF.Code != http.StatusForbidden || !strings.Contains(withoutCSRF.Body.String(), "请求已过期") {
|
||||
t.Fatalf("login without CSRF = (%d, %q), want rejected form", withoutCSRF.Code, withoutCSRF.Body.String())
|
||||
}
|
||||
|
||||
page = serve(router, http.MethodGet, "/login", nil, cookie)
|
||||
badCredentials := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"csrf_token": {csrfToken(t, page.Body.String())},
|
||||
"username": {"unknown"},
|
||||
"password": {"wrong"},
|
||||
}, cookie)
|
||||
if badCredentials.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("login with invalid credentials status = %d, want 401", badCredentials.Code)
|
||||
}
|
||||
if body := badCredentials.Body.String(); !strings.Contains(body, "账号或密码不正确") || strings.Contains(body, "unknown") {
|
||||
t.Fatalf("invalid login leaked account detail: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTamperedCookieCannotAccessTasks(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
page := serve(router, http.MethodGet, "/login", nil, nil)
|
||||
cookie := sessionCookie(t, page)
|
||||
|
||||
tampered := *cookie
|
||||
tampered.Value = flipCookieValue(t, cookie.Value)
|
||||
response := serve(router, http.MethodGet, "/tasks", nil, &tampered)
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("tampered cookie status = %d, want 303", response.Code)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestTaskCreationRendersSharedFormsAndPersistsOnlyDraft(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
cookie := authenticate(t, router)
|
||||
|
||||
modal := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
|
||||
if modal.Code != http.StatusOK {
|
||||
t.Fatalf("GET dialog form status = %d, want 200", modal.Code)
|
||||
}
|
||||
fullPage := serve(router, http.MethodGet, "/tasks/new", nil, cookie)
|
||||
if fullPage.Code != http.StatusOK {
|
||||
t.Fatalf("GET full form status = %d, want 200", fullPage.Code)
|
||||
}
|
||||
for _, want := range []string{`<div class="modal-scrim"`, `<dialog open`, `aria-modal="true"`, `name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `type="url" inputmode="url" maxlength="2048"`, `type="number" inputmode="numeric" min="1" step="1"`, `inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?"`, `maxlength="120"`, `maxlength="80"`, `required`, `autofocus`, `导入</button><a class="button primary"`, `type="search" disabled`, `disabled>筛选</button>`, `disabled>清除</button>`, `min-height:44px`, `overflow-x:auto`, `prefers-reduced-motion`} {
|
||||
if !strings.Contains(modal.Body.String(), want) {
|
||||
t.Fatalf("dialog form is missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{`name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `name="form_mode" value="full"`} {
|
||||
if !strings.Contains(fullPage.Body.String(), want) {
|
||||
t.Fatalf("full-page form is missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := serve(router, http.MethodPost, "/tasks", url.Values{
|
||||
"csrf_token": {csrfToken(t, modal.Body.String())},
|
||||
"create_key": {createKey(t, modal.Body.String())},
|
||||
"title": {`<script>alert(1)</script>`},
|
||||
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&uin=discard"},
|
||||
"sku_color": {"black"},
|
||||
"sku_size": {"M"},
|
||||
"quantity": {"0"},
|
||||
"max_total_price": {"12.80"},
|
||||
"form_mode": {"dialog"},
|
||||
}, cookie)
|
||||
if invalid.Code != http.StatusBadRequest || !strings.Contains(invalid.Body.String(), `<dialog open`) || !strings.Contains(invalid.Body.String(), "数量必须是正整数") || !strings.Contains(invalid.Body.String(), `role="alert"`) || !strings.Contains(invalid.Body.String(), `href="#quantity"`) || !strings.Contains(invalid.Body.String(), `aria-describedby="quantity-error"`) || !strings.Contains(invalid.Body.String(), `autofocus`) {
|
||||
t.Fatalf("invalid create = (%d, %q), want dialog validation response", invalid.Code, invalid.Body.String())
|
||||
}
|
||||
if strings.Contains(invalid.Body.String(), `<script>alert(1)</script>`) || !strings.Contains(invalid.Body.String(), `<script>alert(1)</script>`) {
|
||||
t.Fatalf("invalid create did not safely preserve title: %q", invalid.Body.String())
|
||||
}
|
||||
if strings.Contains(invalid.Body.String(), "uin=discard") || !strings.Contains(invalid.Body.String(), `value="https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"`) {
|
||||
t.Fatalf("invalid create did not canonicalize product URL: %q", invalid.Body.String())
|
||||
}
|
||||
|
||||
createPage := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
|
||||
key := createKey(t, createPage.Body.String())
|
||||
created := serve(router, http.MethodPost, "/tasks", url.Values{
|
||||
"csrf_token": {csrfToken(t, createPage.Body.String())},
|
||||
"create_key": {key},
|
||||
"title": {"<b>夏季上衣</b>"},
|
||||
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=discard"},
|
||||
"sku_color": {"black"},
|
||||
"sku_size": {"M"},
|
||||
"quantity": {"2"},
|
||||
"max_total_price": {"12.8"},
|
||||
"form_mode": {"dialog"},
|
||||
}, cookie)
|
||||
if created.Code != http.StatusSeeOther || !strings.HasPrefix(created.Header().Get("Location"), "/tasks?created=") {
|
||||
t.Fatalf("valid create = (%d, %q), want 303 to a created-task acknowledgement", created.Code, created.Header().Get("Location"))
|
||||
}
|
||||
replay := serve(router, http.MethodPost, "/tasks", url.Values{
|
||||
"csrf_token": {csrfToken(t, createPage.Body.String())},
|
||||
"create_key": {key},
|
||||
"title": {"<b>夏季上衣</b>"},
|
||||
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=discard"},
|
||||
"sku_color": {"black"},
|
||||
"sku_size": {"M"},
|
||||
"quantity": {"2"},
|
||||
"max_total_price": {"12.8"},
|
||||
"form_mode": {"dialog"},
|
||||
}, cookie)
|
||||
if replay.Code != http.StatusSeeOther {
|
||||
t.Fatalf("idempotent replay status = %d, want 303", replay.Code)
|
||||
}
|
||||
conflict := serve(router, http.MethodPost, "/tasks", url.Values{
|
||||
"csrf_token": {csrfToken(t, createPage.Body.String())},
|
||||
"create_key": {key},
|
||||
"title": {"different task"},
|
||||
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"},
|
||||
"sku_color": {"black"},
|
||||
"sku_size": {"M"},
|
||||
"quantity": {"2"},
|
||||
"max_total_price": {"12.80"},
|
||||
"form_mode": {"dialog"},
|
||||
}, cookie)
|
||||
if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "该创建请求已用于另一条任务") {
|
||||
t.Fatalf("conflicting create = (%d, %q), want a 409 form error", conflict.Code, conflict.Body.String())
|
||||
}
|
||||
|
||||
list := serve(router, http.MethodGet, created.Header().Get("Location"), nil, cookie)
|
||||
if list.Code != http.StatusOK {
|
||||
t.Fatalf("GET /tasks status = %d, want 200", list.Code)
|
||||
}
|
||||
body := list.Body.String()
|
||||
for _, want := range []string{`任务已创建,已显示在列表首行。`, `<b>夏季上衣</b>`, `https://mobile.yangkeduo.com/goods.html?goods_id=937122477375`, `target="_blank"`, `rel="noopener noreferrer"`, `¥12.80`, `待开始`, `选择全部任务`, `选择任务`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("task list is missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"utm_source", "试选", "PENDING", "支付", "订单确认", "真机", "提交订单"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("task list exposed deferred scope %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCreationRequiresAuthenticationAndCSRF(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, nil); response.Code != http.StatusForbidden {
|
||||
t.Fatalf("anonymous POST /tasks = %d, want 403", response.Code)
|
||||
}
|
||||
cookie := authenticate(t, router)
|
||||
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, cookie); response.Code != http.StatusForbidden {
|
||||
t.Fatalf("POST /tasks without CSRF = %d, want 403", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCreationFailsClosedForMalformedOrOversizedForms(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
cookie := authenticate(t, router)
|
||||
page := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
|
||||
base := url.Values{
|
||||
"csrf_token": {csrfToken(t, page.Body.String())},
|
||||
"create_key": {createKey(t, page.Body.String())},
|
||||
"title": {"title"},
|
||||
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=malformed"},
|
||||
"sku_color": {"black"},
|
||||
"sku_size": {"M"},
|
||||
"quantity": {"1"},
|
||||
"max_total_price": {"1.00"},
|
||||
"form_mode": {"dialog"},
|
||||
}
|
||||
malformed := serve(router, http.MethodPost, "/tasks", base, cookie)
|
||||
if malformed.Code != http.StatusBadRequest || !strings.Contains(malformed.Body.String(), "canonical 商品链接") {
|
||||
t.Fatalf("malformed URL create = (%d, %q), want validation failure", malformed.Code, malformed.Body.String())
|
||||
}
|
||||
|
||||
oversized := url.Values{"csrf_token": {csrfToken(t, page.Body.String())}, "title": {strings.Repeat("x", 9<<10)}}
|
||||
if response := serve(router, http.MethodPost, "/tasks", oversized, cookie); response.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("oversized form status = %d, want 413", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSecurityHeaders(t *testing.T, response *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
want := map[string]string{
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"Content-Security-Policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
||||
}
|
||||
for name, expected := range want {
|
||||
if got := response.Header().Get(name); got != expected {
|
||||
t.Fatalf("%s = %q, want %q", name, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func flipCookieValue(t *testing.T, value string) string {
|
||||
t.Helper()
|
||||
if value == "" {
|
||||
t.Fatal("cannot tamper with an empty cookie")
|
||||
}
|
||||
if value[0] == 'A' {
|
||||
return "B" + value[1:]
|
||||
}
|
||||
return "A" + value[1:]
|
||||
}
|
||||
|
||||
func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
loginPage := serve(router, http.MethodGet, "/login", nil, nil)
|
||||
loginCookie := sessionCookie(t, loginPage)
|
||||
login := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"csrf_token": {csrfToken(t, loginPage.Body.String())},
|
||||
"username": {"admin"},
|
||||
"password": {"test-password"},
|
||||
}, loginCookie)
|
||||
authenticatedCookie := sessionCookie(t, login)
|
||||
|
||||
missingCSRF := serve(router, http.MethodPost, "/logout", url.Values{}, authenticatedCookie)
|
||||
if missingCSRF.Code != http.StatusForbidden {
|
||||
t.Fatalf("logout without CSRF status = %d, want 403", missingCSRF.Code)
|
||||
}
|
||||
|
||||
tasks := serve(router, http.MethodGet, "/tasks", nil, authenticatedCookie)
|
||||
logout := serve(router, http.MethodPost, "/logout", url.Values{
|
||||
"csrf_token": {csrfToken(t, tasks.Body.String())},
|
||||
}, authenticatedCookie)
|
||||
if logout.Code != http.StatusSeeOther || logout.Header().Get("Location") != "/login" {
|
||||
t.Fatalf("logout = (%d, %q), want 303 /login", logout.Code, logout.Header().Get("Location"))
|
||||
}
|
||||
if cookie := sessionCookie(t, logout); cookie.MaxAge >= 0 {
|
||||
t.Fatalf("logout cookie MaxAge = %d, want a deletion cookie", cookie.MaxAge)
|
||||
}
|
||||
|
||||
reused := serve(router, http.MethodGet, "/tasks", nil, authenticatedCookie)
|
||||
if reused.Code != http.StatusSeeOther {
|
||||
t.Fatalf("revoked session status = %d, want 303", reused.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatalf("generate bcrypt hash: %v", err)
|
||||
}
|
||||
manager := auth.NewManager([]byte(strings.Repeat("s", 32)), false)
|
||||
router, err := server.NewRouter(server.Options{
|
||||
AdminUsername: "admin",
|
||||
AdminPasswordBcrypt: string(hash),
|
||||
Sessions: manager,
|
||||
Tasks: &memoryStore{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter: %v", err)
|
||||
}
|
||||
return router, manager
|
||||
}
|
||||
|
||||
type memoryStore struct{ drafts []tasks.Draft }
|
||||
|
||||
func (store *memoryStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) {
|
||||
for _, existing := range store.drafts {
|
||||
if existing.ID == draft.ID {
|
||||
if existing.Title != draft.Title || existing.GoodsID != draft.GoodsID || existing.SKUColor != draft.SKUColor || existing.SKUSize != draft.SKUSize || existing.Quantity != draft.Quantity || existing.MaxTotalPrice != draft.MaxTotalPrice {
|
||||
return tasks.Draft{}, tasks.ErrCreateKeyConflict
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
store.drafts = append(store.drafts, draft)
|
||||
return draft, nil
|
||||
}
|
||||
func (store *memoryStore) ListDrafts(_ context.Context) ([]tasks.Draft, error) {
|
||||
return append([]tasks.Draft(nil), store.drafts...), nil
|
||||
}
|
||||
|
||||
func serve(router http.Handler, method, target string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
var body *strings.Reader
|
||||
if form == nil {
|
||||
body = strings.NewReader("")
|
||||
} else {
|
||||
body = strings.NewReader(form.Encode())
|
||||
}
|
||||
request := httptest.NewRequest(method, target, body)
|
||||
if form != nil {
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
if cookie != nil {
|
||||
request.AddCookie(cookie)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func sessionCookie(t *testing.T, response *httptest.ResponseRecorder) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, cookie := range response.Result().Cookies() {
|
||||
if cookie.Name == auth.CookieName {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
t.Fatalf("response did not set %s cookie", auth.CookieName)
|
||||
return nil
|
||||
}
|
||||
|
||||
func csrfToken(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
matches := csrfPattern.FindStringSubmatch(body)
|
||||
if len(matches) != 2 || matches[1] == "" {
|
||||
t.Fatalf("no CSRF token in response body: %q", body)
|
||||
}
|
||||
return matches[1]
|
||||
}
|
||||
|
||||
func createKey(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
matches := createKeyPattern.FindStringSubmatch(body)
|
||||
if len(matches) != 2 || matches[1] == "" {
|
||||
t.Fatalf("no create key in response body: %q", body)
|
||||
}
|
||||
return matches[1]
|
||||
}
|
||||
|
||||
func authenticate(t *testing.T, router http.Handler) *http.Cookie {
|
||||
t.Helper()
|
||||
page := serve(router, http.MethodGet, "/login", nil, nil)
|
||||
login := serve(router, http.MethodPost, "/login", url.Values{
|
||||
"csrf_token": {csrfToken(t, page.Body.String())},
|
||||
"username": {"admin"},
|
||||
"password": {"test-password"},
|
||||
}, sessionCookie(t, page))
|
||||
if login.Code != http.StatusSeeOther {
|
||||
t.Fatalf("authenticate status = %d, want 303", login.Code)
|
||||
}
|
||||
return sessionCookie(t, login)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const sqliteWriteTimeout = 2 * time.Second
|
||||
|
||||
type Store interface {
|
||||
CreateDraft(context.Context, Draft) (Draft, error)
|
||||
ListDrafts(context.Context) ([]Draft, error)
|
||||
}
|
||||
type SQLiteStore struct {
|
||||
database *sql.DB
|
||||
now func() time.Time
|
||||
createGate chan struct{}
|
||||
}
|
||||
|
||||
func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
|
||||
if database == nil {
|
||||
return nil, errors.New("database is required")
|
||||
}
|
||||
if _, err := database.Exec("SELECT 1 FROM tasks LIMIT 1"); err != nil {
|
||||
return nil, fmt.Errorf("tasks migration is not available: %w", err)
|
||||
}
|
||||
return &SQLiteStore{database: database, now: time.Now, createGate: make(chan struct{}, 1)}, nil
|
||||
}
|
||||
|
||||
func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) {
|
||||
writeContext, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
|
||||
defer cancel()
|
||||
// SQLite permits one writer at a time. Serializing this store's short create
|
||||
// transaction prevents concurrent retries of one create key from surfacing as busy.
|
||||
select {
|
||||
case store.createGate <- struct{}{}:
|
||||
defer func() { <-store.createGate }()
|
||||
case <-writeContext.Done():
|
||||
return Draft{}, writeContext.Err()
|
||||
}
|
||||
draft.CreatedAt = store.now().UTC()
|
||||
transaction, err := store.database.BeginTx(writeContext, nil)
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
defer transaction.Rollback()
|
||||
_, err = transaction.ExecContext(writeContext, `INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', ?, ?, ?, ?, ?, ?, 'DRAFT', 1, ?, ?)`, draft.ID, draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.Quantity, draft.MaxTotalPrice, draft.CreatedAt.Format(time.RFC3339Nano), draft.CreatedAt.Format(time.RFC3339Nano))
|
||||
if err == nil {
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
return draft, nil
|
||||
}
|
||||
existing, found, currentPhase, lookupErr := findDraft(writeContext, transaction, draft.ID)
|
||||
if lookupErr != nil {
|
||||
return Draft{}, lookupErr
|
||||
}
|
||||
if found && currentPhase && samePayload(existing, draft) {
|
||||
if err := transaction.Commit(); err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
if found {
|
||||
return Draft{}, ErrCreateKeyConflict
|
||||
}
|
||||
return Draft{}, err
|
||||
}
|
||||
|
||||
func (store *SQLiteStore) ListDrafts(ctx context.Context) ([]Draft, error) {
|
||||
// rowid makes equal timestamps deterministic: SQLite assigns it in insertion order,
|
||||
// whereas UUID v4 is deliberately not time-sortable.
|
||||
rows, err := store.database.QueryContext(ctx, `SELECT id, title, goods_id, sku_color, sku_size, quantity, max_total_price, created_at FROM tasks WHERE source = 'MANUAL' AND status = 'DRAFT' ORDER BY created_at DESC, rowid DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := []Draft{}
|
||||
for rows.Next() {
|
||||
draft, err := scanDraft(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, draft)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func findDraft(ctx context.Context, transaction *sql.Tx, id string) (Draft, bool, bool, error) {
|
||||
row := transaction.QueryRowContext(ctx, `SELECT id, title, goods_id, sku_color, sku_size, quantity, max_total_price, created_at, source, status, version FROM tasks WHERE id = ?`, id)
|
||||
var draft Draft
|
||||
var created, source, status string
|
||||
var version int
|
||||
err := row.Scan(&draft.ID, &draft.Title, &draft.GoodsID, &draft.SKUColor, &draft.SKUSize, &draft.Quantity, &draft.MaxTotalPrice, &created, &source, &status, &version)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Draft{}, false, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Draft{}, false, false, err
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339Nano, created)
|
||||
if err != nil {
|
||||
return Draft{}, false, false, err
|
||||
}
|
||||
draft.CreatedAt = parsed
|
||||
return draft, true, source == "MANUAL" && status == "DRAFT" && version == 1, nil
|
||||
}
|
||||
|
||||
type scanner interface{ Scan(...any) error }
|
||||
|
||||
func scanDraft(row scanner) (Draft, error) {
|
||||
var draft Draft
|
||||
var created string
|
||||
if err := row.Scan(&draft.ID, &draft.Title, &draft.GoodsID, &draft.SKUColor, &draft.SKUSize, &draft.Quantity, &draft.MaxTotalPrice, &created); err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339Nano, created)
|
||||
if err != nil {
|
||||
return Draft{}, err
|
||||
}
|
||||
draft.CreatedAt = parsed
|
||||
return draft, nil
|
||||
}
|
||||
func samePayload(left, right Draft) bool {
|
||||
return left.ID == right.ID && left.Title == right.Title && left.GoodsID == right.GoodsID && left.SKUColor == right.SKUColor && left.SKUSize == right.SKUSize && left.Quantity == right.Quantity && left.MaxTotalPrice == right.MaxTotalPrice
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Package tasks 定义手工 DRAFT 任务的校验与窄仓储边界。
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxTitleLength = 120
|
||||
maxSKUText = 80
|
||||
)
|
||||
|
||||
var ErrCreateKeyConflict = errors.New("create key conflicts with a different task")
|
||||
|
||||
type Draft struct {
|
||||
ID string
|
||||
Title string
|
||||
GoodsID string
|
||||
SKUColor string
|
||||
SKUSize string
|
||||
Quantity int
|
||||
MaxTotalPrice string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Form struct{ CreateKey, Title, ProductURL, SKUColor, SKUSize, Quantity, MaxTotalPrice string }
|
||||
type Errors map[string]string
|
||||
|
||||
func (errors Errors) Valid() bool { return len(errors) == 0 }
|
||||
|
||||
// Validate trims and normalizes a user form. It never reads a product page or derives price data.
|
||||
func Validate(form Form) (Draft, Errors) {
|
||||
draft := Draft{ID: strings.TrimSpace(form.CreateKey), Title: strings.TrimSpace(form.Title), SKUColor: strings.TrimSpace(form.SKUColor), SKUSize: strings.TrimSpace(form.SKUSize)}
|
||||
errors := Errors{}
|
||||
if !validUUID(draft.ID) {
|
||||
errors["create_key"] = "创建请求已过期,请重新打开表单。"
|
||||
}
|
||||
if draft.Title == "" || len([]rune(draft.Title)) > maxTitleLength {
|
||||
errors["title"] = "任务名称不能为空,且不能超过 120 个字符。"
|
||||
}
|
||||
if draft.SKUColor == "" || len([]rune(draft.SKUColor)) > maxSKUText {
|
||||
errors["sku_color"] = "颜色分类不能为空,且不能超过 80 个字符。"
|
||||
}
|
||||
if draft.SKUSize == "" || len([]rune(draft.SKUSize)) > maxSKUText {
|
||||
errors["sku_size"] = "尺码不能为空,且不能超过 80 个字符。"
|
||||
}
|
||||
goodsID, ok := CanonicalGoodsID(strings.TrimSpace(form.ProductURL))
|
||||
if !ok {
|
||||
errors["product_url"] = "请输入唯一的 canonical 商品链接。"
|
||||
} else {
|
||||
draft.GoodsID = goodsID
|
||||
}
|
||||
quantity, err := strconv.ParseInt(strings.TrimSpace(form.Quantity), 10, 0)
|
||||
if err != nil || quantity < 1 {
|
||||
errors["quantity"] = "数量必须是正整数。"
|
||||
} else {
|
||||
draft.Quantity = int(quantity)
|
||||
}
|
||||
money, ok := normalizeMoney(strings.TrimSpace(form.MaxTotalPrice))
|
||||
if !ok {
|
||||
errors["max_total_price"] = "价格上限必须大于零,且最多两位小数。"
|
||||
} else {
|
||||
draft.MaxTotalPrice = money
|
||||
}
|
||||
return draft, errors
|
||||
}
|
||||
|
||||
// CanonicalGoodsID only accepts the one verified manual-entry URL shape; untrusted query data is discarded.
|
||||
func CanonicalGoodsID(value string) (string, bool) {
|
||||
if value == "" || strings.Contains(value, "\\") || strings.Contains(value, "%") {
|
||||
return "", false
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(value)
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host != "mobile.yangkeduo.com" || parsed.User != nil || parsed.Port() != "" || parsed.Path != "/goods.html" || parsed.Fragment != "" {
|
||||
return "", false
|
||||
}
|
||||
values, err := url.ParseQuery(parsed.RawQuery)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
goodsIDs := values["goods_id"]
|
||||
if len(goodsIDs) != 1 || goodsIDs[0] == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, character := range goodsIDs[0] {
|
||||
if character < '0' || character > '9' {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return goodsIDs[0], true
|
||||
}
|
||||
|
||||
func CanonicalURL(goodsID string) string {
|
||||
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID
|
||||
}
|
||||
|
||||
func NewCreateKey() (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
hexValue := hex.EncodeToString(bytes)
|
||||
return hexValue[0:8] + "-" + hexValue[8:12] + "-" + hexValue[12:16] + "-" + hexValue[16:20] + "-" + hexValue[20:32], nil
|
||||
}
|
||||
|
||||
func validUUID(value string) bool {
|
||||
if len(value) != 36 {
|
||||
return false
|
||||
}
|
||||
for index, character := range value {
|
||||
if index == 8 || index == 13 || index == 18 || index == 23 {
|
||||
if character != '-' {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b')
|
||||
}
|
||||
|
||||
func normalizeMoney(value string) (string, bool) {
|
||||
parts := strings.Split(value, ".")
|
||||
if len(parts) > 2 || parts[0] == "" || len(parts) == 2 && (len(parts[1]) == 0 || len(parts[1]) > 2) {
|
||||
return "", false
|
||||
}
|
||||
for _, character := range parts[0] {
|
||||
if character < '0' || character > '9' {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
fraction := ""
|
||||
if len(parts) == 2 {
|
||||
fraction = parts[1]
|
||||
for _, character := range fraction {
|
||||
if character < '0' || character > '9' {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
}
|
||||
whole := strings.TrimLeft(parts[0], "0")
|
||||
if whole == "" {
|
||||
whole = "0"
|
||||
}
|
||||
if whole == "0" && strings.Trim(fraction, "0") == "" {
|
||||
return "", false
|
||||
}
|
||||
return whole + "." + (fraction + "00")[:2], true
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/migrations"
|
||||
"cmbuyer/admin/internal/storage/sqlite"
|
||||
)
|
||||
|
||||
const testKey = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||
|
||||
func TestValidateNormalizesManualDraft(t *testing.T) {
|
||||
draft, validation := Validate(Form{
|
||||
CreateKey: " " + testKey + " ",
|
||||
Title: " 夏季上衣 ",
|
||||
ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=untrusted",
|
||||
SKUColor: " 黑色CHA(纯棉) ",
|
||||
SKUSize: " M(建议100-115) ",
|
||||
Quantity: "2",
|
||||
MaxTotalPrice: "00012.8",
|
||||
})
|
||||
if !validation.Valid() {
|
||||
t.Fatalf("Validate errors = %#v", validation)
|
||||
}
|
||||
if draft.ID != testKey || draft.GoodsID != "937122477375" || draft.Title != "夏季上衣" || draft.SKUColor != "黑色CHA(纯棉)" || draft.SKUSize != "M(建议100-115)" || draft.Quantity != 2 || draft.MaxTotalPrice != "12.80" {
|
||||
t.Fatalf("normalized draft = %#v", draft)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
|
||||
base := Form{CreateKey: testKey, Title: "title", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", SKUColor: "black", SKUSize: "M", Quantity: "1", MaxTotalPrice: "1"}
|
||||
for name, update := range map[string]func(*Form){
|
||||
"empty title": func(form *Form) { form.Title = " " },
|
||||
"long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) },
|
||||
"fraction quantity": func(form *Form) { form.Quantity = "1.5" },
|
||||
"zero quantity": func(form *Form) { form.Quantity = "0" },
|
||||
"too many decimals": func(form *Form) { form.MaxTotalPrice = "1.234" },
|
||||
"trailing decimal": func(form *Form) { form.MaxTotalPrice = "1." },
|
||||
"zero money": func(form *Form) { form.MaxTotalPrice = "0.00" },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
form := base
|
||||
update(&form)
|
||||
if _, validation := Validate(form); validation.Valid() {
|
||||
t.Fatal("invalid form was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, value := range []string{
|
||||
"http://mobile.yangkeduo.com/goods.html?goods_id=1",
|
||||
"https://yangkeduo.com/goods.html?goods_id=1",
|
||||
"https://mobile.yangkeduo.com:443/goods.html?goods_id=1",
|
||||
"https://user@mobile.yangkeduo.com/goods.html?goods_id=1",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=1#fragment",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=1&goods_id=2",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=one",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=%31",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=1%26goods_id%3D2",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=bad",
|
||||
"https://mobile.yangkeduo.com/other.html?goods_id=1",
|
||||
} {
|
||||
if _, ok := CanonicalGoodsID(value); ok {
|
||||
t.Fatalf("CanonicalGoodsID accepted %q", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMoneyBoundaries(t *testing.T) {
|
||||
for value, want := range map[string]string{"1": "1.00", "1.2": "1.20", "000.01": "0.01", "999999999999999999": "999999999999999999.00"} {
|
||||
got, ok := normalizeMoney(value)
|
||||
if !ok || got != want {
|
||||
t.Fatalf("normalizeMoney(%q) = (%q, %t), want (%q, true)", value, got, ok, want)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"0", "0.0", "0.00", "1.", ".1", "1.000", "-1", "1e2", " 1"} {
|
||||
if got, ok := normalizeMoney(value); ok {
|
||||
t.Fatalf("normalizeMoney(%q) = %q, want rejection", value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCreateKeyIsUUIDv4(t *testing.T) {
|
||||
key, err := NewCreateKey()
|
||||
if err != nil {
|
||||
t.Fatalf("NewCreateKey: %v", err)
|
||||
}
|
||||
if !regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`).MatchString(key) {
|
||||
t.Fatalf("create key %q is not UUID v4", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreRequiresMigratedDatabase(t *testing.T) {
|
||||
database := openDatabase(t)
|
||||
if _, err := NewSQLiteStore(database); err == nil {
|
||||
t.Fatal("NewSQLiteStore accepted an unmigrated database")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreCreatesListsAndHandlesIdempotency(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store, err := NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStore: %v", err)
|
||||
}
|
||||
baseTime := time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC)
|
||||
call := 0
|
||||
store.now = func() time.Time {
|
||||
result := baseTime.Add(time.Duration(call) * time.Minute)
|
||||
call++
|
||||
return result
|
||||
}
|
||||
first := testDraft(testKey, "first")
|
||||
created, err := store.CreateDraft(context.Background(), first)
|
||||
if err != nil {
|
||||
t.Fatalf("create first draft: %v", err)
|
||||
}
|
||||
replayed, err := store.CreateDraft(context.Background(), first)
|
||||
if err != nil {
|
||||
t.Fatalf("replay first draft: %v", err)
|
||||
}
|
||||
if replayed.CreatedAt != created.CreatedAt {
|
||||
t.Fatalf("replayed CreatedAt = %s, want original %s", replayed.CreatedAt, created.CreatedAt)
|
||||
}
|
||||
second := testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "second")
|
||||
if _, err := store.CreateDraft(context.Background(), second); err != nil {
|
||||
t.Fatalf("create second draft: %v", err)
|
||||
}
|
||||
drafts, err := store.ListDrafts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list drafts: %v", err)
|
||||
}
|
||||
if len(drafts) != 2 || drafts[0].ID != second.ID || drafts[1].ID != first.ID {
|
||||
t.Fatalf("draft order = %#v, want second then first", drafts)
|
||||
}
|
||||
var source, status string
|
||||
var version int
|
||||
if err := database.QueryRow(`SELECT source, status, version FROM tasks WHERE id = ?`, first.ID).Scan(&source, &status, &version); err != nil {
|
||||
t.Fatalf("read stored task: %v", err)
|
||||
}
|
||||
if source != "MANUAL" || status != "DRAFT" || version != 1 {
|
||||
t.Fatalf("stored metadata = (%q, %q, %d)", source, status, version)
|
||||
}
|
||||
|
||||
conflicting := first
|
||||
conflicting.Title = "different"
|
||||
if _, err := store.CreateDraft(context.Background(), conflicting); !errors.Is(err, ErrCreateKeyConflict) {
|
||||
t.Fatalf("conflicting create error = %v, want ErrCreateKeyConflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreRollsBackFailedCreate(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store, err := NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStore: %v", err)
|
||||
}
|
||||
if _, err := database.Exec(`CREATE TRIGGER reject_task BEFORE INSERT ON tasks BEGIN SELECT RAISE(ABORT, 'reject test insert'); END`); err != nil {
|
||||
t.Fatalf("create trigger: %v", err)
|
||||
}
|
||||
if _, err := store.CreateDraft(context.Background(), testDraft(testKey, "blocked")); err == nil {
|
||||
t.Fatal("CreateDraft succeeded despite rejecting trigger")
|
||||
}
|
||||
drafts, err := store.ListDrafts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list after failed create: %v", err)
|
||||
}
|
||||
if len(drafts) != 0 {
|
||||
t.Fatalf("failed create persisted drafts: %#v", drafts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreUsesInsertionOrderForEqualTimesAndFiltersPhase(t *testing.T) {
|
||||
database := migratedDatabase(t)
|
||||
store, err := NewSQLiteStore(database)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStore: %v", err)
|
||||
}
|
||||
store.now = func() time.Time { return time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) }
|
||||
first := testDraft(testKey, "first")
|
||||
second := testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "second")
|
||||
for _, draft := range []Draft{first, second} {
|
||||
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
|
||||
t.Fatalf("create %s: %v", draft.Title, err)
|
||||
}
|
||||
}
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES ('excel-draft', 'EXCEL', 'other', '1', 'black', 'M', 1, '1.00', 'DRAFT', 1, '2026-08-04T10:00:00Z', '2026-08-04T10:00:00Z'), ('manual-pending', 'MANUAL', 'other', '2', 'black', 'M', 1, '1.00', 'PENDING', 1, '2026-08-04T10:00:00Z', '2026-08-04T10:00:00Z')`); err != nil {
|
||||
t.Fatalf("insert out-of-scope tasks: %v", err)
|
||||
}
|
||||
drafts, err := store.ListDrafts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list drafts: %v", err)
|
||||
}
|
||||
if len(drafts) != 2 || drafts[0].ID != second.ID || drafts[1].ID != first.ID {
|
||||
t.Fatalf("equal-time draft order/filter = %#v, want second then first only", drafts)
|
||||
}
|
||||
if _, err := database.Exec(`UPDATE tasks SET status = 'PENDING' WHERE id = ?`, first.ID); err != nil {
|
||||
t.Fatalf("move draft outside current phase: %v", err)
|
||||
}
|
||||
if _, err := store.CreateDraft(context.Background(), first); !errors.Is(err, ErrCreateKeyConflict) {
|
||||
t.Fatalf("replay of non-DRAFT record error = %v, want conflict", err)
|
||||
}
|
||||
third := testDraft("c3c9f507-7473-4fa6-8d71-8786c34c6301", "third")
|
||||
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'EXCEL', ?, ?, ?, ?, ?, ?, 'DRAFT', 1, '2026-08-04T09:00:00Z', '2026-08-04T09:00:00Z')`, third.ID, third.Title, third.GoodsID, third.SKUColor, third.SKUSize, third.Quantity, third.MaxTotalPrice); err != nil {
|
||||
t.Fatalf("insert same-payload EXCEL record: %v", err)
|
||||
}
|
||||
if _, err := store.CreateDraft(context.Background(), third); !errors.Is(err, ErrCreateKeyConflict) {
|
||||
t.Fatalf("replay of non-MANUAL record error = %v, want conflict", err)
|
||||
}
|
||||
if _, err := database.Exec(`UPDATE tasks SET version = 2, source = 'MANUAL' WHERE id = ?`, third.ID); err != nil {
|
||||
t.Fatalf("change replay record version: %v", err)
|
||||
}
|
||||
if _, err := store.CreateDraft(context.Background(), third); !errors.Is(err, ErrCreateKeyConflict) {
|
||||
t.Fatalf("replay of non-v1 record error = %v, want conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteStoreConcurrentIdenticalCreateIsOneDraft(t *testing.T) {
|
||||
store, err := NewSQLiteStore(migratedDatabase(t))
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLiteStore: %v", err)
|
||||
}
|
||||
const callers = 20
|
||||
start := make(chan struct{})
|
||||
errors := make(chan error, callers)
|
||||
results := make(chan Draft, callers)
|
||||
var group sync.WaitGroup
|
||||
for range callers {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
<-start
|
||||
draft, err := store.CreateDraft(context.Background(), testDraft(testKey, "same"))
|
||||
if err != nil {
|
||||
errors <- err
|
||||
return
|
||||
}
|
||||
results <- draft
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
group.Wait()
|
||||
close(errors)
|
||||
close(results)
|
||||
for err := range errors {
|
||||
t.Fatalf("concurrent create: %v", err)
|
||||
}
|
||||
for result := range results {
|
||||
if result.ID != testKey {
|
||||
t.Fatalf("concurrent result = %#v", result)
|
||||
}
|
||||
}
|
||||
drafts, err := store.ListDrafts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("list after concurrent create: %v", err)
|
||||
}
|
||||
if len(drafts) != 1 || drafts[0].ID != testKey {
|
||||
t.Fatalf("concurrent creates persisted %#v, want exactly one", drafts)
|
||||
}
|
||||
}
|
||||
|
||||
func testDraft(id, title string) Draft {
|
||||
return Draft{ID: id, Title: title, GoodsID: "937122477375", SKUColor: "black", SKUSize: "M", Quantity: 2, MaxTotalPrice: "12.80"}
|
||||
}
|
||||
|
||||
func openDatabase(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
database, err := sqlite.Open(filepath.Join(t.TempDir(), "tasks.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
return database
|
||||
}
|
||||
|
||||
func migratedDatabase(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
database := openDatabase(t)
|
||||
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
|
||||
t.Fatalf("migrate database: %v", err)
|
||||
}
|
||||
return database
|
||||
}
|
||||
|
||||
func migrationDirectory(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("locate test source")
|
||||
}
|
||||
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{{define "login.html"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>登录 · 采购服务</title>
|
||||
<style>
|
||||
:root { color-scheme: light; --bg:#f4f7fb; --surface:#fff; --text:#172033; --muted:#526079; --border:#cfd8e6; --primary:#155eef; --primary-hover:#0b4ed1; --primary-soft:#eaf1ff; --danger:#b42318; --danger-soft:#fef3f2; --focus:#ffbf47; --shadow:0 12px 30px rgba(23,32,51,.1); font-family:"Segoe UI","Microsoft YaHei UI",system-ui,sans-serif; }
|
||||
* { box-sizing:border-box; }
|
||||
html { min-width:320px; background:var(--bg); }
|
||||
body { min-height:100dvh; margin:0; color:var(--text); background:var(--bg); font-size:16px; line-height:1.55; }
|
||||
button,input { font:inherit; }
|
||||
:focus-visible { outline:3px solid var(--focus); outline-offset:3px; }
|
||||
.skip-link { position:fixed; z-index:10; top:8px; left:8px; padding:10px 14px; color:#fff; background:var(--text); transform:translateY(-160%); }
|
||||
.skip-link:focus { transform:translateY(0); }
|
||||
main { display:grid; min-height:100dvh; place-items:center; padding:24px 16px; }
|
||||
.card { width:min(100%,440px); padding:32px; border:1px solid var(--border); border-radius:14px; background:var(--surface); box-shadow:var(--shadow); }
|
||||
.brand { display:flex; align-items:center; gap:10px; margin:0 0 24px; font-size:1rem; font-weight:700; }
|
||||
.brand-mark { display:grid; width:32px; height:32px; place-items:center; border-radius:8px; color:#fff; background:var(--primary); font-size:.82rem; }
|
||||
h1 { margin:0; font-size:clamp(1.6rem,5vw,2rem); line-height:1.25; }
|
||||
.intro { margin:8px 0 24px; color:var(--muted); }
|
||||
.field { margin-top:16px; }
|
||||
label { display:block; margin-bottom:6px; font-weight:700; }
|
||||
input { width:100%; min-height:44px; padding:10px 12px; border:1px solid #9ba9bc; border-radius:8px; color:var(--text); background:#fff; }
|
||||
input[aria-invalid="true"] { border-color:var(--danger); box-shadow:0 0 0 1px var(--danger); }
|
||||
.hint { margin:5px 0 0; color:var(--muted); font-size:.875rem; }
|
||||
.error { margin:0 0 18px; padding:12px 14px; border-left:4px solid var(--danger); border-radius:6px; color:var(--danger); background:var(--danger-soft); font-weight:650; }
|
||||
.submit { width:100%; min-height:44px; margin-top:24px; padding:10px 16px; border:1px solid transparent; border-radius:8px; color:#fff; background:var(--primary); font-weight:700; cursor:pointer; transition:background-color 180ms ease-out; }
|
||||
.submit:hover { background:var(--primary-hover); }
|
||||
.notice { margin:20px 0 0; padding:12px 14px; border:1px solid #b9cffc; border-radius:8px; color:#29466f; background:var(--primary-soft); font-size:.9rem; }
|
||||
@media (max-width:420px) { main { padding-inline:12px; } .card { padding:24px 16px; } }
|
||||
@media (prefers-reduced-motion:reduce) { *,*::before,*::after { transition-duration:.01ms !important; animation-duration:.01ms !important; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">跳到主要内容</a>
|
||||
<main id="main">
|
||||
<section class="card" aria-labelledby="login-title">
|
||||
<p class="brand"><span class="brand-mark" aria-hidden="true">采</span><span>采购服务</span></p>
|
||||
<h1 id="login-title">管理端登录</h1>
|
||||
<p class="intro">登录后进入采购任务工作台。设备身份不能使用此入口。</p>
|
||||
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
|
||||
<form method="post" action="/login">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="return_to" value="{{.ReturnTo}}">
|
||||
<div class="field">
|
||||
<label for="username">账号</label>
|
||||
<input id="username" name="username" type="text" value="{{.Username}}" autocomplete="username" required aria-invalid="{{if .Error}}true{{else}}false{{end}}" aria-describedby="username-hint">
|
||||
<p class="hint" id="username-hint">使用采购管理员账号登录。</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">密码</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required aria-invalid="{{if .Error}}true{{else}}false{{end}}">
|
||||
</div>
|
||||
<button class="submit" type="submit">登录并继续</button>
|
||||
</form>
|
||||
<p class="notice">系统只创建待付款订单,付款始终由人完成。</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,19 @@
|
||||
{{define "tasks.html"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>采购任务 · 采购服务</title>
|
||||
<style>
|
||||
:root{--bg:#f4f7fb;--surface:#fff;--text:#172033;--muted:#526079;--border:#cfd8e6;--primary:#155eef;--danger:#b42318;--success:#067647;--focus:#ffbf47;font-family:"Segoe UI","Microsoft YaHei UI",system-ui,sans-serif}*{box-sizing:border-box}html{min-width:320px;background:var(--bg)}body{min-height:100dvh;margin:0;color:var(--text);background:var(--bg);font-size:16px;line-height:1.55}button,input{font:inherit}:focus-visible{outline:3px solid var(--focus);outline-offset:3px}.skip{position:fixed;z-index:100;top:8px;left:8px;padding:10px;color:#fff;background:#172033;transform:translateY(-160%)}.skip:focus{transform:translateY(0)}header{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:64px;padding:10px clamp(16px,4vw,40px);border-bottom:1px solid var(--border);background:var(--surface)}.brand{font-weight:700}.brand b{display:inline-grid;place-items:center;width:32px;height:32px;margin-right:8px;border-radius:8px;background:var(--primary);color:#fff;font-size:.82rem}.logout,.button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:9px 14px;border:1px solid var(--border);border-radius:8px;color:var(--text);background:#fff;font-weight:700;text-decoration:none;cursor:pointer}.button.primary{border-color:var(--primary);background:var(--primary);color:#fff}.button:disabled,.filter input:disabled{opacity:.5;cursor:not-allowed}main{width:min(100% - 32px,1200px);margin:32px auto}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:16px}.toolbar-actions,.filters,.actions{display:flex;flex-wrap:wrap;gap:10px}.muted,.placeholder{color:var(--muted)}.filters{align-items:end;margin:0 0 16px}.filters label{display:grid;gap:4px;font-weight:700}.filters input{min-height:44px;min-width:180px;padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:#fff}.table-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:12px;background:var(--surface)}table{width:100%;min-width:880px;border-collapse:collapse}th,td{padding:12px 14px;border-bottom:1px solid var(--border);text-align:left;vertical-align:top}th{background:#f8fafc;font-size:.88rem}td a{color:#124cc5;font-weight:700;text-underline-offset:3px}.status{display:inline-block;padding:3px 8px;border-radius:999px;background:#eaf1ff;color:#173d8f;font-size:.85rem;font-weight:700}.empty,.success{padding:20px;border:1px solid var(--border);border-radius:12px;background:var(--surface)}.success{margin:0 0 16px;border-color:#9dd9b8;background:#ecfdf3;color:var(--success)}.modal-scrim{position:fixed;z-index:20;inset:0;background:rgba(23,32,51,.52)}dialog[open]{position:fixed;z-index:30;top:50%;left:50%;width:min(calc(100% - 24px),640px);max-height:calc(100dvh - 24px);margin:0;padding:28px;overflow-y:auto;border:1px solid var(--border);border-radius:14px;box-shadow:0 18px 48px rgba(23,32,51,.24);transform:translate(-50%,-50%);background:var(--surface)}.form-page{width:min(100% - 32px,640px);margin:32px auto;padding:28px;border:1px solid var(--border);border-radius:14px;background:var(--surface)}.form-grid{display:grid;gap:16px}.field label{display:block;margin-bottom:6px;font-weight:700}.required{color:var(--danger)}.field input{width:100%;min-height:44px;padding:10px 12px;border:1px solid #9ba9bc;border-radius:8px}.field input[aria-invalid=true]{border-color:var(--danger)}.error{margin:5px 0 0;color:var(--danger);font-size:.9rem}.summary{margin:0 0 16px;padding:12px;border-left:4px solid var(--danger);background:#fef3f2;color:var(--danger)}.summary p{margin:0}.summary ul{margin:8px 0 0;padding-left:20px}.summary a{color:inherit}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:420px){main,.form-page{width:calc(100% - 24px);margin:24px auto}.toolbar{align-items:stretch;flex-direction:column}.toolbar-actions,.toolbar .button{width:100%}.toolbar-actions .button{flex:1}.filters{align-items:stretch;flex-direction:column}.filters input,.filters .button{width:100%}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition-duration:.01ms!important;animation-duration:.01ms!important}}</style>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip" href="#main">跳到主要内容</a>
|
||||
<header><div class="brand"><b aria-hidden="true">采</b>采购服务</div><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button class="logout" type="submit">退出登录</button></form></header>
|
||||
{{if .FullPage}}<main class="form-page" id="main">{{template "form" .}}</main>{{else}}<main id="main"><div class="toolbar"><div><h1>采购任务</h1><p class="muted">只显示待开始的手工任务。</p></div><div class="toolbar-actions"><button class="button" type="button" disabled>导入</button><a class="button primary" href="/tasks?create=1">创建任务</a></div></div><div class="filters" aria-label="暂不可用的列表条件"><label>关键词<input type="search" disabled></label><button class="button" type="button" disabled>筛选</button><button class="button" type="button" disabled>清除</button></div>{{if .Success}}<p class="success" role="status">任务已创建,已显示在列表首行。</p>{{end}}{{if .Drafts}}<div class="table-wrap"><table><thead><tr><th scope="col"><input type="checkbox" disabled aria-label="选择全部任务"></th><th scope="col">标题</th><th scope="col">颜色分类</th><th scope="col">尺码</th><th scope="col">价格上限</th><th scope="col">数量</th><th scope="col">采购结果</th><th scope="col">状态</th><th scope="col">创建时间</th></tr></thead><tbody>{{range .Drafts}}<tr><td><input type="checkbox" disabled aria-label="选择任务 {{.Title}}"></td><td><a href="https://mobile.yangkeduo.com/goods.html?goods_id={{.GoodsID}}" target="_blank" rel="noopener noreferrer">{{.Title}}</a></td><td>{{.SKUColor}}</td><td>{{.SKUSize}}</td><td>¥{{.MaxTotalPrice}}</td><td>{{.Quantity}}</td><td>—</td><td><span class="status">待开始</span></td><td><time datetime="{{.CreatedAt.Format "2006-01-02T15:04:05Z07:00"}}">{{.CreatedAt.Format "2006-01-02 15:04 UTC"}}</time></td></tr>{{end}}</tbody></table></div>{{else}}<section class="empty"><h2>还没有待开始任务</h2><p>创建一条手工任务后会显示在这里。</p></section>{{end}}</main>{{if .OpenForm}}<div class="modal-scrim" aria-hidden="true"></div><dialog open aria-modal="true" aria-labelledby="form-title">{{template "form" .}}</dialog>{{end}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
{{define "form"}}<h1 id="form-title">创建采购任务</h1><p class="muted">保存后仅生成待开始任务,不会执行其他动作。</p>{{if .Errors}}<div class="summary" role="alert" aria-live="assertive"><p>请修正下列字段后再保存。</p><ul>{{with index .Errors "title"}}<li><a href="#title">任务名称:{{.}}</a></li>{{end}}{{with index .Errors "product_url"}}<li><a href="#product_url">商品链接:{{.}}</a></li>{{end}}{{with index .Errors "sku_color"}}<li><a href="#sku_color">颜色分类:{{.}}</a></li>{{end}}{{with index .Errors "sku_size"}}<li><a href="#sku_size">尺码:{{.}}</a></li>{{end}}{{with index .Errors "quantity"}}<li><a href="#quantity">数量:{{.}}</a></li>{{end}}{{with index .Errors "max_total_price"}}<li><a href="#max_total_price">价格上限:{{.}}</a></li>{{end}}{{with index .Errors "create_key"}}<li>{{.}}</li>{{end}}</ul></div>{{end}}<form method="post" action="/tasks" class="form-grid"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><input type="hidden" name="create_key" value="{{.Form.CreateKey}}"><input type="hidden" name="form_mode" value="{{if .FullPage}}full{{else}}dialog{{end}}">{{template "field" (list "title" "任务名称" .Form.Title .Errors .FocusField)}}{{template "field" (list "product_url" "商品链接" .Form.ProductURL .Errors .FocusField)}}{{template "field" (list "sku_color" "颜色分类" .Form.SKUColor .Errors .FocusField)}}{{template "field" (list "sku_size" "尺码" .Form.SKUSize .Errors .FocusField)}}{{template "field" (list "quantity" "数量" .Form.Quantity .Errors .FocusField)}}{{template "field" (list "max_total_price" "价格上限" .Form.MaxTotalPrice .Errors .FocusField)}}<div class="actions"><button class="button primary" type="submit">保存任务</button><a class="button" href="/tasks">取消</a></div></form>{{end}}
|
||||
{{define "field"}}{{$name:=index . 0}}{{$label:=index . 1}}{{$value:=index . 2}}{{$errors:=index . 3}}{{$focus:=index . 4}}<div class="field"><label for="{{$name}}">{{$label}} <span class="required" aria-hidden="true">*</span><span class="sr-only">(必填)</span></label><input id="{{$name}}" name="{{$name}}" value="{{$value}}" required {{if eq $focus $name}}autofocus{{end}} aria-invalid="{{if index $errors $name}}true{{else}}false{{end}}"{{with index $errors $name}} aria-describedby="{{$name}}-error"{{end}} {{if eq $name "product_url"}}type="url" inputmode="url" maxlength="2048"{{else if eq $name "quantity"}}type="number" inputmode="numeric" min="1" step="1"{{else if eq $name "max_total_price"}}type="text" inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?" maxlength="64"{{else if eq $name "title"}}type="text" maxlength="120"{{else}}type="text" maxlength="80"{{end}}>{{with index $errors $name}}<p class="error" id="{{$name}}-error">{{.}}</p>{{end}}</div>{{end}}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Package webui 渲染采购服务当前可用的服务端页面。
|
||||
package webui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"io"
|
||||
|
||||
"cmbuyer/admin/internal/tasks"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html
|
||||
var templateFiles embed.FS
|
||||
|
||||
var templates = template.Must(template.New("webui").Funcs(template.FuncMap{"list": func(values ...any) []any { return values }}).ParseFS(templateFiles, "templates/*.html"))
|
||||
|
||||
// LoginData 是登录页面所需的非敏感展示数据。
|
||||
type LoginData struct {
|
||||
CSRFToken string
|
||||
ReturnTo string
|
||||
Username string
|
||||
Error string
|
||||
}
|
||||
|
||||
// TasksData 是受保护的 DRAFT 建单与列表页面所需数据。
|
||||
type TasksData struct {
|
||||
CSRFToken string
|
||||
Drafts []tasks.Draft
|
||||
Form tasks.Form
|
||||
Errors tasks.Errors
|
||||
OpenForm bool
|
||||
FullPage bool
|
||||
FocusField string
|
||||
Success bool
|
||||
}
|
||||
|
||||
// RenderLogin 写入登录页。
|
||||
func RenderLogin(writer io.Writer, data LoginData) error {
|
||||
return templates.ExecuteTemplate(writer, "login.html", data)
|
||||
}
|
||||
|
||||
// RenderTasks 写入登录后的受保护任务页。
|
||||
func RenderTasks(writer io.Writer, data TasksData) error {
|
||||
return templates.ExecuteTemplate(writer, "tasks.html", data)
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
-- +goose Up
|
||||
-- v1 的试选/锁旧价记录无法安全推断为单趟执行事实。先在同一事务中拒绝它们,
|
||||
-- 避免删除审计数据后再尝试猜测映射。
|
||||
CREATE TABLE single_pass_upgrade_guard (
|
||||
valid INTEGER NOT NULL CHECK (valid = 1)
|
||||
);
|
||||
|
||||
INSERT INTO single_pass_upgrade_guard (valid)
|
||||
SELECT CASE WHEN
|
||||
(SELECT COUNT(*) FROM spec_trials) = 0
|
||||
AND (SELECT COUNT(*) FROM order_authorizations) = 0
|
||||
AND (SELECT COUNT(*) FROM order_submissions) = 0
|
||||
AND (SELECT COUNT(*) FROM tasks WHERE source <> 'MANUAL' OR status <> 'DRAFT') = 0
|
||||
-- v2 的金额边界是严格正数;不把 v1 中不能无损纳入该边界的数据悄悄改写。
|
||||
AND (SELECT COUNT(*) FROM tasks WHERE
|
||||
max_total_price = ''
|
||||
OR max_total_price GLOB '*[^0-9.]*'
|
||||
OR length(max_total_price) - length(replace(max_total_price, '.', '')) > 1
|
||||
OR max_total_price = '.'
|
||||
OR (instr(max_total_price, '.') > 0 AND (
|
||||
instr(max_total_price, '.') = 1
|
||||
OR length(max_total_price) = instr(max_total_price, '.')
|
||||
OR length(max_total_price) - instr(max_total_price, '.') > 2
|
||||
))
|
||||
OR replace(replace(max_total_price, '.', ''), '0', '') = ''
|
||||
) = 0
|
||||
THEN 1 ELSE 0 END;
|
||||
|
||||
DROP TABLE single_pass_upgrade_guard;
|
||||
|
||||
ALTER TABLE tasks RENAME TO tasks_v1;
|
||||
DROP TABLE order_submissions;
|
||||
DROP TABLE order_authorizations;
|
||||
DROP TABLE spec_trials;
|
||||
|
||||
CREATE TABLE tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL CHECK (source IN ('MANUAL', 'EXCEL', 'ERP')),
|
||||
source_ref TEXT,
|
||||
title TEXT NOT NULL,
|
||||
goods_id TEXT NOT NULL,
|
||||
sku_color TEXT NOT NULL,
|
||||
sku_size TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
|
||||
max_total_price TEXT NOT NULL CHECK (
|
||||
max_total_price <> ''
|
||||
AND max_total_price NOT GLOB '*[^0-9.]*'
|
||||
AND length(max_total_price) - length(replace(max_total_price, '.', '')) <= 1
|
||||
AND max_total_price <> '.'
|
||||
AND (instr(max_total_price, '.') = 0 OR (
|
||||
instr(max_total_price, '.') > 1
|
||||
AND length(max_total_price) > instr(max_total_price, '.')
|
||||
AND length(max_total_price) - instr(max_total_price, '.') <= 2
|
||||
))
|
||||
AND replace(replace(max_total_price, '.', ''), '0', '') <> ''
|
||||
),
|
||||
reference_asset_id TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN (
|
||||
'DRAFT', 'PENDING', 'CLAIMED', 'ORDERING', 'NEEDS_MANUAL', 'WAITING_PAYMENT',
|
||||
'RECONCILIATION_REQUIRED', 'SUCCEEDED', 'FAILED', 'CANCELED'
|
||||
)),
|
||||
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0 AND typeof(version) = 'integer'),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO tasks (
|
||||
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||
reference_asset_id, status, version, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||
reference_asset_id, status, version, created_at, updated_at
|
||||
FROM tasks_v1;
|
||||
|
||||
DROP TABLE tasks_v1;
|
||||
|
||||
CREATE TABLE order_authorizations (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
task_version INTEGER NOT NULL CHECK (task_version > 0 AND typeof(task_version) = 'integer'),
|
||||
start_key TEXT NOT NULL,
|
||||
goods_id TEXT NOT NULL,
|
||||
sku_color TEXT NOT NULL,
|
||||
sku_size TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
|
||||
total_price_cap TEXT NOT NULL CHECK (
|
||||
total_price_cap <> ''
|
||||
AND total_price_cap NOT GLOB '*[^0-9.]*'
|
||||
AND length(total_price_cap) - length(replace(total_price_cap, '.', '')) <= 1
|
||||
AND total_price_cap <> '.'
|
||||
AND (instr(total_price_cap, '.') = 0 OR (
|
||||
instr(total_price_cap, '.') > 1
|
||||
AND length(total_price_cap) > instr(total_price_cap, '.')
|
||||
AND length(total_price_cap) - instr(total_price_cap, '.') <= 2
|
||||
))
|
||||
AND replace(replace(total_price_cap, '.', ''), '0', '') <> ''
|
||||
),
|
||||
status TEXT NOT NULL CHECK (status IN ('ACTIVE', 'CLAIMED', 'FENCED', 'CONSUMED', 'EXPIRED', 'ABANDONED')),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
UNIQUE (task_id, task_version),
|
||||
UNIQUE (start_key, task_id),
|
||||
UNIQUE (task_id, id)
|
||||
);
|
||||
|
||||
CREATE TABLE purchase_attempts (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
authorization_id TEXT NOT NULL,
|
||||
claim_generation INTEGER NOT NULL CHECK (claim_generation > 0 AND typeof(claim_generation) = 'integer'),
|
||||
status TEXT NOT NULL CHECK (status IN ('CLAIMED', 'ORDERING', 'FAILED', 'FENCED', 'ABANDONED')),
|
||||
gate1_unit_price TEXT CHECK (
|
||||
gate1_unit_price IS NULL OR (
|
||||
gate1_unit_price <> ''
|
||||
AND gate1_unit_price NOT GLOB '*[^0-9.]*'
|
||||
AND length(gate1_unit_price) - length(replace(gate1_unit_price, '.', '')) <= 1
|
||||
AND gate1_unit_price <> '.'
|
||||
AND (instr(gate1_unit_price, '.') = 0 OR (
|
||||
instr(gate1_unit_price, '.') > 1
|
||||
AND length(gate1_unit_price) > instr(gate1_unit_price, '.')
|
||||
AND length(gate1_unit_price) - instr(gate1_unit_price, '.') <= 2
|
||||
))
|
||||
AND replace(replace(gate1_unit_price, '.', ''), '0', '') <> ''
|
||||
)
|
||||
),
|
||||
gate2_unit_price TEXT CHECK (
|
||||
gate2_unit_price IS NULL OR (
|
||||
gate2_unit_price <> ''
|
||||
AND gate2_unit_price NOT GLOB '*[^0-9.]*'
|
||||
AND length(gate2_unit_price) - length(replace(gate2_unit_price, '.', '')) <= 1
|
||||
AND gate2_unit_price <> '.'
|
||||
AND (instr(gate2_unit_price, '.') = 0 OR (
|
||||
instr(gate2_unit_price, '.') > 1
|
||||
AND length(gate2_unit_price) > instr(gate2_unit_price, '.')
|
||||
AND length(gate2_unit_price) - instr(gate2_unit_price, '.') <= 2
|
||||
))
|
||||
AND replace(replace(gate2_unit_price, '.', ''), '0', '') <> ''
|
||||
)
|
||||
),
|
||||
quantity_read INTEGER CHECK (quantity_read IS NULL OR (quantity_read > 0 AND typeof(quantity_read) = 'integer')),
|
||||
confirm_amount TEXT CHECK (
|
||||
confirm_amount IS NULL OR (
|
||||
confirm_amount <> ''
|
||||
AND confirm_amount NOT GLOB '*[^0-9.]*'
|
||||
AND length(confirm_amount) - length(replace(confirm_amount, '.', '')) <= 1
|
||||
AND confirm_amount <> '.'
|
||||
AND (instr(confirm_amount, '.') = 0 OR (
|
||||
instr(confirm_amount, '.') > 1
|
||||
AND length(confirm_amount) > instr(confirm_amount, '.')
|
||||
AND length(confirm_amount) - instr(confirm_amount, '.') <= 2
|
||||
))
|
||||
AND replace(replace(confirm_amount, '.', ''), '0', '') <> ''
|
||||
)
|
||||
),
|
||||
failure_code TEXT CHECK (failure_code IS NULL OR failure_code IN (
|
||||
'AUTHORIZATION_EXPIRED', 'LEASE_LOST', 'GATE_1_REJECTED', 'QUANTITY_MISMATCH',
|
||||
'GATE_2_REJECTED', 'GATE_3_REJECTED', 'FENCE_REJECTED', 'SAFE_ABORTED'
|
||||
)),
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
UNIQUE (task_id, claim_generation),
|
||||
UNIQUE (task_id, id),
|
||||
UNIQUE (task_id, authorization_id, id),
|
||||
FOREIGN KEY (task_id, authorization_id) REFERENCES order_authorizations(task_id, id)
|
||||
);
|
||||
|
||||
CREATE TABLE order_submissions (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
authorization_id TEXT NOT NULL,
|
||||
attempt_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('FENCED', 'SUBMITTED', 'RECONCILIATION_REQUIRED', 'MANUAL_RESOLVED')),
|
||||
gate1_unit_price TEXT NOT NULL CHECK (
|
||||
gate1_unit_price <> ''
|
||||
AND gate1_unit_price NOT GLOB '*[^0-9.]*'
|
||||
AND length(gate1_unit_price) - length(replace(gate1_unit_price, '.', '')) <= 1
|
||||
AND gate1_unit_price <> '.'
|
||||
AND (instr(gate1_unit_price, '.') = 0 OR (
|
||||
instr(gate1_unit_price, '.') > 1
|
||||
AND length(gate1_unit_price) > instr(gate1_unit_price, '.')
|
||||
AND length(gate1_unit_price) - instr(gate1_unit_price, '.') <= 2
|
||||
))
|
||||
AND replace(replace(gate1_unit_price, '.', ''), '0', '') <> ''
|
||||
),
|
||||
gate2_unit_price TEXT NOT NULL CHECK (
|
||||
gate2_unit_price <> ''
|
||||
AND gate2_unit_price NOT GLOB '*[^0-9.]*'
|
||||
AND length(gate2_unit_price) - length(replace(gate2_unit_price, '.', '')) <= 1
|
||||
AND gate2_unit_price <> '.'
|
||||
AND (instr(gate2_unit_price, '.') = 0 OR (
|
||||
instr(gate2_unit_price, '.') > 1
|
||||
AND length(gate2_unit_price) > instr(gate2_unit_price, '.')
|
||||
AND length(gate2_unit_price) - instr(gate2_unit_price, '.') <= 2
|
||||
))
|
||||
AND replace(replace(gate2_unit_price, '.', ''), '0', '') <> ''
|
||||
),
|
||||
quantity_read INTEGER NOT NULL CHECK (quantity_read > 0 AND typeof(quantity_read) = 'integer'),
|
||||
confirm_amount TEXT NOT NULL CHECK (
|
||||
confirm_amount <> ''
|
||||
AND confirm_amount NOT GLOB '*[^0-9.]*'
|
||||
AND length(confirm_amount) - length(replace(confirm_amount, '.', '')) <= 1
|
||||
AND confirm_amount <> '.'
|
||||
AND (instr(confirm_amount, '.') = 0 OR (
|
||||
instr(confirm_amount, '.') > 1
|
||||
AND length(confirm_amount) > instr(confirm_amount, '.')
|
||||
AND length(confirm_amount) - instr(confirm_amount, '.') <= 2
|
||||
))
|
||||
AND replace(replace(confirm_amount, '.', ''), '0', '') <> ''
|
||||
),
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_at TEXT,
|
||||
UNIQUE (authorization_id),
|
||||
UNIQUE (attempt_id),
|
||||
FOREIGN KEY (task_id, authorization_id, attempt_id) REFERENCES purchase_attempts(task_id, authorization_id, id)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
-- 只有尚未产生任何单趟授权或执行事实的纯 MANUAL/DRAFT 数据才能无损回到 v1。
|
||||
CREATE TABLE single_pass_downgrade_guard (
|
||||
valid INTEGER NOT NULL CHECK (valid = 1)
|
||||
);
|
||||
|
||||
INSERT INTO single_pass_downgrade_guard (valid)
|
||||
SELECT CASE WHEN
|
||||
(SELECT COUNT(*) FROM order_authorizations) = 0
|
||||
AND (SELECT COUNT(*) FROM purchase_attempts) = 0
|
||||
AND (SELECT COUNT(*) FROM order_submissions) = 0
|
||||
AND (SELECT COUNT(*) FROM tasks WHERE source <> 'MANUAL' OR status <> 'DRAFT') = 0
|
||||
THEN 1 ELSE 0 END;
|
||||
|
||||
DROP TABLE single_pass_downgrade_guard;
|
||||
|
||||
ALTER TABLE tasks RENAME TO tasks_v2;
|
||||
DROP TABLE order_submissions;
|
||||
DROP TABLE purchase_attempts;
|
||||
DROP TABLE order_authorizations;
|
||||
|
||||
CREATE TABLE tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL CHECK (source IN ('MANUAL', 'EXCEL', 'ERP')),
|
||||
source_ref TEXT,
|
||||
title TEXT NOT NULL,
|
||||
goods_id TEXT NOT NULL,
|
||||
sku_color TEXT NOT NULL,
|
||||
sku_size TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
|
||||
max_total_price TEXT NOT NULL CHECK (
|
||||
max_total_price <> ''
|
||||
AND max_total_price NOT GLOB '*[^0-9.]*'
|
||||
AND length(max_total_price) - length(replace(max_total_price, '.', '')) <= 1
|
||||
AND max_total_price <> '.'
|
||||
AND (instr(max_total_price, '.') = 0 OR (
|
||||
instr(max_total_price, '.') > 1
|
||||
AND length(max_total_price) > instr(max_total_price, '.')
|
||||
AND length(max_total_price) - instr(max_total_price, '.') <= 2
|
||||
))
|
||||
),
|
||||
reference_asset_id TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN (
|
||||
'DRAFT', 'PENDING', 'CLAIMED', 'RUNNING', 'WAITING_CONFIRMATION',
|
||||
'PENDING_RETRIAL', 'AUTHORIZED', 'ORDERING', 'WAITING_PAYMENT',
|
||||
'RECONCILIATION_REQUIRED', 'NEEDS_MANUAL', 'SUCCEEDED', 'CANCELED'
|
||||
)),
|
||||
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0 AND typeof(version) = 'integer'),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO tasks (
|
||||
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||
reference_asset_id, status, version, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
|
||||
reference_asset_id, status, version, created_at, updated_at
|
||||
FROM tasks_v2;
|
||||
|
||||
DROP TABLE tasks_v2;
|
||||
|
||||
CREATE TABLE spec_trials (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
attempt INTEGER NOT NULL CHECK (attempt > 0 AND typeof(attempt) = 'integer'),
|
||||
product_title TEXT NOT NULL,
|
||||
selected_color TEXT NOT NULL,
|
||||
selected_size TEXT NOT NULL,
|
||||
unit_price TEXT NOT NULL CHECK (unit_price <> '' AND unit_price NOT GLOB '*[^0-9.]*' AND length(unit_price) - length(replace(unit_price, '.', '')) <= 1 AND unit_price <> '.' AND (instr(unit_price, '.') = 0 OR (instr(unit_price, '.') > 1 AND length(unit_price) > instr(unit_price, '.') AND length(unit_price) - instr(unit_price, '.') <= 2))),
|
||||
total_price TEXT NOT NULL CHECK (total_price <> '' AND total_price NOT GLOB '*[^0-9.]*' AND length(total_price) - length(replace(total_price, '.', '')) <= 1 AND total_price <> '.' AND (instr(total_price, '.') = 0 OR (instr(total_price, '.') > 1 AND length(total_price) > instr(total_price, '.') AND length(total_price) - instr(total_price, '.') <= 2))),
|
||||
evidence_sha256 TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (task_id, attempt),
|
||||
UNIQUE (task_id, id)
|
||||
);
|
||||
|
||||
CREATE TABLE order_authorizations (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
spec_trial_id TEXT NOT NULL REFERENCES spec_trials(id),
|
||||
version INTEGER NOT NULL CHECK (version > 0 AND typeof(version) = 'integer'),
|
||||
goods_id TEXT NOT NULL,
|
||||
sku_color TEXT NOT NULL,
|
||||
sku_size TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
|
||||
authorized_unit_price TEXT NOT NULL CHECK (authorized_unit_price <> '' AND authorized_unit_price NOT GLOB '*[^0-9.]*' AND length(authorized_unit_price) - length(replace(authorized_unit_price, '.', '')) <= 1 AND authorized_unit_price <> '.' AND (instr(authorized_unit_price, '.') = 0 OR (instr(authorized_unit_price, '.') > 1 AND length(authorized_unit_price) > instr(authorized_unit_price, '.') AND length(authorized_unit_price) - instr(authorized_unit_price, '.') <= 2))),
|
||||
total_price_cap TEXT NOT NULL CHECK (total_price_cap <> '' AND total_price_cap NOT GLOB '*[^0-9.]*' AND length(total_price_cap) - length(replace(total_price_cap, '.', '')) <= 1 AND total_price_cap <> '.' AND (instr(total_price_cap, '.') = 0 OR (instr(total_price_cap, '.') > 1 AND length(total_price_cap) > instr(total_price_cap, '.') AND length(total_price_cap) - instr(total_price_cap, '.') <= 2))),
|
||||
note TEXT,
|
||||
status TEXT NOT NULL CHECK (status IN ('PENDING_DELIVERY', 'DELIVERED', 'ACKNOWLEDGED', 'EXECUTING', 'FENCED', 'CONSUMED', 'SUPERSEDED', 'EXPIRED')),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
UNIQUE (task_id, version),
|
||||
UNIQUE (task_id, id),
|
||||
FOREIGN KEY (task_id, spec_trial_id) REFERENCES spec_trials(task_id, id)
|
||||
);
|
||||
|
||||
CREATE TABLE order_submissions (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
authorization_id TEXT NOT NULL REFERENCES order_authorizations(id),
|
||||
command_id TEXT NOT NULL,
|
||||
dry_run_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('FENCED', 'SUBMITTED', 'RECONCILIATION_REQUIRED', 'MANUAL_RESOLVED')),
|
||||
verified_unit_price TEXT NOT NULL CHECK (verified_unit_price <> '' AND verified_unit_price NOT GLOB '*[^0-9.]*' AND length(verified_unit_price) - length(replace(verified_unit_price, '.', '')) <= 1 AND verified_unit_price <> '.' AND (instr(verified_unit_price, '.') = 0 OR (instr(verified_unit_price, '.') > 1 AND length(verified_unit_price) > instr(verified_unit_price, '.') AND length(verified_unit_price) - instr(verified_unit_price, '.') <= 2))),
|
||||
quantity_read INTEGER NOT NULL CHECK (quantity_read > 0 AND typeof(quantity_read) = 'integer'),
|
||||
confirm_page_amount TEXT NOT NULL CHECK (confirm_page_amount <> '' AND confirm_page_amount NOT GLOB '*[^0-9.]*' AND length(confirm_page_amount) - length(replace(confirm_page_amount, '.', '')) <= 1 AND confirm_page_amount <> '.' AND (instr(confirm_page_amount, '.') = 0 OR (instr(confirm_page_amount, '.') > 1 AND length(confirm_page_amount) > instr(confirm_page_amount, '.') AND length(confirm_page_amount) - instr(confirm_page_amount, '.') <= 2))),
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_at TEXT,
|
||||
UNIQUE (authorization_id),
|
||||
UNIQUE (command_id),
|
||||
FOREIGN KEY (task_id, authorization_id) REFERENCES order_authorizations(task_id, id)
|
||||
);
|
||||
@@ -1,7 +1,9 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# 桌面界面(Qt 官方 Python 绑定)。
|
||||
PySide6
|
||||
# 后续真机取证会使用;本阶段不导入或连接设备。
|
||||
# T-101 基线取证使用;只连接显式 serial,不打开或操作拼多多页面。
|
||||
uiautomator2
|
||||
# T-101 直接使用当前 ADB server 的已列出设备对象交给 uiautomator2,禁止 WiFi 自动重连。
|
||||
adbutils>=2.11,<3
|
||||
# 后续截图完整性检查会使用。
|
||||
Pillow
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""采集指定 Android 设备的本地基线证据;不打开或操作拼多多页面。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
|
||||
from cmbuyer_client.device.baseline import (
|
||||
BaselineCaptureError,
|
||||
DeviceBaselineCapturer,
|
||||
NoReconnectUiautomatorConnector,
|
||||
)
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="采集显式指定 Android 设备的本地基线证据。")
|
||||
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||
parser.add_argument("--output-dir", required=True, type=Path, help="新建的本地证据目录;不得覆盖已有目录。")
|
||||
parser.add_argument("--timeout", type=float, default=10.0, help="ADB、uiautomator2 RPC 与 ADB socket 超时(秒)。")
|
||||
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def validate_arguments(arguments: argparse.Namespace) -> None:
|
||||
"""在导入设备库前拒绝危险或无效输入,便于离线测试。"""
|
||||
|
||||
if not arguments.serial.strip():
|
||||
raise ValueError("必须显式提供非空 --serial。")
|
||||
if arguments.timeout <= 0:
|
||||
raise ValueError("--timeout 必须大于 0。")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
validate_arguments(arguments)
|
||||
except ValueError as error:
|
||||
print(f"失败:{error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
import adbutils
|
||||
import uiautomator2 as u2
|
||||
except ImportError:
|
||||
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
client = AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout)
|
||||
connector = NoReconnectUiautomatorConnector(
|
||||
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
|
||||
u2.connect,
|
||||
)
|
||||
capturer = DeviceBaselineCapturer(client, connector, timeout_seconds=arguments.timeout)
|
||||
try:
|
||||
result = capturer.capture(arguments.serial, arguments.output_dir)
|
||||
except (DeviceConnectionError, BaselineCaptureError) as error:
|
||||
# 错误类型只表达状态,不打印 ADB 输出、serial、XML 或页面正文。
|
||||
print(f"基线取证失败:{error}", file=sys.stderr)
|
||||
return 1
|
||||
except OSError:
|
||||
print("基线取证失败:无法创建或发布本地证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"基线取证完成:{result.output_directory}")
|
||||
print(f"manifest:{result.manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,75 @@
|
||||
"""打开已验证的拼多多商品直链并采集只读本地证据。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
|
||||
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
|
||||
from cmbuyer_client.pdd.product_open import ProductOpenCapturer, ProductOpenError
|
||||
from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="打开 canonical 拼多多商品链接并采集只读证据。")
|
||||
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||
parser.add_argument("--url", required=True, help="唯一允许的 goods.html?goods_id= 直链。")
|
||||
parser.add_argument("--output-dir", required=True, type=Path, help="新建的本地证据目录;不得覆盖已有目录。")
|
||||
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 和只读 RPC 超时(秒)。")
|
||||
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def validate_arguments(arguments: argparse.Namespace) -> None:
|
||||
if not arguments.serial.strip():
|
||||
raise ValueError("必须显式提供非空 --serial。")
|
||||
if arguments.timeout <= 0:
|
||||
raise ValueError("--timeout 必须大于 0。")
|
||||
parse_product_url(arguments.url)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
validate_arguments(arguments)
|
||||
link = parse_product_url(arguments.url)
|
||||
except (ValueError, ProductUrlError) as error:
|
||||
print(f"失败:{error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
import adbutils
|
||||
import uiautomator2 as u2
|
||||
except ImportError:
|
||||
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
client = AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout)
|
||||
connector = NoReconnectUiautomatorConnector(
|
||||
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
|
||||
u2.connect,
|
||||
)
|
||||
capturer = ProductOpenCapturer(client, connector, timeout_seconds=arguments.timeout)
|
||||
try:
|
||||
result = capturer.open_and_capture(arguments.serial, link.canonical_url, arguments.output_dir)
|
||||
except (DeviceConnectionError, ProductOpenError) as error:
|
||||
# 不打印 ADB 输出、serial、Activity、XML 或页面正文。
|
||||
print(f"商品打开取证失败:{error}", file=sys.stderr)
|
||||
return 1
|
||||
except OSError:
|
||||
print("商品打开取证失败:无法创建或发布本地证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"商品打开取证完成:{result.output_directory}")
|
||||
print(f"manifest:{result.manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,103 @@
|
||||
"""采集人工已停在规格面板的三种状态证据;不执行任何页面操作。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from math import isfinite
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
|
||||
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
|
||||
from cmbuyer_client.pdd.product_url import ProductUrl, ProductUrlError, parse_product_url
|
||||
from cmbuyer_client.pdd.sku_panel_spike import (
|
||||
HUMAN_DECLARED_STATES,
|
||||
SkuPanelEvidenceCapturer,
|
||||
SkuPanelEvidenceError,
|
||||
)
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="采集人工已打开的拼多多规格面板状态证据。")
|
||||
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||
product_source = parser.add_mutually_exclusive_group(required=True)
|
||||
product_source.add_argument("--url", help="唯一 canonical goods.html?goods_id= 直链。")
|
||||
product_source.add_argument("--goods-id", help="纯数字商品标识;仅用于记录证据归属。")
|
||||
parser.add_argument("--state", required=True, choices=sorted(HUMAN_DECLARED_STATES), help="人工声明的面板状态。")
|
||||
parser.add_argument("--output-dir", required=True, type=Path, help="新建的本地证据目录;不得覆盖已有目录。")
|
||||
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 和只读 RPC 超时(秒)。")
|
||||
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def resolve_product_url(arguments: argparse.Namespace) -> ProductUrl:
|
||||
if isinstance(arguments.url, str):
|
||||
return parse_product_url(arguments.url)
|
||||
if isinstance(arguments.goods_id, str):
|
||||
# 仅使用严格 parser 重新验证并构建,不把输入交给 ADB 或页面。
|
||||
return parse_product_url(f"https://mobile.yangkeduo.com/goods.html?goods_id={arguments.goods_id}")
|
||||
raise ValueError("必须提供 --url 或 --goods-id。")
|
||||
|
||||
|
||||
def validate_arguments(arguments: argparse.Namespace) -> ProductUrl:
|
||||
if not isinstance(arguments.serial, str) or not arguments.serial.strip():
|
||||
raise ValueError("必须显式提供非空 --serial。")
|
||||
if (
|
||||
not isinstance(arguments.timeout, (int, float))
|
||||
or isinstance(arguments.timeout, bool)
|
||||
or arguments.timeout <= 0
|
||||
or not isfinite(arguments.timeout)
|
||||
):
|
||||
raise ValueError("--timeout 必须是大于 0 的有限数值。")
|
||||
if arguments.state not in HUMAN_DECLARED_STATES:
|
||||
raise ValueError("--state 必须是允许的人工声明状态。")
|
||||
return resolve_product_url(arguments)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
link = validate_arguments(arguments)
|
||||
except (ValueError, ProductUrlError) as error:
|
||||
print(f"失败:{error}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
import adbutils
|
||||
import uiautomator2 as u2
|
||||
except ImportError:
|
||||
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
client = AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout)
|
||||
connector = NoReconnectUiautomatorConnector(
|
||||
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
|
||||
u2.connect,
|
||||
)
|
||||
capturer = SkuPanelEvidenceCapturer(client, connector, timeout_seconds=arguments.timeout)
|
||||
try:
|
||||
result = capturer.capture(
|
||||
arguments.serial,
|
||||
link.canonical_url,
|
||||
arguments.state,
|
||||
arguments.output_dir,
|
||||
)
|
||||
except (DeviceConnectionError, SkuPanelEvidenceError) as error:
|
||||
# 不打印 ADB 输出、serial、Activity、XML 或页面正文。
|
||||
print(f"规格面板证据采集失败:{error}", file=sys.stderr)
|
||||
return 1
|
||||
except OSError:
|
||||
print("规格面板证据采集失败:无法创建或发布本地证据目录。", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"规格面板证据采集完成:{result.output_directory}")
|
||||
print(f"manifest:{result.manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,41 @@
|
||||
"""本机脱敏 T-103 raw 证据到同级 derived;不连接设备或解析页面语义。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.sku_evidence_sanitizer import (
|
||||
SkuEvidenceSanitizationError,
|
||||
sanitize_sku_panel_evidence,
|
||||
)
|
||||
|
||||
|
||||
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="将本机 raw 规格面板证据确定性脱敏到同级 derived。")
|
||||
parser.add_argument("--raw-dir", required=True, type=Path, help="仅允许名为 raw 的本机原始证据目录。")
|
||||
parser.add_argument("--output-dir", required=True, type=Path, help="仅允许 raw 同级且名为 derived 的新目录。")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = parse_arguments(argv)
|
||||
try:
|
||||
result = sanitize_sku_panel_evidence(arguments.raw_dir, arguments.output_dir)
|
||||
except SkuEvidenceSanitizationError as error:
|
||||
# 错误不回显 raw 路径、manifest/XML、地址、手机号或 serial。
|
||||
print(f"证据脱敏失败:{error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"派生证据脱敏完成:{result.output_directory}")
|
||||
print(f"manifest:{result.manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,15 @@
|
||||
"""设备连接与基线取证边界。
|
||||
|
||||
本包只提供显式设备选择、非敏感身份核验和本地基线采集;不包含任何采购页面或订单操作。
|
||||
"""
|
||||
|
||||
from .adb import AdbClient, AdbDevice, CommandResult
|
||||
from .baseline import BaselineCaptureResult, DeviceBaselineCapturer
|
||||
|
||||
__all__ = [
|
||||
"AdbClient",
|
||||
"AdbDevice",
|
||||
"BaselineCaptureResult",
|
||||
"CommandResult",
|
||||
"DeviceBaselineCapturer",
|
||||
]
|
||||
@@ -0,0 +1,282 @@
|
||||
"""ADB 设备清单与物理设备冲突的 fail-closed 边界。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Protocol, Sequence
|
||||
|
||||
|
||||
class DeviceConnectionError(RuntimeError):
|
||||
"""显式设备连接边界的基础错误,不携带命令输出或设备敏感内容。"""
|
||||
|
||||
|
||||
class SerialRequiredError(DeviceConnectionError):
|
||||
"""调用方没有明确指定设备 serial。"""
|
||||
|
||||
|
||||
class DeviceNotFoundError(DeviceConnectionError):
|
||||
"""指定 serial 不在 ADB 当前清单中。"""
|
||||
|
||||
|
||||
class DeviceOfflineError(DeviceConnectionError):
|
||||
"""指定设备处于 offline 状态。"""
|
||||
|
||||
|
||||
class DeviceUnauthorizedError(DeviceConnectionError):
|
||||
"""指定设备尚未授权此电脑。"""
|
||||
|
||||
|
||||
class DeviceStateError(DeviceConnectionError):
|
||||
"""指定设备处于其他不可用状态。"""
|
||||
|
||||
|
||||
class DeviceCommandTimeoutError(DeviceConnectionError):
|
||||
"""ADB 命令超过调用方指定的超时。"""
|
||||
|
||||
|
||||
class DeviceCommandError(DeviceConnectionError):
|
||||
"""ADB 命令失败;错误文本刻意不回显设备输出。"""
|
||||
|
||||
|
||||
class DeviceIdentityUnconfirmedError(DeviceConnectionError):
|
||||
"""多条在线通道无法完成同机身份判断,必须由人处理。"""
|
||||
|
||||
|
||||
class DuplicatePhysicalDeviceError(DeviceConnectionError):
|
||||
"""同一物理手机通过多个 ADB 通道同时在线。"""
|
||||
|
||||
|
||||
class IntentLaunchUnconfirmedError(DeviceConnectionError):
|
||||
"""`am start -W` 没有给出可确认的启动成功结果。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandResult:
|
||||
"""可注入命令执行器的最小、可离线构造结果。"""
|
||||
|
||||
stdout: str
|
||||
stderr: str = ""
|
||||
returncode: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntentLaunchSummary:
|
||||
"""不含 Activity、页面内容或 ADB 输出的受限启动摘要。"""
|
||||
|
||||
status: str
|
||||
returncode: int
|
||||
|
||||
|
||||
class CommandRunner(Protocol):
|
||||
"""运行 ADB 子命令的可替换边界。"""
|
||||
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
"""运行参数,不得通过 shell 拼接。"""
|
||||
|
||||
|
||||
class SubprocessAdbRunner:
|
||||
"""使用 subprocess 的生产执行器,所有调用必须带超时。"""
|
||||
|
||||
def __init__(self, executable: str | Path = "adb") -> None:
|
||||
self._executable = str(executable)
|
||||
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[self._executable, *arguments],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise DeviceCommandTimeoutError("ADB 命令超时,请检查设备连接后由人工重试。") from error
|
||||
except OSError as error:
|
||||
raise DeviceCommandError("无法启动 ADB,请检查 adb 路径与本机工具链。") from error
|
||||
|
||||
return CommandResult(
|
||||
stdout=completed.stdout,
|
||||
stderr=completed.stderr,
|
||||
returncode=completed.returncode,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdbDevice:
|
||||
"""`adb devices -l` 的单行非敏感传输元数据。"""
|
||||
|
||||
serial: str
|
||||
state: str
|
||||
product: str | None = None
|
||||
model: str | None = None
|
||||
device: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceInspection:
|
||||
"""选定通道的只读身份结果,原始硬件标识只在内存中参与比较。"""
|
||||
|
||||
device: AdbDevice
|
||||
model: str
|
||||
android_version: str
|
||||
|
||||
|
||||
def parse_adb_devices(output: str) -> list[AdbDevice]:
|
||||
"""解析 `adb devices -l`,忽略标题、空行和 adb 附加提示。"""
|
||||
|
||||
devices: list[AdbDevice] = []
|
||||
for raw_line in output.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("List of devices attached") or line.startswith("*"):
|
||||
continue
|
||||
fields = line.split()
|
||||
if len(fields) < 2:
|
||||
continue
|
||||
details = {
|
||||
key: value
|
||||
for field in fields[2:]
|
||||
if ":" in field
|
||||
for key, value in [field.split(":", 1)]
|
||||
}
|
||||
devices.append(
|
||||
AdbDevice(
|
||||
serial=fields[0],
|
||||
state=fields[1],
|
||||
product=details.get("product"),
|
||||
model=details.get("model"),
|
||||
device=details.get("device"),
|
||||
)
|
||||
)
|
||||
return devices
|
||||
|
||||
|
||||
class AdbClient:
|
||||
"""显式 serial 的 ADB 只读查询。
|
||||
|
||||
多个在线通道必须完成硬件身份比对。比对失败时不能用相同 model/product 猜测同一台手机,
|
||||
因为那会把不确定性隐藏成错误的安全结论。
|
||||
"""
|
||||
|
||||
def __init__(self, runner: CommandRunner, timeout_seconds: float = 10.0) -> None:
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds 必须大于 0")
|
||||
self._runner = runner
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
def inspect(self, serial: str) -> DeviceInspection:
|
||||
"""确认指定通道在线且不与另一在线通道指向同一物理设备。"""
|
||||
|
||||
selected_serial = _require_serial(serial)
|
||||
devices = self.devices()
|
||||
selected = next((device for device in devices if device.serial == selected_serial), None)
|
||||
if selected is None:
|
||||
raise DeviceNotFoundError("指定设备不在 ADB 清单中,请显式检查 serial。")
|
||||
_raise_for_state(selected.state)
|
||||
|
||||
online_devices = [device for device in devices if device.state == "device"]
|
||||
if len(online_devices) > 1:
|
||||
identities: dict[str, frozenset[str]] = {}
|
||||
for candidate in online_devices:
|
||||
try:
|
||||
identities[candidate.serial] = self._physical_identity(candidate)
|
||||
except DeviceConnectionError as error:
|
||||
raise DeviceIdentityUnconfirmedError(
|
||||
"存在多个在线 ADB 通道且身份无法确认,已拒绝选择设备。"
|
||||
) from error
|
||||
|
||||
selected_identity = identities[selected.serial]
|
||||
if any(
|
||||
candidate_serial != selected.serial and selected_identity.intersection(candidate_identity)
|
||||
for candidate_serial, candidate_identity in identities.items()
|
||||
):
|
||||
raise DuplicatePhysicalDeviceError(
|
||||
"同一物理手机的多个 ADB 通道同时在线,已拒绝继续;请仅保留一个通道。"
|
||||
)
|
||||
|
||||
model = self._getprop(selected.serial, "ro.product.model") or selected.model or "unknown"
|
||||
android_version = self._getprop(selected.serial, "ro.build.version.release") or "unknown"
|
||||
return DeviceInspection(device=selected, model=model, android_version=android_version)
|
||||
|
||||
def devices(self) -> list[AdbDevice]:
|
||||
"""读取并解析 ADB 设备清单。"""
|
||||
|
||||
result = self._run_checked(("devices", "-l"))
|
||||
return parse_adb_devices(result.stdout)
|
||||
|
||||
def start_pdd_view_intent(self, serial: str, goods_id: str) -> IntentLaunchSummary:
|
||||
"""以参数数组启动唯一允许的拼多多 ACTION_VIEW Intent。
|
||||
|
||||
这里刻意不提供任意 shell 或任意 package 的执行接口。调用方必须先完成
|
||||
``inspect`` 和应用版本核验;本方法在本层从纯数字 ``goods_id`` 重建 URL,调用方不能
|
||||
把另一个 URL 直接交给 ADB。本方法既不点击控件,也不解析 Activity 或页面文本。
|
||||
"""
|
||||
|
||||
selected_serial = _require_serial(serial)
|
||||
if (
|
||||
not isinstance(goods_id, str)
|
||||
or not goods_id
|
||||
or any(character < "0" or character > "9" for character in goods_id)
|
||||
):
|
||||
raise ValueError("goods_id 必须是纯数字")
|
||||
canonical_url = f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}"
|
||||
result = self._run_checked(
|
||||
(
|
||||
"-s",
|
||||
selected_serial,
|
||||
"shell",
|
||||
"am",
|
||||
"start",
|
||||
"-W",
|
||||
"-a",
|
||||
"android.intent.action.VIEW",
|
||||
"-d",
|
||||
canonical_url,
|
||||
"-p",
|
||||
"com.xunmeng.pinduoduo",
|
||||
)
|
||||
)
|
||||
if not any(line.strip() == "Status: ok" for line in result.stdout.splitlines()):
|
||||
raise IntentLaunchUnconfirmedError("商品链接启动结果无法确认,已停止后续取证。")
|
||||
return IntentLaunchSummary(status="ok", returncode=result.returncode)
|
||||
|
||||
def _physical_identity(self, device: AdbDevice) -> frozenset[str]:
|
||||
serialno = self._getprop(device.serial, "ro.serialno")
|
||||
boot_serialno = self._getprop(device.serial, "ro.boot.serialno")
|
||||
identifiers = frozenset(value for value in (serialno, boot_serialno) if value)
|
||||
if identifiers:
|
||||
return identifiers
|
||||
# model/product/device 只能作为展示元数据,不能证明两台同型号设备是同一物理机。
|
||||
raise DeviceIdentityUnconfirmedError("无法读取设备硬件身份摘要。")
|
||||
|
||||
def _getprop(self, serial: str, property_name: str) -> str:
|
||||
result = self._run_checked(("-s", serial, "shell", "getprop", property_name))
|
||||
return result.stdout.strip()
|
||||
|
||||
def _run_checked(self, arguments: Sequence[str]) -> CommandResult:
|
||||
try:
|
||||
result = self._runner.run(arguments, self._timeout_seconds)
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise DeviceCommandTimeoutError("ADB 命令超时,请检查设备连接后由人工重试。") from error
|
||||
if result.returncode != 0:
|
||||
raise DeviceCommandError("ADB 命令失败,请检查设备连接或授权状态。")
|
||||
return result
|
||||
|
||||
|
||||
def _require_serial(serial: str) -> str:
|
||||
if not isinstance(serial, str) or not serial.strip():
|
||||
raise SerialRequiredError("必须显式提供设备 serial,禁止自动选择设备。")
|
||||
return serial.strip()
|
||||
|
||||
|
||||
def _raise_for_state(state: str) -> None:
|
||||
if state == "device":
|
||||
return
|
||||
if state == "offline":
|
||||
raise DeviceOfflineError("指定设备处于 offline 状态。")
|
||||
if state == "unauthorized":
|
||||
raise DeviceUnauthorizedError("指定设备尚未授权此电脑。")
|
||||
raise DeviceStateError("指定设备不处于可用状态。")
|
||||
@@ -0,0 +1,238 @@
|
||||
"""只读设备基线取证,严格限制在元数据、截图和完整节点树。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
import base64
|
||||
import binascii
|
||||
from io import BytesIO
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from adbutils.errors import AdbTimeout
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from uiautomator2.exceptions import HTTPTimeoutError
|
||||
|
||||
from .adb import AdbClient, DeviceInspection
|
||||
|
||||
|
||||
PDD_PACKAGE = "com.xunmeng.pinduoduo"
|
||||
SCREENSHOT_PARAMS = [1, 80]
|
||||
HIERARCHY_PARAMS = [False, 50]
|
||||
_BASE64_ASCII_WHITESPACE = " \t\r\n"
|
||||
|
||||
|
||||
class BaselineCaptureError(RuntimeError):
|
||||
"""基线取证无法完整落盘时的失败,不会伪造成功产物。"""
|
||||
|
||||
|
||||
class BaselineCaptureTimeoutError(BaselineCaptureError):
|
||||
"""设备基线取证超时;底层异常文本不向 CLI 或日志泄露。"""
|
||||
|
||||
|
||||
class UiAutomatorDevice(Protocol):
|
||||
"""本任务唯一需要的 uiautomator2 只读能力。"""
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]:
|
||||
"""读取已安装应用元数据。"""
|
||||
|
||||
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any:
|
||||
"""调用公开 uiautomator2 JSON-RPC 接口。"""
|
||||
|
||||
|
||||
class NoReconnectUiautomatorConnector:
|
||||
"""只把当前 ADB server 已列出的设备对象交给 uiautomator2。
|
||||
|
||||
uiautomator2 直接接收 IP serial 时会在内部尝试 adb disconnect/connect。这里先从已列出设备中
|
||||
取对象再调用 ``u2.connect(device_object)``,避免连接阶段隐式重连已经掉线的 WiFi 通道。
|
||||
"""
|
||||
|
||||
def __init__(self, list_devices: Callable[[], list[Any]], connect: Callable[[Any], UiAutomatorDevice]) -> None:
|
||||
self._list_devices = list_devices
|
||||
self._connect = connect
|
||||
|
||||
def __call__(self, serial: str) -> UiAutomatorDevice:
|
||||
device = next((item for item in self._list_devices() if item.serial == serial), None)
|
||||
if device is None:
|
||||
raise BaselineCaptureError("设备在连接前已从 ADB 清单消失,已拒绝自动重连。")
|
||||
return self._connect(device)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BaselineCaptureResult:
|
||||
"""已原子发布的基线取证摘要,不包含页面正文或原始 serial。"""
|
||||
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
screenshot_path: Path
|
||||
hierarchy_path: Path
|
||||
|
||||
|
||||
class DeviceBaselineCapturer:
|
||||
"""以先校验通道、后连接、最后原子发布的顺序采集基线。
|
||||
|
||||
截图和 XML 可能包含页面敏感内容,因此仅落在调用方明确指定的本地目录;manifest 只写
|
||||
哈希、设备非敏感元数据和脱敏后的 serial 摘要,绝不嵌入 XML 或页面文本。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adb_client: AdbClient,
|
||||
connector: Callable[[str], UiAutomatorDevice],
|
||||
timeout_seconds: float,
|
||||
) -> None:
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds 必须大于 0")
|
||||
self._adb_client = adb_client
|
||||
self._connector = connector
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
def capture(self, serial: str, output_directory: Path) -> BaselineCaptureResult:
|
||||
"""采集完整基线,任何一步失败均不发布 output_directory。"""
|
||||
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
target = Path(output_directory)
|
||||
if target.exists():
|
||||
raise BaselineCaptureError("输出目录已存在;为防止混入旧证据,拒绝覆盖。")
|
||||
if not target.name:
|
||||
raise BaselineCaptureError("输出目录必须是明确的新目录。")
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
||||
staging.mkdir()
|
||||
try:
|
||||
device = self._connector(serial)
|
||||
app_info = device.app_info(PDD_PACKAGE)
|
||||
version = _extract_version(app_info)
|
||||
|
||||
screenshot_path = staging / "screenshot.png"
|
||||
screenshot_base64 = device.jsonrpc_call(
|
||||
"takeScreenshot",
|
||||
SCREENSHOT_PARAMS,
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
_save_base64_screenshot(screenshot_base64, screenshot_path)
|
||||
|
||||
hierarchy = device.jsonrpc_call(
|
||||
"dumpWindowHierarchy",
|
||||
HIERARCHY_PARAMS,
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
_validate_hierarchy(hierarchy)
|
||||
hierarchy_path = staging / "hierarchy.xml"
|
||||
hierarchy_path.write_text(hierarchy, encoding="utf-8")
|
||||
|
||||
manifest_path = staging / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
_manifest(inspection, serial, version, screenshot_path, hierarchy_path),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(staging, target)
|
||||
except BaselineCaptureError:
|
||||
# 仅删除本次创建、名称带随机标识的暂存目录,绝不触碰调用方原有输出目录。
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
raise BaselineCaptureTimeoutError("设备基线取证超时,未发布任何证据产物。") from error
|
||||
except Exception as error:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
# uiautomator2/adbutils 可能把 serial、路径或远端响应放入异常文本,不能直接传播到 CLI。
|
||||
raise BaselineCaptureError("设备基线取证未完成,未发布任何证据产物。") from error
|
||||
|
||||
return BaselineCaptureResult(
|
||||
output_directory=target,
|
||||
manifest_path=target / "manifest.json",
|
||||
screenshot_path=target / "screenshot.png",
|
||||
hierarchy_path=target / "hierarchy.xml",
|
||||
)
|
||||
|
||||
|
||||
def _extract_version(app_info: dict[str, Any]) -> str:
|
||||
version = app_info.get("versionName") or app_info.get("version_name")
|
||||
if not isinstance(version, str) or not version.strip():
|
||||
raise BaselineCaptureError("无法读取拼多多版本,拒绝发布不完整取证。")
|
||||
return version.strip()
|
||||
|
||||
|
||||
def _save_base64_screenshot(value: Any, target: Path) -> None:
|
||||
"""规范化常见 ASCII Base64 空白后严格解码;没有 adb screenshot fallback。"""
|
||||
|
||||
if not isinstance(value, str) or not value:
|
||||
raise BaselineCaptureError("截图 RPC 未返回 base64 数据,拒绝发布不完整取证。")
|
||||
try:
|
||||
normalized = value.translate({ord(character): None for character in _BASE64_ASCII_WHITESPACE})
|
||||
raw_image = base64.b64decode(normalized.encode("ascii"), validate=True)
|
||||
except (UnicodeEncodeError, ValueError, binascii.Error) as error:
|
||||
raise BaselineCaptureError("截图 RPC Base64 语法无效,拒绝发布不完整取证。") from error
|
||||
try:
|
||||
with Image.open(BytesIO(raw_image)) as image:
|
||||
image.load()
|
||||
image.save(target, format="PNG")
|
||||
except (UnidentifiedImageError, OSError) as error:
|
||||
raise BaselineCaptureError("截图 RPC 图像数据无效,拒绝发布不完整取证。") from error
|
||||
|
||||
|
||||
def _validate_hierarchy(value: Any) -> None:
|
||||
"""确认 RPC 返回的是完整节点树,不把原始 XML 放进错误或日志。"""
|
||||
|
||||
if not isinstance(value, str) or not value:
|
||||
raise BaselineCaptureError("节点树导出为空,拒绝发布不完整取证。")
|
||||
try:
|
||||
root = ElementTree.fromstring(value)
|
||||
except ElementTree.ParseError as error:
|
||||
raise BaselineCaptureError("节点树不是有效 XML,拒绝发布不完整取证。") from error
|
||||
if root.tag != "hierarchy":
|
||||
raise BaselineCaptureError("节点树根节点无效,拒绝发布不完整取证。")
|
||||
|
||||
|
||||
def _manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
pdd_version: str,
|
||||
screenshot_path: Path,
|
||||
hierarchy_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
"""只序列化审计摘要;页面内容留在 XML 文件,不进入日志或 manifest。"""
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"channel": "wifi" if ":" in serial else "usb",
|
||||
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||
"device": {
|
||||
"model": inspection.model,
|
||||
"android_version": inspection.android_version,
|
||||
"pdd_package": PDD_PACKAGE,
|
||||
"pdd_version": pdd_version,
|
||||
},
|
||||
"artifacts": [
|
||||
{"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)},
|
||||
{"path": hierarchy_path.name, "sha256": _sha256_file(hierarchy_path)},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
@@ -0,0 +1,580 @@
|
||||
"""T-103 原始规格面板证据的本机确定性隐私脱敏。
|
||||
|
||||
此模块只处理人工采集的本地文件:不连接设备、不识别规格;仅可按已取证的固定
|
||||
几何和严格格式,将跨隐私边界的价格叶节点投影到派生 XML。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from PIL import Image, ImageDraw, UnidentifiedImageError
|
||||
|
||||
from ..pdd.product_url import ProductUrl, ProductUrlError, parse_product_url
|
||||
from ..pdd.sku_panel_state import HUMAN_DECLARED_STATES
|
||||
|
||||
|
||||
SANITIZER_VERSION = "t103-privacy-v5"
|
||||
EXPECTED_GOODS_ID = "937122477375"
|
||||
EXPECTED_PDD_VERSION = "8.17.0"
|
||||
EXPECTED_DEVICE_MODEL = "PKG110"
|
||||
EXPECTED_ANDROID_VERSION = "16"
|
||||
EXPECTED_SCREENSHOT_WIDTH = 1080
|
||||
EXPECTED_SCREENSHOT_HEIGHT = 2376
|
||||
EXPECTED_XML_WIDTH = 1080
|
||||
EXPECTED_XML_HEIGHT = 2376
|
||||
_ARTIFACT_FILES = ("screenshot.png", "hierarchy.xml")
|
||||
_SHA256_RE = re.compile(r"[0-9a-f]{64}\Z")
|
||||
_BOUNDS_RE = re.compile(r"\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]\Z")
|
||||
_FULL_PHONE_RE = re.compile(r"(?:\+?86)?1[3-9]\d{9}")
|
||||
_MASKED_PHONE_RE = re.compile(r"1[3-9]\d\*{4}\d{4}")
|
||||
_MASK_TRANSLATION = str.maketrans({"*": "*", "•": "*", "·": "*", "×": "*", "x": "*", "X": "*"})
|
||||
_SEPARATOR_RE = re.compile(r"[\s\-‐‑‒–—―()()]+")
|
||||
# 这两个槽位来自 T-103 当前第一态、1080×2376 XML 坐标的人工审查。它们不是通用
|
||||
# 页面判据;坐标、文本或结构任何变化都停止发布,交由人重新取证。
|
||||
_CROSSING_PRICE_SLOTS = {
|
||||
(396, 503, 712, 570): "[396,503][712,570]",
|
||||
(730, 503, 895, 570): "[730,503][895,570]",
|
||||
}
|
||||
_CROSSING_PRICE_BOUNDS = frozenset(_CROSSING_PRICE_SLOTS)
|
||||
_PRICE_PROJECTION_ATTRIBUTES = (
|
||||
"bounds",
|
||||
"text",
|
||||
"package",
|
||||
"class",
|
||||
"clickable",
|
||||
"enabled",
|
||||
"visible-to-user",
|
||||
)
|
||||
# 仅接受普通 ASCII 空格,且每个可分隔位置最多一个;禁止换行、折扣、支付/提交文案和
|
||||
# 任何其它字符。前缀捕获组用于区分当前价与至多一个划线/原价候选。
|
||||
# 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):
|
||||
"""原始证据不能被安全地发布为派生证据。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CleanupStats:
|
||||
"""仅记录节点数量,供派生 manifest 审计;不记录任何页面文本。"""
|
||||
|
||||
removed_nodes: int = 0
|
||||
cleared_crossing_nodes: int = 0
|
||||
preserved_crossing_price_nodes: int = 0
|
||||
retained_below_nodes: int = 0
|
||||
max_right: int = 0
|
||||
max_bottom: int = 0
|
||||
current_price_candidates: int = 0
|
||||
original_price_candidates: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PrivacyMaskConfig:
|
||||
"""仅描述已人工确认的隐私几何区域,绝不承担页面或规格判据。"""
|
||||
|
||||
version: str
|
||||
screenshot_width: int
|
||||
screenshot_height: int
|
||||
xml_width: int
|
||||
xml_height: int
|
||||
privacy_top: int
|
||||
|
||||
|
||||
PRIVACY_MASK_CONFIG = PrivacyMaskConfig(
|
||||
version=SANITIZER_VERSION,
|
||||
screenshot_width=EXPECTED_SCREENSHOT_WIDTH,
|
||||
screenshot_height=EXPECTED_SCREENSHOT_HEIGHT,
|
||||
xml_width=EXPECTED_XML_WIDTH,
|
||||
xml_height=EXPECTED_XML_HEIGHT,
|
||||
# 主审在原始截图确认 y < 540 为收货/手机号区域;整宽遮罩优先保护隐私而非保留版面。
|
||||
privacy_top=540,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkuEvidenceSanitizationResult:
|
||||
"""已经原子发布的派生证据位置。"""
|
||||
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
screenshot_path: Path
|
||||
hierarchy_path: Path
|
||||
|
||||
|
||||
def sanitize_sku_panel_evidence(raw_directory: Path, output_directory: Path) -> SkuEvidenceSanitizationResult:
|
||||
"""校验 raw 三文件,并发布同级 ``derived`` 的脱敏副本。
|
||||
|
||||
目标已存在时在读取任何输入前拒绝,避免混入旧派生物或覆盖人工保留文件。
|
||||
"""
|
||||
|
||||
raw = Path(raw_directory)
|
||||
target = Path(output_directory)
|
||||
_validate_directories(raw, target)
|
||||
if target.exists():
|
||||
raise SkuEvidenceSanitizationError("派生证据目录已存在,拒绝覆盖。")
|
||||
|
||||
staging: Path | None = None
|
||||
try:
|
||||
source_manifest_path = _required_file(raw, "manifest.json")
|
||||
source_screenshot_path = _required_file(raw, "screenshot.png")
|
||||
source_hierarchy_path = _required_file(raw, "hierarchy.xml")
|
||||
manifest = _read_source_manifest(source_manifest_path)
|
||||
link, state, source_hashes = _validate_source_manifest(manifest)
|
||||
_verify_source_hashes(source_screenshot_path, source_hierarchy_path, source_hashes)
|
||||
|
||||
staging = raw.parent / f".derived.staging-{uuid4().hex}"
|
||||
staging.mkdir()
|
||||
derived_screenshot_path = staging / "screenshot.png"
|
||||
_sanitize_screenshot(source_screenshot_path, derived_screenshot_path)
|
||||
derived_hierarchy_path = staging / "hierarchy.xml"
|
||||
cleanup_stats = _sanitize_hierarchy(source_hierarchy_path, derived_hierarchy_path)
|
||||
|
||||
derived_manifest_path = staging / "manifest.json"
|
||||
derived_manifest_path.write_text(
|
||||
json.dumps(
|
||||
_derived_manifest(
|
||||
manifest,
|
||||
link,
|
||||
state,
|
||||
source_manifest_path,
|
||||
source_screenshot_path,
|
||||
source_hierarchy_path,
|
||||
derived_screenshot_path,
|
||||
derived_hierarchy_path,
|
||||
cleanup_stats,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
_publish_staging(staging, target)
|
||||
except SkuEvidenceSanitizationError:
|
||||
_clean_staging(staging)
|
||||
raise
|
||||
except (OSError, ValueError, ElementTree.ParseError, UnidentifiedImageError) as error:
|
||||
_clean_staging(staging)
|
||||
# 原始异常可能含文件路径、JSON/XML 文本或其他敏感内容,不能向 CLI/日志传播。
|
||||
raise SkuEvidenceSanitizationError("原始证据无法安全脱敏,未发布任何派生产物。") from error
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuEvidenceSanitizationError("原始证据脱敏未完成,未发布任何派生产物。") from error
|
||||
|
||||
return SkuEvidenceSanitizationResult(
|
||||
output_directory=target,
|
||||
manifest_path=target / "manifest.json",
|
||||
screenshot_path=target / "screenshot.png",
|
||||
hierarchy_path=target / "hierarchy.xml",
|
||||
)
|
||||
|
||||
|
||||
def _validate_directories(raw: Path, target: Path) -> None:
|
||||
if raw.name != "raw" or not raw.is_dir():
|
||||
raise SkuEvidenceSanitizationError("原始证据目录必须是存在的 raw 目录。")
|
||||
if target.name != "derived" or target.parent != raw.parent:
|
||||
raise SkuEvidenceSanitizationError("派生证据目录必须是 raw 同级的 derived 目录。")
|
||||
|
||||
|
||||
def _required_file(raw: Path, filename: str) -> Path:
|
||||
candidate = raw / filename
|
||||
if not candidate.is_file():
|
||||
raise SkuEvidenceSanitizationError("原始证据文件集合不完整。")
|
||||
return candidate
|
||||
|
||||
|
||||
def _read_source_manifest(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
raise SkuEvidenceSanitizationError("原始证据 manifest 无效。") from error
|
||||
if not isinstance(value, dict):
|
||||
raise SkuEvidenceSanitizationError("原始证据 manifest 结构无效。")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_source_manifest(manifest: dict[str, Any]) -> tuple[ProductUrl, str, dict[str, str]]:
|
||||
product = manifest.get("product")
|
||||
device = manifest.get("device")
|
||||
state = manifest.get("human_declared_state")
|
||||
if manifest.get("schema_version") != 1 or not isinstance(product, dict) or not isinstance(device, dict):
|
||||
raise SkuEvidenceSanitizationError("原始证据 manifest 缺少必要元数据。")
|
||||
canonical_url = product.get("canonical_url")
|
||||
goods_id = product.get("goods_id")
|
||||
try:
|
||||
link = parse_product_url(canonical_url)
|
||||
except ProductUrlError as error:
|
||||
raise SkuEvidenceSanitizationError("原始证据商品元数据不匹配。") from error
|
||||
if (
|
||||
link.goods_id != EXPECTED_GOODS_ID
|
||||
or goods_id != EXPECTED_GOODS_ID
|
||||
or device.get("model") != EXPECTED_DEVICE_MODEL
|
||||
or device.get("android_version") != EXPECTED_ANDROID_VERSION
|
||||
or device.get("pdd_version") != EXPECTED_PDD_VERSION
|
||||
or device.get("pdd_package") != "com.xunmeng.pinduoduo"
|
||||
or not isinstance(state, str)
|
||||
or state not in HUMAN_DECLARED_STATES
|
||||
):
|
||||
raise SkuEvidenceSanitizationError("原始证据元数据与脱敏配置不匹配。")
|
||||
return link, state, _artifact_hashes(manifest)
|
||||
|
||||
|
||||
def _artifact_hashes(manifest: dict[str, Any]) -> dict[str, str]:
|
||||
artifacts = manifest.get("artifacts")
|
||||
if not isinstance(artifacts, list):
|
||||
raise SkuEvidenceSanitizationError("原始证据 manifest 缺少文件校验信息。")
|
||||
hashes: dict[str, str] = {}
|
||||
for artifact in artifacts:
|
||||
if not isinstance(artifact, dict):
|
||||
raise SkuEvidenceSanitizationError("原始证据 manifest 文件校验信息无效。")
|
||||
path = artifact.get("path")
|
||||
digest = artifact.get("sha256")
|
||||
if path not in _ARTIFACT_FILES or path in hashes or not isinstance(digest, str) or not _SHA256_RE.fullmatch(digest):
|
||||
raise SkuEvidenceSanitizationError("原始证据 manifest 文件校验信息无效。")
|
||||
hashes[path] = digest
|
||||
if set(hashes) != set(_ARTIFACT_FILES):
|
||||
raise SkuEvidenceSanitizationError("原始证据 manifest 文件校验信息不完整。")
|
||||
return hashes
|
||||
|
||||
|
||||
def _verify_source_hashes(screenshot_path: Path, hierarchy_path: Path, expected: dict[str, str]) -> None:
|
||||
if (
|
||||
_sha256_file(screenshot_path) != expected["screenshot.png"]
|
||||
or _sha256_file(hierarchy_path) != expected["hierarchy.xml"]
|
||||
):
|
||||
raise SkuEvidenceSanitizationError("原始证据文件校验失败。")
|
||||
|
||||
|
||||
def _sanitize_screenshot(source: Path, target: Path) -> None:
|
||||
try:
|
||||
with Image.open(source) as image:
|
||||
image.load()
|
||||
if image.format != "PNG" or image.size != (
|
||||
PRIVACY_MASK_CONFIG.screenshot_width,
|
||||
PRIVACY_MASK_CONFIG.screenshot_height,
|
||||
):
|
||||
raise SkuEvidenceSanitizationError("原始截图分辨率或格式与脱敏配置不匹配。")
|
||||
sanitized = image.convert("RGBA")
|
||||
except SkuEvidenceSanitizationError:
|
||||
raise
|
||||
except (OSError, UnidentifiedImageError) as error:
|
||||
raise SkuEvidenceSanitizationError("原始截图无效。") from error
|
||||
|
||||
# 用不透明黑色覆盖 y < 540,保证截图与 XML 使用相同的隐私几何边界。
|
||||
ImageDraw.Draw(sanitized).rectangle(
|
||||
(0, 0, PRIVACY_MASK_CONFIG.screenshot_width - 1, PRIVACY_MASK_CONFIG.privacy_top - 1),
|
||||
fill=(0, 0, 0, 255),
|
||||
)
|
||||
sanitized.save(target, format="PNG", optimize=False, compress_level=9)
|
||||
|
||||
|
||||
def _sanitize_hierarchy(source: Path, target: Path) -> _CleanupStats:
|
||||
try:
|
||||
root = ElementTree.parse(source).getroot()
|
||||
except (OSError, ElementTree.ParseError) as error:
|
||||
raise SkuEvidenceSanitizationError("原始节点树无效。") from error
|
||||
if root.tag != "hierarchy":
|
||||
raise SkuEvidenceSanitizationError("原始节点树结构不匹配。")
|
||||
if not list(root):
|
||||
raise SkuEvidenceSanitizationError("原始节点树结构不匹配。")
|
||||
stats = _CleanupStats()
|
||||
_clear_node_text(root)
|
||||
for child in list(root):
|
||||
_sanitize_node(root, child, stats)
|
||||
_require_expected_xml_coordinate_space(stats)
|
||||
if stats.removed_nodes < 1 or stats.retained_below_nodes < 1:
|
||||
raise SkuEvidenceSanitizationError("原始节点树未满足隐私几何结构。")
|
||||
_require_safe_crossing_price_projection(stats)
|
||||
if _contains_phone(root):
|
||||
raise SkuEvidenceSanitizationError("派生节点树仍包含手机号,拒绝发布。")
|
||||
ElementTree.ElementTree(root).write(target, encoding="utf-8", xml_declaration=True)
|
||||
return stats
|
||||
|
||||
|
||||
def _sanitize_node(parent: ElementTree.Element, node: ElementTree.Element, stats: _CleanupStats) -> None:
|
||||
if node.tag != "node":
|
||||
raise SkuEvidenceSanitizationError("原始节点树结构不匹配。")
|
||||
bounds = _parse_bounds(node.get("bounds"))
|
||||
_observe_bounds(stats, bounds)
|
||||
position = _vertical_position(bounds)
|
||||
if position == "private":
|
||||
# 私有带内的父节点不可以悄然包含下方子节点,否则会把仍需审计的下方内容一起丢失。
|
||||
for descendant in node.iter("node"):
|
||||
descendant_bounds = _parse_bounds(descendant.get("bounds"))
|
||||
_observe_bounds(stats, descendant_bounds)
|
||||
if _vertical_position(descendant_bounds) != "private":
|
||||
raise SkuEvidenceSanitizationError("原始节点树 bounds 结构不匹配。")
|
||||
stats.removed_nodes += sum(1 for _ in node.iter("node"))
|
||||
parent.remove(node)
|
||||
return
|
||||
if position == "crossing":
|
||||
if bounds in _CROSSING_PRICE_BOUNDS and node.get("text"):
|
||||
_project_crossing_price_node(node, bounds, stats)
|
||||
else:
|
||||
# 全屏/跨界容器可保留其下方子节点,但自身所有属性和文本都可能含地址或手机号。
|
||||
_clear_node_text(node)
|
||||
stats.cleared_crossing_nodes += 1
|
||||
else:
|
||||
stats.retained_below_nodes += 1
|
||||
for child in list(node):
|
||||
_sanitize_node(node, child, stats)
|
||||
|
||||
|
||||
def _parse_bounds(value: object) -> tuple[int, int, int, int]:
|
||||
if not isinstance(value, str):
|
||||
raise SkuEvidenceSanitizationError("原始节点树 bounds 缺失或无效。")
|
||||
match = _BOUNDS_RE.fullmatch(value)
|
||||
if match is None:
|
||||
raise SkuEvidenceSanitizationError("原始节点树 bounds 缺失或无效。")
|
||||
left, top, right, bottom = (int(group) for group in match.groups())
|
||||
if not (0 <= left < right and 0 <= top < bottom):
|
||||
raise SkuEvidenceSanitizationError("原始节点树 bounds 缺失或无效。")
|
||||
return left, top, right, bottom
|
||||
|
||||
|
||||
def _observe_bounds(stats: _CleanupStats, bounds: tuple[int, int, int, int]) -> None:
|
||||
_, _, right, bottom = bounds
|
||||
stats.max_right = max(stats.max_right, right)
|
||||
stats.max_bottom = max(stats.max_bottom, bottom)
|
||||
|
||||
|
||||
def _require_expected_xml_coordinate_space(stats: _CleanupStats) -> None:
|
||||
if (
|
||||
stats.max_right != PRIVACY_MASK_CONFIG.xml_width
|
||||
or stats.max_bottom != PRIVACY_MASK_CONFIG.xml_height
|
||||
):
|
||||
raise SkuEvidenceSanitizationError(
|
||||
f"原始节点树坐标范围不匹配(observed {stats.max_right}x{stats.max_bottom})。"
|
||||
)
|
||||
|
||||
|
||||
def _vertical_position(bounds: tuple[int, int, int, int]) -> str:
|
||||
_, top, _, bottom = bounds
|
||||
if bottom <= PRIVACY_MASK_CONFIG.privacy_top:
|
||||
return "private"
|
||||
if top >= PRIVACY_MASK_CONFIG.privacy_top:
|
||||
return "below"
|
||||
return "crossing"
|
||||
|
||||
|
||||
def _project_crossing_price_node(
|
||||
node: ElementTree.Element,
|
||||
bounds: tuple[int, int, int, int],
|
||||
stats: _CleanupStats,
|
||||
) -> None:
|
||||
"""投影唯一允许的跨界价格叶节点;任何结构漂移一律拒绝发布。"""
|
||||
|
||||
if (
|
||||
len(node) != 0
|
||||
or node.get("package") != "com.xunmeng.pinduoduo"
|
||||
or node.get("class") != "android.widget.TextView"
|
||||
or node.get("clickable") != "false"
|
||||
or node.get("enabled") != "true"
|
||||
or node.get("visible-to-user") != "true"
|
||||
):
|
||||
raise SkuEvidenceSanitizationError("跨界价格节点结构不匹配,拒绝发布。")
|
||||
text = node.get("text")
|
||||
if text is None:
|
||||
raise SkuEvidenceSanitizationError("跨界价格节点文本不匹配,拒绝发布。")
|
||||
match = _CROSSING_PRICE_TEXT_RE.fullmatch(text)
|
||||
if match is None:
|
||||
raise _crossing_price_text_mismatch_error(bounds, text)
|
||||
|
||||
# 只有这七项经上述检查后可进入派生 XML;尤其不复制 content-desc、resource-id 等原始属性。
|
||||
node.attrib = {attribute: node.attrib[attribute] for attribute in _PRICE_PROJECTION_ATTRIBUTES}
|
||||
node.text = None
|
||||
node.tail = None
|
||||
stats.preserved_crossing_price_nodes += 1
|
||||
if match.group(1) is not None:
|
||||
stats.current_price_candidates += 1
|
||||
else:
|
||||
stats.original_price_candidates += 1
|
||||
|
||||
|
||||
def _crossing_price_text_mismatch_error(
|
||||
bounds: tuple[int, int, int, int],
|
||||
text: str,
|
||||
) -> SkuEvidenceSanitizationError:
|
||||
"""仅输出固定槽位与 reason,避免将任意 raw 正文带入 CLI 或日志。"""
|
||||
|
||||
reason = _crossing_price_text_mismatch_reason(text)
|
||||
slot = _CROSSING_PRICE_SLOTS[bounds]
|
||||
return SkuEvidenceSanitizationError(f"跨界价格节点文本不匹配:slot={slot};reason={reason}。")
|
||||
|
||||
|
||||
def _crossing_price_text_mismatch_reason(text: str) -> str:
|
||||
"""将未匹配文本归类为受控枚举;返回值绝不包含原始片段。"""
|
||||
|
||||
if "\r" in text or "\n" in text:
|
||||
return "newline"
|
||||
if any(character.isspace() and character != " " for character in text):
|
||||
return "non_ascii_whitespace"
|
||||
if any(marker in text for marker in ("提交订单", "支付", "下单", "优惠")):
|
||||
return "extra_or_order"
|
||||
without_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:
|
||||
node.attrib = {"bounds": node.attrib["bounds"]} if "bounds" in node.attrib else {}
|
||||
node.text = None
|
||||
node.tail = None
|
||||
|
||||
|
||||
def _contains_phone(root: ElementTree.Element) -> bool:
|
||||
"""逐项与跨节点复检电话,避免分隔符、遮罩字符或节点切分绕过。"""
|
||||
|
||||
all_values: list[str] = []
|
||||
content_values: list[str] = []
|
||||
for element in root.iter():
|
||||
if element.text:
|
||||
all_values.append(element.text)
|
||||
content_values.append(element.text)
|
||||
for attribute, value in element.attrib.items():
|
||||
all_values.append(value)
|
||||
if attribute != "bounds":
|
||||
content_values.append(value)
|
||||
if element.tail:
|
||||
all_values.append(element.tail)
|
||||
content_values.append(element.tail)
|
||||
normalized_values = [_normalize_phone_value(value) for value in all_values]
|
||||
normalized_all_document = "".join(normalized_values)
|
||||
normalized_document = "".join(_normalize_phone_value(value) for value in content_values)
|
||||
return (
|
||||
any(_matches_phone(value) for value in normalized_values)
|
||||
or _matches_phone(normalized_all_document)
|
||||
or _matches_phone(normalized_document)
|
||||
)
|
||||
|
||||
|
||||
def _normalize_phone_value(value: str) -> str:
|
||||
return _SEPARATOR_RE.sub("", value.translate(_MASK_TRANSLATION))
|
||||
|
||||
|
||||
def _matches_phone(value: str) -> bool:
|
||||
return _FULL_PHONE_RE.search(value) is not None or _MASKED_PHONE_RE.search(value) is not None
|
||||
|
||||
|
||||
def _derived_manifest(
|
||||
source_manifest: dict[str, Any],
|
||||
link: ProductUrl,
|
||||
state: str,
|
||||
source_manifest_path: Path,
|
||||
source_screenshot_path: Path,
|
||||
source_hierarchy_path: Path,
|
||||
derived_screenshot_path: Path,
|
||||
derived_hierarchy_path: Path,
|
||||
cleanup_stats: _CleanupStats,
|
||||
) -> dict[str, Any]:
|
||||
device = source_manifest["device"]
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"privacy_tier": "SANITIZED",
|
||||
"sanitizer_version": PRIVACY_MASK_CONFIG.version,
|
||||
"screenshot_space": {
|
||||
"width": PRIVACY_MASK_CONFIG.screenshot_width,
|
||||
"height": PRIVACY_MASK_CONFIG.screenshot_height,
|
||||
"privacy_mask_rectangle": [
|
||||
0,
|
||||
0,
|
||||
PRIVACY_MASK_CONFIG.screenshot_width,
|
||||
PRIVACY_MASK_CONFIG.privacy_top,
|
||||
],
|
||||
},
|
||||
"xml_coordinate_space": {
|
||||
"width": PRIVACY_MASK_CONFIG.xml_width,
|
||||
"height": PRIVACY_MASK_CONFIG.xml_height,
|
||||
"privacy_mask_rectangle": [0, 0, PRIVACY_MASK_CONFIG.xml_width, PRIVACY_MASK_CONFIG.privacy_top],
|
||||
"observed_max": {"right": cleanup_stats.max_right, "bottom": cleanup_stats.max_bottom},
|
||||
},
|
||||
"privacy_cleanup": {
|
||||
"removed_nodes": cleanup_stats.removed_nodes,
|
||||
"cleared_crossing_nodes": cleanup_stats.cleared_crossing_nodes,
|
||||
"preserved_crossing_price_nodes": cleanup_stats.preserved_crossing_price_nodes,
|
||||
"retained_below_nodes": cleanup_stats.retained_below_nodes,
|
||||
"max_right": cleanup_stats.max_right,
|
||||
"max_bottom": cleanup_stats.max_bottom,
|
||||
},
|
||||
"product": {"goods_id": link.goods_id},
|
||||
"human_declared_state": state,
|
||||
"device": {
|
||||
"model": device["model"],
|
||||
"android_version": device.get("android_version"),
|
||||
"pdd_package": device["pdd_package"],
|
||||
"pdd_version": device["pdd_version"],
|
||||
},
|
||||
# source hashes stay only in the local derived manifest; no raw path, serial or body is retained.
|
||||
"source": {
|
||||
"manifest_sha256": _sha256_file(source_manifest_path),
|
||||
"artifacts": [
|
||||
{"path": "screenshot.png", "sha256": _sha256_file(source_screenshot_path)},
|
||||
{"path": "hierarchy.xml", "sha256": _sha256_file(source_hierarchy_path)},
|
||||
],
|
||||
},
|
||||
"derived": {
|
||||
"artifacts": [
|
||||
{"path": "screenshot.png", "sha256": _sha256_file(derived_screenshot_path)},
|
||||
{"path": "hierarchy.xml", "sha256": _sha256_file(derived_hierarchy_path)},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _clean_staging(staging: Path | None) -> None:
|
||||
if staging is not None and staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
|
||||
|
||||
def _publish_staging(staging: Path, target: Path) -> None:
|
||||
"""发布前二次检查,并使用目录 rename 而不是会覆盖目标的 replace。"""
|
||||
|
||||
if target.exists():
|
||||
raise SkuEvidenceSanitizationError("派生证据目录已存在,拒绝覆盖。")
|
||||
try:
|
||||
staging.rename(target)
|
||||
except OSError as error:
|
||||
# 竞态中新目标出现或文件系统拒绝 rename 时一律不尝试覆盖或重试。
|
||||
raise SkuEvidenceSanitizationError("派生证据目录发布失败,未覆盖已有目录。") from error
|
||||
@@ -0,0 +1,15 @@
|
||||
"""拼多多链接的受限打开与只读取证。
|
||||
|
||||
此包不提供页面选择器、输入、滑动、下单或支付能力。
|
||||
"""
|
||||
|
||||
from .product_open import ProductOpenCapturer, ProductOpenResult
|
||||
from .product_url import ProductUrl, ProductUrlError, parse_product_url
|
||||
|
||||
__all__ = [
|
||||
"ProductOpenCapturer",
|
||||
"ProductOpenResult",
|
||||
"ProductUrl",
|
||||
"ProductUrlError",
|
||||
"parse_product_url",
|
||||
]
|
||||
@@ -0,0 +1,262 @@
|
||||
"""安全打开 canonical 商品链接后的只读取证。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from math import isfinite
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from time import monotonic, sleep
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from adbutils.errors import AdbTimeout
|
||||
from uiautomator2.exceptions import HTTPTimeoutError
|
||||
|
||||
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection, IntentLaunchSummary
|
||||
from ..device.baseline import (
|
||||
HIERARCHY_PARAMS,
|
||||
PDD_PACKAGE,
|
||||
SCREENSHOT_PARAMS,
|
||||
_save_base64_screenshot,
|
||||
_sha256_file,
|
||||
_validate_hierarchy,
|
||||
)
|
||||
from .product_url import ProductUrl, parse_product_url
|
||||
|
||||
|
||||
EXPECTED_PDD_VERSION = "8.17.0"
|
||||
|
||||
|
||||
class ProductOpenError(RuntimeError):
|
||||
"""商品打开或证据发布未完整完成。"""
|
||||
|
||||
|
||||
class ProductVersionMismatchError(ProductOpenError):
|
||||
"""运行时拼多多版本不是经取证允许的版本。"""
|
||||
|
||||
|
||||
class ProductPackageMismatchError(ProductOpenError):
|
||||
"""Intent 后在有限时间内未观察到拼多多前台包。"""
|
||||
|
||||
|
||||
class ProductOpenTimeoutError(ProductOpenError):
|
||||
"""商品打开后的只读取证超时。"""
|
||||
|
||||
|
||||
class ProductScreenshotCaptureError(ProductOpenError):
|
||||
"""Intent 后截图不能作为完整 PNG 证据保存。"""
|
||||
|
||||
|
||||
class ProductHierarchyCaptureError(ProductOpenError):
|
||||
"""Intent 后完整节点树不能作为有效 XML 证据保存。"""
|
||||
|
||||
|
||||
class ProductOpenUiDevice(Protocol):
|
||||
"""本任务所需的只读 uiautomator2 接口;故意没有任何 UI 操作方法。"""
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]:
|
||||
"""读取应用元数据。"""
|
||||
|
||||
def app_current(self) -> dict[str, Any]:
|
||||
"""读取当前前台应用元数据。"""
|
||||
|
||||
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any:
|
||||
"""调用只读取证所需的公开 JSON-RPC 方法。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProductOpenResult:
|
||||
"""已原子发布的商品打开证据位置。"""
|
||||
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
screenshot_path: Path
|
||||
hierarchy_path: Path
|
||||
|
||||
|
||||
class ProductOpenCapturer:
|
||||
"""以 fail-closed 顺序打开已重建链接,并在打开后只读留证。
|
||||
|
||||
本类不判断商品页、Activity、文案或控件;打开后只确认当前 package,随后采集截图与
|
||||
完整节点树。任何失败都不会发布半成品证据目录。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adb_client: AdbClient,
|
||||
connector: Callable[[str], ProductOpenUiDevice],
|
||||
timeout_seconds: float,
|
||||
foreground_poll_interval_seconds: float = 0.2,
|
||||
monotonic_clock: Callable[[], float] = monotonic,
|
||||
sleep_function: Callable[[float], None] = sleep,
|
||||
) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
if not _is_positive_finite(foreground_poll_interval_seconds):
|
||||
raise ValueError("foreground_poll_interval_seconds 必须是大于 0 的有限数值")
|
||||
self._adb_client = adb_client
|
||||
self._connector = connector
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._foreground_poll_interval_seconds = foreground_poll_interval_seconds
|
||||
self._monotonic_clock = monotonic_clock
|
||||
self._sleep_function = sleep_function
|
||||
|
||||
def open_and_capture(self, serial: str, product_url: str, output_directory: Path) -> ProductOpenResult:
|
||||
"""完成唯一允许的 Intent 打开及其后的只读取证。"""
|
||||
|
||||
# 公共入口只接收原始字符串并每次重新解析,不能由调用方构造不一致的值对象伪造 manifest。
|
||||
link = parse_product_url(product_url)
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
|
||||
staging: Path | None = None
|
||||
try:
|
||||
# inspect 必须先于连接和 Intent,复用 T-101 的显式 serial、重复物理设备拒绝逻辑。
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
device = self._connector(serial)
|
||||
pdd_version = _require_expected_version(device.app_info(PDD_PACKAGE))
|
||||
|
||||
# 版本精确匹配是 Intent 的前置条件,失败时绝不调用 start_pdd_view_intent。
|
||||
intent = self._adb_client.start_pdd_view_intent(serial, link.goods_id)
|
||||
self._wait_for_pdd_foreground(device)
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
||||
staging.mkdir()
|
||||
screenshot_path = staging / "screenshot.png"
|
||||
try:
|
||||
_save_base64_screenshot(
|
||||
device.jsonrpc_call("takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds),
|
||||
screenshot_path,
|
||||
)
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError):
|
||||
raise
|
||||
except Exception as error:
|
||||
raise ProductScreenshotCaptureError("商品打开后截图取证失败,未发布任何证据产物。") from error
|
||||
|
||||
try:
|
||||
hierarchy = device.jsonrpc_call(
|
||||
"dumpWindowHierarchy",
|
||||
HIERARCHY_PARAMS,
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
_validate_hierarchy(hierarchy)
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError):
|
||||
raise
|
||||
except Exception as error:
|
||||
raise ProductHierarchyCaptureError("商品打开后节点树取证失败,未发布任何证据产物。") from error
|
||||
hierarchy_path = staging / "hierarchy.xml"
|
||||
hierarchy_path.write_text(hierarchy, encoding="utf-8")
|
||||
|
||||
manifest_path = staging / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
_manifest(inspection, serial, link, pdd_version, intent, screenshot_path, hierarchy_path),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(staging, target)
|
||||
except (ProductOpenError, DeviceConnectionError):
|
||||
_clean_staging(staging)
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
_clean_staging(staging)
|
||||
raise ProductOpenTimeoutError("商品打开后的只读取证超时,未发布任何证据产物。") from error
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
# 底层异常可能含 serial、路径或远端页面内容,不能直接向 CLI 或日志传播。
|
||||
raise ProductOpenError("商品打开或只读取证未完成,未发布任何证据产物。") from error
|
||||
|
||||
return ProductOpenResult(
|
||||
output_directory=target,
|
||||
manifest_path=target / "manifest.json",
|
||||
screenshot_path=target / "screenshot.png",
|
||||
hierarchy_path=target / "hierarchy.xml",
|
||||
)
|
||||
|
||||
def _wait_for_pdd_foreground(self, device: ProductOpenUiDevice) -> None:
|
||||
"""只轮询当前 package,直到 deadline;Activity 和节点树均不参与本判据。"""
|
||||
|
||||
deadline = self._monotonic_clock() + self._timeout_seconds
|
||||
while True:
|
||||
if _is_pdd_foreground(device.app_current()):
|
||||
return
|
||||
remaining = deadline - self._monotonic_clock()
|
||||
if remaining <= 0:
|
||||
raise ProductPackageMismatchError(
|
||||
"商品链接打开后未在限定时间内进入拼多多,已停止后续取证。"
|
||||
)
|
||||
# 每个失败观察后都等待正的、受 deadline 约束的时长,避免 busy-loop。
|
||||
self._sleep_function(min(self._foreground_poll_interval_seconds, remaining))
|
||||
|
||||
|
||||
def _validate_new_target(target: Path) -> None:
|
||||
if target.exists():
|
||||
raise ProductOpenError("输出目录已存在;为防止混入旧证据,拒绝覆盖。")
|
||||
if not target.name:
|
||||
raise ProductOpenError("输出目录必须是明确的新目录。")
|
||||
|
||||
|
||||
def _clean_staging(staging: Path | None) -> None:
|
||||
if staging is not None and staging.exists():
|
||||
# staging 仅在本次调用中创建,删除前不解析或扩展任何调用方提供的路径。
|
||||
shutil.rmtree(staging)
|
||||
|
||||
|
||||
def _require_expected_version(app_info: dict[str, Any]) -> str:
|
||||
if not isinstance(app_info, dict):
|
||||
raise ProductVersionMismatchError("拼多多版本与已取证版本不一致,已停止打开商品链接。")
|
||||
version = app_info.get("versionName") or app_info.get("version_name")
|
||||
if not isinstance(version, str) or version != EXPECTED_PDD_VERSION:
|
||||
raise ProductVersionMismatchError("拼多多版本与已取证版本不一致,已停止打开商品链接。")
|
||||
return version
|
||||
|
||||
|
||||
def _is_positive_finite(value: object) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
|
||||
|
||||
|
||||
def _is_pdd_foreground(current: object) -> bool:
|
||||
return isinstance(current, dict) and current.get("package") == PDD_PACKAGE
|
||||
|
||||
|
||||
def _manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
link: ProductUrl,
|
||||
pdd_version: str,
|
||||
intent: IntentLaunchSummary,
|
||||
screenshot_path: Path,
|
||||
hierarchy_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
"""只写审计摘要;原始 serial、Activity、ADB 输出和页面正文均不进入 manifest。"""
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
|
||||
"channel": "wifi" if ":" in serial else "usb",
|
||||
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||
"device": {
|
||||
"model": inspection.model,
|
||||
"android_version": inspection.android_version,
|
||||
"pdd_package": PDD_PACKAGE,
|
||||
"pdd_version": pdd_version,
|
||||
},
|
||||
"intent": {"status": intent.status, "returncode": intent.returncode},
|
||||
"current_package": PDD_PACKAGE,
|
||||
"artifacts": [
|
||||
{"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)},
|
||||
{"path": hierarchy_path.name, "sha256": _sha256_file(hierarchy_path)},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"""唯一允许交给 Android Intent 的商品链接。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import parse_qsl, urlsplit
|
||||
|
||||
|
||||
_SCHEME = "https"
|
||||
_HOST = "mobile.yangkeduo.com"
|
||||
_PATH = "/goods.html"
|
||||
|
||||
|
||||
class ProductUrlError(ValueError):
|
||||
"""输入不是可安全重建的 canonical 商品链接。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProductUrl:
|
||||
"""经验证的商品标识及由它重建的 canonical URL。"""
|
||||
|
||||
goods_id: str
|
||||
canonical_url: str
|
||||
|
||||
|
||||
def parse_product_url(value: str) -> ProductUrl:
|
||||
"""只接受一个 ASCII 数字 ``goods_id`` 的拼多多商品直链。
|
||||
|
||||
解析结果绝不原样透传:Intent 使用的 URL 必须从 ``goods_id`` 重新构建,以排除
|
||||
短链、额外参数、userinfo、fragment 和 URL 解析器的边缘表示。
|
||||
"""
|
||||
|
||||
if not isinstance(value, str):
|
||||
raise ProductUrlError("商品链接必须是字符串。")
|
||||
try:
|
||||
parsed = urlsplit(value)
|
||||
port = parsed.port
|
||||
query_pairs = parse_qsl(parsed.query, keep_blank_values=True, strict_parsing=True)
|
||||
except ValueError as error:
|
||||
raise ProductUrlError("商品链接格式无效。") from error
|
||||
|
||||
if (
|
||||
parsed.scheme != _SCHEME
|
||||
or parsed.hostname != _HOST
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or port is not None
|
||||
or parsed.path != _PATH
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ProductUrlError("商品链接不是允许的拼多多商品直链。")
|
||||
if len(query_pairs) != 1 or query_pairs[0][0] != "goods_id":
|
||||
raise ProductUrlError("商品链接必须且只能包含一个 goods_id 参数。")
|
||||
|
||||
goods_id = query_pairs[0][1]
|
||||
if not goods_id or any(character < "0" or character > "9" for character in goods_id):
|
||||
raise ProductUrlError("goods_id 必须是纯数字。")
|
||||
canonical_url = f"{_SCHEME}://{_HOST}{_PATH}?goods_id={goods_id}"
|
||||
if value != canonical_url:
|
||||
raise ProductUrlError("商品链接必须使用唯一 canonical 表示。")
|
||||
return ProductUrl(goods_id=goods_id, canonical_url=canonical_url)
|
||||
@@ -0,0 +1,252 @@
|
||||
"""人工停留在规格面板后的只读取证。
|
||||
|
||||
本模块不识别规格面板,不打开商品链接,也不读取价格;三种面板状态完全由现场人员声明。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from math import isfinite
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from adbutils.errors import AdbTimeout
|
||||
from uiautomator2.exceptions import HTTPTimeoutError
|
||||
|
||||
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
|
||||
from ..device.baseline import (
|
||||
HIERARCHY_PARAMS,
|
||||
PDD_PACKAGE,
|
||||
SCREENSHOT_PARAMS,
|
||||
_save_base64_screenshot,
|
||||
_sha256_file,
|
||||
_validate_hierarchy,
|
||||
)
|
||||
from .product_open import EXPECTED_PDD_VERSION
|
||||
from .product_url import ProductUrl, parse_product_url
|
||||
from .sku_panel_state import HUMAN_DECLARED_STATES
|
||||
|
||||
|
||||
class SkuPanelEvidenceError(RuntimeError):
|
||||
"""人工规格面板证据无法完整发布。"""
|
||||
|
||||
|
||||
class SkuPanelDeclaredStateError(SkuPanelEvidenceError):
|
||||
"""调用方没有提供允许的人工声明状态。"""
|
||||
|
||||
|
||||
class SkuPanelVersionMismatchError(SkuPanelEvidenceError):
|
||||
"""运行时拼多多版本不是已取证版本。"""
|
||||
|
||||
|
||||
class SkuPanelPackageMismatchError(SkuPanelEvidenceError):
|
||||
"""人工声明前台不是拼多多时仍试图留证。"""
|
||||
|
||||
|
||||
class SkuPanelEvidenceTimeoutError(SkuPanelEvidenceError):
|
||||
"""只读截图或节点树取证超时。"""
|
||||
|
||||
|
||||
class SkuPanelScreenshotError(SkuPanelEvidenceError):
|
||||
"""截图不能保存为严格有效的 PNG。"""
|
||||
|
||||
|
||||
class SkuPanelHierarchyError(SkuPanelEvidenceError):
|
||||
"""节点树不能保存为严格有效的 XML。"""
|
||||
|
||||
|
||||
class SkuPanelUiDevice(Protocol):
|
||||
"""人工面板证据所需的只读接口,故意没有任何页面操作方法。"""
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]:
|
||||
"""读取应用元数据。"""
|
||||
|
||||
def app_current(self) -> dict[str, Any]:
|
||||
"""读取当前前台应用元数据。"""
|
||||
|
||||
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any:
|
||||
"""调用公开 JSON-RPC 的只读取证方法。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkuPanelEvidenceResult:
|
||||
"""已原子发布的本地证据目录。"""
|
||||
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
screenshot_path: Path
|
||||
hierarchy_path: Path
|
||||
|
||||
|
||||
class SkuPanelEvidenceCapturer:
|
||||
"""把人工已停留的面板状态留证,不对页面作任何自动结论。
|
||||
|
||||
状态字段命名为 ``human_declared_state``,防止消费者把本模块误解为自动面板/规格/价格识别。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adb_client: AdbClient,
|
||||
connector: Callable[[str], SkuPanelUiDevice],
|
||||
timeout_seconds: float,
|
||||
) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._adb_client = adb_client
|
||||
self._connector = connector
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
def capture(
|
||||
self,
|
||||
serial: str,
|
||||
product_url: str,
|
||||
human_declared_state: str,
|
||||
output_directory: Path,
|
||||
) -> SkuPanelEvidenceResult:
|
||||
"""采集人工已准备的状态;不会打开链接、面板或执行任何 UI 操作。"""
|
||||
|
||||
link = parse_product_url(product_url)
|
||||
state = _validate_human_declared_state(human_declared_state)
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
|
||||
staging: Path | None = None
|
||||
try:
|
||||
# 沿用 T-101 的显式 serial、在线状态与重复物理设备 fail-closed 核验。
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
device = self._connector(serial)
|
||||
pdd_version = _require_expected_version(device.app_info(PDD_PACKAGE))
|
||||
_require_pdd_foreground(device.app_current())
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
||||
staging.mkdir()
|
||||
screenshot_path = staging / "screenshot.png"
|
||||
try:
|
||||
_save_base64_screenshot(
|
||||
device.jsonrpc_call("takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds),
|
||||
screenshot_path,
|
||||
)
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError):
|
||||
raise
|
||||
except Exception as error:
|
||||
raise SkuPanelScreenshotError("规格面板截图取证失败,未发布任何证据产物。") from error
|
||||
|
||||
try:
|
||||
hierarchy = device.jsonrpc_call(
|
||||
"dumpWindowHierarchy",
|
||||
HIERARCHY_PARAMS,
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
_validate_hierarchy(hierarchy)
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError):
|
||||
raise
|
||||
except Exception as error:
|
||||
raise SkuPanelHierarchyError("规格面板节点树取证失败,未发布任何证据产物。") from error
|
||||
hierarchy_path = staging / "hierarchy.xml"
|
||||
hierarchy_path.write_text(hierarchy, encoding="utf-8")
|
||||
|
||||
manifest_path = staging / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
_manifest(inspection, serial, link, state, pdd_version, screenshot_path, hierarchy_path),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(staging, target)
|
||||
except (SkuPanelEvidenceError, DeviceConnectionError):
|
||||
_clean_staging(staging)
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuPanelEvidenceTimeoutError("规格面板只读取证超时,未发布任何证据产物。") from error
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
# 第三方异常可能含 serial、Activity 或页面正文,不能直接向 CLI/日志传播。
|
||||
raise SkuPanelEvidenceError("规格面板只读取证未完成,未发布任何证据产物。") from error
|
||||
|
||||
return SkuPanelEvidenceResult(
|
||||
output_directory=target,
|
||||
manifest_path=target / "manifest.json",
|
||||
screenshot_path=target / "screenshot.png",
|
||||
hierarchy_path=target / "hierarchy.xml",
|
||||
)
|
||||
|
||||
|
||||
def _is_positive_finite(value: object) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
|
||||
|
||||
|
||||
def _validate_human_declared_state(value: object) -> str:
|
||||
if not isinstance(value, str) or value not in HUMAN_DECLARED_STATES:
|
||||
raise SkuPanelDeclaredStateError("必须提供允许的人工声明规格面板状态。")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_new_target(target: Path) -> None:
|
||||
if target.exists():
|
||||
raise SkuPanelEvidenceError("输出目录已存在;为防止混入旧证据,拒绝覆盖。")
|
||||
if not target.name:
|
||||
raise SkuPanelEvidenceError("输出目录必须是明确的新目录。")
|
||||
|
||||
|
||||
def _clean_staging(staging: Path | None) -> None:
|
||||
if staging is not None and staging.exists():
|
||||
# staging 仅在本次调用中创建,绝不删除调用方已存在的目录。
|
||||
shutil.rmtree(staging)
|
||||
|
||||
|
||||
def _require_expected_version(app_info: object) -> str:
|
||||
if not isinstance(app_info, dict):
|
||||
raise SkuPanelVersionMismatchError("拼多多版本与已取证版本不一致,已停止取证。")
|
||||
version = app_info.get("versionName") or app_info.get("version_name")
|
||||
if not isinstance(version, str) or version != EXPECTED_PDD_VERSION:
|
||||
raise SkuPanelVersionMismatchError("拼多多版本与已取证版本不一致,已停止取证。")
|
||||
return version
|
||||
|
||||
|
||||
def _require_pdd_foreground(current: object) -> None:
|
||||
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
|
||||
raise SkuPanelPackageMismatchError("当前前台应用不是拼多多,已停止取证。")
|
||||
|
||||
|
||||
def _manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
link: ProductUrl,
|
||||
human_declared_state: str,
|
||||
pdd_version: str,
|
||||
screenshot_path: Path,
|
||||
hierarchy_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
"""仅记录人工声明与非敏感审计摘要,不写入 Activity 或页面内容。"""
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
|
||||
"human_declared_state": human_declared_state,
|
||||
"channel": "wifi" if ":" in serial else "usb",
|
||||
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||
"device": {
|
||||
"model": inspection.model,
|
||||
"android_version": inspection.android_version,
|
||||
"pdd_package": PDD_PACKAGE,
|
||||
"pdd_version": pdd_version,
|
||||
},
|
||||
"artifacts": [
|
||||
{"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)},
|
||||
{"path": hierarchy_path.name, "sha256": _sha256_file(hierarchy_path)},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"""T-103 的人工声明证据状态;不含任何页面识别或规格语义。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
# 这些值由现场人员填写到 manifest,不能被解释为自动检测出的页面或选择状态。
|
||||
HUMAN_DECLARED_STATES = frozenset(
|
||||
{
|
||||
"panel-opened-target-preselected",
|
||||
"alternate-all-dimensions-selected",
|
||||
"target-selection-restored",
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""设备连接与基线取证的离线测试。"""
|
||||
@@ -0,0 +1,273 @@
|
||||
"""ADB 设备边界测试:所有命令执行器均为 mock,不连接真机。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import (
|
||||
AdbClient,
|
||||
CommandResult,
|
||||
DeviceIdentityUnconfirmedError,
|
||||
DeviceCommandError,
|
||||
DeviceCommandTimeoutError,
|
||||
DeviceNotFoundError,
|
||||
DeviceOfflineError,
|
||||
DeviceStateError,
|
||||
DeviceUnauthorizedError,
|
||||
DuplicatePhysicalDeviceError,
|
||||
IntentLaunchUnconfirmedError,
|
||||
SerialRequiredError,
|
||||
)
|
||||
|
||||
|
||||
USB_SERIAL = "3B65BD02H7F00000"
|
||||
WIFI_SERIAL = "192.168.0.173:5555"
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
def __init__(self, devices_output: str, properties: dict[tuple[str, str], CommandResult | str]) -> None:
|
||||
self.devices_output = devices_output
|
||||
self.properties = properties
|
||||
self.calls: list[tuple[str, ...]] = []
|
||||
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
self.calls.append(tuple(arguments))
|
||||
if tuple(arguments) == ("devices", "-l"):
|
||||
return CommandResult(stdout=self.devices_output)
|
||||
key = (arguments[1], arguments[-1])
|
||||
value = self.properties.get(key, "")
|
||||
return value if isinstance(value, CommandResult) else CommandResult(stdout=value)
|
||||
|
||||
|
||||
def _properties(serials: tuple[str, ...]) -> dict[tuple[str, str], str]:
|
||||
values: dict[tuple[str, str], str] = {}
|
||||
for serial in serials:
|
||||
values[(serial, "ro.serialno")] = "physical-phone-1"
|
||||
values[(serial, "ro.boot.serialno")] = "physical-phone-1"
|
||||
values[(serial, "ro.product.model")] = "PKG110"
|
||||
values[(serial, "ro.product.name")] = "PKG110"
|
||||
values[(serial, "ro.product.device")] = "OP5D2BL1"
|
||||
values[(serial, "ro.build.version.release")] = "16"
|
||||
return values
|
||||
|
||||
|
||||
class AdbClientTests(unittest.TestCase):
|
||||
def test_requires_explicit_serial(self) -> None:
|
||||
runner = FakeRunner("List of devices attached\n", {})
|
||||
|
||||
with self.assertRaises(SerialRequiredError):
|
||||
AdbClient(runner).inspect(" ")
|
||||
|
||||
self.assertEqual(runner.calls, [])
|
||||
|
||||
def test_missing_offline_and_unauthorized_are_distinct(self) -> None:
|
||||
missing = AdbClient(FakeRunner("List of devices attached\n", {}))
|
||||
with self.assertRaises(DeviceNotFoundError):
|
||||
missing.inspect(USB_SERIAL)
|
||||
|
||||
offline = AdbClient(FakeRunner(f"List of devices attached\n{USB_SERIAL}\toffline\n", {}))
|
||||
with self.assertRaises(DeviceOfflineError):
|
||||
offline.inspect(USB_SERIAL)
|
||||
|
||||
unauthorized = AdbClient(FakeRunner(f"List of devices attached\n{USB_SERIAL}\tunauthorized\n", {}))
|
||||
with self.assertRaises(DeviceUnauthorizedError):
|
||||
unauthorized.inspect(USB_SERIAL)
|
||||
|
||||
def test_two_channels_with_same_physical_identity_fail_closed(self) -> None:
|
||||
output = (
|
||||
"List of devices attached\n"
|
||||
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
)
|
||||
runner = FakeRunner(output, _properties((WIFI_SERIAL, USB_SERIAL)))
|
||||
|
||||
with self.assertRaises(DuplicatePhysicalDeviceError):
|
||||
AdbClient(runner).inspect(USB_SERIAL)
|
||||
|
||||
self.assertIn(("-s", WIFI_SERIAL, "shell", "getprop", "ro.serialno"), runner.calls)
|
||||
self.assertIn(("-s", USB_SERIAL, "shell", "getprop", "ro.serialno"), runner.calls)
|
||||
|
||||
def test_multiple_online_devices_with_failed_identity_fail_closed(self) -> None:
|
||||
output = (
|
||||
"List of devices attached\n"
|
||||
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
)
|
||||
properties = _properties((WIFI_SERIAL, USB_SERIAL))
|
||||
properties[(WIFI_SERIAL, "ro.serialno")] = CommandResult(stdout="", returncode=1)
|
||||
runner = FakeRunner(output, properties)
|
||||
|
||||
with self.assertRaises(DeviceIdentityUnconfirmedError):
|
||||
AdbClient(runner).inspect(USB_SERIAL)
|
||||
|
||||
def test_online_explicit_serial_reads_non_sensitive_metadata(self) -> None:
|
||||
output = f"List of devices attached\n{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
inspection = AdbClient(FakeRunner(output, _properties((USB_SERIAL,)))).inspect(USB_SERIAL)
|
||||
|
||||
self.assertEqual(inspection.device.serial, USB_SERIAL)
|
||||
self.assertEqual(inspection.model, "PKG110")
|
||||
self.assertEqual(inspection.android_version, "16")
|
||||
|
||||
def test_single_online_device_does_not_require_hardware_identity(self) -> None:
|
||||
output = f"List of devices attached\n{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
properties = _properties((USB_SERIAL,))
|
||||
properties[(USB_SERIAL, "ro.serialno")] = ""
|
||||
properties[(USB_SERIAL, "ro.boot.serialno")] = ""
|
||||
|
||||
inspection = AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
|
||||
|
||||
self.assertEqual(inspection.model, "PKG110")
|
||||
|
||||
def test_multiple_online_devices_without_hardware_identity_are_unconfirmed(self) -> None:
|
||||
output = (
|
||||
"List of devices attached\n"
|
||||
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
)
|
||||
properties = _properties((WIFI_SERIAL, USB_SERIAL))
|
||||
properties[(WIFI_SERIAL, "ro.serialno")] = ""
|
||||
properties[(WIFI_SERIAL, "ro.boot.serialno")] = ""
|
||||
|
||||
with self.assertRaises(DeviceIdentityUnconfirmedError):
|
||||
AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
|
||||
|
||||
def test_multiple_online_devices_with_different_identity_keep_explicit_selection(self) -> None:
|
||||
output = (
|
||||
"List of devices attached\n"
|
||||
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
)
|
||||
properties = _properties((WIFI_SERIAL, USB_SERIAL))
|
||||
properties[(WIFI_SERIAL, "ro.serialno")] = "physical-phone-2"
|
||||
properties[(WIFI_SERIAL, "ro.boot.serialno")] = "physical-phone-2"
|
||||
|
||||
inspection = AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
|
||||
|
||||
self.assertEqual(inspection.device.serial, USB_SERIAL)
|
||||
|
||||
def test_shared_boot_serial_is_duplicate_even_when_ro_serial_differs(self) -> None:
|
||||
output = (
|
||||
"List of devices attached\n"
|
||||
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
|
||||
)
|
||||
properties = _properties((WIFI_SERIAL, USB_SERIAL))
|
||||
properties[(WIFI_SERIAL, "ro.serialno")] = "wifi-transport-serial"
|
||||
properties[(USB_SERIAL, "ro.serialno")] = "usb-transport-serial"
|
||||
properties[(WIFI_SERIAL, "ro.boot.serialno")] = "shared-hardware-serial"
|
||||
properties[(USB_SERIAL, "ro.boot.serialno")] = "shared-hardware-serial"
|
||||
|
||||
with self.assertRaises(DuplicatePhysicalDeviceError):
|
||||
AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
|
||||
|
||||
def test_unknown_adb_state_is_rejected(self) -> None:
|
||||
client = AdbClient(FakeRunner(f"List of devices attached\n{USB_SERIAL}\trecovery\n", {}))
|
||||
|
||||
with self.assertRaises(DeviceStateError):
|
||||
client.inspect(USB_SERIAL)
|
||||
|
||||
def test_runner_timeout_is_a_distinct_connection_error(self) -> None:
|
||||
class TimeoutRunner:
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
raise subprocess.TimeoutExpired(arguments, timeout_seconds)
|
||||
|
||||
with self.assertRaises(DeviceCommandTimeoutError):
|
||||
AdbClient(TimeoutRunner()).inspect(USB_SERIAL)
|
||||
|
||||
def test_product_intent_is_fixed_to_action_view_and_pdd_package(self) -> None:
|
||||
class IntentRunner:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, ...]] = []
|
||||
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
self.calls.append(tuple(arguments))
|
||||
return CommandResult(stdout="Status: ok\n")
|
||||
|
||||
runner = IntentRunner()
|
||||
summary = AdbClient(runner).start_pdd_view_intent(
|
||||
USB_SERIAL,
|
||||
"123",
|
||||
)
|
||||
|
||||
self.assertEqual(summary.status, "ok")
|
||||
self.assertEqual(
|
||||
runner.calls,
|
||||
[
|
||||
(
|
||||
"-s",
|
||||
USB_SERIAL,
|
||||
"shell",
|
||||
"am",
|
||||
"start",
|
||||
"-W",
|
||||
"-a",
|
||||
"android.intent.action.VIEW",
|
||||
"-d",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||
"-p",
|
||||
"com.xunmeng.pinduoduo",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_product_intent_without_explicit_success_is_rejected(self) -> None:
|
||||
class UnknownIntentRunner:
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
return CommandResult(stdout="Starting: Intent { ... }\n")
|
||||
|
||||
with self.assertRaises(IntentLaunchUnconfirmedError):
|
||||
AdbClient(UnknownIntentRunner()).start_pdd_view_intent(
|
||||
USB_SERIAL,
|
||||
"123",
|
||||
)
|
||||
|
||||
def test_product_intent_rejects_invalid_goods_id_before_runner(self) -> None:
|
||||
class RecordingRunner:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, ...]] = []
|
||||
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
self.calls.append(tuple(arguments))
|
||||
return CommandResult(stdout="Status: ok\n")
|
||||
|
||||
invalid_values: tuple[object, ...] = (
|
||||
"",
|
||||
"12a",
|
||||
"123",
|
||||
" 123",
|
||||
"123 ",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||
"am start -W -d anything",
|
||||
123,
|
||||
None,
|
||||
)
|
||||
for value in invalid_values:
|
||||
with self.subTest(value=repr(value)):
|
||||
runner = RecordingRunner()
|
||||
with self.assertRaises(ValueError):
|
||||
AdbClient(runner).start_pdd_view_intent(USB_SERIAL, value) # type: ignore[arg-type]
|
||||
self.assertEqual(runner.calls, [])
|
||||
|
||||
def test_product_intent_nonzero_and_timeout_remain_distinct(self) -> None:
|
||||
class FailedIntentRunner:
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
return CommandResult(stdout="sensitive command output", returncode=1)
|
||||
|
||||
class TimeoutIntentRunner:
|
||||
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
|
||||
raise subprocess.TimeoutExpired(arguments, timeout_seconds)
|
||||
|
||||
with self.assertRaises(DeviceCommandError) as command_error:
|
||||
AdbClient(FailedIntentRunner()).start_pdd_view_intent(USB_SERIAL, "123")
|
||||
self.assertNotIn("sensitive command output", str(command_error.exception))
|
||||
|
||||
with self.assertRaises(DeviceCommandTimeoutError):
|
||||
AdbClient(TimeoutIntentRunner()).start_pdd_view_intent(USB_SERIAL, "123")
|
||||
@@ -0,0 +1,281 @@
|
||||
"""基线取证测试:mock ADB/uiautomator2,不连接手机。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from PIL import Image
|
||||
from uiautomator2.exceptions import HTTPTimeoutError
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "scripts"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
|
||||
from cmbuyer_client.device.baseline import (
|
||||
BaselineCaptureError,
|
||||
BaselineCaptureTimeoutError,
|
||||
DeviceBaselineCapturer,
|
||||
NoReconnectUiautomatorConnector,
|
||||
PDD_PACKAGE,
|
||||
)
|
||||
from capture_device_baseline import parse_arguments, validate_arguments
|
||||
|
||||
|
||||
SERIAL = "USB-serial-for-test"
|
||||
|
||||
|
||||
class StaticAdbClient:
|
||||
def __init__(self) -> None:
|
||||
self.serials: list[str] = []
|
||||
|
||||
def inspect(self, serial: str) -> DeviceInspection:
|
||||
self.serials.append(serial)
|
||||
return DeviceInspection(
|
||||
device=AdbDevice(serial=serial, state="device", model="Test Model"),
|
||||
model="Test Model",
|
||||
android_version="16",
|
||||
)
|
||||
|
||||
|
||||
class FakeUiDevice:
|
||||
def __init__(self, fail_dump: bool = False) -> None:
|
||||
self.fail_dump = fail_dump
|
||||
self.rpc_calls: list[tuple[str, object, float]] = []
|
||||
self.app_info_calls: list[str] = []
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, str]:
|
||||
self.app_info_calls.append(package_name)
|
||||
return {"versionName": "8.17.0"}
|
||||
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
self.rpc_calls.append((method, params, timeout))
|
||||
if method == "takeScreenshot":
|
||||
image_data = BytesIO()
|
||||
Image.new("RGB", (1, 1), color="white").save(image_data, format="PNG")
|
||||
return base64.b64encode(image_data.getvalue()).decode("ascii")
|
||||
if method != "dumpWindowHierarchy":
|
||||
raise AssertionError(f"unexpected method: {method}")
|
||||
if self.fail_dump:
|
||||
raise RuntimeError("mock dump failed")
|
||||
return "<hierarchy><node text='page body must stay out of manifest'/></hierarchy>"
|
||||
|
||||
|
||||
class BaselineCaptureTests(unittest.TestCase):
|
||||
def test_capture_writes_hashes_without_xml_or_raw_serial_in_manifest(self) -> None:
|
||||
adb = StaticAdbClient()
|
||||
device = FakeUiDevice()
|
||||
capturer = DeviceBaselineCapturer(adb, lambda serial: device, timeout_seconds=7.5)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
result = capturer.capture(SERIAL, output)
|
||||
manifest = result.manifest_path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertEqual(adb.serials, [SERIAL])
|
||||
self.assertEqual(device.app_info_calls, [PDD_PACKAGE])
|
||||
self.assertEqual(
|
||||
device.rpc_calls,
|
||||
[
|
||||
("takeScreenshot", [1, 80], 7.5),
|
||||
("dumpWindowHierarchy", [False, 50], 7.5),
|
||||
],
|
||||
)
|
||||
self.assertTrue(result.screenshot_path.is_file())
|
||||
self.assertTrue(result.hierarchy_path.is_file())
|
||||
self.assertIn('"sha256"', manifest)
|
||||
self.assertNotIn("page body must stay out of manifest", manifest)
|
||||
self.assertNotIn(SERIAL, manifest)
|
||||
self.assertIn('"channel": "usb"', manifest)
|
||||
|
||||
def test_capture_failure_cleans_staging_and_does_not_publish_partial_output(self) -> None:
|
||||
device = FakeUiDevice(fail_dump=True)
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: device, timeout_seconds=5)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
parent = Path(directory)
|
||||
output = parent / "baseline"
|
||||
with self.assertRaises(BaselineCaptureError) as raised:
|
||||
capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(list(parent.iterdir()), [])
|
||||
self.assertNotIn("mock dump failed", str(raised.exception))
|
||||
|
||||
def test_existing_output_is_never_overwritten(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
output.mkdir()
|
||||
sentinel = output / "keep.txt"
|
||||
sentinel.write_text("preserve", encoding="utf-8")
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: FakeUiDevice(), timeout_seconds=5)
|
||||
|
||||
with self.assertRaises(BaselineCaptureError):
|
||||
capturer.capture(SERIAL, output)
|
||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "preserve")
|
||||
|
||||
def test_no_reconnect_connector_passes_only_current_adb_device_object(self) -> None:
|
||||
class ListedDevice:
|
||||
serial = SERIAL
|
||||
|
||||
listed = ListedDevice()
|
||||
connected: list[object] = []
|
||||
|
||||
connector = NoReconnectUiautomatorConnector(lambda: [listed], lambda device: connected.append(device) or FakeUiDevice())
|
||||
connector(SERIAL)
|
||||
|
||||
self.assertEqual(connected, [listed])
|
||||
|
||||
def test_no_reconnect_connector_refuses_disappeared_serial(self) -> None:
|
||||
connector = NoReconnectUiautomatorConnector(lambda: [], lambda device: FakeUiDevice())
|
||||
|
||||
with self.assertRaises(BaselineCaptureError) as raised:
|
||||
connector(SERIAL)
|
||||
self.assertIn("拒绝自动重连", str(raised.exception))
|
||||
|
||||
def test_connector_exception_is_redacted_and_publishes_no_partial_output(self) -> None:
|
||||
def failing_connector(serial: str) -> FakeUiDevice:
|
||||
raise RuntimeError(f"third party leaked {serial}")
|
||||
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), failing_connector, timeout_seconds=5)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
with self.assertRaises(BaselineCaptureError) as raised:
|
||||
capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertNotIn(SERIAL, str(raised.exception))
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(list(Path(directory).iterdir()), [])
|
||||
|
||||
def test_invalid_screenshot_base64_syntax_fails_closed_without_partial_output(self) -> None:
|
||||
class InvalidScreenshotDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "takeScreenshot":
|
||||
valid = super().jsonrpc_call(method, params, timeout)
|
||||
return valid[:12] + "!" + valid[12:]
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: InvalidScreenshotDevice(), timeout_seconds=5)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
with self.assertRaises(BaselineCaptureError) as raised:
|
||||
capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertIn("Base64 语法无效", str(raised.exception))
|
||||
self.assertNotIn("!", str(raised.exception))
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(list(Path(directory).iterdir()), [])
|
||||
|
||||
def test_invalid_padding_and_unapproved_ascii_whitespace_fail_closed(self) -> None:
|
||||
invalid_insertions = {
|
||||
"padding": lambda value: value[:-1],
|
||||
"vertical-tab": lambda value: value[:12] + "\v" + value[12:],
|
||||
"form-feed": lambda value: value[:12] + "\f" + value[12:],
|
||||
}
|
||||
|
||||
for name, make_invalid in invalid_insertions.items():
|
||||
with self.subTest(name=name), tempfile.TemporaryDirectory() as directory:
|
||||
class InvalidScreenshotDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
value = super().jsonrpc_call(method, params, timeout)
|
||||
if method == "takeScreenshot":
|
||||
return make_invalid(value)
|
||||
return value
|
||||
|
||||
output = Path(directory) / "baseline"
|
||||
capturer = DeviceBaselineCapturer(
|
||||
StaticAdbClient(),
|
||||
lambda serial: InvalidScreenshotDevice(),
|
||||
timeout_seconds=5,
|
||||
)
|
||||
with self.assertRaises(BaselineCaptureError) as raised:
|
||||
capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertIn("Base64 语法无效", str(raised.exception))
|
||||
self.assertNotIn(SERIAL, str(raised.exception))
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(list(Path(directory).iterdir()), [])
|
||||
|
||||
def test_base64_decoded_nonimage_fails_closed_without_partial_output(self) -> None:
|
||||
class NonImageScreenshotDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "takeScreenshot":
|
||||
return base64.b64encode(b"not an image").decode("ascii")
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: NonImageScreenshotDevice(), timeout_seconds=5)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
with self.assertRaises(BaselineCaptureError) as raised:
|
||||
capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertIn("图像数据无效", str(raised.exception))
|
||||
self.assertNotIn("not an image", str(raised.exception))
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(list(Path(directory).iterdir()), [])
|
||||
|
||||
def test_ascii_base64_whitespace_is_normalized_before_strict_decode(self) -> None:
|
||||
class WhitespaceScreenshotDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
value = super().jsonrpc_call(method, params, timeout)
|
||||
if method == "takeScreenshot":
|
||||
return value[:10] + " \t\r\n" + value[10:30] + "\n" + value[30:]
|
||||
return value
|
||||
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: WhitespaceScreenshotDevice(), timeout_seconds=5)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
result = capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertTrue(result.screenshot_path.is_file())
|
||||
with Image.open(result.screenshot_path) as image:
|
||||
self.assertEqual(image.size, (1, 1))
|
||||
|
||||
def test_invalid_or_non_hierarchy_xml_fails_closed_without_partial_output(self) -> None:
|
||||
class InvalidHierarchyDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "dumpWindowHierarchy":
|
||||
return "<not-hierarchy/>"
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: InvalidHierarchyDevice(), timeout_seconds=5)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
with self.assertRaises(BaselineCaptureError) as raised:
|
||||
capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertNotIn("not-hierarchy", str(raised.exception))
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(list(Path(directory).iterdir()), [])
|
||||
|
||||
def test_rpc_timeout_is_distinct_redacted_and_does_not_publish_partial_output(self) -> None:
|
||||
class TimeoutRpcDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
raise HTTPTimeoutError(f"raw serial={SERIAL} xml=<hierarchy/>")
|
||||
|
||||
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: TimeoutRpcDevice(), timeout_seconds=5)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "baseline"
|
||||
with self.assertRaises(BaselineCaptureTimeoutError) as raised:
|
||||
capturer.capture(SERIAL, output)
|
||||
|
||||
self.assertIn("超时", str(raised.exception))
|
||||
self.assertNotIn(SERIAL, str(raised.exception))
|
||||
self.assertNotIn("hierarchy", str(raised.exception))
|
||||
self.assertFalse(output.exists())
|
||||
self.assertEqual(list(Path(directory).iterdir()), [])
|
||||
|
||||
def test_cli_validation_rejects_empty_serial_and_nonpositive_timeout(self) -> None:
|
||||
empty_serial = parse_arguments(["--serial", "", "--output-dir", "baseline"])
|
||||
with self.assertRaisesRegex(ValueError, "非空 --serial"):
|
||||
validate_arguments(empty_serial)
|
||||
|
||||
nonpositive_timeout = parse_arguments(["--serial", SERIAL, "--output-dir", "baseline", "--timeout", "0"])
|
||||
with self.assertRaisesRegex(ValueError, "必须大于 0"):
|
||||
validate_arguments(nonpositive_timeout)
|
||||
@@ -0,0 +1,735 @@
|
||||
"""T-103 脱敏器测试:全部证据为合成数据,绝不读取真实 raw 目录。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.sku_evidence_sanitizer import (
|
||||
EXPECTED_GOODS_ID,
|
||||
EXPECTED_SCREENSHOT_HEIGHT,
|
||||
EXPECTED_SCREENSHOT_WIDTH,
|
||||
EXPECTED_XML_HEIGHT,
|
||||
EXPECTED_XML_WIDTH,
|
||||
HUMAN_DECLARED_STATES,
|
||||
SkuEvidenceSanitizationError,
|
||||
sanitize_sku_panel_evidence,
|
||||
)
|
||||
|
||||
|
||||
TEST_SERIAL = "synthetic-serial-never-publish"
|
||||
TEST_ADDRESS = "SYNTHETIC_ADDRESS_NEVER_PUBLISH"
|
||||
FULL_PHONE = "13800138000"
|
||||
MASKED_PHONE = "138****0000"
|
||||
SAFE_TEXT = "synthetic-safe-lower-content"
|
||||
CURRENT_PRICE = "快卖完 ¥12.88"
|
||||
ORIGINAL_PRICE = "¥29.00"
|
||||
PRICE_CURRENT_BOUNDS = "[396,503][712,570]"
|
||||
PRICE_ORIGINAL_BOUNDS = "[730,503][895,570]"
|
||||
|
||||
|
||||
def _hash(path: Path) -> str:
|
||||
digest = sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _default_xml() -> str:
|
||||
return (
|
||||
"<hierarchy rotation='0'>"
|
||||
f"<node bounds='[0,0][1080,540]' text='{TEST_ADDRESS}' content-desc='{MASKED_PHONE} {FULL_PHONE}' />"
|
||||
f"{_price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS)}"
|
||||
f"{_price_node(ORIGINAL_PRICE, PRICE_ORIGINAL_BOUNDS)}"
|
||||
f"<node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' />"
|
||||
"</hierarchy>"
|
||||
)
|
||||
|
||||
|
||||
def _price_node(
|
||||
text: str,
|
||||
bounds: str,
|
||||
*,
|
||||
package: str = "com.xunmeng.pinduoduo",
|
||||
node_class: str = "android.widget.TextView",
|
||||
clickable: str = "false",
|
||||
enabled: str = "true",
|
||||
visible: str = "true",
|
||||
extra_attributes: str = "",
|
||||
children: str = "",
|
||||
) -> str:
|
||||
attributes = (
|
||||
f"bounds='{bounds}' text='{text}' package='{package}' class='{node_class}' "
|
||||
f"clickable='{clickable}' enabled='{enabled}' visible-to-user='{visible}'{extra_attributes}"
|
||||
)
|
||||
return f"<node {attributes}>{children}</node>"
|
||||
|
||||
|
||||
def _xml_with_prices(
|
||||
current: str = CURRENT_PRICE,
|
||||
original: str = ORIGINAL_PRICE,
|
||||
*,
|
||||
current_node: str | None = None,
|
||||
original_node: str | None = None,
|
||||
include_original: bool = True,
|
||||
extra_nodes: str = "",
|
||||
) -> str:
|
||||
current_markup = current_node if current_node is not None else _price_node(current, PRICE_CURRENT_BOUNDS)
|
||||
original_markup = (
|
||||
original_node if original_node is not None else _price_node(original, PRICE_ORIGINAL_BOUNDS)
|
||||
) if include_original else ""
|
||||
return (
|
||||
"<hierarchy>"
|
||||
f"<node bounds='[0,0][1080,540]' text='{TEST_ADDRESS}' />"
|
||||
f"{current_markup}"
|
||||
f"{original_markup}"
|
||||
f"{extra_nodes}"
|
||||
f"<node bounds='[0,570][1080,2376]' text='{SAFE_TEXT}' />"
|
||||
"</hierarchy>"
|
||||
)
|
||||
|
||||
|
||||
def _write_raw(
|
||||
root: Path,
|
||||
*,
|
||||
state: str = "panel-opened-target-preselected",
|
||||
goods_id: str = EXPECTED_GOODS_ID,
|
||||
model: str = "PKG110",
|
||||
android_version: str = "16",
|
||||
pdd_version: str = "8.17.0",
|
||||
size: tuple[int, int] = (EXPECTED_SCREENSHOT_WIDTH, EXPECTED_SCREENSHOT_HEIGHT),
|
||||
xml: str | None = None,
|
||||
) -> Path:
|
||||
raw = root / "raw"
|
||||
raw.mkdir(parents=True)
|
||||
screenshot = raw / "screenshot.png"
|
||||
image = Image.new("RGB", size, color=(0, 180, 0))
|
||||
if size == (EXPECTED_SCREENSHOT_WIDTH, EXPECTED_SCREENSHOT_HEIGHT):
|
||||
for y in range(540):
|
||||
for x in range(8):
|
||||
image.putpixel((x, y), (255, 0, 0))
|
||||
image.save(screenshot, format="PNG")
|
||||
hierarchy = raw / "hierarchy.xml"
|
||||
hierarchy.write_text(_default_xml() if xml is None else xml, encoding="utf-8")
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"product": {
|
||||
"goods_id": goods_id,
|
||||
"canonical_url": f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}",
|
||||
},
|
||||
"human_declared_state": state,
|
||||
"serial": TEST_SERIAL,
|
||||
"channel": "wifi",
|
||||
"device": {
|
||||
"model": model,
|
||||
"android_version": android_version,
|
||||
"pdd_package": "com.xunmeng.pinduoduo",
|
||||
"pdd_version": pdd_version,
|
||||
},
|
||||
"artifacts": [
|
||||
{"path": "screenshot.png", "sha256": _hash(screenshot)},
|
||||
{"path": "hierarchy.xml", "sha256": _hash(hierarchy)},
|
||||
],
|
||||
}
|
||||
(raw / "manifest.json").write_text(json.dumps(manifest, sort_keys=True), encoding="utf-8")
|
||||
return raw
|
||||
|
||||
|
||||
class SkuEvidenceSanitizerTests(unittest.TestCase):
|
||||
def test_all_declared_states_mask_screenshot_and_xml_without_raw_metadata(self) -> None:
|
||||
for state in sorted(HUMAN_DECLARED_STATES):
|
||||
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), state=state)
|
||||
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
with Image.open(result.screenshot_path) as image:
|
||||
self.assertEqual(image.getpixel((0, 0)), (0, 0, 0, 255))
|
||||
self.assertEqual(image.getpixel((100, 600)), (0, 180, 0, 255))
|
||||
derived_xml = result.hierarchy_path.read_text(encoding="utf-8")
|
||||
manifest = result.manifest_path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn(SAFE_TEXT, derived_xml)
|
||||
self.assertNotIn(TEST_ADDRESS, derived_xml)
|
||||
self.assertNotIn(FULL_PHONE, derived_xml)
|
||||
self.assertNotIn(MASKED_PHONE, derived_xml)
|
||||
self.assertIn(f'"human_declared_state": "{state}"', manifest)
|
||||
self.assertIn('"privacy_tier": "SANITIZED"', manifest)
|
||||
self.assertIn('"sanitizer_version": "t103-privacy-v5"', manifest)
|
||||
self.assertIn('"screenshot_space": {', manifest)
|
||||
self.assertIn('"xml_coordinate_space": {', manifest)
|
||||
self.assertIn('"height": 2376', manifest)
|
||||
self.assertIn('"height": 2376', manifest)
|
||||
self.assertIn('"privacy_mask_rectangle": [', manifest)
|
||||
self.assertIn('"removed_nodes": 1', manifest)
|
||||
self.assertIn('"cleared_crossing_nodes": 0', manifest)
|
||||
self.assertIn('"preserved_crossing_price_nodes": 2', manifest)
|
||||
self.assertIn('"retained_below_nodes": 1', manifest)
|
||||
self.assertIn('"max_right": 1080', manifest)
|
||||
self.assertIn('"max_bottom": 2376', manifest)
|
||||
self.assertNotIn("canonical_url", manifest)
|
||||
self.assertNotIn(TEST_SERIAL, manifest)
|
||||
self.assertNotIn("serial", manifest)
|
||||
self.assertNotIn("channel", manifest)
|
||||
self.assertNotIn(TEST_ADDRESS, manifest)
|
||||
self.assertNotIn(FULL_PHONE, manifest)
|
||||
|
||||
def test_crossing_container_keeps_lower_children_but_clears_its_text(self) -> None:
|
||||
xml = (
|
||||
"<hierarchy>"
|
||||
f"<node bounds='[0,0][1080,2376]' text='{TEST_ADDRESS}' content-desc='{MASKED_PHONE}'>"
|
||||
f"<node bounds='[0,0][1080,540]' text='{FULL_PHONE}' />"
|
||||
f"{_price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS)}"
|
||||
f"{_price_node(ORIGINAL_PRICE, PRICE_ORIGINAL_BOUNDS)}"
|
||||
f"<node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' />"
|
||||
"</node></hierarchy>"
|
||||
)
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=xml)
|
||||
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
root = ElementTree.parse(result.hierarchy_path).getroot()
|
||||
crossing = root.find("node")
|
||||
|
||||
self.assertIsNotNone(crossing)
|
||||
assert crossing is not None
|
||||
self.assertEqual(crossing.attrib, {"bounds": "[0,0][1080,2376]"})
|
||||
self.assertEqual(len(list(crossing)), 3)
|
||||
self.assertEqual(list(crossing)[-1].get("text"), SAFE_TEXT)
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(
|
||||
manifest["privacy_cleanup"],
|
||||
{
|
||||
"removed_nodes": 1,
|
||||
"cleared_crossing_nodes": 1,
|
||||
"preserved_crossing_price_nodes": 2,
|
||||
"retained_below_nodes": 1,
|
||||
"max_right": 1080,
|
||||
"max_bottom": 2376,
|
||||
},
|
||||
)
|
||||
|
||||
def test_only_strict_crossing_price_leaves_are_projected_with_whitelisted_attributes(self) -> None:
|
||||
xml = _xml_with_prices(
|
||||
current_node=_price_node(
|
||||
CURRENT_PRICE,
|
||||
PRICE_CURRENT_BOUNDS,
|
||||
extra_attributes=" content-desc='discard' resource-id='discard' focused='true'",
|
||||
),
|
||||
original_node=_price_node(
|
||||
ORIGINAL_PRICE,
|
||||
PRICE_ORIGINAL_BOUNDS,
|
||||
extra_attributes=" content-desc='discard-too' resource-id='discard-too'",
|
||||
),
|
||||
)
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=xml)
|
||||
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
root = ElementTree.parse(result.hierarchy_path).getroot()
|
||||
prices = [node for node in root.findall("node") if node.get("text") in {CURRENT_PRICE, ORIGINAL_PRICE}]
|
||||
|
||||
self.assertEqual(len(prices), 2)
|
||||
for node in prices:
|
||||
self.assertEqual(
|
||||
set(node.attrib),
|
||||
{"bounds", "text", "package", "class", "clickable", "enabled", "visible-to-user"},
|
||||
)
|
||||
self.assertEqual(node.get("package"), "com.xunmeng.pinduoduo")
|
||||
self.assertEqual(node.get("class"), "android.widget.TextView")
|
||||
self.assertEqual(node.get("clickable"), "false")
|
||||
self.assertEqual(node.get("enabled"), "true")
|
||||
self.assertEqual(node.get("visible-to-user"), "true")
|
||||
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(manifest["privacy_cleanup"]["preserved_crossing_price_nodes"], 2)
|
||||
self.assertNotIn("discard", result.hierarchy_path.read_text(encoding="utf-8"))
|
||||
|
||||
def test_crossing_price_window_rejects_text_and_structure_drift(self) -> None:
|
||||
bad_texts = (
|
||||
f"快卖完 ¥12.88 {TEST_ADDRESS}",
|
||||
f"快卖完 ¥12.88 {FULL_PHONE}",
|
||||
"快卖完 ¥12.88 使用微信支付",
|
||||
"快卖完 ¥12.88 提交订单",
|
||||
"快卖完 ¥12.88 优惠-11元",
|
||||
"快要抢光 ¥12.88",
|
||||
"快卖完 ¥0.00",
|
||||
"快卖完 ¥12.8",
|
||||
"快卖完 ¥12.880",
|
||||
"快卖完 ¥12.88",
|
||||
)
|
||||
for text in bad_texts:
|
||||
with self.subTest(text=text), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=text))
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
def test_crossing_price_text_mismatch_reports_only_fixed_slot_and_reason(self) -> None:
|
||||
newline_node = _price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS).replace(
|
||||
"快卖完 ¥12.88", "快卖完 ¥12.88"
|
||||
)
|
||||
cases = (
|
||||
("newline", _xml_with_prices(current_node=newline_node), "newline", PRICE_CURRENT_BOUNDS),
|
||||
(
|
||||
"non-ascii-whitespace",
|
||||
_xml_with_prices(current="快卖完 ¥12.88"),
|
||||
"non_ascii_whitespace",
|
||||
PRICE_CURRENT_BOUNDS,
|
||||
),
|
||||
(
|
||||
"known-prefix-missing",
|
||||
_xml_with_prices(current="快要抢光 ¥12.88"),
|
||||
"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:
|
||||
with TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
left_raw = _write_raw(root / "left")
|
||||
right_raw = _write_raw(root / "right")
|
||||
left = sanitize_sku_panel_evidence(left_raw, left_raw.parent / "derived")
|
||||
right = sanitize_sku_panel_evidence(right_raw, right_raw.parent / "derived")
|
||||
|
||||
for left_path, right_path in (
|
||||
(left.screenshot_path, right.screenshot_path),
|
||||
(left.hierarchy_path, right.hierarchy_path),
|
||||
(left.manifest_path, right.manifest_path),
|
||||
):
|
||||
self.assertEqual(left_path.read_bytes(), right_path.read_bytes())
|
||||
|
||||
def test_manifest_records_distinct_screenshot_and_xml_coordinate_spaces(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary))
|
||||
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
self.assertEqual(
|
||||
manifest["screenshot_space"],
|
||||
{"width": 1080, "height": 2376, "privacy_mask_rectangle": [0, 0, 1080, 540]},
|
||||
)
|
||||
self.assertEqual(
|
||||
manifest["xml_coordinate_space"],
|
||||
{
|
||||
"width": 1080,
|
||||
"height": 2376,
|
||||
"privacy_mask_rectangle": [0, 0, 1080, 540],
|
||||
"observed_max": {"right": 1080, "bottom": 2376},
|
||||
},
|
||||
)
|
||||
|
||||
def test_hash_and_metadata_mismatch_fail_closed_without_leak(self) -> None:
|
||||
scenarios = (
|
||||
("hash", {}, "screenshot"),
|
||||
("model", {"model": "other"}, None),
|
||||
("android", {"android_version": "15"}, None),
|
||||
("version", {"pdd_version": "8.17.1"}, None),
|
||||
("goods", {"goods_id": "123"}, None),
|
||||
("state", {"state": "guessed"}, None),
|
||||
("old-screenshot-space", {"size": (1080, 2400)}, None),
|
||||
("other-screenshot-space", {"size": (100, 100)}, None),
|
||||
)
|
||||
for name, kwargs, corrupt_file in scenarios:
|
||||
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), **kwargs)
|
||||
if corrupt_file is not None:
|
||||
(raw / f"{corrupt_file}.png").write_bytes(b"changed")
|
||||
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
|
||||
message = str(raised.exception)
|
||||
self.assertNotIn(TEST_SERIAL, message)
|
||||
self.assertNotIn(TEST_ADDRESS, message)
|
||||
self.assertNotIn(FULL_PHONE, message)
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||
|
||||
def test_old_and_unknown_human_states_are_rejected(self) -> None:
|
||||
for state in ("initial", "one-dimension-selected", "all-dimensions-selected", "guessed"):
|
||||
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), state=state)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
def test_malformed_inputs_and_bounds_or_phone_residue_fail_closed(self) -> None:
|
||||
malformed = (
|
||||
("manifest", None),
|
||||
("png", None),
|
||||
("xml", None),
|
||||
("bounds", "<hierarchy><node text='missing bounds' /></hierarchy>"),
|
||||
(
|
||||
"private-parent-with-lower-child",
|
||||
"<hierarchy><node bounds='[0,0][1080,540]'><node bounds='[0,540][1080,2376]' text='x' /></node></hierarchy>",
|
||||
),
|
||||
("full-phone-below", f"<hierarchy><node bounds='[0,540][1080,2376]' text='{FULL_PHONE}' /></hierarchy>"),
|
||||
("masked-phone-below", f"<hierarchy><node bounds='[0,540][1080,2376]' text='{MASKED_PHONE}' /></hierarchy>"),
|
||||
)
|
||||
for kind, xml in malformed:
|
||||
with self.subTest(kind=kind), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=xml)
|
||||
if kind == "manifest":
|
||||
(raw / "manifest.json").write_text("{invalid", encoding="utf-8")
|
||||
elif kind == "png":
|
||||
(raw / "screenshot.png").write_bytes(b"not a png")
|
||||
manifest = json.loads((raw / "manifest.json").read_text(encoding="utf-8"))
|
||||
manifest["artifacts"][0]["sha256"] = _hash(raw / "screenshot.png")
|
||||
(raw / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
elif kind == "xml":
|
||||
(raw / "hierarchy.xml").write_text("<hierarchy>", encoding="utf-8")
|
||||
manifest = json.loads((raw / "manifest.json").read_text(encoding="utf-8"))
|
||||
manifest["artifacts"][1]["sha256"] = _hash(raw / "hierarchy.xml")
|
||||
(raw / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||
|
||||
def test_phone_recheck_rejects_separator_mask_and_cross_node_bypasses(self) -> None:
|
||||
variants = (
|
||||
"138 0013-8000",
|
||||
"138****0000",
|
||||
"138••••0000",
|
||||
"138xxxx0000",
|
||||
"138XXXX0000",
|
||||
)
|
||||
for value in variants:
|
||||
xml = (
|
||||
"<hierarchy>"
|
||||
"<node bounds='[0,0][1080,540]' text='private' />"
|
||||
f"<node bounds='[0,540][1080,2376]' text='{value}' />"
|
||||
"</hierarchy>"
|
||||
)
|
||||
with self.subTest(value=value), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=xml)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
split_xml = (
|
||||
"<hierarchy>"
|
||||
"<node bounds='[0,0][1080,540]' text='private' />"
|
||||
"<node bounds='[0,540][1080,1000]' text='138' content-desc='0013' />"
|
||||
"<node bounds='[0,1000][1080,2376]' text='8000' />"
|
||||
"</hierarchy>"
|
||||
)
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=split_xml)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
def test_privacy_geometry_requires_removed_and_retained_nodes(self) -> None:
|
||||
scenarios = (
|
||||
("no-private", f"<hierarchy><node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' /></hierarchy>"),
|
||||
("no-below", "<hierarchy><node bounds='[0,0][1080,540]' text='private' /></hierarchy>"),
|
||||
)
|
||||
for name, xml in scenarios:
|
||||
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=xml)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||
|
||||
def test_xml_coordinate_space_must_have_exact_configured_maximums(self) -> None:
|
||||
scenarios = (
|
||||
(
|
||||
"short-width",
|
||||
"<hierarchy><node bounds='[0,0][1079,540]' text='private' />"
|
||||
"<node bounds='[0,540][1079,2376]' text='safe' /></hierarchy>",
|
||||
"1079x2376",
|
||||
),
|
||||
(
|
||||
"short-height",
|
||||
"<hierarchy><node bounds='[0,0][1080,540]' text='private' />"
|
||||
"<node bounds='[0,540][1080,2375]' text='safe' /></hierarchy>",
|
||||
"1080x2375",
|
||||
),
|
||||
(
|
||||
"wide-width",
|
||||
"<hierarchy><node bounds='[0,0][1081,540]' text='private' />"
|
||||
"<node bounds='[0,540][1081,2376]' text='safe' /></hierarchy>",
|
||||
"1081x2376",
|
||||
),
|
||||
(
|
||||
"old-v2-xml-height",
|
||||
"<hierarchy><node bounds='[0,0][1080,540]' text='private' />"
|
||||
"<node bounds='[0,540][1080,2400]' text='safe' /></hierarchy>",
|
||||
"1080x2400",
|
||||
),
|
||||
)
|
||||
for name, xml, observed in scenarios:
|
||||
with self.subTest(name=name), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary), xml=xml)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
|
||||
self.assertIn(observed, str(raised.exception))
|
||||
self.assertNotIn(TEST_SERIAL, str(raised.exception))
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||
|
||||
def test_manifest_schema_package_and_artifact_structure_are_required(self) -> None:
|
||||
def mutate(manifest: dict[str, object], kind: str) -> None:
|
||||
if kind == "schema":
|
||||
manifest["schema_version"] = 2
|
||||
elif kind == "package":
|
||||
manifest["device"]["pdd_package"] = "com.example.other" # type: ignore[index]
|
||||
elif kind == "missing":
|
||||
manifest.pop("artifacts")
|
||||
elif kind == "duplicate":
|
||||
manifest["artifacts"].append(manifest["artifacts"][0]) # type: ignore[index]
|
||||
elif kind == "bad-hash":
|
||||
manifest["artifacts"][0]["sha256"] = "g" * 64 # type: ignore[index]
|
||||
|
||||
for kind in ("schema", "package", "missing", "duplicate", "bad-hash"):
|
||||
with self.subTest(kind=kind), TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary))
|
||||
manifest_path = raw / "manifest.json"
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
mutate(manifest, kind)
|
||||
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
|
||||
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
|
||||
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
|
||||
|
||||
self.assertNotIn(TEST_SERIAL, str(raised.exception))
|
||||
self.assertNotIn(TEST_ADDRESS, str(raised.exception))
|
||||
self.assertFalse((raw.parent / "derived").exists())
|
||||
|
||||
def test_target_created_during_publish_is_preserved_and_staging_is_removed(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary))
|
||||
target = raw.parent / "derived"
|
||||
|
||||
def race_rename(destination: Path) -> None:
|
||||
destination.mkdir()
|
||||
(destination / "sentinel.txt").write_text("keep", encoding="utf-8")
|
||||
raise FileExistsError("simulated publish race")
|
||||
|
||||
with patch("cmbuyer_client.device.sku_evidence_sanitizer.Path.rename", side_effect=race_rename):
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, target)
|
||||
|
||||
self.assertEqual((target / "sentinel.txt").read_text(encoding="utf-8"), "keep")
|
||||
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||
|
||||
def test_existing_derived_is_preserved_without_reading_or_writing_raw(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
raw = _write_raw(Path(temporary))
|
||||
target = raw.parent / "derived"
|
||||
target.mkdir()
|
||||
sentinel = target / "sentinel.txt"
|
||||
sentinel.write_text("keep", encoding="utf-8")
|
||||
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, target)
|
||||
|
||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
||||
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
|
||||
|
||||
def test_directory_contract_rejects_non_sibling_paths(self) -> None:
|
||||
with TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
raw = _write_raw(root)
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(raw, root / "not-derived")
|
||||
with self.assertRaises(SkuEvidenceSanitizationError):
|
||||
sanitize_sku_panel_evidence(root / "not-raw", root / "derived")
|
||||
@@ -0,0 +1 @@
|
||||
"""拼多多受限打开模块的离线测试。"""
|
||||
@@ -0,0 +1,266 @@
|
||||
"""商品打开围栏的离线测试;所有设备和命令均为 fake。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection, IntentLaunchSummary
|
||||
from cmbuyer_client.pdd.product_open import (
|
||||
ProductOpenCapturer,
|
||||
ProductOpenTimeoutError,
|
||||
ProductOpenUiDevice,
|
||||
ProductHierarchyCaptureError,
|
||||
ProductPackageMismatchError,
|
||||
ProductScreenshotCaptureError,
|
||||
ProductVersionMismatchError,
|
||||
)
|
||||
from cmbuyer_client.pdd.product_url import ProductUrl, ProductUrlError
|
||||
|
||||
|
||||
SERIAL = "192.168.0.173:5555"
|
||||
URL = "https://mobile.yangkeduo.com/goods.html?goods_id=123"
|
||||
HIERARCHY = "<?xml version='1.0' encoding='UTF-8'?><hierarchy rotation='0'><node /></hierarchy>"
|
||||
|
||||
|
||||
def _png_base64() -> str:
|
||||
image_data = BytesIO()
|
||||
Image.new("RGB", (1, 1), color="white").save(image_data, format="PNG")
|
||||
return base64.b64encode(image_data.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
class FakeAdbClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str | None]] = []
|
||||
self.inspection = DeviceInspection(
|
||||
device=AdbDevice(serial=SERIAL, state="device", model="PKG110"),
|
||||
model="PKG110",
|
||||
android_version="16",
|
||||
)
|
||||
|
||||
def inspect(self, serial: str) -> DeviceInspection:
|
||||
self.calls.append(("inspect", serial))
|
||||
return self.inspection
|
||||
|
||||
def start_pdd_view_intent(self, serial: str, goods_id: str) -> IntentLaunchSummary:
|
||||
self.calls.append(("intent", goods_id))
|
||||
return IntentLaunchSummary(status="ok", returncode=0)
|
||||
|
||||
|
||||
class FakeUiDevice:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
version: str = "8.17.0",
|
||||
current_package: str = "com.xunmeng.pinduoduo",
|
||||
current_packages: list[str] | None = None,
|
||||
hierarchy: str = HIERARCHY,
|
||||
timeout_on_screenshot: bool = False,
|
||||
) -> None:
|
||||
self.version = version
|
||||
self.current_package = current_package
|
||||
self.current_packages = list(current_packages) if current_packages is not None else None
|
||||
self.hierarchy = hierarchy
|
||||
self.timeout_on_screenshot = timeout_on_screenshot
|
||||
self.calls: list[str] = []
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, str]:
|
||||
self.calls.append("app_info")
|
||||
return {"versionName": self.version}
|
||||
|
||||
def app_current(self) -> dict[str, str]:
|
||||
self.calls.append("app_current")
|
||||
if self.current_packages:
|
||||
package = self.current_packages.pop(0)
|
||||
self.current_package = package
|
||||
return {"package": self.current_package, "activity": "sensitive.activity.name"}
|
||||
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
self.calls.append(method)
|
||||
if method == "takeScreenshot":
|
||||
if self.timeout_on_screenshot:
|
||||
raise TimeoutError("raw remote detail")
|
||||
return _png_base64()
|
||||
if method == "dumpWindowHierarchy":
|
||||
return self.hierarchy
|
||||
raise AssertionError(f"unexpected RPC {method}")
|
||||
|
||||
|
||||
class ProductOpenTests(unittest.TestCase):
|
||||
def _capturer(self, adb: FakeAdbClient, device: FakeUiDevice, **kwargs: object) -> ProductOpenCapturer:
|
||||
return ProductOpenCapturer(adb, lambda serial: device, timeout_seconds=2, **kwargs)
|
||||
|
||||
def test_success_uses_canonical_url_and_redacted_atomic_manifest(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeUiDevice()
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
result = self._capturer(adb, device).open_and_capture(SERIAL, URL, target)
|
||||
manifest = result.manifest_path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertTrue(result.screenshot_path.exists())
|
||||
self.assertTrue(result.hierarchy_path.exists())
|
||||
self.assertEqual(adb.calls, [("inspect", SERIAL), ("intent", "123")])
|
||||
self.assertEqual(device.calls, ["app_info", "app_current", "takeScreenshot", "dumpWindowHierarchy"])
|
||||
self.assertIn('"goods_id": "123"', manifest)
|
||||
self.assertIn('"canonical_url": "https://mobile.yangkeduo.com/goods.html?goods_id=123"', manifest)
|
||||
self.assertNotIn(SERIAL, manifest)
|
||||
self.assertNotIn("sensitive.activity.name", manifest)
|
||||
self.assertNotIn(HIERARCHY, manifest)
|
||||
|
||||
def test_version_mismatch_halts_before_intent(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
for version in ("8.17.1", " 8.17.0 "):
|
||||
with self.subTest(version=version), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(ProductVersionMismatchError):
|
||||
self._capturer(adb, FakeUiDevice(version=version)).open_and_capture(SERIAL, URL, target)
|
||||
|
||||
self.assertEqual(adb.calls[-1:], [("inspect", SERIAL)])
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
def test_public_entry_rejects_caller_constructed_url_value_object(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
with TemporaryDirectory() as temporary:
|
||||
with self.assertRaises(ProductUrlError):
|
||||
self._capturer(adb, FakeUiDevice()).open_and_capture(
|
||||
SERIAL,
|
||||
ProductUrl(goods_id="123", canonical_url="https://example.invalid/"), # type: ignore[arg-type]
|
||||
Path(temporary) / "evidence",
|
||||
)
|
||||
|
||||
self.assertEqual(adb.calls, [])
|
||||
|
||||
def test_foreground_package_mismatch_halts_before_capture(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeUiDevice(current_package="com.example.other")
|
||||
clock = FakeClock()
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(ProductPackageMismatchError):
|
||||
self._capturer(
|
||||
adb,
|
||||
device,
|
||||
foreground_poll_interval_seconds=0.5,
|
||||
monotonic_clock=clock.monotonic,
|
||||
sleep_function=clock.sleep,
|
||||
).open_and_capture(SERIAL, URL, target)
|
||||
|
||||
self.assertEqual(adb.calls, [("inspect", SERIAL), ("intent", "123")])
|
||||
self.assertEqual(device.calls, ["app_info", "app_current", "app_current", "app_current", "app_current", "app_current"])
|
||||
self.assertEqual(clock.sleeps, [0.5, 0.5, 0.5, 0.5])
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
def test_foreground_package_poll_waits_for_pdd_before_reading_evidence(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeUiDevice(current_packages=["com.example.other", "com.xunmeng.pinduoduo"])
|
||||
clock = FakeClock()
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
result = self._capturer(
|
||||
adb,
|
||||
device,
|
||||
foreground_poll_interval_seconds=0.25,
|
||||
monotonic_clock=clock.monotonic,
|
||||
sleep_function=clock.sleep,
|
||||
).open_and_capture(SERIAL, URL, target)
|
||||
|
||||
self.assertTrue(result.manifest_path.exists())
|
||||
self.assertEqual(clock.sleeps, [0.25])
|
||||
self.assertEqual(
|
||||
device.calls,
|
||||
["app_info", "app_current", "app_current", "takeScreenshot", "dumpWindowHierarchy"],
|
||||
)
|
||||
|
||||
def test_foreground_package_poll_stops_at_deadline_without_evidence(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeUiDevice(current_packages=["com.example.other", "", "com.example.other"])
|
||||
clock = FakeClock()
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(ProductPackageMismatchError):
|
||||
self._capturer(
|
||||
adb,
|
||||
device,
|
||||
foreground_poll_interval_seconds=0.8,
|
||||
monotonic_clock=clock.monotonic,
|
||||
sleep_function=clock.sleep,
|
||||
).open_and_capture(SERIAL, URL, target)
|
||||
|
||||
self.assertEqual(len(clock.sleeps), 3)
|
||||
for actual, expected in zip(clock.sleeps, (0.8, 0.8, 0.4), strict=True):
|
||||
self.assertAlmostEqual(actual, expected)
|
||||
self.assertEqual(device.calls, ["app_info", "app_current", "app_current", "app_current", "app_current"])
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_foreground_poll_interval_must_be_positive_and_finite(self) -> None:
|
||||
for interval in (0, -0.1, float("inf"), float("nan"), True):
|
||||
with self.subTest(interval=interval):
|
||||
with self.assertRaises(ValueError):
|
||||
ProductOpenCapturer(
|
||||
FakeAdbClient(),
|
||||
lambda serial: FakeUiDevice(),
|
||||
timeout_seconds=2,
|
||||
foreground_poll_interval_seconds=interval, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def test_timeout_and_invalid_hierarchy_leave_no_partial_evidence(self) -> None:
|
||||
scenarios = (
|
||||
(FakeUiDevice(timeout_on_screenshot=True), ProductOpenTimeoutError),
|
||||
(FakeUiDevice(hierarchy="<not-hierarchy />"), ProductHierarchyCaptureError),
|
||||
)
|
||||
for device, error_type in scenarios:
|
||||
with self.subTest(error_type=error_type.__name__), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(error_type):
|
||||
self._capturer(FakeAdbClient(), device).open_and_capture(SERIAL, URL, target)
|
||||
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_invalid_screenshot_is_a_distinct_redacted_failure(self) -> None:
|
||||
class InvalidScreenshotDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "takeScreenshot":
|
||||
self.calls.append(method)
|
||||
return "not valid base64!"
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(ProductScreenshotCaptureError) as raised:
|
||||
self._capturer(FakeAdbClient(), InvalidScreenshotDevice()).open_and_capture(SERIAL, URL, target)
|
||||
|
||||
self.assertNotIn("base64", str(raised.exception).lower())
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_read_only_protocol_has_no_ui_operation_methods(self) -> None:
|
||||
forbidden = {"click", "swipe", "send_keys", "set_text", "press", "long_click"}
|
||||
|
||||
self.assertTrue(forbidden.isdisjoint(ProductOpenUiDevice.__dict__))
|
||||
self.assertEqual(base64.b64decode(_png_base64())[:8], b"\x89PNG\r\n\x1a\n")
|
||||
|
||||
|
||||
class FakeClock:
|
||||
def __init__(self) -> None:
|
||||
self.value = 0.0
|
||||
self.sleeps: list[float] = []
|
||||
|
||||
def monotonic(self) -> float:
|
||||
return self.value
|
||||
|
||||
def sleep(self, seconds: float) -> None:
|
||||
self.sleeps.append(seconds)
|
||||
self.value += seconds
|
||||
@@ -0,0 +1,49 @@
|
||||
"""canonical 商品 URL 的离线解析测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
|
||||
|
||||
|
||||
class ProductUrlTests(unittest.TestCase):
|
||||
def test_rebuilds_url_from_goods_id(self) -> None:
|
||||
link = parse_product_url("https://mobile.yangkeduo.com/goods.html?goods_id=00123")
|
||||
|
||||
self.assertEqual(link.goods_id, "00123")
|
||||
self.assertEqual(
|
||||
link.canonical_url,
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=00123",
|
||||
)
|
||||
|
||||
def test_rejects_noncanonical_and_ambiguous_urls(self) -> None:
|
||||
rejected = (
|
||||
"http://mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||
"https://other.example/goods.html?goods_id=123",
|
||||
"https://mobile.yangkeduo.com/other.html?goods_id=123",
|
||||
"https://user@mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||
"https://mobile.yangkeduo.com:8443/goods.html?goods_id=123",
|
||||
"https://mobile.yangkeduo.com:443/goods.html?goods_id=123",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=123#fragment",
|
||||
"https://mobile.yangkeduo.com/goods.html",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=123&goods_id=456",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=123&source=share",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=12a",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=%EF%BC%91%EF%BC%92%EF%BC%93",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=",
|
||||
" https://mobile.yangkeduo.com/goods.html?goods_id=123",
|
||||
"https://MOBILE.YANGKEDUO.COM/goods.html?goods_id=123",
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=%31%32%33",
|
||||
)
|
||||
|
||||
for value in rejected:
|
||||
with self.subTest(value=value):
|
||||
with self.assertRaises(ProductUrlError):
|
||||
parse_product_url(value)
|
||||
@@ -0,0 +1,263 @@
|
||||
"""人工声明规格面板状态的离线只读取证测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from tempfile import TemporaryDirectory
|
||||
import unittest
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
CLIENT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||
|
||||
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
|
||||
from cmbuyer_client.pdd.product_url import ProductUrlError
|
||||
from cmbuyer_client.pdd.sku_panel_spike import (
|
||||
HUMAN_DECLARED_STATES,
|
||||
SkuPanelDeclaredStateError,
|
||||
SkuPanelEvidenceCapturer,
|
||||
SkuPanelEvidenceError,
|
||||
SkuPanelEvidenceTimeoutError,
|
||||
SkuPanelHierarchyError,
|
||||
SkuPanelPackageMismatchError,
|
||||
SkuPanelScreenshotError,
|
||||
SkuPanelUiDevice,
|
||||
SkuPanelVersionMismatchError,
|
||||
)
|
||||
|
||||
|
||||
SERIAL = "192.168.0.173:5555"
|
||||
URL = "https://mobile.yangkeduo.com/goods.html?goods_id=123"
|
||||
HIERARCHY = "<?xml version='1.0' encoding='UTF-8'?><hierarchy><node text='sensitive page text' /></hierarchy>"
|
||||
|
||||
|
||||
def _png_base64() -> str:
|
||||
image_data = BytesIO()
|
||||
Image.new("RGB", (1, 1), color="white").save(image_data, format="PNG")
|
||||
return base64.b64encode(image_data.getvalue()).decode("ascii")
|
||||
|
||||
|
||||
class FakeAdbClient:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[str] = []
|
||||
self.inspection = DeviceInspection(
|
||||
device=AdbDevice(serial=SERIAL, state="device", model="PKG110"),
|
||||
model="PKG110",
|
||||
android_version="16",
|
||||
)
|
||||
|
||||
def inspect(self, serial: str) -> DeviceInspection:
|
||||
self.calls.append(serial)
|
||||
return self.inspection
|
||||
|
||||
|
||||
class FakeUiDevice:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
version: str = "8.17.0",
|
||||
package: str = "com.xunmeng.pinduoduo",
|
||||
screenshot: str | None = None,
|
||||
hierarchy: str = HIERARCHY,
|
||||
) -> None:
|
||||
self.version = version
|
||||
self.package = package
|
||||
self.screenshot = screenshot if screenshot is not None else _png_base64()
|
||||
self.hierarchy = hierarchy
|
||||
self.calls: list[str] = []
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, str]:
|
||||
self.calls.append("app_info")
|
||||
return {"versionName": self.version}
|
||||
|
||||
def app_current(self) -> dict[str, str]:
|
||||
self.calls.append("app_current")
|
||||
return {"package": self.package, "activity": "sensitive.activity.name"}
|
||||
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
self.calls.append(method)
|
||||
if method == "takeScreenshot":
|
||||
return self.screenshot
|
||||
if method == "dumpWindowHierarchy":
|
||||
return self.hierarchy
|
||||
raise AssertionError(f"unexpected RPC {method}")
|
||||
|
||||
|
||||
def _load_spike_script() -> object:
|
||||
script_path = CLIENT_ROOT / "scripts" / "capture_sku_panel_spike.py"
|
||||
spec = spec_from_file_location("capture_sku_panel_spike_for_test", script_path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class SkuPanelEvidenceTests(unittest.TestCase):
|
||||
def _capturer(self, adb: FakeAdbClient, device: FakeUiDevice) -> SkuPanelEvidenceCapturer:
|
||||
return SkuPanelEvidenceCapturer(adb, lambda serial: device, timeout_seconds=2)
|
||||
|
||||
def test_all_human_declared_states_publish_redacted_manifest(self) -> None:
|
||||
for state in sorted(HUMAN_DECLARED_STATES):
|
||||
with self.subTest(state=state), TemporaryDirectory() as temporary:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeUiDevice()
|
||||
target = Path(temporary) / "evidence"
|
||||
result = self._capturer(adb, device).capture(SERIAL, URL, state, target)
|
||||
manifest = result.manifest_path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertEqual(adb.calls, [SERIAL])
|
||||
self.assertEqual(device.calls, ["app_info", "app_current", "takeScreenshot", "dumpWindowHierarchy"])
|
||||
self.assertIn(f'"human_declared_state": "{state}"', manifest)
|
||||
self.assertIn('"goods_id": "123"', manifest)
|
||||
self.assertIn('"canonical_url": "https://mobile.yangkeduo.com/goods.html?goods_id=123"', manifest)
|
||||
self.assertNotIn("detected_state", manifest)
|
||||
self.assertNotIn(SERIAL, manifest)
|
||||
self.assertNotIn("sensitive.activity.name", manifest)
|
||||
self.assertNotIn(HIERARCHY, manifest)
|
||||
|
||||
def test_invalid_state_and_url_fail_before_device_access(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
rejected_states = ("initial", "one-dimension-selected", "all-dimensions-selected", "guessed")
|
||||
with TemporaryDirectory() as temporary:
|
||||
for state in rejected_states:
|
||||
with self.subTest(state=state), self.assertRaises(SkuPanelDeclaredStateError):
|
||||
self._capturer(adb, FakeUiDevice()).capture(SERIAL, URL, state, Path(temporary) / "state")
|
||||
with self.assertRaises(ProductUrlError):
|
||||
self._capturer(adb, FakeUiDevice()).capture(
|
||||
SERIAL,
|
||||
"https://mobile.yangkeduo.com/goods.html?goods_id=12x",
|
||||
"panel-opened-target-preselected",
|
||||
Path(temporary) / "url",
|
||||
)
|
||||
|
||||
self.assertEqual(adb.calls, [])
|
||||
|
||||
def test_version_or_foreground_package_mismatch_stops_before_artifacts(self) -> None:
|
||||
scenarios = (
|
||||
(FakeUiDevice(version="8.17.1"), SkuPanelVersionMismatchError, ["app_info"]),
|
||||
(FakeUiDevice(package="com.example.other"), SkuPanelPackageMismatchError, ["app_info", "app_current"]),
|
||||
)
|
||||
for device, error_type, expected_calls in scenarios:
|
||||
with self.subTest(error_type=error_type.__name__), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(error_type):
|
||||
self._capturer(FakeAdbClient(), device).capture(SERIAL, URL, "panel-opened-target-preselected", target)
|
||||
|
||||
self.assertEqual(device.calls, expected_calls)
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
def test_screenshot_and_xml_failure_leave_no_partial_evidence(self) -> None:
|
||||
scenarios = (
|
||||
(FakeUiDevice(screenshot="not valid base64!"), SkuPanelScreenshotError),
|
||||
(FakeUiDevice(hierarchy="<not-hierarchy />"), SkuPanelHierarchyError),
|
||||
)
|
||||
for device, error_type in scenarios:
|
||||
with self.subTest(error_type=error_type.__name__), TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(error_type):
|
||||
self._capturer(FakeAdbClient(), device).capture(SERIAL, URL, "panel-opened-target-preselected", target)
|
||||
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_screenshot_timeout_is_redacted_and_leaves_no_partial_evidence(self) -> None:
|
||||
class TimeoutScreenshotDevice(FakeUiDevice):
|
||||
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||
if method == "takeScreenshot":
|
||||
self.calls.append(method)
|
||||
raise TimeoutError("adb 192.168.0.173:5555 raw detail")
|
||||
return super().jsonrpc_call(method, params, timeout)
|
||||
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
with self.assertRaises(SkuPanelEvidenceTimeoutError) as raised:
|
||||
self._capturer(FakeAdbClient(), TimeoutScreenshotDevice()).capture(SERIAL, URL, "panel-opened-target-preselected", target)
|
||||
|
||||
self.assertNotIn(SERIAL, str(raised.exception))
|
||||
self.assertNotIn("adb", str(raised.exception).lower())
|
||||
self.assertFalse(target.exists())
|
||||
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
|
||||
|
||||
def test_existing_output_directory_is_not_overwritten_or_connected(self) -> None:
|
||||
adb = FakeAdbClient()
|
||||
device = FakeUiDevice()
|
||||
with TemporaryDirectory() as temporary:
|
||||
target = Path(temporary) / "evidence"
|
||||
target.mkdir()
|
||||
sentinel = target / "sentinel.txt"
|
||||
sentinel.write_text("keep", encoding="utf-8")
|
||||
|
||||
with self.assertRaises(SkuPanelEvidenceError):
|
||||
self._capturer(adb, device).capture(SERIAL, URL, "panel-opened-target-preselected", target)
|
||||
|
||||
self.assertEqual(adb.calls, [])
|
||||
self.assertEqual(device.calls, [])
|
||||
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
||||
|
||||
def test_protocol_has_no_ui_or_purchase_operation_methods(self) -> None:
|
||||
forbidden = {
|
||||
"click",
|
||||
"swipe",
|
||||
"send_keys",
|
||||
"set_text",
|
||||
"press",
|
||||
"open_product",
|
||||
"open_sku_panel",
|
||||
"set_quantity",
|
||||
"go_to_order_confirm",
|
||||
"submit_order",
|
||||
"pay",
|
||||
}
|
||||
self.assertTrue(forbidden.isdisjoint(SkuPanelUiDevice.__dict__))
|
||||
self.assertEqual({name for name in SkuPanelEvidenceCapturer.__dict__ if not name.startswith("_")}, {"capture"})
|
||||
|
||||
|
||||
class SkuPanelSpikeCliTests(unittest.TestCase):
|
||||
def test_validate_arguments_rejects_invalid_serial_timeout_state_and_url(self) -> None:
|
||||
script = _load_spike_script()
|
||||
valid = {
|
||||
"serial": SERIAL,
|
||||
"url": URL,
|
||||
"goods_id": None,
|
||||
"state": "panel-opened-target-preselected",
|
||||
"output_dir": Path("evidence"),
|
||||
"timeout": 10.0,
|
||||
"adb": "adb",
|
||||
}
|
||||
invalid_values = (
|
||||
("serial", ""),
|
||||
("timeout", 0),
|
||||
("timeout", float("inf")),
|
||||
("state", "not-declared"),
|
||||
("state", "initial"),
|
||||
("state", "one-dimension-selected"),
|
||||
("state", "all-dimensions-selected"),
|
||||
("url", "https://mobile.yangkeduo.com/goods.html?goods_id=bad"),
|
||||
)
|
||||
for field, value in invalid_values:
|
||||
with self.subTest(field=field, value=value):
|
||||
arguments = argparse.Namespace(**(valid | {field: value}))
|
||||
with self.assertRaises((ValueError, ProductUrlError)):
|
||||
script.validate_arguments(arguments) # type: ignore[attr-defined]
|
||||
|
||||
def test_goods_id_is_rebuilt_as_canonical_url(self) -> None:
|
||||
script = _load_spike_script()
|
||||
arguments = argparse.Namespace(
|
||||
serial=SERIAL,
|
||||
url=None,
|
||||
goods_id="00123",
|
||||
state="target-selection-restored",
|
||||
output_dir=Path("evidence"),
|
||||
timeout=10.0,
|
||||
adb="adb",
|
||||
)
|
||||
|
||||
link = script.validate_arguments(arguments) # type: ignore[attr-defined]
|
||||
self.assertEqual(link.canonical_url, "https://mobile.yangkeduo.com/goods.html?goods_id=00123")
|
||||
+26
-29
@@ -9,13 +9,10 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
**采购工具**(桌面端,`client/`)驱动 Android 手机在拼多多完成找货和下单,
|
||||
**付款始终由人完成**。
|
||||
|
||||
第一版 MVP 只做**任务自带商品链接**的情形,**分两趟跑**:
|
||||
|
||||
1. **第一趟试选**:桌面端定时领取 → 开商品 → 精确勾选颜色分类和尺码 → 读单价 →
|
||||
截图 → **退出释放手机** → 回传。
|
||||
2. **人工确认**:管理员在网页端看「机器选对了吗」,确认后签发授权并**锁定单价**。
|
||||
3. **第二趟下单**:重新开商品 → 重新选同一规格 → **三道价格闸门** → 提交订单一次 →
|
||||
任务转「待付款」,人在拼多多核对后付款。
|
||||
第一版 MVP 只做**任务自带商品链接**的情形。创建任务不授权;管理员勾选 `DRAFT` 并点击
|
||||
“开始采购(只创建待付款订单)”后,服务端锁定商品、规格、数量和最高总价并签发一次性授权。
|
||||
采购工具领取后在**同一趟**完成精确选规格、三道价格闸门、数量复核、确认页、提交围栏和唯一一次
|
||||
“提交订单”点击。中间不等待人工确认;订单创建后转待付款,由人核对和付款。
|
||||
|
||||
## 上下文读取
|
||||
|
||||
@@ -24,7 +21,7 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
1. [`01-vision.md`](01-vision.md):为什么做、为谁做、什么不做。
|
||||
2. [`02-requirements.md`](02-requirements.md):MVP 要什么、怎么算达成。
|
||||
3. [`03-tech-stack.md`](03-tech-stack.md):两端各自的技术选型与验证矩阵。
|
||||
4. [`04-architecture.md`](04-architecture.md):双端职责、**两趟执行**、**三道价格闸门**、安全边界、数据模型。
|
||||
4. [`04-architecture.md`](04-architecture.md):双端职责、**单趟执行**、**三道价格闸门**、安全边界、数据模型。
|
||||
5. [`05-coding-rules.md`](05-coding-rules.md):写代码前必须遵守的规则。
|
||||
6. [`06-tasks.md`](06-tasks.md):阶段路线图与建议拆分。
|
||||
7. [`tasks/README.md`](tasks/README.md):任务文件约定。
|
||||
@@ -61,7 +58,7 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
### 2. 安全边界只能收紧,不能放宽
|
||||
|
||||
[`04-architecture.md`](04-architecture.md) 第四节的硬约束(不付款、提交订单四条件与服务端围栏、
|
||||
规格精确匹配、提交控件唯一、数量必须复核、三道价格闸门、第一趟不下单、外部支付停止、
|
||||
规格精确匹配、提交控件唯一、数量必须复核、三道价格闸门、隔离能力不越界、外部支付停止、
|
||||
安全校验停止、敏感信息不提取、授权一次性)**不得在任务中顺手放宽**。
|
||||
|
||||
确需变更时先改架构文档并说明理由,再动代码。任何「为了让流程跑通先放宽一下」的改动
|
||||
@@ -77,30 +74,31 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
|
||||
理由与三道闸门的定义见 [`04-architecture.md`](04-architecture.md) 第三节。
|
||||
|
||||
### 4. 第一趟绝不下单
|
||||
### 4. 真机能力逐段取证、隔离开放
|
||||
|
||||
试选阶段只勾选规格和读价,**绝不点击「现在买」或任何进入下单流程的入口**。
|
||||
第一趟的代码路径不得引用 `go_to_order_confirm()` 与 `submit_order()`,必须有测试
|
||||
证明它们不可达。
|
||||
T-103 的 `SkuSelectionFlow` 只允许证据绑定的受控入口、精确选择、读价和安全退出;不得引用
|
||||
数量、确认页、提交围栏、提交或付款能力。T-105~T-107 分别取证后才可组合。生产单趟只有在全部
|
||||
能力均获证据、三道闸门通过且服务端围栏明确许可后才能点击一次;支付能力永不开放。
|
||||
|
||||
## 当前阶段
|
||||
|
||||
**Phase 0 · 地基。** 采购服务与采购工具骨架均已初始化,尚无采购业务代码。
|
||||
**Phase 1 · 真机可行性,并行启动采购服务基础能力。** 两端骨架与核心数据模型已完成,T-103
|
||||
仍是当前真机关键路径;与真机可读字段无关的采购服务能力不再空等。
|
||||
|
||||
执行按任务依赖驱动,**不按 Phase 整段串行等待**。当前优先路径:
|
||||
|
||||
1. T-003 统一入口与 T-004 核心数据模型已完成;T-101 真机环境盘点已就绪,但尚未真机验收。
|
||||
2. T-002 已完成,立即推进 **T-101 → T-102 → T-103 真机取证**。
|
||||
3. T-103 结论确认后,才开始依赖真机可读字段的 Phase 2 生产页面;采购服务核心与
|
||||
T-104 → T-107 后续真机安全判据按依赖并行推进。
|
||||
4. Phase 3:双端打通与**第一趟试选**端到端。
|
||||
5. Phase 4:**第二趟下单**与收尾。
|
||||
1. T-111 先冻结“开始采购授权”的单趟契约;同时主审并合入只创建 `DRAFT` 的 T-202。
|
||||
2. T-111 完成后恢复 **T-103 真机取证**;按依赖推进 T-104 → T-107 的分段安全判据。
|
||||
3. admin agent 先做 T-209 把旧两趟 schema / 状态机迁到单趟,再推进 T-203 服务端批量开始采购 /
|
||||
一次性授权;两者不依赖页面选择器。T-205 起的 attempt 与证据字段等待 T-103。
|
||||
4. Phase 3:双端打通与单趟围栏前 dry-run。
|
||||
5. Phase 4:单趟真实提交、待付款、调和与收尾。
|
||||
6. V2 及以后:图搜、Excel、ERP、订单自动核对、AI 辅助。
|
||||
|
||||
> **M2 是本项目的生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、
|
||||
> **读到该 SKU 的单价**(T-103)。前序项目正是卡在选规格和读价。
|
||||
> M2 不通过之前不要写 Phase 2 的生产页面;Phase 0 原型只确认流程与信息架构,实际可读
|
||||
> 字段仍以真机证据为准。
|
||||
> M2 不通过之前不要写**依赖真机可读字段的生产执行逻辑**;管理会话、`DRAFT` 建单和只锁定
|
||||
> 已有任务字段的服务端授权事务可以并行。实际可读字段仍以真机证据为准。
|
||||
|
||||
## 领取任务规则
|
||||
|
||||
@@ -121,12 +119,11 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
|
||||
只做:
|
||||
|
||||
- **手工填链接**建单、任务查询、勾选待开始任务后批量开始第一趟试选
|
||||
- 桌面端**定时轮询**领取(试选与下单两类)
|
||||
- 第一趟试选:精确勾选规格、读单价、截图、退出释放手机
|
||||
- 人工确认「机器选对了吗」并签发授权(**锁定单价**)
|
||||
- 授权超时与放弃,任务不会被永久锁死
|
||||
- 第二趟下单:三道闸门全过后提交订单一次
|
||||
- **手工填链接**建单、任务查询、勾选待开始任务后批量“开始采购”
|
||||
- 点击开始采购即签发锁定商品、规格、数量和最高总价的一次性授权
|
||||
- 桌面端定时轮询只领取已授权任务,在同一趟精确选规格、两次读价、复核数量和确认页金额
|
||||
- 围栏前失败/过期可人工回到待开始;围栏后只能调和
|
||||
- 三道闸门全过、服务端围栏明确许可后提交订单一次
|
||||
- 真实点击前服务端原子建立提交围栏;围栏失败不点击,围栏后只调和同一提交记录
|
||||
- 待付款展示订单截图,人核对付款后手工标记完成
|
||||
- 失败分类与转人工
|
||||
@@ -166,7 +163,7 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
| 采购服务页面 | `02-requirements.md` 验收 → `07-user-stories.md` → `08-interaction-checklist.md` → `routes.md` → `04-architecture.md` |
|
||||
| 采购工具界面 | 同上,`routes.md` 看第三节桌面端结构 |
|
||||
| 设备侧 API | `api.md` → `04-architecture.md` 数据模型与鉴权边界 |
|
||||
| 真机自动化 | `04-architecture.md` 第三节两趟执行与三道闸门 → 第四节边界 → `api.md` 第三节模块合约 → **先真机取证** |
|
||||
| 真机自动化 | `04-architecture.md` 第三节单趟执行与三道闸门 → 第四节边界 → `api.md` 第三节模块合约 → **先真机取证** |
|
||||
| 数据模型 | `04-architecture.md` 第五节;schema 变化必须同步 `api.md` 和 `current-state.md` |
|
||||
| 部署 / 运行 | `03-tech-stack.md` → `current-state.md` |
|
||||
|
||||
|
||||
+7
-6
@@ -5,15 +5,15 @@
|
||||
cmbuyer 要解决:**采购人员为了履约一笔外部订单,必须手工去拼多多找到同款商品、选对
|
||||
颜色尺码、下单,再把订单号抄回系统——这个过程重复、易错、且无法追溯。**
|
||||
|
||||
> 让采购人员把「买什么」一次说清楚,由系统驱动手机完成找货、选规格和下单,人只在
|
||||
> 两个关键点介入:**挑哪一个**和**付不付款**。
|
||||
> 让采购人员把“买什么、买多少、最多多少钱”一次说清楚,并明确点击开始采购;系统驱动手机
|
||||
> 精确选规格并创建待付款订单,**付不付款始终由人决定**。
|
||||
|
||||
它不是无人值守的抢购脚本,也不是绕过平台规则的爬虫。它是一个**带人工闸门的采购执行
|
||||
工具**:机器负责重复劳动,人保留花钱的决定权。
|
||||
|
||||
## 二、目标用户
|
||||
|
||||
- **采购管理员**:在网页端建单、复核候选商品、签发下单授权、查看执行证据。
|
||||
- **采购管理员**:在网页端建单、点击开始采购签发一次性授权、查看执行证据。
|
||||
- **采购执行员**:在桌面端连接手机、启动批次、处理需要人工接管的任务、完成付款。
|
||||
- **ERP 对接身份**:只读同步第三方系统的货运单与商品明细,不参与采购决策。
|
||||
- **系统管理员(后续)**:人员、设备、权限和审计策略管理,MVP 不提供完整界面。
|
||||
@@ -27,7 +27,8 @@ cmbuyer 要解决:**采购人员为了履约一笔外部订单,必须手工
|
||||
绝不猜测。宁可少做,不可做错。
|
||||
- **先走确定路径**:MVP 只做已知商品链接的情形。图片搜索推到 V2,且届时其唯一职责
|
||||
是产出 `goods_id`,不在搜索结果页上做价格或规格判断。
|
||||
- **人站在不可逆动作正前方**:机器先试选并回传,人看过之后才授权下单。
|
||||
- **授权点明确且前置**:创建任务不授权;管理员点击“开始采购”才允许创建一笔待付款订单。
|
||||
- **实时闸门不依赖旧截图**:授权后同一设备会话两次读取规格面板价格,并在确认页校验总额。
|
||||
- **价格只在可靠位置读**:规格面板和订单确认页。别处的数字一律不信。
|
||||
- **失败要可诊断**:不能只返回「失败」,必须有步骤、错误码、截图和页面快照。
|
||||
- **证据分层**:人工决策需要的证据上传服务端,完整执行轨迹留在桌面端本地。
|
||||
@@ -37,9 +38,9 @@ cmbuyer 要解决:**采购人员为了履约一笔外部订单,必须手工
|
||||
| 价值点 | 用户得到什么 |
|
||||
| --- | --- |
|
||||
| 消除重复劳动 | 不再逐条手工搜索、选规格、抄订单号 |
|
||||
| 决策集中可控 | 所有「买哪个」的决定收敛到网页端一处,有据可查 |
|
||||
| 决策集中可控 | 商品、规格、数量、最高总价和开始采购授权收敛到网页端,有据可查 |
|
||||
| 资金边界清晰 | 系统能下单不能付款,误操作不会直接造成损失 |
|
||||
| 执行可追溯 | 每笔采购留下候选、截图、授权理由和订单核对记录 |
|
||||
| 执行可追溯 | 每笔采购留下授权、三道闸门、截图、围栏和订单核对记录 |
|
||||
| 批量顺序执行 | 一次导入多条,按顺序跑,遇到问题停在该停的地方 |
|
||||
|
||||
## 五、不做什么(非目标)
|
||||
|
||||
+107
-122
@@ -1,50 +1,48 @@
|
||||
# 需求
|
||||
|
||||
> 本文只描述**要什么**与**怎么算达成**,用产品 / 用户语言表达,**不涉及技术实现**。
|
||||
> 技术方案、数据结构、字段定义见 [架构设计](04-architecture.md)。
|
||||
> 本文只描述**要什么**与**怎么算达成**,用产品 / 用户语言表达。技术方案、数据结构与
|
||||
> 字段定义见[架构设计](04-architecture.md)。
|
||||
|
||||
## 一、业务现状
|
||||
|
||||
| 项 | 状态 |
|
||||
| --- | --- |
|
||||
| 用户 | 采购人员为履约外部订单,逐条手工在拼多多找同款、选颜色尺码、下单、抄回订单号 |
|
||||
| 任务来源 | 第三方 ERP(顺运宝)货运单与商品明细为主;Excel 批量导入和手工填链接为补充 |
|
||||
| 数据 | ERP 提供商品标题、规格、数量、参考图;部分明细带拼多多链接,部分只有图 |
|
||||
| 现有系统 | 两个前序原型(见 [愿景](01-vision.md) 第六节),代码不直接复用 |
|
||||
| MVP 任务来源 | 管理员手工填写拼多多商品链接、规格、数量和金额上限 |
|
||||
| 后续任务来源 | Excel 与第三方 ERP(顺运宝)货运明细 |
|
||||
| 设备 | 一台 Windows 电脑 + 一台已登录拼多多的 Android 手机,USB 或 WiFi ADB 连接 |
|
||||
| 约束 | 不得绕过平台风控;付款必须人工;手机需保持亮屏解锁 |
|
||||
| 约束 | 不绕过平台风控;系统只创建待付款订单;付款必须由人完成;手机保持亮屏解锁 |
|
||||
|
||||
## 二、用户角色
|
||||
|
||||
- **采购管理员**:在网页端建单、查询、复核候选、签发下单授权、查看审计记录。
|
||||
- **采购执行员**:在桌面端连接设备、启动批次、处理待人工任务、在拼多多完成付款。
|
||||
- **采购管理员**:在采购服务建单、查询、点击“开始采购”签发一次性采购授权、查看审计记录。
|
||||
- **采购执行员**:在采购工具连接设备、启动轮询、处理待人工任务、在拼多多完成人工付款。
|
||||
- **ERP 对接身份**:只读同步货运单与商品明细,不能建单、授权或访问采购结果。
|
||||
- **设备身份**:一台已授权桌面端实例,用于领取任务和回传结果,不能建单或授权。
|
||||
- **设备身份**:一台已授权采购工具实例,用于领取已授权任务和回传结果,不能建单或授权。
|
||||
- **未登录用户**:不能访问任何任务、图片、证据或设备接口。
|
||||
|
||||
## 三、功能清单
|
||||
|
||||
### 第一版 MVP(最小闭环)
|
||||
|
||||
MVP 只做**任务自带商品链接**的情形,分两趟执行:第一趟试选并回传,人确认后第二趟下单。
|
||||
流程见[架构设计](04-architecture.md)第三节。
|
||||
MVP 只做**任务自带商品链接**的情形。管理员点击“开始采购”后,采购工具在同一次设备会话中
|
||||
完成选规格、校验价格与数量、创建待付款订单;中间不再回到采购服务等待“机器选对了吗”。
|
||||
|
||||
| ID | 功能 | 用户能做什么 | 优先级 | 关联用户故事 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| F-001 | 手工建单 | 管理员在任务列表内填写任务名称、拼多多链接、颜色分类、尺码、数量、价格上限,保存一条待开始任务 | P0 | US-001 |
|
||||
| F-004 | 任务查询 | 管理员按关键词、状态、时间范围找到目标任务 | P0 | US-002 |
|
||||
| F-005 | 桌面端定时领取 | 执行员启动会话后,桌面端定时轮询领取待试选和已授权两类任务 | P0 | US-003 |
|
||||
| F-006 | 第一趟试选 | 系统打开商品、按维度精确勾选颜色分类和尺码、读单价、截图后退出 | P0 | US-003 |
|
||||
| F-007 | 试选结果回传 | 管理员看到机器实际选中的规格、单价、合计和规格面板截图 | P0 | US-004 |
|
||||
| F-008 | 人工确认与授权 | 管理员确认机器选对了,签发一次性授权并锁定价格;或退回不买 | P0 | US-004、US-005 |
|
||||
| F-009 | 第二趟下单 | 系统重新选同一规格、过三道价格闸门后提交订单,回传订单截图 | P0 | US-005 |
|
||||
| F-010 | 授权超时与放弃 | 管理员在授权卡住时能放弃并重新确认,任务不会被永久锁死 | P0 | US-005 |
|
||||
| F-011 | 结果与失败分类回传 | 管理员看到成功、失败、已取消、待人工,以及可区分的失败原因和证据 | P0 | US-002、US-008 |
|
||||
| F-013 | 登录与身份隔离 | 管理员用账号登录网页端;桌面端用设备凭据接入,两者权限不互通 | P0 | US-007 |
|
||||
| F-017 | 下单 dry-run、提交围栏与调和 | 真机先只读演练到订单确认页;真实点击前由服务端原子冻结授权;点击后只调和结果、绝不重试 | P0 | US-005、US-008 |
|
||||
| F-018 | 批量开始试选 | 管理员勾选一条或多条待开始任务,统一放入第一趟试选队列 | P0 | US-010 |
|
||||
| F-001 | 手工建单 | 填写标题、拼多多链接、颜色分类、尺码、数量、最高总价,保存为待开始任务 | P0 | US-001 |
|
||||
| F-004 | 任务查询 | 按标题关键词、状态、时间范围找到任务,双击行查看详情 | P0 | US-002 |
|
||||
| F-005 | 桌面端定时领取 | 执行员启动会话后定时领取已获采购授权的任务 | P0 | US-003 |
|
||||
| F-006 | 单趟采购执行 | 打开商品、精确选择规格、校验价格、设置数量、进入确认页并创建待付款订单 | P0 | US-003、US-005 |
|
||||
| F-007 | 执行证据回传 | 管理员查看实际规格、三道闸门、原始页面截图和步骤记录 | P0 | US-002、US-008 |
|
||||
| F-008 | 开始采购授权 | 管理员批量点击一次,原子锁定每条任务并签发一次性待付款订单授权 | P0 | US-005、US-010 |
|
||||
| F-010 | 围栏前过期与重新授权 | 未建立提交围栏的失败或过期任务回到待开始,可由管理员重新点击开始采购 | P0 | US-005 |
|
||||
| F-011 | 结果与失败分类回传 | 查看成功、失败、已取消、待人工及可诊断原因和证据 | P0 | US-002、US-008 |
|
||||
| F-013 | 登录与身份隔离 | 管理员用账号登录;采购工具用设备凭据接入,权限不互通 | P0 | US-007 |
|
||||
| F-017 | 提交围栏与调和 | 真机点击前服务端原子冻结授权;点击后只调和同一提交,绝不重试 | P0 | US-005、US-008 |
|
||||
| F-018 | 批量开始采购 | 勾选一条或多条待开始任务,统一授权采购工具创建待付款订单 | P0 | US-010 |
|
||||
|
||||
> F-002、F-003、F-012 已移出 MVP,编号保留不重用,见下表。
|
||||
> 原 F-009 的“两趟下单”并入 F-006;F-002、F-003、F-012 移出 MVP,编号不重用。
|
||||
|
||||
### 后续迭代
|
||||
|
||||
@@ -52,141 +50,128 @@ MVP 只做**任务自带商品链接**的情形,分两趟执行:第一趟试
|
||||
| --- | --- | --- |
|
||||
| F-002 Excel 批量建单 | 上传固定表头表格批量生成任务 | V2,被表头契约待确认阻塞 |
|
||||
| F-003 从 ERP 货运明细建单 | 同步顺运宝货运单后复核生成任务 | V2,被字段映射待确认阻塞 |
|
||||
| F-012 批量顺序编排与人工接管 | 对已开始任务排序、暂停继续、运行中接管 | V2;MVP 仅支持批量开始试选,实际执行仍由定时轮询领取 |
|
||||
| F-014 图片搜索路径(B 路径) | 任务只有参考图时搜图产出 goods_id 候选 | V2 |
|
||||
| F-012 批量编排与运行中接管 | 排序、暂停继续、运行中接管 | V2;MVP 只批量授权,设备逐条领取 |
|
||||
| F-014 图片搜索路径 | 任务只有参考图时搜图产出 goods_id 候选 | V2 |
|
||||
| F-015 候选对照台 | 多个候选并排对照挑选 | V2,随 F-014 |
|
||||
| F-016 订单自动核对回读 | 付款后只读读取订单页做五项唯一匹配并回写 | V2;MVP 用截图 + 人眼核对 |
|
||||
| F-016 订单自动核对回读 | 付款后只读读取订单页做唯一匹配并回写 | V2;MVP 用截图 + 人眼核对 |
|
||||
| F-101 AI 辅助候选判断 | 用模型看搜索结果页截图判断同款 | V2 之后 |
|
||||
| F-102 AI 辅助页面理解 | 规则读不到规格或价格时用模型兜底 | V2 之后 |
|
||||
| F-103 执行轨迹本地留档 | 记录模型输入输出与规则判断的分歧 | V2 之后 |
|
||||
| F-104 多设备并行 | 一个桌面端驱动多台手机 | V2 之后 |
|
||||
| F-103 执行轨迹本地留档 | 记录模型输入输出与规则判断分歧 | V2 之后 |
|
||||
| F-104 多设备并行 | 一个采购工具驱动多台手机 | V2 之后 |
|
||||
| F-105 完整 RBAC | 管理员、执行员、审核员细粒度权限 | V2 之后 |
|
||||
| F-106 多平台比价 | 淘宝、1688、京东 | V3 |
|
||||
| 支付自动化 | **不在规划内** | 未规划 |
|
||||
|
||||
## 四、核心用户故事(MVP)
|
||||
|
||||
详细故事以[用户故事清单](07-user-stories.md)为准;本文只维护功能、优先级与 US 编号的
|
||||
索引,避免两处成为相互冲突的权威来源。
|
||||
详细故事以[用户故事清单](07-user-stories.md)为准。
|
||||
|
||||
| 功能 | 用户故事 | 优先级 |
|
||||
| --- | --- | --- |
|
||||
| F-001 | US-001 | P0 |
|
||||
| F-004、F-011 | US-002 | P0 |
|
||||
| F-004、F-007、F-011 | US-002 | P0 |
|
||||
| F-005、F-006 | US-003 | P0 |
|
||||
| F-007、F-008 | US-004 | P0 |
|
||||
| F-008、F-009、F-010、F-017 | US-005 | P0 |
|
||||
| F-008、F-010、F-017 | US-005 | P0 |
|
||||
| F-013 | US-007 | P0 |
|
||||
| F-011 | US-008 | P0 |
|
||||
| F-018 | US-010 | P0 |
|
||||
| F-007、F-011、F-017 | US-008 | P0 |
|
||||
| F-008、F-018 | US-010 | P0 |
|
||||
|
||||
US-006(付款前核对)在 MVP 降级为:系统展示订单截图与授权信息,人在拼多多自行核对
|
||||
后付款并手工标记完成。自动回读核对是 F-016(V2)。US-009(ERP 建单)随 F-003 移出 MVP。
|
||||
US-004 的试选后人工确认已从 MVP 删除。US-006 在 MVP 为:系统展示订单截图与授权信息,
|
||||
人在拼多多核对、付款并手工标记完成;自动回读核对是 F-016(V2)。
|
||||
|
||||
## 五、验收标准(MVP)
|
||||
|
||||
### 建单
|
||||
### 建单与授权
|
||||
|
||||
- **F-001**:在任务列表点击创建,填入任务名称、合法拼多多链接、颜色分类、尺码、数量、
|
||||
价格上限后保存,返回任务编号;弹窗关闭,任务按创建时间倒序出现在第一行且状态为待开始。
|
||||
链接格式非法或无法解析出 `goods_id` 时明确报错并保留已填内容。
|
||||
- **F-018**:列表只允许勾选待开始任务;全选只覆盖当前筛选结果中可开始的任务。点击
|
||||
「开始试选」后,被选任务原子转为待领取,并明确提示这只是进入第一趟试选队列,
|
||||
**不签发下单授权、不创建订单、不付款**。任一任务状态已变化时整批不提交,刷新后重选。
|
||||
- **F-001**:任务列表点击创建,填入标题、合法拼多多链接、颜色分类、尺码、数量和最高总价后
|
||||
保存,弹窗关闭;新任务按创建时间倒序出现在第一行,状态为 `DRAFT`。非法链接或无法解析
|
||||
`goods_id` 时明确报错并保留输入。
|
||||
- 创建任务**不构成采购授权**,设备不能领取 `DRAFT`。
|
||||
- **F-018**:只允许勾选当前筛选结果中的 `DRAFT`。点击“开始采购(只创建待付款订单)”前,
|
||||
界面显示已选数量和最高总额;点击本身即管理员明确授权,不再增加选品确认步骤。
|
||||
- 服务端在一个事务中校验整批任务仍为预期版本,逐条锁定 `goods_id`、颜色、尺码、数量、最高
|
||||
总价、授权人和有效期,并把任务转 `PENDING`。任一条冲突时整批不变,刷新后重选。
|
||||
- 相同 `start_key` 重放返回同一结果;不得产生第二份有效授权。成功后即时提示“已授权 N 条,
|
||||
采购工具只会创建待付款订单,不会付款”。
|
||||
|
||||
### 第一趟:试选
|
||||
### 单趟采购执行
|
||||
|
||||
- **F-005**:执行员启动会话后桌面端定时轮询,同时领取待试选与已授权两类任务。
|
||||
两个实例并发领取同一条时只有一个成功,另一个得到明确的「无可领任务」而不是报错。
|
||||
**关闭会话即停止轮询;连续失败达到阈值自动停止并提示原因。**
|
||||
- **F-006**:手机打开对应商品详情页,打开规格面板,按维度精确匹配颜色分类和尺码。
|
||||
**任一维度找不到精确值即停止并转人工,不选相近选项。** 勾选后读取该 SKU 单价
|
||||
(闸门一),读不到即转人工,**不用商品详情页正文或搜索页的数字凑合**。
|
||||
- **F-006 释放要求**:试选完成后**必须退出商品页释放手机**,不得停在规格面板等待人工。
|
||||
- **F-006 硬边界**:试选阶段**绝不点击「现在买」或任何进入下单流程的入口**。
|
||||
必须有测试证明该路径不调用任何下单语义动作。
|
||||
- **F-007**:回传商品标题、实际勾选到的颜色分类与尺码、单价、合计(单价 × 数量)和
|
||||
规格面板截图;任务转「等你确认」。
|
||||
- **F-005**:采购工具只领取 `PENDING` 且授权有效的任务。并发领取同一条时只有一个成功;关闭
|
||||
会话即停止轮询,连续失败达到阈值自动停止并显示原因。
|
||||
- **F-006 受控入口**:只点击与本项目真机证据及 App 版本绑定的精确唯一规格面板入口。当前仅确认
|
||||
拼多多 `8.17.0`、goods_id `937122477375` 的“快要抢光”;其他入口必须分别取证,不按购买语义泛化。
|
||||
- **F-006 规格与数量**:颜色分类、尺码必须在各自维度内精确唯一匹配并读回;数量设置后必须读回
|
||||
等于任务值。缺失、重复、禁用、前缀碰撞、读回不一致都停止,不选相近项。
|
||||
- **F-006 三道价格闸门**:价格只从规格面板和订单确认页读取,金额使用十进制字符串。
|
||||
1. 选中目标规格后读取当前 SKU 单价,计算 `单价 × 数量`,不得超过授权最高总价。
|
||||
2. 设置并复核数量后再次读取同一规格与单价;规格必须仍相同,单价必须与闸门一完全相等。
|
||||
3. 订单确认页核对规格、数量与应付总额;总额不得超过授权最高总价。
|
||||
任一道读不到、有歧义或不通过都停止并转人工,不从详情页、搜索页或按钮文字凑价格。
|
||||
- 同一设备会话连续完成上述步骤;不退出商品再等采购服务确认,也不把旧截图或旧页面读数当作
|
||||
当前提交依据。
|
||||
|
||||
### 决策与资金
|
||||
### 提交围栏与资金边界
|
||||
|
||||
- **F-008**:管理员在确认页看到需求与机器所选的对照、单价、合计和截图,点击确认后
|
||||
签发一次性授权,**授权锁定当次试选的单价**。授权重复提交幂等,不产生第二笔订单。
|
||||
管理员也可选择「退回,不买」,任务终止。
|
||||
- **F-009 三道闸门**:第二趟重新打开商品并重新勾选同一规格后,
|
||||
① 重读单价必须与授权锁定价一致;
|
||||
② 数量设置后读回必须精确等于要求值;
|
||||
③ 订单确认页「实付款」不得超过授权总额上限。
|
||||
**任一道不通过即停止并转人工。**
|
||||
- **F-009 提交条件**:授权存在且未消费、闸门全过、「提交订单」控件文本精确相等且可
|
||||
点击祖先唯一——四者同时满足,且服务端已原子建立提交围栏,才允许**点击一次**。点击后
|
||||
无论超时、跳外部支付还是遇到安全校验,**一律进入结果调和或人工核查且禁止重试**。
|
||||
- **F-010**:授权带过期时间。建立提交围栏前,超时或主动放弃都会作废授权并让任务进入
|
||||
「待重新试选」,不得直接复用旧试选再次确认;建立提交围栏后,授权不得过期或放弃,
|
||||
只能调和结果或转人工核查。**任何状态都必须给出安全且可执行的下一步。**
|
||||
- **F-017 dry-run**:首次真实下单前必须先完成一次只读演练:到订单确认页读取规格、数量、
|
||||
「实付款」,验证提交控件唯一,然后停止并退出;**不点击「提交订单」**。演练结果与证据
|
||||
必须回传,真实提交不得把旧演练当成当前页面事实。
|
||||
- **F-017 提交围栏**:desk 端在真实点击前向 web 端申请提交围栏;web 端必须在一个原子
|
||||
事务中复核任务版本、授权未消费、命令与演练关联正确,然后冻结授权并生成唯一
|
||||
`order_submission`。申请失败或响应不明确时不得点击。围栏成功后只能点击一次;点击结果
|
||||
不明确时保留金额额度并进入调和,不能重新申请或重新点击。
|
||||
- **资金硬边界**:系统在任何路径下都不点击支付、免密支付、先用后付或任何扣款控件。
|
||||
必须有测试证明提交订单之后不调用任何支付动作。
|
||||
- **F-017**:闸门二、闸门三和提交控件唯一均通过后,采购工具把当前验证摘要发给采购服务并申请
|
||||
提交围栏。服务端原子复核任务版本、一次性授权未消费、领取身份、规格、数量、闸门值和唯一性,
|
||||
创建或重放唯一 `order_submission`,才返回 `click_permitted=true`。
|
||||
- 围栏申请失败、冲突、超时、断网或响应不明确时**不得点击**。围栏成功后只可精确点击一次
|
||||
“提交订单”;点击后无论结果、超时、外部支付交接或安全校验都禁止重试。
|
||||
- 明确创建订单后任务转 `WAITING_PAYMENT`;结果不明确转 `RECONCILIATION_REQUIRED`,授权和围栏
|
||||
保持占用,只能调和同一提交记录。
|
||||
- 围栏建立前,授权过期、设备失败或人工放弃可使任务回到 `DRAFT`;必须由管理员重新点击“开始采购”
|
||||
产生新授权。围栏建立后不得释放、重新授权、重新领取或再次点击。
|
||||
- 系统任何路径都不点击支付、免密支付、先用后付或扣款控件;提交订单后的代码路径不含支付动作。
|
||||
- 发布真实下单能力前必须用独立真机任务完成只读 dry-run:到订单确认页验证规格、数量、金额和提交
|
||||
控件唯一但不点击。dry-run 是版本发布门禁,不是每笔订单的第一趟,也不能代替实时三道闸门。
|
||||
|
||||
### 结果与异常
|
||||
### 结果、证据与异常
|
||||
|
||||
- **F-011**:失败必须可区分至少这些原因:设备未连接、商品页打不开、规格面板打不开、
|
||||
规格不匹配、单价读不到、单价与授权价不符、数量设置失败、金额超上限、提交控件不唯一、
|
||||
页面识别失败、安全校验、外部支付交接、超时。每种都保留截图和页面快照。
|
||||
- **版本失配**:运行时读取到的拼多多 App 版本与当前已取证版本不一致时,桌面端必须停止
|
||||
领取真机任务并提示重新取证;不得继续使用旧页面判据。
|
||||
- **付款收口(MVP 简化版)**:订单创建后任务转「待付款」,页面展示订单截图、商品、
|
||||
规格、数量和授权金额供人核对。**人在拼多多付款后手工标记完成。** 自动回读核对是
|
||||
F-016(V2)。
|
||||
- **F-007**:按步骤回传实际规格、两次规格面板价格、数量读回、确认页总额、判定、时间和截图。
|
||||
截图是内部审计证据,不是提交前审批前置。
|
||||
- 内部系统允许上传规格面板与订单确认页的原始截图,截图中可保留页面已显示的地址和手机号;只允许
|
||||
已认证设备显式上传、已登录管理员查看。完整 XML 只留采购工具本机,不上传;地址/手机号不解析为
|
||||
业务字段或日志;外部支付页、支付凭据永不上传。
|
||||
- **F-011**:至少区分设备未连接、版本失配、商品打不开、面板打不开、规格不匹配、价格不可读、
|
||||
两次价格不一致、数量复核失败、金额超上限、确认页不一致、提交控件不唯一、围栏失败、页面识别
|
||||
失败、安全校验、外部支付交接和超时。
|
||||
- 运行时拼多多 App 版本与已取证版本不一致时停止领取真机任务,提示重新取证。
|
||||
- 订单明确创建后展示截图、商品、规格、数量和授权金额;人在拼多多付款后手工标记完成。
|
||||
**待付款不等于采购完成。**
|
||||
|
||||
### 通用
|
||||
|
||||
- 每条 P0 判据关联至少一个 US 编号;有用户界面的判据同时关联相关 IX 编号。
|
||||
- 任务终态一次原子回写,中间态不落盘产生「看起来在跑其实已死」的记录。
|
||||
- 每条 P0 判据关联至少一个 US;有用户界面的判据同时关联 IX。
|
||||
- 状态流转与提交记录原子落盘,不产生“界面在跑但服务端无记录”的悬空事实。
|
||||
- 失败页面给出可执行下一步;不会把“重试”显示在已建立围栏的提交上。
|
||||
|
||||
## 六、范围边界与决策
|
||||
|
||||
| 问题 | 决策 |
|
||||
| --- | --- |
|
||||
| 第一版平台 | 网页端(管理)+ Windows 桌面端(执行),驱动一台 Android 手机 |
|
||||
| 是否需要账号 | 是。管理员账号 + 桌面端设备凭据,两套身份分离 |
|
||||
| 第一版范围 | 手工建单 → 批量开始试选 → 定时领取 → **第一趟试选** → 人工确认 → dry-run / 提交围栏 → **第二趟下单** → 待付款 |
|
||||
| 任务来源 | **仅手工填链接。** Excel 与 ERP 移出 MVP |
|
||||
| 找货方式 | **仅按链接。** 图片搜索移出 MVP |
|
||||
| 第一版平台 | 采购服务(网页端)+ 采购工具(Windows 桌面端)+ 一台 Android 手机 |
|
||||
| 身份 | 管理员账号 + 设备凭据,两套身份分离 |
|
||||
| 第一版流程 | 手工建单 → 批量开始采购并授权 → 定时领取 → 单趟选规格 / 三闸门 / 围栏 / 提交一次 → 待付款 |
|
||||
| 人工授权点 | 管理员点击“开始采购(只创建待付款订单)”;不再有试选后确认 |
|
||||
| 任务来源 | 仅手工填链接;Excel 与 ERP 移出 MVP |
|
||||
| 找货方式 | 仅按链接;图片搜索移出 MVP |
|
||||
| 采购平台 | 仅拼多多 |
|
||||
| 领取方式 | 定时轮询,只在执行员启动的会话内运行 |
|
||||
| 任务启动 | 创建后为待开始;管理员在列表勾选一条或多条并统一开始第一趟试选 |
|
||||
| 设备连接 | ADB over USB 或 WiFi 均支持;同一台手机不得同时以两种方式在线 |
|
||||
| 付款收口 | 系统展示订单截图,人核对后付款并手工标记完成 |
|
||||
| 设备连接 | ADB over USB 或 WiFi;同一手机不得以两种方式同时在线 |
|
||||
| 付款收口 | 系统展示待付款订单证据,人核对、付款并手工标记完成 |
|
||||
| 暂不支持 | 自动付款、图搜、Excel、ERP、多设备并行、多平台、退款、审批链、AI 辅助 |
|
||||
|
||||
## 七、待确认 / 风险点
|
||||
|
||||
- **第三方平台风险**:拼多多 App 版本更新会改变页面结构。前序项目已观察到详情页
|
||||
没有独立规格入口、价格文本被拆成多个节点等变化。**每次页面结构判据都必须有真机
|
||||
证据,不得从旧版本推断。**
|
||||
- **资金风险**:涉及创建真实待付款订单。授权、金额上限、一次性围栏的规则由采购管理员
|
||||
确认;付款始终人工。**下单动作在真机上第一次验证前,必须先取得授权。**
|
||||
- **账号风险**:手机上是真实拼多多账号。频繁自动化操作有被风控或封号的可能,需要
|
||||
可配置的动作节奏,并在检测到安全校验时立即停止。
|
||||
- **自动化边界风险**:会自动点击并创建订单,属不可逆操作。必须支持 dry-run(跑到
|
||||
订单确认页停止)、服务端提交围栏和点击后调和;任一环节状态不明都不得继续点击。
|
||||
- **隐私风险**:订单确认页含收货地址和掩码手机号。**只读取非敏感摘要,不提取地址
|
||||
原文、手机号或支付凭据**;上传服务端的证据需先脱敏。
|
||||
- **规格面板上的单价位置未取证(阻塞 F-006 闸门一)**:选中 SKU 后价格显示在哪个节点、
|
||||
是否带「券后」前缀、是否与原价并列,尚无本项目的真机证据。**T-103 必须一并取证。**
|
||||
若规格面板上无法可靠读到单价,闸门一要改为「只截图不判价」,确认页设计随之调整。
|
||||
- **两趟之间的状态漂移**:第二趟重新进入时价格可能已变、规格选项可能已改、商品可能
|
||||
下架。闸门二负责拦截,一律转人工——但这意味着价格波动频繁的类目会产生大量待人工。
|
||||
需真机观察实际发生率。
|
||||
- **定时轮询的节奏**:固定间隔的机器节奏比人工节奏更容易被风控识别。间隔需可配置,
|
||||
并在检测到安全校验时立即停止轮询。具体间隔待真机观察后确定。
|
||||
- **待确认(阻塞 F-002,已移出 MVP)**:Excel 表头契约的最终字段集与列名。
|
||||
- **待确认(阻塞 F-003,已移出 MVP)**:ERP 货运明细到任务颜色 / 尺码的字段映射。
|
||||
- **待确认(V2 之后)**:AI 辅助的模型供应商、调用预算与失败降级策略。
|
||||
- **第三方平台风险**:拼多多更新会改变页面结构。页面判据必须来自本项目真机证据,并绑定 App
|
||||
版本;不能从前序项目或旧版本推断。
|
||||
- **资金风险**:管理员点击开始采购会授权创建真实待付款订单。按钮必须明确影响范围与最高总额,
|
||||
服务端一次性授权、提交围栏和点击一次共同防止重复订单;付款始终人工。
|
||||
- **单趟时效风险**:授权到实际执行之间价格可能变化。任务不锁定旧观察价,而是在同一趟内两次读取
|
||||
规格面板价格并在确认页校验总额;任何变化或歧义都停止。
|
||||
- **账号与风控风险**:固定机器节奏可能触发平台风控。轮询和动作间隔需可配置,检测到验证码、短信、
|
||||
人脸或风险校验立即停止,不绕过。
|
||||
- **隐私风险**:内部原始截图可能含地址和手机号。只允许受控内部链路;不提取结构化隐私字段,完整
|
||||
XML 不上传,支付凭据与外部支付页截图禁止进入系统。
|
||||
- **首次真实提交风险**:生产提交能力启用前需独立 dry-run 和明确真机授权;点击后结果不明时只能调和。
|
||||
- **待确认(移出 MVP)**:Excel 表头、ERP 字段映射、AI 模型与预算。
|
||||
|
||||
+99
-2
@@ -43,7 +43,7 @@
|
||||
| 调用位置 | 采购工具 | 已定 | PC 有算力;改 prompt 不需要重新打包 |
|
||||
| provider | 待定 | **待定** | 需先确认预算与合规;不得由 agent 自行选定 |
|
||||
| 凭据存储 | 采购工具本机配置文件,不入库、不上传 | 已定 | 采购服务不保存、不代理、不下发任何模型凭据 |
|
||||
| 输入 | 完整节点树 XML + 页面截图 | 已定 | `dump_hierarchy(compressed=False)` 不丢节点 |
|
||||
| 输入 | 待 V2 单独定义 | 待定 | MVP 不调用 AI;内部原始截图许可不自动扩大到外部模型 |
|
||||
|
||||
## 四、决策记录与演进
|
||||
|
||||
@@ -72,6 +72,102 @@
|
||||
| 测试 | `go test ./...` | `.\.venv\Scripts\python.exe -m unittest discover -s tests -t .` |
|
||||
| 静态检查 | `go vet ./...` | `.\.venv\Scripts\python.exe -m compileall -q src tests scripts` |
|
||||
|
||||
### T-101 设备基线取证(2026-08-04 已完成人工双通道验收)
|
||||
|
||||
`client/scripts/capture_device_baseline.py` 只允许对**手工明确填写**的 ADB serial 做连接前核验、
|
||||
设备型号 / Android / 拼多多版本读取、截图和 `dump_hierarchy(compressed=False)`。它不打开商品、
|
||||
不读取页面判据,也不执行采购、下单或付款动作。多个在线通道必须完成 `getprop` 物理身份比对:
|
||||
同一手机 USB + WiFi 同时在线,或任一在线通道的身份读取失败,都会 fail closed,不能随机继续。
|
||||
|
||||
人工验收前,先由人把手机切换到不含收货地址、手机号、支付信息或其他无关隐私的安全页面,再在
|
||||
`adb devices -l` 中**手工复制**一个在线 serial;USB 和 WiFi 分别验收,且每次只保留一个通道在线。
|
||||
WiFi 通道必须由人先行建立;脚本禁止 `adb connect`、`adb disconnect` 或自动重连。以下命令中的尖括号
|
||||
必须替换为该次人工确认的实际 serial,不能省略或改成自动选择:
|
||||
|
||||
```powershell
|
||||
# 仓库根目录;先手工确认设备状态,命令本身只读 ADB 清单
|
||||
D:\Portable\adb\adb.exe devices -l
|
||||
|
||||
# USB:粘贴该次 devices -l 显示的 USB serial
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_device_baseline.py --serial <USB_SERIAL> --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-101\usb-baseline" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
|
||||
# WiFi:由人先建立 WiFi ADB 通道、断开 USB 后,粘贴该次 devices -l 显示的 WiFi serial
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_device_baseline.py --serial <WIFI_SERIAL> --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-101\wifi-baseline" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
```
|
||||
|
||||
成功时输出目录仅包含截图、完整 XML 与不含页面正文的 `manifest.json`(设备元数据、通道、时间、
|
||||
文件 SHA-256 和 serial 哈希)。`--timeout` 约束 ADB 命令、ADB socket 及 `takeScreenshot` /
|
||||
`dumpWindowHierarchy(compressed=False, max_depth=50)` 的公开 JSON-RPC 调用;uiautomator2 初始化仍有
|
||||
上游固定启动上限。截图 Base64 仅兼容 RPC 返回值中的空格、TAB、CR、LF,其余字符
|
||||
仍严格拒绝。XML 仅留在本机明确指定的证据目录;人工必须先在本地检查截图/XML,再只记录路径
|
||||
和哈希,不得把原始证据提交 Git。USB 与 WiFi 已分别由人完成取证和隐私检查,设备型号、Android、
|
||||
拼多多版本、产物路径及 SHA-256 已记录到 T-101;上述命令保留用于可审计的复现与排障。
|
||||
|
||||
### T-102 按链接打开商品取证(2026-08-04 已完成人工真机验收)
|
||||
|
||||
`client/scripts/capture_product_open.py` 只接受
|
||||
`https://mobile.yangkeduo.com/goods.html?goods_id=<纯数字>` 的唯一 canonical 表示。解析后由
|
||||
`goods_id` 再次重建 URL,并以参数数组执行显式限定 `com.xunmeng.pinduoduo` 的 Android `VIEW`
|
||||
intent;没有任意 URL、任意 shell 或其他 App 控件操作入口。运行拼多多版本必须精确等于 T-101 已
|
||||
取证的 `8.17.0`,版本失配会在 intent 前停止。
|
||||
|
||||
```powershell
|
||||
# 仓库根目录;USB 或 WiFi 每次只保留一个通道在线,再手工复制该次 serial
|
||||
D:\Portable\adb\adb.exe devices -l
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_product_open.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-102\product-open-<GOODS_ID>" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
```
|
||||
|
||||
脚本在 intent 成功后,以 `--timeout` 为明确上限只读轮询前台 package;只有观察到拼多多才采集截图与
|
||||
完整 XML,超时仍 fail closed。这一等待只解决 App 异步切换,不根据 Activity、节点文本或旧项目常量
|
||||
声称已到详情页。成功目录以原子方式发布,manifest 仅记录 `goods_id`、canonical URL、
|
||||
设备/App 非敏感元数据、受限命令摘要、文件路径和 SHA-256,不含原始 serial、Activity 或页面正文。
|
||||
必须由人本地确认截图对应目标商品并检查截图/XML 无地址、手机号、支付信息或其他无关隐私;原始
|
||||
证据不得提交 Git。T-102 已由人确认 goods_id `958756616606` 对应目标商品并完成截图/XML 隐私检查,
|
||||
设备、版本、证据路径与 SHA-256 已记录到任务执行记录。
|
||||
|
||||
### T-103 规格面板三状态只读取证(T-110 边界调整后待重新验证)
|
||||
|
||||
`client/scripts/capture_sku_panel_spike.py` 只采集人已在手机上准备好的规格面板截图和完整 XML。
|
||||
它不打开链接或规格面板,不识别面板,不点击、滑动、输入或选择规格,也不读取价格;接口只暴露
|
||||
`app_info`、`app_current` 和两个只读 JSON-RPC 方法。`panel-opened-target-preselected`、
|
||||
`alternate-all-dimensions-selected`、`target-selection-restored` 三种值写入 manifest 的字段名是
|
||||
`human_declared_state`,明确表示人工声明,不得将其当作自动识别结果。旧的“未选 / 单维度 / 全选”
|
||||
状态假设已被真机事实推翻,旧枚举会被采集器和脱敏器明确拒绝。运行拼多多版本必须精确为 `8.17.0`,
|
||||
前台 package 必须是拼多多,否则 fail closed;目标目录已存在、截图/XML 无效或超时均不得覆盖已有内容
|
||||
或发布半成品。
|
||||
|
||||
T-103 已证明当前衣服商品没有独立「规格/已选」入口。T-110 只批准拼多多 `8.17.0`、goods_id
|
||||
`937122477375` 上经真机确认的精确唯一 `快要抢光` 作为受控规格面板入口;“免拼购买 / 单独购买 /
|
||||
直接拼成”等其他文案不能凭人工经验复用,必须分别重新取证。项目所有者已确认面板刚打开时目标颜色
|
||||
“黑色CHA(纯棉)”和尺码“M(建议100-115)”均已自动选中;取证不强行取消选择,而是记录刚打开状态、
|
||||
人工把两个维度都改成非目标值、再恢复目标值三个真实状态。只读取证 CLI 本身仍不执行点击;三种状态
|
||||
分别使用一个全新原始证据目录:
|
||||
|
||||
```powershell
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state panel-opened-target-preselected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-opened-target-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state alternate-all-dimensions-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-alternate-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state target-selection-restored --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
```
|
||||
|
||||
PDD 规格面板不可避免显示收货区域和手机号。项目所有者确认 cmbuyer 是内部系统,正式流程允许把
|
||||
页面原始截图上传采购服务供已登录管理员查看,不做遮罩或裁剪。完整 XML 仍只留在
|
||||
`%LOCALAPPDATA%\cmbuyer\artifacts\...\raw`;采购工具运行时在内存中读取当前页面树,但只返回规格、
|
||||
选中态、价格和页面状态摘要,不把地址或手机号解析为业务字段,也不写入日志、Git 或 Vikunja。
|
||||
|
||||
T-103 已完成 `t103-privacy-v5` 的一次真实派生:人确认 XML 无实际地址/手机号、目标颜色
|
||||
“黑色CHA(纯棉)”与尺码“M(建议100-115)”保留;安全节点显示顶部当前价“快卖完 ¥12.88”与原价
|
||||
“¥29.88”,底部“提交订单 ¥12.88”明确属于 T-103 隔离能力的硬拒绝区。派生截图的整宽黑色隐私带
|
||||
遮住了顶部价格文字的一半,这只说明旧脱敏实验不适合展示,不否定已确认的规格面板价格事实。
|
||||
|
||||
为加快 MVP,T-103 从此不再修改或调用截图遮罩器。现有 v5 派生 XML 只用于提取不含页面个人信息的
|
||||
最小 fixture;T-111 完成后的 `SkuSelectionFlow` 在本机实时页面树上按拼多多 8.17.0 与面板结构读取允许字段,完整
|
||||
页面树不落日志、不上传。T-204 直接接入原始截图上传,资产标记 `privacy_tier=INTERNAL_RAW`,只允许
|
||||
已认证设备写入、已登录管理员读取;不上传 XML,不允许外部支付页或支付凭据。
|
||||
|
||||
这个范围调整不授权 T-103 调整数量、进入确认页、点击“提交订单”或触碰支付控件。T-111 完成后,
|
||||
T-103 以 `SkuSelectionFlow` 继续:由人把两个维度改成明确的非目标值,再运行受控入口、精确选择、
|
||||
读价和安全退出脚本完成真机验收。生产业务最终为单趟,但能力仍按 T-103 / T-105 / T-107 分段取证。
|
||||
|
||||
Windows 的标准入口是仓库根 `./init.ps1`。它要求 Go、两端目录及其哨兵文件存在;已有合规
|
||||
`client/.venv` 时,所有采购工具检查与 validator 都使用该解释器。只有 venv 不存在时,才从 `py -0p`
|
||||
枚举的版本中确定性选择最高的 Python 3.11+ 创建它;没有合规版本时明确失败,绝不回退默认 `python`。
|
||||
@@ -85,7 +181,8 @@ compileall。既有 `client/.venv` 若不是 Python 3.11+ 会明确失败,不
|
||||
|
||||
当前 Windows 默认 `python` 仍可能指向 Python 3.10,不满足采购工具的 Python 3.11+ 下限;
|
||||
不得把未加版本选择器的 `python` 当作采购工具命令。统一入口优先使用既有合规 venv,仅在需要创建时
|
||||
自动选择 Launcher 中最高的合规版本;本机现有 venv 实际为 Python 3.12。桌面 GUI 与真机流程不属于 T-003 验收范围。
|
||||
自动选择 Launcher 中最高的合规版本;本机 Python 3.12 与 3.14 均已验证,主工作区当前选择 Python
|
||||
3.14。桌面 GUI 与真机流程不属于 T-003 验收范围。
|
||||
|
||||
Windows PowerShell 差异:
|
||||
|
||||
|
||||
+242
-324
@@ -1,446 +1,364 @@
|
||||
# 架构设计
|
||||
|
||||
> 本文讲「怎么把技术栈搭起来」:系统结构、职责划分、数据模型、技术难点、开发顺序。
|
||||
> 具体用了哪些框架 / 库 / 数据库 / 部署方式,见 [技术栈](03-tech-stack.md)。
|
||||
> 本文定义系统结构、职责、单趟采购流程、安全边界和数据模型。框架与运行命令见
|
||||
> [技术栈](03-tech-stack.md),双端线协议以 [API 合约](api.md)为唯一权威。
|
||||
|
||||
## 一、系统结构
|
||||
|
||||
```text
|
||||
第三方 ERP(顺运宝) Excel 表格 人工填链接
|
||||
│ │ │
|
||||
└────────────┬───────────┴────────────────────┘
|
||||
v
|
||||
人工填链接(MVP) Excel / ERP(V2)
|
||||
│ │
|
||||
└──────────────┬───────────────┘
|
||||
v
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 采购服务(admin/,Go 单二进制) │
|
||||
│ · 建单、批量开始试选与任务生命周期 │
|
||||
│ · 候选确认与下单授权(唯一决策权威) │
|
||||
│ · 证据存储与审计 │
|
||||
│ · 管理 Web(服务端渲染) │
|
||||
└─────────────────────────────────────────┘
|
||||
^ HTTP / JSON
|
||||
│ Bearer Token + 设备绑定
|
||||
v
|
||||
│ 采购服务(admin/,Go) │
|
||||
│ · 建单、查询、开始采购授权与任务状态 │
|
||||
│ · 提交围栏、结果调和、内部证据与审计 │
|
||||
│ · 服务端渲染管理页面 │
|
||||
└───────────────────┬─────────────────────┘
|
||||
│ HTTPS / JSON
|
||||
│ Bearer + 设备绑定
|
||||
v
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 采购工具(client/,Python + PySide6) │
|
||||
│ · 领任务、跑流程、回传结果 │
|
||||
│ · 本地执行轨迹与证据落盘 │
|
||||
│ · AI 辅助调用(P1) │
|
||||
└─────────────────────────────────────────┘
|
||||
│ ADB(USB / WiFi)
|
||||
v
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Android 手机(拼多多 App) │
|
||||
└─────────────────────────────────────────┘
|
||||
│ 采购工具(client/,Python + PySide6) │
|
||||
│ · 轮询领取、单趟执行、回传状态与证据 │
|
||||
│ · 本地完整节点树与执行轨迹 │
|
||||
└───────────────────┬─────────────────────┘
|
||||
│ ADB(USB / WiFi)
|
||||
v
|
||||
Android 手机(拼多多 App)
|
||||
```
|
||||
|
||||
组件落位:
|
||||
|
||||
- 采购服务:Go + gin,入口 `admin/cmd/server/main.go`,模板
|
||||
`admin/internal/transport/webui/templates/`
|
||||
- 采购工具:Python,入口 `client/src/main.py`,真机流程 `client/src/android/pdd_flow.py`
|
||||
- 数据库:SQLite,迁移由 goose 管理
|
||||
- 证据存储:采购服务本地文件系统,SHA-256 寻址
|
||||
- 外部服务:顺运宝 ERP(只读)、AI provider(P1,仅采购工具调用)
|
||||
- 采购服务:Go + gin,SQLite,goose migration,本地 SHA-256 证据存储。
|
||||
- 采购工具:Python + uiautomator2 + PySide6;执行器只依赖 `TaskSource` / `ResultSink` 抽象。
|
||||
- 页面判据与拼多多 App 版本绑定;版本不同即停止,不把前序项目页面结构当作事实。
|
||||
|
||||
## 二、职责划分
|
||||
|
||||
### 采购服务(网页端,`admin/`)
|
||||
### 采购服务(`admin/`)
|
||||
|
||||
**独占**:
|
||||
独占以下权威:
|
||||
|
||||
- 任务的创建、状态流转和终态判定
|
||||
- 候选商品的接收与展示
|
||||
- **下单授权的签发与作废**——这是唯一的资金决策权威
|
||||
- 金额上限的判定
|
||||
- 证据资产的存储与访问控制
|
||||
- 管理员会话与设备凭据
|
||||
- 任务创建、批量开始采购、状态流转和终态判定;
|
||||
- **一次性采购授权的签发**:管理员点击“开始采购”是唯一的人类授权动作;
|
||||
- 任务不可变字段和最高总价校验;
|
||||
- 提交订单前的原子围栏与点击后结果调和;
|
||||
- 管理员会话、设备凭据、内部截图证据和审计记录。
|
||||
|
||||
**不做**:
|
||||
采购服务不连接手机、不发 ADB 命令、不解析拼多多页面,也不持有支付或 AI provider 凭据。
|
||||
|
||||
- 不连接手机、不发 ADB 命令
|
||||
- 不保存、代理或下发任何 AI provider 凭据
|
||||
- 不解析拼多多页面
|
||||
### 采购工具(`client/`)
|
||||
|
||||
### 采购工具(桌面端,`client/`)
|
||||
独占以下设备能力:
|
||||
|
||||
**独占**:
|
||||
- ADB 连接、设备健康检查和已取证 App 版本校验;
|
||||
- 打开商品、识别页面、精确选规格、设置数量、读取价格;
|
||||
- 在满足全部门禁并取得服务端围栏后,精确点击一次“提交订单”;
|
||||
- 截图、完整节点树和执行日志的本地采集,显式上传内部截图。
|
||||
|
||||
- ADB 连接与设备健康检查
|
||||
- 拼多多页面识别、点击、选规格、设数量
|
||||
- 页面截图与节点树采集
|
||||
- AI 调用(P1)
|
||||
- 完整执行轨迹的本地留档
|
||||
|
||||
**不做**:
|
||||
|
||||
- **不自行决定买哪个候选**——必须等采购服务的授权
|
||||
- **不自行放宽金额上限**——本地校验只能更严,不能更松
|
||||
- 不直接读 Excel 或访问 ERP
|
||||
- 不在没有授权的情况下执行任何创建订单的动作
|
||||
采购工具不自行修改任务约束、不扩大金额上限、不领取 `DRAFT`,也不能签发授权。没有服务端
|
||||
明确返回 `click_permitted=true` 时,任何本地判断都不能创建订单。
|
||||
|
||||
### 权威冲突规则
|
||||
|
||||
两端都会校验规格、数量、金额。**判定不一致时一律转人工,不取任一方结论。**
|
||||
这条是硬规则:双闸门的价值在于分歧能被发现,自动选一边等于把双闸门降级成单闸门。
|
||||
服务端校验锁定的任务约束,客户端校验当前真机事实。任一端拒绝或两端摘要不一致,一律停止并
|
||||
转人工;不能为了“继续跑”选择相信其中一端。
|
||||
|
||||
## 三、两趟执行
|
||||
## 三、单趟采购执行
|
||||
|
||||
这是本项目最核心的结构决策。**MVP 只做 A 路径(任务自带商品链接),分两趟跑完。**
|
||||
MVP 只做任务自带商品链接的 A 路径。创建任务与开始采购分离,但管理员开始后不再插入试选确认:
|
||||
|
||||
```text
|
||||
┌────────── 采购服务开始第一趟 ───────────┐
|
||||
│ 新任务先保存为 DRAFT │
|
||||
│ 管理员在任务表格勾选一条或多条 │
|
||||
│ 原子转为 PENDING,只进入试选队列 │
|
||||
└────────────────────┬─────────────────────┘
|
||||
v
|
||||
┌──────────────── 第一趟:试选 ────────────────┐
|
||||
│ 采购工具轮询领取 PENDING 任务 │
|
||||
│ 1. open_product(url) │
|
||||
│ 2. 打开规格面板 │
|
||||
│ 3. 按维度精确勾选颜色分类、尺码 │
|
||||
│ 4. 【闸门一】读该 SKU 单价,算合计 │
|
||||
│ 5. 截图 │
|
||||
│ 6. 退出商品,释放手机 │
|
||||
│ 7. 回传标题 / 选中规格 / 单价 / 合计 / 截图 │
|
||||
└────────────────────┬─────────────────────────┘
|
||||
v
|
||||
任务转 WAITING_CONFIRMATION
|
||||
│
|
||||
┌────────────────────┴─────────────────────────┐
|
||||
│ 人在采购服务确认:机器选对了吗 │
|
||||
│ 看:需求 vs 选中规格、单价、合计、截图 │
|
||||
│ 点「确认下单(不付款)」→ 签发授权,锁定授权价 │
|
||||
│ 或「退回,不买」→ 任务终止 │
|
||||
└────────────────────┬─────────────────────────┘
|
||||
v
|
||||
┌──────────────── 第二趟:下单 ────────────────┐
|
||||
│ 采购工具轮询拿到授权 │
|
||||
│ 1. 重新 open_product(url) │
|
||||
│ 2. 重新按维度精确勾选同一规格 │
|
||||
│ 3. 【闸门二】重读单价,必须与授权价一致 │
|
||||
│ 4. 设数量并复核 │
|
||||
│ 5. 进订单确认页 │
|
||||
│ 6. 【闸门三】读「实付款」,不得超授权上限 │
|
||||
│ 7. 三个闸门全过 → 点一次「提交订单」 │
|
||||
│ 8. 回传订单截图 │
|
||||
└────────────────────┬─────────────────────────┘
|
||||
v
|
||||
任务转 WAITING_PAYMENT
|
||||
│
|
||||
人工在拼多多核对后付款
|
||||
管理员创建 DRAFT
|
||||
│
|
||||
├─ 勾选 DRAFT,查看选中数与最高总额
|
||||
└─ 点击“开始采购(只创建待付款订单)”
|
||||
│ 同一事务:校验版本 + 创建一次性授权 + PENDING
|
||||
v
|
||||
采购工具领取授权任务
|
||||
│ 1. 打开 canonical 商品链接
|
||||
│ 2. 通过证据/版本绑定、精确唯一的受控入口打开规格面板
|
||||
│ 3. 按维度精确选择并读回颜色、尺码
|
||||
│ 4. 【闸门一】读 SKU 单价;单价×数量不得超过最高总价
|
||||
│ 5. 设置数量并精确读回
|
||||
│ 6. 【闸门二】重读规格与单价;规格不变且价格等于闸门一
|
||||
│ 7. 进入订单确认页
|
||||
│ 8. 【闸门三】规格/数量一致,应付总额不超最高总价
|
||||
│ 9. 上传验证摘要并申请服务端提交围栏
|
||||
│ 10. 仅在 click_permitted=true 且提交控件唯一时点击一次
|
||||
│ 11. 回传观察结果;不确定时只调和,不重试
|
||||
v
|
||||
WAITING_PAYMENT ──人核对与付款──> SUCCEEDED
|
||||
或
|
||||
RECONCILIATION_REQUIRED ──人工核查同一提交──> WAITING_PAYMENT / FAILED
|
||||
```
|
||||
|
||||
### 为什么分两趟而不是停在面板上等人
|
||||
“开始采购”锁定的是管理员填写的 `goods_id`、颜色、尺码、数量和**最高总价**,不是一张旧页面
|
||||
截图里观察到的价格。价格在执行时实时读取,因此取消试选确认不会取消价格保护。
|
||||
|
||||
一台手机是瓶颈。若第一趟停在规格面板等人确认,手机被占住跑不了别的任务,面板还可能
|
||||
超时或被拼多多重置。**第一趟必须退出并释放手机**,第二趟重新进入。
|
||||
### 受控规格面板入口
|
||||
|
||||
代价是同一商品走两遍,但第二趟很快,而且换来两个好处:手机可以在人思考时继续跑别的
|
||||
任务的试选;价格变动能被第二趟抓住。
|
||||
T-103 真机证据表明:拼多多 `8.17.0`、goods_id `937122477375` 通过精确文本“快要抢光”打开
|
||||
规格面板。T-110 已批准把该**特定证据、版本和页面状态**绑定的点击定义为受控导航。
|
||||
|
||||
- 只能精确唯一匹配;缺失、重复、版本失配或打开后面板不唯一时零后续点击。
|
||||
- “免拼购买 / 单独购买 / 直接拼成”等其他文案不能用包含、前缀、同义或坐标兜底。
|
||||
- 受控入口只负责进入已取证面板,不等于支付授权,也不能暴露通用任意点击能力。
|
||||
- T-103 的隔离验证 capability 只包含开商品、开面板、选规格、读价和安全退出;数量、确认页、
|
||||
提交和支付仍由后续真机任务分别取证后才能接入生产单趟执行器。
|
||||
|
||||
### 三道价格闸门
|
||||
|
||||
| 闸门 | 位置 | 作用 | 不通过时 |
|
||||
| 闸门 | 当前页面 | 判据 | 拒绝条件 |
|
||||
| --- | --- | --- | --- |
|
||||
| 一 | 第一趟规格面板 | 读该 SKU 单价,算合计,回传给人看 | 读不到即停,转人工 |
|
||||
| 二 | 第二趟规格面板 | 重读单价,**必须与授权时锁定的价格一致** | 不一致即停,转人工 |
|
||||
| 三 | 订单确认页 | 读「实付款」,不得超授权总额上限 | 超出即停,转人工 |
|
||||
| 一 | 规格面板,选中目标规格后 | 单价唯一可读;`单价 × 授权数量 <= 最高总价` | 不可读、有歧义或超上限 |
|
||||
| 二 | 规格面板,数量读回后 | 颜色、尺码仍正确;重读单价与闸门一完全相等 | 规格漂移或价格变化 |
|
||||
| 三 | 订单确认页 | 规格、数量正确;应付总额唯一可读且不超最高总价 | 任一不一致、不可读或超上限 |
|
||||
|
||||
**闸门二不可省略。** 人确认的是「32.50 元这一单」,不是「这个商品」。拼多多价格波动
|
||||
常见,不能因为「人已经确认过」就照下不误。
|
||||
金额一律使用十进制字符串和十进制定点运算,不用浮点数。价格只从规格面板和订单确认页读取;
|
||||
详情正文、搜索卡片、底部购买/提交按钮的数字不作为价格来源。
|
||||
|
||||
**价格只在规格面板和订单确认页读。** 商品详情页正文和搜索结果卡片上的价格文本在真机上
|
||||
被拆成多个节点(`¥` 与数字分离)、带 `券后` 一类前缀、实付价 / 原价 / 促销价难以区分
|
||||
——前序项目在这里耗掉大量时间且无可靠结论。
|
||||
闸门一与闸门二发生在同一设备会话中。它们用于发现选择或设置数量造成的页面变化,不需要管理员
|
||||
在中间确认。旧截图、缓存值和发布前 dry-run 都不能替代这次实时读取。
|
||||
|
||||
### B 路径(图片搜索)——V2,MVP 不做
|
||||
### B 路径(V2)
|
||||
|
||||
任务只有参考图、没有链接时,需要先搜图找出商品。**该路径推迟到 V2**,MVP 阶段建单必须
|
||||
提供商品链接。
|
||||
|
||||
V2 实现时仍遵守:**图搜的唯一产出是 goods_id**,不在搜索结果卡片上读价格或据价筛选,
|
||||
拿到 goods_id 后汇入本节的两趟流程。
|
||||
没有商品链接时由图片搜索只产出 `goods_id`,再汇入上述流程。不在搜索结果页读取价格或规格。
|
||||
|
||||
## 四、安全边界(硬约束)
|
||||
|
||||
以下每一条都必须有单元测试证明,且不得在任务中「顺手放宽」。
|
||||
以下每条都必须有测试证明,不得在任务中顺手放宽:
|
||||
|
||||
| 边界 | 规则 | 违反后果 |
|
||||
| 边界 | 规则 | 防止什么 |
|
||||
| --- | --- | --- |
|
||||
| 不付款 | 任何路径都不点击支付、免密支付、先用后付或扣款控件 | 真实资金损失 |
|
||||
| **提交订单四条件** | 见下方专节。四者缺一不可,且**只允许点击一次** | 误下单 / 重复下单 |
|
||||
| 订单确认页其余零点击 | 除「提交订单」与返回外,不点击确认页上任何控件 | 误触发未知动作 |
|
||||
| 规格精确匹配 | 按维度等值匹配,防前缀碰撞(`红`/`粉红`、`1`/`10`);找不到即停 | 买错货 |
|
||||
| 提交订单控件唯一 | 文本精确等于「提交订单」且可点击祖先唯一,否则停 | 点到未知控件 |
|
||||
| 数量必须复核 | 设置后读回确认精确等于要求值,否则停 | 买错数量 |
|
||||
| 价格三道闸门 | 见第三节。任一道读不到或不通过即停,**不用其他位置的数字凑合** | 超预算采购 |
|
||||
| 第一趟不下单 | 试选阶段只勾选规格和读价,**绝不点击「现在买」或任何进入下单流程的入口** | 无授权下单 |
|
||||
| 外部支付页 | 检测到微信等外部支付交接立即停止、转人工、保留证据 | 凭据泄露 |
|
||||
| 安全校验 | 检测到验证码、风控、人脸、短信校验立即停止,不尝试绕过 | 封号 / 违规 |
|
||||
| 敏感信息 | 只读非敏感摘要,不提取收货地址原文、手机号、支付凭据 | 隐私泄露 |
|
||||
| 授权一次性 | 一笔授权只能产生一笔订单,重复提交幂等 | 重复采购 |
|
||||
| 服务端提交围栏 | 真机点击前必须由采购服务原子冻结授权并创建唯一提交记录;失败或响应不明不得点击 | 并发 / 断网导致重复下单 |
|
||||
| App 版本失配即停 | 运行版本与本项目已取证版本不一致时停止领取真机任务,先重新取证 | 旧判据误点新页面 |
|
||||
| 不付款 | 不点击支付、免密支付、先用后付或任何扣款控件 | 真实资金损失 |
|
||||
| 提交四条件 | 授权+围栏、闸门二、闸门三、控件唯一同时成立,只点一次 | 误下单 / 重复下单 |
|
||||
| 确认页零点击 | 除返回和满足四条件后的“提交订单”外不点击任何控件 | 未知副作用 |
|
||||
| 规格精确匹配 | 维度内等值唯一匹配,防 `红/粉红`、`1/10` 前缀碰撞 | 买错规格 |
|
||||
| 数量读回复核 | 设置后精确读回,不一致即停 | 买错数量 |
|
||||
| 三道价格闸门 | 任一道不可读、有歧义或不通过都停,不用别处数字凑 | 超预算 |
|
||||
| 受控页面能力 | 页面动作按任务与证据分层;不得把通用 `click` 传入业务流程 | 边界扩散 |
|
||||
| 外部支付页 | 检测到外部支付交接立即停止,不读取、保存或输入凭据 | 凭据泄露 |
|
||||
| 安全校验 | 验证码、风控、人脸、短信出现即停止,不绕过 | 封号 / 违规 |
|
||||
| 内部截图 | 可上传页面已显示的地址/手机号;不解析成字段或日志,完整 XML 不上传 | 非必要扩散 |
|
||||
| 授权一次性 | 一条任务版本只有一份有效授权;幂等重放不生成第二份 | 重复采购 |
|
||||
| 服务端提交围栏 | 点击前原子创建唯一提交记录;失败或响应不明不得点击 | 并发 / 断网重复下单 |
|
||||
| App 版本绑定 | 运行版本不同于证据版本时停止并重新取证 | 旧判据误点 |
|
||||
|
||||
### 提交订单的四个前置条件
|
||||
|
||||
这是本项目唯一会创建真实待付款订单的动作。**四者同时满足才允许点击,且只点一次:**
|
||||
“提交订单”是系统唯一会创建真实待付款订单的动作。以下四项同时满足才允许点击一次:
|
||||
|
||||
1. **授权存在且未消费,并已建立服务端提交围栏**——采购服务已签发、采购工具已 ack;
|
||||
真机点击前,采购服务在一个原子事务中把授权从可执行态冻结为本次唯一
|
||||
`order_submission`。围栏接口失败或响应不明时不得点击。
|
||||
2. **闸门二通过**——第二趟重读的单价与授权时锁定的价格一致。
|
||||
3. **闸门三通过**——订单确认页「实付款」不超过授权总额上限。
|
||||
4. **控件唯一**——文本精确等于「提交订单」且可点击祖先唯一。
|
||||
1. **一次性授权有效且服务端提交围栏已建立**:围栏把授权、任务、领取和本次验证摘要原子绑定到
|
||||
唯一 `order_submission`;明确响应包含 `click_permitted=true`。
|
||||
2. **闸门二通过**:目标规格未漂移,第二次规格面板单价等于第一次。
|
||||
3. **闸门三通过**:确认页规格、数量正确,应付总额不超过授权最高总价。
|
||||
4. **提交控件唯一**:文本精确等于“提交订单”,可点击祖先唯一。
|
||||
|
||||
点击之后,**无论发生什么都不重试**:
|
||||
围栏请求超时、断网、冲突或响应不明时不得点击。围栏建立后:
|
||||
|
||||
| 点击后观察到 | 处置 |
|
||||
| 观察结果 | 处置 |
|
||||
| --- | --- |
|
||||
| 正常进入订单结果页 | 回传订单截图,任务转 `WAITING_PAYMENT` |
|
||||
| 跳转微信等外部支付 | 立即停止,转人工,提示「订单可能已创建、支付未完成」 |
|
||||
| 安全校验 | 立即停止,转人工,保留证据 |
|
||||
| 超时或页面无法判定 | 转人工,**预留金额额度**,提示订单状态不明 |
|
||||
| 明确进入订单结果 / 待付款页 | 上报 `SUBMITTED`,任务转 `WAITING_PAYMENT` |
|
||||
| 跳转外部支付 | 立即停止,上报结果不明确,不执行支付 |
|
||||
| 出现验证码 / 风控 / 人脸 / 短信 | 立即停止,上报结果不明确,不绕过 |
|
||||
| 超时、断连、页面无法判定 | 转 `RECONCILIATION_REQUIRED`,保留围栏和金额额度 |
|
||||
|
||||
后三种情况一律**禁止自动重试点击**。授权保持永久围栏,结果明确后再记为已消费;在此之前
|
||||
也绝不能重新开放——宁可人工核实一遍,不可能重复下单。
|
||||
后三种情况都禁止释放围栏、重新授权、重新领取或再次点击。只能调和同一提交记录。
|
||||
|
||||
### dry-run、提交围栏与结果调和
|
||||
### 发布前 dry-run 与生产围栏的区别
|
||||
|
||||
下单被拆成三个不可逆程度不同的阶段,任何客户端本地判断都不能替代服务端围栏:
|
||||
|
||||
1. **dry-run(只读演练)**:进入订单确认页,读取规格、数量和「实付款」,确认提交控件
|
||||
唯一,上传证据后退出。该阶段绝不点击「提交订单」,也不消费授权。
|
||||
2. **提交围栏**:真实第二趟再次读取并通过三道闸门后,采购工具向采购服务申请围栏。采购服务
|
||||
原子校验任务版本、命令、未消费授权和唯一性,创建 `order_submissions` 记录并冻结授权。
|
||||
只有明确收到成功响应,采购工具才可点击一次。
|
||||
3. **结果调和**:点击后只上报观察结果。明确创建则转 `WAITING_PAYMENT`;超时、外部支付、
|
||||
安全校验或断连均转 `RECONCILIATION_REQUIRED`,保留额度并由人核查。**不得释放围栏、
|
||||
重新签发授权或自动重试点击。**
|
||||
|
||||
授权超时和主动放弃只允许发生在提交围栏建立之前。围栏之后即使租约过期,也只能恢复
|
||||
同一提交记录并进入调和,不能把任务重新放回可领取队列。
|
||||
首次启用某一 App 版本的真实提交能力前,必须用独立真机任务完成只读 dry-run:进入确认页,验证
|
||||
规格、数量、金额与提交控件唯一,然后退出且不点击。它用于证明判据和不可达测试,不是每笔采购的
|
||||
“第一趟”,也不产生可复用页面事实。生产任务仍在同一趟内重新通过三道闸门并申请服务端围栏。
|
||||
|
||||
## 五、数据模型
|
||||
|
||||
### 5.1 核心实体
|
||||
|
||||
```sql
|
||||
-- 采购任务:创建后业务约束不可变
|
||||
CREATE TABLE tasks (
|
||||
id TEXT PRIMARY KEY, -- UUID
|
||||
source TEXT NOT NULL, -- MANUAL | EXCEL | ERP
|
||||
source_ref TEXT, -- 外部单号(如虾皮订单号),非内部 id
|
||||
title TEXT NOT NULL,
|
||||
goods_id TEXT NOT NULL, -- MVP 必填;B 路径(可为空)推迟到 V2
|
||||
sku_color TEXT NOT NULL,
|
||||
sku_size TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0),
|
||||
max_total_price TEXT NOT NULL, -- 十进制字符串,资金边界不得为空
|
||||
reference_asset_id TEXT, -- 参考图,MVP 可选;B 路径(V2)必填
|
||||
status TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,-- 乐观锁
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 第一趟试选结果:人做确认决策的依据
|
||||
CREATE TABLE spec_trials (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
attempt INTEGER NOT NULL,
|
||||
product_title TEXT NOT NULL, -- 商品页读到的标题
|
||||
selected_color TEXT NOT NULL, -- 实际勾选到的颜色分类
|
||||
selected_size TEXT NOT NULL, -- 实际勾选到的尺码
|
||||
unit_price TEXT NOT NULL, -- 闸门一读到的单价
|
||||
total_price TEXT NOT NULL, -- unit_price × quantity
|
||||
evidence_sha256 TEXT NOT NULL, -- 规格面板截图
|
||||
source TEXT NOT NULL, -- MANUAL | EXCEL | ERP
|
||||
source_ref TEXT,
|
||||
title TEXT NOT NULL,
|
||||
goods_id TEXT NOT NULL,
|
||||
sku_color TEXT NOT NULL,
|
||||
sku_size TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL CHECK (quantity > 0),
|
||||
max_total_price TEXT NOT NULL, -- 十进制字符串
|
||||
reference_asset_id TEXT,
|
||||
status TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (task_id, attempt)
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- 下单授权:唯一的资金决策记录
|
||||
-- 管理员点击“开始采购”产生;锁定任务约束,不锁定旧观察价
|
||||
CREATE TABLE order_authorizations (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
spec_trial_id TEXT NOT NULL REFERENCES spec_trials(id),
|
||||
version INTEGER NOT NULL,
|
||||
task_version INTEGER NOT NULL,
|
||||
start_key TEXT NOT NULL,
|
||||
goods_id TEXT NOT NULL,
|
||||
sku_color TEXT NOT NULL,
|
||||
sku_size TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL,
|
||||
authorized_unit_price TEXT NOT NULL, -- 锁定价:闸门二据此比对
|
||||
total_price_cap TEXT NOT NULL, -- 授权总额上限:闸门三据此比对
|
||||
note TEXT, -- 可选备注
|
||||
status TEXT NOT NULL,
|
||||
total_price_cap TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- ACTIVE | CLAIMED | FENCED | CONSUMED | EXPIRED | ABANDONED
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL, -- 围栏前超时自动作废;围栏后不再释放
|
||||
UNIQUE (task_id, version)
|
||||
expires_at TEXT NOT NULL,
|
||||
UNIQUE (task_id, task_version),
|
||||
UNIQUE (start_key, task_id)
|
||||
);
|
||||
|
||||
-- 真实点击前的服务端一次性围栏;一笔授权最多一条
|
||||
-- 一次领取产生一条可恢复执行;保存步骤摘要,不接收完整 XML
|
||||
CREATE TABLE purchase_attempts (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
authorization_id TEXT NOT NULL REFERENCES order_authorizations(id),
|
||||
claim_generation INTEGER NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
gate1_unit_price TEXT,
|
||||
gate2_unit_price TEXT,
|
||||
quantity_read INTEGER,
|
||||
confirm_amount TEXT,
|
||||
failure_code TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
UNIQUE (task_id, claim_generation)
|
||||
);
|
||||
|
||||
-- 真机真实点击前建立;一份授权最多一条
|
||||
CREATE TABLE order_submissions (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(id),
|
||||
authorization_id TEXT NOT NULL REFERENCES order_authorizations(id),
|
||||
command_id TEXT NOT NULL,
|
||||
dry_run_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- FENCED | SUBMITTED | RECONCILIATION_REQUIRED | MANUAL_RESOLVED
|
||||
verified_unit_price TEXT NOT NULL,
|
||||
quantity_read INTEGER NOT NULL,
|
||||
confirm_page_amount TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_at TEXT,
|
||||
attempt_id TEXT NOT NULL REFERENCES purchase_attempts(id),
|
||||
status TEXT NOT NULL, -- FENCED | SUBMITTED | RECONCILIATION_REQUIRED | MANUAL_RESOLVED
|
||||
gate1_unit_price TEXT NOT NULL,
|
||||
gate2_unit_price TEXT NOT NULL,
|
||||
quantity_read INTEGER NOT NULL,
|
||||
confirm_amount TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_at TEXT,
|
||||
UNIQUE (authorization_id),
|
||||
UNIQUE (command_id)
|
||||
UNIQUE (attempt_id)
|
||||
);
|
||||
```
|
||||
|
||||
`authorized_unit_price` 是第二趟闸门二的比对基准,**必须来自人确认时看到的那个试选
|
||||
结果**,不能在签发时重新取值。`expires_at` 见 5.3 节。
|
||||
|
||||
MVP 的授权没有「选择理由 / 拒绝理由」——那是从多个候选里挑一个时的留档需求。这里人
|
||||
只回答「机器选对了吗」,保留一个可选 `note` 即可。
|
||||
|
||||
金额一律用**十进制字符串**存储和传输,不用浮点数。
|
||||
MVP 不再用 `spec_trials` 作为审批记录,也不存在 `authorized_unit_price`。实际读价属于
|
||||
`purchase_attempts` / `order_submissions` 的执行与审计事实;管理员授权的资金边界始终是
|
||||
`total_price_cap`。
|
||||
|
||||
### 5.2 状态机
|
||||
|
||||
任务状态(采购服务权威)。创建与开始试选分离;两趟执行对应两次 `CLAIMED → RUNNING`:
|
||||
|
||||
```text
|
||||
DRAFT ─start trial→ PENDING ─┐
|
||||
PENDING_RETRIAL ─────────────┴─claim→ CLAIMED ─start→ RUNNING(TRIAL)
|
||||
↑ │ ├→ NEEDS_MANUAL
|
||||
└────────release──────┘ └→ WAITING_CONFIRMATION
|
||||
├→ CANCELED
|
||||
└→ AUTHORIZED
|
||||
└claim→ ORDERING
|
||||
├→ NEEDS_MANUAL(围栏前失败)
|
||||
└→ [submission FENCED]
|
||||
├→ WAITING_PAYMENT
|
||||
│ └→ SUCCEEDED
|
||||
└→ RECONCILIATION_REQUIRED
|
||||
└→ 人工核查 / 调和
|
||||
DRAFT
|
||||
└─开始采购(创建授权)→ PENDING
|
||||
└─claim→ CLAIMED ─start→ ORDERING
|
||||
├─围栏前验证失败→ NEEDS_MANUAL ─人工处理/重置→ DRAFT
|
||||
├─围栏前授权过期/安全释放→ DRAFT
|
||||
└─submission FENCED
|
||||
├─明确创建→ WAITING_PAYMENT ─人工付款并标记→ SUCCEEDED
|
||||
└─结果不明→ RECONCILIATION_REQUIRED
|
||||
└─人工调和同一提交→ WAITING_PAYMENT / FAILED
|
||||
|
||||
DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
|
||||
```
|
||||
|
||||
| 状态 | 含义 |
|
||||
| 状态 | 含义与安全下一步 |
|
||||
| --- | --- |
|
||||
| `DRAFT` | 已保存、等待管理员开始试选;设备不可领取,也不存在下单授权 |
|
||||
| `RUNNING(TRIAL)` | 第一趟试选中:正在勾选规格、读价、截图 |
|
||||
| `WAITING_CONFIRMATION` | 试选已回传,**等人确认机器选对了没** |
|
||||
| `PENDING_RETRIAL` | 旧授权已过期或在围栏前被放弃,必须重新跑第一趟取得新价格 |
|
||||
| `AUTHORIZED` | 已签发授权,等采购工具下一轮轮询领走 |
|
||||
| `ORDERING` | 第二趟下单中:重新选规格、过闸门二三、提交订单 |
|
||||
| `WAITING_PAYMENT` | 订单已创建,等人在拼多多付款。**这不是成功** |
|
||||
| `RECONCILIATION_REQUIRED` | 已建立提交围栏,但点击结果不明确;可能已创建订单,只能核查,不能重试 |
|
||||
| `NEEDS_MANUAL` | 围栏前的转人工情形(规格不匹配、价格不符、页面识别失败等) |
|
||||
| `SUCCEEDED` | 订单已付款且核对通过 |
|
||||
| `DRAFT` | 已保存,未授权;管理员可编辑/取消或点击开始采购;设备不可领取 |
|
||||
| `PENDING` | 已有有效一次性授权,等待采购工具领取 |
|
||||
| `CLAIMED` | 已由一个设备实例持有租约,尚未开始页面操作 |
|
||||
| `ORDERING` | 单趟执行中,正在选规格、过闸门或申请围栏 |
|
||||
| `NEEDS_MANUAL` | 围栏前失败;显示原因,由人核查后重置为 DRAFT 或取消 |
|
||||
| `WAITING_PAYMENT` | 订单已明确创建,等待人在拼多多付款;**不是成功** |
|
||||
| `RECONCILIATION_REQUIRED` | 围栏后结果不明;只能核查同一提交,不能重试 |
|
||||
| `SUCCEEDED` | 人已付款并完成核对 |
|
||||
| `FAILED` | 人工调和确认订单未创建或任务无法完成 |
|
||||
| `CANCELED` | 围栏前由管理员取消,不再执行 |
|
||||
|
||||
授权状态:`PENDING_DELIVERY → DELIVERED → ACKNOWLEDGED → EXECUTING → FENCED → CONSUMED`。
|
||||
人退回或重新确认时旧授权转 `SUPERSEDED`;超时转 `EXPIRED`。
|
||||
批量 `DRAFT → PENDING` 必须全有或全无。服务端同时创建授权;“先改状态、稍后补授权”无效。
|
||||
`WAITING_CONFIRMATION`、`PENDING_RETRIAL`、`AUTHORIZED` 和 `RUNNING(TRIAL)` 不再属于 MVP 状态。
|
||||
|
||||
`DRAFT → PENDING` 只能由管理端“开始试选”动作触发。批量开始在一个事务中校验全部任务仍为
|
||||
`DRAFT` 且版本一致后统一流转;任一冲突时整批不变,避免用户误以为选中的任务都已开始。
|
||||
这个动作只开放第一趟领取资格,不创建 `order_authorizations` 或 `order_submissions`。
|
||||
### 5.3 授权、租约与恢复
|
||||
|
||||
### 5.3 授权超时(MVP 必做,不得推后)
|
||||
|
||||
> **前序项目的教训**:曾出现 `EXECUTING` 授权永不推进,导致确认表单被永久隐藏、任务
|
||||
> 锁死,只能新建任务绕过。
|
||||
|
||||
规则:
|
||||
|
||||
- 每笔授权带 `expires_at`。**仅在尚未建立提交围栏时**,超时自动转 `EXPIRED`。
|
||||
- 授权 `EXPIRED` 后任务转 `PENDING_RETRIAL`,先重新跑第一趟取得新价格,再回到人工确认;
|
||||
不允许在旧 `spec_trials` 上直接重新确认。
|
||||
- 采购服务在围栏建立前提供「放弃当前授权」入口;围栏建立后改为「进入人工核查」,不得
|
||||
作废或释放授权。
|
||||
- **任何时候都不允许出现「任务停在某状态且界面上没有任何可用动作」的组合。**
|
||||
这是验收项,不是实现细节。
|
||||
|
||||
授权过期后重新确认时,必须重新走第一趟试选取得新的 `spec_trials` 记录——不能复用旧的
|
||||
锁定价,因为价格可能已经变了。已建立围栏的授权不参与本超时流程。
|
||||
- 授权带 `expires_at`,只有围栏前可转 `EXPIRED` / `ABANDONED`;任务回到 `DRAFT`,必须重新点击
|
||||
开始采购。旧授权永不复活。
|
||||
- 设备租约丢失不等于授权可安全重用。只有服务端确认该 attempt 未建立围栏,才能关闭 attempt 并
|
||||
回到 `DRAFT` / `NEEDS_MANUAL`;不能自动重新领取并重复页面动作。
|
||||
- 围栏建立后即使租约过期也只恢复同一 `order_submission` 的调和,不能回到可领取队列。
|
||||
- 每种非终态都必须给出安全下一步,不能出现隐藏表单导致任务永久锁死。
|
||||
|
||||
### 5.4 证据分层
|
||||
|
||||
| 数据 | 位置 | 理由 |
|
||||
| 数据 | 位置 | 边界 |
|
||||
| --- | --- | --- |
|
||||
| 候选商品页 / 规格页截图 | 上传采购服务 | 管理员做授权决策必须看 |
|
||||
| 订单确认页截图 | 上传采购服务 | 授权后核对与审计必须留 |
|
||||
| 订单核对截图 | 上传采购服务 | 资金核对证据 |
|
||||
| 完整节点树 XML | **仅采购工具本地** | 体积大、含页面全文、只用于排障 |
|
||||
| AI 调用记录(P1) | **仅采购工具本地** | 含 prompt / 响应全文,脱敏成本高 |
|
||||
| 失败现场快照 | 仅采购工具本地,可按需手工导出 | 同上 |
|
||||
| 商品 / 规格 / 确认页原始 screenshot | 采购工具本机 + 采购服务内部证据存储 | 可含页面已显示地址/手机号;设备鉴权上传、管理员登录查看,不遮罩 |
|
||||
| 完整 XML | 仅采购工具本机隔离目录 | 可在内存解析页面判据;不上传、不写日志、Git、Vikunja |
|
||||
| 最小 XML fixture | 采购工具测试 / Git | 只保留判据所需结构,确认无地址、手机号、支付凭据 |
|
||||
| 外部支付页或支付凭据 | 不保存、不上传 | 检测到交接立即停止 |
|
||||
| AI 调用记录(V2) | 仅采购工具本地 | 不进入采购服务 |
|
||||
|
||||
上传前必须脱敏:**不上传含收货地址、手机号、支付凭据的截图区域或文本。**
|
||||
截图上传器只能接收调用方显式指定的截图,不能枚举证据目录或顺带上传 XML/manifest。证据响应
|
||||
使用 `Cache-Control: no-store`,不能暴露为免登录静态目录。
|
||||
|
||||
## 六、关键技术难点
|
||||
|
||||
| 难点 | 说明 | 应对 |
|
||||
| 难点 | 风险 | 应对 |
|
||||
| --- | --- | --- |
|
||||
| 拼多多页面结构随版本变化 | 前序项目已观察到详情页无独立规格入口、价格节点拆分等变化 | **每条判据先做真机 spike 取证再写代码**;判据与 App 版本一并记录 |
|
||||
| 规格面板上的价格位置 | 选中 SKU 后价格显示在哪、是否含券后前缀,未取证 | **T-103 必须一并取证**,闸门一依赖它;读不到就转人工,不用详情页数字凑合 |
|
||||
| 同一商品两趟结果不一致 | 第二趟价格变了、规格选项变了或商品下架 | 闸门二拦截;一律转人工,不自动放弃也不自动继续 |
|
||||
| 图搜结果含跨类目商品(V2) | 搜服装出现纸巾 | B 路径只产 goods_id 且限 5 个;后续用 VLM 看截图筛同款 |
|
||||
| WiFi ADB 稳定性 | 息屏、换网、DHCP 续租会断连 | 超时可配置;断连视为技术失败并保留现场,不重试点击 |
|
||||
| 同一手机 USB + WiFi 同时在线 | `adb devices` 列出两条,自动选设备会失败 | 设备档案必须显式指定 serial,不允许留空自动选 |
|
||||
| 不可逆动作的重试 | 点击「现在买」后超时,无法判断订单是否已创建 | 一律转人工并预留金额额度,**禁止自动重试点击** |
|
||||
| 双端契约漂移 | 两端独立演进会静默不兼容 | 契约改动必跑完整门禁;[api.md](api.md) 是唯一权威 |
|
||||
|
||||
**高风险功能先做最小原型。** Phase 1 的真机 spike 必须先于 Phase 2 的界面开发完成。
|
||||
| 页面结构随版本变化 | 旧选择器误点新页面 | 每条判据先真机取证,记录 App 版本、截图、XML、goods_id |
|
||||
| 购买语义入口才打开面板 | 能力范围易扩散 | 只批准证据绑定的精确唯一入口;每种文案单独取证 |
|
||||
| 当前价与原价/按钮价混杂 | 读错价格 | 限定已取证结构和语义;价格只在面板/确认页读,歧义即停 |
|
||||
| 单趟页面状态变化 | 数量或促销导致价格变化 | 同一趟两次读规格面板价,再以确认页总额兜底 |
|
||||
| WiFi ADB / 双通道 | 断连或操作错设备 | serial 必填;USB/WiFi 同设备或身份不明时 fail closed |
|
||||
| 不可逆动作超时 | 可能已创建订单 | 服务端围栏 + 点击一次 + 只调和,不重试 |
|
||||
| 双端契约漂移 | 静默不兼容 | [api.md](api.md) 唯一权威;契约改动跑完整双端门禁 |
|
||||
|
||||
## 七、推荐开发顺序
|
||||
|
||||
1. **Phase 0 地基**:两端骨架、测试命令、`init` 脚本可运行。
|
||||
2. **Phase 1 真机取证**:WiFi ADB 连通;打开商品 → 打开规格面板 → 按维度精确勾选颜色
|
||||
分类和尺码 → **读到该 SKU 单价** → 设数量 → 进订单确认页 → 读「实付款」。
|
||||
**结论写入文档,判据带拼多多 App 版本。**
|
||||
3. **Phase 2 采购服务核心**:数据模型与状态机、手工建单、任务查询、试选结果接收、
|
||||
确认页与授权签发、**授权超时与放弃**。
|
||||
4. **Phase 3 双端打通**:设备侧 API、采购工具 `HttpTaskSource`/`HttpResultSink`、
|
||||
定时轮询、第一趟试选端到端。
|
||||
5. **Phase 4 闭环收尾**:第二趟下单(含三道闸门与提交)、失败分类、完整验收、打包。
|
||||
6. **V2 及以后**:图片搜索路径、候选对照台、Excel 导入、ERP 建单、订单自动核对、AI 辅助。
|
||||
1. **Phase 0 地基**:双端骨架、测试命令、初始化脚本和原型。
|
||||
2. **Phase 1 真机取证**:逐段验证商品打开、受控面板入口、精确规格与读价、数量、确认页和提交
|
||||
控件;真实点击前先完成独立 dry-run。每个 spike 的 capability 只覆盖当期动作。
|
||||
3. **Phase 2 采购服务核心**:DRAFT 建单/列表、批量开始采购与授权、状态/证据/围栏/调和接口。
|
||||
4. **Phase 3 双端打通**:设备身份、原子领取、单趟执行到围栏前、事件与截图。
|
||||
5. **Phase 4 闭环**:经明确真机授权验证一次提交、待付款收口、失败分类、打包。
|
||||
6. **V2**:图搜、Excel、ERP、自动核对、AI、多设备。
|
||||
|
||||
**不要在 Phase 1 结论出来之前写 Phase 2 的页面**——确认页要显示什么,取决于真机上
|
||||
究竟能读到什么。尤其是闸门一的单价,如果规格面板上读不可靠,整个确认页的设计要改。
|
||||
T-103 继续作为“规格选择与读价”的隔离前置,不含数量、确认页或提交。生产单趟并不意味着在一个
|
||||
任务里跳过逐段取证;它只意味着这些已验证能力集成后,每笔业务任务不再等待中途人工确认。
|
||||
|
||||
## 八、项目结构
|
||||
|
||||
```text
|
||||
cmbuyer/
|
||||
├── docs/
|
||||
├── admin/ # 采购服务(Go)
|
||||
├── admin/
|
||||
│ ├── cmd/server/
|
||||
│ ├── internal/
|
||||
│ │ ├── domain/ # 实体与状态机,无外部依赖
|
||||
│ │ ├── usecase/ # 业务用例
|
||||
│ │ ├── transport/
|
||||
│ │ │ ├── httpapi/ # 设备侧 API
|
||||
│ │ │ └── webui/ # 管理页面 + 模板 + 静态资源
|
||||
│ │ └── storage/ # SQLite 与证据资产
|
||||
│ ├── internal/domain/
|
||||
│ ├── internal/usecase/
|
||||
│ ├── internal/transport/httpapi/
|
||||
│ ├── internal/transport/webui/
|
||||
│ ├── internal/storage/
|
||||
│ └── migrations/
|
||||
├── client/ # 采购工具(Python)
|
||||
│ ├── src/
|
||||
│ │ ├── android/ # adb / device / pdd_flow
|
||||
│ │ ├── core/ # models / task_runner / sources 抽象
|
||||
│ │ ├── remote/ # HttpTaskSource / HttpResultSink
|
||||
│ │ └── app/ # PySide6 GUI
|
||||
├── client/
|
||||
│ ├── src/cmbuyer_client/device/
|
||||
│ ├── src/cmbuyer_client/pdd/
|
||||
│ ├── src/cmbuyer_client/core/
|
||||
│ ├── src/cmbuyer_client/remote/
|
||||
│ ├── src/cmbuyer_client/app/
|
||||
│ └── tests/
|
||||
└── scripts/
|
||||
```
|
||||
|
||||
`client/src/core/sources.py` 必须保留 `TaskSource` / `ResultSink` 抽象,执行器只依赖抽象。
|
||||
这样离线 Excel 模式可作为降级路径存在,且执行器不因来源变化而改动。
|
||||
执行器依赖 `TaskSource` / `ResultSink`,不直接读取 Excel 或拼接 HTTP。来源变化不得改变安全执行器。
|
||||
|
||||
## 九、架构纪律
|
||||
|
||||
- 业务事实和 schema 变化必须同步更新本文与 [api.md](api.md)。
|
||||
- 不在代码里发明文档没有的接口、字段和状态。
|
||||
- 第四节的安全边界不得在任务中放宽;确需变更时先改本文并说明理由。
|
||||
- 高风险模块先单独真机验证,再接入完整流程。
|
||||
- 前序项目 `cmroubao` / `cmpdd` 是设计依据,**不是事实来源**;引用其结论时必须在本项目
|
||||
重新验证。
|
||||
- 业务事实和 schema 变化同步本文与 [api.md](api.md),代码不得另起一套字段或状态。
|
||||
- 第四节安全边界只能收紧。需要变更时先更新架构、任务边界和理由。
|
||||
- 页面高风险能力先真机取证并隔离测试,再接入完整流程。
|
||||
- 前序项目 `cmroubao` / `cmpdd` 只提供设计理由,不提供可直接复用的页面事实。
|
||||
|
||||
+22
-10
@@ -24,10 +24,14 @@
|
||||
明确建立、闸门二通过、闸门三通过、控件唯一。
|
||||
- 围栏接口超时、冲突、网络失败或响应不明时不得点击;围栏建立后不得释放授权、重新领取
|
||||
或再次点击,只能恢复同一 `order_submission` 并调和结果。
|
||||
- 点击后无论超时、跳外部支付还是遇安全校验,**一律转人工、禁止重试**,授权立即标记
|
||||
已消费。
|
||||
- **第一趟试选的代码路径不得引用 `go_to_order_confirm()` 与 `submit_order()`**,
|
||||
必须有测试证明不可达。
|
||||
- 点击后无论超时、跳外部支付还是遇安全校验,**一律转人工、禁止重试**。授权保持已围栏;
|
||||
只有明确结果或人工调和后才记为已消费/已解决。
|
||||
- T-103 的 `SkuSelectionFlow` 不得引用数量、订单确认、提交围栏、`submit_order_once()` 或付款能力,
|
||||
必须有静态调用链和测试证明不可达。
|
||||
- `open_sku_panel()` 只可点击本项目真机证据与 App 版本绑定的精确唯一入口;当前仅允许拼多多
|
||||
`8.17.0` 上已取证的 `快要抢光`。不得暴露通用 `click`,不得把其他购买文案按包含/同义/坐标兜底。
|
||||
- 规格面板中的“提交订单”、微信支付、先用后付或 0 元下单在 T-103 只能作为硬拒绝判据,绝不能
|
||||
返回可点击对象。后续生产单趟也只能在各段已取证且四条件全通过时触发封装的一次性提交能力。
|
||||
|
||||
### 1.2 匹配纪律
|
||||
|
||||
@@ -41,21 +45,28 @@
|
||||
- 价格**只在规格面板(闸门一 / 二)和订单确认页(闸门三)读**。
|
||||
- **不从商品详情页正文、搜索结果卡片或任何其他位置读价格。**
|
||||
- 读不到就转人工,**不用别处的数字凑合**。
|
||||
- 第二趟重读的单价**必须与授权锁定价一致**,不一致即停——人确认的是那个价格,
|
||||
不是那个商品。
|
||||
- 同一趟设置并复核数量后,闸门二重读的规格必须不变、单价必须与闸门一完全一致;授权锁定的是
|
||||
最高总价而不是旧观察价。价格变化即停。
|
||||
- 图片搜索(V2)的唯一产出是 `goods_id`,同样不读价。
|
||||
|
||||
### 1.4 安全与隐私
|
||||
|
||||
- 检测到验证码、风控、人脸、短信校验时立即停止,**不尝试绕过**。
|
||||
- 检测到外部支付交接立即停止,**不读取、不保存、不输入任何凭据**。
|
||||
- 只读非敏感摘要,**不提取收货地址原文、手机号、支付凭据**。
|
||||
- 上传服务端的证据必须先脱敏。
|
||||
- 页面已显示的地址和手机号允许随**内部原始截图**上传采购服务,供已登录管理员核对;不做遮罩或裁剪。
|
||||
- 地址和手机号不得被解析为结构化业务字段,也不得写入普通日志、Git、Vikunja、fixture 或错误消息。
|
||||
原始完整 XML 只留采购工具本机;真机流程可在内存中读取,但只能返回规格、选中态、价格和页面状态摘要。
|
||||
- 截图上传器只能接收显式文件,不得枚举原始目录或顺带上传 XML/manifest/其他文件;服务端必须校验
|
||||
设备身份和 SHA-256,证据只允许已登录管理员访问,不得提供公开静态目录。
|
||||
- **支付凭据仍不得读取、保存或上传。** 外部支付交接不在“内部原图允许上传”的例外范围内。
|
||||
- T-103 停止遮罩器开发并专注真机选择/读价;生产原始截图上传由 T-204 实现。
|
||||
|
||||
### 1.5 页面判据
|
||||
|
||||
- **不得从前序项目、旧文档或推理直接写页面判据。** 必须有本项目的真机取证。
|
||||
- 每条判据必须记录取证时的**拼多多 App 版本**。
|
||||
- 购买语义按钮不能按“作用相同”共享判据。`快要抢光`、`免拼购买`、`单独购买`、`直接拼成` 等
|
||||
每个入口文案都必须分别取证;只允许精确唯一匹配,不允许包含、前缀、相似或坐标兜底。
|
||||
- 判据失效时先重新取证,不要靠加兜底分支硬扛。
|
||||
|
||||
> 这些红线不是建议。[`04-architecture.md`](04-architecture.md) 第四节列出的每一条都必须有
|
||||
@@ -85,8 +96,9 @@
|
||||
- V2 / V3 只记录,不实现。**图搜、Excel、ERP、订单自动核对、AI 辅助全部不在 MVP。**
|
||||
- 需求明确排除的非目标不得实现。
|
||||
- 不为「将来可能用到」提前抽象。
|
||||
- **Phase 1 真机结论出来之前不写 Phase 2 的页面**——确认页显示什么,取决于真机上
|
||||
能读到什么,尤其是规格面板上的单价。
|
||||
- **Phase 1 真机结论出来之前,不写依赖真机可读字段的生产执行功能。** T-201 管理会话、只创建
|
||||
`DRAFT` 的 T-202,以及 T-203 只锁定现有任务字段的服务端开始采购授权事务可以并行;T-205 起
|
||||
的实际 attempt / 页面证据等待 T-103。T-201 / T-202 不得夹带授权、机器结果、提交或付款字段。
|
||||
|
||||
## 5. 架构纪律
|
||||
|
||||
|
||||
+90
-93
@@ -1,40 +1,37 @@
|
||||
# 任务路线图(Roadmap)
|
||||
|
||||
> 本文是**只读路线图**:维护阶段划分、里程碑、待办池和建议拆分清单。
|
||||
> 真实任务以「一任务一文件」存放在 [`tasks/`](tasks/README.md)(`docs/tasks/T-<编号>.md`),
|
||||
> 状态权威在任务文件 frontmatter。**本文不跟踪单任务状态。**
|
||||
> 本文维护阶段、依赖和建议拆分,不跟踪状态。真实状态、写路径和执行证据在
|
||||
> [`docs/tasks/T-XXX.md`](tasks/README.md);状态权威是 git frontmatter。
|
||||
|
||||
## 使用规则
|
||||
|
||||
1. **开工先落文件**:从下方清单把下一个任务落成 `docs/tasks/T-<编号>.md`(沿用建议编号),
|
||||
把验收要点展开成可执行、可观察的步骤,再开始实现。
|
||||
2. **每个 agent 一次只做一个任务**:领取、状态流转、执行记录、完成定义遵循
|
||||
[`tasks/README.md`](tasks/README.md) 和[编码规则](05-coding-rules.md)。
|
||||
3. **不跳步**:依赖未完成的任务不能开工。Phase 0 可先做使用假数据的低保真交互原型;
|
||||
**Phase 1 的真机结论出来之前不写 Phase 2 的生产页面**,T-103 若改变可读字段则先修订原型与 IX。
|
||||
4. **本文只在规划变化时修改**:单个任务开工或完成**不**修改本文。
|
||||
5. **动手前**先读 `00-ai-start-here.md`、`05-coding-rules.md` 和 `current-state.md`。
|
||||
1. 从路线图领取任务前先落任务文件、边界、写路径和验收门禁。
|
||||
2. 一个 agent 同时只负责一个 `DOING`;只有写路径互不重叠才并行。
|
||||
3. `needs_device: true` 只能由人完成验收,agent 写完代码仍保持 `DOING`。
|
||||
4. 页面判据必须先在本项目真机取证。真机结论变化时先修订契约/原型,再写生产流程。
|
||||
5. 管理员点击“开始采购”是创建待付款订单的授权;单趟业务流程不等于跳过分段取证。
|
||||
|
||||
## 并行执行与关键路径
|
||||
|
||||
执行顺序以任务表中的 `依赖` 为准,Phase 是里程碑分组,**不是要求整段串行的栅栏**。
|
||||
在 `write_paths` 不重叠且依赖已完成的前提下,按以下波次推进:
|
||||
Phase 是里程碑分组,不是整段串行栅栏。推荐波次:
|
||||
|
||||
| 波次 | 采购服务方向 | 采购工具 / 真机方向 | 集成方向 |
|
||||
| 波次 | 采购服务 agent | 采购工具 agent | 大脑 / 集成 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | T-001 初始化 `admin/` | T-002 初始化 `client/` | - |
|
||||
| 2 | T-001 后立即做 T-004 | T-002 后立即做 T-101 → T-102 → **T-103** | T-001、T-002 都完成后做 T-003 |
|
||||
| 3 | T-103 通过后推进 T-201 → T-207 | T-103 后推进 T-104 → T-107 | T-208 等待 T-207 与 T-107 均完成 |
|
||||
| 4 | T-301 → T-302 | T-302 后交接 T-303 → T-304 → T-306 | T-306 与 T-104 完成后做 T-305 |
|
||||
| 5 | - | T-305、T-208、T-306 后做 T-401 | T-401 后并行 T-402 / T-403,再做 T-404 → T-405 |
|
||||
| 1 | T-001 → T-004 → T-201 → T-202 | T-002 → T-101 → T-102 | T-003、原型与契约 |
|
||||
| 2 | T-209 → T-203 → T-204 | T-103 → T-104 → T-105 → T-107 | T-111 单趟重构;逐任务主审 |
|
||||
| 3 | T-205 → T-207 → T-208 | T-301 → T-304 → T-306 | 双端契约完整门禁 |
|
||||
| 4 | - | T-305 | 围栏前端到端 dry-run 主审 |
|
||||
| 5 | T-402 / T-403 | T-401 | 真机人工授权、提交与调和验收 |
|
||||
| 6 | - | T-405 | T-404 完整验收 |
|
||||
|
||||
**T-103 是当前最高优先级和 MVP 生死线。** T-002 一完成就启动 T-101,不等待 T-003、
|
||||
T-004 或整个 Phase 0 收尾。并行只优化等待关系,不改变下列门禁:
|
||||
并行门禁:
|
||||
|
||||
- T-103 的真机结论出来前,不写依赖真机可读字段的 Phase 2 生产页面。
|
||||
- `needs_device: true` 的任务仍只能由人完成验收。
|
||||
- 不复用前序项目页面判据,不放宽三道价格闸门,不让第一趟引用任何下单函数。
|
||||
- 第三个 agent 优先做写路径独立的集成任务或只读复核,不与两端任务争写共享文档。
|
||||
- T-202 只创建/展示 `DRAFT`,可与 T-103 并行;T-203 只实现管理员授权与任务状态,不依赖页面
|
||||
选择器,也可在 T-103 完成前推进。
|
||||
- T-205 起涉及实际 attempt/证据字段,必须等待 T-103 确认规格和价格最小事实。
|
||||
- T-103、T-105、T-106、T-107 的 capability 相互隔离;未经取证的后续动作不可提前暴露。
|
||||
- T-401 前任何任务都不得真实点击“提交订单”;支付能力永远不实现。
|
||||
- 主 agent 独立审阅每个子 agent 的 diff 并重跑验证,不以其自报完成作为证据。
|
||||
|
||||
## 建议拆分清单
|
||||
|
||||
@@ -42,100 +39,100 @@ T-004 或整个 Phase 0 收尾。并行只优化等待关系,不改变下列
|
||||
|
||||
| ID | 任务 | 依赖 | 验收要点 |
|
||||
| --- | --- | --- | --- |
|
||||
| T-001 | 初始化采购服务 `admin/` Go 骨架 | - | 在 `admin/` 创建 Go 模块;`go test ./...`、`go vet ./...` 可运行;健康检查端点可访问;用真实命令替换 `03-tech-stack.md`、`00-ai-start-here.md`、`current-state.md` 中的占位命令 |
|
||||
| T-002 | 初始化采购工具 `client/` Python 骨架 | - | 在 `client/` 创建虚拟环境、`requirements.txt` 与应用骨架;`unittest`、`python -m compileall` 可运行;日志与产物目录策略明确且不记录敏感信息 |
|
||||
| T-003 | 建立 `init.ps1` 统一入口 | T-001, T-002 | 一条命令完成两端安装与基础验证并打印启动命令;未配置时主动失败而不是静默跳过 |
|
||||
| T-004 | 建立核心数据模型与状态机 | T-001 | `tasks`、`order_authorizations`、`order_submissions` 表与 `04-architecture.md` 一致;状态流转有单元测试;金额用十进制字符串 |
|
||||
| T-005 | 网页端 MVP 交互原型 | - | “采购服务”的 `docs/design/web-*.html` 单文件假数据原型覆盖登录、表格工作台、创建弹窗、批量开始试选、路由化详情抽屉与完整详情;键盘、窄屏、空态 / 错误 / 加载、围栏后调和状态可演示;经人工确认前保持 `DOING` |
|
||||
| T-006 | 桌面端 MVP 交互原型 | - | `docs/design/desk-*.html` 单文件假数据原型覆盖“采购工具”的采购执行 / 配置固定页签、顶部轮询控制、当前任务图片、滚动日志、时间倒序执行记录,以及左侧当前任务 / 记录详情双视图;明确 dry-run / 真实下单、App 版本失配、围栏后不可重试;经人工确认前保持 `DOING` |
|
||||
| T-007 | 统一产品名称与源码目录契约 | - | 项目名固定为 `cmbuyer`;采购服务使用 `admin/`,采购工具使用 `client/`;架构、任务和启动脚本不再把 `web/` / `desk/` 当生产源码目录 |
|
||||
| T-001 | 初始化采购服务 `admin/` | - | Go 模块、健康检查、`go test` / `go vet` 可运行 |
|
||||
| T-002 | 初始化采购工具 `client/` | - | Python 包、unittest、compileall、日志/产物目录可运行 |
|
||||
| T-003 | 建立 `init.ps1` 统一入口 | T-001, T-002 | 一条命令安装并验证两端;失败明确,不静默跳过 |
|
||||
| T-004 | 建立核心数据模型与状态机 | T-001 | 基础 tasks / authorization / submission 模型、十进制金额和状态测试 |
|
||||
| T-005 | 网页端 MVP 原型 | - | 采购服务登录、传统表格、创建、批量开始采购、路由详情、待付款/调和状态可演示 |
|
||||
| T-006 | 桌面端 MVP 原型 | - | 采购工具固定 tabs、轮询、当前任务/图片/日志、记录详情双视图和单趟状态可演示 |
|
||||
| T-007 | 统一产品与目录名称 | - | 项目 `cmbuyer`;网页端“采购服务”在 `admin/`;桌面端“采购工具”在 `client/` |
|
||||
| T-010 | 原型与交互核对 | T-005, T-006 | 人工确认 P0 页面、布局、键盘、异常和无生产副作用 |
|
||||
|
||||
### Phase 1 · 真机取证(最高风险,必须先做)
|
||||
### Phase 1 · 真机取证(最高风险)
|
||||
|
||||
> 本阶段每个任务都需要真机,**只能由人完成验收**。结论写入任务文件并同步
|
||||
> `04-architecture.md`;**每条页面判据必须记录拼多多 App 版本**。
|
||||
> 每个任务均 `needs_device: true`。截图、XML、goods_id、设备、Android、连接方式和拼多多 App
|
||||
> 版本写入任务记录;只有人能把任务标为 DONE。
|
||||
|
||||
| ID | 任务 | 依赖 | 验收要点 |
|
||||
| --- | --- | --- | --- |
|
||||
| T-101 | 验证 ADB 与 uiautomator2 连接(USB + WiFi) | T-002 | 两种连接都能列设备、截图、`dump_hierarchy(compressed=False)`;同一手机双通道在线时报明确错误;超时参数可配置 |
|
||||
| T-102 | 验证按链接打开商品详情页 | T-101 | 输入 `goods_id` 链接后真机进入对应详情页;打不开时有可区分的失败原因;保存截图与页面 XML |
|
||||
| T-103 | 验证规格面板打开、按维度精确选择、**读取 SKU 单价** | T-102 | 能打开规格面板;按 `颜色分类=X`、`尺码=Y` 精确选中并读回确认;**防前缀碰撞**;找不到精确值时停止且不点相近项;**取证单价在哪个节点、是否带券后前缀**——闸门一依赖此结论 |
|
||||
| T-104 | 验证试选后安全退出并释放手机 | T-103 | 读完价截完图后退出商品页;**全程不点击「现在买」或任何下单入口**,有测试证明;退出后可立即开始下一条任务 |
|
||||
| T-105 | 验证数量设置与复核 | T-103 | 设置后读回精确等于要求值;不等时停止,不进入购买入口 |
|
||||
| T-106 | dry-run:验证进入订单确认页并读「实付款」 | T-105 | 进入确认页读出规格、数量、实付金额;验证提交控件唯一但**绝不点击**;不提取地址原文与手机号;保存截图与 XML 证据到任务产物 |
|
||||
| T-107 | 固化提交控件判据与 dry-run 安全边界 | T-106 | 只把本项目真机证据转成可测试判据;第一趟和 dry-run 路径不可达 `submit_order`;App 版本不匹配时 fail closed;**不创建真实订单** |
|
||||
| T-101 | 验证 ADB / uiautomator2(USB + WiFi) | T-002 | 两通道截图和 dump;同手机双通道 fail closed;超时可配置 |
|
||||
| T-102 | 验证按链接打开商品详情页 | T-101 | canonical goods 链接进入对应商品;前台包与 goods_id 有证据 |
|
||||
| T-103 | 验证受控规格面板、精确选择与 SKU 单价 | T-102, T-110, T-111 | 精确唯一入口、维度内等值选择/读回、防前缀碰撞、当前价唯一读取;隔离 capability 不含数量/确认/提交 |
|
||||
| T-104 | 验证规格选择能力安全退出 | T-103 | 完成选择/读价/截图后可关闭面板或退出;不进入确认页、不提交 |
|
||||
| T-105 | 验证数量设置、读回与闸门二 | T-103 | 设置数量并精确读回;再次读规格/单价等于闸门一;不一致即停 |
|
||||
| T-106 | dry-run:确认页与闸门三取证 | T-105 | 进入确认页读规格、数量、应付总额并验证提交控件唯一;绝不点击 |
|
||||
| T-107 | 固化确认页与提交控件安全判据 | T-106 | 只使用本项目证据;dry-run 静态不可达提交;版本失配 fail closed |
|
||||
| T-110 | 批准证据绑定的受控规格入口 | T-103 no-go 决策 | 仅批准拼多多 8.17.0 已取证的精确唯一“快要抢光”,不泛化 |
|
||||
| T-111 | 调整为开始采购授权的单趟流程 | T-004, T-010, T-110 | 状态/API/原型/任务依赖一致;删除试选后确认但不放宽三闸门和围栏 |
|
||||
|
||||
> **T-106 与 T-107 只做只读演练,不得点击提交订单。** 本路线图中首次允许创建真实订单
|
||||
> 的任务是 T-401;执行前必须取得明确授权,并在任务文件记录订单是否产生、如何处置。
|
||||
T-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次允许创建真实待付款订单的是 T-401,
|
||||
执行前必须有明确人工授权,并记录订单是否产生及如何处置。
|
||||
|
||||
### Phase 2 · 采购服务核心
|
||||
|
||||
| ID | 任务 | 依赖 | 验收要点 |
|
||||
| --- | --- | --- | --- |
|
||||
| T-201 | 管理员登录与会话 | T-004, T-005 | 登录建立会话;CSRF 生效;未登录跳转并只接受站内返回路径 |
|
||||
| T-202 | 手工建单(F-001) | T-201 | 列表内创建弹窗与 `/tasks/new` 复用表单;任务名称、链接、颜色分类、尺码、数量、价格上限校验;成功创建 `DRAFT` 并倒序显示第一行 |
|
||||
| T-203 | 任务表格、查询与批量开始试选(F-004、F-018) | T-202 | 传统表格无操作列;标题关键词筛选;只选 `DRAFT`;批量 `DRAFT → PENDING` 全有或全无;空状态可清除筛选 |
|
||||
| T-204 | 路由化任务详情(F-011) | T-203 | 从列表进入显示保留现场的右侧抽屉,直达显示完整页;按状态呈现唯一主区块与主动作;执行证据可查看 |
|
||||
| T-205 | 试选结果接收与确认页(F-007) | T-204, T-103 | 接收 `spec_trials` 与规格面板截图;确认页展示需求 vs 所选、单价、合计、截图;**轻量版,非对照台** |
|
||||
| T-206 | 人工确认与授权签发(F-008) | T-205 | 确认即签发一次性授权并**锁定试选单价**;`expected_task_version` 冲突返回 409;金额上限服务端校验;支持「退回,不买」 |
|
||||
| T-207 | 授权超时与围栏前放弃(F-010) | T-206 | 围栏前超时 / 放弃后任务转 `PENDING_RETRIAL`,必须重新试选;围栏后禁止超时释放或放弃,改走人工核查;每个状态都有安全下一步 |
|
||||
| T-208 | 提交围栏与结果调和 API(F-017) | T-207, T-107 | dry-run start / ready、submission start / reconcile / manual-review 幂等;围栏事务原子消费执行权;响应不明不允许点击;不确定结果保留额度且不可重试 |
|
||||
| T-201 | 管理员登录与会话 | T-004, T-005 | 会话、CSRF、站内 return path 与权限测试 |
|
||||
| T-202 | 手工建单(F-001) | T-201 | 创建/展示 `MANUAL + DRAFT`;链接/金额/幂等/倒序;不含授权或执行 |
|
||||
| T-209 | 把核心 schema / 状态机迁移为单趟模型 | T-004, T-111 | 新 migration 安全替换旧 `spec_trials` / 授权锁价结构;新增 `purchase_attempts`;删除旧 MVP 状态;无业务数据事实写入迁移前置,遇未知旧数据 fail closed;领域/迁移测试同步 |
|
||||
| T-203 | 表格查询与批量开始采购(F-004、F-008、F-018) | T-202, T-209 | 只选 DRAFT;按钮明确“不付款”;事务内创建锁定任务字段的一次性授权并全批 `DRAFT → PENDING`;重放/冲突测试 |
|
||||
| T-204 | 路由化任务详情与内部截图资产(F-007、F-011) | T-203 | 抽屉/完整页共享 URL;状态详情;受保护 `INTERNAL_RAW` screenshot 上传/读取、SHA-256 与 no-store;不接收 XML |
|
||||
| T-205 | purchase attempt、事件与执行摘要 API | T-204, T-103 | 记录实际规格、两次单价、数量、确认页金额、固定 reason;不再创建 `spec_trials` 审批记录 |
|
||||
| T-206 | 单趟状态视图与安全主动作 | T-205 | PENDING/CLAIMED/ORDERING/NEEDS_MANUAL/WAITING_PAYMENT/RECONCILIATION_REQUIRED 显示和允许动作一致;无中途确认 |
|
||||
| T-207 | 授权有效期与围栏前重置(F-010) | T-206 | 围栏前过期/关闭旧授权后回 DRAFT;围栏后拒绝释放、取消、重新授权 |
|
||||
| T-208 | 提交围栏与结果调和 API(F-017) | T-207, T-107 | 原子复核授权/attempt/三闸门/唯一控件;首次许可一次;重放不许可点击;结果不明永久围栏且只调和 |
|
||||
|
||||
### Phase 3 · 双端打通(第一趟)
|
||||
### Phase 3 · 双端打通(单趟围栏前)
|
||||
|
||||
| ID | 任务 | 依赖 | 验收要点 |
|
||||
| --- | --- | --- | --- |
|
||||
| T-301 | 设备凭据与身份隔离(F-013) | T-201 | 设备 Bearer 不能建单或授权;管理会话不能调设备接口;凭据可撤销 |
|
||||
| T-302 | 原子领取与租约(F-005) | T-301, T-004 | 并发领取只有一个成功;重复领取重放同一结果;**同时支持领取待试选与已授权两类**;`claim_token` 与 `claim_generation` 校验生效 |
|
||||
| T-303 | 采购工具 `HttpTaskSource` / `HttpResultSink` | T-302, T-002 | 执行器只依赖抽象;测试假数据与断连 JSONL 暂存不扩大 Excel MVP 范围;补传使用幂等键 |
|
||||
| T-304 | 定时轮询与会话边界 | T-303, T-006 | 只在执行员启动的会话内轮询,关窗口即停;连续失败达阈值自动停止并提示原因;间隔可配置 |
|
||||
| T-306 | 证据上传与分层 | T-304, T-103 | 规格面板截图与订单截图上传服务端并脱敏;节点树与失败现场留本地;上传前校验不含地址与手机号 |
|
||||
| T-305 | **第一趟试选端到端** | T-306, T-104 | 从领取跑到试选回传:开商品、勾选规格、读单价、截图、退出释放手机;任务转「等你确认」;全程有事件与证据 |
|
||||
| T-301 | 设备凭据与身份隔离(F-013) | T-201 | Bearer 不能建单/授权;管理会话不能领任务;凭据可撤销 |
|
||||
| T-302 | 已授权任务原子领取与租约(F-005) | T-301, T-203 | 只领 PENDING+有效授权;并发唯一;重放同一 attempt;claim token/generation 有效 |
|
||||
| T-303 | `HttpTaskSource` / `HttpResultSink` | T-302, T-002 | 执行器依赖抽象;幂等重放;线协议严格校验;不扩大 Excel 范围 |
|
||||
| T-304 | 定时轮询与会话边界 | T-303, T-006 | 人启动后轮询;停止只阻止下次领取;连续失败停;当前任务/记录详情双视图 |
|
||||
| T-306 | 单趟截图与证据上传 | T-304, T-204 | 显式上传原始 screenshot;可含页面地址/手机号;完整 XML、本机路径、外部支付页不上传 |
|
||||
| T-305 | **单趟围栏前端到端 dry-run** | T-306, T-107, T-205 | 从领取连续跑到闸门三与提交控件验证,随后安全停止;全程事件/证据齐全,不申请真实许可、不点击提交 |
|
||||
|
||||
### Phase 4 · 第二趟与收尾
|
||||
### Phase 4 · 真实提交与收尾
|
||||
|
||||
| ID | 任务 | 依赖 | 验收要点 |
|
||||
| --- | --- | --- | --- |
|
||||
| T-401 | **第二趟下单端到端** | T-305, T-208, T-306 | 拿到授权后重新开商品、重新选同一规格、过三道闸门、原子建立提交围栏后提交一次;不确定结果进入调和且不可重试;明确创建才转「待付款」 |
|
||||
| T-402 | 待付款收口与手工完成 | T-401 | 详情页展示订单截图与授权信息供核对;人付款后手工标记完成;**待付款不等于成功** |
|
||||
| T-403 | 失败分类与证据归档(F-011) | T-401 | 覆盖需求列出的全部失败原因;终态一次原子回写,不落中间态 |
|
||||
| T-404 | 完整验收 MVP | T-402, T-403 | `02-requirements.md` 的 P0 验收全部通过;真机记录写入任务文件与 `current-state.md` |
|
||||
| T-405 | 采购工具打包与运行文档 | T-404 | 运营电脑可按文档运行;记录产物 SHA-256 |
|
||||
| T-401 | **单趟采购与提交围栏端到端** | T-305, T-208 | 同一设备会话通过三闸门、首次明确围栏许可后点击一次;结果不明只调和;明确创建转待付款;绝不付款 |
|
||||
| T-402 | 待付款收口与手工完成 | T-401 | 详情展示订单证据;人付款后手工标记;待付款不等于成功 |
|
||||
| T-403 | 失败分类与证据归档 | T-401 | 覆盖需求 reason;终态原子回写;围栏后无重试 |
|
||||
| T-404 | 完整 MVP 验收 | T-402, T-403 | P0 验收、双端门禁、真机记录和 current-state 全部通过 |
|
||||
| T-405 | 采购工具打包与运行文档 | T-404 | 运营电脑可安装运行,记录产物 SHA-256 |
|
||||
|
||||
### V2 及以后(不在 MVP,编号预留)
|
||||
### V2 及以后
|
||||
|
||||
| ID | 任务 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| T-501 | 图片搜索产出 goods_id(F-014) | 推图到相册、搜图、从结果页取 goods_id;**不读价格**;最多 5 个 |
|
||||
| T-502 | 候选对照台(F-015) | 多候选并排对照,跨列逐行对齐,窄屏降级 |
|
||||
| T-503 | Excel 批量建单(F-002) | 被表头契约待确认阻塞 |
|
||||
| T-504 | ERP 货运同步与建单(F-003) | 被字段映射待确认阻塞 |
|
||||
| T-505 | 批量顺序编排与人工接管(F-012) | 勾选多条、暂停继续、运行中冻结 |
|
||||
| T-506 | 订单自动核对回读(F-016) | 五项唯一匹配才自动回写,否则待人工 |
|
||||
| T-507 | 本地执行轨迹留档(F-103) | NDJSON,含规则与模型判断的分歧字段 |
|
||||
| T-508 | AI 辅助(F-101、F-102) | 模型结论**不能放宽任何安全边界** |
|
||||
| T-501 | 图片搜索产出 goods_id(F-014) | 不在结果页读价;最多 5 个 |
|
||||
| T-502 | 候选对照台(F-015) | 多候选对照,窄屏降级 |
|
||||
| T-503 | Excel 批量建单(F-002) | 等表头契约 |
|
||||
| T-504 | ERP 同步与建单(F-003) | 等字段映射 |
|
||||
| T-505 | 批量编排与运行中接管(F-012) | 排序、暂停继续、运行中冻结 |
|
||||
| T-506 | 订单自动核对回读(F-016) | 五项唯一匹配,否则人工 |
|
||||
| T-507 | 本地执行轨迹留档(F-103) | NDJSON 与模型/规则分歧 |
|
||||
| T-508 | AI 辅助(F-101、F-102) | 模型结论不能放宽安全边界 |
|
||||
|
||||
## 里程碑
|
||||
|
||||
- **M0**:网页端与桌面端 P0 原型经人工确认,流程、状态与主动作可枚举。(T-005、T-006)
|
||||
- **M1**:两端骨架可运行,数据模型与状态机落地。(Phase 0)
|
||||
- **M2**:真机能按链接打开商品、精确勾选颜色分类和尺码、**读到该 SKU 单价**。(T-103)
|
||||
- **M3**:真机能设对数量、以 dry-run 进入订单确认页读到「实付款」并验证唯一提交控件,
|
||||
但不点击。(T-107)
|
||||
- **M4**:管理员能建单、看到试选结果、确认并签发授权。(Phase 2)
|
||||
- **M5**:第一趟试选端到端跑通,任务能停在「等你确认」。(T-305)
|
||||
- **M6**:MVP 闭环——第二趟下单成功,任务停在「待付款」。(T-401)
|
||||
- **M0**:两端原型确认;单趟主动作、状态、异常和键盘可枚举。(T-005、T-006、T-111)
|
||||
- **M1**:两端骨架、基础模型与验证入口可运行。
|
||||
- **M2**:真机按链接打开商品、精确选择颜色/尺码并读 SKU 单价。(T-103)
|
||||
- **M3**:真机设置数量、闸门二、确认页闸门三和唯一提交控件 dry-run 通过,不点击。(T-107)
|
||||
- **M4**:管理员建单并通过批量开始采购创建一次性授权;设备可安全领取。(T-203、T-302)
|
||||
- **M5**:单趟从领取到围栏前 dry-run 跑通,事件与证据齐全。(T-305)
|
||||
- **M6**:单趟真实提交只创建待付款订单,围栏/调和/人工收口闭环。(T-401~T-404)
|
||||
|
||||
**M2 是本项目的生死线。** 前序项目正是卡在选规格和读价;M2 不通过之前不要写 Phase 2
|
||||
的生产页面。Phase 0 原型只用于确认信息架构,T-103 若改变可读字段必须先回修原型与 IX。
|
||||
**M2 仍是生死线。** M2 前可以并行管理会话、DRAFT 建单和开始采购的服务端授权事务,但不能编写
|
||||
依赖未取证页面字段的生产执行逻辑。提速来自两端并行、窄任务和主审复用门禁,不来自删除安全闸门。
|
||||
|
||||
## 待办池(Backlog)
|
||||
## 待办池
|
||||
|
||||
- V2 全部条目见上方「V2 及以后」表(T-501 ~ T-508)
|
||||
- 多设备并行(F-104)
|
||||
- 完整 RBAC(F-105)
|
||||
- 多平台比价(F-106)
|
||||
- 证据保留期与自动清理策略
|
||||
- 采购服务从 SQLite 迁移到 Postgres 的评估
|
||||
- 设备凭据轮换机制
|
||||
- 定时轮询间隔的风控友好节奏(需真机观察后确定)
|
||||
- V2:T-501~T-508;多设备、完整 RBAC、多平台。
|
||||
- 证据保留期和自动清理策略。
|
||||
- SQLite → Postgres 迁移评估。
|
||||
- 设备凭据轮换。
|
||||
- 风控友好的轮询与动作间隔(需真机观察)。
|
||||
|
||||
+89
-209
@@ -1,259 +1,139 @@
|
||||
# 用户故事清单
|
||||
|
||||
> 本文记录「谁在什么场景下,为了获得什么价值,要完成什么目标」。不写接口、数据字段、
|
||||
> 组件实现或逐个按钮的行为。
|
||||
> 页面如何响应操作见[交互清单](08-interaction-checklist.md);页面入口见[路由与页面结构](routes.md);
|
||||
> 接口形状以 [API 合约](api.md) 为准。
|
||||
> 本文从用户目标描述 MVP。功能编号以[需求](02-requirements.md)为准,具体界面行为以
|
||||
> [交互清单](08-interaction-checklist.md)为准。
|
||||
|
||||
## 一、职责边界
|
||||
|
||||
| 信息 | 权威文档 |
|
||||
| --- | --- |
|
||||
| MVP 范围、优先级、非目标 | [需求](02-requirements.md) |
|
||||
| 用户目标、场景与验收场景 | 本文 |
|
||||
| 页面操作、状态与反馈 | [交互清单](08-interaction-checklist.md) |
|
||||
| 页面入口、导航与组件归属 | [路由与页面结构](routes.md) |
|
||||
| 接口、事件与错误格式 | [API 合约](api.md) |
|
||||
|
||||
US 编号一经引用不再重用。需求变化时先改[需求](02-requirements.md),再同步本文。
|
||||
- 采购管理员决定买什么、最高允许多少钱,并通过“开始采购”明确授权创建待付款订单。
|
||||
- 采购工具只执行已授权任务,不修改规格、数量或金额上限。
|
||||
- 系统不付款;待付款订单由人核对、付款并手工收口。
|
||||
- 截图是内部审计证据,不是提交前审批环节。
|
||||
|
||||
## 二、用户故事总表
|
||||
|
||||
| ID | 标题 | 优先级 | 角色 | 要达成的目标 | 关联功能 | 关联交互 | 状态 |
|
||||
| ID | 故事 | 优先级 | 角色 | 用户价值 | 功能 | 交互 | 状态 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| US-001 | 把「要买什么」变成一条任务 | P0 | 采购管理员 | 填一次链接和规格,后面不用再管 | F-001 | IX-002 | 已定 |
|
||||
| US-002 | 知道每条任务现在卡在谁那里 | P0 | 采购管理员 | 一眼分出「等我」和「机器在跑」,快速找到要处理的那条 | F-004、F-011 | IX-003、IX-004 | 已定 |
|
||||
| US-003 | 让手机自己跑,不用我盯着 | P0 | 采购执行员 | 开一次轮询,手机排着队跑,只在停下来时介入 | F-005、F-006 | IX-007、IX-008 | 已定 |
|
||||
| US-004 | 确认机器有没有选对 | P0 | 采购管理员 | 看一眼机器实际勾选到的规格和价格,判断能不能买 | F-007、F-008 | IX-005 | 已定 |
|
||||
| US-005 | 放心地授权一笔下单 | P0 | 采购管理员 | 授权后确信系统只下单不付款,且不会因断网重复下单 | F-008、F-009、F-010、F-017 | IX-005、IX-010、IX-011 | 已定 |
|
||||
| US-006 | 付款前核对订单再花钱 | P0 | 采购管理员 | 对着系统给的几项组织人工核对,不一致就不付 | F-009 收口 | IX-006 | 已定 |
|
||||
| US-007 | 管理身份和设备身份互不越权 | P0 | 采购管理员 | 设备被盗用也不能建单或改授权 | F-013 | IX-001 | 已定 |
|
||||
| US-008 | 任务出问题时知道该怎么办 | P0 | 采购执行员 | 看到具体原因和下一步,而不是一个「失败」 | F-011 | IX-008 | 已定 |
|
||||
| US-010 | 把选中的任务交给机器试选 | P0 | 采购管理员 | 一次选中多条待开始任务,让桌面端逐条领取第一趟试选 | F-018 | IX-012 | 已定 |
|
||||
|
||||
> US-009(从 ERP 货运明细建单)随 F-003 移出 MVP,编号保留不重用。
|
||||
| US-001 | 把“要买什么”变成待开始任务 | P0 | 采购管理员 | 一次填写链接、规格、数量和最高总价 | F-001 | IX-002 | 已定 |
|
||||
| US-002 | 知道任务当前卡在哪里 | P0 | 采购管理员 | 快速找到待授权、执行中、待付款或待人工任务 | F-004、F-007、F-011 | IX-003、IX-004 | 已定 |
|
||||
| US-003 | 让手机逐条执行已授权任务 | P0 | 采购执行员 | 开启轮询后无需盯每一步,异常时再介入 | F-005、F-006 | IX-007、IX-008 | 已定 |
|
||||
| US-004 | 查看机器实际执行证据 | P0 | 采购管理员 | 追溯规格、价格、数量、围栏和截图 | F-007 | IX-004 | 已定 |
|
||||
| US-005 | 明确授权且不会重复下单 | P0 | 采购管理员 | 点击开始采购后,只创建一笔待付款订单且绝不自动付款 | F-008、F-010、F-017 | IX-005、IX-010、IX-011 | 已定 |
|
||||
| US-006 | 付款前核对订单 | P0 | 采购管理员 | 对照系统证据后由人决定是否付款 | F-006 收口 | IX-006 | 已定 |
|
||||
| US-007 | 管理身份与设备身份不越权 | P0 | 采购管理员 | 设备凭据泄露也不能建单或授权 | F-013 | IX-001 | 已定 |
|
||||
| US-008 | 出问题时知道下一步 | P0 | 采购执行员 | 看到具体阶段、原因和安全恢复方式 | F-011、F-017 | IX-008、IX-011 | 已定 |
|
||||
| US-010 | 批量授权待开始任务 | P0 | 采购管理员 | 一次把选中任务交给采购工具逐条创建待付款订单 | F-008、F-018 | IX-012 | 已定 |
|
||||
|
||||
## 三、故事详情
|
||||
|
||||
### US-001 把「要买什么」变成一条任务
|
||||
### US-001 把“要买什么”变成待开始任务
|
||||
|
||||
- 优先级:P0 | 关联功能:F-001 | 关联交互:IX-002
|
||||
- 角色:采购管理员
|
||||
- 前置条件:已登录,手上有拼多多商品链接和要买的颜色分类、尺码。
|
||||
作为采购管理员,我希望在任务列表点击创建,填写标题、拼多多链接、颜色、尺码、数量和最高总价,
|
||||
保存后立即在首行看到 `DRAFT`,以便稍后统一授权。
|
||||
|
||||
**用户故事**
|
||||
验收场景:
|
||||
|
||||
作为采购管理员,我想要填一次链接和规格就把任务交出去,从而不必自己再去手机上操作。
|
||||
- 合法输入保存成功并规范化商品链接;创建动作不触发设备和授权。
|
||||
- 链接、规格、数量或金额不合法时,在字段附近说明原因并保留其他输入。
|
||||
- 重复提交同一 `create_key` 只返回原任务,不创建重复记录。
|
||||
|
||||
**范围**
|
||||
### US-002 知道任务当前卡在哪里
|
||||
|
||||
- 包含:任务名称、手工填链接、颜色分类、尺码、数量、价格上限。
|
||||
- 不包含:Excel 批量导入、从 ERP 生成、任务模板(均为 V2)。
|
||||
作为采购管理员,我希望在传统表格中按标题和状态筛选,看到标题链接、颜色、尺码、价格、数量、
|
||||
采购结果、状态和创建时间,并双击行查看详情。
|
||||
|
||||
**验收场景**
|
||||
验收场景:
|
||||
|
||||
1. 假如我有商品链接和规格,当我在任务列表的创建弹窗填完并保存,那么得到任务编号,
|
||||
弹窗关闭,任务按创建时间倒序出现在表格第一行且状态为待开始。
|
||||
2. 假如链接无法解析出商品标识,当我提交,那么系统明确报错并保留我已填的内容。
|
||||
3. 假如我没填价格上限,当我提交,那么系统拒绝——**价格上限是资金边界,不能留空**。
|
||||
- 默认按创建时间倒序;标题是指向拼多多商品页的链接。
|
||||
- 没有操作列;详情通过双击行打开路由化抽屉,完整 URL 可复制,浏览器返回恢复列表现场。
|
||||
- 状态明确区分待开始、已授权待领取、执行中、待人工、围栏后待调和、待付款和终态。
|
||||
- 空筛选结果提供“清除筛选”,错误页保留安全重试;不把已围栏提交显示为可重试。
|
||||
|
||||
### US-002 知道每条任务现在卡在谁那里
|
||||
### US-003 让手机逐条执行已授权任务
|
||||
|
||||
- 优先级:P0 | 关联功能:F-004、F-011 | 关联交互:IX-003、IX-004
|
||||
- 角色:采购管理员
|
||||
- 前置条件:已登录,系统中有若干条不同状态的任务。
|
||||
作为采购执行员,我希望配置服务、设备 serial 和轮询间隔后,点击一次开始轮询,让采购工具逐条领取
|
||||
已授权任务并在同一趟中完成选规格、价格校验、数量、确认页和提交围栏。
|
||||
|
||||
**用户故事**
|
||||
验收场景:
|
||||
|
||||
作为采购管理员,我想要打开就看清哪几条在等我,从而不必逐条点进去确认还需不需要我
|
||||
处理。
|
||||
- 启动前同时校验采购服务、ADB 设备和已取证拼多多版本;任一不满足则不启动。
|
||||
- 顶部始终显示会话状态;当前任务、图片、滚动日志和倒序采购记录实时更新。
|
||||
- 精确规格、两次单价、数量读回、确认页总额或提交控件任一不合格时停止该任务。
|
||||
- 围栏明确成功后只点一次“提交订单”;系统绝不点击付款。
|
||||
- 连续轮询失败达到阈值自动停止;停止轮询不取消正在执行或已围栏的任务。
|
||||
|
||||
**范围**
|
||||
### US-004 查看机器实际执行证据
|
||||
|
||||
- 包含:传统任务表格、关键词与时间范围查询、任务详情抽屉和完整详情页中的结果与证据。
|
||||
- 不包含:自定义视图、导出报表、跨任务统计。
|
||||
作为采购管理员,我希望打开任务详情后看到本次授权、设备领取、实际规格、三道价格闸门、截图、
|
||||
提交围栏与结果,以便审计发生了什么,而不是在中途批准机器选择。
|
||||
|
||||
**验收场景**
|
||||
验收场景:
|
||||
|
||||
1. 假如有任务停在等待人工确认,当我打开工作台,那么表格状态与采购结果能让我一眼识别,
|
||||
双击该行即可在不丢失列表现场的情况下查看详情。
|
||||
2. 假如我按关键词筛选后没有匹配,当结果为空,那么显示空状态并提供清除筛选,而不是
|
||||
一片空白或报错。
|
||||
3. 假如任务失败了,当我打开详情,那么我看到可区分的失败原因和当时的截图,而不只是
|
||||
「失败」两个字。
|
||||
- 证据按 attempt 和时间排序,区分规格面板、确认页、提交结果。
|
||||
- 原始截图仅登录管理员可看,响应不缓存;完整 XML 不出现在页面或接口中。
|
||||
- 地址/手机号不提取为字段或日志,外部支付页和支付凭据不存储。
|
||||
- 执行中没有“确认机器选对了吗”或“确认后继续第二趟”按钮。
|
||||
|
||||
### US-003 让手机自己跑,不用我盯着
|
||||
### US-005 明确授权且不会重复下单
|
||||
|
||||
- 优先级:P0 | 关联功能:F-005、F-006 | 关联交互:IX-007、IX-008
|
||||
- 角色:采购执行员
|
||||
- 前置条件:电脑已连上 web 端;手机已连接、已解锁、已登录拼多多。
|
||||
作为采购管理员,我希望点击“开始采购(只创建待付款订单)”时明确知道影响范围,并确信系统不会
|
||||
超出金额上限、不会付款,也不会因为断网重复提交。
|
||||
|
||||
**用户故事**
|
||||
验收场景:
|
||||
|
||||
作为采购执行员,我想要开一次轮询就让手机排着队把任务跑掉,从而把注意力留给真正需要
|
||||
判断的时刻。
|
||||
- 只有 `DRAFT` 可选;操作条显示选中数量和最高总额。
|
||||
- 点击即创建一次性授权并整批转 `PENDING`;不再要求试选后确认。
|
||||
- 任一任务版本冲突时整批不变;同一开始键重放同一结果。
|
||||
- 设备在同一趟通过三道闸门;服务端围栏明确建立后才准许点击一次。
|
||||
- 围栏前失败可由人重置并重新授权;围栏后只能调和同一提交,永不出现“再次下单”。
|
||||
|
||||
**范围**
|
||||
### US-006 付款前核对订单
|
||||
|
||||
- 包含:定时轮询领取、第一趟试选、第二趟下单、连续失败自动停。
|
||||
- 不包含:多台手机并行、无人值守整夜运行、已开始任务的排序、暂停 / 继续和运行中接管(V2)。
|
||||
作为采购管理员,我希望任务转为待付款后看到商品、规格、数量、授权上限、确认页金额与订单截图,
|
||||
在拼多多核对后由人决定是否付款。
|
||||
|
||||
**验收场景**
|
||||
验收场景:
|
||||
|
||||
1. 假如有待处理任务且设备就绪,当我点击开始轮询,那么系统按间隔领取任务并显示**这一趟
|
||||
是试选还是下单**;两个实例并发不会领到同一条。
|
||||
2. 假如第一趟试选完成,当机器读完价截完图,那么**它退出商品页释放手机**,立刻可以开始
|
||||
下一条,而不是停在规格面板上等我。
|
||||
3. 假如某条任务需要人工,当它停下来,那么界面显著提示缺什么,该任务不再被本端领取,
|
||||
但轮询继续跑其他任务。
|
||||
4. 假如连续失败达到阈值,当失败累积,那么轮询自动停止并说明原因,**不无限重试**。
|
||||
5. 假如我关闭窗口,那么轮询立即停止,不留后台进程。
|
||||
- `WAITING_PAYMENT` 明确写“订单已创建,系统尚未付款”。
|
||||
- 标记完成前再次提示该操作只记录人工付款事实,不执行支付。
|
||||
- 人确认已付款后才转 `SUCCEEDED`;发现不一致时不付款并进入人工处理。
|
||||
|
||||
### US-004 确认机器有没有选对
|
||||
### US-007 管理身份与设备身份不越权
|
||||
|
||||
- 优先级:P0 | 关联功能:F-007、F-008 | 关联交互:IX-005
|
||||
- 角色:采购管理员
|
||||
- 前置条件:任务已完成第一趟试选并回传结果。
|
||||
作为采购管理员,我希望网页会话与设备 token 完全隔离,以便设备只能做被授权的执行工作。
|
||||
|
||||
**用户故事**
|
||||
验收场景:
|
||||
|
||||
作为采购管理员,我想要看一眼机器**实际勾选到**的规格和读到的价格,从而在它去下单之前
|
||||
确认没选错。
|
||||
- 未登录不能查看任务或截图;登录会话具备 CSRF 防护。
|
||||
- 设备 token 不能创建任务、开始采购、重置任务或人工调和。
|
||||
- 管理会话不能伪装设备领取或建立围栏;设备凭据可撤销。
|
||||
|
||||
**范围**
|
||||
### US-008 出问题时知道下一步
|
||||
|
||||
- 包含:需求与机器所选的逐项对照、单价与合计、规格面板截图、确认或退回。
|
||||
- 不包含:在多个候选之间挑选(MVP 只有一个商品,多候选对照台是 V2)。
|
||||
作为采购执行员,我希望错误包含阶段、固定原因和安全下一步,以便处理而不冒险重试。
|
||||
|
||||
**验收场景**
|
||||
验收场景:
|
||||
|
||||
1. 假如机器选到的颜色分类和尺码与我要的一致、合计没超上限,当我打开详情,那么各项
|
||||
显示 ✓ 且确认按钮可用。
|
||||
2. 假如机器选到的规格与我要的不符,当我打开详情,那么显示 ✗ 并说明哪一项不符,
|
||||
**确认按钮禁用**,我只能退回或转人工。
|
||||
3. 假如合计超出我设的上限,当我打开详情,那么显示超出多少且确认按钮禁用,
|
||||
**不提供「仍然确认」入口**。
|
||||
4. 假如价格读不到,那么任务根本不会进到这一步,而是直接转人工并说明原因。
|
||||
- 至少区分设备、App 版本、商品、面板、规格、价格、数量、确认页、围栏、安全校验和网络错误。
|
||||
- 围栏前错误提示停止、留证并等待人工处理;不会自动重新开始。
|
||||
- 围栏后错误明确写“可能已创建订单,只能调和,禁止再次点击”。
|
||||
- 桌面端详情切换不改变服务端状态;Esc 只回到当前任务视图,不取消业务流程。
|
||||
|
||||
### US-005 放心地授权一笔下单
|
||||
### US-010 批量授权待开始任务
|
||||
|
||||
- 优先级:P0 | 关联功能:F-008、F-009、F-010、F-017 | 关联交互:IX-005、IX-010、IX-011
|
||||
- 角色:采购管理员
|
||||
- 前置条件:任务处于等待确认,已看过试选结果。
|
||||
作为采购管理员,我希望勾选多条 `DRAFT` 后统一点击开始采购,以便减少重复操作,同时知道每条都
|
||||
会允许采购工具创建一笔待付款订单。
|
||||
|
||||
**用户故事**
|
||||
验收场景:
|
||||
|
||||
作为采购管理员,我想要确认后签发一次授权,从而让系统去下单,同时确信它不会替我付钱,
|
||||
也不会因为卡住而让任务永久停摆。
|
||||
|
||||
**范围**
|
||||
|
||||
- 包含:确认签发、锁定单价、退回不买、围栏前放弃、dry-run、提交围栏与结果调和。
|
||||
- 不包含:审批链、多人会签、金额分级授权。
|
||||
|
||||
**验收场景**
|
||||
|
||||
1. 假如我提交授权,当系统接受,那么明确告知只创建待付款订单、付款需我在拼多多完成。
|
||||
2. 假如授权时的单价是 32.50,当机器第二趟发现价格变了,那么它**停下来转人工**,
|
||||
不会按新价照下——我确认的是那个价格,不是那个商品。
|
||||
3. 假如一笔授权在提交围栏前卡住,当我需要重来,那么我可以放弃它,任务进入待重新试选。
|
||||
假如围栏已经建立,则不能放弃或重试,只能核查这一次提交。
|
||||
4. 假如授权自动过期,那么系统要求**重新试选取新价**后再确认,不复用旧价。
|
||||
5. 假如我重复提交同一笔授权,那么只产生一笔订单。
|
||||
6. 假如真实点击前网络超时、无法确认服务端是否已建立围栏,那么系统不点击并提示核查;
|
||||
假如点击后结果不明,那么系统保留围栏并进入调和,绝不再点一次。
|
||||
|
||||
### US-006 付款前核对订单再花钱
|
||||
|
||||
- 优先级:P0 | 关联功能:F-009 收口 | 关联交互:IX-006
|
||||
- 角色:采购管理员(采购执行员在拼多多完成人工付款并反馈结果)
|
||||
- 前置条件:系统已创建待付款订单,任务状态为等待付款。
|
||||
|
||||
**用户故事**
|
||||
|
||||
作为采购执行员,我想要拿着系统给的几项去拼多多逐一核对,从而不会付错单。
|
||||
|
||||
**范围**
|
||||
|
||||
- 包含:展示订单截图、商品、规格、数量与授权金额;人付款后手工标记完成。
|
||||
- 不包含:系统代付、免密支付、在拼多多改单、**自动回读核对(F-016,V2)**。
|
||||
|
||||
**验收场景**
|
||||
|
||||
1. 假如订单已创建,当我打开详情,那么我看到需要核对的项和设备回传的订单截图。
|
||||
2. 假如各项都对得上,当我在拼多多付完款回来点「已付款」,那么任务转为已完成并记录
|
||||
标记人与时间。
|
||||
3. 假如金额或规格对不上,当我发现不一致,那么我能「标记异常」转人工,
|
||||
**不能直接标记已付款**。
|
||||
4. 假如订单截图缺失,那么系统不允许标记完成,直接转人工——**无证据不得收口**。
|
||||
|
||||
### US-007 管理身份和设备身份互不越权
|
||||
|
||||
- 优先级:P0 | 关联功能:F-013 | 关联交互:IX-001
|
||||
- 角色:采购管理员
|
||||
- 前置条件:系统有管理员账号与已授权设备各一。
|
||||
|
||||
**用户故事**
|
||||
|
||||
作为采购管理员,我想要设备只能做执行、不能做决策,从而即使设备凭据泄露也不会有人
|
||||
凭它建单或改授权。
|
||||
|
||||
**验收场景**
|
||||
|
||||
1. 假如持有设备凭据,当尝试创建任务或签发授权,那么被拒绝并记录。
|
||||
2. 假如持有管理会话,当尝试调用设备接口,那么被拒绝。
|
||||
3. 假如设备凭据被撤销,当设备下次请求,那么立即失效且当前任务安全停止。
|
||||
|
||||
### US-008 任务出问题时知道该怎么办
|
||||
|
||||
- 优先级:P0 | 关联功能:F-011 | 关联交互:IX-008
|
||||
- 角色:采购执行员
|
||||
- 前置条件:任务执行中遇到异常。
|
||||
|
||||
**用户故事**
|
||||
|
||||
作为采购执行员,我想要看到具体卡在哪一步、为什么,从而知道是自己能处理还是要找管理员。
|
||||
|
||||
**验收场景**
|
||||
|
||||
1. 假如规格找不到精确匹配,那么系统说明缺的是颜色分类还是尺码、页面上有哪些可选值,
|
||||
并转人工——**不选相近的**。
|
||||
2. 假如规格面板上读不到单价,那么系统转人工并说明原因,
|
||||
**不用商品详情页正文的数字凑合**。
|
||||
3. 假如第二趟发现价格与授权价不一致,那么系统说明「授权 ¥X、现价 ¥Y」并转人工,
|
||||
既不按新价下单,也不自动放弃。
|
||||
4. 假如遇到验证码或风控,那么系统立即停止、保留截图、转人工,**不尝试绕过**。
|
||||
5. 假如点击提交订单后无法判断是否已创建,那么系统转人工并提示「订单可能已创建、
|
||||
支付未完成」,进入同一提交记录的调和,**不自动重试、不释放围栏**。
|
||||
|
||||
### US-010 把选中的任务交给机器试选
|
||||
|
||||
- 优先级:P0 | 关联功能:F-018 | 关联交互:IX-012
|
||||
- 角色:采购管理员
|
||||
- 前置条件:已登录;任务表格中至少有一条待开始任务。
|
||||
|
||||
**用户故事**
|
||||
|
||||
作为采购管理员,我想要勾选一条或多条待开始任务统一开始试选,从而在集中建单后一次交给
|
||||
桌面端逐条领取,而不必逐行点击操作按钮。
|
||||
|
||||
**范围**
|
||||
|
||||
- 包含:当前筛选结果中的可开始任务全选、清除选择、批量进入第一趟试选队列。
|
||||
- 不包含:指定执行顺序、暂停 / 继续、运行中接管、多设备调度(F-012,V2)。
|
||||
|
||||
**验收场景**
|
||||
|
||||
1. 假如我勾选了多条待开始任务,当我点击「开始试选」,那么这些任务一起转为待领取,
|
||||
页面明确说明不会创建订单或付款。
|
||||
2. 假如任务不是待开始,那么该行不能被本动作勾选,并能看见不可开始的原因。
|
||||
3. 假如其中一条任务在提交前已被别人启动,当版本冲突发生,那么整批不产生部分成功,
|
||||
页面刷新状态并让我重新选择。
|
||||
- 表头复选框只选择当前筛选结果中的 `DRAFT`;切换筛选时选择范围可见且可清空。
|
||||
- 未选择时按钮禁用;选中后显示数量、最高总额与“不付款”说明。
|
||||
- 点击后批量全有或全无;成功反馈授权数量,冲突反馈刷新重选。
|
||||
- 非 `DRAFT` 行不能勾选;表格不提供每行“开始采购”按钮,避免逐行语义不一致。
|
||||
|
||||
## 四、交付前检查
|
||||
|
||||
- [ ] 每个 P0 功能至少关联一个 US 编号。
|
||||
- [ ] 每个故事说明角色、目标、价值和可验证的验收场景。
|
||||
- [ ] UI 故事已关联对应 IX 编号。
|
||||
- [ ] 故事没有复制接口、字段或组件实现细节。
|
||||
- [ ] 范围、优先级与[需求](02-requirements.md)一致。
|
||||
- 每个 P0 故事至少有一个自动化测试或明确的人机验收证据。
|
||||
- 页面、API、状态机只使用单趟采购术语,不再出现试选后确认、授权旧试选价或第二趟下单。
|
||||
- 任一可见“开始采购”都同时说明“只创建待付款订单,系统不付款”。
|
||||
- 任一围栏后状态都不提供重试、释放授权或重新开始入口。
|
||||
|
||||
+107
-157
@@ -1,197 +1,147 @@
|
||||
# 交互清单
|
||||
|
||||
> 本文把用户故事落成可实现、可测试的界面行为:用户如何触发、系统处于什么状态、如何
|
||||
> 反馈,以及失败时怎样恢复。覆盖 web 端页面与 desk 端桌面界面。
|
||||
> 本文是界面行为权威。需求范围见[需求](02-requirements.md),页面位置见[routes](routes.md)。
|
||||
|
||||
## 一、职责边界
|
||||
|
||||
| 信息 | 写在哪里 |
|
||||
| --- | --- |
|
||||
| 用户目标、价值与业务验收 | [用户故事清单](07-user-stories.md) |
|
||||
| MVP 范围与优先级 | [需求](02-requirements.md) |
|
||||
| 页面入口、路由和组件归属 | [路由与页面结构](routes.md) |
|
||||
| API、事件和错误格式 | [API 合约](api.md) |
|
||||
|
||||
IX 编号一经引用不再重用。交互清单不能扩大需求范围。
|
||||
- 采购服务负责建单、开始采购授权、任务详情、内部证据、调和和人工付款收口。
|
||||
- 采购工具负责连接手机、轮询领取、单趟执行、日志和本地记录详情。
|
||||
- “开始采购”是管理员对创建待付款订单的明确授权;不再有试选后的确认页面。
|
||||
- 任一端都不自动付款;围栏后不提供重试或释放动作。
|
||||
|
||||
## 二、交互总表
|
||||
|
||||
| ID | 关联 US | 页面 / 组件 | 触发 | 用户目标 | 预期结果 | 优先级 | 状态 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| IX-001 | US-007 | web `/login` | 提交表单 | 进入管理后台 | 建立会话并进入原目标页或 `/tasks` | P0 | 已定 |
|
||||
| IX-002 | US-001 | web `/tasks` 创建弹窗;`/tasks/new` 直达兜底 | 提交表单 | 建单 | 解析出 `goods_id`,关闭弹窗并把待开始任务插入表格第一行 | P0 | 已定 |
|
||||
| IX-003 | US-002 | web `/tasks` 查询条与任务表格 | 输入后筛选 | 找到要处理的任务 | 默认显示标题关键词,可展开状态 / 时间条件;创建时间倒序;空状态可清除 | P0 | 已定 |
|
||||
| IX-004 | US-002 | web `/tasks/{id}` 路由化详情抽屉 / 完整页 | 双击行、任务编号链接或直接进入 | 了解任务当前处境 | 从列表进入时右侧打开且保留现场;直达时显示完整页;按状态呈现唯一主区块与主动作 | P0 | 已定 |
|
||||
| IX-005 | US-004、US-005 | web 试选确认卡 | 查看后点确认 / 退回 | 确认机器选对了并授权 | 签发一次性授权并锁定单价,明确不付款 | P0 | 已定 |
|
||||
| IX-006 | US-006 | web 待付款核对卡 | 查看后手工标记 | 核对后付款 | 展示订单截图与授权信息;人付款后标记完成 | P0 | 已定 |
|
||||
| IX-007 | US-003 | desk 配置页 | 填 serial → 连接检查 | 让设备就绪 | 连接成功并确认拼多多已安装 | P0 | 已定 |
|
||||
| IX-008 | US-003、US-008 | desk 采购执行页 | 顶部开始 / 停止轮询;查看执行记录 | 让手机自动跑并处理异常 | 三块工作区保留当前任务、滚动日志和时间倒序记录;待人工时显著提示缺什么;连续失败自动停 | P0 | 已定 |
|
||||
| IX-010 | US-005 | web 围栏前放弃授权入口 | 点击放弃 | 解开尚未提交的授权 | 授权作废,任务转待重新试选 | P0 | 已定 |
|
||||
| IX-011 | US-005、US-008 | web / desk 提交围栏与调和状态 | 真实点击前申请围栏;点击后回报 | 防止断网或重复操作产生第二笔订单 | 围栏前失败不点击;围栏后不明确只核查、不重试 | P0 | 已定 |
|
||||
| IX-012 | US-010 | web `/tasks` 表格选择与上下文批量栏 | 勾选后点「开始试选」 | 批量交给桌面端试选 | `DRAFT → PENDING` 原子提交;仅开始第一趟,不授权、不下单、不付款 | P0 | 已定 |
|
||||
| ID | 故事 | 位置 | 触发 | 结果 | 优先级 | 状态 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| IX-001 | US-007 | web `/login` | 登录 / 退出 | 建立或销毁管理员会话 | P0 | 已定 |
|
||||
| IX-002 | US-001 | web 任务表格创建弹窗 | 点击创建、保存 | 创建 `DRAFT` 并在首行反馈 | P0 | 已定 |
|
||||
| IX-003 | US-002 | web `/tasks` 筛选行 | 输入关键词、筛选、清除 | 更新表格并保留可理解的筛选状态 | P0 | 已定 |
|
||||
| IX-004 | US-002、US-004 | web 列表行 / `/tasks/{id}` | 双击行、浏览器前进后退 | 路由化详情抽屉或完整页,显示执行证据 | P0 | 已定 |
|
||||
| IX-005 | US-005 | web 批量操作条 | 点击开始采购 | 创建一次性授权并进入 `PENDING` | P0 | 已定 |
|
||||
| IX-006 | US-006 | web 待付款详情 | 查看后手工标记 | 只记录人工付款完成,不执行支付 | P0 | 已定 |
|
||||
| IX-007 | US-003 | desk 配置 tab | 保存并检查连接 | 服务、ADB、App 版本就绪 | P0 | 已定 |
|
||||
| IX-008 | US-003、US-008 | desk 采购执行 tab | 开始 / 停止轮询、选择记录、Esc | 单趟执行和安全人工接管 | P0 | 已定 |
|
||||
| IX-009 | US-008 | web / desk 通用错误反馈 | 请求或流程失败 | 显示阶段、原因和安全下一步 | P0 | 已定 |
|
||||
| IX-010 | US-005 | web 围栏前人工处理 | 重置 / 取消 | 关闭旧授权后回 `DRAFT` 或取消 | P0 | 已定 |
|
||||
| IX-011 | US-005、US-008 | desk 围栏 + web 调和 | 自动申请围栏 / 人工调和 | 点击一次或只处理同一提交 | P0 | 已定 |
|
||||
| IX-012 | US-010 | web 表格选择与操作条 | 勾选任务 | 明确批量范围、数量、最高总额 | P0 | 已定 |
|
||||
|
||||
> IX-009(ERP 建单)随 F-003 移出 MVP,编号保留不重用。
|
||||
## 三、P0 高风险交互
|
||||
|
||||
## 三、交互详情(P0 高风险)
|
||||
### IX-005 开始采购授权
|
||||
|
||||
以下三项涉及资金、不可逆动作或权限,必须完整填写。其余交互保留总表条目。
|
||||
**目标**:让管理员用一个明确动作把选中任务授权给采购工具创建待付款订单,同时避免重复授权或
|
||||
让用户误以为系统会付款。
|
||||
|
||||
### IX-005 试选确认与下单授权
|
||||
- 前置:已登录;至少一条 `DRAFT` 被选中;页面持有每条任务当前版本。
|
||||
- 触发:点击“开始采购(只创建待付款订单)”。
|
||||
- 点击本身即授权,不再弹出“机器选对了吗”或试选确认,也不需要第二个同义确认按钮。
|
||||
|
||||
- 关联用户故事:US-004、US-005
|
||||
- 关联需求 / 验收:F-007、F-008;[需求](02-requirements.md)第五节「决策与资金」
|
||||
- 页面 / 组件:web `/tasks/{id}` 路由化详情抽屉 / 完整页的 `SpecTrialCard` + `AuthorizePanel`
|
||||
- 目标角色:采购管理员
|
||||
- 前置条件:已登录;任务状态为 `WAITING_CONFIRMATION`;已收到试选结果与规格面板截图
|
||||
- 触发方式:查看后点击「确认下单(不付款)」或「退回,不买」
|
||||
- 服务依赖:`POST /tasks/{id}/order-authorizations`、`POST /tasks/{id}/reject`
|
||||
- 关联原型:待补(`docs/design/`)
|
||||
操作条必须持续展示:
|
||||
|
||||
**这不是候选对照台。** 只有一个商品,人回答的是「机器选对了吗」,不是「哪个更好」。
|
||||
1. “已选 N 条”;
|
||||
2. 所选任务最高总价之和;
|
||||
3. “采购工具会逐条创建待付款订单,系统不会付款”;
|
||||
4. 主按钮“开始采购(只创建待付款订单)”。
|
||||
|
||||
**正常路径**
|
||||
|
||||
1. 用户进入详情页,看到需求与机器所选的逐项对照、单价、合计和规格面板截图。
|
||||
2. 各项均为 ✓ 时,「确认下单(不付款)」可用。
|
||||
3. 用户点击确认,系统签发授权并**锁定本次试选的单价**,任务转 `AUTHORIZED`。
|
||||
4. 页面明确提示:只会创建待付款订单,付款需人在拼多多完成。
|
||||
|
||||
**状态与异常清单**
|
||||
|
||||
| 场景 | 本交互约定 |
|
||||
| 状态 | 行为 |
|
||||
| --- | --- |
|
||||
| 默认 / 可操作 | 各项 ✓ 时确认按钮可用;截图必须已加载出来才允许确认 |
|
||||
| 规格不一致(✗) | **确认按钮禁用**并说明哪一项不符;只能「退回,不买」或转人工 |
|
||||
| 金额超上限(✗) | **确认按钮禁用**并显示超出多少;不提供「仍然确认」入口 |
|
||||
| 加载 / 提交中 | 按钮禁用并显示进行中,防重复提交 |
|
||||
| 成功 | 关闭详情抽屉或返回工作台,该任务显示为已授权并等待桌面端领取第二趟 |
|
||||
| 退回 | 二次确认后任务终止为 `CANCELED`,说明不会再自动执行 |
|
||||
| 服务或网络错误 | 保留页面状态,说明原因并允许重试 |
|
||||
| 权限不足 | 设备凭据调用此接口一律 403 并记录;页面不暴露任务内容 |
|
||||
| 冲突 / 重复提交 | `expected_task_version` 不匹配返回 409,提示任务已变化并刷新;重复提交幂等,只产生一笔订单 |
|
||||
| 破坏性操作 | 确认会导致真实下单。提交前必须显示授权金额上限与「系统只下单不付款」 |
|
||||
| 试选已过期 | 授权过期后任务转待重新试选;完成新试选前不显示确认入口,页面明确说明旧价不再有效 |
|
||||
| 中断 / 离线 | 未提交的备注不保存;重进页面回到未确认状态 |
|
||||
| 未选择 | 按钮禁用,说明“请选择待开始任务” |
|
||||
| 选择中 | 只统计可选 `DRAFT`;非 DRAFT checkbox 禁用且有原因 |
|
||||
| 提交中 | 按钮禁用并显示“正在授权…”;避免双击,焦点不跳走 |
|
||||
| 成功 | 表格更新为 `PENDING`,清空已成功选择,toast/实时区提示授权数量和“不付款” |
|
||||
| 任一版本冲突 | 整批不变,保留筛选,清除失效选择并提示刷新后重选 |
|
||||
| 网络结果不明 | 使用同一 `start_key` 查询/重放,不生成新 key;不要直接再次授权 |
|
||||
|
||||
**可访问性与多端**
|
||||
|
||||
- 确认与退回按钮可键盘到达,焦点可见。
|
||||
- ✓ / ✗ 不能只靠颜色区分,必须带文字。
|
||||
- 窄屏下截图可放大查看,不被裁切到看不清规格。
|
||||
无障碍:操作条出现在表格前,状态变化用礼貌 `aria-live`;按钮具有可见焦点,文案不依赖颜色或图标。
|
||||
|
||||
### IX-006 待付款核对与标记完成
|
||||
|
||||
- 关联用户故事:US-006 | 关联需求:F-009 收口
|
||||
- 页面 / 组件:web `/tasks/{id}` 的 `PaymentCheckCard`
|
||||
- 目标角色:采购管理员(采购执行员在拼多多完成人工付款并反馈结果)
|
||||
- 前置条件:任务状态为 `WAITING_PAYMENT`,已收到订单截图
|
||||
- 服务依赖:`POST /tasks/{id}/mark-paid`
|
||||
- 页面显示商品、目标与实际规格、数量、授权上限、两次单价、确认页总额和订单截图。
|
||||
- 首屏警示:“订单已创建,系统尚未付款。请在拼多多人工核对并付款。”
|
||||
- “标记为已付款并完成”只写系统状态,不打开支付页面、不触发手机操作。
|
||||
- 提交前用简短确认对话框说明这是人工事实记录;成功转 `SUCCEEDED`,重复提交幂等。
|
||||
- 信息不一致时提供“转人工处理”,不提供“重新提交订单”。
|
||||
|
||||
**MVP 简化版**:系统只展示,不自动回读。自动核对是 F-016(V2)。
|
||||
### IX-008 桌面端轮询与单趟执行
|
||||
|
||||
**正常路径**
|
||||
采购工具固定两个 tab:“采购执行”(默认)和“配置”。采购执行顶部第一行依次显示采购服务、设备、
|
||||
拼多多版本、会话状态与“开始/停止轮询”;不另占一行。
|
||||
|
||||
1. 用户看到订单截图、商品、规格、数量和授权金额。
|
||||
2. 用户在拼多多手工核对并付款。
|
||||
3. 用户回到页面点「已付款」,任务转 `SUCCEEDED`。
|
||||
主体保持左右结构:
|
||||
|
||||
**状态与异常清单**
|
||||
- 左上:当前任务文字占约 2/3,商品图片占右侧约 1/3;无图显示稳定占位。
|
||||
- 左下:滚动日志,占用剩余高度;最新日志可自动跟随,用户上滚后不强制跳底。
|
||||
- 右侧:采购记录表格,按时间倒序,仅显示标题和状态。
|
||||
- 双击/Enter 记录后不弹窗,在左侧显示该记录:左侧原始文字、右侧图片、下方执行结果。
|
||||
- 按 Esc 或“返回当前任务”恢复当前任务视图;查看历史不暂停、不取消、不更改服务端状态。
|
||||
|
||||
| 场景 | 本交互约定 |
|
||||
单趟可见阶段:
|
||||
|
||||
```text
|
||||
待领取 → 已领取 → 打开商品 → 打开规格面板 → 选择规格
|
||||
→ 闸门一 → 数量复核 → 闸门二 → 确认页 / 闸门三
|
||||
→ 申请围栏 → 已发出唯一提交 → 待付款 / 待调和
|
||||
```
|
||||
|
||||
| 情况 | 反馈和下一步 |
|
||||
| --- | --- |
|
||||
| 默认 | 显著提示「对不上就别付」,并说明不一致时回来标记异常而不是在拼多多改单 |
|
||||
| 成功 | 任务转已完成,记录标记人与时间 |
|
||||
| 金额或规格对不上 | 提供「标记异常」转 `NEEDS_MANUAL`,不允许直接标已付款 |
|
||||
| 截图缺失 | 说明证据缺失并转人工,**不允许在无证据情况下标记完成** |
|
||||
| 重复标记 | 幂等,不产生第二条完成记录 |
|
||||
| 破坏性操作 | 本交互不产生任何写平台动作,只改本地状态 |
|
||||
| 服务/设备/App 未就绪 | 开始轮询禁用;状态区给出具体修复入口 |
|
||||
| 正在领取或执行 | 显示阶段与任务;停止轮询只阻止下一次领取,不中断当前安全步骤 |
|
||||
| 规格、价格、数量或确认页失败 | 记录固定 reason code,停止点击并进入待人工 |
|
||||
| 正在申请围栏 | 禁用停止当前任务、退出和任何提交动作;界面只显示进度 |
|
||||
| 围栏响应不明 | 不点击,进入待调和 |
|
||||
| 已围栏 / 点击结果不明 | 红色安全横幅“可能已创建订单,禁止再次提交”;只能调和 |
|
||||
| 外部支付或安全校验 | 立即停止,不与页面继续交互;保存允许的证据并提示人工接管 |
|
||||
| 连续轮询失败 | 达阈值自动停止会话;当前已围栏提交不受影响 |
|
||||
|
||||
### IX-008 桌面端定时轮询与人工接管
|
||||
|
||||
- 关联用户故事:US-003、US-008 | 关联需求:F-005、F-011
|
||||
- 页面 / 组件:desk 采购执行页 `PollControls` + `DeviceStatusBar` + `CurrentTaskPanel` +
|
||||
`ExecutionLog` + `ExecutionRecordTable` + `ExecutionRecordDetailPanel`
|
||||
- 目标角色:采购执行员
|
||||
- 前置条件:web 端连接正常;设备已通过连接检查
|
||||
|
||||
**正常路径**
|
||||
|
||||
1. 用户确认 web、设备和已取证 App 版本三项状态都就绪,在顶部状态区点击「开始轮询」。
|
||||
2. 每到间隔时间领取一条任务,界面显示**这一趟是试选还是下单**、商品、规格、当前步骤。
|
||||
3. 第一趟试选完成后**退出商品页释放手机**,继续下一轮轮询。
|
||||
4. 执行结果按时间倒序进入右侧两列表格;单击只选中,双击、Enter 或可见查看命令在左侧打开
|
||||
记录详情。宽屏可继续选择其他记录;可见返回按钮或 `Esc` 回到当前任务并恢复当前行焦点。
|
||||
|
||||
**状态与异常清单**
|
||||
|
||||
| 场景 | 本交互约定 |
|
||||
| --- | --- |
|
||||
| 默认 | 任一就绪条件不满足时「开始轮询」禁用,并说明是 web、设备还是 App 版本问题 |
|
||||
| 轮询中 | 显示下次轮询倒计时与连续失败计数 |
|
||||
| 执行中 | 显示当前趟次与步骤;**真机步骤期间禁用硬取消和关闭窗口** |
|
||||
| 当前任务图片 | 无可信图片时显示空态;有图时标来源与采集时间,不用其他来源图片凑合 |
|
||||
| 执行记录详情 | 左栏在当前任务 / 记录详情间切换,不打开模态框;详情内左侧原始文字、右侧图片证据、下方结构化采购结果;顶部实时状态不中断;不提供下单、重试提交或付款动作 |
|
||||
| 详情读取异常 | 加载、失败、无图和记录不存在分别说明;仅可重试读取同一稳定记录 ID,过期异步结果不得覆盖当前视图 |
|
||||
| 无可领任务 | 显示「暂无待领任务」并继续下一轮,**不当作错误、不计入失败计数** |
|
||||
| 待人工 | 整页显著变色,说明缺什么、下一步做什么;该任务不再被本端领取 |
|
||||
| 连续失败 | 达到阈值自动停止轮询并显示原因,**不无限重试** |
|
||||
| 关闭窗口 | 立即停止轮询,不留后台进程 |
|
||||
| 设备断连 | 当前任务标技术失败、保留现场;**不重试任何可能创建订单的点击** |
|
||||
| 安全校验 | 立即停止轮询、截图、转人工,**不提供绕过入口** |
|
||||
| 外部支付交接 | 立即停止并提示「订单可能已创建、支付未完成」;**不提供「继续」按钮** |
|
||||
| 点击提交后超时 | 转 `RECONCILIATION_REQUIRED` 并预留金额额度,**禁止自动重试**;授权保持永久围栏 |
|
||||
| 闸门二不通过 | 说明「价格已变:授权 ¥X,现价 ¥Y」并转人工,不自动下单也不自动放弃 |
|
||||
| 运行中变更 | 冻结设备切换和参数保存 |
|
||||
| 服务端不可达 | 保留本地结果与证据,提示待补传,不丢弃已完成工作 |
|
||||
键盘:Tab 顺序与视觉一致;表格上下箭头移动当前行,Enter 查看,Shift+F10 打开上下文菜单;Esc
|
||||
优先关闭菜单,其次返回当前任务视图。Esc、关闭窗口和停止轮询都不能释放围栏或触发重试。
|
||||
|
||||
### IX-011 提交围栏与结果调和
|
||||
|
||||
- 关联用户故事:US-005、US-008 | 关联需求:F-017
|
||||
- 页面 / 组件:desk 采购执行页;web 任务详情的提交围栏摘要
|
||||
- 前置条件:已完成 dry-run;真实第二趟重新通过三道闸门;授权尚未消费且未建立围栏
|
||||
- 服务依赖:`order-dry-runs/start`、`order-dry-runs/{rid}/ready`、
|
||||
`order-submissions/start`、`order-submissions/{sid}/reconcile`、`manual-review`
|
||||
围栏由后台流程自动申请,不向执行员提供“提交订单”按钮。
|
||||
|
||||
**正常路径**
|
||||
1. 当前任务在同一趟内完成闸门二和闸门三,并确认提交控件精确唯一。
|
||||
2. 客户端显示“正在申请提交围栏”,提交当前验证摘要。
|
||||
3. 只有服务端首次明确返回 `click_permitted=true`,客户端才先持久化唯一点击意图,再精确点击一次。
|
||||
4. 明确创建转待付款;外部支付、安全校验、超时、断连或未知页面转待调和。
|
||||
5. 围栏后的 web 详情只显示同一 `submission_id` 的证据、调和记录与人工结论。
|
||||
|
||||
1. dry-run 以醒目的「只读演练」标识运行,到确认页读取并验证后退出,不出现提交动作。
|
||||
2. 真实第二趟在三道闸门通过后显示「正在申请提交围栏」,此时不允许点击或取消。
|
||||
3. 只有服务端明确返回 `click_permitted` 后执行一次点击,界面立即进入「正在核对订单结果」。
|
||||
4. 明确创建后转待付款;结果不明确则两端都显示同一 `submission_id` 与「可能已创建」,
|
||||
只提供人工核查,不提供重试或放弃。
|
||||
| 状态 | 允许动作 | 禁止动作 |
|
||||
| --- | --- | --- |
|
||||
| 围栏前 | 停止并转人工;管理员可安全重置为 DRAFT | 绕过闸门、客户端自签授权 |
|
||||
| 围栏申请结果不明 | 不点击,记录待调和 | 换 key 申请、点击猜测 |
|
||||
| 围栏已建立 | 上报一次观察结果、人工调和 | 释放授权、重新开始、再次点击 |
|
||||
| 明确待付款 | 人工核对/付款/标记完成 | 系统支付 |
|
||||
|
||||
**状态与异常清单**
|
||||
围栏重放响应不授予点击许可。调和页面不得出现普通“重试”按钮。
|
||||
|
||||
| 场景 | 本交互约定 |
|
||||
| --- | --- |
|
||||
| dry-run | 蓝色信息态并固定显示「不会提交订单」;完成后有证据摘要 |
|
||||
| 围栏申请中 | 主操作禁用、显示进度;关闭窗口受控,避免用户误以为可重来 |
|
||||
| 围栏申请失败 / 响应不明 | **不点击**;展示幂等键与核查入口 |
|
||||
| 围栏成功 | 显示唯一提交编号;只允许内部流程点击一次,不向用户暴露第二个提交按钮 |
|
||||
| 点击后明确创建 | 转待付款并显示订单证据 |
|
||||
| 点击后超时 / 外部支付 / 安全校验 | 转 `RECONCILIATION_REQUIRED`;显著提示可能已创建并预留额度 |
|
||||
| 重复打开或恢复 | 恢复同一提交编号和调和状态,不重新领取、不重新点击 |
|
||||
| 人工核查 | 可记录「已创建 / 未创建 / 仍不明确」及证据;系统本身不发起新的下单 |
|
||||
### IX-012 批量选择
|
||||
|
||||
- 第一列为 checkbox;表头全选只覆盖当前筛选结果中可授权的 `DRAFT`。
|
||||
- 行单击只改变当前/焦点行,不等于勾选;双击行打开详情,不切换 checkbox。
|
||||
- 状态变化、翻页或筛选后失效的选择必须清除并告知,不能静默作用于不可见旧数据。
|
||||
- 不在每行增加“开始采购”操作列;批量操作统一位于表格上方上下文操作条。
|
||||
- 移动端表格允许水平滚动,不把关键金额和授权动作压成不可读卡片。
|
||||
|
||||
## 四、通用要求
|
||||
|
||||
适用于所有 P0 交互:
|
||||
|
||||
- **禁用按钮必须说明原因**,不留用户猜。
|
||||
- **失败必须给下一步**,不只报错。
|
||||
- **空状态是邀请,不是错误**。
|
||||
- 破坏性与不可逆动作必须二次确认,并说明影响范围。
|
||||
- 键盘可达、焦点可见、`prefers-reduced-motion` 生效。
|
||||
- 状态不能只靠颜色表达。
|
||||
- 金额一律显示两位小数并标注币种。
|
||||
- 双击只能作为效率快捷方式;任务编号链接和聚焦行后按 Enter 必须提供等价详情入口。
|
||||
- 表格行内的复选框、商品外链和任务编号链接不得误触发行双击;关闭详情抽屉后焦点回到原行。
|
||||
- 表格批量栏只在存在选择时出现;全选只覆盖当前筛选结果中允许当前动作的行。
|
||||
- 中文文案,状态名面向用户;技术 code 只出现在可复制详情中。
|
||||
- 所有输入有可见 label,错误与字段关联;必填不只靠星号。
|
||||
- 可交互元素有键盘可见焦点,目标尺寸至少 40–44 px;不依赖 hover 才能发现关键操作。
|
||||
- 加载、空态、失败、离线、禁用、成功、超时和结果不明均有明确表现。
|
||||
- 尊重 `prefers-reduced-motion`;状态变化不使用闪烁或大幅位移动画。
|
||||
- 高影响动作要说明范围与后果。开始采购已由持续可见的操作条和具体按钮文案完成确认,不再叠加
|
||||
一个同义模态框;取消、人工付款事实等不同后果仍按各自流程确认。
|
||||
- 关闭抽屉、返回列表或查看历史记录必须恢复触发点焦点和筛选/滚动现场。
|
||||
- 内部截图有权限和不缓存标识;不把地址/手机号做成搜索字段,不展示完整 XML。
|
||||
|
||||
## 五、交付前检查
|
||||
|
||||
- [ ] 每项 P0 交互回链至少一个 US 编号。
|
||||
- [ ] 涉及资金或不可逆动作的交互已填写完整状态表。
|
||||
- [ ] 所有禁用态都有说明文案。
|
||||
- [ ] 所有空状态都有可执行的下一步。
|
||||
- [ ] 未在本文自行定义接口路径、字段或状态码。
|
||||
- Web:375 / 768 / 1024 / 1440 px;键盘、焦点、表格横向滚动、抽屉返回均检查。
|
||||
- Desk:compact / medium / wide;浅色、深色、高对比近似;键盘和窗口关闭路径检查。
|
||||
- 单趟状态与 [architecture](04-architecture.md)、[api](api.md) 完全一致。
|
||||
- 页面没有 `WAITING_CONFIRMATION`、第二趟、锁定试选价或管理员中途确认。
|
||||
- “开始采购”处始终可见“只创建待付款订单 / 系统不付款”。
|
||||
- 围栏后没有重试、释放授权或重新开始入口。
|
||||
|
||||
+6
-6
@@ -4,8 +4,8 @@
|
||||
|
||||
cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`)负责建单与人工决策,
|
||||
**采购工具**(Windows 桌面端,`client/`)驱动 Android 手机在拼多多完成选规格和下单,
|
||||
**付款始终由人完成**。第一版先跑通「手工建单 → 定时领取 → 第一趟试选 → 人工确认 →
|
||||
第二趟下单 → 待付款」闭环。
|
||||
**付款始终由人完成**。第一版先跑通“手工建单 → 管理员开始采购授权 → 定时领取 → 单趟规格/
|
||||
价格/数量校验 → 提交围栏与一次点击 → 待付款”闭环。
|
||||
|
||||
## 文档导航
|
||||
|
||||
@@ -17,7 +17,7 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
- [需求](02-requirements.md):功能清单、验收标准、风险点。
|
||||
- [用户故事清单](07-user-stories.md):US 编号、用户目标与验收场景。
|
||||
- [技术栈](03-tech-stack.md):两端选型、运行命令、**验证矩阵**。
|
||||
- [架构设计](04-architecture.md):双端职责、**两趟执行**、**三道价格闸门**、安全边界、数据模型。
|
||||
- [架构设计](04-architecture.md):双端职责、**单趟执行**、**三道价格闸门**、安全边界、数据模型。
|
||||
- [编码规则](05-coding-rules.md):硬约束,第 1 节是本项目红线。
|
||||
- [任务路线图](06-tasks.md):阶段划分、里程碑、建议拆分清单。
|
||||
- [任务文件](tasks/README.md):一任务一文件约定与真机验收要求。
|
||||
@@ -45,13 +45,13 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
|
||||
|
||||
新接手时如果只看四个地方,看这四个:
|
||||
|
||||
1. [`04-architecture.md`](04-architecture.md) **第三节两趟执行与三道价格闸门**——为什么
|
||||
分两趟、价格为什么只在两个地方读。
|
||||
1. [`04-architecture.md`](04-architecture.md) **第三节单趟执行与三道价格闸门**——开始采购
|
||||
如何授权、两次面板价与确认页金额如何实时保护上限。
|
||||
2. [`04-architecture.md`](04-architecture.md) **第四节安全边界与提交订单四条件**——每条
|
||||
都要有测试。
|
||||
3. [`05-coding-rules.md`](05-coding-rules.md) **第 1 节红线**——违反即拒绝。
|
||||
4. [`00-ai-start-here.md`](00-ai-start-here.md) **四条特有纪律**——先取证、只收紧、
|
||||
只在两处读价、第一趟不下单。
|
||||
只在两处读价、隔离验证能力不越界。
|
||||
|
||||
## 维护原则
|
||||
|
||||
|
||||
+210
-208
@@ -1,313 +1,315 @@
|
||||
# API 合约
|
||||
|
||||
> 本文定义采购服务(`admin/`)对外的 HTTP 接口,以及采购工具(`client/`)本地模块的合约。
|
||||
> **这是双端之间的唯一权威。** 实现前可细化,但不得在代码里另起一套不兼容接口。
|
||||
>
|
||||
> 本合约的设备心跳、任务领取、事件、证据、授权命令与 ack 结构参考了前序项目
|
||||
> `cmroubao`;同时在本项目重新审计并补回 dry-run、提交前服务端围栏和点击后调和。
|
||||
> 前序接口是设计依据,不是可直接照搬的运行事实。
|
||||
> 本文是采购服务与采购工具之间的唯一线协议权威。页面判据和本地类名不是线协议。
|
||||
> 接口形状参考前序项目的经验,但所有状态与安全语义在 cmbuyer 重新定义、测试和取证。
|
||||
|
||||
## 通用约定
|
||||
|
||||
- 传输:JSON over HTTP。MVP 局域网内运行,生产部署应加 HTTPS。
|
||||
- 编码:UTF-8。
|
||||
- 时间:RFC 3339,带时区,UTC 存储。
|
||||
- 金额:**十进制字符串**(如 `"45.60"`),不用浮点数。
|
||||
- 幂等:所有创建类接口接受幂等键,重复提交返回同一结果而不是第二笔。
|
||||
- 生产前缀:`/api/v1`;管理页面路由见 [routes.md](routes.md)。
|
||||
- JSON 使用 UTF-8;时间为 UTC RFC 3339;ID 为 UUID 字符串。
|
||||
- 金额均为规范十进制字符串,如 `"12.88"`;禁止 JSON number 和浮点计算。
|
||||
- 所有写接口接受 `request_id` / 业务幂等键;同键同载荷重放同一结果,同键异载荷返回 `409`。
|
||||
- 任务写入携带 `expected_task_version`;版本冲突返回 `409 version_conflict`。
|
||||
- 服务端错误不得回显设备 token、完整节点树、地址、手机号或支付信息。
|
||||
|
||||
### 鉴权
|
||||
### 身份
|
||||
|
||||
| 客户端 | 方式 | 说明 |
|
||||
| 身份 | 凭据 | 能力 |
|
||||
| --- | --- | --- |
|
||||
| 管理 Web | Session Cookie + CSRF Token | 表单提交必须带 CSRF |
|
||||
| 采购工具 | `Authorization: Bearer <device_token>` | 凭据绑定设备标识,可单独撤销 |
|
||||
| ERP 对接 | `Authorization: Bearer <connector_token>` | 只能调用货运同步接口 |
|
||||
| 管理员 | `HttpOnly; Secure; SameSite=Lax` 会话 cookie + CSRF | 建单、开始采购、查看内部证据、人工调和 |
|
||||
| 设备 | `Authorization: Bearer <device-token>` + 设备 id | 心跳、领取、事件、截图、围栏与结果 |
|
||||
| ERP(V2) | 独立凭据 | 只读来源同步,不访问采购结果 |
|
||||
|
||||
三种身份互不通用。设备凭据**不能**创建任务或签发授权;管理会话**不能**调用设备接口。
|
||||
设备凭据不能建单或开始采购;管理会话不能调用设备接口。未认证统一返回 `401`,无权返回 `403`。
|
||||
|
||||
### 错误响应
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "invalid_argument",
|
||||
"message": "数量必须是正整数",
|
||||
"field": "quantity"
|
||||
"code": "version_conflict",
|
||||
"message": "任务已变化,请刷新后重选",
|
||||
"retryable": false,
|
||||
"request_id": "018f..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
错误码枚举:`invalid_argument`、`unauthenticated`、`permission_denied`、`not_found`、
|
||||
`conflict`、`failed_precondition`、`internal`。
|
||||
|
||||
- 未登录访问受保护资源:`401`,管理页面重定向到 `/login`。
|
||||
- 已登录但无权限:`403`。**不存在**与**无权限**必须使用不同内部原因,但响应体不得泄露
|
||||
任务内容。
|
||||
`retryable=true` 只表示接口调用可以按同一幂等键重放,不表示可以重试任何真机点击。
|
||||
|
||||
## 一、管理端接口
|
||||
|
||||
管理页面为服务端渲染,表单直接 POST 到下列路径,成功后 303 重定向。
|
||||
|
||||
| 方法 | 路径 | 职责 |
|
||||
| 方法 | 路径 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/login` | 建立管理会话 |
|
||||
| `POST` | `/logout` | 销毁会话 |
|
||||
| `GET` | `/tasks` | 任务列表,支持 `q`、`status`、`days`、`cursor` |
|
||||
| `POST` | `/tasks` | 手工建单,初始状态为 `DRAFT`(F-001) |
|
||||
| `POST` | `/tasks/start-trials` | 批量把 `DRAFT` 原子转为 `PENDING`,开始第一趟试选(F-018) |
|
||||
| `GET` | `/tasks/{id}` | 任务详情 |
|
||||
| `POST` | `/tasks/{id}/cancel` | 取消任务 |
|
||||
| `POST` | `/tasks/{id}/order-authorizations` | 确认试选结果并签发授权(F-008) |
|
||||
| `POST` | `/tasks/{id}/order-authorizations/{aid}/abandon` | 围栏前放弃授权,任务转待重新试选(F-010) |
|
||||
| `POST` | `/tasks/{id}/reject` | 退回不买,任务终止 |
|
||||
| `POST` | `/tasks/{id}/mark-paid` | 人工核对付款后标记完成(MVP 简化收口) |
|
||||
| `GET/POST` | `/login` | 登录页 / 建立管理员会话 |
|
||||
| `POST` | `/logout` | 退出并使会话失效 |
|
||||
| `GET` | `/tasks` | SSR 任务表格;关键词、状态、时间筛选 |
|
||||
| `POST` | `/tasks` | 手工创建 `DRAFT` |
|
||||
| `POST` | `/tasks/start-purchases` | 批量开始采购:创建一次性授权并原子转 `PENDING` |
|
||||
| `GET` | `/tasks/{id}` | 任务完整页;同一 URL 也可由列表详情抽屉加载 |
|
||||
| `POST` | `/tasks/{id}/reset-to-draft` | 围栏前人工处理后关闭旧授权,回到 `DRAFT` |
|
||||
| `POST` | `/tasks/{id}/cancel` | 围栏前取消任务 |
|
||||
| `POST` | `/order-submissions/{sid}/reconcile` | 围栏后人工调和同一提交 |
|
||||
| `POST` | `/tasks/{id}/mark-paid` | 人工确认已付款并完成核对 |
|
||||
| `GET` | `/evidence/{asset_id}` | 登录后读取内部截图;`Cache-Control: no-store` |
|
||||
|
||||
> Excel 导入(`/tasks/import`)与 ERP 货运(`/freight*`)已移出 MVP,见
|
||||
> [需求](02-requirements.md)第三节后续迭代表。
|
||||
### `POST /tasks`
|
||||
|
||||
### `POST /tasks/start-trials`
|
||||
|
||||
管理页面以带 CSRF 的表单提交结构化任务版本列表:
|
||||
核心字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"start_key": "<幂等键>",
|
||||
"create_key": "018f...",
|
||||
"title": "纯棉短袖",
|
||||
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
|
||||
"sku_color": "黑色CHA(纯棉)",
|
||||
"sku_size": "M(建议100-115)",
|
||||
"quantity": 2,
|
||||
"max_total_price": "30.00"
|
||||
}
|
||||
```
|
||||
|
||||
- 服务端解析并保存 canonical URL 与 `goods_id`;URL 非拼多多商品页、`goods_id` 缺失或含歧义则拒绝。
|
||||
- `max_total_price` 是本任务允许创建待付款订单的总额上限,不是参考单价。
|
||||
- 成功只产生 `DRAFT`;不得创建授权、开放设备领取或触发真机。
|
||||
|
||||
### `POST /tasks/start-purchases`
|
||||
|
||||
```json
|
||||
{
|
||||
"start_key": "018f...",
|
||||
"tasks": [
|
||||
{ "id": "018f...", "expected_task_version": 1 },
|
||||
{ "id": "0190...", "expected_task_version": 3 }
|
||||
{"task_id": "018f-task-1", "expected_task_version": 1},
|
||||
{"task_id": "018f-task-2", "expected_task_version": 1}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- 只接受当前状态为 `DRAFT` 的任务;该动作含义是允许采购工具领取第一趟试选,**不签发下单
|
||||
授权、不建立提交围栏、不创建订单、不付款**。
|
||||
- 服务端在一个事务内校验全部任务存在、属于当前管理范围、状态仍为 `DRAFT` 且版本匹配,
|
||||
然后统一转 `PENDING` 并递增版本。任一项失败返回 `409 conflict`,整批不产生部分成功。
|
||||
- `tasks` 为空或包含重复 id 返回 `400 invalid_argument`;重复 `start_key` 返回第一次的结果,
|
||||
不重复推进版本。
|
||||
- SSR 成功后 `303` 返回原任务列表查询地址;冲突时保留筛选条件,刷新表格并要求重新选择。
|
||||
管理员按钮必须显示为“开始采购(只创建待付款订单)”。**点击本身就是授权**:允许采购工具按任务
|
||||
锁定字段创建一笔待付款订单;不再等待试选后人工确认,也不授权付款。
|
||||
|
||||
### `POST /tasks/{id}/order-authorizations`
|
||||
服务端在一个事务中:
|
||||
|
||||
1. 校验列表非空、无重复任务,所有任务均为 `DRAFT` 且版本一致;
|
||||
2. 校验每条任务的 `goods_id`、规格、正整数数量和最高总价完整;
|
||||
3. 为每条任务创建一次性 `order_authorization`,锁定任务版本、上述字段、管理员、时间和有效期;
|
||||
4. 把所有任务转为 `PENDING` 并递增版本。
|
||||
|
||||
任一条失败则整批不变。相同 `start_key` + 相同任务集合重放同一批结果;集合或版本不同返回 409。
|
||||
|
||||
```json
|
||||
{
|
||||
"authorization_key": "<幂等键>",
|
||||
"expected_task_version": 3,
|
||||
"spec_trial_id": "018f...",
|
||||
"note": ""
|
||||
"start_key": "018f...",
|
||||
"authorized_count": 2,
|
||||
"tasks": [
|
||||
{"task_id": "018f-task-1", "task_version": 2, "authorization_id": "018f-auth-1"},
|
||||
{"task_id": "018f-task-2", "task_version": 2, "authorization_id": "018f-auth-2"}
|
||||
],
|
||||
"payment_automated": false
|
||||
}
|
||||
```
|
||||
|
||||
- **授权内容不由客户端提交。** `goods_id`、规格、数量、`authorized_unit_price` 全部由
|
||||
服务端从 `spec_trial_id` 指向的试选记录取值——人确认的是那一次试选,不是一组自由填写
|
||||
的参数。
|
||||
- `expected_task_version` 不匹配返回 `409 conflict`。
|
||||
- `total_price_cap` 由服务端按任务的价格上限计算,客户端无法提高。
|
||||
- 响应中返回 `expires_at`;围栏建立前超时后授权自动 `EXPIRED`,任务转
|
||||
`PENDING_RETRIAL`,必须重新跑第一趟。
|
||||
- 已存在 `order_submission` 时,放弃或超时处理返回 `409 conflict`;该授权只能调和结果或
|
||||
转人工核查,不能重新开放为可执行。
|
||||
- MVP 没有「选择理由 / 拒绝理由」——那是多候选择一时的留档需求。这里只有可选 `note`。
|
||||
### 围栏前重置与围栏后调和
|
||||
|
||||
## 二、设备侧接口(采购工具调用)
|
||||
- `reset-to-draft` 必须同时校验任务版本、授权 id 与“尚无 `order_submission`”。关闭旧授权后回到
|
||||
`DRAFT`;重新开始必须产生新版本与新授权。
|
||||
- 一旦存在 `order_submission`,重置、取消、授权过期和重新开始都返回 `409 submission_fenced`。
|
||||
- `reconcile` 只能处理指定 `sid`:人工记录“已创建待付款订单”或“确认未创建/无法完成”。它不能
|
||||
触发设备点击、释放围栏或签发新授权。
|
||||
|
||||
全部要求有效设备 Bearer;凭据中的设备标识是权威身份,请求体里的设备字段仅作核对。
|
||||
## 二、设备侧接口
|
||||
|
||||
| 方法 | 路径 | 职责 |
|
||||
| 方法 | 路径 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `POST` | `/api/v1/devices/heartbeat` | 上报版本与就绪位,核对服务端活跃任务 |
|
||||
| `POST` | `/api/v1/tasks/claim-next` | 原子领取或重放;**同时覆盖待试选与已授权两类** |
|
||||
| `POST` | `/api/v1/tasks/{id}/start` | `CLAIMED → RUNNING`,创建 execution |
|
||||
| `POST` | `/api/v1/tasks/{id}/heartbeat` | 更新当前步骤与运行租约 |
|
||||
| `POST` | `/api/v1/tasks/{id}/release` | 未开始时退回 `PENDING` |
|
||||
| `POST` | `/api/v1/tasks/{id}/events` | 幂等补报执行事件 |
|
||||
| `POST` | `/api/v1/tasks/{id}/evidence` | 上传证据资产,SHA-256 寻址 |
|
||||
| `POST` | `/api/v1/tasks/{id}/spec-trial` | **第一趟**:回传试选结果,任务转 `WAITING_CONFIRMATION` |
|
||||
| `POST` | `/api/v1/tasks/{id}/commands/next` | **第二趟**:拉取或重放已签发的下单授权 |
|
||||
| `POST` | `/api/v1/tasks/{id}/commands/{cid}/ack` | 落盘后幂等确认命令 |
|
||||
| `POST` | `/api/v1/tasks/{id}/order-dry-runs/start` | 开始只读演练;绝不消费授权、绝不允许提交 |
|
||||
| `POST` | `/api/v1/order-dry-runs/{rid}/ready` | 回传确认页只读结果与证据,结束演练 |
|
||||
| `POST` | `/api/v1/tasks/{id}/order-submissions/start` | **真实点击前**原子建立唯一提交围栏 |
|
||||
| `POST` | `/api/v1/order-submissions/{sid}/reconcile` | 点击后上报明确或不明确结果,只调和不重试 |
|
||||
| `POST` | `/api/v1/order-submissions/{sid}/manual-review` | 将围栏后的不确定结果交给人工核查 |
|
||||
| `POST` | `/api/v1/tasks/{id}/needs-manual` | 转人工,带原因码与证据 |
|
||||
| `POST` | `/api/v1/tasks/{id}/fail` | 提交结构化失败与证据 |
|
||||
| `POST` | `/api/v1/devices/heartbeat` | 上报设备、ADB、App 版本和能力状态 |
|
||||
| `POST` | `/api/v1/tasks/claim-next` | 原子领取一个 `PENDING` 授权任务或重放本设备未结束领取 |
|
||||
| `POST` | `/api/v1/tasks/{id}/lease/renew` | 续租;只允许当前 claim |
|
||||
| `POST` | `/api/v1/tasks/{id}/events` | 批量追加结构化步骤事件 |
|
||||
| `POST` | `/api/v1/tasks/{id}/evidence` | 显式上传一个内部原始截图 |
|
||||
| `POST` | `/api/v1/purchase-attempts/{aid}/fail` | 围栏前停止并回传失败摘要 |
|
||||
| `POST` | `/api/v1/purchase-attempts/{aid}/submission-fence` | 提交当前三闸门摘要并原子申请唯一围栏 |
|
||||
| `POST` | `/api/v1/order-submissions/{sid}/result` | 点击后一次性上报观察结果;只调和不重试 |
|
||||
|
||||
> `/candidates`(多候选回传)、`/reference-image`(图搜参考图)、`/order-record`
|
||||
> (订单自动核对)随 B 路径与 F-016 一并推迟到 V2。
|
||||
### `POST /api/v1/devices/heartbeat`
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "desk-01",
|
||||
"client_version": "0.1.0",
|
||||
"adb_serial": "192.168.0.173:5555",
|
||||
"android_release": "16",
|
||||
"pdd_version": "8.17.0",
|
||||
"state": "READY"
|
||||
}
|
||||
```
|
||||
|
||||
服务端可返回 `app_version_allowed=false`;采购工具必须停止领取,不能只显示警告后继续。
|
||||
|
||||
### `POST /api/v1/tasks/claim-next`
|
||||
|
||||
```json
|
||||
{ "device_id": "desk-01", "claim_key": "<幂等键>" }
|
||||
```
|
||||
|
||||
成功:
|
||||
请求携带 `device_id`、`session_id`、`claim_request_id`。领取与授权绑定且具租约:
|
||||
|
||||
```json
|
||||
{
|
||||
"task": {
|
||||
"id": "018f...",
|
||||
"leg": "TRIAL",
|
||||
"goods_id": "7531364299",
|
||||
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=7531364299",
|
||||
"sku_color": "白色",
|
||||
"sku_size": "XL",
|
||||
"id": "018f-task",
|
||||
"version": 3,
|
||||
"title": "纯棉短袖",
|
||||
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
|
||||
"goods_id": "937122477375",
|
||||
"sku_color": "黑色CHA(纯棉)",
|
||||
"sku_size": "M(建议100-115)",
|
||||
"quantity": 2,
|
||||
"max_total_price": "80.00"
|
||||
"max_total_price": "30.00"
|
||||
},
|
||||
"claim_token": "...",
|
||||
"claim_generation": 1,
|
||||
"lease_expires_at": "2026-08-03T10:30:00Z"
|
||||
"authorization": {
|
||||
"id": "018f-auth",
|
||||
"task_version": 2,
|
||||
"expires_at": "2026-08-04T10:00:00Z"
|
||||
},
|
||||
"attempt": {
|
||||
"id": "018f-attempt",
|
||||
"claim_token": "opaque-single-claim-token",
|
||||
"claim_generation": 1,
|
||||
"lease_expires_at": "2026-08-04T09:05:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **`leg` 决定这一趟做什么**:`"TRIAL"` = 第一趟试选,`"ORDER"` = 第二趟下单。
|
||||
`leg` 为 `"ORDER"` 时只返回 `authorization_id`;完整、不可变的授权命令必须通过
|
||||
`/commands/next` 拉取并落盘,再调用 `/commands/{cid}/ack`。领取接口不重复定义授权载荷。
|
||||
- 无可领任务返回 `200` 且 `task` 为 `null`,**不是 404**。
|
||||
- 后续所有该任务的调用必须携带 `X-Claim-Token` 与匹配的 `claim_generation`。
|
||||
- 只返回有 `ACTIVE` 授权的 `PENDING`;服务端在一个事务中转为 `CLAIMED` 并创建 attempt。
|
||||
- 同一 `claim_request_id` 同载荷重放同一结果;并发设备只有一个成功。
|
||||
- 一个设备有未结束领取时优先重放该领取,不能悄悄领第二条。
|
||||
- 响应不得包含自由动作脚本、CSS/XPath、通用坐标或支付能力。
|
||||
|
||||
### `POST /api/v1/tasks/{id}/spec-trial`(第一趟回传)
|
||||
### 事件与证据
|
||||
|
||||
事件只包含固定 `step` / `outcome` / `reason_code` 和非敏感摘要。禁止把完整 XML、地址、手机号、
|
||||
页面全文或 token 塞进日志字段。
|
||||
|
||||
截图接口使用 `multipart/form-data`,只接受单个显式文件及以下元数据:
|
||||
|
||||
```json
|
||||
{
|
||||
"attempt": 1,
|
||||
"product_title": "2026夏季新款纯棉圆领短袖T恤男女同款宽松半袖",
|
||||
"selected_color": "白色",
|
||||
"selected_size": "XL",
|
||||
"unit_price": "32.50",
|
||||
"total_price": "65.00",
|
||||
"evidence_sha256": "…"
|
||||
"attempt_id": "018f-attempt",
|
||||
"kind": "SKU_PANEL_GATE_1",
|
||||
"privacy_tier": "INTERNAL_RAW",
|
||||
"sha256": "64-lowercase-hex",
|
||||
"captured_at": "2026-08-04T09:01:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
- `selected_color` / `selected_size` 是**实际勾选到的值**,不是任务要求的值。
|
||||
服务端据此与任务要求比对并在确认页显示 ✓ / ✗。
|
||||
- `unit_price` 来自闸门一(规格面板)。**读不到时不要发这个接口**,改发
|
||||
`/needs-manual` 并带原因码 `UNIT_PRICE_UNREADABLE`。
|
||||
- `total_price` = `unit_price` × 任务数量,服务端会重算校验。
|
||||
- 证据须先经 `/evidence` 上传。
|
||||
- 服务端接收后创建 `spec_trials` 记录,任务转 `WAITING_CONFIRMATION`。
|
||||
- 允许规格面板和确认页截图保留页面已显示的地址/手机号;不要求遮罩或裁剪。
|
||||
- 不接受 XML、目录、manifest、本机绝对路径、外部支付页截图或支付凭据。
|
||||
- MIME、尺寸、字节数和 SHA-256 必须校验;资产只经管理员鉴权端点读取。
|
||||
|
||||
### dry-run 与真实提交协议
|
||||
### `POST /api/v1/purchase-attempts/{aid}/submission-fence`
|
||||
|
||||
`POST /api/v1/tasks/{id}/order-dry-runs/start` 创建或重放一次演练记录。采购工具随后只允许
|
||||
进入订单确认页、读取非敏感摘要和验证提交控件唯一,不允许点击。完成后调用
|
||||
`POST /api/v1/order-dry-runs/{rid}/ready`:
|
||||
客户端只有在当前页面四条件中的后三项已经满足后才能调用:
|
||||
|
||||
```json
|
||||
{
|
||||
"command_id": "…",
|
||||
"verified_unit_price": "32.50",
|
||||
"fence_key": "018f-fence-request",
|
||||
"task_id": "018f-task",
|
||||
"expected_task_version": 3,
|
||||
"authorization_id": "018f-auth",
|
||||
"claim_token": "opaque-single-claim-token",
|
||||
"selected_color": "黑色CHA(纯棉)",
|
||||
"selected_size": "M(建议100-115)",
|
||||
"gate1_unit_price": "12.88",
|
||||
"gate2_unit_price": "12.88",
|
||||
"quantity_read": 2,
|
||||
"confirm_page_amount": "65.00",
|
||||
"has_address": true,
|
||||
"evidence_sha256": "…"
|
||||
"confirm_page_amount": "25.76",
|
||||
"submit_control_match_count": 1
|
||||
}
|
||||
```
|
||||
|
||||
- dry-run 只证明当次页面达到 `READY`,不冻结授权,也不能作为稍后真实点击时的页面事实。
|
||||
- `has_address` 只报布尔值,**不得回传地址原文或手机号**。
|
||||
服务端在一个事务中校验:任务/版本/claim/attempt 一致;授权有效未消费且字段等于任务快照;
|
||||
规格与授权相等;数量相等;两个单价相等;计算金额及确认页金额均不超过 `total_price_cap`;提交控件
|
||||
计数为一;此前不存在该授权或 attempt 的 submission。随后创建唯一 `order_submission`,授权转
|
||||
`FENCED`,任务保持不可重领。
|
||||
|
||||
真实第二趟重新通过三道闸门后,采购工具在点击前调用
|
||||
`POST /api/v1/tasks/{id}/order-submissions/start`:
|
||||
首次明确成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"submission_key": "<幂等键>",
|
||||
"command_id": "…",
|
||||
"dry_run_id": "…",
|
||||
"expected_task_version": 5,
|
||||
"verified_unit_price": "32.50",
|
||||
"quantity_read": 2,
|
||||
"confirm_page_amount": "65.00"
|
||||
"submission_id": "018f-submission",
|
||||
"status": "FENCED",
|
||||
"click_permitted": true,
|
||||
"submit_text": "提交订单"
|
||||
}
|
||||
```
|
||||
|
||||
- 服务端在一个事务中校验命令、任务版本、授权未消费、闸门值与唯一性,创建或重放唯一
|
||||
`order_submission` 并把授权置为 `FENCED`。同一授权或命令不得产生第二条提交记录。
|
||||
- 只有明确收到 `201/200` 且响应中的 `click_permitted: true`,采购工具才允许点击一次。
|
||||
超时、网络错误、冲突或响应无法解析时**不得点击**,转人工查询该幂等键。
|
||||
- `dry_run_id` 只证明曾完成安全演练;服务端仍以本次真实提交请求携带的闸门读数复核。
|
||||
- 任一校验失败返回错误,绝不返回 `click_permitted=true`。
|
||||
- 同一 `fence_key` 的重放返回同一 `submission_id`,但 `click_permitted=false` 且
|
||||
`reconciliation_required=true`;客户端不能凭重放响应点击。
|
||||
- 客户端收到首次许可后,必须先把“围栏已取得/即将发出唯一点击”持久化,再执行点击。进程崩溃或
|
||||
本地状态不明时宁可转调和,也不再次点击。
|
||||
|
||||
点击后调用 `POST /api/v1/order-submissions/{sid}/reconcile`:
|
||||
### `POST /api/v1/order-submissions/{sid}/result`
|
||||
|
||||
```json
|
||||
{
|
||||
"outcome": "SUBMITTED",
|
||||
"evidence_sha256": "…"
|
||||
"result_key": "018f-result",
|
||||
"attempt_id": "018f-attempt",
|
||||
"observation": "SUBMITTED",
|
||||
"evidence_asset_id": "018f-asset"
|
||||
}
|
||||
```
|
||||
|
||||
- `outcome` 枚举:`SUBMITTED`(明确看到订单结果)、`UNCERTAIN`(超时或无法判断)、
|
||||
`HANDED_OFF`(外部支付)、`SECURITY_CHECK`。
|
||||
- `SUBMITTED` 转 `WAITING_PAYMENT`;其余一律转 `RECONCILIATION_REQUIRED`,授权保持已围栏并
|
||||
预留金额额度。重复调用只重放同一调和结果。
|
||||
- 任一结果都**禁止再次点击、释放围栏或重新签发授权**。无法自动调和时调用
|
||||
`/manual-review` 记录人工核查请求与证据。
|
||||
`observation` 只允许:
|
||||
|
||||
### 文本字段校验
|
||||
- `SUBMITTED`:明确订单已创建,转 `WAITING_PAYMENT`;
|
||||
- `EXTERNAL_PAYMENT_HANDOFF`:已跳外部支付,停止并转 `RECONCILIATION_REQUIRED`;
|
||||
- `SECURITY_CHALLENGE`:出现安全校验,停止并转调和;
|
||||
- `UNKNOWN`:超时、断连或页面不明,转调和。
|
||||
|
||||
所有自由文本字段(事件消息、失败原因、备注):
|
||||
提交后没有“retry”观察值。任何结果都不能释放围栏或开放第二次点击。
|
||||
|
||||
- UTF-8,有长度上限(事件消息 1000 字节,备注 500 字节)。
|
||||
- 拒绝含 `authorization:`、`api_key`、`bearer ` 的内容,防止凭据误入审计日志。
|
||||
- **超长必须由客户端截断后再发,服务端拒绝而不是静默截断。**
|
||||
### 文本和金额校验
|
||||
|
||||
- 规格字段:Unicode 规范化后精确相等;不得包含、前缀、编辑距离或 AI 猜测。
|
||||
- `goods_id`:仅 ASCII 十进制数字,canonical URL 中唯一。
|
||||
- 金额:`0.01` 到系统配置上限,至多两位小数;规范化后再比较和持久化。
|
||||
- 数量:正整数,服务端与设备均设置合理上限;不能从字符串静默截断。
|
||||
|
||||
## 三、采购工具本地模块合约
|
||||
|
||||
### `TaskSource` / `ResultSink`
|
||||
|
||||
执行器只依赖抽象,不认识来源:
|
||||
|
||||
```python
|
||||
class TaskSource(ABC):
|
||||
@abstractmethod
|
||||
def load_tasks(self) -> list[OrderTask]: ...
|
||||
class TaskSource(Protocol):
|
||||
def claim_next(self, session: Session) -> ClaimedPurchase | None: ...
|
||||
def renew_lease(self, claim: Claim) -> Lease: ...
|
||||
|
||||
class ResultSink(ABC):
|
||||
@abstractmethod
|
||||
def save_task_result(self, task: OrderTask) -> None: ...
|
||||
class ResultSink(Protocol):
|
||||
def append_events(self, claim: Claim, events: list[TaskEvent]) -> None: ...
|
||||
def upload_screenshot(self, claim: Claim, asset: ScreenshotAsset) -> AssetRef: ...
|
||||
def fail_attempt(self, claim: Claim, failure: AttemptFailure) -> None: ...
|
||||
def create_submission_fence(self, claim: Claim, proof: SubmissionProof) -> SubmissionPermit: ...
|
||||
def report_submission_result(self, permit: SubmissionPermit, result: SubmissionResult) -> None: ...
|
||||
```
|
||||
|
||||
实现:
|
||||
执行器不能依赖具体 HTTP 或 Excel 实现。`SubmissionPermit` 只能由 `ResultSink` 的服务端成功响应构造,
|
||||
业务代码不能手工 new 一个许可。
|
||||
|
||||
| 实现 | 用途 |
|
||||
| --- | --- |
|
||||
| `HttpTaskSource` | 从采购服务领取任务(默认) |
|
||||
| `HttpResultSink` | 回传结果到采购服务(默认) |
|
||||
| `FixtureTaskSource` | 仅测试 / 演示:读取仓库内假数据,不接触真实订单 |
|
||||
| `JsonlResultSink` | 仅测试 / 断连暂存:本地追加写入,恢复连接后按幂等键补传 |
|
||||
### 真机能力分层
|
||||
|
||||
### 真机流程模块
|
||||
|
||||
`client/src/android/pdd_flow.py` 的公开入口,每个都不得越界:
|
||||
|
||||
| 函数 | 输入 | 输出 | 副作用边界 |
|
||||
| 能力 | 输入 | 输出 | 安全边界 |
|
||||
| --- | --- | --- | --- |
|
||||
| `open_product(url)` | 商品 URL | 页面快照路径 | 只打开页面,不点击购买 |
|
||||
| `open_sku_panel()` | - | 面板快照 | 只点规格入口,不提交 |
|
||||
| `select_sku_options(items)` | `{维度: 值}` | 选中证据 | 按维度精确匹配,找不到抛错 |
|
||||
| `set_quantity(n)` | 数量 | 读回值 | 必须复核等于 n |
|
||||
| `read_sku_unit_price(xml)` | 规格面板 XML | 单价或 `None` | **闸门一**;读不到返回 `None`,不猜 |
|
||||
| `leave_product()` | - | - | 第一趟结束时退出并释放手机 |
|
||||
| `go_to_order_confirm()` | - | 确认页摘要 | **可能创建订单**,需显式授权 |
|
||||
| `read_order_confirm_info(xml)` | 页面 XML | 非敏感摘要 | **闸门三**;不提取地址原文、手机号 |
|
||||
| `submit_order(auth, submission)` | 授权 + 已建立的提交围栏 | 提交结果 | **唯一创建真实订单入口**,四条件与围栏全通过后只点一次 |
|
||||
| `open_product()` | canonical URL + 证据版本 | 已确认商品页 | URL、前台包、App 版本全部匹配 |
|
||||
| `open_sku_panel()` | 版本绑定受控入口 | 已确认规格面板 | 精确唯一;无通用 click |
|
||||
| `select_sku_options()` | 维度 → 精确值 | 选中态摘要 | 维度内唯一匹配并读回 |
|
||||
| `read_sku_unit_price()` | 已确认规格面板 | 十进制单价 | 排除原价、按钮价和歧义候选 |
|
||||
| `set_quantity_and_readback()` | 授权数量 | 实际数量 | 精确读回,否则停 |
|
||||
| `go_to_order_confirm()` | 已通过闸门二 | 确认页摘要 | 后续真机任务取证后才实现 |
|
||||
| `submit_order_once()` | 不可伪造的首次 `SubmissionPermit` | 观察结果 | 许可、闸门、唯一控件全校验;点前持久化;绝不重试 |
|
||||
|
||||
两个不可逆入口:
|
||||
T-103 只实现隔离的 `SkuSelectionFlow`:前四项加安全退出。它的模块和静态依赖不得引用数量、确认页、
|
||||
围栏、提交或支付能力。后续任务按取证顺序组合成生产 `SinglePassPurchaseFlow`。
|
||||
|
||||
- `go_to_order_confirm()` 必须校验授权存在;`submit_order()` 还必须校验授权已由服务端围栏
|
||||
且 `submission` 与当前任务、命令、授权完全一致。
|
||||
- **第一趟的代码路径不得引用这两个函数。** 必须有测试证明试选流程不可达它们。
|
||||
- `search_by_image()` 属 B 路径,V2 再实现。
|
||||
## 四、实现前仍需定值
|
||||
|
||||
## 四、待实现时确认
|
||||
|
||||
- **规格面板上单价的节点位置与文本形态**(阻塞闸门一,由 T-103 真机取证确定)。
|
||||
- 授权 `expires_at` 的默认时长。
|
||||
- 定时轮询的默认间隔与连续失败停止阈值。
|
||||
- 分页游标的编码方式。
|
||||
- 设备凭据的有效期与轮换策略。
|
||||
- 证据资产的保留期与清理策略。
|
||||
- 授权有效期、领取租约时长、心跳/轮询间隔和连续失败停止阈值;
|
||||
- 截图大小上限和内部保留期限;
|
||||
- 可配置单任务数量与最高总价系统上限;
|
||||
- 首次真实提交真机任务的人工授权和待付款订单处置步骤。
|
||||
|
||||
+84
-21
@@ -11,35 +11,48 @@
|
||||
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-08-03
|
||||
- 阶段:**Phase 0 · 地基(两端骨架、核心数据模型与统一入口已完成,尚无真机采购业务代码)**
|
||||
- MVP 形态:手工填链接建单 → 批量开始试选 → 定时轮询 → **第一趟试选** → 人工确认 → **第二趟下单** → 待付款
|
||||
- 日期:2026-08-04
|
||||
- 阶段:**Phase 1 · T-103 规格选择/读价与 Phase 2 安全服务端任务并行**
|
||||
- MVP 形态:手工填链接建单 → 批量“开始采购”并签发一次性授权 → 定时领取 → 同一趟精确
|
||||
选规格 / 三道价格闸门 / 提交围栏 / 点击一次 → 待付款。管理员点击“开始采购”即授权桌面端
|
||||
创建待付款订单;不再有试选后确认,系统绝不自动付款。
|
||||
- 技术栈:已定。采购服务(`admin/`)使用 Go 1.23+ / gin / SQLite;采购工具(`client/`)
|
||||
使用 Python 3.11+ / uiautomator2 / PySide6。
|
||||
详见 [`03-tech-stack.md`](03-tech-stack.md)
|
||||
- 生产代码:`admin/` 已有最小 Go 服务、健康检查、核心领域模型、SQLite 迁移与任务状态机;
|
||||
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略;尚无真机采购流程
|
||||
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 6 项离线单元测试
|
||||
(不连接真机)
|
||||
- 数据:SQLite 核心表与迁移已落成;无业务实例数据
|
||||
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
|
||||
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板状态的只读取证 CLI,
|
||||
以及绑定 PKG110 / Android 16 / 拼多多 8.17.0 的规格证据确定性脱敏 CLI;尚无规格选择、价格读取或下单流程
|
||||
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 80 项离线单元测试
|
||||
(全部 mock,不连接真机)
|
||||
- 数据:SQLite v1 核心表与迁移已落成,但仍是旧两趟 schema(含 `spec_trials`、
|
||||
`authorized_unit_price` 和旧状态);无业务实例数据。T-111 只冻结目标契约,不改生产代码;T-209
|
||||
必须先迁移 schema / 领域状态机,T-203 才能实现新“开始采购”事务。
|
||||
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
|
||||
优先使用合规的既有 venv;仅在其缺失时才从 Python Launcher 已安装版本中选择最高的 Python 3.11+,
|
||||
并且不覆盖低版本环境;成功后打印真实启动命令。
|
||||
- 标准验证路径:`./init.ps1` 已实际跑通 admin 的 mod download / test / vet / build、client 的
|
||||
editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。
|
||||
- 当前 blocker:无外部 blocker。T-002、T-003、T-004 已完成;T-101 真机环境盘点已就绪,
|
||||
但尚未真机验收。桌面 GUI 与真机流程尚未验收。
|
||||
- 当前设备门禁:人工已确认拼多多 8.17.0、goods_id `937122477375` 的衣服商品只能通过“快要抢光”
|
||||
打开规格面板;T-110 已获项目所有者批准,只把该证据/版本绑定的精确唯一入口作为受控导航。
|
||||
T-103 的隔离 capability 仍不得包含数量、确认页、提交订单、付款或通用点击;这些动作由后续任务
|
||||
分别取证后才接入生产单趟流程。T-103 的 v5 派生 XML 已由人确认无实际地址/
|
||||
手机号,并保留目标预选规格、顶部当前价“快卖完 ¥12.88”和原价“¥29.88”;底部“提交订单 ¥12.88”
|
||||
继续属于硬拒绝区。派生截图虽然把顶部价格遮住一半,但项目已停止遮罩器开发,视觉完整性不再阻塞
|
||||
规格选择与读价;T-204 将直接上传内部原始截图供管理员查看。T-010 已允许不依赖真机字段的 T-201
|
||||
和只创建 `DRAFT` 的 T-202 并行。T-209 的 schema / 状态机迁移不依赖页面选择器,可在 T-111
|
||||
完成后推进;随后做 T-203 服务端“开始采购”授权事务。T-205 起实际 attempt / 真机字段继续等待 T-103。
|
||||
|
||||
## 当前目录要点
|
||||
|
||||
| 路径 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
|
||||
| `docs/tasks/` | 已有(T-001~T-004、T-005~T-009、T-101) | T-001~T-004 已完成;T-101 已就绪、待真机验收 |
|
||||
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-202) | T-111 单趟契约已完成;T-103 已恢复;T-202 在独立工作树待主审提交 |
|
||||
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
||||
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
||||
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 |
|
||||
| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、离线测试与 wheel 元数据检查;无真机或采购流程 |
|
||||
| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、显式 serial 的基线/商品打开/规格面板只读取证、确定性证据脱敏、离线测试与 wheel 元数据检查;无规格选择、价格读取或下单流程 |
|
||||
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
|
||||
|
||||
## 任务状态
|
||||
@@ -50,9 +63,13 @@
|
||||
源码目录契约)、T-008(Vikunja 任务权威与单向导出)、T-009(MVP 关键路径与并行波次),
|
||||
以及 T-001(采购服务 Go 骨架)。
|
||||
- 已完成:T-002(采购工具 Python 骨架)、T-003(双端统一初始化与验证入口)、
|
||||
T-004(核心数据模型)。T-101(真机环境盘点)已建档并就绪,尚未真机验收,接续推进
|
||||
T-101 → T-102 → T-103。
|
||||
- T-103 是当前最高优先级和 MVP 生死线。通过前不开发依赖真机可读字段的 Phase 2 生产页面。
|
||||
T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)、T-102(canonical
|
||||
链接打开与目标商品/隐私人工验收)。
|
||||
- 已完成 T-010(安全并行门禁)与 T-201(管理员登录与会话)。T-202 已由 admin agent 实现且
|
||||
测试通过,仍只创建/展示 `DRAFT`;当前在独立工作树等待主 agent 审阅、提交和推送。
|
||||
- 已完成 T-110(受控规格入口边界)与 T-111(开始采购授权的单趟契约)。T-103 已恢复为
|
||||
`DOING`,以 `SkuSelectionFlow` 继续最小 fixture、精确规格和读价,不实现数量、确认页或提交;
|
||||
内部原始截图上传交给 T-204。admin 方向在 T-202 主审合入后,先落 T-209 迁移旧 schema/状态机。
|
||||
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
|
||||
可读字段时必须先回修原型与交互清单。
|
||||
|
||||
@@ -93,9 +110,55 @@ cd client
|
||||
```
|
||||
|
||||
`client/requirements.txt` 是唯一依赖来源,`client/pyproject.toml` 动态读取它生成 wheel 的
|
||||
`Requires-Dist`。Python 3.12 已验证 6 项离线测试、编译与 wheel 元数据;完整运行时依赖安装
|
||||
`Requires-Dist`。Python 3.12 与 3.14 均已验证离线测试、编译与 wheel 元数据;完整运行时依赖安装
|
||||
(`pip install -e .`)已通过。`init.ps1` 优先使用合规既有 venv,缺失时自动选择最高的 Python 3.11+;
|
||||
本机现有 venv 实际验证为 Python 3.12。桌面 GUI 与真机流程未作为 T-003 验收执行。
|
||||
本机 Python 3.12 与 3.14 均已验证,主工作区当前选择 Python 3.14。桌面 GUI 与真机流程未作为 T-003
|
||||
验收执行。
|
||||
|
||||
T-101 的人工真机验收命令(先把手机切到不含收货地址、手机号、支付信息或其他无关隐私的安全页面;
|
||||
必须从 `adb devices -l` 手工复制在线 serial,不能留空或自动选择):
|
||||
|
||||
```powershell
|
||||
# 仓库根目录;USB 和 WiFi 分开执行,每次只保留一个通道在线
|
||||
D:\Portable\adb\adb.exe devices -l
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_device_baseline.py --serial <USB_SERIAL> --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-101\usb-baseline" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
# WiFi 必须由人先建立通道、断开 USB 后再手工粘贴在线 WiFi serial;脚本不自动 connect/reconnect
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_device_baseline.py --serial <WIFI_SERIAL> --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-101\wifi-baseline" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
```
|
||||
|
||||
脚本只读取非敏感设备元数据、拼多多版本、截图和完整 XML;不会打开商品或写页面判据。它对同机双
|
||||
通道、身份读取失败、offline、unauthorized、超时或 serial 不存在均 fail closed。成功目录的
|
||||
`manifest.json` 只记录元数据、产物路径和 SHA-256,不记录 XML 页面正文或原始 serial。T-101 已由人
|
||||
完成两次验收并确认原始证据不含敏感信息;`--timeout` 约束 ADB 命令、ADB socket 以及截图/节点树的
|
||||
公开 JSON-RPC 调用,uiautomator2 初始化仍有上游固定启动上限。截图/XML 只保留在本地,执行记录只写
|
||||
路径和 SHA-256,原始证据不得提交 Git。
|
||||
|
||||
T-102 的人工真机验收命令(只接受唯一 canonical 链接;USB 或 WiFi 每次只保留一个通道在线):
|
||||
|
||||
```powershell
|
||||
# 仓库根目录;从输出中手工复制本次在线 serial
|
||||
D:\Portable\adb\adb.exe devices -l
|
||||
.\client\.venv\Scripts\python.exe client\scripts\capture_product_open.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-102\product-open-<GOODS_ID>" --timeout 10 --adb D:\Portable\adb\adb.exe
|
||||
```
|
||||
|
||||
脚本只允许 Android `VIEW` intent,并把 package 固定为 `com.xunmeng.pinduoduo`;intent 后会在
|
||||
`--timeout` 的有限窗口内只读轮询前台 package,解决 App 异步切换造成的一次性误判,超时仍会停止。
|
||||
它不点击、滑动、输入或判断商品页节点,也不打开规格、读取价格、进入下单或支付。运行拼多多版本必须精确为
|
||||
`8.17.0`,否则在 intent 前停止。成功后由人本地查看截图/XML,确认页面确为该 `goods_id` 对应商品并
|
||||
检查无地址、手机号、支付信息或其他无关隐私;只回报 manifest 路径及截图/XML SHA-256,原始证据
|
||||
不得提交 Git。T-102 已由人确认 goods_id `958756616606` 的目标商品及截图/XML 隐私,并完成验收。
|
||||
|
||||
T-103 已确认当前衣服商品只能从精确文案“快要抢光”进入规格面板;T-110 只批准该证据/版本绑定入口,
|
||||
不授权“免拼购买 / 单独购买 / 直接拼成”等其他文案。面板刚打开时目标颜色“黑色CHA(纯棉)”与尺码
|
||||
“M(建议100-115)”已经自动选中。v5 派生 XML 与人工观察已证明顶部当前价为“快卖完 ¥12.88”、
|
||||
并列原价为“¥29.88”;促销前缀会变化,正式读价不能把该文案作为主判据,必须以面板结构、价格角色、
|
||||
严格金额格式和唯一性组合判断,冲突时返回 unreadable。
|
||||
|
||||
本轮不再运行或修改截图遮罩器。T-103 先用本机证据验证真机能力;T-204 再把原始规格页截图上传采购
|
||||
服务,允许页面中已显示的地址和手机号,完整 XML 不上传。代码从已确认的派生 XML 提取最小 fixture,
|
||||
并在真机运行时只返回获准摘要。T-111 完成后,下一步实现 `SkuSelectionFlow` 的受控入口、
|
||||
按维度精确选择、选中态读回、SKU 单价读取与安全退出;完成离线主审后,由人先把两个维度改成明确
|
||||
记录的非目标值,再执行真机脚本确认恢复目标规格与 `12.88` 单价。生产原始截图展示/上传由 T-204 处理。
|
||||
|
||||
## 关键背景
|
||||
|
||||
@@ -112,14 +175,14 @@ cd client
|
||||
## 已知风险(开工前须知)
|
||||
|
||||
1. **M2 是生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、**读到该 SKU 单价**
|
||||
(T-103)。Phase 1 不通过之前不要写生产页面;Phase 0 原型只确认流程和信息架构,真机
|
||||
结论改变字段时必须回修。
|
||||
(T-103)。M2 前可以写不依赖页面字段的管理员会话、DRAFT 建单和服务端授权事务;不能写
|
||||
依赖未取证选择器的生产执行代码。真机结论改变字段时先回修契约与原型。
|
||||
2. **拼多多页面结构随版本变化**,已观察到详情页无独立规格入口、价格节点被拆分等情况。
|
||||
3. **授权卡死**:前序项目出现过 `EXECUTING` 授权永不推进导致任务锁死。本项目在 T-207
|
||||
实现围栏前超时 / 放弃,在 T-208 实现围栏后调和;围栏后不得释放或重试。
|
||||
4. **规格面板单价位置未取证**:闸门一依赖它,T-103 必须一并取证。若读不可靠,
|
||||
确认页设计要改。
|
||||
5. **MVP 已收窄**:只做手工填链接、批量开始第一趟试选和两趟执行。Excel、ERP、图搜、
|
||||
4. **规格面板读价尚未形成生产判据**:已人工观察当前价与原价,但 T-103 仍需把证据固化为
|
||||
唯一、版本绑定且排除按钮价的读取规则;失败就转人工,不删价格闸门。
|
||||
5. **MVP 已收窄**:只做手工填链接、批量开始采购和单趟创建待付款订单。Excel、ERP、图搜、
|
||||
批量顺序编排 / 暂停接管、订单自动核对、AI 辅助全部推到 V2(见 `06-tasks.md` 的
|
||||
T-501~T-508)。
|
||||
6. **App 版本必须 fail closed**:运行时拼多多版本与本项目已取证版本不一致就停止领取,
|
||||
|
||||
@@ -51,6 +51,8 @@
|
||||
- **开工门槛(一次性)**:P0 的 UI 模块首次实现前应有原型;没有就先生成原型、人工确认后再拆任务。
|
||||
- **两阶段确认**:Phase 0 先确认流程、状态、主动作和布局;T-103 真机取证后再核对实际可读
|
||||
字段与文案。第二阶段若有变化,先修订 IX 与原型,再写生产页面。
|
||||
- 管理员“开始采购”授权的单趟流程属于显著改版,T-111 重新生成任务创建、工作台、详情和桌面执行
|
||||
四个原型;旧的试选后确认/第二趟状态不得继续作为实现输入。
|
||||
- **触发式重新生成**:新需求显著改变某页面的布局或控件集合时,把"重新生成该页原型 → 更新 IX 草稿"作为该任务的第一步。判断标准只有一条:这次变更是否让 agent 需要重新"看图"才能枚举交互。换文案、加字段等小改动只改 IX 条目,不碰原型。
|
||||
- **实现后即过期**:页面实现后,原型自动视为过期,不回头修补;实现后的视觉事实由任务文件 `## 执行记录` 中的真实截图或可运行验证承担。
|
||||
- 需要新原型时整页重新生成,不逐次修补旧文件。
|
||||
|
||||
@@ -398,12 +398,12 @@
|
||||
<option value="version_mismatch">未就绪 · 拼多多版本失配</option>
|
||||
<option value="idle" selected>已就绪 · 尚未轮询</option>
|
||||
<option value="polling">轮询中 · 暂无任务</option>
|
||||
<option value="trial">执行中 · 第一趟试选</option>
|
||||
<option value="trial_done">第一趟完成 · 已释放手机</option>
|
||||
<option value="dry_run">执行中 · 下单只读演练</option>
|
||||
<option value="dry_run_ready">演练完成 · 等待真实第二趟</option>
|
||||
<option value="order_prefence">真实第二趟 · 围栏前校验</option>
|
||||
<option value="fencing">真实第二趟 · 申请围栏中</option>
|
||||
<option value="claimed">单趟执行 · 已领取</option>
|
||||
<option value="selecting">单趟执行 · 选择规格</option>
|
||||
<option value="gate1">单趟执行 · 闸门一</option>
|
||||
<option value="quantity_gate2">单趟执行 · 数量与闸门二</option>
|
||||
<option value="confirm_gate3">单趟执行 · 确认页与闸门三</option>
|
||||
<option value="fencing">单趟执行 · 申请围栏中</option>
|
||||
<option value="fence_failed">围栏失败或响应不明 · 不点击</option>
|
||||
<option value="fenced">围栏后 · 核对同一提交</option>
|
||||
<option value="waiting_payment">明确已创建 · 等人付款</option>
|
||||
@@ -469,7 +469,7 @@
|
||||
<div class="span-2"><dt>商品</dt><dd id="product">暂无商品;轮询会在安全就绪后领取一条任务</dd></div>
|
||||
<div><dt>颜色分类</dt><dd id="color">—</dd></div>
|
||||
<div><dt>尺码 / 数量</dt><dd id="size-qty">—</dd></div>
|
||||
<div><dt>授权单价</dt><dd id="auth-price">—</dd></div>
|
||||
<div><dt>闸门一单价</dt><dd id="gate1-price">—</dd></div>
|
||||
<div><dt>金额上限</dt><dd id="price-cap">—</dd></div>
|
||||
<div class="span-2"><dt>提交围栏</dt><dd id="submission">未建立</dd></div>
|
||||
</dl>
|
||||
@@ -583,41 +583,41 @@
|
||||
const task = {
|
||||
id: 'CB-20260803-0042',
|
||||
product: '纯棉圆领短袖 T 恤(原型假数据)',
|
||||
color: '白色', sizeQty: 'XL / 2 件', authPrice: 'CNY 32.50', cap: 'CNY 80.00'
|
||||
color: '白色', sizeQty: 'XL / 2 件', gate1Price: 'CNY 32.50', cap: 'CNY 80.00'
|
||||
};
|
||||
const records = [
|
||||
{
|
||||
id:'REC-DEMO-0042', taskId:'CB-20260803-0042', title:'纯棉圆领短袖 T 恤(原型假数据)', collectedAt:'2026-08-03T13:52:18+08:00',
|
||||
status:'RECONCILIATION_REQUIRED', spec:'白色 | XL | 2 件', cap:'CNY 80.00', submission:'SUB-DEMO-0042 · 结果不明确',
|
||||
note:'围栏后网络中断,订单可能已创建。只允许核查同一提交记录,不能重试。', evidence:['商品页截图裁剪(内联假证据)','规格面板截图(内联假证据)'],
|
||||
timeline:['13:51:04 重新选择同一规格','13:51:41 三道价格闸门通过(假数据)','13:51:58 围栏已建立','13:52:18 点击结果不明确,转人工核查'],
|
||||
note:'围栏后网络中断,订单可能已创建。只允许核查同一提交记录,不能重试。', evidence:['商品页原始截图(内联假证据)','规格面板原始截图(内联假证据)'],
|
||||
timeline:['13:51:04 同一趟精确选择规格','13:51:41 三道价格闸门通过(假数据)','13:51:58 围栏已建立','13:52:18 点击结果不明确,转人工核查'],
|
||||
result:'下单待核查:白色 | XL | CNY 32.50 | 2 件', resultNote:'订单可能已创建、支付未完成。系统不会再次提交,也不会付款。'
|
||||
},
|
||||
{
|
||||
id:'REC-DEMO-0041', taskId:'CB-20260803-0041', title:'办公室桌面收纳盒(原型假数据)', collectedAt:'2026-08-03T12:24:09+08:00',
|
||||
status:'WAITING_CONFIRMATION', spec:'透明 | L | 2 件', cap:'CNY 60.00', submission:'未建立',
|
||||
note:'第一趟试选完成,规格面板证据已回传,等待管理员确认。', evidence:['商品页截图裁剪(内联假证据)','规格面板截图(内联假证据)'],
|
||||
timeline:['12:22:31 打开商品','12:23:04 匹配透明 / L','12:23:38 读取规格面板单价','12:24:09 截图并退出商品页'],
|
||||
result:'试选:透明 | L | CNY 24.00 | 2 件', resultNote:'这只是试选结果,不代表已下单。'
|
||||
status:'ORDERING', spec:'透明 | L | 2 件', cap:'CNY 60.00', submission:'未建立',
|
||||
note:'管理员已开始采购,当前单趟已完成规格与闸门一,正在设置并复核数量。', evidence:['商品页截图(内联假证据)','规格面板原始截图(内联假证据)'],
|
||||
timeline:['12:22:31 领取一次性授权','12:23:04 精确匹配透明 / L','12:23:38 闸门一读取 CNY 24.00','12:24:09 正在复核数量与闸门二'],
|
||||
result:'执行中:透明 | L | CNY 24.00 | 2 件', resultNote:'无需等待管理员中途确认;任一门禁失败即停止。'
|
||||
},
|
||||
{
|
||||
id:'REC-DEMO-0038', taskId:'CB-20260803-0038', title:'标签打印纸补货(原型假数据)', collectedAt:'2026-08-03T10:06:42+08:00',
|
||||
status:'WAITING_PAYMENT', spec:'白色 | 50×30 | 1 件', cap:'CNY 50.00', submission:'SUB-DEMO-0038 · SUBMITTED',
|
||||
note:'已明确创建待付款订单,自动化已停止,等待人核对后自行付款。', evidence:['订单确认页非敏感截图(内联假证据)'],
|
||||
timeline:['10:04:22 重新选择同一规格','10:05:10 价格与数量复核通过','10:05:36 获得唯一围栏许可','10:06:42 明确创建待付款订单'],
|
||||
note:'已明确创建待付款订单,自动化已停止,等待人核对后自行付款。', evidence:['订单确认页内部原始截图(内联假证据)'],
|
||||
timeline:['10:04:22 同一趟精确选择规格','10:05:10 两次价格与数量复核通过','10:05:36 获得唯一围栏许可','10:06:42 明确创建待付款订单'],
|
||||
result:'已创建待付款订单:白色 | 50×30 | CNY 46.00 | 1 件', resultNote:'系统只创建待付款订单,付款始终由人完成。'
|
||||
},
|
||||
{
|
||||
id:'REC-DEMO-0032', taskId:'CB-20260803-0032', title:'仓库打包胶带(原型假数据)', collectedAt:'2026-08-03T09:18:05+08:00',
|
||||
status:'NEEDS_MANUAL', spec:'透明 | 45mm | 4 件', cap:'CNY 55.00', submission:'未建立',
|
||||
note:'商品页面未取得可信图片,授权价与当前规格面板价格不一致,已转人工。', evidence:[],
|
||||
note:'设置数量后规格面板价格与闸门一不一致,已在围栏前转人工。', evidence:[],
|
||||
timeline:['09:16:40 打开商品','09:17:22 匹配透明 / 45mm','09:18:05 价格闸门二不通过,停止自动流程'],
|
||||
result:'未采购:授权价 CNY 12.00 | 当前价 CNY 13.50', resultNote:'没有创建订单,也没有付款。'
|
||||
result:'未采购:闸门一 CNY 12.00 | 闸门二 CNY 13.50', resultNote:'没有建立围栏、创建订单或付款。'
|
||||
}
|
||||
].sort((a, b) => b.collectedAt.localeCompare(a.collectedAt));
|
||||
const recordStatus = {
|
||||
RECONCILIATION_REQUIRED:{label:'结果待核查',tone:'critical'},
|
||||
WAITING_CONFIRMATION:{label:'等待确认',tone:'caution'},
|
||||
ORDERING:{label:'采购执行中',tone:'info'},
|
||||
WAITING_PAYMENT:{label:'待付款',tone:'success'},
|
||||
NEEDS_MANUAL:{label:'转人工',tone:'caution'}
|
||||
};
|
||||
@@ -627,40 +627,38 @@
|
||||
let detailRequestId = 0;
|
||||
const baseSteps = {
|
||||
idle: [],
|
||||
trial: ['打开商品(待真机判据)', '精确匹配颜色分类与尺码', '从规格面板读取单价', '截图并退出商品页'],
|
||||
dry: ['重新打开商品(待真机判据)', '精确选择与数量复核', '读取订单确认页非敏感摘要', '验证提交控件唯一后退出'],
|
||||
order: ['重新选择同一规格', '闸门二:单价必须等于授权价', '数量复核与闸门三', '申请服务端提交围栏', '围栏许可后内部仅点击一次', '调和同一提交结果']
|
||||
single: ['核对一次性授权并打开商品', '精确选择颜色分类与尺码', '闸门一:读取 SKU 单价并校验上限', '设置数量、读回并执行闸门二', '确认页执行闸门三与控件唯一校验', '申请服务端提交围栏', '首次许可后持久化并内部点击一次', '调和同一提交结果']
|
||||
};
|
||||
const scenarios = {
|
||||
not_ready: { tone:'critical', title:'web 服务不可达', text:'未开始轮询。检查本地网络和服务地址;已完成的本地证据会保留待补传。', web:'bad', device:'ok', version:'ok', leg:'未就绪', legTone:'critical', action:'开始轮询', enabled:false, reason:'web 服务未就绪,不能领取任务。', session:'未启动', step:'连接检查', kind:'idle', active:-1, countdown:'—', fail:'1 / 3' },
|
||||
version_mismatch: { tone:'critical', title:'拼多多版本与证据不一致', text:'已 fail closed:停止领取真机任务。下一步是由人重新取证并记录 App 版本,不能沿用旧判据。', web:'ok', device:'ok', version:'bad', leg:'版本失配', legTone:'critical', action:'开始轮询', enabled:false, reason:'App 版本失配;必须先完成本项目真机取证。', session:'已停止', step:'等待重新取证', kind:'idle', active:-1, countdown:'—', fail:'0 / 3' },
|
||||
idle: { tone:'success', title:'设备已就绪(模拟)', text:'三项就绪条件在本场景中均为模拟通过,可以演示定时轮询;不会连接真实设备。', web:'ok', device:'ok', version:'ok', leg:'空闲', legTone:'success', action:'开始轮询', enabled:true, reason:'点击只切换原型状态,不发起网络请求。', session:'未启动', step:'等待开始', kind:'idle', active:-1, countdown:'—', fail:'0 / 3' },
|
||||
polling: { tone:'info', title:'正在轮询,暂无待领任务', text:'无任务是正常空状态,不计入失败;将按配置间隔继续检查。', web:'ok', device:'ok', version:'ok', leg:'轮询中', legTone:'info', action:'停止轮询', enabled:true, reason:'停止后不再领取新任务。', session:'运行中', step:'等待下一轮', kind:'idle', active:-1, countdown:'00:12', fail:'0 / 3', count:2 },
|
||||
trial: { tone:'info', title:'第一趟试选正在执行', text:'只勾选规格、读价和截图;不会进入订单确认流程,完成后必须退出商品页释放手机。', web:'ok', device:'ok', version:'ok', leg:'TRIAL · 第一趟试选', legTone:'info', action:'真机步骤执行中', enabled:false, reason:'真机步骤期间禁用硬取消和关闭,避免留下无法判定的中间态。', session:'运行中', step:'读取规格面板单价', kind:'trial', active:2, countdown:'暂停', fail:'0 / 3', lease:'04:18' },
|
||||
trial_done: { tone:'success', title:'试选已回传,手机已释放', text:'规格与价格证据已保存(模拟);任务等待管理员确认,本设备继续下一轮。', web:'ok', device:'ok', version:'ok', leg:'TRIAL · 已完成', legTone:'success', action:'停止轮询', enabled:true, reason:'手机已退出商品页,可以安全停止轮询。', session:'运行中', step:'等待下一轮', kind:'trial', active:4, countdown:'00:09', fail:'0 / 3', count:3 },
|
||||
dry_run: { tone:'info', title:'DRY-RUN · 只读演练', text:'只到订单确认页读取非敏感摘要并验证控件唯一;不会提交订单,也不会消费授权。', web:'ok', device:'ok', version:'ok', leg:'DRY-RUN · 不会提交', legTone:'info', action:'只读演练进行中', enabled:false, reason:'演练中不提供提交动作;真机步骤期间受控关闭。', session:'运行中', step:'读取订单确认页', kind:'dry', active:2, countdown:'暂停', fail:'0 / 3', lease:'03:42' },
|
||||
dry_run_ready: { tone:'success', title:'只读演练已完成', text:'演练证据已回传(模拟)并退出确认页。旧演练不能替代真实第二趟的当前页面事实。', web:'ok', device:'ok', version:'ok', leg:'DRY-RUN · READY', legTone:'success', action:'停止轮询', enabled:true, reason:'等待服务端安排真实第二趟;不会由用户直接触发提交。', session:'运行中', step:'等待真实第二趟', kind:'dry', active:4, countdown:'00:16', fail:'0 / 3', count:4 },
|
||||
order_prefence: { tone:'caution', title:'真实第二趟 · 围栏前校验', text:'正在重新验证授权、闸门二、数量、闸门三和控件唯一。此时尚无点击许可。', web:'ok', device:'ok', version:'ok', leg:'ORDER · 围栏前', legTone:'caution', action:'安全校验进行中', enabled:false, reason:'没有服务端围栏许可,绝不能点击提交订单。', session:'运行中', step:'闸门三:核对实付款', kind:'order', active:2, countdown:'暂停', fail:'0 / 3', lease:'02:58' },
|
||||
fencing: { tone:'caution', title:'正在申请服务端提交围栏', text:'等待明确的 click_permitted。超时、冲突、网络失败或响应不明都不会点击。', web:'ok', device:'ok', version:'ok', leg:'ORDER · 申请围栏', legTone:'caution', action:'等待围栏响应', enabled:false, reason:'申请中禁止取消、关闭或重复申请。', session:'运行中', step:'申请唯一提交围栏', kind:'order', active:3, countdown:'暂停', fail:'0 / 3', lease:'02:31' },
|
||||
fence_failed: { tone:'critical', title:'未获得点击许可', text:'围栏申请失败或响应不明:没有点击提交订单。请按幂等键核查服务端记录,不能直接重试。', web:'bad', device:'ok', version:'ok', leg:'ORDER · 未获许可', legTone:'critical', action:'打开围栏核查说明', enabled:true, safeAction:'review', reason:'只核查围栏状态,不会触发提交。', session:'已停止', step:'核查幂等键', kind:'order', active:3, countdown:'—', fail:'1 / 3', submission:'围栏状态未知 · 幂等键 DEMO-FENCE-042' },
|
||||
fenced: { tone:'caution', title:'围栏已建立,只核对同一提交', text:'submission_id 已固定。内部流程至多点击一次;用户界面不提供提交、重领、放弃或重试入口。', web:'ok', device:'ok', version:'ok', leg:'ORDER · 已围栏', legTone:'caution', action:'打开同一提交核查说明', enabled:true, safeAction:'review', reason:'只能查看与调和同一提交记录。', session:'受控执行', step:'调和订单结果', kind:'order', active:5, countdown:'暂停', fail:'0 / 3', lease:'01:49', submission:'SUB-DEMO-0042 · FENCED' },
|
||||
waiting_payment: { tone:'success', title:'已明确创建待付款订单', text:'自动化已停止。系统不会付款;请人在拼多多核对后自行付款,再到采购服务标记。', web:'ok', device:'ok', version:'ok', leg:'WAITING_PAYMENT', legTone:'success', action:'查看待付款说明', enabled:true, safeAction:'paymentInfo', reason:'仅显示人工收口说明,不执行支付。', session:'已停止', step:'等待人工付款', kind:'order', active:6, countdown:'—', fail:'0 / 3', count:5, submission:'SUB-DEMO-0042 · SUBMITTED' },
|
||||
uncertain: { tone:'critical', title:'订单可能已创建,结果不明确', text:'保持金额预留和永久围栏,只核查 SUB-DEMO-0042;禁止重新领取、放弃授权或再次点击。', web:'ok', device:'ok', version:'ok', leg:'RECONCILIATION_REQUIRED', legTone:'critical', action:'打开人工核查说明', enabled:true, safeAction:'review', reason:'核查不会发起新的下单。可另行导出假证据摘要。', session:'已停止', step:'人工核查同一提交', kind:'order', active:5, countdown:'—', fail:'0 / 3', submission:'SUB-DEMO-0042 · 结果不明确' },
|
||||
security: { tone:'critical', title:'检测到安全校验,已停止', text:'保留现场并转人工;不尝试绕过验证码、风控、人脸或短信校验。若已围栏,只调和同一提交。', web:'ok', device:'ok', version:'ok', leg:'待人工 · 安全校验', legTone:'critical', action:'打开人工核查说明', enabled:true, safeAction:'review', reason:'只显示核查说明,不提供绕过或继续入口。', session:'已停止', step:'等待人工处理', kind:'order', active:5, countdown:'—', fail:'1 / 3', submission:'SUB-DEMO-0042 · RECONCILIATION_REQUIRED' },
|
||||
payment_handoff: { tone:'critical', title:'进入外部支付交接,已停止', text:'订单可能已创建、支付未完成。不会读取或输入支付凭据,也没有“继续付款”按钮。', web:'ok', device:'ok', version:'ok', leg:'待人工 · 外部支付', legTone:'critical', action:'打开人工核查说明', enabled:true, safeAction:'paymentInfo', reason:'只显示人工核查与付款边界。', session:'已停止', step:'人工核查同一提交', kind:'order', active:5, countdown:'—', fail:'0 / 3', submission:'SUB-DEMO-0042 · HANDED_OFF' },
|
||||
manual: { tone:'caution', title:'任务已转人工:授权价与现价不一致', text:'授权 CNY 32.50,规格面板现价 CNY 35.00。没有下单,也不会自动放弃;请联系管理员处理。', web:'ok', device:'ok', version:'ok', leg:'NEEDS_MANUAL', legTone:'caution', action:'导出假诊断摘要', enabled:true, safeAction:'export', reason:'导出仅生成本地假文本,不包含页面全文或敏感信息。', session:'继续轮询其他任务', step:'当前任务已移交', kind:'order', active:1, countdown:'00:11', fail:'0 / 3', submission:'未建立' },
|
||||
claimed: { tone:'info', title:'已领取管理员授权的采购任务', text:'任务字段与最高总价已锁定;当前只准备打开目标商品,尚未建立提交围栏。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 已领取', legTone:'info', action:'真机步骤执行中', enabled:false, reason:'当前任务已开始,停止轮询只应阻止下一次领取,不能硬取消本任务。', session:'运行中', step:'核对授权与商品', kind:'single', active:0, countdown:'暂停', fail:'0 / 3', lease:'04:48' },
|
||||
selecting: { tone:'info', title:'正在精确选择颜色与尺码', text:'只在已取证维度容器内精确唯一匹配并读回;缺失、重复或版本变化立即停止。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 选择规格', legTone:'info', action:'真机步骤执行中', enabled:false, reason:'当前受控步骤不能被硬取消。', session:'运行中', step:'精确选择规格', kind:'single', active:1, countdown:'暂停', fail:'0 / 3', lease:'04:18' },
|
||||
gate1: { tone:'info', title:'闸门一:读取 SKU 单价', text:'当前规格单价必须唯一可读,单价乘数量不得超过管理员授权的最高总价。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 闸门一', legTone:'info', action:'安全校验进行中', enabled:false, reason:'价格不可读或超上限时立即停止。', session:'运行中', step:'闸门一:读取 SKU 单价', kind:'single', active:2, countdown:'暂停', fail:'0 / 3', lease:'03:58' },
|
||||
quantity_gate2: { tone:'info', title:'数量复核与闸门二', text:'数量必须读回为 2;目标规格不能漂移,第二次单价必须等于闸门一 CNY 32.50。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 闸门二', legTone:'info', action:'安全校验进行中', enabled:false, reason:'数量、规格或价格变化都停止。', session:'运行中', step:'数量读回与闸门二', kind:'single', active:3, countdown:'暂停', fail:'0 / 3', lease:'03:31' },
|
||||
confirm_gate3: { tone:'caution', title:'确认页与闸门三', text:'正在核对规格、数量、应付总额与提交控件唯一;此时没有点击许可。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 闸门三', legTone:'caution', action:'安全校验进行中', enabled:false, reason:'没有服务端围栏许可,绝不能点击提交订单。', session:'运行中', step:'闸门三:核对应付总额', kind:'single', active:4, countdown:'暂停', fail:'0 / 3', lease:'02:58' },
|
||||
fencing: { tone:'caution', title:'正在申请服务端提交围栏', text:'等待首次明确的 click_permitted。超时、冲突、网络失败或响应不明都不会点击。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 申请围栏', legTone:'caution', action:'等待围栏响应', enabled:false, reason:'申请中禁止取消、关闭或换 key 重复申请。', session:'运行中', step:'申请唯一提交围栏', kind:'single', active:5, countdown:'暂停', fail:'0 / 3', lease:'02:31' },
|
||||
fence_failed: { tone:'critical', title:'未获得首次明确点击许可', text:'围栏失败或响应不明:没有点击提交订单。只核查同一幂等键,不能直接重试。', web:'bad', device:'ok', version:'ok', leg:'单趟 · 未获许可', legTone:'critical', action:'打开围栏核查说明', enabled:true, safeAction:'review', reason:'只核查围栏状态,不触发提交。', session:'已停止', step:'核查幂等键', kind:'single', active:5, countdown:'—', fail:'1 / 3', submission:'围栏状态未知 · 幂等键 DEMO-FENCE-042' },
|
||||
fenced: { tone:'caution', title:'围栏已建立,只核对同一提交', text:'submission_id 已固定。内部流程至多点击一次;界面不提供提交、重领、放弃或重试。', web:'ok', device:'ok', version:'ok', leg:'单趟 · 已围栏', legTone:'caution', action:'打开同一提交核查说明', enabled:true, safeAction:'review', reason:'只能查看与调和同一提交记录。', session:'受控执行', step:'调和订单结果', kind:'single', active:7, countdown:'暂停', fail:'0 / 3', lease:'01:49', submission:'SUB-DEMO-0042 · FENCED' },
|
||||
waiting_payment: { tone:'success', title:'已明确创建待付款订单', text:'自动化已停止。系统不会付款;请人在拼多多核对后自行付款,再到采购服务标记。', web:'ok', device:'ok', version:'ok', leg:'WAITING_PAYMENT', legTone:'success', action:'查看待付款说明', enabled:true, safeAction:'paymentInfo', reason:'仅显示人工收口说明,不执行支付。', session:'已停止', step:'等待人工付款', kind:'single', active:8, countdown:'—', fail:'0 / 3', count:5, submission:'SUB-DEMO-0042 · SUBMITTED' },
|
||||
uncertain: { tone:'critical', title:'订单可能已创建,结果不明确', text:'保持金额预留和永久围栏,只核查 SUB-DEMO-0042;禁止重新领取、释放授权或再次点击。', web:'ok', device:'ok', version:'ok', leg:'RECONCILIATION_REQUIRED', legTone:'critical', action:'打开人工核查说明', enabled:true, safeAction:'review', reason:'核查不会发起新的下单。可另行导出假证据摘要。', session:'已停止', step:'人工核查同一提交', kind:'single', active:7, countdown:'—', fail:'0 / 3', submission:'SUB-DEMO-0042 · 结果不明确' },
|
||||
security: { tone:'critical', title:'检测到安全校验,已停止', text:'不绕过验证码、风控、人脸或短信。若已围栏,只调和同一提交;围栏前转人工。', web:'ok', device:'ok', version:'ok', leg:'待人工 · 安全校验', legTone:'critical', action:'打开人工核查说明', enabled:true, safeAction:'review', reason:'只显示核查说明,不提供绕过或继续。', session:'已停止', step:'等待人工处理', kind:'single', active:7, countdown:'—', fail:'1 / 3', submission:'SUB-DEMO-0042 · RECONCILIATION_REQUIRED' },
|
||||
payment_handoff: { tone:'critical', title:'进入外部支付交接,已停止', text:'订单可能已创建、支付未完成。不会读取或输入支付凭据,也没有“继续付款”按钮。', web:'ok', device:'ok', version:'ok', leg:'待人工 · 外部支付', legTone:'critical', action:'打开人工核查说明', enabled:true, safeAction:'paymentInfo', reason:'只显示人工核查与付款边界。', session:'已停止', step:'人工核查同一提交', kind:'single', active:7, countdown:'—', fail:'0 / 3', submission:'SUB-DEMO-0042 · HANDED_OFF' },
|
||||
manual: { tone:'caution', title:'任务已转人工:闸门二价格变化', text:'闸门一 CNY 32.50,数量设置后重读 CNY 35.00。围栏未建立,没有创建订单;需管理员核查后重置。', web:'ok', device:'ok', version:'ok', leg:'NEEDS_MANUAL', legTone:'caution', action:'导出假诊断摘要', enabled:true, safeAction:'export', reason:'导出只生成本地假文本,不含完整 XML、页面全文或敏感字段。', session:'继续轮询其他任务', step:'当前任务已移交', kind:'single', active:3, countdown:'00:11', fail:'0 / 3', submission:'未建立' },
|
||||
failed_stop: { tone:'critical', title:'连续失败达到阈值,轮询已停止', text:'最近三次均为服务连接失败。检查设置和网络;修复后重新开始普通轮询,不会重试任何提交点击。', web:'bad', device:'ok', version:'ok', leg:'自动停止', legTone:'critical', action:'前往配置', enabled:true, safeAction:'settings', reason:'先修复连接;不会自动恢复轮询。', session:'已停止', step:'等待修复', kind:'idle', active:-1, countdown:'—', fail:'3 / 3', count:4 }
|
||||
};
|
||||
const sessionControls = {
|
||||
not_ready:{label:'开始轮询',enabled:false,reason:'web 服务未就绪,不能领取任务。'},
|
||||
version_mismatch:{label:'开始轮询',enabled:false,reason:'App 版本失配,必须先完成本项目真机取证。'},
|
||||
idle:{label:'开始轮询',enabled:true,reason:'只启动普通轮询,不会直接执行下单。'},
|
||||
idle:{label:'开始轮询',enabled:true,reason:'只启动领取会话;只有已获管理员开始采购授权的任务可执行。'},
|
||||
polling:{label:'停止轮询',enabled:true,reason:'停止后不再领取新任务。'},
|
||||
trial:{label:'停止轮询',enabled:false,reason:'真机步骤执行中,禁止硬停止。'},
|
||||
trial_done:{label:'停止轮询',enabled:true,reason:'手机已退出商品页,可以安全停止轮询。'},
|
||||
dry_run:{label:'停止轮询',enabled:false,reason:'只读演练执行中,禁止硬停止。'},
|
||||
dry_run_ready:{label:'停止轮询',enabled:true,reason:'演练已退出确认页,可以安全停止轮询。'},
|
||||
order_prefence:{label:'停止轮询',enabled:false,reason:'真实第二趟校验中,禁止硬停止。'},
|
||||
claimed:{label:'停止轮询',enabled:false,reason:'当前任务已领取;停止只应阻止下次领取,不能硬取消本任务。'},
|
||||
selecting:{label:'停止轮询',enabled:false,reason:'受控真机步骤执行中,禁止硬停止。'},
|
||||
gate1:{label:'停止轮询',enabled:false,reason:'价格闸门执行中,禁止硬停止。'},
|
||||
quantity_gate2:{label:'停止轮询',enabled:false,reason:'数量与闸门二执行中,禁止硬停止。'},
|
||||
confirm_gate3:{label:'停止轮询',enabled:false,reason:'确认页只允许安全校验,禁止硬停止或额外点击。'},
|
||||
fencing:{label:'停止轮询',enabled:false,reason:'围栏申请中,禁止关闭、取消或重复申请。'},
|
||||
fence_failed:{label:'开始轮询',enabled:false,reason:'先核查围栏状态;不能用重新轮询绕过不明确结果。'},
|
||||
fenced:{label:'停止轮询',enabled:false,reason:'已建立围栏,只能调和同一提交。'},
|
||||
@@ -836,10 +834,10 @@
|
||||
$('task-id').textContent = hasTask ? task.id : '—'; $('lease').textContent = s.lease || '—';
|
||||
$('product').textContent = hasTask ? task.product : '暂无商品;轮询会在安全就绪后领取一条任务';
|
||||
$('color').textContent = hasTask ? task.color : '—'; $('size-qty').textContent = hasTask ? task.sizeQty : '—';
|
||||
$('auth-price').textContent = hasTask && s.kind !== 'trial' ? task.authPrice : '—'; $('price-cap').textContent = hasTask ? task.cap : '—';
|
||||
$('gate1-price').textContent = hasTask && ['gate1','quantity_gate2','confirm_gate3','fencing','fence_failed','fenced','waiting_payment','uncertain','security','payment_handoff','manual'].includes($('scenario').value) ? task.gate1Price : '—'; $('price-cap').textContent = hasTask ? task.cap : '—';
|
||||
$('submission').textContent = s.submission || '未建立';
|
||||
$('preview-empty').hidden = hasTask; $('product-preview-image').hidden = !hasTask;
|
||||
$('preview-source').textContent = hasTask ? '来源:商品页截图裁剪(内联假证据)' : '来源:尚无可信图片';
|
||||
$('preview-source').textContent = hasTask ? '来源:内部原始商品截图(内联假证据)' : '来源:尚无可信图片';
|
||||
$('preview-time').textContent = hasTask ? '采集时间:2026-08-03 13:51(假数据)' : '采集时间:—';
|
||||
renderSteps(s.kind, s.active);
|
||||
$('countdown').textContent = s.countdown; $('failure-count').textContent = s.fail; $('completed-count').textContent = String(s.count || 0);
|
||||
@@ -888,7 +886,7 @@
|
||||
window.addEventListener('resize', hideRecordMenu);
|
||||
$('export-log').addEventListener('click', exportDemo);
|
||||
$('close-demo').addEventListener('click', () => {
|
||||
const unsafe = ['trial','dry_run','order_prefence','fencing','fenced'].includes($('scenario').value);
|
||||
const unsafe = ['claimed','selecting','gate1','quantity_gate2','confirm_gate3','fencing','fenced'].includes($('scenario').value);
|
||||
showDialog(unsafe ? '当前不能直接关闭' : '关闭窗口协议', unsafe ? '真机或围栏流程处于受控阶段。原生实现需先完成安全停靠或保持同一 submission 调和,不能让关闭看起来等于“可以重来”。' : '原生实现关闭窗口后立即停止普通轮询,不留后台进程。此 HTML 只演示规则,不会关闭浏览器。');
|
||||
});
|
||||
$('dialog-close').addEventListener('click', () => $('info-dialog').close());
|
||||
|
||||
@@ -203,11 +203,11 @@
|
||||
<h2>创建后发生什么</h2>
|
||||
<ol class="steps">
|
||||
<li>任务先保存为「待开始」。</li>
|
||||
<li>在任务表格勾选后统一点击「开始试选」。</li>
|
||||
<li>第一趟只试选、读价、截图,然后退出商品。</li>
|
||||
<li>你看过证据后再决定是否授权。</li>
|
||||
<li>在任务表格勾选后点击「开始采购(只创建待付款订单)」。</li>
|
||||
<li>该点击锁定商品、规格、数量和最高总价,并授权采购工具执行。</li>
|
||||
<li>采购工具同一趟校验规格、价格和数量后,只创建待付款订单。</li>
|
||||
</ol>
|
||||
<p style="margin-top:10px"><strong>系统不会自动付款。</strong></p>
|
||||
<p style="margin-top:10px"><strong>创建任务本身不授权;系统任何时候都不会自动付款。</strong></p>
|
||||
</section>
|
||||
<section class="prototype-controls">
|
||||
<h2>原型状态</h2>
|
||||
@@ -310,7 +310,7 @@
|
||||
setReady();
|
||||
form.hidden = true;
|
||||
status.className = 'status success show';
|
||||
status.innerHTML = '<h2>任务已创建</h2><p>任务编号 <strong>T-20260803-019</strong>,当前为「待开始」。请回到任务表格勾选后点击“开始试选”;系统尚未进行任何拼多多操作。</p><a class="button button-primary" href="web-task-workbench.html">查看任务工作台</a>';
|
||||
status.innerHTML = '<h2>任务已创建</h2><p>任务编号 <strong>T-20260803-019</strong>,当前为「待开始」。请回到任务表格勾选后点击“开始采购(只创建待付款订单)”;创建本身尚未授权,也没有进行任何拼多多操作。</p><a class="button button-primary" href="web-task-workbench.html">查看任务工作台</a>';
|
||||
status.focus?.();
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -191,8 +191,8 @@
|
||||
|
||||
<main id="main">
|
||||
<header class="page-head">
|
||||
<div><h1>采购任务</h1><p class="subtitle">创建后先保存为待开始;勾选后统一进入第一趟试选队列。</p></div>
|
||||
<p class="safety-note"><strong>只开始试选,不会下单。</strong> 下单必须在试选完成并经人工确认后另行授权,付款始终由人完成。</p>
|
||||
<div><h1>采购任务</h1><p class="subtitle">创建后先保存为待开始;勾选并开始采购后,采购工具按授权逐条执行。</p></div>
|
||||
<p class="safety-note"><strong>开始采购会授权创建待付款订单。</strong> 系统校验规格、数量和金额后最多提交一次,任何时候都不会自动付款。</p>
|
||||
</header>
|
||||
|
||||
<section class="workbench" aria-label="采购任务表格工作台">
|
||||
@@ -213,13 +213,13 @@
|
||||
<form class="filter-form" id="filter-form" role="search">
|
||||
<div class="filter-field"><label for="keyword">标题关键词</label><input id="keyword" type="search" placeholder="输入采购任务标题" autocomplete="off"></div>
|
||||
<div class="filter-actions">
|
||||
<details class="advanced-filters" id="advanced-filters"><summary>更多条件</summary><div class="filter-popover"><div><label for="status-filter">状态</label><select id="status-filter"><option value="all">全部状态</option><option value="DRAFT">待开始</option><option value="PENDING">待领取</option><option value="WAITING_CONFIRMATION">等待确认</option><option value="WAITING_PAYMENT">待付款</option><option value="RECONCILIATION_REQUIRED">结果待核查</option><option value="SUCCEEDED">已完成</option></select></div><div><label for="time-filter">创建时间</label><select id="time-filter"><option value="all">全部时间</option><option value="today">今天</option><option value="week">最近 7 天</option></select></div></div></details>
|
||||
<details class="advanced-filters" id="advanced-filters"><summary>更多条件</summary><div class="filter-popover"><div><label for="status-filter">状态</label><select id="status-filter"><option value="all">全部状态</option><option value="DRAFT">待开始</option><option value="PENDING">已授权待领取</option><option value="ORDERING">采购执行中</option><option value="NEEDS_MANUAL">待人工处理</option><option value="WAITING_PAYMENT">待付款</option><option value="RECONCILIATION_REQUIRED">结果待核查</option><option value="SUCCEEDED">已完成</option></select></div><div><label for="time-filter">创建时间</label><select id="time-filter"><option value="all">全部时间</option><option value="today">今天</option><option value="week">最近 7 天</option></select></div></div></details>
|
||||
<div class="toolbar-group"><button class="button button-primary" type="submit">筛选</button><button class="button button-secondary" id="clear-filter" type="button">清除</button></div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="selection-mode" id="selection-mode" hidden>
|
||||
<div class="selection-copy"><strong id="selection-count">已选择 0 条</strong><span>只会加入第一趟试选队列,不签发授权、不创建订单、不付款。</span></div>
|
||||
<div class="toolbar-group"><button class="button button-primary" id="start-trial" type="button">开始试选</button><button class="button button-secondary" id="clear-selection" type="button">清除选择</button></div>
|
||||
<div class="selection-copy"><strong id="selection-count">已选择 0 条</strong><span id="selection-cap">最高总额合计 ¥0.00</span><span>点击将授权采购工具逐条创建待付款订单;系统不会付款。</span></div>
|
||||
<div class="toolbar-group"><button class="button button-primary" id="start-purchase" type="button">开始采购(只创建待付款订单)</button><button class="button button-secondary" id="clear-selection" type="button">清除选择</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,7 +268,7 @@
|
||||
<section class="drawer-panel"><h3>采购要求</h3><dl class="detail-grid" id="drawer-requirements"></dl></section>
|
||||
<section class="drawer-panel"><h3>采购结果</h3><div id="drawer-result"></div></section>
|
||||
<section class="drawer-panel"><h3>当前状态与下一步</h3><div class="next-step" id="drawer-next-step"></div></section>
|
||||
<section class="drawer-panel"><h3>执行证据</h3><div class="evidence-placeholder"><div><strong>假数据证据占位</strong><br>真实可读字段必须等待 T-103 真机取证;不展示地址、手机号或付款信息。</div></div></section>
|
||||
<section class="drawer-panel"><h3>执行证据</h3><div class="evidence-placeholder"><div><strong>内部截图证据占位</strong><br>真实字段等待真机取证;完整 XML 与支付凭据不会进入采购服务。</div></div></section>
|
||||
</div>
|
||||
<footer class="drawer-actions"><button class="button button-secondary" id="drawer-close-secondary" type="button">关闭</button><a class="button button-primary" id="full-detail-link" href="web-task-detail.html">在完整页面打开</a></footer>
|
||||
</dialog>
|
||||
@@ -278,7 +278,7 @@
|
||||
'use strict';
|
||||
const tasks = [
|
||||
{ id:'T-20260803-023', title:'仓库雨衣补货', productUrl:'https://mobile.yangkeduo.invalid/goods.html?goods_id=900000023', color:'荧光黄', size:'XXL', maxPrice:'96.00', quantity:3, status:'DRAFT', createdAt:'2026-08-03T14:42:00+08:00', result:null },
|
||||
{ id:'T-20260803-022', title:'8 月仓库工作服补货', productUrl:'https://mobile.yangkeduo.invalid/goods.html?goods_id=900000022', color:'白色', size:'XL', maxPrice:'80.00', quantity:2, status:'WAITING_CONFIRMATION', createdAt:'2026-08-03T13:18:00+08:00', result:{ phase:'试选', color:'白色', size:'XL', unitPrice:'32.50', quantity:2 } },
|
||||
{ id:'T-20260803-022', title:'8 月仓库工作服补货', productUrl:'https://mobile.yangkeduo.invalid/goods.html?goods_id=900000022', color:'白色', size:'XL', maxPrice:'80.00', quantity:2, status:'ORDERING', createdAt:'2026-08-03T13:18:00+08:00', result:{ phase:'采购执行', color:'白色', size:'XL', unitPrice:'32.50', quantity:2 } },
|
||||
{ id:'T-20260803-021', title:'办公室收纳盒', productUrl:'https://mobile.yangkeduo.invalid/goods.html?goods_id=900000021', color:'透明', size:'L', maxPrice:'60.00', quantity:2, status:'RECONCILIATION_REQUIRED', createdAt:'2026-08-03T11:36:00+08:00', result:{ phase:'下单待核查', color:'透明', size:'L', unitPrice:'24.00', quantity:2 } },
|
||||
{ id:'T-20260803-020', title:'仓库防滑手套', productUrl:'https://mobile.yangkeduo.invalid/goods.html?goods_id=900000020', color:'黑色', size:'均码', maxPrice:'64.00', quantity:5, status:'PENDING', createdAt:'2026-08-03T10:45:00+08:00', result:null },
|
||||
{ id:'T-20260801-009', title:'标签打印纸补货', productUrl:'https://mobile.yangkeduo.invalid/goods.html?goods_id=900000009', color:'白色', size:'50×30', maxPrice:'50.00', quantity:1, status:'WAITING_PAYMENT', createdAt:'2026-08-01T16:12:00+08:00', result:{ phase:'已下单', color:'白色', size:'50×30', unitPrice:'46.00', quantity:1 } },
|
||||
@@ -286,9 +286,10 @@
|
||||
];
|
||||
|
||||
const statusMeta = {
|
||||
DRAFT:{ label:'待开始', tone:'neutral', next:'勾选后点击“开始试选”,任务才会进入设备领取队列。' },
|
||||
PENDING:{ label:'待领取', tone:'', next:'已进入第一趟试选队列,等待桌面端轮询领取。' },
|
||||
WAITING_CONFIRMATION:{ label:'等待确认', tone:'', next:'查看规格面板证据,确认机器选对后才可签发下单授权。' },
|
||||
DRAFT:{ label:'待开始', tone:'neutral', next:'勾选后点击“开始采购(只创建待付款订单)”,才会签发一次性授权。' },
|
||||
PENDING:{ label:'已授权待领取', tone:'', next:'管理员已授权创建待付款订单,等待采购工具领取;系统不会付款。' },
|
||||
ORDERING:{ label:'采购执行中', tone:'', next:'采购工具正在同一趟校验规格、价格和数量;无需中途确认。' },
|
||||
NEEDS_MANUAL:{ label:'待人工处理', tone:'warning', next:'围栏前执行已停止。核查原因后可重置为待开始或取消。' },
|
||||
RECONCILIATION_REQUIRED:{ label:'结果待核查', tone:'warning', next:'订单可能已创建。只核查同一提交记录,不得重新提交。' },
|
||||
WAITING_PAYMENT:{ label:'待付款', tone:'warning', next:'人在拼多多核对商品、规格、数量和金额后手工付款;系统不会付款。' },
|
||||
SUCCEEDED:{ label:'已完成', tone:'success', next:'已由人工付款并标记完成,可查看执行证据与审计记录。' }
|
||||
@@ -303,6 +304,7 @@
|
||||
const filterForm = document.querySelector('#filter-form');
|
||||
const selectionMode = document.querySelector('#selection-mode');
|
||||
const selectionCount = document.querySelector('#selection-count');
|
||||
const selectionCap = document.querySelector('#selection-cap');
|
||||
const selectAll = document.querySelector('#select-all');
|
||||
const states = { loading:document.querySelector('#loading-state'), empty:document.querySelector('#empty-state'), error:document.querySelector('#error-state') };
|
||||
const selectedIds = new Set();
|
||||
@@ -325,6 +327,12 @@
|
||||
return `${whole}.${fraction}`;
|
||||
}
|
||||
|
||||
function moneyToCents(value) {
|
||||
const normalized = normalizeMoney(value);
|
||||
const [whole, fraction] = normalized.split('.');
|
||||
return Number.parseInt(whole, 10) * 100 + Number.parseInt(fraction, 10);
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
return new Intl.DateTimeFormat('zh-CN',{ year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', hour12:false }).format(new Date(value));
|
||||
}
|
||||
@@ -375,6 +383,8 @@
|
||||
filterForm.hidden = count > 0;
|
||||
selectionMode.hidden = count === 0;
|
||||
selectionCount.textContent = `已选择 ${count} 条待开始任务`;
|
||||
const capInCents = tasks.filter((task) => selectedIds.has(task.id)).reduce((sum, task) => sum + moneyToCents(task.maxPrice), 0);
|
||||
selectionCap.textContent = `最高总额合计 ¥${(capInCents / 100).toFixed(2)}`;
|
||||
const selectableVisible = visibleTasks().filter((task) => task.status === 'DRAFT');
|
||||
const selectedVisible = selectableVisible.filter((task) => selectedIds.has(task.id));
|
||||
selectAll.checked = selectableVisible.length > 0 && selectedVisible.length === selectableVisible.length;
|
||||
@@ -438,12 +448,12 @@
|
||||
});
|
||||
|
||||
document.querySelector('#clear-selection').addEventListener('click', () => { selectedIds.clear(); renderRows(); keyword.focus(); });
|
||||
document.querySelector('#start-trial').addEventListener('click', () => {
|
||||
document.querySelector('#start-purchase').addEventListener('click', () => {
|
||||
const ids = [...selectedIds];
|
||||
tasks.forEach((task) => { if (selectedIds.has(task.id) && task.status === 'DRAFT') task.status = 'PENDING'; });
|
||||
selectedIds.clear();
|
||||
renderRows();
|
||||
showToast(`已将 ${ids.length} 条任务加入第一趟试选队列。没有签发下单授权,也不会付款。`);
|
||||
showToast(`已授权 ${ids.length} 条任务创建待付款订单。采购工具将逐条执行,系统不会付款。`);
|
||||
});
|
||||
|
||||
function isInteractive(target) { return Boolean(target.closest('a,button,input,label,select,textarea,summary')); }
|
||||
@@ -548,7 +558,7 @@
|
||||
drawerId.textContent = `${task.id} · ${meta.label}`;
|
||||
drawerTitle.textContent = task.title;
|
||||
drawerRequirements.innerHTML = `<div><dt>颜色</dt><dd>${escapeHtml(task.color)}</dd></div><div><dt>尺码</dt><dd>${escapeHtml(task.size)}</dd></div><div><dt>价格上限</dt><dd>¥${escapeHtml(task.maxPrice)}</dd></div><div><dt>数量</dt><dd>${task.quantity} 件</dd></div><div><dt>状态</dt><dd><span class="status ${meta.tone}">${escapeHtml(meta.label)}</span></dd></div><div><dt>创建时间</dt><dd>${escapeHtml(formatDate(task.createdAt))}</dd></div>`;
|
||||
drawerResult.innerHTML = task.result ? `<p style="margin:0 0 10px">${formatResult(task)}</p><p style="margin:0;color:var(--muted);font-size:.82rem">展示值来自结构化颜色、尺码、单价和数量;“|”只用于页面格式化。</p>` : '<p class="result empty" style="margin:0">尚无采购结果。任务未完成试选或下单,不用其他数据填充。</p>';
|
||||
drawerResult.innerHTML = task.result ? `<p style="margin:0 0 10px">${formatResult(task)}</p><p style="margin:0;color:var(--muted);font-size:.82rem">展示值来自结构化颜色、尺码、单价和数量;“|”只用于页面格式化。</p>` : '<p class="result empty" style="margin:0">尚无采购结果。任务未开始或仍在执行,不用其他数据填充。</p>';
|
||||
drawerNext.textContent = meta.next;
|
||||
fullDetailLink.href = `web-task-detail.html#${encodeURIComponent(task.id)}`;
|
||||
}
|
||||
|
||||
+131
-141
@@ -1,157 +1,147 @@
|
||||
# 路由与页面结构
|
||||
|
||||
> 本文约定采购服务(网页端,`admin/`)页面路由、页面职责和组件归属,以及采购工具
|
||||
> (桌面端,`client/`)的界面结构。
|
||||
> 具体交互行为以[交互清单](08-interaction-checklist.md)为准,接口形状以 [API 合约](api.md) 为准。
|
||||
> 采购服务使用服务端渲染;采购工具使用固定 tab 的 Windows 桌面外壳。交互细节以
|
||||
> [08-interaction-checklist.md](08-interaction-checklist.md) 为准。
|
||||
|
||||
## 一、采购服务页面路由
|
||||
|
||||
网页端用户可见产品名统一为“采购服务”。`cmbuyer` 只作为仓库和系统内部标识,不出现在
|
||||
网页标题、页头品牌或无障碍名称中;桌面端仍使用独立名称“采购工具”。
|
||||
|
||||
| 路由 | 页面 | 职责 | 用户故事 | 交互 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `/login` | 登录 | 建立管理会话 | US-007 | IX-001 |
|
||||
| `/tasks` | 采购任务 | 传统表格查询、创建弹窗、批量开始试选、进入详情抽屉 | US-001、US-002、US-010 | IX-002、IX-003、IX-004、IX-012 |
|
||||
| `/tasks/new` | 手工建单直达页 | 复用创建表单,作为无脚本和直接访问的完整页兜底 | US-001 | IX-002 |
|
||||
| `/tasks/{id}` | 任务详情 | 从列表进入时显示右侧详情抽屉;直接访问时显示完整页;承载试选确认、授权与待付款核对 | US-002、US-004、US-005 | IX-004、IX-005、IX-006 |
|
||||
|
||||
> `/tasks/import`(Excel)与 `/freight*`(ERP)随 F-002 / F-003 推迟到 V2。
|
||||
|
||||
登录后默认进入 `/tasks`。未登录访问受保护页面时跳转 `/login` 并携带站内返回路径;
|
||||
**只接受 `/tasks` 及其子路径**,拒绝绝对 URL、`//` 和反斜杠。
|
||||
|
||||
## 二、采购服务页面职责
|
||||
|
||||
### 采购任务 `/tasks`
|
||||
|
||||
采用采购人员熟悉的传统表格,默认按创建时间倒序。固定列为:选择、标题、颜色、尺码、
|
||||
价格上限、数量、采购结果、状态、创建时间;不设置操作列。
|
||||
|
||||
- 标题链接在新标签打开拼多多商品页;标题下方任务编号链接打开内部任务详情。
|
||||
- “采购结果”由结构化字段在展示层格式化为“阶段:颜色 | 尺码 | 单价 | 数量”;未产生
|
||||
试选或下单结果时显示 `—`,不得把试选写成已下单。
|
||||
- 双击行非控件区域,或聚焦行后按 Enter,打开 `/tasks/{id}` 的右侧详情抽屉。关闭抽屉、
|
||||
按 Esc 或浏览器返回后,保留筛选、选择、滚动位置并把焦点还给原行。
|
||||
- 直接访问 `/tasks/{id}` 或在抽屉中选择“在完整页面打开”时,使用完整详情页;关键路由可
|
||||
深链接,不能只有无法复制地址的弹层状态。
|
||||
- 第一工具行:导入(MVP 禁用占位并说明原因)、创建。创建打开模态表单;保存后任务以
|
||||
`DRAFT` 状态出现在第一行。`/tasks/new` 保留为同表单的直达兜底。
|
||||
- 第二工具行默认显示标题关键词、筛选、清除;状态和创建时间放在默认折叠的“更多条件”中,
|
||||
保留 F-004 查询范围而不挤占常用操作。勾选 `DRAFT` 后切换成上下文批量栏,显示已选数量、
|
||||
开始试选、清除选择;全选只覆盖当前筛选结果中的 `DRAFT`。
|
||||
- “开始试选”把所选任务原子转为 `PENDING`,只进入第一趟试选队列;不签发授权、不创建
|
||||
订单、不付款。其他状态复选框禁用并说明原因。
|
||||
- 窄屏允许表格容器内部横向滚动,但页面本身不得横向溢出;选择列与标题列保持可见。
|
||||
|
||||
### 任务详情 `/tasks/{id}`
|
||||
|
||||
按任务当前状态呈现不同主区块,同一时刻只出现一个主动作:
|
||||
|
||||
| 状态 | 主区块 |
|
||||
| --- | --- |
|
||||
| `WAITING_CONFIRMATION` | **试选确认卡** + 确认 / 退回 |
|
||||
| `AUTHORIZED` / 未围栏的 `ORDERING` | 授权摘要 + **放弃授权**入口 |
|
||||
| 已围栏的 `ORDERING` / `RECONCILIATION_REQUIRED` | 提交围栏摘要 + 「订单可能已创建」提示 + 人工核查入口;**无重试 / 放弃按钮** |
|
||||
| `WAITING_PAYMENT` | 待付款核对卡(订单截图、商品、规格、数量、授权金额)+ 标记完成 |
|
||||
| `NEEDS_MANUAL` | 原因说明 + 处理入口 |
|
||||
| 终态 | 结果摘要 + 执行证据 |
|
||||
|
||||
**试选确认卡**是 MVP 的核心交互。人只回答一个问题:**机器选对了吗**。
|
||||
|
||||
```text
|
||||
你要的: 白色 · XL · 2 件 · 上限 ¥80
|
||||
机器选到: 白色 · XL ✓ 一致
|
||||
单价 ¥32.50 × 2 = ¥65.00 ✓ 没超
|
||||
[规格面板截图]
|
||||
商品标题:2026夏季新款纯棉圆领短袖T恤…
|
||||
|
||||
[ 确认下单(不付款) ] [ 退回,不买 ]
|
||||
```
|
||||
|
||||
- 这是**轻量确认,不是对照台**——只有一个商品,不需要并排比较多个候选。
|
||||
多候选对照台随 B 路径推迟到 V2。
|
||||
- 不需要「选择理由 / 拒绝理由」下拉,保留一个可选备注即可。
|
||||
- 机器选到的值与需求不一致时(✗),确认按钮**默认禁用**,需先退回或转人工。
|
||||
- **不提供列表页一键确认。** 确认前必须看过截图,这道闸不能省。
|
||||
|
||||
### 授权卡住时的出口
|
||||
|
||||
`AUTHORIZED` / 未建立提交围栏的 `ORDERING` 必须提供「放弃授权」入口;放弃后进入
|
||||
`PENDING_RETRIAL`,重新跑第一趟。围栏建立后不得放弃,页面改为提供「进入人工核查」入口,
|
||||
并明确禁止再次提交。**每个状态都必须给出安全且可执行的下一步**——这是验收项。
|
||||
|
||||
### 建单页
|
||||
|
||||
`/tasks` 的创建弹窗与 `/tasks/new` 直达页复用同一表单:任务名称、拼多多链接、颜色分类、
|
||||
尺码、数量、价格上限。链接无法解析出 `goods_id` 时明确报错并保留已填内容;保存成功后
|
||||
任务为 `DRAFT`,尚未进入设备领取队列。
|
||||
|
||||
## 三、采购工具界面结构
|
||||
|
||||
桌面端产品名为“采购工具”,不是网页;使用顶部固定两页签,不做多级导航,默认打开采购执行:
|
||||
|
||||
| 页签 | 职责 | 用户故事 | 交互 |
|
||||
| 方法 | 路径 | 页面 / 动作 | 身份 |
|
||||
| --- | --- | --- | --- |
|
||||
| 采购执行 | 连接与会话状态、当前任务与可选图片、滚动日志、执行记录 | US-003、US-008 | IX-007、IX-008 |
|
||||
| 配置 | 设备档案、ADB 路径与 serial、常用超时参数、连接检查 | US-003 | IX-007 |
|
||||
| `GET` | `/login` | 登录页 | 匿名 |
|
||||
| `POST` | `/login` | 建立会话 | 匿名 + CSRF |
|
||||
| `POST` | `/logout` | 退出 | 管理员 + CSRF |
|
||||
| `GET` | `/tasks` | 采购任务表格、筛选、批量选择和创建入口 | 管理员 |
|
||||
| `GET` | `/tasks/new` | 无 JS 时的创建表单;有 JS 时装入对话框 | 管理员 |
|
||||
| `POST` | `/tasks` | 创建 `DRAFT` | 管理员 + CSRF |
|
||||
| `POST` | `/tasks/start-purchases` | 批量开始采购并签发一次性授权 | 管理员 + CSRF |
|
||||
| `GET` | `/tasks/{id}` | 完整任务详情;也作为列表抽屉的可复制 URL | 管理员 |
|
||||
| `POST` | `/tasks/{id}/reset-to-draft` | 围栏前人工处理后回待开始 | 管理员 + CSRF |
|
||||
| `POST` | `/tasks/{id}/cancel` | 围栏前取消 | 管理员 + CSRF |
|
||||
| `POST` | `/tasks/{id}/mark-paid` | 记录人工已付款并完成 | 管理员 + CSRF |
|
||||
| `POST` | `/order-submissions/{sid}/reconcile` | 围栏后调和同一提交 | 管理员 + CSRF |
|
||||
| `GET` | `/evidence/{asset_id}` | 受保护内部截图 | 管理员;不缓存 |
|
||||
|
||||
### 采购执行页
|
||||
设备 JSON API 不属于页面路由,见 [api.md](api.md)。
|
||||
|
||||
- 顶部状态区:web、设备、拼多多 App 实际 / 已取证版本和会话状态;开始 / 停止轮询固定在
|
||||
最右侧且只控制轮询会话。版本不一致时 fail closed 并提示重新取证。
|
||||
- 宽屏是主从工作区:左侧在“当前任务 + 滚动日志”和“执行记录详情”之间切换,右侧执行记录
|
||||
保持可见;窄屏进入详情时暂时收起记录表,返回后恢复列表现场。
|
||||
- 当前任务左侧显示商品、规格、数量、**当前是第一趟还是第二趟**、步骤和剩余租约;右侧约
|
||||
1/3 显示可选商品 / 证据预览。无可信图片时保持空态,有图必须标来源与采集时间。
|
||||
- 滚动日志标题区同时显示下次轮询、连续失败和本次完成,不再使用独立会话卡或底部控制条。
|
||||
- 执行记录只显示标题、状态两列,任务编号与时间放在标题次行,按采集时间倒序。单击选择;
|
||||
双击、Enter、可见“查看所选记录”或右键在左侧打开详情;详情态单击另一行直接更新详情。
|
||||
- 记录详情不是模态框:上部左侧显示原始文字、右侧显示图片证据,下部显示结构化采购结果;
|
||||
可见“返回当前任务”与 `Esc` 均能返回并恢复当前行焦点。加载失败、无图和记录不存在有明确
|
||||
状态,图片缺失不使用其他来源凑合。
|
||||
- **待人工时整页显著变色并说明缺什么**,不要让执行员盯着一个静止画面猜。
|
||||
- 关闭窗口即停止轮询;连续失败达阈值自动停止并显示原因。
|
||||
## 二、采购任务工作台 `/tasks`
|
||||
|
||||
### 配置页
|
||||
页面标题:**采购服务**。
|
||||
|
||||
- 设备档案必须**显式填写 serial**,不允许留空自动选——同一手机 USB + WiFi 同时在线时
|
||||
自动选会失败(见[架构设计](04-architecture.md)第六节)。
|
||||
- 连接检查只做连接和确认拼多多已安装,**不打开商品、不选规格、不创建订单**。
|
||||
- 运行中冻结设备切换与参数保存。
|
||||
表格上方两行:
|
||||
|
||||
## 四、导航规则
|
||||
1. 第一行:导入(disabled 占位)、创建按钮;存在选择时显示上下文批量操作条。
|
||||
2. 第二行:标题关键词输入框(默认筛选)、其他可选筛选,右侧“筛选”“清除”。
|
||||
|
||||
- 采购服务从路由化详情抽屉返回工作台时不重新加载列表;完整详情页返回时保留原筛选条件。
|
||||
- 采购工具启动时若服务端有本设备的活跃任务,优先恢复该任务,不允许直接领取下一条。
|
||||
- 第一趟试选完成后**必须退出商品页**再进入下一轮轮询,不停在规格面板等人。
|
||||
- 采购工具在真机步骤执行期间禁用硬取消和关闭窗口,避免留下无法判定的中间态。
|
||||
- 任何进入外部支付页的情形,采购工具立即停止并跳回待人工,**不提供「继续」按钮**。
|
||||
- dry-run 与真实下单必须使用不同的醒目标识;dry-run 不得出现可触发真实提交的控件。
|
||||
- 已建立提交围栏后,不提供重新领取、重新提交或放弃授权,只能恢复同一提交记录并调和。
|
||||
表格字段:
|
||||
|
||||
## 五、组件归属
|
||||
| 字段 | 行为 |
|
||||
| --- | --- |
|
||||
| checkbox | 只允许选择 `DRAFT`;表头全选当前筛选结果中的可选行 |
|
||||
| 标题 | `<a>` 指向 canonical 拼多多商品页;点击链接不触发行详情 |
|
||||
| 颜色 | 任务目标颜色 |
|
||||
| 尺码 | 任务目标尺码 |
|
||||
| 价格 | 任务最高总价;执行后详情另列实际闸门金额 |
|
||||
| 数量 | 正整数 |
|
||||
| 采购结果 | 用 `颜色|尺码|价格|数量` 展示已执行摘要;无结果显示 `—` |
|
||||
| 状态 | 中文状态徽标,不只靠颜色 |
|
||||
| 创建时间 | 本地时区显示,数据按 UTC 保存 |
|
||||
|
||||
| 组件 | 归属 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `AppShell` | web 全局 | 导航、登录态、CSRF 注入 |
|
||||
| `TaskTable` | 采购任务 | 任务表格、创建时间倒序、选择状态与结构化采购结果格式化 |
|
||||
| `QueryBar` | 采购任务 | 标题关键词、筛选与清除 |
|
||||
| `BulkTrialBar` | 采购任务 | `DRAFT` 批量选择与开始第一趟试选 |
|
||||
| `CreateTaskDialog` | 采购任务 / 建单直达页 | 复用手工建单表单与字段错误 |
|
||||
| `TaskDetailDrawer` | 采购任务 | `/tasks/{id}` 路由驱动的右侧详情容器与焦点恢复 |
|
||||
| `SpecTrialCard` | 任务详情 | 试选确认卡(MVP 签名组件) |
|
||||
| `AuthorizePanel` | 任务详情 | 确认 / 退回 / 放弃授权 |
|
||||
| `PaymentCheckCard` | 任务详情 | 待付款核对与标记完成 |
|
||||
| `DeviceStatusBar` | desk 采购执行页 | web、设备与 App 版本三项就绪状态 |
|
||||
| `PollControls` | desk 采购执行页 | 顶部固定的轮询开关与会话状态;日志标题区承载倒计时、失败和完成计数 |
|
||||
| `CurrentTaskPanel` | desk 采购执行页 | 当前任务文字、步骤与带来源的可选图片证据 |
|
||||
| `ExecutionLog` | desk 采购执行页 | 最新在上的滚动日志与假诊断导出 |
|
||||
| `ExecutionRecordTable` | desk 采购执行页 | 标题 / 状态两列、时间倒序和稳定记录 ID |
|
||||
| `ExecutionRecordDetailPanel` | desk 采购执行页 | 左侧内联原始文字、图片证据、采购结果、读取状态与返回焦点 |
|
||||
没有操作列。双击非控件区域或键盘 Enter 打开 `/tasks/{id}` 路由化详情抽屉;新 tab 直接访问同 URL
|
||||
则显示完整详情页。关闭抽屉或浏览器返回恢复筛选、滚动和触发行焦点。
|
||||
|
||||
## 六、原型
|
||||
### 批量开始采购
|
||||
|
||||
低保真原型放 `docs/design/`,约定见 [`design/README.md`](design/README.md)。
|
||||
原型只回答「页面上有什么」,行为权威是[交互清单](08-interaction-checklist.md);
|
||||
实现时按真实框架重写,**不复制原型代码**。
|
||||
选择 `DRAFT` 后,表格前的上下文操作条显示:
|
||||
|
||||
- 已选条数;
|
||||
- 所选最高总价合计;
|
||||
- “将授权采购工具逐条创建待付款订单,系统不会付款”;
|
||||
- 主按钮“开始采购(只创建待付款订单)”。
|
||||
|
||||
点击即为最终授权,不另弹“机器选对了吗”的同义确认框。整批全有或全无;成功更新 `PENDING`,
|
||||
冲突时保持页面现场并要求刷新重选。
|
||||
|
||||
### 创建任务
|
||||
|
||||
字段:标题、拼多多商品链接、颜色分类、尺码、最高总价、数量。保存成功后关闭对话框,新任务插到
|
||||
首行且状态为待开始;创建本身不授权、不领取、不执行。
|
||||
|
||||
## 三、任务详情 `/tasks/{id}`
|
||||
|
||||
详情按状态展示同一条单趟采购的事实,而不是审批流程:
|
||||
|
||||
1. 任务要求:商品链接 / goods_id、颜色、尺码、数量、最高总价、版本。
|
||||
2. 开始采购授权:授权 id、授权人、锁定任务版本、创建/有效期、当前状态。
|
||||
3. 设备执行:attempt、设备、App 版本、步骤时间线和失败 code。
|
||||
4. 三道闸门:两次规格面板单价、规格/数量读回、确认页总额与判定。
|
||||
5. 内部截图:规格面板、确认页和结果页;只经受保护端点读取。
|
||||
6. 提交围栏:submission id、是否首次明确许可、唯一点击和调和记录。
|
||||
7. 待付款收口:明确写系统尚未付款;人工核对后记录完成。
|
||||
|
||||
状态动作:
|
||||
|
||||
| 状态 | 页面动作 |
|
||||
| --- | --- |
|
||||
| `DRAFT` | 返回列表勾选并开始采购;可编辑/取消(按任务版本) |
|
||||
| `PENDING` / `CLAIMED` / `ORDERING` | 只读进度;围栏前异常由人工处理,不中途确认规格 |
|
||||
| `NEEDS_MANUAL` | 查看原因;确认没有围栏后重置为 DRAFT 或取消 |
|
||||
| `RECONCILIATION_REQUIRED` | 只调和同一 submission;无重试、释放或重新授权 |
|
||||
| `WAITING_PAYMENT` | 查看证据、人工付款、标记完成或转人工 |
|
||||
| 终态 | 只读审计 |
|
||||
|
||||
详情中不出现 `WAITING_CONFIRMATION`、“确认机器选对了吗”、“签发第二趟授权”或“重新试选”。
|
||||
|
||||
## 四、采购工具界面结构
|
||||
|
||||
应用名:**采购工具**。顶部固定 tab:
|
||||
|
||||
1. **采购执行**(默认)
|
||||
2. **配置**
|
||||
|
||||
### 采购执行 tab
|
||||
|
||||
- 顶部第一行:采购服务、ADB、拼多多版本、会话状态;其右侧是“开始轮询 / 停止轮询”。
|
||||
- 左上“当前任务”:左侧文字约 2/3,右侧商品图片约 1/3;空闲显示占位。
|
||||
- 左下“滚动日志”:占满剩余高度,显示阶段、固定 reason 和安全下一步。
|
||||
- 右侧“采购记录”:时间倒序表格,MVP 只显示标题和状态。
|
||||
- 双击或 Enter 记录:左侧原位切换到记录详情,不弹窗;上方文字/图片,下方执行结果。
|
||||
- Esc 或“返回当前任务”:恢复当前任务视图;不暂停执行、不释放围栏。
|
||||
|
||||
单趟状态:待领取、已领取、打开商品、选择规格、闸门一、数量复核、闸门二、确认页/闸门三、
|
||||
申请围栏、已发出唯一提交、待付款或待调和。桌面端没有让用户手工点击“提交订单”的按钮。
|
||||
|
||||
### 配置 tab
|
||||
|
||||
- 采购服务 URL、设备 token(密码框,不回显完整值);
|
||||
- ADB 路径、设备 serial、USB/WiFi 通道选择;
|
||||
- 轮询间隔与连续失败停止阈值;
|
||||
- 连接检查:服务、设备身份、拼多多安装和已取证版本。
|
||||
|
||||
配置凭据进入系统安全存储;日志和界面不显示完整 token。
|
||||
|
||||
## 五、导航和焦点规则
|
||||
|
||||
- Web 主导航 MVP 只有“采购任务”;logo 文案为“采购服务”。
|
||||
- 商品标题链接是外部导航;行详情是内部导航,两者事件相互隔离。
|
||||
- Web 路由抽屉与完整页共享数据和 URL;关闭恢复触发行焦点。
|
||||
- Desk tab 使用标准键盘关系;历史详情 Esc 返回当前任务,不关闭应用。
|
||||
- 真机执行、围栏和提交不受页面/视图切换影响;关闭窗口时若有活跃任务,提示只影响 UI/轮询,
|
||||
不把它解释为撤销服务端授权。
|
||||
|
||||
## 六、组件归属
|
||||
|
||||
| 组件 | 归属 |
|
||||
| --- | --- |
|
||||
| 会话 / CSRF / SSR 模板 | `admin/internal/transport/webui` |
|
||||
| 任务 / 授权 / attempt / submission 用例 | `admin/internal/usecase` |
|
||||
| SQLite / 证据存储 | `admin/internal/storage` |
|
||||
| 设备 API | `admin/internal/transport/httpapi` |
|
||||
| ADB / PDD 页面能力 | `client/src/cmbuyer_client/device`、`pdd` |
|
||||
| 任务来源与结果 sink | `client/src/cmbuyer_client/core`、`remote` |
|
||||
| PySide6 UI | `client/src/cmbuyer_client/app` |
|
||||
|
||||
## 七、原型
|
||||
|
||||
- Web:`docs/design/web-task-create.html`、`web-task-workbench.html`、`web-task-detail.html`。
|
||||
- Desk:`docs/design/desk-execution.html`;配置结构见 `desk-device-settings.html`。
|
||||
- 原型只使用假数据、无网络和生产副作用;显著流程变更先更新原型再实现。
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
| T-0xx | Phase 0 地基 |
|
||||
| T-1xx | Phase 1 真机取证 |
|
||||
| T-2xx | Phase 2 采购服务核心 |
|
||||
| T-3xx | Phase 3 双端打通与第一趟试选 |
|
||||
| T-4xx | Phase 4 第二趟下单与收尾 |
|
||||
| T-3xx | Phase 3 双端打通与单趟围栏前执行 |
|
||||
| T-4xx | Phase 4 真实提交与收尾 |
|
||||
| T-5xx | V2 及以后(图搜、Excel、ERP、订单核对、AI 辅助) |
|
||||
|
||||
## 领取规则
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
id: T-010
|
||||
title: 收窄 Phase 2 门禁并启动安全并行
|
||||
phase: 0
|
||||
deps: [T-009]
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 25
|
||||
context_ref: d27fda6
|
||||
work_branch: task/t-010-parallel-gate
|
||||
needs_device: false
|
||||
needs_human_review: false
|
||||
write_paths:
|
||||
- docs/tasks/T-010.md
|
||||
- docs/00-ai-start-here.md
|
||||
- docs/05-coding-rules.md
|
||||
- docs/06-tasks.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=25 synced=2026-08-04T06:39:13Z sha256=b3485476184414158cac243aa3f2d08567372179c2e91c2cf4bb5552e7191510 -->
|
||||
## 问题 / 背景
|
||||
|
||||
客户要求加快 MVP。现有全局门禁把全部 Phase 2 页面都冻结到 T-103 完成,范围过宽;管理员登录与只创建 DRAFT 的基础建单不读取真机页面字段,也不会启动试选或触发下单。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 把门禁收窄为:T-103 前允许 T-201 管理会话与 T-202 DRAFT 建单/基础列表;T-203 的批量开始试选、T-204 试选证据详情和 T-205 以后继续冻结。
|
||||
2. 同步 AI 入口、编码规则和路线图;不修改 T-103 安全边界,不放宽不付款、第一趟禁下单、真机判据先取证或隐私证据分层。
|
||||
3. 并行启动 admin T-201;共享文档由本任务所有者统一修改。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 门禁文字不存在“冻结全部 Phase 2”的歧义。
|
||||
- T-201/T-202 不得出现 PDD 页面判据、试选结果、证据、授权、提交或支付能力。
|
||||
- 上下文校验、Vikunja 导出检查与 diff-check 通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T06:38:51Z · ila
|
||||
|
||||
2026-08-04 完成并行门禁收窄:提交 3f2e0e5 更新 AI 入口、编码规则和路线图。T-103 期间只放行 T-201 管理会话与不启动试选的 T-202 DRAFT 建单/基础列表;T-203~T-207 继续等待 T-103。不付款、第一趟禁下单、真机判据与证据边界均未放宽。主 agent 复跑 agent-context validator、冲突文案检索和 git diff --check,全部通过。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 本任务只调整依赖门禁和并行顺序,不实现任何采购服务、真机自动化或页面判据代码。
|
||||
- T-103 完成前只放行与真机可读字段无关的 T-201 管理会话,以及只创建 `DRAFT`、不启动试选的
|
||||
T-202 基础建单与列表壳。
|
||||
- T-203 的批量 `DRAFT → PENDING`、T-204 的试选证据详情以及 T-205 以后仍由 T-103 阻塞;不得用
|
||||
假字段、原型假数据或前序项目结论提前固化生产契约。
|
||||
- 不付款、第一趟不可达下单、价格读取位置、真机判据先取证、原始证据本机隔离与派生物消费边界
|
||||
均保持不变。
|
||||
+30
-3
@@ -3,7 +3,7 @@ id: T-101
|
||||
title: 验证 ADB 与 uiautomator2 双通道连接
|
||||
phase: 1
|
||||
deps: [T-002]
|
||||
status: TODO
|
||||
status: DONE
|
||||
created: 2026-08-03
|
||||
vikunja_task_id: 21
|
||||
context_ref: 87591e8
|
||||
@@ -13,6 +13,7 @@ needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-101.md
|
||||
- client/.gitignore
|
||||
- client/requirements.txt
|
||||
- client/src/cmbuyer_client/device/**
|
||||
- client/tests/device/**
|
||||
- client/scripts/capture_device_baseline.py
|
||||
@@ -22,7 +23,7 @@ write_paths:
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=21 synced=2026-08-03T10:40:11Z sha256=e6a7a6c24df85d52435692fc120d3d3ddea3148b46ea3cf1e99c3debfe53321e -->
|
||||
<!-- BEGIN VIKUNJA EXPORT id=21 synced=2026-08-04T01:02:36Z sha256=b6458effd879f2e258c861b1b7b99d1162fdbea586e3fa467d3a65178c900453 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-002 已建立采购工具骨架,但本项目还没有对实际 Android 手机、ADB 通道或 uiautomator2 做过取证。后续所有拼多多页面判据都依赖稳定、显式且可审计的设备连接;若复用前序项目结论或自动猜设备,会把错误设备和旧页面事实带入生产流程。
|
||||
@@ -51,7 +52,33 @@ T-002 已建立采购工具骨架,但本项目还没有对实际 Android 手
|
||||
|
||||
## 执行记录
|
||||
|
||||
(暂无)
|
||||
### 2026-08-03T10:56:19Z · ila
|
||||
|
||||
T-101 已领取,Git 状态将提交为 DOING。只读环境盘点:adb 位于 D:\Portable\adb\adb.exe;当前同时列出 WiFi serial 192.168.0.173:5555 与 USB serial 3B65BD02H7F00000,二者 product/model/device 均为 PKG110/PKG110/OP5D2BL1。该现场必须由实现识别为同机双通道并 fail closed。尚未截图、dump 或操作手机,本记录不是人工真机验收;needs_device 规则继续生效。
|
||||
|
||||
### 2026-08-03T11:27:08Z · ila
|
||||
|
||||
2026-08-03 离线实现与主审完成:新增显式 serial 的 ADB 边界、USB/WiFi 同机多通道 fail-closed、禁止 uiautomator2 隐式重连的连接器,以及只读基线 CLI(型号、Android、拼多多版本、截图、compressed=false XML、manifest SHA-256)。主 agent 三轮审查后补齐:截图/XML JSON-RPC 可配置超时、adbutils/uiautomator2 类型化超时、完整硬件身份集合比对、非法截图/XML fail-closed、异常脱敏、暂存清理,以及人工安全页面/原始证据不入 Git 的操作要求。独立验证:init.ps1 通过;admin go test/vet/build 通过;client 27 项 mock 单测、compileall、wheel METADATA、validator、bash -n 与 diff check 通过。未连接或操作真机,T-101 继续保持 DOING;等待人分别完成 USB 与 WiFi 取证并记录 manifest 中的设备型号、Android、拼多多版本、路径与 SHA-256。已知边界:ADB/ADB socket/截图与节点树 RPC 超时可配置,uiautomator2 初始化仍受上游固定启动上限约束。
|
||||
|
||||
### 2026-08-04T00:36:03Z · ila
|
||||
|
||||
2026-08-04 人工进展:用户确认 WiFi ADB 已连接,serial 为 192.168.0.173:5555。主 agent 随后仅执行 `adb devices -l` 复核,WiFi 与 USB 3B65BD02H7F00000 当前均为 device,product/model/device 均显示 PKG110/PKG110/OP5D2BL1;本机尚无 `%LOCALAPPDATA%\cmbuyer\artifacts\T-101` 取证产物。该结果只证明 WiFi 通道在线,不是 T-101 完整人工验收。按 fail-closed 边界,人工进行 WiFi 取证前须先断开 USB,只保留 WiFi;USB 取证时须断开 WiFi,只保留 USB。仍等待两次截图、compressed=false XML 与 manifest 路径/SHA-256,任务保持 DOING。
|
||||
|
||||
### 2026-08-04T00:43:38Z · ila
|
||||
|
||||
2026-08-04 人工 WiFi 取证失败:用户在仅 WiFi serial 192.168.0.173:5555 在线时运行基线 CLI,工具返回“截图 RPC 返回的数据不是有效图像,拒绝发布不完整取证”。主 agent 只读复核:ADB 仅列出该 WiFi 通道为 device,T-101 产物目录为空,说明暂存清理与 fail-closed 生效。核对本机 uiautomator2 3.7.0 源码后发现其高层 screenshot 使用允许 Base64 空白的 `base64.b64decode`,而当前实现直接 `validate=True`,可能错误拒绝真实 RPC 返回中的 CR/LF/空格。已退回 client agent 修复为“只规范化 ASCII Base64 空白,再严格 validate=True;其他非法字符与非图像继续拒绝”,并补回归测试。该失败不是验收通过,T-101 继续 DOING;修复、主审、提交后由人重新执行 WiFi 取证。
|
||||
|
||||
### 2026-08-04T00:49:31Z · ila
|
||||
|
||||
2026-08-04 WiFi 截图兼容性修复已完成主审:实现只删除 RPC Base64 返回值中的空格、TAB、CR、LF,再使用 `validate=True` 严格解码;非法字符、错误 padding、vertical-tab、form-feed、data URI 与解码后非图像仍 fail closed,不增加 adb screenshot fallback。错误安全地区分为“Base64 语法无效”和“图像数据无效”,均不输出原始内容,失败继续清理暂存目录。新增真实 PNG Base64 空白兼容、非法字符、错误 padding、未白名单空白及非图像测试;client 共 30 项离线测试。主 agent 独立复核并运行 client 测试/compileall、admin test/vet/build、完整 init.ps1、validator、bash 语法与 diff check,均通过;Python 3.12 和主工作区 3.14 均已验证。未由 agent 连接或截图真机,T-101 保持 DOING,等待人工重新执行 WiFi 取证确认现场问题是否解决。
|
||||
|
||||
### 2026-08-04T00:55:14Z · ila
|
||||
|
||||
2026-08-04 WiFi 人工取证技术验收通过:用户报告基线命令成功,manifest 位于 `%LOCALAPPDATA%\cmbuyer\artifacts\T-101\wifi-baseline\manifest.json`。主 agent 仅读取 manifest 元数据并计算文件哈希,未打开或展示截图/XML 内容。manifest schema=1、channel=wifi、设备型号 PKG110、Android 16、拼多多包 com.xunmeng.pinduoduo、版本 8.17.0;serial 仅保存 64 位 SHA-256。`screenshot.png` 存在,SHA-256=`8f86c94700d93380805b275469123a31749d592d99c0c4d2bcfd540784d1620b`;`hierarchy.xml` 存在,SHA-256=`708182200e25085c07cbb546c9b0adbc7310022bb75a7285c5581bd262661573`;两者均与 manifest 一致。仍等待人工确认原始截图/XML 不含敏感信息,并完成仅 USB 通道的同等取证;T-101 保持 DOING。
|
||||
|
||||
### 2026-08-04T01:02:03Z · ila
|
||||
|
||||
2026-08-04 USB 人工取证与隐私验收通过:用户报告仅 USB 通道基线命令成功,manifest 位于 `%LOCALAPPDATA%\cmbuyer\artifacts\T-101\usb-baseline\manifest.json`。主 agent 仅读取 manifest 元数据并计算哈希,未打开或展示截图/XML:schema=1、channel=usb、设备 PKG110、Android 16、拼多多 8.17.0;`screenshot.png` SHA-256=`da66e27ef6df1d4b99a64590e9aa383069c8c3995eb9b3695e5daec3a0e7bcf6`,`hierarchy.xml` SHA-256=`e0817f26c07e6861f2ab80052995aff2622ce7cdb61f18689d55c5591bb2045a`,均与 manifest 一致。用户随后明确确认 USB 与 WiFi 两组截图/XML 均不含地址、手机号、支付信息或其他无关隐私。至此 USB/WiFi 显式 serial、截图、compressed=false XML、设备/Android/拼多多版本、路径和 SHA-256 的人工验收全部满足;T-101 可关闭,后续进入 T-102。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
---
|
||||
id: T-102
|
||||
title: 验证按链接打开商品详情页
|
||||
phase: 1
|
||||
deps: [T-101]
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 22
|
||||
context_ref: 393f26d
|
||||
work_branch: task/t-102-open-product
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-102.md
|
||||
- client/src/cmbuyer_client/pdd/**
|
||||
- client/src/cmbuyer_client/device/**
|
||||
- client/tests/pdd/**
|
||||
- client/tests/device/**
|
||||
- client/scripts/capture_product_open.py
|
||||
- docs/03-tech-stack.md
|
||||
- docs/04-architecture.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=22 synced=2026-08-04T01:50:40Z sha256=ee2405865a36aa01a3503e6d24d62a3dddd0f19b451845e1e2c3937734c31442 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-101 已证明同一台 PKG110(Android 16、拼多多 8.17.0)可通过 USB/WiFi 显式 serial 完成只读截图与完整 XML 取证。MVP 下一风险是任务自带的合法拼多多链接能否由 Android intent 打开到对应商品详情页。此前项目和旧文档不能证明当前 App 的行为;若先写详情页判据,会把未经本项目真机验证的假设带入 T-103。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-006 的第一步 `open_product(url)`;仅覆盖链接打开,不覆盖规格面板、规格选择或价格。
|
||||
- 用户故事 / 交互:采购工具执行链接任务前的只读真机 spike;无生产 GUI。
|
||||
- 架构 / API:`docs/04-architecture.md` 第三节 A 路径;`docs/api.md` 的 canonical `product_url` / `goods_id` 示例;T-103 前置。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 只接受 HTTPS canonical 链接 `https://mobile.yangkeduo.com/goods.html?goods_id=<纯数字>`;拒绝 userinfo、非默认端口、fragment、重复/缺失/非数字 goods_id、其他 host/path 和额外业务参数。解析后由代码按 goods_id 重建 canonical URL,绝不把任意输入拼入 shell。
|
||||
2. 复用 T-101 的显式 serial、同机多通道 fail-closed、no-reconnect、类型化超时和原子证据发布边界;运行拼多多版本必须精确等于本项目已取证的 8.17.0,否则在打开链接前停止。
|
||||
3. 通过参数数组执行只含 Android `VIEW` intent 语义的 `adb shell am start -W`,显式限定包 `com.xunmeng.pinduoduo`;不使用 shell 字符串、不点击/滑动/输入任何控件。Android 命令失败、超时、未解析 intent、未停留在拼多多包、截图/XML 失败分别给出脱敏错误。
|
||||
4. 启动后只读取 current app/package、截图和 `dumpWindowHierarchy(compressed=False)`,manifest 记录 goods_id、canonical URL、设备/App 元数据、命令结果摘要、路径与 SHA-256,不记录页面全文或原始 serial。原始截图/XML 仅存 `%LOCALAPPDATA%`,不提交 Git。
|
||||
5. 离线 mock 测试覆盖 URL 正反例、参数数组与超时、版本失配在 intent 前停止、包不匹配、无 UI 点击 API、证据原子性和异常脱敏。代码完成后由人使用一个明确 goods_id 在仅单通道在线的真机执行,人工确认截图确为对应商品详情页并检查 XML/截图无敏感信息;再把设备、Android、拼多多版本、goods_id、路径、SHA-256 与结论写入执行记录。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 合法 canonical 链接可在拼多多 8.17.0 上打开;人工确认到达与 goods_id 对应的商品详情页。
|
||||
- 非法链接、设备状态、App 版本失配、intent 失败/超时、落在非拼多多包、证据失败均有可区分且不泄露页面内容的错误。
|
||||
- 取证记录包含 PKG110、Android 16、连接方式、拼多多 8.17.0、goods_id、canonical URL、截图/XML 本地路径与 SHA-256。
|
||||
- client 全部单测、compileall、wheel metadata、上下文校验及完整 `init.ps1` 通过。
|
||||
- `needs_device=true`:agent 只能完成离线实现并保持 DOING;只有人完成真机链接打开、页面对应性与隐私检查后才能 DONE。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T01:09:47Z · ila
|
||||
|
||||
2026-08-04:已认领 T-102,工作分支 `task/t-102-open-product`,从任务定义提交 `4281b06` 开始实现。离线阶段只实现严格 canonical URL 校验、Android VIEW intent、版本/设备 fail-closed 与原子证据采集;不写商品详情页节点判据,不操作任何 App 控件。`needs_device=true`,离线实现完成后仍保持 DOING,等待人工提供明确商品链接并完成真机页面对应性和隐私验收。
|
||||
|
||||
### 2026-08-04T01:27:08Z · ila
|
||||
|
||||
2026-08-04:T-102 离线实现已由子 agent 完成并经主 agent 独立审查、两轮退回修正后通过,代码提交 `7040bb6`。实现包含:唯一 canonical 商品 URL 校验与重建、ADB 内层纯数字 goods_id 二次围栏、固定 package 的 Android VIEW intent、拼多多 8.17.0 intent 前版本围栏、前台 package 核验,以及截图/XML 原子取证与脱敏 manifest;无点击、滑动、输入、规格、价格、下单或支付能力,也未写商品详情页节点/Activity 判据。主审验证:43 项 client 离线测试、compileall、完整 init.ps1(含 admin test/vet/build)、wheel metadata、agent-context 与 diff-check 均通过;当前 wheel SHA-256 为 c675cd527849149ec25f213a0d67da31d0bb497cd8c5ed255d71154c103659ed(审计临时产物已清理)。未连接或操作真机,T-102 继续保持 DOING,等待人工使用明确 canonical 商品链接执行取证,并确认页面对应性与隐私。
|
||||
|
||||
### 2026-08-04T01:32:31Z · ila
|
||||
|
||||
2026-08-04 人工真机首轮:使用 WiFi serial `192.168.0.173:5555` 与 canonical 商品链接 `https://mobile.yangkeduo.com/goods.html?goods_id=958756616606` 执行 T-102 脚本。脚本通过 URL/intent 前置围栏,但在 intent 后返回“商品链接打开后前台应用不是拼多多”,按 fail-closed 规则停止,未发布完整取证。人在 Chrome 中可打开该 URL,只能证明网页链接有效,不能证明拼多多 App 已接管。T-102 保持 DOING;下一步由人只读取当前前台 package,区分瞬时切换/检查过早与实际落到 Chrome、系统解析器或其他包,在得到事实前不移除 package 围栏、不增加页面判据或 UI 兜底。
|
||||
|
||||
### 2026-08-04T01:34:39Z · ila
|
||||
|
||||
2026-08-04 人工追加诊断:T-102 首轮返回前台包不符后,人在未切换 App 的情况下立即执行只读 `dumpsys window`,得到 `mCurrentFocus` 与 `mFocusedApp` 均为 `com.xunmeng.pinduoduo/com.xunmeng.pinduoduo.activity.NewPageActivity`。该事实证明失败返回后前台已稳定到拼多多,支持“一次性 app_current 检查过早/存在异步切换窗口”的诊断;Activity 名只记录为诊断证据,不作为商品详情页判据。修复保持 package 围栏:改为有限时长、只读 package 的轮询,超时仍 fail closed,期间不点击、滑动、输入或读取页面节点。
|
||||
|
||||
### 2026-08-04T01:40:42Z · ila
|
||||
|
||||
2026-08-04:已按人工 foreground 证据完成最小修复,代码提交 `cb646b4`。保留 PDD package 围栏,将 intent 后的一次性判断改为以 CLI `--timeout` 为 deadline、默认 0.2 秒间隔的只读 package 轮询;只有观察到 `com.xunmeng.pinduoduo` 才采集截图/XML,超时仍 fail closed。轮询不读取 Activity/节点,不点击、滑动或输入。主 agent 已独立验证 46 项 client 测试、compileall、完整 init.ps1、wheel metadata、agent-context、diff-check;wheel SHA-256 `80fbc73785bb12b40856c6b1ef503bdb5cbc350fadd2b23cc6cc58358499bfe1`,临时产物已清理。未由 agent 连接真机,T-102 保持 DOING,等待人以 goods_id `958756616606` 重跑原命令并检查页面对应性与隐私。
|
||||
|
||||
### 2026-08-04T01:46:35Z · ila
|
||||
|
||||
2026-08-04 人工真机重跑已成功:人先退出到拼多多首页,再用 WiFi serial `192.168.0.173:5555`、goods_id `958756616606` 执行同一命令,证据原子发布到 `C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-102\product-open-958756616606`。manifest 记录 PKG110 / Android 16 / PDD 8.17.0 / current package `com.xunmeng.pinduoduo` / intent status ok;截图 SHA-256 `be9ea1f53b13fec82fd716875567870132ef0a1480cfbec5ad603436d774d81c`,XML SHA-256 `1449e78a154fddf7108e63a14a2e85bf74f162f4689c1fe95f2cb67fdb7b4fc5`。主 agent 只读取 manifest 并重新计算哈希,二者均匹配;未打开原始截图/XML。仍等待人明确确认截图对应目标商品且截图/XML 无地址、手机号、支付信息或其他无关隐私,确认前 T-102 保持 DOING。
|
||||
|
||||
### 2026-08-04T01:49:55Z · ila
|
||||
|
||||
2026-08-04 人工最终验收:人已查看本地 screenshot.png,确认对应 goods_id 958756616606;并确认截图与 hierarchy.xml 不含地址、手机号、支付信息或其他无关隐私。T-102 的 canonical 链接打开、PDD 8.17.0 前台 package、证据原子发布与人工页面对应性验收全部通过,允许关闭。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 本任务只验证由显式 canonical 链接启动拼多多并采集本地证据;不编写或声称商品详情页节点判据,
|
||||
页面是否为对应商品必须由人查看本项目新产物确认。
|
||||
- 只允许 Android `VIEW` intent;不点击、滑动、长按、输入任何 App 控件,不打开规格面板,不选择
|
||||
颜色或尺码,不读取价格,不进入购买或订单页面。
|
||||
- 不引用或实现 `go_to_order_confirm()`、`submit_order()`、提交订单、付款、支付、授权或采购服务接口;
|
||||
不把前序项目的 URL、activity、节点文本或页面结论当作事实。
|
||||
- 输入 URL 必须先严格解析并按纯数字 `goods_id` 重建,再以参数数组传给 ADB;禁止 shell 拼接、
|
||||
任意 scheme/host/path、短链跳转、额外参数或自动猜测链接。
|
||||
- 运行拼多多版本与本项目已取证版本不一致时必须在 intent 前停止;证据失败、当前包不符或结果无法
|
||||
由人确认时均不得声称成功,不增加自动点击或其他兜底路径。
|
||||
- 截图和完整 XML 只保存在明确的本地 T-102 目录,人工检查后只把路径、SHA-256 与非敏感元数据
|
||||
写入执行记录;不得提交原始证据,不记录地址、手机号、支付信息或无关页面正文。
|
||||
- 不修改 `admin/`、业务 API、数据模型、生产 GUI 或 T-103 规格面板逻辑。
|
||||
- `needs_device: true`:agent 完成离线实现后保持 `DOING`;只有人使用明确 goods_id 完成真机打开、
|
||||
确认对应商品详情页并完成隐私检查后才能标 `DONE`。
|
||||
@@ -0,0 +1,205 @@
|
||||
---
|
||||
id: T-103
|
||||
title: 验证规格面板精确选择与 SKU 单价
|
||||
phase: 1
|
||||
deps: [T-102, T-110, T-111]
|
||||
status: DOING
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 23
|
||||
context_ref: 1dc8308
|
||||
work_branch: task/t-103-sku-panel
|
||||
needs_device: true
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-103.md
|
||||
- client/src/cmbuyer_client/pdd/**
|
||||
- client/src/cmbuyer_client/device/**
|
||||
- client/tests/pdd/**
|
||||
- client/tests/device/**
|
||||
- client/scripts/capture_sku_panel_spike.py
|
||||
- client/scripts/sanitize_sku_panel_evidence.py
|
||||
- docs/02-requirements.md
|
||||
- docs/03-tech-stack.md
|
||||
- docs/04-architecture.md
|
||||
- docs/api.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-04T08:24:57Z sha256=19a0864d83c5e41e57a29002b69f7767b50429dcf0fbace02f8303f92b71bac6 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:规格面板由详情页精确唯一的“快要抢光”打开;T-110 已把该证据/版本绑定入口批准为受控导航。面板刚打开时目标颜色“黑色CHA(纯棉)”和尺码“M(建议100-115)”自动选中。
|
||||
|
||||
项目所有者已把 MVP 改为单趟:管理员点击“开始采购”即授权采购工具按锁定的商品、规格、数量和最高总价创建待付款订单,不再有试选后确认。T-103 仍只验证规格选择与读价,是生产单趟的窄前置;数量、确认页、提交围栏和提交订单由后续取证任务开放。系统绝不自动付款。
|
||||
|
||||
`t103-privacy-v5` 派生 XML 已由人确认保留目标规格、顶部当前价“快卖完 ¥12.88”和原价“¥29.88”,且不含实际地址/手机号;底部“提交订单 ¥12.88”是硬拒绝区。项目已停止遮罩器开发。正式流程允许把规格/确认页内部原始截图上传采购服务,完整 XML 只留客户端本机。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-006 单趟采购的规格面板前置;覆盖 `open_sku_panel()`、`select_sku_options()`、`read_sku_unit_price()` 和安全退出的真机 spike。
|
||||
- 架构/API:`docs/04-architecture.md` 第三、四、5.4、六节;`docs/api.md` 的 `SkuSelectionFlow`;T-104、T-105 与 T-205 的前置。
|
||||
- 本任务没有生产 GUI,也不调用 T-204 的 HTTP 截图链路。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 停止修改、调用或扩展截图遮罩器。T-103 原始截图保存在本机产物目录完成真机验收;T-204 以后显式上传 `privacy_tier=INTERNAL_RAW` 的截图。完整 XML 不上传、不进日志、Vikunja 或 Git。
|
||||
2. 从已确认 v5 派生 XML 提取只含页面判据节点的最小 fixture;fixture 不含地址、手机号或支付凭据。
|
||||
3. 实现 `SkuSelectionFlow` 窄 capability。`open_sku_panel()` 只允许拼多多 8.17.0、goods_id `937122477375` 上证据绑定、精确唯一的“快要抢光”;缺失、重复、版本失配或面板不唯一时零后续点击。其他购买文案不得复用。
|
||||
4. 本任务只含打开商品、受控打开面板、维度内精确选择、选中态读回、读取 SKU 单价、原始截图和安全退出。不得暴露通用 `click`、数量、确认页、授权、提交围栏、提交订单或支付能力。
|
||||
5. 规格选项按维度容器隔离后精确唯一匹配,防“红/粉红”“1/10”前缀碰撞;缺失、重复、禁用、维度不明或选中态不唯一均停止。
|
||||
6. 单价只从已取证规格面板当前价角色读取,严格解析人民币十进制字符串。促销前缀可变,不能把“快卖完”作为主判据;必须限定 PDD package、面板结构/相对位置、非点击价格叶节点、唯一当前价并排除原价、提交/支付/优惠语义。歧义返回 unreadable,不从详情页或底部按钮补值。
|
||||
7. 真机运行只返回规格、选中态、价格和页面状态摘要;地址/手机号不解析为字段。截图上传器不属于本任务。
|
||||
8. 离线主审通过后,由人先把颜色和尺码改成记录的非目标值,再运行脚本恢复目标规格、读取 12.88、保存原始截图并安全退出。`needs_device=true`,agent 保持 DOING 等人工验收。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 最小 fixture 只来自本项目拼多多 8.17.0、goods_id `937122477375` 已验收的 v5 派生 XML,不含地址、手机号或支付凭据。
|
||||
- 受控入口、规格容器、精确选择、选中态和当前价任一歧义均 fail closed;价格能区分 12.88、原价 29.88 与硬拒绝区“提交订单 ¥12.88”。
|
||||
- 静态 API/导入测试证明 `SkuSelectionFlow` 无通用点击、数量、确认页、授权、围栏、提交或支付能力。
|
||||
- T-103 不再开发遮罩。T-204 的原始截图上传必须限制为显式截图文件、设备鉴权、SHA-256 和管理员会话访问,不上传完整 XML 或支付凭据。
|
||||
- client 全部单测、compileall、wheel metadata、上下文校验、完整 init 与 diff-check 通过。
|
||||
- 只有人完成受控入口、精确规格选择、SKU 单价语义、页面对应性、原始截图与安全退出验收后才能 DONE。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T01:58:01Z · ila
|
||||
|
||||
2026-08-04:已认领 T-103,工作分支 `task/t-103-sku-panel`,任务定义基线 `27999c8`。先执行人工证据门禁,不立即编写页面判据:T-102 现有详情截图只证明目标商品,且当前视口可见“单独购买 / 免拼购买”“先用后付 / 0元下单”等禁触入口,没有证明独立安全规格入口。下一步由人手工寻找非购买的“规格 / 已选”入口;若不存在就记录 no-go,不点击购买按钮兜底。规格面板三态证据与隐私确认完成前,受托 agent 只允许做证据采集脚手架、纯函数边界和只读审计,T-103 保持 DOING。
|
||||
|
||||
### 2026-08-04T02:09:48Z · ila
|
||||
|
||||
2026-08-04 离线实现与主审记录:
|
||||
|
||||
- 提交 `a92b8f6` 新增 T-103 人工声明三状态的只读取证 CLI、捕获模块与离线测试;脚本不打开链接/面板,不点击、滑动、选择规格或读取价格。
|
||||
- 失败闭合覆盖:严格 URL/状态/版本/前台包校验、截图/XML 有效性、超时脱敏、原子发布、已有输出目录不覆盖;协议无购买、下单、支付或数量操作。
|
||||
- 主 agent 独立复跑:client 55 项单测、compileall、wheel metadata、agent-context validator、diff-check 与完整 `init.ps1` 全部通过。
|
||||
- `needs_device=true`,任务保持 DOING;等待人通过独立安全规格入口分别采集 initial、one-dimension-selected、all-dimensions-selected 三组证据并完成对应性与隐私检查。若只有购买/下单/资金入口可打开面板,则记录 no-go,不放宽边界。
|
||||
|
||||
### 2026-08-04T02:36:33Z · ila
|
||||
|
||||
2026-08-04 真机证据主审退回:
|
||||
|
||||
- goods_id `937122477375` 的三组 manifest、设备/App 元数据与文件 SHA-256 均一致。
|
||||
- 人工给出隐私确认后,主 agent 只查看三张 screenshot,未读取 XML。
|
||||
- `initial` 截图实际仍为商品详情页,不是规格面板初始态;`one-dimension-selected` 与 `all-dimensions-selected` 截图顶部含收货区域和掩码手机号,不满足任务的无地址/手机号证据边界。
|
||||
- 本组证据判定不合格:不提取 fixture、不编写页面判据、不提交原始证据;T-103 保持 DOING。
|
||||
- 下一步使用全新输出目录重采:人先在规格面板取消全部规格选择,并滚动到收货区域和手机号完全离开可见视口,同时保留规格面板价格与规格区域;三态逐次人工核对后再交主审。
|
||||
|
||||
### 2026-08-04T02:41:12Z · ila
|
||||
|
||||
2026-08-04 T-103 真机 no-go 结论:
|
||||
|
||||
- 人工确认 goods_id `937122477375` 的规格面板是通过商品详情页右下角“快要抢光”进入;通常“免拼购买 / 快要抢光 / 单独购买 / 直接拼成”等购买语义按钮才会打开规格面板。
|
||||
- 这直接触发任务方案与边界中的 no-go 条件:第一趟不得点击任何购买、下单、提交或资金入口,也不得把购买按钮包装成规格入口。
|
||||
- 因此不读取本轮 XML、不提取 fixture、不实现选择器;已有截图仅用于判定入口不可行,原始证据不提交 Git。
|
||||
- 当前 MVP 的自动第一趟试选在现有安全边界下不可实现,T-103 转 BLOCKED。解除阻塞需要项目所有者选择新的架构方向,并先更新架构/任务边界;不得由 agent 擅自放宽。
|
||||
|
||||
### 2026-08-04T02:56:36Z · ila
|
||||
|
||||
2026-08-04:T-110 已获项目所有者授权并同步架构契约。T-103 方案已重写为“先自动脱敏派生证据,再实现受控 `快要抢光` 入口与精确规格/价格判据”;T-110 完成并转 Done 后,本任务恢复 Doing。
|
||||
|
||||
### 2026-08-04T03:20:51Z · ila
|
||||
|
||||
2026-08-04 T-103 自动脱敏第一阶段实现与主审:
|
||||
|
||||
- 提交 `9b2eb74` 新增离线确定性脱敏 CLI、实现模块和纯合成测试;仅允许 `raw` 到同级全新 `derived`,不连接设备。
|
||||
- 绑定 PKG110 / Android 16 / 拼多多 8.17.0 / goods_id `937122477375` / 1080×2400 / 人工声明三态;校验源 manifest 与文件哈希。
|
||||
- 截图整宽遮罩 `[0,0,1080,540)`;XML 递归删除隐私带节点、清空跨界容器并保留下方规格节点,完整/掩码/分隔/跨节点手机号残留均 fail closed。
|
||||
- 主审首轮发现手机号跨节点绕过、隐私结构未确认和发布竞态风险后退回;第二轮修复并补测,目标目录已存在或竞态出现均不覆盖且不留 staging。
|
||||
- 主 agent 独立验证:66 项 client 单测、compileall、CLI help、wheel metadata、完整 `init.ps1`、上下文校验与 diff-check 全部通过。
|
||||
- 实现与审查过程未读取、列举或打开任何真实 raw artifacts,也未实现 PDD 页面判据、点击、规格选择、价格、数量、确认页、提交或付款能力。
|
||||
- `needs_device=true`,T-103 保持 DOING;等待人重新采集三态 raw、运行脱敏器并只核对 derived 后,才能继续页面判据与真机选择/读价。
|
||||
|
||||
### 2026-08-04T03:34:47Z · ila
|
||||
|
||||
项目所有者确认:规格面板刚打开时已自动选中目标颜色“黑色CHA(纯棉)”与尺码“M(建议100-115)”。旧的未选择/单维度/全选三态假设失效;T-103 改为记录刚打开目标预选、两个维度改为非目标值、两个维度恢复目标值三态。修改 CLI 状态枚举与测试前不再采集旧状态。
|
||||
|
||||
### 2026-08-04T03:40:39Z · ila
|
||||
|
||||
T-103 新三态离线实现与主审:提交 dbb69a7 将人工声明状态集中到 sku_panel_state.py,采集器和脱敏器仅接受 panel-opened-target-preselected、alternate-all-dimensions-selected、target-selection-restored;旧 initial / one-dimension-selected / all-dimensions-selected 与未知值均 fail closed。主 agent 独立复跑 67 项 client 单测、compileall、两个 CLI help、静态禁用能力检索与完整 init.ps1,全部通过。过程未连接设备、未读取真实 raw、未新增页面判据或点击/数量/确认/提交/付款能力。T-103 保持 DOING,等待新三态 derived 人工验收。
|
||||
|
||||
### 2026-08-04T03:54:21Z · ila
|
||||
|
||||
第一组新三态 raw 已由人成功采集;脱敏器 v1 按设计拒绝。人只读取 PNG 头部元数据并确认实际 format=PNG、size=1080x2376,证明 v1 将截图尺寸与 Android XML 坐标空间统一设为 1080x2400 的假设错误。原始证据不重采、不删除、不读取正文;下一步把 screenshot 与 XML coordinate space 分离配置,升级 sanitizer 版本并继续 fail closed。
|
||||
|
||||
### 2026-08-04T03:58:54Z · ila
|
||||
|
||||
T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space 固定为人确认的 1080x2376,并把 XML coordinate space 独立严格配置为待验证的 1080x2400;sanitizer_version 升为 t103-privacy-v2。XML 会统计所有 bounds 的 max right/bottom,任何小于或大于配置的偏差均 fail closed,错误只含非敏感 observed 尺寸。派生 manifest 记录两个坐标空间和各自遮罩。主 agent 独立复跑 69 项 client 单测、compileall、CLI help 与 diff-check,全部通过;未读取真实 raw 正文、未连接设备。等待人用同一第一态 raw 重跑 v2 脱敏。
|
||||
|
||||
### 2026-08-04T06:01:02Z · ila
|
||||
|
||||
人使用 sanitizer v2 对同一第一态 raw 重跑,安全诊断返回 XML observed 1080x2376;未发布 derived。由此确认本次 screenshot space 与 XML coordinate space 均为 1080x2376,v2 对 XML=1080x2400 的待验证假设被真机证据否定。原始证据继续保留且不重采;下一步升级 v3,仍分离建模两个空间但分别精确绑定当前相同尺寸。
|
||||
|
||||
### 2026-08-04T06:05:53Z · ila
|
||||
|
||||
2026-08-04 T-103 sanitizer v3 坐标修正与主审:人用同一第一态 raw 运行 v2,脱敏器安全拒绝并仅报告 observed 1080x2376,证明 XML 实际坐标与截图相同;原始证据仍有效、未重采、未读取正文。提交 acf7e11 将 sanitizer_version 升为 t103-privacy-v3;截图与 XML 仍分别建模并分别严格校验,但当前均精确绑定 1080x2376,隐私带保持 [0,0,1080,540)。合成测试明确拒绝旧截图 1080x2400、旧 v2 XML 1080x2400,以及较小/较大坐标;手机号残留、哈希/状态/版本、确定性、原子发布和不覆盖门禁保持不变。主 agent 独立复跑 69 项 client 单测、compileall、CLI help、静态禁用能力检索与 diff-check,全部通过;未连接设备、未读取真实 raw。T-103 保持 DOING,等待人用同一第一态 raw 重跑 v3 并只核对 derived。
|
||||
|
||||
### 2026-08-04T06:17:05Z · ila
|
||||
|
||||
2026-08-04 第一态 derived 人工隐私验收与主审:项目所有者确认截图顶部隐私区完全遮黑、派生 XML 不含地址/手机号、目标颜色和尺码仍为预选;顶部价格因隐私带只显示一部分,最底部价格完整。人工确认后主 agent 才读取 derived,未访问 raw。路径 C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-panel-opened-target- 937122477375-v2\derived;manifest 为 privacy_tier=SANITIZED、sanitizer_version=t103-privacy-v3、state=panel-opened-target-preselected,派生 screenshot/XML SHA-256 分别为 f9e370747cbef4facf6ec4a20d5af72b36c7615e7be7038144b1834632cdbf57 / f2024b7bcc69a03b05f5e95610708c0dbd33001698f40a011ddc895cf0410fb1,复算一致。XML 中“黑色 CHA (纯棉)”与“M(建议100-115)”各有明确 selected=true,汇总文本一致。完整金额候选仅见“提交订单 ¥12.88”;该文本只能在不可点击的派生快照中用于离线价格语义验证,第一趟绝不能获得该控件或其可点击父节点。另观察到 com.android.systemui 的“肉包采购辅助”浮层节点,后续最小 fixture/判据必须限定 PDD package,并建议后两态采集前关闭该浮层。第一态隐私与状态证据通过,但价格语义仍需另两态交叉验证,T-103 保持 DOING。
|
||||
|
||||
### 2026-08-04T06:20:03Z · ila
|
||||
|
||||
主审补充澄清:上一条所述“提交订单 ¥12.88”只能证明面板中存在该禁触文本,不得作为第一趟 SKU 单价证据,也不得驱动任何控件解析或交互。原因是它属于可点击“提交订单”父区域,违反 T-110 硬拒绝区边界。v3 第一态因此只通过隐私与预选状态验收,价格证据仍不通过。下一步不缩小 [0,0,1080,540) 截图遮罩,而由 v4 脱敏器在顶部交界带仅保留严格白名单、PDD package、非点击叶节点的价格文本到派生 XML;任何混杂文本、点击节点、包外节点或非唯一候选均 fail closed。
|
||||
|
||||
### 2026-08-04T06:43:53Z · ila
|
||||
|
||||
2026-08-04 客户确认加速方案:手机规格面板显示地址/手机号不再作为真机流程阻塞条件;不再扩大或调整截图黑色遮罩。原始证据仍只留本机,agent/Git/服务端仍只消费派生物。提交 1e69d27 实现 t103-privacy-v4:截图遮罩与 1080x2376 双坐标校验完全不变,只把已取证的两个顶部交界价格槽中,PDD package、TextView、非点击、可见启用、叶节点且严格匹配“唯一快卖光当前价 + 至多一个原价”的七属性安全文本投影到派生 XML;结构/文本/候选数漂移均原子拒绝,底部“提交订单 ¥12.88”仍不是价格候选。主 agent 两轮退回测试职责问题后独立复跑 76 项 client 单测、compileall、CLI help、禁用能力检索与 diff-check,全部通过。下一步保留已验收 v3 derived,用同一 raw 生成 v4 derived;T-103 保持 DOING。
|
||||
|
||||
### 2026-08-04T06:55:52Z · ila
|
||||
|
||||
2026-08-04 项目所有者用同一第一态 raw 运行 v4,脱敏器按设计 fail closed:跨界价格节点文本不匹配,未发布 derived;raw 与已保留的 derived-v3-reviewed 未受损。提交 ef1ac60 增加最小安全诊断:失败只输出两个固定价格槽位和受控 reason 枚举,不回显原文、金额、数字串、字符码点、长度或原始属性,v4 成功白名单与原子不发布语义不变。主 agent 独立复跑 78 项 client 单测、compileall、CLI help 与 diff-check,全部通过。等待项目所有者用同一 raw 重跑并反馈 slot/reason;T-103 保持 DOING。
|
||||
|
||||
### 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。
|
||||
|
||||
### 2026-08-04T07:52:38Z · ila
|
||||
|
||||
2026-08-04:项目所有者确认管理员点击“开始采购”即授权创建待付款订单,MVP 从两趟人工确认改为单趟采购。T-103 暂停,等待 T-111 先重写状态机、授权、价格闸门与任务依赖;暂停期间不得继续实现 TrialSkuFlow 或任何数量/确认页/提交代码。T-103 仍为未完成任务。
|
||||
|
||||
### 2026-08-04T08:24:12Z · ila
|
||||
|
||||
2026-08-04:T-111 单趟契约主审通过,T-103 解除架构阻塞。恢复后只实现 SkuSelectionFlow:受控入口、维度内精确选择、选中态读回、SKU 当前价唯一读取、原始截图和安全退出;不得包含数量、确认页、授权、提交围栏、提交订单或支付。客户端工作树现有未提交 v6 遮罩测试残留必须先撤销到已提交 v5 基线,再开始新实现。needs_device=true,离线实现后仍等待人工真机验收。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 任何规格面板入口、维度容器、选项、选中态和价格判据都必须来自本项目拼多多 8.17.0 真机证据;
|
||||
T-102 详情页证据只能证明已到目标商品,不能证明规格面板结构。T-103 只使用已由人确认无实际地址/
|
||||
手机号的 v5 派生 XML 提取最小 fixture;派生截图的上传适用性不再是本任务的代码门禁。
|
||||
- `SkuSelectionFlow` 只允许 T-110 批准的 `open_sku_panel()` 窄能力点击证据/版本绑定、精确唯一的
|
||||
`快要抢光`;当前事实仅覆盖 goods_id `937122477375`、拼多多 `8.17.0`。不得把“免拼购买 /
|
||||
单独购买 / 直接拼成”等其他文案加入包含、前缀、同义或坐标兜底,分别取证前一律拒绝。
|
||||
- 规格选项只能在已确认的面板及对应维度容器内按文本精确唯一匹配;缺失、重复、禁用、维度不明、
|
||||
选中态无法唯一读回或页面版本不符均 fail closed。不得前缀、包含、模糊、相似或跨维度匹配,
|
||||
不得用 OCR / 坐标兜底猜选项。
|
||||
- 单价只允许从规格面板证据确认的节点读取,并以十进制字符串表达;不得使用浮点,不得从详情页、
|
||||
搜索卡片或其他页面的数字补值。券后价、原价、区间价、货币符号拆分或多个候选的语义无法唯一
|
||||
证明时必须返回 unreadable 并转人工。
|
||||
- T-103 代码路径不得引用或实现 `set_quantity_and_readback()`、`go_to_order_confirm()`、
|
||||
`create_submission_fence()`、`submit_order_once()`、
|
||||
通用 `click`、创建订单、授权、提交围栏或任何支付能力;不得进入订单确认页,不得创建待付款订单。
|
||||
规格面板中的“提交订单”、微信支付、先用后付、0 元下单只可作为硬拒绝判据,不能返回可点击对象。
|
||||
- 不把 `/mnt/d/chengma/cmroubao`、`/mnt/d/chengma/cmpdd` 或任何旧版本的节点、Activity、选择器、
|
||||
坐标、文本形态和页面结论当作事实;前序项目只可用于理解为什么必须 fail closed。
|
||||
- 原始完整 XML 只保存在 `%LOCALAPPDATA%\cmbuyer\artifacts\T-103\...\raw`,不得由 HTTP sink、
|
||||
Vikunja 或 Git 读取/上传。真机流程可在内存中读取当前页面树,但只返回规格、选中态、价格和页面
|
||||
状态摘要;不得把地址、手机号或支付凭据解析成业务字段或写入日志。
|
||||
- T-103 不再扩展截图遮罩器;本任务先以本机原始截图完成真机验收。生产单趟原始截图上传由 T-204
|
||||
接入,允许截图包含页面已显示的地址和手机号,但只允许已认证设备上传、已登录管理员查看;完整 XML、
|
||||
外部支付页和支付凭据不得上传。
|
||||
- `needs_device: true`:agent 只能完成离线实现并保持 `DOING`;只有人完成安全入口、精确规格选择、
|
||||
SKU 单价语义、页面对应性与安全退出验收后才能标 `DONE`。HTTP 截图上传实现不属于 T-103 验收。
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
id: T-110
|
||||
title: 调整第一趟受控规格入口与隐私脱敏边界
|
||||
phase: 1
|
||||
deps: [T-102]
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 24
|
||||
context_ref: abeefbb
|
||||
work_branch: task/t-110-trial-sku-boundary
|
||||
needs_device: false
|
||||
needs_human_review: true
|
||||
write_paths:
|
||||
- docs/tasks/T-110.md
|
||||
- docs/tasks/T-103.md
|
||||
- docs/02-requirements.md
|
||||
- docs/03-tech-stack.md
|
||||
- docs/04-architecture.md
|
||||
- docs/05-coding-rules.md
|
||||
- docs/api.md
|
||||
- docs/current-state.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=24 synced=2026-08-04T03:00:16Z sha256=dd7209dda3769397f96882556864c1836955507fd5ee293103295d4fd9f9a840 -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-103 在 PKG110 / Android 16 / 拼多多 8.17.0 真机确认:衣服商品 goods_id `937122477375` 的规格面板只能从详情页右下角“快要抢光”等购买语义按钮进入,不存在原架构假设的独立“规格 / 已选”入口。与此同时,规格面板固定展示收货区域与掩码手机号。项目所有者已批准调整第一趟入口边界,但“不付款、第一趟不可达提交订单、价格只从规格面板读取、敏感信息不进入业务数据/日志/上传/Git”等边界不变。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-006 第一趟试选;为 T-103 解阻,不实现页面选择器或真机点击代码。
|
||||
- 架构:`docs/04-architecture.md` 第一趟流程、安全边界、证据分层;`docs/05-coding-rules.md` 不可逆动作和敏感信息纪律。
|
||||
- API:设备试选证据只允许上传脱敏派生物;原始截图/XML 仅留采购工具本机隔离目录。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 把“独立规格入口”改为“受控规格面板入口”:第一趟只可点击本项目真机证据证明、与拼多多版本绑定的唯一精确入口;当前仅确认 `快要抢光`。其他文案即使语义相近也不得推断复用,必须重新取证。
|
||||
2. 将点击入口与下单能力隔离。第一趟 capability 只允许打开受控面板、在维度内精确选择、读规格面板单价、生成脱敏证据和返回商品页;接口不得出现设置数量、进入确认页、提交订单、支付或通用任意点击能力。静态调用链和测试必须证明这些能力不可达。
|
||||
3. 规格面板内的“提交订单”以及微信支付、先用后付、0 元下单等控件全部列入硬拒绝区。入口缺失/重复、版本失配、打开后不是已取证面板、选择或价格不唯一时立即返回,不尝试相近按钮。
|
||||
4. 接受 PDD 页面会显示收货区域与掩码手机号这一事实,但不允许业务系统提取或传播。原始 screenshot/XML 仅写入 `%LOCALAPPDATA%` 隔离目录,不打印页面正文、不上传、不提交 Git;本地确定性脱敏器生成派生 screenshot/XML 和哈希,开发、fixture、服务端证据只消费派生物。
|
||||
5. 脱敏必须 fail closed:无法确认地址/手机号区域已移除、派生 XML 仍命中手机号模式、分辨率/结构不符合已取证版本,均拒绝发布派生证据。不得依赖人工口头“无需处理”绕过自动检查。
|
||||
6. 同步需求、架构、技术栈、编码规则、API、current-state 与 T-103。T-110 完成后把 T-103 依赖补为 T-110 并重新转 DOING;真正的脱敏器、入口判据和真机实现仍由 T-103 完成。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 文档明确区分“可逆的受控规格面板入口”与“不可逆的提交订单动作”,不把购买文案泛化为通用可点击入口。
|
||||
- 第一趟的 capability 列表与硬拒绝列表完整;提交订单、数量、确认页、付款和任意点击能力不可达。
|
||||
- 原始证据与脱敏派生证据的目录、生命周期、消费者和 fail-closed 条件清楚;服务端和 Git 永不接收原始隐私证据。
|
||||
- `docs/tasks/T-103.md` 在 T-110 完成后恢复 DOING,并要求先产出自动脱敏证据再写页面判据。
|
||||
- `python scripts/validate_agent_context.py`、Vikunja 导出检查与 `git diff --check` 通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T02:48:25Z · ila
|
||||
|
||||
2026-08-04:项目所有者明确批准受控规格面板入口方案;PDD 页面不可避免显示地址/手机号的事实被接受,但系统仍按本机原始证据隔离、自动脱敏派生物消费的方式收紧传播边界。任务已认领,分支 `task/t-110-trial-sku-boundary`,基线 `abeefbb`。
|
||||
|
||||
### 2026-08-04T02:58:44Z · ila
|
||||
|
||||
2026-08-04 完成记录:
|
||||
|
||||
- 已同步 `docs/02-requirements.md`、`03-tech-stack.md`、`04-architecture.md`、`05-coding-rules.md`、`api.md`、`current-state.md` 与 T-103。
|
||||
- 第一趟只批准证据/版本绑定、精确唯一的 `快要抢光`;其他购买文案不泛化。`TrialSkuFlow` 不暴露通用点击、数量、确认页、提交订单或支付能力。
|
||||
- 原始 screenshot/XML 只留本机隔离目录;服务端只接收自动复检通过的派生截图哈希与 sanitizer 版本,不接收原始文件、哈希、路径或 XML。
|
||||
- Phase 1 已拆分第一趟规格 spike 与第二趟数量/确认页 spike;T-103 已增加 T-110 依赖并按新边界恢复 DOING。
|
||||
- `validate_agent_context.py` 与 `git diff --check` 通过;本任务没有实现或运行任何真机点击代码。
|
||||
|
||||
### 2026-08-04T03:00:01Z · ila
|
||||
|
||||
最终提交 `82f57c7`。主 agent 独立复跑完整 `init.ps1`:admin go test/vet/build、client editable install/55 项 unittest/compileall、agent-context validator 全部通过;`git diff --check` 通过。T-110 已完成且未实现或执行任何真机点击。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 本任务只调整架构、需求、API、编码规则与后续任务契约,不实现或运行任何真机点击代码。
|
||||
- 仍然绝不点击支付、免密支付、先用后付、0 元下单或扣款控件;不创建订单,不进入订单确认页。
|
||||
- 第一趟只允许未来实现点击**本项目真机证据证明、与拼多多版本绑定、精确唯一**的规格面板入口;
|
||||
当前只确认 goods_id `937122477375`、拼多多 `8.17.0` 上的“快要抢光”。不得把人工经验中的
|
||||
“免拼购买 / 单独购买 / 直接拼成”等文案直接加入白名单,分别取证前一律拒绝。
|
||||
- 第一趟 capability 不得包含通用 `click`、数量调整、订单确认、提交订单或付款能力;规格面板中的
|
||||
“提交订单”及任何支付提示始终属于硬拒绝区,静态调用链必须保持不可达。
|
||||
- 接受原始规格面板证据在手机本地可能包含收货区域和掩码手机号,但不得从中提取业务字段、打印、
|
||||
上传或提交 Git。只有确定性脱敏并通过自动检查的派生 screenshot/XML 才能供 agent、fixture、
|
||||
采购服务或人工远程评审使用;脱敏不确定即 fail closed。
|
||||
- 价格仍只允许从规格面板和订单确认页读取,使用十进制字符串;不得用详情页价格补齐。
|
||||
- 本任务不得把 T-103 标为 `DONE`。文档边界完成并获得本次用户明确授权后,只能把 T-103 重新置为
|
||||
`DOING`,由 T-103 继续实现脱敏器、判据、离线测试和人工真机验收。
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
id: T-111
|
||||
title: 调整为开始采购授权的单趟流程
|
||||
phase: 1
|
||||
deps: [T-004, T-010, T-110]
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 28
|
||||
context_ref: efeb2d9
|
||||
work_branch: task/t-111-single-pass
|
||||
needs_device: false
|
||||
needs_human_review: false
|
||||
write_paths:
|
||||
- AGENTS.md
|
||||
- README.md
|
||||
- docs/00-ai-start-here.md
|
||||
- docs/01-vision.md
|
||||
- docs/02-requirements.md
|
||||
- docs/03-tech-stack.md
|
||||
- docs/04-architecture.md
|
||||
- docs/05-coding-rules.md
|
||||
- docs/06-tasks.md
|
||||
- docs/07-user-stories.md
|
||||
- docs/08-interaction-checklist.md
|
||||
- docs/api.md
|
||||
- docs/current-state.md
|
||||
- docs/README.md
|
||||
- docs/routes.md
|
||||
- docs/design/README.md
|
||||
- docs/design/desk-execution.html
|
||||
- docs/design/web-task-create.html
|
||||
- docs/design/web-task-detail.html
|
||||
- docs/design/web-task-workbench.html
|
||||
- docs/tasks/README.md
|
||||
- docs/tasks/T-103.md
|
||||
- docs/tasks/T-111.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=28 synced=2026-08-04T08:24:54Z sha256=46931a0016ef5adcd69e2115e5706465078dea91506cbe5a0055c287eb6a52d7 -->
|
||||
## 问题 / 背景
|
||||
|
||||
项目原设计为两趟:先试选并等待管理员确认,再重新进入商品创建待付款订单。项目所有者为加快内部采购,决定取消试选后的人工确认;管理员在任务列表勾选并点击“开始采购”即明确授权采购工具创建一笔待付款订单。系统仍绝不自动付款。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 重写 MVP 主流程、状态机、价格闸门、授权与提交围栏。
|
||||
- 同步需求、用户故事、交互、路由、API、任务路线图、原型与当前状态。
|
||||
- T-103 改为单趟流程的规格选择/读价前置;T-202 只建 DRAFT,不受影响;新增 T-209 先迁移旧 schema/状态机,T-203 起按新流程实现。
|
||||
|
||||
## 方案
|
||||
|
||||
1. DRAFT 任务只有管理员勾选并点击“开始采购(只创建待付款订单)”后才能进入设备队列;该动作在服务端事务中创建一次性授权,锁定任务版本、goods_id、颜色、尺码、数量和最高总价。
|
||||
2. 采购工具领取后同一趟完成:打开商品、受控打开规格面板、精确选规格、闸门一读价并校验上限、设置及复核数量、闸门二重读同价与选中态、进入确认页、闸门三核对应付总额、服务端原子提交围栏、只点击一次“提交订单”。
|
||||
3. 删除 WAITING_CONFIRMATION、人工“机器选对了吗”、授权锁定试选价和第二次重新打开商品;截图改为内部审计证据,不作为下单前人工审批。
|
||||
4. 授权在围栏前失败/过期可回到 DRAFT 等管理员重新开始;围栏建立后只能调和同一提交,禁止重试、释放或签发第二笔。
|
||||
5. 不付款、提交四条件、精确规格、数量复核、价格只读规格面板/订单确认页、外部支付/安全校验停止等红线不放宽。
|
||||
6. 先提交文档契约,再恢复客户端或新增生产代码。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 所有活跃文档只描述单趟采购,不再把两趟或 WAITING_CONFIRMATION 当作 MVP 现实。
|
||||
- “开始采购”与一次性授权、批量原子性、价格闸门、提交围栏和一次点击的关系明确。
|
||||
- API/状态机/目标数据模型/任务路线图一致;当前旧 schema 的迁移由 T-209 显式前置。
|
||||
- 截图为内部证据而非审批前置;T-103 新边界继续禁止数量、确认页和提交。
|
||||
- Vikunja 导出、agent-context、HTML/JS 静态检查、diff-check 与完整双端门禁通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T08:24:12Z · ila
|
||||
|
||||
2026-08-04 T-111 主审完成:已把 AGENTS、入口、愿景、需求、架构、编码规则、API、路线图、用户故事、交互、路由、当前状态及四个两端 HTML 原型统一为“管理员点击开始采购即授权创建待付款订单”的单趟流程。保留三道价格闸门、提交四条件、服务端原子围栏、首次许可后只点一次和绝不付款;删除 WAITING_CONFIRMATION / PENDING_RETRIAL / AUTHORIZED 等旧 MVP 状态。审计发现生产 v1 schema 仍为旧 spec_trials/锁定试选价模型,因此路线图新增 T-209,先迁移 schema/领域状态机,再允许 T-203 实现新授权事务。
|
||||
|
||||
独立验证通过:四个 HTML 由 Python HTMLParser 解析;四个 JavaScript 由 Node new Function 解析;桌面 18 个 scenario/options/sessionControls 一致;web 关键旧状态不存在;Vikunja T-103/T-111 export check;validate_agent_context;git diff --check;完整 init.ps1(admin test/vet/build,client 80 tests/compileall,包安装与上下文门禁)。未连接真机、未触发采购/提交/付款;未触碰用户 docs/ppt。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 管理员点击“开始采购(只创建待付款订单)”是进入设备队列前唯一的人类授权动作;创建 `DRAFT`
|
||||
本身不得触发设备领取、创建授权、提交订单或付款。
|
||||
- 单趟流程仍必须保留规格精确匹配、数量读回复核、规格面板两次同价校验、订单确认页金额上限、
|
||||
服务端提交围栏、提交控件唯一和点击一次;不得以“提速”为由删除或弱化任一项。
|
||||
- 系统只创建待付款订单,任何路径都不自动付款,不点击支付、免密支付、先用后付或扣款控件。
|
||||
- 围栏前失败或授权过期可以回到 `DRAFT` 等管理员重新授权;围栏建立后只能调和同一提交记录,
|
||||
不得释放、重领、重试点击或签发第二笔。
|
||||
- 本任务只修改文档契约和任务依赖,不修改 `admin/`、`client/`、数据库 migration 或生产代码。
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
id: T-201
|
||||
title: 管理员登录与会话
|
||||
phase: 2
|
||||
deps: [T-004, T-005]
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 26
|
||||
context_ref: 80ed9b7
|
||||
work_branch: task/t-201-admin-session
|
||||
needs_device: false
|
||||
needs_human_review: false
|
||||
write_paths:
|
||||
- docs/tasks/T-201.md
|
||||
- admin/cmd/server/main.go
|
||||
- admin/internal/config/**
|
||||
- admin/internal/server/**
|
||||
- admin/internal/auth/**
|
||||
- admin/internal/transport/webui/**
|
||||
- admin/go.mod
|
||||
- admin/go.sum
|
||||
- admin/README.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=26 synced=2026-08-04T07:04:10Z sha256=25607da2b9c35781f56fdea1f399bdfb65b76fa9a5ca8210693714793f5ef5f4 -->
|
||||
## 问题 / 背景
|
||||
|
||||
采购服务已有 Go/Gin/SQLite 骨架和核心模型,但没有管理员会话。T-201 与真机规格字段无关,可在 T-103 进行时并行。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
F-013、US-007;路由 GET/POST /login、POST /logout、受保护的 GET /tasks 空壳;沿用已确认采购服务原型。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 启动时从环境变量读取管理员用户名、bcrypt 密码哈希、至少 32 字节 session secret;缺失或无效时明确失败,不提供默认凭据,不打印秘密。
|
||||
2. 使用签名会话和 CSRF;登录成功轮换会话,Cookie 为 HttpOnly、SameSite,Secure 由显式配置控制。
|
||||
3. return_to 仅接受 /tasks 及其子路径,拒绝绝对 URL、双斜杠、反斜杠和其他站内路径。
|
||||
4. 实现登录、登出与受保护的任务空壳;/healthz 保持公开。
|
||||
5. 不改数据库 schema,不实现设备 Bearer、建单、试选、授权、提交或付款。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 覆盖未登录跳转、成功登录与会话轮换、统一失败错误、CSRF 拒绝、登出失效和开放重定向拦截。
|
||||
- 无默认账号/密码/密钥,日志与响应不泄露凭据。
|
||||
- go test ./...、go vet ./...、go build ./...、上下文校验与 diff-check 通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T06:58:30Z · ila
|
||||
|
||||
2026-08-04 T-201 实现与主审完成:提交 47c0844 新增强制环境配置、bcrypt 校验、HMAC-SHA256 签名进程内 session、随机 CSRF、登录轮换、登出撤销、受保护 /tasks 空壳和无外部资源的 SSR 登录页。主 agent 首轮退回 /tasks/.. 返回路径绕过、含 CSRF 页面缺安全响应头、Cookie 篡改测试概率失效三项;修正后独立复跑 go test ./...、go test -race ./...、go vet ./...、go build ./...、上下文校验与 diff-check,全部通过。实现未包含建单、试选、真机字段、授权、提交或付款;T-201 验收通过。
|
||||
|
||||
### 2026-08-04T07:03:54Z · ila
|
||||
|
||||
集成门禁补充:T-201 分支已合入 main@88d8f77(含 T-103 最新安全诊断),随后在独立 Windows worktree 运行完整 init.ps1,admin test/vet/build、client 78 项测试/compileall、editable install 与 agent-context 校验全部通过。首次运行因 120 秒工具超时中断在大体积 PySide6 依赖安装,未产生代码或证据问题;复用已建 venv 后完整成功。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 启动配置只接受显式环境变量中的管理员用户名、bcrypt 密码哈希和至少 32 字节 session secret;
|
||||
不得内置开发默认值、明文密码或密钥,不得打印、回显或提交凭据。
|
||||
- 本任务只实现 `/login`、`/logout`、受保护的 `/tasks` 空壳和保持公开的 `/healthz`;不实现建单、
|
||||
查询、批量开始试选、设备 Bearer、真机自动化或任务状态流转。
|
||||
- 不增加或修改数据库 schema,不读写 `spec_trials`、授权或 `order_submissions`,不实现任何提交订单、
|
||||
付款、免密支付、先用后付或资金控件代码。
|
||||
- 页面和测试不得夹带机器实际规格、规格面板价格、截图、证据哈希或任何 PDD 页面判据;这些仍等待
|
||||
T-103 真机结论。
|
||||
- `return_to` 必须是 `/tasks` 或其子路径;绝对 URL、`//`、反斜杠、编码绕过和其他站内路径均拒绝。
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
id: T-202
|
||||
title: 手工建单与 DRAFT 基础列表
|
||||
phase: 2
|
||||
deps: [T-201, T-004, T-005]
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 27
|
||||
context_ref: 1c35155
|
||||
work_branch: task/t-202-admin-draft
|
||||
needs_device: false
|
||||
needs_human_review: false
|
||||
write_paths:
|
||||
- docs/tasks/T-202.md
|
||||
- admin/cmd/server/main.go
|
||||
- admin/internal/config/**
|
||||
- admin/internal/server/**
|
||||
- admin/internal/tasks/**
|
||||
- admin/internal/storage/sqlite/**
|
||||
- admin/internal/transport/webui/**
|
||||
- admin/README.md
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=27 synced=2026-08-04T08:32:16Z sha256=39e3b06bab4ca86e97b961a4eb6bb0a4f1e88b29dae4d50f916779f7e761424c -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-201 已提供管理员会话;T-004 已提供 tasks 表。根据 T-010 加速门禁,T-103 尚未完成时只允许实现不启动试选的 DRAFT 手工建单与基础列表。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
F-001、US-001、IX-002;GET /tasks、GET /tasks/new、POST /tasks;沿用已确认的传统表格与创建弹窗/直达页。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 显式数据库配置并打开已迁移 SQLite;以仓储接口隔离 HTTP 和 SQL,创建事务只写 MANUAL、DRAFT、version=1。
|
||||
2. 表单校验任务名称、canonical 拼多多链接、颜色分类、尺码、正整数数量和正十进制总额上限;金额只用字符串并规范为两位小数。链接只接受 HTTPS mobile.yangkeduo.com/goods.html 且 goods_id 为唯一纯数字参数,额外查询参数不进入数据库。
|
||||
3. 以服务端生成的 create_key 同时作为任务 ID;重复相同 key 和相同内容返回原结果,不创建第二条,内容不同则冲突。
|
||||
4. GET /tasks 默认 created_at DESC 显示 DRAFT 基础表格;创建入口用服务端渲染的 modal 状态,/tasks/new 复用同一表单作为无脚本兜底;失败保留非密码输入并显示字段错误,成功 303 回列表且新任务第一行。
|
||||
5. 页面只显示需求字段、采购结果占位、DRAFT 状态与创建时间;不读取或伪造规格面板价格/证据,不提供勾选开始试选、状态推进、详情或设备接口。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 覆盖创建成功、倒序第一行、严格链接/goods_id、数量、金额、空白/长度、CSRF/未登录、幂等重放与冲突、SQL 错误 fail closed。
|
||||
- 弹窗与 /tasks/new 共享校验;错误保留输入并可访问;标题只链接到由 goods_id 重建的 canonical PDD URL并使用安全新标签属性。
|
||||
- go test ./...、go test -race ./...、go vet ./...、go build ./...、完整 init.ps1、上下文校验和 diff-check 通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-08-04T08:30:47Z · ila
|
||||
|
||||
已完成:DRAFT 手工建单与基础列表;已验证链接、金额、CSRF、幂等、SQLite 并发和 SSR 无障碍,Go 与上下文门禁均通过。
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 本任务只创建 `source=MANUAL`、`status=DRAFT`、`version=1` 的任务并显示 DRAFT 基础列表;不得
|
||||
实现勾选、批量开始试选、`DRAFT → PENDING` 或任何其他状态流转,也不得新增设备领取接口。
|
||||
- 不增加或修改数据库 schema,不读写 `spec_trials`、`order_authorizations`、`order_submissions`,
|
||||
不生成或展示机器实际规格、规格面板单价、截图、证据哈希或 PDD 页面判据。
|
||||
- 启动服务必须从显式 `CMBUYER_DATABASE_SOURCE` 读取 SQLite data source;缺失时明确失败,不提供
|
||||
隐式内存库或仓库内默认数据库。服务不自动猜迁移目录;README 必须先给出显式迁移命令。
|
||||
- 商品链接只接受 `https://mobile.yangkeduo.com/goods.html`,且必须恰有一个纯数字 `goods_id`;
|
||||
拒绝 userinfo、端口、fragment、重复参数、其他 host/scheme/path 和编码绕过。数据库只保存 goods_id,
|
||||
展示链接由 goods_id 重建 canonical URL;`uin` 等额外查询参数既不保存也不回显。
|
||||
- 标题、颜色分类、尺码必须去除首尾空白后非空并受明确长度上限约束;数量必须是可表示的正整数;
|
||||
总额上限必须是大于零、最多两位小数的十进制字符串并规范为两位小数。金额校验、保存与展示均不得
|
||||
使用浮点数或从其他数字推测。
|
||||
- `create_key` 由服务端用 `crypto/rand` 生成并验证格式,同时作为任务 ID;相同 key 与相同规范化内容
|
||||
重放只能返回原任务,不得二次 INSERT,相同 key 携带不同内容必须冲突。SQL 必须参数化,创建失败
|
||||
不得留下半条或未知状态记录。
|
||||
- `GET /tasks`、`GET /tasks/new`、`POST /tasks` 都必须复用 T-201 管理会话;POST 必须验证 CSRF。
|
||||
校验失败保留非敏感输入并逐字段提示,数据库内部错误只给通用响应,不泄露 SQL、路径或凭据。
|
||||
- 页面只使用服务端模板转义;标题商品链接在新标签打开时必须带 `noopener noreferrer`。导入按钮只作
|
||||
禁用占位;不得加载外部资源或把原型假数据、真机数据、地址、手机号带进生产页面。
|
||||
- 不实现或引用试选、数量设置、订单确认、提交围栏、提交订单、付款、免密支付或先用后付能力。
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
id: T-209
|
||||
title: 把核心 schema / 状态机迁移为单趟模型
|
||||
phase: 2
|
||||
deps: [T-004, T-111]
|
||||
status: DONE
|
||||
created: 2026-08-04
|
||||
vikunja_task_id: 29
|
||||
context_ref: da540bf
|
||||
work_branch: task/t-209-single-pass-schema
|
||||
needs_device: false
|
||||
needs_human_review: false
|
||||
write_paths:
|
||||
- docs/tasks/T-209.md
|
||||
- admin/migrations/**
|
||||
- admin/internal/migrations/**
|
||||
- admin/internal/domain/**
|
||||
---
|
||||
|
||||
<!-- BEGIN VIKUNJA EXPORT id=29 synced=2026-08-04T08:38:26Z sha256=2cac52971eda168b1d17bc4b6930f97876324d5685af26dc26495de07b44cb7d -->
|
||||
## 问题 / 背景
|
||||
|
||||
T-111 已把业务契约改为管理员点击“开始采购”即授权桌面端在同一趟创建待付款订单;现有 SQLite v1 与领域模型仍是旧两趟结构,包含 spec_trials、authorized_unit_price 及 WAITING_CONFIRMATION/PENDING_RETRIAL/AUTHORIZED/RUNNING 等旧状态。T-203 不能在旧结构上继续实现。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
T-111;F-004、F-005、F-008、F-017、F-018;docs/04-architecture.md 第四、五节;不包含页面选择器或真机操作。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 保留 00001_core_data.sql 作为不可变迁移历史,新增 00002 单趟模型迁移;迁移只允许保留既有 MANUAL+DRAFT 任务,发现任一旧 spec_trials/authorization/submission 数据或非 DRAFT 任务即整体失败并回滚,不能猜测映射。
|
||||
2. 重建 tasks 的状态约束为 DRAFT、PENDING、CLAIMED、ORDERING、NEEDS_MANUAL、WAITING_PAYMENT、RECONCILIATION_REQUIRED、SUCCEEDED、FAILED、CANCELED;保留 DRAFT 内容、版本与时间。
|
||||
3. 删除旧 spec_trials 结构;重建 order_authorizations,锁定 task_version/start_key/goods_id/颜色/尺码/数量/total_price_cap,状态仅 ACTIVE/CLAIMED/FENCED/CONSUMED/EXPIRED/ABANDONED,不保存 observed/authorized unit price。
|
||||
4. 新增 purchase_attempts 保存 claim generation、三闸门摘要和固定 failure code;重建 order_submissions,原子关联同一 task/authorization/attempt,保存 gate1、gate2、quantity、confirm amount,并限制一份授权/attempt 最多一条围栏。
|
||||
5. 同步 Go 领域实体和 fail-closed 状态机;围栏前允许安全失败/重置,FENCED 后授权只能 CONSUMED,提交结果不明只能调和同一记录,任何未列出转移拒绝。
|
||||
6. Down 迁移同样只在没有新业务执行数据且任务仍可无损回退时执行,否则失败并保持 v2;覆盖 up/down、幂等、DRAFT 保留、未知旧数据回滚、外键/唯一约束、金额 TEXT、状态转移与旧标识符消失测试。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- 新 migration 不修改 00001;从空 v1 和仅含 DRAFT 的 v1 升级成功,DRAFT 字段逐项不变。
|
||||
- 任一旧执行/授权/提交记录或非 DRAFT 状态都使升级失败,版本和原数据保持 v1;没有半迁移。
|
||||
- 新 schema 无 spec_trials、authorized_unit_price、spec_trial_id、command_id、dry_run_id;包含 purchase_attempts 与架构规定的关系、唯一性、金额字符串和状态 CHECK。
|
||||
- 领域模型不再暴露旧两趟状态;未知状态或未列出的转移全部失败。第一趟试选/下单函数均不在本任务范围。
|
||||
- go test ./...、go test -race ./...、go vet ./...、go build ./...、完整 init.ps1、上下文校验和 diff-check 通过。
|
||||
|
||||
## 执行记录
|
||||
|
||||
(暂无)
|
||||
<!-- END VIKUNJA EXPORT -->
|
||||
|
||||
## 边界
|
||||
|
||||
- 本任务只迁移数据结构和纯领域状态机,不实现管理员“开始采购”HTTP/usecase、设备领取、租约、
|
||||
attempt 写入 API、截图上传、提交围栏 API、页面自动化或任何真机动作;这些能力仍分别属于
|
||||
T-203、T-205、T-208 与 Phase 3/4 任务。
|
||||
- 不修改已经发布的 `00001_core_data.sql`;只能追加 `00002`。Up/Down 都必须置于单个事务,前置
|
||||
检查失败时版本、schema 和数据原样保留,不能删除、转换或猜测任何旧执行记录。
|
||||
- Up 只允许空库或仅含可无损保留的 `MANUAL + DRAFT` 任务。任一 `spec_trials`、旧
|
||||
`order_authorizations`、旧 `order_submissions` 数据,或任一非 DRAFT / 非 MANUAL 任务均拒绝升级。
|
||||
- Down 只允许没有授权、attempt、submission 且全部任务都能无损回到 v1 DRAFT 的 v2 数据库;否则
|
||||
拒绝回退。迁移测试不得为通过而临时关闭外键后漏恢复,也不得留下临时表或 guard 表。
|
||||
- 金额继续只用严格正十进制 `TEXT`;不得引入浮点数。新授权只锁定 `total_price_cap`,不得重新加入
|
||||
`authorized_unit_price`、观察价格或 `spec_trial_id`。
|
||||
- `FENCED` 授权不得回到可领取、可过期或可放弃状态;围栏后的提交只能记录明确提交或进入同一记录
|
||||
调和,不能提供重试、释放或第二次点击的状态转移。
|
||||
- 本任务不实现、不引用点击“提交订单”的函数,更不涉及支付、免密支付、先用后付或任何扣款动作。
|
||||
Reference in New Issue
Block a user