Compare commits

...
Author SHA1 Message Date
QiuSW e04f05b20b feat(admin): migrate core schema to single-pass model 2026-08-04 17:01:35 +08:00
QiuSW 64a7468cab merge: T-202 draft task creation 2026-08-04 16:38:36 +08:00
QiuSW 442ab88fd7 docs(tasks): define T-209 single-pass schema migration 2026-08-04 16:38:29 +08:00
QiuSW d38cfb61af feat(admin): add draft task creation 2026-08-04 16:33:22 +08:00
QiuSW da540bfdf6 merge: authorized single-pass purchase contract 2026-08-04 16:25:42 +08:00
QiuSW cfd5440ac0 docs(architecture): adopt authorized single-pass purchase 2026-08-04 16:25:34 +08:00
QiuSW 8ba9b231f4 docs(tasks): start single-pass purchase redesign 2026-08-04 15:53:16 +08:00
QiuSW efeb2d958a docs(architecture): allow internal raw evidence screenshots 2026-08-04 15:47:47 +08:00
QiuSW 549099ad24 merge: T-103 observed price prefix 2026-08-04 15:23:10 +08:00
QiuSW 76a522f13f fix(client): bind T-103 observed price prefix 2026-08-04 15:23:02 +08:00
QiuSW 5088d544f2 merge: T-103 refined prefix diagnostics 2026-08-04 15:16:44 +08:00
QiuSW 6e001b2729 fix(client): refine T-103 prefix diagnostics 2026-08-04 15:16:30 +08:00
QiuSW cea27ff7ef docs(tasks): define T-202 draft creation 2026-08-04 15:08:13 +08:00
QiuSW 1c35155c2d merge: T-201 administrator sessions 2026-08-04 15:04:38 +08:00
QiuSW babb530b99 docs(tasks): complete T-201 admin session 2026-08-04 15:04:25 +08:00
QiuSW 55d9e09bda merge: main into T-201 2026-08-04 14:59:28 +08:00
QiuSW 47c0844f9c feat(admin): add administrator sessions 2026-08-04 14:57:40 +08:00
QiuSW 88d8f77417 merge: T-103 safe price diagnostics 2026-08-04 14:56:50 +08:00
QiuSW f7efa4a161 docs(tasks): record T-103 safe diagnostics 2026-08-04 14:56:41 +08:00
QiuSW ef1ac60ac5 fix(client): diagnose T-103 price text safely 2026-08-04 14:55:14 +08:00
QiuSW 5332d67b5e merge: T-103 sanitizer v4 2026-08-04 14:48:44 +08:00
QiuSW 5f6b3ce01a docs(tasks): record T-103 sanitizer v4 2026-08-04 14:48:36 +08:00
QiuSW 824a628733 Merge branch 'main' into task/t-103-sku-panel 2026-08-04 14:42:57 +08:00
QiuSW 1e69d274b8 feat(client): project sanitized SKU price candidates 2026-08-04 14:42:49 +08:00
QiuSW 8ee26be95a docs(tasks): define T-201 admin session 2026-08-04 14:40:59 +08:00
QiuSW a45a1b5afc docs(tasks): review first T-103 derived evidence 2026-08-04 14:20:23 +08:00
54 changed files with 4775 additions and 2171 deletions
+7 -4
View File
@@ -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. 安全边界只能收紧 ### 2. 安全边界只能收紧
@@ -141,8 +144,8 @@ python scripts/validate_agent_context.py
**跨端契约改动必跑完整门禁**——两端会同时坏。 **跨端契约改动必跑完整门禁**——两端会同时坏。
代码尚未初始化,上述命令在 T-001 / T-002 完成前不可运行;届时由对应任务替换为真实命令 两端已初始化;跨端契约改动还需在仓库根运行 `./init.ps1`(或 `./init.sh`)完成安装、测试、
并同步文档。 vet/build、compileall 和上下文门禁。
## 风格 ## 风格
+12 -20
View File
@@ -8,13 +8,14 @@
## 它做什么 ## 它做什么
一笔外部订单进来,采购人员需要去拼多多找到同款、选对颜色尺码、下单、把订单号抄回系统。 一笔外部订单进来,采购人员需要去拼多多找到同款、选对颜色尺码、下单、把订单号抄回系统。
cmbuyer 把这个过程自动化,人只在两个点介入:**机器选对了吗**和**付不付款**。 cmbuyer 把这个过程自动化:管理员明确“买什么、买多少、最多多少钱”并点击开始采购,系统只
创建待付款订单,**是否付款始终由人决定**。
```text ```text
手工填链接(MVP) / Excel · ERP(V2) 手工填链接(MVP) / Excel · ERP(V2)
│ │
v v
采购服务(admin/,Go) 建单 · 试选确认 · 下单授权 · 审计 采购服务(admin/,Go) 建单 · 开始采购授权 · 围栏 · 审计
│ HTTP │ HTTP
v v
采购工具(client/,Python) 领任务 · 跑流程 · 回传 采购工具(client/,Python) 领任务 · 跑流程 · 回传
@@ -23,31 +24,22 @@ cmbuyer 把这个过程自动化,人只在两个点介入:**机器选对了
Android 手机(拼多多 App) Android 手机(拼多多 App)
``` ```
## 两趟执行 ## 单趟执行
MVP 只做**任务自带商品链接**的情形,分两趟跑完: MVP 只做**任务自带商品链接**的情形:管理员先把任务保存为 `DRAFT`,再在表格中勾选并点击
“开始采购(只创建待付款订单)”。该点击创建一次性授权,锁定商品、颜色、尺码、数量和最高总价。
| 趟次 | 做什么 | 采购工具领取后在同一次设备会话中完成:打开商品 → 精确选择规格 → 闸门一读 SKU 单价并校验
| --- | --- | 上限 → 设置并复核数量 → 闸门二重读规格与同价 → 进入确认页 → 闸门三校验应付总额 → 服务端
| **第一趟 · 试选** | 开商品 → 精确勾选颜色分类和尺码 → 读单价 → 截图 → **退出释放手机** → 回传 | 原子建立提交围栏 → 精确点击一次“提交订单” → 转待付款。中间不再回网页端等“机器选对了吗”。
| **人工确认** | 人在网页端看「机器选对了吗」→ 确认并**锁定单价** |
| **第二趟 · 下单** | 重新开商品 → 重新选同一规格 → **三道价格闸门** → 提交订单一次 → 转「待付款」 |
为什么分两趟:一台手机是瓶颈,不能停在规格面板上等人。代价是走两遍,换来手机不空闲,
且第二趟能抓住价格变动。
**三道价格闸门**:① 第一趟规格面板读价 ② 第二趟重读必须与授权价一致
③ 订单确认页「实付款」不超上限。任一道读不到或不通过即停,转人工。
价格**只在规格面板和订单确认页读**——别处的价格文本被拆成多个节点、带券后前缀、 价格**只在规格面板和订单确认页读**——别处的价格文本被拆成多个节点、带券后前缀、
实付价与原价混在一起,不可靠。 实付价与原价混在一起,不可靠。
## 现在处于什么阶段 ## 现在处于什么阶段
**Phase 0 · 地基。仓库目前只有文档,尚未开始编码。** **Phase 1 真机取证与 Phase 2 的安全服务端工作并行。** 两端骨架、基础模型、登录和真机基线已
落地;T-111 正在冻结单趟采购契约,T-103 随后继续规格精确选择与读价,T-202 等待主审合入。
下一步:T-005(网页端 MVP 原型)与 T-006(桌面端 MVP 原型)的文件和自动检查已完成,
请先人工确认 6 个 HTML 原型,再进入 T-001 / T-002 骨架和生产实现。
详见 [`docs/current-state.md`](docs/current-state.md)。 详见 [`docs/current-state.md`](docs/current-state.md)。
**生死线是 M2**:真机能按链接打开商品、精确勾选颜色分类和尺码、**读到该 SKU 单价**。 **生死线是 M2**:真机能按链接打开商品、精确勾选颜色分类和尺码、**读到该 SKU 单价**。
@@ -74,7 +66,7 @@ MVP 只做**任务自带商品链接**的情形,分两趟跑完:
4. 规格按维度精确匹配,防前缀碰撞,找不到即停 4. 规格按维度精确匹配,防前缀碰撞,找不到即停
5. 数量设置后必须读回复核 5. 数量设置后必须读回复核
6. **三道价格闸门**,任一道读不到或不通过即停 6. **三道价格闸门**,任一道读不到或不通过即停
7. **第一趟绝不下单**——试选路径不得引用下单函数 7. **能力分层**——T-103 规格验证路径不得引用数量、确认页或下单函数;后续能力逐段取证
8. 检测到外部支付交接立即停止,不读取不保存凭据 8. 检测到外部支付交接立即停止,不读取不保存凭据
9. 检测到验证码 / 风控 / 人脸 / 短信校验立即停止,不绕过 9. 检测到验证码 / 风控 / 人脸 / 短信校验立即停止,不绕过
10. 只读非敏感摘要,不提取收货地址原文、手机号、支付凭据 10. 只读非敏感摘要,不提取收货地址原文、手机号、支付凭据
+25
View File
@@ -0,0 +1,25 @@
# 采购服务
启动前必须显式设置下列环境变量;服务不提供默认管理员账号、密码或会话密钥。
| 变量 | 要求 |
| --- | --- |
| `CMBUYER_ADMIN_USERNAME` | 非空管理员账号。 |
| `CMBUYER_ADMIN_PASSWORD_BCRYPT` | 非空 bcrypt 密码哈希,不接受明文密码。 |
| `CMBUYER_SESSION_SECRET` | 至少 32 字节的会话签名密钥。 |
| `CMBUYER_COOKIE_SECURE` | 可选;存在时只能精确为 `true` 或 `false`。HTTPS 部署应设为 `true`。 |
| `CMBUYER_DATABASE_SOURCE` | 已迁移 SQLite 的显式 data source。 |
示例仅展示变量名,不提供可运行凭据:
```powershell
$env:CMBUYER_ADMIN_USERNAME = '<管理员账号>'
$env:CMBUYER_ADMIN_PASSWORD_BCRYPT = '<bcrypt 密码哈希>'
$env:CMBUYER_SESSION_SECRET = '<至少 32 字节的随机密钥>'
$env:CMBUYER_COOKIE_SECURE = 'true'
$env:CMBUYER_DATABASE_SOURCE = '<SQLite data source>'
go run ./cmd/migrate -database $env:CMBUYER_DATABASE_SOURCE up
go run ./cmd/server
```
采购服务会话仅保存在当前进程内;进程重启后既有登录会话会安全失效。
+29 -1
View File
@@ -5,7 +5,11 @@ import (
"log" "log"
"net/http" "net/http"
"cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/config"
"cmbuyer/admin/internal/server" "cmbuyer/admin/internal/server"
"cmbuyer/admin/internal/storage/sqlite"
"cmbuyer/admin/internal/tasks"
) )
const listenAddress = ":8080" const listenAddress = ":8080"
@@ -17,7 +21,31 @@ func main() {
} }
func run() error { 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) { if errors.Is(err, http.ErrServerClosed) {
return nil return nil
} }
+1 -1
View File
@@ -6,6 +6,7 @@ require (
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.11.0
github.com/mattn/go-sqlite3 v1.14.49 github.com/mattn/go-sqlite3 v1.14.49
github.com/pressly/goose/v3 v3.24.0 github.com/pressly/goose/v3 v3.24.0
golang.org/x/crypto v0.40.0
) )
require ( require (
@@ -35,7 +36,6 @@ require (
go.uber.org/mock v0.5.0 // indirect go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.20.0 // indirect golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.25.0 // indirect golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect golang.org/x/sync v0.16.0 // indirect
+198
View File
@@ -0,0 +1,198 @@
// Package auth 提供内存会话与 CSRF 防护。会话不落库,服务重启会安全地使所有登录失效。
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
const (
CookieName = "cmbuyer_session"
SessionLifetime = 8 * time.Hour
csrfTokenByteSize = 32
)
type session struct {
csrfToken string
authenticated bool
expiresAt time.Time
}
// Manager 签发、验证并撤销进程内会话。cookie 仅承载经过 HMAC 签名的随机 session ID。
type Manager struct {
secret []byte
cookieSecure bool
now func() time.Time
random io.Reader
mu sync.Mutex
sessions map[string]session
}
// NewManager 创建会话管理器。secret 在启动时已由 config 验证为足够长度。
func NewManager(secret []byte, cookieSecure bool) *Manager {
return &Manager{
secret: append([]byte(nil), secret...),
cookieSecure: cookieSecure,
now: time.Now,
random: rand.Reader,
sessions: make(map[string]session),
}
}
// Ensure 返回当前有效会话;不存在或过期时签发匿名会话,以保护登录表单本身的 POST。
func (manager *Manager) Ensure(writer http.ResponseWriter, request *http.Request) (csrfToken string, authenticated bool) {
if id, current, ok := manager.current(request); ok {
return current.csrfToken, current.authenticated
} else if id != "" {
manager.delete(id)
}
id, current := manager.create(false)
manager.writeCookie(writer, id, current.expiresAt)
return current.csrfToken, false
}
// VerifyCSRF 只接受当前未过期会话中以恒定时间比较匹配的 token。
func (manager *Manager) VerifyCSRF(request *http.Request, token string) (authenticated bool, ok bool) {
_, current, found := manager.current(request)
if !found || token == "" {
return false, false
}
if subtle.ConstantTimeCompare([]byte(current.csrfToken), []byte(token)) != 1 {
return false, false
}
return current.authenticated, true
}
// RotateAuthenticated 在登录成功后撤销旧会话并签发全新认证会话,避免 session fixation 与 CSRF 复用。
func (manager *Manager) RotateAuthenticated(writer http.ResponseWriter, request *http.Request) string {
if id, _, ok := manager.current(request); ok {
manager.delete(id)
}
id, current := manager.create(true)
manager.writeCookie(writer, id, current.expiresAt)
return current.csrfToken
}
// Logout 撤销当前会话并立即清除浏览器 cookie。
func (manager *Manager) Logout(writer http.ResponseWriter, request *http.Request) {
if id, _, ok := manager.current(request); ok {
manager.delete(id)
}
http.SetCookie(writer, &http.Cookie{
Name: CookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: manager.cookieSecure,
SameSite: http.SameSiteLaxMode,
})
}
func (manager *Manager) current(request *http.Request) (string, session, bool) {
cookie, err := request.Cookie(CookieName)
if err != nil {
return "", session{}, false
}
id, expiresAt, ok := manager.verifyCookie(cookie.Value)
if !ok || !manager.now().Before(expiresAt) {
return id, session{}, false
}
manager.mu.Lock()
defer manager.mu.Unlock()
current, found := manager.sessions[id]
if !found || !manager.now().Before(current.expiresAt) {
return id, session{}, false
}
return id, current, true
}
func (manager *Manager) create(authenticated bool) (string, session) {
id := manager.randomToken()
current := session{
csrfToken: manager.randomToken(),
authenticated: authenticated,
expiresAt: manager.now().Add(SessionLifetime),
}
manager.mu.Lock()
manager.sessions[id] = current
manager.mu.Unlock()
return id, current
}
func (manager *Manager) delete(id string) {
manager.mu.Lock()
delete(manager.sessions, id)
manager.mu.Unlock()
}
func (manager *Manager) randomToken() string {
bytes := make([]byte, csrfTokenByteSize)
if _, err := io.ReadFull(manager.random, bytes); err != nil {
panic("crypto/rand failed while creating a session token")
}
return base64.RawURLEncoding.EncodeToString(bytes)
}
func (manager *Manager) writeCookie(writer http.ResponseWriter, id string, expiresAt time.Time) {
http.SetCookie(writer, &http.Cookie{
Name: CookieName,
Value: manager.signCookie(id, expiresAt),
Path: "/",
MaxAge: int(expiresAt.Sub(manager.now()).Seconds()),
Expires: expiresAt,
HttpOnly: true,
Secure: manager.cookieSecure,
SameSite: http.SameSiteLaxMode,
})
}
func (manager *Manager) signCookie(id string, expiresAt time.Time) string {
payload := id + "." + strconv.FormatInt(expiresAt.Unix(), 10)
mac := hmac.New(sha256.New, manager.secret)
_, _ = mac.Write([]byte(payload))
return payload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (manager *Manager) verifyCookie(value string) (string, time.Time, bool) {
parts := strings.Split(value, ".")
if len(parts) != 3 || parts[0] == "" {
return "", time.Time{}, false
}
expiresUnix, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return "", time.Time{}, false
}
provided, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return "", time.Time{}, false
}
payload := parts[0] + "." + parts[1]
mac := hmac.New(sha256.New, manager.secret)
_, _ = mac.Write([]byte(payload))
if !hmac.Equal(provided, mac.Sum(nil)) {
return "", time.Time{}, false
}
return parts[0], time.Unix(expiresUnix, 0), true
}
+49
View File
@@ -0,0 +1,49 @@
package auth
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestManagerRejectsTamperedAndExpiredCookies(t *testing.T) {
manager := NewManager([]byte(strings.Repeat("s", 32)), true)
request := httptest.NewRequest(http.MethodGet, "/login", nil)
response := httptest.NewRecorder()
csrf, authenticated := manager.Ensure(response, request)
if csrf == "" || authenticated {
t.Fatalf("Ensure = (%q, %t), want anonymous CSRF session", csrf, authenticated)
}
cookie := response.Result().Cookies()[0]
if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteLaxMode || cookie.Path != "/" {
t.Fatalf("session cookie is missing security attributes: %#v", cookie)
}
tampered := *cookie
tampered.Value = flipCookieValue(t, cookie.Value)
tamperedRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
tamperedRequest.AddCookie(&tampered)
if _, ok := manager.VerifyCSRF(tamperedRequest, csrf); ok {
t.Fatal("tampered signed cookie passed CSRF verification")
}
manager.now = func() time.Time { return time.Now().Add(9 * time.Hour) }
expiredRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
expiredRequest.AddCookie(cookie)
if _, ok := manager.VerifyCSRF(expiredRequest, csrf); ok {
t.Fatal("expired cookie passed CSRF verification")
}
}
func flipCookieValue(t *testing.T, value string) string {
t.Helper()
if value == "" {
t.Fatal("cannot tamper with an empty cookie")
}
if value[0] == 'A' {
return "B" + value[1:]
}
return "A" + value[1:]
}
+91
View File
@@ -0,0 +1,91 @@
// Package config 读取采购服务的启动配置。凭据只允许来自显式环境变量,避免把秘密写入代码或仓库。
package config
import (
"errors"
"fmt"
"os"
"strings"
"golang.org/x/crypto/bcrypt"
)
const (
adminUsernameEnv = "CMBUYER_ADMIN_USERNAME"
adminPasswordBcryptEnv = "CMBUYER_ADMIN_PASSWORD_BCRYPT"
sessionSecretEnv = "CMBUYER_SESSION_SECRET"
cookieSecureEnv = "CMBUYER_COOKIE_SECURE"
databaseSourceEnv = "CMBUYER_DATABASE_SOURCE"
minimumSecretLength = 32
)
// Config 是启动采购服务所需的最小安全配置。
type Config struct {
AdminUsername string
AdminPasswordBcrypt string
SessionSecret []byte
CookieSecure bool
DatabaseSource string
}
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
func LoadFromEnv() (Config, error) {
return Load(os.LookupEnv)
}
// Load 使用 lookup 读取配置,以便在不污染进程环境的情况下测试启动边界。
func Load(lookup func(string) (string, bool)) (Config, error) {
username, err := required(lookup, adminUsernameEnv)
if err != nil {
return Config{}, err
}
passwordHash, err := required(lookup, adminPasswordBcryptEnv)
if err != nil {
return Config{}, err
}
if _, err := bcrypt.Cost([]byte(passwordHash)); err != nil {
return Config{}, fmt.Errorf("%s is not a valid bcrypt hash", adminPasswordBcryptEnv)
}
secret, err := required(lookup, sessionSecretEnv)
if err != nil {
return Config{}, err
}
if len([]byte(secret)) < minimumSecretLength {
return Config{}, fmt.Errorf("%s must be at least %d bytes", sessionSecretEnv, minimumSecretLength)
}
cookieSecure := false
if value, present := lookup(cookieSecureEnv); present {
switch value {
case "true":
cookieSecure = true
case "false":
cookieSecure = false
default:
return Config{}, fmt.Errorf("%s must be exactly true or false", cookieSecureEnv)
}
}
databaseSource, err := required(lookup, databaseSourceEnv)
if err != nil {
return Config{}, err
}
return Config{
AdminUsername: username,
AdminPasswordBcrypt: passwordHash,
SessionSecret: []byte(secret),
CookieSecure: cookieSecure,
DatabaseSource: databaseSource,
}, nil
}
func required(lookup func(string) (string, bool), name string) (string, error) {
value, present := lookup(name)
if !present || strings.TrimSpace(value) == "" {
return "", errors.New(name + " must be set")
}
return value, nil
}
+85
View File
@@ -0,0 +1,85 @@
package config_test
import (
"strings"
"testing"
"cmbuyer/admin/internal/config"
"golang.org/x/crypto/bcrypt"
)
func TestLoad(t *testing.T) {
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
if err != nil {
t.Fatalf("generate bcrypt hash: %v", err)
}
values := map[string]string{
"CMBUYER_ADMIN_USERNAME": "admin",
"CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash),
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
"CMBUYER_COOKIE_SECURE": "true",
"CMBUYER_DATABASE_SOURCE": ":memory:",
}
got, err := config.Load(lookup(values))
if err != nil {
t.Fatalf("Load: %v", err)
}
if got.AdminUsername != "admin" || !got.CookieSecure {
t.Fatalf("Load returned unexpected public configuration: %#v", got)
}
}
func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
if err != nil {
t.Fatalf("generate bcrypt hash: %v", err)
}
base := map[string]string{
"CMBUYER_ADMIN_USERNAME": "admin",
"CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash),
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
"CMBUYER_DATABASE_SOURCE": ":memory:",
}
tests := []struct {
name string
mutate func(map[string]string)
want string
}{
{"missing username", func(values map[string]string) { delete(values, "CMBUYER_ADMIN_USERNAME") }, "CMBUYER_ADMIN_USERNAME"},
{"invalid bcrypt", func(values map[string]string) { values["CMBUYER_ADMIN_PASSWORD_BCRYPT"] = "not-a-bcrypt-hash" }, "CMBUYER_ADMIN_PASSWORD_BCRYPT"},
{"short secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = "short" }, "CMBUYER_SESSION_SECRET"},
{"invalid secure flag", func(values map[string]string) { values["CMBUYER_COOKIE_SECURE"] = "1" }, "CMBUYER_COOKIE_SECURE"},
{"missing database", func(values map[string]string) { delete(values, "CMBUYER_DATABASE_SOURCE") }, "CMBUYER_DATABASE_SOURCE"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
values := copyValues(base)
test.mutate(values)
_, err := config.Load(lookup(values))
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Load error = %v, want mention of %s", err, test.want)
}
})
}
}
func lookup(values map[string]string) func(string) (string, bool) {
return func(key string) (string, bool) {
value, ok := values[key]
return value, ok
}
}
func copyValues(values map[string]string) map[string]string {
copy := make(map[string]string, len(values))
for key, value := range values {
copy[key] = value
}
return copy
}
+27 -41
View File
@@ -10,32 +10,28 @@ var ErrInvalidAuthorizationTransition = errors.New("invalid authorization status
type AuthorizationStatus string type AuthorizationStatus string
const ( const (
AuthorizationStatusPendingDelivery AuthorizationStatus = "PENDING_DELIVERY" AuthorizationStatusActive AuthorizationStatus = "ACTIVE"
AuthorizationStatusDelivered AuthorizationStatus = "DELIVERED" AuthorizationStatusClaimed AuthorizationStatus = "CLAIMED"
AuthorizationStatusAcknowledged AuthorizationStatus = "ACKNOWLEDGED" AuthorizationStatusFenced AuthorizationStatus = "FENCED"
AuthorizationStatusExecuting AuthorizationStatus = "EXECUTING" AuthorizationStatusConsumed AuthorizationStatus = "CONSUMED"
AuthorizationStatusFenced AuthorizationStatus = "FENCED" AuthorizationStatusExpired AuthorizationStatus = "EXPIRED"
AuthorizationStatusConsumed AuthorizationStatus = "CONSUMED" AuthorizationStatusAbandoned AuthorizationStatus = "ABANDONED"
AuthorizationStatusSuperseded AuthorizationStatus = "SUPERSEDED"
AuthorizationStatusExpired AuthorizationStatus = "EXPIRED"
) )
type OrderAuthorization struct { type OrderAuthorization struct {
ID string ID string
TaskID string TaskID string
SpecTrialID string TaskVersion int
Version int StartKey string
GoodsID string GoodsID string
SKUColor string SKUColor string
SKUSize string SKUSize string
Quantity int Quantity int
AuthorizedUnitPrice string TotalPriceCap string
TotalPriceCap string Status AuthorizationStatus
Note *string CreatedBy string
Status AuthorizationStatus CreatedAt time.Time
CreatedBy string ExpiresAt time.Time
CreatedAt time.Time
ExpiresAt time.Time
} }
// CanTransitionTo 围栏后的授权只能消费,不能回到可领取或可过期状态,以防重复采购。 // CanTransitionTo 围栏后的授权只能消费,不能回到可领取或可过期状态,以防重复采购。
@@ -54,25 +50,15 @@ func TransitionAuthorization(current, next AuthorizationStatus) (AuthorizationSt
} }
var authorizationTransitions = map[AuthorizationStatus]map[AuthorizationStatus]struct{}{ var authorizationTransitions = map[AuthorizationStatus]map[AuthorizationStatus]struct{}{
AuthorizationStatusPendingDelivery: { AuthorizationStatusActive: {
AuthorizationStatusDelivered: {}, AuthorizationStatusClaimed: {},
AuthorizationStatusSuperseded: {}, AuthorizationStatusExpired: {},
AuthorizationStatusExpired: {}, AuthorizationStatusAbandoned: {},
}, },
AuthorizationStatusDelivered: { AuthorizationStatusClaimed: {
AuthorizationStatusAcknowledged: {}, AuthorizationStatusFenced: {},
AuthorizationStatusSuperseded: {}, AuthorizationStatusExpired: {},
AuthorizationStatusExpired: {}, AuthorizationStatusAbandoned: {},
},
AuthorizationStatusAcknowledged: {
AuthorizationStatusExecuting: {},
AuthorizationStatusSuperseded: {},
AuthorizationStatusExpired: {},
},
AuthorizationStatusExecuting: {
AuthorizationStatusFenced: {},
AuthorizationStatusSuperseded: {},
AuthorizationStatusExpired: {},
}, },
AuthorizationStatusFenced: { AuthorizationStatusFenced: {
AuthorizationStatusConsumed: {}, AuthorizationStatusConsumed: {},
+9 -11
View File
@@ -14,19 +14,17 @@ func TestAuthorizationTransitions(t *testing.T) {
next domain.AuthorizationStatus next domain.AuthorizationStatus
allowed bool allowed bool
}{ }{
{"deliver", domain.AuthorizationStatusPendingDelivery, domain.AuthorizationStatusDelivered, true}, {"claim", domain.AuthorizationStatusActive, domain.AuthorizationStatusClaimed, true},
{"acknowledge", domain.AuthorizationStatusDelivered, domain.AuthorizationStatusAcknowledged, true}, {"fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusFenced, true},
{"execute", domain.AuthorizationStatusAcknowledged, domain.AuthorizationStatusExecuting, true},
{"fence", domain.AuthorizationStatusExecuting, domain.AuthorizationStatusFenced, true},
{"consume fenced authorization", domain.AuthorizationStatusFenced, domain.AuthorizationStatusConsumed, true}, {"consume fenced authorization", domain.AuthorizationStatusFenced, domain.AuthorizationStatusConsumed, true},
{"expire pending delivery", domain.AuthorizationStatusPendingDelivery, domain.AuthorizationStatusExpired, true}, {"expire active", domain.AuthorizationStatusActive, domain.AuthorizationStatusExpired, true},
{"supersede pending delivery", domain.AuthorizationStatusPendingDelivery, domain.AuthorizationStatusSuperseded, true}, {"abandon active", domain.AuthorizationStatusActive, domain.AuthorizationStatusAbandoned, true},
{"expire before fence", domain.AuthorizationStatusExecuting, domain.AuthorizationStatusExpired, true}, {"expire claimed before fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusExpired, true},
{"supersede before fence", domain.AuthorizationStatusDelivered, domain.AuthorizationStatusSuperseded, true}, {"abandon claimed before fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusAbandoned, true},
{"fenced authorization cannot expire", domain.AuthorizationStatusFenced, domain.AuthorizationStatusExpired, false}, {"fenced authorization cannot expire", domain.AuthorizationStatusFenced, domain.AuthorizationStatusExpired, false},
{"fenced authorization cannot be superseded", domain.AuthorizationStatusFenced, domain.AuthorizationStatusSuperseded, false}, {"fenced authorization cannot be abandoned", domain.AuthorizationStatusFenced, domain.AuthorizationStatusAbandoned, false},
{"fenced authorization cannot be delivered again", domain.AuthorizationStatusFenced, domain.AuthorizationStatusDelivered, false}, {"fenced authorization cannot be claimed again", domain.AuthorizationStatusFenced, domain.AuthorizationStatusClaimed, false},
{"consumed authorization cannot restart", domain.AuthorizationStatusConsumed, domain.AuthorizationStatusDelivered, false}, {"consumed authorization cannot restart", domain.AuthorizationStatusConsumed, domain.AuthorizationStatusClaimed, false},
} }
for _, test := range tests { for _, test := range tests {
+74
View File
@@ -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)
}
}
}
-16
View File
@@ -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
}
+11 -11
View File
@@ -17,17 +17,17 @@ const (
) )
type OrderSubmission struct { type OrderSubmission struct {
ID string ID string
TaskID string TaskID string
AuthorizationID string AuthorizationID string
CommandID string AttemptID string
DryRunID string Status SubmissionStatus
Status SubmissionStatus Gate1UnitPrice string
VerifiedUnitPrice string Gate2UnitPrice string
QuantityRead int QuantityRead int
ConfirmPageAmount string ConfirmAmount string
CreatedAt time.Time CreatedAt time.Time
ResolvedAt *time.Time ResolvedAt *time.Time
} }
// CanTransitionTo 只允许围栏记录向最终观察结果调和,拒绝回退以防触发第二次真实动作。 // CanTransitionTo 只允许围栏记录向最终观察结果调和,拒绝回退以防触发第二次真实动作。
+1
View File
@@ -20,6 +20,7 @@ func TestSubmissionTransitions(t *testing.T) {
{"cannot reopen fenced submission", domain.SubmissionStatusSubmitted, domain.SubmissionStatusFenced, false}, {"cannot reopen fenced submission", domain.SubmissionStatusSubmitted, domain.SubmissionStatusFenced, false},
{"submitted cannot require reconciliation", domain.SubmissionStatusSubmitted, domain.SubmissionStatusReconciliationRequired, false}, {"submitted cannot require reconciliation", domain.SubmissionStatusSubmitted, domain.SubmissionStatusReconciliationRequired, false},
{"cannot skip reconciliation", domain.SubmissionStatusFenced, domain.SubmissionStatusManualResolved, 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 { for _, test := range tests {
+15 -21
View File
@@ -14,15 +14,12 @@ const (
TaskStatusDraft TaskStatus = "DRAFT" TaskStatusDraft TaskStatus = "DRAFT"
TaskStatusPending TaskStatus = "PENDING" TaskStatusPending TaskStatus = "PENDING"
TaskStatusClaimed TaskStatus = "CLAIMED" TaskStatusClaimed TaskStatus = "CLAIMED"
TaskStatusRunning TaskStatus = "RUNNING"
TaskStatusWaitingConfirmation TaskStatus = "WAITING_CONFIRMATION"
TaskStatusPendingRetrial TaskStatus = "PENDING_RETRIAL"
TaskStatusAuthorized TaskStatus = "AUTHORIZED"
TaskStatusOrdering TaskStatus = "ORDERING" TaskStatusOrdering TaskStatus = "ORDERING"
TaskStatusWaitingPayment TaskStatus = "WAITING_PAYMENT" TaskStatusWaitingPayment TaskStatus = "WAITING_PAYMENT"
TaskStatusReconciliationRequired TaskStatus = "RECONCILIATION_REQUIRED" TaskStatusReconciliationRequired TaskStatus = "RECONCILIATION_REQUIRED"
TaskStatusNeedsManual TaskStatus = "NEEDS_MANUAL" TaskStatusNeedsManual TaskStatus = "NEEDS_MANUAL"
TaskStatusSucceeded TaskStatus = "SUCCEEDED" TaskStatusSucceeded TaskStatus = "SUCCEEDED"
TaskStatusFailed TaskStatus = "FAILED"
TaskStatusCanceled TaskStatus = "CANCELED" TaskStatusCanceled TaskStatus = "CANCELED"
) )
@@ -68,35 +65,32 @@ func TransitionTask(current, next TaskStatus) (TaskStatus, error) {
var taskTransitions = map[TaskStatus]map[TaskStatus]struct{}{ var taskTransitions = map[TaskStatus]map[TaskStatus]struct{}{
TaskStatusDraft: { TaskStatusDraft: {
TaskStatusPending: {}, TaskStatusPending: {},
TaskStatusCanceled: {},
}, },
TaskStatusPending: { TaskStatusPending: {
TaskStatusClaimed: {}, TaskStatusClaimed: {},
}, TaskStatusDraft: {},
TaskStatusPendingRetrial: { TaskStatusCanceled: {},
TaskStatusClaimed: {},
}, },
TaskStatusClaimed: { TaskStatusClaimed: {
TaskStatusRunning: {},
TaskStatusPending: {},
},
TaskStatusRunning: {
TaskStatusWaitingConfirmation: {},
TaskStatusNeedsManual: {},
},
TaskStatusWaitingConfirmation: {
TaskStatusCanceled: {},
TaskStatusAuthorized: {},
},
TaskStatusAuthorized: {
TaskStatusOrdering: {}, TaskStatusOrdering: {},
TaskStatusDraft: {},
}, },
TaskStatusOrdering: { TaskStatusOrdering: {
TaskStatusNeedsManual: {}, TaskStatusNeedsManual: {},
TaskStatusWaitingPayment: {}, TaskStatusWaitingPayment: {},
TaskStatusReconciliationRequired: {}, TaskStatusReconciliationRequired: {},
}, },
TaskStatusNeedsManual: {
TaskStatusDraft: {},
TaskStatusCanceled: {},
},
TaskStatusWaitingPayment: { TaskStatusWaitingPayment: {
TaskStatusSucceeded: {}, TaskStatusSucceeded: {},
}, },
TaskStatusReconciliationRequired: {
TaskStatusWaitingPayment: {},
TaskStatusFailed: {},
},
} }
+14 -12
View File
@@ -14,22 +14,24 @@ func TestTaskTransitions(t *testing.T) {
next domain.TaskStatus next domain.TaskStatus
allowed bool allowed bool
}{ }{
{"start trial", domain.TaskStatusDraft, domain.TaskStatusPending, true}, {"start purchase", domain.TaskStatusDraft, domain.TaskStatusPending, true},
{"claim trial", domain.TaskStatusPending, domain.TaskStatusClaimed, true}, {"cancel draft before fence", domain.TaskStatusDraft, domain.TaskStatusCanceled, true},
{"claim retrial", domain.TaskStatusPendingRetrial, domain.TaskStatusClaimed, true}, {"claim purchase", domain.TaskStatusPending, domain.TaskStatusClaimed, true},
{"start trial execution", domain.TaskStatusClaimed, domain.TaskStatusRunning, true}, {"release expired authorization", domain.TaskStatusPending, domain.TaskStatusDraft, true},
{"release unstarted claim", domain.TaskStatusClaimed, domain.TaskStatusPending, true}, {"start ordering", domain.TaskStatusClaimed, domain.TaskStatusOrdering, true},
{"trial completes", domain.TaskStatusRunning, domain.TaskStatusWaitingConfirmation, true}, {"release unstarted claim", domain.TaskStatusClaimed, domain.TaskStatusDraft, true},
{"trial needs manual review", domain.TaskStatusRunning, domain.TaskStatusNeedsManual, true}, {"ordering needs manual review", domain.TaskStatusOrdering, domain.TaskStatusNeedsManual, true},
{"authorize confirmed trial", domain.TaskStatusWaitingConfirmation, domain.TaskStatusAuthorized, true},
{"reject confirmed trial", domain.TaskStatusWaitingConfirmation, domain.TaskStatusCanceled, true},
{"start authorized order leg", domain.TaskStatusAuthorized, domain.TaskStatusOrdering, true},
{"order reaches payment", domain.TaskStatusOrdering, domain.TaskStatusWaitingPayment, true}, {"order reaches payment", domain.TaskStatusOrdering, domain.TaskStatusWaitingPayment, true},
{"order needs manual review before fence", domain.TaskStatusOrdering, domain.TaskStatusNeedsManual, true}, {"order needs manual review before fence", domain.TaskStatusOrdering, domain.TaskStatusNeedsManual, true},
{"order needs reconciliation", domain.TaskStatusOrdering, domain.TaskStatusReconciliationRequired, 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}, {"payment verified", domain.TaskStatusWaitingPayment, domain.TaskStatusSucceeded, true},
{"cannot skip trial", domain.TaskStatusDraft, domain.TaskStatusAuthorized, false}, {"reconcile confirms waiting payment", domain.TaskStatusReconciliationRequired, domain.TaskStatusWaitingPayment, true},
{"trial cannot enter order leg", domain.TaskStatusRunning, domain.TaskStatusOrdering, false}, {"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}, {"terminal task cannot restart", domain.TaskStatusSucceeded, domain.TaskStatusPending, false},
{"unknown status is rejected", domain.TaskStatus("UNKNOWN"), domain.TaskStatusPending, false}, {"unknown status is rejected", domain.TaskStatus("UNKNOWN"), domain.TaskStatusPending, false},
} }
+279 -161
View File
@@ -3,8 +3,11 @@ package migrations_test
import ( import (
"context" "context"
"database/sql" "database/sql"
"os"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv"
"strings"
"testing" "testing"
"cmbuyer/admin/internal/migrations" "cmbuyer/admin/internal/migrations"
@@ -13,6 +16,8 @@ import (
"github.com/pressly/goose/v3" "github.com/pressly/goose/v3"
) )
const migrationTime = "2026-08-04T00:00:00Z"
func TestUpDownAndIdempotence(t *testing.T) { func TestUpDownAndIdempotence(t *testing.T) {
database := openTestDatabase(t) database := openTestDatabase(t)
directory := migrationDirectory(t) directory := migrationDirectory(t)
@@ -21,173 +26,325 @@ func TestUpDownAndIdempotence(t *testing.T) {
if err := migrations.Up(context, database, directory); err != nil { if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("apply migrations: %v", err) t.Fatalf("apply migrations: %v", err)
} }
assertVersion(t, database, 1) assertVersion(t, database, 2)
assertTableExists(t, database, "tasks", true) 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, "order_authorizations", true)
assertTableExists(t, database, "purchase_attempts", true)
assertTableExists(t, database, "order_submissions", true) assertTableExists(t, database, "order_submissions", true)
assertTableExists(t, database, "single_pass_upgrade_guard", false)
if err := migrations.Up(context, database, directory); err != nil { if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("reapply migrations: %v", err) t.Fatalf("reapply migrations: %v", err)
} }
assertVersion(t, database, 1) assertVersion(t, database, 2)
if err := migrations.Down(context, database, directory); err != nil { if err := migrations.Down(context, database, directory); err != nil {
t.Fatalf("roll back migration: %v", err) t.Fatalf("roll back v2 migration: %v", err)
}
assertVersion(t, database, 0)
assertTableExists(t, database, "tasks", false)
assertTableExists(t, database, "spec_trials", false)
assertTableExists(t, database, "order_authorizations", false)
assertTableExists(t, database, "order_submissions", false)
if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("apply migration after rollback: %v", err)
} }
assertVersion(t, database, 1) 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) database := openTestDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil { if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("apply migrations: %v", err) t.Fatalf("apply migrations: %v", err)
} }
for _, column := range []struct{ table, name string }{
for _, column := range []struct {
table string
name string
}{
{"tasks", "max_total_price"}, {"tasks", "max_total_price"},
{"spec_trials", "unit_price"},
{"spec_trials", "total_price"},
{"order_authorizations", "authorized_unit_price"},
{"order_authorizations", "total_price_cap"}, {"order_authorizations", "total_price_cap"},
{"order_submissions", "verified_unit_price"}, {"purchase_attempts", "gate1_unit_price"},
{"order_submissions", "confirm_page_amount"}, {"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") assertColumnType(t, database, column.table, column.name, "TEXT")
} }
for _, legacy := range []string{"spec_trials", "authorized_unit_price", "spec_trial_id", "command_id", "dry_run_id"} {
if _, err := database.Exec(` var count int
INSERT INTO tasks ( if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE sql LIKE '%' || ? || '%'`, legacy).Scan(&count); err != nil {
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, t.Fatalf("search schema for %s: %v", legacy, err)
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') if count != 0 {
`); err == nil { t.Fatalf("legacy identifier %q remains in v2 schema", legacy)
t.Fatal("insert task with quantity 0 succeeded") }
} }
if _, err := database.Exec(` insertV2Task(t, database, "task-one", "MANUAL", "DRAFT")
INSERT INTO tasks ( insertV2Task(t, database, "task-two", "MANUAL", "DRAFT")
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, for index, value := range []string{"", "0", "0.00", "-1.00", "1e2", "1.", "1.234", " 1.00", "one"} {
status, created_at, updated_at 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 {
) VALUES ('bad-price', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '80..00', 'DRAFT', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z') t.Fatalf("invalid total price %q succeeded", value)
`); err == nil { }
t.Fatal("insert task with malformed decimal price succeeded")
} }
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES ('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(` func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) {
INSERT INTO tasks ( tests := []struct {
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, name string
status, created_at, updated_at setup func(*testing.T, *sql.DB)
) 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 { {"authorization", func(t *testing.T, database *sql.DB) {
t.Fatal("insert task with fractional quantity succeeded") 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(` func migrateToV1(t *testing.T, database *sql.DB) {
INSERT INTO tasks ( t.Helper()
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
status, created_at, updated_at t.Fatalf("apply v1: %v", err)
) 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")
} }
assertVersion(t, database, 1)
}
if _, err := database.Exec(` func insertV1Task(t *testing.T, database *sql.DB, id, source, status, price string) {
INSERT INTO tasks ( t.Helper()
id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, 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 {
status, created_at, updated_at t.Fatalf("insert v1 task: %v", err)
) VALUES ('bad-status', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '80.00', 'UNKNOWN', '2026-08-03T00:00:00Z', '2026-08-03T00:00:00Z')
`); err == nil {
t.Fatal("insert task with invalid status succeeded")
} }
}
insertTask(t, database, "task-one") func insertV1SpecTrial(t *testing.T, database *sql.DB, id, taskID string) {
insertTask(t, database, "task-two") t.Helper()
if _, err := database.Exec(` 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 {
INSERT INTO spec_trials ( t.Fatalf("insert v1 spec trial: %v", err)
id, task_id, attempt, product_title, selected_color, selected_size, unit_price,
total_price, evidence_sha256, created_at
) VALUES ('orphan-trial', 'missing-task', 1, 'title', 'white', 'XL', '32.50', '65.00', 'hash', '2026-08-03T00:00:00Z')
`); err == nil {
t.Fatal("insert spec trial without task succeeded")
} }
}
insertSpecTrial(t, database, "trial-one", "task-one") func insertV1Authorization(t *testing.T, database *sql.DB, id, taskID, trialID string) {
insertSpecTrial(t, database, "trial-two", "task-two") t.Helper()
if _, err := database.Exec(` 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 {
INSERT INTO order_authorizations ( t.Fatalf("insert v1 authorization: %v", err)
id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity,
authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at
) VALUES ('authorization-cross-task', 'task-one', 'trial-two', 1, 'goods', 'white', 'XL', 2, '32.50', '80.00', 'PENDING_DELIVERY', 'admin-one', '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z')
`); err == nil {
t.Fatal("insert authorization with a spec trial from another task succeeded")
} }
}
insertAuthorization(t, database, "authorization-one", "task-one", "trial-one", 1) func insertV2Task(t *testing.T, database *sql.DB, id, source, status string) {
insertAuthorization(t, database, "authorization-task-two", "task-two", "trial-two", 1) t.Helper()
if _, err := database.Exec(` 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 {
INSERT INTO order_submissions ( t.Fatalf("insert v2 task: %v", err)
id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price,
quantity_read, confirm_page_amount, created_at
) VALUES ('submission-cross-task', 'task-one', 'authorization-task-two', 'command-cross-task', 'dry-run-cross-task', 'FENCED', '32.50', 2, '65.00', '2026-08-03T00:00:00Z')
`); err == nil {
t.Fatal("insert submission with an authorization from another task succeeded")
} }
}
if _, err := database.Exec(` func insertV2Authorization(t *testing.T, database *sql.DB, id, taskID string, version int, startKey string) {
INSERT INTO order_authorizations ( t.Helper()
id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity, 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 {
authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at t.Fatalf("insert v2 authorization: %v", err)
) VALUES ('authorization-duplicate', 'task-one', 'trial-one', 1, 'goods', 'white', 'XL', 2, '32.50', '80.00', 'PENDING_DELIVERY', 'admin-one', '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z')
`); err == nil {
t.Fatal("insert authorization with duplicate task version succeeded")
} }
}
insertSubmission(t, database, "submission-one", "authorization-one", "command-one") func insertV2Attempt(t *testing.T, database *sql.DB, id, taskID, authorizationID string, generation int) {
if _, err := database.Exec(` t.Helper()
INSERT INTO order_submissions ( 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 {
id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price, t.Fatalf("insert v2 attempt: %v", err)
quantity_read, confirm_page_amount, created_at
) VALUES ('submission-duplicate-auth', 'task-one', 'authorization-one', 'command-two', 'dry-run-two', 'FENCED', '32.50', 2, '65.00', '2026-08-03T00:00:00Z')
`); err == nil {
t.Fatal("insert submission with duplicate authorization succeeded")
} }
}
insertAuthorization(t, database, "authorization-two", "task-one", "trial-one", 2) func insertV2Submission(t *testing.T, database *sql.DB, id, taskID, authorizationID, attemptID string) {
if _, err := database.Exec(` t.Helper()
INSERT INTO order_submissions ( 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 {
id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price, t.Fatalf("insert v2 submission: %v", err)
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 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 { func openTestDatabase(t *testing.T) *sql.DB {
t.Helper() t.Helper()
database, err := sqlite.Open(filepath.Join(t.TempDir(), "migrations.db")) database, err := sqlite.Open(filepath.Join(t.TempDir(), "migrations.db"))
if err != nil { if err != nil {
t.Fatalf("open test database: %v", err) t.Fatalf("open test database: %v", err)
} }
t.Cleanup(func() { t.Cleanup(func() { _ = database.Close() })
if err := database.Close(); err != nil {
t.Errorf("close test database: %v", err)
}
})
return database return database
} }
@@ -197,7 +354,6 @@ func migrationDirectory(t *testing.T) string {
if !ok { if !ok {
t.Fatal("locate migration test source") t.Fatal("locate migration test source")
} }
return filepath.Join(filepath.Dir(file), "..", "..", "migrations") 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) { func TestV2MigrationSQLDoesNotDisableForeignKeys(t *testing.T) {
t.Helper() contents, err := os.ReadFile(filepath.Join(migrationDirectory(t), "00002_single_pass_model.sql"))
if _, err := database.Exec(` if err != nil {
INSERT INTO tasks ( t.Fatalf("read migration: %v", err)
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)
} }
} if strings.Contains(strings.ToUpper(string(contents)), "PRAGMA FOREIGN_KEYS = OFF") {
t.Fatal("migration disables foreign keys")
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)
} }
} }
+282 -7
View File
@@ -2,18 +2,293 @@
package server package server
import ( import (
"crypto/subtle"
"errors"
"net/http" "net/http"
"net/url"
"strings"
"cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/tasks"
"cmbuyer/admin/internal/transport/webui"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
) )
// NewRouter 返回当前服务范围内的完整 HTTP 路由。 const maxFormBytes = 8 << 10
func NewRouter() *gin.Engine {
router := gin.New()
router.GET("/healthz", func(context *gin.Context) { // Options 是路由层需要的安全依赖。凭据由启动配置注入,不能在路由中设置默认值。
context.JSON(http.StatusOK, gin.H{"status": "ok"}) type Options struct {
}) AdminUsername string
AdminPasswordBcrypt string
Sessions *auth.Manager
Tasks tasks.Store
}
return router // NewRouter 返回当前服务范围内的完整 HTTP 路由。
func NewRouter(options Options) (*gin.Engine, error) {
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil {
return nil, errors.New("server authentication options are incomplete")
}
router := gin.New()
router.Use(gin.Recovery())
router.Use(securityHeaders())
router.GET("/healthz", healthz)
router.GET("/login", loginPage(options))
router.POST("/login", login(options))
router.POST("/logout", logout(options))
router.GET("/tasks", tasksPage(options))
router.GET("/tasks/new", newTaskPage(options))
router.POST("/tasks", createTask(options))
return router, nil
}
func healthz(context *gin.Context) {
context.JSON(http.StatusOK, gin.H{"status": "ok"})
}
func securityHeaders() gin.HandlerFunc {
return func(context *gin.Context) {
context.Header("Cache-Control", "no-store")
context.Header("X-Content-Type-Options", "nosniff")
context.Header("Referrer-Policy", "no-referrer")
context.Header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
context.Next()
}
}
func loginPage(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
csrfToken, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
if authenticated {
context.Redirect(http.StatusSeeOther, "/tasks")
return
}
renderLogin(context, http.StatusOK, csrfToken, returnTo(context.Query("return_to")), "", "")
}
}
func login(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
if !parseForm(context) {
return
}
form := context.Request.PostForm
csrfToken := form.Get("csrf_token")
returnPath := returnTo(form.Get("return_to"))
username := form.Get("username")
password := form.Get("password")
if _, ok := options.Sessions.VerifyCSRF(context.Request, csrfToken); !ok {
newCSRF, _ := options.Sessions.Ensure(context.Writer, context.Request)
renderLogin(context, http.StatusForbidden, newCSRF, returnPath, "", "请求已过期,请重新登录。")
return
}
usernameMatches := subtle.ConstantTimeCompare([]byte(options.AdminUsername), []byte(username)) == 1
passwordMatches := bcrypt.CompareHashAndPassword([]byte(options.AdminPasswordBcrypt), []byte(password)) == nil
if !usernameMatches || !passwordMatches {
csrf, _ := options.Sessions.Ensure(context.Writer, context.Request)
renderLogin(context, http.StatusUnauthorized, csrf, returnPath, "", "账号或密码不正确,请检查后重试。")
return
}
options.Sessions.RotateAuthenticated(context.Writer, context.Request)
context.Redirect(http.StatusSeeOther, returnPath)
}
}
func logout(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
if !parseForm(context) {
return
}
authenticated, ok := options.Sessions.VerifyCSRF(context.Request, context.Request.PostForm.Get("csrf_token"))
if !ok || !authenticated {
context.Status(http.StatusForbidden)
return
}
options.Sessions.Logout(context.Writer, context.Request)
context.Redirect(http.StatusSeeOther, "/login")
}
}
func tasksPage(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
csrfToken, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
if !authenticated {
context.Redirect(http.StatusSeeOther, "/login?return_to="+url.QueryEscape(context.Request.URL.RequestURI()))
return
}
drafts, err := options.Tasks.ListDrafts(context.Request.Context())
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
data := webui.TasksData{CSRFToken: csrfToken, Drafts: drafts}
for _, draft := range drafts {
if draft.ID == context.Query("created") {
data.Success = true
break
}
}
if context.Query("create") == "1" {
form, err := newTaskForm()
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
data.OpenForm = true
data.Form = form
data.FocusField = "title"
}
renderTasks(context, http.StatusOK, data)
}
}
func newTaskPage(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
csrf, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
if !authenticated {
context.Redirect(http.StatusSeeOther, "/login?return_to=%2Ftasks%2Fnew")
return
}
form, err := newTaskForm()
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
renderTasks(context, http.StatusOK, webui.TasksData{CSRFToken: csrf, Form: form, FullPage: true, FocusField: "title"})
}
}
func createTask(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
if !parseForm(context) {
return
}
requestForm := context.Request.PostForm
authenticated, csrfOK := options.Sessions.VerifyCSRF(context.Request, requestForm.Get("csrf_token"))
if !csrfOK || !authenticated {
context.Status(http.StatusForbidden)
return
}
form := taskForm(requestForm)
draft, validation := tasks.Validate(form)
if draft.GoodsID != "" {
form.ProductURL = tasks.CanonicalURL(draft.GoodsID)
}
fullPage := requestForm.Get("form_mode") == "full"
if !validation.Valid() {
drafts, err := options.Tasks.ListDrafts(context.Request.Context())
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
renderTasks(context, http.StatusBadRequest, webui.TasksData{CSRFToken: csrfFor(context, options), Drafts: drafts, Form: form, Errors: validation, OpenForm: !fullPage, FullPage: fullPage, FocusField: firstError(validation)})
return
}
created, err := options.Tasks.CreateDraft(context.Request.Context(), draft)
if err != nil {
if errors.Is(err, tasks.ErrCreateKeyConflict) {
validation["create_key"] = "该创建请求已用于另一条任务,请重新打开表单。"
drafts, listErr := options.Tasks.ListDrafts(context.Request.Context())
if listErr != nil {
context.Status(http.StatusInternalServerError)
return
}
renderTasks(context, http.StatusConflict, webui.TasksData{CSRFToken: csrfFor(context, options), Drafts: drafts, Form: form, Errors: validation, OpenForm: !fullPage, FullPage: fullPage, FocusField: firstError(validation)})
return
}
context.Status(http.StatusInternalServerError)
return
}
context.Redirect(http.StatusSeeOther, "/tasks?created="+url.QueryEscape(created.ID))
}
}
func newTaskForm() (tasks.Form, error) {
key, err := tasks.NewCreateKey()
if err != nil {
return tasks.Form{}, err
}
return tasks.Form{CreateKey: key}, nil
}
func taskForm(form url.Values) tasks.Form {
return tasks.Form{CreateKey: form.Get("create_key"), Title: form.Get("title"), ProductURL: form.Get("product_url"), SKUColor: form.Get("sku_color"), SKUSize: form.Get("sku_size"), Quantity: form.Get("quantity"), MaxTotalPrice: form.Get("max_total_price")}
}
func csrfFor(context *gin.Context, options Options) string {
csrf, _ := options.Sessions.Ensure(context.Writer, context.Request)
return csrf
}
func renderTasks(context *gin.Context, status int, data webui.TasksData) {
context.Header("Content-Type", "text/html; charset=utf-8")
context.Status(status)
if err := webui.RenderTasks(context.Writer, data); err != nil {
_ = context.Error(err)
}
}
func renderLogin(context *gin.Context, status int, csrfToken, returnPath, username, message string) {
context.Header("Content-Type", "text/html; charset=utf-8")
context.Status(status)
if err := webui.RenderLogin(context.Writer, webui.LoginData{
CSRFToken: csrfToken,
ReturnTo: returnPath,
Username: username,
Error: message,
}); err != nil {
_ = context.Error(err)
}
}
func parseForm(context *gin.Context) bool {
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxFormBytes)
if err := context.Request.ParseForm(); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
context.Status(http.StatusRequestEntityTooLarge)
} else {
context.Status(http.StatusBadRequest)
}
return false
}
return true
}
func firstError(validation tasks.Errors) string {
for _, field := range []string{"title", "product_url", "sku_color", "sku_size", "quantity", "max_total_price"} {
if _, ok := validation[field]; ok {
return field
}
}
return "title"
}
func returnTo(value string) string {
if value == "/tasks" || strings.HasPrefix(value, "/tasks/") || strings.HasPrefix(value, "/tasks?") {
if strings.Contains(value, "\\") || strings.Contains(value, "%") || strings.HasPrefix(value, "//") {
return "/tasks"
}
parsed, err := url.ParseRequestURI(value)
if err == nil && parsed.IsAbs() == false && parsed.Host == "" && hasSafeTaskPath(parsed.Path) {
return value
}
}
return "/tasks"
}
func hasSafeTaskPath(path string) bool {
for _, segment := range strings.Split(path, "/") {
if segment == "." || segment == ".." {
return false
}
}
return true
} }
+457 -4
View File
@@ -1,28 +1,481 @@
package server_test package server_test
import ( import (
"context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"regexp"
"strings"
"testing" "testing"
"cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/server" "cmbuyer/admin/internal/server"
"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) request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
response := httptest.NewRecorder() response := httptest.NewRecorder()
server.NewRouter().ServeHTTP(response, request) router.ServeHTTP(response, request)
if response.Code != http.StatusOK { if response.Code != http.StatusOK {
t.Fatalf("healthz status = %d, want %d", response.Code, http.StatusOK) t.Fatalf("healthz status = %d, want %d", response.Code, http.StatusOK)
} }
if contentType := response.Header().Get("Content-Type"); contentType != "application/json; charset=utf-8" { if contentType := response.Header().Get("Content-Type"); contentType != "application/json; charset=utf-8" {
t.Fatalf("healthz content type = %q, want application/json; charset=utf-8", contentType) t.Fatalf("healthz content type = %q, want application/json; charset=utf-8", contentType)
} }
if body := response.Body.String(); body != "{\"status\":\"ok\"}" { if body := response.Body.String(); body != "{\"status\":\"ok\"}" {
t.Fatalf("healthz body = %q, want {\"status\":\"ok\"}", body) t.Fatalf("healthz body = %q, want {\"status\":\"ok\"}", body)
} }
assertSecurityHeaders(t, response)
}
func TestTasksRequiresLoginAndBlocksOpenRedirects(t *testing.T) {
router, _ := newRouter(t)
tasks := serve(router, http.MethodGet, "/tasks", nil, nil)
if tasks.Code != http.StatusSeeOther {
t.Fatalf("GET /tasks status = %d, want %d", tasks.Code, http.StatusSeeOther)
}
if location := tasks.Header().Get("Location"); location != "/login?return_to=%2Ftasks" {
t.Fatalf("GET /tasks location = %q, want login return path", location)
}
for _, target := range []string{"https://example.invalid", "//example.invalid", `\\example.invalid`, "/other", "/tasks/..", "/tasks/../other", "/tasks/%2e%2e", "%2F%2Fevil.invalid", "%252F%252Fevil.invalid"} {
response := serve(router, http.MethodGet, "/login?return_to="+url.QueryEscape(target), nil, nil)
if response.Code != http.StatusOK {
t.Fatalf("GET /login return_to=%q status = %d, want 200", target, response.Code)
}
if strings.Contains(response.Body.String(), target) || !strings.Contains(response.Body.String(), `name="return_to" value="/tasks"`) {
t.Fatalf("GET /login accepted unsafe return_to %q", target)
}
}
encodedPath := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%252F..", nil, nil)
if !strings.Contains(encodedPath.Body.String(), `name="return_to" value="/tasks"`) {
t.Fatal("encoded parent path was accepted as return_to")
}
encodedQuery := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%3Fnext%3D%252Ftasks%252F..", nil, nil)
if !strings.Contains(encodedQuery.Body.String(), `name="return_to" value="/tasks"`) {
t.Fatal("encoded query bypass was accepted as return_to")
}
}
func TestLoginRotatesSessionAndCSRF(t *testing.T) {
router, _ := newRouter(t)
initial := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%3Fview%3Dmine", nil, nil)
oldCookie := sessionCookie(t, initial)
oldCSRF := csrfToken(t, initial.Body.String())
login := serve(router, http.MethodPost, "/login", url.Values{
"csrf_token": {oldCSRF},
"return_to": {"/tasks?view=mine"},
"username": {"admin"},
"password": {"test-password"},
}, oldCookie)
if login.Code != http.StatusSeeOther || login.Header().Get("Location") != "/tasks?view=mine" {
t.Fatalf("successful login = (%d, %q), want 303 /tasks?view=mine", login.Code, login.Header().Get("Location"))
}
newCookie := sessionCookie(t, login)
if newCookie.Value == oldCookie.Value {
t.Fatal("successful login reused the anonymous session cookie")
}
tasks := serve(router, http.MethodGet, "/tasks", nil, newCookie)
if tasks.Code != http.StatusOK {
t.Fatalf("GET /tasks after login status = %d, want 200", tasks.Code)
}
if newCSRF := csrfToken(t, tasks.Body.String()); newCSRF == oldCSRF {
t.Fatal("successful login reused the anonymous CSRF token")
}
for _, forbidden := range []string{"建单", "试选", "拼多多", "规格", "单价", "证据"} {
if strings.Contains(tasks.Body.String(), forbidden) {
t.Fatalf("task shell must not expose deferred feature content %q", forbidden)
}
}
assertSecurityHeaders(t, initial)
assertSecurityHeaders(t, tasks)
}
func TestLoginPageIncludesAccessibleFormBasics(t *testing.T) {
router, _ := newRouter(t)
page := serve(router, http.MethodGet, "/login", nil, nil)
body := page.Body.String()
for _, want := range []string{
`<label for="username">`,
`<label for="password">`,
`autocomplete="username"`,
`autocomplete="current-password"`,
`min-height:44px`,
`:focus-visible`,
`prefers-reduced-motion`,
} {
if !strings.Contains(body, want) {
t.Fatalf("login page is missing %q", want)
}
}
if strings.Contains(body, "http://") || strings.Contains(body, "https://") || strings.Contains(body, "<script") {
t.Fatal("login page must not load external resources or require client-side JavaScript")
}
failure := serve(router, http.MethodPost, "/login", url.Values{
"csrf_token": {csrfToken(t, body)},
"username": {"admin"},
"password": {"wrong"},
}, sessionCookie(t, page))
if !strings.Contains(failure.Body.String(), `role="alert"`) {
t.Fatal("login failure must announce its error")
}
}
func TestLoginCSRFAndCredentialFailuresAreSafe(t *testing.T) {
router, _ := newRouter(t)
page := serve(router, http.MethodGet, "/login", nil, nil)
cookie := sessionCookie(t, page)
withoutCSRF := serve(router, http.MethodPost, "/login", url.Values{
"username": {"admin"},
"password": {"test-password"},
}, cookie)
if withoutCSRF.Code != http.StatusForbidden || !strings.Contains(withoutCSRF.Body.String(), "请求已过期") {
t.Fatalf("login without CSRF = (%d, %q), want rejected form", withoutCSRF.Code, withoutCSRF.Body.String())
}
page = serve(router, http.MethodGet, "/login", nil, cookie)
badCredentials := serve(router, http.MethodPost, "/login", url.Values{
"csrf_token": {csrfToken(t, page.Body.String())},
"username": {"unknown"},
"password": {"wrong"},
}, cookie)
if badCredentials.Code != http.StatusUnauthorized {
t.Fatalf("login with invalid credentials status = %d, want 401", badCredentials.Code)
}
if body := badCredentials.Body.String(); !strings.Contains(body, "账号或密码不正确") || strings.Contains(body, "unknown") {
t.Fatalf("invalid login leaked account detail: %q", body)
}
}
func TestTamperedCookieCannotAccessTasks(t *testing.T) {
router, _ := newRouter(t)
page := serve(router, http.MethodGet, "/login", nil, nil)
cookie := sessionCookie(t, page)
tampered := *cookie
tampered.Value = flipCookieValue(t, cookie.Value)
response := serve(router, http.MethodGet, "/tasks", nil, &tampered)
if response.Code != http.StatusSeeOther {
t.Fatalf("tampered cookie status = %d, want 303", response.Code)
}
}
func TestTaskCreationRendersSharedFormsAndPersistsOnlyDraft(t *testing.T) {
router, _ := newRouter(t)
cookie := authenticate(t, router)
modal := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
if modal.Code != http.StatusOK {
t.Fatalf("GET dialog form status = %d, want 200", modal.Code)
}
fullPage := serve(router, http.MethodGet, "/tasks/new", nil, cookie)
if fullPage.Code != http.StatusOK {
t.Fatalf("GET full form status = %d, want 200", fullPage.Code)
}
for _, want := range []string{`<div class="modal-scrim"`, `<dialog open`, `aria-modal="true"`, `name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `type="url" inputmode="url" maxlength="2048"`, `type="number" inputmode="numeric" min="1" step="1"`, `inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?"`, `maxlength="120"`, `maxlength="80"`, `required`, `autofocus`, `导入</button><a class="button primary"`, `type="search" disabled`, `disabled>筛选</button>`, `disabled>清除</button>`, `min-height:44px`, `overflow-x:auto`, `prefers-reduced-motion`} {
if !strings.Contains(modal.Body.String(), want) {
t.Fatalf("dialog form is missing %q", want)
}
}
for _, want := range []string{`name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `name="form_mode" value="full"`} {
if !strings.Contains(fullPage.Body.String(), want) {
t.Fatalf("full-page form is missing %q", want)
}
}
invalid := serve(router, http.MethodPost, "/tasks", url.Values{
"csrf_token": {csrfToken(t, modal.Body.String())},
"create_key": {createKey(t, modal.Body.String())},
"title": {`<script>alert(1)</script>`},
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&uin=discard"},
"sku_color": {"black"},
"sku_size": {"M"},
"quantity": {"0"},
"max_total_price": {"12.80"},
"form_mode": {"dialog"},
}, cookie)
if invalid.Code != http.StatusBadRequest || !strings.Contains(invalid.Body.String(), `<dialog open`) || !strings.Contains(invalid.Body.String(), "数量必须是正整数") || !strings.Contains(invalid.Body.String(), `role="alert"`) || !strings.Contains(invalid.Body.String(), `href="#quantity"`) || !strings.Contains(invalid.Body.String(), `aria-describedby="quantity-error"`) || !strings.Contains(invalid.Body.String(), `autofocus`) {
t.Fatalf("invalid create = (%d, %q), want dialog validation response", invalid.Code, invalid.Body.String())
}
if strings.Contains(invalid.Body.String(), `<script>alert(1)</script>`) || !strings.Contains(invalid.Body.String(), `&lt;script&gt;alert(1)&lt;/script&gt;`) {
t.Fatalf("invalid create did not safely preserve title: %q", invalid.Body.String())
}
if strings.Contains(invalid.Body.String(), "uin=discard") || !strings.Contains(invalid.Body.String(), `value="https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"`) {
t.Fatalf("invalid create did not canonicalize product URL: %q", invalid.Body.String())
}
createPage := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
key := createKey(t, createPage.Body.String())
created := serve(router, http.MethodPost, "/tasks", url.Values{
"csrf_token": {csrfToken(t, createPage.Body.String())},
"create_key": {key},
"title": {"<b>夏季上衣</b>"},
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=discard"},
"sku_color": {"black"},
"sku_size": {"M"},
"quantity": {"2"},
"max_total_price": {"12.8"},
"form_mode": {"dialog"},
}, cookie)
if created.Code != http.StatusSeeOther || !strings.HasPrefix(created.Header().Get("Location"), "/tasks?created=") {
t.Fatalf("valid create = (%d, %q), want 303 to a created-task acknowledgement", created.Code, created.Header().Get("Location"))
}
replay := serve(router, http.MethodPost, "/tasks", url.Values{
"csrf_token": {csrfToken(t, createPage.Body.String())},
"create_key": {key},
"title": {"<b>夏季上衣</b>"},
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=discard"},
"sku_color": {"black"},
"sku_size": {"M"},
"quantity": {"2"},
"max_total_price": {"12.8"},
"form_mode": {"dialog"},
}, cookie)
if replay.Code != http.StatusSeeOther {
t.Fatalf("idempotent replay status = %d, want 303", replay.Code)
}
conflict := serve(router, http.MethodPost, "/tasks", url.Values{
"csrf_token": {csrfToken(t, createPage.Body.String())},
"create_key": {key},
"title": {"different task"},
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"},
"sku_color": {"black"},
"sku_size": {"M"},
"quantity": {"2"},
"max_total_price": {"12.80"},
"form_mode": {"dialog"},
}, cookie)
if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "该创建请求已用于另一条任务") {
t.Fatalf("conflicting create = (%d, %q), want a 409 form error", conflict.Code, conflict.Body.String())
}
list := serve(router, http.MethodGet, created.Header().Get("Location"), nil, cookie)
if list.Code != http.StatusOK {
t.Fatalf("GET /tasks status = %d, want 200", list.Code)
}
body := list.Body.String()
for _, want := range []string{`任务已创建,已显示在列表首行。`, `&lt;b&gt;夏季上衣&lt;/b&gt;`, `https://mobile.yangkeduo.com/goods.html?goods_id=937122477375`, `target="_blank"`, `rel="noopener noreferrer"`, `¥12.80`, `待开始`, `选择全部任务`, `选择任务`} {
if !strings.Contains(body, want) {
t.Fatalf("task list is missing %q", want)
}
}
for _, forbidden := range []string{"utm_source", "试选", "PENDING", "支付", "订单确认", "真机", "提交订单"} {
if strings.Contains(body, forbidden) {
t.Fatalf("task list exposed deferred scope %q", forbidden)
}
}
}
func TestTaskCreationRequiresAuthenticationAndCSRF(t *testing.T) {
router, _ := newRouter(t)
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, nil); response.Code != http.StatusForbidden {
t.Fatalf("anonymous POST /tasks = %d, want 403", response.Code)
}
cookie := authenticate(t, router)
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, cookie); response.Code != http.StatusForbidden {
t.Fatalf("POST /tasks without CSRF = %d, want 403", response.Code)
}
}
func TestTaskCreationFailsClosedForMalformedOrOversizedForms(t *testing.T) {
router, _ := newRouter(t)
cookie := authenticate(t, router)
page := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
base := url.Values{
"csrf_token": {csrfToken(t, page.Body.String())},
"create_key": {createKey(t, page.Body.String())},
"title": {"title"},
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=malformed"},
"sku_color": {"black"},
"sku_size": {"M"},
"quantity": {"1"},
"max_total_price": {"1.00"},
"form_mode": {"dialog"},
}
malformed := serve(router, http.MethodPost, "/tasks", base, cookie)
if malformed.Code != http.StatusBadRequest || !strings.Contains(malformed.Body.String(), "canonical 商品链接") {
t.Fatalf("malformed URL create = (%d, %q), want validation failure", malformed.Code, malformed.Body.String())
}
oversized := url.Values{"csrf_token": {csrfToken(t, page.Body.String())}, "title": {strings.Repeat("x", 9<<10)}}
if response := serve(router, http.MethodPost, "/tasks", oversized, cookie); response.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized form status = %d, want 413", response.Code)
}
}
func assertSecurityHeaders(t *testing.T, response *httptest.ResponseRecorder) {
t.Helper()
want := map[string]string{
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"Content-Security-Policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
}
for name, expected := range want {
if got := response.Header().Get(name); got != expected {
t.Fatalf("%s = %q, want %q", name, got, expected)
}
}
}
func flipCookieValue(t *testing.T, value string) string {
t.Helper()
if value == "" {
t.Fatal("cannot tamper with an empty cookie")
}
if value[0] == 'A' {
return "B" + value[1:]
}
return "A" + value[1:]
}
func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
router, _ := newRouter(t)
loginPage := serve(router, http.MethodGet, "/login", nil, nil)
loginCookie := sessionCookie(t, loginPage)
login := serve(router, http.MethodPost, "/login", url.Values{
"csrf_token": {csrfToken(t, loginPage.Body.String())},
"username": {"admin"},
"password": {"test-password"},
}, loginCookie)
authenticatedCookie := sessionCookie(t, login)
missingCSRF := serve(router, http.MethodPost, "/logout", url.Values{}, authenticatedCookie)
if missingCSRF.Code != http.StatusForbidden {
t.Fatalf("logout without CSRF status = %d, want 403", missingCSRF.Code)
}
tasks := serve(router, http.MethodGet, "/tasks", nil, authenticatedCookie)
logout := serve(router, http.MethodPost, "/logout", url.Values{
"csrf_token": {csrfToken(t, tasks.Body.String())},
}, authenticatedCookie)
if logout.Code != http.StatusSeeOther || logout.Header().Get("Location") != "/login" {
t.Fatalf("logout = (%d, %q), want 303 /login", logout.Code, logout.Header().Get("Location"))
}
if cookie := sessionCookie(t, logout); cookie.MaxAge >= 0 {
t.Fatalf("logout cookie MaxAge = %d, want a deletion cookie", cookie.MaxAge)
}
reused := serve(router, http.MethodGet, "/tasks", nil, authenticatedCookie)
if reused.Code != http.StatusSeeOther {
t.Fatalf("revoked session status = %d, want 303", reused.Code)
}
}
func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
t.Helper()
gin.SetMode(gin.TestMode)
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
if err != nil {
t.Fatalf("generate bcrypt hash: %v", err)
}
manager := auth.NewManager([]byte(strings.Repeat("s", 32)), false)
router, err := server.NewRouter(server.Options{
AdminUsername: "admin",
AdminPasswordBcrypt: string(hash),
Sessions: manager,
Tasks: &memoryStore{},
})
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
return router, manager
}
type memoryStore struct{ drafts []tasks.Draft }
func (store *memoryStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) {
for _, existing := range store.drafts {
if existing.ID == draft.ID {
if existing.Title != draft.Title || existing.GoodsID != draft.GoodsID || existing.SKUColor != draft.SKUColor || existing.SKUSize != draft.SKUSize || existing.Quantity != draft.Quantity || existing.MaxTotalPrice != draft.MaxTotalPrice {
return tasks.Draft{}, tasks.ErrCreateKeyConflict
}
return existing, nil
}
}
store.drafts = append(store.drafts, draft)
return draft, nil
}
func (store *memoryStore) ListDrafts(_ context.Context) ([]tasks.Draft, error) {
return append([]tasks.Draft(nil), store.drafts...), nil
}
func serve(router http.Handler, method, target string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
var body *strings.Reader
if form == nil {
body = strings.NewReader("")
} else {
body = strings.NewReader(form.Encode())
}
request := httptest.NewRequest(method, target, body)
if form != nil {
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if cookie != nil {
request.AddCookie(cookie)
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func sessionCookie(t *testing.T, response *httptest.ResponseRecorder) *http.Cookie {
t.Helper()
for _, cookie := range response.Result().Cookies() {
if cookie.Name == auth.CookieName {
return cookie
}
}
t.Fatalf("response did not set %s cookie", auth.CookieName)
return nil
}
func csrfToken(t *testing.T, body string) string {
t.Helper()
matches := csrfPattern.FindStringSubmatch(body)
if len(matches) != 2 || matches[1] == "" {
t.Fatalf("no CSRF token in response body: %q", body)
}
return matches[1]
}
func createKey(t *testing.T, body string) string {
t.Helper()
matches := createKeyPattern.FindStringSubmatch(body)
if len(matches) != 2 || matches[1] == "" {
t.Fatalf("no create key in response body: %q", body)
}
return matches[1]
}
func authenticate(t *testing.T, router http.Handler) *http.Cookie {
t.Helper()
page := serve(router, http.MethodGet, "/login", nil, nil)
login := serve(router, http.MethodPost, "/login", url.Values{
"csrf_token": {csrfToken(t, page.Body.String())},
"username": {"admin"},
"password": {"test-password"},
}, sessionCookie(t, page))
if login.Code != http.StatusSeeOther {
t.Fatalf("authenticate status = %d, want 303", login.Code)
}
return sessionCookie(t, login)
} }
+129
View File
@@ -0,0 +1,129 @@
package tasks
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
const sqliteWriteTimeout = 2 * time.Second
type Store interface {
CreateDraft(context.Context, Draft) (Draft, error)
ListDrafts(context.Context) ([]Draft, error)
}
type SQLiteStore struct {
database *sql.DB
now func() time.Time
createGate chan struct{}
}
func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
if database == nil {
return nil, errors.New("database is required")
}
if _, err := database.Exec("SELECT 1 FROM tasks LIMIT 1"); err != nil {
return nil, fmt.Errorf("tasks migration is not available: %w", err)
}
return &SQLiteStore{database: database, now: time.Now, createGate: make(chan struct{}, 1)}, nil
}
func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) {
writeContext, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
defer cancel()
// SQLite permits one writer at a time. Serializing this store's short create
// transaction prevents concurrent retries of one create key from surfacing as busy.
select {
case store.createGate <- struct{}{}:
defer func() { <-store.createGate }()
case <-writeContext.Done():
return Draft{}, writeContext.Err()
}
draft.CreatedAt = store.now().UTC()
transaction, err := store.database.BeginTx(writeContext, nil)
if err != nil {
return Draft{}, err
}
defer transaction.Rollback()
_, err = transaction.ExecContext(writeContext, `INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', ?, ?, ?, ?, ?, ?, 'DRAFT', 1, ?, ?)`, draft.ID, draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.Quantity, draft.MaxTotalPrice, draft.CreatedAt.Format(time.RFC3339Nano), draft.CreatedAt.Format(time.RFC3339Nano))
if err == nil {
if err := transaction.Commit(); err != nil {
return Draft{}, err
}
return draft, nil
}
existing, found, currentPhase, lookupErr := findDraft(writeContext, transaction, draft.ID)
if lookupErr != nil {
return Draft{}, lookupErr
}
if found && currentPhase && samePayload(existing, draft) {
if err := transaction.Commit(); err != nil {
return Draft{}, err
}
return existing, nil
}
if found {
return Draft{}, ErrCreateKeyConflict
}
return Draft{}, err
}
func (store *SQLiteStore) ListDrafts(ctx context.Context) ([]Draft, error) {
// rowid makes equal timestamps deterministic: SQLite assigns it in insertion order,
// whereas UUID v4 is deliberately not time-sortable.
rows, err := store.database.QueryContext(ctx, `SELECT id, title, goods_id, sku_color, sku_size, quantity, max_total_price, created_at FROM tasks WHERE source = 'MANUAL' AND status = 'DRAFT' ORDER BY created_at DESC, rowid DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
result := []Draft{}
for rows.Next() {
draft, err := scanDraft(rows)
if err != nil {
return nil, err
}
result = append(result, draft)
}
return result, rows.Err()
}
func findDraft(ctx context.Context, transaction *sql.Tx, id string) (Draft, bool, bool, error) {
row := transaction.QueryRowContext(ctx, `SELECT id, title, goods_id, sku_color, sku_size, quantity, max_total_price, created_at, source, status, version FROM tasks WHERE id = ?`, id)
var draft Draft
var created, source, status string
var version int
err := row.Scan(&draft.ID, &draft.Title, &draft.GoodsID, &draft.SKUColor, &draft.SKUSize, &draft.Quantity, &draft.MaxTotalPrice, &created, &source, &status, &version)
if errors.Is(err, sql.ErrNoRows) {
return Draft{}, false, false, nil
}
if err != nil {
return Draft{}, false, false, err
}
parsed, err := time.Parse(time.RFC3339Nano, created)
if err != nil {
return Draft{}, false, false, err
}
draft.CreatedAt = parsed
return draft, true, source == "MANUAL" && status == "DRAFT" && version == 1, nil
}
type scanner interface{ Scan(...any) error }
func scanDraft(row scanner) (Draft, error) {
var draft Draft
var created string
if err := row.Scan(&draft.ID, &draft.Title, &draft.GoodsID, &draft.SKUColor, &draft.SKUSize, &draft.Quantity, &draft.MaxTotalPrice, &created); err != nil {
return Draft{}, err
}
parsed, err := time.Parse(time.RFC3339Nano, created)
if err != nil {
return Draft{}, err
}
draft.CreatedAt = parsed
return draft, nil
}
func samePayload(left, right Draft) bool {
return left.ID == right.ID && left.Title == right.Title && left.GoodsID == right.GoodsID && left.SKUColor == right.SKUColor && left.SKUSize == right.SKUSize && left.Quantity == right.Quantity && left.MaxTotalPrice == right.MaxTotalPrice
}
+159
View File
@@ -0,0 +1,159 @@
// Package tasks 定义手工 DRAFT 任务的校验与窄仓储边界。
package tasks
import (
"crypto/rand"
"encoding/hex"
"errors"
"net/url"
"strconv"
"strings"
"time"
)
const (
maxTitleLength = 120
maxSKUText = 80
)
var ErrCreateKeyConflict = errors.New("create key conflicts with a different task")
type Draft struct {
ID string
Title string
GoodsID string
SKUColor string
SKUSize string
Quantity int
MaxTotalPrice string
CreatedAt time.Time
}
type Form struct{ CreateKey, Title, ProductURL, SKUColor, SKUSize, Quantity, MaxTotalPrice string }
type Errors map[string]string
func (errors Errors) Valid() bool { return len(errors) == 0 }
// Validate trims and normalizes a user form. It never reads a product page or derives price data.
func Validate(form Form) (Draft, Errors) {
draft := Draft{ID: strings.TrimSpace(form.CreateKey), Title: strings.TrimSpace(form.Title), SKUColor: strings.TrimSpace(form.SKUColor), SKUSize: strings.TrimSpace(form.SKUSize)}
errors := Errors{}
if !validUUID(draft.ID) {
errors["create_key"] = "创建请求已过期,请重新打开表单。"
}
if draft.Title == "" || len([]rune(draft.Title)) > maxTitleLength {
errors["title"] = "任务名称不能为空,且不能超过 120 个字符。"
}
if draft.SKUColor == "" || len([]rune(draft.SKUColor)) > maxSKUText {
errors["sku_color"] = "颜色分类不能为空,且不能超过 80 个字符。"
}
if draft.SKUSize == "" || len([]rune(draft.SKUSize)) > maxSKUText {
errors["sku_size"] = "尺码不能为空,且不能超过 80 个字符。"
}
goodsID, ok := CanonicalGoodsID(strings.TrimSpace(form.ProductURL))
if !ok {
errors["product_url"] = "请输入唯一的 canonical 商品链接。"
} else {
draft.GoodsID = goodsID
}
quantity, err := strconv.ParseInt(strings.TrimSpace(form.Quantity), 10, 0)
if err != nil || quantity < 1 {
errors["quantity"] = "数量必须是正整数。"
} else {
draft.Quantity = int(quantity)
}
money, ok := normalizeMoney(strings.TrimSpace(form.MaxTotalPrice))
if !ok {
errors["max_total_price"] = "价格上限必须大于零,且最多两位小数。"
} else {
draft.MaxTotalPrice = money
}
return draft, errors
}
// CanonicalGoodsID only accepts the one verified manual-entry URL shape; untrusted query data is discarded.
func CanonicalGoodsID(value string) (string, bool) {
if value == "" || strings.Contains(value, "\\") || strings.Contains(value, "%") {
return "", false
}
parsed, err := url.ParseRequestURI(value)
if err != nil || parsed.Scheme != "https" || parsed.Host != "mobile.yangkeduo.com" || parsed.User != nil || parsed.Port() != "" || parsed.Path != "/goods.html" || parsed.Fragment != "" {
return "", false
}
values, err := url.ParseQuery(parsed.RawQuery)
if err != nil {
return "", false
}
goodsIDs := values["goods_id"]
if len(goodsIDs) != 1 || goodsIDs[0] == "" {
return "", false
}
for _, character := range goodsIDs[0] {
if character < '0' || character > '9' {
return "", false
}
}
return goodsIDs[0], true
}
func CanonicalURL(goodsID string) string {
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID
}
func NewCreateKey() (string, error) {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
hexValue := hex.EncodeToString(bytes)
return hexValue[0:8] + "-" + hexValue[8:12] + "-" + hexValue[12:16] + "-" + hexValue[16:20] + "-" + hexValue[20:32], nil
}
func validUUID(value string) bool {
if len(value) != 36 {
return false
}
for index, character := range value {
if index == 8 || index == 13 || index == 18 || index == 23 {
if character != '-' {
return false
}
continue
}
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
return false
}
}
return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b')
}
func normalizeMoney(value string) (string, bool) {
parts := strings.Split(value, ".")
if len(parts) > 2 || parts[0] == "" || len(parts) == 2 && (len(parts[1]) == 0 || len(parts[1]) > 2) {
return "", false
}
for _, character := range parts[0] {
if character < '0' || character > '9' {
return "", false
}
}
fraction := ""
if len(parts) == 2 {
fraction = parts[1]
for _, character := range fraction {
if character < '0' || character > '9' {
return "", false
}
}
}
whole := strings.TrimLeft(parts[0], "0")
if whole == "" {
whole = "0"
}
if whole == "0" && strings.Trim(fraction, "0") == "" {
return "", false
}
return whole + "." + (fraction + "00")[:2], true
}
+300
View File
@@ -0,0 +1,300 @@
package tasks
import (
"context"
"database/sql"
"errors"
"path/filepath"
"regexp"
"runtime"
"sync"
"testing"
"time"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
)
const testKey = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
func TestValidateNormalizesManualDraft(t *testing.T) {
draft, validation := Validate(Form{
CreateKey: " " + testKey + " ",
Title: " 夏季上衣 ",
ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=untrusted",
SKUColor: " 黑色CHA(纯棉) ",
SKUSize: " M(建议100-115) ",
Quantity: "2",
MaxTotalPrice: "00012.8",
})
if !validation.Valid() {
t.Fatalf("Validate errors = %#v", validation)
}
if draft.ID != testKey || draft.GoodsID != "937122477375" || draft.Title != "夏季上衣" || draft.SKUColor != "黑色CHA(纯棉)" || draft.SKUSize != "M(建议100-115)" || draft.Quantity != 2 || draft.MaxTotalPrice != "12.80" {
t.Fatalf("normalized draft = %#v", draft)
}
}
func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
base := Form{CreateKey: testKey, Title: "title", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", SKUColor: "black", SKUSize: "M", Quantity: "1", MaxTotalPrice: "1"}
for name, update := range map[string]func(*Form){
"empty title": func(form *Form) { form.Title = " " },
"long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) },
"fraction quantity": func(form *Form) { form.Quantity = "1.5" },
"zero quantity": func(form *Form) { form.Quantity = "0" },
"too many decimals": func(form *Form) { form.MaxTotalPrice = "1.234" },
"trailing decimal": func(form *Form) { form.MaxTotalPrice = "1." },
"zero money": func(form *Form) { form.MaxTotalPrice = "0.00" },
} {
t.Run(name, func(t *testing.T) {
form := base
update(&form)
if _, validation := Validate(form); validation.Valid() {
t.Fatal("invalid form was accepted")
}
})
}
for _, value := range []string{
"http://mobile.yangkeduo.com/goods.html?goods_id=1",
"https://yangkeduo.com/goods.html?goods_id=1",
"https://mobile.yangkeduo.com:443/goods.html?goods_id=1",
"https://user@mobile.yangkeduo.com/goods.html?goods_id=1",
"https://mobile.yangkeduo.com/goods.html?goods_id=1#fragment",
"https://mobile.yangkeduo.com/goods.html?goods_id=1&goods_id=2",
"https://mobile.yangkeduo.com/goods.html?goods_id=one",
"https://mobile.yangkeduo.com/goods.html?goods_id=%31",
"https://mobile.yangkeduo.com/goods.html?goods_id=1%26goods_id%3D2",
"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=bad",
"https://mobile.yangkeduo.com/other.html?goods_id=1",
} {
if _, ok := CanonicalGoodsID(value); ok {
t.Fatalf("CanonicalGoodsID accepted %q", value)
}
}
}
func TestNormalizeMoneyBoundaries(t *testing.T) {
for value, want := range map[string]string{"1": "1.00", "1.2": "1.20", "000.01": "0.01", "999999999999999999": "999999999999999999.00"} {
got, ok := normalizeMoney(value)
if !ok || got != want {
t.Fatalf("normalizeMoney(%q) = (%q, %t), want (%q, true)", value, got, ok, want)
}
}
for _, value := range []string{"0", "0.0", "0.00", "1.", ".1", "1.000", "-1", "1e2", " 1"} {
if got, ok := normalizeMoney(value); ok {
t.Fatalf("normalizeMoney(%q) = %q, want rejection", value, got)
}
}
}
func TestNewCreateKeyIsUUIDv4(t *testing.T) {
key, err := NewCreateKey()
if err != nil {
t.Fatalf("NewCreateKey: %v", err)
}
if !regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`).MatchString(key) {
t.Fatalf("create key %q is not UUID v4", key)
}
}
func TestSQLiteStoreRequiresMigratedDatabase(t *testing.T) {
database := openDatabase(t)
if _, err := NewSQLiteStore(database); err == nil {
t.Fatal("NewSQLiteStore accepted an unmigrated database")
}
}
func TestSQLiteStoreCreatesListsAndHandlesIdempotency(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
baseTime := time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC)
call := 0
store.now = func() time.Time {
result := baseTime.Add(time.Duration(call) * time.Minute)
call++
return result
}
first := testDraft(testKey, "first")
created, err := store.CreateDraft(context.Background(), first)
if err != nil {
t.Fatalf("create first draft: %v", err)
}
replayed, err := store.CreateDraft(context.Background(), first)
if err != nil {
t.Fatalf("replay first draft: %v", err)
}
if replayed.CreatedAt != created.CreatedAt {
t.Fatalf("replayed CreatedAt = %s, want original %s", replayed.CreatedAt, created.CreatedAt)
}
second := testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "second")
if _, err := store.CreateDraft(context.Background(), second); err != nil {
t.Fatalf("create second draft: %v", err)
}
drafts, err := store.ListDrafts(context.Background())
if err != nil {
t.Fatalf("list drafts: %v", err)
}
if len(drafts) != 2 || drafts[0].ID != second.ID || drafts[1].ID != first.ID {
t.Fatalf("draft order = %#v, want second then first", drafts)
}
var source, status string
var version int
if err := database.QueryRow(`SELECT source, status, version FROM tasks WHERE id = ?`, first.ID).Scan(&source, &status, &version); err != nil {
t.Fatalf("read stored task: %v", err)
}
if source != "MANUAL" || status != "DRAFT" || version != 1 {
t.Fatalf("stored metadata = (%q, %q, %d)", source, status, version)
}
conflicting := first
conflicting.Title = "different"
if _, err := store.CreateDraft(context.Background(), conflicting); !errors.Is(err, ErrCreateKeyConflict) {
t.Fatalf("conflicting create error = %v, want ErrCreateKeyConflict", err)
}
}
func TestSQLiteStoreRollsBackFailedCreate(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
if _, err := database.Exec(`CREATE TRIGGER reject_task BEFORE INSERT ON tasks BEGIN SELECT RAISE(ABORT, 'reject test insert'); END`); err != nil {
t.Fatalf("create trigger: %v", err)
}
if _, err := store.CreateDraft(context.Background(), testDraft(testKey, "blocked")); err == nil {
t.Fatal("CreateDraft succeeded despite rejecting trigger")
}
drafts, err := store.ListDrafts(context.Background())
if err != nil {
t.Fatalf("list after failed create: %v", err)
}
if len(drafts) != 0 {
t.Fatalf("failed create persisted drafts: %#v", drafts)
}
}
func TestSQLiteStoreUsesInsertionOrderForEqualTimesAndFiltersPhase(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
store.now = func() time.Time { return time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) }
first := testDraft(testKey, "first")
second := testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "second")
for _, draft := range []Draft{first, second} {
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
t.Fatalf("create %s: %v", draft.Title, err)
}
}
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES ('excel-draft', 'EXCEL', 'other', '1', 'black', 'M', 1, '1.00', 'DRAFT', 1, '2026-08-04T10:00:00Z', '2026-08-04T10:00:00Z'), ('manual-pending', 'MANUAL', 'other', '2', 'black', 'M', 1, '1.00', 'PENDING', 1, '2026-08-04T10:00:00Z', '2026-08-04T10:00:00Z')`); err != nil {
t.Fatalf("insert out-of-scope tasks: %v", err)
}
drafts, err := store.ListDrafts(context.Background())
if err != nil {
t.Fatalf("list drafts: %v", err)
}
if len(drafts) != 2 || drafts[0].ID != second.ID || drafts[1].ID != first.ID {
t.Fatalf("equal-time draft order/filter = %#v, want second then first only", drafts)
}
if _, err := database.Exec(`UPDATE tasks SET status = 'PENDING' WHERE id = ?`, first.ID); err != nil {
t.Fatalf("move draft outside current phase: %v", err)
}
if _, err := store.CreateDraft(context.Background(), first); !errors.Is(err, ErrCreateKeyConflict) {
t.Fatalf("replay of non-DRAFT record error = %v, want conflict", err)
}
third := testDraft("c3c9f507-7473-4fa6-8d71-8786c34c6301", "third")
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'EXCEL', ?, ?, ?, ?, ?, ?, 'DRAFT', 1, '2026-08-04T09:00:00Z', '2026-08-04T09:00:00Z')`, third.ID, third.Title, third.GoodsID, third.SKUColor, third.SKUSize, third.Quantity, third.MaxTotalPrice); err != nil {
t.Fatalf("insert same-payload EXCEL record: %v", err)
}
if _, err := store.CreateDraft(context.Background(), third); !errors.Is(err, ErrCreateKeyConflict) {
t.Fatalf("replay of non-MANUAL record error = %v, want conflict", err)
}
if _, err := database.Exec(`UPDATE tasks SET version = 2, source = 'MANUAL' WHERE id = ?`, third.ID); err != nil {
t.Fatalf("change replay record version: %v", err)
}
if _, err := store.CreateDraft(context.Background(), third); !errors.Is(err, ErrCreateKeyConflict) {
t.Fatalf("replay of non-v1 record error = %v, want conflict", err)
}
}
func TestSQLiteStoreConcurrentIdenticalCreateIsOneDraft(t *testing.T) {
store, err := NewSQLiteStore(migratedDatabase(t))
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
const callers = 20
start := make(chan struct{})
errors := make(chan error, callers)
results := make(chan Draft, callers)
var group sync.WaitGroup
for range callers {
group.Add(1)
go func() {
defer group.Done()
<-start
draft, err := store.CreateDraft(context.Background(), testDraft(testKey, "same"))
if err != nil {
errors <- err
return
}
results <- draft
}()
}
close(start)
group.Wait()
close(errors)
close(results)
for err := range errors {
t.Fatalf("concurrent create: %v", err)
}
for result := range results {
if result.ID != testKey {
t.Fatalf("concurrent result = %#v", result)
}
}
drafts, err := store.ListDrafts(context.Background())
if err != nil {
t.Fatalf("list after concurrent create: %v", err)
}
if len(drafts) != 1 || drafts[0].ID != testKey {
t.Fatalf("concurrent creates persisted %#v, want exactly one", drafts)
}
}
func testDraft(id, title string) Draft {
return Draft{ID: id, Title: title, GoodsID: "937122477375", SKUColor: "black", SKUSize: "M", Quantity: 2, MaxTotalPrice: "12.80"}
}
func openDatabase(t *testing.T) *sql.DB {
t.Helper()
database, err := sqlite.Open(filepath.Join(t.TempDir(), "tasks.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
return database
}
func migratedDatabase(t *testing.T) *sql.DB {
t.Helper()
database := openDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("migrate database: %v", err)
}
return database
}
func migrationDirectory(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate test source")
}
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
}
@@ -0,0 +1,63 @@
{{define "login.html"}}
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登录 · 采购服务</title>
<style>
:root { color-scheme: light; --bg:#f4f7fb; --surface:#fff; --text:#172033; --muted:#526079; --border:#cfd8e6; --primary:#155eef; --primary-hover:#0b4ed1; --primary-soft:#eaf1ff; --danger:#b42318; --danger-soft:#fef3f2; --focus:#ffbf47; --shadow:0 12px 30px rgba(23,32,51,.1); font-family:"Segoe UI","Microsoft YaHei UI",system-ui,sans-serif; }
* { box-sizing:border-box; }
html { min-width:320px; background:var(--bg); }
body { min-height:100dvh; margin:0; color:var(--text); background:var(--bg); font-size:16px; line-height:1.55; }
button,input { font:inherit; }
:focus-visible { outline:3px solid var(--focus); outline-offset:3px; }
.skip-link { position:fixed; z-index:10; top:8px; left:8px; padding:10px 14px; color:#fff; background:var(--text); transform:translateY(-160%); }
.skip-link:focus { transform:translateY(0); }
main { display:grid; min-height:100dvh; place-items:center; padding:24px 16px; }
.card { width:min(100%,440px); padding:32px; border:1px solid var(--border); border-radius:14px; background:var(--surface); box-shadow:var(--shadow); }
.brand { display:flex; align-items:center; gap:10px; margin:0 0 24px; font-size:1rem; font-weight:700; }
.brand-mark { display:grid; width:32px; height:32px; place-items:center; border-radius:8px; color:#fff; background:var(--primary); font-size:.82rem; }
h1 { margin:0; font-size:clamp(1.6rem,5vw,2rem); line-height:1.25; }
.intro { margin:8px 0 24px; color:var(--muted); }
.field { margin-top:16px; }
label { display:block; margin-bottom:6px; font-weight:700; }
input { width:100%; min-height:44px; padding:10px 12px; border:1px solid #9ba9bc; border-radius:8px; color:var(--text); background:#fff; }
input[aria-invalid="true"] { border-color:var(--danger); box-shadow:0 0 0 1px var(--danger); }
.hint { margin:5px 0 0; color:var(--muted); font-size:.875rem; }
.error { margin:0 0 18px; padding:12px 14px; border-left:4px solid var(--danger); border-radius:6px; color:var(--danger); background:var(--danger-soft); font-weight:650; }
.submit { width:100%; min-height:44px; margin-top:24px; padding:10px 16px; border:1px solid transparent; border-radius:8px; color:#fff; background:var(--primary); font-weight:700; cursor:pointer; transition:background-color 180ms ease-out; }
.submit:hover { background:var(--primary-hover); }
.notice { margin:20px 0 0; padding:12px 14px; border:1px solid #b9cffc; border-radius:8px; color:#29466f; background:var(--primary-soft); font-size:.9rem; }
@media (max-width:420px) { main { padding-inline:12px; } .card { padding:24px 16px; } }
@media (prefers-reduced-motion:reduce) { *,*::before,*::after { transition-duration:.01ms !important; animation-duration:.01ms !important; } }
</style>
</head>
<body>
<a class="skip-link" href="#main">跳到主要内容</a>
<main id="main">
<section class="card" aria-labelledby="login-title">
<p class="brand"><span class="brand-mark" aria-hidden="true">采</span><span>采购服务</span></p>
<h1 id="login-title">管理端登录</h1>
<p class="intro">登录后进入采购任务工作台。设备身份不能使用此入口。</p>
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
<form method="post" action="/login">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="return_to" value="{{.ReturnTo}}">
<div class="field">
<label for="username">账号</label>
<input id="username" name="username" type="text" value="{{.Username}}" autocomplete="username" required aria-invalid="{{if .Error}}true{{else}}false{{end}}" aria-describedby="username-hint">
<p class="hint" id="username-hint">使用采购管理员账号登录。</p>
</div>
<div class="field">
<label for="password">密码</label>
<input id="password" name="password" type="password" autocomplete="current-password" required aria-invalid="{{if .Error}}true{{else}}false{{end}}">
</div>
<button class="submit" type="submit">登录并继续</button>
</form>
<p class="notice">系统只创建待付款订单,付款始终由人完成。</p>
</section>
</main>
</body>
</html>
{{end}}
@@ -0,0 +1,19 @@
{{define "tasks.html"}}
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>采购任务 · 采购服务</title>
<style>
:root{--bg:#f4f7fb;--surface:#fff;--text:#172033;--muted:#526079;--border:#cfd8e6;--primary:#155eef;--danger:#b42318;--success:#067647;--focus:#ffbf47;font-family:"Segoe UI","Microsoft YaHei UI",system-ui,sans-serif}*{box-sizing:border-box}html{min-width:320px;background:var(--bg)}body{min-height:100dvh;margin:0;color:var(--text);background:var(--bg);font-size:16px;line-height:1.55}button,input{font:inherit}:focus-visible{outline:3px solid var(--focus);outline-offset:3px}.skip{position:fixed;z-index:100;top:8px;left:8px;padding:10px;color:#fff;background:#172033;transform:translateY(-160%)}.skip:focus{transform:translateY(0)}header{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:64px;padding:10px clamp(16px,4vw,40px);border-bottom:1px solid var(--border);background:var(--surface)}.brand{font-weight:700}.brand b{display:inline-grid;place-items:center;width:32px;height:32px;margin-right:8px;border-radius:8px;background:var(--primary);color:#fff;font-size:.82rem}.logout,.button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:9px 14px;border:1px solid var(--border);border-radius:8px;color:var(--text);background:#fff;font-weight:700;text-decoration:none;cursor:pointer}.button.primary{border-color:var(--primary);background:var(--primary);color:#fff}.button:disabled,.filter input:disabled{opacity:.5;cursor:not-allowed}main{width:min(100% - 32px,1200px);margin:32px auto}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:16px}.toolbar-actions,.filters,.actions{display:flex;flex-wrap:wrap;gap:10px}.muted,.placeholder{color:var(--muted)}.filters{align-items:end;margin:0 0 16px}.filters label{display:grid;gap:4px;font-weight:700}.filters input{min-height:44px;min-width:180px;padding:8px 10px;border:1px solid var(--border);border-radius:8px;background:#fff}.table-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:12px;background:var(--surface)}table{width:100%;min-width:880px;border-collapse:collapse}th,td{padding:12px 14px;border-bottom:1px solid var(--border);text-align:left;vertical-align:top}th{background:#f8fafc;font-size:.88rem}td a{color:#124cc5;font-weight:700;text-underline-offset:3px}.status{display:inline-block;padding:3px 8px;border-radius:999px;background:#eaf1ff;color:#173d8f;font-size:.85rem;font-weight:700}.empty,.success{padding:20px;border:1px solid var(--border);border-radius:12px;background:var(--surface)}.success{margin:0 0 16px;border-color:#9dd9b8;background:#ecfdf3;color:var(--success)}.modal-scrim{position:fixed;z-index:20;inset:0;background:rgba(23,32,51,.52)}dialog[open]{position:fixed;z-index:30;top:50%;left:50%;width:min(calc(100% - 24px),640px);max-height:calc(100dvh - 24px);margin:0;padding:28px;overflow-y:auto;border:1px solid var(--border);border-radius:14px;box-shadow:0 18px 48px rgba(23,32,51,.24);transform:translate(-50%,-50%);background:var(--surface)}.form-page{width:min(100% - 32px,640px);margin:32px auto;padding:28px;border:1px solid var(--border);border-radius:14px;background:var(--surface)}.form-grid{display:grid;gap:16px}.field label{display:block;margin-bottom:6px;font-weight:700}.required{color:var(--danger)}.field input{width:100%;min-height:44px;padding:10px 12px;border:1px solid #9ba9bc;border-radius:8px}.field input[aria-invalid=true]{border-color:var(--danger)}.error{margin:5px 0 0;color:var(--danger);font-size:.9rem}.summary{margin:0 0 16px;padding:12px;border-left:4px solid var(--danger);background:#fef3f2;color:var(--danger)}.summary p{margin:0}.summary ul{margin:8px 0 0;padding-left:20px}.summary a{color:inherit}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:420px){main,.form-page{width:calc(100% - 24px);margin:24px auto}.toolbar{align-items:stretch;flex-direction:column}.toolbar-actions,.toolbar .button{width:100%}.toolbar-actions .button{flex:1}.filters{align-items:stretch;flex-direction:column}.filters input,.filters .button{width:100%}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition-duration:.01ms!important;animation-duration:.01ms!important}}</style>
</head>
<body>
<a class="skip" href="#main">跳到主要内容</a>
<header><div class="brand"><b aria-hidden="true">采</b>采购服务</div><form method="post" action="/logout"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><button class="logout" type="submit">退出登录</button></form></header>
{{if .FullPage}}<main class="form-page" id="main">{{template "form" .}}</main>{{else}}<main id="main"><div class="toolbar"><div><h1>采购任务</h1><p class="muted">只显示待开始的手工任务。</p></div><div class="toolbar-actions"><button class="button" type="button" disabled>导入</button><a class="button primary" href="/tasks?create=1">创建任务</a></div></div><div class="filters" aria-label="暂不可用的列表条件"><label>关键词<input type="search" disabled></label><button class="button" type="button" disabled>筛选</button><button class="button" type="button" disabled>清除</button></div>{{if .Success}}<p class="success" role="status">任务已创建,已显示在列表首行。</p>{{end}}{{if .Drafts}}<div class="table-wrap"><table><thead><tr><th scope="col"><input type="checkbox" disabled aria-label="选择全部任务"></th><th scope="col">标题</th><th scope="col">颜色分类</th><th scope="col">尺码</th><th scope="col">价格上限</th><th scope="col">数量</th><th scope="col">采购结果</th><th scope="col">状态</th><th scope="col">创建时间</th></tr></thead><tbody>{{range .Drafts}}<tr><td><input type="checkbox" disabled aria-label="选择任务 {{.Title}}"></td><td><a href="https://mobile.yangkeduo.com/goods.html?goods_id={{.GoodsID}}" target="_blank" rel="noopener noreferrer">{{.Title}}</a></td><td>{{.SKUColor}}</td><td>{{.SKUSize}}</td><td>¥{{.MaxTotalPrice}}</td><td>{{.Quantity}}</td><td>—</td><td><span class="status">待开始</span></td><td><time datetime="{{.CreatedAt.Format "2006-01-02T15:04:05Z07:00"}}">{{.CreatedAt.Format "2006-01-02 15:04 UTC"}}</time></td></tr>{{end}}</tbody></table></div>{{else}}<section class="empty"><h2>还没有待开始任务</h2><p>创建一条手工任务后会显示在这里。</p></section>{{end}}</main>{{if .OpenForm}}<div class="modal-scrim" aria-hidden="true"></div><dialog open aria-modal="true" aria-labelledby="form-title">{{template "form" .}}</dialog>{{end}}{{end}}
</body>
</html>
{{end}}
{{define "form"}}<h1 id="form-title">创建采购任务</h1><p class="muted">保存后仅生成待开始任务,不会执行其他动作。</p>{{if .Errors}}<div class="summary" role="alert" aria-live="assertive"><p>请修正下列字段后再保存。</p><ul>{{with index .Errors "title"}}<li><a href="#title">任务名称:{{.}}</a></li>{{end}}{{with index .Errors "product_url"}}<li><a href="#product_url">商品链接:{{.}}</a></li>{{end}}{{with index .Errors "sku_color"}}<li><a href="#sku_color">颜色分类:{{.}}</a></li>{{end}}{{with index .Errors "sku_size"}}<li><a href="#sku_size">尺码:{{.}}</a></li>{{end}}{{with index .Errors "quantity"}}<li><a href="#quantity">数量:{{.}}</a></li>{{end}}{{with index .Errors "max_total_price"}}<li><a href="#max_total_price">价格上限:{{.}}</a></li>{{end}}{{with index .Errors "create_key"}}<li>{{.}}</li>{{end}}</ul></div>{{end}}<form method="post" action="/tasks" class="form-grid"><input type="hidden" name="csrf_token" value="{{.CSRFToken}}"><input type="hidden" name="create_key" value="{{.Form.CreateKey}}"><input type="hidden" name="form_mode" value="{{if .FullPage}}full{{else}}dialog{{end}}">{{template "field" (list "title" "任务名称" .Form.Title .Errors .FocusField)}}{{template "field" (list "product_url" "商品链接" .Form.ProductURL .Errors .FocusField)}}{{template "field" (list "sku_color" "颜色分类" .Form.SKUColor .Errors .FocusField)}}{{template "field" (list "sku_size" "尺码" .Form.SKUSize .Errors .FocusField)}}{{template "field" (list "quantity" "数量" .Form.Quantity .Errors .FocusField)}}{{template "field" (list "max_total_price" "价格上限" .Form.MaxTotalPrice .Errors .FocusField)}}<div class="actions"><button class="button primary" type="submit">保存任务</button><a class="button" href="/tasks">取消</a></div></form>{{end}}
{{define "field"}}{{$name:=index . 0}}{{$label:=index . 1}}{{$value:=index . 2}}{{$errors:=index . 3}}{{$focus:=index . 4}}<div class="field"><label for="{{$name}}">{{$label}} <span class="required" aria-hidden="true">*</span><span class="sr-only">(必填)</span></label><input id="{{$name}}" name="{{$name}}" value="{{$value}}" required {{if eq $focus $name}}autofocus{{end}} aria-invalid="{{if index $errors $name}}true{{else}}false{{end}}"{{with index $errors $name}} aria-describedby="{{$name}}-error"{{end}} {{if eq $name "product_url"}}type="url" inputmode="url" maxlength="2048"{{else if eq $name "quantity"}}type="number" inputmode="numeric" min="1" step="1"{{else if eq $name "max_total_price"}}type="text" inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?" maxlength="64"{{else if eq $name "title"}}type="text" maxlength="120"{{else}}type="text" maxlength="80"{{end}}>{{with index $errors $name}}<p class="error" id="{{$name}}-error">{{.}}</p>{{end}}</div>{{end}}
+45
View File
@@ -0,0 +1,45 @@
// Package webui 渲染采购服务当前可用的服务端页面。
package webui
import (
"embed"
"html/template"
"io"
"cmbuyer/admin/internal/tasks"
)
//go:embed templates/*.html
var templateFiles embed.FS
var templates = template.Must(template.New("webui").Funcs(template.FuncMap{"list": func(values ...any) []any { return values }}).ParseFS(templateFiles, "templates/*.html"))
// LoginData 是登录页面所需的非敏感展示数据。
type LoginData struct {
CSRFToken string
ReturnTo string
Username string
Error string
}
// TasksData 是受保护的 DRAFT 建单与列表页面所需数据。
type TasksData struct {
CSRFToken string
Drafts []tasks.Draft
Form tasks.Form
Errors tasks.Errors
OpenForm bool
FullPage bool
FocusField string
Success bool
}
// RenderLogin 写入登录页。
func RenderLogin(writer io.Writer, data LoginData) error {
return templates.ExecuteTemplate(writer, "login.html", data)
}
// RenderTasks 写入登录后的受保护任务页。
func RenderTasks(writer io.Writer, data TasksData) error {
return templates.ExecuteTemplate(writer, "tasks.html", data)
}
@@ -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,6 +1,7 @@
"""T-103 原始规格面板证据的本机确定性隐私脱敏。 """T-103 原始规格面板证据的本机确定性隐私脱敏。
此模块只处理人工采集的本地文件:不连接设备、不理解拼多多页面,也不识别规格或价格。 此模块只处理人工采集的本地文件:不连接设备、不识别规格;仅可按已取证的固定
几何和严格格式,将跨隐私边界的价格叶节点投影到派生 XML。
""" """
from __future__ import annotations from __future__ import annotations
@@ -21,7 +22,7 @@ from ..pdd.product_url import ProductUrl, ProductUrlError, parse_product_url
from ..pdd.sku_panel_state import HUMAN_DECLARED_STATES from ..pdd.sku_panel_state import HUMAN_DECLARED_STATES
SANITIZER_VERSION = "t103-privacy-v3" SANITIZER_VERSION = "t103-privacy-v5"
EXPECTED_GOODS_ID = "937122477375" EXPECTED_GOODS_ID = "937122477375"
EXPECTED_PDD_VERSION = "8.17.0" EXPECTED_PDD_VERSION = "8.17.0"
EXPECTED_DEVICE_MODEL = "PKG110" EXPECTED_DEVICE_MODEL = "PKG110"
@@ -37,6 +38,29 @@ _FULL_PHONE_RE = re.compile(r"(?:\+?86)?1[3-9]\d{9}")
_MASKED_PHONE_RE = re.compile(r"1[3-9]\d\*{4}\d{4}") _MASKED_PHONE_RE = re.compile(r"1[3-9]\d\*{4}\d{4}")
_MASK_TRANSLATION = str.maketrans({"*": "*", "•": "*", "·": "*", "×": "*", "x": "*", "X": "*"}) _MASK_TRANSLATION = str.maketrans({"*": "*", "•": "*", "·": "*", "×": "*", "x": "*", "X": "*"})
_SEPARATOR_RE = re.compile(r"[\s\-‐‑‒–—―()()]+") _SEPARATOR_RE = re.compile(r"[\s\-‐‑‒–—―()()]+")
# 这两个槽位来自 T-103 当前第一态、1080×2376 XML 坐标的人工审查。它们不是通用
# 页面判据;坐标、文本或结构任何变化都停止发布,交由人重新取证。
_CROSSING_PRICE_SLOTS = {
(396, 503, 712, 570): "[396,503][712,570]",
(730, 503, 895, 570): "[730,503][895,570]",
}
_CROSSING_PRICE_BOUNDS = frozenset(_CROSSING_PRICE_SLOTS)
_PRICE_PROJECTION_ATTRIBUTES = (
"bounds",
"text",
"package",
"class",
"clickable",
"enabled",
"visible-to-user",
)
# 仅接受普通 ASCII 空格,且每个可分隔位置最多一个;禁止换行、折扣、支付/提交文案和
# 任何其它字符。前缀捕获组用于区分当前价与至多一个划线/原价候选。
# T-103 人工在 live 规格面板确认当前价槽的完整非敏感前缀仅为“快卖完”;不得兼容
# 未取证的“快卖光”或其它相近文案。
_CROSSING_PRICE_TEXT_RE = re.compile(r" {0,1}(?:(快卖完) {0,1})?[¥¥] {0,1}[1-9]\d*\.\d{2} {0,1}\Z")
_CROSSING_PRICE_PREFIX_RE = re.compile(r" {0,1}(?:快卖完 {0,1})?[¥¥] {0,1}[1-9]\d*\.\d{2} {0,1}")
_CROSSING_PRICE_ALLOWED_CHARACTERS = frozenset(" 快卖完¥¥0123456789.")
class SkuEvidenceSanitizationError(RuntimeError): class SkuEvidenceSanitizationError(RuntimeError):
@@ -49,9 +73,12 @@ class _CleanupStats:
removed_nodes: int = 0 removed_nodes: int = 0
cleared_crossing_nodes: int = 0 cleared_crossing_nodes: int = 0
preserved_crossing_price_nodes: int = 0
retained_below_nodes: int = 0 retained_below_nodes: int = 0
max_right: int = 0 max_right: int = 0
max_bottom: int = 0 max_bottom: int = 0
current_price_candidates: int = 0
original_price_candidates: int = 0
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -271,6 +298,7 @@ def _sanitize_hierarchy(source: Path, target: Path) -> _CleanupStats:
_require_expected_xml_coordinate_space(stats) _require_expected_xml_coordinate_space(stats)
if stats.removed_nodes < 1 or stats.retained_below_nodes < 1: if stats.removed_nodes < 1 or stats.retained_below_nodes < 1:
raise SkuEvidenceSanitizationError("原始节点树未满足隐私几何结构。") raise SkuEvidenceSanitizationError("原始节点树未满足隐私几何结构。")
_require_safe_crossing_price_projection(stats)
if _contains_phone(root): if _contains_phone(root):
raise SkuEvidenceSanitizationError("派生节点树仍包含手机号,拒绝发布。") raise SkuEvidenceSanitizationError("派生节点树仍包含手机号,拒绝发布。")
ElementTree.ElementTree(root).write(target, encoding="utf-8", xml_declaration=True) ElementTree.ElementTree(root).write(target, encoding="utf-8", xml_declaration=True)
@@ -294,9 +322,12 @@ def _sanitize_node(parent: ElementTree.Element, node: ElementTree.Element, stats
parent.remove(node) parent.remove(node)
return return
if position == "crossing": if position == "crossing":
# 全屏/跨界容器可保留其下方子节点,但自身所有属性和文本都可能含地址或手机号。 if bounds in _CROSSING_PRICE_BOUNDS and node.get("text"):
_clear_node_text(node) _project_crossing_price_node(node, bounds, stats)
stats.cleared_crossing_nodes += 1 else:
# 全屏/跨界容器可保留其下方子节点,但自身所有属性和文本都可能含地址或手机号。
_clear_node_text(node)
stats.cleared_crossing_nodes += 1
else: else:
stats.retained_below_nodes += 1 stats.retained_below_nodes += 1
for child in list(node): for child in list(node):
@@ -340,6 +371,85 @@ def _vertical_position(bounds: tuple[int, int, int, int]) -> str:
return "crossing" return "crossing"
def _project_crossing_price_node(
node: ElementTree.Element,
bounds: tuple[int, int, int, int],
stats: _CleanupStats,
) -> None:
"""投影唯一允许的跨界价格叶节点;任何结构漂移一律拒绝发布。"""
if (
len(node) != 0
or node.get("package") != "com.xunmeng.pinduoduo"
or node.get("class") != "android.widget.TextView"
or node.get("clickable") != "false"
or node.get("enabled") != "true"
or node.get("visible-to-user") != "true"
):
raise SkuEvidenceSanitizationError("跨界价格节点结构不匹配,拒绝发布。")
text = node.get("text")
if text is None:
raise SkuEvidenceSanitizationError("跨界价格节点文本不匹配,拒绝发布。")
match = _CROSSING_PRICE_TEXT_RE.fullmatch(text)
if match is None:
raise _crossing_price_text_mismatch_error(bounds, text)
# 只有这七项经上述检查后可进入派生 XML;尤其不复制 content-desc、resource-id 等原始属性。
node.attrib = {attribute: node.attrib[attribute] for attribute in _PRICE_PROJECTION_ATTRIBUTES}
node.text = None
node.tail = None
stats.preserved_crossing_price_nodes += 1
if match.group(1) is not None:
stats.current_price_candidates += 1
else:
stats.original_price_candidates += 1
def _crossing_price_text_mismatch_error(
bounds: tuple[int, int, int, int],
text: str,
) -> SkuEvidenceSanitizationError:
"""仅输出固定槽位与 reason,避免将任意 raw 正文带入 CLI 或日志。"""
reason = _crossing_price_text_mismatch_reason(text)
slot = _CROSSING_PRICE_SLOTS[bounds]
return SkuEvidenceSanitizationError(f"跨界价格节点文本不匹配:slot={slot};reason={reason}。")
def _crossing_price_text_mismatch_reason(text: str) -> str:
"""将未匹配文本归类为受控枚举;返回值绝不包含原始片段。"""
if "\r" in text or "\n" in text:
return "newline"
if any(character.isspace() and character != " " for character in text):
return "non_ascii_whitespace"
if any(marker in text for marker in ("提交订单", "支付", "下单", "优惠")):
return "extra_or_order"
without_one_leading_space = text[1:] if text.startswith(" ") else text
if without_one_leading_space.startswith("快要抢光"):
return "observed_prefix_kuaiyaoqiangguang"
if without_one_leading_space.startswith("快") and not without_one_leading_space.startswith("快卖完"):
return "known_prefix_missing"
if "¥" not in text and "¥" not in text:
return "currency_missing"
if _CROSSING_PRICE_PREFIX_RE.match(text) is not None:
return "extra_or_order"
if any(character not in _CROSSING_PRICE_ALLOWED_CHARACTERS for character in text):
return "forbidden_characters"
return "amount_shape"
def _require_safe_crossing_price_projection(stats: _CleanupStats) -> None:
"""当前价必须唯一;原价仅可选且唯一,避免把任意金额释放为价格证据。"""
if (
stats.current_price_candidates != 1
or stats.original_price_candidates > 1
or stats.preserved_crossing_price_nodes != stats.current_price_candidates + stats.original_price_candidates
):
raise SkuEvidenceSanitizationError("跨界价格候选不唯一或缺失,拒绝发布。")
def _clear_node_text(node: ElementTree.Element) -> None: def _clear_node_text(node: ElementTree.Element) -> None:
node.attrib = {"bounds": node.attrib["bounds"]} if "bounds" in node.attrib else {} node.attrib = {"bounds": node.attrib["bounds"]} if "bounds" in node.attrib else {}
node.text = None node.text = None
@@ -415,6 +525,7 @@ def _derived_manifest(
"privacy_cleanup": { "privacy_cleanup": {
"removed_nodes": cleanup_stats.removed_nodes, "removed_nodes": cleanup_stats.removed_nodes,
"cleared_crossing_nodes": cleanup_stats.cleared_crossing_nodes, "cleared_crossing_nodes": cleanup_stats.cleared_crossing_nodes,
"preserved_crossing_price_nodes": cleanup_stats.preserved_crossing_price_nodes,
"retained_below_nodes": cleanup_stats.retained_below_nodes, "retained_below_nodes": cleanup_stats.retained_below_nodes,
"max_right": cleanup_stats.max_right, "max_right": cleanup_stats.max_right,
"max_bottom": cleanup_stats.max_bottom, "max_bottom": cleanup_stats.max_bottom,
@@ -34,6 +34,10 @@ TEST_ADDRESS = "SYNTHETIC_ADDRESS_NEVER_PUBLISH"
FULL_PHONE = "13800138000" FULL_PHONE = "13800138000"
MASKED_PHONE = "138****0000" MASKED_PHONE = "138****0000"
SAFE_TEXT = "synthetic-safe-lower-content" SAFE_TEXT = "synthetic-safe-lower-content"
CURRENT_PRICE = "快卖完 ¥12.88"
ORIGINAL_PRICE = "¥29.00"
PRICE_CURRENT_BOUNDS = "[396,503][712,570]"
PRICE_ORIGINAL_BOUNDS = "[730,503][895,570]"
def _hash(path: Path) -> str: def _hash(path: Path) -> str:
@@ -48,11 +52,56 @@ def _default_xml() -> str:
return ( return (
"<hierarchy rotation='0'>" "<hierarchy rotation='0'>"
f"<node bounds='[0,0][1080,540]' text='{TEST_ADDRESS}' content-desc='{MASKED_PHONE} {FULL_PHONE}' />" f"<node bounds='[0,0][1080,540]' text='{TEST_ADDRESS}' content-desc='{MASKED_PHONE} {FULL_PHONE}' />"
f"{_price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS)}"
f"{_price_node(ORIGINAL_PRICE, PRICE_ORIGINAL_BOUNDS)}"
f"<node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' />" f"<node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' />"
"</hierarchy>" "</hierarchy>"
) )
def _price_node(
text: str,
bounds: str,
*,
package: str = "com.xunmeng.pinduoduo",
node_class: str = "android.widget.TextView",
clickable: str = "false",
enabled: str = "true",
visible: str = "true",
extra_attributes: str = "",
children: str = "",
) -> str:
attributes = (
f"bounds='{bounds}' text='{text}' package='{package}' class='{node_class}' "
f"clickable='{clickable}' enabled='{enabled}' visible-to-user='{visible}'{extra_attributes}"
)
return f"<node {attributes}>{children}</node>"
def _xml_with_prices(
current: str = CURRENT_PRICE,
original: str = ORIGINAL_PRICE,
*,
current_node: str | None = None,
original_node: str | None = None,
include_original: bool = True,
extra_nodes: str = "",
) -> str:
current_markup = current_node if current_node is not None else _price_node(current, PRICE_CURRENT_BOUNDS)
original_markup = (
original_node if original_node is not None else _price_node(original, PRICE_ORIGINAL_BOUNDS)
) if include_original else ""
return (
"<hierarchy>"
f"<node bounds='[0,0][1080,540]' text='{TEST_ADDRESS}' />"
f"{current_markup}"
f"{original_markup}"
f"{extra_nodes}"
f"<node bounds='[0,570][1080,2376]' text='{SAFE_TEXT}' />"
"</hierarchy>"
)
def _write_raw( def _write_raw(
root: Path, root: Path,
*, *,
@@ -117,7 +166,7 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
self.assertNotIn(MASKED_PHONE, derived_xml) self.assertNotIn(MASKED_PHONE, derived_xml)
self.assertIn(f'"human_declared_state": "{state}"', manifest) self.assertIn(f'"human_declared_state": "{state}"', manifest)
self.assertIn('"privacy_tier": "SANITIZED"', manifest) self.assertIn('"privacy_tier": "SANITIZED"', manifest)
self.assertIn('"sanitizer_version": "t103-privacy-v3"', manifest) self.assertIn('"sanitizer_version": "t103-privacy-v5"', manifest)
self.assertIn('"screenshot_space": {', manifest) self.assertIn('"screenshot_space": {', manifest)
self.assertIn('"xml_coordinate_space": {', manifest) self.assertIn('"xml_coordinate_space": {', manifest)
self.assertIn('"height": 2376', manifest) self.assertIn('"height": 2376', manifest)
@@ -125,6 +174,7 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
self.assertIn('"privacy_mask_rectangle": [', manifest) self.assertIn('"privacy_mask_rectangle": [', manifest)
self.assertIn('"removed_nodes": 1', manifest) self.assertIn('"removed_nodes": 1', manifest)
self.assertIn('"cleared_crossing_nodes": 0', manifest) self.assertIn('"cleared_crossing_nodes": 0', manifest)
self.assertIn('"preserved_crossing_price_nodes": 2', manifest)
self.assertIn('"retained_below_nodes": 1', manifest) self.assertIn('"retained_below_nodes": 1', manifest)
self.assertIn('"max_right": 1080', manifest) self.assertIn('"max_right": 1080', manifest)
self.assertIn('"max_bottom": 2376', manifest) self.assertIn('"max_bottom": 2376', manifest)
@@ -140,6 +190,8 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
"<hierarchy>" "<hierarchy>"
f"<node bounds='[0,0][1080,2376]' text='{TEST_ADDRESS}' content-desc='{MASKED_PHONE}'>" f"<node bounds='[0,0][1080,2376]' text='{TEST_ADDRESS}' content-desc='{MASKED_PHONE}'>"
f"<node bounds='[0,0][1080,540]' text='{FULL_PHONE}' />" f"<node bounds='[0,0][1080,540]' text='{FULL_PHONE}' />"
f"{_price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS)}"
f"{_price_node(ORIGINAL_PRICE, PRICE_ORIGINAL_BOUNDS)}"
f"<node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' />" f"<node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' />"
"</node></hierarchy>" "</node></hierarchy>"
) )
@@ -152,20 +204,281 @@ class SkuEvidenceSanitizerTests(unittest.TestCase):
self.assertIsNotNone(crossing) self.assertIsNotNone(crossing)
assert crossing is not None assert crossing is not None
self.assertEqual(crossing.attrib, {"bounds": "[0,0][1080,2376]"}) self.assertEqual(crossing.attrib, {"bounds": "[0,0][1080,2376]"})
self.assertEqual(len(list(crossing)), 1) self.assertEqual(len(list(crossing)), 3)
self.assertEqual(list(crossing)[0].get("text"), SAFE_TEXT) self.assertEqual(list(crossing)[-1].get("text"), SAFE_TEXT)
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8")) manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
self.assertEqual( self.assertEqual(
manifest["privacy_cleanup"], manifest["privacy_cleanup"],
{ {
"removed_nodes": 1, "removed_nodes": 1,
"cleared_crossing_nodes": 1, "cleared_crossing_nodes": 1,
"preserved_crossing_price_nodes": 2,
"retained_below_nodes": 1, "retained_below_nodes": 1,
"max_right": 1080, "max_right": 1080,
"max_bottom": 2376, "max_bottom": 2376,
}, },
) )
def test_only_strict_crossing_price_leaves_are_projected_with_whitelisted_attributes(self) -> None:
xml = _xml_with_prices(
current_node=_price_node(
CURRENT_PRICE,
PRICE_CURRENT_BOUNDS,
extra_attributes=" content-desc='discard' resource-id='discard' focused='true'",
),
original_node=_price_node(
ORIGINAL_PRICE,
PRICE_ORIGINAL_BOUNDS,
extra_attributes=" content-desc='discard-too' resource-id='discard-too'",
),
)
with TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), xml=xml)
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
root = ElementTree.parse(result.hierarchy_path).getroot()
prices = [node for node in root.findall("node") if node.get("text") in {CURRENT_PRICE, ORIGINAL_PRICE}]
self.assertEqual(len(prices), 2)
for node in prices:
self.assertEqual(
set(node.attrib),
{"bounds", "text", "package", "class", "clickable", "enabled", "visible-to-user"},
)
self.assertEqual(node.get("package"), "com.xunmeng.pinduoduo")
self.assertEqual(node.get("class"), "android.widget.TextView")
self.assertEqual(node.get("clickable"), "false")
self.assertEqual(node.get("enabled"), "true")
self.assertEqual(node.get("visible-to-user"), "true")
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
self.assertEqual(manifest["privacy_cleanup"]["preserved_crossing_price_nodes"], 2)
self.assertNotIn("discard", result.hierarchy_path.read_text(encoding="utf-8"))
def test_crossing_price_window_rejects_text_and_structure_drift(self) -> None:
bad_texts = (
f"快卖完 ¥12.88 {TEST_ADDRESS}",
f"快卖完 ¥12.88 {FULL_PHONE}",
"快卖完 ¥12.88 使用微信支付",
"快卖完 ¥12.88 提交订单",
"快卖完 ¥12.88 优惠-11元",
"快要抢光 ¥12.88",
"快卖完 ¥0.00",
"快卖完 ¥12.8",
"快卖完 ¥12.880",
"快卖完 ¥12.88",
)
for text in bad_texts:
with self.subTest(text=text), TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=text))
with self.assertRaises(SkuEvidenceSanitizationError):
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
self.assertFalse((raw.parent / "derived").exists())
def test_crossing_price_text_mismatch_reports_only_fixed_slot_and_reason(self) -> None:
newline_node = _price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS).replace(
"快卖完 ¥12.88", "快卖完&#10;¥12.88"
)
cases = (
("newline", _xml_with_prices(current_node=newline_node), "newline", PRICE_CURRENT_BOUNDS),
(
"non-ascii-whitespace",
_xml_with_prices(current="快卖完 ¥12.88"),
"non_ascii_whitespace",
PRICE_CURRENT_BOUNDS,
),
(
"known-prefix-missing",
_xml_with_prices(current="快要抢光 ¥12.88"),
"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", "快卖完&#10;¥12.88"
)
with TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current_node=encoded_newline))
with self.assertRaises(SkuEvidenceSanitizationError):
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
self.assertFalse((raw.parent / "derived").exists())
bad_structure = (
("package", {"package": "com.android.systemui"}),
("class", {"node_class": "android.view.View"}),
("clickable", {"clickable": "true"}),
("disabled", {"enabled": "false"}),
("hidden", {"visible": "false"}),
("children", {"children": "<node bounds='[400,510][500,520]' />"}),
)
for name, kwargs in bad_structure:
with self.subTest(structure=name), TemporaryDirectory() as temporary:
raw = _write_raw(
Path(temporary),
xml=_xml_with_prices(current_node=_price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS, **kwargs)),
)
with self.assertRaises(SkuEvidenceSanitizationError):
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
self.assertFalse((raw.parent / "derived").exists())
def test_crossing_price_candidates_require_unique_current_and_at_most_one_original(self) -> None:
scenarios = (
(
"missing-current",
_xml_with_prices(current="¥12.88", original=ORIGINAL_PRICE),
),
(
"duplicate-current",
_xml_with_prices(current=CURRENT_PRICE, original=f"快卖完 {ORIGINAL_PRICE}"),
),
(
"multiple-original",
_xml_with_prices(
extra_nodes=_price_node("¥39.88", PRICE_ORIGINAL_BOUNDS),
),
),
)
for name, xml in scenarios:
with self.subTest(name=name), TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), xml=xml)
with self.assertRaises(SkuEvidenceSanitizationError):
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
self.assertFalse((raw.parent / "derived").exists())
def test_crossing_price_outside_fixed_windows_is_cleared_and_submit_price_is_not_candidate(self) -> None:
outside_crossing = _price_node("快卖光 ¥99.99", "[396,498][712,570]")
submit = (
"<node bounds='[369,2225][710,2284]' text='提交订单 ¥12.88' "
"package='com.xunmeng.pinduoduo' class='android.widget.TextView' clickable='false' "
"enabled='true' visible-to-user='true' resource-id='submit-button' />"
)
with TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), xml=_xml_with_prices(extra_nodes=outside_crossing + submit))
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
hierarchy = result.hierarchy_path.read_text(encoding="utf-8")
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
self.assertNotIn("快卖光 ¥99.99", hierarchy)
self.assertIn("提交订单 ¥12.88", hierarchy)
self.assertIn("submit-button", hierarchy)
self.assertEqual(manifest["privacy_cleanup"]["preserved_crossing_price_nodes"], 2)
def test_same_raw_and_config_produce_identical_derived_files(self) -> None: def test_same_raw_and_config_produce_identical_derived_files(self) -> None:
with TemporaryDirectory() as temporary: with TemporaryDirectory() as temporary:
root = Path(temporary) root = Path(temporary)
+24 -29
View File
@@ -9,13 +9,10 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
**采购工具**(桌面端,`client/`)驱动 Android 手机在拼多多完成找货和下单, **采购工具**(桌面端,`client/`)驱动 Android 手机在拼多多完成找货和下单,
**付款始终由人完成**。 **付款始终由人完成**。
第一版 MVP 只做**任务自带商品链接**的情形,**分两趟跑**: 第一版 MVP 只做**任务自带商品链接**的情形。创建任务不授权;管理员勾选 `DRAFT` 并点击
“开始采购(只创建待付款订单)”后,服务端锁定商品、规格、数量和最高总价并签发一次性授权。
1. **第一趟试选**:桌面端定时领取 → 开商品 → 精确勾选颜色分类和尺码 → 读单价 → 采购工具领取后在**同一趟**完成精确选规格、三道价格闸门、数量复核、确认页、提交围栏和唯一一次
截图 → **退出释放手机** → 回传。 “提交订单”点击。中间不等待人工确认;订单创建后转待付款,由人核对和付款。
2. **人工确认**:管理员在网页端看「机器选对了吗」,确认后签发授权并**锁定单价**。
3. **第二趟下单**:重新开商品 → 重新选同一规格 → **三道价格闸门** → 提交订单一次 →
任务转「待付款」,人在拼多多核对后付款。
## 上下文读取 ## 上下文读取
@@ -24,7 +21,7 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
1. [`01-vision.md`](01-vision.md):为什么做、为谁做、什么不做。 1. [`01-vision.md`](01-vision.md):为什么做、为谁做、什么不做。
2. [`02-requirements.md`](02-requirements.md):MVP 要什么、怎么算达成。 2. [`02-requirements.md`](02-requirements.md):MVP 要什么、怎么算达成。
3. [`03-tech-stack.md`](03-tech-stack.md):两端各自的技术选型与验证矩阵。 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):写代码前必须遵守的规则。 5. [`05-coding-rules.md`](05-coding-rules.md):写代码前必须遵守的规则。
6. [`06-tasks.md`](06-tasks.md):阶段路线图与建议拆分。 6. [`06-tasks.md`](06-tasks.md):阶段路线图与建议拆分。
7. [`tasks/README.md`](tasks/README.md):任务文件约定。 7. [`tasks/README.md`](tasks/README.md):任务文件约定。
@@ -61,7 +58,7 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
### 2. 安全边界只能收紧,不能放宽 ### 2. 安全边界只能收紧,不能放宽
[`04-architecture.md`](04-architecture.md) 第四节的硬约束(不付款、提交订单四条件与服务端围栏、 [`04-architecture.md`](04-architecture.md) 第四节的硬约束(不付款、提交订单四条件与服务端围栏、
规格精确匹配、提交控件唯一、数量必须复核、三道价格闸门、第一趟不下单、外部支付停止、 规格精确匹配、提交控件唯一、数量必须复核、三道价格闸门、隔离能力不越界、外部支付停止、
安全校验停止、敏感信息不提取、授权一次性)**不得在任务中顺手放宽**。 安全校验停止、敏感信息不提取、授权一次性)**不得在任务中顺手放宽**。
确需变更时先改架构文档并说明理由,再动代码。任何「为了让流程跑通先放宽一下」的改动 确需变更时先改架构文档并说明理由,再动代码。任何「为了让流程跑通先放宽一下」的改动
@@ -77,11 +74,11 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
理由与三道闸门的定义见 [`04-architecture.md`](04-architecture.md) 第三节。 理由与三道闸门的定义见 [`04-architecture.md`](04-architecture.md) 第三节。
### 4. 第一趟绝不下单 ### 4. 真机能力逐段取证、隔离开放
试选阶段只勾选规格和读价,**绝不点击「现在买」或任何进入下单流程的入口**。 T-103 的 `SkuSelectionFlow` 只允许证据绑定的受控入口、精确选择、读价和安全退出;不得引用
第一趟的代码路径不得引用 `go_to_order_confirm()` 与 `submit_order()`,必须有测试 数量、确认页、提交围栏、提交或付款能力。T-105~T-107 分别取证后才可组合。生产单趟只有在全部
证明它们不可达。 能力均获证据、三道闸门通过且服务端围栏明确许可后才能点击一次;支付能力永不开放。
## 当前阶段 ## 当前阶段
@@ -90,19 +87,18 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
执行按任务依赖驱动,**不按 Phase 整段串行等待**。当前优先路径: 执行按任务依赖驱动,**不按 Phase 整段串行等待**。当前优先路径:
1. T-001~T-004 与 T-101~T-102 已完成,继续推进 **T-103 真机取证**。 1. T-111 先冻结“开始采购授权”的单趟契约;同时主审并合入只创建 `DRAFT` 的 T-202。
2. T-103 进行时并行推进 T-201 管理会话 → T-202 `DRAFT` 建单与基础列表;两者不得启动试选, 2. T-111 完成后恢复 **T-103 真机取证**;按依赖推进 T-104 → T-107 的分段安全判据。
也不得引入机器结果、规格面板单价或证据字段。 3. admin agent 先做 T-209 把旧两趟 schema / 状态机迁到单趟,再推进 T-203 服务端批量开始采购 /
3. T-103 结论确认后,再推进 T-203 批量开始试选、T-204 试选证据详情、T-205~T-207, 一次性授权;两者不依赖页面选择器。T-205 起的 attempt 与证据字段等待 T-103。
并按依赖推进 T-104 → T-107 后续真机安全判据。 4. Phase 3:双端打通与单趟围栏前 dry-run。
4. Phase 3:双端打通与**第一趟试选**端到端。 5. Phase 4:单趟真实提交、待付款、调和与收尾。
5. Phase 4:**第二趟下单**与收尾。
6. V2 及以后:图搜、Excel、ERP、订单自动核对、AI 辅助。 6. V2 及以后:图搜、Excel、ERP、订单自动核对、AI 辅助。
> **M2 是本项目的生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、 > **M2 是本项目的生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、
> **读到该 SKU 的单价**(T-103)。前序项目正是卡在选规格和读价。 > **读到该 SKU 的单价**(T-103)。前序项目正是卡在选规格和读价。
> M2 不通过之前不要写**依赖真机可读字段或会启动试选**的 Phase 2 功能;T-201 与仅创建 > M2 不通过之前不要写**依赖真机可读字段的生产执行逻辑**;管理会话、`DRAFT` 建单和只锁定
> `DRAFT` 的 T-202 可以并行。原型只确认流程与信息架构,实际可读字段仍以真机证据为准。 > 已有任务字段的服务端授权事务可以并行。实际可读字段仍以真机证据为准。
## 领取任务规则 ## 领取任务规则
@@ -123,12 +119,11 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
只做: 只做:
- **手工填链接**建单、任务查询、勾选待开始任务后批量开始第一趟试选 - **手工填链接**建单、任务查询、勾选待开始任务后批量“开始采购”
- 桌面端**定时轮询**领取(试选与下单两类) - 点击开始采购即签发锁定商品、规格、数量和最高总价的一次性授权
- 第一趟试选:精确勾选规格、读单价、截图、退出释放手机 - 桌面端定时轮询只领取已授权任务,在同一趟精确选规格、两次读价、复核数量和确认页金额
- 人工确认「机器选对了吗」并签发授权(**锁定单价**) - 围栏前失败/过期可人工回到待开始;围栏后只能调和
- 授权超时与放弃,任务不会被永久锁死 - 三道闸门全过、服务端围栏明确许可后提交订单一次
- 第二趟下单:三道闸门全过后提交订单一次
- 真实点击前服务端原子建立提交围栏;围栏失败不点击,围栏后只调和同一提交记录 - 真实点击前服务端原子建立提交围栏;围栏失败不点击,围栏后只调和同一提交记录
- 待付款展示订单截图,人核对付款后手工标记完成 - 待付款展示订单截图,人核对付款后手工标记完成
- 失败分类与转人工 - 失败分类与转人工
@@ -168,7 +163,7 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
| 采购服务页面 | `02-requirements.md` 验收 → `07-user-stories.md` → `08-interaction-checklist.md` → `routes.md` → `04-architecture.md` | | 采购服务页面 | `02-requirements.md` 验收 → `07-user-stories.md` → `08-interaction-checklist.md` → `routes.md` → `04-architecture.md` |
| 采购工具界面 | 同上,`routes.md` 看第三节桌面端结构 | | 采购工具界面 | 同上,`routes.md` 看第三节桌面端结构 |
| 设备侧 API | `api.md` → `04-architecture.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` | | 数据模型 | `04-architecture.md` 第五节;schema 变化必须同步 `api.md` 和 `current-state.md` |
| 部署 / 运行 | `03-tech-stack.md` → `current-state.md` | | 部署 / 运行 | `03-tech-stack.md` → `current-state.md` |
+7 -6
View File
@@ -5,15 +5,15 @@
cmbuyer 要解决:**采购人员为了履约一笔外部订单,必须手工去拼多多找到同款商品、选对 cmbuyer 要解决:**采购人员为了履约一笔外部订单,必须手工去拼多多找到同款商品、选对
颜色尺码、下单,再把订单号抄回系统——这个过程重复、易错、且无法追溯。** 颜色尺码、下单,再把订单号抄回系统——这个过程重复、易错、且无法追溯。**
> 让采购人员把「买什么」一次说清楚,由系统驱动手机完成找货、选规格和下单,人只在 > 让采购人员把“买什么、买多少、最多多少钱”一次说清楚,并明确点击开始采购;系统驱动手机
> 两个关键点介入:**挑哪一个**和**付不付款**。 > 精确选规格并创建待付款订单,**付不付款始终由人决定**。
它不是无人值守的抢购脚本,也不是绕过平台规则的爬虫。它是一个**带人工闸门的采购执行 它不是无人值守的抢购脚本,也不是绕过平台规则的爬虫。它是一个**带人工闸门的采购执行
工具**:机器负责重复劳动,人保留花钱的决定权。 工具**:机器负责重复劳动,人保留花钱的决定权。
## 二、目标用户 ## 二、目标用户
- **采购管理员**:在网页端建单、复核候选商品、签发下单授权、查看执行证据。 - **采购管理员**:在网页端建单、点击开始采购签发一次性授权、查看执行证据。
- **采购执行员**:在桌面端连接手机、启动批次、处理需要人工接管的任务、完成付款。 - **采购执行员**:在桌面端连接手机、启动批次、处理需要人工接管的任务、完成付款。
- **ERP 对接身份**:只读同步第三方系统的货运单与商品明细,不参与采购决策。 - **ERP 对接身份**:只读同步第三方系统的货运单与商品明细,不参与采购决策。
- **系统管理员(后续)**:人员、设备、权限和审计策略管理,MVP 不提供完整界面。 - **系统管理员(后续)**:人员、设备、权限和审计策略管理,MVP 不提供完整界面。
@@ -27,7 +27,8 @@ cmbuyer 要解决:**采购人员为了履约一笔外部订单,必须手工
绝不猜测。宁可少做,不可做错。 绝不猜测。宁可少做,不可做错。
- **先走确定路径**:MVP 只做已知商品链接的情形。图片搜索推到 V2,且届时其唯一职责 - **先走确定路径**:MVP 只做已知商品链接的情形。图片搜索推到 V2,且届时其唯一职责
是产出 `goods_id`,不在搜索结果页上做价格或规格判断。 是产出 `goods_id`,不在搜索结果页上做价格或规格判断。
- **人站在不可逆动作正前方**:机器先试选并回传,人看过之后才授权下单。 - **授权点明确且前置**:创建任务不授权;管理员点击“开始采购”才允许创建一笔待付款订单。
- **实时闸门不依赖旧截图**:授权后同一设备会话两次读取规格面板价格,并在确认页校验总额。
- **价格只在可靠位置读**:规格面板和订单确认页。别处的数字一律不信。 - **价格只在可靠位置读**:规格面板和订单确认页。别处的数字一律不信。
- **失败要可诊断**:不能只返回「失败」,必须有步骤、错误码、截图和页面快照。 - **失败要可诊断**:不能只返回「失败」,必须有步骤、错误码、截图和页面快照。
- **证据分层**:人工决策需要的证据上传服务端,完整执行轨迹留在桌面端本地。 - **证据分层**:人工决策需要的证据上传服务端,完整执行轨迹留在桌面端本地。
@@ -37,9 +38,9 @@ cmbuyer 要解决:**采购人员为了履约一笔外部订单,必须手工
| 价值点 | 用户得到什么 | | 价值点 | 用户得到什么 |
| --- | --- | | --- | --- |
| 消除重复劳动 | 不再逐条手工搜索、选规格、抄订单号 | | 消除重复劳动 | 不再逐条手工搜索、选规格、抄订单号 |
| 决策集中可控 | 所有「买哪个」的决定收敛到网页端一处,有据可查 | | 决策集中可控 | 商品、规格、数量、最高总价和开始采购授权收敛到网页端,有据可查 |
| 资金边界清晰 | 系统能下单不能付款,误操作不会直接造成损失 | | 资金边界清晰 | 系统能下单不能付款,误操作不会直接造成损失 |
| 执行可追溯 | 每笔采购留下候选、截图、授权理由和订单核对记录 | | 执行可追溯 | 每笔采购留下授权、三道闸门、截图、围栏和订单核对记录 |
| 批量顺序执行 | 一次导入多条,按顺序跑,遇到问题停在该停的地方 | | 批量顺序执行 | 一次导入多条,按顺序跑,遇到问题停在该停的地方 |
## 五、不做什么(非目标) ## 五、不做什么(非目标)
+107 -134
View File
@@ -1,50 +1,48 @@
# 需求 # 需求
> 本文只描述**要什么**与**怎么算达成**,用产品 / 用户语言表达,**不涉及技术实现**。 > 本文只描述**要什么**与**怎么算达成**,用产品 / 用户语言表达。技术方案、数据结构与
> 技术方案、数据结构、字段定义见 [架构设计](04-architecture.md)。 > 字段定义见[架构设计](04-architecture.md)。
## 一、业务现状 ## 一、业务现状
| 项 | 状态 | | 项 | 状态 |
| --- | --- | | --- | --- |
| 用户 | 采购人员为履约外部订单,逐条手工在拼多多找同款、选颜色尺码、下单、抄回订单号 | | 用户 | 采购人员为履约外部订单,逐条手工在拼多多找同款、选颜色尺码、下单、抄回订单号 |
| 任务来源 | 第三方 ERP(顺运宝)货运单与商品明细为主;Excel 批量导入和手工填链接为补充 | | MVP 任务来源 | 管理员手工填写拼多多商品链接、规格、数量和金额上限 |
| 数据 | ERP 提供商品标题、规格、数量、参考图;部分明细带拼多多链接,部分只有图 | | 后续任务来源 | Excel 与第三方 ERP(顺运宝)货运明细 |
| 现有系统 | 两个前序原型(见 [愿景](01-vision.md) 第六节),代码不直接复用 |
| 设备 | 一台 Windows 电脑 + 一台已登录拼多多的 Android 手机,USB 或 WiFi ADB 连接 | | 设备 | 一台 Windows 电脑 + 一台已登录拼多多的 Android 手机,USB 或 WiFi ADB 连接 |
| 约束 | 不得绕过平台风控;付款必须人工;手机需保持亮屏解锁 | | 约束 | 不绕过平台风控;系统只创建待付款订单;付款必须由人完成;手机保持亮屏解锁 |
## 二、用户角色 ## 二、用户角色
- **采购管理员**:在网页端建单、查询、复核候选、签发下单授权、查看审计记录。 - **采购管理员**:在采购服务建单、查询、点击“开始采购”签发一次性采购授权、查看审计记录。
- **采购执行员**:在桌面端连接设备、启动批次、处理待人工任务、在拼多多完成付款。 - **采购执行员**:在采购工具连接设备、启动轮询、处理待人工任务、在拼多多完成人工付款。
- **ERP 对接身份**:只读同步货运单与商品明细,不能建单、授权或访问采购结果。 - **ERP 对接身份**:只读同步货运单与商品明细,不能建单、授权或访问采购结果。
- **设备身份**:一台已授权桌面端实例,用于领取任务和回传结果,不能建单或授权。 - **设备身份**:一台已授权采购工具实例,用于领取已授权任务和回传结果,不能建单或授权。
- **未登录用户**:不能访问任何任务、图片、证据或设备接口。 - **未登录用户**:不能访问任何任务、图片、证据或设备接口。
## 三、功能清单 ## 三、功能清单
### 第一版 MVP(最小闭环) ### 第一版 MVP(最小闭环)
MVP 只做**任务自带商品链接**的情形,分两趟执行:第一趟试选并回传,人确认后第二趟下单。 MVP 只做**任务自带商品链接**的情形。管理员点击“开始采购”后,采购工具在同一次设备会话中
流程见[架构设计](04-architecture.md)第三节。 完成选规格、校验价格与数量、创建待付款订单;中间不再回到采购服务等待“机器选对了吗”。
| ID | 功能 | 用户能做什么 | 优先级 | 关联用户故事 | | ID | 功能 | 用户能做什么 | 优先级 | 关联用户故事 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| F-001 | 手工建单 | 管理员在任务列表内填写任务名称、拼多多链接、颜色分类、尺码、数量、价格上限,保存一条待开始任务 | P0 | US-001 | | F-001 | 手工建单 | 填写标题、拼多多链接、颜色分类、尺码、数量、最高总价,保存为待开始任务 | P0 | US-001 |
| F-004 | 任务查询 | 管理员按关键词、状态、时间范围找到目标任务 | P0 | US-002 | | F-004 | 任务查询 | 按标题关键词、状态、时间范围找到任务,双击行查看详情 | P0 | US-002 |
| F-005 | 桌面端定时领取 | 执行员启动会话后,桌面端定时轮询领取待试选和已授权两类任务 | P0 | US-003 | | F-005 | 桌面端定时领取 | 执行员启动会话后定时领取已获采购授权的任务 | P0 | US-003 |
| F-006 | 第一趟试选 | 系统打开商品、按维度精确勾选颜色分类和尺码、读单价、截图后退出 | P0 | US-003 | | F-006 | 单趟采购执行 | 打开商品、精确选择规格、校验价格、设置数量、进入确认页并创建待付款订单 | P0 | US-003、US-005 |
| F-007 | 试选结果回传 | 管理员看到机器实际选中的规格、单价、合计和规格面板截图 | P0 | US-004 | | F-007 | 执行证据回传 | 管理员查看实际规格、三道闸门、原始页面截图和步骤记录 | P0 | US-002、US-008 |
| F-008 | 人工确认与授权 | 管理员确认机器选对了,签发一次性授权并锁定价格;或退回不买 | P0 | US-004、US-005 | | F-008 | 开始采购授权 | 管理员批量点击一次,原子锁定每条任务并签发一次性待付款订单授权 | P0 | US-005、US-010 |
| F-009 | 第二趟下单 | 系统重新选同一规格、过三道价格闸门后提交订单,回传订单截图 | P0 | US-005 | | F-010 | 围栏前过期与重新授权 | 未建立提交围栏的失败或过期任务回到待开始,可由管理员重新点击开始采购 | P0 | US-005 |
| F-010 | 授权超时与放弃 | 管理员在授权卡住时能放弃并重新确认,任务不会被永久锁死 | P0 | US-005 | | F-011 | 结果与失败分类回传 | 查看成功、失败、已取消、待人工及可诊断原因和证据 | P0 | US-002、US-008 |
| F-011 | 结果与失败分类回传 | 管理员看到成功、失败、已取消、待人工,以及可区分的失败原因和证据 | P0 | US-002、US-008 | | F-013 | 登录与身份隔离 | 管理员用账号登录;采购工具用设备凭据接入,权限不互通 | P0 | US-007 |
| F-013 | 登录与身份隔离 | 管理员用账号登录网页端;桌面端用设备凭据接入,两者权限不互通 | P0 | US-007 | | F-017 | 提交围栏与调和 | 真机点击前服务端原子冻结授权;点击后只调和同一提交,绝不重试 | P0 | US-005、US-008 |
| F-017 | 下单 dry-run、提交围栏与调和 | 真机先只读演练到订单确认页;真实点击前由服务端原子冻结授权;点击后只调和结果、绝不重试 | P0 | US-005、US-008 | | F-018 | 批量开始采购 | 勾选一条或多条待开始任务,统一授权采购工具创建待付款订单 | P0 | US-010 |
| F-018 | 批量开始试选 | 管理员勾选一条或多条待开始任务,统一放入第一趟试选队列 | P0 | US-010 |
> F-002、F-003、F-012 已移出 MVP,编号保留不重用,见下表。 > 原 F-009 的“两趟下单”并入 F-006;F-002、F-003、F-012 移出 MVP,编号不重用。
### 后续迭代 ### 后续迭代
@@ -52,153 +50,128 @@ MVP 只做**任务自带商品链接**的情形,分两趟执行:第一趟试
| --- | --- | --- | | --- | --- | --- |
| F-002 Excel 批量建单 | 上传固定表头表格批量生成任务 | V2,被表头契约待确认阻塞 | | F-002 Excel 批量建单 | 上传固定表头表格批量生成任务 | V2,被表头契约待确认阻塞 |
| F-003 从 ERP 货运明细建单 | 同步顺运宝货运单后复核生成任务 | V2,被字段映射待确认阻塞 | | F-003 从 ERP 货运明细建单 | 同步顺运宝货运单后复核生成任务 | V2,被字段映射待确认阻塞 |
| F-012 批量顺序编排与人工接管 | 对已开始任务排序、暂停继续、运行中接管 | V2;MVP 仅支持批量开始试选,实际执行仍由定时轮询领取 | | F-012 批量编排与运行中接管 | 排序、暂停继续、运行中接管 | V2;MVP 只批量授权,设备逐条领取 |
| F-014 图片搜索路径(B 路径) | 任务只有参考图时搜图产出 goods_id 候选 | V2 | | F-014 图片搜索路径 | 任务只有参考图时搜图产出 goods_id 候选 | V2 |
| F-015 候选对照台 | 多个候选并排对照挑选 | V2,随 F-014 | | F-015 候选对照台 | 多个候选并排对照挑选 | V2,随 F-014 |
| F-016 订单自动核对回读 | 付款后只读读取订单页做五项唯一匹配并回写 | V2;MVP 用截图 + 人眼核对 | | F-016 订单自动核对回读 | 付款后只读读取订单页做唯一匹配并回写 | V2;MVP 用截图 + 人眼核对 |
| F-101 AI 辅助候选判断 | 用模型看搜索结果页截图判断同款 | V2 之后 | | F-101 AI 辅助候选判断 | 用模型看搜索结果页截图判断同款 | V2 之后 |
| F-102 AI 辅助页面理解 | 规则读不到规格或价格时用模型兜底 | V2 之后 | | F-102 AI 辅助页面理解 | 规则读不到规格或价格时用模型兜底 | V2 之后 |
| F-103 执行轨迹本地留档 | 记录模型输入输出与规则判断的分歧 | V2 之后 | | F-103 执行轨迹本地留档 | 记录模型输入输出与规则判断分歧 | V2 之后 |
| F-104 多设备并行 | 一个桌面端驱动多台手机 | V2 之后 | | F-104 多设备并行 | 一个采购工具驱动多台手机 | V2 之后 |
| F-105 完整 RBAC | 管理员、执行员、审核员细粒度权限 | V2 之后 | | F-105 完整 RBAC | 管理员、执行员、审核员细粒度权限 | V2 之后 |
| F-106 多平台比价 | 淘宝、1688、京东 | V3 | | F-106 多平台比价 | 淘宝、1688、京东 | V3 |
| 支付自动化 | **不在规划内** | 未规划 | | 支付自动化 | **不在规划内** | 未规划 |
## 四、核心用户故事(MVP) ## 四、核心用户故事(MVP)
详细故事以[用户故事清单](07-user-stories.md)为准;本文只维护功能、优先级与 US 编号的 详细故事以[用户故事清单](07-user-stories.md)为准。
索引,避免两处成为相互冲突的权威来源。
| 功能 | 用户故事 | 优先级 | | 功能 | 用户故事 | 优先级 |
| --- | --- | --- | | --- | --- | --- |
| F-001 | US-001 | P0 | | 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-005、F-006 | US-003 | P0 |
| F-007、F-008 | US-004 | P0 | | F-008、F-010、F-017 | US-005 | P0 |
| F-008、F-009、F-010、F-017 | US-005 | P0 |
| F-013 | US-007 | P0 | | F-013 | US-007 | P0 |
| F-011 | US-008 | P0 | | F-007、F-011、F-017 | US-008 | P0 |
| F-018 | US-010 | P0 | | F-008、F-018 | US-010 | P0 |
US-006(付款前核对)在 MVP 降级为:系统展示订单截图与授权信息,人在拼多多自行核对 US-004 的试选后人工确认已从 MVP 删除。US-006 在 MVP 为:系统展示订单截图与授权信息,
后付款并手工标记完成。自动回读核对是 F-016(V2)。US-009(ERP 建单)随 F-003 移出 MVP。 人在拼多多核对、付款并手工标记完成;自动回读核对是 F-016(V2)。
## 五、验收标准(MVP) ## 五、验收标准(MVP)
### 建单 ### 建单与授权
- **F-001**:在任务列表点击创建,填入任务名称、合法拼多多链接、颜色分类、尺码、数量、 - **F-001**:任务列表点击创建,填入标题、合法拼多多链接、颜色分类、尺码、数量和最高总价后
价格上限后保存,返回任务编号;弹窗关闭,任务按创建时间倒序出现在第一行且状态为待开始。 保存,弹窗关闭;新任务按创建时间倒序出现在第一行,状态为 `DRAFT`。非法链接或无法解析
链接格式非法或无法解析出 `goods_id` 时明确报错并保留已填内容。 `goods_id` 时明确报错并保留输入。
- **F-018**:列表只允许勾选待开始任务;全选只覆盖当前筛选结果中可开始的任务。点击 - 创建任务**不构成采购授权**,设备不能领取 `DRAFT`。
「开始试选」后,被选任务原子转为待领取,并明确提示这只是进入第一趟试选队列, - **F-018**:只允许勾选当前筛选结果中的 `DRAFT`。点击“开始采购(只创建待付款订单)”前,
**不签发下单授权、不创建订单、不付款**。任一任务状态已变化时整批不提交,刷新后重选。 界面显示已选数量和最高总额;点击本身即管理员明确授权,不再增加选品确认步骤。
- 服务端在一个事务中校验整批任务仍为预期版本,逐条锁定 `goods_id`、颜色、尺码、数量、最高
总价、授权人和有效期,并把任务转 `PENDING`。任一条冲突时整批不变,刷新后重选。
- 相同 `start_key` 重放返回同一结果;不得产生第二份有效授权。成功后即时提示“已授权 N 条,
采购工具只会创建待付款订单,不会付款”。
### 第一趟:试选 ### 单趟采购执行
- **F-005**:执行员启动会话后桌面端定时轮询,同时领取待试选与已授权两类任务。 - **F-005**:采购工具只领取 `PENDING` 且授权有效的任务。并发领取同一条时只有一个成功;关闭
两个实例并发领取同一条时只有一个成功,另一个得到明确的「无可领任务」而不是报错。 会话即停止轮询,连续失败达到阈值自动停止并显示原因。
**关闭会话即停止轮询;连续失败达到阈值自动停止并提示原因。** - **F-006 受控入口**:只点击与本项目真机证据及 App 版本绑定的精确唯一规格面板入口。当前仅确认
- **F-006**:手机打开对应商品详情页,只点击与当前拼多多版本及本项目真机证据绑定的精确唯一 拼多多 `8.17.0`、goods_id `937122477375` 的“快要抢光”;其他入口必须分别取证,不按购买语义泛化。
**受控规格面板入口**,打开规格面板后按维度精确匹配颜色分类和尺码。当前只确认拼多多 - **F-006 规格与数量**:颜色分类、尺码必须在各自维度内精确唯一匹配并读回;数量设置后必须读回
`8.17.0`、goods_id `937122477375` 上的“快要抢光”;其他入口文案必须分别取证,不能按购买 等于任务值。缺失、重复、禁用、前缀碰撞、读回不一致都停止,不选相近项。
语义泛化。 - **F-006 三道价格闸门**:价格只从规格面板和订单确认页读取,金额使用十进制字符串。
**任一维度找不到精确值即停止并转人工,不选相近选项。** 勾选后读取该 SKU 单价 1. 选中目标规格后读取当前 SKU 单价,计算 `单价 × 数量`,不得超过授权最高总价。
(闸门一),读不到即转人工,**不用商品详情页正文或搜索页的数字凑合**。 2. 设置并复核数量后再次读取同一规格与单价;规格必须仍相同,单价必须与闸门一完全相等。
- **F-006 释放要求**:试选完成后**必须退出商品页释放手机**,不得停在规格面板等待人工。 3. 订单确认页核对规格、数量与应付总额;总额不得超过授权最高总价。
- **F-006 硬边界**:第一趟只把上述精确入口点击视为可逆的“打开规格面板”能力;进入面板后 任一道读不到、有歧义或不通过都停止并转人工,不从详情页、搜索页或按钮文字凑价格。
不得调整数量、进入订单确认页、点击“提交订单”或任何支付/资金控件,也不得暴露通用任意点击能力。 - 同一设备会话连续完成上述步骤;不退出商品再等采购服务确认,也不把旧截图或旧页面读数当作
必须有静态调用链和测试证明上述下单语义动作不可达。入口缺失、重复、版本失配或打开后不是已取证 当前提交依据。
面板时立即停止,不尝试“免拼购买 / 单独购买 / 直接拼成”等相似文案。
- **F-007**:回传商品标题、实际勾选到的颜色分类与尺码、单价、合计(单价 × 数量)和
**自动脱敏后的**规格面板截图;任务转「等你确认」。原始 screenshot/XML 只能留在采购工具本机
隔离目录,不能上传、写日志或提交 Git。
### 决策与资金 ### 提交围栏与资金边界
- **F-008**:管理员在确认页看到需求与机器所选的对照、单价、合计和截图,点击确认后 - **F-017**:闸门二、闸门三和提交控件唯一均通过后,采购工具把当前验证摘要发给采购服务并申请
签发一次性授权,**授权锁定当次试选的单价**。授权重复提交幂等,不产生第二笔订单。 提交围栏。服务端原子复核任务版本、一次性授权未消费、领取身份、规格、数量、闸门值和唯一性,
管理员也可选择「退回,不买」,任务终止。 创建或重放唯一 `order_submission`,才返回 `click_permitted=true`。
- **F-009 三道闸门**:第二趟重新打开商品并重新勾选同一规格后, - 围栏申请失败、冲突、超时、断网或响应不明确时**不得点击**。围栏成功后只可精确点击一次
① 重读单价必须与授权锁定价一致; “提交订单”;点击后无论结果、超时、外部支付交接或安全校验都禁止重试。
② 数量设置后读回必须精确等于要求值; - 明确创建订单后任务转 `WAITING_PAYMENT`;结果不明确转 `RECONCILIATION_REQUIRED`,授权和围栏
③ 订单确认页「实付款」不得超过授权总额上限。 保持占用,只能调和同一提交记录。
**任一道不通过即停止并转人工。** - 围栏建立前,授权过期、设备失败或人工放弃可使任务回到 `DRAFT`;必须由管理员重新点击“开始采购”
- **F-009 提交条件**:授权存在且未消费、闸门全过、「提交订单」控件文本精确相等且可 产生新授权。围栏建立后不得释放、重新授权、重新领取或再次点击。
点击祖先唯一——四者同时满足,且服务端已原子建立提交围栏,才允许**点击一次**。点击后 - 系统任何路径都不点击支付、免密支付、先用后付或扣款控件;提交订单后的代码路径不含支付动作。
无论超时、跳外部支付还是遇到安全校验,**一律进入结果调和或人工核查且禁止重试**。 - 发布真实下单能力前必须用独立真机任务完成只读 dry-run:到订单确认页验证规格、数量、金额和提交
- **F-010**:授权带过期时间。建立提交围栏前,超时或主动放弃都会作废授权并让任务进入 控件唯一但不点击。dry-run 是版本发布门禁,不是每笔订单的第一趟,也不能代替实时三道闸门。
「待重新试选」,不得直接复用旧试选再次确认;建立提交围栏后,授权不得过期或放弃,
只能调和结果或转人工核查。**任何状态都必须给出安全且可执行的下一步。**
- **F-017 dry-run**:首次真实下单前必须先完成一次只读演练:到订单确认页读取规格、数量、
「实付款」,验证提交控件唯一,然后停止并退出;**不点击「提交订单」**。演练结果与证据
必须回传,真实提交不得把旧演练当成当前页面事实。
- **F-017 提交围栏**:desk 端在真实点击前向 web 端申请提交围栏;web 端必须在一个原子
事务中复核任务版本、授权未消费、命令与演练关联正确,然后冻结授权并生成唯一
`order_submission`。申请失败或响应不明确时不得点击。围栏成功后只能点击一次;点击结果
不明确时保留金额额度并进入调和,不能重新申请或重新点击。
- **资金硬边界**:系统在任何路径下都不点击支付、免密支付、先用后付或任何扣款控件。
必须有测试证明提交订单之后不调用任何支付动作。
### 结果与异常 ### 结果、证据与异常
- **F-011**:失败必须可区分至少这些原因:设备未连接、商品页打不开、规格面板打不开、 - **F-007**:按步骤回传实际规格、两次规格面板价格、数量读回、确认页总额、判定、时间和截图。
规格不匹配、单价读不到、单价与授权价不符、数量设置失败、金额超上限、提交控件不唯一、 截图是内部审计证据,不是提交前审批前置。
页面识别失败、安全校验、外部支付交接、超时。原始截图/页面快照只留采购工具本机隔离目录; - 内部系统允许上传规格面板与订单确认页的原始截图,截图中可保留页面已显示的地址和手机号;只允许
脱敏成功时保留派生物,任何上传、远程审阅或服务端展示都只能使用自动复检通过的派生证据。 已认证设备显式上传、已登录管理员查看。完整 XML 只留采购工具本机,不上传;地址/手机号不解析为
- **版本失配**:运行时读取到的拼多多 App 版本与当前已取证版本不一致时,桌面端必须停止 业务字段或日志;外部支付页、支付凭据永不上传。
领取真机任务并提示重新取证;不得继续使用旧页面判据。 - **F-011**:至少区分设备未连接、版本失配、商品打不开、面板打不开、规格不匹配、价格不可读、
- **付款收口(MVP 简化版)**:订单创建后任务转「待付款」,页面展示订单截图、商品、 两次价格不一致、数量复核失败、金额超上限、确认页不一致、提交控件不唯一、围栏失败、页面识别
规格、数量和授权金额供人核对。**人在拼多多付款后手工标记完成。** 自动回读核对是 失败、安全校验、外部支付交接和超时。
F-016(V2)。 - 运行时拼多多 App 版本与已取证版本不一致时停止领取真机任务,提示重新取证。
- 订单明确创建后展示截图、商品、规格、数量和授权金额;人在拼多多付款后手工标记完成。
**待付款不等于采购完成。**
### 通用 ### 通用
- 每条 P0 判据关联至少一个 US 编号;有用户界面的判据同时关联相关 IX 编号。 - 每条 P0 判据关联至少一个 US;有用户界面的判据同时关联 IX。
- 任务终态一次原子回写,中间态不落盘产生「看起来在跑其实已死」的记录。 - 状态流转与提交记录原子落盘,不产生“界面在跑但服务端无记录”的悬空事实。
- 失败页面给出可执行下一步;不会把“重试”显示在已建立围栏的提交上。
## 六、范围边界与决策 ## 六、范围边界与决策
| 问题 | 决策 | | 问题 | 决策 |
| --- | --- | | --- | --- |
| 第一版平台 | 网页端(管理)+ Windows 桌面端(执行),驱动一台 Android 手机 | | 第一版平台 | 采购服务(网页端)+ 采购工具(Windows 桌面端)+ 一台 Android 手机 |
| 是否需要账号 | 是。管理员账号 + 桌面端设备凭据,两套身份分离 | | 身份 | 管理员账号 + 设备凭据,两套身份分离 |
| 第一版范围 | 手工建单 → 批量开始试选 → 定时领取 → **第一趟试选** → 人工确认 → dry-run / 提交围栏 → **第二趟下单** → 待付款 | | 第一版流程 | 手工建单 → 批量开始采购并授权 → 定时领取 → 单趟选规格 / 三闸门 / 围栏 / 提交一次 → 待付款 |
| 任务来源 | **仅手工填链接。** Excel 与 ERP 移出 MVP | | 人工授权点 | 管理员点击“开始采购(只创建待付款订单)”;不再有试选后确认 |
| 找货方式 | **仅按链接。** 图片搜索移出 MVP | | 任务来源 | 仅手工填链接;Excel 与 ERP 移出 MVP |
| 找货方式 | 仅按链接;图片搜索移出 MVP |
| 采购平台 | 仅拼多多 | | 采购平台 | 仅拼多多 |
| 领取方式 | 定时轮询,只在执行员启动的会话内运行 | | 领取方式 | 定时轮询,只在执行员启动的会话内运行 |
| 任务启动 | 创建后为待开始;管理员在列表勾选一条或多条并统一开始第一趟试选 | | 设备连接 | ADB over USB 或 WiFi;同一手机不得以两种方式同时在线 |
| 设备连接 | ADB over USB 或 WiFi 均支持;同一台手机不得同时以两种方式在线 | | 付款收口 | 系统展示待付款订单证据,人核对、付款并手工标记完成 |
| 付款收口 | 系统展示订单截图,人核对后付款并手工标记完成 |
| 暂不支持 | 自动付款、图搜、Excel、ERP、多设备并行、多平台、退款、审批链、AI 辅助 | | 暂不支持 | 自动付款、图搜、Excel、ERP、多设备并行、多平台、退款、审批链、AI 辅助 |
## 七、待确认 / 风险点 ## 七、待确认 / 风险点
- **第三方平台风险**:拼多多 App 版本更新会改变页面结构。前序项目已观察到详情页 - **第三方平台风险**:拼多多更新会改变页面结构。页面判据必须来自本项目真机证据,并绑定 App
没有独立规格入口、价格文本被拆成多个节点等变化。**每次页面结构判据都必须有真机 版本;不能从前序项目或旧版本推断。
证据,不得从旧版本推断。** - **资金风险**:管理员点击开始采购会授权创建真实待付款订单。按钮必须明确影响范围与最高总额,
- **资金风险**:涉及创建真实待付款订单。授权、金额上限、一次性围栏的规则由采购管理员 服务端一次性授权、提交围栏和点击一次共同防止重复订单;付款始终人工。
确认;付款始终人工。**下单动作在真机上第一次验证前,必须先取得授权。** - **单趟时效风险**:授权到实际执行之间价格可能变化。任务不锁定旧观察价,而是在同一趟内两次读取
- **账号风险**:手机上是真实拼多多账号。频繁自动化操作有被风控或封号的可能,需要 规格面板价格并在确认页校验总额;任何变化或歧义都停止。
可配置的动作节奏,并在检测到安全校验时立即停止。 - **账号与风控风险**:固定机器节奏可能触发平台风控。轮询和动作间隔需可配置,检测到验证码、短信、
- **自动化边界风险**:会自动点击并创建订单,属不可逆操作。必须支持 dry-run(跑到 人脸或风险校验立即停止,不绕过。
订单确认页停止)、服务端提交围栏和点击后调和;任一环节状态不明都不得继续点击。 - **隐私风险**:内部原始截图可能含地址和手机号。只允许受控内部链路;不提取结构化隐私字段,完整
- **隐私风险**:规格面板和订单确认页都会显示收货地址和掩码手机号。允许原始 screenshot/XML XML 不上传,支付凭据与外部支付页截图禁止进入系统。
仅在采购工具本机隔离目录短链路落盘供确定性脱敏器消费;业务逻辑、日志、agent、fixture 与采购 - **首次真实提交风险**:生产提交能力启用前需独立 dry-run 和明确真机授权;点击后结果不明时只能调和。
服务**不得提取或接收地址原文、手机号或支付凭据**。只有脱敏成功且自动复检通过的派生证据才能 - **待确认(移出 MVP)**:Excel 表头、ERP 字段映射、AI 模型与预算。
上传或进入开发材料,无法确认脱敏完整即 fail closed。
- **受控规格入口风险**:T-103 已证明当前衣服商品只有购买语义按钮能打开规格面板。项目所有者批准
仅把经真机取证的精确唯一入口作为第一趟可逆导航;该批准不覆盖其他文案,不授权调整数量、提交订单、
进入确认页或付款。T-103 必须用 capability 隔离和不可达测试证明边界没有扩散。
- **规格面板上的单价位置未取证(阻塞 F-006 闸门一)**:选中 SKU 后价格显示在哪个节点、
是否带「券后」前缀、是否与原价并列,尚无本项目的真机证据。**T-103 必须一并取证。**
若规格面板上无法可靠读到单价,闸门一要改为「只截图不判价」,确认页设计随之调整。
- **两趟之间的状态漂移**:第二趟重新进入时价格可能已变、规格选项可能已改、商品可能
下架。闸门二负责拦截,一律转人工——但这意味着价格波动频繁的类目会产生大量待人工。
需真机观察实际发生率。
- **定时轮询的节奏**:固定间隔的机器节奏比人工节奏更容易被风控识别。间隔需可配置,
并在检测到安全校验时立即停止轮询。具体间隔待真机观察后确定。
- **待确认(阻塞 F-002,已移出 MVP)**:Excel 表头契约的最终字段集与列名。
- **待确认(阻塞 F-003,已移出 MVP)**:ERP 货运明细到任务颜色 / 尺码的字段映射。
- **待确认(V2 之后)**:AI 辅助的模型供应商、调用预算与失败降级策略。
+17 -26
View File
@@ -43,7 +43,7 @@
| 调用位置 | 采购工具 | 已定 | PC 有算力;改 prompt 不需要重新打包 | | 调用位置 | 采购工具 | 已定 | PC 有算力;改 prompt 不需要重新打包 |
| provider | 待定 | **待定** | 需先确认预算与合规;不得由 agent 自行选定 | | provider | 待定 | **待定** | 需先确认预算与合规;不得由 agent 自行选定 |
| 凭据存储 | 采购工具本机配置文件,不入库、不上传 | 已定 | 采购服务不保存、不代理、不下发任何模型凭据 | | 凭据存储 | 采购工具本机配置文件,不入库、不上传 | 已定 | 采购服务不保存、不代理、不下发任何模型凭据 |
| 输入 | 自动复检通过的脱敏派生 XML + 页面截图 | 已定 | 原始证据只允许本机确定性脱敏器消费,AI/agent 不读取原始地址或手机号 | | 输入 | 待 V2 单独定义 | 待定 | MVP 不调用 AI;内部原始截图许可不自动扩大到外部模型 |
## 四、决策记录与演进 ## 四、决策记录与演进
@@ -137,7 +137,7 @@ D:\Portable\adb\adb.exe devices -l
或发布半成品。 或发布半成品。
T-103 已证明当前衣服商品没有独立「规格/已选」入口。T-110 只批准拼多多 `8.17.0`、goods_id T-103 已证明当前衣服商品没有独立「规格/已选」入口。T-110 只批准拼多多 `8.17.0`、goods_id
`937122477375` 上经真机确认的精确唯一 `快要抢光` 作为可逆的受控规格面板入口;“免拼购买 / 单独购买 / `937122477375` 上经真机确认的精确唯一 `快要抢光` 作为受控规格面板入口;“免拼购买 / 单独购买 /
直接拼成”等其他文案不能凭人工经验复用,必须分别重新取证。项目所有者已确认面板刚打开时目标颜色 直接拼成”等其他文案不能凭人工经验复用,必须分别重新取证。项目所有者已确认面板刚打开时目标颜色
“黑色CHA(纯棉)”和尺码“M(建议100-115)”均已自动选中;取证不强行取消选择,而是记录刚打开状态、 “黑色CHA(纯棉)”和尺码“M(建议100-115)”均已自动选中;取证不强行取消选择,而是记录刚打开状态、
人工把两个维度都改成非目标值、再恢复目标值三个真实状态。只读取证 CLI 本身仍不执行点击;三种状态 人工把两个维度都改成非目标值、再恢复目标值三个真实状态。只读取证 CLI 本身仍不执行点击;三种状态
@@ -149,33 +149,24 @@ T-103 已证明当前衣服商品没有独立「规格/已选」入口。T-110
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state target-selection-restored --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe .\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state target-selection-restored --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
``` ```
PDD 规格面板不可避免显示收货区域和掩码手机号。原始截图/XML 只能留在 PDD 规格面板不可避免显示收货区域和手机号。项目所有者确认 cmbuyer 是内部系统,正式流程允许把
`%LOCALAPPDATA%\cmbuyer\artifacts\...\raw` 隔离目录,不供 agent、fixture、业务或 HTTP 上传读取; 页面原始截图上传采购服务供已登录管理员查看,不做遮罩或裁剪。完整 XML 仍只留在
T-103 已在 `client/src/cmbuyer_client/device/sku_evidence_sanitizer.py` 实现本机确定性脱敏器,并由 `%LOCALAPPDATA%\cmbuyer\artifacts\...\raw`;采购工具运行时在内存中读取当前页面树,但只返回规格、
`client/scripts/sanitize_sku_panel_evidence.py` 提供离线 CLI。第一组新 raw 的 PNG 头部由人确认实际为 选中态、价格和页面状态摘要,不把地址或手机号解析为业务字段,也不写入日志、Git 或 Vikunja。
1080×2376,推翻了 v1 将截图和 XML 坐标空间都写成 1080×2400 的假设;v1 正确拒绝且原始证据未重采。
随后 v2 在同一份 raw 上安全报告 XML `observed 1080x2376` 并拒绝发布,证明其独立配置的
1080×2400 假设同样不成立。当前 `t103-privacy-v3` 配置精确绑定 PKG110 / Android 16 / 拼多多
8.17.0 / goods_id `937122477375`;screenshot space 与 XML coordinate space 仍分别建模、分别校验,
当前都精确为 1080×2376,且都把
`[0,0,1080,540)` 作为整宽隐私带。截图覆盖该区域;XML 递归移除区域内节点,跨界容器只清空自身
敏感属性并保留下方子节点。地址依靠已确认的整块几何隔离而不是易漏的关键词表;完整、掩码、带分隔符
或跨节点手机号残留会被自动复检拒绝。
```powershell T-103 已完成 `t103-privacy-v5` 的一次真实派生:人确认 XML 无实际地址/手机号、目标颜色
.\client\.venv\Scripts\python.exe client\scripts\sanitize_sku_panel_evidence.py --raw-dir "<证据目录>\raw" --output-dir "<证据目录>\derived" “黑色CHA(纯棉)”与尺码“M(建议100-115)”保留;安全节点显示顶部当前价“快卖完 ¥12.88”与原价
``` “¥29.88”,底部“提交订单 ¥12.88”明确属于 T-103 隔离能力的硬拒绝区。派生截图的整宽黑色隐私带
遮住了顶部价格文字的一半,这只说明旧脱敏实验不适合展示,不否定已确认的规格面板价格事实。
脱敏器校验源 manifest 与文件哈希、设备/App/商品/人工状态、截图分辨率和 XML 隐私结构;XML 所有 为加快 MVP,T-103 从此不再修改或调用截图遮罩器。现有 v5 派生 XML 只用于提取不含页面个人信息的
节点观察到的最大 right/bottom 必须精确为 1080×2376,否则只报告非敏感 observed 尺寸并拒绝。它只允许发布到 最小 fixture;T-111 完成后的 `SkuSelectionFlow` 在本机实时页面树上按拼多多 8.17.0 与面板结构读取允许字段,完整
同级且尚不存在的 `derived`;失败或发布竞态不覆盖已有目录、不留下 staging。派生 manifest 记录 页面树不落日志、不上传。T-204 直接接入原始截图上传,资产标记 `privacy_tier=INTERNAL_RAW`,只允许
`privacy_tier=SANITIZED`、sanitizer 版本、两个坐标空间、清理计数及本机 source/derived 哈希,不记录 已认证设备写入、已登录管理员读取;不上传 XML,不允许外部支付页或支付凭据。
原始路径、serial 或页面正文。只有自动复检通过并经人确认状态对应性的派生物,agent 才能读取并提取
最小 fixture、编写判据。
2026-08-04 的首轮旧证据只用于确认上述入口事实;其中旧 `initial` 不是规格面板,另两张原始截图含隐私 这个范围调整不授权 T-103 调整数量、进入确认页、点击“提交订单”或触碰支付控件。T-111 完成后,
区域,因此 XML 未读取、fixture 与选择器未生成。T-110 完成受控入口与脱敏契约后,T-103 重新取证; T-103 以 `SkuSelectionFlow` 继续:由人把两个维度改成明确的非目标值,再运行受控入口、精确选择、
该边界调整不授权第一趟调整数量、进入确认页、点击“提交订单”或触碰任何支付控件。 读价和安全退出脚本完成真机验收。生产业务最终为单趟,但能力仍按 T-103 / T-105 / T-107 分段取证。
Windows 的标准入口是仓库根 `./init.ps1`。它要求 Go、两端目录及其哨兵文件存在;已有合规 Windows 的标准入口是仓库根 `./init.ps1`。它要求 Go、两端目录及其哨兵文件存在;已有合规
`client/.venv` 时,所有采购工具检查与 validator 都使用该解释器。只有 venv 不存在时,才从 `py -0p` `client/.venv` 时,所有采购工具检查与 validator 都使用该解释器。只有 venv 不存在时,才从 `py -0p`
+241 -352
View File
@@ -1,475 +1,364 @@
# 架构设计 # 架构设计
> 本文讲「怎么把技术栈搭起来」:系统结构、职责划分、数据模型、技术难点、开发顺序。 > 本文定义系统结构、职责、单趟采购流程、安全边界和数据模型。框架与运行命令见
> 具体用了哪些框架 / 库 / 数据库 / 部署方式,见 [技术栈](03-tech-stack.md)。 > [技术栈](03-tech-stack.md),双端线协议以 [API 合约](api.md)为唯一权威。
## 一、系统结构 ## 一、系统结构
```text ```text
第三方 ERP(顺运宝) Excel 表格 人工填链接 人工填链接(MVP) Excel / ERP(V2)
│ │ │ │ │
└────────────┬───────────┴────────────────────┘ └──────────────┬───────────────┘
v v
┌─────────────────────────────────────────┐ ┌─────────────────────────────────────────┐
│ 采购服务(admin/,Go 单二进制) │ │ 采购服务(admin/,Go) │
│ · 建单、批量开始试选与任务生命周期 │ │ · 建单、查询、开始采购授权与任务状态 │
│ · 候选确认与下单授权(唯一决策权威) │ │ · 提交围栏、结果调和、内部证据与审计 │
│ · 证据存储与审计 │ │ · 服务端渲染管理页面 │
│ · 管理 Web(服务端渲染) │ └───────────────────┬─────────────────────┘
└─────────────────────────────────────────┘ │ HTTPS / JSON
^ HTTP / JSON │ Bearer + 设备绑定
│ Bearer Token + 设备绑定 v
v
┌─────────────────────────────────────────┐ ┌─────────────────────────────────────────┐
│ 采购工具(client/,Python + PySide6) │ │ 采购工具(client/,Python + PySide6) │
│ · 领任务、跑流程、回传结果 │ │ · 轮询领取、单趟执行、回传状态与证据 │
│ · 本地执行轨迹与证据落盘 │ │ · 本地完整节点树与执行轨迹 │
│ · AI 辅助调用(P1) │ └───────────────────┬─────────────────────┘
└─────────────────────────────────────────┘ │ ADB(USB / WiFi)
│ ADB(USB / WiFi) v
v Android 手机(拼多多 App)
┌─────────────────────────────────────────┐
│ Android 手机(拼多多 App) │
└─────────────────────────────────────────┘
``` ```
组件落位: - 采购服务:Go + gin,SQLite,goose migration,本地 SHA-256 证据存储。
- 采购工具:Python + uiautomator2 + PySide6;执行器只依赖 `TaskSource` / `ResultSink` 抽象。
- 采购服务:Go + gin,入口 `admin/cmd/server/main.go`,模板 - 页面判据与拼多多 App 版本绑定;版本不同即停止,不把前序项目页面结构当作事实。
`admin/internal/transport/webui/templates/`
- 采购工具:Python,入口 `client/src/main.py`,真机流程 `client/src/android/pdd_flow.py`
- 数据库:SQLite,迁移由 goose 管理
- 证据存储:采购服务本地文件系统,SHA-256 寻址
- 外部服务:顺运宝 ERP(只读)、AI provider(P1,仅采购工具调用)
## 二、职责划分 ## 二、职责划分
### 采购服务(网页端,`admin/`) ### 采购服务(`admin/`)
**独占**: 独占以下权威:
- 任务的创建、状态流转和终态判定 - 任务创建、批量开始采购、状态流转和终态判定;
- 候选商品的接收与展示 - **一次性采购授权的签发**:管理员点击“开始采购”是唯一的人类授权动作;
- **下单授权的签发与作废**——这是唯一的资金决策权威 - 任务不可变字段和最高总价校验;
- 金额上限的判定 - 提交订单前的原子围栏与点击后结果调和;
- 证据资产的存储与访问控制 - 管理员会话、设备凭据、内部截图证据和审计记录。
- 管理员会话与设备凭据
**不做**: 采购服务不连接手机、不发 ADB 命令、不解析拼多多页面,也不持有支付或 AI provider 凭据。
- 不连接手机、不发 ADB 命令 ### 采购工具(`client/`)
- 不保存、代理或下发任何 AI provider 凭据
- 不解析拼多多页面
### 采购工具(桌面端,`client/`) 独占以下设备能力:
**独占**: - ADB 连接、设备健康检查和已取证 App 版本校验;
- 打开商品、识别页面、精确选规格、设置数量、读取价格;
- 在满足全部门禁并取得服务端围栏后,精确点击一次“提交订单”;
- 截图、完整节点树和执行日志的本地采集,显式上传内部截图。
- ADB 连接与设备健康检查 采购工具不自行修改任务约束、不扩大金额上限、不领取 `DRAFT`,也不能签发授权。没有服务端
- 拼多多页面识别、点击、选规格、设数量 明确返回 `click_permitted=true` 时,任何本地判断都不能创建订单。
- 页面截图与节点树采集
- AI 调用(P1)
- 完整执行轨迹的本地留档
**不做**:
- **不自行决定买哪个候选**——必须等采购服务的授权
- **不自行放宽金额上限**——本地校验只能更严,不能更松
- 不直接读 Excel 或访问 ERP
- 不在没有授权的情况下执行任何创建订单的动作
### 权威冲突规则 ### 权威冲突规则
两端都会校验规格、数量、金额。**判定不一致时一律转人工,不取任一方结论。** 服务端校验锁定的任务约束,客户端校验当前真机事实。任一端拒绝或两端摘要不一致,一律停止并
这条是硬规则:双闸门的价值在于分歧能被发现,自动选一边等于把双闸门降级成单闸门。 转人工;不能为了“继续跑”选择相信其中一端。
## 三、两趟执行 ## 三、单趟采购执行
这是本项目最核心的结构决策。**MVP 只做 A 路径(任务自带商品链接),分两趟跑完。** MVP 只做任务自带商品链接的 A 路径。创建任务与开始采购分离,但管理员开始后不再插入试选确认:
```text ```text
┌────────── 采购服务开始第一趟 ───────────┐ 管理员创建 DRAFT
│ 新任务先保存为 DRAFT │ │
│ 管理员在任务表格勾选一条或多条 │ ├─ 勾选 DRAFT,查看选中数与最高总额
│ 原子转为 PENDING,只进入试选队列 │ └─ 点击“开始采购(只创建待付款订单)”
└────────────────────┬─────────────────────┘ │ 同一事务:校验版本 + 创建一次性授权 + PENDING
v v
┌──────────────── 第一趟:试选 ────────────────┐ 采购工具领取授权任务
│ 采购工具轮询领取 PENDING 任务 │ │ 1. 打开 canonical 商品链接
│ 1. open_product(url) │ │ 2. 通过证据/版本绑定、精确唯一的受控入口打开规格面板
│ 2. 经版本绑定、精确唯一的受控入口打开规格面板 │ │ 3. 按维度精确选择并读回颜色、尺码
│ 3. 按维度精确勾选颜色分类、尺码 │ │ 4. 【闸门一】读 SKU 单价;单价×数量不得超过最高总价
│ 4. 【闸门一】读该 SKU 单价,算合计 │ │ 5. 设置数量并精确读回
│ 5. 截图 │ │ 6. 【闸门二】重读规格与单价;规格不变且价格等于闸门一
│ 6. 退出商品,释放手机 │ │ 7. 进入订单确认页
│ 7. 回传标题 / 选中规格 / 单价 / 合计 / 截图 │ │ 8. 【闸门三】规格/数量一致,应付总额不超最高总价
└────────────────────┬─────────────────────────┘ │ 9. 上传验证摘要并申请服务端提交围栏
v │ 10. 仅在 click_permitted=true 且提交控件唯一时点击一次
任务转 WAITING_CONFIRMATION │ 11. 回传观察结果;不确定时只调和,不重试
│ v
┌────────────────────┴─────────────────────────┐ WAITING_PAYMENT ──人核对与付款──> SUCCEEDED
│ 人在采购服务确认:机器选对了吗 │ 或
│ 看:需求 vs 选中规格、单价、合计、截图 │ RECONCILIATION_REQUIRED ──人工核查同一提交──> WAITING_PAYMENT / FAILED
│ 点「确认下单(不付款)」→ 签发授权,锁定授权价 │
│ 或「退回,不买」→ 任务终止 │
└────────────────────┬─────────────────────────┘
v
┌──────────────── 第二趟:下单 ────────────────┐
│ 采购工具轮询拿到授权 │
│ 1. 重新 open_product(url) │
│ 2. 重新按维度精确勾选同一规格 │
│ 3. 【闸门二】重读单价,必须与授权价一致 │
│ 4. 设数量并复核 │
│ 5. 进订单确认页 │
│ 6. 【闸门三】读「实付款」,不得超授权上限 │
│ 7. 三个闸门全过 → 点一次「提交订单」 │
│ 8. 回传订单截图 │
└────────────────────┬─────────────────────────┘
v
任务转 WAITING_PAYMENT
│
人工在拼多多核对后付款
``` ```
### 为什么分两趟而不是停在面板上等人 “开始采购”锁定的是管理员填写的 `goods_id`、颜色、尺码、数量和**最高总价**,不是一张旧页面
截图里观察到的价格。价格在执行时实时读取,因此取消试选确认不会取消价格保护。
一台手机是瓶颈。若第一趟停在规格面板等人确认,手机被占住跑不了别的任务,面板还可能 ### 受控规格面板入口
超时或被拼多多重置。**第一趟必须退出并释放手机**,第二趟重新进入。
代价是同一商品走两遍,但第二趟很快,而且换来两个好处:手机可以在人思考时继续跑别的 T-103 真机证据表明:拼多多 `8.17.0`、goods_id `937122477375` 通过精确文本“快要抢光”打开
任务的试选;价格变动能被第二趟抓住。 规格面板。T-110 已批准把该**特定证据、版本和页面状态**绑定的点击定义为受控导航。
### 第一趟受控规格面板入口 - 只能精确唯一匹配;缺失、重复、版本失配或打开后面板不唯一时零后续点击。
- “免拼购买 / 单独购买 / 直接拼成”等其他文案不能用包含、前缀、同义或坐标兜底。
T-103 的真机证据推翻了“商品详情页存在独立规格入口”的假设:拼多多 8.17.0、goods_id - 受控入口只负责进入已取证面板,不等于支付授权,也不能暴露通用任意点击能力。
`937122477375` 只能从“快要抢光”打开规格面板。项目所有者于 2026-08-04 批准把这一点击定义为 - T-103 的隔离验证 capability 只包含开商品、开面板、选规格、读价和安全退出;数量、确认页、
**可逆且能力受限的规格面板导航**,不把它当作下单授权,也不把购买语义文案整体加入白名单。 提交和支付仍由后续真机任务分别取证后才能接入生产单趟执行器。
第一趟执行器只能持有以下 capability:
1. 打开 canonical 商品链接。
2. 点击与证据哈希、拼多多版本和页面状态绑定的精确唯一 `快要抢光` 入口。
3. 在已确认的维度容器中精确选择规格并读回选中态。
4. 从规格面板读取单价、生成脱敏派生证据、关闭面板并退出商品页。
第一趟 capability **不得包含**通用 `click`、数量增减、进入订单确认页、提交订单或付款能力。
“免拼购买 / 单独购买 / 直接拼成”等其他文案即使人工认为行为相同,也必须各自重新取证后才能评审;
入口缺失、重复、版本失配、打开后面板判据不唯一,或出现“提交订单”以外的未知终态按钮时立即停止。
第一趟的静态依赖检查必须证明 `set_quantity()`、`go_to_order_confirm()`、`submit_order()` 和任何
支付函数不可达。
### 三道价格闸门 ### 三道价格闸门
| 闸门 | 位置 | 作用 | 不通过时 | | 闸门 | 当前页面 | 判据 | 拒绝条件 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 一 | 第一趟规格面板 | 读该 SKU 单价,算合计,回传给人看 | 读不到即停,转人工 | | 一 | 规格面板,选中目标规格后 | 单价唯一可读;`单价 × 授权数量 <= 最高总价` | 不可读、有歧义或超上限 |
| 二 | 第二趟规格面板 | 重读单价,**必须与授权时锁定的价格一致** | 不一致即停,转人工 | | 二 | 规格面板,数量读回后 | 颜色、尺码仍正确;重读单价与闸门一完全相等 | 规格漂移或价格变化 |
| 三 | 订单确认页 | 读「实付款」,不得超授权总额上限 | 超出即停,转人工 | | 三 | 订单确认页 | 规格、数量正确;应付总额唯一可读且不超最高总价 | 任一不一致、不可读或超上限 |
**闸门二不可省略。** 人确认的是「32.50 元这一单」,不是「这个商品」。拼多多价格波动 金额一律使用十进制字符串和十进制定点运算,不用浮点数。价格只从规格面板和订单确认页读取;
常见,不能因为「人已经确认过」就照下不误。 详情正文、搜索卡片、底部购买/提交按钮的数字不作为价格来源。
**价格只在规格面板和订单确认页读。** 商品详情页正文和搜索结果卡片上的价格文本在真机上 闸门一与闸门二发生在同一设备会话中。它们用于发现选择或设置数量造成的页面变化,不需要管理员
被拆成多个节点(`¥` 与数字分离)、带 `券后` 一类前缀、实付价 / 原价 / 促销价难以区分 在中间确认。旧截图、缓存值和发布前 dry-run 都不能替代这次实时读取。
——前序项目在这里耗掉大量时间且无可靠结论。
### B 路径(图片搜索)——V2,MVP 不做 ### B 路径(V2)
任务只有参考图、没有链接时,需要先搜图找出商品。**该路径推迟到 V2**,MVP 阶段建单必须 没有商品链接时由图片搜索只产出 `goods_id`,再汇入上述流程。不在搜索结果页读取价格或规格。
提供商品链接。
V2 实现时仍遵守:**图搜的唯一产出是 goods_id**,不在搜索结果卡片上读价格或据价筛选,
拿到 goods_id 后汇入本节的两趟流程。
## 四、安全边界(硬约束) ## 四、安全边界(硬约束)
以下每一条都必须有单元测试证明,且不得在任务中「顺手放宽」。 以下每条都必须有测试证明,不得在任务中顺手放宽:
| 边界 | 规则 | 违反后果 | | 边界 | 规则 | 防止什么 |
| --- | --- | --- | | --- | --- | --- |
| 不付款 | 任何路径都不点击支付、免密支付、先用后付或扣款控件 | 真实资金损失 | | 不付款 | 不点击支付、免密支付、先用后付或任何扣款控件 | 真实资金损失 |
| **提交订单四条件** | 见下方专节。四者缺一不可,且**只允许点击一次** | 误下单 / 重复下单 | | 提交四条件 | 授权+围栏、闸门二、闸门三、控件唯一同时成立,只点一次 | 误下单 / 重复下单 |
| 订单确认页其余零点击 | 除「提交订单」与返回外,不点击确认页上任何控件 | 误触发未知动作 | | 确认页零点击 | 除返回和满足四条件后的“提交订单”外不点击任何控件 | 未知副作用 |
| 规格精确匹配 | 按维度等值匹配,防前缀碰撞(`红`/`粉红`、`1`/`10`);找不到即停 | 买错货 | | 规格精确匹配 | 维度内等值唯一匹配,防 `红/粉红`、`1/10` 前缀碰撞 | 买错规格 |
| 提交订单控件唯一 | 文本精确等于「提交订单」且可点击祖先唯一,否则停 | 点到未知控件 | | 数量读回复核 | 设置后精确读回,不一致即停 | 买错数量 |
| 数量必须复核 | 设置后读回确认精确等于要求值,否则停 | 买错数量 | | 三道价格闸门 | 任一道不可读、有歧义或不通过都停,不用别处数字凑 | 超预算 |
| 价格三道闸门 | 见第三节。任一道读不到或不通过即停,**不用其他位置的数字凑合** | 超预算采购 | | 受控页面能力 | 页面动作按任务与证据分层;不得把通用 `click` 传入业务流程 | 边界扩散 |
| 第一趟不下单 | 只允许点击证据/版本绑定的精确唯一受控入口打开规格面板,当前仅为 `快要抢光`;随后只选规格、读价、脱敏取证和返回。数量、确认页、`提交订单`、付款与通用点击能力均不可达 | 无授权下单 | | 外部支付页 | 检测到外部支付交接立即停止,不读取、保存或输入凭据 | 凭据泄露 |
| 外部支付页 | 检测到微信等外部支付交接立即停止、转人工、保留证据 | 凭据泄露 | | 安全校验 | 验证码、风控、人脸、短信出现即停止,不绕过 | 封号 / 违规 |
| 安全校验 | 检测到验证码、风控、人脸、短信校验立即停止,不尝试绕过 | 封号 / 违规 | | 内部截图 | 可上传页面已显示的地址/手机号;不解析成字段或日志,完整 XML 不上传 | 非必要扩散 |
| 敏感信息 | 只读非敏感摘要,不提取收货地址原文、手机号、支付凭据 | 隐私泄露 | | 授权一次性 | 一条任务版本只有一份有效授权;幂等重放不生成第二份 | 重复采购 |
| 授权一次性 | 一笔授权只能产生一笔订单,重复提交幂等 | 重复采购 | | 服务端提交围栏 | 点击前原子创建唯一提交记录;失败或响应不明不得点击 | 并发 / 断网重复下单 |
| 服务端提交围栏 | 真机点击前必须由采购服务原子冻结授权并创建唯一提交记录;失败或响应不明不得点击 | 并发 / 断网导致重复下单 | | App 版本绑定 | 运行版本不同于证据版本时停止并重新取证 | 旧判据误点 |
| App 版本失配即停 | 运行版本与本项目已取证版本不一致时停止领取真机任务,先重新取证 | 旧判据误点新页面 |
### 提交订单的四个前置条件 ### 提交订单的四个前置条件
这是本项目唯一会创建真实待付款订单的动作。**四者同时满足才允许点击,且只点一次:** “提交订单”是系统唯一会创建真实待付款订单的动作。以下四项同时满足才允许点击一次:
1. **授权存在且未消费,并已建立服务端提交围栏**——采购服务已签发、采购工具已 ack; 1. **一次性授权有效且服务端提交围栏已建立**:围栏把授权、任务、领取和本次验证摘要原子绑定到
真机点击前,采购服务在一个原子事务中把授权从可执行态冻结为本次唯一 唯一 `order_submission`;明确响应包含 `click_permitted=true`。
`order_submission`。围栏接口失败或响应不明时不得点击。 2. **闸门二通过**:目标规格未漂移,第二次规格面板单价等于第一次。
2. **闸门二通过**——第二趟重读的单价与授权时锁定的价格一致。 3. **闸门三通过**:确认页规格、数量正确,应付总额不超过授权最高总价。
3. **闸门三通过**——订单确认页「实付款」不超过授权总额上限。 4. **提交控件唯一**:文本精确等于“提交订单”,可点击祖先唯一。
4. **控件唯一**——文本精确等于「提交订单」且可点击祖先唯一。
点击之后,**无论发生什么都不重试**: 围栏请求超时、断网、冲突或响应不明时不得点击。围栏建立后:
| 点击后观察到 | 处置 | | 观察结果 | 处置 |
| --- | --- | | --- | --- |
| 正常进入订单结果页 | 回传订单截图,任务转 `WAITING_PAYMENT` | | 明确进入订单结果 / 待付款页 | 上报 `SUBMITTED`,任务转 `WAITING_PAYMENT` |
| 跳转微信等外部支付 | 立即停止,转人工,提示「订单可能已创建、支付未完成」 | | 跳转外部支付 | 立即停止,上报结果不明确,不执行支付 |
| 安全校验 | 立即停止,转人工,保留证据 | | 出现验证码 / 风控 / 人脸 / 短信 | 立即停止,上报结果不明确,不绕过 |
| 超时或页面无法判定 | 转人工,**预留金额额度**,提示订单状态不明 | | 超时、断连、页面无法判定 | 转 `RECONCILIATION_REQUIRED`,保留围栏和金额额度 |
后三种情况一律**禁止自动重试点击**。授权保持永久围栏,结果明确后再记为已消费;在此之前 后三种情况都禁止释放围栏、重新授权、重新领取或再次点击。只能调和同一提交记录。
也绝不能重新开放——宁可人工核实一遍,不可能重复下单。
### dry-run、提交围栏与结果调和 ### 发布前 dry-run 与生产围栏的区别
下单被拆成三个不可逆程度不同的阶段,任何客户端本地判断都不能替代服务端围栏: 首次启用某一 App 版本的真实提交能力前,必须用独立真机任务完成只读 dry-run:进入确认页,验证
规格、数量、金额与提交控件唯一,然后退出且不点击。它用于证明判据和不可达测试,不是每笔采购的
1. **dry-run(只读演练)**:进入订单确认页,读取规格、数量和「实付款」,确认提交控件 “第一趟”,也不产生可复用页面事实。生产任务仍在同一趟内重新通过三道闸门并申请服务端围栏。
唯一,上传证据后退出。该阶段绝不点击「提交订单」,也不消费授权。
2. **提交围栏**:真实第二趟再次读取并通过三道闸门后,采购工具向采购服务申请围栏。采购服务
原子校验任务版本、命令、未消费授权和唯一性,创建 `order_submissions` 记录并冻结授权。
只有明确收到成功响应,采购工具才可点击一次。
3. **结果调和**:点击后只上报观察结果。明确创建则转 `WAITING_PAYMENT`;超时、外部支付、
安全校验或断连均转 `RECONCILIATION_REQUIRED`,保留额度并由人核查。**不得释放围栏、
重新签发授权或自动重试点击。**
授权超时和主动放弃只允许发生在提交围栏建立之前。围栏之后即使租约过期,也只能恢复
同一提交记录并进入调和,不能把任务重新放回可领取队列。
## 五、数据模型 ## 五、数据模型
### 5.1 核心实体 ### 5.1 核心实体
```sql ```sql
-- 采购任务:创建后业务约束不可变
CREATE TABLE tasks ( 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, id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id), source TEXT NOT NULL, -- MANUAL | EXCEL | ERP
attempt INTEGER NOT NULL, source_ref TEXT,
product_title TEXT NOT NULL, -- 商品页读到的标题 title TEXT NOT NULL,
selected_color TEXT NOT NULL, -- 实际勾选到的颜色分类 goods_id TEXT NOT NULL,
selected_size TEXT NOT NULL, -- 实际勾选到的尺码 sku_color TEXT NOT NULL,
unit_price TEXT NOT NULL, -- 闸门一读到的单价 sku_size TEXT NOT NULL,
total_price TEXT NOT NULL, -- unit_price × quantity quantity INTEGER NOT NULL CHECK (quantity > 0),
evidence_sha256 TEXT NOT NULL, -- 规格面板截图 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, created_at TEXT NOT NULL,
UNIQUE (task_id, attempt) updated_at TEXT NOT NULL
); );
-- 下单授权:唯一的资金决策记录 -- 管理员点击“开始采购”产生;锁定任务约束,不锁定旧观察价
CREATE TABLE order_authorizations ( CREATE TABLE order_authorizations (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id), task_id TEXT NOT NULL REFERENCES tasks(id),
spec_trial_id TEXT NOT NULL REFERENCES spec_trials(id), task_version INTEGER NOT NULL,
version INTEGER NOT NULL, start_key TEXT NOT NULL,
goods_id TEXT NOT NULL, goods_id TEXT NOT NULL,
sku_color TEXT NOT NULL, sku_color TEXT NOT NULL,
sku_size TEXT NOT NULL, sku_size TEXT NOT NULL,
quantity INTEGER NOT NULL, quantity INTEGER NOT NULL,
authorized_unit_price TEXT NOT NULL, -- 锁定价:闸门二据此比对 total_price_cap TEXT NOT NULL,
total_price_cap TEXT NOT NULL, -- 授权总额上限:闸门三据此比对 status TEXT NOT NULL, -- ACTIVE | CLAIMED | FENCED | CONSUMED | EXPIRED | ABANDONED
note TEXT, -- 可选备注
status TEXT NOT NULL,
created_by TEXT NOT NULL, created_by TEXT NOT NULL,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
expires_at TEXT NOT NULL, -- 围栏前超时自动作废;围栏后不再释放 expires_at TEXT NOT NULL,
UNIQUE (task_id, version) 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 ( CREATE TABLE order_submissions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id), task_id TEXT NOT NULL REFERENCES tasks(id),
authorization_id TEXT NOT NULL REFERENCES order_authorizations(id), authorization_id TEXT NOT NULL REFERENCES order_authorizations(id),
command_id TEXT NOT NULL, attempt_id TEXT NOT NULL REFERENCES purchase_attempts(id),
dry_run_id TEXT NOT NULL, status TEXT NOT NULL, -- FENCED | SUBMITTED | RECONCILIATION_REQUIRED | MANUAL_RESOLVED
status TEXT NOT NULL, -- FENCED | SUBMITTED | RECONCILIATION_REQUIRED | MANUAL_RESOLVED gate1_unit_price TEXT NOT NULL,
verified_unit_price TEXT NOT NULL, gate2_unit_price TEXT NOT NULL,
quantity_read INTEGER NOT NULL, quantity_read INTEGER NOT NULL,
confirm_page_amount TEXT NOT NULL, confirm_amount TEXT NOT NULL,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
resolved_at TEXT, resolved_at TEXT,
UNIQUE (authorization_id), UNIQUE (authorization_id),
UNIQUE (command_id) UNIQUE (attempt_id)
); );
``` ```
`authorized_unit_price` 是第二趟闸门二的比对基准,**必须来自人确认时看到的那个试选 MVP 不再用 `spec_trials` 作为审批记录,也不存在 `authorized_unit_price`。实际读价属于
结果**,不能在签发时重新取值。`expires_at` 见 5.3 节。 `purchase_attempts` / `order_submissions` 的执行与审计事实;管理员授权的资金边界始终是
`total_price_cap`。
MVP 的授权没有「选择理由 / 拒绝理由」——那是从多个候选里挑一个时的留档需求。这里人
只回答「机器选对了吗」,保留一个可选 `note` 即可。
金额一律用**十进制字符串**存储和传输,不用浮点数。
### 5.2 状态机 ### 5.2 状态机
任务状态(采购服务权威)。创建与开始试选分离;两趟执行对应两次 `CLAIMED → RUNNING`:
```text ```text
DRAFT ─start trial→ PENDING ─┐ DRAFT
PENDING_RETRIAL ─────────────┴─claim→ CLAIMED ─start→ RUNNING(TRIAL) └─开始采购(创建授权)→ PENDING
↑ │ ├→ NEEDS_MANUAL └─claim→ CLAIMED ─start→ ORDERING
└────────release──────┘ └→ WAITING_CONFIRMATION ├─围栏前验证失败→ NEEDS_MANUAL ─人工处理/重置→ DRAFT
├→ CANCELED ├─围栏前授权过期/安全释放→ DRAFT
└→ AUTHORIZED └─submission FENCED
└claim→ ORDERING ├─明确创建→ WAITING_PAYMENT ─人工付款并标记→ SUCCEEDED
├→ NEEDS_MANUAL(围栏前失败) └─结果不明→ RECONCILIATION_REQUIRED
└→ [submission FENCED] └─人工调和同一提交→ WAITING_PAYMENT / FAILED
├→ WAITING_PAYMENT
│ └→ SUCCEEDED DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
└→ RECONCILIATION_REQUIRED
└→ 人工核查 / 调和
``` ```
| 状态 | 含义 | | 状态 | 含义与安全下一步 |
| --- | --- | | --- | --- |
| `DRAFT` | 已保存、等待管理员开始试选;设备不可领取,也不存在下单授权 | | `DRAFT` | 已保存,未授权;管理员可编辑/取消或点击开始采购;设备不可领取 |
| `RUNNING(TRIAL)` | 第一趟试选中:正在勾选规格、读价、截图 | | `PENDING` | 已有有效一次性授权,等待采购工具领取 |
| `WAITING_CONFIRMATION` | 试选已回传,**等人确认机器选对了没** | | `CLAIMED` | 已由一个设备实例持有租约,尚未开始页面操作 |
| `PENDING_RETRIAL` | 旧授权已过期或在围栏前被放弃,必须重新跑第一趟取得新价格 | | `ORDERING` | 单趟执行中,正在选规格、过闸门或申请围栏 |
| `AUTHORIZED` | 已签发授权,等采购工具下一轮轮询领走 | | `NEEDS_MANUAL` | 围栏前失败;显示原因,由人核查后重置为 DRAFT 或取消 |
| `ORDERING` | 第二趟下单中:重新选规格、过闸门二三、提交订单 | | `WAITING_PAYMENT` | 订单已明确创建,等待人在拼多多付款;**不是成功** |
| `WAITING_PAYMENT` | 订单已创建,等人在拼多多付款。**这不是成功** | | `RECONCILIATION_REQUIRED` | 围栏后结果不明;只能核查同一提交,不能重试 |
| `RECONCILIATION_REQUIRED` | 已建立提交围栏,但点击结果不明确;可能已创建订单,只能核查,不能重试 | | `SUCCEEDED` | 人已付款并完成核对 |
| `NEEDS_MANUAL` | 围栏前的转人工情形(规格不匹配、价格不符、页面识别失败等) | | `FAILED` | 人工调和确认订单未创建或任务无法完成 |
| `SUCCEEDED` | 订单已付款且核对通过 | | `CANCELED` | 围栏前由管理员取消,不再执行 |
授权状态:`PENDING_DELIVERY → DELIVERED → ACKNOWLEDGED → EXECUTING → FENCED → CONSUMED`。 批量 `DRAFT → PENDING` 必须全有或全无。服务端同时创建授权;“先改状态、稍后补授权”无效。
人退回或重新确认时旧授权转 `SUPERSEDED`;超时转 `EXPIRED`。 `WAITING_CONFIRMATION`、`PENDING_RETRIAL`、`AUTHORIZED` 和 `RUNNING(TRIAL)` 不再属于 MVP 状态。
`DRAFT → PENDING` 只能由管理端“开始试选”动作触发。批量开始在一个事务中校验全部任务仍为 ### 5.3 授权、租约与恢复
`DRAFT` 且版本一致后统一流转;任一冲突时整批不变,避免用户误以为选中的任务都已开始。
这个动作只开放第一趟领取资格,不创建 `order_authorizations` 或 `order_submissions`。
### 5.3 授权超时(MVP 必做,不得推后) - 授权带 `expires_at`,只有围栏前可转 `EXPIRED` / `ABANDONED`;任务回到 `DRAFT`,必须重新点击
开始采购。旧授权永不复活。
> **前序项目的教训**:曾出现 `EXECUTING` 授权永不推进,导致确认表单被永久隐藏、任务 - 设备租约丢失不等于授权可安全重用。只有服务端确认该 attempt 未建立围栏,才能关闭 attempt 并
> 锁死,只能新建任务绕过。 回到 `DRAFT` / `NEEDS_MANUAL`;不能自动重新领取并重复页面动作。
- 围栏建立后即使租约过期也只恢复同一 `order_submission` 的调和,不能回到可领取队列。
规则: - 每种非终态都必须给出安全下一步,不能出现隐藏表单导致任务永久锁死。
- 每笔授权带 `expires_at`。**仅在尚未建立提交围栏时**,超时自动转 `EXPIRED`。
- 授权 `EXPIRED` 后任务转 `PENDING_RETRIAL`,先重新跑第一趟取得新价格,再回到人工确认;
不允许在旧 `spec_trials` 上直接重新确认。
- 采购服务在围栏建立前提供「放弃当前授权」入口;围栏建立后改为「进入人工核查」,不得
作废或释放授权。
- **任何时候都不允许出现「任务停在某状态且界面上没有任何可用动作」的组合。**
这是验收项,不是实现细节。
授权过期后重新确认时,必须重新走第一趟试选取得新的 `spec_trials` 记录——不能复用旧的
锁定价,因为价格可能已经变了。已建立围栏的授权不参与本超时流程。
### 5.4 证据分层 ### 5.4 证据分层
| 数据 | 位置 | 理由 | | 数据 | 位置 | 边界 |
| --- | --- | --- | | --- | --- | --- |
| 原始商品页 / 规格页 screenshot/XML | **仅采购工具本机隔离目录** | PDD 页面不可避免包含收货区域和掩码手机号;只允许确定性脱敏器读取,不供业务、agent、fixture 或上传消费 | | 商品 / 规格 / 确认页原始 screenshot | 采购工具本机 + 采购服务内部证据存储 | 可含页面已显示地址/手机号;设备鉴权上传、管理员登录查看,不遮罩 |
| 脱敏派生商品页 / 规格页截图 | 上传采购服务 | 管理员做授权决策必须看;服务端只接收派生哈希和 sanitizer 版本,原始/派生哈希映射仅留本机 manifest | | 完整 XML | 仅采购工具本机隔离目录 | 可在内存解析页面判据;不上传、不写日志、Git、Vikunja |
| 最小脱敏 XML fixture | 采购工具测试 / 可提交 Git | 只保留页面判据所需结构;自动复检无地址、手机号、支付凭据后才可发布 | | 最小 XML fixture | 采购工具测试 / Git | 只保留判据所需结构,确认无地址、手机号、支付凭据 |
| 脱敏派生订单确认页截图 | 上传采购服务 | 授权后核对与审计必须留;原始物仍只在本机隔离目录 | | 外部支付页或支付凭据 | 不保存、不上传 | 检测到交接立即停止 |
| 脱敏派生订单核对截图 | 上传采购服务 | 资金核对证据;原始物仍只在本机隔离目录 | | AI 调用记录(V2) | 仅采购工具本地 | 不进入采购服务 |
| AI 调用记录(P1) | **仅采购工具本地** | 含 prompt / 响应全文,脱敏成本高 |
| 失败现场快照 | 原始物仅采购工具本机;只能手工导出脱敏派生物 | 同上 |
原始目录不得被 HTTP sink、Vikunja 导出、日志或 fixture 构建器读取。脱敏器必须先验证设备分辨率、 截图上传器只能接收调用方显式指定的截图,不能枚举证据目录或顺带上传 XML/manifest。证据响应
页面/App 版本和预期隐私区域,再同时生成派生 screenshot/XML;派生 XML 仍命中手机号模式、截图隐私 使用 `Cache-Control: no-store`,不能暴露为免登录静态目录。
区域无法确定、sanitizer 异常或任一哈希不一致时,不发布派生目录。上传端只接受 manifest 明确标记
`privacy_tier=SANITIZED` 的派生截图,**不上传含收货地址、手机号、支付凭据的区域或文本。**
## 六、关键技术难点 ## 六、关键技术难点
| 难点 | 说明 | 应对 | | 难点 | 风险 | 应对 |
| --- | --- | --- | | --- | --- | --- |
| 拼多多页面结构随版本变化 | 前序项目已观察到详情页无独立规格入口、价格节点拆分等变化 | **每条判据先做真机 spike 取证再写代码**;判据与 App 版本一并记录 | | 页面结构随版本变化 | 旧选择器误点新页面 | 每条判据先真机取证,记录 App 版本、截图、XML、goods_id |
| 规格面板安全入口 | T-103 已在拼多多 8.17.0 真机确认衣服商品只能通过购买语义入口打开规格面板 | T-110 已批准仅使用当前证据证明的精确唯一 `快要抢光` 作为受控导航;其他文案不泛化,第一趟下单能力保持不可达 | | 购买语义入口才打开面板 | 能力范围易扩散 | 只批准证据绑定的精确唯一入口;每种文案单独取证 |
| 规格面板上的价格位置 | 选中 SKU 后价格显示在哪、是否含券后前缀,未取证 | **T-103 必须一并取证**,闸门一依赖它;读不到就转人工,不用详情页数字凑合 | | 当前价与原价/按钮价混杂 | 读错价格 | 限定已取证结构和语义;价格只在面板/确认页读,歧义即停 |
| 同一商品两趟结果不一致 | 第二趟价格变了、规格选项变了或商品下架 | 闸门二拦截;一律转人工,不自动放弃也不自动继续 | | 单趟页面状态变化 | 数量或促销导致价格变化 | 同一趟两次读规格面板价,再以确认页总额兜底 |
| 图搜结果含跨类目商品(V2) | 搜服装出现纸巾 | B 路径只产 goods_id 且限 5 个;后续用 VLM 看截图筛同款 | | WiFi ADB / 双通道 | 断连或操作错设备 | serial 必填;USB/WiFi 同设备或身份不明时 fail closed |
| WiFi ADB 稳定性 | 息屏、换网、DHCP 续租会断连 | 超时可配置;断连视为技术失败并保留现场,不重试点击 | | 不可逆动作超时 | 可能已创建订单 | 服务端围栏 + 点击一次 + 只调和,不重试 |
| 同一手机 USB + WiFi 同时在线 | `adb devices` 列出两条,自动选设备会失败 | serial 必填;多在线通道必须读到 `ro.serialno` 或 `ro.boot.serialno` 才能比对。身份一致或任一身份读取失败时都 fail closed,不能以相同 model/product 猜测后继续 | | 双端契约漂移 | 静默不兼容 | [api.md](api.md) 唯一权威;契约改动跑完整双端门禁 |
| 不可逆动作的重试 | 点击「现在买」后超时,无法判断订单是否已创建 | 一律转人工并预留金额额度,**禁止自动重试点击** |
| 双端契约漂移 | 两端独立演进会静默不兼容 | 契约改动必跑完整门禁;[api.md](api.md) 是唯一权威 |
**高风险功能先做最小原型。** Phase 1 的真机 spike 必须先于 Phase 2 的界面开发完成。
## 七、推荐开发顺序 ## 七、推荐开发顺序
1. **Phase 0 地基**:两端骨架、测试命令、`init` 脚本可运行。 1. **Phase 0 地基**:双端骨架、测试命令、初始化脚本和原型。
2. **Phase 1 真机取证**:WiFi ADB 连通;先验证第一趟的打开商品 → 受控打开规格面板 → 按维度 2. **Phase 1 真机取证**:逐段验证商品打开、受控面板入口、精确规格与读价、数量、确认页和提交
精确勾选颜色分类和尺码 → **读到该 SKU 单价** → 脱敏取证 → 退出;再以独立的第二趟 spike 控件;真实点击前先完成独立 dry-run。每个 spike 的 capability 只覆盖当期动作。
验证设数量 → 进订单确认页 → 读「实付款」。第一趟 capability 不含后三项。 3. **Phase 2 采购服务核心**:DRAFT 建单/列表、批量开始采购与授权、状态/证据/围栏/调和接口。
**结论写入文档,判据带拼多多 App 版本。** 4. **Phase 3 双端打通**:设备身份、原子领取、单趟执行到围栏前、事件与截图。
3. **Phase 2 采购服务核心**:数据模型与状态机、手工建单、任务查询、试选结果接收、 5. **Phase 4 闭环**:经明确真机授权验证一次提交、待付款收口、失败分类、打包。
确认页与授权签发、**授权超时与放弃**。 6. **V2**:图搜、Excel、ERP、自动核对、AI、多设备。
4. **Phase 3 双端打通**:设备侧 API、采购工具 `HttpTaskSource`/`HttpResultSink`、
定时轮询、第一趟试选端到端。
5. **Phase 4 闭环收尾**:第二趟下单(含三道闸门与提交)、失败分类、完整验收、打包。
6. **V2 及以后**:图片搜索路径、候选对照台、Excel 导入、ERP 建单、订单自动核对、AI 辅助。
**不要在 Phase 1 结论出来之前写 Phase 2 的页面**——确认页要显示什么,取决于真机上 T-103 继续作为“规格选择与读价”的隔离前置,不含数量、确认页或提交。生产单趟并不意味着在一个
究竟能读到什么。尤其是闸门一的单价,如果规格面板上读不可靠,整个确认页的设计要改。 任务里跳过逐段取证;它只意味着这些已验证能力集成后,每笔业务任务不再等待中途人工确认。
> 2026-08-04:Phase 1 证明已验证衣服商品没有独立规格入口。项目所有者随后批准 T-110 的受控入口
> 方案:当前只允许证据绑定的精确唯一 `快要抢光` 打开规格面板,并以 capability 隔离保证数量、确认页、
> 提交订单和付款在第一趟不可达。T-103 仍须先实现自动脱敏与新证据门禁,完成前 Phase 2 保持冻结。
## 八、项目结构 ## 八、项目结构
```text ```text
cmbuyer/ cmbuyer/
├── docs/ ├── docs/
├── admin/ # 采购服务(Go) ├── admin/
│ ├── cmd/server/ │ ├── cmd/server/
│ ├── internal/ │ ├── internal/domain/
│ │ ├── domain/ # 实体与状态机,无外部依赖 │ ├── internal/usecase/
│ │ ├── usecase/ # 业务用例 │ ├── internal/transport/httpapi/
│ │ ├── transport/ │ ├── internal/transport/webui/
│ │ │ ├── httpapi/ # 设备侧 API │ ├── internal/storage/
│ │ │ └── webui/ # 管理页面 + 模板 + 静态资源
│ │ └── storage/ # SQLite 与证据资产
│ └── migrations/ │ └── migrations/
├── client/ # 采购工具(Python) ├── client/
│ ├── src/ │ ├── src/cmbuyer_client/device/
│ │ ├── android/ # adb / device / pdd_flow │ ├── src/cmbuyer_client/pdd/
│ │ ├── core/ # models / task_runner / sources 抽象 │ ├── src/cmbuyer_client/core/
│ │ ├── remote/ # HttpTaskSource / HttpResultSink │ ├── src/cmbuyer_client/remote/
│ │ └── app/ # PySide6 GUI │ ├── src/cmbuyer_client/app/
│ └── tests/ │ └── tests/
└── scripts/ └── scripts/
``` ```
`client/src/core/sources.py` 必须保留 `TaskSource` / `ResultSink` 抽象,执行器只依赖抽象。 执行器依赖 `TaskSource` / `ResultSink`,不直接读取 Excel 或拼接 HTTP。来源变化不得改变安全执行器。
这样离线 Excel 模式可作为降级路径存在,且执行器不因来源变化而改动。
## 九、架构纪律 ## 九、架构纪律
- 业务事实和 schema 变化必须同步更新本文与 [api.md](api.md)。 - 业务事实和 schema 变化同步本文与 [api.md](api.md),代码不得另起一套字段或状态。
- 不在代码里发明文档没有的接口、字段和状态。 - 第四节安全边界只能收紧。需要变更时先更新架构、任务边界和理由。
- 第四节的安全边界不得在任务中放宽;确需变更时先改本文并说明理由。 - 页面高风险能力先真机取证并隔离测试,再接入完整流程。
- 高风险模块先单独真机验证,再接入完整流程。 - 前序项目 `cmroubao` / `cmpdd` 只提供设计理由,不提供可直接复用的页面事实。
- 前序项目 `cmroubao` / `cmpdd` 是设计依据,**不是事实来源**;引用其结论时必须在本项目
重新验证。
+20 -21
View File
@@ -24,15 +24,14 @@
明确建立、闸门二通过、闸门三通过、控件唯一。 明确建立、闸门二通过、闸门三通过、控件唯一。
- 围栏接口超时、冲突、网络失败或响应不明时不得点击;围栏建立后不得释放授权、重新领取 - 围栏接口超时、冲突、网络失败或响应不明时不得点击;围栏建立后不得释放授权、重新领取
或再次点击,只能恢复同一 `order_submission` 并调和结果。 或再次点击,只能恢复同一 `order_submission` 并调和结果。
- 点击后无论超时、跳外部支付还是遇安全校验,**一律转人工、禁止重试**,授权立即标记 - 点击后无论超时、跳外部支付还是遇安全校验,**一律转人工、禁止重试**。授权保持已围栏;
已消费。 只有明确结果或人工调和后才记为已消费/已解决。
- **第一趟试选的代码路径不得引用 `go_to_order_confirm()` 与 `submit_order()`**, - T-103 的 `SkuSelectionFlow` 不得引用数量、订单确认、提交围栏、`submit_order_once()` 或付款能力,
必须有测试证明不可达。 必须有静态调用链和测试证明不可达。
- 第一趟只可通过独立的 `open_trial_sku_panel()` capability 点击本项目真机证据与 App 版本绑定的 - `open_sku_panel()` 只可点击本项目真机证据与 App 版本绑定的精确唯一入口;当前仅允许拼多多
精确唯一入口;当前仅允许拼多多 `8.17.0` 上已取证的 `快要抢光`。不得向第一趟暴露通用 `click`、 `8.17.0` 上已取证的 `快要抢光`。不得暴露通用 `click`,不得把其他购买文案按包含/同义/坐标兜底。
`set_quantity()`、订单确认、提交或付款能力,不得把其他购买文案作为包含/同义匹配兜底。 - 规格面板中的“提交订单”、微信支付、先用后付或 0 元下单在 T-103 只能作为硬拒绝判据,绝不能
- 规格面板内即使可见“提交订单”、微信支付、先用后付或 0 元下单,第一趟也只能把它们作为硬拒绝 返回可点击对象。后续生产单趟也只能在各段已取证且四条件全通过时触发封装的一次性提交能力。
判据,绝不能返回可点击对象或尝试继续。
### 1.2 匹配纪律 ### 1.2 匹配纪律
@@ -46,20 +45,21 @@
- 价格**只在规格面板(闸门一 / 二)和订单确认页(闸门三)读**。 - 价格**只在规格面板(闸门一 / 二)和订单确认页(闸门三)读**。
- **不从商品详情页正文、搜索结果卡片或任何其他位置读价格。** - **不从商品详情页正文、搜索结果卡片或任何其他位置读价格。**
- 读不到就转人工,**不用别处的数字凑合**。 - 读不到就转人工,**不用别处的数字凑合**。
- 第二趟重读的单价**必须与授权锁定价一致**,不一致即停——人确认的是那个价格, - 同一趟设置并复核数量后,闸门二重读的规格必须不变、单价必须与闸门一完全一致;授权锁定的是
不是那个商品。 最高总价而不是旧观察价。价格变化即停。
- 图片搜索(V2)的唯一产出是 `goods_id`,同样不读价。 - 图片搜索(V2)的唯一产出是 `goods_id`,同样不读价。
### 1.4 安全与隐私 ### 1.4 安全与隐私
- 检测到验证码、风控、人脸、短信校验时立即停止,**不尝试绕过**。 - 检测到验证码、风控、人脸、短信校验时立即停止,**不尝试绕过**。
- 检测到外部支付交接立即停止,**不读取、不保存、不输入任何凭据**。 - 检测到外部支付交接立即停止,**不读取、不保存、不输入任何凭据**。
- 只读非敏感摘要,**不提取收货地址原文、手机号、支付凭据**。 - 页面已显示的地址和手机号允许随**内部原始截图**上传采购服务,供已登录管理员核对;不做遮罩或裁剪。
- 上传服务端的证据必须先脱敏。 - 地址和手机号不得被解析为结构化业务字段,也不得写入普通日志、Git、Vikunja、fixture 或错误消息。
- PDD 页面不可避免显示地址/掩码手机号时,原始 screenshot/XML 只允许写入采购工具本机隔离目录, 原始完整 XML 只留采购工具本机;真机流程可在内存中读取,但只能返回规格、选中态、价格和页面状态摘要。
只由确定性脱敏器消费;业务代码、agent、fixture、日志和 HTTP sink 只能读取自动复检通过且 manifest - 截图上传器只能接收显式文件,不得枚举原始目录或顺带上传 XML/manifest/其他文件;服务端必须校验
标记 `privacy_tier=SANITIZED` 的派生物。脱敏失败、分辨率/版本不符或派生 XML 仍命中手机号模式时 设备身份和 SHA-256,证据只允许已登录管理员访问,不得提供公开静态目录。
必须拒绝发布,不得用人工口头确认绕过。 - **支付凭据仍不得读取、保存或上传。** 外部支付交接不在“内部原图允许上传”的例外范围内。
- T-103 停止遮罩器开发并专注真机选择/读价;生产原始截图上传由 T-204 实现。
### 1.5 页面判据 ### 1.5 页面判据
@@ -96,10 +96,9 @@
- V2 / V3 只记录,不实现。**图搜、Excel、ERP、订单自动核对、AI 辅助全部不在 MVP。** - V2 / V3 只记录,不实现。**图搜、Excel、ERP、订单自动核对、AI 辅助全部不在 MVP。**
- 需求明确排除的非目标不得实现。 - 需求明确排除的非目标不得实现。
- 不为「将来可能用到」提前抽象。 - 不为「将来可能用到」提前抽象。
- **Phase 1 真机结论出来之前,不写依赖真机可读字段或会启动试选的 Phase 2 功能。** T-201 - **Phase 1 真机结论出来之前,不写依赖真机可读字段的生产执行功能。** T-201 管理会话、只创建
管理会话与只创建 `DRAFT` 的 T-202 基础建单 / 列表可以并行;T-203 的批量开始试选、T-204 `DRAFT` 的 T-202,以及 T-203 只锁定现有任务字段的服务端开始采购授权事务可以并行;T-205 起
的试选证据详情以及 T-205 以后仍等待 T-103。T-201 / T-202 不得夹带机器实际规格、规格面板 的实际 attempt / 页面证据等待 T-103。T-201 / T-202 不得夹带授权、机器结果、提交或付款字段。
单价、证据、授权、提交或付款字段。
## 5. 架构纪律 ## 5. 架构纪律
+90 -96
View File
@@ -1,42 +1,37 @@
# 任务路线图(Roadmap) # 任务路线图(Roadmap)
> 本文是**只读路线图**:维护阶段划分、里程碑、待办池和建议拆分清单。 > 本文维护阶段、依赖和建议拆分,不跟踪状态。真实状态、写路径和执行证据在
> 真实任务以「一任务一文件」存放在 [`tasks/`](tasks/README.md)(`docs/tasks/T-<编号>.md`), > [`docs/tasks/T-XXX.md`](tasks/README.md);状态权威是 git frontmatter。
> 状态权威在任务文件 frontmatter。**本文不跟踪单任务状态。**
## 使用规则 ## 使用规则
1. **开工先落文件**:从下方清单把下一个任务落成 `docs/tasks/T-<编号>.md`(沿用建议编号), 1. 从路线图领取任务前先落任务文件、边界、写路径和验收门禁。
把验收要点展开成可执行、可观察的步骤,再开始实现。 2. 一个 agent 同时只负责一个 `DOING`;只有写路径互不重叠才并行。
2. **每个 agent 一次只做一个任务**:领取、状态流转、执行记录、完成定义遵循 3. `needs_device: true` 只能由人完成验收,agent 写完代码仍保持 `DOING`。
[`tasks/README.md`](tasks/README.md) 和[编码规则](05-coding-rules.md)。 4. 页面判据必须先在本项目真机取证。真机结论变化时先修订契约/原型,再写生产流程。
3. **不跳步**:依赖未完成的任务不能开工。T-103 完成前可并行 T-201 管理会话与只创建 `DRAFT` 5. 管理员点击“开始采购”是创建待付款订单的授权;单趟业务流程不等于跳过分段取证。
的 T-202 基础建单 / 列表;**依赖真机可读字段或会启动试选的 Phase 2 功能仍不得抢跑**,
T-103 若改变可读字段则先修订原型与 IX。
4. **本文只在规划变化时修改**:单个任务开工或完成**不**修改本文。
5. **动手前**先读 `00-ai-start-here.md`、`05-coding-rules.md` 和 `current-state.md`。
## 并行执行与关键路径 ## 并行执行与关键路径
执行顺序以任务表中的 `依赖` 为准,Phase 是里程碑分组,**不是要求整段串行的栅栏**。 Phase 是里程碑分组,不是整段串行栅栏。推荐波次:
在 `write_paths` 不重叠且依赖已完成的前提下,按以下波次推进:
| 波次 | 采购服务方向 | 采购工具 / 真机方向 | 集成方向 | | 波次 | 采购服务 agent | 采购工具 agent | 大脑 / 集成 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 1 | T-001 初始化 `admin/` | T-002 初始化 `client/` | - | | 1 | T-001 → T-004 → T-201 → T-202 | T-002 → T-101 → T-102 | T-003、原型与契约 |
| 2 | T-001 后立即做 T-004 | T-002 后立即做 T-101 → T-102 → **T-103** | T-001、T-002 都完成后做 T-003 | | 2 | T-209 → T-203 → T-204 | T-103 → T-104 → T-105 → T-107 | T-111 单趟重构;逐任务主审 |
| 3 | 与 T-103 并行做 T-201 → T-202;T-103 后做 T-203 → T-207 | T-103 后推进 T-104 → T-107 | T-208 等待 T-207 与 T-107 均完成 | | 3 | T-205 → T-207 → T-208 | T-301 → T-304 → T-306 | 双端契约完整门禁 |
| 4 | T-301 → T-302 | T-302 后交接 T-303 → T-304 → T-306 | T-306 与 T-104 完成后做 T-305 | | 4 | - | T-305 | 围栏前端到端 dry-run 主审 |
| 5 | - | T-305、T-208、T-306 后做 T-401 | T-401 后并行 T-402 / T-403,再做 T-404 → T-405 | | 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 的真机结论出来前,T-201 / T-202 只能落管理会话、`DRAFT` 建单与基础列表;不启动试选, - T-202 只创建/展示 `DRAFT`,可与 T-103 并行;T-203 只实现管理员授权与任务状态,不依赖页面
不展示机器规格、规格面板单价或证据。T-203~T-207 继续等待 T-103。 选择器,也可在 T-103 完成前推进。
- `needs_device: true` 的任务仍只能由人完成验收。 - T-205 起涉及实际 attempt/证据字段,必须等待 T-103 确认规格和价格最小事实。
- 不复用前序项目页面判据,不放宽三道价格闸门,不让第一趟引用任何下单函数。 - T-103、T-105、T-106、T-107 的 capability 相互隔离;未经取证的后续动作不可提前暴露。
- 第三个 agent 优先做写路径独立的集成任务或只读复核,不与两端任务争写共享文档。 - T-401 前任何任务都不得真实点击“提交订单”;支付能力永远不实现。
- 主 agent 独立审阅每个子 agent 的 diff 并重跑验证,不以其自报完成作为证据。
## 建议拆分清单 ## 建议拆分清单
@@ -44,101 +39,100 @@ T-004 或整个 Phase 0 收尾。并行只优化等待关系,不改变下列
| ID | 任务 | 依赖 | 验收要点 | | ID | 任务 | 依赖 | 验收要点 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| T-001 | 初始化采购服务 `admin/` Go 骨架 | - | 在 `admin/` 创建 Go 模块;`go test ./...`、`go vet ./...` 可运行;健康检查端点可访问;用真实命令替换 `03-tech-stack.md`、`00-ai-start-here.md`、`current-state.md` 中的占位命令 | | T-001 | 初始化采购服务 `admin/` | - | Go 模块、健康检查、`go test` / `go vet` 可运行 |
| T-002 | 初始化采购工具 `client/` Python 骨架 | - | 在 `client/` 创建虚拟环境、`requirements.txt` 与应用骨架;`unittest`、`python -m compileall` 可运行;日志与产物目录策略明确且不记录敏感信息 | | T-002 | 初始化采购工具 `client/` | - | Python 包、unittest、compileall、日志/产物目录可运行 |
| T-003 | 建立 `init.ps1` 统一入口 | T-001, T-002 | 一条命令完成两端安装与基础验证并打印启动命令;未配置时主动失败而不是静默跳过 | | T-003 | 建立 `init.ps1` 统一入口 | T-001, T-002 | 一条命令安装并验证两端;失败明确,不静默跳过 |
| T-004 | 建立核心数据模型与状态机 | T-001 | `tasks`、`order_authorizations`、`order_submissions` 表与 `04-architecture.md` 一致;状态流转有单元测试;金额用十进制字符串 | | T-004 | 建立核心数据模型与状态机 | T-001 | 基础 tasks / authorization / submission 模型、十进制金额和状态测试 |
| T-005 | 网页端 MVP 交互原型 | - | “采购服务”的 `docs/design/web-*.html` 单文件假数据原型覆盖登录、表格工作台、创建弹窗、批量开始试选、路由化详情抽屉与完整详情;键盘、窄屏、空态 / 错误 / 加载、围栏后调和状态可演示;经人工确认前保持 `DOING` | | T-005 | 网页端 MVP 原型 | - | 采购服务登录、传统表格、创建、批量开始采购、路由详情、待付款/调和状态可演示 |
| T-006 | 桌面端 MVP 交互原型 | - | `docs/design/desk-*.html` 单文件假数据原型覆盖“采购工具”的采购执行 / 配置固定页签、顶部轮询控制、当前任务图片、滚动日志、时间倒序执行记录,以及左侧当前任务 / 记录详情双视图;明确 dry-run / 真实下单、App 版本失配、围栏后不可重试;经人工确认前保持 `DOING` | | T-006 | 桌面端 MVP 原型 | - | 采购工具固定 tabs、轮询、当前任务/图片/日志、记录详情双视图和单趟状态可演示 |
| T-007 | 统一产品名称与源码目录契约 | - | 项目名固定为 `cmbuyer`;采购服务使用 `admin/`,采购工具使用 `client/`;架构、任务和启动脚本不再把 `web/` / `desk/` 当生产源码目录 | | T-007 | 统一产品与目录名称 | - | 项目 `cmbuyer`;网页端“采购服务”在 `admin/`;桌面端“采购工具”在 `client/` |
| T-010 | 原型与交互核对 | T-005, T-006 | 人工确认 P0 页面、布局、键盘、异常和无生产副作用 |
### Phase 1 · 真机取证(最高风险,必须先做) ### Phase 1 · 真机取证(最高风险)
> 本阶段每个任务都需要真机,**只能由人完成验收**。结论写入任务文件并同步 > 每个任务均 `needs_device: true`。截图、XML、goods_id、设备、Android、连接方式和拼多多 App
> `04-architecture.md`;**每条页面判据必须记录拼多多 App 版本**。 > 版本写入任务记录;只有人能把任务标为 DONE。
| ID | 任务 | 依赖 | 验收要点 | | ID | 任务 | 依赖 | 验收要点 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| T-101 | 验证 ADB 与 uiautomator2 连接(USB + WiFi) | T-002 | 两种连接都能列设备、截图、`dump_hierarchy(compressed=False)`;同一手机双通道在线时报明确错误;超时参数可配置 | | T-101 | 验证 ADB / uiautomator2(USB + WiFi) | T-002 | 两通道截图和 dump;同手机双通道 fail closed;超时可配置 |
| T-102 | 验证按链接打开商品详情页 | T-101 | 输入 `goods_id` 链接后真机进入对应详情页;打不开时有可区分的失败原因;保存截图与页面 XML | | T-102 | 验证按链接打开商品详情页 | T-101 | canonical goods 链接进入对应商品;前台包与 goods_id 有证据 |
| T-103 | 验证规格面板打开、按维度精确选择、**读取 SKU 单价** | T-102 | 能打开规格面板;按 `颜色分类=X`、`尺码=Y` 精确选中并读回确认;**防前缀碰撞**;找不到精确值时停止且不点相近项;**取证单价在哪个节点、是否带券后前缀**——闸门一依赖此结论 | | T-103 | 验证受控规格面板、精确选择与 SKU 单价 | T-102, T-110, T-111 | 精确唯一入口、维度内等值选择/读回、防前缀碰撞、当前价唯一读取;隔离 capability 不含数量/确认/提交 |
| T-104 | 验证试选后安全退出并释放手机 | T-103 | 读完价截完图后退出商品页;**全程不点击「现在买」或任何下单入口**,有测试证明;退出后可立即开始下一条任务 | | T-104 | 验证规格选择能力安全退出 | T-103 | 完成选择/读价/截图后可关闭面板或退出;不进入确认页、不提交 |
| T-105 | 验证数量设置与复核 | T-103 | 设置后读回精确等于要求值;不等时停止,不进入购买入口 | | T-105 | 验证数量设置、读回与闸门二 | T-103 | 设置数量并精确读回;再次读规格/单价等于闸门一;不一致即停 |
| T-106 | dry-run:验证进入订单确认页并读「实付款」 | T-105 | 进入确认页读出规格、数量、实付金额;验证提交控件唯一但**绝不点击**;不提取地址原文与手机号;保存截图与 XML 证据到任务产物 | | T-106 | dry-run:确认页与闸门三取证 | T-105 | 进入确认页读规格、数量、应付总额并验证提交控件唯一;绝不点击 |
| T-107 | 固化提交控件判据与 dry-run 安全边界 | T-106 | 只把本项目真机证据转成可测试判据;第一趟和 dry-run 路径不可达 `submit_order`;App 版本不匹配时 fail closed;**不创建真实订单** | | 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-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次允许创建真实待付款订单的是 T-401,
> 的任务是 T-401;执行前必须取得明确授权,并在任务文件记录订单是否产生、如何处置。 执行前必须有明确人工授权,并记录订单是否产生及如何处置。
### Phase 2 · 采购服务核心 ### Phase 2 · 采购服务核心
| ID | 任务 | 依赖 | 验收要点 | | ID | 任务 | 依赖 | 验收要点 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| T-201 | 管理员登录与会话 | T-004, T-005 | 登录建立会话;CSRF 生效;未登录跳转并只接受站内返回路径 | | T-201 | 管理员登录与会话 | T-004, T-005 | 会话、CSRF、站内 return path 与权限测试 |
| T-202 | 手工建单(F-001) | T-201 | 列表内创建弹窗与 `/tasks/new` 复用表单;任务名称、链接、颜色分类、尺码、数量、价格上限校验;成功创建 `DRAFT` 并倒序显示第一行 | | T-202 | 手工建单(F-001) | T-201 | 创建/展示 `MANUAL + DRAFT`;链接/金额/幂等/倒序;不含授权或执行 |
| T-203 | 任务表格、查询与批量开始试选(F-004、F-018) | T-202 | 传统表格无操作列;标题关键词筛选;只选 `DRAFT`;批量 `DRAFT → PENDING` 全有或全无;空状态可清除筛选 | | T-209 | 把核心 schema / 状态机迁移为单趟模型 | T-004, T-111 | 新 migration 安全替换旧 `spec_trials` / 授权锁价结构;新增 `purchase_attempts`;删除旧 MVP 状态;无业务数据事实写入迁移前置,遇未知旧数据 fail closed;领域/迁移测试同步 |
| T-204 | 路由化任务详情(F-011) | T-203 | 从列表进入显示保留现场的右侧抽屉,直达显示完整页;按状态呈现唯一主区块与主动作;执行证据可查看 | | T-203 | 表格查询与批量开始采购(F-004、F-008、F-018) | T-202, T-209 | 只选 DRAFT;按钮明确“不付款”;事务内创建锁定任务字段的一次性授权并全批 `DRAFT → PENDING`;重放/冲突测试 |
| T-205 | 试选结果接收与确认页(F-007) | T-204, T-103 | 接收 `spec_trials` 与规格面板截图;确认页展示需求 vs 所选、单价、合计、截图;**轻量版,非对照台** | | T-204 | 路由化任务详情与内部截图资产(F-007、F-011) | T-203 | 抽屉/完整页共享 URL;状态详情;受保护 `INTERNAL_RAW` screenshot 上传/读取、SHA-256 与 no-store;不接收 XML |
| T-206 | 人工确认与授权签发(F-008) | T-205 | 确认即签发一次性授权并**锁定试选单价**;`expected_task_version` 冲突返回 409;金额上限服务端校验;支持「退回,不买」 | | T-205 | purchase attempt、事件与执行摘要 API | T-204, T-103 | 记录实际规格、两次单价、数量、确认页金额、固定 reason;不再创建 `spec_trials` 审批记录 |
| T-207 | 授权超时与围栏前放弃(F-010) | T-206 | 围栏前超时 / 放弃后任务转 `PENDING_RETRIAL`,必须重新试选;围栏后禁止超时释放或放弃,改走人工核查;每个状态都有安全下一步 | | T-206 | 单趟状态视图与安全主动作 | T-205 | PENDING/CLAIMED/ORDERING/NEEDS_MANUAL/WAITING_PAYMENT/RECONCILIATION_REQUIRED 显示和允许动作一致;无中途确认 |
| T-208 | 提交围栏与结果调和 API(F-017) | T-207, T-107 | dry-run start / ready、submission start / reconcile / manual-review 幂等;围栏事务原子消费执行权;响应不明不允许点击;不确定结果保留额度且不可重试 | | T-207 | 授权有效期与围栏前重置(F-010) | T-206 | 围栏前过期/关闭旧授权后回 DRAFT;围栏后拒绝释放、取消、重新授权 |
| T-208 | 提交围栏与结果调和 API(F-017) | T-207, T-107 | 原子复核授权/attempt/三闸门/唯一控件;首次许可一次;重放不许可点击;结果不明永久围栏且只调和 |
### Phase 3 · 双端打通(第一趟) ### Phase 3 · 双端打通(单趟围栏前)
| ID | 任务 | 依赖 | 验收要点 | | ID | 任务 | 依赖 | 验收要点 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| T-301 | 设备凭据与身份隔离(F-013) | T-201 | 设备 Bearer 不能建单或授权;管理会话不能调设备接口;凭据可撤销 | | T-301 | 设备凭据与身份隔离(F-013) | T-201 | Bearer 不能建单/授权;管理会话不能领任务;凭据可撤销 |
| T-302 | 原子领取与租约(F-005) | T-301, T-004 | 并发领取只有一个成功;重复领取重放同一结果;**同时支持领取待试选与已授权两类**;`claim_token` 与 `claim_generation` 校验生效 | | T-302 | 已授权任务原子领取与租约(F-005) | T-301, T-203 | 只领 PENDING+有效授权;并发唯一;重放同一 attempt;claim token/generation 有效 |
| T-303 | 采购工具 `HttpTaskSource` / `HttpResultSink` | T-302, T-002 | 执行器只依赖抽象;测试假数据与断连 JSONL 暂存不扩大 Excel MVP 范围;补传使用幂等键 | | T-303 | `HttpTaskSource` / `HttpResultSink` | T-302, T-002 | 执行器依赖抽象;幂等重放;线协议严格校验;不扩大 Excel 范围 |
| T-304 | 定时轮询与会话边界 | T-303, T-006 | 只在执行员启动的会话内轮询,关窗口即停;连续失败达阈值自动停止并提示原因;间隔可配置 | | T-304 | 定时轮询与会话边界 | T-303, T-006 | 人启动后轮询;停止只阻止下次领取;连续失败停;当前任务/记录详情双视图 |
| T-306 | 证据上传与分层 | T-304, T-103 | 规格面板截图与订单截图上传服务端并脱敏;节点树与失败现场留本地;上传前校验不含地址与手机号 | | T-306 | 单趟截图与证据上传 | T-304, T-204 | 显式上传原始 screenshot;可含页面地址/手机号;完整 XML、本机路径、外部支付页不上传 |
| T-305 | **第一趟试选端到端** | T-306, T-104 | 从领取跑到试选回传:开商品、勾选规格、读单价、截图、退出释放手机;任务转「等你确认」;全程有事件与证据 | | T-305 | **单趟围栏前端到端 dry-run** | T-306, T-107, T-205 | 从领取连续跑到闸门三与提交控件验证,随后安全停止;全程事件/证据齐全,不申请真实许可、不点击提交 |
### Phase 4 · 第二趟与收尾 ### Phase 4 · 真实提交与收尾
| ID | 任务 | 依赖 | 验收要点 | | ID | 任务 | 依赖 | 验收要点 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| T-401 | **第二趟下单端到端** | T-305, T-208, T-306 | 拿到授权后重新开商品、重新选同一规格、过三道闸门、原子建立提交围栏后提交一次;不确定结果进入调和且不可重试;明确创建才转「待付款」 | | T-401 | **单趟采购与提交围栏端到端** | T-305, T-208 | 同一设备会话通过三闸门、首次明确围栏许可后点击一次;结果不明只调和;明确创建转待付款;绝不付款 |
| T-402 | 待付款收口与手工完成 | T-401 | 详情页展示订单截图与授权信息供核对;人付款后手工标记完成;**待付款不等于成功** | | T-402 | 待付款收口与手工完成 | T-401 | 详情展示订单证据;人付款后手工标记;待付款不等于成功 |
| T-403 | 失败分类与证据归档(F-011) | T-401 | 覆盖需求列出的全部失败原因;终态一次原子回写,不落中间态 | | T-403 | 失败分类与证据归档 | T-401 | 覆盖需求 reason;终态原子回写;围栏后无重试 |
| T-404 | 完整验收 MVP | T-402, T-403 | `02-requirements.md` 的 P0 验收全部通过;真机记录写入任务文件与 `current-state.md` | | T-404 | 完整 MVP 验收 | T-402, T-403 | P0 验收、双端门禁、真机记录和 current-state 全部通过 |
| T-405 | 采购工具打包与运行文档 | T-404 | 运营电脑可按文档运行;记录产物 SHA-256 | | T-405 | 采购工具打包与运行文档 | T-404 | 运营电脑可安装运行,记录产物 SHA-256 |
### V2 及以后(不在 MVP,编号预留) ### V2 及以后
| ID | 任务 | 说明 | | ID | 任务 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| T-501 | 图片搜索产出 goods_id(F-014) | 推图到相册、搜图、从结果页取 goods_id;**不读价格**;最多 5 个 | | T-501 | 图片搜索产出 goods_id(F-014) | 不在结果页读价;最多 5 个 |
| T-502 | 候选对照台(F-015) | 多候选并排对照,跨列逐行对齐,窄屏降级 | | T-502 | 候选对照台(F-015) | 多候选对照,窄屏降级 |
| T-503 | Excel 批量建单(F-002) | 被表头契约待确认阻塞 | | T-503 | Excel 批量建单(F-002) | 等表头契约 |
| T-504 | ERP 货运同步与建单(F-003) | 被字段映射待确认阻塞 | | T-504 | ERP 同步与建单(F-003) | 等字段映射 |
| T-505 | 批量顺序编排与人工接管(F-012) | 勾选多条、暂停继续、运行中冻结 | | T-505 | 批量编排与运行中接管(F-012) | 排序、暂停继续、运行中冻结 |
| T-506 | 订单自动核对回读(F-016) | 五项唯一匹配才自动回写,否则待人工 | | T-506 | 订单自动核对回读(F-016) | 五项唯一匹配,否则人工 |
| T-507 | 本地执行轨迹留档(F-103) | NDJSON,含规则与模型判断的分歧字段 | | T-507 | 本地执行轨迹留档(F-103) | NDJSON 与模型/规则分歧 |
| T-508 | AI 辅助(F-101、F-102) | 模型结论**不能放宽任何安全边界** | | T-508 | AI 辅助(F-101、F-102) | 模型结论不能放宽安全边界 |
## 里程碑 ## 里程碑
- **M0**:网页端与桌面端 P0 原型经人工确认,流程、状态与主动作可枚举。(T-005、T-006) - **M0**:两端原型确认;单趟主动作、状态、异常和键盘可枚举。(T-005、T-006、T-111)
- **M1**:两端骨架可运行,数据模型与状态机落地。(Phase 0) - **M1**:两端骨架、基础模型与验证入口可运行。
- **M2**:真机能按链接打开商品、精确勾选颜色分类和尺码、**读到该 SKU 单价**。(T-103) - **M2**:真机按链接打开商品、精确选择颜色/尺码并读 SKU 单价。(T-103)
- **M3**:真机能设对数量、以 dry-run 进入订单确认页读到「实付款」并验证唯一提交控件, - **M3**:真机设置数量、闸门二、确认页闸门三和唯一提交控件 dry-run 通过,不点击。(T-107)
但不点击。(T-107) - **M4**:管理员建单并通过批量开始采购创建一次性授权;设备可安全领取。(T-203、T-302)
- **M4**:管理员能建单、看到试选结果、确认并签发授权。(Phase 2) - **M5**:单趟从领取到围栏前 dry-run 跑通,事件与证据齐全。(T-305)
- **M5**:第一趟试选端到端跑通,任务能停在「等你确认」。(T-305) - **M6**:单趟真实提交只创建待付款订单,围栏/调和/人工收口闭环。(T-401~T-404)
- **M6**:MVP 闭环——第二趟下单成功,任务停在「待付款」。(T-401)
**M2 是本项目的生死线。** 前序项目正是卡在选规格和读价;M2 不通过之前只允许 T-201 与 **M2 仍是生死线。** M2 前可以并行管理会话、DRAFT 建单和开始采购的服务端授权事务,但不能编写
不启动试选的 T-202,不写依赖真机字段或会推进任务执行的 Phase 2 功能。原型只用于确认信息架构, 依赖未取证页面字段的生产执行逻辑。提速来自两端并行、窄任务和主审复用门禁,不来自删除安全闸门。
T-103 若改变可读字段必须先回修原型与 IX。
## 待办池(Backlog) ## 待办池
- V2 全部条目见上方「V2 及以后」表(T-501 ~ T-508) - V2:T-501~T-508;多设备、完整 RBAC、多平台。
- 多设备并行(F-104) - 证据保留期和自动清理策略。
- 完整 RBAC(F-105) - SQLite → Postgres 迁移评估。
- 多平台比价(F-106) - 设备凭据轮换。
- 证据保留期与自动清理策略 - 风控友好的轮询与动作间隔(需真机观察)。
- 采购服务从 SQLite 迁移到 Postgres 的评估
- 设备凭据轮换机制
- 定时轮询间隔的风控友好节奏(需真机观察后确定)
+89 -209
View File
@@ -1,259 +1,139 @@
# 用户故事清单 # 用户故事清单
> 本文记录「谁在什么场景下,为了获得什么价值,要完成什么目标」。不写接口、数据字段、 > 本文从用户目标描述 MVP。功能编号以[需求](02-requirements.md)为准,具体界面行为以
> 组件实现或逐个按钮的行为。 > [交互清单](08-interaction-checklist.md)为准。
> 页面如何响应操作见[交互清单](08-interaction-checklist.md);页面入口见[路由与页面结构](routes.md);
> 接口形状以 [API 合约](api.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-001 | 把“要买什么”变成待开始任务 | P0 | 采购管理员 | 一次填写链接、规格、数量和最高总价 | F-001 | IX-002 | 已定 |
| US-002 | 知道每条任务现在卡在谁那里 | P0 | 采购管理员 | 一眼分出「等我」和「机器在跑」,快速找到要处理的那条 | F-004、F-011 | IX-003、IX-004 | 已定 | | 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-003 | 让手机逐条执行已授权任务 | P0 | 采购执行员 | 开启轮询后无需盯每一步,异常时再介入 | F-005、F-006 | IX-007、IX-008 | 已定 |
| US-004 | 确认机器有没有选对 | P0 | 采购管理员 | 看一眼机器实际勾选到的规格和价格,判断能不能买 | F-007、F-008 | IX-005 | 已定 | | US-004 | 查看机器实际执行证据 | P0 | 采购管理员 | 追溯规格、价格、数量、围栏和截图 | F-007 | IX-004 | 已定 |
| US-005 | 放心地授权一笔下单 | P0 | 采购管理员 | 授权后确信系统只下单不付款,且不会因断网重复下单 | F-008、F-009、F-010、F-017 | IX-005、IX-010、IX-011 | 已定 | | US-005 | 明确授权且不会重复下单 | P0 | 采购管理员 | 点击开始采购后,只创建一笔待付款订单且绝不自动付款 | F-008、F-010、F-017 | IX-005、IX-010、IX-011 | 已定 |
| US-006 | 付款前核对订单再花钱 | P0 | 采购管理员 | 对着系统给的几项组织人工核对,不一致就不付 | F-009 收口 | IX-006 | 已定 | | US-006 | 付款前核对订单 | P0 | 采购管理员 | 对照系统证据后由人决定是否付款 | F-006 收口 | IX-006 | 已定 |
| US-007 | 管理身份和设备身份互不越权 | P0 | 采购管理员 | 设备被盗用也不能建单或改授权 | F-013 | IX-001 | 已定 | | US-007 | 管理身份与设备身份不越权 | P0 | 采购管理员 | 设备凭据泄露也不能建单或授权 | F-013 | IX-001 | 已定 |
| US-008 | 任务出问题时知道该怎么办 | P0 | 采购执行员 | 看到具体原因和下一步,而不是一个「失败」 | F-011 | IX-008 | 已定 | | US-008 | 出问题时知道下一步 | P0 | 采购执行员 | 看到具体阶段、原因和安全恢复方式 | F-011、F-017 | IX-008、IX-011 | 已定 |
| US-010 | 把选中的任务交给机器试选 | P0 | 采购管理员 | 一次选中多条待开始任务,让桌面端逐条领取第一趟试选 | F-018 | IX-012 | 已定 | | US-010 | 批量授权待开始任务 | P0 | 采购管理员 | 一次把选中任务交给采购工具逐条创建待付款订单 | F-008、F-018 | IX-012 | 已定 |
> US-009(从 ERP 货运明细建单)随 F-003 移出 MVP,编号保留不重用。
## 三、故事详情 ## 三、故事详情
### US-001 把「要买什么」变成一条任务 ### US-001 把“要买什么”变成待开始任务
- 优先级:P0 | 关联功能:F-001 | 关联交互:IX-002 作为采购管理员,我希望在任务列表点击创建,填写标题、拼多多链接、颜色、尺码、数量和最高总价,
- 角色:采购管理员 保存后立即在首行看到 `DRAFT`,以便稍后统一授权。
- 前置条件:已登录,手上有拼多多商品链接和要买的颜色分类、尺码。
**用户故事** 验收场景:
作为采购管理员,我想要填一次链接和规格就把任务交出去,从而不必自己再去手机上操作。 - 合法输入保存成功并规范化商品链接;创建动作不触发设备和授权。
- 链接、规格、数量或金额不合法时,在字段附近说明原因并保留其他输入。
- 重复提交同一 `create_key` 只返回原任务,不创建重复记录。
**范围** ### US-002 知道任务当前卡在哪里
- 包含:任务名称、手工填链接、颜色分类、尺码、数量、价格上限。 作为采购管理员,我希望在传统表格中按标题和状态筛选,看到标题链接、颜色、尺码、价格、数量、
- 不包含:Excel 批量导入、从 ERP 生成、任务模板(均为 V2)。 采购结果、状态和创建时间,并双击行查看详情。
**验收场景** 验收场景:
1. 假如我有商品链接和规格,当我在任务列表的创建弹窗填完并保存,那么得到任务编号, - 默认按创建时间倒序;标题是指向拼多多商品页的链接。
弹窗关闭,任务按创建时间倒序出现在表格第一行且状态为待开始。 - 没有操作列;详情通过双击行打开路由化抽屉,完整 URL 可复制,浏览器返回恢复列表现场。
2. 假如链接无法解析出商品标识,当我提交,那么系统明确报错并保留我已填的内容。 - 状态明确区分待开始、已授权待领取、执行中、待人工、围栏后待调和、待付款和终态。
3. 假如我没填价格上限,当我提交,那么系统拒绝——**价格上限是资金边界,不能留空**。 - 空筛选结果提供“清除筛选”,错误页保留安全重试;不把已围栏提交显示为可重试。
### US-002 知道每条任务现在卡在谁那里 ### US-003 让手机逐条执行已授权任务
- 优先级:P0 | 关联功能:F-004、F-011 | 关联交互:IX-003、IX-004 作为采购执行员,我希望配置服务、设备 serial 和轮询间隔后,点击一次开始轮询,让采购工具逐条领取
- 角色:采购管理员 已授权任务并在同一趟中完成选规格、价格校验、数量、确认页和提交围栏。
- 前置条件:已登录,系统中有若干条不同状态的任务。
**用户故事** 验收场景:
作为采购管理员,我想要打开就看清哪几条在等我,从而不必逐条点进去确认还需不需要我 - 启动前同时校验采购服务、ADB 设备和已取证拼多多版本;任一不满足则不启动。
处理。 - 顶部始终显示会话状态;当前任务、图片、滚动日志和倒序采购记录实时更新。
- 精确规格、两次单价、数量读回、确认页总额或提交控件任一不合格时停止该任务。
- 围栏明确成功后只点一次“提交订单”;系统绝不点击付款。
- 连续轮询失败达到阈值自动停止;停止轮询不取消正在执行或已围栏的任务。
**范围** ### US-004 查看机器实际执行证据
- 包含:传统任务表格、关键词与时间范围查询、任务详情抽屉和完整详情页中的结果与证据。 作为采购管理员,我希望打开任务详情后看到本次授权、设备领取、实际规格、三道价格闸门、截图、
- 不包含:自定义视图、导出报表、跨任务统计。 提交围栏与结果,以便审计发生了什么,而不是在中途批准机器选择。
**验收场景** 验收场景:
1. 假如有任务停在等待人工确认,当我打开工作台,那么表格状态与采购结果能让我一眼识别, - 证据按 attempt 和时间排序,区分规格面板、确认页、提交结果。
双击该行即可在不丢失列表现场的情况下查看详情。 - 原始截图仅登录管理员可看,响应不缓存;完整 XML 不出现在页面或接口中。
2. 假如我按关键词筛选后没有匹配,当结果为空,那么显示空状态并提供清除筛选,而不是 - 地址/手机号不提取为字段或日志,外部支付页和支付凭据不存储。
一片空白或报错。 - 执行中没有“确认机器选对了吗”或“确认后继续第二趟”按钮。
3. 假如任务失败了,当我打开详情,那么我看到可区分的失败原因和当时的截图,而不只是
「失败」两个字。
### US-003 让手机自己跑,不用我盯着 ### US-005 明确授权且不会重复下单
- 优先级:P0 | 关联功能:F-005、F-006 | 关联交互:IX-007、IX-008 作为采购管理员,我希望点击“开始采购(只创建待付款订单)”时明确知道影响范围,并确信系统不会
- 角色:采购执行员 超出金额上限、不会付款,也不会因为断网重复提交。
- 前置条件:电脑已连上 web 端;手机已连接、已解锁、已登录拼多多。
**用户故事** 验收场景:
作为采购执行员,我想要开一次轮询就让手机排着队把任务跑掉,从而把注意力留给真正需要 - 只有 `DRAFT` 可选;操作条显示选中数量和最高总额。
判断的时刻。 - 点击即创建一次性授权并整批转 `PENDING`;不再要求试选后确认。
- 任一任务版本冲突时整批不变;同一开始键重放同一结果。
- 设备在同一趟通过三道闸门;服务端围栏明确建立后才准许点击一次。
- 围栏前失败可由人重置并重新授权;围栏后只能调和同一提交,永不出现“再次下单”。
**范围** ### US-006 付款前核对订单
- 包含:定时轮询领取、第一趟试选、第二趟下单、连续失败自动停。 作为采购管理员,我希望任务转为待付款后看到商品、规格、数量、授权上限、确认页金额与订单截图,
- 不包含:多台手机并行、无人值守整夜运行、已开始任务的排序、暂停 / 继续和运行中接管(V2)。 在拼多多核对后由人决定是否付款。
**验收场景** 验收场景:
1. 假如有待处理任务且设备就绪,当我点击开始轮询,那么系统按间隔领取任务并显示**这一趟 - `WAITING_PAYMENT` 明确写“订单已创建,系统尚未付款”。
是试选还是下单**;两个实例并发不会领到同一条。 - 标记完成前再次提示该操作只记录人工付款事实,不执行支付。
2. 假如第一趟试选完成,当机器读完价截完图,那么**它退出商品页释放手机**,立刻可以开始 - 人确认已付款后才转 `SUCCEEDED`;发现不一致时不付款并进入人工处理。
下一条,而不是停在规格面板上等我。
3. 假如某条任务需要人工,当它停下来,那么界面显著提示缺什么,该任务不再被本端领取,
但轮询继续跑其他任务。
4. 假如连续失败达到阈值,当失败累积,那么轮询自动停止并说明原因,**不无限重试**。
5. 假如我关闭窗口,那么轮询立即停止,不留后台进程。
### US-004 确认机器有没有选对 ### US-007 管理身份与设备身份不越权
- 优先级:P0 | 关联功能:F-007、F-008 | 关联交互:IX-005 作为采购管理员,我希望网页会话与设备 token 完全隔离,以便设备只能做被授权的执行工作。
- 角色:采购管理员
- 前置条件:任务已完成第一趟试选并回传结果。
**用户故事** 验收场景:
作为采购管理员,我想要看一眼机器**实际勾选到**的规格和读到的价格,从而在它去下单之前 - 未登录不能查看任务或截图;登录会话具备 CSRF 防护。
确认没选错。 - 设备 token 不能创建任务、开始采购、重置任务或人工调和。
- 管理会话不能伪装设备领取或建立围栏;设备凭据可撤销。
**范围** ### US-008 出问题时知道下一步
- 包含:需求与机器所选的逐项对照、单价与合计、规格面板截图、确认或退回。 作为采购执行员,我希望错误包含阶段、固定原因和安全下一步,以便处理而不冒险重试。
- 不包含:在多个候选之间挑选(MVP 只有一个商品,多候选对照台是 V2)。
**验收场景** 验收场景:
1. 假如机器选到的颜色分类和尺码与我要的一致、合计没超上限,当我打开详情,那么各项 - 至少区分设备、App 版本、商品、面板、规格、价格、数量、确认页、围栏、安全校验和网络错误。
显示 ✓ 且确认按钮可用。 - 围栏前错误提示停止、留证并等待人工处理;不会自动重新开始。
2. 假如机器选到的规格与我要的不符,当我打开详情,那么显示 ✗ 并说明哪一项不符, - 围栏后错误明确写“可能已创建订单,只能调和,禁止再次点击”。
**确认按钮禁用**,我只能退回或转人工。 - 桌面端详情切换不改变服务端状态;Esc 只回到当前任务视图,不取消业务流程。
3. 假如合计超出我设的上限,当我打开详情,那么显示超出多少且确认按钮禁用,
**不提供「仍然确认」入口**。
4. 假如价格读不到,那么任务根本不会进到这一步,而是直接转人工并说明原因。
### US-005 放心地授权一笔下单 ### US-010 批量授权待开始任务
- 优先级:P0 | 关联功能:F-008、F-009、F-010、F-017 | 关联交互:IX-005、IX-010、IX-011 作为采购管理员,我希望勾选多条 `DRAFT` 后统一点击开始采购,以便减少重复操作,同时知道每条都
- 角色:采购管理员 会允许采购工具创建一笔待付款订单。
- 前置条件:任务处于等待确认,已看过试选结果。
**用户故事** 验收场景:
作为采购管理员,我想要确认后签发一次授权,从而让系统去下单,同时确信它不会替我付钱, - 表头复选框只选择当前筛选结果中的 `DRAFT`;切换筛选时选择范围可见且可清空。
也不会因为卡住而让任务永久停摆。 - 未选择时按钮禁用;选中后显示数量、最高总额与“不付款”说明。
- 点击后批量全有或全无;成功反馈授权数量,冲突反馈刷新重选。
**范围** - 非 `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. 假如其中一条任务在提交前已被别人启动,当版本冲突发生,那么整批不产生部分成功,
页面刷新状态并让我重新选择。
## 四、交付前检查 ## 四、交付前检查
- [ ] 每个 P0 功能至少关联一个 US 编号。 - 每个 P0 故事至少有一个自动化测试或明确的人机验收证据。
- [ ] 每个故事说明角色、目标、价值和可验证的验收场景。 - 页面、API、状态机只使用单趟采购术语,不再出现试选后确认、授权旧试选价或第二趟下单。
- [ ] UI 故事已关联对应 IX 编号。 - 任一可见“开始采购”都同时说明“只创建待付款订单,系统不付款”。
- [ ] 故事没有复制接口、字段或组件实现细节。 - 任一围栏后状态都不提供重试、释放授权或重新开始入口。
- [ ] 范围、优先级与[需求](02-requirements.md)一致。
+107 -157
View File
@@ -1,197 +1,147 @@
# 交互清单 # 交互清单
> 本文把用户故事落成可实现、可测试的界面行为:用户如何触发、系统处于什么状态、如何 > 本文是界面行为权威。需求范围见[需求](02-requirements.md),页面位置见[routes](routes.md)。
> 反馈,以及失败时怎样恢复。覆盖 web 端页面与 desk 端桌面界面。
## 一、职责边界 ## 一、职责边界
| 信息 | 写在哪里 | - 采购服务负责建单、开始采购授权、任务详情、内部证据、调和和人工付款收口。
| --- | --- | - 采购工具负责连接手机、轮询领取、单趟执行、日志和本地记录详情。
| 用户目标、价值与业务验收 | [用户故事清单](07-user-stories.md) | - “开始采购”是管理员对创建待付款订单的明确授权;不再有试选后的确认页面。
| MVP 范围与优先级 | [需求](02-requirements.md) | - 任一端都不自动付款;围栏后不提供重试或释放动作。
| 页面入口、路由和组件归属 | [路由与页面结构](routes.md) |
| API、事件和错误格式 | [API 合约](api.md) |
IX 编号一经引用不再重用。交互清单不能扩大需求范围。
## 二、交互总表 ## 二、交互总表
| ID | 关联 US | 页面 / 组件 | 触发 | 用户目标 | 预期结果 | 优先级 | 状态 | | ID | 故事 | 位置 | 触发 | 结果 | 优先级 | 状态 |
| --- | --- | --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- | --- |
| IX-001 | US-007 | web `/login` | 提交表单 | 进入管理后台 | 建立会话并进入原目标页或 `/tasks` | P0 | 已定 | | IX-001 | US-007 | web `/login` | 登录 / 退出 | 建立或销毁管理员会话 | P0 | 已定 |
| IX-002 | US-001 | web `/tasks` 创建弹窗;`/tasks/new` 直达兜底 | 提交表单 | 建单 | 解析出 `goods_id`,关闭弹窗并把待开始任务插入表格第一行 | P0 | 已定 | | IX-002 | US-001 | web 任务表格创建弹窗 | 点击创建、保存 | 创建 `DRAFT` 并在首行反馈 | P0 | 已定 |
| IX-003 | US-002 | web `/tasks` 查询条与任务表格 | 输入后筛选 | 找到要处理的任务 | 默认显示标题关键词,可展开状态 / 时间条件;创建时间倒序;空状态可清除 | P0 | 已定 | | IX-003 | US-002 | web `/tasks` 筛选行 | 输入关键词、筛选、清除 | 更新表格并保留可理解的筛选状态 | P0 | 已定 |
| IX-004 | US-002 | web `/tasks/{id}` 路由化详情抽屉 / 完整页 | 双击行、任务编号链接或直接进入 | 了解任务当前处境 | 从列表进入时右侧打开且保留现场;直达时显示完整页;按状态呈现唯一主区块与主动作 | P0 | 已定 | | IX-004 | US-002、US-004 | web 列表行 / `/tasks/{id}` | 双击行、浏览器前进后退 | 路由化详情抽屉或完整页,显示执行证据 | P0 | 已定 |
| IX-005 | US-004、US-005 | web 试选确认卡 | 查看后点确认 / 退回 | 确认机器选对了并授权 | 签发一次性授权并锁定单价,明确不付款 | P0 | 已定 | | IX-005 | US-005 | web 批量操作条 | 点击开始采购 | 创建一次性授权并进入 `PENDING` | P0 | 已定 |
| IX-006 | US-006 | web 待付款核对卡 | 查看后手工标记 | 核对后付款 | 展示订单截图与授权信息;人付款后标记完成 | P0 | 已定 | | IX-006 | US-006 | web 待付款详情 | 查看后手工标记 | 只记录人工付款完成,不执行支付 | P0 | 已定 |
| IX-007 | US-003 | desk 配置页 | 填 serial → 连接检查 | 让设备就绪 | 连接成功并确认拼多多已安装 | P0 | 已定 | | IX-007 | US-003 | desk 配置 tab | 保存并检查连接 | 服务、ADB、App 版本就绪 | P0 | 已定 |
| IX-008 | US-003、US-008 | desk 采购执行页 | 顶部开始 / 停止轮询;查看执行记录 | 让手机自动跑并处理异常 | 三块工作区保留当前任务、滚动日志和时间倒序记录;待人工时显著提示缺什么;连续失败自动停 | P0 | 已定 | | IX-008 | US-003、US-008 | desk 采购执行 tab | 开始 / 停止轮询、选择记录、Esc | 单趟执行和安全人工接管 | P0 | 已定 |
| IX-010 | US-005 | web 围栏前放弃授权入口 | 点击放弃 | 解开尚未提交的授权 | 授权作废,任务转待重新试选 | P0 | 已定 | | IX-009 | US-008 | web / desk 通用错误反馈 | 请求或流程失败 | 显示阶段、原因和安全下一步 | P0 | 已定 |
| IX-011 | US-005、US-008 | web / desk 提交围栏与调和状态 | 真实点击前申请围栏;点击后回报 | 防止断网或重复操作产生第二笔订单 | 围栏前失败不点击;围栏后不明确只核查、不重试 | P0 | 已定 | | IX-010 | US-005 | web 围栏前人工处理 | 重置 / 取消 | 关闭旧授权后回 `DRAFT` 或取消 | P0 | 已定 |
| IX-012 | US-010 | web `/tasks` 表格选择与上下文批量栏 | 勾选后点「开始试选」 | 批量交给桌面端试选 | `DRAFT → PENDING` 原子提交;仅开始第一趟,不授权、不下单、不付款 | 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. 页面明确提示:只会创建待付款订单,付款需人在拼多多完成。
**状态与异常清单**
| 场景 | 本交互约定 |
| --- | --- | | --- | --- |
| 默认 / 可操作 | 各项 ✓ 时确认按钮可用;截图必须已加载出来才允许确认 | | 未选择 | 按钮禁用,说明“请选择待开始任务” |
| 规格不一致(✗) | **确认按钮禁用**并说明哪一项不符;只能「退回,不买」或转人工 | | 选择中 | 只统计可选 `DRAFT`;非 DRAFT checkbox 禁用且有原因 |
| 金额超上限(✗) | **确认按钮禁用**并显示超出多少;不提供「仍然确认」入口 | | 提交中 | 按钮禁用并显示“正在授权…”;避免双击,焦点不跳走 |
| 加载 / 提交中 | 按钮禁用并显示进行中,防重复提交 | | 成功 | 表格更新为 `PENDING`,清空已成功选择,toast/实时区提示授权数量和“不付款” |
| 成功 | 关闭详情抽屉或返回工作台,该任务显示为已授权并等待桌面端领取第二趟 | | 任一版本冲突 | 整批不变,保留筛选,清除失效选择并提示刷新后重选 |
| 退回 | 二次确认后任务终止为 `CANCELED`,说明不会再自动执行 | | 网络结果不明 | 使用同一 `start_key` 查询/重放,不生成新 key;不要直接再次授权 |
| 服务或网络错误 | 保留页面状态,说明原因并允许重试 |
| 权限不足 | 设备凭据调用此接口一律 403 并记录;页面不暴露任务内容 |
| 冲突 / 重复提交 | `expected_task_version` 不匹配返回 409,提示任务已变化并刷新;重复提交幂等,只产生一笔订单 |
| 破坏性操作 | 确认会导致真实下单。提交前必须显示授权金额上限与「系统只下单不付款」 |
| 试选已过期 | 授权过期后任务转待重新试选;完成新试选前不显示确认入口,页面明确说明旧价不再有效 |
| 中断 / 离线 | 未提交的备注不保存;重进页面回到未确认状态 |
**可访问性与多端** 无障碍:操作条出现在表格前,状态变化用礼貌 `aria-live`;按钮具有可见焦点,文案不依赖颜色或图标。
- 确认与退回按钮可键盘到达,焦点可见。
- ✓ / ✗ 不能只靠颜色区分,必须带文字。
- 窄屏下截图可放大查看,不被裁切到看不清规格。
### IX-006 待付款核对与标记完成 ### IX-006 待付款核对与标记完成
- 关联用户故事:US-006 | 关联需求:F-009 收口 - 页面显示商品、目标与实际规格、数量、授权上限、两次单价、确认页总额和订单截图。
- 页面 / 组件:web `/tasks/{id}` 的 `PaymentCheckCard` - 首屏警示:“订单已创建,系统尚未付款。请在拼多多人工核对并付款。”
- 目标角色:采购管理员(采购执行员在拼多多完成人工付款并反馈结果) - “标记为已付款并完成”只写系统状态,不打开支付页面、不触发手机操作。
- 前置条件:任务状态为 `WAITING_PAYMENT`,已收到订单截图 - 提交前用简短确认对话框说明这是人工事实记录;成功转 `SUCCEEDED`,重复提交幂等。
- 服务依赖:`POST /tasks/{id}/mark-paid` - 信息不一致时提供“转人工处理”,不提供“重新提交订单”。
**MVP 简化版**:系统只展示,不自动回读。自动核对是 F-016(V2)。 ### IX-008 桌面端轮询与单趟执行
**正常路径** 采购工具固定两个 tab:“采购执行”(默认)和“配置”。采购执行顶部第一行依次显示采购服务、设备、
拼多多版本、会话状态与“开始/停止轮询”;不另占一行。
1. 用户看到订单截图、商品、规格、数量和授权金额。 主体保持左右结构:
2. 用户在拼多多手工核对并付款。
3. 用户回到页面点「已付款」,任务转 `SUCCEEDED`。
**状态与异常清单** - 左上:当前任务文字占约 2/3,商品图片占右侧约 1/3;无图显示稳定占位。
- 左下:滚动日志,占用剩余高度;最新日志可自动跟随,用户上滚后不强制跳底。
- 右侧:采购记录表格,按时间倒序,仅显示标题和状态。
- 双击/Enter 记录后不弹窗,在左侧显示该记录:左侧原始文字、右侧图片、下方执行结果。
- 按 Esc 或“返回当前任务”恢复当前任务视图;查看历史不暂停、不取消、不更改服务端状态。
| 场景 | 本交互约定 | 单趟可见阶段:
```text
待领取 → 已领取 → 打开商品 → 打开规格面板 → 选择规格
→ 闸门一 → 数量复核 → 闸门二 → 确认页 / 闸门三
→ 申请围栏 → 已发出唯一提交 → 待付款 / 待调和
```
| 情况 | 反馈和下一步 |
| --- | --- | | --- | --- |
| 默认 | 显著提示「对不上就别付」,并说明不一致时回来标记异常而不是在拼多多改单 | | 服务/设备/App 未就绪 | 开始轮询禁用;状态区给出具体修复入口 |
| 成功 | 任务转已完成,记录标记人与时间 | | 正在领取或执行 | 显示阶段与任务;停止轮询只阻止下一次领取,不中断当前安全步骤 |
| 金额或规格对不上 | 提供「标记异常」转 `NEEDS_MANUAL`,不允许直接标已付款 | | 规格、价格、数量或确认页失败 | 记录固定 reason code,停止点击并进入待人工 |
| 截图缺失 | 说明证据缺失并转人工,**不允许在无证据情况下标记完成** | | 正在申请围栏 | 禁用停止当前任务、退出和任何提交动作;界面只显示进度 |
| 重复标记 | 幂等,不产生第二条完成记录 | | 围栏响应不明 | 不点击,进入待调和 |
| 破坏性操作 | 本交互不产生任何写平台动作,只改本地状态 | | 已围栏 / 点击结果不明 | 红色安全横幅“可能已创建订单,禁止再次提交”;只能调和 |
| 外部支付或安全校验 | 立即停止,不与页面继续交互;保存允许的证据并提示人工接管 |
| 连续轮询失败 | 达阈值自动停止会话;当前已围栏提交不受影响 |
### IX-008 桌面端定时轮询与人工接管 键盘:Tab 顺序与视觉一致;表格上下箭头移动当前行,Enter 查看,Shift+F10 打开上下文菜单;Esc
优先关闭菜单,其次返回当前任务视图。Esc、关闭窗口和停止轮询都不能释放围栏或触发重试。
- 关联用户故事: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」并转人工,不自动下单也不自动放弃 |
| 运行中变更 | 冻结设备切换和参数保存 |
| 服务端不可达 | 保留本地结果与证据,提示待补传,不丢弃已完成工作 |
### IX-011 提交围栏与结果调和 ### 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` 后执行一次点击,界面立即进入「正在核对订单结果」。 | 围栏前 | 停止并转人工;管理员可安全重置为 DRAFT | 绕过闸门、客户端自签授权 |
4. 明确创建后转待付款;结果不明确则两端都显示同一 `submission_id` 与「可能已创建」, | 围栏申请结果不明 | 不点击,记录待调和 | 换 key 申请、点击猜测 |
只提供人工核查,不提供重试或放弃。 | 围栏已建立 | 上报一次观察结果、人工调和 | 释放授权、重新开始、再次点击 |
| 明确待付款 | 人工核对/付款/标记完成 | 系统支付 |
**状态与异常清单** 围栏重放响应不授予点击许可。调和页面不得出现普通“重试”按钮。
| 场景 | 本交互约定 | ### IX-012 批量选择
| --- | --- |
| dry-run | 蓝色信息态并固定显示「不会提交订单」;完成后有证据摘要 | - 第一列为 checkbox;表头全选只覆盖当前筛选结果中可授权的 `DRAFT`。
| 围栏申请中 | 主操作禁用、显示进度;关闭窗口受控,避免用户误以为可重来 | - 行单击只改变当前/焦点行,不等于勾选;双击行打开详情,不切换 checkbox。
| 围栏申请失败 / 响应不明 | **不点击**;展示幂等键与核查入口 | - 状态变化、翻页或筛选后失效的选择必须清除并告知,不能静默作用于不可见旧数据。
| 围栏成功 | 显示唯一提交编号;只允许内部流程点击一次,不向用户暴露第二个提交按钮 | - 不在每行增加“开始采购”操作列;批量操作统一位于表格上方上下文操作条。
| 点击后明确创建 | 转待付款并显示订单证据 | - 移动端表格允许水平滚动,不把关键金额和授权动作压成不可读卡片。
| 点击后超时 / 外部支付 / 安全校验 | 转 `RECONCILIATION_REQUIRED`;显著提示可能已创建并预留额度 |
| 重复打开或恢复 | 恢复同一提交编号和调和状态,不重新领取、不重新点击 |
| 人工核查 | 可记录「已创建 / 未创建 / 仍不明确」及证据;系统本身不发起新的下单 |
## 四、通用要求 ## 四、通用要求
适用于所有 P0 交互: - 中文文案,状态名面向用户;技术 code 只出现在可复制详情中。
- 所有输入有可见 label,错误与字段关联;必填不只靠星号。
- **禁用按钮必须说明原因**,不留用户猜。 - 可交互元素有键盘可见焦点,目标尺寸至少 40–44 px;不依赖 hover 才能发现关键操作。
- **失败必须给下一步**,不只报错。 - 加载、空态、失败、离线、禁用、成功、超时和结果不明均有明确表现。
- **空状态是邀请,不是错误**。 - 尊重 `prefers-reduced-motion`;状态变化不使用闪烁或大幅位移动画。
- 破坏性与不可逆动作必须二次确认,并说明影响范围。 - 高影响动作要说明范围与后果。开始采购已由持续可见的操作条和具体按钮文案完成确认,不再叠加
- 键盘可达、焦点可见、`prefers-reduced-motion` 生效。 一个同义模态框;取消、人工付款事实等不同后果仍按各自流程确认。
- 状态不能只靠颜色表达。 - 关闭抽屉、返回列表或查看历史记录必须恢复触发点焦点和筛选/滚动现场。
- 金额一律显示两位小数并标注币种。 - 内部截图有权限和不缓存标识;不把地址/手机号做成搜索字段,不展示完整 XML。
- 双击只能作为效率快捷方式;任务编号链接和聚焦行后按 Enter 必须提供等价详情入口。
- 表格行内的复选框、商品外链和任务编号链接不得误触发行双击;关闭详情抽屉后焦点回到原行。
- 表格批量栏只在存在选择时出现;全选只覆盖当前筛选结果中允许当前动作的行。
## 五、交付前检查 ## 五、交付前检查
- [ ] 每项 P0 交互回链至少一个 US 编号。 - Web:375 / 768 / 1024 / 1440 px;键盘、焦点、表格横向滚动、抽屉返回均检查。
- [ ] 涉及资金或不可逆动作的交互已填写完整状态表。 - Desk:compact / medium / wide;浅色、深色、高对比近似;键盘和窗口关闭路径检查。
- [ ] 所有禁用态都有说明文案。 - 单趟状态与 [architecture](04-architecture.md)、[api](api.md) 完全一致。
- [ ] 所有空状态都有可执行的下一步。 - 页面没有 `WAITING_CONFIRMATION`、第二趟、锁定试选价或管理员中途确认。
- [ ] 未在本文自行定义接口路径、字段或状态码。 - “开始采购”处始终可见“只创建待付款订单 / 系统不付款”。
- 围栏后没有重试、释放授权或重新开始入口。
+6 -6
View File
@@ -4,8 +4,8 @@
cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`)负责建单与人工决策, cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`)负责建单与人工决策,
**采购工具**(Windows 桌面端,`client/`)驱动 Android 手机在拼多多完成选规格和下单, **采购工具**(Windows 桌面端,`client/`)驱动 Android 手机在拼多多完成选规格和下单,
**付款始终由人完成**。第一版先跑通「手工建单 → 定时领取 → 第一趟试选 → 人工确认 → **付款始终由人完成**。第一版先跑通“手工建单 → 管理员开始采购授权 → 定时领取 → 单趟规格/
第二趟下单 → 待付款」闭环。 价格/数量校验 → 提交围栏与一次点击 → 待付款”闭环。
## 文档导航 ## 文档导航
@@ -17,7 +17,7 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
- [需求](02-requirements.md):功能清单、验收标准、风险点。 - [需求](02-requirements.md):功能清单、验收标准、风险点。
- [用户故事清单](07-user-stories.md):US 编号、用户目标与验收场景。 - [用户故事清单](07-user-stories.md):US 编号、用户目标与验收场景。
- [技术栈](03-tech-stack.md):两端选型、运行命令、**验证矩阵**。 - [技术栈](03-tech-stack.md):两端选型、运行命令、**验证矩阵**。
- [架构设计](04-architecture.md):双端职责、**两趟执行**、**三道价格闸门**、安全边界、数据模型。 - [架构设计](04-architecture.md):双端职责、**单趟执行**、**三道价格闸门**、安全边界、数据模型。
- [编码规则](05-coding-rules.md):硬约束,第 1 节是本项目红线。 - [编码规则](05-coding-rules.md):硬约束,第 1 节是本项目红线。
- [任务路线图](06-tasks.md):阶段划分、里程碑、建议拆分清单。 - [任务路线图](06-tasks.md):阶段划分、里程碑、建议拆分清单。
- [任务文件](tasks/README.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) **第四节安全边界与提交订单四条件**——每条 2. [`04-architecture.md`](04-architecture.md) **第四节安全边界与提交订单四条件**——每条
都要有测试。 都要有测试。
3. [`05-coding-rules.md`](05-coding-rules.md) **第 1 节红线**——违反即拒绝。 3. [`05-coding-rules.md`](05-coding-rules.md) **第 1 节红线**——违反即拒绝。
4. [`00-ai-start-here.md`](00-ai-start-here.md) **四条特有纪律**——先取证、只收紧、 4. [`00-ai-start-here.md`](00-ai-start-here.md) **四条特有纪律**——先取证、只收紧、
只在两处读价、第一趟不下单。 只在两处读价、隔离验证能力不越界。
## 维护原则 ## 维护原则
+211 -234
View File
@@ -1,338 +1,315 @@
# API 合约 # API 合约
> 本文定义采购服务(`admin/`)对外的 HTTP 接口,以及采购工具(`client/`)本地模块的合约。 > 本文是采购服务与采购工具之间的唯一线协议权威。页面判据和本地类名不是线协议。
> **这是双端之间的唯一权威。** 实现前可细化,但不得在代码里另起一套不兼容接口。 > 接口形状参考前序项目的经验,但所有状态与安全语义在 cmbuyer 重新定义、测试和取证。
>
> 本合约的设备心跳、任务领取、事件、证据、授权命令与 ack 结构参考了前序项目
> `cmroubao`;同时在本项目重新审计并补回 dry-run、提交前服务端围栏和点击后调和。
> 前序接口是设计依据,不是可直接照搬的运行事实。
## 通用约定 ## 通用约定
- 传输:JSON over HTTP。MVP 局域网内运行,生产部署应加 HTTPS。 - 生产前缀:`/api/v1`;管理页面路由见 [routes.md](routes.md)。
- 编码:UTF-8。 - JSON 使用 UTF-8;时间为 UTC RFC 3339;ID 为 UUID 字符串。
- 时间:RFC 3339,带时区,UTC 存储。 - 金额均为规范十进制字符串,如 `"12.88"`;禁止 JSON number 和浮点计算。
- 金额:**十进制字符串**(如 `"45.60"`),不用浮点数。 - 所有写接口接受 `request_id` / 业务幂等键;同键同载荷重放同一结果,同键异载荷返回 `409`。
- 幂等:所有创建类接口接受幂等键,重复提交返回同一结果而不是第二笔。 - 任务写入携带 `expected_task_version`;版本冲突返回 `409 version_conflict`。
- 服务端错误不得回显设备 token、完整节点树、地址、手机号或支付信息。
### 鉴权 ### 身份
| 客户端 | 方式 | 说明 | | 身份 | 凭据 | 能力 |
| --- | --- | --- | | --- | --- | --- |
| 管理 Web | Session Cookie + CSRF Token | 表单提交必须带 CSRF | | 管理员 | `HttpOnly; Secure; SameSite=Lax` 会话 cookie + CSRF | 建单、开始采购、查看内部证据、人工调和 |
| 采购工具 | `Authorization: Bearer <device_token>` | 凭据绑定设备标识,可单独撤销 | | 设备 | `Authorization: Bearer <device-token>` + 设备 id | 心跳、领取、事件、截图、围栏与结果 |
| ERP 对接 | `Authorization: Bearer <connector_token>` | 只能调用货运同步接口 | | ERP(V2) | 独立凭据 | 只读来源同步,不访问采购结果 |
三种身份互不通用。设备凭据**不能**创建任务或签发授权;管理会话**不能**调用设备接口。 设备凭据不能建单或开始采购;管理会话不能调用设备接口。未认证统一返回 `401`,无权返回 `403`。
### 错误响应 ### 错误响应
```json ```json
{ {
"error": { "error": {
"code": "invalid_argument", "code": "version_conflict",
"message": "数量必须是正整数", "message": "任务已变化,请刷新后重选",
"field": "quantity" "retryable": false,
"request_id": "018f..."
} }
} }
``` ```
错误码枚举:`invalid_argument`、`unauthenticated`、`permission_denied`、`not_found`、 `retryable=true` 只表示接口调用可以按同一幂等键重放,不表示可以重试任何真机点击。
`conflict`、`failed_precondition`、`internal`。
- 未登录访问受保护资源:`401`,管理页面重定向到 `/login`。
- 已登录但无权限:`403`。**不存在**与**无权限**必须使用不同内部原因,但响应体不得泄露
任务内容。
## 一、管理端接口 ## 一、管理端接口
管理页面为服务端渲染,表单直接 POST 到下列路径,成功后 303 重定向。 | 方法 | 路径 | 作用 |
| 方法 | 路径 | 职责 |
| --- | --- | --- | | --- | --- | --- |
| `POST` | `/login` | 建立管理会话 | | `GET/POST` | `/login` | 登录页 / 建立管理员会话 |
| `POST` | `/logout` | 销毁会话 | | `POST` | `/logout` | 退出并使会话失效 |
| `GET` | `/tasks` | 任务列表,支持 `q`、`status`、`days`、`cursor` | | `GET` | `/tasks` | SSR 任务表格;关键词、状态、时间筛选 |
| `POST` | `/tasks` | 手工建单,初始状态为 `DRAFT`(F-001) | | `POST` | `/tasks` | 手工创建 `DRAFT` |
| `POST` | `/tasks/start-trials` | 批量把 `DRAFT` 原子转为 `PENDING`,开始第一趟试选(F-018) | | `POST` | `/tasks/start-purchases` | 批量开始采购:创建一次性授权并原子转 `PENDING` |
| `GET` | `/tasks/{id}` | 任务详情 | | `GET` | `/tasks/{id}` | 任务完整页;同一 URL 也可由列表详情抽屉加载 |
| `POST` | `/tasks/{id}/cancel` | 取消任务 | | `POST` | `/tasks/{id}/reset-to-draft` | 围栏前人工处理后关闭旧授权,回到 `DRAFT` |
| `POST` | `/tasks/{id}/order-authorizations` | 确认试选结果并签发授权(F-008) | | `POST` | `/tasks/{id}/cancel` | 围栏前取消任务 |
| `POST` | `/tasks/{id}/order-authorizations/{aid}/abandon` | 围栏前放弃授权,任务转待重新试选(F-010) | | `POST` | `/order-submissions/{sid}/reconcile` | 围栏后人工调和同一提交 |
| `POST` | `/tasks/{id}/reject` | 退回不买,任务终止 | | `POST` | `/tasks/{id}/mark-paid` | 人工确认已付款并完成核对 |
| `POST` | `/tasks/{id}/mark-paid` | 人工核对付款后标记完成(MVP 简化收口) | | `GET` | `/evidence/{asset_id}` | 登录后读取内部截图;`Cache-Control: no-store` |
> Excel 导入(`/tasks/import`)与 ERP 货运(`/freight*`)已移出 MVP,见 ### `POST /tasks`
> [需求](02-requirements.md)第三节后续迭代表。
### `POST /tasks/start-trials` 核心字段:
管理页面以带 CSRF 的表单提交结构化任务版本列表:
```json ```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": [ "tasks": [
{ "id": "018f...", "expected_task_version": 1 }, {"task_id": "018f-task-1", "expected_task_version": 1},
{ "id": "0190...", "expected_task_version": 3 } {"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 ```json
{ {
"authorization_key": "<幂等键>", "start_key": "018f...",
"expected_task_version": 3, "authorized_count": 2,
"spec_trial_id": "018f...", "tasks": [
"note": "" {"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/devices/heartbeat` | 上报设备、ADB、App 版本和能力状态 |
| `POST` | `/api/v1/tasks/claim-next` | 原子领取或重放;**同时覆盖待试选与已授权两类** | | `POST` | `/api/v1/tasks/claim-next` | 原子领取一个 `PENDING` 授权任务或重放本设备未结束领取 |
| `POST` | `/api/v1/tasks/{id}/start` | `CLAIMED → RUNNING`,创建 execution | | `POST` | `/api/v1/tasks/{id}/lease/renew` | 续租;只允许当前 claim |
| `POST` | `/api/v1/tasks/{id}/heartbeat` | 更新当前步骤与运行租约 | | `POST` | `/api/v1/tasks/{id}/events` | 批量追加结构化步骤事件 |
| `POST` | `/api/v1/tasks/{id}/release` | 未开始时退回 `PENDING` | | `POST` | `/api/v1/tasks/{id}/evidence` | 显式上传一个内部原始截图 |
| `POST` | `/api/v1/tasks/{id}/events` | 幂等补报执行事件 | | `POST` | `/api/v1/purchase-attempts/{aid}/fail` | 围栏前停止并回传失败摘要 |
| `POST` | `/api/v1/tasks/{id}/evidence` | 上传证据资产,SHA-256 寻址 | | `POST` | `/api/v1/purchase-attempts/{aid}/submission-fence` | 提交当前三闸门摘要并原子申请唯一围栏 |
| `POST` | `/api/v1/tasks/{id}/spec-trial` | **第一趟**:回传试选结果,任务转 `WAITING_CONFIRMATION` | | `POST` | `/api/v1/order-submissions/{sid}/result` | 点击后一次性上报观察结果;只调和不重试 |
| `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` | 提交结构化失败与证据 |
> `/candidates`(多候选回传)、`/reference-image`(图搜参考图)、`/order-record` ### `POST /api/v1/devices/heartbeat`
> (订单自动核对)随 B 路径与 F-016 一并推迟到 V2。
```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` ### `POST /api/v1/tasks/claim-next`
```json 请求携带 `device_id`、`session_id`、`claim_request_id`。领取与授权绑定且具租约:
{ "device_id": "desk-01", "claim_key": "<幂等键>" }
```
成功:
```json ```json
{ {
"task": { "task": {
"id": "018f...", "id": "018f-task",
"leg": "TRIAL", "version": 3,
"goods_id": "7531364299", "title": "纯棉短袖",
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=7531364299", "product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
"sku_color": "白色", "goods_id": "937122477375",
"sku_size": "XL", "sku_color": "黑色CHA(纯棉)",
"sku_size": "M(建议100-115)",
"quantity": 2, "quantity": 2,
"max_total_price": "80.00" "max_total_price": "30.00"
}, },
"claim_token": "...", "authorization": {
"claim_generation": 1, "id": "018f-auth",
"lease_expires_at": "2026-08-03T10:30:00Z" "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"` = 第二趟下单。 - 只返回有 `ACTIVE` 授权的 `PENDING`;服务端在一个事务中转为 `CLAIMED` 并创建 attempt。
`leg` 为 `"ORDER"` 时只返回 `authorization_id`;完整、不可变的授权命令必须通过 - 同一 `claim_request_id` 同载荷重放同一结果;并发设备只有一个成功。
`/commands/next` 拉取并落盘,再调用 `/commands/{cid}/ack`。领取接口不重复定义授权载荷。 - 一个设备有未结束领取时优先重放该领取,不能悄悄领第二条。
- 无可领任务返回 `200` 且 `task` 为 `null`,**不是 404**。 - 响应不得包含自由动作脚本、CSS/XPath、通用坐标或支付能力。
- 后续所有该任务的调用必须携带 `X-Claim-Token` 与匹配的 `claim_generation`。
### `POST /api/v1/tasks/{id}/evidence`(只接收脱敏派生物) ### 事件与证据
规格面板和订单确认页可能固定展示地址与掩码手机号。采购工具必须先在本机隔离目录保存原始证据, 事件只包含固定 `step` / `outcome` / `reason_code` 和非敏感摘要。禁止把完整 XML、地址、手机号、
再由确定性脱敏器生成派生 screenshot/XML;本接口只接受派生截图及如下审计元数据: 页面全文或 token 塞进日志字段。
截图接口使用 `multipart/form-data`,只接受单个显式文件及以下元数据:
```json ```json
{ {
"kind": "SKU_PANEL_SCREENSHOT", "attempt_id": "018f-attempt",
"privacy_tier": "SANITIZED", "kind": "SKU_PANEL_GATE_1",
"artifact_sha256": "<派生截图 SHA-256>", "privacy_tier": "INTERNAL_RAW",
"sanitizer_version": "sku-panel-pdd-8.17.0-v1" "sha256": "64-lowercase-hex",
"captured_at": "2026-08-04T09:01:00Z"
} }
``` ```
- `privacy_tier` 必须精确为 `SANITIZED`;缺失、其他值或 sanitizer 元数据不完整均拒绝。 - 允许规格面板和确认页截图保留页面已显示的地址/手机号;不要求遮罩或裁剪。
- 上传内容的 SHA-256 必须等于 `artifact_sha256`。服务端不接收原始文件、原始哈希、原始路径、 - 不接受 XML、目录、manifest、本机绝对路径、外部支付页截图或支付凭据。
原始 XML、地址、手机号或支付凭据;原始/派生哈希映射只存在于采购工具本机 manifest。 - MIME、尺寸、字节数和 SHA-256 必须校验;资产只经管理员鉴权端点读取。
- 原始目录不得被 `HttpResultSink` 或证据上传器枚举;调用方必须显式传入已原子发布的派生目录。
- 完整 XML 永不上传。经自动复检的最小脱敏 XML 只用于采购工具离线 fixture;仍命中手机号模式或
脱敏结果不确定时,客户端调用 `/needs-manual`,不得上传截图或继续试选。
### `POST /api/v1/tasks/{id}/spec-trial`(第一趟回传) ### `POST /api/v1/purchase-attempts/{aid}/submission-fence`
客户端只有在当前页面四条件中的后三项已经满足后才能调用:
```json ```json
{ {
"attempt": 1, "fence_key": "018f-fence-request",
"product_title": "2026夏季新款纯棉圆领短袖T恤男女同款宽松半袖", "task_id": "018f-task",
"selected_color": "白色", "expected_task_version": 3,
"selected_size": "XL", "authorization_id": "018f-auth",
"unit_price": "32.50", "claim_token": "opaque-single-claim-token",
"total_price": "65.00", "selected_color": "黑色CHA(纯棉)",
"evidence_sha256": "…" "selected_size": "M(建议100-115)",
} "gate1_unit_price": "12.88",
``` "gate2_unit_price": "12.88",
- `selected_color` / `selected_size` 是**实际勾选到的值**,不是任务要求的值。
服务端据此与任务要求比对并在确认页显示 ✓ / ✗。
- `unit_price` 来自闸门一(规格面板)。**读不到时不要发这个接口**,改发
`/needs-manual` 并带原因码 `UNIT_PRICE_UNREADABLE`。
- `total_price` = `unit_price` × 任务数量,服务端会重算校验。
- 证据须先经 `/evidence` 上传,且对应资产必须为 `privacy_tier=SANITIZED`。
- 服务端接收后创建 `spec_trials` 记录,任务转 `WAITING_CONFIRMATION`。
### dry-run 与真实提交协议
`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",
"quantity_read": 2, "quantity_read": 2,
"confirm_page_amount": "65.00", "confirm_page_amount": "25.76",
"has_address": true, "submit_control_match_count": 1
"evidence_sha256": "…"
} }
``` ```
- dry-run 只证明当次页面达到 `READY`,不冻结授权,也不能作为稍后真实点击时的页面事实。 服务端在一个事务中校验:任务/版本/claim/attempt 一致;授权有效未消费且字段等于任务快照;
- `has_address` 只报布尔值,**不得回传地址原文或手机号**。 规格与授权相等;数量相等;两个单价相等;计算金额及确认页金额均不超过 `total_price_cap`;提交控件
计数为一;此前不存在该授权或 attempt 的 submission。随后创建唯一 `order_submission`,授权转
`FENCED`,任务保持不可重领。
真实第二趟重新通过三道闸门后,采购工具在点击前调用 首次明确成功响应:
`POST /api/v1/tasks/{id}/order-submissions/start`:
```json ```json
{ {
"submission_key": "<幂等键>", "submission_id": "018f-submission",
"command_id": "…", "status": "FENCED",
"dry_run_id": "…", "click_permitted": true,
"expected_task_version": 5, "submit_text": "提交订单"
"verified_unit_price": "32.50",
"quantity_read": 2,
"confirm_page_amount": "65.00"
} }
``` ```
- 服务端在一个事务中校验命令、任务版本、授权未消费、闸门值与唯一性,创建或重放唯一 - 任一校验失败返回错误,绝不返回 `click_permitted=true`。
`order_submission` 并把授权置为 `FENCED`。同一授权或命令不得产生第二条提交记录。 - 同一 `fence_key` 的重放返回同一 `submission_id`,但 `click_permitted=false` 且
- 只有明确收到 `201/200` 且响应中的 `click_permitted: true`,采购工具才允许点击一次。 `reconciliation_required=true`;客户端不能凭重放响应点击。
超时、网络错误、冲突或响应无法解析时**不得点击**,转人工查询该幂等键。 - 客户端收到首次许可后,必须先把“围栏已取得/即将发出唯一点击”持久化,再执行点击。进程崩溃或
- `dry_run_id` 只证明曾完成安全演练;服务端仍以本次真实提交请求携带的闸门读数复核。 本地状态不明时宁可转调和,也不再次点击。
点击后调用 `POST /api/v1/order-submissions/{sid}/reconcile`: ### `POST /api/v1/order-submissions/{sid}/result`
```json ```json
{ {
"outcome": "SUBMITTED", "result_key": "018f-result",
"evidence_sha256": "…" "attempt_id": "018f-attempt",
"observation": "SUBMITTED",
"evidence_asset_id": "018f-asset"
} }
``` ```
- `outcome` 枚举:`SUBMITTED`(明确看到订单结果)、`UNCERTAIN`(超时或无法判断)、 `observation` 只允许:
`HANDED_OFF`(外部支付)、`SECURITY_CHECK`。
- `SUBMITTED` 转 `WAITING_PAYMENT`;其余一律转 `RECONCILIATION_REQUIRED`,授权保持已围栏并
预留金额额度。重复调用只重放同一调和结果。
- 任一结果都**禁止再次点击、释放围栏或重新签发授权**。无法自动调和时调用
`/manual-review` 记录人工核查请求与证据。
### 文本字段校验 - `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` ### `TaskSource` / `ResultSink`
执行器只依赖抽象,不认识来源:
```python ```python
class TaskSource(ABC): class TaskSource(Protocol):
@abstractmethod def claim_next(self, session: Session) -> ClaimedPurchase | None: ...
def load_tasks(self) -> list[OrderTask]: ... def renew_lease(self, claim: Claim) -> Lease: ...
class ResultSink(ABC): class ResultSink(Protocol):
@abstractmethod def append_events(self, claim: Claim, events: list[TaskEvent]) -> None: ...
def save_task_result(self, task: OrderTask) -> 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` | 仅测试 / 断连暂存:本地追加写入,恢复连接后按幂等键补传 |
### 真机流程模块 | 能力 | 输入 | 输出 | 安全边界 |
| --- | --- | --- | --- |
| `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` | 观察结果 | 许可、闸门、唯一控件全校验;点前持久化;绝不重试 |
`client/src/android/pdd_flow.py` 的公开入口按 capability 分离,每个都不得越界: T-103 只实现隔离的 `SkuSelectionFlow`:前四项加安全退出。它的模块和静态依赖不得引用数量、确认页、
围栏、提交或支付能力。后续任务按取证顺序组合成生产 `SinglePassPurchaseFlow`。
| 函数 | 可用趟次 | 输入 | 输出 | 副作用边界 | ## 四、实现前仍需定值
| --- | --- | --- | --- | --- |
| `open_product(url)` | TRIAL / ORDER | 商品 URL | 页面快照路径 | 只打开页面,不点击控件 |
| `open_trial_sku_panel(evidence_key)` | **仅 TRIAL** | 版本与证据绑定键 | 面板快照 | 只点击精确唯一、已取证的受控入口;当前仅 `快要抢光`,无通用 click |
| `select_sku_options(items)` | TRIAL / ORDER | `{维度: 值}` | 选中证据 | 按维度精确匹配,找不到抛错 |
| `sanitize_evidence(raw_manifest)` | TRIAL / ORDER | 本机隔离目录 manifest | 派生 manifest | 原子发布脱敏派生物;失败不发布,原始内容不进入日志/上传 |
| `read_sku_unit_price(xml)` | TRIAL / ORDER | 脱敏规格面板 XML | 单价或 `None` | **闸门一 / 二**;读不到返回 `None`,不猜 |
| `leave_product()` | TRIAL / ORDER | - | - | 第一趟结束时退出并释放手机 |
| `set_quantity(n)` | **仅 ORDER** | 数量 | 读回值 | 必须复核等于 n;TRIAL capability 不暴露 |
| `go_to_order_confirm()` | **仅 ORDER** | - | 确认页摘要 | 需显式授权;TRIAL capability 不暴露 |
| `read_order_confirm_info(xml)` | **仅 ORDER** | 脱敏页面 XML | 非敏感摘要 | **闸门三**;不提取地址原文、手机号 |
| `submit_order(auth, submission)` | **仅 ORDER** | 授权 + 已建立的提交围栏 | 提交结果 | **唯一创建真实订单入口**,四条件与围栏全通过后只点一次 |
能力隔离规则: - 授权有效期、领取租约时长、心跳/轮询间隔和连续失败停止阈值;
- 截图大小上限和内部保留期限;
- `go_to_order_confirm()` 必须校验授权存在;`submit_order()` 还必须校验授权已由服务端围栏 - 可配置单任务数量与最高总价系统上限;
且 `submission` 与当前任务、命令、授权完全一致。 - 首次真实提交真机任务的人工授权和待付款订单处置步骤。
- 第一趟只能拿到 `TrialSkuFlow` 窄接口,接口中不得出现通用 `click`、`set_quantity()`、
`go_to_order_confirm()`、`submit_order()` 或支付能力;静态依赖测试必须证明试选流程不可达它们。
- `open_trial_sku_panel()` 的点击是唯一批准的购买语义控件例外,只用于打开已取证规格面板;入口
缺失/重复、App 版本不符、面板判据不唯一或出现未知终态控件时停止。其他入口文案不得推断复用。
- `search_by_image()` 属 B 路径,V2 再实现。
## 四、待实现时确认
- **规格面板上单价的节点位置与文本形态**(阻塞闸门一,由 T-103 真机取证确定)。
- 授权 `expires_at` 的默认时长。
- 定时轮询的默认间隔与连续失败停止阈值。
- 分页游标的编码方式。
- 设备凭据的有效期与轮换策略。
- 证据资产的保留期与清理策略。
+36 -45
View File
@@ -12,8 +12,10 @@
## 当前快照 ## 当前快照
- 日期:2026-08-04 - 日期:2026-08-04
- 阶段:**Phase 1 · 真机可行性(T-110 已批准受控规格入口,T-103 重新执行)** - 阶段:**Phase 1 · T-103 规格选择/读价与 Phase 2 安全服务端任务并行**
- MVP 形态:手工填链接建单 → 批量开始试选 → 定时轮询 → **第一趟试选** → 人工确认 → **第二趟下单** → 待付款 - MVP 形态:手工填链接建单 → 批量“开始采购”并签发一次性授权 → 定时领取 → 同一趟精确
选规格 / 三道价格闸门 / 提交围栏 / 点击一次 → 待付款。管理员点击“开始采购”即授权桌面端
创建待付款订单;不再有试选后确认,系统绝不自动付款。
- 技术栈:已定。采购服务(`admin/`)使用 Go 1.23+ / gin / SQLite;采购工具(`client/`) - 技术栈:已定。采购服务(`admin/`)使用 Go 1.23+ / gin / SQLite;采购工具(`client/`)
使用 Python 3.11+ / uiautomator2 / PySide6。 使用 Python 3.11+ / uiautomator2 / PySide6。
详见 [`03-tech-stack.md`](03-tech-stack.md) 详见 [`03-tech-stack.md`](03-tech-stack.md)
@@ -21,28 +23,32 @@
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB `client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板状态的只读取证 CLI, 连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板状态的只读取证 CLI,
以及绑定 PKG110 / Android 16 / 拼多多 8.17.0 的规格证据确定性脱敏 CLI;尚无规格选择、价格读取或下单流程 以及绑定 PKG110 / Android 16 / 拼多多 8.17.0 的规格证据确定性脱敏 CLI;尚无规格选择、价格读取或下单流程
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 69 项离线单元测试 - 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 80 项离线单元测试
(全部 mock,不连接真机) (全部 mock,不连接真机)
- 数据:SQLite 核心表与迁移已落成;无业务实例数据 - 数据:SQLite v1 核心表与迁移已落成,但仍是旧两趟 schema(含 `spec_trials`、
`authorized_unit_price` 和旧状态);无业务实例数据。T-111 只冻结目标契约,不改生产代码;T-209
必须先迁移 schema / 领域状态机,T-203 才能实现新“开始采购”事务。
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口 - 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
优先使用合规的既有 venv;仅在其缺失时才从 Python Launcher 已安装版本中选择最高的 Python 3.11+, 优先使用合规的既有 venv;仅在其缺失时才从 Python Launcher 已安装版本中选择最高的 Python 3.11+,
并且不覆盖低版本环境;成功后打印真实启动命令。 并且不覆盖低版本环境;成功后打印真实启动命令。
- 标准验证路径:`./init.ps1` 已实际跑通 admin 的 mod download / test / vet / build、client 的 - 标准验证路径:`./init.ps1` 已实际跑通 admin 的 mod download / test / vet / build、client 的
editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。 editable install / 包导入 / unittest / compileall,以及仓库上下文校验。可单独运行两端命令诊断。
- 当前设备门禁:人工已确认拼多多 8.17.0、goods_id `937122477375` 的衣服商品只能通过“快要抢光” - 当前设备门禁:人工已确认拼多多 8.17.0、goods_id `937122477375` 的衣服商品只能通过“快要抢光”
打开规格面板;T-110 已获项目所有者批准,只把该证据/版本绑定的精确唯一入口作为第一趟可逆导航, 打开规格面板;T-110 已获项目所有者批准,只把该证据/版本绑定的精确唯一入口作为受控导航。
数量、确认页、提交订单、付款与通用点击能力仍不可达。T-103 的原始证据本机隔离与确定性脱敏器 T-103 的隔离 capability 仍不得包含数量、确认页、提交订单、付款或通用点击;这些动作由后续任务
已完成离线实现;人工确认面板刚打开时目标颜色和尺码已经自动选中,取证三态已据此修正。下一步 分别取证后才接入生产单趟流程。T-103 的 v5 派生 XML 已由人确认无实际地址/
用 v3 对已采第一态 raw 重新脱敏;截图与 XML 已分别安全观测为 1080×2376,仍作为两个独立坐标 手机号,并保留目标预选规格、顶部当前价“快卖完 ¥12.88”和原价“¥29.88”;底部“提交订单 ¥12.88”
空间严格校验。第一态 derived 经人验收后,再采集“两个维度改为非目标 / 两个维度恢复目标”的 raw, 继续属于硬拒绝区。派生截图虽然把顶部价格遮住一半,但项目已停止遮罩器开发,视觉完整性不再阻塞
并由人只核对 derived。在派生证据验收前不写页面判据,Phase 2 仍不能抢跑。 规格选择与读价;T-204 将直接上传内部原始截图供管理员查看。T-010 已允许不依赖真机字段的 T-201
和只创建 `DRAFT` 的 T-202 并行。T-209 的 schema / 状态机迁移不依赖页面选择器,可在 T-111
完成后推进;随后做 T-203 服务端“开始采购”授权事务。T-205 起实际 attempt / 真机字段继续等待 T-103。
## 当前目录要点 ## 当前目录要点
| 路径 | 状态 | 说明 | | 路径 | 状态 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 | | `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
| `docs/tasks/` | 已有(T-001~T-004、T-005~T-009、T-101~T-110) | T-001~T-004、T-101~T-102、T-110 已完成;T-103 重新执行 | | `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-202) | T-111 单趟契约已完成;T-103 已恢复;T-202 在独立工作树待主审提交 |
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 | | `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 | | `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 | | `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 |
@@ -59,9 +65,11 @@
- 已完成:T-002(采购工具 Python 骨架)、T-003(双端统一初始化与验证入口)、 - 已完成:T-002(采购工具 Python 骨架)、T-003(双端统一初始化与验证入口)、
T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)、T-102(canonical T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)、T-102(canonical
链接打开与目标商品/隐私人工验收)。 链接打开与目标商品/隐私人工验收)。
- 已完成 T-110(第一趟受控规格入口与隐私脱敏边界)。T-103 是当前最高优先级和 MVP 生死线, - 已完成 T-010(安全并行门禁)与 T-201(管理员登录与会话)。T-202 已由 admin agent 实现且
已补充 T-110 依赖并恢复 `DOING`;脱敏器已完成,三态派生证据和新真机验收完成前不开发依赖真机可读字段的 测试通过,仍只创建/展示 `DRAFT`;当前在独立工作树等待主 agent 审阅、提交和推送。
Phase 2 生产页面。 - 已完成 T-110(受控规格入口边界)与 T-111(开始采购授权的单趟契约)。T-103 已恢复为
`DOING`,以 `SkuSelectionFlow` 继续最小 fixture、精确规格和读价,不实现数量、确认页或提交;
内部原始截图上传交给 T-204。admin 方向在 T-202 主审合入后,先落 T-209 迁移旧 schema/状态机。
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变 - 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
可读字段时必须先回修原型与交互清单。 可读字段时必须先回修原型与交互清单。
@@ -141,33 +149,16 @@ D:\Portable\adb\adb.exe devices -l
不得提交 Git。T-102 已由人确认 goods_id `958756616606` 的目标商品及截图/XML 隐私,并完成验收。 不得提交 Git。T-102 已由人确认 goods_id `958756616606` 的目标商品及截图/XML 隐私,并完成验收。
T-103 已确认当前衣服商品只能从精确文案“快要抢光”进入规格面板;T-110 只批准该证据/版本绑定入口, T-103 已确认当前衣服商品只能从精确文案“快要抢光”进入规格面板;T-110 只批准该证据/版本绑定入口,
不授权“免拼购买 / 单独购买 / 直接拼成”等其他文案。现有只读取证脚本本身仍不打开面板、不点击或选择 不授权“免拼购买 / 单独购买 / 直接拼成”等其他文案。面板刚打开时目标颜色“黑色CHA(纯棉)”与尺码
规格,也不读取价格;`--state` 只是人工声明,不能作为自动判据。面板刚打开时目标颜色 “M(建议100-115)”已经自动选中。v5 派生 XML 与人工观察已证明顶部当前价为“快卖完 ¥12.88”、
“黑色CHA(纯棉)”与尺码“M(建议100-115)”已经自动选中,不强行取消。人依次准备刚打开目标预选、 并列原价为“¥29.88”;促销前缀会变化,正式读价不能把该文案作为主判据,必须以面板结构、价格角色、
颜色和尺码均为非目标值、再恢复目标值三个状态,并分别采集到全新 `raw` 目录: 严格金额格式和唯一性组合判断,冲突时返回 unreadable。
```powershell 本轮不再运行或修改截图遮罩器。T-103 先用本机证据验证真机能力;T-204 再把原始规格页截图上传采购
# 仓库根目录;三次采集使用三个全新的 output-dir 服务,允许页面中已显示的地址和手机号,完整 XML 不上传。代码从已确认的派生 XML 提取最小 fixture,
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state panel-opened-target-preselected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-opened-target-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe 并在真机运行时只返回获准摘要。T-111 完成后,下一步实现 `SkuSelectionFlow` 的受控入口、
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state alternate-all-dimensions-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-alternate-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe 按维度精确选择、选中态读回、SKU 单价读取与安全退出;完成离线主审后,由人先把两个维度改成明确
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state target-selection-restored --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe 记录的非目标值,再执行真机脚本确认恢复目标规格与 `12.88` 单价。生产原始截图展示/上传由 T-204 处理。
```
每组 raw 采集成功后分别运行离线脱敏器;`derived` 必须尚不存在:
```powershell
.\client\.venv\Scripts\python.exe client\scripts\sanitize_sku_panel_evidence.py --raw-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-opened-target-<GOODS_ID>-v2\raw" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-opened-target-<GOODS_ID>-v2\derived"
.\client\.venv\Scripts\python.exe client\scripts\sanitize_sku_panel_evidence.py --raw-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-alternate-<GOODS_ID>-v2\raw" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-alternate-<GOODS_ID>-v2\derived"
.\client\.venv\Scripts\python.exe client\scripts\sanitize_sku_panel_evidence.py --raw-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored-<GOODS_ID>-v2\raw" --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored-<GOODS_ID>-v2\derived"
```
原始 screenshot/XML 可能包含 PDD 固定展示的地址和掩码手机号,只能留在本机 `raw` 目录,不能由
agent、fixture、业务或上传端消费。`t103-privacy-v3` 把截图与 XML 保持为两个独立坐标空间,
并根据同一第一态 raw 的两次 fail-closed 安全观测将二者分别精确固定为 1080×2376;它校验源哈希、
设备/App/商品/人工状态与隐私结构,在 sibling
`derived` 目录原子发布 screenshot/XML 与 manifest;任何不匹配、手机号残留或已有目标均拒绝发布。
自动复检通过且人确认三态对应性后,agent 才能读取派生物并提取最小 fixture、编写判据。
人工确认中还必须记录中间态实际选择的非目标颜色和尺码,不能只写“已切换”。
## 关键背景 ## 关键背景
@@ -184,14 +175,14 @@ agent、fixture、业务或上传端消费。`t103-privacy-v3` 把截图与 XML
## 已知风险(开工前须知) ## 已知风险(开工前须知)
1. **M2 是生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、**读到该 SKU 单价** 1. **M2 是生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、**读到该 SKU 单价**
(T-103)。Phase 1 不通过之前不要写生产页面;Phase 0 原型只确认流程和信息架构,真机 (T-103)。M2 前可以写不依赖页面字段的管理员会话、DRAFT 建单和服务端授权事务;不能写
结论改变字段时必须回修。 依赖未取证选择器的生产执行代码。真机结论改变字段时先回修契约与原型。
2. **拼多多页面结构随版本变化**,已观察到详情页无独立规格入口、价格节点被拆分等情况。 2. **拼多多页面结构随版本变化**,已观察到详情页无独立规格入口、价格节点被拆分等情况。
3. **授权卡死**:前序项目出现过 `EXECUTING` 授权永不推进导致任务锁死。本项目在 T-207 3. **授权卡死**:前序项目出现过 `EXECUTING` 授权永不推进导致任务锁死。本项目在 T-207
实现围栏前超时 / 放弃,在 T-208 实现围栏后调和;围栏后不得释放或重试。 实现围栏前超时 / 放弃,在 T-208 实现围栏后调和;围栏后不得释放或重试。
4. **规格面板单价位置未取证**:闸门一依赖它,T-103 必须一并取证。若读不可靠, 4. **规格面板读价尚未形成生产判据**:已人工观察当前价与原价,但 T-103 仍需把证据固化为
确认页设计要改。 唯一、版本绑定且排除按钮价的读取规则;失败就转人工,不删价格闸门。
5. **MVP 已收窄**:只做手工填链接、批量开始第一趟试选和两趟执行。Excel、ERP、图搜、 5. **MVP 已收窄**:只做手工填链接、批量开始采购和单趟创建待付款订单。Excel、ERP、图搜、
批量顺序编排 / 暂停接管、订单自动核对、AI 辅助全部推到 V2(见 `06-tasks.md` 的 批量顺序编排 / 暂停接管、订单自动核对、AI 辅助全部推到 V2(见 `06-tasks.md` 的
T-501~T-508)。 T-501~T-508)。
6. **App 版本必须 fail closed**:运行时拼多多版本与本项目已取证版本不一致就停止领取, 6. **App 版本必须 fail closed**:运行时拼多多版本与本项目已取证版本不一致就停止领取,
+2
View File
@@ -51,6 +51,8 @@
- **开工门槛(一次性)**:P0 的 UI 模块首次实现前应有原型;没有就先生成原型、人工确认后再拆任务。 - **开工门槛(一次性)**:P0 的 UI 模块首次实现前应有原型;没有就先生成原型、人工确认后再拆任务。
- **两阶段确认**:Phase 0 先确认流程、状态、主动作和布局;T-103 真机取证后再核对实际可读 - **两阶段确认**:Phase 0 先确认流程、状态、主动作和布局;T-103 真机取证后再核对实际可读
字段与文案。第二阶段若有变化,先修订 IX 与原型,再写生产页面。 字段与文案。第二阶段若有变化,先修订 IX 与原型,再写生产页面。
- 管理员“开始采购”授权的单趟流程属于显著改版,T-111 重新生成任务创建、工作台、详情和桌面执行
四个原型;旧的试选后确认/第二趟状态不得继续作为实现输入。
- **触发式重新生成**:新需求显著改变某页面的布局或控件集合时,把"重新生成该页原型 → 更新 IX 草稿"作为该任务的第一步。判断标准只有一条:这次变更是否让 agent 需要重新"看图"才能枚举交互。换文案、加字段等小改动只改 IX 条目,不碰原型。 - **触发式重新生成**:新需求显著改变某页面的布局或控件集合时,把"重新生成该页原型 → 更新 IX 草稿"作为该任务的第一步。判断标准只有一条:这次变更是否让 agent 需要重新"看图"才能枚举交互。换文案、加字段等小改动只改 IX 条目,不碰原型。
- **实现后即过期**:页面实现后,原型自动视为过期,不回头修补;实现后的视觉事实由任务文件 `## 执行记录` 中的真实截图或可运行验证承担。 - **实现后即过期**:页面实现后,原型自动视为过期,不回头修补;实现后的视觉事实由任务文件 `## 执行记录` 中的真实截图或可运行验证承担。
- 需要新原型时整页重新生成,不逐次修补旧文件。 - 需要新原型时整页重新生成,不逐次修补旧文件。
+42 -44
View File
@@ -398,12 +398,12 @@
<option value="version_mismatch">未就绪 · 拼多多版本失配</option> <option value="version_mismatch">未就绪 · 拼多多版本失配</option>
<option value="idle" selected>已就绪 · 尚未轮询</option> <option value="idle" selected>已就绪 · 尚未轮询</option>
<option value="polling">轮询中 · 暂无任务</option> <option value="polling">轮询中 · 暂无任务</option>
<option value="trial">执行中 · 第一趟试选</option> <option value="claimed">单趟执行 · 已领取</option>
<option value="trial_done">第一趟完成 · 已释放手机</option> <option value="selecting">单趟执行 · 选择规格</option>
<option value="dry_run">执行中 · 下单只读演练</option> <option value="gate1">单趟执行 · 闸门一</option>
<option value="dry_run_ready">演练完成 · 等待真实第二趟</option> <option value="quantity_gate2">单趟执行 · 数量与闸门二</option>
<option value="order_prefence">真实第二趟 · 围栏前校验</option> <option value="confirm_gate3">单趟执行 · 确认页与闸门三</option>
<option value="fencing">真实第二趟 · 申请围栏中</option> <option value="fencing">单趟执行 · 申请围栏中</option>
<option value="fence_failed">围栏失败或响应不明 · 不点击</option> <option value="fence_failed">围栏失败或响应不明 · 不点击</option>
<option value="fenced">围栏后 · 核对同一提交</option> <option value="fenced">围栏后 · 核对同一提交</option>
<option value="waiting_payment">明确已创建 · 等人付款</option> <option value="waiting_payment">明确已创建 · 等人付款</option>
@@ -469,7 +469,7 @@
<div class="span-2"><dt>商品</dt><dd id="product">暂无商品;轮询会在安全就绪后领取一条任务</dd></div> <div class="span-2"><dt>商品</dt><dd id="product">暂无商品;轮询会在安全就绪后领取一条任务</dd></div>
<div><dt>颜色分类</dt><dd id="color">—</dd></div> <div><dt>颜色分类</dt><dd id="color">—</dd></div>
<div><dt>尺码 / 数量</dt><dd id="size-qty">—</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><dt>金额上限</dt><dd id="price-cap">—</dd></div>
<div class="span-2"><dt>提交围栏</dt><dd id="submission">未建立</dd></div> <div class="span-2"><dt>提交围栏</dt><dd id="submission">未建立</dd></div>
</dl> </dl>
@@ -583,41 +583,41 @@
const task = { const task = {
id: 'CB-20260803-0042', id: 'CB-20260803-0042',
product: '纯棉圆领短袖 T 恤(原型假数据)', 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 = [ const records = [
{ {
id:'REC-DEMO-0042', taskId:'CB-20260803-0042', title:'纯棉圆领短袖 T 恤(原型假数据)', collectedAt:'2026-08-03T13:52:18+08:00', 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 · 结果不明确', status:'RECONCILIATION_REQUIRED', spec:'白色 | XL | 2 件', cap:'CNY 80.00', submission:'SUB-DEMO-0042 · 结果不明确',
note:'围栏后网络中断,订单可能已创建。只允许核查同一提交记录,不能重试。', evidence:['商品页截图裁剪(内联假证据)','规格面板截图(内联假证据)'], note:'围栏后网络中断,订单可能已创建。只允许核查同一提交记录,不能重试。', evidence:['商品页原始截图(内联假证据)','规格面板原始截图(内联假证据)'],
timeline:['13:51:04 重新选择同一规格','13:51:41 三道价格闸门通过(假数据)','13:51:58 围栏已建立','13:52:18 点击结果不明确,转人工核查'], timeline:['13:51:04 同一趟精确选择规格','13:51:41 三道价格闸门通过(假数据)','13:51:58 围栏已建立','13:52:18 点击结果不明确,转人工核查'],
result:'下单待核查:白色 | XL | CNY 32.50 | 2 件', resultNote:'订单可能已创建、支付未完成。系统不会再次提交,也不会付款。' result:'下单待核查:白色 | XL | CNY 32.50 | 2 件', resultNote:'订单可能已创建、支付未完成。系统不会再次提交,也不会付款。'
}, },
{ {
id:'REC-DEMO-0041', taskId:'CB-20260803-0041', title:'办公室桌面收纳盒(原型假数据)', collectedAt:'2026-08-03T12:24:09+08:00', 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:'未建立', status:'ORDERING', spec:'透明 | L | 2 件', cap:'CNY 60.00', submission:'未建立',
note:'第一趟试选完成,规格面板证据已回传,等待管理员确认。', evidence:['商品页截图裁剪(内联假证据)','规格面板截图(内联假证据)'], note:'管理员已开始采购,当前单趟已完成规格与闸门一,正在设置并复核数量。', evidence:['商品页截图(内联假证据)','规格面板原始截图(内联假证据)'],
timeline:['12:22:31 打开商品','12:23:04 匹配透明 / L','12:23:38 读取规格面板单价','12:24:09 截图并退出商品页'], timeline:['12:22:31 领取一次性授权','12:23:04 精确匹配透明 / L','12:23:38 闸门一读取 CNY 24.00','12:24:09 正在复核数量与闸门二'],
result:'试选:透明 | L | CNY 24.00 | 2 件', resultNote:'这只是试选结果,不代表已下单。' result:'执行中:透明 | L | CNY 24.00 | 2 件', resultNote:'无需等待管理员中途确认;任一门禁失败即停止。'
}, },
{ {
id:'REC-DEMO-0038', taskId:'CB-20260803-0038', title:'标签打印纸补货(原型假数据)', collectedAt:'2026-08-03T10:06:42+08:00', 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', status:'WAITING_PAYMENT', spec:'白色 | 50×30 | 1 件', cap:'CNY 50.00', submission:'SUB-DEMO-0038 · SUBMITTED',
note:'已明确创建待付款订单,自动化已停止,等待人核对后自行付款。', evidence:['订单确认页非敏感截图(内联假证据)'], note:'已明确创建待付款订单,自动化已停止,等待人核对后自行付款。', evidence:['订单确认页内部原始截图(内联假证据)'],
timeline:['10:04:22 重新选择同一规格','10:05:10 价格与数量复核通过','10:05:36 获得唯一围栏许可','10:06:42 明确创建待付款订单'], timeline:['10:04:22 同一趟精确选择规格','10:05:10 两次价格与数量复核通过','10:05:36 获得唯一围栏许可','10:06:42 明确创建待付款订单'],
result:'已创建待付款订单:白色 | 50×30 | CNY 46.00 | 1 件', resultNote:'系统只创建待付款订单,付款始终由人完成。' 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', 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:'未建立', 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 价格闸门二不通过,停止自动流程'], 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)); ].sort((a, b) => b.collectedAt.localeCompare(a.collectedAt));
const recordStatus = { const recordStatus = {
RECONCILIATION_REQUIRED:{label:'结果待核查',tone:'critical'}, RECONCILIATION_REQUIRED:{label:'结果待核查',tone:'critical'},
WAITING_CONFIRMATION:{label:'等待确认',tone:'caution'}, ORDERING:{label:'采购执行中',tone:'info'},
WAITING_PAYMENT:{label:'待付款',tone:'success'}, WAITING_PAYMENT:{label:'待付款',tone:'success'},
NEEDS_MANUAL:{label:'转人工',tone:'caution'} NEEDS_MANUAL:{label:'转人工',tone:'caution'}
}; };
@@ -627,40 +627,38 @@
let detailRequestId = 0; let detailRequestId = 0;
const baseSteps = { const baseSteps = {
idle: [], idle: [],
trial: ['打开商品(待真机判据)', '精确匹配颜色分类与尺码', '从规格面板读取单价', '截图并退出商品页'], single: ['核对一次性授权并打开商品', '精确选择颜色分类与尺码', '闸门一:读取 SKU 单价并校验上限', '设置数量、读回并执行闸门二', '确认页执行闸门三与控件唯一校验', '申请服务端提交围栏', '首次许可后持久化并内部点击一次', '调和同一提交结果']
dry: ['重新打开商品(待真机判据)', '精确选择与数量复核', '读取订单确认页非敏感摘要', '验证提交控件唯一后退出'],
order: ['重新选择同一规格', '闸门二:单价必须等于授权价', '数量复核与闸门三', '申请服务端提交围栏', '围栏许可后内部仅点击一次', '调和同一提交结果']
}; };
const scenarios = { 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' }, 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' }, 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' }, 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 }, 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' }, 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' },
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 }, 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' },
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' }, 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' },
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 }, 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' },
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' }, 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:'ORDER · 申请围栏', legTone:'caution', action:'等待围栏响应', enabled:false, reason:'申请中禁止取消、关闭或重复申请。', session:'运行中', step:'申请唯一提交围栏', kind:'order', active:3, countdown:'暂停', fail:'0 / 3', lease:'02:31' }, 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:'ORDER · 未获许可', legTone:'critical', action:'打开围栏核查说明', enabled:true, safeAction:'review', reason:'只核查围栏状态,不会触发提交。', session:'已停止', step:'核查幂等键', kind:'order', active:3, countdown:'—', fail:'1 / 3', submission:'围栏状态未知 · 幂等键 DEMO-FENCE-042' }, 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:'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' }, 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:'order', active:6, countdown:'—', fail:'0 / 3', count:5, submission:'SUB-DEMO-0042 · SUBMITTED' }, 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:'order', active:5, countdown:'—', fail:'0 / 3', submission:'SUB-DEMO-0042 · 结果不明确' }, 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:'order', active:5, countdown:'—', fail:'1 / 3', submission:'SUB-DEMO-0042 · RECONCILIATION_REQUIRED' }, 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:'order', active:5, countdown:'—', fail:'0 / 3', submission:'SUB-DEMO-0042 · HANDED_OFF' }, 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:'导出仅生成本地假文本,不包含页面全文或敏感信息。', session:'继续轮询其他任务', step:'当前任务已移交', kind:'order', active:1, countdown:'00:11', fail:'0 / 3', submission:'未建立' }, 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 } 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 = { const sessionControls = {
not_ready:{label:'开始轮询',enabled:false,reason:'web 服务未就绪,不能领取任务。'}, not_ready:{label:'开始轮询',enabled:false,reason:'web 服务未就绪,不能领取任务。'},
version_mismatch:{label:'开始轮询',enabled:false,reason:'App 版本失配,必须先完成本项目真机取证。'}, version_mismatch:{label:'开始轮询',enabled:false,reason:'App 版本失配,必须先完成本项目真机取证。'},
idle:{label:'开始轮询',enabled:true,reason:'只启动普通轮询,不会直接执行下单。'}, idle:{label:'开始轮询',enabled:true,reason:'只启动领取会话;只有已获管理员开始采购授权的任务可执行。'},
polling:{label:'停止轮询',enabled:true,reason:'停止后不再领取新任务。'}, polling:{label:'停止轮询',enabled:true,reason:'停止后不再领取新任务。'},
trial:{label:'停止轮询',enabled:false,reason:'真机步骤执行中,禁止硬停止。'}, claimed:{label:'停止轮询',enabled:false,reason:'当前任务已领取;停止只应阻止下次领取,不能硬取消本任务。'},
trial_done:{label:'停止轮询',enabled:true,reason:'手机已退出商品页,可以安全停止轮询。'}, selecting:{label:'停止轮询',enabled:false,reason:'受控真机步骤执行中,禁止硬停止。'},
dry_run:{label:'停止轮询',enabled:false,reason:'只读演练执行中,禁止硬停止。'}, gate1:{label:'停止轮询',enabled:false,reason:'价格闸门执行中,禁止硬停止。'},
dry_run_ready:{label:'停止轮询',enabled:true,reason:'演练已退出确认页,可以安全停止轮询。'}, quantity_gate2:{label:'停止轮询',enabled:false,reason:'数量与闸门二执行中,禁止硬停止。'},
order_prefence:{label:'停止轮询',enabled:false,reason:'真实第二趟校验中,禁止硬停止。'}, confirm_gate3:{label:'停止轮询',enabled:false,reason:'确认页只允许安全校验,禁止硬停止或额外点击。'},
fencing:{label:'停止轮询',enabled:false,reason:'围栏申请中,禁止关闭、取消或重复申请。'}, fencing:{label:'停止轮询',enabled:false,reason:'围栏申请中,禁止关闭、取消或重复申请。'},
fence_failed:{label:'开始轮询',enabled:false,reason:'先核查围栏状态;不能用重新轮询绕过不明确结果。'}, fence_failed:{label:'开始轮询',enabled:false,reason:'先核查围栏状态;不能用重新轮询绕过不明确结果。'},
fenced:{label:'停止轮询',enabled:false,reason:'已建立围栏,只能调和同一提交。'}, fenced:{label:'停止轮询',enabled:false,reason:'已建立围栏,只能调和同一提交。'},
@@ -836,10 +834,10 @@
$('task-id').textContent = hasTask ? task.id : '—'; $('lease').textContent = s.lease || '—'; $('task-id').textContent = hasTask ? task.id : '—'; $('lease').textContent = s.lease || '—';
$('product').textContent = hasTask ? task.product : '暂无商品;轮询会在安全就绪后领取一条任务'; $('product').textContent = hasTask ? task.product : '暂无商品;轮询会在安全就绪后领取一条任务';
$('color').textContent = hasTask ? task.color : '—'; $('size-qty').textContent = hasTask ? task.sizeQty : '—'; $('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 || '未建立'; $('submission').textContent = s.submission || '未建立';
$('preview-empty').hidden = hasTask; $('product-preview-image').hidden = !hasTask; $('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(假数据)' : '采集时间:—'; $('preview-time').textContent = hasTask ? '采集时间:2026-08-03 13:51(假数据)' : '采集时间:—';
renderSteps(s.kind, s.active); renderSteps(s.kind, s.active);
$('countdown').textContent = s.countdown; $('failure-count').textContent = s.fail; $('completed-count').textContent = String(s.count || 0); $('countdown').textContent = s.countdown; $('failure-count').textContent = s.fail; $('completed-count').textContent = String(s.count || 0);
@@ -888,7 +886,7 @@
window.addEventListener('resize', hideRecordMenu); window.addEventListener('resize', hideRecordMenu);
$('export-log').addEventListener('click', exportDemo); $('export-log').addEventListener('click', exportDemo);
$('close-demo').addEventListener('click', () => { $('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 只演示规则,不会关闭浏览器。'); showDialog(unsafe ? '当前不能直接关闭' : '关闭窗口协议', unsafe ? '真机或围栏流程处于受控阶段。原生实现需先完成安全停靠或保持同一 submission 调和,不能让关闭看起来等于“可以重来”。' : '原生实现关闭窗口后立即停止普通轮询,不留后台进程。此 HTML 只演示规则,不会关闭浏览器。');
}); });
$('dialog-close').addEventListener('click', () => $('info-dialog').close()); $('dialog-close').addEventListener('click', () => $('info-dialog').close());
+5 -5
View File
@@ -203,11 +203,11 @@
<h2>创建后发生什么</h2> <h2>创建后发生什么</h2>
<ol class="steps"> <ol class="steps">
<li>任务先保存为「待开始」。</li> <li>任务先保存为「待开始」。</li>
<li>在任务表格勾选后统一点击「开始试选」。</li> <li>在任务表格勾选后点击「开始采购(只创建待付款订单)」。</li>
<li>第一趟只试选、读价、截图,然后退出商品。</li> <li>该点击锁定商品、规格、数量和最高总价,并授权采购工具执行。</li>
<li>你看过证据后再决定是否授权。</li> <li>采购工具同一趟校验规格、价格和数量后,只创建待付款订单。</li>
</ol> </ol>
<p style="margin-top:10px"><strong>系统不会自动付款。</strong></p> <p style="margin-top:10px"><strong>创建任务本身不授权;系统任何时候都不会自动付款。</strong></p>
</section> </section>
<section class="prototype-controls"> <section class="prototype-controls">
<h2>原型状态</h2> <h2>原型状态</h2>
@@ -310,7 +310,7 @@
setReady(); setReady();
form.hidden = true; form.hidden = true;
status.className = 'status success show'; 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?.(); status.focus?.();
} }
File diff suppressed because one or more lines are too long
+23 -13
View File
@@ -191,8 +191,8 @@
<main id="main"> <main id="main">
<header class="page-head"> <header class="page-head">
<div><h1>采购任务</h1><p class="subtitle">创建后先保存为待开始;勾选后统一进入第一趟试选队列。</p></div> <div><h1>采购任务</h1><p class="subtitle">创建后先保存为待开始;勾选并开始采购后,采购工具按授权逐条执行。</p></div>
<p class="safety-note"><strong>只开始试选,不会下单。</strong> 下单必须在试选完成并经人工确认后另行授权,付款始终由人完成。</p> <p class="safety-note"><strong>开始采购会授权创建待付款订单。</strong> 系统校验规格、数量和金额后最多提交一次,任何时候都不会自动付款。</p>
</header> </header>
<section class="workbench" aria-label="采购任务表格工作台"> <section class="workbench" aria-label="采购任务表格工作台">
@@ -213,13 +213,13 @@
<form class="filter-form" id="filter-form" role="search"> <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-field"><label for="keyword">标题关键词</label><input id="keyword" type="search" placeholder="输入采购任务标题" autocomplete="off"></div>
<div class="filter-actions"> <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 class="toolbar-group"><button class="button button-primary" type="submit">筛选</button><button class="button button-secondary" id="clear-filter" type="button">清除</button></div>
</div> </div>
</form> </form>
<div class="selection-mode" id="selection-mode" hidden> <div class="selection-mode" id="selection-mode" hidden>
<div class="selection-copy"><strong id="selection-count">已选择 0 条</strong><span>只会加入第一趟试选队列,不签发授权、不创建订单、不付款。</span></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-trial" type="button">开始试选</button><button class="button button-secondary" id="clear-selection" type="button">清除选择</button></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> </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><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 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="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> </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> <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> </dialog>
@@ -278,7 +278,7 @@
'use strict'; 'use strict';
const tasks = [ 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-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-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-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 } }, { 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 = { const statusMeta = {
DRAFT:{ label:'待开始', tone:'neutral', next:'勾选后点击“开始试选”,任务才会进入设备领取队列。' }, DRAFT:{ label:'待开始', tone:'neutral', next:'勾选后点击“开始采购(只创建待付款订单)”,才会签发一次性授权。' },
PENDING:{ label:'待领取', tone:'', next:'已进入第一趟试选队列,等待桌面端轮询领取。' }, PENDING:{ label:'已授权待领取', tone:'', next:'管理员已授权创建待付款订单,等待采购工具领取;系统不会付款。' },
WAITING_CONFIRMATION:{ label:'等待确认', tone:'', next:'查看规格面板证据,确认机器选对后才可签发下单授权。' }, ORDERING:{ label:'采购执行中', tone:'', next:'采购工具正在同一趟校验规格、价格和数量;无需中途确认。' },
NEEDS_MANUAL:{ label:'待人工处理', tone:'warning', next:'围栏前执行已停止。核查原因后可重置为待开始或取消。' },
RECONCILIATION_REQUIRED:{ label:'结果待核查', tone:'warning', next:'订单可能已创建。只核查同一提交记录,不得重新提交。' }, RECONCILIATION_REQUIRED:{ label:'结果待核查', tone:'warning', next:'订单可能已创建。只核查同一提交记录,不得重新提交。' },
WAITING_PAYMENT:{ label:'待付款', tone:'warning', next:'人在拼多多核对商品、规格、数量和金额后手工付款;系统不会付款。' }, WAITING_PAYMENT:{ label:'待付款', tone:'warning', next:'人在拼多多核对商品、规格、数量和金额后手工付款;系统不会付款。' },
SUCCEEDED:{ label:'已完成', tone:'success', next:'已由人工付款并标记完成,可查看执行证据与审计记录。' } SUCCEEDED:{ label:'已完成', tone:'success', next:'已由人工付款并标记完成,可查看执行证据与审计记录。' }
@@ -303,6 +304,7 @@
const filterForm = document.querySelector('#filter-form'); const filterForm = document.querySelector('#filter-form');
const selectionMode = document.querySelector('#selection-mode'); const selectionMode = document.querySelector('#selection-mode');
const selectionCount = document.querySelector('#selection-count'); const selectionCount = document.querySelector('#selection-count');
const selectionCap = document.querySelector('#selection-cap');
const selectAll = document.querySelector('#select-all'); const selectAll = document.querySelector('#select-all');
const states = { loading:document.querySelector('#loading-state'), empty:document.querySelector('#empty-state'), error:document.querySelector('#error-state') }; const states = { loading:document.querySelector('#loading-state'), empty:document.querySelector('#empty-state'), error:document.querySelector('#error-state') };
const selectedIds = new Set(); const selectedIds = new Set();
@@ -325,6 +327,12 @@
return `${whole}.${fraction}`; 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) { 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)); 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; filterForm.hidden = count > 0;
selectionMode.hidden = count === 0; selectionMode.hidden = count === 0;
selectionCount.textContent = `已选择 ${count} 条待开始任务`; 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 selectableVisible = visibleTasks().filter((task) => task.status === 'DRAFT');
const selectedVisible = selectableVisible.filter((task) => selectedIds.has(task.id)); const selectedVisible = selectableVisible.filter((task) => selectedIds.has(task.id));
selectAll.checked = selectableVisible.length > 0 && selectedVisible.length === selectableVisible.length; 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('#clear-selection').addEventListener('click', () => { selectedIds.clear(); renderRows(); keyword.focus(); });
document.querySelector('#start-trial').addEventListener('click', () => { document.querySelector('#start-purchase').addEventListener('click', () => {
const ids = [...selectedIds]; const ids = [...selectedIds];
tasks.forEach((task) => { if (selectedIds.has(task.id) && task.status === 'DRAFT') task.status = 'PENDING'; }); tasks.forEach((task) => { if (selectedIds.has(task.id) && task.status === 'DRAFT') task.status = 'PENDING'; });
selectedIds.clear(); selectedIds.clear();
renderRows(); renderRows();
showToast(`已将 ${ids.length} 条任务加入第一趟试选队列。没有签发下单授权,也不会付款。`); showToast(`已授权 ${ids.length} 条任务创建待付款订单。采购工具将逐条执行,系统不会付款。`);
}); });
function isInteractive(target) { return Boolean(target.closest('a,button,input,label,select,textarea,summary')); } function isInteractive(target) { return Boolean(target.closest('a,button,input,label,select,textarea,summary')); }
@@ -548,7 +558,7 @@
drawerId.textContent = `${task.id} · ${meta.label}`; drawerId.textContent = `${task.id} · ${meta.label}`;
drawerTitle.textContent = task.title; 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>`; 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; drawerNext.textContent = meta.next;
fullDetailLink.href = `web-task-detail.html#${encodeURIComponent(task.id)}`; fullDetailLink.href = `web-task-detail.html#${encodeURIComponent(task.id)}`;
} }
+131 -141
View File
@@ -1,157 +1,147 @@
# 路由与页面结构 # 路由与页面结构
> 本文约定采购服务(网页端,`admin/`)页面路由、页面职责和组件归属,以及采购工具 > 采购服务使用服务端渲染;采购工具使用固定 tab 的 Windows 桌面外壳。交互细节以
> (桌面端,`client/`)的界面结构。 > [08-interaction-checklist.md](08-interaction-checklist.md) 为准。
> 具体交互行为以[交互清单](08-interaction-checklist.md)为准,接口形状以 [API 合约](api.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 | | `GET` | `/login` | 登录页 | 匿名 |
| 配置 | 设备档案、ADB 路径与 serial、常用超时参数、连接检查 | US-003 | IX-007 | | `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 实际 / 已取证版本和会话状态;开始 / 停止轮询固定在 ## 二、采购任务工作台 `/tasks`
最右侧且只控制轮询会话。版本不一致时 fail closed 并提示重新取证。
- 宽屏是主从工作区:左侧在“当前任务 + 滚动日志”和“执行记录详情”之间切换,右侧执行记录
保持可见;窄屏进入详情时暂时收起记录表,返回后恢复列表现场。
- 当前任务左侧显示商品、规格、数量、**当前是第一趟还是第二趟**、步骤和剩余租约;右侧约
1/3 显示可选商品 / 证据预览。无可信图片时保持空态,有图必须标来源与采集时间。
- 滚动日志标题区同时显示下次轮询、连续失败和本次完成,不再使用独立会话卡或底部控制条。
- 执行记录只显示标题、状态两列,任务编号与时间放在标题次行,按采集时间倒序。单击选择;
双击、Enter、可见“查看所选记录”或右键在左侧打开详情;详情态单击另一行直接更新详情。
- 记录详情不是模态框:上部左侧显示原始文字、右侧显示图片证据,下部显示结构化采购结果;
可见“返回当前任务”与 `Esc` 均能返回并恢复当前行焦点。加载失败、无图和记录不存在有明确
状态,图片缺失不使用其他来源凑合。
- **待人工时整页显著变色并说明缺什么**,不要让执行员盯着一个静止画面猜。
- 关闭窗口即停止轮询;连续失败达阈值自动停止并显示原因。
### 配置页 页面标题:**采购服务**。
- 设备档案必须**显式填写 serial**,不允许留空自动选——同一手机 USB + WiFi 同时在线时 表格上方两行:
自动选会失败(见[架构设计](04-architecture.md)第六节)。
- 连接检查只做连接和确认拼多多已安装,**不打开商品、不选规格、不创建订单**。
- 运行中冻结设备切换与参数保存。
## 四、导航规则 1. 第一行:导入(disabled 占位)、创建按钮;存在选择时显示上下文批量操作条。
2. 第二行:标题关键词输入框(默认筛选)、其他可选筛选,右侧“筛选”“清除”。
- 采购服务从路由化详情抽屉返回工作台时不重新加载列表;完整详情页返回时保留原筛选条件。 表格字段:
- 采购工具启动时若服务端有本设备的活跃任务,优先恢复该任务,不允许直接领取下一条。
- 第一趟试选完成后**必须退出商品页**再进入下一轮轮询,不停在规格面板等人。
- 采购工具在真机步骤执行期间禁用硬取消和关闭窗口,避免留下无法判定的中间态。
- 任何进入外部支付页的情形,采购工具立即停止并跳回待人工,**不提供「继续」按钮**。
- dry-run 与真实下单必须使用不同的醒目标识;dry-run 不得出现可触发真实提交的控件。
- 已建立提交围栏后,不提供重新领取、重新提交或放弃授权,只能恢复同一提交记录并调和。
## 五、组件归属 | 字段 | 行为 |
| --- | --- |
| checkbox | 只允许选择 `DRAFT`;表头全选当前筛选结果中的可选行 |
| 标题 | `<a>` 指向 canonical 拼多多商品页;点击链接不触发行详情 |
| 颜色 | 任务目标颜色 |
| 尺码 | 任务目标尺码 |
| 价格 | 任务最高总价;执行后详情另列实际闸门金额 |
| 数量 | 正整数 |
| 采购结果 | 用 `颜色|尺码|价格|数量` 展示已执行摘要;无结果显示 `—` |
| 状态 | 中文状态徽标,不只靠颜色 |
| 创建时间 | 本地时区显示,数据按 UTC 保存 |
| 组件 | 归属 | 说明 | 没有操作列。双击非控件区域或键盘 Enter 打开 `/tasks/{id}` 路由化详情抽屉;新 tab 直接访问同 URL
| --- | --- | --- | 则显示完整详情页。关闭抽屉或浏览器返回恢复筛选、滚动和触发行焦点。
| `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 采购执行页 | 左侧内联原始文字、图片证据、采购结果、读取状态与返回焦点 |
## 六、原型 ### 批量开始采购
低保真原型放 `docs/design/`,约定见 [`design/README.md`](design/README.md)。 选择 `DRAFT` 后,表格前的上下文操作条显示:
原型只回答「页面上有什么」,行为权威是[交互清单](08-interaction-checklist.md);
实现时按真实框架重写,**不复制原型代码**。 - 已选条数;
- 所选最高总价合计;
- “将授权采购工具逐条创建待付款订单,系统不会付款”;
- 主按钮“开始采购(只创建待付款订单)”。
点击即为最终授权,不另弹“机器选对了吗”的同义确认框。整批全有或全无;成功更新 `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`。
- 原型只使用假数据、无网络和生产副作用;显著流程变更先更新原型再实现。
+2 -2
View File
@@ -17,8 +17,8 @@
| T-0xx | Phase 0 地基 | | T-0xx | Phase 0 地基 |
| T-1xx | Phase 1 真机取证 | | T-1xx | Phase 1 真机取证 |
| T-2xx | Phase 2 采购服务核心 | | T-2xx | Phase 2 采购服务核心 |
| T-3xx | Phase 3 双端打通与第一趟试选 | | T-3xx | Phase 3 双端打通与单趟围栏前执行 |
| T-4xx | Phase 4 第二趟下单与收尾 | | T-4xx | Phase 4 真实提交与收尾 |
| T-5xx | V2 及以后(图搜、Excel、ERP、订单核对、AI 辅助) | | T-5xx | V2 及以后(图搜、Excel、ERP、订单核对、AI 辅助) |
## 领取规则 ## 领取规则
+77 -35
View File
@@ -2,7 +2,7 @@
id: T-103 id: T-103
title: 验证规格面板精确选择与 SKU 单价 title: 验证规格面板精确选择与 SKU 单价
phase: 1 phase: 1
deps: [T-102, T-110] deps: [T-102, T-110, T-111]
status: DOING status: DOING
created: 2026-08-04 created: 2026-08-04
vikunja_task_id: 23 vikunja_task_id: 23
@@ -25,38 +25,40 @@ write_paths:
- docs/current-state.md - docs/current-state.md
--- ---
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-04T06:06:05Z sha256=9de2a35beba4cca7a29cf4dc6b5ff643d829f6467ff576da976589a8d1f3dcad --> <!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-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)”;因此本任务不再假设存在“未选择”或“只选择一个维度”的初始状态。 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_trial_sku_panel()`、`select_sku_options()`、`read_sku_unit_price()` 的真机 spike。 - 功能:F-006 单趟采购的规格面板前置;覆盖 `open_sku_panel()`、`select_sku_options()`、`read_sku_unit_price()` 和安全退出的真机 spike。
- 用户故事:US-003、US-004、US-008;本任务无生产 GUI。 - 架构/API:`docs/04-architecture.md` 第三、四、5.4、六节;`docs/api.md` 的 `SkuSelectionFlow`;T-104、T-105 与 T-205 的前置。
- 架构 / API:`docs/04-architecture.md` 第三、四、5.4、六节;`docs/api.md` 证据上传和 `TrialSkuFlow`;T-104、T-105 与 Phase 2 的前置。 - 本任务没有生产 GUI,也不调用 T-204 的 HTTP 截图链路。
## 方案 ## 方案
1. 先实现本机确定性脱敏器,不先写页面判据。原始 screenshot/XML 只保存到 `%LOCALAPPDATA%\cmbuyer\artifacts\T-103\...\raw`,只允许脱敏器消费;agent、fixture、业务、日志、HTTP sink、Vikunja 与 Git 均不得读取/上传原始内容。 1. 停止修改、调用或扩展截图遮罩器。T-103 原始截图保存在本机产物目录完成真机验收;T-204 以后显式上传 `privacy_tier=INTERNAL_RAW` 的截图。完整 XML 不上传、不进日志、Vikunja 或 Git。
2. 脱敏器绑定 PKG110 分辨率、拼多多 8.17.0、goods_id、人工声明状态和本项目证据配置,同时从 screenshot/XML 移除收货区域、地址与手机号内容,在 sibling `derived` 目录原子发布派生物。派生 XML 仍命中手机号模式、隐私区域无法确认、结构/分辨率/版本失配、哈希不一致或目标目录存在时 fail closed,不发布半成品。服务端只接收派生截图哈希和 sanitizer 版本,不接收原始文件/哈希/路径/XML。 2. 从已确认 v5 派生 XML 提取只含页面判据节点的最小 fixture;fixture 不含地址、手机号或支付凭据。
3. 由人运行脱敏器并本地确认派生 screenshot/XML 的三种真实状态:panel-opened-target-preselected(刚打开且目标规格已自动选中)、alternate-all-dimensions-selected(人工把颜色和尺码都改成非目标值)、target-selection-restored(再把两个维度恢复为目标值)。主 agent 只在派生 manifest、每态实际选中值与人工确认齐全后读取派生证据,提取最小 fixture。首轮旧证据不得复用为 fixture。 3. 实现 `SkuSelectionFlow` 窄 capability。`open_sku_panel()` 只允许拼多多 8.17.0、goods_id `937122477375` 上证据绑定、精确唯一的“快要抢光”;缺失、重复、版本失配或面板不唯一时零后续点击。其他购买文案不得复用。
4. 实现 `TrialSkuFlow` 窄 capability。`open_trial_sku_panel()` 只允许拼多多 8.17.0、goods_id `937122477375` 上证据绑定、精确唯一的 `快要抢光`;缺失、重复、版本失配或打开后面板判据不唯一时零后续点击。`免拼购买 / 单独购买 / 直接拼成` 等其他文案必须分别取证,不能包含、前缀、同义、OCR 或坐标兜底。 4. 本任务只含打开商品、受控打开面板、维度内精确选择、选中态读回、读取 SKU 单价、原始截图和安全退出。不得暴露通用 `click`、数量、确认页、授权、提交围栏、提交订单或支付能力。
5. 第一趟 capability 只含打开商品、受控打开面板、在维度容器内精确选择、读面板单价、脱敏取证和返回。接口不得暴露通用 `click`、`set_quantity()`、`go_to_order_confirm()`、`submit_order()` 或支付能力;规格面板中的“提交订单”、微信支付、先用后付和 0 元下单只作为硬拒绝判据,静态调用链测试证明不可达。 5. 规格选项按维度容器隔离后精确唯一匹配,防“红/粉红”“1/10”前缀碰撞;缺失、重复、禁用、维度不明或选中态不唯一均停止。
6. 在脱敏派生 XML 上按维度容器隔离候选,文本等值匹配并读回选中态;缺失、重复、禁用、维度不明或选中态不唯一均停止。纯函数测试覆盖 `红`/`粉红`、`1`/`10` 等前缀碰撞。 6. 单价只从已取证规格面板当前价角色读取,严格解析人民币十进制字符串。促销前缀可变,不能把“快卖完”作为主判据;必须限定 PDD package、面板结构/相对位置、非点击价格叶节点、唯一当前价并排除原价、提交/支付/优惠语义。歧义返回 unreadable,不从详情页或底部按钮补值。
7. 单价只从规格面板证据确定的唯一节点读取,以十进制字符串返回,禁止浮点。记录真实文本是否含优惠前缀、货币符号是否拆节点、是否并列原价/区间价;语义不唯一、节点缺失、候选冲突或只能从详情页读取时返回 unreadable。 7. 真机运行只返回规格、选中态、价格和页面状态摘要;地址/手机号不解析为字段。截图上传器不属于本任务。
8. 人最终在真机先把两个维度改成一组明确记录的非目标值,再执行受控入口、精确选择和读价脚本恢复目标规格,确认实际选择颜色分类=黑色 CHA(纯棉)、尺码=M(建议100-115)(以派生 XML 精确文本为准)、面板单价语义、派生证据隐私与退出行为;agent 保持 DOING 直到人工验收。 8. 离线主审通过后,由人先把颜色和尺码改成记录的非目标值,再运行脚本恢复目标规格、读取 12.88、保存原始截图并安全退出。`needs_device=true`,agent 保持 DOING 等人工验收。
## 验收要点 ## 验收要点
- 三组原始证据分别对应 panel-opened-target-preselected、alternate-all-dimensions-selected、target-selection-restored,且仅在本机 raw 目录;脱敏器离线测试覆盖地址/手机号移除、截图与 XML 同步处理、状态/版本/分辨率/结构失配、手机号残留、原子性、异常脱敏和已有目录不覆盖。 - 最小 fixture 只来自本项目拼多多 8.17.0、goods_id `937122477375` 已验收的 v5 派生 XML,不含地址、手机号或支付凭据。
- 派生 manifest 记录 source/derived 本机哈希、sanitizer 版本、设备/App/goods_id/人工状态;原始页面正文、serial、地址和手机号不进入 manifest、日志、错误或上传。 - 受控入口、规格容器、精确选择、选中态和当前价任一歧义均 fail closed;价格能区分 12.88、原价 29.88 与硬拒绝区“提交订单 ¥12.88”。
- 只从人工验收通过的派生证据编写页面判据与最小 fixture;判据绑定拼多多 8.17.0 和证据哈希。 - 静态 API/导入测试证明 `SkuSelectionFlow` 无通用点击、数量、确认页、授权、围栏、提交或支付能力。
- `快要抢光` 精确唯一时才打开面板;其他文案、入口缺失/重复、面板不唯一、规格缺失/碰撞/禁用、价格不唯一均 fail closed。 - T-103 不再开发遮罩。T-204 的原始截图上传必须限制为显式截图文件、设备鉴权、SHA-256 和管理员会话访问,不上传完整 XML 或支付凭据。
- 静态 API/导入测试证明 T-103 第一趟无通用点击、数量、确认页、提交订单或支付能力,“提交订单”及资金控件不可达。 - client 全部单测、compileall、wheel metadata、上下文校验、完整 init 与 diff-check 通过。
- 从规格面板可靠读取选中 SKU 单价为十进制字符串;无法唯一解释优惠价/原价/区间价时返回 unreadable。 - 只有人完成受控入口、精确规格选择、SKU 单价语义、页面对应性、原始截图与安全退出验收后才能 DONE。
- client 全部单测、compileall、wheel metadata、上下文校验、完整 `init.ps1` 与 diff-check 通过。
- `needs_device=true`:只有人完成受控入口、精确选择、单价读取、派生证据对应性/隐私和安全退出验收后才能 DONE。
## 执行记录 ## 执行记录
@@ -131,15 +133,54 @@ T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space
### 2026-08-04T06:05:53Z · ila ### 2026-08-04T06:05:53Z · ila
2026-08-04 T-103 sanitizer v3 坐标修正与主审:人用同一第一态 raw 运行 v2,脱敏器安全拒绝并仅报告 observed 1080x2376,证明 XML 实际坐标与截图相同;原始证据仍有效、未重采、未读取正文。提交 acf7e11 将 sanitizer_version 升为 t103-privacy-v3;截图与 XML 仍分别建模并分别严格校验,但当前均精确绑定 1080x2376,隐私带保持 [0,0,1080,540)。合成测试明确拒绝旧截图 1080x2400、旧 v2 XML 1080x2400,以及较小/较大坐标;手机号残留、哈希/状态/版本、确定性、原子发布和不覆盖门禁保持不变。主 agent 独立复跑 69 项 client 单测、compileall、CLI help、静态禁用能力检索与 diff-check,全部通过;未连接设备、未读取真实 raw。T-103 保持 DOING,等待人用同一第一态 raw 重跑 v3 并只核对 derived。 2026-08-04 T-103 sanitizer v3 坐标修正与主审:人用同一第一态 raw 运行 v2,脱敏器安全拒绝并仅报告 observed 1080x2376,证明 XML 实际坐标与截图相同;原始证据仍有效、未重采、未读取正文。提交 acf7e11 将 sanitizer_version 升为 t103-privacy-v3;截图与 XML 仍分别建模并分别严格校验,但当前均精确绑定 1080x2376,隐私带保持 [0,0,1080,540)。合成测试明确拒绝旧截图 1080x2400、旧 v2 XML 1080x2400,以及较小/较大坐标;手机号残留、哈希/状态/版本、确定性、原子发布和不覆盖门禁保持不变。主 agent 独立复跑 69 项 client 单测、compileall、CLI help、静态禁用能力检索与 diff-check,全部通过;未连接设备、未读取真实 raw。T-103 保持 DOING,等待人用同一第一态 raw 重跑 v3 并只核对 derived。
### 2026-08-04T06:17:05Z · ila
2026-08-04 第一态 derived 人工隐私验收与主审:项目所有者确认截图顶部隐私区完全遮黑、派生 XML 不含地址/手机号、目标颜色和尺码仍为预选;顶部价格因隐私带只显示一部分,最底部价格完整。人工确认后主 agent 才读取 derived,未访问 raw。路径 C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\sku-panel-opened-target- 937122477375-v2\derived;manifest 为 privacy_tier=SANITIZED、sanitizer_version=t103-privacy-v3、state=panel-opened-target-preselected,派生 screenshot/XML SHA-256 分别为 f9e370747cbef4facf6ec4a20d5af72b36c7615e7be7038144b1834632cdbf57 / f2024b7bcc69a03b05f5e95610708c0dbd33001698f40a011ddc895cf0410fb1,复算一致。XML 中“黑色 CHA (纯棉)”与“M(建议100-115)”各有明确 selected=true,汇总文本一致。完整金额候选仅见“提交订单 ¥12.88”;该文本只能在不可点击的派生快照中用于离线价格语义验证,第一趟绝不能获得该控件或其可点击父节点。另观察到 com.android.systemui 的“肉包采购辅助”浮层节点,后续最小 fixture/判据必须限定 PDD package,并建议后两态采集前关闭该浮层。第一态隐私与状态证据通过,但价格语义仍需另两态交叉验证,T-103 保持 DOING。
### 2026-08-04T06:20:03Z · ila
主审补充澄清:上一条所述“提交订单 ¥12.88”只能证明面板中存在该禁触文本,不得作为第一趟 SKU 单价证据,也不得驱动任何控件解析或交互。原因是它属于可点击“提交订单”父区域,违反 T-110 硬拒绝区边界。v3 第一态因此只通过隐私与预选状态验收,价格证据仍不通过。下一步不缩小 [0,0,1080,540) 截图遮罩,而由 v4 脱敏器在顶部交界带仅保留严格白名单、PDD package、非点击叶节点的价格文本到派生 XML;任何混杂文本、点击节点、包外节点或非唯一候选均 fail closed。
### 2026-08-04T06:43:53Z · ila
2026-08-04 客户确认加速方案:手机规格面板显示地址/手机号不再作为真机流程阻塞条件;不再扩大或调整截图黑色遮罩。原始证据仍只留本机,agent/Git/服务端仍只消费派生物。提交 1e69d27 实现 t103-privacy-v4:截图遮罩与 1080x2376 双坐标校验完全不变,只把已取证的两个顶部交界价格槽中,PDD package、TextView、非点击、可见启用、叶节点且严格匹配“唯一快卖光当前价 + 至多一个原价”的七属性安全文本投影到派生 XML;结构/文本/候选数漂移均原子拒绝,底部“提交订单 ¥12.88”仍不是价格候选。主 agent 两轮退回测试职责问题后独立复跑 76 项 client 单测、compileall、CLI help、禁用能力检索与 diff-check,全部通过。下一步保留已验收 v3 derived,用同一 raw 生成 v4 derived;T-103 保持 DOING。
### 2026-08-04T06:55:52Z · ila
2026-08-04 项目所有者用同一第一态 raw 运行 v4,脱敏器按设计 fail closed:跨界价格节点文本不匹配,未发布 derived;raw 与已保留的 derived-v3-reviewed 未受损。提交 ef1ac60 增加最小安全诊断:失败只输出两个固定价格槽位和受控 reason 枚举,不回显原文、金额、数字串、字符码点、长度或原始属性,v4 成功白名单与原子不发布语义不变。主 agent 独立复跑 78 项 client 单测、compileall、CLI help 与 diff-check,全部通过。等待项目所有者用同一 raw 重跑并反馈 slot/reason;T-103 保持 DOING。
### 2026-08-04T07:17:10Z · ila
2026-08-04 项目所有者重跑安全诊断,固定当前价槽 [396,503][712,570] 返回 reason=known_prefix_missing,证明 raw 文本以“快”开头但不是 v4 假设的精确“快卖光”;该原因不足以证明具体前缀,成功白名单未放宽。提交 6e001b2 仅细分一个已人工观察过的安全枚举:若该槽精确以“快要抢光”开头则返回 observed_prefix_kuaiyaoqiangguang,其余“快”开头仍为 generic;不回显后缀、金额、长度、码点或 raw 属性。主 agent 独立复跑 79 项 client 单测、compileall 与 diff-check 通过。等待项目所有者用同一 raw 再次重跑;T-103 保持 DOING。
### 2026-08-04T07:19:42Z · ila
2026-08-04 项目所有者在手机 live 规格面板人工确认:固定顶部当前价槽的完整非敏感文字为“快卖完 ¥12.88”;底部购买按钮“快要抢光”是另一控件。主 agent 未读取 raw。该事实解释了 v4 对未经验证“快卖光”假设的两次安全拒绝。下一步升级 v5:删除“快卖光”成功假设,只允许证据绑定的精确“快卖完”当前价;坐标、遮罩、结构、PDD package、非点击叶节点、唯一性与原价限制全部不变。T-103 保持 DOING。
### 2026-08-04T07:42:53Z · ila
2026-08-04 范围调整:`t103-privacy-v5` 已在真实第一态成功生成,人工确认派生 XML 无实际地址/手机号、目标规格保留;顶部当前价为“快卖完 ¥12.88”、原价为“¥29.88”,底部“提交订单 ¥12.88”继续属于第一趟硬拒绝区。派生截图的整宽遮罩把顶部价格遮住一半。项目所有者要求停止遮罩返工并加速 MVP:T-103 不上传规格页截图,以安全最小 XML fixture 继续选择/读价;正式管理员证据截图归 T-204,上传端仍不得接收地址、手机号或支付凭据。未完成真机选择/读价验收,T-103 保持 Doing。
### 2026-08-04T07:45:57Z · ila
2026-08-04 最终范围澄清:上一条“正式截图仍需脱敏/裁剪”的记录已被项目所有者新决定取代。cmbuyer 是内部系统,正式第一趟允许把包含页面已显示地址/手机号的原始截图上传采购服务,不做遮罩或裁剪;T-204 负责设备鉴权上传、SHA-256 校验与管理员会话访问。完整 XML、普通日志、Git、Vikunja 和支付凭据仍不接收。T-103 立即停止遮罩开发,继续受控规格选择与 SKU 读价,任务保持 Doing。
### 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 --> <!-- END VIKUNJA EXPORT -->
## 边界 ## 边界
- 任何规格面板入口、维度容器、选项、选中态和价格判据都必须来自本项目新采集、自动复检通过的 - 任何规格面板入口、维度容器、选项、选中态和价格判据都必须来自本项目拼多多 8.17.0 真机证据;
拼多多 8.17.0 **脱敏派生** screenshot/XML;T-102 详情页证据只能证明已到目标商品,不能证明 T-102 详情页证据只能证明已到目标商品,不能证明规格面板结构。T-103 只使用已由人确认无实际地址/
规格面板结构。派生证据路径、SHA-256、goods_id、设备、Android、App 与 sanitizer 版本写入 手机号的 v5 派生 XML 提取最小 fixture;派生截图的上传适用性不再是本任务的代码门禁。
执行记录之前,不得提交页面判据代码。 - `SkuSelectionFlow` 只允许 T-110 批准的 `open_sku_panel()` 窄能力点击证据/版本绑定、精确唯一的
- 第一趟只允许 T-110 批准的 `open_trial_sku_panel()` 窄能力点击证据/版本绑定、精确唯一的
`快要抢光`;当前事实仅覆盖 goods_id `937122477375`、拼多多 `8.17.0`。不得把“免拼购买 / `快要抢光`;当前事实仅覆盖 goods_id `937122477375`、拼多多 `8.17.0`。不得把“免拼购买 /
单独购买 / 直接拼成”等其他文案加入包含、前缀、同义或坐标兜底,分别取证前一律拒绝。 单独购买 / 直接拼成”等其他文案加入包含、前缀、同义或坐标兜底,分别取证前一律拒绝。
- 规格选项只能在已确认的面板及对应维度容器内按文本精确唯一匹配;缺失、重复、禁用、维度不明、 - 规格选项只能在已确认的面板及对应维度容器内按文本精确唯一匹配;缺失、重复、禁用、维度不明、
@@ -148,16 +189,17 @@ T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space
- 单价只允许从规格面板证据确认的节点读取,并以十进制字符串表达;不得使用浮点,不得从详情页、 - 单价只允许从规格面板证据确认的节点读取,并以十进制字符串表达;不得使用浮点,不得从详情页、
搜索卡片或其他页面的数字补值。券后价、原价、区间价、货币符号拆分或多个候选的语义无法唯一 搜索卡片或其他页面的数字补值。券后价、原价、区间价、货币符号拆分或多个候选的语义无法唯一
证明时必须返回 unreadable 并转人工。 证明时必须返回 unreadable 并转人工。
- T-103 代码路径不得引用或实现 `set_quantity()`、`go_to_order_confirm()`、`submit_order()`、 - T-103 代码路径不得引用或实现 `set_quantity_and_readback()`、`go_to_order_confirm()`、
`create_submission_fence()`、`submit_order_once()`、
通用 `click`、创建订单、授权、提交围栏或任何支付能力;不得进入订单确认页,不得创建待付款订单。 通用 `click`、创建订单、授权、提交围栏或任何支付能力;不得进入订单确认页,不得创建待付款订单。
规格面板中的“提交订单”、微信支付、先用后付、0 元下单只可作为硬拒绝判据,不能返回可点击对象。 规格面板中的“提交订单”、微信支付、先用后付、0 元下单只可作为硬拒绝判据,不能返回可点击对象。
- 不把 `/mnt/d/chengma/cmroubao`、`/mnt/d/chengma/cmpdd` 或任何旧版本的节点、Activity、选择器、 - 不把 `/mnt/d/chengma/cmroubao`、`/mnt/d/chengma/cmpdd` 或任何旧版本的节点、Activity、选择器、
坐标、文本形态和页面结论当作事实;前序项目只可用于理解为什么必须 fail closed。 坐标、文本形态和页面结论当作事实;前序项目只可用于理解为什么必须 fail closed。
- 原始 screenshot/XML 只保存在 `%LOCALAPPDATA%\cmbuyer\artifacts\T-103\...\raw`,只允许本机 - 原始完整 XML 只保存在 `%LOCALAPPDATA%\cmbuyer\artifacts\T-103\...\raw`,不得由 HTTP sink、
确定性脱敏器消费,不得由 agent、业务、fixture、HTTP sink、Vikunja 或 Git 读取/上传。脱敏器必须 Vikunja 或 Git 读取/上传。真机流程可在内存中读取当前页面树,但只返回规格、选中态、价格和页面
校验设备分辨率、页面/App 版本和预期隐私区域,同时移除 screenshot/XML 中的地址与手机号内容; 状态摘要;不得把地址、手机号或支付凭据解析成业务字段或写入日志。
派生 XML 仍命中手机号模式、结构/版本/分辨率失配或哈希不一致时不得发布 `derived`。 - T-103 不再扩展截图遮罩器;本任务先以本机原始截图完成真机验收。生产单趟原始截图上传由 T-204
- 只有自动复检通过的最小脱敏 fixture、派生路径/哈希、sanitizer 版本与非敏感结论可入库;服务端 接入,允许截图包含页面已显示的地址和手机号,但只允许已认证设备上传、已登录管理员查看;完整 XML、
不接收原始文件、原始路径或原始哈希。 外部支付页和支付凭据不得上传。
- `needs_device: true`:agent 只能完成离线实现并保持 `DOING`;只有人完成安全入口、精确规格选择、 - `needs_device: true`:agent 只能完成离线实现并保持 `DOING`;只有人完成安全入口、精确规格选择、
SKU 单价语义、页面对应性与派生证据验收后才能标 `DONE`。 SKU 单价语义、页面对应性与安全退出验收后才能标 `DONE`。HTTP 截图上传实现不属于 T-103 验收。
+85
View File
@@ -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 或生产代码。
+69
View File
@@ -0,0 +1,69 @@
---
id: T-201
title: 管理员登录与会话
phase: 2
deps: [T-004, T-005]
status: DONE
created: 2026-08-04
vikunja_task_id: 26
context_ref: 80ed9b7
work_branch: task/t-201-admin-session
needs_device: false
needs_human_review: false
write_paths:
- docs/tasks/T-201.md
- admin/cmd/server/main.go
- admin/internal/config/**
- admin/internal/server/**
- admin/internal/auth/**
- admin/internal/transport/webui/**
- admin/go.mod
- admin/go.sum
- admin/README.md
---
<!-- BEGIN VIKUNJA EXPORT id=26 synced=2026-08-04T07:04:10Z sha256=25607da2b9c35781f56fdea1f399bdfb65b76fa9a5ca8210693714793f5ef5f4 -->
## 问题 / 背景
采购服务已有 Go/Gin/SQLite 骨架和核心模型,但没有管理员会话。T-201 与真机规格字段无关,可在 T-103 进行时并行。
## 关联需求与交互
F-013、US-007;路由 GET/POST /login、POST /logout、受保护的 GET /tasks 空壳;沿用已确认采购服务原型。
## 方案
1. 启动时从环境变量读取管理员用户名、bcrypt 密码哈希、至少 32 字节 session secret;缺失或无效时明确失败,不提供默认凭据,不打印秘密。
2. 使用签名会话和 CSRF;登录成功轮换会话,Cookie 为 HttpOnly、SameSite,Secure 由显式配置控制。
3. return_to 仅接受 /tasks 及其子路径,拒绝绝对 URL、双斜杠、反斜杠和其他站内路径。
4. 实现登录、登出与受保护的任务空壳;/healthz 保持公开。
5. 不改数据库 schema,不实现设备 Bearer、建单、试选、授权、提交或付款。
## 验收要点
- 覆盖未登录跳转、成功登录与会话轮换、统一失败错误、CSRF 拒绝、登出失效和开放重定向拦截。
- 无默认账号/密码/密钥,日志与响应不泄露凭据。
- go test ./...、go vet ./...、go build ./...、上下文校验与 diff-check 通过。
## 执行记录
### 2026-08-04T06:58:30Z · ila
2026-08-04 T-201 实现与主审完成:提交 47c0844 新增强制环境配置、bcrypt 校验、HMAC-SHA256 签名进程内 session、随机 CSRF、登录轮换、登出撤销、受保护 /tasks 空壳和无外部资源的 SSR 登录页。主 agent 首轮退回 /tasks/.. 返回路径绕过、含 CSRF 页面缺安全响应头、Cookie 篡改测试概率失效三项;修正后独立复跑 go test ./...、go test -race ./...、go vet ./...、go build ./...、上下文校验与 diff-check,全部通过。实现未包含建单、试选、真机字段、授权、提交或付款;T-201 验收通过。
### 2026-08-04T07:03:54Z · ila
集成门禁补充:T-201 分支已合入 main@88d8f77(含 T-103 最新安全诊断),随后在独立 Windows worktree 运行完整 init.ps1,admin test/vet/build、client 78 项测试/compileall、editable install 与 agent-context 校验全部通过。首次运行因 120 秒工具超时中断在大体积 PySide6 依赖安装,未产生代码或证据问题;复用已建 venv 后完整成功。
<!-- END VIKUNJA EXPORT -->
## 边界
- 启动配置只接受显式环境变量中的管理员用户名、bcrypt 密码哈希和至少 32 字节 session secret;
不得内置开发默认值、明文密码或密钥,不得打印、回显或提交凭据。
- 本任务只实现 `/login`、`/logout`、受保护的 `/tasks` 空壳和保持公开的 `/healthz`;不实现建单、
查询、批量开始试选、设备 Bearer、真机自动化或任务状态流转。
- 不增加或修改数据库 schema,不读写 `spec_trials`、授权或 `order_submissions`,不实现任何提交订单、
付款、免密支付、先用后付或资金控件代码。
- 页面和测试不得夹带机器实际规格、规格面板价格、截图、证据哈希或任何 PDD 页面判据;这些仍等待
T-103 真机结论。
- `return_to` 必须是 `/tasks` 或其子路径;绝对 URL、`//`、反斜杠、编码绕过和其他站内路径均拒绝。
+75
View File
@@ -0,0 +1,75 @@
---
id: T-202
title: 手工建单与 DRAFT 基础列表
phase: 2
deps: [T-201, T-004, T-005]
status: DONE
created: 2026-08-04
vikunja_task_id: 27
context_ref: 1c35155
work_branch: task/t-202-admin-draft
needs_device: false
needs_human_review: false
write_paths:
- docs/tasks/T-202.md
- admin/cmd/server/main.go
- admin/internal/config/**
- admin/internal/server/**
- admin/internal/tasks/**
- admin/internal/storage/sqlite/**
- admin/internal/transport/webui/**
- admin/README.md
---
<!-- BEGIN VIKUNJA EXPORT id=27 synced=2026-08-04T08:32:16Z sha256=39e3b06bab4ca86e97b961a4eb6bb0a4f1e88b29dae4d50f916779f7e761424c -->
## 问题 / 背景
T-201 已提供管理员会话;T-004 已提供 tasks 表。根据 T-010 加速门禁,T-103 尚未完成时只允许实现不启动试选的 DRAFT 手工建单与基础列表。
## 关联需求与交互
F-001、US-001、IX-002;GET /tasks、GET /tasks/new、POST /tasks;沿用已确认的传统表格与创建弹窗/直达页。
## 方案
1. 显式数据库配置并打开已迁移 SQLite;以仓储接口隔离 HTTP 和 SQL,创建事务只写 MANUAL、DRAFT、version=1。
2. 表单校验任务名称、canonical 拼多多链接、颜色分类、尺码、正整数数量和正十进制总额上限;金额只用字符串并规范为两位小数。链接只接受 HTTPS mobile.yangkeduo.com/goods.html 且 goods_id 为唯一纯数字参数,额外查询参数不进入数据库。
3. 以服务端生成的 create_key 同时作为任务 ID;重复相同 key 和相同内容返回原结果,不创建第二条,内容不同则冲突。
4. GET /tasks 默认 created_at DESC 显示 DRAFT 基础表格;创建入口用服务端渲染的 modal 状态,/tasks/new 复用同一表单作为无脚本兜底;失败保留非密码输入并显示字段错误,成功 303 回列表且新任务第一行。
5. 页面只显示需求字段、采购结果占位、DRAFT 状态与创建时间;不读取或伪造规格面板价格/证据,不提供勾选开始试选、状态推进、详情或设备接口。
## 验收要点
- 覆盖创建成功、倒序第一行、严格链接/goods_id、数量、金额、空白/长度、CSRF/未登录、幂等重放与冲突、SQL 错误 fail closed。
- 弹窗与 /tasks/new 共享校验;错误保留输入并可访问;标题只链接到由 goods_id 重建的 canonical PDD URL并使用安全新标签属性。
- go test ./...、go test -race ./...、go vet ./...、go build ./...、完整 init.ps1、上下文校验和 diff-check 通过。
## 执行记录
### 2026-08-04T08:30:47Z · ila
已完成:DRAFT 手工建单与基础列表;已验证链接、金额、CSRF、幂等、SQLite 并发和 SSR 无障碍,Go 与上下文门禁均通过。
<!-- END VIKUNJA EXPORT -->
## 边界
- 本任务只创建 `source=MANUAL`、`status=DRAFT`、`version=1` 的任务并显示 DRAFT 基础列表;不得
实现勾选、批量开始试选、`DRAFT → PENDING` 或任何其他状态流转,也不得新增设备领取接口。
- 不增加或修改数据库 schema,不读写 `spec_trials`、`order_authorizations`、`order_submissions`,
不生成或展示机器实际规格、规格面板单价、截图、证据哈希或 PDD 页面判据。
- 启动服务必须从显式 `CMBUYER_DATABASE_SOURCE` 读取 SQLite data source;缺失时明确失败,不提供
隐式内存库或仓库内默认数据库。服务不自动猜迁移目录;README 必须先给出显式迁移命令。
- 商品链接只接受 `https://mobile.yangkeduo.com/goods.html`,且必须恰有一个纯数字 `goods_id`;
拒绝 userinfo、端口、fragment、重复参数、其他 host/scheme/path 和编码绕过。数据库只保存 goods_id,
展示链接由 goods_id 重建 canonical URL;`uin` 等额外查询参数既不保存也不回显。
- 标题、颜色分类、尺码必须去除首尾空白后非空并受明确长度上限约束;数量必须是可表示的正整数;
总额上限必须是大于零、最多两位小数的十进制字符串并规范为两位小数。金额校验、保存与展示均不得
使用浮点数或从其他数字推测。
- `create_key` 由服务端用 `crypto/rand` 生成并验证格式,同时作为任务 ID;相同 key 与相同规范化内容
重放只能返回原任务,不得二次 INSERT,相同 key 携带不同内容必须冲突。SQL 必须参数化,创建失败
不得留下半条或未知状态记录。
- `GET /tasks`、`GET /tasks/new`、`POST /tasks` 都必须复用 T-201 管理会话;POST 必须验证 CSRF。
校验失败保留非敏感输入并逐字段提示,数据库内部错误只给通用响应,不泄露 SQL、路径或凭据。
- 页面只使用服务端模板转义;标题商品链接在新标签打开时必须带 `noopener noreferrer`。导入按钮只作
禁用占位;不得加载外部资源或把原型假数据、真机数据、地址、手机号带进生产页面。
- 不实现或引用试选、数量设置、订单确认、提交围栏、提交订单、付款、免密支付或先用后付能力。
+66
View File
@@ -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` 授权不得回到可领取、可过期或可放弃状态;围栏后的提交只能记录明确提交或进入同一记录
调和,不能提供重试、释放或第二次点击的状态转移。
- 本任务不实现、不引用点击“提交订单”的函数,更不涉及支付、免密支付、先用后付或任何扣款动作。