Compare commits

...
Author SHA1 Message Date
QiuSW b4ff9096c3 test(client): tighten polling test synchronization 2026-08-05 12:47:34 +08:00
QiuSW ae4b9ef08b test(client): accelerate T-103 feedback loop 2026-08-05 12:47:22 +08:00
QiuSW 1bf3acda70 docs(state): record one-shot sku reveal readiness 2026-08-05 12:37:28 +08:00
QiuSW e8ca738b33 feat(client): capture one-shot sku reveal evidence 2026-08-05 12:34:59 +08:00
QiuSW 429c2d33db docs(safety): define one-shot sku reveal evidence 2026-08-05 11:45:58 +08:00
QiuSW a15b6c0911 docs(safety): bind T-103 live panel states 2026-08-05 11:37:19 +08:00
QiuSW 29d1540f7c docs(tasks): record corrected T-103 entry 2026-08-05 10:57:55 +08:00
QiuSW 2f1380d084 fix(client): target verified bottom sku entry 2026-08-05 10:56:55 +08:00
QiuSW 81a44caeef docs(safety): correct verified sku entry boundary 2026-08-05 10:45:28 +08:00
QiuSW 757eca39fd docs(tasks): record T-103 entry substage 2026-08-05 10:34:12 +08:00
QiuSW b754cc9ca9 fix(client): identify sku entry failure substage 2026-08-05 10:32:35 +08:00
QiuSW e45d26fe93 docs(tasks): record T-103 entry evidence 2026-08-05 10:16:05 +08:00
QiuSW a09c58beef fix(client): stabilize verified sku entry 2026-08-05 10:10:56 +08:00
QiuSW 42eac57824 docs(tasks): record safe T-103 diagnostics 2026-08-05 09:24:06 +08:00
QiuSW c9b39a4e05 test(client): remove polling stop race 2026-08-05 09:14:02 +08:00
QiuSW 870e5f861d fix(client): expose safe T-103 failure stages 2026-08-05 09:07:30 +08:00
QiuSW 025edaf273 docs(tasks): record T-304 code-complete review 2026-08-05 02:03:02 +08:00
QiuSW 584c5601c5 task(T-304): record code-complete verification 2026-08-05 01:59:35 +08:00
QiuSW 3a27225977 fix(client): fail closed on polling configuration drift 2026-08-05 01:59:35 +08:00
QiuSW dfd88c3336 feat(client): add safe polling session UI 2026-08-05 01:59:35 +08:00
QiuSW 20112dbd61 feat(client): expose secret-free profile summary 2026-08-05 01:59:35 +08:00
QiuSW 404d0d3ca4 task(T-304): add metadata and stop-latch contract 2026-08-05 01:59:35 +08:00
QiuSW 85e5f1e56e task(T-304): begin polling session UI 2026-08-05 01:59:35 +08:00
QiuSW 6145fc4468 docs(tasks): complete T-211 claim wire bounds 2026-08-05 01:52:49 +08:00
QiuSW f6cd65208d fix(api): align claim bounds across runtime snapshots 2026-08-05 01:39:35 +08:00
QiuSW 7ea1c5349f fix(api): bound claim wire fields end to end 2026-08-05 01:25:23 +08:00
QiuSW 57a5b2e91c chore(tasks): include T-211 claim handler boundary 2026-08-05 01:18:23 +08:00
QiuSW 63c2c59b41 chore(tasks): start T-211 claim wire bounds 2026-08-05 01:11:21 +08:00
QiuSW cfb238d022 docs(tasks): complete T-303 durable client state 2026-08-05 01:04:35 +08:00
QiuSW 31f07ac245 feat(client): add durable HTTP task state 2026-08-05 00:59:32 +08:00
QiuSW 488005ac93 chore(tasks): start T-303 client HTTP state 2026-08-05 00:59:32 +08:00
QiuSW 4186f1315a docs(tasks): tighten exit and evidence contracts 2026-08-04 23:36:01 +08:00
QiuSW 3a0a41db00 docs(tasks): complete MVP safety task graph 2026-08-04 23:20:28 +08:00
QiuSW 8600c33391 docs(tasks): define admin safety closure chain 2026-08-04 22:44:13 +08:00
QiuSW 8092431208 docs(tasks): order evidence before attempt events 2026-08-04 22:41:59 +08:00
QiuSW cd47d0c959 docs(tasks): complete T-302 atomic claim 2026-08-04 22:34:46 +08:00
QiuSW ec42640123 Merge branch 'main' into task/t-302-atomic-claim 2026-08-04 22:33:25 +08:00
QiuSW 5f060f60ee feat(admin): add atomic task claim leases 2026-08-04 22:33:16 +08:00
QiuSW 19f81a5e58 docs(tasks): define T-305 prefence dry run 2026-08-04 22:14:55 +08:00
QiuSW c1cee49fb1 docs(tasks): define T-210 gate evidence kinds 2026-08-04 22:08:58 +08:00
QiuSW 772e379fd6 docs(tasks): define T-107 confirm gate 2026-08-04 22:05:20 +08:00
QiuSW 83b244ff7c docs(tasks): require T-106 navigation-source evidence 2026-08-04 22:02:43 +08:00
QiuSW d9a31cbfa6 docs(tasks): define T-106 confirm evidence 2026-08-04 22:01:45 +08:00
QiuSW 014405466a docs(tasks): define T-105 quantity gate 2026-08-04 21:59:21 +08:00
QiuSW 8c50e1579e docs(tasks): define T-307 attempt sink 2026-08-04 21:50:15 +08:00
QiuSW 35d7ce11d6 docs(tasks): define T-306 evidence publisher 2026-08-04 21:48:22 +08:00
QiuSW 41e54e313e docs(tasks): expose T-104 gate-one artifact 2026-08-04 21:46:54 +08:00
QiuSW e5de76503f docs(tasks): linearize T-205 ordering version 2026-08-04 21:45:31 +08:00
QiuSW 542b2283f4 docs(tasks): freeze T-303 evidence upload slot 2026-08-04 21:42:15 +08:00
QiuSW eb29bcd8b7 docs(tasks): define T-304 polling UI boundary 2026-08-04 21:36:52 +08:00
QiuSW 526af1eb31 docs(tasks): allow T-302 taskdetail fixture repair 2026-08-04 21:33:22 +08:00
QiuSW d9381a644b docs(tasks): define T-303 UI state boundary 2026-08-04 21:28:08 +08:00
QiuSW e863a29641 docs(tasks): allow T-303 roadmap sync 2026-08-04 21:19:58 +08:00
QiuSW 5c97954fa1 docs(tasks): define T-303 HTTP recovery client 2026-08-04 21:19:02 +08:00
QiuSW bd4c855ad2 docs(tasks): harden T-302 replay ownership 2026-08-04 21:15:59 +08:00
QiuSW ee90d76893 docs(tasks): linearize T-302 revocation checks 2026-08-04 21:11:26 +08:00
QiuSW 4a65138fd7 docs(tasks): define T-205 attempt events 2026-08-04 21:07:43 +08:00
QiuSW 8ec740dcef chore(tasks): start T-302 2026-08-04 21:03:38 +08:00
QiuSW 590c84660a docs(tasks): define T-302 atomic claiming 2026-08-04 21:02:57 +08:00
QiuSW 233188297a docs(tasks): complete T-301 2026-08-04 20:58:36 +08:00
QiuSW e674b7f131 merge: T-301 device credential isolation 2026-08-04 20:57:42 +08:00
QiuSW 66355a7f89 feat(admin): add device credential isolation 2026-08-04 20:57:33 +08:00
QiuSW 41881e81f3 docs(tasks): define T-301 auth failure semantics 2026-08-04 20:06:23 +08:00
QiuSW e3c87fdec5 docs(tasks): secure T-301 transport boundary 2026-08-04 20:05:03 +08:00
QiuSW 871cd24d68 fix(tasks): isolate T-301 write paths 2026-08-04 20:01:18 +08:00
QiuSW 4f8e71b256 chore(tasks): start T-301 2026-08-04 20:01:05 +08:00
QiuSW a6ad560f5d docs(tasks): define T-301 device identity 2026-08-04 20:00:29 +08:00
QiuSW 6a5547b323 docs(tasks): complete T-204 2026-08-04 19:51:58 +08:00
QiuSW 46fccc2120 merge: T-204 routed task evidence details 2026-08-04 19:43:20 +08:00
QiuSW 4829be972c docs(tasks): record T-103 entry audit 2026-08-04 18:47:47 +08:00
QiuSW 3b2fa3536e docs(tasks): define T-104 safe exit evidence 2026-08-04 18:47:05 +08:00
127 changed files with 17534 additions and 624 deletions
+30 -2
View File
@@ -13,6 +13,8 @@
| `CMBUYER_MAX_TASK_QUANTITY` | 每条任务允许的正整数数量上限。 |
| `CMBUYER_MAX_TOTAL_PRICE` | 每条任务允许的规范正数总价上限,例如 `999.99`。 |
| `CMBUYER_EVIDENCE_DIR` | 内部原始截图的绝对私有目录;不得指向仓库或公开静态目录。 |
| `CMBUYER_CLAIM_TOKEN_SECRET` | claim token 专用 32 字节密钥的 64 位小写十六进制;不得复用 session 或设备 token。 |
| `CMBUYER_CLAIM_LEASE_TTL` | 正 Go duration,且必须严格短于 `CMBUYER_AUTHORIZATION_TTL`。 |
示例仅展示变量名,不提供可运行凭据:
@@ -26,15 +28,41 @@ $env:CMBUYER_AUTHORIZATION_TTL = '10m'
$env:CMBUYER_MAX_TASK_QUANTITY = '99'
$env:CMBUYER_MAX_TOTAL_PRICE = '999.99'
$env:CMBUYER_EVIDENCE_DIR = '<内部截图绝对目录>'
$env:CMBUYER_CLAIM_TOKEN_SECRET = '<64 位小写十六进制随机值>'
$env:CMBUYER_CLAIM_LEASE_TTL = '1m'
go run ./cmd/migrate -database $env:CMBUYER_DATABASE_SOURCE up
go run ./cmd/server
```
MVP 服务固定绑定 IPv4 回环 `127.0.0.1:8080`,只供同一运营电脑上的采购服务和采购工具使用。不要把监听地址
改成 `0.0.0.0` 或局域网地址;未来若需要非回环访问,必须先单独建立并验收 HTTPS/TLS 终止与代理
信任边界,设备 Bearer 不得经过明文局域网。
数据库迁移完成后,用同一个显式 SQLite data source 管理设备凭据:
```powershell
# 签发:token 只在本次成功输出中显示一次,请立即放入采购工具的受控本机配置。
go run ./cmd/device-credentials -database $env:CMBUYER_DATABASE_SOURCE issue -name '<非秘密设备名称>'
# 仅显示设备 id、名称、状态和时间,不显示 token/hash。
go run ./cmd/device-credentials -database $env:CMBUYER_DATABASE_SOURCE list
# 撤销立即影响之后开始的每次设备请求;重复执行保持 REVOKED,不恢复旧 token。
go run ./cmd/device-credentials -database $env:CMBUYER_DATABASE_SOURCE revoke -device-id '<签发时的设备UUID>'
```
该 CLI 只接受已存在、已迁移的文件型 SQLite 普通路径或 `file:` URI,并强制以 `mode=rw` 打开;
路径拼错或文件缺失时 SQLite 原子拒绝且不会留下空数据库,也不接受内存、只读或可创建模式。CLI
不自动迁移。签发 token 是 32 字节加密随机值的 64 位小写十六进制表示;
SQLite 只保存原始 token 的 32 字节 SHA-256 BLOB。签发输出以外的 list/revoke、日志、错误和 HTTP
响应都不会显示 token 或 hash。
采购服务会话仅保存在当前进程内;进程重启后既有登录会话会安全失效。
管理员的“开始采购(只创建待付款订单)”只签发一次性授权并创建待付款订单的资格;服务不会自动付款,也不包含任何支付操作。
`GET /tasks/{id}` 直接访问时渲染完整详情页,任务列表以同一 URL 加载详情抽屉。内部截图只通过
`GET /evidence/{asset_id}` 向有效管理员会话提供,并始终返回 `no-store`;文件不在静态目录中。
T-301 接入真实设备凭据之前,`POST /api/v1/tasks/{id}/evidence` 的生产认证器固定拒绝全部请求。
测试可以注入 fake 设备主体验证上传契约,但不得用管理员会话、临时 token 或共享密钥绕过该边界。
`POST /api/v1/tasks/{id}/evidence` 使用 `Authorization: Bearer <token>` 和
`X-CMBuyer-Device-ID: <小写UUIDv4>` 逐请求查库认证;管理员会话不能代替设备身份。凭据错误统一空
401,SQLite 认证故障为空 503,且两者都发生在上传 body 被读取之前。
+200
View File
@@ -0,0 +1,200 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/storage/sqlite"
)
func main() {
if err := run(context.Background(), os.Args[1:], os.Stdout, os.Stderr); err != nil {
log.Print(err)
os.Exit(1)
}
}
func run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("device-credentials", flag.ContinueOnError)
flags.SetOutput(stderr)
databaseSource := flags.String("database", "", "explicit migrated SQLite data source")
if err := flags.Parse(args); err != nil {
return err
}
if *databaseSource == "" {
return errors.New("-database is required")
}
if flags.NArg() < 1 {
return errors.New("usage: device-credentials -database <sqlite-data-source> <issue|list|revoke> [options]")
}
command := flags.Arg(0)
commandArgs := flags.Args()[1:]
var issueName, revokeDeviceID string
switch command {
case "issue":
commandFlags := flag.NewFlagSet("issue", flag.ContinueOnError)
commandFlags.SetOutput(stderr)
commandFlags.StringVar(&issueName, "name", "", "non-secret device display name")
if err := commandFlags.Parse(commandArgs); err != nil {
return err
}
if issueName == "" || commandFlags.NArg() != 0 {
return errors.New("usage: device-credentials -database <sqlite-data-source> issue -name <display-name>")
}
case "list":
if len(commandArgs) != 0 {
return errors.New("usage: device-credentials -database <sqlite-data-source> list")
}
case "revoke":
commandFlags := flag.NewFlagSet("revoke", flag.ContinueOnError)
commandFlags.SetOutput(stderr)
commandFlags.StringVar(&revokeDeviceID, "device-id", "", "canonical device UUID")
if err := commandFlags.Parse(commandArgs); err != nil {
return err
}
if revokeDeviceID == "" || commandFlags.NArg() != 0 {
return errors.New("usage: device-credentials -database <sqlite-data-source> revoke -device-id <uuid>")
}
default:
return fmt.Errorf("unsupported device credential command %q", command)
}
if command == "issue" && !deviceauth.ValidDisplayName(issueName) {
return deviceauth.ErrInvalidCredential
}
if command == "revoke" && !deviceauth.ValidDeviceID(revokeDeviceID) {
return deviceauth.ErrInvalidCredential
}
existingSource, err := existingSQLiteDataSource(*databaseSource)
if err != nil {
return err
}
database, err := sqlite.Open(existingSource)
if err != nil {
return fmt.Errorf("open SQLite database: %w", err)
}
defer database.Close()
store, err := deviceauth.NewCredentialStore(database)
if err != nil {
return err
}
switch command {
case "issue":
issued, err := store.Issue(ctx, issueName)
if err != nil {
return err
}
// The token has json:"-" and is printed only by this explicit post-commit path. Generic
// serialization, list, revoke, errors, and server responses therefore cannot disclose it.
if _, err := fmt.Fprintf(stdout, "device_id=%s\ndisplay_name=%s\ntoken=%s\ncreated_at=%s\n",
issued.DeviceID, issued.DisplayName, issued.Token, issued.CreatedAt.Format(time.RFC3339Nano)); err != nil {
return errors.New("write issued device credential")
}
return nil
case "list":
credentials, err := store.List(ctx)
if err != nil {
return err
}
return writeJSON(stdout, credentials)
case "revoke":
credential, changed, err := store.Revoke(ctx, revokeDeviceID)
if err != nil {
return err
}
return writeJSON(stdout, struct {
Credential deviceauth.Credential `json:"credential"`
RevokedNow bool `json:"revoked_now"`
}{Credential: credential, RevokedNow: changed})
}
return errors.New("unreachable device credential command")
}
func existingSQLiteDataSource(value string) (string, error) {
if value == "" || strings.TrimSpace(value) != value {
return "", errors.New("-database must name an existing file-backed SQLite database")
}
var parsed *url.URL
var query url.Values
if strings.HasPrefix(strings.ToLower(value), "file:") {
var err error
parsed, err = url.Parse(value)
if err != nil || !strings.EqualFold(parsed.Scheme, "file") || parsed.User != nil || parsed.Host != "" || parsed.Fragment != "" {
return "", errors.New("-database file URI is invalid")
}
// go-sqlite3 recognizes URI filenames only with the exact lowercase file: prefix.
// Canonicalize accepted scheme casing before mode=rw reaches the driver, otherwise a
// mixed-case input could be treated as a plain filename and recreate a missing database.
parsed.Scheme = "file"
query, err = url.ParseQuery(parsed.RawQuery)
if err != nil {
return "", errors.New("-database query parameters are invalid")
}
fileName := parsed.Path
if parsed.Opaque != "" {
fileName = parsed.Opaque
}
decodedName, err := url.PathUnescape(fileName)
if err != nil || fileName == "" || strings.EqualFold(decodedName, ":memory:") {
return "", errors.New("-database must name an existing file-backed SQLite database")
}
} else {
pathPart, rawQuery, hasQuery := strings.Cut(value, "?")
if pathPart == "" || strings.EqualFold(pathPart, ":memory:") || strings.Contains(pathPart, "://") {
return "", errors.New("-database must name an existing file-backed SQLite database")
}
var err error
query, err = url.ParseQuery(rawQuery)
if err != nil {
return "", errors.New("-database query parameters are invalid")
}
normalizedPath := filepath.ToSlash(pathPart)
if filepath.VolumeName(pathPart) != "" && !strings.HasPrefix(normalizedPath, "/") {
normalizedPath = "/" + normalizedPath
}
parsed = &url.URL{Scheme: "file", Path: normalizedPath}
if !hasQuery {
query = make(url.Values)
}
}
modes := query["mode"]
if len(modes) > 1 || len(modes) == 1 && modes[0] != "rw" {
return "", errors.New("-database only permits SQLite mode=rw")
}
if len(modes) == 0 {
query.Set("mode", "rw")
}
for _, name := range []string{"immutable", "_query_only"} {
for _, setting := range query[name] {
if setting != "0" && !strings.EqualFold(setting, "false") {
return "", errors.New("-database contains a read-only SQLite option")
}
}
}
parsed.RawQuery = query.Encode()
parsed.ForceQuery = false
return parsed.String(), nil
}
func writeJSON(writer io.Writer, value any) error {
encoder := json.NewEncoder(writer)
encoder.SetEscapeHTML(true)
if err := encoder.Encode(value); err != nil {
return errors.New("write device credential metadata")
}
return nil
}
+293
View File
@@ -0,0 +1,293 @@
package main
import (
"bytes"
"context"
"database/sql"
"errors"
"io"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
)
func TestIssueListAndIdempotentRevokeNeverRediscloseSecret(t *testing.T) {
databaseSource := migratedDatabase(t)
var issued bytes.Buffer
if err := run(context.Background(), []string{"-database", databaseSource, "issue", "-name", "采购工具一号"}, &issued, io.Discard); err != nil {
t.Fatalf("issue: %v", err)
}
fields := outputFields(t, issued.String())
deviceID, token := fields["device_id"], fields["token"]
if len(token) != 64 || strings.Count(issued.String(), token) != 1 {
t.Fatalf("issue token occurrence/length = %d/%d", strings.Count(issued.String(), token), len(token))
}
var listed bytes.Buffer
if err := run(context.Background(), []string{"-database", databaseSource, "list"}, &listed, io.Discard); err != nil {
t.Fatalf("list: %v", err)
}
assertNoSecretMetadata(t, listed.String(), token)
if !strings.Contains(listed.String(), deviceID) || !strings.Contains(listed.String(), "采购工具一号") {
t.Fatalf("list omitted safe metadata: %s", listed.String())
}
var revoked bytes.Buffer
if err := run(context.Background(), []string{"-database", databaseSource, "revoke", "-device-id", deviceID}, &revoked, io.Discard); err != nil {
t.Fatalf("revoke: %v", err)
}
assertNoSecretMetadata(t, revoked.String(), token)
if !strings.Contains(revoked.String(), `"revoked_now":true`) {
t.Fatalf("first revoke output = %s", revoked.String())
}
var repeated bytes.Buffer
if err := run(context.Background(), []string{"-database", databaseSource, "revoke", "-device-id", deviceID}, &repeated, io.Discard); err != nil {
t.Fatalf("repeat revoke: %v", err)
}
assertNoSecretMetadata(t, repeated.String(), token)
if !strings.Contains(repeated.String(), `"revoked_now":false`) {
t.Fatalf("repeat revoke output = %s", repeated.String())
}
}
func TestIssueOutputFailureLeavesCommittedCredentialWithoutSecretInError(t *testing.T) {
databaseSource := migratedDatabase(t)
writer := &recordingFailureWriter{}
err := run(context.Background(), []string{"-database", databaseSource, "issue", "-name", "output failure"}, writer, io.Discard)
if err == nil || err.Error() != "write issued device credential" {
t.Fatalf("issue output failure error = %v", err)
}
fields := outputFields(t, writer.contents.String())
if strings.Contains(err.Error(), fields["token"]) {
t.Fatal("output error disclosed token")
}
database, err := sql.Open("sqlite3", databaseSource)
if err != nil {
t.Fatalf("open database: %v", err)
}
defer database.Close()
var count int
if err := database.QueryRow(`SELECT COUNT(*) FROM device_credentials`).Scan(&count); err != nil || count != 1 {
t.Fatalf("committed credential count = %d, err=%v", count, err)
}
}
func TestCLIRequiresPreMigratedExplicitDatabase(t *testing.T) {
if err := run(context.Background(), []string{"list"}, io.Discard, io.Discard); err == nil {
t.Fatal("command without -database succeeded")
}
missing := filepath.Join(t.TempDir(), "missing.db")
if err := run(context.Background(), []string{"-database", missing, "list"}, io.Discard, io.Discard); err == nil {
t.Fatal("list opened a missing database")
}
if _, err := os.Stat(missing); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing database was created: %v", err)
}
unmigrated := filepath.Join(t.TempDir(), "unmigrated.db")
unmigratedDatabase, err := sqlite.Open(unmigrated)
if err != nil {
t.Fatalf("create unmigrated database: %v", err)
}
if _, err := unmigratedDatabase.Exec(`CREATE TABLE unrelated (id INTEGER)`); err != nil {
_ = unmigratedDatabase.Close()
t.Fatalf("initialize unmigrated database: %v", err)
}
if err := unmigratedDatabase.Close(); err != nil {
t.Fatalf("close unmigrated database: %v", err)
}
if err := run(context.Background(), []string{"-database", unmigrated, "list"}, io.Discard, io.Discard); err == nil {
t.Fatal("list accepted an unmigrated database")
}
database, err := sql.Open("sqlite3", unmigrated)
if err != nil {
t.Fatalf("open unmigrated database: %v", err)
}
defer database.Close()
var count int
if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='device_credentials'`).Scan(&count); err != nil || count != 0 {
t.Fatalf("device_credentials table count = %d, err=%v", count, err)
}
undeclared := filepath.Join(t.TempDir(), "undeclared.db")
if err := run(context.Background(), []string{"-database", undeclared, "rotate"}, io.Discard, io.Discard); err == nil {
t.Fatal("undeclared command succeeded")
}
if _, err := os.Stat(undeclared); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("undeclared command opened database: %v", err)
}
invalidIssue := filepath.Join(t.TempDir(), "invalid-issue.db")
if err := run(context.Background(), []string{"-database", invalidIssue, "issue", "-name", " padded"}, io.Discard, io.Discard); !errors.Is(err, deviceauth.ErrInvalidCredential) {
t.Fatalf("invalid issue error = %v", err)
}
if _, err := os.Stat(invalidIssue); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("invalid issue opened database: %v", err)
}
invalidRevoke := filepath.Join(t.TempDir(), "invalid-revoke.db")
if err := run(context.Background(), []string{"-database", invalidRevoke, "revoke", "-device-id", "not-a-uuid"}, io.Discard, io.Discard); !errors.Is(err, deviceauth.ErrInvalidCredential) {
t.Fatalf("invalid revoke error = %v", err)
}
if _, err := os.Stat(invalidRevoke); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("invalid revoke opened database: %v", err)
}
migrated := migratedDatabase(t)
unknownID := "13c9f507-7473-4fa6-8d71-8786c34c6301"
var unknownOutput bytes.Buffer
err = run(context.Background(), []string{"-database", migrated, "revoke", "-device-id", unknownID}, &unknownOutput, io.Discard)
if !errors.Is(err, deviceauth.ErrCredentialNotFound) || unknownOutput.Len() != 0 || strings.Contains(err.Error(), unknownID) {
t.Fatalf("unknown revoke = output %q, error %v", unknownOutput.String(), err)
}
}
func TestExistingSQLiteDataSourcePreservesSafeOptionsAndRejectsCreationModes(t *testing.T) {
databaseSource := migratedDatabase(t)
fileURI := (&url.URL{
Scheme: "file",
Path: sqliteURIPath(databaseSource),
RawQuery: "_busy_timeout=5000&cache=shared",
}).String()
normalized, err := existingSQLiteDataSource(fileURI)
if err != nil {
t.Fatalf("normalize file URI: %v", err)
}
parsed, err := url.Parse(normalized)
if err != nil {
t.Fatalf("parse normalized URI: %v", err)
}
query := parsed.Query()
if query.Get("mode") != "rw" || query.Get("_busy_timeout") != "5000" || query.Get("cache") != "shared" {
t.Fatalf("normalized query = %v", query)
}
if err := run(context.Background(), []string{"-database", fileURI, "list"}, io.Discard, io.Discard); err != nil {
t.Fatalf("list existing file URI: %v", err)
}
plainNormalized, err := existingSQLiteDataSource(databaseSource + "?_foreign_keys=on")
if err != nil {
t.Fatalf("normalize ordinary path: %v", err)
}
plainURI, err := url.Parse(plainNormalized)
if err != nil || plainURI.Scheme != "file" || plainURI.Query().Get("mode") != "rw" || plainURI.Query().Get("_foreign_keys") != "on" {
t.Fatalf("ordinary path normalization = %q, err=%v", plainNormalized, err)
}
missing := filepath.Join(t.TempDir(), "missing-uri.db")
missingURI := (&url.URL{Scheme: "file", Path: sqliteURIPath(missing)}).String()
if err := run(context.Background(), []string{"-database", missingURI, "list"}, io.Discard, io.Discard); err == nil {
t.Fatal("missing file URI succeeded")
}
if _, err := os.Stat(missing); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing file URI created a file: %v", err)
}
for _, scheme := range []string{"FILE", "File"} {
mixedMissing := filepath.Join(t.TempDir(), strings.ToLower(scheme)+"-missing.db")
canonical := (&url.URL{Scheme: "file", Path: sqliteURIPath(mixedMissing)}).String()
mixedURI := scheme + canonical[len("file"):]
normalized, err := existingSQLiteDataSource(mixedURI)
if err != nil || !strings.HasPrefix(normalized, "file:") {
t.Fatalf("normalize %s URI = %q, err=%v", scheme, normalized, err)
}
if err := run(context.Background(), []string{"-database", mixedURI, "list"}, io.Discard, io.Discard); err == nil {
t.Fatalf("missing %s URI succeeded", scheme)
}
if _, err := os.Stat(mixedMissing); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing %s URI created a file: %v", scheme, err)
}
}
for name, source := range map[string]string{
"plain memory": ":memory:",
"URI memory": "file::memory:?cache=shared",
"memory mode": fileURI + "&mode=memory",
"read only mode": fileURI + "&mode=ro",
"create mode": fileURI + "&mode=rwc",
"duplicate mode": fileURI + "&mode=rw&mode=rw",
"immutable": fileURI + "&immutable=1",
"query only": fileURI + "&_query_only=1",
"remote authority": "file://server/share/database.db?mode=rw",
} {
t.Run(name, func(t *testing.T) {
if _, err := existingSQLiteDataSource(source); err == nil {
t.Fatalf("unsafe source accepted: %q", source)
}
})
}
}
func sqliteURIPath(path string) string {
normalized := filepath.ToSlash(path)
if filepath.VolumeName(path) != "" && !strings.HasPrefix(normalized, "/") {
return "/" + normalized
}
return normalized
}
type recordingFailureWriter struct {
contents bytes.Buffer
}
func (writer *recordingFailureWriter) Write(value []byte) (int, error) {
_, _ = writer.contents.Write(value)
return 0, errors.New("injected stdout failure")
}
func assertNoSecretMetadata(t *testing.T, output, token string) {
t.Helper()
if strings.Contains(output, token) || strings.Contains(output, "token") || strings.Contains(output, "hash") || strings.Contains(output, "sha256") {
t.Fatalf("metadata output disclosed secret material: %s", output)
}
}
func outputFields(t *testing.T, output string) map[string]string {
t.Helper()
fields := make(map[string]string)
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
name, value, found := strings.Cut(line, "=")
if !found || name == "" || value == "" {
t.Fatalf("invalid issue output line %q", line)
}
fields[name] = value
}
for _, required := range []string{"device_id", "display_name", "token", "created_at"} {
if fields[required] == "" {
t.Fatalf("issue output missing %s: %q", required, output)
}
}
return fields
}
func migratedDatabase(t *testing.T) string {
t.Helper()
databaseSource := filepath.Join(t.TempDir(), "credentials.db")
database, err := sqlite.Open(databaseSource)
if err != nil {
t.Fatalf("open database: %v", err)
}
if err := migrations.Up(context.Background(), database, commandMigrationDirectory(t)); err != nil {
_ = database.Close()
t.Fatalf("migrate database: %v", err)
}
if err := database.Close(); err != nil {
t.Fatalf("close migrated database: %v", err)
}
return databaseSource
}
func commandMigrationDirectory(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate migrations")
}
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
}
+15 -3
View File
@@ -7,15 +7,18 @@ import (
"cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/config"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/server"
evidencestorage "cmbuyer/admin/internal/storage/evidence"
"cmbuyer/admin/internal/storage/sqlite"
"cmbuyer/admin/internal/taskclaim"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks"
)
const listenAddress = ":8080"
// Device Bearer credentials must not cross a plaintext LAN. The MVP is a same-computer
// deployment, so widening this address requires a separately reviewed TLS boundary first.
const listenAddress = "127.0.0.1:8080"
func main() {
if err := run(); err != nil {
@@ -46,6 +49,14 @@ func run() error {
if err != nil {
return err
}
deviceAuthenticator, err := deviceauth.NewSQLiteAuthenticator(database)
if err != nil {
return err
}
claimStore, err := taskclaim.NewStore(database, configuration.ClaimTokenSecret, configuration.ClaimLeaseTTL)
if err != nil {
return err
}
router, err := server.NewRouter(server.Options{
AdminUsername: configuration.AdminUsername,
@@ -54,7 +65,8 @@ func run() error {
Tasks: taskStore,
TaskDetails: detailStore,
Evidence: evidenceStore,
DeviceAuthenticator: evidence.RejectAllDeviceAuthenticator{},
DeviceAuthenticator: deviceAuthenticator,
TaskClaims: claimStore,
})
if err != nil {
return err
+9
View File
@@ -0,0 +1,9 @@
package main
import "testing"
func TestListenAddressIsIPv4LoopbackOnly(t *testing.T) {
if listenAddress != "127.0.0.1:8080" {
t.Fatalf("listenAddress = %q, want loopback-only endpoint", listenAddress)
}
}
+29
View File
@@ -2,6 +2,8 @@
package config
import (
"bytes"
"encoding/hex"
"errors"
"fmt"
"os"
@@ -23,6 +25,8 @@ const (
maxTaskQuantityEnv = "CMBUYER_MAX_TASK_QUANTITY"
maxTotalPriceEnv = "CMBUYER_MAX_TOTAL_PRICE"
evidenceDirectoryEnv = "CMBUYER_EVIDENCE_DIR"
claimTokenSecretEnv = "CMBUYER_CLAIM_TOKEN_SECRET"
claimLeaseTTLEnv = "CMBUYER_CLAIM_LEASE_TTL"
minimumSecretLength = 32
)
@@ -37,6 +41,8 @@ type Config struct {
MaxTaskQuantity int
MaxTotalPrice string
EvidenceDirectory string
ClaimTokenSecret []byte
ClaimLeaseTTL time.Duration
}
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
@@ -112,6 +118,27 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
if strings.TrimSpace(evidenceDirectory) != evidenceDirectory || !filepath.IsAbs(evidenceDirectory) {
return Config{}, fmt.Errorf("%s must be an absolute path without surrounding whitespace", evidenceDirectoryEnv)
}
claimSecretText, err := required(lookup, claimTokenSecretEnv)
if err != nil {
return Config{}, err
}
claimSecret, err := hex.DecodeString(claimSecretText)
if err != nil || len(claimSecret) != 32 || hex.EncodeToString(claimSecret) != claimSecretText {
return Config{}, fmt.Errorf("%s must be exactly 64 lowercase hexadecimal characters", claimTokenSecretEnv)
}
// Claim ownership, admin sessions and device authentication are separate security domains.
// Reject both identical configuration text and identical effective key bytes.
if claimSecretText == secret || bytes.Equal(claimSecret, []byte(secret)) {
return Config{}, fmt.Errorf("%s must be isolated from %s", claimTokenSecretEnv, sessionSecretEnv)
}
claimTTLText, err := required(lookup, claimLeaseTTLEnv)
if err != nil {
return Config{}, err
}
claimTTL, err := time.ParseDuration(claimTTLText)
if err != nil || claimTTL <= 0 || claimTTL >= ttl {
return Config{}, fmt.Errorf("%s must be positive and shorter than %s", claimLeaseTTLEnv, authorizationTTLEnv)
}
return Config{
AdminUsername: username,
@@ -121,6 +148,8 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
DatabaseSource: databaseSource,
AuthorizationTTL: ttl, MaxTaskQuantity: maxQuantity, MaxTotalPrice: maxPrice,
EvidenceDirectory: evidenceDirectory,
ClaimTokenSecret: claimSecret,
ClaimLeaseTTL: claimTTL,
}, nil
}
+12 -1
View File
@@ -3,6 +3,7 @@ package config_test
import (
"strings"
"testing"
"time"
"cmbuyer/admin/internal/config"
@@ -25,13 +26,15 @@ func TestLoad(t *testing.T) {
"CMBUYER_MAX_TASK_QUANTITY": "99",
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
"CMBUYER_CLAIM_TOKEN_SECRET": strings.Repeat("ab", 32),
"CMBUYER_CLAIM_LEASE_TTL": "1m",
}
got, err := config.Load(lookup(values))
if err != nil {
t.Fatalf("Load: %v", err)
}
if got.AdminUsername != "admin" || !got.CookieSecure {
if got.AdminUsername != "admin" || !got.CookieSecure || len(got.ClaimTokenSecret) != 32 || got.ClaimLeaseTTL != time.Minute {
t.Fatalf("Load returned unexpected public configuration: %#v", got)
}
}
@@ -51,6 +54,8 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
"CMBUYER_MAX_TASK_QUANTITY": "99",
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
"CMBUYER_CLAIM_TOKEN_SECRET": strings.Repeat("ab", 32),
"CMBUYER_CLAIM_LEASE_TTL": "1m",
}
tests := []struct {
@@ -68,6 +73,12 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
{"invalid maximum total price", func(values map[string]string) { values["CMBUYER_MAX_TOTAL_PRICE"] = "1" }, "CMBUYER_MAX_TOTAL_PRICE"},
{"missing evidence directory", func(values map[string]string) { delete(values, "CMBUYER_EVIDENCE_DIR") }, "CMBUYER_EVIDENCE_DIR"},
{"relative evidence directory", func(values map[string]string) { values["CMBUYER_EVIDENCE_DIR"] = "evidence" }, "CMBUYER_EVIDENCE_DIR"},
{"invalid claim secret", func(values map[string]string) { values["CMBUYER_CLAIM_TOKEN_SECRET"] = strings.Repeat("A", 64) }, "CMBUYER_CLAIM_TOKEN_SECRET"},
{"claim secret same raw session secret", func(values map[string]string) {
values["CMBUYER_SESSION_SECRET"] = values["CMBUYER_CLAIM_TOKEN_SECRET"]
}, "CMBUYER_CLAIM_TOKEN_SECRET"},
{"claim secret same decoded session secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = strings.Repeat("\xab", 32) }, "CMBUYER_CLAIM_TOKEN_SECRET"},
{"invalid claim lease ttl", func(values map[string]string) { values["CMBUYER_CLAIM_LEASE_TTL"] = "10m" }, "CMBUYER_CLAIM_LEASE_TTL"},
}
for _, test := range tests {
+212
View File
@@ -0,0 +1,212 @@
package deviceauth
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io"
"strings"
"sync"
"time"
"unicode"
)
const (
StatusActive = "ACTIVE"
StatusRevoked = "REVOKED"
)
var (
ErrInvalidCredential = errors.New("invalid device credential input")
ErrCredentialNotFound = errors.New("device credential not found")
)
type Credential struct {
DeviceID string `json:"device_id"`
DisplayName string `json:"display_name"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
// IssuedCredential is the only value that can carry the plaintext token. It is returned only
// after SQLite has committed the hash and is intended for the management CLI's one stdout write.
type IssuedCredential struct {
Credential
Token string `json:"-"`
}
type CredentialStore struct {
database *sql.DB
now func() time.Time
random io.Reader
randomMu sync.Mutex
}
func NewCredentialStore(database *sql.DB) (*CredentialStore, error) {
if database == nil {
return nil, errors.New("device credential database is required")
}
if _, err := database.Exec("SELECT device_id FROM device_credentials LIMIT 1"); err != nil {
return nil, errors.New("device credential migration is not available")
}
return &CredentialStore{database: database, now: time.Now, random: rand.Reader}, nil
}
func (store *CredentialStore) Issue(ctx context.Context, displayName string) (IssuedCredential, error) {
if !ValidDisplayName(displayName) {
return IssuedCredential{}, ErrInvalidCredential
}
randomBytes := make([]byte, 16+32)
store.randomMu.Lock()
_, randomErr := io.ReadFull(store.random, randomBytes)
store.randomMu.Unlock()
if randomErr != nil {
return IssuedCredential{}, fmt.Errorf("generate device credential: %w", randomErr)
}
deviceID := formatUUIDv4(randomBytes[:16])
token := hex.EncodeToString(randomBytes[16:])
tokenHash := sha256.Sum256(randomBytes[16:])
createdAt := store.now().UTC()
if createdAt.IsZero() {
return IssuedCredential{}, errors.New("device credential clock is invalid")
}
_, err := store.database.ExecContext(ctx, `INSERT INTO device_credentials
(device_id, display_name, token_sha256, status, created_at, revoked_at)
VALUES (?, ?, ?, ?, ?, NULL)`,
deviceID, displayName, tokenHash[:], StatusActive, createdAt.Format(time.RFC3339Nano))
if err != nil {
return IssuedCredential{}, fmt.Errorf("persist device credential: %w", err)
}
return IssuedCredential{Credential: Credential{
DeviceID: deviceID, DisplayName: displayName, Status: StatusActive, CreatedAt: createdAt,
}, Token: token}, nil
}
func (store *CredentialStore) List(ctx context.Context) ([]Credential, error) {
rows, err := store.database.QueryContext(ctx, `SELECT device_id, display_name, status, created_at, revoked_at
FROM device_credentials ORDER BY created_at, device_id`)
if err != nil {
return nil, fmt.Errorf("list device credentials: %w", err)
}
defer rows.Close()
credentials := make([]Credential, 0)
for rows.Next() {
credential, err := scanCredential(rows)
if err != nil {
return nil, err
}
credentials = append(credentials, credential)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list device credentials: %w", err)
}
return credentials, nil
}
func (store *CredentialStore) Revoke(ctx context.Context, deviceID string) (Credential, bool, error) {
if !ValidDeviceID(deviceID) {
return Credential{}, false, ErrInvalidCredential
}
revokedAt := store.now().UTC()
if revokedAt.IsZero() {
return Credential{}, false, errors.New("device credential clock is invalid")
}
transaction, err := store.database.BeginTx(ctx, nil)
if err != nil {
return Credential{}, false, fmt.Errorf("begin device credential revocation: %w", err)
}
defer transaction.Rollback()
result, err := transaction.ExecContext(ctx, `UPDATE device_credentials
SET status = ?, revoked_at = ? WHERE device_id = ? AND status = ?`,
StatusRevoked, revokedAt.Format(time.RFC3339Nano), deviceID, StatusActive)
if err != nil {
return Credential{}, false, fmt.Errorf("revoke device credential: %w", err)
}
changedRows, err := result.RowsAffected()
if err != nil {
return Credential{}, false, fmt.Errorf("inspect device credential revocation: %w", err)
}
credential, err := scanCredential(transaction.QueryRowContext(ctx, `SELECT device_id, display_name, status, created_at, revoked_at
FROM device_credentials WHERE device_id = ?`, deviceID))
if errors.Is(err, sql.ErrNoRows) {
return Credential{}, false, ErrCredentialNotFound
}
if err != nil {
return Credential{}, false, err
}
if err := transaction.Commit(); err != nil {
return Credential{}, false, fmt.Errorf("commit device credential revocation: %w", err)
}
return credential, changedRows == 1, nil
}
type rowScanner interface {
Scan(...any) error
}
func scanCredential(row rowScanner) (Credential, error) {
var credential Credential
var created string
var revoked sql.NullString
if err := row.Scan(&credential.DeviceID, &credential.DisplayName, &credential.Status, &created, &revoked); err != nil {
return Credential{}, err
}
if !ValidDeviceID(credential.DeviceID) || !ValidDisplayName(credential.DisplayName) || (credential.Status != StatusActive && credential.Status != StatusRevoked) {
return Credential{}, errors.New("stored device credential metadata is invalid")
}
createdAt, err := parseStoredTime(created)
if err != nil {
return Credential{}, err
}
credential.CreatedAt = createdAt
if revoked.Valid {
revokedAt, err := parseStoredTime(revoked.String)
if err != nil {
return Credential{}, err
}
if revokedAt.Before(createdAt) {
return Credential{}, errors.New("stored device credential status is invalid")
}
credential.RevokedAt = &revokedAt
}
if (credential.Status == StatusActive) != (credential.RevokedAt == nil) {
return Credential{}, errors.New("stored device credential status is invalid")
}
return credential, nil
}
func ValidDisplayName(value string) bool {
if value == "" || len([]rune(value)) > 128 || strings.TrimSpace(value) != value {
return false
}
for _, character := range value {
if unicode.IsControl(character) {
return false
}
}
return true
}
func parseStoredTime(value string) (time.Time, error) {
if strings.TrimSpace(value) != value || !strings.HasSuffix(value, "Z") {
return time.Time{}, errors.New("stored device credential time is invalid")
}
parsed, err := time.Parse(time.RFC3339Nano, value)
if err != nil || parsed.Location() != time.UTC {
return time.Time{}, errors.New("stored device credential time is invalid")
}
return parsed, nil
}
func formatUUIDv4(bytes []byte) string {
copyBytes := append([]byte(nil), bytes...)
copyBytes[6] = (copyBytes[6] & 0x0f) | 0x40
copyBytes[8] = (copyBytes[8] & 0x3f) | 0x80
encoded := hex.EncodeToString(copyBytes)
return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:]
}
+168
View File
@@ -0,0 +1,168 @@
// Package deviceauth owns the machine identity boundary shared by all device routes.
package deviceauth
import (
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"errors"
"net/http"
"strings"
)
const (
AuthorizationHeader = "Authorization"
DeviceIDHeader = "X-CMBuyer-Device-ID"
tokenHexLength = 64
)
var (
// ErrUnauthenticated deliberately covers every credential defect. Callers must not reveal
// whether a device exists, is revoked, or supplied a mismatched token.
ErrUnauthenticated = errors.New("device authentication failed")
// ErrUnavailable is distinct so a storage outage is not disguised as a bad credential.
// HTTP callers still return no diagnostic body because database details are server-only.
ErrUnavailable = errors.New("device authentication unavailable")
)
type Principal struct {
ID string
}
type Authenticator interface {
Authenticate(*http.Request) (Principal, error)
}
// RejectAllAuthenticator is useful for tests and for fail-closed wiring where no credential
// store is available. Production startup uses SQLiteAuthenticator.
type RejectAllAuthenticator struct{}
func (RejectAllAuthenticator) Authenticate(*http.Request) (Principal, error) {
return Principal{}, ErrUnauthenticated
}
type SQLiteAuthenticator struct {
database *sql.DB
}
func NewSQLiteAuthenticator(database *sql.DB) (*SQLiteAuthenticator, error) {
if database == nil {
return nil, errors.New("device credential database is required")
}
if _, err := database.Exec("SELECT device_id FROM device_credentials LIMIT 1"); err != nil {
return nil, errors.New("device credential migration is not available")
}
return &SQLiteAuthenticator{database: database}, nil
}
func (authenticator *SQLiteAuthenticator) Authenticate(request *http.Request) (Principal, error) {
if request == nil {
return Principal{}, ErrUnauthenticated
}
deviceID, token, ok := requestCredentials(request)
if !ok {
return Principal{}, ErrUnauthenticated
}
candidateHash := sha256.Sum256(token)
var storedHash []byte
var hashType string
var hashLength sql.NullInt64
var status sql.NullString
var revokedAt sql.NullString
var found bool
err := authenticator.database.QueryRowContext(
request.Context(),
`SELECT CASE WHEN credentials.device_id IS NULL THEN zeroblob(32) ELSE credentials.token_sha256 END,
typeof(credentials.token_sha256),
length(credentials.token_sha256),
credentials.status,
credentials.revoked_at,
credentials.device_id IS NOT NULL
FROM (SELECT 1) AS singleton
LEFT JOIN device_credentials AS credentials ON credentials.device_id = ?`,
deviceID,
).Scan(&storedHash, &hashType, &hashLength, &status, &revokedAt, &found)
if err != nil {
return Principal{}, ErrUnavailable
}
if len(storedHash) != sha256.Size {
return Principal{}, ErrUnavailable
}
matched := subtle.ConstantTimeCompare(candidateHash[:], storedHash) == 1
if !found {
// The LEFT JOIN supplies a 32-byte dummy hash, so unknown ids take the same compare path
// as known credentials without requiring a plaintext token lookup.
return Principal{}, ErrUnauthenticated
}
if hashType != "blob" || !hashLength.Valid || hashLength.Int64 != sha256.Size || len(storedHash) != sha256.Size || !status.Valid {
return Principal{}, ErrUnavailable
}
switch status.String {
case StatusActive:
if revokedAt.Valid {
return Principal{}, ErrUnavailable
}
case StatusRevoked:
if !revokedAt.Valid {
return Principal{}, ErrUnavailable
}
if _, err := parseStoredTime(revokedAt.String); err != nil {
return Principal{}, ErrUnavailable
}
default:
return Principal{}, ErrUnavailable
}
if !matched || status.String == StatusRevoked {
return Principal{}, ErrUnauthenticated
}
return Principal{ID: deviceID}, nil
}
func requestCredentials(request *http.Request) (string, []byte, bool) {
authorizations := request.Header.Values(AuthorizationHeader)
deviceIDs := request.Header.Values(DeviceIDHeader)
if len(authorizations) != 1 || len(deviceIDs) != 1 {
return "", nil, false
}
authorization := authorizations[0]
if len(authorization) != len("Bearer ")+tokenHexLength || !strings.EqualFold(authorization[:len("Bearer")], "Bearer") || authorization[len("Bearer")] != ' ' {
return "", nil, false
}
tokenHex := authorization[len("Bearer "):]
if !validLowerHex(tokenHex, tokenHexLength) || !ValidDeviceID(deviceIDs[0]) {
return "", nil, false
}
token, err := hex.DecodeString(tokenHex)
if err != nil {
return "", nil, false
}
return deviceIDs[0], token, true
}
func ValidDeviceID(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 validLowerHex(value string, length int) bool {
if len(value) != length {
return false
}
decoded, err := hex.DecodeString(value)
return err == nil && hex.EncodeToString(decoded) == value
}
@@ -0,0 +1,407 @@
package deviceauth
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
)
func TestIssueStoresOnlyRawTokenHashAndGenericJSONOmitsSecret(t *testing.T) {
database, store := newCredentialStore(t)
first, err := store.Issue(context.Background(), "采购工具一号")
if err != nil {
t.Fatalf("Issue first: %v", err)
}
second, err := store.Issue(context.Background(), "采购工具二号")
if err != nil {
t.Fatalf("Issue second: %v", err)
}
if first.DeviceID == second.DeviceID || first.Token == second.Token || !ValidDeviceID(first.DeviceID) || !validLowerHex(first.Token, tokenHexLength) {
t.Fatalf("issued identifiers are not independent canonical values")
}
rawToken, err := hex.DecodeString(first.Token)
if err != nil {
t.Fatalf("decode issued token: %v", err)
}
wantHash := sha256.Sum256(rawToken)
var storedHash []byte
var storageType string
if err := database.QueryRow(`SELECT token_sha256, typeof(token_sha256) FROM device_credentials WHERE device_id = ?`, first.DeviceID).Scan(&storedHash, &storageType); err != nil {
t.Fatalf("read stored hash: %v", err)
}
if storageType != "blob" || len(storedHash) != sha256.Size || !equalBytes(storedHash, wantHash[:]) {
t.Fatalf("stored hash type/length/value = %q/%d/%t", storageType, len(storedHash), equalBytes(storedHash, wantHash[:]))
}
var leakedCopies int
if err := database.QueryRow(`SELECT COUNT(*) FROM device_credentials WHERE CAST(token_sha256 AS TEXT) IN (?, ?)`, first.Token, hex.EncodeToString(wantHash[:])).Scan(&leakedCopies); err != nil {
t.Fatalf("search token copies: %v", err)
}
if leakedCopies != 0 {
t.Fatal("database stored a plaintext or hex-encoded token/hash copy")
}
encoded, err := json.Marshal(first)
if err != nil {
t.Fatalf("marshal issued credential: %v", err)
}
if strings.Contains(string(encoded), first.Token) || strings.Contains(string(encoded), "token") {
t.Fatalf("generic serialization disclosed token field: %s", encoded)
}
}
func TestAuthenticateStrictHeaderMatrixAndBinding(t *testing.T) {
_, store := newCredentialStore(t)
first, err := store.Issue(context.Background(), "one")
if err != nil {
t.Fatalf("issue first: %v", err)
}
second, err := store.Issue(context.Background(), "two")
if err != nil {
t.Fatalf("issue second: %v", err)
}
authenticator := authenticatorForStore(t, store)
for _, scheme := range []string{"Bearer", "bearer", "BEARER"} {
request := credentialRequest(first.DeviceID, scheme+" "+first.Token)
principal, err := authenticator.Authenticate(request)
if err != nil || principal.ID != first.DeviceID {
t.Fatalf("scheme %q Authenticate = (%q, %v)", scheme, principal.ID, err)
}
}
unknownID := newRuntimeUUID(t)
wrongToken := newRuntimeToken(t)
tests := []struct {
name string
mutate func(*http.Request)
}{
{"missing authorization", func(request *http.Request) { request.Header.Del(AuthorizationHeader) }},
{"missing device", func(request *http.Request) { request.Header.Del(DeviceIDHeader) }},
{"empty authorization", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "") }},
{"empty device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, "") }},
{"duplicate authorization", func(request *http.Request) { request.Header.Add(AuthorizationHeader, "Bearer "+first.Token) }},
{"duplicate device", func(request *http.Request) { request.Header.Add(DeviceIDHeader, first.DeviceID) }},
{"combined authorization", func(request *http.Request) {
request.Header.Set(AuthorizationHeader, "Bearer "+first.Token+", Bearer "+first.Token)
}},
{"combined device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, first.DeviceID+", "+first.DeviceID) }},
{"extra separator", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token) }},
{"tab separator", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer\t"+first.Token) }},
{"uppercase token", func(request *http.Request) {
request.Header.Set(AuthorizationHeader, "Bearer "+strings.ToUpper(first.Token))
}},
{"short token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token[:62]) }},
{"long token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token+"00") }},
{"non hex token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token[:63]+"g") }},
{"token separator", func(request *http.Request) {
request.Header.Set(AuthorizationHeader, "Bearer "+first.Token[:32]+"-"+first.Token[33:])
}},
{"uppercase device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, strings.ToUpper(first.DeviceID)) }},
{"padded device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, " "+first.DeviceID) }},
{"wrong uuid version", func(request *http.Request) {
request.Header.Set(DeviceIDHeader, first.DeviceID[:14]+"3"+first.DeviceID[15:])
}},
{"wrong uuid variant", func(request *http.Request) {
request.Header.Set(DeviceIDHeader, first.DeviceID[:19]+"7"+first.DeviceID[20:])
}},
{"unknown device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, unknownID) }},
{"wrong token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+wrongToken) }},
{"token device mismatch", func(request *http.Request) { request.Header.Set(DeviceIDHeader, second.DeviceID) }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
request := credentialRequest(first.DeviceID, "Bearer "+first.Token)
test.mutate(request)
principal, err := authenticator.Authenticate(request)
if !errors.Is(err, ErrUnauthenticated) || principal != (Principal{}) {
t.Fatalf("Authenticate = (%#v, %v), want empty unauthenticated", principal, err)
}
})
}
if principal, err := authenticator.Authenticate(nil); !errors.Is(err, ErrUnauthenticated) || principal != (Principal{}) {
t.Fatalf("Authenticate(nil) = (%#v, %v)", principal, err)
}
}
func TestRevokeIsImmediateAndIdempotent(t *testing.T) {
_, store := newCredentialStore(t)
issued, err := store.Issue(context.Background(), "device")
if err != nil {
t.Fatalf("Issue: %v", err)
}
authenticator := authenticatorForStore(t, store)
request := credentialRequest(issued.DeviceID, "Bearer "+issued.Token)
if _, err := authenticator.Authenticate(request); err != nil {
t.Fatalf("Authenticate before revoke: %v", err)
}
first, changed, err := store.Revoke(context.Background(), issued.DeviceID)
if err != nil || !changed || first.Status != StatusRevoked || first.RevokedAt == nil {
t.Fatalf("first Revoke = (%#v, %t, %v)", first, changed, err)
}
if principal, err := authenticator.Authenticate(request); !errors.Is(err, ErrUnauthenticated) || principal != (Principal{}) {
t.Fatalf("Authenticate after committed revoke = (%#v, %v)", principal, err)
}
second, changed, err := store.Revoke(context.Background(), issued.DeviceID)
if err != nil || changed || second.RevokedAt == nil || !second.RevokedAt.Equal(*first.RevokedAt) {
t.Fatalf("second Revoke = (%#v, %t, %v)", second, changed, err)
}
listed, err := store.List(context.Background())
if err != nil || len(listed) != 1 || listed[0].Status != StatusRevoked {
t.Fatalf("List = (%#v, %v)", listed, err)
}
}
func TestConcurrentAuthenticationAndRevocation(t *testing.T) {
_, store := newCredentialStore(t)
issued, err := store.Issue(context.Background(), "concurrent")
if err != nil {
t.Fatalf("Issue: %v", err)
}
authenticator := authenticatorForStore(t, store)
request := func() *http.Request { return credentialRequest(issued.DeviceID, "Bearer "+issued.Token) }
start := make(chan struct{})
results := make(chan error, 16)
var wait sync.WaitGroup
for index := 0; index < 16; index++ {
wait.Add(1)
go func() {
defer wait.Done()
<-start
_, err := authenticator.Authenticate(request())
results <- err
}()
}
close(start)
if _, _, err := store.Revoke(context.Background(), issued.DeviceID); err != nil {
t.Fatalf("Revoke: %v", err)
}
wait.Wait()
close(results)
for err := range results {
if err != nil && !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("concurrent Authenticate error = %v", err)
}
}
for index := 0; index < 16; index++ {
if _, err := authenticator.Authenticate(request()); !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("post-commit Authenticate %d error = %v", index, err)
}
}
}
func TestAuthenticationDatabaseFaultAndCorruptionAreUnavailable(t *testing.T) {
database, store := newCredentialStore(t)
issued, err := store.Issue(context.Background(), "device")
if err != nil {
t.Fatalf("Issue: %v", err)
}
authenticator := authenticatorForStore(t, store)
if err := database.Close(); err != nil {
t.Fatalf("close database: %v", err)
}
if _, err := authenticator.Authenticate(credentialRequest(issued.DeviceID, "Bearer "+issued.Token)); !errors.Is(err, ErrUnavailable) {
t.Fatalf("closed database Authenticate error = %v", err)
}
corruptDB, err := sqlite.Open(filepath.Join(t.TempDir(), "corrupt.db"))
if err != nil {
t.Fatalf("open corrupt database: %v", err)
}
t.Cleanup(func() { _ = corruptDB.Close() })
if _, err := corruptDB.Exec(`CREATE TABLE device_credentials (device_id TEXT PRIMARY KEY, token_sha256 BLOB, status TEXT, revoked_at TEXT)`); err != nil {
t.Fatalf("create corrupt table: %v", err)
}
corruptAuthenticator, err := NewSQLiteAuthenticator(corruptDB)
if err != nil {
t.Fatalf("new corrupt authenticator: %v", err)
}
tests := []struct {
name string
hashValue func([sha256.Size]byte) any
status string
revokedAt any
}{
{name: "null hash", hashValue: func([sha256.Size]byte) any { return nil }, status: StatusActive},
{name: "matching text hash", hashValue: func(hash [sha256.Size]byte) any { return string(hash[:]) }, status: StatusActive},
{name: "unknown status", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: "BROKEN"},
{name: "active with revoked time", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: StatusActive, revokedAt: "2026-08-04T00:00:00Z"},
{name: "revoked without time", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: StatusRevoked},
{name: "revoked with invalid time", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: StatusRevoked, revokedAt: "not-a-time"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
rawToken, token := newRuntimeTokenPair(t)
hash := sha256.Sum256(rawToken)
deviceID := newRuntimeUUID(t)
if _, err := corruptDB.Exec(`INSERT INTO device_credentials VALUES (?, ?, ?, ?)`, deviceID, test.hashValue(hash), test.status, test.revokedAt); err != nil {
t.Fatalf("insert corrupt row: %v", err)
}
principal, err := corruptAuthenticator.Authenticate(credentialRequest(deviceID, "Bearer "+token))
if !errors.Is(err, ErrUnavailable) || principal != (Principal{}) {
t.Fatalf("corrupt Authenticate = (%#v, %v), want unavailable", principal, err)
}
})
}
}
func TestCredentialAuthenticationSurvivesDatabaseReopen(t *testing.T) {
databaseSource := filepath.Join(t.TempDir(), "reopen.db")
database, err := sqlite.Open(databaseSource)
if err != nil {
t.Fatalf("open database: %v", err)
}
if err := migrations.Up(context.Background(), database, deviceMigrationDirectory(t)); err != nil {
_ = database.Close()
t.Fatalf("migrate database: %v", err)
}
store, err := NewCredentialStore(database)
if err != nil {
_ = database.Close()
t.Fatalf("new store: %v", err)
}
issued, err := store.Issue(context.Background(), "reopen")
if err != nil {
_ = database.Close()
t.Fatalf("issue: %v", err)
}
if err := database.Close(); err != nil {
t.Fatalf("close database: %v", err)
}
reopened, err := sqlite.Open(databaseSource)
if err != nil {
t.Fatalf("reopen database: %v", err)
}
defer reopened.Close()
authenticator, err := NewSQLiteAuthenticator(reopened)
if err != nil {
t.Fatalf("new reopened authenticator: %v", err)
}
principal, err := authenticator.Authenticate(credentialRequest(issued.DeviceID, "Bearer "+issued.Token))
if err != nil || principal.ID != issued.DeviceID {
t.Fatalf("Authenticate after reopen = (%#v, %v)", principal, err)
}
}
func TestCredentialInputAndMigrationAreRequired(t *testing.T) {
database, err := sqlite.Open(filepath.Join(t.TempDir(), "unmigrated.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
if _, err := NewCredentialStore(database); err == nil {
t.Fatal("NewCredentialStore accepted an unmigrated database")
}
if _, err := NewSQLiteAuthenticator(database); err == nil {
t.Fatal("NewSQLiteAuthenticator accepted an unmigrated database")
}
_, store := newCredentialStore(t)
for _, name := range []string{"", " leading", "trailing ", "line\nbreak", strings.Repeat("名", 129)} {
if _, err := store.Issue(context.Background(), name); !errors.Is(err, ErrInvalidCredential) {
t.Fatalf("Issue(%q) error = %v", name, err)
}
}
if _, _, err := store.Revoke(context.Background(), "not-a-uuid"); !errors.Is(err, ErrInvalidCredential) {
t.Fatalf("Revoke invalid id error = %v", err)
}
if _, _, err := store.Revoke(context.Background(), newRuntimeUUID(t)); !errors.Is(err, ErrCredentialNotFound) {
t.Fatalf("Revoke unknown id error = %v", err)
}
}
func newCredentialStore(t *testing.T) (*sql.DB, *CredentialStore) {
t.Helper()
databaseSource := filepath.Join(t.TempDir(), "device-auth.db") + "?_busy_timeout=5000&_journal_mode=WAL"
database, err := sqlite.Open(databaseSource)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
if err := migrations.Up(context.Background(), database, deviceMigrationDirectory(t)); err != nil {
t.Fatalf("migrate database: %v", err)
}
store, err := NewCredentialStore(database)
if err != nil {
t.Fatalf("NewCredentialStore: %v", err)
}
store.now = func() time.Time { return time.Date(2026, 8, 4, 12, 0, 0, 123, time.UTC) }
return database, store
}
func authenticatorForStore(t *testing.T, store *CredentialStore) *SQLiteAuthenticator {
t.Helper()
authenticator, err := NewSQLiteAuthenticator(store.database)
if err != nil {
t.Fatalf("NewSQLiteAuthenticator: %v", err)
}
return authenticator
}
func credentialRequest(deviceID, authorization string) *http.Request {
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/id/evidence", nil)
request.Header.Set(DeviceIDHeader, deviceID)
request.Header.Set(AuthorizationHeader, authorization)
return request
}
func newRuntimeToken(t *testing.T) string {
t.Helper()
_, token := newRuntimeTokenPair(t)
return token
}
func newRuntimeTokenPair(t *testing.T) ([]byte, string) {
t.Helper()
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
t.Fatalf("generate runtime token: %v", err)
}
return raw, hex.EncodeToString(raw)
}
func newRuntimeUUID(t *testing.T) string {
t.Helper()
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
t.Fatalf("generate runtime UUID: %v", err)
}
return formatUUIDv4(raw)
}
func equalBytes(left, right []byte) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
func deviceMigrationDirectory(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate migrations")
}
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
}
+3 -19
View File
@@ -5,8 +5,9 @@ import (
"context"
"errors"
"io"
"net/http"
"time"
"cmbuyer/admin/internal/deviceauth"
)
const (
@@ -25,23 +26,6 @@ var (
ErrTooLarge = errors.New("evidence file too large")
)
// DevicePrincipal is the already-authenticated device identity used only for audit and idempotency.
type DevicePrincipal struct {
ID string
}
// DeviceAuthenticator deliberately has no token implementation in T-204. T-301 will supply one.
type DeviceAuthenticator interface {
Authenticate(*http.Request) (DevicePrincipal, bool)
}
// RejectAllDeviceAuthenticator keeps the production upload route fail closed until T-301 wires credentials.
type RejectAllDeviceAuthenticator struct{}
func (RejectAllDeviceAuthenticator) Authenticate(*http.Request) (DevicePrincipal, bool) {
return DevicePrincipal{}, false
}
type UploadMetadata struct {
UploadKey string
TaskID string
@@ -83,6 +67,6 @@ type Asset struct {
type Store interface {
Stage(io.Reader, string) (StagedFile, error)
Discard(StagedFile)
Commit(context.Context, DevicePrincipal, UploadMetadata, StagedFile) (Asset, bool, error)
Commit(context.Context, deviceauth.Principal, UploadMetadata, StagedFile) (Asset, bool, error)
Open(context.Context, string) (Asset, io.ReadSeekCloser, error)
}
+232 -7
View File
@@ -26,19 +26,35 @@ func TestUpDownAndIdempotence(t *testing.T) {
if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("apply migrations: %v", err)
}
assertVersion(t, database, 3)
assertVersion(t, database, 5)
assertTableExists(t, database, "tasks", true)
assertTableExists(t, database, "spec_trials", false)
assertTableExists(t, database, "order_authorizations", true)
assertTableExists(t, database, "purchase_attempts", true)
assertTableExists(t, database, "order_submissions", true)
assertTableExists(t, database, "evidence_assets", true)
assertTableExists(t, database, "device_credentials", true)
assertTableExists(t, database, "purchase_attempt_claims", true)
assertTableExists(t, database, "single_pass_upgrade_guard", false)
if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("reapply migrations: %v", err)
}
assertVersion(t, database, 5)
if err := migrations.Down(context, database, directory); err != nil {
t.Fatalf("roll back task claim migration: %v", err)
}
assertVersion(t, database, 4)
assertTableExists(t, database, "purchase_attempt_claims", false)
assertTableExists(t, database, "device_credentials", true)
if err := migrations.Down(context, database, directory); err != nil {
t.Fatalf("roll back device credential migration: %v", err)
}
assertVersion(t, database, 3)
assertTableExists(t, database, "device_credentials", false)
assertTableExists(t, database, "evidence_assets", true)
if err := migrations.Down(context, database, directory); err != nil {
t.Fatalf("roll back evidence migration: %v", err)
@@ -57,7 +73,7 @@ func TestUpDownAndIdempotence(t *testing.T) {
if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("reapply v2 after rollback: %v", err)
}
assertVersion(t, database, 3)
assertVersion(t, database, 5)
}
func TestUpgradePreservesManualDraftLosslessly(t *testing.T) {
@@ -76,7 +92,7 @@ func TestUpgradePreservesManualDraftLosslessly(t *testing.T) {
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("upgrade v1 draft: %v", err)
}
assertVersion(t, database, 3)
assertVersion(t, database, 5)
var got struct {
id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string
quantity, version int
@@ -227,9 +243,7 @@ func TestV2SchemaConstraintsAndRelationships(t *testing.T) {
func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
database := openTestDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("apply migrations: %v", err)
}
migrateToV3(t, database)
insertV2Task(t, database, "task-one", "MANUAL", "DRAFT")
insertV2Authorization(t, database, "auth-one", "task-one", 1, "start-one")
insertV2Attempt(t, database, "attempt-one", "task-one", "auth-one", 1)
@@ -274,6 +288,199 @@ func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
}
}
func TestDeviceCredentialSchemaConstraintsAndDowngradeGuard(t *testing.T) {
database := openTestDatabase(t)
migrateToV4(t, database)
deviceID := "13c9f507-7473-4fa6-8d71-8786c34c6301"
hash := make([]byte, 32)
for index := range hash {
hash[index] = byte(index + 1)
}
insert := `INSERT INTO device_credentials (device_id, display_name, token_sha256, status, created_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?)`
valid := []any{deviceID, "采购工具一号", hash, "ACTIVE", migrationTime, nil}
if _, err := database.Exec(insert, valid...); err != nil {
t.Fatalf("insert valid credential: %v", err)
}
for name, mutate := range map[string]func([]any){
"uppercase uuid": func(values []any) { values[0], values[2] = strings.ToUpper(deviceID), append([]byte(nil), hash...) },
"wrong uuid version": func(values []any) {
values[0], values[2] = "23c9f507-7473-3fa6-8d71-8786c34c6301", append([]byte(nil), hash...)
},
"blank display name": func(values []any) {
values[0], values[1], values[2] = "33c9f507-7473-4fa6-8d71-8786c34c6301", "", append([]byte(nil), hash...)
},
"padded display name": func(values []any) {
values[0], values[1], values[2] = "43c9f507-7473-4fa6-8d71-8786c34c6301", " padded", append([]byte(nil), hash...)
},
"text hash": func(values []any) {
values[0], values[2] = "53c9f507-7473-4fa6-8d71-8786c34c6301", strings.Repeat("a", 32)
},
"short blob hash": func(values []any) { values[0], values[2] = "63c9f507-7473-4fa6-8d71-8786c34c6301", make([]byte, 31) },
"unknown status": func(values []any) {
values[0], values[2], values[3] = "73c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "UNKNOWN"
},
"active with revoke time": func(values []any) {
values[0], values[2], values[5] = "83c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), migrationTime
},
"revoked without time": func(values []any) {
values[0], values[2], values[3] = "93c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "REVOKED"
},
"revoke before creation": func(values []any) {
values[0], values[2], values[3], values[4], values[5] = "b3c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "REVOKED", "2026-08-04T01:00:00Z", "2026-08-04T00:00:00Z"
},
"non UTC created time": func(values []any) {
values[0], values[2], values[4] = "a3c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "2026-08-04T08:00:00+08:00"
},
} {
t.Run(name, func(t *testing.T) {
values := append([]any(nil), valid...)
mutate(values)
if bytesValue, ok := values[2].([]byte); ok && len(bytesValue) == 32 {
bytesValue[0]++
}
if _, err := database.Exec(insert, values...); err == nil {
t.Fatal("invalid device credential row succeeded")
}
})
}
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("credential-bearing schema downgraded successfully")
}
assertVersion(t, database, 4)
assertTableExists(t, database, "device_credentials", true)
var count int
if err := database.QueryRow(`SELECT COUNT(*) FROM device_credentials`).Scan(&count); err != nil || count != 1 {
t.Fatalf("credentials after rejected downgrade = %d, err=%v", count, err)
}
}
func TestTaskClaimMigrationGuardsOwnershipConstraintsAndDowngradeFacts(t *testing.T) {
t.Run("upgrade rejects unmappable execution facts atomically", func(t *testing.T) {
database := openTestDatabase(t)
migrateToV4(t, database)
insertV2Task(t, database, "legacy-task", "MANUAL", "DRAFT")
insertV2Authorization(t, database, "legacy-auth", "legacy-task", 1, "legacy-start")
insertV2Attempt(t, database, "legacy-attempt", "legacy-task", "legacy-auth", 1)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("v5 upgrade accepted an attempt without device/session ownership")
}
assertVersion(t, database, 4)
assertTableExists(t, database, "purchase_attempt_claims", false)
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM purchase_attempts").Scan(&count); err != nil || count != 1 {
t.Fatalf("legacy attempt after rejected upgrade = %d, err %v", count, err)
}
})
t.Run("schema binds authorization device session generation and token", func(t *testing.T) {
database := openTestDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("apply migrations: %v", err)
}
deviceA := "13c9f507-7473-4fa6-8d71-8786c34c6301"
deviceB := "23c9f507-7473-4fa6-8d71-8786c34c6301"
sessionA := "33c9f507-7473-4fa6-8d71-8786c34c6301"
sessionB := "43c9f507-7473-4fa6-8d71-8786c34c6301"
taskA := "53c9f507-7473-4fa6-8d71-8786c34c6301"
authA := "63c9f507-7473-4fa6-8d71-8786c34c6301"
attemptA := "73c9f507-7473-4fa6-8d71-8786c34c6301"
taskB := "83c9f507-7473-4fa6-8d71-8786c34c6301"
authB := "93c9f507-7473-4fa6-8d71-8786c34c6301"
attemptB := "a3c9f507-7473-4fa6-8d71-8786c34c6301"
tokenA := make([]byte, 32)
for index := range tokenA {
tokenA[index] = byte(index + 1)
}
for index, device := range []string{deviceA, deviceB} {
hash := make([]byte, 32)
hash[0] = byte(index + 100)
if _, err := database.Exec(`INSERT INTO device_credentials
(device_id,display_name,token_sha256,status,created_at,revoked_at)
VALUES (?, ?, ?, 'ACTIVE', ?, NULL)`, device, "device "+strconv.Itoa(index), hash, migrationTime); err != nil {
t.Fatalf("insert device: %v", err)
}
}
insertV2Task(t, database, taskA, "MANUAL", "DRAFT")
insertV2Authorization(t, database, authA, taskA, 1, "start-a")
insertV2Attempt(t, database, attemptA, taskA, authA, 1)
insertClaim := `INSERT INTO purchase_attempt_claims
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
VALUES (?, ?, ?, ?, ?, ?, 2, 'task', 1, 'goods', 'white', 'XL', 1, '1.00',
'2026-08-04T01:00:00Z', ?, ?, '2026-08-04T00:05:00Z', ?, NULL)`
if _, err := database.Exec(insertClaim, attemptA, taskA, authA, deviceA, sessionA, 1, make([]byte, 32), tokenA, migrationTime); err != nil {
t.Fatalf("insert valid claim: %v", err)
}
if _, err := database.Exec(`INSERT INTO purchase_attempts
(id,task_id,authorization_id,claim_generation,status,started_at)
VALUES ('b3c9f507-7473-4fa6-8d71-8786c34c6301', ?, ?, 2, 'CLAIMED', ?)`, taskA, authA, migrationTime); err == nil {
t.Fatal("second attempt for one authorization succeeded")
}
insertV2Task(t, database, taskB, "MANUAL", "DRAFT")
insertV2Authorization(t, database, authB, taskB, 1, "start-b")
insertV2Attempt(t, database, attemptB, taskB, authB, 1)
if _, err := database.Exec(insertClaim, attemptB, taskB, authB, deviceB, sessionB, 2, make([]byte, 32), make([]byte, 32), migrationTime); err == nil {
t.Fatal("claim with generation different from its attempt succeeded")
}
if _, err := database.Exec(insertClaim, attemptB, taskB, authB, deviceA, sessionB, 1, make([]byte, 32), make([]byte, 32), migrationTime); err == nil {
t.Fatal("second open claim for one device succeeded")
}
claimRequest := `INSERT INTO task_claim_requests
(claim_request_id,device_id,session_id,outcome,attempt_id,response_lease_expires_at,error_code,created_at)
VALUES (?, ?, ?, 'CLAIMED', ?, '2026-08-04T00:05:00Z', NULL, ?)`
if _, err := database.Exec(claimRequest, "c3c9f507-7473-4fa6-8d71-8786c34c6301", deviceA, sessionB, attemptA, migrationTime); err == nil {
t.Fatal("claim request with another session succeeded")
}
if _, err := database.Exec(claimRequest, "d3c9f507-7473-4fa6-8d71-8786c34c6301", deviceA, sessionA, attemptA, migrationTime); err != nil {
t.Fatalf("insert bound claim request: %v", err)
}
renewal := `INSERT INTO purchase_attempt_lease_renewals
(renew_request_id,task_id,attempt_id,device_id,session_id,claim_generation,
claim_token_sha256,expected_lease_expires_at,lease_expires_at,created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, '2026-08-04T00:05:00Z', '2026-08-04T00:06:00Z', ?)`
if _, err := database.Exec(renewal, "e3c9f507-7473-4fa6-8d71-8786c34c6301", taskA, attemptA, deviceA, sessionA, 2, tokenA, migrationTime); err == nil {
t.Fatal("renewal with another generation succeeded")
}
wrongHash := append([]byte(nil), tokenA...)
wrongHash[0] ^= 0xff
if _, err := database.Exec(renewal, "f3c9f507-7473-4fa6-8d71-8786c34c6301", taskA, attemptA, deviceA, sessionA, 1, wrongHash, migrationTime); err == nil {
t.Fatal("renewal with another token hash succeeded")
}
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("claim-bearing schema downgraded successfully")
}
assertVersion(t, database, 5)
assertTableExists(t, database, "purchase_attempt_claims", true)
})
t.Run("empty request alone blocks downgrade", func(t *testing.T) {
database := openTestDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatal(err)
}
device := "13c9f507-7473-4fa6-8d71-8786c34c6301"
if _, err := database.Exec(`INSERT INTO device_credentials
(device_id,display_name,token_sha256,status,created_at,revoked_at)
VALUES (?, 'device', ?, 'ACTIVE', ?, NULL)`, device, make([]byte, 32), migrationTime); err != nil {
t.Fatal(err)
}
if _, err := database.Exec(`INSERT INTO task_claim_requests
(claim_request_id,device_id,session_id,outcome,attempt_id,response_lease_expires_at,error_code,created_at)
VALUES ('23c9f507-7473-4fa6-8d71-8786c34c6301', ?,
'33c9f507-7473-4fa6-8d71-8786c34c6301', 'EMPTY', NULL, NULL, NULL, ?)`, device, migrationTime); err != nil {
t.Fatal(err)
}
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("EMPTY request was silently dropped by downgrade")
}
assertVersion(t, database, 5)
})
}
func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) {
tests := []struct {
name string
@@ -336,6 +543,24 @@ func migrateToV2(t *testing.T, database *sql.DB) {
assertVersion(t, database, 2)
}
func migrateToV3(t *testing.T, database *sql.DB) {
t.Helper()
migrateToV2(t, database)
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
t.Fatalf("apply v3: %v", err)
}
assertVersion(t, database, 3)
}
func migrateToV4(t *testing.T, database *sql.DB) {
t.Helper()
migrateToV3(t, database)
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
t.Fatalf("apply v4: %v", err)
}
assertVersion(t, database, 4)
}
func insertV1Task(t *testing.T, database *sql.DB, id, source, status, price string) {
t.Helper()
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, ?, ?, ?, ?)`, id, source, price, status, migrationTime, migrationTime); err != nil {
@@ -456,7 +681,7 @@ func assertColumnType(t *testing.T, database *sql.DB, table, column, want string
}
func TestMigrationsDoNotDisableForeignKeys(t *testing.T) {
for _, name := range []string{"00002_single_pass_model.sql", "00003_evidence_assets.sql"} {
for _, name := range []string{"00002_single_pass_model.sql", "00003_evidence_assets.sql", "00004_device_credentials.sql"} {
contents, err := os.ReadFile(filepath.Join(migrationDirectory(t), name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
+14 -2
View File
@@ -11,6 +11,7 @@ import (
"time"
"unicode/utf8"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/evidence"
"github.com/gin-gonic/gin"
@@ -29,11 +30,22 @@ func uploadEvidence(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
// Authentication deliberately precedes content-type parsing and every body read. A rejected
// device must not make the service spool or inspect a potentially sensitive upload.
principal, authenticated := options.DeviceAuthenticator.Authenticate(context.Request)
if !authenticated {
principal, err := options.DeviceAuthenticator.Authenticate(context.Request)
if errors.Is(err, deviceauth.ErrUnauthenticated) {
context.Header("WWW-Authenticate", "Bearer")
context.Status(http.StatusUnauthorized)
return
}
if err != nil {
context.Status(http.StatusServiceUnavailable)
return
}
if !deviceauth.ValidDeviceID(principal.ID) {
// A custom authenticator is still an untrusted boundary. Do not defer principal
// validation until Commit because multipart bytes would already have been read.
context.Status(http.StatusServiceUnavailable)
return
}
boundary, ok := multipartBoundary(context.GetHeader("Content-Type"))
if !ok {
+204 -16
View File
@@ -19,6 +19,7 @@ import (
"strings"
"testing"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/migrations"
evidencestorage "cmbuyer/admin/internal/storage/evidence"
@@ -30,6 +31,7 @@ const (
evidenceAuthID = "73c9f507-7473-4fa6-8d71-8786c34c6301"
evidenceAttemptID = "83c9f507-7473-4fa6-8d71-8786c34c6301"
evidenceUploadKey = "93c9f507-7473-4fa6-8d71-8786c34c6301"
evidenceDeviceID = "13c9f507-7473-4fa6-8d71-8786c34c6301"
)
func TestEvidenceUploadAuthenticatesBeforeReadingBody(t *testing.T) {
@@ -43,12 +45,60 @@ func TestEvidenceUploadAuthenticatesBeforeReadingBody(t *testing.T) {
router.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized || poison.reads != 0 || authenticator.calls != 1 {
if response.Code != http.StatusUnauthorized || response.Body.Len() != 0 || response.Header().Get("WWW-Authenticate") != "Bearer" || poison.reads != 0 || authenticator.calls != 1 {
t.Fatalf("status/reads/auth calls = %d/%d/%d, want 401/0/1", response.Code, poison.reads, authenticator.calls)
}
assertSecurityHeaders(t, response)
}
func TestEvidenceUploadAuthenticationStorageFailureBeforeReadingBody(t *testing.T) {
database, err := sqlite.Open(filepath.Join(t.TempDir(), "authentication-failure.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
if err := migrations.Up(context.Background(), database, testMigrationDirectory(t)); err != nil {
t.Fatalf("migrate database: %v", err)
}
authenticator, err := deviceauth.NewSQLiteAuthenticator(database)
if err != nil {
t.Fatalf("new authenticator: %v", err)
}
credentialStore, err := deviceauth.NewCredentialStore(database)
if err != nil {
t.Fatalf("new credential store: %v", err)
}
issued, err := credentialStore.Issue(context.Background(), "test device")
if err != nil {
t.Fatalf("issue credential: %v", err)
}
if err := database.Close(); err != nil {
t.Fatalf("close database: %v", err)
}
router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator)
poison := &poisonBody{}
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil)
request.Body = poison
request.Header.Set(deviceauth.AuthorizationHeader, "Bearer "+issued.Token)
request.Header.Set(deviceauth.DeviceIDHeader, issued.DeviceID)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable || response.Body.Len() != 0 || poison.reads != 0 {
t.Fatalf("storage failure status/body/reads = %d/%q/%d, want 503/empty/0", response.Code, response.Body.String(), poison.reads)
}
}
func TestEvidenceUploadRejectsInvalidSuccessfulPrincipalBeforeReadingBody(t *testing.T) {
router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, uncheckedDeviceAuthenticator{})
poison := &poisonBody{}
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil)
request.Body = poison
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable || response.Body.Len() != 0 || poison.reads != 0 {
t.Fatalf("invalid principal status/body/reads = %d/%q/%d, want 503/empty/0", response.Code, response.Body.String(), poison.reads)
}
}
func TestAdminSessionCannotActAsDeviceUploader(t *testing.T) {
router, _ := newRouter(t)
cookie := authenticate(t, router)
@@ -62,8 +112,97 @@ func TestAdminSessionCannotActAsDeviceUploader(t *testing.T) {
}
}
func TestRealDeviceCredentialIdentityIsolationAndMixedCredentials(t *testing.T) {
database, err := sqlite.Open(filepath.Join(t.TempDir(), "identity-isolation.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
if err := migrations.Up(context.Background(), database, testMigrationDirectory(t)); err != nil {
t.Fatalf("migrate database: %v", err)
}
insertEvidenceAttempt(t, database)
assetStore, err := evidencestorage.NewStore(database, filepath.Join(t.TempDir(), "assets"))
if err != nil {
t.Fatalf("new evidence store: %v", err)
}
credentialStore, err := deviceauth.NewCredentialStore(database)
if err != nil {
t.Fatalf("new credential store: %v", err)
}
issued, err := credentialStore.Issue(context.Background(), "采购工具一号")
if err != nil {
t.Fatalf("issue credential: %v", err)
}
insertEvidenceClaim(t, database, issued.DeviceID)
authenticator, err := deviceauth.NewSQLiteAuthenticator(database)
if err != nil {
t.Fatalf("new authenticator: %v", err)
}
taskStore := &memoryStore{}
router, _ := newRouterWithDependencies(t, taskStore, emptyDetailStore{}, assetStore, authenticator)
addDeviceHeaders := func(request *http.Request) {
request.Header.Set(deviceauth.AuthorizationHeader, "Bearer "+issued.Token)
request.Header.Set(deviceauth.DeviceIDHeader, issued.DeviceID)
}
start := newStartRequest(t, validStartBody(), "application/json", "", nil)
addDeviceHeaders(start)
startResponse := httptest.NewRecorder()
router.ServeHTTP(startResponse, start)
create := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader("title=device"))
create.Header.Set("Content-Type", "application/x-www-form-urlencoded")
addDeviceHeaders(create)
createResponse := httptest.NewRecorder()
router.ServeHTTP(createResponse, create)
if startResponse.Code != http.StatusUnauthorized || createResponse.Code != http.StatusUnauthorized || taskStore.startCalls != 0 || len(taskStore.drafts) != 0 {
t.Fatalf("device management isolation = start %d/create %d/calls %d/drafts %d", startResponse.Code, createResponse.Code, taskStore.startCalls, len(taskStore.drafts))
}
adminCookie, csrf := authenticatedStartSession(t, router)
mixedWithoutCSRF := newStartRequest(t, validStartBody(), "application/json", "", adminCookie)
addDeviceHeaders(mixedWithoutCSRF)
mixedWithoutCSRFResponse := httptest.NewRecorder()
router.ServeHTTP(mixedWithoutCSRFResponse, mixedWithoutCSRF)
if mixedWithoutCSRFResponse.Code != http.StatusForbidden || taskStore.startCalls != 0 {
t.Fatalf("mixed request bypassed admin CSRF: status/calls=%d/%d", mixedWithoutCSRFResponse.Code, taskStore.startCalls)
}
mixedAdmin := newStartRequest(t, validStartBody(), "application/json", csrf, adminCookie)
addDeviceHeaders(mixedAdmin)
mixedAdminResponse := httptest.NewRecorder()
router.ServeHTTP(mixedAdminResponse, mixedAdmin)
if mixedAdminResponse.Code != http.StatusBadRequest || taskStore.startCalls != 1 {
t.Fatalf("mixed admin request changed identity domain: status/calls=%d/%d", mixedAdminResponse.Code, taskStore.startCalls)
}
pngBytes := serverTestPNG(t, 3, 2)
upload := newEvidenceUploadRequest(t, evidenceTaskID, validEvidenceFields(pngBytes), pngBytes, evidence.PNGContentType, "raw.png", nil)
addDeviceHeaders(upload)
upload.AddCookie(adminCookie)
uploadResponse := httptest.NewRecorder()
router.ServeHTTP(uploadResponse, upload)
if uploadResponse.Code != http.StatusCreated {
t.Fatalf("mixed upload status/body = %d/%q", uploadResponse.Code, uploadResponse.Body.String())
}
var uploadedBy string
if err := database.QueryRow(`SELECT uploaded_by_device_id FROM evidence_assets`).Scan(&uploadedBy); err != nil || uploadedBy != issued.DeviceID {
t.Fatalf("uploaded principal = %q, err=%v", uploadedBy, err)
}
if _, _, err := credentialStore.Revoke(context.Background(), issued.DeviceID); err != nil {
t.Fatalf("revoke credential: %v", err)
}
revokedUpload := newEvidenceUploadRequest(t, evidenceTaskID, validEvidenceFields(pngBytes), pngBytes, evidence.PNGContentType, "raw.png", nil)
addDeviceHeaders(revokedUpload)
revokedResponse := httptest.NewRecorder()
router.ServeHTTP(revokedResponse, revokedUpload)
if revokedResponse.Code != http.StatusUnauthorized || revokedResponse.Body.Len() != 0 {
t.Fatalf("revoked upload = %d/%q", revokedResponse.Code, revokedResponse.Body.String())
}
}
func TestEvidenceUploadReplayConflictAndProtectedRead(t *testing.T) {
router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{allowed: true, principal: evidence.DevicePrincipal{ID: "device-one"}})
router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: evidenceDeviceID}})
pngBytes := serverTestPNG(t, 6, 4)
fields := validEvidenceFields(pngBytes)
@@ -118,7 +257,7 @@ func TestEvidenceUploadReplayConflictAndProtectedRead(t *testing.T) {
}
func TestEvidenceUploadRejectsStrictMultipartViolations(t *testing.T) {
router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{allowed: true, principal: evidence.DevicePrincipal{ID: "device-one"}})
router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: evidenceDeviceID}})
pngBytes := serverTestPNG(t, 2, 2)
base := validEvidenceFields(pngBytes)
wrongHash := copyStringMap(base)
@@ -174,39 +313,49 @@ func TestEvidenceUploadRejectsStrictMultipartViolations(t *testing.T) {
}
type fakeDeviceAuthenticator struct {
allowed bool
principal evidence.DevicePrincipal
principal deviceauth.Principal
err error
calls int
}
func (authenticator *fakeDeviceAuthenticator) Authenticate(*http.Request) (evidence.DevicePrincipal, bool) {
func (authenticator *fakeDeviceAuthenticator) Authenticate(*http.Request) (deviceauth.Principal, error) {
authenticator.calls++
return authenticator.principal, authenticator.allowed
if authenticator.err != nil {
return deviceauth.Principal{}, authenticator.err
}
if authenticator.principal.ID == "" {
return deviceauth.Principal{}, deviceauth.ErrUnauthenticated
}
return authenticator.principal, nil
}
type poisonBody struct{ reads int }
type uncheckedDeviceAuthenticator struct{}
func (uncheckedDeviceAuthenticator) Authenticate(*http.Request) (deviceauth.Principal, error) {
return deviceauth.Principal{}, nil
}
func (body *poisonBody) Read([]byte) (int, error) {
body.reads++
return 0, io.ErrUnexpectedEOF
}
func (*poisonBody) Close() error { return nil }
func newEvidenceRouter(t *testing.T, authenticator evidence.DeviceAuthenticator) (http.Handler, *sql.DB) {
func newEvidenceRouter(t *testing.T, authenticator deviceauth.Authenticator) (http.Handler, *sql.DB) {
t.Helper()
database, err := sqlite.Open(filepath.Join(t.TempDir(), "server-evidence.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate migration directory")
}
if err := migrations.Up(context.Background(), database, filepath.Join(filepath.Dir(file), "..", "..", "migrations")); err != nil {
if err := migrations.Up(context.Background(), database, testMigrationDirectory(t)); err != nil {
t.Fatalf("migrate database: %v", err)
}
insertEvidenceAttempt(t, database)
insertEvidenceClaimDevice(t, database, evidenceDeviceID)
insertEvidenceClaim(t, database, evidenceDeviceID)
store, err := evidencestorage.NewStore(database, filepath.Join(t.TempDir(), "assets"))
if err != nil {
t.Fatalf("new evidence store: %v", err)
@@ -215,6 +364,15 @@ func newEvidenceRouter(t *testing.T, authenticator evidence.DeviceAuthenticator)
return router, database
}
func testMigrationDirectory(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate migration directory")
}
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
}
func insertEvidenceAttempt(t *testing.T, database *sql.DB) {
t.Helper()
timestamp := "2026-08-04T00:00:00Z"
@@ -229,7 +387,39 @@ func insertEvidenceAttempt(t *testing.T, database *sql.DB) {
}
}
func insertEvidenceClaimDevice(t *testing.T, database *sql.DB, deviceID string) {
t.Helper()
digest := sha256.Sum256([]byte("fake evidence device"))
if _, err := database.Exec(`INSERT INTO device_credentials
(device_id,display_name,token_sha256,status,created_at,revoked_at)
VALUES (?, 'fake evidence device', ?, 'ACTIVE', '2026-08-04T00:00:00Z', NULL)`, deviceID, digest[:]); err != nil {
t.Fatalf("insert evidence device: %v", err)
}
}
func insertEvidenceClaim(t *testing.T, database *sql.DB, deviceID string) {
t.Helper()
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
VALUES (?, ?, ?, ?, '23c9f507-7473-4fa6-8d71-8786c34c6301', 1, 1, 'task',
1, '123', 'black', 'M', 1, '1.00', '2026-08-04T00:00:00Z', ?, ?,
'2026-08-04T02:00:00Z', '2026-08-04T00:00:00Z', NULL)`, evidenceAttemptID,
evidenceTaskID, evidenceAuthID, deviceID, bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32)); err != nil {
t.Fatalf("insert evidence claim: %v", err)
}
}
func serveEvidenceUpload(t *testing.T, router http.Handler, taskID string, fields map[string]string, file []byte, fileContentType, filename string, extra func(*multipart.Writer) error) *httptest.ResponseRecorder {
t.Helper()
request := newEvidenceUploadRequest(t, taskID, fields, file, fileContentType, filename, extra)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func newEvidenceUploadRequest(t *testing.T, taskID string, fields map[string]string, file []byte, fileContentType, filename string, extra func(*multipart.Writer) error) *http.Request {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
@@ -258,9 +448,7 @@ func serveEvidenceUpload(t *testing.T, router http.Handler, taskID string, field
}
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+taskID+"/evidence", bytes.NewReader(body.Bytes()))
request.Header.Set("Content-Type", writer.FormDataContentType())
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
return request
}
func validEvidenceFields(pngBytes []byte) map[string]string {
+11 -2
View File
@@ -14,7 +14,9 @@ import (
"unicode/utf8"
"cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/taskclaim"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks"
"cmbuyer/admin/internal/transport/webui"
@@ -34,12 +36,13 @@ type Options struct {
Tasks tasks.Store
TaskDetails taskdetail.Store
Evidence evidence.Store
DeviceAuthenticator evidence.DeviceAuthenticator
DeviceAuthenticator deviceauth.Authenticator
TaskClaims taskclaim.Service
}
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
func NewRouter(options Options) (*gin.Engine, error) {
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil || options.TaskDetails == nil || options.Evidence == nil || options.DeviceAuthenticator == nil {
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil || options.TaskDetails == nil || options.Evidence == nil || options.DeviceAuthenticator == nil || options.TaskClaims == nil {
return nil, errors.New("server authentication options are incomplete")
}
@@ -56,6 +59,8 @@ func NewRouter(options Options) (*gin.Engine, error) {
router.POST("/tasks", createTask(options))
router.POST("/tasks/start-purchases", startPurchases(options))
router.POST("/api/v1/tasks/:id/evidence", uploadEvidence(options))
router.POST("/api/v1/tasks/claim-next", claimNext(options))
router.POST("/api/v1/tasks/:id/lease/renew", renewLease(options))
router.GET("/evidence/:asset_id", readEvidence(options))
router.GET("/static/tasks.js", func(context *gin.Context) {
context.Data(http.StatusOK, "application/javascript; charset=utf-8", webui.TasksScript())
@@ -266,6 +271,10 @@ func newTaskPage(options Options) gin.HandlerFunc {
}
func createTask(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
if !options.Sessions.IsAuthenticated(context.Request) {
context.Status(http.StatusUnauthorized)
return
}
if !parseForm(context) {
return
}
+24 -5
View File
@@ -12,8 +12,10 @@ import (
"time"
"cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/server"
"cmbuyer/admin/internal/taskclaim"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks"
@@ -389,8 +391,8 @@ func TestTasksPageRerendersAccessibleFilterErrorsAndKeepsValues(t *testing.T) {
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)
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, nil); response.Code != http.StatusUnauthorized {
t.Fatalf("anonymous POST /tasks = %d, want 401", response.Code)
}
cookie := authenticate(t, router)
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, cookie); response.Code != http.StatusForbidden {
@@ -488,10 +490,14 @@ func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
}
func newRouterWithStore(t *testing.T, store tasks.Store) (*gin.Engine, *auth.Manager) {
return newRouterWithDependencies(t, store, emptyDetailStore{}, emptyEvidenceStore{}, evidence.RejectAllDeviceAuthenticator{})
return newRouterWithDependencies(t, store, emptyDetailStore{}, emptyEvidenceStore{}, deviceauth.RejectAllAuthenticator{})
}
func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator evidence.DeviceAuthenticator) (*gin.Engine, *auth.Manager) {
func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator) (*gin.Engine, *auth.Manager) {
return newRouterWithClaimService(t, store, details, evidenceStore, deviceAuthenticator, emptyTaskClaimService{})
}
func newRouterWithClaimService(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator, claims taskclaim.Service) (*gin.Engine, *auth.Manager) {
t.Helper()
gin.SetMode(gin.TestMode)
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
@@ -507,6 +513,7 @@ func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdeta
TaskDetails: details,
Evidence: evidenceStore,
DeviceAuthenticator: deviceAuthenticator,
TaskClaims: claims,
})
if err != nil {
t.Fatalf("NewRouter: %v", err)
@@ -516,6 +523,16 @@ func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdeta
type emptyDetailStore struct{}
type emptyTaskClaimService struct{}
func (emptyTaskClaimService) ClaimNext(context.Context, string, taskclaim.ClaimCommand) (taskclaim.ClaimResponse, bool, error) {
return taskclaim.ClaimResponse{}, false, nil
}
func (emptyTaskClaimService) Renew(context.Context, string, taskclaim.RenewCommand) (taskclaim.RenewResponse, error) {
return taskclaim.RenewResponse{}, taskclaim.ErrNotCurrent
}
func (emptyDetailStore) Get(context.Context, string) (taskdetail.Detail, error) {
return taskdetail.Detail{}, taskdetail.ErrNotFound
}
@@ -526,7 +543,7 @@ func (emptyEvidenceStore) Stage(io.Reader, string) (evidence.StagedFile, error)
return evidence.StagedFile{}, evidence.ErrInvalid
}
func (emptyEvidenceStore) Discard(evidence.StagedFile) {}
func (emptyEvidenceStore) Commit(context.Context, evidence.DevicePrincipal, evidence.UploadMetadata, evidence.StagedFile) (evidence.Asset, bool, error) {
func (emptyEvidenceStore) Commit(context.Context, deviceauth.Principal, evidence.UploadMetadata, evidence.StagedFile) (evidence.Asset, bool, error) {
return evidence.Asset{}, false, evidence.ErrInvalid
}
func (emptyEvidenceStore) Open(context.Context, string) (evidence.Asset, io.ReadSeekCloser, error) {
@@ -538,6 +555,7 @@ type memoryStore struct {
rows []tasks.TaskRow
listDraftsCalls int
listTasksCalls int
startCalls int
}
func (store *memoryStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) {
@@ -568,6 +586,7 @@ func (store *memoryStore) ListTasks(_ context.Context, _ tasks.TaskFilter) ([]ta
return result, nil
}
func (store *memoryStore) StartPurchases(_ context.Context, _ tasks.StartCommand, _ string) (tasks.StartResult, error) {
store.startCalls++
return tasks.StartResult{}, tasks.ErrInvalidStart
}
+176
View File
@@ -0,0 +1,176 @@
package server
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"unicode/utf8"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/taskclaim"
"github.com/gin-gonic/gin"
)
const (
maxClaimJSONBytes = 4096
maxClaimResponseJSONBytes = 32 * 1024
)
func claimNext(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
principal, ok := authenticateDevice(context, options)
if !ok {
return
}
var command taskclaim.ClaimCommand
if !decodeClaimJSON(context, &command) {
return
}
response, found, err := options.TaskClaims.ClaimNext(context.Request.Context(), principal.ID, command)
if err != nil {
writeTaskClaimError(context, err)
return
}
if !found {
context.Status(http.StatusNoContent)
return
}
if !taskclaim.ValidClaimResponse(response) {
context.Status(http.StatusServiceUnavailable)
return
}
encoded, err := json.Marshal(response)
if err != nil || len(encoded) > maxClaimResponseJSONBytes {
context.Status(http.StatusServiceUnavailable)
return
}
context.Data(http.StatusOK, "application/json; charset=utf-8", encoded)
}
}
func renewLease(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
principal, ok := authenticateDevice(context, options)
if !ok {
return
}
var command taskclaim.RenewCommand
if !decodeClaimJSON(context, &command) {
return
}
command.TaskID = context.Param("id")
response, err := options.TaskClaims.Renew(context.Request.Context(), principal.ID, command)
if err != nil {
writeTaskClaimError(context, err)
return
}
context.JSON(http.StatusOK, response)
}
}
// Authentication precedes path interpretation, Content-Type parsing and every body read. This
// keeps rejected devices from using parsing differences as an oracle or making the server buffer data.
func authenticateDevice(context *gin.Context, options Options) (deviceauth.Principal, bool) {
principal, err := options.DeviceAuthenticator.Authenticate(context.Request)
if errors.Is(err, deviceauth.ErrUnauthenticated) {
context.Header("WWW-Authenticate", "Bearer")
context.Status(http.StatusUnauthorized)
return deviceauth.Principal{}, false
}
if err != nil || !deviceauth.ValidDeviceID(principal.ID) {
context.Status(http.StatusServiceUnavailable)
return deviceauth.Principal{}, false
}
return principal, true
}
func decodeClaimJSON(context *gin.Context, target any) bool {
if !isJSONContentType(context.GetHeader("Content-Type")) {
writeFixedError(context, http.StatusUnsupportedMediaType, "unsupported_media_type")
return false
}
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxClaimJSONBytes)
raw, err := io.ReadAll(context.Request.Body)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
writeFixedError(context, http.StatusRequestEntityTooLarge, "request_too_large")
} else {
writeFixedError(context, http.StatusBadRequest, "invalid_request")
}
return false
}
if len(raw) == 0 || !utf8.Valid(raw) {
writeFixedError(context, http.StatusBadRequest, "invalid_request")
return false
}
if !hasUniqueTopLevelJSONFields(raw) {
writeFixedError(context, http.StatusBadRequest, "invalid_request")
return false
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
writeFixedError(context, http.StatusBadRequest, "invalid_request")
return false
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
writeFixedError(context, http.StatusBadRequest, "invalid_request")
return false
}
return true
}
func hasUniqueTopLevelJSONFields(raw []byte) bool {
decoder := json.NewDecoder(bytes.NewReader(raw))
first, err := decoder.Token()
if err != nil || first != json.Delim('{') {
return false
}
seen := make(map[string]struct{})
for decoder.More() {
key, err := decoder.Token()
name, ok := key.(string)
if err != nil || !ok {
return false
}
if _, duplicate := seen[name]; duplicate {
return false
}
seen[name] = struct{}{}
var value json.RawMessage
if err := decoder.Decode(&value); err != nil {
return false
}
}
last, err := decoder.Token()
return err == nil && last == json.Delim('}')
}
func writeTaskClaimError(context *gin.Context, err error) {
switch {
case errors.Is(err, taskclaim.ErrInvalid):
writeFixedError(context, http.StatusBadRequest, "invalid_request")
case errors.Is(err, taskclaim.ErrIdempotencyConflict):
writeFixedError(context, http.StatusConflict, "idempotency_conflict")
case errors.Is(err, taskclaim.ErrRequiresManual):
writeFixedError(context, http.StatusConflict, "claim_requires_manual")
case errors.Is(err, taskclaim.ErrNotCurrent):
writeFixedError(context, http.StatusConflict, "claim_not_current")
case errors.Is(err, taskclaim.ErrDeviceInactive):
context.Header("WWW-Authenticate", "Bearer")
context.Status(http.StatusUnauthorized)
default:
// Storage and transaction failures are intentionally bodyless: SQL, paths and candidate
// details are server-only and must not become a device-facing diagnostic oracle.
context.Status(http.StatusServiceUnavailable)
}
}
func writeFixedError(context *gin.Context, status int, code string) {
context.JSON(status, gin.H{"error": code})
}
+215
View File
@@ -0,0 +1,215 @@
package server_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"math"
"net/http"
"net/http/httptest"
"strings"
"testing"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/taskclaim"
)
const (
claimDeviceID = "10000000-0000-4000-8000-000000000001"
claimSessionID = "20000000-0000-4000-8000-000000000001"
claimRequestID = "30000000-0000-4000-8000-000000000001"
claimTaskID = "40000000-0000-4000-8000-000000000001"
claimAttemptID = "50000000-0000-4000-8000-000000000001"
claimRenewID = "60000000-0000-4000-8000-000000000001"
)
func TestTaskClaimEndpointsAuthenticateBeforeBody(t *testing.T) {
for _, authentication := range []struct {
name string
err error
status int
}{
{"unauthenticated", deviceauth.ErrUnauthenticated, http.StatusUnauthorized},
{"authentication storage unavailable", deviceauth.ErrUnavailable, http.StatusServiceUnavailable},
} {
t.Run(authentication.name, func(t *testing.T) {
authenticator := &fakeDeviceAuthenticator{err: authentication.err}
service := &fakeTaskClaimService{}
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
for _, path := range []string{"/api/v1/tasks/claim-next", "/api/v1/tasks/" + claimTaskID + "/lease/renew"} {
body := &poisonBody{}
request := httptest.NewRequest(http.MethodPost, path, nil)
request.Body = body
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != authentication.status || response.Body.Len() != 0 || body.reads != 0 || service.calls != 0 {
t.Fatalf("%s = status %d, body %q, reads %d, calls %d", path, response.Code, response.Body.String(), body.reads, service.calls)
}
}
})
}
}
func TestClaimNextStrictJSONSuccessEmptyAndErrors(t *testing.T) {
authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
service := &fakeTaskClaimService{claimResponse: taskclaim.ClaimResponse{
Task: taskclaim.ClaimedTask{ID: claimTaskID, Version: 3, Title: "测试", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", GoodsID: "1", SKUColor: "黑色", SKUSize: "M", Quantity: 1, MaxTotalPrice: "1.00"},
Authorization: taskclaim.ClaimedAuthorization{ID: "70000000-0000-4000-8000-000000000001", TaskVersion: 2, ExpiresAt: "2026-08-04T01:10:00Z"},
Attempt: taskclaim.ClaimedAttempt{ID: claimAttemptID, ClaimToken: strings.Repeat("a", 64), ClaimGeneration: 1, LeaseExpiresAt: "2026-08-04T01:03:00Z"},
}, claimFound: true}
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
valid := `{"session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `"}`
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", valid, "application/json; charset=utf-8")
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), strings.Repeat("a", 64)) || service.claimCommand.ClaimRequestID != claimRequestID {
t.Fatalf("claim success = %d %q command %#v", response.Code, response.Body.String(), service.claimCommand)
}
service.claimFound = false
response = serveClaimJSON(router, "/api/v1/tasks/claim-next", valid, "application/json")
if response.Code != http.StatusNoContent || response.Body.Len() != 0 {
t.Fatalf("claim empty = %d %q", response.Code, response.Body.String())
}
tests := []struct {
name, body, contentType, code string
status int
}{
{"unsupported type", valid, "text/plain", "unsupported_media_type", http.StatusUnsupportedMediaType},
{"unknown field", strings.TrimSuffix(valid, "}") + `,"device_id":"` + claimDeviceID + `"}`, "application/json", "invalid_request", http.StatusBadRequest},
{"duplicate session", `{"session_id":"` + claimSessionID + `","session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `"}`, "application/json", "invalid_request", http.StatusBadRequest},
{"duplicate request", `{"session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `","claim_request_id":"` + claimRequestID + `"}`, "application/json", "invalid_request", http.StatusBadRequest},
{"extra json", valid + `{}`, "application/json", "invalid_request", http.StatusBadRequest},
{"too large", strings.Repeat(" ", 4097), "application/json", "request_too_large", http.StatusRequestEntityTooLarge},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", test.body, test.contentType)
if response.Code != test.status || response.Body.String() != `{"error":"`+test.code+`"}` {
t.Fatalf("response = %d %q", response.Code, response.Body.String())
}
})
}
invalidUTF8 := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/claim-next", bytes.NewReader([]byte{'{', 0xff, '}'}))
invalidUTF8.Header.Set("Content-Type", "application/json")
invalidResponse := httptest.NewRecorder()
router.ServeHTTP(invalidResponse, invalidUTF8)
if invalidResponse.Code != http.StatusBadRequest || invalidResponse.Body.String() != `{"error":"invalid_request"}` {
t.Fatalf("invalid UTF-8 = %d %q", invalidResponse.Code, invalidResponse.Body.String())
}
}
func TestClaimResponseWorstLegalFieldsStayBelowCapAndInvalidServiceOutputFailsClosed(t *testing.T) {
goodsID := strings.Repeat("1", 32)
worst := taskclaim.ClaimResponse{
Task: taskclaim.ClaimedTask{
ID: claimTaskID, Version: math.MaxInt, Title: strings.Repeat("<", 120),
ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID,
GoodsID: goodsID, SKUColor: strings.Repeat("<", 80), SKUSize: strings.Repeat("<", 80),
Quantity: 9_223_372_036_854_775_807, MaxTotalPrice: strings.Repeat("9", 29) + ".00",
},
Authorization: taskclaim.ClaimedAuthorization{ID: "70000000-0000-4000-8000-000000000001", TaskVersion: math.MaxInt - 1, ExpiresAt: "9999-12-31T23:59:59.999999999Z"},
Attempt: taskclaim.ClaimedAttempt{ID: claimAttemptID, ClaimToken: strings.Repeat("a", 64), ClaimGeneration: 9_223_372_036_854_775_807, LeaseExpiresAt: "9999-12-31T23:59:59.999999999Z"},
}
authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
service := &fakeTaskClaimService{claimResponse: worst, claimFound: true}
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
request := `{"session_id":"` + claimSessionID + `","claim_request_id":"` + claimRequestID + `"}`
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", request, "application/json")
if response.Code != http.StatusOK || !json.Valid(response.Body.Bytes()) || response.Body.Len() >= 32*1024 {
t.Fatalf("worst legal response = status %d, bytes %d, valid JSON %v", response.Code, response.Body.Len(), json.Valid(response.Body.Bytes()))
}
mutations := map[string]func(*taskclaim.ClaimResponse){
"invalid utf8 title": func(response *taskclaim.ClaimResponse) { response.Task.Title = string([]byte{0xff}) },
"c0 separator title": func(response *taskclaim.ClaimResponse) { response.Task.Title = "visible\u001dhidden" },
"overlong title": func(response *taskclaim.ClaimResponse) { response.Task.Title += "<" },
"overlong goods id": func(response *taskclaim.ClaimResponse) {
response.Task.GoodsID += "1"
response.Task.ProductURL += "1"
},
"overlong color": func(response *taskclaim.ClaimResponse) { response.Task.SKUColor += "<" },
"overlong size": func(response *taskclaim.ClaimResponse) { response.Task.SKUSize += "<" },
"overlong money": func(response *taskclaim.ClaimResponse) { response.Task.MaxTotalPrice = strings.Repeat("9", 30) + ".00" },
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
invalid := worst
mutate(&invalid)
service := &fakeTaskClaimService{claimResponse: invalid, claimFound: true}
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
response := serveClaimJSON(router, "/api/v1/tasks/claim-next", request, "application/json")
if response.Code != http.StatusServiceUnavailable || response.Body.Len() != 0 {
t.Fatalf("invalid service response = %d %q", response.Code, response.Body.String())
}
})
}
}
func TestRenewStrictBindingResponseAndFixedErrors(t *testing.T) {
authenticator := &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: claimDeviceID}}
service := &fakeTaskClaimService{renewResponse: taskclaim.RenewResponse{TaskID: claimTaskID, AttemptID: claimAttemptID, ClaimGeneration: 1, LeaseExpiresAt: "2026-08-04T01:04:00Z"}}
router, _ := newRouterWithClaimService(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator, service)
body := `{"renew_request_id":"` + claimRenewID + `","session_id":"` + claimSessionID + `","attempt_id":"` + claimAttemptID + `","claim_generation":1,"claim_token":"` + strings.Repeat("a", 64) + `","expected_lease_expires_at":"2026-08-04T01:03:00Z"}`
response := serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", body, "application/json")
if response.Code != http.StatusOK || strings.Contains(response.Body.String(), "claim_token") || service.renewCommand.TaskID != claimTaskID {
t.Fatalf("renew response = %d %q command %#v", response.Code, response.Body.String(), service.renewCommand)
}
duplicateToken := strings.Replace(body, `"expected_lease_expires_at"`, `"claim_token":"`+strings.Repeat("a", 64)+`","expected_lease_expires_at"`, 1)
response = serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", duplicateToken, "application/json")
if response.Code != http.StatusBadRequest {
t.Fatalf("duplicate token status = %d", response.Code)
}
errorsToCodes := []struct {
err error
status int
body string
}{
{taskclaim.ErrIdempotencyConflict, http.StatusConflict, `{"error":"idempotency_conflict"}`},
{taskclaim.ErrRequiresManual, http.StatusConflict, `{"error":"claim_requires_manual"}`},
{taskclaim.ErrNotCurrent, http.StatusConflict, `{"error":"claim_not_current"}`},
{taskclaim.ErrDeviceInactive, http.StatusUnauthorized, ""},
{errors.New("database path and SQL must stay private"), http.StatusServiceUnavailable, ""},
}
for _, test := range errorsToCodes {
service.renewErr = test.err
response = serveClaimJSON(router, "/api/v1/tasks/"+claimTaskID+"/lease/renew", body, "application/json")
if response.Code != test.status || response.Body.String() != test.body {
t.Fatalf("error %v = %d %q", test.err, response.Code, response.Body.String())
}
}
}
type fakeTaskClaimService struct {
claimResponse taskclaim.ClaimResponse
claimFound bool
claimErr error
renewResponse taskclaim.RenewResponse
renewErr error
claimCommand taskclaim.ClaimCommand
renewCommand taskclaim.RenewCommand
calls int
}
func (service *fakeTaskClaimService) ClaimNext(_ context.Context, _ string, command taskclaim.ClaimCommand) (taskclaim.ClaimResponse, bool, error) {
service.calls++
service.claimCommand = command
return service.claimResponse, service.claimFound, service.claimErr
}
func (service *fakeTaskClaimService) Renew(_ context.Context, _ string, command taskclaim.RenewCommand) (taskclaim.RenewResponse, error) {
service.calls++
service.renewCommand = command
return service.renewResponse, service.renewErr
}
func serveClaimJSON(router http.Handler, path, body, contentType string) *httptest.ResponseRecorder {
request := httptest.NewRequest(http.MethodPost, path, io.NopCloser(strings.NewReader(body)))
request.Header.Set("Content-Type", contentType)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
+4 -4
View File
@@ -8,7 +8,7 @@ import (
"testing"
"time"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/taskdetail"
)
@@ -16,7 +16,7 @@ const detailTaskID = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
func TestTaskDetailRequiresAdminBeforeLookup(t *testing.T) {
details := &recordingDetailStore{detail: taskDetailFixture()}
router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, evidence.RejectAllDeviceAuthenticator{})
router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, deviceauth.RejectAllAuthenticator{})
response := serve(router, http.MethodGet, "/tasks/"+detailTaskID, nil, nil)
if response.Code != http.StatusSeeOther || !strings.HasPrefix(response.Header().Get("Location"), "/login?return_to=") || details.calls != 0 {
t.Fatalf("anonymous detail = %d/%q, calls=%d", response.Code, response.Header().Get("Location"), details.calls)
@@ -25,7 +25,7 @@ func TestTaskDetailRequiresAdminBeforeLookup(t *testing.T) {
func TestTaskDetailFullPageAndDrawerShareAuditContent(t *testing.T) {
details := &recordingDetailStore{detail: taskDetailFixture()}
router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, evidence.RejectAllDeviceAuthenticator{})
router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, deviceauth.RejectAllAuthenticator{})
cookie := authenticate(t, router)
full := serve(router, http.MethodGet, "/tasks/"+detailTaskID, nil, cookie)
if full.Code != http.StatusOK || !strings.Contains(full.Body.String(), "<!doctype html>") || !strings.Contains(full.Body.String(), `data-task-detail-content`) {
@@ -59,7 +59,7 @@ func TestTaskDetailFullPageAndDrawerShareAuditContent(t *testing.T) {
func TestTaskDetailRejectsForgedFragmentAndMissingTask(t *testing.T) {
details := &recordingDetailStore{err: taskdetail.ErrNotFound}
router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, evidence.RejectAllDeviceAuthenticator{})
router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, deviceauth.RejectAllAuthenticator{})
cookie := authenticate(t, router)
for name, headers := range map[string]map[string]string{
"unknown view": {"X-CMBuyer-View": "xml", "Accept": "text/html"},
+13 -16
View File
@@ -16,8 +16,8 @@ import (
"strings"
"sync"
"time"
"unicode"
"cmbuyer/admin/internal/deviceauth"
core "cmbuyer/admin/internal/evidence"
)
@@ -152,7 +152,7 @@ func (store *Store) Discard(staged core.StagedFile) {
}
}
func (store *Store) Commit(ctx context.Context, principal core.DevicePrincipal, metadata core.UploadMetadata, staged core.StagedFile) (core.Asset, bool, error) {
func (store *Store) Commit(ctx context.Context, principal deviceauth.Principal, metadata core.UploadMetadata, staged core.StagedFile) (core.Asset, bool, error) {
if !store.isStagedPath(staged.Path) || !validPrincipal(principal) || !validMetadata(metadata) || metadata.SHA256 != staged.SHA256 || staged.ContentType != core.PNGContentType || staged.ByteSize < 1 || staged.ByteSize > core.MaxFileBytes || staged.Width < 1 || staged.Height < 1 || staged.Width > core.MaxImageSide || staged.Height > core.MaxImageSide || int64(staged.Width)*int64(staged.Height) > core.MaxImagePixels {
store.Discard(staged)
return core.Asset{}, false, core.ErrInvalid
@@ -185,11 +185,16 @@ func (store *Store) Commit(ctx context.Context, principal core.DevicePrincipal,
return existing, true, nil
}
var attemptCount int
if err := transaction.QueryRowContext(ctx, "SELECT COUNT(*) FROM purchase_attempts WHERE task_id = ? AND id = ?", metadata.TaskID, metadata.AttemptID).Scan(&attemptCount); err != nil {
var ownedClaimCount int
if err := transaction.QueryRowContext(ctx, `SELECT COUNT(*) FROM purchase_attempt_claims
WHERE task_id = ? AND attempt_id = ? AND claimed_by_device_id = ? AND closed_at IS NULL`,
metadata.TaskID, metadata.AttemptID, principal.ID).Scan(&ownedClaimCount); err != nil {
return core.Asset{}, false, err
}
if attemptCount != 1 {
// Evidence is auditable only when the authenticated device owns the current attempt. The
// idempotent asset lookup above deliberately remains first so closing a claim later cannot
// destroy stable replay of an already committed screenshot.
if ownedClaimCount != 1 {
return core.Asset{}, false, core.ErrInvalid
}
@@ -447,16 +452,8 @@ func validMetadata(metadata core.UploadMetadata) bool {
return validUUID(metadata.UploadKey) && validUUID(metadata.TaskID) && validUUID(metadata.AttemptID) && metadata.Kind == core.KindSKUPanelGate1 && metadata.PrivacyTier == core.PrivacyInternalRaw && validSHA256(metadata.SHA256) && !metadata.CapturedAt.IsZero() && metadata.CapturedAt.Location() == time.UTC
}
func validPrincipal(principal core.DevicePrincipal) bool {
if principal.ID == "" || strings.TrimSpace(principal.ID) != principal.ID || len(principal.ID) > 128 {
return false
}
for _, character := range principal.ID {
if unicode.IsControl(character) {
return false
}
}
return true
func validPrincipal(principal deviceauth.Principal) bool {
return deviceauth.ValidDeviceID(principal.ID)
}
func validSHA256(value string) bool {
@@ -548,6 +545,6 @@ func scanAsset(row rowScanner) (core.Asset, bool, error) {
return asset, true, nil
}
func sameUpload(asset core.Asset, principal core.DevicePrincipal, metadata core.UploadMetadata, staged core.StagedFile) bool {
func sameUpload(asset core.Asset, principal deviceauth.Principal, metadata core.UploadMetadata, staged core.StagedFile) bool {
return asset.TaskID == metadata.TaskID && asset.AttemptID == metadata.AttemptID && asset.Kind == metadata.Kind && asset.PrivacyTier == metadata.PrivacyTier && asset.SHA256 == metadata.SHA256 && asset.ByteSize == staged.ByteSize && asset.ContentType == staged.ContentType && asset.Width == staged.Width && asset.Height == staged.Height && asset.UploadedByDeviceID == principal.ID && asset.CapturedAt.Equal(metadata.CapturedAt)
}
+61 -9
View File
@@ -19,6 +19,7 @@ import (
"testing"
"time"
"cmbuyer/admin/internal/deviceauth"
core "cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
@@ -29,6 +30,7 @@ const (
testAuthID = "23c9f507-7473-4fa6-8d71-8786c34c6301"
testAttemptID = "33c9f507-7473-4fa6-8d71-8786c34c6301"
testUploadKey = "43c9f507-7473-4fa6-8d71-8786c34c6301"
testDeviceID = "53c9f507-7473-4fa6-8d71-8786c34c6301"
)
func TestStageCommitReplayAndOpen(t *testing.T) {
@@ -37,7 +39,7 @@ func TestStageCommitReplayAndOpen(t *testing.T) {
pngBytes := makePNG(t, 8, 6)
hash := sha256Hex(pngBytes)
metadata := core.UploadMetadata{UploadKey: testUploadKey, TaskID: testTaskID, AttemptID: testAttemptID, Kind: core.KindSKUPanelGate1, PrivacyTier: core.PrivacyInternalRaw, SHA256: hash, CapturedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)}
principal := core.DevicePrincipal{ID: "device-one"}
principal := deviceauth.Principal{ID: testDeviceID}
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
@@ -80,6 +82,41 @@ func TestStageCommitReplayAndOpen(t *testing.T) {
}
}
func TestCommitRequiresCurrentClaimOwnerButClosedClaimKeepsHistoricalReplay(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 4, 3)
metadata := testMetadata(sha256Hex(pngBytes))
stage := func() core.StagedFile {
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage: %v", err)
}
return staged
}
otherDevice := deviceauth.Principal{ID: "73c9f507-7473-4fa6-8d71-8786c34c6301"}
if _, _, err := store.Commit(context.Background(), otherDevice, metadata, stage()); !errors.Is(err, core.ErrInvalid) {
t.Fatalf("device B upload to device A attempt error = %v", err)
}
principal := deviceauth.Principal{ID: testDeviceID}
asset, replayed, err := store.Commit(context.Background(), principal, metadata, stage())
if err != nil || replayed {
t.Fatalf("owner first Commit = replayed %v, err %v", replayed, err)
}
if _, err := database.Exec("UPDATE purchase_attempt_claims SET closed_at='2026-08-04T03:00:00Z' WHERE attempt_id=?", testAttemptID); err != nil {
t.Fatalf("close claim: %v", err)
}
replayedAsset, replayed, err := store.Commit(context.Background(), principal, metadata, stage())
if err != nil || !replayed || replayedAsset.ID != asset.ID {
t.Fatalf("closed claim historical replay = %#v replayed %v err %v", replayedAsset, replayed, err)
}
newMetadata := metadata
newMetadata.UploadKey = "83c9f507-7473-4fa6-8d71-8786c34c6301"
if _, _, err := store.Commit(context.Background(), principal, newMetadata, stage()); !errors.Is(err, core.ErrInvalid) {
t.Fatalf("closed claim new upload error = %v", err)
}
}
func TestConcurrentReplayCreatesOneAsset(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
@@ -101,7 +138,7 @@ func TestConcurrentReplayCreatesOneAsset(t *testing.T) {
for index := range staged {
go func(index int) {
defer wait.Done()
assets[index], replays[index], errorsSeen[index] = store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, metadata, staged[index])
assets[index], replays[index], errorsSeen[index] = store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, metadata, staged[index])
}(index)
}
wait.Wait()
@@ -206,7 +243,7 @@ func TestCommitSyncsShardAndRenameBeforeDatabaseWrite(t *testing.T) {
return os.Rename(oldPath, newPath)
}
if _, replayed, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, metadata, staged); err != nil || replayed {
if _, replayed, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, metadata, staged); err != nil || replayed {
t.Fatalf("Commit = replayed %t, err %v", replayed, err)
}
if got, want := strings.Join(events, ","), "sync-root,sync-root,sync-file,rename,sync-shard"; got != want {
@@ -241,7 +278,7 @@ func TestCommitDirectorySyncFailuresNeverWriteDatabase(t *testing.T) {
return syncDirectory(path)
}
if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, testMetadata(hash), staged); !errors.Is(err, injected) {
if _, _, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), staged); !errors.Is(err, injected) {
t.Fatalf("Commit error = %v, want injected sync failure", err)
}
if calls != failAt {
@@ -275,7 +312,7 @@ func TestCommitRetriesShardParentSyncAfterPriorFailure(t *testing.T) {
}
injected := errors.New("injected first shard parent sync failure")
store.syncDirectory = func(string) error { return injected }
if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, testMetadata(hash), firstStage); !errors.Is(err, injected) {
if _, _, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), firstStage); !errors.Is(err, injected) {
t.Fatalf("first Commit error = %v", err)
}
if info, err := os.Stat(filepath.Dir(finalPath)); err != nil || !info.IsDir() {
@@ -292,7 +329,7 @@ func TestCommitRetriesShardParentSyncAfterPriorFailure(t *testing.T) {
paths = append(paths, path)
return syncDirectory(path)
}
if _, replayed, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, testMetadata(hash), secondStage); err != nil || replayed {
if _, replayed, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), secondStage); err != nil || replayed {
t.Fatalf("retry Commit = replayed %t, err %v", replayed, err)
}
if len(paths) != 2 || paths[0] != store.root || paths[1] != filepath.Dir(finalPath) {
@@ -323,7 +360,7 @@ func TestCommitPublicationFailuresCleanTempAndNeverWriteDatabase(t *testing.T) {
} else {
store.renameFile = func(string, string) error { return injected }
}
if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, testMetadata(hash), staged); !errors.Is(err, injected) {
if _, _, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), staged); !errors.Is(err, injected) {
t.Fatalf("Commit error = %v, want injected publication failure", err)
}
if _, err := os.Stat(finalPath); !errors.Is(err, os.ErrNotExist) {
@@ -377,7 +414,7 @@ func TestCommitDatabaseFailuresAfterDurableRenameLeaveOnlyOrphan(t *testing.T) {
return syncDirectory(path)
}
if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, testMetadata(hash), staged); err == nil {
if _, _, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), staged); err == nil {
t.Fatal("Commit unexpectedly succeeded")
}
if syncCalls != 3 {
@@ -436,7 +473,7 @@ func TestCommitRequiresAttemptOwnedByTaskAndLowercaseHash(t *testing.T) {
}
metadata := base
mutate(&metadata)
if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, metadata, staged); !errors.Is(err, core.ErrInvalid) {
if _, _, err := store.Commit(context.Background(), deviceauth.Principal{ID: testDeviceID}, metadata, staged); !errors.Is(err, core.ErrInvalid) {
t.Fatalf("Commit error = %v", err)
}
})
@@ -492,6 +529,12 @@ func newTestStore(t *testing.T) (*sql.DB, *Store) {
func insertAttemptFixture(t *testing.T, database *sql.DB) {
t.Helper()
timestamp := "2026-08-04T00:00:00Z"
tokenHash := sha256.Sum256([]byte("evidence-device-token"))
if _, err := database.Exec(`INSERT INTO device_credentials
(device_id,display_name,token_sha256,status,created_at,revoked_at)
VALUES (?, 'evidence device', ?, 'ACTIVE', ?, NULL)`, testDeviceID, tokenHash[:], timestamp); err != nil {
t.Fatalf("insert device: %v", 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 (?, 'MANUAL', 'task', '123', 'black', 'M', 1, '1.00', 'DRAFT', 1, ?, ?)`, testTaskID, timestamp, timestamp); err != nil {
t.Fatalf("insert task: %v", err)
}
@@ -501,6 +544,15 @@ func insertAttemptFixture(t *testing.T, database *sql.DB) {
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, testAttemptID, testTaskID, testAuthID, timestamp); err != nil {
t.Fatalf("insert attempt: %v", err)
}
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
VALUES (?, ?, ?, ?, '63c9f507-7473-4fa6-8d71-8786c34c6301', 1, 1, 'task',
1, '123', 'black', 'M', 1, '1.00', ?, ?, ?, '2026-08-04T02:00:00Z', ?, NULL)`,
testAttemptID, testTaskID, testAuthID, testDeviceID, timestamp, bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32), timestamp); err != nil {
t.Fatalf("insert claim: %v", err)
}
}
func testMetadata(hash string) core.UploadMetadata {
+823
View File
@@ -0,0 +1,823 @@
package taskclaim
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io"
"math"
"strings"
"sync"
"time"
"cmbuyer/admin/internal/deviceauth"
taskmodel "cmbuyer/admin/internal/tasks"
)
const writeTimeout = 2 * time.Second
type Store struct {
database *sql.DB
secret []byte
leaseTTL time.Duration
now func() time.Time
random io.Reader
randomMu sync.Mutex
writeGate chan struct{}
// The unexported linearization hooks let package tests coordinate real SQLite
// transactions at the first write. Production construction always leaves them nil.
beforeLinearization func()
afterLinearization func()
}
func NewStore(database *sql.DB, secret []byte, leaseTTL time.Duration) (*Store, error) {
if database == nil {
return nil, errors.New("task claim database is required")
}
if len(secret) != sha256.Size {
return nil, errors.New("task claim secret must be 32 bytes")
}
if leaseTTL <= 0 {
return nil, errors.New("task claim lease TTL must be positive")
}
if _, err := database.Exec("SELECT attempt_id, claim_nonce, claim_token_sha256 FROM purchase_attempt_claims LIMIT 1"); err != nil {
return nil, errors.New("task claim migration is not available")
}
store := &Store{
database: database, secret: append([]byte(nil), secret...), leaseTTL: leaseTTL,
now: time.Now, random: rand.Reader, writeGate: make(chan struct{}, 1),
}
if err := store.validateSecretIsolation(); err != nil {
return nil, err
}
if err := store.validateStoredClaims(context.Background()); err != nil {
return nil, err
}
return store, nil
}
// validateSecretIsolation ensures the HMAC key cannot also authenticate a device. The session
// secret comparison is performed while parsing configuration, before either secret is discarded.
func (store *Store) validateSecretIsolation() error {
digest := sha256.Sum256(store.secret)
var count int
if err := store.database.QueryRow(`SELECT COUNT(*) FROM device_credentials WHERE token_sha256 = ?`, digest[:]).Scan(&count); err != nil {
return errors.New("validate task claim secret isolation")
}
if count != 0 {
return errors.New("task claim secret must be isolated from device credentials")
}
return nil
}
// validateStoredClaims covers open and closed claims. Replacing the secret must fail startup;
// silently signing a new token would destroy idempotent recovery and the ownership audit chain.
func (store *Store) validateStoredClaims(ctx context.Context) error {
rows, err := store.database.QueryContext(ctx, `SELECT claims.claimed_by_device_id, claims.task_id, claims.authorization_id,
claims.attempt_id, claims.claim_generation, claims.claim_nonce, typeof(claims.claim_nonce), length(claims.claim_nonce),
claims.claim_token_sha256, typeof(claims.claim_token_sha256), length(claims.claim_token_sha256),
claims.task_title, claims.authorization_task_version, claims.goods_id, claims.sku_color, claims.sku_size,
claims.quantity, claims.total_price_cap, claims.authorization_expires_at, claims.closed_at,
attempts.claim_generation, attempts.status, authorizations.status, tasks.status
FROM purchase_attempt_claims AS claims
LEFT JOIN purchase_attempts AS attempts ON attempts.id = claims.attempt_id
LEFT JOIN order_authorizations AS authorizations ON authorizations.id = claims.authorization_id
LEFT JOIN tasks ON tasks.id = claims.task_id
ORDER BY claims.attempt_id`)
if err != nil {
return errors.New("validate stored task claims")
}
defer rows.Close()
for rows.Next() {
var deviceID, taskID, authorizationID, attemptID string
var generation, authorizationTaskVersion, quantity int
var nonce, storedHash []byte
var nonceType, hashType, title, goodsID, color, size, price, expires string
var nonceLength, hashLength int
var closed, attemptStatus, authorizationStatus, taskStatus sql.NullString
var attemptGeneration sql.NullInt64
if err := rows.Scan(&deviceID, &taskID, &authorizationID, &attemptID, &generation,
&nonce, &nonceType, &nonceLength, &storedHash, &hashType, &hashLength,
&title, &authorizationTaskVersion, &goodsID, &color, &size, &quantity, &price, &expires, &closed,
&attemptGeneration, &attemptStatus, &authorizationStatus, &taskStatus); err != nil {
return errors.New("validate stored task claims")
}
if !deviceauth.ValidDeviceID(deviceID) || !validUUID(taskID) || !validUUID(authorizationID) || !validUUID(attemptID) ||
generation <= 0 || nonceType != "blob" || nonceLength != sha256.Size || len(nonce) != sha256.Size ||
hashType != "blob" || hashLength != sha256.Size || len(storedHash) != sha256.Size ||
authorizationTaskVersion <= 0 || !taskmodel.ValidTaskWireFields(title, goodsID, color, size, price) || quantity <= 0 ||
!validCanonicalTime(expires) || (closed.Valid && !validCanonicalTime(closed.String)) ||
!attemptGeneration.Valid || attemptGeneration.Int64 != int64(generation) ||
!validAttemptStatus(attemptStatus) || !validAuthorizationStatus(authorizationStatus) || !validTaskStatus(taskStatus) {
return errors.New("stored task claim metadata is invalid")
}
token := deriveToken(store.secret, deviceID, taskID, authorizationID, attemptID, generation, nonce)
if !matchingHash(tokenHash(token), storedHash) {
return errors.New("task claim secret does not match stored claims")
}
}
if err := rows.Err(); err != nil {
return errors.New("validate stored task claims")
}
return nil
}
func (store *Store) ClaimNext(ctx context.Context, deviceID string, command ClaimCommand) (ClaimResponse, bool, error) {
if !deviceauth.ValidDeviceID(deviceID) || !validUUID(command.SessionID) || !validUUID(command.ClaimRequestID) {
return ClaimResponse{}, false, ErrInvalid
}
writeCtx, cancel := context.WithTimeout(ctx, writeTimeout)
defer cancel()
select {
case store.writeGate <- struct{}{}:
defer func() { <-store.writeGate }()
case <-writeCtx.Done():
return ClaimResponse{}, false, writeCtx.Err()
}
transaction, err := store.database.BeginTx(writeCtx, nil)
if err != nil {
return ClaimResponse{}, false, err
}
defer transaction.Rollback()
// This must be the transaction's first database statement. The no-op conditional UPDATE takes
// SQLite's write position and linearizes a concurrent credential revocation before any replay,
// EMPTY response, conflict response, candidate read, or other business write is possible.
if store.beforeLinearization != nil {
store.beforeLinearization()
}
active, err := transaction.ExecContext(writeCtx, `UPDATE device_credentials SET status = status
WHERE device_id = ? AND status = 'ACTIVE' AND revoked_at IS NULL`, deviceID)
if err != nil {
return ClaimResponse{}, false, err
}
if ok, err := exactlyOne(active); err != nil {
return ClaimResponse{}, false, err
} else if !ok {
return ClaimResponse{}, false, ErrDeviceInactive
}
if store.afterLinearization != nil {
store.afterLinearization()
}
now, err := store.serverNow()
if err != nil {
return ClaimResponse{}, false, err
}
request, found, err := findClaimRequest(writeCtx, transaction, command.ClaimRequestID)
if err != nil {
return ClaimResponse{}, false, err
}
if found {
if request.DeviceID != deviceID || request.SessionID != command.SessionID {
return ClaimResponse{}, false, ErrIdempotencyConflict
}
switch request.Outcome {
case "EMPTY":
if err := transaction.Commit(); err != nil {
return ClaimResponse{}, false, err
}
return ClaimResponse{}, false, nil
case "BLOCKED":
if err := transaction.Commit(); err != nil {
return ClaimResponse{}, false, err
}
return ClaimResponse{}, false, ErrRequiresManual
case "CLAIMED":
record, found, err := store.loadClaimByAttempt(writeCtx, transaction, request.AttemptID)
if err != nil || !found {
if err == nil {
err = errors.New("stored claim request has no claim")
}
return ClaimResponse{}, false, err
}
response, err := store.responseFor(record, request.ResponseLeaseExpiresAt)
if err != nil {
return ClaimResponse{}, false, err
}
if err := transaction.Commit(); err != nil {
return ClaimResponse{}, false, err
}
return response, true, nil
default:
return ClaimResponse{}, false, errors.New("stored claim request outcome is invalid")
}
}
existing, found, err := store.loadOpenClaimByDevice(writeCtx, transaction, deviceID)
if err != nil {
return ClaimResponse{}, false, err
}
if found {
current := existing.SessionID == command.SessionID && existing.ClosedAt == "" &&
existing.LeaseExpiresAt.After(now) && existing.AuthorizationExpiresAt.After(now) &&
existing.CurrentAuthorizationExpiresAt.After(now) && existing.AuthorizationStatus == "CLAIMED" &&
existing.authorizationConsistent() && existing.recoverableBusinessState()
if !current {
if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "BLOCKED", "", "", "manual_recovery_required", now); err != nil {
return ClaimResponse{}, false, err
}
if err := transaction.Commit(); err != nil {
return ClaimResponse{}, false, err
}
return ClaimResponse{}, false, ErrRequiresManual
}
response, err := store.responseFor(existing, existing.LeaseExpiresText)
if err != nil {
return ClaimResponse{}, false, err
}
if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "CLAIMED", existing.AttemptID, existing.LeaseExpiresText, "", now); err != nil {
return ClaimResponse{}, false, err
}
if err := transaction.Commit(); err != nil {
return ClaimResponse{}, false, err
}
return response, true, nil
}
candidate, found, err := findCandidate(writeCtx, transaction, now)
if err != nil {
return ClaimResponse{}, false, err
}
if !found {
if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "EMPTY", "", "", "", now); err != nil {
return ClaimResponse{}, false, err
}
if err := transaction.Commit(); err != nil {
return ClaimResponse{}, false, err
}
return ClaimResponse{}, false, nil
}
generation, err := nextGeneration(writeCtx, transaction, candidate.TaskID)
if err != nil {
return ClaimResponse{}, false, err
}
attemptID, err := store.newUUID()
if err != nil {
return ClaimResponse{}, false, err
}
nonce, err := store.randomBytes(sha256.Size)
if err != nil {
return ClaimResponse{}, false, err
}
token := deriveToken(store.secret, deviceID, candidate.TaskID, candidate.AuthorizationID, attemptID, generation, nonce)
storedTokenHash := tokenHash(token)
leaseExpires := now.Add(store.leaseTTL)
if candidate.AuthorizationExpiresAt.Before(leaseExpires) {
leaseExpires = candidate.AuthorizationExpiresAt
}
leaseText := formatTime(leaseExpires)
nowText := formatTime(now)
authorizationUpdate, err := transaction.ExecContext(writeCtx, `UPDATE order_authorizations SET status = 'CLAIMED'
WHERE id = ? AND task_id = ? AND status = 'ACTIVE' AND task_version = ?
AND goods_id = ? AND sku_color = ? AND sku_size = ? AND quantity = ?
AND total_price_cap = ? AND expires_at = ?`,
candidate.AuthorizationID, candidate.TaskID, candidate.TaskVersion, candidate.GoodsID,
candidate.SKUColor, candidate.SKUSize, candidate.Quantity, candidate.TotalPriceCap,
candidate.AuthorizationExpiresText)
if err != nil {
return ClaimResponse{}, false, err
}
if ok, err := exactlyOne(authorizationUpdate); err != nil || !ok {
if err == nil {
err = errors.New("authorization changed during claim")
}
return ClaimResponse{}, false, err
}
taskUpdate, err := transaction.ExecContext(writeCtx, `UPDATE tasks SET status = 'CLAIMED', version = version + 1, updated_at = ?
WHERE id = ? AND status = 'PENDING' AND version = ? AND title = ? AND goods_id = ?
AND sku_color = ? AND sku_size = ? AND quantity = ? AND max_total_price = ?`,
nowText, candidate.TaskID, candidate.TaskVersion, candidate.Title, candidate.GoodsID,
candidate.SKUColor, candidate.SKUSize, candidate.Quantity, candidate.TotalPriceCap)
if err != nil {
return ClaimResponse{}, false, err
}
if ok, err := exactlyOne(taskUpdate); err != nil || !ok {
if err == nil {
err = errors.New("task changed during claim")
}
return ClaimResponse{}, false, err
}
if _, err := transaction.ExecContext(writeCtx, `INSERT INTO purchase_attempts
(id, task_id, authorization_id, claim_generation, status, started_at)
VALUES (?, ?, ?, ?, 'CLAIMED', ?)`, attemptID, candidate.TaskID, candidate.AuthorizationID, generation, nowText); err != nil {
return ClaimResponse{}, false, err
}
if _, err := transaction.ExecContext(writeCtx, `INSERT INTO purchase_attempt_claims
(attempt_id, task_id, authorization_id, claimed_by_device_id, session_id, claim_generation,
task_version, task_title, authorization_task_version, goods_id, sku_color, sku_size, quantity,
total_price_cap, authorization_expires_at, claim_nonce, claim_token_sha256,
lease_expires_at, claimed_at, closed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)`,
attemptID, candidate.TaskID, candidate.AuthorizationID, deviceID, command.SessionID, generation,
candidate.TaskVersion+1, candidate.Title, candidate.TaskVersion, candidate.GoodsID,
candidate.SKUColor, candidate.SKUSize, candidate.Quantity, candidate.TotalPriceCap,
candidate.AuthorizationExpiresText, nonce, storedTokenHash, leaseText, nowText); err != nil {
return ClaimResponse{}, false, err
}
if err := insertClaimRequest(writeCtx, transaction, command.ClaimRequestID, deviceID, command.SessionID, "CLAIMED", attemptID, leaseText, "", now); err != nil {
return ClaimResponse{}, false, err
}
response := ClaimResponse{
Task: ClaimedTask{ID: candidate.TaskID, Version: candidate.TaskVersion + 1, Title: candidate.Title,
ProductURL: productURL(candidate.GoodsID), GoodsID: candidate.GoodsID, SKUColor: candidate.SKUColor,
SKUSize: candidate.SKUSize, Quantity: candidate.Quantity, MaxTotalPrice: candidate.TotalPriceCap},
Authorization: ClaimedAuthorization{ID: candidate.AuthorizationID, TaskVersion: candidate.TaskVersion, ExpiresAt: candidate.AuthorizationExpiresText},
Attempt: ClaimedAttempt{ID: attemptID, ClaimToken: hex.EncodeToString(token), ClaimGeneration: generation, LeaseExpiresAt: leaseText},
}
if err := transaction.Commit(); err != nil {
return ClaimResponse{}, false, err
}
return response, true, nil
}
func (store *Store) Renew(ctx context.Context, deviceID string, command RenewCommand) (RenewResponse, error) {
providedToken, tokenOK := decodeToken(command.ClaimToken)
if !deviceauth.ValidDeviceID(deviceID) || !validUUID(command.TaskID) || !validUUID(command.RenewRequestID) ||
!validUUID(command.SessionID) || !validUUID(command.AttemptID) || command.ClaimGeneration <= 0 ||
!tokenOK || !validCanonicalTime(command.ExpectedLeaseExpiresAt) {
return RenewResponse{}, ErrInvalid
}
providedHash := tokenHash(providedToken)
writeCtx, cancel := context.WithTimeout(ctx, writeTimeout)
defer cancel()
select {
case store.writeGate <- struct{}{}:
defer func() { <-store.writeGate }()
case <-writeCtx.Done():
return RenewResponse{}, writeCtx.Err()
}
transaction, err := store.database.BeginTx(writeCtx, nil)
if err != nil {
return RenewResponse{}, err
}
defer transaction.Rollback()
// As in ClaimNext, this is deliberately the first database statement in the transaction.
if store.beforeLinearization != nil {
store.beforeLinearization()
}
active, err := transaction.ExecContext(writeCtx, `UPDATE device_credentials SET status = status
WHERE device_id = ? AND status = 'ACTIVE' AND revoked_at IS NULL`, deviceID)
if err != nil {
return RenewResponse{}, err
}
if ok, err := exactlyOne(active); err != nil {
return RenewResponse{}, err
} else if !ok {
return RenewResponse{}, ErrDeviceInactive
}
if store.afterLinearization != nil {
store.afterLinearization()
}
renewal, found, err := findRenewal(writeCtx, transaction, command.RenewRequestID)
if err != nil {
return RenewResponse{}, err
}
if found {
if renewal.TaskID != command.TaskID || renewal.AttemptID != command.AttemptID || renewal.DeviceID != deviceID ||
renewal.SessionID != command.SessionID || renewal.Generation != command.ClaimGeneration ||
renewal.ExpectedLeaseExpiresAt != command.ExpectedLeaseExpiresAt || !matchingHash(renewal.TokenHash, providedHash) {
return RenewResponse{}, ErrIdempotencyConflict
}
response := RenewResponse{TaskID: renewal.TaskID, AttemptID: renewal.AttemptID, ClaimGeneration: renewal.Generation, LeaseExpiresAt: renewal.LeaseExpiresAt}
if err := transaction.Commit(); err != nil {
return RenewResponse{}, err
}
return response, nil
}
now, err := store.serverNow()
if err != nil {
return RenewResponse{}, err
}
record, found, err := store.loadClaimByAttempt(writeCtx, transaction, command.AttemptID)
if err != nil {
return RenewResponse{}, err
}
if !found || record.TaskID != command.TaskID || record.DeviceID != deviceID || record.SessionID != command.SessionID ||
record.Generation != command.ClaimGeneration || !matchingHash(record.TokenHash, providedHash) {
return RenewResponse{}, ErrNotCurrent
}
stateCurrent := record.ClosedAt == "" && record.LeaseExpiresAt.After(now) && record.AuthorizationExpiresAt.After(now) &&
record.CurrentAuthorizationExpiresAt.After(now) && record.AuthorizationStatus == "CLAIMED" &&
record.authorizationConsistent() && record.recoverableBusinessState()
if !stateCurrent || record.LeaseExpiresText != command.ExpectedLeaseExpiresAt {
return RenewResponse{}, ErrNotCurrent
}
leaseExpires := now.Add(store.leaseTTL)
if record.AuthorizationExpiresAt.Before(leaseExpires) {
leaseExpires = record.AuthorizationExpiresAt
}
leaseText := formatTime(leaseExpires)
updated, err := transaction.ExecContext(writeCtx, `UPDATE purchase_attempt_claims SET lease_expires_at = ?
WHERE attempt_id = ? AND lease_expires_at = ? AND closed_at IS NULL`, leaseText, command.AttemptID, command.ExpectedLeaseExpiresAt)
if err != nil {
return RenewResponse{}, err
}
if ok, err := exactlyOne(updated); err != nil || !ok {
if err == nil {
err = ErrNotCurrent
}
return RenewResponse{}, err
}
if _, err := transaction.ExecContext(writeCtx, `INSERT INTO purchase_attempt_lease_renewals
(renew_request_id, task_id, attempt_id, device_id, session_id, claim_generation,
claim_token_sha256, expected_lease_expires_at, lease_expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
command.RenewRequestID, command.TaskID, command.AttemptID, deviceID, command.SessionID,
command.ClaimGeneration, record.TokenHash, command.ExpectedLeaseExpiresAt, leaseText, formatTime(now)); err != nil {
return RenewResponse{}, err
}
response := RenewResponse{TaskID: command.TaskID, AttemptID: command.AttemptID, ClaimGeneration: command.ClaimGeneration, LeaseExpiresAt: leaseText}
if err := transaction.Commit(); err != nil {
return RenewResponse{}, err
}
return response, nil
}
type claimRequestRecord struct {
DeviceID, SessionID, Outcome, AttemptID, ResponseLeaseExpiresAt string
}
func findClaimRequest(ctx context.Context, transaction *sql.Tx, requestID string) (claimRequestRecord, bool, error) {
var record claimRequestRecord
var attemptID, responseLease sql.NullString
err := transaction.QueryRowContext(ctx, `SELECT device_id, session_id, outcome, attempt_id, response_lease_expires_at
FROM task_claim_requests WHERE claim_request_id = ?`, requestID).
Scan(&record.DeviceID, &record.SessionID, &record.Outcome, &attemptID, &responseLease)
if errors.Is(err, sql.ErrNoRows) {
return claimRequestRecord{}, false, nil
}
if err != nil {
return claimRequestRecord{}, false, err
}
record.AttemptID, record.ResponseLeaseExpiresAt = attemptID.String, responseLease.String
return record, true, nil
}
func insertClaimRequest(ctx context.Context, transaction *sql.Tx, requestID, deviceID, sessionID, outcome, attemptID, responseLease, errorCode string, now time.Time) error {
var attempt, lease, code any
if attemptID != "" {
attempt = attemptID
}
if responseLease != "" {
lease = responseLease
}
if errorCode != "" {
code = errorCode
}
_, err := transaction.ExecContext(ctx, `INSERT INTO task_claim_requests
(claim_request_id, device_id, session_id, outcome, attempt_id, response_lease_expires_at, error_code, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, requestID, deviceID, sessionID, outcome, attempt, lease, code, formatTime(now))
return err
}
type renewalRecord struct {
TaskID, AttemptID, DeviceID, SessionID string
Generation int
TokenHash []byte
ExpectedLeaseExpiresAt, LeaseExpiresAt string
}
func findRenewal(ctx context.Context, transaction *sql.Tx, requestID string) (renewalRecord, bool, error) {
var record renewalRecord
err := transaction.QueryRowContext(ctx, `SELECT task_id, attempt_id, device_id, session_id,
claim_generation, claim_token_sha256, expected_lease_expires_at, lease_expires_at
FROM purchase_attempt_lease_renewals WHERE renew_request_id = ?`, requestID).
Scan(&record.TaskID, &record.AttemptID, &record.DeviceID, &record.SessionID, &record.Generation,
&record.TokenHash, &record.ExpectedLeaseExpiresAt, &record.LeaseExpiresAt)
if errors.Is(err, sql.ErrNoRows) {
return renewalRecord{}, false, nil
}
return record, err == nil, err
}
type claimRecord struct {
AttemptID, TaskID, AuthorizationID, DeviceID, SessionID string
Generation, TaskVersion, CurrentTaskVersion int
TaskTitle string
Nonce, TokenHash []byte
LeaseExpiresText, ClaimedAt, ClosedAt string
LeaseExpiresAt time.Time
AuthorizationTaskVersion int
GoodsID, SKUColor, SKUSize, TotalPriceCap string
Quantity int
AuthorizationExpiresText, AuthorizationStatus string
AuthorizationExpiresAt time.Time
CurrentAuthorizationTaskVersion int
CurrentGoodsID, CurrentSKUColor, CurrentSKUSize string
CurrentQuantity int
CurrentTotalPriceCap, CurrentAuthorizationExpiresText string
CurrentAuthorizationExpiresAt time.Time
AttemptStatus, TaskStatus string
CurrentTaskTitle, CurrentTaskGoodsID string
CurrentTaskSKUColor, CurrentTaskSKUSize string
CurrentTaskQuantity int
CurrentTaskMaxTotalPrice string
CurrentAttemptGeneration int
}
const claimSelect = `SELECT claims.attempt_id, claims.task_id, claims.authorization_id,
claims.claimed_by_device_id, claims.session_id, claims.claim_generation, claims.task_version,
claims.task_title, claims.authorization_task_version, claims.goods_id, claims.sku_color,
claims.sku_size, claims.quantity, claims.total_price_cap, claims.authorization_expires_at,
claims.claim_nonce, claims.claim_token_sha256, claims.lease_expires_at,
claims.claimed_at, claims.closed_at, authorizations.task_version, authorizations.goods_id,
authorizations.sku_color, authorizations.sku_size, authorizations.quantity,
authorizations.total_price_cap, authorizations.expires_at, authorizations.status,
attempts.claim_generation, attempts.status, tasks.status, tasks.version, tasks.title, tasks.goods_id,
tasks.sku_color, tasks.sku_size, tasks.quantity, tasks.max_total_price
FROM purchase_attempt_claims AS claims
JOIN order_authorizations AS authorizations
ON authorizations.task_id = claims.task_id AND authorizations.id = claims.authorization_id
JOIN purchase_attempts AS attempts ON attempts.id = claims.attempt_id
JOIN tasks ON tasks.id = claims.task_id `
func (store *Store) loadOpenClaimByDevice(ctx context.Context, transaction *sql.Tx, deviceID string) (claimRecord, bool, error) {
return store.scanClaim(transaction.QueryRowContext(ctx, claimSelect+`WHERE claims.claimed_by_device_id = ? AND claims.closed_at IS NULL`, deviceID))
}
func (store *Store) loadClaimByAttempt(ctx context.Context, transaction *sql.Tx, attemptID string) (claimRecord, bool, error) {
return store.scanClaim(transaction.QueryRowContext(ctx, claimSelect+`WHERE claims.attempt_id = ?`, attemptID))
}
type rowScanner interface{ Scan(...any) error }
func (store *Store) scanClaim(row rowScanner) (claimRecord, bool, error) {
var record claimRecord
var closed sql.NullString
err := row.Scan(&record.AttemptID, &record.TaskID, &record.AuthorizationID, &record.DeviceID,
&record.SessionID, &record.Generation, &record.TaskVersion, &record.TaskTitle,
&record.AuthorizationTaskVersion, &record.GoodsID, &record.SKUColor, &record.SKUSize,
&record.Quantity, &record.TotalPriceCap, &record.AuthorizationExpiresText,
&record.Nonce, &record.TokenHash, &record.LeaseExpiresText, &record.ClaimedAt, &closed,
&record.CurrentAuthorizationTaskVersion, &record.CurrentGoodsID, &record.CurrentSKUColor,
&record.CurrentSKUSize, &record.CurrentQuantity, &record.CurrentTotalPriceCap,
&record.CurrentAuthorizationExpiresText,
&record.AuthorizationStatus, &record.CurrentAttemptGeneration, &record.AttemptStatus,
&record.TaskStatus, &record.CurrentTaskVersion,
&record.CurrentTaskTitle, &record.CurrentTaskGoodsID, &record.CurrentTaskSKUColor,
&record.CurrentTaskSKUSize, &record.CurrentTaskQuantity, &record.CurrentTaskMaxTotalPrice)
if errors.Is(err, sql.ErrNoRows) {
return claimRecord{}, false, nil
}
if err != nil {
return claimRecord{}, false, err
}
record.ClosedAt = closed.String
if !validUUID(record.AttemptID) || !validUUID(record.TaskID) || !validUUID(record.AuthorizationID) ||
!deviceauth.ValidDeviceID(record.DeviceID) || !validUUID(record.SessionID) || record.Generation <= 0 ||
record.CurrentAttemptGeneration != record.Generation ||
record.TaskVersion <= 0 || record.AuthorizationTaskVersion <= 0 ||
!taskmodel.ValidTaskWireFields(record.TaskTitle, record.GoodsID, record.SKUColor, record.SKUSize, record.TotalPriceCap) ||
record.Quantity <= 0 || len(record.Nonce) != sha256.Size || len(record.TokenHash) != sha256.Size {
return claimRecord{}, false, errors.New("stored task claim metadata is invalid")
}
record.LeaseExpiresAt, err = parseCanonicalTime(record.LeaseExpiresText)
if err != nil {
return claimRecord{}, false, errors.New("stored task claim lease is invalid")
}
record.AuthorizationExpiresAt, err = parseCanonicalTime(record.AuthorizationExpiresText)
if err != nil {
return claimRecord{}, false, errors.New("stored authorization expiry is invalid")
}
record.CurrentAuthorizationExpiresAt, err = parseCanonicalTime(record.CurrentAuthorizationExpiresText)
if err != nil {
return claimRecord{}, false, errors.New("current authorization expiry is invalid")
}
derived := deriveToken(store.secret, record.DeviceID, record.TaskID, record.AuthorizationID, record.AttemptID, record.Generation, record.Nonce)
if !matchingHash(tokenHash(derived), record.TokenHash) {
return claimRecord{}, false, errors.New("task claim secret does not match stored claim")
}
return record, true, nil
}
func (record claimRecord) authorizationConsistent() bool {
return taskmodel.ValidAuthorizationFields(record.CurrentGoodsID, record.CurrentSKUColor,
record.CurrentSKUSize, record.CurrentTotalPriceCap) &&
taskmodel.ValidTaskWireFields(record.CurrentTaskTitle, record.CurrentTaskGoodsID,
record.CurrentTaskSKUColor, record.CurrentTaskSKUSize, record.CurrentTaskMaxTotalPrice) &&
record.AuthorizationTaskVersion == record.CurrentAuthorizationTaskVersion &&
record.GoodsID == record.CurrentGoodsID && record.SKUColor == record.CurrentSKUColor &&
record.SKUSize == record.CurrentSKUSize && record.Quantity == record.CurrentQuantity &&
record.TotalPriceCap == record.CurrentTotalPriceCap &&
record.AuthorizationExpiresText == record.CurrentAuthorizationExpiresText &&
record.TaskTitle == record.CurrentTaskTitle && record.GoodsID == record.CurrentTaskGoodsID &&
record.SKUColor == record.CurrentTaskSKUColor && record.SKUSize == record.CurrentTaskSKUSize &&
record.Quantity == record.CurrentTaskQuantity && record.TotalPriceCap == record.CurrentTaskMaxTotalPrice
}
func (record claimRecord) recoverableBusinessState() bool {
if record.TaskStatus == "CLAIMED" && record.AttemptStatus == "CLAIMED" {
return record.CurrentTaskVersion == record.TaskVersion
}
// A later server task may advance this same attempt to ORDERING. A valid lease and identical
// ownership recover that attempt; claim-next still cannot select another task.
return record.TaskStatus == "ORDERING" && record.AttemptStatus == "ORDERING" &&
record.TaskVersion < math.MaxInt && record.CurrentTaskVersion == record.TaskVersion+1
}
func (store *Store) responseFor(record claimRecord, responseLease string) (ClaimResponse, error) {
// Exact idempotent replay is allowed to ignore later source-row drift, but the
// immutable response snapshot itself must still satisfy the current wire bounds.
if !validCanonicalTime(responseLease) ||
!taskmodel.ValidTaskWireFields(record.TaskTitle, record.GoodsID, record.SKUColor, record.SKUSize, record.TotalPriceCap) ||
record.Quantity <= 0 {
return ClaimResponse{}, errors.New("stored claim response snapshot is invalid")
}
token := deriveToken(store.secret, record.DeviceID, record.TaskID, record.AuthorizationID, record.AttemptID, record.Generation, record.Nonce)
return ClaimResponse{
Task: ClaimedTask{ID: record.TaskID, Version: record.TaskVersion, Title: record.TaskTitle,
ProductURL: productURL(record.GoodsID), GoodsID: record.GoodsID, SKUColor: record.SKUColor,
SKUSize: record.SKUSize, Quantity: record.Quantity, MaxTotalPrice: record.TotalPriceCap},
Authorization: ClaimedAuthorization{ID: record.AuthorizationID, TaskVersion: record.AuthorizationTaskVersion, ExpiresAt: record.AuthorizationExpiresText},
Attempt: ClaimedAttempt{ID: record.AttemptID, ClaimToken: hex.EncodeToString(token), ClaimGeneration: record.Generation, LeaseExpiresAt: responseLease},
}, nil
}
type candidate struct {
AuthorizationID, TaskID, Title, GoodsID, SKUColor, SKUSize, TotalPriceCap string
TaskVersion, Quantity int
AuthorizationTaskVersion, AuthorizationQuantity int
AuthorizationGoodsID, AuthorizationSKUColor, AuthorizationSKUSize string
AuthorizationTotalPriceCap string
AuthorizationExpiresText string
AuthorizationExpiresAt time.Time
}
func findCandidate(ctx context.Context, transaction *sql.Tx, now time.Time) (candidate, bool, error) {
rows, err := transaction.QueryContext(ctx, `SELECT authorizations.id, tasks.id, tasks.version,
tasks.title, tasks.goods_id, tasks.sku_color, tasks.sku_size, tasks.quantity,
tasks.max_total_price, authorizations.task_version, authorizations.goods_id,
authorizations.sku_color, authorizations.sku_size, authorizations.quantity,
authorizations.total_price_cap, authorizations.expires_at
FROM order_authorizations AS authorizations
JOIN tasks ON tasks.id = authorizations.task_id
WHERE authorizations.status = 'ACTIVE' AND tasks.status = 'PENDING'
ORDER BY authorizations.created_at, authorizations.rowid, authorizations.id`)
if err != nil {
return candidate{}, false, err
}
defer rows.Close()
for rows.Next() {
var item candidate
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &item.Title,
&item.GoodsID, &item.SKUColor, &item.SKUSize, &item.Quantity, &item.TotalPriceCap,
&item.AuthorizationTaskVersion, &item.AuthorizationGoodsID, &item.AuthorizationSKUColor,
&item.AuthorizationSKUSize, &item.AuthorizationQuantity, &item.AuthorizationTotalPriceCap,
&item.AuthorizationExpiresText); err != nil {
return candidate{}, false, err
}
item.AuthorizationExpiresAt, err = parseCanonicalTime(item.AuthorizationExpiresText)
if err != nil {
return candidate{}, false, errors.New("stored authorization expiry is invalid")
}
if !validCandidate(item) {
return candidate{}, false, errors.New("stored claim candidate is invalid")
}
if !candidateSnapshotMatches(item) {
continue
}
if item.AuthorizationExpiresAt.After(now) {
if err := rows.Close(); err != nil {
return candidate{}, false, err
}
return item, true, nil
}
}
if err := rows.Err(); err != nil {
return candidate{}, false, err
}
return candidate{}, false, nil
}
func validCandidate(item candidate) bool {
return validUUID(item.AuthorizationID) && validUUID(item.TaskID) && item.TaskVersion > 0 && item.TaskVersion < math.MaxInt &&
taskmodel.ValidTaskWireFields(item.Title, item.GoodsID, item.SKUColor, item.SKUSize, item.TotalPriceCap) &&
item.Quantity > 0 && item.AuthorizationTaskVersion > 0 && item.AuthorizationTaskVersion < math.MaxInt &&
taskmodel.ValidAuthorizationFields(item.AuthorizationGoodsID, item.AuthorizationSKUColor,
item.AuthorizationSKUSize, item.AuthorizationTotalPriceCap) && item.AuthorizationQuantity > 0
}
func candidateSnapshotMatches(item candidate) bool {
return item.AuthorizationTaskVersion == item.TaskVersion && item.AuthorizationGoodsID == item.GoodsID &&
item.AuthorizationSKUColor == item.SKUColor && item.AuthorizationSKUSize == item.SKUSize &&
item.AuthorizationQuantity == item.Quantity && item.AuthorizationTotalPriceCap == item.TotalPriceCap
}
func validAttemptStatus(value sql.NullString) bool {
return value.Valid && oneOf(value.String, "CLAIMED", "ORDERING", "FAILED", "FENCED", "ABANDONED")
}
func validAuthorizationStatus(value sql.NullString) bool {
return value.Valid && oneOf(value.String, "ACTIVE", "CLAIMED", "FENCED", "CONSUMED", "EXPIRED", "ABANDONED")
}
func validTaskStatus(value sql.NullString) bool {
return value.Valid && oneOf(value.String, "DRAFT", "PENDING", "CLAIMED", "ORDERING", "NEEDS_MANUAL",
"WAITING_PAYMENT", "RECONCILIATION_REQUIRED", "SUCCEEDED", "FAILED", "CANCELED")
}
func oneOf(value string, allowed ...string) bool {
for _, item := range allowed {
if value == item {
return true
}
}
return false
}
func nextGeneration(ctx context.Context, transaction *sql.Tx, taskID string) (int, error) {
var maximum int64
if err := transaction.QueryRowContext(ctx, `SELECT COALESCE(MAX(claim_generation), 0) FROM purchase_attempts WHERE task_id = ?`, taskID).Scan(&maximum); err != nil {
return 0, err
}
if maximum < 0 || maximum >= int64(math.MaxInt) {
return 0, errors.New("task claim generation is exhausted")
}
return int(maximum) + 1, nil
}
func (store *Store) serverNow() (time.Time, error) {
now := store.now().UTC()
if now.IsZero() {
return time.Time{}, errors.New("task claim clock is invalid")
}
return now, nil
}
func (store *Store) randomBytes(size int) ([]byte, error) {
value := make([]byte, size)
store.randomMu.Lock()
_, err := io.ReadFull(store.random, value)
store.randomMu.Unlock()
if err != nil {
return nil, fmt.Errorf("generate task claim randomness: %w", err)
}
return value, nil
}
func (store *Store) newUUID() (string, error) {
value, err := store.randomBytes(16)
if err != nil {
return "", err
}
value[6] = (value[6] & 0x0f) | 0x40
value[8] = (value[8] & 0x3f) | 0x80
encoded := hex.EncodeToString(value)
return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:], nil
}
func exactlyOne(result sql.Result) (bool, error) {
rows, err := result.RowsAffected()
return rows == 1, err
}
func formatTime(value time.Time) string { return value.UTC().Format(time.RFC3339Nano) }
func parseCanonicalTime(value string) (time.Time, error) {
if !strings.HasSuffix(value, "Z") || strings.TrimSpace(value) != value {
return time.Time{}, ErrInvalid
}
parsed, err := time.Parse(time.RFC3339Nano, value)
if err != nil || parsed.Location() != time.UTC || formatTime(parsed) != value {
return time.Time{}, ErrInvalid
}
return parsed, nil
}
func validCanonicalTime(value string) bool {
_, err := parseCanonicalTime(value)
return err == 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 productURL(goodsID string) string {
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID
}
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
package taskclaim
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/binary"
"encoding/hex"
"hash"
)
const tokenDomain = "cmbuyer/task-claim-token/v1\x00"
func deriveToken(secret []byte, deviceID, taskID, authorizationID, attemptID string, generation int, nonce []byte) []byte {
mac := hmac.New(sha256.New, secret)
_, _ = mac.Write([]byte(tokenDomain))
writeTokenField(mac, deviceID)
writeTokenField(mac, taskID)
writeTokenField(mac, authorizationID)
writeTokenField(mac, attemptID)
var number [8]byte
binary.BigEndian.PutUint64(number[:], uint64(generation))
_, _ = mac.Write(number[:])
writeTokenBytes(mac, nonce)
return mac.Sum(nil)
}
func writeTokenField(writer hash.Hash, value string) { writeTokenBytes(writer, []byte(value)) }
func writeTokenBytes(writer hash.Hash, value []byte) {
var size [4]byte
binary.BigEndian.PutUint32(size[:], uint32(len(value)))
_, _ = writer.Write(size[:])
_, _ = writer.Write(value)
}
func tokenHash(token []byte) []byte {
sum := sha256.Sum256(token)
return sum[:]
}
func matchingHash(left, right []byte) bool {
return len(left) == sha256.Size && len(right) == sha256.Size && subtle.ConstantTimeCompare(left, right) == 1
}
func decodeToken(value string) ([]byte, bool) {
if len(value) != sha256.Size*2 {
return nil, false
}
decoded, err := hex.DecodeString(value)
return decoded, err == nil && hex.EncodeToString(decoded) == value
}
+100
View File
@@ -0,0 +1,100 @@
// Package taskclaim owns the atomic task-claim and lease-renewal boundary.
// A claim token proves only ownership of one attempt; it is never permission to submit an order.
package taskclaim
import (
"context"
"errors"
"math"
taskmodel "cmbuyer/admin/internal/tasks"
)
var (
ErrInvalid = errors.New("invalid task claim request")
ErrIdempotencyConflict = errors.New("task claim idempotency conflict")
ErrRequiresManual = errors.New("task claim requires manual recovery")
ErrNotCurrent = errors.New("task claim is not current")
ErrDeviceInactive = errors.New("task claim device is inactive")
)
type ClaimCommand struct {
SessionID string `json:"session_id"`
ClaimRequestID string `json:"claim_request_id"`
}
type RenewCommand struct {
TaskID string `json:"-"`
RenewRequestID string `json:"renew_request_id"`
SessionID string `json:"session_id"`
AttemptID string `json:"attempt_id"`
ClaimGeneration int `json:"claim_generation"`
ClaimToken string `json:"claim_token"`
ExpectedLeaseExpiresAt string `json:"expected_lease_expires_at"`
}
type ClaimedTask struct {
ID string `json:"id"`
Version int `json:"version"`
Title string `json:"title"`
ProductURL string `json:"product_url"`
GoodsID string `json:"goods_id"`
SKUColor string `json:"sku_color"`
SKUSize string `json:"sku_size"`
Quantity int `json:"quantity"`
MaxTotalPrice string `json:"max_total_price"`
}
type ClaimedAuthorization struct {
ID string `json:"id"`
TaskVersion int `json:"task_version"`
ExpiresAt string `json:"expires_at"`
}
type ClaimedAttempt struct {
ID string `json:"id"`
ClaimToken string `json:"claim_token"`
ClaimGeneration int `json:"claim_generation"`
LeaseExpiresAt string `json:"lease_expires_at"`
}
type ClaimResponse struct {
Task ClaimedTask `json:"task"`
Authorization ClaimedAuthorization `json:"authorization"`
Attempt ClaimedAttempt `json:"attempt"`
}
// ValidClaimResponse closes the service-to-HTTP boundary as well as the SQLite
// boundary. A fake or future Service implementation cannot bypass the same field
// limits enforced while creating and claiming the task.
func ValidClaimResponse(response ClaimResponse) bool {
authorizationExpires, authorizationErr := parseCanonicalTime(response.Authorization.ExpiresAt)
leaseExpires, leaseErr := parseCanonicalTime(response.Attempt.LeaseExpiresAt)
return validUUID(response.Task.ID) && response.Task.Version > 0 &&
response.Authorization.TaskVersion > 0 && response.Authorization.TaskVersion < math.MaxInt &&
response.Task.Version == response.Authorization.TaskVersion+1 &&
taskmodel.ValidTaskWireFields(response.Task.Title, response.Task.GoodsID,
response.Task.SKUColor, response.Task.SKUSize, response.Task.MaxTotalPrice) &&
response.Task.ProductURL == productURL(response.Task.GoodsID) && response.Task.Quantity > 0 &&
validUUID(response.Authorization.ID) && authorizationErr == nil &&
validUUID(response.Attempt.ID) && response.Attempt.ClaimGeneration > 0 &&
len(response.Attempt.ClaimToken) == 64 && tokenTextValid(response.Attempt.ClaimToken) &&
leaseErr == nil && !leaseExpires.After(authorizationExpires)
}
func tokenTextValid(value string) bool {
_, ok := decodeToken(value)
return ok
}
type RenewResponse struct {
TaskID string `json:"task_id"`
AttemptID string `json:"attempt_id"`
ClaimGeneration int `json:"claim_generation"`
LeaseExpiresAt string `json:"lease_expires_at"`
}
type Service interface {
ClaimNext(context.Context, string, ClaimCommand) (ClaimResponse, bool, error)
Renew(context.Context, string, RenewCommand) (RenewResponse, error)
}
+16 -1
View File
@@ -17,11 +17,17 @@ const (
detailTask = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
detailAuth = "b3c9f507-7473-4fa6-8d71-8786c34c6301"
detailTry = "c3c9f507-7473-4fa6-8d71-8786c34c6301"
detailDevice = "e3c9f507-7473-4fa6-8d71-8786c34c6301"
)
func TestSQLiteStoreReturnsOnlyPersistedAuditFacts(t *testing.T) {
database := openDetailDatabase(t)
timestamp := "2026-08-04T00:00:00Z"
if _, err := database.Exec(`INSERT INTO device_credentials
(device_id,display_name,token_sha256,status,created_at,revoked_at)
VALUES (?, 'detail test device', zeroblob(32), 'ACTIVE', ?, NULL)`, detailDevice, timestamp); err != nil {
t.Fatalf("insert device: %v", 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 (?, 'MANUAL', 'shirt', '123', 'black', 'M', 2, '30.00', 'CLAIMED', 3, ?, ?)`, detailTask, timestamp, timestamp); err != nil {
t.Fatalf("insert task: %v", err)
}
@@ -31,8 +37,17 @@ func TestSQLiteStoreReturnsOnlyPersistedAuditFacts(t *testing.T) {
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, detailTry, detailTask, detailAuth, timestamp); err != nil {
t.Fatalf("insert attempt: %v", err)
}
if _, err := database.Exec(`INSERT INTO purchase_attempt_claims
(attempt_id,task_id,authorization_id,claimed_by_device_id,session_id,claim_generation,
task_version,task_title,authorization_task_version,goods_id,sku_color,sku_size,quantity,
total_price_cap,authorization_expires_at,claim_nonce,claim_token_sha256,lease_expires_at,claimed_at,closed_at)
VALUES (?, ?, ?, ?, 'f3c9f507-7473-4fa6-8d71-8786c34c6301', 1, 3, 'shirt',
2, '123', 'black', 'M', 2, '30.00', ?, zeroblob(32), zeroblob(32),
'2026-08-04T00:05:00Z', ?, NULL)`, detailTry, detailTask, detailAuth, detailDevice, timestamp, timestamp); err != nil {
t.Fatalf("insert claim: %v", err)
}
hash := strings.Repeat("a", 64)
if _, err := database.Exec(`INSERT INTO evidence_assets (id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at) VALUES ('d3c9f507-7473-4fa6-8d71-8786c34c6301', 'upload', ?, ?, 'SKU_PANEL_GATE_1', 'INTERNAL_RAW', ?, 100, 'image/png', 10, 20, ?, 'device', ?, ?)`, detailTask, detailTry, hash, "aa/"+hash+".png", timestamp, timestamp); err != nil {
if _, err := database.Exec(`INSERT INTO evidence_assets (id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at) VALUES ('d3c9f507-7473-4fa6-8d71-8786c34c6301', 'upload', ?, ?, 'SKU_PANEL_GATE_1', 'INTERNAL_RAW', ?, 100, 'image/png', 10, 20, ?, ?, ?, ?)`, detailTask, detailTry, hash, "aa/"+hash+".png", detailDevice, timestamp, timestamp); err != nil {
t.Fatalf("insert evidence: %v", err)
}
store, err := NewSQLiteStore(database)
+6 -1
View File
@@ -50,7 +50,7 @@ type TaskRow struct {
}
func normalizeCents(value string) (string, *big.Int, bool) {
if value == "" || strings.TrimSpace(value) != value {
if value == "" || len(value) > MaxMoneyASCIICharacters || strings.TrimSpace(value) != value {
return "", nil, false
}
parts := strings.Split(value, ".")
@@ -71,6 +71,11 @@ func normalizeCents(value string) (string, *big.Int, bool) {
return value, cents, true
}
func ValidCanonicalMoney(value string) bool {
canonical, _, ok := normalizeCents(value)
return ok && canonical == value
}
func startItems(command StartCommand) ([]StartItem, error) {
if !validUUID(command.StartKey) || len(command.Tasks) == 0 || len(command.Tasks) > maxStartItems {
return nil, ErrInvalidStart
@@ -8,6 +8,7 @@ import (
"math"
"reflect"
"sort"
"strings"
"sync"
"testing"
"time"
@@ -164,12 +165,27 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T
"nondigit goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET goods_id='937x' WHERE id=?`, id)
},
"overlong goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET goods_id=? WHERE id=?`, strings.Repeat("1", MaxGoodsIDCharacters+1), id)
},
"invalid utf8 title": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET title=? WHERE id=?`, string([]byte{0xff}), id)
},
"overlong title": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", MaxTitleCodePoints+1), id)
},
"empty color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_color='' WHERE id=?`, id)
},
"overlong color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_color=? WHERE id=?`, strings.Repeat("色", MaxSKUTextCodePoints+1), id)
},
"empty size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_size='' WHERE id=?`, id)
},
"overlong size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_size=? WHERE id=?`, strings.Repeat("码", MaxSKUTextCodePoints+1), id)
},
"quantity over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
store.policy.MaxQuantity = 1
},
@@ -179,6 +195,9 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T
"noncanonical leading zero": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET max_total_price='012.80' WHERE id=?`, id)
},
"overlong canonical price": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET max_total_price=? WHERE id=?`, strings.Repeat("1", MaxMoneyASCIICharacters-2)+".00", id)
},
"price over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
store.policy.MaxTotalPrice = "12.79"
},
@@ -199,6 +218,43 @@ func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T
}
}
func TestStartPurchasesReplayRejectsMalformedAuthorizationOrTaskSnapshot(t *testing.T) {
mutations := map[string]func(*testing.T, *sql.DB, string){
"authorization goods id": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE order_authorizations SET goods_id=? WHERE task_id=?`, strings.Repeat("1", MaxGoodsIDCharacters+1), id)
},
"authorization color": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE order_authorizations SET sku_color=? WHERE task_id=?`, strings.Repeat("色", MaxSKUTextCodePoints+1), id)
},
"authorization size": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE order_authorizations SET sku_size=? WHERE task_id=?`, strings.Repeat("码", MaxSKUTextCodePoints+1), id)
},
"authorization money": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE order_authorizations SET total_price_cap=? WHERE task_id=?`, strings.Repeat("1", MaxMoneyASCIICharacters-2)+".00", id)
},
"task title": func(t *testing.T, database *sql.DB, id string) {
execTestSQL(t, database, `UPDATE tasks SET title=? WHERE id=?`, strings.Repeat("😀", MaxTitleCodePoints+1), id)
},
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
id := startTestUUID(1)
createStartDraft(t, store, id)
command := StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: id, ExpectedTaskVersion: 1}}}
if _, err := store.StartPurchases(context.Background(), command, "admin"); err != nil {
t.Fatal(err)
}
mutate(t, database, id)
if _, err := store.StartPurchases(context.Background(), command, "admin"); !errors.Is(err, ErrStartConflict) {
t.Fatalf("replay error = %v, want ErrStartConflict", err)
}
assertAuthorizationCount(t, database, 1)
})
}
}
func TestStartPurchasesRollsBackWholeBatchForLateConflictAndSQLFailure(t *testing.T) {
for _, test := range []struct {
name string
+25 -15
View File
@@ -142,7 +142,9 @@ func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartComma
}
return StartResult{}, err
}
if status != "DRAFT" || version != item.ExpectedTaskVersion || !goodsIDValid(goods) || color == "" || size == "" || quantity < 1 || quantity > store.policy.MaxQuantity {
if status != "DRAFT" || version != item.ExpectedTaskVersion ||
!ValidTaskWireFields(title, goods, color, size, price) ||
quantity < 1 || quantity > store.policy.MaxQuantity {
return StartResult{}, ErrStartConflict
}
canonical, cents, ok := normalizeCents(price)
@@ -179,20 +181,15 @@ func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartComma
return result, nil
}
func goodsIDValid(value string) bool {
if value == "" {
return false
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func replayStart(ctx context.Context, tx *sql.Tx, startKey string, items []StartItem) (StartResult, bool, error) {
rows, err := tx.QueryContext(ctx, "SELECT id,task_id,task_version,expires_at FROM order_authorizations WHERE start_key=? ORDER BY task_id", startKey)
rows, err := tx.QueryContext(ctx, `SELECT authorizations.id,authorizations.task_id,
authorizations.task_version,authorizations.expires_at,authorizations.goods_id,
authorizations.sku_color,authorizations.sku_size,authorizations.quantity,
authorizations.total_price_cap,tasks.title,tasks.goods_id,tasks.sku_color,
tasks.sku_size,tasks.quantity,tasks.max_total_price
FROM order_authorizations AS authorizations
JOIN tasks ON tasks.id = authorizations.task_id
WHERE authorizations.start_key=? ORDER BY authorizations.task_id`, startKey)
if err != nil {
return StartResult{}, false, err
}
@@ -201,9 +198,22 @@ func replayStart(ctx context.Context, tx *sql.Tx, startKey string, items []Start
for rows.Next() {
var item AuthorizedTask
var expires string
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &expires); err != nil {
var authorizationGoodsID, authorizationColor, authorizationSize, authorizationPrice string
var taskTitle, taskGoodsID, taskColor, taskSize, taskPrice string
var authorizationQuantity, taskQuantity int
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &expires,
&authorizationGoodsID, &authorizationColor, &authorizationSize, &authorizationQuantity,
&authorizationPrice, &taskTitle, &taskGoodsID, &taskColor, &taskSize, &taskQuantity,
&taskPrice); err != nil {
return StartResult{}, false, err
}
if !ValidAuthorizationFields(authorizationGoodsID, authorizationColor, authorizationSize, authorizationPrice) ||
authorizationQuantity <= 0 ||
!ValidTaskWireFields(taskTitle, taskGoodsID, taskColor, taskSize, taskPrice) || taskQuantity <= 0 ||
authorizationGoodsID != taskGoodsID || authorizationColor != taskColor ||
authorizationSize != taskSize || authorizationQuantity != taskQuantity || authorizationPrice != taskPrice {
return StartResult{}, false, ErrStartConflict
}
item.ExpiresAt, err = time.Parse(time.RFC3339Nano, expires)
if err != nil {
return StartResult{}, false, err
+5
View File
@@ -37,6 +37,11 @@ func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
}
func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) {
// Validate again at the persistence boundary. HTTP form validation is not the only
// caller, and a malformed row here would later make an authorized claim unencodable.
if !validUUID(draft.ID) || !ValidTaskWireFields(draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.MaxTotalPrice) || draft.Quantity <= 0 {
return Draft{}, ErrInvalidDraft
}
writeContext, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
defer cancel()
// SQLite permits one writer at a time. Serializing this store's short create
+66 -7
View File
@@ -9,14 +9,21 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
)
const (
maxTitleLength = 120
maxSKUText = 80
MaxTitleCodePoints = 120
MaxSKUTextCodePoints = 80
MaxGoodsIDCharacters = 32
MaxMoneyASCIICharacters = 32
maxSKUText = MaxSKUTextCodePoints
)
var ErrCreateKeyConflict = errors.New("create key conflicts with a different task")
var (
ErrCreateKeyConflict = errors.New("create key conflicts with a different task")
ErrInvalidDraft = errors.New("invalid draft")
)
type Draft struct {
ID string
@@ -41,13 +48,13 @@ func Validate(form Form) (Draft, Errors) {
if !validUUID(draft.ID) {
errors["create_key"] = "创建请求已过期,请重新打开表单。"
}
if draft.Title == "" || len([]rune(draft.Title)) > maxTitleLength {
if !validBoundedText(draft.Title, MaxTitleCodePoints) {
errors["title"] = "任务名称不能为空,且不能超过 120 个字符。"
}
if draft.SKUColor == "" || len([]rune(draft.SKUColor)) > maxSKUText {
if !validBoundedText(draft.SKUColor, MaxSKUTextCodePoints) {
errors["sku_color"] = "颜色分类不能为空,且不能超过 80 个字符。"
}
if draft.SKUSize == "" || len([]rune(draft.SKUSize)) > maxSKUText {
if !validBoundedText(draft.SKUSize, MaxSKUTextCodePoints) {
errors["sku_size"] = "尺码不能为空,且不能超过 80 个字符。"
}
goodsID, ok := CanonicalGoodsID(strings.TrimSpace(form.ProductURL))
@@ -93,6 +100,9 @@ func CanonicalGoodsID(value string) (string, bool) {
return "", false
}
}
if !ValidGoodsID(goodsIDs[0]) {
return "", false
}
return goodsIDs[0], true
}
@@ -155,5 +165,54 @@ func normalizeMoney(value string) (string, bool) {
if whole == "0" && strings.Trim(fraction, "0") == "" {
return "", false
}
return whole + "." + (fraction + "00")[:2], true
canonical := whole + "." + (fraction + "00")[:2]
if len(canonical) > MaxMoneyASCIICharacters {
return "", false
}
return canonical, true
}
// ValidTaskWireFields is shared by creation, authorization and claim. Keeping one
// bounded domain prevents a database row from being valid in one stage but impossible
// to encode inside the fixed claim response budget in another stage.
func ValidTaskWireFields(title, goodsID, skuColor, skuSize, maxTotalPrice string) bool {
return validBoundedText(title, MaxTitleCodePoints) &&
ValidAuthorizationFields(goodsID, skuColor, skuSize, maxTotalPrice)
}
func ValidAuthorizationFields(goodsID, skuColor, skuSize, totalPriceCap string) bool {
return ValidGoodsID(goodsID) &&
validBoundedText(skuColor, MaxSKUTextCodePoints) &&
validBoundedText(skuSize, MaxSKUTextCodePoints) &&
ValidCanonicalMoney(totalPriceCap)
}
func ValidGoodsID(value string) bool {
if value == "" || len(value) > MaxGoodsIDCharacters {
return false
}
for index := 0; index < len(value); index++ {
if value[index] < '0' || value[index] > '9' {
return false
}
}
return true
}
func validBoundedText(value string, maximum int) bool {
// RuneCountInString replaces malformed byte sequences with RuneError. Validate first
// so corrupt SQLite text cannot consume the code-point budget as if it were legitimate.
if !utf8.ValidString(value) || value == "" || strings.TrimSpace(value) != value ||
utf8.RuneCountInString(value) > maximum {
return false
}
for _, character := range value {
// Python str.strip treats these four C0 separators as whitespace while Go
// TrimSpace does not. Reject them everywhere so both wire models have one
// explicit persisted-text domain instead of runtime-dependent trimming.
if character >= '\u001c' && character <= '\u001f' {
return false
}
}
return true
}
+81 -2
View File
@@ -7,6 +7,7 @@ import (
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"testing"
"time"
@@ -39,7 +40,10 @@ 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 = " " },
"invalid utf8 title": func(form *Form) { form.Title = string([]byte{0xff}) },
"long title": func(form *Form) { form.Title = strings.Repeat("😀", MaxTitleCodePoints+1) },
"long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) },
"invalid utf8 size": func(form *Form) { form.SKUSize = string([]byte{0xff}) },
"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" },
@@ -67,6 +71,7 @@ func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
"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",
"https://mobile.yangkeduo.com/goods.html?goods_id=" + strings.Repeat("1", MaxGoodsIDCharacters+1),
} {
if _, ok := CanonicalGoodsID(value); ok {
t.Fatalf("CanonicalGoodsID accepted %q", value)
@@ -75,19 +80,62 @@ func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
}
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"} {
maximum := strings.Repeat("9", MaxMoneyASCIICharacters-3) + ".00"
for value, want := range map[string]string{"1": "1.00", "1.2": "1.20", "000.01": "0.01", "999999999999999999": "999999999999999999.00", maximum: maximum} {
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"} {
for _, value := range []string{"0", "0.0", "0.00", "1.", ".1", "1.000", "-1", "1e2", " 1", strings.Repeat("9", MaxMoneyASCIICharacters-2) + ".00"} {
if got, ok := normalizeMoney(value); ok {
t.Fatalf("normalizeMoney(%q) = %q, want rejection", value, got)
}
}
}
func TestValidateAcceptsWorstLegalUnicodeFieldBounds(t *testing.T) {
goodsID := strings.Repeat("1", MaxGoodsIDCharacters)
draft, validation := Validate(Form{
CreateKey: testKey, Title: strings.Repeat("😀", MaxTitleCodePoints),
ProductURL: CanonicalURL(goodsID), SKUColor: strings.Repeat("色", MaxSKUTextCodePoints),
SKUSize: strings.Repeat("码", MaxSKUTextCodePoints), Quantity: "1",
MaxTotalPrice: strings.Repeat("9", MaxMoneyASCIICharacters-3) + ".00",
})
if !validation.Valid() || !ValidTaskWireFields(draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.MaxTotalPrice) {
t.Fatalf("worst legal draft = %#v, validation = %#v", draft, validation)
}
}
func TestPersistedTextHasRuntimeIndependentC0AndNBSPDomain(t *testing.T) {
for name, invalid := range map[string]string{
"c0 prefix": "\u001cvalue",
"c0 suffix": "value\u001f",
"c0 interior": "value\u001dinside",
"nbsp prefix": "\u00a0value",
"nbsp suffix": "value\u00a0",
} {
t.Run(name, func(t *testing.T) {
if validBoundedText(invalid, MaxTitleCodePoints) {
t.Fatalf("validBoundedText(%q) accepted runtime-dependent text", invalid)
}
})
}
if !validBoundedText("left\u00a0right", MaxTitleCodePoints) {
t.Fatal("interior NBSP must remain a valid Unicode code point")
}
// Manual form input is normalized with Go TrimSpace before persistence.
draft, validation := Validate(Form{
CreateKey: testKey, Title: "\u00a0title\u00a0",
ProductURL: CanonicalURL("1"), SKUColor: "\u00a0black\u00a0",
SKUSize: "\u00a0M\u00a0", Quantity: "1", MaxTotalPrice: "1",
})
if !validation.Valid() || draft.Title != "title" || draft.SKUColor != "black" || draft.SKUSize != "M" {
t.Fatalf("NBSP form normalization = %#v, errors = %#v", draft, validation)
}
}
func TestNewCreateKeyIsUUIDv4(t *testing.T) {
key, err := NewCreateKey()
if err != nil {
@@ -157,6 +205,37 @@ func TestSQLiteStoreCreatesListsAndHandlesIdempotency(t *testing.T) {
}
}
func TestSQLiteStoreRejectsInvalidDraftAtPersistenceBoundary(t *testing.T) {
mutations := map[string]func(*Draft){
"untrimmed title": func(draft *Draft) { draft.Title = " title" },
"c0 interior title": func(draft *Draft) { draft.Title = "title\u001dhidden" },
"invalid utf8 title": func(draft *Draft) { draft.Title = string([]byte{0xff}) },
"long title": func(draft *Draft) { draft.Title = strings.Repeat("😀", MaxTitleCodePoints+1) },
"long color": func(draft *Draft) { draft.SKUColor = strings.Repeat("色", MaxSKUTextCodePoints+1) },
"long size": func(draft *Draft) { draft.SKUSize = strings.Repeat("码", MaxSKUTextCodePoints+1) },
"long goods id": func(draft *Draft) { draft.GoodsID = strings.Repeat("1", MaxGoodsIDCharacters+1) },
"long money": func(draft *Draft) { draft.MaxTotalPrice = strings.Repeat("1", MaxMoneyASCIICharacters-2) + ".00" },
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
draft := testDraft(testKey, "title")
mutate(&draft)
if _, err := store.CreateDraft(context.Background(), draft); !errors.Is(err, ErrInvalidDraft) {
t.Fatalf("CreateDraft error = %v, want ErrInvalidDraft", err)
}
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM tasks").Scan(&count); err != nil || count != 0 {
t.Fatalf("tasks after invalid create = %d, err %v", count, err)
}
})
}
}
func TestSQLiteStoreRollsBackFailedCreate(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
@@ -0,0 +1,62 @@
-- +goose Up
CREATE TABLE device_credentials (
device_id TEXT PRIMARY KEY CHECK (
length(device_id) = 36
AND substr(device_id, 9, 1) = '-'
AND substr(device_id, 14, 1) = '-'
AND substr(device_id, 19, 1) = '-'
AND substr(device_id, 24, 1) = '-'
AND length(replace(device_id, '-', '')) = 32
AND replace(device_id, '-', '') NOT GLOB '*[^0-9a-f]*'
AND substr(device_id, 15, 1) = '4'
AND substr(device_id, 20, 1) IN ('8', '9', 'a', 'b')
),
display_name TEXT NOT NULL CHECK (
display_name = trim(display_name)
AND length(display_name) BETWEEN 1 AND 128
),
token_sha256 BLOB NOT NULL UNIQUE CHECK (
typeof(token_sha256) = 'blob'
AND length(token_sha256) = 32
),
status TEXT NOT NULL CHECK (status IN ('ACTIVE', 'REVOKED')),
created_at TEXT NOT NULL CHECK (
created_at = trim(created_at)
AND length(created_at) >= 20
AND substr(created_at, 11, 1) = 'T'
AND substr(created_at, -1, 1) = 'Z'
AND julianday(created_at) IS NOT NULL
),
revoked_at TEXT CHECK (
revoked_at IS NULL OR (
revoked_at = trim(revoked_at)
AND length(revoked_at) >= 20
AND substr(revoked_at, 11, 1) = 'T'
AND substr(revoked_at, -1, 1) = 'Z'
AND julianday(revoked_at) IS NOT NULL
)
),
CHECK (
(status = 'ACTIVE' AND revoked_at IS NULL)
OR (
status = 'REVOKED'
AND revoked_at IS NOT NULL
AND julianday(revoked_at) >= julianday(created_at)
)
)
);
CREATE INDEX device_credentials_status_created_idx
ON device_credentials (status, created_at, device_id);
-- +goose Down
-- 已签发凭据是安全配置;回滚不得静默删除并让设备身份审计链消失。
CREATE TABLE device_credentials_downgrade_guard (
valid INTEGER NOT NULL CHECK (valid = 1)
);
INSERT INTO device_credentials_downgrade_guard (valid)
SELECT CASE WHEN (SELECT COUNT(*) FROM device_credentials) = 0 THEN 1 ELSE 0 END;
DROP TABLE device_credentials_downgrade_guard;
DROP TABLE device_credentials;
+272
View File
@@ -0,0 +1,272 @@
-- +goose Up
-- v4 中的 attempt、submission 或证据没有设备/session/租约归属,不能安全猜测成 claim。
-- 在同一迁移事务中拒绝这类数据库,避免补出虚假的所有权审计链。
CREATE TABLE task_claim_upgrade_guard (
valid INTEGER NOT NULL CHECK (valid = 1)
);
INSERT INTO task_claim_upgrade_guard (valid)
SELECT CASE WHEN
(SELECT COUNT(*) FROM purchase_attempts) = 0
AND (SELECT COUNT(*) FROM order_submissions) = 0
AND (SELECT COUNT(*) FROM evidence_assets) = 0
THEN 1 ELSE 0 END;
DROP TABLE task_claim_upgrade_guard;
-- 该唯一索引把“一条授权只能产生一个 attempt”下沉到数据库;应用层检查不能替代它。
CREATE UNIQUE INDEX purchase_attempts_one_per_authorization_idx
ON purchase_attempts (authorization_id);
-- claim_generation 是 attempt lineage 的组成部分,不能只在应用层比较。
CREATE UNIQUE INDEX purchase_attempts_claim_lineage_idx
ON purchase_attempts (task_id, authorization_id, id, claim_generation);
CREATE TABLE purchase_attempt_claims (
attempt_id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
authorization_id TEXT NOT NULL UNIQUE,
claimed_by_device_id TEXT NOT NULL,
session_id TEXT NOT NULL CHECK (
length(session_id) = 36
AND substr(session_id, 9, 1) = '-'
AND substr(session_id, 14, 1) = '-'
AND substr(session_id, 19, 1) = '-'
AND substr(session_id, 24, 1) = '-'
AND length(replace(session_id, '-', '')) = 32
AND replace(session_id, '-', '') NOT GLOB '*[^0-9a-f]*'
AND substr(session_id, 15, 1) = '4'
AND substr(session_id, 20, 1) IN ('8', '9', 'a', 'b')
),
claim_generation INTEGER NOT NULL CHECK (
typeof(claim_generation) = 'integer' AND claim_generation > 0
),
task_version INTEGER NOT NULL CHECK (
typeof(task_version) = 'integer' AND task_version > 0
),
task_title TEXT NOT NULL CHECK (trim(task_title) <> ''),
authorization_task_version INTEGER NOT NULL CHECK (
typeof(authorization_task_version) = 'integer' AND authorization_task_version > 0
),
goods_id TEXT NOT NULL CHECK (trim(goods_id) <> ''),
sku_color TEXT NOT NULL CHECK (trim(sku_color) <> ''),
sku_size TEXT NOT NULL CHECK (trim(sku_size) <> ''),
quantity INTEGER NOT NULL CHECK (typeof(quantity) = 'integer' AND quantity > 0),
total_price_cap TEXT NOT NULL CHECK (trim(total_price_cap) <> ''),
authorization_expires_at TEXT NOT NULL CHECK (
authorization_expires_at = trim(authorization_expires_at)
AND length(authorization_expires_at) >= 20
AND substr(authorization_expires_at, 11, 1) = 'T'
AND substr(authorization_expires_at, -1, 1) = 'Z'
AND julianday(authorization_expires_at) IS NOT NULL
),
claim_nonce BLOB NOT NULL CHECK (
typeof(claim_nonce) = 'blob' AND length(claim_nonce) = 32
),
claim_token_sha256 BLOB NOT NULL CHECK (
typeof(claim_token_sha256) = 'blob' AND length(claim_token_sha256) = 32
),
lease_expires_at TEXT NOT NULL CHECK (
lease_expires_at = trim(lease_expires_at)
AND length(lease_expires_at) >= 20
AND substr(lease_expires_at, 11, 1) = 'T'
AND substr(lease_expires_at, -1, 1) = 'Z'
AND julianday(lease_expires_at) IS NOT NULL
),
claimed_at TEXT NOT NULL CHECK (
claimed_at = trim(claimed_at)
AND length(claimed_at) >= 20
AND substr(claimed_at, 11, 1) = 'T'
AND substr(claimed_at, -1, 1) = 'Z'
AND julianday(claimed_at) IS NOT NULL
),
closed_at TEXT CHECK (
closed_at IS NULL OR (
closed_at = trim(closed_at)
AND length(closed_at) >= 20
AND substr(closed_at, 11, 1) = 'T'
AND substr(closed_at, -1, 1) = 'Z'
AND julianday(closed_at) IS NOT NULL
AND julianday(closed_at) >= julianday(claimed_at)
)
),
UNIQUE (task_id, attempt_id),
UNIQUE (attempt_id, claimed_by_device_id, session_id),
UNIQUE (
task_id, attempt_id, claimed_by_device_id, session_id,
claim_generation, claim_token_sha256
),
UNIQUE (task_id, authorization_id, attempt_id),
FOREIGN KEY (task_id, authorization_id, attempt_id, claim_generation)
REFERENCES purchase_attempts(task_id, authorization_id, id, claim_generation),
FOREIGN KEY (claimed_by_device_id) REFERENCES device_credentials(device_id)
);
-- 过期、撤销或停轮询都不会自动关闭 claim;partial unique 因而阻止另一条开放归属。
CREATE UNIQUE INDEX purchase_attempt_claims_one_open_per_device_idx
ON purchase_attempt_claims (claimed_by_device_id)
WHERE closed_at IS NULL;
CREATE TABLE task_claim_requests (
claim_request_id TEXT PRIMARY KEY CHECK (
length(claim_request_id) = 36
AND substr(claim_request_id, 9, 1) = '-'
AND substr(claim_request_id, 14, 1) = '-'
AND substr(claim_request_id, 19, 1) = '-'
AND substr(claim_request_id, 24, 1) = '-'
AND length(replace(claim_request_id, '-', '')) = 32
AND replace(claim_request_id, '-', '') NOT GLOB '*[^0-9a-f]*'
AND substr(claim_request_id, 15, 1) = '4'
AND substr(claim_request_id, 20, 1) IN ('8', '9', 'a', 'b')
),
device_id TEXT NOT NULL,
session_id TEXT NOT NULL CHECK (
length(session_id) = 36
AND substr(session_id, 9, 1) = '-'
AND substr(session_id, 14, 1) = '-'
AND substr(session_id, 19, 1) = '-'
AND substr(session_id, 24, 1) = '-'
AND length(replace(session_id, '-', '')) = 32
AND replace(session_id, '-', '') NOT GLOB '*[^0-9a-f]*'
AND substr(session_id, 15, 1) = '4'
AND substr(session_id, 20, 1) IN ('8', '9', 'a', 'b')
),
outcome TEXT NOT NULL CHECK (outcome IN ('CLAIMED', 'EMPTY', 'BLOCKED')),
attempt_id TEXT,
response_lease_expires_at TEXT CHECK (
response_lease_expires_at IS NULL OR (
response_lease_expires_at = trim(response_lease_expires_at)
AND length(response_lease_expires_at) >= 20
AND substr(response_lease_expires_at, 11, 1) = 'T'
AND substr(response_lease_expires_at, -1, 1) = 'Z'
AND julianday(response_lease_expires_at) IS NOT NULL
)
),
error_code TEXT CHECK (error_code IS NULL OR error_code = 'manual_recovery_required'),
created_at TEXT NOT NULL CHECK (
created_at = trim(created_at)
AND length(created_at) >= 20
AND substr(created_at, 11, 1) = 'T'
AND substr(created_at, -1, 1) = 'Z'
AND julianday(created_at) IS NOT NULL
),
CHECK (
(outcome = 'CLAIMED' AND attempt_id IS NOT NULL AND response_lease_expires_at IS NOT NULL AND error_code IS NULL)
OR (outcome = 'EMPTY' AND attempt_id IS NULL AND response_lease_expires_at IS NULL AND error_code IS NULL)
OR (outcome = 'BLOCKED' AND attempt_id IS NULL AND response_lease_expires_at IS NULL AND error_code = 'manual_recovery_required')
),
FOREIGN KEY (device_id) REFERENCES device_credentials(device_id),
-- EMPTY/BLOCKED 行的 attempt_id 为 NULL,SQLite 会跳过复合 FK;CLAIMED 行则必须
-- 同时匹配原 claim 的设备和 session,不能由应用 bug 写成跨设备重放。
FOREIGN KEY (attempt_id, device_id, session_id)
REFERENCES purchase_attempt_claims(attempt_id, claimed_by_device_id, session_id)
);
CREATE TABLE purchase_attempt_lease_renewals (
renew_request_id TEXT PRIMARY KEY CHECK (
length(renew_request_id) = 36
AND substr(renew_request_id, 9, 1) = '-'
AND substr(renew_request_id, 14, 1) = '-'
AND substr(renew_request_id, 19, 1) = '-'
AND substr(renew_request_id, 24, 1) = '-'
AND length(replace(renew_request_id, '-', '')) = 32
AND replace(renew_request_id, '-', '') NOT GLOB '*[^0-9a-f]*'
AND substr(renew_request_id, 15, 1) = '4'
AND substr(renew_request_id, 20, 1) IN ('8', '9', 'a', 'b')
),
task_id TEXT NOT NULL,
attempt_id TEXT NOT NULL,
device_id TEXT NOT NULL,
session_id TEXT NOT NULL CHECK (
length(session_id) = 36
AND substr(session_id, 9, 1) = '-'
AND substr(session_id, 14, 1) = '-'
AND substr(session_id, 19, 1) = '-'
AND substr(session_id, 24, 1) = '-'
AND length(replace(session_id, '-', '')) = 32
AND replace(session_id, '-', '') NOT GLOB '*[^0-9a-f]*'
AND substr(session_id, 15, 1) = '4'
AND substr(session_id, 20, 1) IN ('8', '9', 'a', 'b')
),
claim_generation INTEGER NOT NULL CHECK (
typeof(claim_generation) = 'integer' AND claim_generation > 0
),
claim_token_sha256 BLOB NOT NULL CHECK (
typeof(claim_token_sha256) = 'blob' AND length(claim_token_sha256) = 32
),
expected_lease_expires_at TEXT NOT NULL CHECK (
expected_lease_expires_at = trim(expected_lease_expires_at)
AND length(expected_lease_expires_at) >= 20
AND substr(expected_lease_expires_at, 11, 1) = 'T'
AND substr(expected_lease_expires_at, -1, 1) = 'Z'
AND julianday(expected_lease_expires_at) IS NOT NULL
),
lease_expires_at TEXT NOT NULL CHECK (
lease_expires_at = trim(lease_expires_at)
AND length(lease_expires_at) >= 20
AND substr(lease_expires_at, 11, 1) = 'T'
AND substr(lease_expires_at, -1, 1) = 'Z'
AND julianday(lease_expires_at) IS NOT NULL
),
created_at TEXT NOT NULL CHECK (
created_at = trim(created_at)
AND length(created_at) >= 20
AND substr(created_at, 11, 1) = 'T'
AND substr(created_at, -1, 1) = 'Z'
AND julianday(created_at) IS NOT NULL
),
FOREIGN KEY (
task_id, attempt_id, device_id, session_id,
claim_generation, claim_token_sha256
) REFERENCES purchase_attempt_claims(
task_id, attempt_id, claimed_by_device_id, session_id,
claim_generation, claim_token_sha256
)
);
CREATE INDEX order_authorizations_claim_candidate_idx
ON order_authorizations (status, created_at, id);
-- 首次证据写入必须属于认证设备当前未关闭的 claim。历史资产的幂等重放不触发 INSERT,
-- 因而未来人工关闭 claim 后仍可稳定返回原资产。
-- +goose StatementBegin
CREATE TRIGGER evidence_assets_claim_owner_insert
BEFORE INSERT ON evidence_assets
FOR EACH ROW
WHEN NOT EXISTS (
SELECT 1 FROM purchase_attempt_claims AS claims
WHERE claims.task_id = NEW.task_id
AND claims.attempt_id = NEW.attempt_id
AND claims.claimed_by_device_id = NEW.uploaded_by_device_id
AND claims.closed_at IS NULL
)
BEGIN
SELECT RAISE(ABORT, 'evidence claim ownership required');
END;
-- +goose StatementEnd
-- +goose Down
-- 请求、续租、attempt、submission 和证据都是领取或下游审计事实,回滚不得静默删除。
CREATE TABLE task_claim_downgrade_guard (
valid INTEGER NOT NULL CHECK (valid = 1)
);
INSERT INTO task_claim_downgrade_guard (valid)
SELECT CASE WHEN
(SELECT COUNT(*) FROM task_claim_requests) = 0
AND (SELECT COUNT(*) FROM purchase_attempt_lease_renewals) = 0
AND (SELECT COUNT(*) FROM purchase_attempt_claims) = 0
AND (SELECT COUNT(*) FROM purchase_attempts) = 0
AND (SELECT COUNT(*) FROM order_submissions) = 0
AND (SELECT COUNT(*) FROM evidence_assets) = 0
THEN 1 ELSE 0 END;
DROP TABLE task_claim_downgrade_guard;
DROP TRIGGER evidence_assets_claim_owner_insert;
DROP INDEX order_authorizations_claim_candidate_idx;
DROP TABLE purchase_attempt_lease_renewals;
DROP TABLE task_claim_requests;
DROP INDEX purchase_attempt_claims_one_open_per_device_idx;
DROP TABLE purchase_attempt_claims;
DROP INDEX purchase_attempts_claim_lineage_idx;
DROP INDEX purchase_attempts_one_per_authorization_idx;
@@ -0,0 +1,89 @@
"""采集 T-103 一次性尺码 reveal 的 before/after 本机证据。"""
from __future__ import annotations
import argparse
from math import isfinite
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
from cmbuyer_client.pdd.sku_reveal_spike import SkuRevealSpikeCapturer, SkuRevealSpikeError
from cmbuyer_client.pdd.sku_selection import EXPECTED_GOODS_ID, SkuSelectionError
from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="采集 T-103 一次性尺码 reveal 的本机证据。")
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
parser.add_argument("--goods-id", required=True, help="固定 T-103 已取证 goods_id。")
parser.add_argument("--output-dir", required=True, type=Path, help="全新本机证据目录;不得覆盖。")
parser.add_argument("--timeout", type=float, default=30.0, help="整趟动作与只读调和时限(秒)。")
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
return parser.parse_args(argv)
def validate_arguments(arguments: argparse.Namespace) -> None:
if type(arguments.serial) is not str or not arguments.serial.strip():
raise ValueError("必须显式提供非空 --serial。")
if type(arguments.goods_id) is not str or arguments.goods_id != EXPECTED_GOODS_ID:
raise ValueError("--goods-id 不是 T-103 已取证商品。")
if (
not isinstance(arguments.timeout, (int, float))
or isinstance(arguments.timeout, bool)
or arguments.timeout <= 0
or not isfinite(arguments.timeout)
):
raise ValueError("--timeout 必须是大于 0 的有限数值。")
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
validate_arguments(arguments)
except ValueError as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
return 2
capturer = SkuRevealSpikeCapturer(
AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout),
NoReconnectUiautomatorConnector(
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
u2.connect,
),
timeout_seconds=arguments.timeout,
)
try:
result = capturer.capture(
arguments.serial,
arguments.goods_id,
arguments.output_dir,
)
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError, SkuRevealSpikeError):
# 不回显页面正文、节点、serial、坐标、路径或第三方异常。
print("规格 reveal 取证失败:已停止,未发布本地证据目录。", file=sys.stderr)
return 1
except OSError:
print("规格 reveal 取证失败:无法创建或发布本地证据目录。", file=sys.stderr)
return 1
print(f"规格 reveal 取证完成:{result.output_directory}")
print(f"manifest:{result.manifest_path}")
print("人工复核:请保持手机不动,确认颜色仍选中、S/M 均未选且未进入提交页。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+5 -2
View File
@@ -15,7 +15,7 @@ from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, Subproce
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
from cmbuyer_client.pdd.sku_selection import EXPECTED_GOODS_ID, SkuSelectionError, TASK_TO_UI_SELECTION
from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError, SkuSelectionRunner
from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError, SkuSelectionRunner, safe_failure_stage
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
@@ -66,7 +66,10 @@ def main(argv: list[str] | None = None) -> int:
result = runner.run(arguments.serial, arguments.url, arguments.color, arguments.size, arguments.output_dir)
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError) as error:
# Flow 可能来自测试替身或未来实现;CLI 不回显任何异常正文,避免泄露节点树或页面文本。
print("规格恢复失败:已停止,未发布本地证据目录。", file=sys.stderr)
print(
f"规格恢复失败:stage={safe_failure_stage(error)};已停止,未发布本地证据目录。",
file=sys.stderr,
)
return 1
except OSError:
print("规格恢复失败:无法创建或发布本地证据目录。", file=sys.stderr)
+43 -15
View File
@@ -5,8 +5,9 @@ from __future__ import annotations
import sys
from collections.abc import Sequence
from .core.errors import StateError
from .logging_policy import configure_application_logger
from .runtime import RuntimePaths
from .runtime import LocalStateRuntime, RuntimePaths
def select_application_argv(argv: Sequence[str] | None) -> list[str]:
@@ -30,8 +31,7 @@ def main(argv: Sequence[str] | None = None) -> int:
return 1
try:
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QLabel, QMainWindow
from PySide6.QtWidgets import QApplication
except ImportError:
logger.error("缺少 PySide6,无法启动桌面界面。")
print("无法启动采购工具:缺少 PySide6。请先安装 requirements.txt 中的依赖。", file=sys.stderr)
@@ -39,19 +39,47 @@ def main(argv: Sequence[str] | None = None) -> int:
application = QApplication.instance() or QApplication(select_application_argv(argv))
application.setApplicationName("采购工具")
runtime: LocalStateRuntime | None = None
coordinator = None
try:
runtime = LocalStateRuntime.open(paths)
try:
summary = runtime.store.load_profile_summary("default")
except StateError as error:
if error.reason != "profile_not_found":
raise
summary = None
window = QMainWindow()
window.setWindowTitle("采购工具")
window.setAccessibleName("采购工具")
window.setMinimumSize(420, 240)
window.resize(560, 320)
from .polling.coordinator import PollingCoordinator
from .ui.main_window import PurchaseToolWindow
message = QLabel("应用骨架已初始化。\n采购执行功能尚未启用。")
message.setAlignment(Qt.AlignmentFlag.AlignCenter)
message.setWordWrap(True)
message.setAccessibleName("当前状态")
window.setCentralWidget(message)
logger.info("应用已启动;采购执行功能尚未启用。")
settings = None if summary is None else summary.settings
has_token = False if summary is None else summary.has_stored_device_token
coordinator = PollingCoordinator(
profile_id="default",
store=runtime.store,
gateway_factory=None,
consumer=None,
profile_settings=settings,
poll_interval_seconds=15 if settings is None else settings.poll_interval_seconds,
failure_threshold=3 if settings is None else settings.failure_threshold,
)
window = PurchaseToolWindow(
store=runtime.store,
coordinator=coordinator,
profile_settings=settings,
has_stored_device_token=has_token,
)
logger.info("应用已启动;单趟执行能力尚未接入,真实领取保持禁用。")
window.show()
return application.exec()
except (OSError, RuntimeError, StateError):
logger.error("无法打开采购工具本地安全状态。")
print("无法启动采购工具:本地安全状态不可用。", file=sys.stderr)
return 3
finally:
worker_stopped = True
if coordinator is not None:
worker_stopped = coordinator.shutdown()
if runtime is not None and worker_stopped:
runtime.close()
@@ -0,0 +1,44 @@
"""与 UI、HTTP 和拼多多页面实现无关的客户端核心契约。"""
from .errors import (
AmbiguousRemoteError,
CredentialRemoteError,
ManualRemoteError,
ProtocolRemoteError,
StateError,
ValidationError,
)
from .models import (
AssetReceipt,
AuthorizationSnapshot,
ClaimRequest,
ClaimedTask,
DeviceCredentials,
EvidenceUpload,
PurchaseTask,
RenewRequest,
RenewResult,
SecretToken,
)
from .ports import EvidenceSink, TaskSource
__all__ = [
"AmbiguousRemoteError",
"AssetReceipt",
"AuthorizationSnapshot",
"ClaimRequest",
"ClaimedTask",
"CredentialRemoteError",
"DeviceCredentials",
"EvidenceSink",
"EvidenceUpload",
"ManualRemoteError",
"ProtocolRemoteError",
"PurchaseTask",
"RenewRequest",
"RenewResult",
"SecretToken",
"StateError",
"TaskSource",
"ValidationError",
]
+47
View File
@@ -0,0 +1,47 @@
"""可安全呈现的客户端错误分类。"""
from __future__ import annotations
class ClientError(RuntimeError):
"""错误文本只使用固定 reason code,不携带凭据、响应或本机路径。"""
def __init__(self, reason: str) -> None:
self.reason = reason
super().__init__(reason)
class ValidationError(ClientError):
"""本地输入或 wire schema 不满足固定契约。"""
class StateError(ClientError):
"""本地状态无法安全推进;调用方必须停止而不是绕过。"""
class ProtectionError(ClientError):
"""秘密保护失败。"""
class SingleInstanceError(ClientError):
"""同一配置已经由另一个采购工具进程持有。"""
class RemoteError(ClientError):
"""服务端调用的稳定错误分类。"""
class AmbiguousRemoteError(RemoteError):
"""请求结果不明;只允许以原幂等键、原载荷显式恢复。"""
class CredentialRemoteError(RemoteError):
"""设备凭据无效或已撤销。"""
class ProtocolRemoteError(RemoteError):
"""请求/响应与固定协议不兼容,不得自动重试。"""
class ManualRemoteError(RemoteError):
"""服务端要求人工处理的确定性冲突。"""
+328
View File
@@ -0,0 +1,328 @@
"""任务领取、续租和单张证据上传的不可变值对象。"""
from __future__ import annotations
from dataclasses import dataclass, field
import hashlib
from pathlib import Path
from .errors import ValidationError
from .validation import (
MAX_SKU_TEXT_CODE_POINTS,
MAX_TITLE_CODE_POINTS,
canonical_product_url,
require_exact_fields,
require_goods_id,
require_lower_hex_64,
require_money,
require_persisted_text,
require_positive_int,
require_rfc3339_z,
rfc3339_z_nanoseconds,
require_string,
require_uuid4,
)
EVIDENCE_KIND = "SKU_PANEL_GATE_1"
PRIVACY_TIER = "INTERNAL_RAW"
@dataclass(frozen=True, repr=False)
class SecretToken:
"""64 位小写 token;repr 永不暴露明文。"""
value: str
def __post_init__(self) -> None:
require_lower_hex_64(self.value, "invalid_token")
def __repr__(self) -> str:
return "SecretToken([已隐藏])"
def __str__(self) -> str:
return "[已隐藏]"
@dataclass(frozen=True, repr=False)
class DeviceCredentials:
device_id: str
token: SecretToken
def __post_init__(self) -> None:
require_uuid4(self.device_id, "invalid_device_id")
if not isinstance(self.token, SecretToken):
raise ValidationError("invalid_device_token")
def __repr__(self) -> str:
return f"DeviceCredentials(device_id={self.device_id!r}, token=[已隐藏])"
@dataclass(frozen=True)
class ClaimRequest:
session_id: str
claim_request_id: str
def __post_init__(self) -> None:
require_uuid4(self.session_id, "invalid_session_id")
require_uuid4(self.claim_request_id, "invalid_claim_request_id")
def to_wire(self) -> dict[str, object]:
return {"session_id": self.session_id, "claim_request_id": self.claim_request_id}
@dataclass(frozen=True)
class PurchaseTask:
id: str
version: int
title: str
product_url: str
goods_id: str
sku_color: str
sku_size: str
quantity: int
max_total_price: str
def __post_init__(self) -> None:
require_uuid4(self.id, "invalid_task_id")
require_positive_int(self.version, "invalid_task_version")
require_persisted_text(self.title, "invalid_task_title", maximum=MAX_TITLE_CODE_POINTS)
require_goods_id(self.goods_id)
if self.product_url != canonical_product_url(self.goods_id):
raise ValidationError("invalid_product_url")
require_persisted_text(self.sku_color, "invalid_sku_color", maximum=MAX_SKU_TEXT_CODE_POINTS)
require_persisted_text(self.sku_size, "invalid_sku_size", maximum=MAX_SKU_TEXT_CODE_POINTS)
require_positive_int(self.quantity, "invalid_quantity")
require_money(self.max_total_price, "invalid_max_total_price")
@classmethod
def from_wire(cls, value: object) -> "PurchaseTask":
data = require_exact_fields(
value,
("id", "version", "title", "product_url", "goods_id", "sku_color", "sku_size", "quantity", "max_total_price"),
)
return cls(**data) # type: ignore[arg-type]
@dataclass(frozen=True)
class AuthorizationSnapshot:
id: str
task_version: int
expires_at: str
def __post_init__(self) -> None:
require_uuid4(self.id, "invalid_authorization_id")
require_positive_int(self.task_version, "invalid_authorization_task_version")
require_rfc3339_z(self.expires_at, "invalid_authorization_expiry")
@classmethod
def from_wire(cls, value: object) -> "AuthorizationSnapshot":
data = require_exact_fields(value, ("id", "task_version", "expires_at"))
return cls(**data) # type: ignore[arg-type]
@dataclass(frozen=True)
class AttemptSnapshot:
id: str
claim_token: SecretToken
claim_generation: int
lease_expires_at: str
def __post_init__(self) -> None:
require_uuid4(self.id, "invalid_attempt_id")
if not isinstance(self.claim_token, SecretToken):
object.__setattr__(self, "claim_token", SecretToken(self.claim_token))
require_positive_int(self.claim_generation, "invalid_claim_generation")
require_rfc3339_z(self.lease_expires_at, "invalid_lease_expiry")
@classmethod
def from_wire(cls, value: object) -> "AttemptSnapshot":
data = require_exact_fields(value, ("id", "claim_token", "claim_generation", "lease_expires_at"))
return cls(
id=data["id"], # type: ignore[arg-type]
claim_token=SecretToken(data["claim_token"]), # type: ignore[arg-type]
claim_generation=data["claim_generation"], # type: ignore[arg-type]
lease_expires_at=data["lease_expires_at"], # type: ignore[arg-type]
)
@dataclass(frozen=True)
class ClaimedTask:
task: PurchaseTask
authorization: AuthorizationSnapshot
attempt: AttemptSnapshot = field(repr=False)
def __post_init__(self) -> None:
if self.task.version != self.authorization.task_version + 1:
raise ValidationError("task_authorization_version_mismatch")
if rfc3339_z_nanoseconds(self.attempt.lease_expires_at) > rfc3339_z_nanoseconds(self.authorization.expires_at):
raise ValidationError("claim_lease_exceeds_authorization")
@classmethod
def from_wire(cls, value: object) -> "ClaimedTask":
data = require_exact_fields(value, ("task", "authorization", "attempt"))
return cls(
task=PurchaseTask.from_wire(data["task"]),
authorization=AuthorizationSnapshot.from_wire(data["authorization"]),
attempt=AttemptSnapshot.from_wire(data["attempt"]),
)
@dataclass(frozen=True, repr=False)
class RenewRequest:
task_id: str
renew_request_id: str
session_id: str
attempt_id: str
claim_generation: int
claim_token: SecretToken
expected_lease_expires_at: str
authorization_expires_at: str
def __post_init__(self) -> None:
require_uuid4(self.task_id, "invalid_task_id")
require_uuid4(self.renew_request_id, "invalid_renew_request_id")
require_uuid4(self.session_id, "invalid_session_id")
require_uuid4(self.attempt_id, "invalid_attempt_id")
require_positive_int(self.claim_generation, "invalid_claim_generation")
if not isinstance(self.claim_token, SecretToken):
object.__setattr__(self, "claim_token", SecretToken(self.claim_token))
require_rfc3339_z(self.expected_lease_expires_at, "invalid_expected_lease_expiry")
require_rfc3339_z(self.authorization_expires_at, "invalid_authorization_expiry")
def __repr__(self) -> str:
return (
f"RenewRequest(task_id={self.task_id!r}, renew_request_id={self.renew_request_id!r}, "
"claim_token=[已隐藏])"
)
def to_wire(self) -> dict[str, object]:
return {
"renew_request_id": self.renew_request_id,
"session_id": self.session_id,
"attempt_id": self.attempt_id,
"claim_generation": self.claim_generation,
"claim_token": self.claim_token.value,
"expected_lease_expires_at": self.expected_lease_expires_at,
}
@dataclass(frozen=True)
class RenewResult:
task_id: str
attempt_id: str
claim_generation: int
lease_expires_at: str
def __post_init__(self) -> None:
require_uuid4(self.task_id, "invalid_task_id")
require_uuid4(self.attempt_id, "invalid_attempt_id")
require_positive_int(self.claim_generation, "invalid_claim_generation")
require_rfc3339_z(self.lease_expires_at, "invalid_lease_expiry")
@classmethod
def from_wire(cls, value: object) -> "RenewResult":
data = require_exact_fields(value, ("task_id", "attempt_id", "claim_generation", "lease_expires_at"))
return cls(**data) # type: ignore[arg-type]
@dataclass(frozen=True, repr=False)
class EvidenceUpload:
task_id: str
upload_key: str
attempt_id: str
sha256: str
captured_at: str
content: bytes = field(repr=False)
kind: str = EVIDENCE_KIND
privacy_tier: str = PRIVACY_TIER
width_px: int = field(init=False)
height_px: int = field(init=False)
def __post_init__(self) -> None:
require_uuid4(self.task_id, "invalid_task_id")
require_uuid4(self.upload_key, "invalid_upload_key")
require_uuid4(self.attempt_id, "invalid_attempt_id")
require_lower_hex_64(self.sha256, "invalid_evidence_sha256")
require_rfc3339_z(self.captured_at, "invalid_captured_at")
if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER:
raise ValidationError("invalid_evidence_metadata")
if not isinstance(self.content, bytes) or not self.content or len(self.content) > 10 * 1024 * 1024:
raise ValidationError("invalid_evidence_size")
if len(self.content) < 24 or not self.content.startswith(b"\x89PNG\r\n\x1a\n") or self.content[12:16] != b"IHDR":
raise ValidationError("invalid_evidence_png")
width = int.from_bytes(self.content[16:20], "big")
height = int.from_bytes(self.content[20:24], "big")
if width <= 0 or height <= 0 or width > 8192 or height > 8192 or width * height > 16_777_216:
raise ValidationError("invalid_evidence_dimensions")
object.__setattr__(self, "width_px", width)
object.__setattr__(self, "height_px", height)
if hashlib.sha256(self.content).hexdigest() != self.sha256:
raise ValidationError("evidence_hash_mismatch")
def __repr__(self) -> str:
return (
f"EvidenceUpload(task_id={self.task_id!r}, upload_key={self.upload_key!r}, "
f"attempt_id={self.attempt_id!r}, byte_size={len(self.content)})"
)
@dataclass(frozen=True)
class AssetReceipt:
asset_id: str
task_id: str
attempt_id: str
kind: str
privacy_tier: str
sha256: str
byte_size: int
content_type: str
width_px: int
height_px: int
captured_at: str
def __post_init__(self) -> None:
require_uuid4(self.asset_id, "invalid_asset_id")
require_uuid4(self.task_id, "invalid_task_id")
require_uuid4(self.attempt_id, "invalid_attempt_id")
if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER:
raise ValidationError("invalid_asset_metadata")
require_lower_hex_64(self.sha256, "invalid_asset_sha256")
require_positive_int(self.byte_size, "invalid_asset_byte_size")
if self.byte_size > 10 * 1024 * 1024 or self.content_type != "image/png":
raise ValidationError("invalid_asset_content")
width = require_positive_int(self.width_px, "invalid_asset_width")
height = require_positive_int(self.height_px, "invalid_asset_height")
if width > 8192 or height > 8192 or width * height > 16_777_216:
raise ValidationError("invalid_asset_dimensions")
require_rfc3339_z(self.captured_at, "invalid_captured_at")
@classmethod
def from_wire(cls, value: object) -> "AssetReceipt":
data = require_exact_fields(
value,
("asset_id", "task_id", "attempt_id", "kind", "privacy_tier", "sha256", "byte_size", "content_type", "width_px", "height_px", "captured_at"),
)
return cls(**data) # type: ignore[arg-type]
@dataclass(frozen=True, repr=False)
class ScreenshotAsset:
"""调用方显式选择的唯一 PNG;路径不会进入 repr 或 HTTP。"""
path: Path = field(repr=False)
task_id: str
attempt_id: str
captured_at: str
kind: str = EVIDENCE_KIND
privacy_tier: str = PRIVACY_TIER
def __post_init__(self) -> None:
require_uuid4(self.task_id, "invalid_task_id")
require_uuid4(self.attempt_id, "invalid_attempt_id")
require_rfc3339_z(self.captured_at, "invalid_captured_at")
if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER:
raise ValidationError("invalid_evidence_metadata")
def __repr__(self) -> str:
return f"ScreenshotAsset(task_id={self.task_id!r}, attempt_id={self.attempt_id!r}, path=[已隐藏])"
+17
View File
@@ -0,0 +1,17 @@
"""由 HTTP 适配器实现的窄端口。"""
from __future__ import annotations
from typing import Protocol
from .models import AssetReceipt, ClaimRequest, ClaimedTask, DeviceCredentials, EvidenceUpload, RenewRequest, RenewResult
class TaskSource(Protocol):
def claim_next(self, credentials: DeviceCredentials, request: ClaimRequest) -> ClaimedTask | None: ...
def renew(self, credentials: DeviceCredentials, request: RenewRequest) -> RenewResult: ...
class EvidenceSink(Protocol):
def upload(self, credentials: DeviceCredentials, evidence: EvidenceUpload) -> AssetReceipt: ...
@@ -0,0 +1,197 @@
"""客户端与服务端共享 wire 的严格值校验。"""
from __future__ import annotations
import calendar
from datetime import datetime, timezone
import json
import re
from typing import Any, Iterable, Mapping
from urllib.parse import quote
from .errors import ValidationError
UUID4_RE = re.compile(
r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
)
LOWER_HEX_64_RE = re.compile(r"[0-9a-f]{64}")
RFC3339_Z_RE = re.compile(
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z"
)
MONEY_RE = re.compile(r"(?:0|[1-9][0-9]*)\.[0-9]{2}")
GOODS_ID_RE = re.compile(r"[0-9]+")
MAX_TITLE_CODE_POINTS = 120
MAX_SKU_TEXT_CODE_POINTS = 80
MAX_GOODS_ID_ASCII_CHARACTERS = 32
MAX_MONEY_ASCII_CHARACTERS = 32
# Go strings.TrimSpace uses Unicode White_Space plus the six ASCII space
# characters below, but unlike Python str.strip it does not include U+001C--
# U+001F. Keep the wire contract independent of either runtime's defaults.
GO_UNICODE_WHITE_SPACE = "\t\n\v\f\r \u0085\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000"
def require_string(value: object, reason: str, *, maximum: int = 4096) -> str:
if not isinstance(value, str) or not value or len(value) > maximum:
raise ValidationError(reason)
if any(0xD800 <= ord(character) <= 0xDFFF for character in value):
raise ValidationError(reason)
return value
def require_persisted_text(value: object, reason: str, *, maximum: int) -> str:
"""Validate text stored by Go after TrimSpace, without Python trim drift."""
text = require_string(value, reason, maximum=maximum)
if text.strip(GO_UNICODE_WHITE_SPACE) != text:
raise ValidationError(reason)
# Python str.strip treats these C0 separators as whitespace while Go does
# not. Reject them anywhere on both ends instead of assigning them two
# runtime-dependent meanings.
if any(0x1C <= ord(character) <= 0x1F for character in text):
raise ValidationError(reason)
return text
def require_uuid4(value: object, reason: str = "invalid_uuid") -> str:
text = require_string(value, reason, maximum=36)
if UUID4_RE.fullmatch(text) is None:
raise ValidationError(reason)
return text
def require_lower_hex_64(value: object, reason: str = "invalid_hex") -> str:
text = require_string(value, reason, maximum=64)
if LOWER_HEX_64_RE.fullmatch(text) is None:
raise ValidationError(reason)
return text
def require_rfc3339_z(value: object, reason: str = "invalid_timestamp") -> str:
text = require_string(value, reason, maximum=40)
if RFC3339_Z_RE.fullmatch(text) is None:
raise ValidationError(reason)
parsed: datetime | None = None
try:
parsed = datetime.fromisoformat(text[:-1] + "+00:00")
except ValueError:
pass
if parsed is None:
raise ValidationError(reason)
if parsed.utcoffset() is None or parsed.utcoffset().total_seconds() != 0:
raise ValidationError(reason)
return text
def rfc3339_z_nanoseconds(value: object, reason: str = "invalid_timestamp") -> int:
"""无浮点、无微秒截断地把 UTC RFC3339Nano 转成纳秒时间轴。"""
text = require_rfc3339_z(value, reason)
base: datetime | None = None
try:
base = datetime.strptime(text[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
except ValueError:
pass
if base is None:
raise ValidationError(reason)
fraction = "" if len(text) == 20 else text[20:-1]
nanoseconds = int(fraction.ljust(9, "0")) if fraction else 0
return calendar.timegm(base.utctimetuple()) * 1_000_000_000 + nanoseconds
def datetime_nanoseconds(value: datetime, reason: str = "invalid_timestamp") -> int:
if not isinstance(value, datetime) or value.utcoffset() is None:
raise ValidationError(reason)
utc = value.astimezone(timezone.utc)
return calendar.timegm(utc.utctimetuple()) * 1_000_000_000 + utc.microsecond * 1_000
def require_positive_int(value: object, reason: str = "invalid_integer") -> int:
# bool 是 int 的子类;wire 中必须显式拒绝 true/false。
if type(value) is not int or value <= 0 or value > 9_223_372_036_854_775_807:
raise ValidationError(reason)
return value
def require_money(value: object, reason: str = "invalid_money") -> str:
text = require_string(value, reason, maximum=MAX_MONEY_ASCII_CHARACTERS)
if MONEY_RE.fullmatch(text) is None or text == "0.00":
raise ValidationError(reason)
return text
def require_goods_id(value: object) -> str:
text = require_string(value, "invalid_goods_id", maximum=MAX_GOODS_ID_ASCII_CHARACTERS)
if GOODS_ID_RE.fullmatch(text) is None:
raise ValidationError("invalid_goods_id")
return text
def canonical_product_url(goods_id: str) -> str:
require_goods_id(goods_id)
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + quote(goods_id, safe="")
def require_exact_fields(
value: object,
required: Iterable[str],
reason: str = "invalid_schema",
) -> Mapping[str, Any]:
if not isinstance(value, dict):
raise ValidationError(reason)
expected = frozenset(required)
if frozenset(value) != expected:
raise ValidationError(reason)
return value
def strict_json_loads(raw: bytes, *, maximum: int) -> object:
if not isinstance(raw, bytes) or len(raw) == 0 or len(raw) > maximum:
raise ValidationError("invalid_json_size")
text: str | None = None
try:
text = raw.decode("utf-8")
except UnicodeDecodeError:
pass
if text is None:
raise ValidationError("invalid_json_utf8")
if text.startswith("\ufeff"):
raise ValidationError("invalid_json_bom")
def pairs_hook(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
result: dict[str, Any] = {}
for key, value in pairs:
if key in result:
raise ValidationError("duplicate_json_key")
result[key] = value
return result
def reject_number(_: str) -> object:
raise ValidationError("invalid_json_number")
def parse_integer(value: str) -> int:
digits = value[1:] if value.startswith("-") else value
if len(digits) > 19:
raise ValidationError("invalid_json_integer")
parsed = int(value)
if parsed < -9_223_372_036_854_775_808 or parsed > 9_223_372_036_854_775_807:
raise ValidationError("invalid_json_integer")
return parsed
parsed_json: object | None = None
failed = False
try:
parsed_json = json.loads(
text,
object_pairs_hook=pairs_hook,
parse_int=parse_integer,
parse_float=reject_number,
parse_constant=reject_number,
)
except ValidationError:
raise
except (json.JSONDecodeError, UnicodeError, ValueError, RecursionError):
failed = True
if failed:
raise ValidationError("invalid_json")
return parsed_json
@@ -0,0 +1,18 @@
"""Windows 本地恢复、凭据保护和单实例底座。"""
from .models import PollingSession, ProfileSettings, RecoverySnapshot
from .facade import DurableClientGateway
from .protection import DpapiProtector, SecretProtector
from .single_instance import NamedMutex
from .store import LocalStateStore
__all__ = [
"DpapiProtector",
"DurableClientGateway",
"LocalStateStore",
"NamedMutex",
"PollingSession",
"ProfileSettings",
"RecoverySnapshot",
"SecretProtector",
]
@@ -0,0 +1,82 @@
"""把“先持久化,再发一次 HTTP”固化成 T-304/T-306 的唯一集成入口。"""
from __future__ import annotations
from cmbuyer_client.core.errors import (
AmbiguousRemoteError,
CredentialRemoteError,
ManualRemoteError,
ProtocolRemoteError,
)
from cmbuyer_client.core.models import AssetReceipt, ClaimedTask, ScreenshotAsset
from cmbuyer_client.core.ports import EvidenceSink, TaskSource
from .store import LocalStateStore
class DurableClientGateway:
"""不隐藏重试;每次方法调用最多发一次请求,结果不明保留原槽。"""
def __init__(self, store: LocalStateStore, task_source: TaskSource, evidence_sink: EvidenceSink) -> None:
self._store = store
self._task_source = task_source
self._evidence_sink = evidence_sink
def claim_next(self, profile_id: str) -> ClaimedTask | None:
request = self._store.prepare_claim(profile_id)
credentials = self._store.load_profile(profile_id).credentials
try:
claimed = self._task_source.claim_next(credentials, request)
except AmbiguousRemoteError:
raise
except CredentialRemoteError:
raise
except ProtocolRemoteError:
self._store.mark_claim_terminal(profile_id, request, "PROTOCOL")
raise
except ManualRemoteError:
self._store.mark_claim_terminal(profile_id, request, "MANUAL")
raise
if claimed is None:
self._store.commit_claim_empty(profile_id, request)
return None
self._store.commit_claim_success(profile_id, request, claimed)
return claimed
def renew(self, profile_id: str):
request = self._store.prepare_renew(profile_id)
credentials = self._store.load_profile(profile_id).credentials
try:
result = self._task_source.renew(credentials, request)
except AmbiguousRemoteError:
raise
except CredentialRemoteError:
raise
except ProtocolRemoteError:
self._store.mark_renew_terminal(profile_id, request, "PROTOCOL")
raise
except ManualRemoteError:
self._store.mark_renew_terminal(profile_id, request, "MANUAL")
raise
self._store.commit_renew_success(profile_id, request, result)
return result
def upload_evidence(self, profile_id: str, asset: ScreenshotAsset) -> AssetReceipt:
prepared = self._store.prepare_or_resume_evidence(profile_id, asset)
if isinstance(prepared, AssetReceipt):
return prepared
credentials = self._store.load_profile(profile_id).credentials
try:
receipt = self._evidence_sink.upload(credentials, prepared)
except AmbiguousRemoteError:
raise
except CredentialRemoteError:
raise
except ProtocolRemoteError:
self._store.mark_evidence_terminal(profile_id, prepared, "PROTOCOL")
raise
except ManualRemoteError:
self._store.mark_evidence_terminal(profile_id, prepared, "MANUAL")
raise
self._store.commit_evidence_success(profile_id, prepared, receipt)
return receipt
@@ -0,0 +1,98 @@
"""供 T-304 使用的稳定本地配置与恢复快照。"""
from __future__ import annotations
from dataclasses import dataclass, field
import re
from cmbuyer_client.core.models import ClaimRequest, ClaimedTask, DeviceCredentials, RenewRequest
from cmbuyer_client.core.validation import require_string, require_uuid4
LOOPBACK_SERVICE_URL = "http://127.0.0.1:8080"
PROFILE_ID_RE = re.compile(r"[a-z0-9][a-z0-9_-]{0,63}")
@dataclass(frozen=True)
class ProfileSettings:
profile_id: str
service_url: str
device_id: str
adb_path: str
adb_serial: str
transport: str
poll_interval_seconds: int = 15
failure_threshold: int = 3
http_timeout_seconds: int = 10
step_timeout_seconds: int = 45
def __post_init__(self) -> None:
if not isinstance(self.profile_id, str) or PROFILE_ID_RE.fullmatch(self.profile_id) is None:
raise ValueError("invalid_profile_id")
if self.service_url != LOOPBACK_SERVICE_URL:
raise ValueError("service_url_not_allowed")
require_uuid4(self.device_id, "invalid_device_id")
require_string(self.adb_path, "invalid_adb_path", maximum=1024)
require_string(self.adb_serial, "invalid_adb_serial", maximum=200)
if self.transport not in ("usb", "wifi"):
raise ValueError("invalid_transport")
_range(self.poll_interval_seconds, 5, 300, "invalid_poll_interval")
_range(self.failure_threshold, 1, 10, "invalid_failure_threshold")
_range(self.http_timeout_seconds, 1, 120, "invalid_http_timeout")
_range(self.step_timeout_seconds, 5, 300, "invalid_step_timeout")
@dataclass(frozen=True, repr=False)
class LoadedProfile:
settings: ProfileSettings
credentials: DeviceCredentials = field(repr=False)
def __repr__(self) -> str:
return f"LoadedProfile(settings={self.settings!r}, credentials=[已隐藏])"
@dataclass(frozen=True)
class ProfileSummary:
"""不解密、不返回任何 token 数据的配置页只读摘要。"""
settings: ProfileSettings
has_stored_device_token: bool
def __post_init__(self) -> None:
if type(self.has_stored_device_token) is not bool:
raise ValueError("invalid_token_presence")
@dataclass(frozen=True)
class PollingSession:
profile_id: str
session_id: str
accept_new: bool
def __post_init__(self) -> None:
require_uuid4(self.session_id, "invalid_session_id")
if type(self.accept_new) is not bool:
raise ValueError("invalid_accept_new")
@dataclass(frozen=True)
class PendingEvidence:
task_id: str
attempt_id: str
kind: str
upload_key: str
status: str
@dataclass(frozen=True)
class RecoverySnapshot:
session: PollingSession | None
pending_claim: ClaimRequest | None
active_claim: ClaimedTask | None = field(repr=False)
pending_renew: RenewRequest | None = field(repr=False)
pending_evidence: tuple[PendingEvidence, ...]
def _range(value: object, minimum: int, maximum: int, reason: str) -> None:
if type(value) is not int or not minimum <= value <= maximum:
raise ValueError(reason)
@@ -0,0 +1,112 @@
"""Windows 当前用户范围 DPAPI 封装;生产环境绝不降级为明文。"""
from __future__ import annotations
import ctypes
from ctypes import wintypes
import os
import re
from typing import Protocol
from cmbuyer_client.core.errors import ProtectionError
class SecretProtector(Protocol):
def protect(self, plaintext: bytes, *, purpose: str) -> bytes: ...
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes: ...
class _DataBlob(ctypes.Structure):
_fields_ = (("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_ubyte)))
def _blob(data: bytes) -> tuple[_DataBlob, object]:
buffer = (ctypes.c_ubyte * len(data)).from_buffer_copy(data) if data else (ctypes.c_ubyte * 1)()
return _DataBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte))), buffer
class DpapiProtector:
"""使用 CryptProtectData/UI_FORBIDDEN;错误只暴露固定 reason code。"""
_UI_FORBIDDEN = 0x1
_ENTROPY_PREFIX = b"cmbuyer-localstate-v1:"
_PURPOSE_RE = re.compile(
r"(?:device-token:[a-z0-9][a-z0-9_-]{0,63}:[0-9a-f-]{36}|"
r"claim-token:[a-z0-9][a-z0-9_-]{0,63}:[0-9a-f-]{36})",
flags=re.ASCII,
)
def __init__(self) -> None:
if os.name != "nt":
raise ProtectionError("dpapi_requires_windows")
self._crypt32 = ctypes.WinDLL("crypt32", use_last_error=True)
self._kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
self._crypt32.CryptProtectData.argtypes = (
ctypes.POINTER(_DataBlob),
wintypes.LPCWSTR,
ctypes.POINTER(_DataBlob),
wintypes.LPVOID,
wintypes.LPVOID,
wintypes.DWORD,
ctypes.POINTER(_DataBlob),
)
self._crypt32.CryptProtectData.restype = wintypes.BOOL
self._crypt32.CryptUnprotectData.argtypes = (
ctypes.POINTER(_DataBlob),
ctypes.POINTER(wintypes.LPWSTR),
ctypes.POINTER(_DataBlob),
wintypes.LPVOID,
wintypes.LPVOID,
wintypes.DWORD,
ctypes.POINTER(_DataBlob),
)
self._crypt32.CryptUnprotectData.restype = wintypes.BOOL
self._kernel32.LocalFree.argtypes = (wintypes.HLOCAL,)
self._kernel32.LocalFree.restype = wintypes.HLOCAL
def protect(self, plaintext: bytes, *, purpose: str) -> bytes:
if not isinstance(plaintext, bytes) or not plaintext:
raise ProtectionError("invalid_plaintext")
entropy = self._entropy(purpose)
source, source_buffer = _blob(plaintext)
entropy_blob, entropy_buffer = _blob(entropy)
output = _DataBlob()
if not self._crypt32.CryptProtectData(
ctypes.byref(source), None, ctypes.byref(entropy_blob), None, None, self._UI_FORBIDDEN, ctypes.byref(output)
):
raise ProtectionError("dpapi_protect_failed")
# ctypes 指针不持有底层 Python buffer;局部引用必须活到系统调用返回。
del source_buffer, entropy_buffer
return self._take_output(output, "dpapi_protect_failed")
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes:
if not isinstance(ciphertext, bytes) or not ciphertext:
raise ProtectionError("invalid_ciphertext")
entropy = self._entropy(purpose)
source, source_buffer = _blob(ciphertext)
entropy_blob, entropy_buffer = _blob(entropy)
output = _DataBlob()
description = wintypes.LPWSTR()
if not self._crypt32.CryptUnprotectData(
ctypes.byref(source), ctypes.byref(description), ctypes.byref(entropy_blob), None, None, self._UI_FORBIDDEN, ctypes.byref(output)
):
raise ProtectionError("dpapi_unprotect_failed")
del source_buffer, entropy_buffer
if description:
self._kernel32.LocalFree(ctypes.cast(description, wintypes.HLOCAL))
return self._take_output(output, "dpapi_unprotect_failed")
def _take_output(self, output: _DataBlob, reason: str) -> bytes:
if not output.pbData or output.cbData <= 0:
raise ProtectionError(reason)
try:
return ctypes.string_at(output.pbData, output.cbData)
finally:
self._kernel32.LocalFree(ctypes.cast(output.pbData, wintypes.HLOCAL))
@classmethod
def _entropy(cls, purpose: str) -> bytes:
if not isinstance(purpose, str) or cls._PURPOSE_RE.fullmatch(purpose) is None:
raise ProtectionError("invalid_protection_purpose")
return cls._ENTROPY_PREFIX + purpose.encode("ascii")
@@ -0,0 +1,51 @@
"""同一本地数据库的 Windows named mutex。"""
from __future__ import annotations
import ctypes
from ctypes import wintypes
import hashlib
import os
from pathlib import Path
from cmbuyer_client.core.errors import SingleInstanceError
class NamedMutex:
_ALREADY_EXISTS = 183
def __init__(self, database_path: Path) -> None:
if os.name != "nt":
raise SingleInstanceError("named_mutex_requires_windows")
canonical = str(database_path.expanduser().resolve()).casefold().encode("utf-8")
# Global namespace 覆盖同一 Windows 用户的多个交互 session;默认 DACL 不向其他用户泄露句柄。
name = "Global\\cmbuyer-" + hashlib.sha256(canonical).hexdigest()
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
kernel32.CreateMutexW.argtypes = (wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR)
kernel32.CreateMutexW.restype = wintypes.HANDLE
kernel32.ReleaseMutex.argtypes = (wintypes.HANDLE,)
kernel32.ReleaseMutex.restype = wintypes.BOOL
kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
kernel32.CloseHandle.restype = wintypes.BOOL
ctypes.set_last_error(0)
handle = kernel32.CreateMutexW(None, True, name)
if not handle:
raise SingleInstanceError("named_mutex_failed")
if ctypes.get_last_error() == self._ALREADY_EXISTS:
kernel32.CloseHandle(handle)
raise SingleInstanceError("instance_already_running")
self._kernel32 = kernel32
self._handle = handle
def close(self) -> None:
handle = getattr(self, "_handle", None)
if handle:
self._kernel32.ReleaseMutex(handle)
self._kernel32.CloseHandle(handle)
self._handle = None
def __enter__(self) -> "NamedMutex":
return self
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
self.close()
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -23,6 +23,8 @@ _KEY_VALUE_PATTERN = re.compile(
flags=re.IGNORECASE,
)
_PHONE_PATTERN = re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")
_BEARER_PATTERN = re.compile(r"(?i)\bBearer\s+[0-9a-f]{64}\b")
_BARE_TOKEN_PATTERN = re.compile(r"(?<![0-9a-fA-F])[0-9a-fA-F]{64}(?![0-9a-fA-F])")
def redact_text(message: str) -> str:
@@ -31,7 +33,9 @@ def redact_text(message: str) -> str:
def replace_key_value(match: re.Match[str]) -> str:
return f"{match.group('key')}{match.group('separator')}{REDACTED}"
redacted = _KEY_VALUE_PATTERN.sub(replace_key_value, message)
redacted = _BEARER_PATTERN.sub("Bearer " + REDACTED, message)
redacted = _KEY_VALUE_PATTERN.sub(replace_key_value, redacted)
redacted = _BARE_TOKEN_PATTERN.sub(REDACTED, redacted)
return _PHONE_PATTERN.sub(REDACTED, redacted)
@@ -47,6 +51,16 @@ class SensitiveDataFilter(logging.Filter):
return True
class RedactingFormatter(logging.Formatter):
"""再次处理完整格式化文本,覆盖异常 traceback 中的敏感值。"""
def format(self, record: logging.LogRecord) -> str:
return redact_text(super().format(record))
def formatException(self, exc_info: tuple[type[BaseException], BaseException, object]) -> str:
return redact_text(super().formatException(exc_info))
def configure_application_logger(paths: RuntimePaths) -> logging.Logger:
"""配置唯一的 UTF-8 文件日志,并确保其先经过脱敏过滤。"""
@@ -61,6 +75,6 @@ def configure_application_logger(paths: RuntimePaths) -> logging.Logger:
handler = logging.FileHandler(Path(paths.logs) / "client.log", encoding="utf-8")
handler.addFilter(SensitiveDataFilter())
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
handler.setFormatter(RedactingFormatter("%(asctime)s %(levelname)s %(message)s"))
logger.addHandler(handler)
return logger
@@ -0,0 +1,436 @@
"""T-103 尺码显示动作的一次性真机取证;不属于生产采购 Flow。"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from hashlib import sha256
import json
from math import isfinite
import os
from pathlib import Path
import shutil
from time import monotonic, sleep
from typing import Any
from uuid import uuid4
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
from ..device.baseline import PDD_PACKAGE, _save_base64_screenshot, _sha256_file
from .product_url import parse_product_url
from .sku_selection import (
EXPECTED_GOODS_ID,
_COLOR_ONLY_SUMMARY,
_PANEL_SURFACE,
_PanelProfile,
_REVEAL_NOT_PROVEN,
_S_SIZE_UI,
_TARGET_COLOR_UI,
_TARGET_SIZE_UI,
_action_bounds,
_clickable_before,
_descendant,
_exact_color_action,
_exact_inert,
_exact_live_layout,
_exact_readonly_text,
_exact_recycler,
_is_size_action_text,
_one,
_parse_nodes,
_require_color_only_panel,
_require_color_action_chain,
_require_color_subtree,
_require_exact_selected_set,
_require_panel_chain,
_require_size_wrapper,
_require_size_action_chain,
_spec_for,
resolve_task_selection,
SkuSelectionError,
SkuSelectionFlow,
)
from .sku_selection_runner import (
EXPECTED_ANDROID_VERSION,
EXPECTED_DEVICE_MODEL,
EXPECTED_SCREEN_SIZE,
SkuSelectionRunError,
UiautomatorSkuPanelAdapter,
_require_expected_device,
_require_expected_version,
_require_screenshot_size,
)
_TARGET_URL = f"https://mobile.yangkeduo.com/goods.html?goods_id={EXPECTED_GOODS_ID}"
_REVEAL_START = (360, 1900)
_REVEAL_END = (360, 1300)
_REVEAL_STEPS = 30
class SkuRevealSpikeError(RuntimeError):
"""一次性 reveal 取证未形成可发布证据。"""
@dataclass(frozen=True)
class SkuRevealSpikeResult:
output_directory: Path
manifest_path: Path
class _RevealEvidenceAdapter(UiautomatorSkuPanelAdapter):
"""只为 spike 增加一个无参数、固定 profile 的一次性手势。"""
def __init__(self, device: Any, timeout_seconds: float) -> None:
super().__init__(device, timeout_seconds)
self._reveal_attempted = False
@property
def reveal_attempted(self) -> bool:
return self._reveal_attempted
def reveal_size_options_once(self) -> None:
if self._reveal_attempted:
raise SkuRevealSpikeError("规格显示动作已经尝试过,拒绝重试。")
# RPC 超时也可能表示手势已经送达,必须在调用前封存唯一机会。
self._reveal_attempted = True
self._call(
"jsonrpc_call",
"swipe",
[*_REVEAL_START, *_REVEAL_END, _REVEAL_STEPS],
timeout=self._timeout_seconds,
)
class SkuRevealSpikeCapturer:
"""打开已取证面板、选一次目标颜色,再采集唯一 reveal 的前后证据。"""
def __init__(
self,
adb_client: AdbClient,
connector: Callable[[str], Any],
timeout_seconds: float,
monotonic_clock: Callable[[], float] = monotonic,
sleep_function: Callable[[float], None] = sleep,
) -> None:
if not _positive_finite(timeout_seconds):
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
self._adb_client = adb_client
self._connector = connector
self._timeout_seconds = timeout_seconds
self._clock = monotonic_clock
self._sleep = sleep_function
def capture(
self,
serial: str,
goods_id: str,
output_directory: Path,
) -> SkuRevealSpikeResult:
if type(goods_id) is not str or goods_id != EXPECTED_GOODS_ID:
raise SkuRevealSpikeError("商品不是 T-103 已取证目标,已停止取证。")
link = parse_product_url(_TARGET_URL)
target = Path(output_directory)
_validate_new_target(target)
staging: Path | None = None
try:
staging = _prepare_staging(target)
deadline = self._clock() + self._timeout_seconds
inspection = self._adb_client.inspect(serial)
_require_expected_device(inspection)
adapter = _RevealEvidenceAdapter(self._connector(serial), self._timeout_seconds)
_require_expected_version(adapter.app_info(PDD_PACKAGE))
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
raise SkuRevealSpikeError("设备不是已取证的竖屏坐标空间,已停止取证。")
pre_intent = adapter.dump_window_hierarchy()
self._adb_client.start_pdd_view_intent(serial, link.goods_id)
remaining = deadline - self._clock()
if remaining <= 0:
raise SkuRevealSpikeError("规格入口取证超时,未执行 reveal。")
flow = SkuSelectionFlow(
adapter,
entry_wait_timeout_seconds=remaining,
monotonic_clock=self._clock,
sleep_function=self._sleep,
)
flow.open_sku_panel(link.canonical_url, pre_intent)
try:
flow.select_sku_options(resolve_task_selection("黑色CHA(纯棉)", "M(建议100-115)"))
except SkuSelectionError as error:
if error.args != (_REVEAL_NOT_PROVEN,):
raise
else:
raise SkuRevealSpikeError("规格流程未停在已取证的仅颜色状态。")
before_hierarchy = adapter.dump_window_hierarchy()
before_nodes = _parse_nodes(before_hierarchy)
_require_color_only_panel(before_nodes)
_require_safe_reveal_path(before_nodes)
_capture_frame(adapter, staging / "before", before_hierarchy)
# 截图 RPC 期间页面也可能变化;真正发送手势前必须用新树再次证明同一前置与安全通道。
before_hierarchy = adapter.dump_window_hierarchy()
before_nodes = _parse_nodes(before_hierarchy)
_require_color_only_panel(before_nodes)
_require_safe_reveal_path(before_nodes)
_require_expected_version(adapter.app_info(PDD_PACKAGE))
current = adapter.app_current()
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
raise SkuRevealSpikeError("reveal 前拼多多不在前台,未执行手势。")
(staging / "before" / "hierarchy.xml").write_text(before_hierarchy, encoding="utf-8")
rpc_outcome = "completed"
try:
adapter.reveal_size_options_once()
except SkuSelectionRunError:
rpc_outcome = "ambiguous_reconciled"
projection, after_hierarchy = self._wait_for_candidate(adapter, deadline)
after_directory = staging / "after"
_capture_frame(adapter, after_directory, after_hierarchy)
reverified = adapter.dump_window_hierarchy()
if _candidate_projection(_parse_nodes(reverified)) != projection:
raise SkuRevealSpikeError("截图后候选状态漂移,未发布证据。")
(after_directory / "hierarchy.xml").write_text(reverified, encoding="utf-8")
manifest = _manifest(inspection, serial, rpc_outcome, staging)
manifest_path = staging / "manifest.json"
manifest_path.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
os.rename(staging, target)
staging = None
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError, SkuRevealSpikeError):
_clean_staging(staging)
raise
except Exception as error:
_clean_staging(staging)
raise SkuRevealSpikeError("规格 reveal 取证未完成,未发布本地证据目录。") from error
return SkuRevealSpikeResult(target, target / "manifest.json")
def _wait_for_candidate(
self,
adapter: _RevealEvidenceAdapter,
deadline: float,
) -> tuple[tuple[tuple[str, ...], ...], str]:
stable: tuple[tuple[str, ...], ...] | None = None
while True:
_require_expected_version(adapter.app_info(PDD_PACKAGE))
current = adapter.app_current()
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
raise SkuRevealSpikeError("reveal 后拼多多不在前台,未发布证据。")
hierarchy = adapter.dump_window_hierarchy()
try:
projection = _candidate_projection(_parse_nodes(hierarchy))
except SkuSelectionError:
projection = None
if projection is not None and projection == stable:
return projection, hierarchy
stable = projection
remaining = deadline - self._clock()
if remaining <= 0:
raise SkuRevealSpikeError("reveal 后未形成稳定候选状态,未发布证据。")
self._sleep(min(0.2, remaining))
def _require_safe_reveal_path(nodes: list[Any]) -> None:
# 当前真机证据证明 x=360 是两列规格卡之间的空隙;必须验证完整线段,离散采样会漏掉窄浮层。
surface = _one(
[node for node in nodes if _exact_inert(node, "android.view.ViewGroup", _PANEL_SURFACE)],
"固定 reveal 通道无法绑定面板内容面。",
)
_require_panel_chain(surface)
content = surface.parent
action_root = content.parent if content is not None else None
action_parent = action_root.parent if action_root is not None else None
if (
content is None
or action_root is None
or action_parent is None
or not _exact_live_layout(action_root, "android.view.ViewGroup", "[0,366][1080,2328]")
or not _exact_live_layout(action_parent, "android.widget.LinearLayout", "[0,120][1080,2328]")
):
raise SkuRevealSpikeError("固定 reveal 通道祖先身份漂移,未执行手势。")
allowed = {id(action_root.element), id(action_parent.element)}
occupants: set[int] = set()
for node in nodes:
if node.element.get("clickable") != "true":
continue
try:
left, top, right, bottom = _action_bounds(node.bounds)
except SkuSelectionError as error:
raise SkuRevealSpikeError("可点击节点坐标不可验证,未执行手势。") from error
if (
left <= _REVEAL_START[0] < right
and top <= _REVEAL_START[1]
and bottom > _REVEAL_END[1]
):
occupants.add(id(node.element))
if occupants != allowed:
raise SkuRevealSpikeError("固定 reveal 通道被未取证可点击节点占用,未执行手势。")
if _REVEAL_START[1] >= 2079 or _REVEAL_END[1] >= 2079:
raise SkuRevealSpikeError("固定 reveal 通道越过规格内容区,未执行手势。")
def _candidate_projection(nodes: list[Any]) -> tuple[tuple[str, ...], ...]:
surface = _one(
[node for node in nodes if _exact_inert(node, "android.view.ViewGroup", _PANEL_SURFACE)],
"候选面板内容面不唯一。",
)
_require_panel_chain(surface)
header = _one(
[node for node in nodes if node.parent is surface and _exact_inert(node, "android.widget.LinearLayout", "[0,366][1080,1000]")],
"候选面板头部不唯一。",
)
outer = _one(
[node for node in nodes if node.parent is surface and _exact_recycler(node, "[0,1000][1080,2079]")],
"候选维度容器不唯一。",
)
price_row = _one(
[node for node in nodes if _descendant(node, header) and _exact_inert(node, "android.widget.LinearLayout", "[396,498][895,570]")],
"候选价格行不唯一。",
)
current = _one(
[node for node in nodes if node.parent is price_row and _exact_readonly_text(node, "快卖完 ¥12.88", "[396,503][712,570]")],
"候选当前价不唯一。",
)
original = _one(
[node for node in nodes if node.parent is price_row and _exact_readonly_text(node, "¥29.88", "[730,503][895,570]")],
"候选原价不唯一。",
)
if _clickable_before(current, surface):
raise SkuSelectionError("候选当前价位于可点击内容祖先下。")
summary = _one(
[node for node in nodes if _descendant(node, header) and _exact_readonly_text(node, _COLOR_ONLY_SUMMARY, "[396,654][1053,716]")],
"候选摘要不唯一。",
)
color_region = _one(
[node for node in nodes if _descendant(node, outer) and _exact_recycler(node, "[36,1000][1080,1483]")],
"候选颜色容器不唯一。",
)
color = _one(
[node for node in nodes if node.parent is color_region and _exact_color_action(node, "[372,1000][684,1024]", True)],
"候选目标颜色不唯一。",
)
rolled_spec = _spec_for(_PanelProfile.TARGETS_SELECTED)
selected_color_nodes = _require_color_subtree(color, rolled_spec)
_require_color_action_chain(color, color_region, outer, surface, rolled_spec)
size_label = _one(
[node for node in nodes if _descendant(node, outer) and _exact_readonly_text(node, "尺码", "[36,1506][114,1552]")],
"候选尺码标题不唯一。",
)
size_header = size_label.parent
if size_header is None or not _exact_live_layout(size_header, "android.widget.LinearLayout", "[36,1489][1044,1570]"):
raise SkuSelectionError("候选尺码标题父结构漂移。")
size_options = _one(
[node for node in nodes if _descendant(node, outer) and _exact_inert(node, "android.view.ViewGroup", "[36,1582][1044,1897]")],
"候选尺码 options 根不唯一。",
)
actions = [node for node in nodes if _descendant(node, size_options) and _is_size_action_text(node)]
s_action = _one(
[node for node in actions if node.text == _S_SIZE_UI and node.bounds == "[36,1582][409,1667]"],
"候选 S action 不唯一。",
)
m_action = _one(
[node for node in actions if node.text == _TARGET_SIZE_UI and node.bounds == "[439,1582][831,1667]"],
"候选 M action 不唯一。",
)
_require_size_wrapper(s_action, size_options)
_require_size_wrapper(m_action, size_options)
_require_size_action_chain(s_action, size_options, outer, surface)
_require_size_action_chain(m_action, size_options, outer, surface)
if any(node.element.get("selected") == "true" for node in actions):
raise SkuSelectionError("reveal 候选态已有尺码被选中。")
_require_exact_selected_set(nodes, surface, selected_color_nodes)
dangerous = [
node for node in nodes
if "提交订单" in node.text
and node.element.get("package") == PDD_PACKAGE
and node.element.get("class") == "android.widget.TextView"
]
if len(dangerous) != 1 or _action_bounds(dangerous[0].bounds)[1] < 2079:
raise SkuSelectionError("提交硬拒绝区位置不唯一。")
return tuple(
(
node.element.get("class", ""),
node.bounds,
node.text,
node.desc,
node.element.get("selected", ""),
node.element.get("clickable", ""),
)
for node in (surface, header, outer, price_row, current, original, summary, color_region, color, size_label, size_options, s_action, m_action, dangerous[0])
)
def _capture_frame(adapter: _RevealEvidenceAdapter, directory: Path, hierarchy: str) -> None:
directory.mkdir()
hierarchy_path = directory / "hierarchy.xml"
hierarchy_path.write_text(hierarchy, encoding="utf-8")
screenshot_path = directory / "screenshot.png"
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
_require_screenshot_size(screenshot_path)
def _manifest(
inspection: DeviceInspection,
serial: str,
rpc_outcome: str,
staging: Path,
) -> dict[str, Any]:
artifacts = []
for relative in (
"before/screenshot.png",
"before/hierarchy.xml",
"after/screenshot.png",
"after/hierarchy.xml",
):
path = staging / relative
artifacts.append({"path": relative, "sha256": _sha256_file(path)})
return {
"schema_version": 1,
"captured_at": datetime.now(UTC).isoformat(),
"operation": "t103-sku-reveal-evidence",
"profile_id": "pdd-8.17.0-size-reveal-gap-v1",
"product": {"goods_id": EXPECTED_GOODS_ID},
"channel": "wifi" if ":" in serial else "usb",
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
"device": {
"model": inspection.model,
"android_version": inspection.android_version,
"pdd_package": PDD_PACKAGE,
"pdd_version": "8.17.0",
},
"reveal_attempts": 1,
"rpc_outcome": rpc_outcome,
"candidate_status": "human_review_required",
"artifacts": artifacts,
}
def _validate_new_target(target: Path) -> None:
if target.exists() or not target.name:
raise SkuRevealSpikeError("输出目录必须是不存在的明确新目录。")
def _prepare_staging(target: Path) -> Path:
target.parent.mkdir(parents=True, exist_ok=True)
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
staging.mkdir()
return staging
def _clean_staging(staging: Path | None) -> None:
if staging is not None and staging.exists():
shutil.rmtree(staging)
def _positive_finite(value: object) -> bool:
return (
isinstance(value, (int, float))
and not isinstance(value, bool)
and value > 0
and isfinite(value)
)
+710 -154
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
import re
from time import monotonic, sleep
from typing import Any, Callable, Protocol
@@ -16,23 +17,29 @@ EXPECTED_UNIT_PRICE = "12.88"
# 任务值不是页面判据;右侧是 v5 取证的唯一 accessibility 文案(空格/全角括号均有意义)。
TASK_TO_UI_SELECTION = {("黑色CHA(纯棉)", "M(建议100-115)"): ("黑色 CHA (纯棉)", "M(建议100-115)")}
_TARGET_COLOR_UI, _TARGET_SIZE_UI = next(iter(TASK_TO_UI_SELECTION.values()))
_ENTRY = "快要抢光"
_ENTRY_TEXT_BOUNDS = "[900,1312][1056,1355]"
_ENTRY_INNER_BOUNDS = "[712,1312][1056,1355]"
_ENTRY_ACTION_BOUNDS = "[0,1256][1080,1355]"
_ENTRY = "快要抢光 ¥ 12.88"
_ENTRY_PROMOTION_LABEL = "快要抢光"
_ENTRY_TEXT_BOUNDS = "[688,2184][1042,2253]"
_ENTRY_ACTION_DESC = "快要抢光¥12.88"
_ENTRY_ACTION_BOUNDS = "[446,2166][1080,2328]"
_ENTRY_SIBLING = "免拼购买"
_ENTRY_SIBLING_BOUNDS = "[688,2256][856,2305]"
_FORBIDDEN_ENTRY_ACTION_DESC = (
"购买", "下单", "付款", "订单", "单独购买", "直接拼成", "提交订单", "支付",
"先用后付", "0元下单", "0 元下单",
)
_SIZE = "尺码"
_W, _H = 1080, 2376
_PRICE_PARENT = "[396,498][895,570]"
_CURRENT = "[396,503][712,570]"
_ORIGINAL = "[730,503][895,570]"
_SUMMARY = "[396,654][1053,716]"
_COLOR_REGION = "[36,1000][1080,1631]"
_SIZE_LABEL = "[36,1654][114,1700]"
_SIZE_HEADER = "[36,1637][1044,1718]"
_SIZE_OPTIONS = "[36,1730][1044,2045]"
_PANEL_SURFACE = "[0,366][1080,2079]"
_EMPTY_SUMMARY = "请选择: 颜色分类 尺码"
_COLOR_ONLY_SUMMARY = "请选择: 尺码"
_S_SIZE_UI = "S(建议80-100)"
_S_SUMMARY = f"已选: {_TARGET_COLOR_UI} {_S_SIZE_UI}"
_TARGET_SUMMARY = f"已选: {_TARGET_COLOR_UI} {_TARGET_SIZE_UI}"
_REVEAL_NOT_PROVEN = "尺码仍在已取证视口外;受控显示动作尚未取证,已停止后续点击。"
_BOUNDS = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$")
_PRICE = re.compile(r"^[^0-9¥¥]*[¥¥]([1-9][0-9]*\.[0-9]{2})$")
_ORIGINAL_PRICE = re.compile(r"^[¥¥][1-9][0-9]*\.[0-9]{2}$")
_ROLLED_CURRENT_PRICE = "快卖完 ¥12.88"
_ROLLED_ORIGINAL_PRICE = "¥29.88"
_BAD_PRICE_ROLE = ("提交订单", "支付", "优惠", "券", "会员", "补贴", "区间", "实付", "到手", "原价", "划线价", "最低", "低至", "起价", "下单", "先用后付", "预估")
@@ -40,6 +47,43 @@ class SkuSelectionError(RuntimeError):
"""已取证判据不成立时的脱敏停止。"""
_SKU_ENTRY_FAILURE_STAGES = frozenset(
(
"sku_entry_pre_intent",
"sku_entry_discovery",
"sku_entry_click",
"sku_entry_panel_verify",
)
)
_SKU_ENTRY_FAILURE_MARKER = object()
def _annotate_sku_entry_failure(error: BaseException, stage: str) -> None:
"""把 Flow 实际经过的固定入口子阶段附到原异常,不改变异常类型。"""
if type(stage) is not str or stage not in _SKU_ENTRY_FAILURE_STAGES:
return
try:
# marker 最后写入:若第三方异常拒绝任一属性写入,就不能形成可信诊断。
setattr(error, "_cmbuyer_failure_stage", stage)
setattr(error, "_cmbuyer_sku_entry_failure_marker", _SKU_ENTRY_FAILURE_MARKER)
except BaseException:
pass
def _safe_sku_entry_failure_stage(error: BaseException) -> str | None:
"""只读取由本模块写入的入口子阶段;任意异常自报的值不可信。"""
try:
marker = getattr(error, "_cmbuyer_sku_entry_failure_marker", None)
stage = getattr(error, "_cmbuyer_failure_stage", None)
if marker is not _SKU_ENTRY_FAILURE_MARKER or type(stage) is not str:
return None
return stage if stage in _SKU_ENTRY_FAILURE_STAGES else None
except BaseException:
return None
class SkuPanelDevice(Protocol):
def app_info(self, package_name: str) -> dict[str, Any]: ...
def app_current(self) -> dict[str, Any]: ...
@@ -74,6 +118,73 @@ class _Node:
def bounds(self) -> str: return self.element.get("bounds", "")
class _PanelProfile(Enum):
PANEL_OPEN_EMPTY = "panel_open_empty"
COLOR_SELECTED_SIZE_HIDDEN = "color_selected_size_hidden"
SIZE_VISIBLE_NON_TARGET = "size_visible_non_target"
TARGETS_SELECTED = "targets_selected"
@dataclass(frozen=True)
class _PanelSpec:
profile: _PanelProfile
header_bounds: str
outer_bounds: str
price_row_bounds: str
current_text: str
current_bounds: str
original_text: str
original_bounds: str
summary_text: str
summary_bounds: str
color_label_bounds: str | None
color_region_bounds: str
color_bounds: str
color_selected: bool
size_label_bounds: str
selected_size: str | None
_PANEL_SPECS = (
_PanelSpec(
_PanelProfile.PANEL_OPEN_EMPTY,
"[0,366][1080,1077]", "[0,1077][1080,2079]",
"[396,575][912,647]", "限1件 ¥12.88 ", "[396,580][675,647]",
"券前¥29.88", "[693,580][912,647]",
_EMPTY_SUMMARY, "[396,731][1053,793]", "[36,1106][192,1159]",
"[36,1188][1080,2046]", "[372,1188][684,1587]", False,
"[36,2069][114,2079]", None,
),
_PanelSpec(
_PanelProfile.COLOR_SELECTED_SIZE_HIDDEN,
"[0,366][1080,1077]", "[0,1077][1080,2079]",
"[396,575][912,647]", "限1件 ¥12.88 ", "[396,580][675,647]",
"券前¥29.88", "[693,580][912,647]",
_COLOR_ONLY_SUMMARY, "[396,731][1053,793]", "[36,1106][192,1159]",
"[36,1188][1080,2046]", "[372,1188][684,1587]", True,
"[36,2069][114,2079]", None,
),
_PanelSpec(
_PanelProfile.SIZE_VISIBLE_NON_TARGET,
"[0,366][1080,1000]", "[0,1000][1080,2079]",
"[396,498][895,570]", _ROLLED_CURRENT_PRICE, "[396,503][712,570]",
_ROLLED_ORIGINAL_PRICE, "[730,503][895,570]",
_S_SUMMARY, "[396,654][1053,716]", None,
"[36,1000][1080,1483]", "[372,1000][684,1024]", True,
"[36,1506][114,1552]", _S_SIZE_UI,
),
_PanelSpec(
_PanelProfile.TARGETS_SELECTED,
"[0,366][1080,1000]", "[0,1000][1080,2079]",
"[396,498][895,570]", _ROLLED_CURRENT_PRICE, "[396,503][712,570]",
_ROLLED_ORIGINAL_PRICE, "[730,503][895,570]",
_TARGET_SUMMARY, "[396,654][1053,716]", None,
"[36,1000][1080,1483]", "[372,1000][684,1024]", True,
"[36,1506][114,1552]", _TARGET_SIZE_UI,
),
)
class SkuSelectionFlow:
def __init__(self, device: SkuPanelDevice, entry_wait_timeout_seconds: float = 0.2,
entry_poll_interval_seconds: float = 0.2, monotonic_clock: Callable[[], float] = monotonic,
@@ -85,40 +196,84 @@ class SkuSelectionFlow:
self._pending: tuple[str, Callable[[list[_Node]], Any]] | None = None
def open_sku_panel(self, product_url: str, pre_intent_hierarchy: str | None = None) -> None:
try:
if parse_product_url(product_url).goods_id != EXPECTED_GOODS_ID:
raise SkuSelectionError("商品不是已取证目标,已停止操作。")
if pre_intent_hierarchy is not None:
previous_nodes = _parse_nodes(pre_intent_hierarchy)
if _eligible_entries(previous_nodes):
# intent 前只判断旧页是否已经存在完整入口链;浮层或额外动作节点不能把旧商品
# 伪装成“不安全所以不存在”,否则 intent 后可能误把旧页当成新目标页。
if _physical_entries(previous_nodes):
raise SkuSelectionError("intent 前页面已出现规格入口,已拒绝旧商品误点。")
except BaseException as error:
_annotate_sku_entry_failure(error, "sku_entry_pre_intent")
raise
try:
entry, before = self._wait_for_entry(pre_intent_hierarchy)
_action_bounds(entry.bounds)
self._pending = (before, _panel)
except BaseException as error:
_annotate_sku_entry_failure(error, "sku_entry_discovery")
raise
try:
self._pending = (before, _require_empty_panel)
self._device.tap_sku_entry(entry.bounds)
self._wait_after_action(before, _panel)
except BaseException as error:
_annotate_sku_entry_failure(error, "sku_entry_click")
raise
try:
self._wait_after_action(before, _require_empty_panel)
except BaseException as error:
_annotate_sku_entry_failure(error, "sku_entry_panel_verify")
raise
def select_sku_options(self, selection: SkuSelection) -> None:
if selection not in {SkuSelection(*item) for item in TASK_TO_UI_SELECTION.values()}:
if selection != SkuSelection(_TARGET_COLOR_UI, _TARGET_SIZE_UI):
raise SkuSelectionError("规格 UI 文案不是获准目标,已停止操作。")
initial = self._verified_nodes()
_option(initial, "color", selection.color); _option(initial, "size", selection.size)
_selected_label(initial, "color"); _selected_label(initial, "size")
self._restore("color", selection.color)
self._restore("size", selection.size)
self._require_foreground()
before = self._read_hierarchy()
nodes = _parse_nodes(before)
profile = _classify_panel(nodes)
if profile is _PanelProfile.PANEL_OPEN_EMPTY:
target = _target_color_action(nodes, selected=False)
_action_bounds(target.bounds)
_require_action_occupants(nodes, target)
self._pending = (before, _require_color_only_panel)
self._device.tap_sku_option(target.bounds)
self._wait_after_action(before, _require_color_only_panel)
# 当前证据只证明颜色选择;尺码仍在视口外。没有动作证据时必须在此停住,
# 不能把一次通用 swipe 或下一次规格点击伪装成已验证流程。
raise SkuSelectionError(_REVEAL_NOT_PROVEN)
if profile is _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN:
raise SkuSelectionError(_REVEAL_NOT_PROVEN)
if profile is _PanelProfile.SIZE_VISIBLE_NON_TARGET:
target = _target_size_action(nodes, selected=False)
_action_bounds(target.bounds)
_require_action_occupants(nodes, target)
self._pending = (before, _require_target_panel)
self._device.tap_sku_option(target.bounds)
self._wait_after_action(before, _require_target_panel)
return
if profile is _PanelProfile.TARGETS_SELECTED:
return
raise SkuSelectionError("规格面板状态不属于已取证 profile,已停止操作。")
def read_sku_unit_price(self) -> str:
return _unit_price(self._verified_nodes())
def verify_target_selection_and_read_price(self, selection: SkuSelection) -> str:
if selection != SkuSelection(_TARGET_COLOR_UI, _TARGET_SIZE_UI):
raise SkuSelectionError("规格 UI 文案不是获准目标,已停止读取。")
nodes = self._verified_nodes()
_selected(nodes, "color", selection.color)
_selected(nodes, "size", selection.size)
return _unit_price(nodes)
def exit_sku_panel_safely(self) -> None:
self._require_foreground()
before = self._read_hierarchy()
_panel(_parse_nodes(before))
_classify_panel(_parse_nodes(before))
self._device.leave_sku_panel()
deadline = self._clock() + self._entry_timeout
while True:
@@ -126,7 +281,7 @@ class SkuSelectionFlow:
raw = self._read_hierarchy()
if raw != before:
try:
_panel(_parse_nodes(raw))
_classify_panel(_parse_nodes(raw))
except SkuSelectionError:
return
remaining = deadline - self._clock()
@@ -141,26 +296,6 @@ class SkuSelectionFlow:
before, condition = self._pending
self._wait_after_action(before, condition)
def _restore(self, dimension: str, expected: str) -> None:
self._require_foreground()
before = self._read_hierarchy()
nodes = _panel(_parse_nodes(before))
target = _option(nodes, dimension, expected)
if _selected_label(nodes, dimension) == expected:
return
_action_bounds(target.bounds)
condition: Callable[[list[_Node]], Any]
if dimension == "color":
condition = lambda refreshed: _post_color(refreshed, expected)
else:
condition = lambda refreshed: _post_all_targets(refreshed, expected)
self._pending = (before, condition)
self._device.tap_sku_option(target.bounds)
if dimension == "color":
self._wait_after_action(before, condition)
else:
self._wait_after_action(before, condition)
def _wait_for_entry(self, previous: str | None) -> tuple[_Node, str]:
deadline, stable = self._clock() + self._entry_timeout, None
while True:
@@ -168,13 +303,20 @@ class SkuSelectionFlow:
current = self._device.app_current()
if isinstance(current, dict) and current.get("package") == PDD_PACKAGE:
raw = self._read_hierarchy()
entries = _eligible_entries(_parse_nodes(raw))
nodes = _parse_nodes(raw)
entries = _eligible_entries(nodes)
if len(entries) > 1:
raise SkuSelectionError("商品页规格入口不唯一,已停止操作。")
if len(entries) == 1 and raw != previous:
if stable == raw:
# 商品详情正文包含倒计时等动态节点,全文 XML 稳定不是已取证入口的安全属性。
# 连续两帧只比较证据绑定的底部入口、直接父容器和不可点击兄弟节点;商品正文及
# 父容器内其他非危险动态节点不是入口身份,不能迫使实现退回坐标兜底。
projection = _entry_projection(entries[0], nodes)
if projection is None:
raise SkuSelectionError("商品页规格入口结构失效,已停止操作。")
if stable == projection:
return entries[0], raw
stable = raw
stable = projection
else:
stable = None
else:
@@ -204,7 +346,9 @@ class SkuSelectionFlow:
def _verified_nodes(self) -> list[_Node]:
self._require_foreground()
return _panel(self._read_nodes())
nodes = self._read_nodes()
_classify_panel(nodes)
return nodes
def _require_version(self) -> None:
info = self._device.app_info(PDD_PACKAGE)
@@ -239,85 +383,415 @@ def _parse_nodes(raw: str) -> list[_Node]:
return result
def _panel(nodes: list[_Node]) -> list[_Node]:
parent = _one([n for n in nodes if n.bounds == _PRICE_PARENT], "规格面板价格区域不唯一,已停止操作。")
_one([n for n in nodes if n.parent is parent and n.bounds == _ORIGINAL and _readonly(n) and _ORIGINAL_PRICE.fullmatch(n.text)], "规格面板原价槽位不唯一,已停止操作。")
_one([n for n in nodes if n.bounds == _SUMMARY and _readonly(n) and n.text.startswith("已选:")], "规格面板已选摘要不唯一,已停止操作。")
_color_container(nodes); _size_container(nodes)
return nodes
def _classify_panel(nodes: list[_Node]) -> _PanelProfile:
matches: list[_PanelProfile] = []
for spec in _PANEL_SPECS:
try:
_match_panel_profile(nodes, spec)
except SkuSelectionError:
continue
matches.append(spec.profile)
if len(matches) != 1:
raise SkuSelectionError("规格面板不符合唯一完整取证 profile,已停止操作。")
return matches[0]
def _require_empty_panel(nodes: list[_Node]) -> None:
_require_profile(nodes, _PanelProfile.PANEL_OPEN_EMPTY)
def _require_color_only_panel(nodes: list[_Node]) -> None:
_require_profile(nodes, _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN)
def _require_target_panel(nodes: list[_Node]) -> None:
_require_profile(nodes, _PanelProfile.TARGETS_SELECTED)
def _require_profile(nodes: list[_Node], expected: _PanelProfile) -> None:
if _classify_panel(nodes) is not expected:
raise SkuSelectionError("规格面板动作后状态与已取证 profile 不一致,已停止操作。")
def _match_panel_profile(nodes: list[_Node], spec: _PanelSpec) -> None:
surface = _one(
[node for node in nodes if _exact_inert(node, "android.view.ViewGroup", _PANEL_SURFACE)],
"规格面板内容面不唯一。",
)
_require_panel_chain(surface)
header = _one(
[node for node in nodes if node.parent is surface and _exact_inert(node, "android.widget.LinearLayout", spec.header_bounds)],
"规格面板头部不唯一。",
)
outer = _one(
[node for node in nodes if node.parent is surface and _exact_recycler(node, spec.outer_bounds)],
"规格面板维度容器不唯一。",
)
price_row = _one(
[node for node in nodes if _descendant(node, header) and _exact_inert(node, "android.widget.LinearLayout", spec.price_row_bounds)],
"规格面板价格行不唯一。",
)
if len([child for child in price_row.element if child.tag == "node"]) != 2:
raise SkuSelectionError("规格面板价格行子节点数量漂移。")
current = _one(
[node for node in nodes if node.parent is price_row and _exact_readonly_text(node, spec.current_text, spec.current_bounds)],
"规格面板当前价角色不唯一。",
)
_one(
[node for node in nodes if node.parent is price_row and _exact_readonly_text(node, spec.original_text, spec.original_bounds)],
"规格面板原价角色不唯一。",
)
if _clickable_before(current, surface):
raise SkuSelectionError("规格面板价格角色位于可点击内容祖先下。")
_one(
[node for node in nodes if _descendant(node, header) and _exact_readonly_text(node, spec.summary_text, spec.summary_bounds)],
"规格面板摘要不唯一。",
)
color_region = _one(
[node for node in nodes if _descendant(node, outer) and _exact_recycler(node, spec.color_region_bounds)],
"规格面板颜色容器不唯一。",
)
if spec.color_label_bounds is None:
if any(node.text == "颜色分类" and _readonly(node) for node in nodes):
raise SkuSelectionError("滚动态出现未取证颜色标题。")
else:
_one(
[node for node in nodes if _descendant(node, outer) and not _descendant(node, color_region) and _exact_readonly_text(node, "颜色分类", spec.color_label_bounds)],
"规格面板颜色标题不唯一。",
)
color = _one(
[node for node in nodes if node.parent is color_region and _exact_color_action(node, spec.color_bounds, spec.color_selected)],
"目标颜色 action 不唯一。",
)
selected_nodes = _require_color_subtree(color, spec)
_require_color_action_chain(color, color_region, outer, surface, spec)
size_label = _one(
[node for node in nodes if _descendant(node, outer) and not _descendant(node, color_region) and _exact_readonly_text(node, _SIZE, spec.size_label_bounds)],
"规格面板尺码标题不唯一。",
)
if spec.selected_size is None:
if any(
node.text in {_S_SIZE_UI, _TARGET_SIZE_UI}
and _is_size_action_text(node)
for node in nodes
):
raise SkuSelectionError("尺码隐藏 profile 出现可点击尺码。")
_require_exact_selected_set(nodes, surface, selected_nodes)
return
size_header = size_label.parent
if size_header is None or not _exact_live_layout(size_header, "android.widget.LinearLayout", "[36,1489][1044,1570]"):
raise SkuSelectionError("规格面板尺码标题父结构漂移。")
size_options = _one(
[node for node in nodes if _descendant(node, outer) and _exact_inert(node, "android.view.ViewGroup", "[36,1582][1044,1897]")],
"规格面板尺码 options 根不唯一。",
)
size_actions = [
node for node in nodes
if _descendant(node, size_options)
and _is_size_action_text(node)
]
s_action = _one([node for node in size_actions if node.text == _S_SIZE_UI and node.bounds == "[36,1582][409,1667]"], "S 尺码 action 不唯一。")
m_action = _one([node for node in size_actions if node.text == _TARGET_SIZE_UI and node.bounds == "[439,1582][831,1667]"], "M 尺码 action 不唯一。")
_require_size_wrapper(s_action, size_options)
_require_size_wrapper(m_action, size_options)
_require_size_action_chain(s_action, size_options, outer, surface)
_require_size_action_chain(m_action, size_options, outer, surface)
selected_sizes = [node for node in size_actions if node.element.get("selected") == "true"]
expected_action = s_action if spec.selected_size == _S_SIZE_UI else m_action
if selected_sizes != [expected_action]:
raise SkuSelectionError("尺码维度 selected 状态不唯一。")
_require_exact_selected_set(nodes, surface, [*selected_nodes, expected_action])
def _unit_price(nodes: list[_Node]) -> str:
parent = _one([n for n in nodes if n.bounds == _PRICE_PARENT], "规格面板价格区域不唯一,已停止读取。")
money = [n for n in nodes if n.parent is parent and _readonly(n) and any(mark in n.text for mark in "¥¥")]
if len(money) != 2: raise SkuSelectionError("规格面板金额槽位不唯一,已停止读取。")
current = _one([n for n in money if n.bounds == _CURRENT and not _clickable_ancestor(n) and not any(word in n.text for word in _BAD_PRICE_ROLE) and _PRICE.fullmatch(n.text)], "规格面板现价不唯一或不符合已取证槽位,已停止读取。")
if not any(n.bounds == _ORIGINAL and _ORIGINAL_PRICE.fullmatch(n.text) for n in money):
raise SkuSelectionError("规格面板原价槽位无效,已停止读取。")
match = _PRICE.fullmatch(current.text)
if match is None: raise SkuSelectionError("规格面板现价格式失效,已停止读取。")
return match.group(1)
_require_profile(nodes, _PanelProfile.TARGETS_SELECTED)
spec = _spec_for(_PanelProfile.TARGETS_SELECTED)
candidates = [
node for node in nodes
if _exact_readonly_text(node, _ROLLED_CURRENT_PRICE, spec.current_bounds)
]
current = _one(candidates, "规格面板现价不唯一,已停止读取。")
surface = _one([node for node in nodes if _exact_inert(node, "android.view.ViewGroup", _PANEL_SURFACE)], "规格面板内容面不唯一。")
if _clickable_before(current, surface) or any(word in current.text for word in _BAD_PRICE_ROLE):
raise SkuSelectionError("规格面板现价角色不可安全读取。")
return EXPECTED_UNIT_PRICE
def _option(nodes: list[_Node], dimension: str, expected: str) -> _Node:
_panel(nodes)
return _one([n for n in _options(nodes, dimension) if _label(n) == expected], "规格选项不唯一或不是精确匹配,已停止操作。")
def _target_color_action(nodes: list[_Node], *, selected: bool) -> _Node:
profile = _classify_panel(nodes)
expected_profile = _PanelProfile.COLOR_SELECTED_SIZE_HIDDEN if selected else _PanelProfile.PANEL_OPEN_EMPTY
if profile is not expected_profile:
raise SkuSelectionError("目标颜色 action 不属于预期 profile。")
spec = _spec_for(profile)
return _one([node for node in nodes if _exact_color_action(node, spec.color_bounds, selected)], "目标颜色 action 不唯一。")
def _selected(nodes: list[_Node], dimension: str, expected: str) -> None:
if _selected_label(nodes, dimension) != expected:
raise SkuSelectionError("规格选择后读回的 selected 文案不一致,已停止操作。")
def _target_size_action(nodes: list[_Node], *, selected: bool) -> _Node:
profile = _classify_panel(nodes)
expected_profile = _PanelProfile.TARGETS_SELECTED if selected else _PanelProfile.SIZE_VISIBLE_NON_TARGET
if profile is not expected_profile:
raise SkuSelectionError("目标尺码 action 不属于预期 profile。")
return _one(
[node for node in nodes if _is_size_action_text(node) and node.text == _TARGET_SIZE_UI and node.bounds == "[439,1582][831,1667]" and node.element.get("selected") == str(selected).lower()],
"目标尺码 action 不唯一。",
)
def _selected_label(nodes: list[_Node], dimension: str) -> str:
selected = [n for n in _options(nodes, dimension) if n.element.get("selected") == "true"]
label = _label(_one(selected, "规格维度没有唯一 selected 状态,已停止操作。"))
if label is None: raise SkuSelectionError("规格维度 selected 文案无效,已停止操作。")
return label
def _spec_for(profile: _PanelProfile) -> _PanelSpec:
return next(spec for spec in _PANEL_SPECS if spec.profile is profile)
def _post_color(nodes: list[_Node], expected: str) -> None:
_panel(nodes)
_selected(nodes, "color", expected)
_selected_label(nodes, "size")
def _exact_inert(node: _Node, class_name: str, bounds: str) -> bool:
return _exact_common(node, class_name, bounds, clickable="false", selected="false", scrollable="false") and not node.text and not node.desc
def _post_all_targets(nodes: list[_Node], expected_size: str) -> None:
_panel(nodes)
_selected(nodes, "color", _TARGET_COLOR_UI)
_selected(nodes, "size", expected_size)
def _exact_recycler(node: _Node, bounds: str) -> bool:
return _exact_common(node, "androidx.recyclerview.widget.RecyclerView", bounds, clickable="false", selected="false", scrollable="true") and not node.text and not node.desc
def _options(nodes: list[_Node], dimension: str) -> list[_Node]:
container = _color_container(nodes) if dimension == "color" else _size_container(nodes) if dimension == "size" else None
if container is None: raise SkuSelectionError("未知规格维度,已停止操作。")
candidates = [n for n in nodes if _descendant(n, container) and _contained(n, container) and _choice(n) and _label(n) is not None]
return [n for n in candidates if not _labeled_ancestor(n, candidates)]
def _exact_readonly_text(node: _Node, text: str, bounds: str) -> bool:
return _exact_common(node, "android.widget.TextView", bounds, clickable="false", selected="false", scrollable="false") and node.text == text and not node.desc
def _color_container(nodes: list[_Node]) -> _Node:
return _one([n for n in nodes if n.element.get("package") == PDD_PACKAGE and n.element.get("class") == "androidx.recyclerview.widget.RecyclerView" and n.bounds == _COLOR_REGION], "规格面板颜色容器不唯一,已停止操作。")
def _exact_color_action(node: _Node, bounds: str, selected: bool) -> bool:
return (
_exact_common(node, "android.view.ViewGroup", bounds, clickable="true", selected=str(selected).lower(), scrollable="false")
and not node.text
and node.desc == _TARGET_COLOR_UI
)
def _size_container(nodes: list[_Node]) -> _Node:
label = _one([n for n in nodes if n.text == _SIZE and n.bounds == _SIZE_LABEL and _readonly(n)], "规格面板尺码标签不唯一,已停止操作。")
header = label.parent
if header is None or header.element.get("package") != PDD_PACKAGE or header.element.get("class") != "android.widget.LinearLayout" or header.bounds != _SIZE_HEADER or header.parent is None:
raise SkuSelectionError("规格面板尺码标题容器不符合已取证结构,已停止操作。")
return _one([n for n in nodes if n.parent is header.parent and n.element.get("package") == PDD_PACKAGE and n.element.get("class") == "android.widget.LinearLayout" and n.bounds == _SIZE_OPTIONS], "规格面板尺码选项容器不唯一,已停止操作。")
def _exact_live_layout(node: _Node, class_name: str, bounds: str) -> bool:
return _exact_common(node, class_name, bounds, clickable="true", selected="false", scrollable="false") and not node.text and not node.desc
def _label(node: _Node) -> str | None:
values = {value for value in (node.text, node.desc) if value}
return values.pop() if len(values) == 1 else None
def _exact_common(node: _Node, class_name: str, bounds: str, *, clickable: str, selected: str, scrollable: str) -> bool:
return (
node.element.get("package") == PDD_PACKAGE
and node.element.get("class") == class_name
and node.bounds == bounds
and node.element.get("clickable") == clickable
and node.element.get("enabled") == "true"
and node.element.get("visible-to-user") == "true"
and node.element.get("selected") == selected
and node.element.get("scrollable") == scrollable
)
def _labeled_ancestor(node: _Node, candidates: list[_Node]) -> bool:
ids, parent = {id(n.element) for n in candidates}, node.parent
def _is_size_action_text(node: _Node) -> bool:
return (
node.element.get("package") == PDD_PACKAGE
and node.element.get("class") == "android.widget.TextView"
and node.element.get("clickable") == "true"
and node.element.get("enabled") == "true"
and node.element.get("visible-to-user") == "true"
and node.element.get("selected") in {"true", "false"}
and node.element.get("scrollable") == "false"
and not node.desc
)
def _require_size_wrapper(action: _Node, size_options: _Node) -> None:
wrapper = action.parent
if (
wrapper is None
or wrapper.parent is not size_options
or not _exact_live_layout(wrapper, "android.view.ViewGroup", action.bounds)
or wrapper.element[0] is not action.element
):
raise SkuSelectionError("尺码 action 父结构漂移。")
children = [child for child in wrapper.element if child.tag == "node"]
if action.element.get("selected") == "true":
if len(children) != 2:
raise SkuSelectionError("已选尺码指示子树数量漂移。")
marker = _Node(children[1], wrapper)
if (
not _exact_common(
marker,
"android.view.View",
action.bounds,
clickable="false",
selected="false",
scrollable="false",
)
or marker.text
or marker.desc
):
raise SkuSelectionError("已选尺码指示节点结构漂移。")
elif len(children) != 1:
raise SkuSelectionError("未选尺码 action 子树数量漂移。")
def _require_panel_chain(surface: _Node) -> None:
expected = (
("android.widget.LinearLayout", "[0,366][1080,2079]", "false"),
("android.view.ViewGroup", "[0,366][1080,2328]", "true"),
("android.widget.LinearLayout", "[0,120][1080,2328]", "true"),
("android.widget.FrameLayout", "[0,120][1080,2328]", "false"),
("android.widget.FrameLayout", "[0,120][1080,2328]", "false"),
("android.widget.LinearLayout", "[0,0][1080,2328]", "false"),
("android.widget.FrameLayout", "[0,0][1080,2376]", "false"),
)
node = surface.parent
for class_name, bounds, clickable in expected:
if (
node is None
or not _exact_common(
node,
class_name,
bounds,
clickable=clickable,
selected="false",
scrollable="false",
)
or node.text
or node.desc
):
raise SkuSelectionError("规格面板祖先链不符合已取证结构。")
node = node.parent
if node is None or node.element.tag != "hierarchy" or node.parent is not None:
raise SkuSelectionError("规格面板根节点结构漂移。")
def _require_color_subtree(color: _Node, spec: _PanelSpec) -> list[_Node]:
selected = str(spec.color_selected).lower()
children = [child for child in color.element if child.tag == "node"]
expected_selected: list[_Node] = [color] if spec.color_selected else []
if spec.color_bounds == "[372,1188][684,1587]":
if len(children) != 4:
raise SkuSelectionError("目标颜色完整卡片子树数量漂移。")
child_nodes = [node for node in _walk_direct_children(color)]
view, image, zoom, label_layout = child_nodes
if not _exact_common(view, "android.view.View", spec.color_bounds, clickable="false", selected=selected, scrollable="false") or view.text or view.desc:
raise SkuSelectionError("目标颜色选中遮罩结构漂移。")
if not _exact_common(image, "android.widget.ImageView", "[372,1188][684,1500]", clickable="true", selected=selected, scrollable="false") or image.text or image.desc != _TARGET_COLOR_UI:
raise SkuSelectionError("目标颜色图片 action 结构漂移。")
if not _exact_common(zoom, "android.widget.ImageView", "[372,1188][483,1299]", clickable="true", selected=selected, scrollable="false") or zoom.text or zoom.desc != "打开大图":
raise SkuSelectionError("目标颜色大图 action 结构漂移。")
if not _exact_common(label_layout, "android.widget.LinearLayout", "[372,1479][684,1587]", clickable="false", selected=selected, scrollable="false") or label_layout.text or label_layout.desc or len(label_layout.element) != 1:
raise SkuSelectionError("目标颜色文字容器结构漂移。")
label = _Node(label_layout.element[0], label_layout)
if not _exact_common(label, "android.widget.TextView", "[372,1479][684,1587]", clickable="true", selected=selected, scrollable="false") or label.text != _TARGET_COLOR_UI or label.desc:
raise SkuSelectionError("目标颜色文字 action 结构漂移。")
if spec.color_selected:
expected_selected.extend((view, image, zoom, label_layout, label))
return expected_selected
if len(children) != 2:
raise SkuSelectionError("目标颜色滚动态子树数量漂移。")
view, label_layout = [node for node in _walk_direct_children(color)]
if not _exact_common(view, "android.view.View", spec.color_bounds, clickable="false", selected=selected, scrollable="false") or view.text or view.desc:
raise SkuSelectionError("目标颜色滚动态遮罩结构漂移。")
if not _exact_common(label_layout, "android.widget.LinearLayout", spec.color_bounds, clickable="false", selected=selected, scrollable="false") or label_layout.text or label_layout.desc or len(label_layout.element) != 1:
raise SkuSelectionError("目标颜色滚动态文字容器漂移。")
label = _Node(label_layout.element[0], label_layout)
if not _exact_common(label, "android.widget.TextView", spec.color_bounds, clickable="true", selected=selected, scrollable="false") or label.text != _TARGET_COLOR_UI or label.desc:
raise SkuSelectionError("目标颜色滚动态文字 action 漂移。")
if spec.color_selected:
expected_selected.extend((view, label_layout, label))
return expected_selected
def _require_color_action_chain(
color: _Node,
color_region: _Node,
outer: _Node,
surface: _Node,
spec: _PanelSpec,
) -> None:
if color.parent is not color_region:
raise SkuSelectionError("目标颜色 action 不属于已取证颜色容器。")
if spec.color_bounds == "[372,1188][684,1587]":
expected = (
("android.widget.FrameLayout", "[0,1188][1080,2046]"),
("android.widget.LinearLayout", "[0,1188][1080,2052]"),
("android.widget.LinearLayout", "[0,1077][1080,2052]"),
)
else:
expected = (
("android.widget.FrameLayout", "[0,1000][1080,1483]"),
("android.widget.LinearLayout", "[0,1000][1080,1489]"),
("android.widget.LinearLayout", "[0,1000][1080,1489]"),
)
node = color_region.parent
for class_name, bounds in expected:
if node is None or not _exact_inert(node, class_name, bounds):
raise SkuSelectionError("目标颜色 action 父链不符合已取证结构。")
node = node.parent
if node is not outer or outer.parent is not surface:
raise SkuSelectionError("目标颜色 action 未沿已取证维度父链回到面板。")
def _require_size_action_chain(
action: _Node,
size_options: _Node,
outer: _Node,
surface: _Node,
) -> None:
wrapper = action.parent
if wrapper is None or wrapper.parent is not size_options:
raise SkuSelectionError("尺码 action 不属于已取证 options 容器。")
node = size_options.parent
for class_name, bounds in (
("android.widget.LinearLayout", "[36,1582][1044,1897]"),
("android.widget.LinearLayout", "[0,1489][1080,1930]"),
):
if node is None or not _exact_inert(node, class_name, bounds):
raise SkuSelectionError("尺码 action 父链不符合已取证结构。")
node = node.parent
if node is not outer or outer.parent is not surface:
raise SkuSelectionError("尺码 action 未沿已取证维度父链回到面板。")
def _walk_direct_children(parent: _Node) -> list[_Node]:
return [_Node(child, parent) for child in parent.element if child.tag == "node"]
def _require_exact_selected_set(nodes: list[_Node], surface: _Node, expected: list[_Node]) -> None:
actual = [
node for node in nodes
if _descendant(node, surface) and node.element.get("selected") == "true"
]
if {id(node.element) for node in actual} != {id(node.element) for node in expected}:
raise SkuSelectionError("规格面板 selected 节点集合与完整 profile 不一致。")
def _require_action_occupants(nodes: list[_Node], target: _Node) -> None:
left, top, right, bottom = _action_bounds(target.bounds)
point = (left + (right - left) // 2, top + (bottom - top) // 2)
allowed: set[int] = {id(target.element)}
parent = target.parent
while parent is not None:
if id(parent.element) in ids and _label(parent) is not None: return True
if _is_live_clickable(parent):
if not (
_exact_live_layout(parent, "android.view.ViewGroup", target.bounds)
or _exact_live_layout(parent, "android.view.ViewGroup", "[0,366][1080,2328]")
or _exact_live_layout(parent, "android.widget.LinearLayout", "[0,120][1080,2328]")
):
raise SkuSelectionError("规格 action 存在未取证可点击祖先,已停止操作。")
allowed.add(id(parent.element))
parent = parent.parent
return False
for node in nodes:
if _descendant(node, target) and _is_live_clickable(node):
node_left, node_top, node_right, node_bottom = _action_bounds(node.bounds)
if node_left <= point[0] < node_right and node_top <= point[1] < node_bottom:
allowed.add(id(node.element))
occupants = _live_clickables_covering(nodes, point)
if {id(node.element) for node in occupants} != allowed:
raise SkuSelectionError("规格 action 中心存在未取证可点击占用,已停止操作。")
def _clickable_before(node: _Node, stop: _Node) -> bool:
parent = node.parent
while parent is not None and parent.element is not stop.element:
if parent.element.get("clickable") == "true":
return True
parent = parent.parent
# 当前价必须能沿已取证内容祖先回到面板内容面;不接受另一个树枝上的同坐标文本。
return parent is None
def _descendant(node: _Node, ancestor: _Node) -> bool:
@@ -328,20 +802,6 @@ def _descendant(node: _Node, ancestor: _Node) -> bool:
return False
def _contained(node: _Node, container: _Node) -> bool:
left, top, right, bottom = _action_bounds(node.bounds)
outer_left, outer_top, outer_right, outer_bottom = _action_bounds(container.bounds)
return outer_left <= left < right <= outer_right and outer_top <= top < bottom <= outer_bottom
def _clickable_ancestor(node: _Node) -> bool:
parent = node.parent
while parent is not None:
if parent.element.get("clickable") == "true": return True
parent = parent.parent
return False
def _readonly(node: _Node) -> bool:
return node.element.get("package") == PDD_PACKAGE and node.element.get("class") == "android.widget.TextView" and node.element.get("clickable") == "false" and node.element.get("enabled") == "true" and node.element.get("visible-to-user") == "true"
@@ -350,49 +810,145 @@ def _live(node: _Node) -> bool:
return node.element.get("package") == PDD_PACKAGE and node.element.get("clickable") == "true" and node.element.get("enabled") == "true" and node.element.get("visible-to-user") == "true" and bool(node.bounds)
def _choice(node: _Node) -> bool:
return _live(node) and node.element.get("class") == "android.view.ViewGroup" and node.element.get("selected") in {"true", "false"}
def _eligible_entries(nodes: list[_Node]) -> list[_Node]:
# 入口文本本身不可点击:必须逐层证明它仍位于已取证的唯一可点击祖先中,但动作坐标继续
# 使用文本子节点的窄 bounds,避免把同一祖先内未知区域变成坐标兜底。“免拼购买”等底部
# 容器既不属于这条祖先链,也绝不能作为替代入口。
if any(node.bounds == _PRICE_PARENT for node in nodes): return []
# 入口文本本身不可点击:必须证明它仍是已取证底部父容器的直接子节点,但动作坐标继续
# 使用第一行文本的窄 bounds,避免把父容器中心或第二行“免拼购买”变成坐标兜底。
if any(
node.bounds == _PANEL_SURFACE
and node.element.get("package") == PDD_PACKAGE
and node.element.get("class") == "android.view.ViewGroup"
for node in nodes
):
return []
entries = _physical_entries(nodes)
if len(entries) != 1:
return entries
entry = entries[0]
chain = _physical_entry_chain(entry, nodes)
if chain is None or _entry_projection(entry, nodes) is None:
return []
ancestor = chain[1]
left, top, right, bottom = _action_bounds(entry.bounds)
center = (left + (right - left) // 2, top + (bottom - top) // 2)
occupants = _live_clickables_covering(nodes, center)
# RPC 点的是文本中心而不是祖先对象。只有完整入口链自己的动作祖先占用该坐标时才可点击;
# SystemUI 浮层、额外按钮或任意部分覆盖矩形都可能截获触摸,必须零点击失败关闭。
return entries if len(occupants) == 1 and occupants[0].element is ancestor.element else []
def _physical_entries(nodes: list[_Node]) -> list[_Node]:
entry_labels = [
node for node in nodes
if node.text == _ENTRY
and node.element.get("package") == PDD_PACKAGE
and node.element.get("class") == "android.widget.TextView"
]
if len(entry_labels) != 1:
return entry_labels
action_ancestors = [
node for node in nodes
if _exact_entry_node(node, "android.view.ViewGroup", _ENTRY_ACTION_BOUNDS, "true")
]
if len(action_ancestors) != 1:
return []
action_ancestor = action_ancestors[0]
entries: list[_Node] = []
for node in entry_labels:
if not _exact_entry_node(node, "android.widget.TextView", _ENTRY_TEXT_BOUNDS, "false"):
return []
inner = node.parent
switcher = inner.parent if inner is not None else None
frame = switcher.parent if switcher is not None else None
ancestor = frame.parent if frame is not None else None
if (
inner is not None
and _exact_entry_node(inner, "android.view.ViewGroup", _ENTRY_INNER_BOUNDS, "false")
and switcher is not None
and _exact_entry_node(switcher, "android.widget.ViewSwitcher", _ENTRY_INNER_BOUNDS, "false")
and frame is not None
and _exact_entry_node(frame, "android.widget.FrameLayout", _ENTRY_INNER_BOUNDS, "false")
and ancestor is action_ancestor
# 这里故意只证明物理结构。pre-intent 必须识别旧商品,不能让父容器描述或浮层等
# post-intent 安全条件把已经存在的旧入口伪装成“不存在”。
return [node for node in entry_labels if _physical_entry_chain(node, nodes) is not None]
def _physical_entry_chain(node: _Node, nodes: list[_Node]) -> tuple[_Node, ...] | None:
if node.text != _ENTRY or not _exact_entry_node(
node, "android.widget.TextView", _ENTRY_TEXT_BOUNDS, "false"
) or len(node.element) != 0:
return None
ancestor = node.parent
if ancestor is None or not _exact_entry_node(
ancestor, "android.view.ViewGroup", _ENTRY_ACTION_BOUNDS, "true"
):
entries.append(node)
return entries
return None
siblings = [
candidate for candidate in nodes
if candidate.parent is ancestor
and candidate.text == _ENTRY_SIBLING
and _exact_entry_node(
candidate, "android.widget.TextView", _ENTRY_SIBLING_BOUNDS, "false"
)
and not candidate.desc
and len(candidate.element) == 0
]
if len(siblings) != 1:
return None
return node, ancestor, siblings[0]
def _entry_projection(node: _Node, nodes: list[_Node]) -> tuple[object, ...] | None:
chain = _physical_entry_chain(node, nodes)
if chain is None:
return None
_, ancestor, sibling = chain
if node.desc:
return None
# 金额只作为这个已取证入口的不可变身份。这里既不解析也不返回它,价格闸门仍只能读取规格面板。
if ancestor.text or ancestor.desc != _ENTRY_ACTION_DESC:
return None
sibling_mentions = [
candidate for candidate in nodes
if _ENTRY_SIBLING in candidate.text or _ENTRY_SIBLING in candidate.desc
]
if len(sibling_mentions) != 1 or sibling_mentions[0].element is not sibling.element:
return None
subtree = [
candidate for candidate in nodes
if candidate.element is ancestor.element or _descendant(candidate, ancestor)
]
if any(
forbidden in value
for candidate in subtree
for value in (candidate.text, candidate.desc)
if candidate.element is not sibling.element
for forbidden in _FORBIDDEN_ENTRY_ACTION_DESC
):
return None
if any(
value == _ENTRY_PROMOTION_LABEL
for candidate in subtree
for value in (candidate.text, candidate.desc)
):
return None
entry_text_nodes = [candidate for candidate in subtree if candidate.text == _ENTRY]
if len(entry_text_nodes) != 1 or entry_text_nodes[0].element is not node.element:
return None
return tuple(
_entry_node_projection(candidate)
for candidate in (node, ancestor, sibling)
)
def _entry_node_projection(node: _Node) -> tuple[str, ...]:
return (
node.element.tag,
node.element.get("package", ""),
node.element.get("class", ""),
node.bounds,
node.element.get("clickable", ""),
node.element.get("enabled", ""),
node.element.get("visible-to-user", ""),
node.text,
node.desc,
)
def _is_live_clickable(node: _Node) -> bool:
return (
node.element.get("clickable") == "true"
and node.element.get("enabled") == "true"
and node.element.get("visible-to-user") == "true"
)
def _live_clickables_covering(nodes: list[_Node], point: tuple[int, int]) -> list[_Node]:
occupants: list[_Node] = []
x, y = point
for node in nodes:
if not _is_live_clickable(node):
continue
# 活跃可点击节点的 bounds 无法验证时,无法证明它不会截获入口坐标,因此整体失败关闭。
left, top, right, bottom = _action_bounds(node.bounds)
if left <= x < right and top <= y < bottom:
occupants.append(node)
return occupants
def _exact_entry_node(node: _Node, class_name: str, bounds: str, clickable: str) -> bool:
@@ -23,14 +23,17 @@ from uiautomator2.exceptions import HTTPTimeoutError
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
from ..device.baseline import PDD_PACKAGE, SCREENSHOT_PARAMS, _save_base64_screenshot, _sha256_file
from .product_open import EXPECTED_PDD_VERSION
from .product_url import ProductUrl, parse_product_url
from .product_url import ProductUrl, ProductUrlError, parse_product_url
from .sku_selection import (
EXPECTED_GOODS_ID,
EXPECTED_UNIT_PRICE,
SkuPanelDevice,
SkuSelectionError,
SkuSelectionFlow,
_SKU_ENTRY_FAILURE_STAGES,
_action_bounds,
_annotate_sku_entry_failure,
_safe_sku_entry_failure_stage,
resolve_task_selection,
)
@@ -39,6 +42,25 @@ EXPECTED_DEVICE_MODEL = "PKG110"
EXPECTED_ANDROID_VERSION = "16"
EXPECTED_SCREEN_SIZE = (1080, 2376)
# CLI 只允许输出这些固定阶段码。阶段码描述运行器自己的控制流,不包含页面
# 文本、节点属性、serial、路径或第三方异常;未知/伪造值统一降级为 unknown。
_FAILURE_STAGES = frozenset(
(
"precheck",
"device_inspection",
"device_session",
"product_open",
"sku_entry",
*_SKU_ENTRY_FAILURE_STAGES,
"sku_selection",
"price_verification",
"screenshot_capture",
"screenshot_reverify",
"safe_exit",
"publish",
)
)
class SkuSelectionRunError(RuntimeError):
"""T-103 运行未完整完成;错误文本不携带设备或页面原文。"""
@@ -60,6 +82,48 @@ class SkuSelectionDeviceAdapterError(SkuSelectionRunError):
"""第三方设备接口失败的脱敏映射。"""
def safe_failure_stage(error: BaseException) -> str:
"""返回允许公开的固定阶段码,绝不回显异常正文。"""
try:
stage = getattr(error, "_cmbuyer_failure_stage", None)
# exact str 避免恶意 str 子类在 hash/eq 中执行任意异常;诊断路径
# 自己也必须失败闭合,不能让异常正文越过 CLI 的统一脱敏出口。
if type(stage) is not str or stage not in _FAILURE_STAGES:
return "unknown"
if stage in _SKU_ENTRY_FAILURE_STAGES:
return stage if _safe_sku_entry_failure_stage(error) == stage else "unknown"
return stage
except BaseException:
return "unknown"
def _annotate_failure(error: BaseException, stage: str) -> None:
"""只给本次异常附加白名单控制流事实;原异常文本仍不对外输出。"""
safe_stage = stage if stage in _FAILURE_STAGES else "unknown"
try:
setattr(error, "_cmbuyer_failure_stage", safe_stage)
except BaseException:
# 极端第三方异常不允许写属性时仍保持原失败闭合语义。
pass
def _failure_stage_for(error: BaseException, runner_stage: str) -> str:
if runner_stage == "sku_entry":
flow_stage = _safe_sku_entry_failure_stage(error)
if flow_stage is not None:
return flow_stage
return runner_stage
def _annotate_mapped_failure(mapped: BaseException, source: BaseException, runner_stage: str) -> None:
stage = _failure_stage_for(source, runner_stage)
if stage in _SKU_ENTRY_FAILURE_STAGES:
_annotate_sku_entry_failure(mapped, stage)
_annotate_failure(mapped, stage)
@dataclass(frozen=True)
class SkuSelectionRunResult:
"""已发布的截图和无页面正文 manifest 摘要。"""
@@ -183,38 +247,52 @@ class SkuSelectionRunner:
task_size: str,
output_directory: Path,
) -> SkuSelectionRunResult:
stage = "precheck"
staging: Path | None = None
adapter: UiautomatorSkuPanelAdapter | None = None
flow: SkuSelectionFlow | None = None
try:
link = parse_product_url(product_url)
if link.goods_id != EXPECTED_GOODS_ID:
raise SkuSelectionRunError("商品不是 T-103 已取证目标,已停止操作。")
selection = resolve_task_selection(task_color, task_size)
target = Path(output_directory)
_validate_new_target(target)
adapter: UiautomatorSkuPanelAdapter | None = None
flow: SkuSelectionFlow | None = None
staging = _prepare_staging(target)
deadline = self._monotonic_clock() + self._timeout_seconds
try:
stage = "device_inspection"
inspection = self._adb_client.inspect(serial)
_require_expected_device(inspection)
stage = "device_session"
adapter = UiautomatorSkuPanelAdapter(self._connector(serial), self._timeout_seconds)
_require_expected_version(adapter.app_info(PDD_PACKAGE))
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
raise SkuSelectionRunError("设备不是已取证的竖屏坐标空间,已停止操作。")
pre_intent_hierarchy = adapter.dump_window_hierarchy()
# 固定 ACTION_VIEW、固定 PDD package 和 canonical goods_id;不接受任意 URL 或 shell。
stage = "product_open"
self._adb_client.start_pdd_view_intent(serial, link.goods_id)
remaining = deadline - self._monotonic_clock()
if remaining <= 0:
raise SkuSelectionRunTimeoutError("等待规格入口超时,未执行点击。")
stage = "sku_entry"
flow = SkuSelectionFlow(adapter, entry_wait_timeout_seconds=remaining)
flow.open_sku_panel(link.canonical_url, pre_intent_hierarchy)
stage = "sku_selection"
flow.select_sku_options(selection)
stage = "price_verification"
unit_price = flow.verify_target_selection_and_read_price(selection)
if unit_price != EXPECTED_UNIT_PRICE:
raise SkuSelectionUnexpectedPriceError("规格面板现价不是本任务已确认值,已停止操作。")
stage = "screenshot_capture"
screenshot_path = staging / "screenshot.png"
try:
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
@@ -226,11 +304,15 @@ class SkuSelectionRunner:
manifest_path = staging / "manifest.json"
# 截图可能落在动态页面切换边界;发布前必须用一棵更新节点树同时重证两维和现价。
stage = "screenshot_reverify"
final_price = flow.verify_target_selection_and_read_price(selection)
if final_price != EXPECTED_UNIT_PRICE:
raise SkuSelectionUnexpectedPriceError("截图后规格面板现价不是本任务已确认值,已停止操作。")
# 正常路径仍经 Flow 做最后一次前台和面板判定;返回操作只发生一次。
stage = "safe_exit"
flow.exit_sku_panel_safely()
stage = "publish"
manifest_path.write_text(
json.dumps(_manifest(inspection, serial, link, screenshot_path, task_color, task_size), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
@@ -238,18 +320,25 @@ class SkuSelectionRunner:
# Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。
os.rename(staging, target)
staging = None
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError):
except (DeviceConnectionError, ProductUrlError, SkuSelectionRunError, SkuSelectionError) as error:
_clean_staging(staging)
_annotate_failure(error, _failure_stage_for(error, stage))
raise
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
_clean_staging(staging)
raise SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。") from error
mapped = SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。")
_annotate_mapped_failure(mapped, error, stage)
raise mapped from error
except OSError as error:
_clean_staging(staging)
raise SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。") from error
mapped = SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。")
_annotate_mapped_failure(mapped, error, stage)
raise mapped from error
except Exception as error:
_clean_staging(staging)
raise SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。") from error
mapped = SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。")
_annotate_mapped_failure(mapped, error, stage)
raise mapped from error
finally:
# 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。
if flow is not None and adapter is not None and adapter.entry_was_tapped and not adapter.left_panel:
@@ -0,0 +1,5 @@
"""采购工具轮询会话协调器。"""
from .coordinator import ClaimedTaskView, PollingCoordinator, PollingState, RecoveryStatus, StartReadiness
__all__ = ["ClaimedTaskView", "PollingCoordinator", "PollingState", "RecoveryStatus", "StartReadiness"]
@@ -0,0 +1,589 @@
"""在 Qt 事件循环中协调可恢复的领取会话。"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum
from typing import Protocol
from PySide6.QtCore import QObject, QThread, QTimer, Signal, Slot
from cmbuyer_client.core.errors import (
AmbiguousRemoteError,
ClientError,
CredentialRemoteError,
ManualRemoteError,
ProtocolRemoteError,
StateError,
)
from cmbuyer_client.core.models import ClaimedTask
from cmbuyer_client.localstate.models import PollingSession, ProfileSettings, RecoverySnapshot
from cmbuyer_client.logging_policy import redact_text
SAFE_AMBIGUOUS_REASONS = frozenset(
("http_result_unknown", "server_result_unknown", "truncated_response")
)
class PollingState(str, Enum):
STOPPED = "STOPPED"
STARTING = "STARTING"
BLOCKED = "BLOCKED"
RECOVERING = "RECOVERING"
WAITING = "WAITING"
CLAIMING = "CLAIMING"
ACTIVE = "ACTIVE"
RECOVERY_REQUIRED = "RECOVERY_REQUIRED"
class PollingStore(Protocol):
def recovery_snapshot(self, profile_id: str) -> RecoverySnapshot: ...
def start_or_resume_polling(self, profile_id: str) -> PollingSession: ...
def request_stop(self, profile_id: str) -> PollingSession: ...
class ClaimGateway(Protocol):
def claim_next(self, profile_id: str) -> ClaimedTask | None: ...
class ExecutionConsumer(Protocol):
def accept_claim(self, claimed: ClaimedTask, profile: ProfileSettings) -> None: ...
@dataclass(frozen=True)
class StartReadiness:
"""由后续已取证执行能力注入;T-304 自己不探测网络或设备。"""
ready: bool
reason: str
@dataclass(frozen=True)
class ClaimedTaskView:
"""允许发往 UI 的最小投影,刻意不包含 authorization/claim token。"""
task_id: str
title: str
status: str = "已领取"
@classmethod
def from_claim(cls, claimed: ClaimedTask) -> "ClaimedTaskView":
return cls(task_id=claimed.task.id, title=redact_text(claimed.task.title))
@dataclass(frozen=True)
class RecoveryStatus:
"""可进入 UI 的恢复摘要;不携带 task snapshot、claim token 或密文。"""
has_open_session: bool
session_accept_new: bool
has_pending_claim: bool
has_active_claim: bool
has_pending_active_work: bool
@classmethod
def from_snapshot(cls, snapshot: RecoverySnapshot) -> "RecoveryStatus":
return cls(
has_open_session=snapshot.session is not None,
session_accept_new=bool(snapshot.session and snapshot.session.accept_new),
has_pending_claim=snapshot.pending_claim is not None,
has_active_claim=snapshot.active_claim is not None,
has_pending_active_work=bool(snapshot.pending_renew or snapshot.pending_evidence),
)
@dataclass(frozen=True)
class _BootstrapResult:
recovery: RecoveryStatus
session: PollingSession | None
normalized_stop: PollingSession | None = None
class _PollingWorker(QObject):
bootstrap_finished = Signal(object)
claim_finished = Signal(object)
stop_finished = Signal(object)
failed = Signal(str, object)
def __init__(self, store: PollingStore) -> None:
super().__init__()
self._store = store
self._gateway: ClaimGateway | None = None
@Slot(object)
def configure_gateway(self, gateway: object) -> None:
if not hasattr(gateway, "claim_next"):
self.failed.emit("configure", RuntimeError("invalid_claim_gateway"))
return
self._gateway = gateway # type: ignore[assignment]
@Slot(str)
def bootstrap(self, profile_id: str) -> None:
try:
snapshot = self._store.recovery_snapshot(profile_id)
recovery = RecoveryStatus.from_snapshot(snapshot)
if snapshot.session is not None and snapshot.session.accept_new:
stopped = self._store.request_stop(profile_id)
self.bootstrap_finished.emit(_BootstrapResult(recovery, None, stopped))
return
if snapshot.active_claim is not None:
self.bootstrap_finished.emit(_BootstrapResult(recovery, None))
return
session = self._store.start_or_resume_polling(profile_id)
self.bootstrap_finished.emit(_BootstrapResult(recovery, session))
except Exception as error:
self.failed.emit("bootstrap", error)
@Slot(str)
def inspect_restart(self, profile_id: str) -> None:
try:
snapshot = self._store.recovery_snapshot(profile_id)
recovery = RecoveryStatus.from_snapshot(snapshot)
stopped = None
if snapshot.session is not None and snapshot.session.accept_new:
stopped = self._store.request_stop(profile_id)
self.bootstrap_finished.emit(_BootstrapResult(recovery, None, stopped))
except Exception as error:
self.failed.emit("inspect", error)
@Slot(str)
def claim(self, profile_id: str) -> None:
try:
# DurableClientGateway 在返回前已经提交 EMPTY 或 active claim;UI 不能
# 以 generation 过期为由丢弃这个业务结果。
if self._gateway is None:
raise RuntimeError("claim_gateway_not_configured")
self.claim_finished.emit(self._gateway.claim_next(profile_id))
except Exception as error:
self.failed.emit("claim", error)
@Slot(str)
def stop(self, profile_id: str) -> None:
try:
self.stop_finished.emit(self._store.request_stop(profile_id))
except Exception as error:
self.failed.emit("stop", error)
class PollingCoordinator(QObject):
"""把计时、阻塞 I/O 和可见状态收敛到一个会话边界。"""
state_changed = Signal(object, str, int)
claim_visible = Signal(object)
recovery_status_changed = Signal(object)
configuration_freeze_changed = Signal(bool)
settled = Signal()
_configure_gateway_requested = Signal(object)
_bootstrap_requested = Signal(str)
_inspect_requested = Signal(str)
_claim_requested = Signal(str)
_stop_requested_signal = Signal(str)
def __init__(
self,
*,
profile_id: str,
store: PollingStore | None,
gateway_factory: Callable[[ProfileSettings], ClaimGateway] | None,
consumer: ExecutionConsumer | None,
profile_settings: ProfileSettings | None = None,
readiness: StartReadiness | None = None,
poll_interval_seconds: int = 15,
failure_threshold: int = 3,
timer_interval_ms: int | None = None,
parent: QObject | None = None,
) -> None:
super().__init__(parent)
if not 5 <= poll_interval_seconds <= 300:
raise ValueError("invalid_poll_interval")
if not 1 <= failure_threshold <= 10:
raise ValueError("invalid_failure_threshold")
self.profile_id = profile_id
self._store = store
self._gateway_factory = gateway_factory
self._gateway: ClaimGateway | None = None
self._consumer = consumer
self._profile_settings = profile_settings
self._frozen_profile: ProfileSettings | None = None
self._readiness = readiness
self.recovery_status: RecoveryStatus | None = None
self._timer_interval_override = timer_interval_ms
self._interval_ms = timer_interval_ms or (
profile_settings.poll_interval_seconds * 1000 if profile_settings is not None else poll_interval_seconds * 1000
)
if self._interval_ms <= 0:
raise ValueError("invalid_timer_interval")
self._failure_threshold = (
profile_settings.failure_threshold if profile_settings is not None else failure_threshold
)
self._consecutive_failures = 0
self._operation: str | None = None
self._stop_requested = False
self._epoch = 0
self._scheduled_epoch: int | None = None
self._post_stop_state = PollingState.STOPPED
self._post_stop_reason = "轮询已停止。"
self._thread: QThread | None = None
self._worker: _PollingWorker | None = None
self._timer = QTimer(self)
self._timer.setSingleShot(True)
self._timer.timeout.connect(self._on_timer_timeout)
if consumer is None:
self.state = PollingState.BLOCKED
self.reason = "单趟执行能力尚未接入,不能领取真实任务。"
elif store is None or gateway_factory is None:
self.state = PollingState.BLOCKED
self.reason = "轮询依赖未完整注入,不能领取真实任务。"
elif profile_settings is None:
self.state = PollingState.BLOCKED
self.reason = "尚未保存完整配置,不能开始轮询。"
elif readiness is None or not readiness.ready:
self.state = PollingState.BLOCKED
self.reason = "执行就绪条件未满足,不能开始轮询。" if readiness is None else readiness.reason
else:
self.state = PollingState.STOPPED
self.reason = "轮询已停止。"
if store is not None:
self._thread = QThread(self)
self._worker = _PollingWorker(store)
self._worker.moveToThread(self._thread)
self._configure_gateway_requested.connect(self._worker.configure_gateway)
self._inspect_requested.connect(self._worker.inspect_restart)
self._bootstrap_requested.connect(self._worker.bootstrap)
self._claim_requested.connect(self._worker.claim)
self._stop_requested_signal.connect(self._worker.stop)
self._worker.bootstrap_finished.connect(self._on_bootstrap_finished)
self._worker.claim_finished.connect(self._on_claim_finished)
self._worker.stop_finished.connect(self._on_stop_finished)
self._worker.failed.connect(self._on_worker_failed)
self._thread.start()
self._operation = "inspect"
self._set_state(PollingState.STARTING, "正在读取重启恢复状态并关闭遗留自动领取许可…")
self._inspect_requested.emit(self.profile_id)
@property
def can_start(self) -> bool:
return (
self._consumer is not None
and self._store is not None
and self._gateway_factory is not None
and self._profile_settings is not None
and self._readiness is not None
and self._readiness.ready
and self.state == PollingState.STOPPED
and self._operation is None
)
@property
def consecutive_failures(self) -> int:
return self._consecutive_failures
@property
def operation_in_flight(self) -> bool:
return self._operation is not None
def update_profile_settings(self, settings: ProfileSettings) -> None:
if self._operation is None and self.state in (PollingState.STOPPED, PollingState.BLOCKED):
self._profile_settings = settings
self._refresh_idle_gate()
def update_readiness(self, readiness: StartReadiness) -> None:
self._readiness = readiness
self._refresh_idle_gate()
def _refresh_idle_gate(self) -> None:
if self._operation is not None or self.state == PollingState.RECOVERY_REQUIRED:
return
if self._consumer is None:
self._set_state(PollingState.BLOCKED, "单趟执行能力尚未接入,不能领取真实任务。")
elif self._gateway_factory is None or self._store is None:
self._set_state(PollingState.BLOCKED, "轮询依赖未完整注入,不能领取真实任务。")
elif self._profile_settings is None:
self._set_state(PollingState.BLOCKED, "尚未保存完整配置,不能开始轮询。")
elif self._readiness is None or not self._readiness.ready:
reason = "执行就绪条件未满足,不能开始轮询。" if self._readiness is None else self._readiness.reason
self._set_state(PollingState.BLOCKED, reason)
else:
self._set_state(PollingState.STOPPED, "轮询已停止。")
def start(self) -> None:
# 这道门禁必须早于任何 store/gateway 调用;独立应用没有 consumer,
# 即使调用方绕过禁用按钮直接调用本方法也保持零 HTTP。
if self._consumer is None:
self._set_state(PollingState.BLOCKED, "单趟执行能力尚未接入,不能领取真实任务。")
return
if self._store is None or self._gateway_factory is None or self._worker is None:
self._set_state(PollingState.BLOCKED, "轮询依赖未完整注入,不能领取真实任务。")
return
if self._profile_settings is None:
self._set_state(PollingState.BLOCKED, "尚未保存完整配置,不能开始轮询。")
return
if self._readiness is None or not self._readiness.ready:
reason = "执行就绪条件未满足,不能开始轮询。" if self._readiness is None else self._readiness.reason
self._set_state(PollingState.BLOCKED, reason)
return
if not self.can_start:
return
frozen = self._profile_settings
try:
gateway = self._gateway_factory(frozen)
except Exception:
self._set_state(PollingState.BLOCKED, "领取网关无法按本次冻结配置建立,不能开始轮询。")
return
if gateway is None or not hasattr(gateway, "claim_next"):
self._set_state(PollingState.BLOCKED, "领取网关未完整注入,不能开始轮询。")
return
self._frozen_profile = frozen
self._interval_ms = self._timer_interval_override or frozen.poll_interval_seconds * 1000
self._failure_threshold = frozen.failure_threshold
self._gateway = gateway
self._configure_gateway_requested.emit(gateway)
self._epoch += 1
self._scheduled_epoch = None
self._timer.stop()
self._stop_requested = False
self._post_stop_state = PollingState.STOPPED
self._consecutive_failures = 0
self._operation = "bootstrap"
self._set_state(PollingState.STARTING, "正在读取本地恢复状态并建立轮询会话…")
self._bootstrap_requested.emit(self.profile_id)
def stop(self) -> None:
if self.state == PollingState.STOPPED and self._operation is None:
return
if self._store is None or self._worker is None:
return
# epoch/latch 双保险:stopEvent 先让 timer 队列中已经排队的 timeout
# 失效,再处理持久 stop;之后只有显式 Start 才会获得新 epoch。
self._epoch += 1
self._scheduled_epoch = None
self._stop_requested = True
self._timer.stop()
if self._operation in ("inspect", "bootstrap", "claim", "stop"):
if self._operation == "claim":
self._set_state(PollingState.CLAIMING, "正在等待本次有界领取返回;不会取消或重发请求。")
return
self._request_stop(PollingState.STOPPED, "轮询已停止;只阻止下一次领取。")
def shutdown(self, wait_ms: int = 5000) -> bool:
"""只结束空闲 worker;飞行中 I/O 必须由事件循环等待 settled。"""
self._timer.stop()
if self._operation is not None:
return False
if self._thread is not None and self._thread.isRunning():
self._thread.quit()
return self._thread.wait(wait_ms)
return True
@Slot(object)
def _on_bootstrap_finished(self, raw: object) -> None:
completed_operation = self._operation
self._operation = None
result = raw
if not isinstance(result, _BootstrapResult):
self._block("本地恢复结果无效,轮询已阻止。")
return
self.recovery_status = result.recovery
self.recovery_status_changed.emit(result.recovery)
self.configuration_freeze_changed.emit(
result.recovery.has_pending_claim or result.recovery.has_active_claim
)
if completed_operation == "inspect":
if result.recovery.has_active_claim:
self._set_state(PollingState.RECOVERY_REQUIRED, "遗留会话已停止;必须先安全恢复当前任务。")
elif result.recovery.has_pending_claim:
self._set_state(PollingState.STOPPED, "遗留领取请求已停止;显式开始后只使用原幂等键恢复。")
else:
self._refresh_idle_gate()
self.settled.emit()
return
if result.normalized_stop is not None:
if result.recovery.has_active_claim:
self._set_state(PollingState.RECOVERY_REQUIRED, "遗留会话已停止;必须先安全恢复当前任务。")
else:
self._set_state(PollingState.STOPPED, "遗留会话已停止;请再次显式开始轮询。")
self.settled.emit()
return
if result.recovery.has_active_claim:
self._set_state(
PollingState.RECOVERY_REQUIRED,
"检测到未关闭的采购任务,必须先完成安全恢复,不能领取新任务。",
)
self.settled.emit()
return
if self._stop_requested:
self._request_stop(PollingState.STOPPED, "轮询已停止;未发起领取请求。")
return
if result.recovery.has_pending_claim:
self._set_state(PollingState.RECOVERING, "正在使用原幂等键恢复结果不明的领取请求…")
else:
self._set_state(PollingState.WAITING, "轮询会话已启动,正在等待领取。")
self._schedule_claim(0)
def _schedule_claim(self, delay_ms: int) -> None:
self._scheduled_epoch = self._epoch
self._timer.start(delay_ms)
@Slot()
def _on_timer_timeout(self) -> None:
scheduled_epoch = self._scheduled_epoch
self._scheduled_epoch = None
if scheduled_epoch != self._epoch:
return
self._begin_claim(scheduled_epoch)
def _begin_claim(self, dispatch_epoch: int) -> None:
if dispatch_epoch != self._epoch:
return
if self._operation is not None or self._stop_requested:
return
if self.state not in (PollingState.WAITING, PollingState.RECOVERING):
return
self._operation = "claim"
self.configuration_freeze_changed.emit(True)
self._set_state(PollingState.CLAIMING, "正在领取已授权任务…")
self._claim_requested.emit(self.profile_id)
@Slot(object)
def _on_claim_finished(self, claimed: object) -> None:
self._operation = None
self._consecutive_failures = 0
if claimed is not None and not isinstance(claimed, ClaimedTask):
self._request_stop(PollingState.BLOCKED, "领取结果类型无效,轮询已阻止。")
return
if isinstance(claimed, ClaimedTask):
self.claim_visible.emit(ClaimedTaskView.from_claim(claimed))
if self._stop_requested:
self._request_stop(
PollingState.RECOVERY_REQUIRED,
"停止期间领取已落库;必须先安全恢复该任务,不能领取下一条。",
)
return
try:
consumer = self._consumer
frozen_profile = self._frozen_profile
if consumer is None or frozen_profile is None:
raise RuntimeError("execution_consumer_missing")
# consumer 只能使用本次显式 Start 冻结的不可变配置;不得在
# 已领取后回读可变 UI/store,否则 ADB 身份和超时会发生趟内漂移。
consumer.accept_claim(claimed, frozen_profile)
except Exception:
self._request_stop(
PollingState.RECOVERY_REQUIRED,
"执行 consumer 未接收已落库任务;必须安全恢复,不能重新领取。",
)
return
self._set_state(PollingState.ACTIVE, "任务已安全领取并交给单趟执行能力。")
return
self.configuration_freeze_changed.emit(False)
if self._stop_requested:
self._request_stop(PollingState.STOPPED, "轮询已停止;本次没有可领取任务。")
return
self._set_state(PollingState.WAITING, "暂无已授权任务,等待下一次轮询。")
self._schedule_claim(self._interval_ms)
def _request_stop(self, target: PollingState, reason: str) -> None:
if self._operation == "stop":
return
self._timer.stop()
self._post_stop_state = target
self._post_stop_reason = reason
self._operation = "stop"
self._stop_requested_signal.emit(self.profile_id)
@Slot(object)
def _on_stop_finished(self, session: object) -> None:
self._operation = None
if not isinstance(session, PollingSession) or session.accept_new:
self._block("停止状态未能持久化,轮询已阻止。")
return
self._set_state(self._post_stop_state, self._post_stop_reason)
self.settled.emit()
@Slot(str, object)
def _on_worker_failed(self, operation: str, error: object) -> None:
self._operation = None
if operation == "inspect":
if isinstance(error, StateError) and error.reason == "profile_not_found":
self.recovery_status = RecoveryStatus(False, False, False, False, False)
self.recovery_status_changed.emit(self.recovery_status)
self._refresh_idle_gate()
else:
self._block("本地恢复状态无法安全读取;已停止且不能领取任务。")
self.settled.emit()
return
if (
operation == "claim"
and isinstance(error, AmbiguousRemoteError)
and error.reason in SAFE_AMBIGUOUS_REASONS
):
self._consecutive_failures += 1
if self._stop_requested:
self._request_stop(PollingState.STOPPED, "轮询已停止;结果不明的原领取请求已保留。")
elif self._consecutive_failures >= self._failure_threshold:
self._request_stop(
PollingState.BLOCKED,
"连续领取失败达到阈值;原幂等请求已保留,需排查后重新开始。",
)
else:
self._set_state(
PollingState.RECOVERING,
"领取结果不明;等待使用相同幂等键恢复,不会创建新请求。",
)
self._schedule_claim(self._interval_ms)
return
if operation == "claim" and isinstance(error, AmbiguousRemoteError):
self.configuration_freeze_changed.emit(True)
self._request_stop(
PollingState.BLOCKED,
"领取响应无法证明可安全定时恢复;原槽已保留,需显式开始后同键恢复。",
)
return
if operation == "stop":
self._block("停止状态无法安全落库,轮询已阻止;未清除任何恢复事实。")
return
if isinstance(error, CredentialRemoteError):
reason = "设备凭据无效或已撤销;修复凭据后再手工开始。"
elif isinstance(error, ProtocolRemoteError):
reason = "服务响应与固定协议不兼容;已停止普通重试。"
elif isinstance(error, ManualRemoteError):
reason = "服务端要求人工处理;已停止普通重试。"
elif isinstance(error, ClientError):
reason = "本地安全状态无法推进;已停止普通重试。"
else:
reason = "轮询发生未分类错误;已失败闭合。"
if operation == "claim":
# DurableClientGateway 已把协议错误和 409 人工冲突标成 terminal,
# 二者没有 pending/active;凭据或本地错误则可能保留 pending,继续冻结。
self.configuration_freeze_changed.emit(
not isinstance(error, (ProtocolRemoteError, ManualRemoteError))
)
if operation in ("bootstrap", "configure"):
self._block(reason)
else:
self._request_stop(PollingState.BLOCKED, reason)
def _block(self, reason: str) -> None:
self._timer.stop()
self._scheduled_epoch = None
self._set_state(PollingState.BLOCKED, reason)
if self._operation is None:
self.settled.emit()
def _set_state(self, state: PollingState, reason: str) -> None:
self.state = state
self.reason = reason
self.state_changed.emit(state, reason, self._consecutive_failures)
@@ -0,0 +1,7 @@
"""只连接固定本机采购服务的 HTTP 适配器。"""
from .evidence_sink import HttpEvidenceSink
from .http_transport import HttpTransport, LOOPBACK_SERVICE_URL
from .task_source import HttpTaskSource
__all__ = ["HttpEvidenceSink", "HttpTaskSource", "HttpTransport", "LOOPBACK_SERVICE_URL"]
@@ -0,0 +1,84 @@
"""仅上传调用方显式提供的单个 PNG 的窄 EvidenceSink。"""
from __future__ import annotations
from cmbuyer_client.core.errors import AmbiguousRemoteError, ProtocolRemoteError, ValidationError
from cmbuyer_client.core.models import AssetReceipt, DeviceCredentials, EvidenceUpload
from cmbuyer_client.core.validation import rfc3339_z_nanoseconds
from .http_transport import HttpTransport
from .wire import SMALL_RESPONSE_LIMIT, classify_bodyless_error, common_headers, parse_json_response
class HttpEvidenceSink:
def __init__(self, transport: HttpTransport) -> None:
self._transport = transport
def upload(self, credentials: DeviceCredentials, evidence: EvidenceUpload) -> AssetReceipt:
boundary = "cmbuyer-" + evidence.upload_key.replace("-", "")
marker = ("--" + boundary).encode("ascii")
if marker in evidence.content:
raise ProtocolRemoteError("multipart_boundary_collision")
body = _multipart_body(boundary, evidence)
response = self._transport.request(
"POST",
f"/api/v1/tasks/{evidence.task_id}/evidence",
common_headers(
credentials.device_id,
credentials.token.value,
"multipart/form-data; boundary=" + boundary,
),
body,
response_limit=SMALL_RESPONSE_LIMIT,
)
if response.status not in (200, 201):
classify_bodyless_error(response)
try:
receipt = AssetReceipt.from_wire(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT))
except ValidationError as error:
raise AmbiguousRemoteError("invalid_evidence_success_response") from error
if (
receipt.task_id != evidence.task_id
or receipt.attempt_id != evidence.attempt_id
or receipt.kind != evidence.kind
or receipt.privacy_tier != evidence.privacy_tier
or receipt.sha256 != evidence.sha256
or receipt.byte_size != len(evidence.content)
or receipt.width_px != evidence.width_px
or receipt.height_px != evidence.height_px
or rfc3339_z_nanoseconds(receipt.captured_at) != rfc3339_z_nanoseconds(evidence.captured_at)
):
raise AmbiguousRemoteError("evidence_response_mismatch")
return receipt
def _multipart_body(boundary: str, evidence: EvidenceUpload) -> bytes:
chunks: list[bytes] = []
def add_field(name: str, value: str) -> None:
chunks.extend(
(
f"--{boundary}\r\n".encode("ascii"),
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode("ascii"),
value.encode("utf-8"),
b"\r\n",
)
)
add_field("upload_key", evidence.upload_key)
add_field("attempt_id", evidence.attempt_id)
add_field("kind", evidence.kind)
add_field("privacy_tier", evidence.privacy_tier)
add_field("sha256", evidence.sha256)
add_field("captured_at", evidence.captured_at)
chunks.extend(
(
f"--{boundary}\r\n".encode("ascii"),
b'Content-Disposition: form-data; name="file"; filename="evidence.png"\r\n',
b"Content-Type: image/png\r\n\r\n",
evidence.content,
b"\r\n",
f"--{boundary}--\r\n".encode("ascii"),
)
)
return b"".join(chunks)
@@ -0,0 +1,116 @@
"""无代理、无重定向、无隐藏重试的 localhost HTTP transport。"""
from __future__ import annotations
from dataclasses import dataclass
import http.client
import re
from typing import Callable, Iterable
from cmbuyer_client.core.errors import AmbiguousRemoteError, ProtocolRemoteError
LOOPBACK_SERVICE_URL = "http://127.0.0.1:8080"
_HOST = "127.0.0.1"
_PORT = 8080
@dataclass(frozen=True)
class HttpResponse:
status: int
headers: tuple[tuple[str, str], ...]
body: bytes
def header_values(self, name: str) -> tuple[str, ...]:
wanted = name.lower()
return tuple(value for key, value in self.headers if key.lower() == wanted)
class HttpTransport:
"""每次调用只创建一个直连 TCP 请求;重试只能由持久化恢复层决定。"""
def __init__(
self,
service_url: str = LOOPBACK_SERVICE_URL,
*,
timeout_seconds: int = 10,
connection_factory: Callable[..., http.client.HTTPConnection] = http.client.HTTPConnection,
) -> None:
if service_url != LOOPBACK_SERVICE_URL:
raise ProtocolRemoteError("service_url_not_allowed")
if type(timeout_seconds) is not int or not 1 <= timeout_seconds <= 120:
raise ProtocolRemoteError("invalid_http_timeout")
self._timeout_seconds = timeout_seconds
self._connection_factory = connection_factory
def request(
self,
method: str,
path: str,
headers: Iterable[tuple[str, str]],
body: bytes,
*,
response_limit: int,
) -> HttpResponse:
if method != "POST" or not path.startswith("/api/v1/") or "?" in path or "#" in path:
raise ProtocolRemoteError("invalid_http_target")
if not isinstance(body, bytes) or type(response_limit) is not int or response_limit <= 0:
raise ProtocolRemoteError("invalid_http_request")
header_items = tuple(headers)
normalized: dict[str, str] = {}
for key, value in header_items:
lowered = key.lower()
if lowered in normalized or "\r" in key or "\n" in key or "\r" in value or "\n" in value:
raise ProtocolRemoteError("invalid_http_headers")
normalized[lowered] = value
connection: http.client.HTTPConnection | None = None
result: HttpResponse | None = None
failure: str | None = None
try:
connection = self._connection_factory(_HOST, _PORT, timeout=self._timeout_seconds)
connection.request(method, path, body=body, headers={key: value for key, value in header_items})
response = connection.getresponse()
response_headers = tuple(response.getheaders())
content_lengths = tuple(value for key, value in response_headers if key.lower() == "content-length")
transfer_encodings = tuple(value for key, value in response_headers if key.lower() == "transfer-encoding")
if len(content_lengths) > 1:
raise AmbiguousRemoteError("invalid_content_length")
if content_lengths and transfer_encodings:
raise AmbiguousRemoteError("ambiguous_response_framing")
if len(transfer_encodings) > 1 or (
transfer_encodings and transfer_encodings[0].lower() != "chunked"
):
raise AmbiguousRemoteError("invalid_transfer_encoding")
declared = content_lengths[0] if content_lengths else None
declared_length: int | None = None
if declared is not None:
if re.fullmatch(r"[0-9]+", declared, flags=re.ASCII) is None:
raise AmbiguousRemoteError("invalid_content_length")
if len(declared) > 10:
raise AmbiguousRemoteError("response_too_large")
declared_length = int(declared)
if declared_length > response_limit:
raise AmbiguousRemoteError("response_too_large")
response_body = response.read(response_limit + 1)
if len(response_body) > response_limit:
raise AmbiguousRemoteError("response_too_large")
if declared_length is not None and len(response_body) != declared_length:
raise AmbiguousRemoteError("truncated_response")
result = HttpResponse(response.status, response_headers, response_body)
except AmbiguousRemoteError as error:
failure = error.reason
except (OSError, TimeoutError, http.client.HTTPException):
failure = "http_result_unknown"
finally:
if connection is not None:
try:
connection.close()
except OSError:
if result is None:
failure = "http_result_unknown"
if failure is not None:
raise AmbiguousRemoteError(failure)
if result is None:
raise AmbiguousRemoteError("http_result_unknown")
return result
@@ -0,0 +1,75 @@
"""领取与续租的固定 localhost HTTP 适配器。"""
from __future__ import annotations
from cmbuyer_client.core.errors import AmbiguousRemoteError, ValidationError
from cmbuyer_client.core.models import ClaimRequest, ClaimedTask, DeviceCredentials, RenewRequest, RenewResult
from cmbuyer_client.core.validation import rfc3339_z_nanoseconds
from .http_transport import HttpTransport
from .wire import (
JSON_RESPONSE_LIMIT,
SMALL_RESPONSE_LIMIT,
classify_json_error,
common_headers,
encode_json,
parse_json_response,
)
class HttpTaskSource:
def __init__(self, transport: HttpTransport) -> None:
self._transport = transport
def claim_next(self, credentials: DeviceCredentials, request: ClaimRequest) -> ClaimedTask | None:
body = encode_json(request.to_wire())
response = self._transport.request(
"POST",
"/api/v1/tasks/claim-next",
common_headers(credentials.device_id, credentials.token.value, "application/json"),
body,
response_limit=JSON_RESPONSE_LIMIT,
)
if response.status == 204:
if response.body or response.header_values("Content-Encoding"):
raise AmbiguousRemoteError("invalid_empty_claim_response")
return None
if response.status != 200:
classify_json_error(
response,
allowed_409=frozenset(("idempotency_conflict", "claim_requires_manual")),
)
try:
claimed = ClaimedTask.from_wire(parse_json_response(response, maximum=JSON_RESPONSE_LIMIT))
except ValidationError as error:
raise AmbiguousRemoteError("invalid_claim_success_response") from error
if rfc3339_z_nanoseconds(claimed.attempt.lease_expires_at) > rfc3339_z_nanoseconds(claimed.authorization.expires_at):
raise AmbiguousRemoteError("invalid_claim_lease")
return claimed
def renew(self, credentials: DeviceCredentials, request: RenewRequest) -> RenewResult:
response = self._transport.request(
"POST",
f"/api/v1/tasks/{request.task_id}/lease/renew",
common_headers(credentials.device_id, credentials.token.value, "application/json"),
encode_json(request.to_wire()),
response_limit=SMALL_RESPONSE_LIMIT,
)
if response.status != 200:
classify_json_error(
response,
allowed_409=frozenset(("idempotency_conflict", "claim_not_current")),
)
try:
result = RenewResult.from_wire(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT))
except ValidationError as error:
raise AmbiguousRemoteError("invalid_renew_success_response") from error
if (
result.task_id != request.task_id
or result.attempt_id != request.attempt_id
or result.claim_generation != request.claim_generation
or rfc3339_z_nanoseconds(result.lease_expires_at) < rfc3339_z_nanoseconds(request.expected_lease_expires_at)
or rfc3339_z_nanoseconds(result.lease_expires_at) > rfc3339_z_nanoseconds(request.authorization_expires_at)
):
raise AmbiguousRemoteError("renew_response_mismatch")
return result
+106
View File
@@ -0,0 +1,106 @@
"""T-302/T-204 固定 HTTP wire 的编码、解码与错误分类。"""
from __future__ import annotations
import json
from typing import Any, Mapping
from cmbuyer_client.core.errors import (
AmbiguousRemoteError,
CredentialRemoteError,
ManualRemoteError,
ProtocolRemoteError,
ValidationError,
)
from cmbuyer_client.core.validation import require_exact_fields, strict_json_loads
from .http_transport import HttpResponse
JSON_REQUEST_LIMIT = 4096
JSON_RESPONSE_LIMIT = 32 * 1024
SMALL_RESPONSE_LIMIT = 8 * 1024
JSON_CONTENT_TYPES = frozenset(("application/json", "application/json; charset=utf-8"))
def encode_json(value: Mapping[str, object]) -> bytes:
body = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
if len(body) > JSON_REQUEST_LIMIT:
raise ProtocolRemoteError("request_too_large")
return body
def common_headers(device_id: str, token: str, content_type: str) -> tuple[tuple[str, str], ...]:
return (
("Authorization", "Bearer " + token),
("X-CMBuyer-Device-ID", device_id),
("Accept", "application/json"),
("Content-Type", content_type),
)
def parse_json_response(response: HttpResponse, *, maximum: int) -> object:
encodings = response.header_values("Content-Encoding")
types = response.header_values("Content-Type")
if encodings or len(types) != 1 or types[0].lower() not in JSON_CONTENT_TYPES:
raise ValidationError("invalid_response_content_type")
return strict_json_loads(response.body, maximum=maximum)
def require_empty_response(response: HttpResponse) -> None:
if response.body or response.header_values("Content-Encoding"):
raise ProtocolRemoteError("unexpected_error_body")
def classify_json_error(response: HttpResponse, *, allowed_409: frozenset[str]) -> None:
"""抛出错误,不返回。调用方只在非成功状态使用。"""
if 200 <= response.status <= 299:
# 服务端可能已提交幂等事实;未知 2xx 绝不能终结本地槽或换 key。
raise AmbiguousRemoteError("unknown_success_status")
if response.status == 401:
require_empty_response(response)
raise CredentialRemoteError("device_credential_rejected")
if response.status == 503 or 500 <= response.status <= 599:
# 5xx 无法证明服务端是否在提交响应前完成事务。
raise AmbiguousRemoteError("server_result_unknown")
if response.status == 409:
try:
data = require_exact_fields(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT), ("error",))
code = data["error"]
except ValidationError as error:
raise ProtocolRemoteError("invalid_conflict_response") from error
if not isinstance(code, str) or code not in allowed_409:
raise ProtocolRemoteError("unknown_conflict")
raise ManualRemoteError(code)
expected = {400: "invalid_request", 413: "request_too_large", 415: "unsupported_media_type"}
if response.status in expected:
try:
data = require_exact_fields(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT), ("error",))
except ValidationError as error:
raise ProtocolRemoteError("invalid_error_response") from error
if data["error"] != expected[response.status]:
raise ProtocolRemoteError("unexpected_error_code")
raise ProtocolRemoteError(expected[response.status])
if 300 <= response.status <= 399:
raise ProtocolRemoteError("redirect_rejected")
raise ProtocolRemoteError("unexpected_http_status")
def classify_bodyless_error(response: HttpResponse) -> None:
if 200 <= response.status <= 299:
raise AmbiguousRemoteError("unknown_success_status")
if response.status == 401:
require_empty_response(response)
raise CredentialRemoteError("device_credential_rejected")
if response.status == 503 or 500 <= response.status <= 599:
raise AmbiguousRemoteError("server_result_unknown")
if response.status == 409:
require_empty_response(response)
raise ManualRemoteError("evidence_conflict")
if response.status in (400, 403, 413, 415):
require_empty_response(response)
raise ProtocolRemoteError("evidence_request_rejected")
if 300 <= response.status <= 399:
raise ProtocolRemoteError("redirect_rejected")
raise ProtocolRemoteError("unexpected_http_status")
+62 -2
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass
import os
from pathlib import Path
from typing import Any, Callable
@dataclass(frozen=True)
@@ -17,21 +18,34 @@ class RuntimePaths:
root: Path
logs: Path
artifacts: Path
state: Path
database: Path
@classmethod
def from_root(cls, root: Path) -> "RuntimePaths":
resolved_root = root.expanduser()
# 路径在进程启动时一次性固化;之后 cwd 改变不能打开第二套数据库或绕过原 mutex。
resolved_root = root.expanduser().resolve(strict=False)
state = resolved_root / "state"
return cls(
root=resolved_root,
logs=resolved_root / "logs",
artifacts=resolved_root / "artifacts",
state=state,
database=state / "client-state.sqlite3",
)
@classmethod
def default(cls) -> "RuntimePaths":
local_app_data = os.environ.get("LOCALAPPDATA")
if local_app_data:
return cls.from_root(Path(local_app_data) / "cmbuyer")
local_root = Path(local_app_data).expanduser()
if not local_root.is_absolute():
raise RuntimeError("local_app_data_must_be_absolute")
return cls.from_root(local_root / "cmbuyer")
if os.name == "nt":
# Windows 上回退到 home 会悄悄创建第二套状态库并绕开同一 mutex,必须失败闭合。
raise RuntimeError("local_app_data_required")
return cls.from_root(Path.home() / ".local" / "share" / "cmbuyer")
@@ -40,3 +54,49 @@ class RuntimePaths:
self.logs.mkdir(parents=True, exist_ok=True)
self.artifacts.mkdir(parents=True, exist_ok=True)
self.state.mkdir(parents=True, exist_ok=True)
@dataclass
class LocalStateRuntime:
"""持有 named mutex 与本地状态库,保证 mutex 总是先取得。"""
paths: RuntimePaths
mutex: Any
store: Any
@classmethod
def open(
cls,
paths: RuntimePaths | None = None,
*,
mutex_factory: Callable[[Path], Any] | None = None,
protector_factory: Callable[[], Any] | None = None,
store_factory: Callable[[Path, Any], Any] | None = None,
) -> "LocalStateRuntime":
from .localstate.protection import DpapiProtector
from .localstate.single_instance import NamedMutex
from .localstate.store import LocalStateStore
selected = paths or RuntimePaths.default()
selected.ensure_exists()
make_mutex = mutex_factory or NamedMutex
make_protector = protector_factory or DpapiProtector
make_store = store_factory or LocalStateStore
mutex = make_mutex(selected.database)
try:
protector = make_protector()
store = make_store(selected.database, protector)
except Exception:
mutex.close()
raise
return cls(selected, mutex, store)
def close(self) -> None:
self.mutex.close()
def __enter__(self) -> "LocalStateRuntime":
return self
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
self.close()
+5
View File
@@ -0,0 +1,5 @@
"""采购工具原生 Qt Widgets 界面。"""
from .main_window import PurchaseToolWindow
__all__ = ["PurchaseToolWindow"]
+419
View File
@@ -0,0 +1,419 @@
"""采购执行 Tab:状态、当前任务、滚动日志和历史记录主从视图。"""
from __future__ import annotations
from PySide6.QtCore import QModelIndex, QSize, Qt, Signal, Slot
from PySide6.QtGui import QAction, QKeySequence, QShortcut
from PySide6.QtWidgets import (
QAbstractItemView,
QFrame,
QGroupBox,
QHBoxLayout,
QLabel,
QMenu,
QPlainTextEdit,
QPushButton,
QSizePolicy,
QSplitter,
QStackedWidget,
QTableView,
QToolButton,
QVBoxLayout,
QWidget,
)
from cmbuyer_client.polling.coordinator import ClaimedTaskView, PollingCoordinator, PollingState
from cmbuyer_client.logging_policy import redact_text
from .records import PurchaseRecord, PurchaseRecordModel, PurchaseRecordProvider
class _LogView(QPlainTextEdit):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("rollingLog")
self.setReadOnly(True)
self.setPlaceholderText("轮询启动后将在这里显示脱敏日志。")
self.setAccessibleName("滚动日志")
def append_event(self, text: str) -> None:
bar = self.verticalScrollBar()
follow = bar.value() >= bar.maximum() - 2
self.appendPlainText(redact_text(text))
if follow:
bar.setValue(bar.maximum())
class _RecordTableView(QTableView):
"""把双击与 Enter 收敛为唯一 activation 信号,避免平台重复发命令。"""
record_activated = Signal(object)
def mouseDoubleClickEvent(self, event) -> None:
index = self.indexAt(event.position().toPoint())
if index.isValid():
self.setCurrentIndex(index.siblingAtColumn(0))
self.record_activated.emit(index)
event.accept()
return
super().mouseDoubleClickEvent(event)
def keyPressEvent(self, event) -> None:
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter) and self.currentIndex().isValid():
self.record_activated.emit(self.currentIndex())
event.accept()
return
super().keyPressEvent(event)
class ExecutionPage(QWidget):
LIVE_PAGE = 0
DETAIL_PAGE = 1
COMPACT_DETAIL_WIDTH = 760
def __init__(
self,
coordinator: PollingCoordinator,
record_provider: PurchaseRecordProvider | None = None,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("executionPage")
self._coordinator = coordinator
self._selected_record_id: str | None = None
self._saved_scroll = 0
outer = QVBoxLayout(self)
outer.setContentsMargins(12, 12, 12, 12)
status_row = QHBoxLayout()
self.service_status = self._status("采购服务", "待首次真实领取验证")
self.device_status = self._status("ADB", "待后续执行能力验证")
self.app_status = self._status("拼多多版本", "待后续执行能力验证")
self.session_status = self._status("会话", "已停止")
for widget in (self.service_status, self.device_status, self.app_status, self.session_status):
status_row.addWidget(widget)
status_row.addStretch(1)
self.poll_action = QAction("开始轮询", self)
self.poll_action.setObjectName("pollAction")
self.poll_action.triggered.connect(self._toggle_polling)
self.addAction(self.poll_action)
self.poll_button = QPushButton()
self.poll_button.setObjectName("pollButton")
self.poll_button.clicked.connect(self.poll_action.trigger)
status_row.addWidget(self.poll_button)
outer.addLayout(status_row)
self.banner = QLabel()
self.banner.setObjectName("sessionBanner")
self.banner.setWordWrap(True)
self.banner.setAccessibleName("轮询会话状态")
self.banner.setFrameShape(QFrame.Shape.StyledPanel)
outer.addWidget(self.banner)
self.body_splitter = QSplitter(Qt.Orientation.Horizontal)
self.body_splitter.setObjectName("executionSplitter")
self.body_splitter.setChildrenCollapsible(False)
self.left_stack = QStackedWidget()
self.left_stack.setObjectName("leftWorkspace")
self.left_stack.addWidget(self._build_live_page())
self.left_stack.addWidget(self._build_detail_page())
self.body_splitter.addWidget(self.left_stack)
self.body_splitter.addWidget(self._build_records_page())
self.body_splitter.setStretchFactor(0, 2)
self.body_splitter.setStretchFactor(1, 1)
self.body_splitter.setSizes([760, 380])
outer.addWidget(self.body_splitter, 1)
self.view_record_action = QAction("查看所选记录", self)
self.view_record_action.setObjectName("viewSelectedRecord")
self.view_record_action.setEnabled(False)
self.view_record_action.triggered.connect(self.open_selected_record)
self.addAction(self.view_record_action)
self.view_record_button.setDefaultAction(self.view_record_action)
self.return_action = QAction("返回当前任务", self)
self.return_action.setObjectName("returnToCurrentTask")
self.return_action.setEnabled(False)
self.return_action.triggered.connect(self.return_to_live)
self.addAction(self.return_action)
self.return_button.setDefaultAction(self.return_action)
self.escape_shortcut = QShortcut(QKeySequence(Qt.Key.Key_Escape), self)
self.escape_shortcut.setContext(Qt.ShortcutContext.WidgetWithChildrenShortcut)
self.escape_shortcut.activated.connect(self._escape)
self.record_view.clicked.connect(self._on_record_selected)
self.record_view.record_activated.connect(self._open_index)
self.record_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.record_view.customContextMenuRequested.connect(self._show_record_menu)
self.record_view.selectionModel().currentChanged.connect(self._on_current_changed)
coordinator.state_changed.connect(self._on_polling_state)
coordinator.claim_visible.connect(self._show_claimed_task)
self._on_polling_state(coordinator.state, coordinator.reason, coordinator.consecutive_failures)
if record_provider is not None:
# T-304 没有公共历史仓库;只接受调用方准备好的 View DTO 快照,
# 独立应用不注入 provider,模型保持真实空态。
self.set_records(record_provider.snapshot())
@staticmethod
def _status(name: str, value: str) -> QLabel:
label = QLabel(f"{name}\n{value}")
label.setFrameShape(QFrame.Shape.StyledPanel)
label.setMinimumWidth(118)
label.setAccessibleName(name)
return label
def _build_live_page(self) -> QWidget:
page = QWidget()
layout = QVBoxLayout(page)
task_group = QGroupBox("当前任务")
task_layout = QHBoxLayout(task_group)
self.current_task_text = QLabel("当前没有任务。\n启动后只领取已授权任务。")
self.current_task_text.setObjectName("currentTaskText")
self.current_task_text.setWordWrap(True)
self.current_task_text.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft)
self.current_task_text.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
self.current_image = QLabel("暂无可信商品图片")
self.current_image.setObjectName("currentTaskImage")
self.current_image.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.current_image.setFrameShape(QFrame.Shape.StyledPanel)
self.current_image.setMinimumSize(QSize(180, 120))
self.current_image.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
task_layout.addWidget(self.current_task_text, 2)
task_layout.addWidget(self.current_image, 1)
layout.addWidget(task_group, 1)
log_group = QGroupBox("滚动日志")
log_layout = QVBoxLayout(log_group)
self.log_view = _LogView()
log_layout.addWidget(self.log_view)
layout.addWidget(log_group, 2)
return page
def _build_detail_page(self) -> QWidget:
page = QWidget()
page.setObjectName("recordDetailPage")
layout = QVBoxLayout(page)
header = QHBoxLayout()
self.detail_title = QLabel("采购记录详情")
self.detail_title.setObjectName("recordDetailTitle")
self.detail_title.setStyleSheet("font-size: 18px; font-weight: 600;")
header.addWidget(self.detail_title)
header.addStretch(1)
self.return_button = QToolButton()
self.return_button.setObjectName("returnCurrentTaskButton")
header.addWidget(self.return_button)
layout.addLayout(header)
top = QSplitter(Qt.Orientation.Horizontal)
self.detail_original = QPlainTextEdit()
self.detail_original.setObjectName("recordOriginalText")
self.detail_original.setReadOnly(True)
self.detail_original.setPlaceholderText("没有可显示的原始文字。")
self.detail_image = QLabel("没有可显示的可信图片")
self.detail_image.setObjectName("recordImage")
self.detail_image.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.detail_image.setFrameShape(QFrame.Shape.StyledPanel)
top.addWidget(self.detail_original)
top.addWidget(self.detail_image)
top.setStretchFactor(0, 2)
top.setStretchFactor(1, 1)
layout.addWidget(top, 2)
result_group = QGroupBox("采购结果")
result_layout = QVBoxLayout(result_group)
self.detail_result = QPlainTextEdit()
self.detail_result.setObjectName("recordResult")
self.detail_result.setReadOnly(True)
self.detail_result.setPlaceholderText("暂无采购结果。")
result_layout.addWidget(self.detail_result)
layout.addWidget(result_group, 1)
return page
def _build_records_page(self) -> QWidget:
page = QGroupBox("采购记录")
page.setObjectName("recordsPanel")
self.records_panel = page
layout = QVBoxLayout(page)
header = QHBoxLayout()
self.records_summary = QLabel("暂无记录")
header.addWidget(self.records_summary)
header.addStretch(1)
self.view_record_button = QToolButton()
self.view_record_button.setObjectName("viewSelectedRecordButton")
header.addWidget(self.view_record_button)
layout.addLayout(header)
self.record_model = PurchaseRecordModel(parent=self)
self.record_view = _RecordTableView()
self.record_view.setObjectName("purchaseRecordTable")
self.record_view.setModel(self.record_model)
self.record_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.record_view.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.record_view.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.record_view.setAlternatingRowColors(True)
self.record_view.setSortingEnabled(False)
self.record_view.horizontalHeader().setStretchLastSection(False)
self.record_view.horizontalHeader().setSectionResizeMode(0, self.record_view.horizontalHeader().ResizeMode.Stretch)
self.record_view.horizontalHeader().setSectionResizeMode(1, self.record_view.horizontalHeader().ResizeMode.ResizeToContents)
self.record_view.verticalHeader().setVisible(False)
layout.addWidget(self.record_view)
return page
def set_records(self, records: list[PurchaseRecord]) -> None:
selected_id = self._selected_record_id
self.record_model.set_records(records)
self.records_summary.setText(f"共 {len(records)} 条" if records else "暂无记录")
if selected_id is not None:
row = self.record_model.row_for_id(selected_id)
if row >= 0:
self.record_view.setCurrentIndex(self.record_model.index(row, 0))
if self.left_stack.currentIndex() == self.DETAIL_PAGE:
self._render_record(self.record_model.record_at(row))
return
self._selected_record_id = None
self.view_record_action.setEnabled(False)
if self.left_stack.currentIndex() == self.DETAIL_PAGE:
self.return_to_live()
@Slot(object, str, int)
def _on_polling_state(self, state: object, reason: str, failures: int) -> None:
polling_state = state if isinstance(state, PollingState) else PollingState.BLOCKED
self.session_status.setText(f"会话\n{self._state_text(polling_state)}")
suffix = f"(连续失败 {failures} 次)" if failures else ""
self.banner.setText(reason + suffix)
running = polling_state in (
PollingState.STARTING,
PollingState.RECOVERING,
PollingState.WAITING,
PollingState.CLAIMING,
PollingState.ACTIVE,
)
self.poll_action.setText("停止轮询" if running else "开始轮询")
self.poll_action.setEnabled(running or self._coordinator.can_start)
self.poll_button.setText(self.poll_action.text())
self.poll_button.setEnabled(self.poll_action.isEnabled())
self.poll_button.setToolTip("" if self.poll_action.isEnabled() else reason)
@staticmethod
def _state_text(state: PollingState) -> str:
return {
PollingState.STOPPED: "已停止",
PollingState.STARTING: "启动中",
PollingState.BLOCKED: "已阻止",
PollingState.RECOVERING: "安全恢复",
PollingState.WAITING: "等待领取",
PollingState.CLAIMING: "正在领取",
PollingState.ACTIVE: "任务执行中",
PollingState.RECOVERY_REQUIRED: "待安全恢复",
}[state]
@Slot()
def _toggle_polling(self) -> None:
if self._coordinator.state in (
PollingState.STARTING,
PollingState.RECOVERING,
PollingState.WAITING,
PollingState.CLAIMING,
PollingState.ACTIVE,
):
self._coordinator.stop()
else:
self._coordinator.start()
@Slot(QModelIndex)
def _on_record_selected(self, index: QModelIndex) -> None:
record = self.record_model.record_at(index.row())
if record is None:
return
self._selected_record_id = record.record_id
self.view_record_action.setEnabled(True)
if self.left_stack.currentIndex() == self.DETAIL_PAGE:
self._saved_scroll = self.record_view.verticalScrollBar().value()
self._render_record(record)
@Slot(object)
def _show_claimed_task(self, raw: object) -> None:
if not isinstance(raw, ClaimedTaskView):
return
self.current_task_text.setText(
f"标题:{raw.title}\n任务 ID:{raw.task_id}\n状态:{raw.status}"
)
self.log_view.append_event(f"已安全领取任务 {raw.task_id};等待单趟执行能力处理。")
@Slot(QModelIndex, QModelIndex)
def _on_current_changed(self, current: QModelIndex, previous: QModelIndex) -> None:
del previous
if current.isValid():
self._on_record_selected(current)
@Slot(QModelIndex)
def _open_index(self, index: QModelIndex) -> None:
if index.isValid():
self.record_view.setCurrentIndex(index.siblingAtColumn(0))
self._on_record_selected(index)
self.view_record_action.trigger()
@Slot()
def open_selected_record(self) -> None:
if self._selected_record_id is None:
return
row = self.record_model.row_for_id(self._selected_record_id)
record = self.record_model.record_at(row)
if record is None:
return
self._saved_scroll = self.record_view.verticalScrollBar().value()
self._render_record(record)
self.left_stack.setCurrentIndex(self.DETAIL_PAGE)
self.return_action.setEnabled(True)
self._apply_compact_detail()
self.return_button.setFocus()
def _render_record(self, record: PurchaseRecord | None) -> None:
if record is None:
self.detail_title.setText("记录不存在")
self.detail_original.clear()
self.detail_result.clear()
self.detail_image.setText("没有可显示的可信图片")
return
self.detail_title.setText(record.title)
self.detail_original.setPlainText(record.original_text)
self.detail_result.setPlainText(record.result_text)
self.detail_image.setText(record.image_description or "没有可显示的可信图片")
@Slot()
def return_to_live(self) -> None:
if self.left_stack.currentIndex() != self.DETAIL_PAGE:
return
self.left_stack.setCurrentIndex(self.LIVE_PAGE)
self.return_action.setEnabled(False)
self.records_panel.setVisible(True)
row = self.record_model.row_for_id(self._selected_record_id or "")
if row >= 0:
self.record_view.setCurrentIndex(self.record_model.index(row, 0))
self.record_view.verticalScrollBar().setValue(self._saved_scroll)
self.record_view.setFocus()
@Slot()
def _escape(self) -> None:
# Qt popup/menu 优先消费 Esc;只有详情态的页面级 shortcut 会执行返回。
if self.left_stack.currentIndex() == self.DETAIL_PAGE:
self.return_action.trigger()
@Slot(object)
def _show_record_menu(self, point: object) -> None:
index = self.record_view.indexAt(point)
if index.isValid():
self.record_view.setCurrentIndex(index.siblingAtColumn(0))
self._on_record_selected(index)
menu = QMenu(self.record_view)
menu.addAction(self.view_record_action)
menu.exec(self.record_view.viewport().mapToGlobal(point))
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
self._apply_compact_detail()
def _apply_compact_detail(self) -> None:
compact_detail = self.width() < self.COMPACT_DETAIL_WIDTH and self.left_stack.currentIndex() == self.DETAIL_PAGE
self.records_panel.setVisible(not compact_detail)
@@ -0,0 +1,95 @@
"""采购工具固定双 Tab 原生窗口。"""
from __future__ import annotations
from PySide6.QtCore import QTimer, Qt, Slot
from PySide6.QtGui import QCloseEvent
from PySide6.QtWidgets import QMainWindow, QTabWidget
from cmbuyer_client.localstate.models import ProfileSettings
from cmbuyer_client.polling.coordinator import PollingCoordinator, PollingState, RecoveryStatus
from .execution import ExecutionPage
from .records import PurchaseRecordProvider
from .settings import ProfileStore, SettingsPage
class PurchaseToolWindow(QMainWindow):
EXECUTION_PAGE_ID = "purchase-execution"
SETTINGS_PAGE_ID = "settings"
def __init__(
self,
*,
store: ProfileStore,
coordinator: PollingCoordinator,
profile_settings: ProfileSettings | None,
has_stored_device_token: bool,
record_provider: PurchaseRecordProvider | None = None,
parent=None,
) -> None:
super().__init__(parent)
self.setWindowTitle("采购工具")
self.setAccessibleName("采购工具")
self.setMinimumSize(720, 520)
self.resize(1180, 760)
self._coordinator = coordinator
self._close_pending = False
recovery = coordinator.recovery_status
frozen = recovery is None or recovery.has_pending_claim or recovery.has_active_claim
self.tabs = QTabWidget()
self.tabs.setObjectName("mainTabs")
self.tabs.setTabsClosable(False)
self.tabs.setMovable(False)
self.execution_page = ExecutionPage(coordinator, record_provider)
self.execution_page.setProperty("pageId", self.EXECUTION_PAGE_ID)
self.settings_page = SettingsPage(
store,
settings=profile_settings,
has_stored_device_token=has_stored_device_token,
identity_frozen=frozen,
)
self.settings_page.setProperty("pageId", self.SETTINGS_PAGE_ID)
self.tabs.addTab(self.execution_page, "采购执行")
self.tabs.addTab(self.settings_page, "配置")
self.tabs.setCurrentWidget(self.execution_page)
self.setCentralWidget(self.tabs)
self.settings_page.settings_saved.connect(
lambda settings, has_token: self._coordinator.update_profile_settings(settings)
)
coordinator.recovery_status_changed.connect(self._on_recovery_status)
coordinator.configuration_freeze_changed.connect(self.settings_page.set_identity_frozen)
coordinator.settled.connect(self._finish_pending_close)
@Slot(object)
def _on_recovery_status(self, raw: object) -> None:
if not isinstance(raw, RecoveryStatus):
self.settings_page.set_identity_frozen(True)
return
self.settings_page.set_identity_frozen(raw.has_pending_claim or raw.has_active_claim)
def closeEvent(self, event: QCloseEvent) -> None:
running = self._coordinator.state in (
PollingState.STARTING,
PollingState.RECOVERING,
PollingState.WAITING,
PollingState.CLAIMING,
PollingState.ACTIVE,
) or self._coordinator.operation_in_flight
if running:
# 不 terminate 飞行中的 QThread。先提升 stop latch,等待 HTTP 自身
# 超时和 DurableClientGateway 落库,再由 settled 重试关闭。
self._close_pending = True
self._coordinator.stop()
event.ignore()
return
event.accept()
@Slot()
def _finish_pending_close(self) -> None:
if not self._close_pending:
return
self._close_pending = False
QTimer.singleShot(0, self.close)
+95
View File
@@ -0,0 +1,95 @@
"""采购记录的只读 Qt Model/View 数据源。"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
from cmbuyer_client.logging_policy import redact_text
from cmbuyer_client.core.errors import ValidationError
from cmbuyer_client.core.validation import rfc3339_z_nanoseconds
@dataclass(frozen=True)
class PurchaseRecord:
record_id: str
title: str
status: str
created_at: str
original_text: str = ""
result_text: str = ""
image_description: str = ""
created_at_nanoseconds: int = field(init=False, repr=False)
def __post_init__(self) -> None:
# 记录 provider 只能注入可显示摘要;最终 UI 边界仍统一脱敏,避免
# consumer bug 把 Bearer/裸 token 放入 model、详情或可见日志。
for field in ("title", "status", "original_text", "result_text", "image_description"):
object.__setattr__(self, field, redact_text(getattr(self, field)))
if not isinstance(self.created_at, str):
raise ValueError("noncanonical_record_timestamp")
fraction = self.created_at[20:-1] if len(self.created_at) > 20 and self.created_at.endswith("Z") else ""
if fraction and fraction.endswith("0"):
raise ValueError("noncanonical_record_timestamp")
try:
timestamp = rfc3339_z_nanoseconds(self.created_at)
except ValidationError:
raise ValueError("noncanonical_record_timestamp") from None
object.__setattr__(self, "created_at_nanoseconds", timestamp)
class PurchaseRecordProvider(Protocol):
"""只返回已准备好的无秘密 View DTO;不得在 GUI 线程查询 SQLite/HTTP。"""
def snapshot(self) -> list[PurchaseRecord]: ...
class PurchaseRecordModel(QAbstractTableModel):
RECORD_ID_ROLE = int(Qt.ItemDataRole.UserRole) + 1
def __init__(self, records: list[PurchaseRecord] | None = None, parent=None) -> None:
super().__init__(parent)
self._records: list[PurchaseRecord] = []
self.set_records(records or [])
def rowCount(self, parent: QModelIndex = QModelIndex()) -> int:
return 0 if parent.isValid() else len(self._records)
def columnCount(self, parent: QModelIndex = QModelIndex()) -> int:
return 0 if parent.isValid() else 2
def data(self, index: QModelIndex, role: int = int(Qt.ItemDataRole.DisplayRole)):
if not index.isValid() or not 0 <= index.row() < len(self._records):
return None
record = self._records[index.row()]
if role == int(Qt.ItemDataRole.DisplayRole):
return record.title if index.column() == 0 else record.status
if role == self.RECORD_ID_ROLE:
return record.record_id
if role == int(Qt.ItemDataRole.ToolTipRole):
return f"{record.title}\n{record.created_at}"
if role == int(Qt.ItemDataRole.TextAlignmentRole) and index.column() == 1:
return int(Qt.AlignmentFlag.AlignCenter)
return None
def headerData(self, section: int, orientation: Qt.Orientation, role: int = int(Qt.ItemDataRole.DisplayRole)):
if role != int(Qt.ItemDataRole.DisplayRole) or orientation != Qt.Orientation.Horizontal:
return None
return ("标题", "状态")[section] if 0 <= section < 2 else None
def set_records(self, records: list[PurchaseRecord]) -> None:
self.beginResetModel()
self._records = sorted(
records,
key=lambda item: (item.created_at_nanoseconds, item.record_id),
reverse=True,
)
self.endResetModel()
def record_at(self, row: int) -> PurchaseRecord | None:
return self._records[row] if 0 <= row < len(self._records) else None
def row_for_id(self, record_id: str) -> int:
return next((row for row, item in enumerate(self._records) if item.record_id == record_id), -1)
+249
View File
@@ -0,0 +1,249 @@
"""只做本地校验和显式保存的配置页。"""
from __future__ import annotations
from pathlib import Path
from typing import Protocol
from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtWidgets import (
QComboBox,
QFormLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QSpinBox,
QVBoxLayout,
QWidget,
)
from cmbuyer_client.core.models import SecretToken
from cmbuyer_client.core.errors import ValidationError
from cmbuyer_client.core.validation import require_uuid4
from cmbuyer_client.localstate.models import LOOPBACK_SERVICE_URL, ProfileSettings
class ProfileStore(Protocol):
def save_profile(self, settings: ProfileSettings, token: SecretToken | None) -> None: ...
class SettingsPage(QScrollArea):
settings_saved = Signal(object, bool)
VALIDATION_HINT = "服务身份将在首次真实领取时验证;设备与 App 状态由后续已取证执行能力验证。"
def __init__(
self,
store: ProfileStore,
*,
profile_id: str = "default",
settings: ProfileSettings | None = None,
has_stored_device_token: bool = False,
identity_frozen: bool = False,
parent: QWidget | None = None,
) -> None:
super().__init__(parent)
self.setObjectName("settingsPage")
self.setWidgetResizable(True)
self._store = store
self._profile_id = profile_id
self._has_stored_device_token = has_stored_device_token
self._loaded_settings = settings
self._identity_frozen = identity_frozen
content = QWidget()
outer = QVBoxLayout(content)
title = QLabel("配置")
title.setObjectName("settingsTitle")
title.setStyleSheet("font-size: 20px; font-weight: 600;")
outer.addWidget(title)
form = QFormLayout()
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
outer.addLayout(form)
self.service_url = QLineEdit(LOOPBACK_SERVICE_URL)
self.service_url.setReadOnly(True)
self.service_url.setObjectName("serviceUrl")
form.addRow("采购服务 URL", self.service_url)
self.device_id = QLineEdit()
self.device_id.setObjectName("deviceId")
self.device_id.setPlaceholderText("小写 UUIDv4")
form.addRow("设备 UUID", self.device_id)
self.device_token = QLineEdit()
self.device_token.setObjectName("deviceToken")
self.device_token.setEchoMode(QLineEdit.EchoMode.Password)
# 控件只负责给输入设置合理上限;长度与字符集必须在 save() 中显式
# 验证。若这里限制为 64,粘贴 65 位 token 会被 Qt 静默截成合法
# 64 位并覆盖原凭据。
self.device_token.setMaxLength(256)
self.device_token.setPlaceholderText("首次必填;已有凭据时留空表示保留")
form.addRow("设备 token", self.device_token)
self.token_status = QLabel()
self.token_status.setObjectName("tokenStatus")
form.addRow("凭据状态", self.token_status)
self.adb_path = QLineEdit()
self.adb_path.setObjectName("adbPath")
form.addRow("ADB 路径", self.adb_path)
self.adb_serial = QLineEdit()
self.adb_serial.setObjectName("adbSerial")
form.addRow("设备 serial", self.adb_serial)
self.transport = QComboBox()
self.transport.setObjectName("transport")
self.transport.addItem("USB", "usb")
self.transport.addItem("WiFi", "wifi")
form.addRow("连接方式", self.transport)
self.poll_interval = self._spin(5, 300, 15, "pollInterval", " 秒")
form.addRow("轮询间隔", self.poll_interval)
self.failure_threshold = self._spin(1, 10, 3, "failureThreshold", " 次")
form.addRow("连续失败停止阈值", self.failure_threshold)
self.http_timeout = self._spin(1, 120, 10, "httpTimeout", " 秒")
form.addRow("HTTP 超时", self.http_timeout)
self.step_timeout = self._spin(5, 300, 45, "stepTimeout", " 秒")
form.addRow("真机步骤超时", self.step_timeout)
self.validation_hint = QLabel(self.VALIDATION_HINT)
self.validation_hint.setObjectName("deferredValidationHint")
self.validation_hint.setWordWrap(True)
outer.addWidget(self.validation_hint)
self.feedback = QLabel()
self.feedback.setObjectName("settingsFeedback")
self.feedback.setWordWrap(True)
self.feedback.setAccessibleName("配置保存状态")
outer.addWidget(self.feedback)
self.save_action = QAction("保存配置", self)
self.save_action.setShortcut(QKeySequence.StandardKey.Save)
self.save_action.triggered.connect(self.save)
self.addAction(self.save_action)
self.save_button = QPushButton("保存配置")
self.save_button.setObjectName("saveSettings")
self.save_button.clicked.connect(self.save_action.trigger)
outer.addWidget(self.save_button, 0, Qt.AlignmentFlag.AlignRight)
outer.addStretch(1)
self.setWidget(content)
if settings is not None:
self._load(settings)
self._update_token_status()
self.set_identity_frozen(identity_frozen)
@staticmethod
def _spin(minimum: int, maximum: int, value: int, name: str, suffix: str) -> QSpinBox:
field = QSpinBox()
field.setObjectName(name)
field.setRange(minimum, maximum)
field.setValue(value)
field.setSuffix(suffix)
return field
def _load(self, settings: ProfileSettings) -> None:
self.device_id.setText(settings.device_id)
self.adb_path.setText(settings.adb_path)
self.adb_serial.setText(settings.adb_serial)
self.transport.setCurrentIndex(max(0, self.transport.findData(settings.transport)))
self.poll_interval.setValue(settings.poll_interval_seconds)
self.failure_threshold.setValue(settings.failure_threshold)
self.http_timeout.setValue(settings.http_timeout_seconds)
self.step_timeout.setValue(settings.step_timeout_seconds)
def set_identity_frozen(self, frozen: bool) -> None:
self._identity_frozen = frozen
for field in (
self.device_id,
self.adb_path,
self.adb_serial,
self.transport,
self.poll_interval,
self.failure_threshold,
self.http_timeout,
self.step_timeout,
):
field.setEnabled(not frozen)
if frozen:
self.feedback.setText("存在待恢复或执行中的领取;服务与设备身份参数已冻结。")
elif self.feedback.text().startswith("存在待恢复或执行中的领取"):
self.feedback.clear()
@Slot()
def save(self) -> None:
self.feedback.clear()
token_text = self.device_token.text().strip()
device_id = self.device_id.text().strip()
try:
require_uuid4(device_id, "invalid_device_id")
except (TypeError, ValueError, ValidationError):
self._validation_error(self.device_id, "设备 UUID 必须是小写 UUIDv4。")
return
if not self._has_stored_device_token and not token_text:
self._validation_error(self.device_token, "首次保存必须填写设备 token。")
return
if token_text and (len(token_text) != 64 or any(character not in "0123456789abcdef" for character in token_text)):
self._validation_error(self.device_token, "设备 token 必须为 64 位小写十六进制。")
return
try:
if self._identity_frozen:
if self._loaded_settings is None:
self._validation_error(self.device_id, "冻结配置缺少原始设置,不能保存。")
return
settings = self._loaded_settings
else:
adb_text = self.adb_path.text().strip()
if not adb_text:
self._validation_error(self.adb_path, "请填写 ADB 路径。")
return
adb_file = Path(adb_text).expanduser()
if not adb_file.is_file():
self._validation_error(self.adb_path, "ADB 路径必须指向本机已存在的文件。")
return
adb_file = adb_file.resolve(strict=True)
serial = self.adb_serial.text().strip()
if not serial:
self._validation_error(self.adb_serial, "请填写设备 serial。")
return
settings = ProfileSettings(
profile_id=self._profile_id,
service_url=LOOPBACK_SERVICE_URL,
device_id=device_id,
adb_path=str(adb_file),
adb_serial=serial,
transport=str(self.transport.currentData()),
poll_interval_seconds=self.poll_interval.value(),
failure_threshold=self.failure_threshold.value(),
http_timeout_seconds=self.http_timeout.value(),
step_timeout_seconds=self.step_timeout.value(),
)
token = SecretToken(token_text) if token_text else None
except (TypeError, ValueError):
self._validation_error(self.device_id, "配置格式不正确,请检查设备 UUID 与各项参数。")
return
try:
# None 明确表示保留 T-303 中已有的 DPAPI 密文,绝不是清除凭据。
self._store.save_profile(settings, token)
except Exception:
self.feedback.setText("配置保存失败,本地安全存储未更新。")
(self.device_token if token_text else self.device_id).setFocus()
return
self._has_stored_device_token = True
self._loaded_settings = settings
self.device_token.clear()
self._update_token_status()
self.feedback.setText("配置已保存。本地校验不代表服务、设备或 App 已就绪。")
self.settings_saved.emit(settings, True)
def _validation_error(self, field: QWidget, message: str) -> None:
self.feedback.setText(message)
field.setFocus()
def _update_token_status(self) -> None:
self.token_status.setText("已保存" if self._has_stored_device_token else "未保存")
+1
View File
@@ -0,0 +1 @@
"""core tests。"""
+187
View File
@@ -0,0 +1,187 @@
from __future__ import annotations
import json
import unittest
from cmbuyer_client.core.errors import ValidationError
from cmbuyer_client.core.models import ClaimedTask, SecretToken
from cmbuyer_client.core.validation import rfc3339_z_nanoseconds, strict_json_loads
TASK_ID = "13c9f507-7473-4fa6-8d71-8786c34c6301"
AUTH_ID = "73c9f507-7473-4fa6-8d71-8786c34c6301"
ATTEMPT_ID = "53c9f507-7473-4fa6-8d71-8786c34c6301"
TOKEN = "0123456789abcdef" * 4
def claim_wire() -> dict[str, object]:
return {
"task": {
"id": TASK_ID,
"version": 3,
"title": "纯棉短袖",
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
"goods_id": "937122477375",
"sku_color": "黑色CHA(纯棉)",
"sku_size": "M(建议100-115)",
"quantity": 2,
"max_total_price": "30.00",
},
"authorization": {"id": AUTH_ID, "task_version": 2, "expires_at": "2026-08-04T10:00:00Z"},
"attempt": {
"id": ATTEMPT_ID,
"claim_token": TOKEN,
"claim_generation": 1,
"lease_expires_at": "2026-08-04T09:05:00Z",
},
}
class CoreModelsTests(unittest.TestCase):
def test_claim_wire_round_trip_and_secret_repr(self) -> None:
claimed = ClaimedTask.from_wire(claim_wire())
self.assertEqual(claimed.task.quantity, 2)
self.assertNotIn(TOKEN, repr(claimed))
self.assertNotIn(TOKEN, repr(SecretToken(TOKEN)))
def test_rejects_bool_float_wrong_url_and_version_drift(self) -> None:
mutations = []
for mutate in (
lambda value: value["task"].__setitem__("quantity", True),
lambda value: value["task"].__setitem__("max_total_price", "30.0"),
lambda value: value["task"].__setitem__("max_total_price", "0.00"),
lambda value: value["task"].__setitem__("product_url", "https://example.invalid/"),
lambda value: value["task"].__setitem__("version", 2),
):
value = claim_wire()
mutate(value)
mutations.append(value)
for value in mutations:
with self.subTest(value=value), self.assertRaises(ValidationError):
ClaimedTask.from_wire(value)
def test_strict_json_rejects_nested_duplicates_float_nan_bom_and_utf8(self) -> None:
bad_values = (
b'{"task":{"id":1,"id":2}}',
b'{"value":1.0}',
b'{"value":NaN}',
b'\xef\xbb\xbf{}',
b'\xff',
('{"value":' + "9" * 5000 + '}').encode(),
)
for raw in bad_values:
with self.subTest(raw=raw), self.assertRaises(ValidationError):
strict_json_loads(raw, maximum=1024)
self.assertEqual(strict_json_loads(json.dumps({"value": 1}).encode(), maximum=1024), {"value": 1})
def test_rfc3339_nano_comparison_preserves_all_fraction_digits(self) -> None:
equal = (
"2026-08-04T09:01:00.1Z",
"2026-08-04T09:01:00.100000Z",
"2026-08-04T09:01:00.100000000Z",
)
self.assertEqual(len({rfc3339_z_nanoseconds(value) for value in equal}), 1)
ordered = (
"2026-08-04T09:01:00Z",
"2026-08-04T09:01:00.000001Z",
"2026-08-04T09:01:00.0000011Z",
"2026-08-04T09:01:00.000001101Z",
"2026-08-04T09:01:01Z",
)
self.assertEqual([rfc3339_z_nanoseconds(value) for value in ordered], sorted(rfc3339_z_nanoseconds(value) for value in ordered))
def test_money_accepts_positive_subunit_but_rejects_zero_and_noncanonical_forms(self) -> None:
value = claim_wire()
value["task"]["max_total_price"] = "0.01"
self.assertEqual(ClaimedTask.from_wire(value).task.max_total_price, "0.01")
for invalid in ("0.00", "00.01", "1.0", "1.000", "1", 1.0, "1.12", "1.٠٠", "12.00"):
with self.subTest(invalid=invalid), self.assertRaises(ValidationError):
changed = claim_wire()
changed["task"]["max_total_price"] = invalid
ClaimedTask.from_wire(changed)
wide_quantity = claim_wire()
wide_quantity["task"]["quantity"] = 2_147_483_648
self.assertEqual(ClaimedTask.from_wire(wide_quantity).task.quantity, 2_147_483_648)
for invalid_goods in ("123", "1٢3"):
changed = claim_wire()
changed["task"]["goods_id"] = invalid_goods
changed["task"]["product_url"] = "https://mobile.yangkeduo.com/goods.html?goods_id=" + invalid_goods
with self.subTest(invalid_goods=invalid_goods), self.assertRaises(ValidationError):
ClaimedTask.from_wire(changed)
too_large = claim_wire()
too_large["task"]["quantity"] = 9_223_372_036_854_775_808
with self.assertRaises(ValidationError):
ClaimedTask.from_wire(too_large)
def test_claim_fields_share_explicit_server_bounds(self) -> None:
legal = claim_wire()
legal_goods = "1" * 32
legal["task"].update(
version=9_223_372_036_854_775_807,
title="😀" * 120,
goods_id=legal_goods,
product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + legal_goods,
sku_color="色" * 80,
sku_size="码" * 80,
max_total_price="1" * 29 + ".00",
)
legal["authorization"]["task_version"] = 9_223_372_036_854_775_806
claimed = ClaimedTask.from_wire(legal)
self.assertEqual(len(claimed.task.title), 120)
# Python's default ensure_ascii=True expands astral characters to surrogate
# escape pairs, so this is a conservative parser-budget proof as well.
self.assertLess(len(json.dumps(legal, separators=(",", ":")).encode()), 32 * 1024)
mutations = (
("title", "😀" * 121),
("title", " title"),
("sku_color", "色" * 81),
("sku_color", "black "),
("sku_size", "码" * 81),
("sku_size", " M"),
("max_total_price", "1" * 30 + ".00"),
)
for field, invalid in mutations:
changed = claim_wire()
changed["task"][field] = invalid
with self.subTest(field=field, length=len(invalid)), self.assertRaises(ValidationError):
ClaimedTask.from_wire(changed)
overlong_goods = "1" * 33
changed = claim_wire()
changed["task"].update(
goods_id=overlong_goods,
product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + overlong_goods,
)
with self.assertRaises(ValidationError):
ClaimedTask.from_wire(changed)
def test_wire_strings_reject_lone_surrogates_but_accept_valid_pair(self) -> None:
for escaped in (r'"\ud800"', r'"\udc00"'):
value = claim_wire()
value["task"]["title"] = json.loads(escaped)
with self.subTest(escaped=escaped), self.assertRaises(ValidationError):
ClaimedTask.from_wire(value)
value = claim_wire()
value["task"]["title"] = json.loads(r'"\ud83d\ude00"')
self.assertEqual(ClaimedTask.from_wire(value).task.title, "😀")
def test_title_rejects_ascii_and_unicode_whitespace_only(self) -> None:
for title in ("", " \t\r\n", "\u3000", " \u3000\t", "\u00a0title", "title\u00a0"):
value = claim_wire()
value["task"]["title"] = title
with self.subTest(title=repr(title)), self.assertRaises(ValidationError):
ClaimedTask.from_wire(value)
def test_persisted_text_has_runtime_independent_c0_and_nbsp_domain(self) -> None:
for field in ("title", "sku_color", "sku_size"):
for invalid in ("\u001cvalue", "value\u001f", "value\u001dinside", "\u00a0value", "value\u00a0"):
value = claim_wire()
value["task"][field] = invalid
with self.subTest(field=field, invalid=repr(invalid)), self.assertRaises(ValidationError):
ClaimedTask.from_wire(value)
value = claim_wire()
value["task"][field] = "left\u00a0right"
self.assertEqual(getattr(ClaimedTask.from_wire(value).task, field), "left\u00a0right")
@@ -2,7 +2,9 @@
from __future__ import annotations
from functools import lru_cache
from hashlib import sha256
from io import BytesIO
import json
from pathlib import Path
import sys
@@ -48,6 +50,16 @@ def _hash(path: Path) -> str:
return digest.hexdigest()
@lru_cache(maxsize=None)
def _source_png(size: tuple[int, int]) -> bytes:
image = Image.new("RGB", size, color=(0, 180, 0))
if size == (EXPECTED_SCREENSHOT_WIDTH, EXPECTED_SCREENSHOT_HEIGHT):
image.paste((255, 0, 0), (0, 0, 8, 540))
output = BytesIO()
image.save(output, format="PNG")
return output.getvalue()
def _default_xml() -> str:
return (
"<hierarchy rotation='0'>"
@@ -116,12 +128,7 @@ def _write_raw(
raw = root / "raw"
raw.mkdir(parents=True)
screenshot = raw / "screenshot.png"
image = Image.new("RGB", size, color=(0, 180, 0))
if size == (EXPECTED_SCREENSHOT_WIDTH, EXPECTED_SCREENSHOT_HEIGHT):
for y in range(540):
for x in range(8):
image.putpixel((x, y), (255, 0, 0))
image.save(screenshot, format="PNG")
screenshot.write_bytes(_source_png(size))
hierarchy = raw / "hierarchy.xml"
hierarchy.write_text(_default_xml() if xml is None else xml, encoding="utf-8")
manifest = {
+1
View File
@@ -0,0 +1 @@
"""localstate tests。"""
@@ -0,0 +1,36 @@
from __future__ import annotations
import ast
from pathlib import Path
import unittest
SRC = Path(__file__).resolve().parents[2] / "src" / "cmbuyer_client"
SCOPED = tuple((SRC / name) for name in ("core", "remote", "localstate"))
class StaticBoundaryTests(unittest.TestCase):
def test_scoped_modules_do_not_import_device_pdd_or_unapproved_capabilities(self) -> None:
forbidden_modules = ("cmbuyer_client.device", "cmbuyer_client.pdd")
forbidden_text = (
"ResultSink",
"/events",
"/fail",
"/submission-fence",
"/result",
"click_permitted",
)
for directory in SCOPED:
for path in directory.glob("*.py"):
text = path.read_text(encoding="utf-8")
tree = ast.parse(text)
imports = []
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imports.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
imports.append(node.module)
for module in forbidden_modules:
self.assertFalse(any(name.startswith(module) for name in imports), (path, module))
for value in forbidden_text:
self.assertNotIn(value, text, (path, value))
+321
View File
@@ -0,0 +1,321 @@
from __future__ import annotations
import base64
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import sqlite3
import tempfile
import unittest
from unittest import mock
from cmbuyer_client.core.errors import AmbiguousRemoteError, CredentialRemoteError, ManualRemoteError, StateError
from cmbuyer_client.core.models import AssetReceipt, ClaimedTask, RenewResult, ScreenshotAsset, SecretToken
from cmbuyer_client.localstate.facade import DurableClientGateway
from cmbuyer_client.localstate.models import ProfileSettings
from cmbuyer_client.localstate.store import LocalStateStore
from cmbuyer_client.remote.evidence_sink import HttpEvidenceSink
from cmbuyer_client.remote.http_transport import HttpResponse
from cmbuyer_client.remote.task_source import HttpTaskSource
from tests.core.test_models import ATTEMPT_ID, TASK_ID, claim_wire
from tests.localstate.test_store import DEVICE_TOKEN, FakeProtector, PROFILE
from tests.remote.test_task_source import DEVICE_ID, FakeTransport, response
PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
)
class InspectingSource:
def __init__(self, store: LocalStateStore, profile_id: str) -> None:
self.store = store
self.profile_id = profile_id
self.calls = 0
self.mode = "success"
self.request_ids: list[str] = []
def claim_next(self, credentials, request):
self.calls += 1
self.request_ids.append(request.claim_request_id)
# HTTP 适配器被调用时,幂等请求必须已经 durable。
self.assert_pending(request.claim_request_id)
if self.mode == "ambiguous":
raise AmbiguousRemoteError("http_result_unknown")
if self.mode == "manual":
raise ManualRemoteError("claim_requires_manual")
return ClaimedTask.from_wire(claim_wire())
def renew(self, credentials, request):
raise AssertionError("not used")
def assert_pending(self, request_id: str) -> None:
snapshot = LocalStateStore(self.store.database_path, FakeProtector()).recovery_snapshot(self.profile_id)
if snapshot.pending_claim is None or snapshot.pending_claim.claim_request_id != request_id:
raise AssertionError("HTTP happened before durable prepare")
class InspectingSink:
def __init__(self, store: LocalStateStore, profile_id: str) -> None:
self.store = store
self.profile_id = profile_id
self.calls = 0
def upload(self, credentials, upload):
self.calls += 1
snapshot = LocalStateStore(self.store.database_path, FakeProtector()).recovery_snapshot(self.profile_id)
if not snapshot.pending_evidence or snapshot.pending_evidence[0].upload_key != upload.upload_key:
raise AssertionError("HTTP happened before durable evidence slot")
return AssetReceipt(
"63c9f507-7473-4fa6-8d71-8786c34c6301",
upload.task_id,
upload.attempt_id,
upload.kind,
upload.privacy_tier,
upload.sha256,
len(upload.content),
"image/png",
1,
1,
upload.captured_at,
)
class InspectingRenewSource:
def __init__(self, store: LocalStateStore, profile_id: str) -> None:
self.store = store
self.profile_id = profile_id
self.calls = 0
def claim_next(self, credentials, request):
raise AssertionError("not used")
def renew(self, credentials, request):
self.calls += 1
snapshot = LocalStateStore(self.store.database_path, FakeProtector()).recovery_snapshot(self.profile_id)
if snapshot.pending_renew is None or snapshot.pending_renew.renew_request_id != request.renew_request_id:
raise AssertionError("HTTP happened before durable renew")
return RenewResult(request.task_id, request.attempt_id, request.claim_generation, request.expected_lease_expires_at)
class DurableClientGatewayTests(unittest.TestCase):
def setUp(self) -> None:
self.directory = tempfile.TemporaryDirectory()
self.database = Path(self.directory.name) / "client-state.sqlite3"
self.store = LocalStateStore(
self.database,
FakeProtector(),
now=lambda: datetime(2026, 8, 4, 9, 0, tzinfo=timezone.utc),
)
profile = ProfileSettings(
PROFILE,
"http://127.0.0.1:8080",
DEVICE_ID,
"D:/Portable/adb/adb.exe",
"192.168.0.173:5555",
"wifi",
)
self.store.save_profile(profile, SecretToken(DEVICE_TOKEN))
self.store.start_or_resume_polling(PROFILE)
self.source = InspectingSource(self.store, PROFILE)
self.sink = InspectingSink(self.store, PROFILE)
self.gateway = DurableClientGateway(self.store, self.source, self.sink)
def tearDown(self) -> None:
self.directory.cleanup()
def test_claim_unknown_replays_same_durable_key_then_commits(self) -> None:
self.source.mode = "ambiguous"
with self.assertRaises(AmbiguousRemoteError):
self.gateway.claim_next(PROFILE)
self.source.mode = "success"
claimed = self.gateway.claim_next(PROFILE)
self.assertEqual(claimed.task.id, TASK_ID)
self.assertEqual(self.source.request_ids[0], self.source.request_ids[1])
self.assertIsNotNone(self.store.active_claim(PROFILE))
def test_unknown_claim_2xx_keeps_pending_key_for_real_adapter_replay(self) -> None:
transport = FakeTransport(response(201, claim_wire()))
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
with self.assertRaises(AmbiguousRemoteError):
gateway.claim_next(PROFILE)
pending = self.store.recovery_snapshot(PROFILE).pending_claim
self.assertIsNotNone(pending)
transport.response = response(200, claim_wire())
claimed = gateway.claim_next(PROFILE)
self.assertEqual(claimed.task.id, TASK_ID)
sent = [call[3] for call in transport.calls]
self.assertEqual(sent[0], sent[1])
def test_claim_401_allows_token_repair_and_same_key_replay(self) -> None:
transport = FakeTransport(HttpResponse(401, (), b""))
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
with self.assertRaises(CredentialRemoteError):
gateway.claim_next(PROFILE)
request_id = self.store.recovery_snapshot(PROFILE).pending_claim.claim_request_id
self.store.save_profile(self.store.load_profile(PROFILE).settings, SecretToken("c" * 64))
transport.response = response(200, claim_wire())
gateway.claim_next(PROFILE)
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
self.assertNotEqual(dict(transport.calls[0][2])["Authorization"], dict(transport.calls[1][2])["Authorization"])
self.assertEqual(json.loads(transport.calls[1][3])["claim_request_id"], request_id)
def test_profile_read_sql_failure_after_prepare_is_fixed_error_and_zero_http(self) -> None:
original_connect = self.store._connect
calls = 0
def fail_second_connection():
nonlocal calls
calls += 1
connection = original_connect()
if calls == 2:
connection.set_authorizer(
lambda action, table, *_: sqlite3.SQLITE_DENY
if action == sqlite3.SQLITE_READ and table == "profiles"
else sqlite3.SQLITE_OK
)
return connection
with mock.patch.object(self.store, "_connect", side_effect=fail_second_connection):
with self.assertRaisesRegex(StateError, "localstate_read_failed") as captured:
self.gateway.claim_next(PROFILE)
self.assertEqual(self.source.calls, 0)
self.assertNotIn(str(self.database), repr(captured.exception))
def test_renew_is_durable_before_http(self) -> None:
self.gateway.claim_next(PROFILE)
source = InspectingRenewSource(self.store, PROFILE)
gateway = DurableClientGateway(self.store, source, self.sink)
result = gateway.renew(PROFILE)
self.assertEqual(result.attempt_id, ATTEMPT_ID)
self.assertEqual(source.calls, 1)
def test_unknown_renew_2xx_keeps_pending_payload_for_replay(self) -> None:
self.gateway.claim_next(PROFILE)
payload = {
"task_id": TASK_ID,
"attempt_id": ATTEMPT_ID,
"claim_generation": 1,
"lease_expires_at": "2026-08-04T09:06:00Z",
}
transport = FakeTransport(response(201, payload))
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
with self.assertRaises(AmbiguousRemoteError):
gateway.renew(PROFILE)
pending = self.store.recovery_snapshot(PROFILE).pending_renew
self.assertIsNotNone(pending)
transport.response = response(200, payload)
gateway.renew(PROFILE)
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
def test_renew_401_allows_bearer_repair_without_changing_claim_payload(self) -> None:
self.gateway.claim_next(PROFILE)
payload = {
"task_id": TASK_ID,
"attempt_id": ATTEMPT_ID,
"claim_generation": 1,
"lease_expires_at": "2026-08-04T09:06:00Z",
}
transport = FakeTransport(HttpResponse(401, (), b""))
gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink)
with self.assertRaises(CredentialRemoteError):
gateway.renew(PROFILE)
self.assertIsNotNone(self.store.recovery_snapshot(PROFILE).pending_renew)
self.store.save_profile(self.store.load_profile(PROFILE).settings, SecretToken("c" * 64))
transport.response = response(200, payload)
gateway.renew(PROFILE)
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
self.assertNotEqual(dict(transport.calls[0][2])["Authorization"], dict(transport.calls[1][2])["Authorization"])
def test_unknown_evidence_2xx_keeps_pending_multipart_for_replay(self) -> None:
self.gateway.claim_next(PROFILE)
path = Path(self.directory.name) / "unknown.png"
path.write_bytes(PNG)
asset = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
transport = FakeTransport(HttpResponse(202, (), b""))
gateway = DurableClientGateway(self.store, self.source, HttpEvidenceSink(transport))
with self.assertRaises(AmbiguousRemoteError):
gateway.upload_evidence(PROFILE, asset)
pending = self.store.recovery_snapshot(PROFILE).pending_evidence
self.assertEqual(len(pending), 1)
digest = hashlib.sha256(PNG).hexdigest()
receipt = {
"asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301",
"task_id": TASK_ID,
"attempt_id": ATTEMPT_ID,
"kind": "SKU_PANEL_GATE_1",
"privacy_tier": "INTERNAL_RAW",
"sha256": digest,
"byte_size": len(PNG),
"content_type": "image/png",
"width_px": 1,
"height_px": 1,
"captured_at": "2026-08-04T09:01:00Z",
}
transport.response = HttpResponse(201, (("Content-Type", "application/json"),), json.dumps(receipt).encode())
gateway.upload_evidence(PROFILE, asset)
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
def test_evidence_401_allows_bearer_repair_with_same_file_and_multipart(self) -> None:
self.gateway.claim_next(PROFILE)
path = Path(self.directory.name) / "credential.png"
path.write_bytes(PNG)
asset = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
transport = FakeTransport(HttpResponse(401, (), b""))
gateway = DurableClientGateway(self.store, self.source, HttpEvidenceSink(transport))
with self.assertRaises(CredentialRemoteError):
gateway.upload_evidence(PROFILE, asset)
self.assertEqual(len(self.store.recovery_snapshot(PROFILE).pending_evidence), 1)
self.store.save_profile(self.store.load_profile(PROFILE).settings, SecretToken("c" * 64))
digest = hashlib.sha256(PNG).hexdigest()
receipt = {
"asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301",
"task_id": TASK_ID,
"attempt_id": ATTEMPT_ID,
"kind": "SKU_PANEL_GATE_1",
"privacy_tier": "INTERNAL_RAW",
"sha256": digest,
"byte_size": len(PNG),
"content_type": "image/png",
"width_px": 1,
"height_px": 1,
"captured_at": "2026-08-04T09:01:00Z",
}
transport.response = HttpResponse(201, (("Content-Type", "application/json"),), json.dumps(receipt).encode())
gateway.upload_evidence(PROFILE, asset)
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
self.assertNotEqual(dict(transport.calls[0][2])["Authorization"], dict(transport.calls[1][2])["Authorization"])
def test_equivalent_captured_at_replays_exact_original_multipart_bytes(self) -> None:
self.gateway.claim_next(PROFILE)
path = Path(self.directory.name) / "exact-replay.png"
path.write_bytes(PNG)
transport = FakeTransport(HttpResponse(202, (), b""))
gateway = DurableClientGateway(self.store, self.source, HttpEvidenceSink(transport))
first = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00.1Z")
equivalent = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00.100000Z")
with self.assertRaises(AmbiguousRemoteError):
gateway.upload_evidence(PROFILE, first)
with self.assertRaises(AmbiguousRemoteError):
gateway.upload_evidence(PROFILE, equivalent)
self.assertEqual(transport.calls[0][3], transport.calls[1][3])
def test_manual_claim_is_durable_and_never_gets_new_key(self) -> None:
self.source.mode = "manual"
with self.assertRaises(ManualRemoteError):
self.gateway.claim_next(PROFILE)
with self.assertRaises(Exception):
self.gateway.claim_next(PROFILE)
self.assertEqual(self.source.calls, 1)
def test_evidence_slot_exists_before_http_and_success_never_reuploads(self) -> None:
self.gateway.claim_next(PROFILE)
path = Path(self.directory.name) / "one.png"
path.write_bytes(PNG)
asset = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00.120000Z")
first = self.gateway.upload_evidence(PROFILE, asset)
path.write_bytes(PNG + b"changed")
second = self.gateway.upload_evidence(PROFILE, asset)
self.assertEqual(first, second)
self.assertEqual(self.sink.calls, 1)
@@ -0,0 +1,24 @@
from __future__ import annotations
import os
import unittest
from cmbuyer_client.core.errors import ProtectionError
from cmbuyer_client.localstate.protection import DpapiProtector
@unittest.skipUnless(os.name == "nt", "DPAPI 仅在 Windows 验证")
class DpapiProtectorTests(unittest.TestCase):
def test_current_user_round_trip_purpose_isolation_and_corruption(self) -> None:
protector = DpapiProtector()
plaintext = b"a" * 64
device_purpose = "device-token:default:33c9f507-7473-4fa6-8d71-8786c34c6301"
claim_purpose = "claim-token:default:53c9f507-7473-4fa6-8d71-8786c34c6301"
ciphertext = protector.protect(plaintext, purpose=device_purpose)
self.assertNotIn(plaintext, ciphertext)
self.assertEqual(protector.unprotect(ciphertext, purpose=device_purpose), plaintext)
with self.assertRaises(ProtectionError):
protector.unprotect(ciphertext, purpose=claim_purpose)
damaged = ciphertext[:-1] + bytes((ciphertext[-1] ^ 1,))
with self.assertRaises(ProtectionError):
protector.unprotect(damaged, purpose=device_purpose)
@@ -0,0 +1,38 @@
from __future__ import annotations
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import unittest
from cmbuyer_client.core.errors import SingleInstanceError
from cmbuyer_client.localstate.single_instance import NamedMutex
@unittest.skipUnless(os.name == "nt", "named mutex 仅在 Windows 验证")
class NamedMutexTests(unittest.TestCase):
def test_second_process_for_same_database_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as directory:
database = Path(directory) / "state.sqlite3"
first = NamedMutex(database)
try:
with self.assertRaises(SingleInstanceError):
NamedMutex(database)
code = (
"from pathlib import Path; "
"from cmbuyer_client.localstate.single_instance import NamedMutex; "
"from cmbuyer_client.core.errors import SingleInstanceError; "
f"p=Path({str(database)!r}); "
"\ntry:\n NamedMutex(p)\nexcept SingleInstanceError:\n raise SystemExit(17)\nraise SystemExit(0)"
)
environment = dict(os.environ)
environment["PYTHONPATH"] = str(Path(__file__).resolve().parents[2] / "src")
result = subprocess.run([sys.executable, "-c", code], env=environment, check=False)
self.assertEqual(result.returncode, 17)
finally:
first.close()
with NamedMutex(database):
pass
+895
View File
@@ -0,0 +1,895 @@
from __future__ import annotations
import base64
from dataclasses import replace
from datetime import datetime, timezone
import hashlib
import hmac
import json
import os
from pathlib import Path
import sqlite3
import tempfile
import threading
from types import SimpleNamespace
import unittest
from unittest import mock
from cmbuyer_client.core.errors import ProtectionError, StateError
from cmbuyer_client.core.models import AssetReceipt, ClaimedTask, RenewResult, ScreenshotAsset, SecretToken
from cmbuyer_client.localstate.models import ProfileSettings
from cmbuyer_client.localstate.store import LocalStateStore, _read_stable_png
from tests.core.test_models import ATTEMPT_ID, TASK_ID, TOKEN, claim_wire
from tests.remote.test_task_source import DEVICE_ID
DEVICE_TOKEN = "b" * 64
PROFILE = "default"
PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
)
class FakeProtector:
def protect(self, plaintext: bytes, *, purpose: str) -> bytes:
key = hashlib.sha256(("test:" + purpose).encode()).digest()
encrypted = bytes(value ^ key[index % len(key)] for index, value in enumerate(plaintext))
return hmac.digest(key, plaintext, "sha256") + encrypted
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes:
if len(ciphertext) < 33:
raise ProtectionError("fake_unprotect_failed")
key = hashlib.sha256(("test:" + purpose).encode()).digest()
plaintext = bytes(value ^ key[index % len(key)] for index, value in enumerate(ciphertext[32:]))
if not hmac.compare_digest(ciphertext[:32], hmac.digest(key, plaintext, "sha256")):
raise ProtectionError("fake_unprotect_failed")
return plaintext
class NoUnprotectProtector(FakeProtector):
def __init__(self) -> None:
self.unprotect_calls = 0
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes:
self.unprotect_calls += 1
raise AssertionError("metadata_read_must_not_unprotect")
def settings(device_id: str = DEVICE_ID) -> ProfileSettings:
return ProfileSettings(
PROFILE,
"http://127.0.0.1:8080",
device_id,
"D:/Portable/adb/adb.exe",
"192.168.0.173:5555",
"wifi",
)
class LocalStateStoreTests(unittest.TestCase):
def setUp(self) -> None:
self.directory = tempfile.TemporaryDirectory()
self.database = Path(self.directory.name) / "state" / "client-state.sqlite3"
self.clock = [datetime(2026, 8, 4, 9, 0, tzinfo=timezone.utc)]
self.store = self.new_store()
self.store.save_profile(settings(), SecretToken(DEVICE_TOKEN))
def tearDown(self) -> None:
self.directory.cleanup()
def new_store(self) -> LocalStateStore:
return LocalStateStore(self.database, FakeProtector(), now=lambda: self.clock[0])
def test_claim_unknown_restart_stop_and_atomic_success(self) -> None:
session = self.store.start_or_resume_polling(PROFILE)
request = self.store.prepare_claim(PROFILE)
self.assertEqual(self.store.prepare_claim(PROFILE), request)
# 模拟 HTTP 已成功但进程在落库前退出;重启只能恢复原 key。
restarted = self.new_store()
self.assertEqual(restarted.recovery_snapshot(PROFILE).pending_claim, request)
self.assertEqual(restarted.prepare_claim(PROFILE), request)
# stop 不能丢弃飞行中的 claim,返回结果仍必须落 active。
restarted.request_stop(PROFILE)
claimed = ClaimedTask.from_wire(claim_wire())
restarted.commit_claim_success(PROFILE, request, claimed)
snapshot = self.new_store().recovery_snapshot(PROFILE)
self.assertFalse(snapshot.session.accept_new)
self.assertIsNone(snapshot.pending_claim)
self.assertEqual(snapshot.active_claim.task.id, TASK_ID)
self.assertEqual(snapshot.active_claim.attempt.claim_token.value, TOKEN)
with self.assertRaises(StateError):
restarted.prepare_claim(PROFILE)
def test_relative_database_path_is_frozen_across_cwd_changes(self) -> None:
original_cwd = Path.cwd()
first = Path(self.directory.name) / "first-cwd"
second = Path(self.directory.name) / "second-cwd"
first.mkdir()
second.mkdir()
try:
os.chdir(first)
relative_store = LocalStateStore(Path("relative/state.sqlite3"), FakeProtector(), now=lambda: self.clock[0])
relative_store.save_profile(settings(), SecretToken(DEVICE_TOKEN))
frozen_path = relative_store.database_path
os.chdir(second)
self.assertEqual(relative_store.load_profile(PROFILE).settings, settings())
self.assertEqual(relative_store.database_path, frozen_path)
self.assertTrue(frozen_path.is_absolute())
self.assertFalse((second / "relative" / "state.sqlite3").exists())
finally:
os.chdir(original_cwd)
def test_profile_summary_reads_settings_and_token_presence_without_unprotect(self) -> None:
protector = NoUnprotectProtector()
summary_store = LocalStateStore(
self.database,
protector,
now=lambda: self.clock[0],
)
summary = summary_store.load_profile_summary(PROFILE)
self.assertEqual(summary.settings, settings())
self.assertTrue(summary.has_stored_device_token)
self.assertNotIn(DEVICE_TOKEN, repr(summary))
self.assertEqual(protector.unprotect_calls, 0)
def test_profile_summary_empty_cipher_fails_closed_without_unprotect(self) -> None:
connection = sqlite3.connect(self.database)
try:
connection.execute(
"UPDATE profiles SET device_token_cipher=? WHERE profile_id=?",
(sqlite3.Binary(b""), PROFILE),
)
connection.commit()
finally:
connection.close()
protector = NoUnprotectProtector()
summary_store = LocalStateStore(self.database, protector, now=lambda: self.clock[0])
with self.assertRaisesRegex(StateError, "invalid_device_token_cipher"):
summary_store.load_profile_summary(PROFILE)
self.assertEqual(protector.unprotect_calls, 0)
def test_profile_summary_wrong_cipher_storage_class_fails_closed_without_unprotect(self) -> None:
current = settings()
row = (
current.service_url,
current.device_id,
current.adb_path,
current.adb_serial,
current.transport,
current.poll_interval_seconds,
current.failure_threshold,
current.http_timeout_seconds,
current.step_timeout_seconds,
"text",
64,
)
protector = NoUnprotectProtector()
summary_store = LocalStateStore(self.database, protector, now=lambda: self.clock[0])
fake_connection = mock.Mock()
fake_connection.execute.return_value.fetchone.return_value = row
with mock.patch.object(summary_store, "_read_transaction") as read_transaction:
read_transaction.return_value.__enter__.return_value = fake_connection
with self.assertRaisesRegex(StateError, "invalid_device_token_cipher"):
summary_store.load_profile_summary(PROFILE)
self.assertEqual(protector.unprotect_calls, 0)
def test_profile_summary_invalid_stored_settings_are_normalized_without_unprotect(self) -> None:
original = settings()
protector = NoUnprotectProtector()
summary_store = LocalStateStore(self.database, protector, now=lambda: self.clock[0])
for column, invalid, valid in (
("service_url", "http://127.0.0.1:9999", original.service_url),
("transport", "bluetooth", original.transport),
("poll_interval_seconds", 4, original.poll_interval_seconds),
):
with self.subTest(column=column):
connection = sqlite3.connect(self.database)
try:
connection.execute("PRAGMA ignore_check_constraints=ON")
connection.execute(
f"UPDATE profiles SET {column}=? WHERE profile_id=?",
(invalid, PROFILE),
)
connection.commit()
finally:
connection.close()
try:
with self.assertRaisesRegex(StateError, "stored_profile_invalid") as captured:
summary_store.load_profile_summary(PROFILE)
self.assertNotIn(DEVICE_TOKEN, str(captured.exception))
finally:
connection = sqlite3.connect(self.database)
try:
connection.execute("PRAGMA ignore_check_constraints=ON")
connection.execute(
f"UPDATE profiles SET {column}=? WHERE profile_id=?",
(valid, PROFILE),
)
connection.commit()
finally:
connection.close()
self.assertEqual(protector.unprotect_calls, 0)
def test_profile_summary_missing_profile_fails_without_creating_defaults(self) -> None:
with self.assertRaisesRegex(StateError, "profile_not_found"):
self.store.load_profile_summary("missing")
connection = sqlite3.connect(self.database)
try:
self.assertEqual(connection.execute("SELECT count(*) FROM profiles").fetchone()[0], 1)
finally:
connection.close()
def test_empty_allows_new_key_but_terminal_does_not(self) -> None:
self.store.start_or_resume_polling(PROFILE)
first = self.store.prepare_claim(PROFILE)
self.store.commit_claim_empty(PROFILE, first)
second = self.store.prepare_claim(PROFILE)
self.assertNotEqual(first.claim_request_id, second.claim_request_id)
self.store.mark_claim_terminal(PROFILE, second, "MANUAL")
with self.assertRaises(StateError):
self.store.prepare_claim(PROFILE)
def test_idle_profile_identity_change_creates_new_session_and_key(self) -> None:
first_session = self.store.start_or_resume_polling(PROFILE)
first = self.store.prepare_claim(PROFILE)
self.store.commit_claim_empty(PROFILE, first)
other_id = "f3c9f507-7473-4fa6-8d71-8786c34c6301"
self.store.save_profile(settings(other_id), SecretToken("c" * 64))
self.assertIsNone(self.store.recovery_snapshot(PROFILE).session)
second_session = self.store.start_or_resume_polling(PROFILE)
second = self.store.prepare_claim(PROFILE)
self.assertNotEqual(first_session.session_id, second_session.session_id)
self.assertNotEqual(first.claim_request_id, second.claim_request_id)
self.assertEqual(second.session_id, second_session.session_id)
def test_device_change_requires_new_token_because_cipher_is_identity_bound(self) -> None:
other_id = "f3c9f507-7473-4fa6-8d71-8786c34c6301"
with self.assertRaisesRegex(StateError, "device_token_required_for_device_change"):
self.store.save_profile(settings(other_id), None)
self.store.save_profile(settings(other_id), SecretToken("c" * 64))
self.assertEqual(self.store.load_profile(PROFILE).credentials.token.value, "c" * 64)
def test_tampered_profile_session_mismatch_is_not_auto_repaired(self) -> None:
self.store.start_or_resume_polling(PROFILE)
connection = sqlite3.connect(self.database)
try:
connection.execute(
"UPDATE profiles SET device_id=? WHERE profile_id=?",
("f3c9f507-7473-4fa6-8d71-8786c34c6301", PROFILE),
)
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "polling_identity_mismatch"):
self.store.start_or_resume_polling(PROFILE)
def test_pending_and_active_freeze_service_device_and_token_identity(self) -> None:
self.store.start_or_resume_polling(PROFILE)
request = self.store.prepare_claim(PROFILE)
self.store.save_profile(settings(), SecretToken("c" * 64))
self.assertEqual(self.store.load_profile(PROFILE).credentials.token.value, "c" * 64)
other_id = "f3c9f507-7473-4fa6-8d71-8786c34c6301"
with self.assertRaises(StateError):
self.store.save_profile(settings(other_id), None)
base = settings()
changed_profiles = (
replace(base, adb_path="D:/other/adb.exe"),
replace(base, adb_serial="usb-other"),
replace(base, transport="usb"),
replace(base, poll_interval_seconds=16),
replace(base, failure_threshold=4),
replace(base, http_timeout_seconds=11),
replace(base, step_timeout_seconds=46),
)
for changed in changed_profiles:
with self.subTest(changed=changed), self.assertRaises(StateError):
self.store.save_profile(changed, None)
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(claim_wire()))
self.store.save_profile(settings(), SecretToken("d" * 64))
self.assertEqual(self.store.load_profile(PROFILE).credentials.token.value, "d" * 64)
def test_renew_reuses_exact_payload_across_restart_and_cas_updates_only_lease(self) -> None:
self._claim_active()
request = self.store.prepare_renew(PROFILE)
self.clock[0] = datetime(2026, 8, 4, 9, 7, tzinfo=timezone.utc)
recovered = self.new_store().prepare_renew(PROFILE)
self.assertEqual(recovered.renew_request_id, request.renew_request_id)
self.assertEqual(recovered.to_wire(), request.to_wire())
result = RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T09:06:00Z")
self.store.commit_renew_success(PROFILE, request, result)
active = self.new_store().active_claim(PROFILE)
self.assertEqual(active.attempt.claim_generation, 1)
self.assertEqual(active.attempt.claim_token.value, TOKEN)
self.assertEqual(active.attempt.lease_expires_at, "2026-08-04T09:06:00Z")
with self.assertRaises(StateError):
self.store.prepare_renew(PROFILE)
def test_evidence_slot_is_persisted_before_send_and_rejects_file_change(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "explicit.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
recovered = self.new_store().prepare_or_resume_evidence(PROFILE, asset)
self.assertEqual(recovered.upload_key, upload.upload_key)
self.assertEqual(recovered.content, upload.content)
image.write_bytes(PNG + b"changed")
with self.assertRaises(StateError):
self.store.prepare_or_resume_evidence(PROFILE, asset)
image.write_bytes(PNG)
receipt = AssetReceipt(
"63c9f507-7473-4fa6-8d71-8786c34c6301",
TASK_ID,
ATTEMPT_ID,
"SKU_PANEL_GATE_1",
"INTERNAL_RAW",
upload.sha256,
len(PNG),
"image/png",
1,
1,
"2026-08-04T09:01:00Z",
)
self.store.commit_evidence_success(PROFILE, upload, receipt)
image.write_bytes(PNG + b"different-after-success")
self.assertEqual(self.new_store().prepare_or_resume_evidence(PROFILE, asset), receipt)
image.unlink()
self.assertEqual(self.new_store().prepare_or_resume_evidence(PROFILE, asset), receipt)
def test_evidence_terminal_outcome_retains_slot_and_blocks_resend(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "manual.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
self.store.mark_evidence_terminal(PROFILE, upload, "MANUAL")
snapshot = self.store.recovery_snapshot(PROFILE)
self.assertEqual(snapshot.pending_evidence[0].status, "MANUAL")
with self.assertRaises(StateError):
self.store.prepare_or_resume_evidence(PROFILE, asset)
def test_receipt_dimensions_must_match_local_png_and_pending_slot_survives(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "dimension.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
wrong = AssetReceipt(
"63c9f507-7473-4fa6-8d71-8786c34c6301", TASK_ID, ATTEMPT_ID, upload.kind,
upload.privacy_tier, upload.sha256, len(upload.content), "image/png", 2, 2, upload.captured_at,
)
with self.assertRaisesRegex(StateError, "evidence_response_mismatch"):
self.store.commit_evidence_success(PROFILE, upload, wrong)
pending = self.new_store().recovery_snapshot(PROFILE).pending_evidence
self.assertEqual(len(pending), 1)
self.assertEqual(pending[0].upload_key, upload.upload_key)
def test_evidence_slot_is_profile_owned_and_missing_half_fails_closed(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "owned.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
self.store.prepare_or_resume_evidence(PROFILE, asset)
other = replace(settings(), profile_id="other")
self.store.save_profile(other, SecretToken("c" * 64))
with self.assertRaisesRegex(StateError, "evidence_slot_not_owned"):
self.store.prepare_or_resume_evidence("other", asset)
connection = sqlite3.connect(self.database)
connection.execute("DROP TRIGGER evidence_slots_no_delete")
connection.execute("DELETE FROM evidence_slots WHERE attempt_id=?", (ATTEMPT_ID,))
connection.commit()
connection.close()
with self.assertRaisesRegex(StateError, "evidence_marker_mismatch"):
self.store.prepare_or_resume_evidence(PROFILE, asset)
def test_evidence_reader_rejects_reparse_and_path_identity_change(self) -> None:
image = Path(self.directory.name) / "stable.png"
image.write_bytes(PNG)
real = os.lstat(image)
reparse = SimpleNamespace(
st_mode=real.st_mode,
st_file_attributes=0x400,
st_dev=real.st_dev,
st_ino=real.st_ino,
st_size=real.st_size,
st_mtime_ns=real.st_mtime_ns,
)
with mock.patch("cmbuyer_client.localstate.store.os.lstat", return_value=reparse):
with self.assertRaisesRegex(StateError, "evidence_file_not_regular"):
_read_stable_png(image)
replaced = SimpleNamespace(
st_mode=real.st_mode,
st_file_attributes=0,
st_dev=real.st_dev,
st_ino=real.st_ino + 1,
st_size=real.st_size,
st_mtime_ns=real.st_mtime_ns,
)
with mock.patch("cmbuyer_client.localstate.store.os.lstat", side_effect=(real, replaced)):
with self.assertRaisesRegex(StateError, "evidence_changed_during_read"):
_read_stable_png(image)
def test_corrupt_database_and_ciphertext_fail_closed(self) -> None:
self._claim_active()
connection = sqlite3.connect(self.database)
try:
connection.execute("DROP TRIGGER active_claims_identity_immutable")
connection.execute("UPDATE active_claims SET claim_token_cipher=?", (sqlite3.Binary(b"corrupt"),))
connection.commit()
finally:
connection.close()
with self.assertRaises(ProtectionError):
self.new_store().active_claim(PROFILE)
corrupt = Path(self.directory.name) / "corrupt.sqlite3"
corrupt.write_bytes(b"not-a-sqlite-database")
with self.assertRaises(StateError):
LocalStateStore(corrupt, FakeProtector())
def test_claim_token_cipher_cannot_be_swapped_between_attempt_histories(self) -> None:
self._claim_active()
connection = sqlite3.connect(self.database)
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
connection.commit()
connection.close()
request = self.store.prepare_claim(PROFILE)
wire = claim_wire()
wire["task"]["id"] = "83c9f507-7473-4fa6-8d71-8786c34c6301"
wire["authorization"]["id"] = "93c9f507-7473-4fa6-8d71-8786c34c6301"
wire["attempt"]["id"] = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
wire["attempt"]["claim_token"] = "f" * 64
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(wire))
connection = sqlite3.connect(self.database)
try:
connection.execute("DROP TRIGGER active_claims_identity_immutable")
rows = connection.execute("SELECT attempt_id,claim_token_cipher FROM active_claims ORDER BY created_at,attempt_id").fetchall()
connection.execute("UPDATE active_claims SET claim_token_cipher=? WHERE attempt_id=?", (rows[1][1], rows[0][0]))
connection.execute("UPDATE active_claims SET claim_token_cipher=? WHERE attempt_id=?", (rows[0][1], rows[1][0]))
connection.commit()
finally:
connection.close()
with self.assertRaises(ProtectionError):
self.new_store().active_claim(PROFILE)
def test_device_token_cipher_cannot_be_swapped_between_profiles(self) -> None:
other = replace(settings(), profile_id="other", device_id="f3c9f507-7473-4fa6-8d71-8786c34c6301")
self.store.save_profile(other, SecretToken("c" * 64))
connection = sqlite3.connect(self.database)
try:
rows = connection.execute(
"SELECT profile_id,device_token_cipher FROM profiles WHERE profile_id IN (?,?) ORDER BY profile_id",
(PROFILE, "other"),
).fetchall()
connection.execute("UPDATE profiles SET device_token_cipher=? WHERE profile_id=?", (rows[1][1], rows[0][0]))
connection.execute("UPDATE profiles SET device_token_cipher=? WHERE profile_id=?", (rows[0][1], rows[1][0]))
connection.commit()
finally:
connection.close()
with self.assertRaises(ProtectionError):
self.new_store().load_profile(PROFILE)
def test_state_graph_rejects_missing_active_pending_overlap_and_snapshot_drift(self) -> None:
self._claim_active()
connection = sqlite3.connect(self.database)
try:
connection.execute(
"INSERT INTO claim_requests VALUES(?,?,?,'PENDING',?,?)",
(
"63c9f507-7473-4fa6-8d71-8786c34c6301",
PROFILE,
self.store.recovery_snapshot(PROFILE).session.session_id,
"2026-08-04T09:00:00Z",
"2026-08-04T09:00:00Z",
),
)
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "claim_state_conflict"):
self.store.prepare_claim(PROFILE)
connection = sqlite3.connect(self.database)
try:
connection.execute("DROP TRIGGER claim_requests_no_delete")
connection.execute("DELETE FROM claim_requests WHERE status='PENDING'")
connection.execute("DROP TRIGGER active_claims_identity_immutable")
connection.execute("UPDATE active_claims SET task_id=?", ("83c9f507-7473-4fa6-8d71-8786c34c6301",))
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "active_claim_snapshot_mismatch"):
self.store.active_claim(PROFILE)
def test_succeeded_claim_tombstone_detects_missing_history(self) -> None:
self._claim_active()
connection = sqlite3.connect(self.database)
try:
connection.execute("DROP TRIGGER active_claims_no_delete")
connection.execute("DELETE FROM active_claims")
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "active_claim_request_mismatch"):
self.store.prepare_claim(PROFILE)
def test_closed_history_is_retained_but_does_not_block_next_claim(self) -> None:
self._claim_active()
connection = sqlite3.connect(self.database)
try:
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z'")
connection.commit()
finally:
connection.close()
self.assertIsNone(self.store.active_claim(PROFILE))
changed = replace(settings(), poll_interval_seconds=16)
self.store.save_profile(changed, None)
self.store.start_or_resume_polling(PROFILE)
request = self.store.prepare_claim(PROFILE)
self.assertIsNotNone(request.claim_request_id)
def test_pending_renew_and_evidence_revalidate_active_graph(self) -> None:
self._claim_active()
renew = self.store.prepare_renew(PROFILE)
connection = sqlite3.connect(self.database)
try:
connection.execute("DROP TRIGGER renew_requests_identity_immutable")
connection.execute("UPDATE renew_requests SET task_id=?", ("83c9f507-7473-4fa6-8d71-8786c34c6301",))
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "renew_active_mismatch"):
self.store.prepare_renew(PROFILE)
def test_success_receipt_must_match_immutable_slot(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "receipt.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
receipt = AssetReceipt(
"63c9f507-7473-4fa6-8d71-8786c34c6301",
TASK_ID,
ATTEMPT_ID,
upload.kind,
upload.privacy_tier,
upload.sha256,
len(upload.content),
"image/png",
1,
1,
upload.captured_at,
)
self.store.commit_evidence_success(PROFILE, upload, receipt)
connection = sqlite3.connect(self.database)
try:
wrong = dict(receipt.__dict__)
wrong["asset_id"] = "83c9f507-7473-4fa6-8d71-8786c34c6301"
with self.assertRaises(sqlite3.IntegrityError):
connection.execute("UPDATE evidence_slots SET receipt_json=?", (json.dumps(wrong),))
connection.rollback()
connection.execute("DROP TRIGGER evidence_slots_receipt_immutable")
connection.execute("UPDATE evidence_slots SET receipt_json=?", (json.dumps(wrong),))
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "evidence_receipt_mismatch"):
self.store.prepare_or_resume_evidence(PROFILE, asset)
def test_marker_and_slot_append_only_triggers_prevent_erasing_history(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "append-only.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
self.store.prepare_or_resume_evidence(PROFILE, asset)
connection = sqlite3.connect(self.database)
try:
with self.assertRaises(sqlite3.IntegrityError):
connection.execute("DELETE FROM evidence_slot_markers")
with self.assertRaises(sqlite3.IntegrityError):
connection.execute("UPDATE evidence_slot_markers SET upload_key=?", ("83c9f507-7473-4fa6-8d71-8786c34c6301",))
with self.assertRaises(sqlite3.IntegrityError):
connection.execute("DELETE FROM evidence_slots")
finally:
connection.close()
def test_capped_equal_renew_is_success_but_authorization_overrun_is_rejected(self) -> None:
self._claim_active()
request = self.store.prepare_renew(PROFILE)
equal = RenewResult(TASK_ID, ATTEMPT_ID, 1, request.expected_lease_expires_at)
self.store.commit_renew_success(PROFILE, request, equal)
self.assertEqual(self.store.active_claim(PROFILE).attempt.lease_expires_at, request.expected_lease_expires_at)
next_request = self.store.prepare_renew(PROFILE)
beyond = RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T10:00:00.000000001Z")
with self.assertRaisesRegex(StateError, "renew_response_mismatch"):
self.store.commit_renew_success(PROFILE, next_request, beyond)
def test_sqlite_database_wal_and_shm_never_contain_plaintext_tokens(self) -> None:
self._claim_active()
# 强制 checkpoint 后同时扫描主文件和可能存在的 WAL/SHM。
connection = sqlite3.connect(self.database)
connection.execute("PRAGMA wal_checkpoint(FULL)")
connection.close()
for path in (self.database, Path(str(self.database) + "-wal"), Path(str(self.database) + "-shm")):
if path.exists():
raw = path.read_bytes()
self.assertNotIn(DEVICE_TOKEN.encode(), raw)
self.assertNotIn(bytes.fromhex(DEVICE_TOKEN), raw)
self.assertNotIn(TOKEN.encode(), raw)
self.assertNotIn(bytes.fromhex(TOKEN), raw)
def test_concurrent_stop_and_claim_commit_never_loses_result(self) -> None:
self.store.start_or_resume_polling(PROFILE)
request = self.store.prepare_claim(PROFILE)
barrier = threading.Barrier(2)
failures: list[BaseException] = []
def stop() -> None:
try:
barrier.wait()
self.store.request_stop(PROFILE)
except BaseException as error:
failures.append(error)
thread = threading.Thread(target=stop)
thread.start()
barrier.wait()
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(claim_wire()))
thread.join()
self.assertEqual(failures, [])
snapshot = self.store.recovery_snapshot(PROFILE)
self.assertFalse(snapshot.session.accept_new)
self.assertIsNotNone(snapshot.active_claim)
def test_recovery_snapshot_uses_one_sqlite_read_snapshot(self) -> None:
self.store.start_or_resume_polling(PROFILE)
writer = self.new_store()
failures: list[BaseException] = []
class PausingStore(LocalStateStore):
armed = False
def _connect(inner_self):
connection = super(PausingStore, inner_self)._connect()
if inner_self.armed:
def trace(statement: str) -> None:
if inner_self.armed and "FROM claim_requests" in statement:
inner_self.armed = False
thread = threading.Thread(target=do_stop)
thread.start()
thread.join()
connection.set_trace_callback(trace)
return connection
def do_stop() -> None:
try:
writer.request_stop(PROFILE)
except BaseException as error:
failures.append(error)
reader = PausingStore(self.database, FakeProtector(), now=lambda: self.clock[0])
reader.armed = True
snapshot = reader.recovery_snapshot(PROFILE)
self.assertEqual(failures, [])
self.assertTrue(snapshot.session.accept_new)
self.assertFalse(writer.recovery_snapshot(PROFILE).session.accept_new)
def test_business_snapshot_is_immutable_hashed_and_renew_never_rewrites_it(self) -> None:
self._claim_active()
connection = sqlite3.connect(self.database)
try:
raw, digest, initial_lease, current_lease = connection.execute(
"""SELECT snapshot_json,snapshot_digest,initial_lease_expires_at,lease_expires_at
FROM active_claims WHERE closed_at IS NULL"""
).fetchone()
with self.assertRaises(sqlite3.IntegrityError):
connection.execute("UPDATE active_claims SET snapshot_json='{}' WHERE closed_at IS NULL")
connection.rollback()
connection.execute("DROP TRIGGER active_claims_identity_immutable")
for field, changed in (
("sku_color", "白色"),
("sku_size", "XL"),
("quantity", 99),
("max_total_price", "999.00"),
):
payload = json.loads(raw)
payload["task"][field] = changed
tampered = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True)
connection.execute("UPDATE active_claims SET snapshot_json=? WHERE closed_at IS NULL", (tampered,))
connection.commit()
with self.subTest(field=field), self.assertRaisesRegex(StateError, "active_claim_snapshot_mismatch"):
self.store.active_claim(PROFILE)
connection.execute(
"UPDATE active_claims SET snapshot_json=?,snapshot_digest=? WHERE closed_at IS NULL", (raw, digest)
)
connection.commit()
connection.execute(
"""UPDATE active_claims SET initial_lease_expires_at='2026-08-04T09:59:00Z',
lease_expires_at='2026-08-04T09:59:00Z' WHERE closed_at IS NULL"""
)
connection.commit()
with self.assertRaisesRegex(StateError, "invalid_stored_claim"):
self.store.active_claim(PROFILE)
connection.execute(
"""UPDATE active_claims SET initial_lease_expires_at=?,lease_expires_at=?
WHERE closed_at IS NULL""",
(initial_lease, current_lease),
)
connection.commit()
finally:
connection.close()
renew = self.store.prepare_renew(PROFILE)
self.store.commit_renew_success(PROFILE, renew, RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T09:06:00Z"))
connection = sqlite3.connect(self.database)
try:
self.assertEqual(
connection.execute("SELECT snapshot_json,snapshot_digest FROM active_claims WHERE closed_at IS NULL").fetchone(),
(raw, digest),
)
finally:
connection.close()
def test_open_session_detects_tampering_of_every_non_token_profile_setting(self) -> None:
self._claim_active()
changed_values = {
"service_url": "http://127.0.0.1:9999",
"device_id": "f3c9f507-7473-4fa6-8d71-8786c34c6301",
"adb_path": "D:/other/adb.exe",
"adb_serial": "usb-other",
"transport": "usb",
"poll_interval_seconds": 16,
"failure_threshold": 4,
"http_timeout_seconds": 11,
"step_timeout_seconds": 46,
}
connection = sqlite3.connect(self.database)
try:
for field, changed in changed_values.items():
original = connection.execute(f"SELECT {field} FROM profiles WHERE profile_id=?", (PROFILE,)).fetchone()[0]
connection.execute(f"UPDATE profiles SET {field}=? WHERE profile_id=?", (changed, PROFILE))
connection.commit()
with self.subTest(field=field), self.assertRaisesRegex(StateError, "polling_identity_mismatch"):
self.store.active_claim(PROFILE)
connection.execute(f"UPDATE profiles SET {field}=? WHERE profile_id=?", (original, PROFILE))
connection.commit()
finally:
connection.close()
def test_closed_attempt_evidence_is_history_not_current_recovery_work(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "old-manual.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
self.store.mark_evidence_terminal(PROFILE, upload, "MANUAL")
connection = sqlite3.connect(self.database)
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
connection.commit()
connection.close()
next_request = self.store.prepare_claim(PROFILE)
self.assertIsNotNone(next_request.claim_request_id)
self.assertEqual(self.store.recovery_snapshot(PROFILE).pending_evidence, ())
def test_succeeded_evidence_history_does_not_block_next_claim(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "old-success.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
receipt = AssetReceipt(
"63c9f507-7473-4fa6-8d71-8786c34c6301", TASK_ID, ATTEMPT_ID, upload.kind,
upload.privacy_tier, upload.sha256, len(upload.content), "image/png", 1, 1, upload.captured_at,
)
self.store.commit_evidence_success(PROFILE, upload, receipt)
connection = sqlite3.connect(self.database)
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
connection.commit()
connection.close()
self.assertIsNotNone(self.store.prepare_claim(PROFILE))
def test_evidence_slot_without_corresponding_claim_history_fails_closed(self) -> None:
self._claim_active()
image = Path(self.directory.name) / "orphan.png"
image.write_bytes(PNG)
asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z")
upload = self.store.prepare_or_resume_evidence(PROFILE, asset)
receipt = AssetReceipt(
"63c9f507-7473-4fa6-8d71-8786c34c6301", TASK_ID, ATTEMPT_ID, upload.kind,
upload.privacy_tier, upload.sha256, len(upload.content), "image/png", 1, 1, upload.captured_at,
)
self.store.commit_evidence_success(PROFILE, upload, receipt)
orphan = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
connection = sqlite3.connect(self.database)
try:
connection.execute("DROP TRIGGER evidence_slot_markers_immutable")
connection.execute("DROP TRIGGER evidence_slots_identity_immutable")
connection.execute("UPDATE evidence_slot_markers SET attempt_id=?", (orphan,))
connection.execute("UPDATE evidence_slots SET attempt_id=?", (orphan,))
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "evidence_claim_history_mismatch"):
self.store.recovery_snapshot(PROFILE)
def test_terminal_renew_is_scoped_to_old_closed_attempt(self) -> None:
self._claim_active()
old_renew = self.store.prepare_renew(PROFILE)
self.store.mark_renew_terminal(PROFILE, old_renew, "MANUAL")
connection = sqlite3.connect(self.database)
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
connection.commit()
connection.close()
request = self.store.prepare_claim(PROFILE)
wire = claim_wire()
wire["task"]["id"] = "83c9f507-7473-4fa6-8d71-8786c34c6301"
wire["authorization"]["id"] = "93c9f507-7473-4fa6-8d71-8786c34c6301"
wire["attempt"]["id"] = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(wire))
next_renew = self.store.prepare_renew(PROFILE)
self.assertNotEqual(next_renew.renew_request_id, old_renew.renew_request_id)
self.assertEqual(self.store.recovery_snapshot(PROFILE).pending_renew, next_renew)
def test_successful_renew_response_is_write_once_and_digest_checked(self) -> None:
self._claim_active()
request = self.store.prepare_renew(PROFILE)
self.store.commit_renew_success(PROFILE, request, RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T09:06:00Z"))
connection = sqlite3.connect(self.database)
try:
with self.assertRaises(sqlite3.IntegrityError):
connection.execute("UPDATE renew_requests SET response_json='{}' WHERE renew_request_id=?", (request.renew_request_id,))
connection.rollback()
connection.execute("DROP TRIGGER renew_requests_response_immutable")
connection.execute("UPDATE renew_requests SET response_json='{}' WHERE renew_request_id=?", (request.renew_request_id,))
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "renew_response_mismatch"):
self.store.active_claim(PROFILE)
def test_invalid_or_reversed_session_and_claim_timestamps_fail_closed(self) -> None:
self._claim_active()
connection = sqlite3.connect(self.database)
try:
connection.execute("UPDATE active_claims SET closed_at='2026-08-04T08:59:00Z' WHERE attempt_id=?", (ATTEMPT_ID,))
connection.commit()
finally:
connection.close()
with self.assertRaisesRegex(StateError, "invalid_claim_timeline"):
self.store.recovery_snapshot(PROFILE)
def test_invalid_session_closed_at_fails_closed(self) -> None:
self.store.start_or_resume_polling(PROFILE)
connection = sqlite3.connect(self.database)
try:
connection.execute("UPDATE polling_sessions SET closed_at='not-a-time' WHERE profile_id=?", (PROFILE,))
connection.commit()
finally:
connection.close()
with self.assertRaises(StateError):
self.store.recovery_snapshot(PROFILE)
def test_store_rejects_forged_claim_whose_lease_exceeds_authorization(self) -> None:
self.store.start_or_resume_polling(PROFILE)
request = self.store.prepare_claim(PROFILE)
claimed = ClaimedTask.from_wire(claim_wire())
object.__setattr__(claimed.attempt, "lease_expires_at", "2026-08-04T10:00:00.000000001Z")
with self.assertRaisesRegex(StateError, "claim_lease_exceeds_authorization"):
self.store.commit_claim_success(PROFILE, request, claimed)
self.assertEqual(self.store.recovery_snapshot(PROFILE).pending_claim, request)
def _claim_active(self) -> None:
self.store.start_or_resume_polling(PROFILE)
request = self.store.prepare_claim(PROFILE)
self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(claim_wire()))
@@ -1,4 +1,5 @@
<hierarchy>
<!-- 商品内容区同名促销小字:它不是规格入口,保留为防误点反例。 -->
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,1256][1080,1355]" clickable="true" enabled="true" visible-to-user="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.ViewSwitcher" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true">
@@ -8,7 +9,9 @@
</node>
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[446,2166][1080,2328]" clickable="true" enabled="true" visible-to-user="true">
<!-- 只允许点击底部购买区第一行文本叶节点中心;第二行只用于精确结构校验。 -->
<node content-desc="快要抢光¥12.88" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[446,2166][1080,2328]" clickable="true" enabled="true" visible-to-user="true">
<node text="快要抢光 ¥ 12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[688,2184][1042,2253]" clickable="false" enabled="true" visible-to-user="true" />
<node text="免拼购买" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[688,2256][856,2305]" clickable="false" enabled="true" visible-to-user="true" />
</node>
</hierarchy>
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1077]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,575][912,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="限1件 ¥12.88 " package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,580][675,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node text="券前¥29.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[693,580][912,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node text="请选择: 尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,731][1053,793]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1077][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,1188]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="颜色分类" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1106][192,1159]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1188][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1188][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1188][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][684,1500]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
<node content-desc="打开大图" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][483,1299]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1479][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1479][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,2052][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,2052][1044,2079]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,2069][114,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1077]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,575][912,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="限1件 ¥12.88 " package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,580][675,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node text="券前¥29.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[693,580][912,647]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node text="请选择: 颜色分类 尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,731][1053,793]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1077][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1077][1080,1188]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="颜色分类" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1106][192,1159]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1188][1080,2052]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1188][1080,2046]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1188][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1188][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][684,1500]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node content-desc="打开大图" package="com.xunmeng.pinduoduo" class="android.widget.ImageView" bounds="[372,1188][483,1299]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1479][684,1587]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1479][684,1587]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,2052][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,2052][1044,2079]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,2069][114,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1000]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,498][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="快卖完 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,503][712,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node text="¥29.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[730,503][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node text="已选: 黑色 CHA (纯棉) M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,654][1053,716]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1000][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1000][1080,1489]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1000][1080,1489]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1000][1080,1483]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1000][1080,1483]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1000][684,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1000][684,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1000][684,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1000][684,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1489][1080,1930]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1489][1044,1570]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1506][114,1552]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1582][1044,1897]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1582][1044,1897]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1582][409,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="S(建议80-100)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1582][409,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[439,1582][831,1667]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>
@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,0][1080,2376]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,0][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,120][1080,2328]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,120][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2328]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,366][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,366][1080,1000]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[396,498][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="快卖完 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,503][712,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
<node text="¥29.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[730,503][895,570]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node text="已选: 黑色 CHA (纯棉) S(建议80-100)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[396,654][1053,716]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[0,1000][1080,2079]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1000][1080,1489]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1000][1080,1489]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[0,1000][1080,1483]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1000][1080,1483]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="true">
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[372,1000][684,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[372,1000][684,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[372,1000][684,1024]" clickable="false" enabled="true" visible-to-user="true" selected="true" scrollable="false">
<node text="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[372,1000][684,1024]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[0,1489][1080,1930]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1489][1044,1570]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1506][114,1552]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1582][1044,1897]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1582][1044,1897]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1582][409,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="S(建议80-100)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[36,1582][409,1667]" clickable="true" enabled="true" visible-to-user="true" selected="true" scrollable="false" />
<node package="com.xunmeng.pinduoduo" class="android.view.View" bounds="[36,1582][409,1667]" clickable="false" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false">
<node text="M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[439,1582][831,1667]" clickable="true" enabled="true" visible-to-user="true" selected="false" scrollable="false" />
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</node>
</hierarchy>
+396
View File
@@ -0,0 +1,396 @@
from __future__ import annotations
import base64
from contextlib import redirect_stderr
from io import BytesIO
import importlib.util
import io
import json
from pathlib import Path
from tempfile import TemporaryDirectory
import unittest
from unittest.mock import patch
from xml.etree import ElementTree
from PIL import Image
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
from cmbuyer_client.pdd.sku_reveal_spike import (
SkuRevealSpikeCapturer,
SkuRevealSpikeError,
_require_safe_reveal_path,
)
from cmbuyer_client.pdd.sku_selection import SkuSelectionError, _parse_nodes
_FIXTURES = Path(__file__).with_name("fixtures")
_PRODUCT = (_FIXTURES / "product_entry_8_17_0.xml").read_text(encoding="utf-8")
_EMPTY = (_FIXTURES / "sku_panel_empty_8_17_0.xml").read_text(encoding="utf-8")
_COLOR_ONLY = (_FIXTURES / "sku_panel_color_selected_size_hidden_8_17_0.xml").read_text(encoding="utf-8")
_M_SELECTED = (_FIXTURES / "sku_panel_size_m_restored_8_17_0.xml").read_text(encoding="utf-8")
def _candidate_unselected() -> str:
root = ElementTree.fromstring(_M_SELECTED)
parents = {child: parent for parent in root.iter() for child in parent}
for node in root.iter("node"):
if node.get("text") == "已选: 黑色 CHA (纯棉) M(建议100-115)":
node.set("text", "请选择: 尺码")
if node.get("text") in {"S(建议80-100)", "M(建议100-115)"}:
node.set("selected", "false")
wrapper = parents[node]
for child in list(wrapper):
if child.get("class") == "android.view.View":
wrapper.remove(child)
action_root = next(
node
for node in root.iter("node")
if node.get("class") == "android.view.ViewGroup"
and node.get("clickable") == "true"
and node.get("bounds") == "[0,366][1080,2328]"
)
button_frame = ElementTree.SubElement(
action_root,
"node",
{
"class": "android.widget.FrameLayout",
"package": "com.xunmeng.pinduoduo",
"text": "",
"content-desc": "",
"clickable": "true",
"enabled": "true",
"visible-to-user": "true",
"selected": "false",
"scrollable": "false",
"bounds": "[0,2181][1080,2328]",
},
)
button_content = ElementTree.SubElement(
button_frame,
"node",
{
"class": "android.widget.LinearLayout",
"package": "com.xunmeng.pinduoduo",
"text": "",
"content-desc": "",
"clickable": "false",
"enabled": "true",
"visible-to-user": "true",
"selected": "false",
"scrollable": "false",
"bounds": "[273,2181][807,2328]",
},
)
ElementTree.SubElement(
button_content,
"node",
{
"class": "android.widget.TextView",
"package": "com.xunmeng.pinduoduo",
"text": "选择尺码后,提交订单",
"content-desc": "",
"clickable": "false",
"enabled": "true",
"visible-to-user": "true",
"selected": "false",
"scrollable": "false",
"bounds": "[285,2225][795,2284]",
},
)
return ElementTree.tostring(root, encoding="unicode")
def _with_clickable_overlay(package: str, class_name: str, bounds: str) -> str:
root = ElementTree.fromstring(_COLOR_ONLY)
container = next(root.iter("node"))
ElementTree.SubElement(
container,
"node",
{
"package": package,
"class": class_name,
"text": "",
"content-desc": "",
"clickable": "true",
"enabled": "true",
"visible-to-user": "true",
"selected": "false",
"scrollable": "false",
"bounds": bounds,
},
)
return ElementTree.tostring(root, encoding="unicode")
def _png() -> str:
image = Image.new("RGB", (1080, 2376), "white")
raw = BytesIO()
image.save(raw, format="PNG")
return base64.b64encode(raw.getvalue()).decode("ascii")
class _FakeDevice:
def __init__(self) -> None:
self.hierarchy = "<hierarchy />"
self.after_hierarchy = _candidate_unselected()
self.calls: list[tuple[object, ...]] = []
self.swipe_error = False
def app_info(self, package_name: str) -> dict[str, str]:
self.calls.append(("app_info", package_name))
return {"versionName": "8.17.0"}
def app_current(self) -> dict[str, str]:
self.calls.append(("app_current",))
return {"package": "com.xunmeng.pinduoduo"}
def window_size(self) -> tuple[int, int]:
self.calls.append(("window_size",))
return 1080, 2376
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
self.calls.append(("jsonrpc", method, params, timeout))
if method == "dumpWindowHierarchy":
return self.hierarchy
if method == "takeScreenshot":
return _png()
if method == "click":
if params == [865, 2218]:
self.hierarchy = _EMPTY
return ""
if params == [528, 1387]:
self.hierarchy = _COLOR_ONLY
return ""
raise AssertionError(params)
if method == "swipe":
self.hierarchy = self.after_hierarchy
if self.swipe_error:
raise TimeoutError("unknown outcome")
return ""
raise AssertionError(method)
class _FakeAdb:
def __init__(self, device: _FakeDevice) -> None:
self.device = device
self.calls: list[tuple[object, ...]] = []
def inspect(self, serial: str) -> DeviceInspection:
self.calls.append(("inspect", serial))
return DeviceInspection(AdbDevice(serial=serial, state="device"), "PKG110", "16")
def start_pdd_view_intent(self, serial: str, goods_id: str) -> object:
self.calls.append(("intent", serial, goods_id))
self.device.hierarchy = _PRODUCT
return object()
def _swipes(device: _FakeDevice) -> list[tuple[object, ...]]:
return [call for call in device.calls if call[:2] == ("jsonrpc", "swipe")]
class SkuRevealSpikeTests(unittest.TestCase):
def _capturer(self, device: _FakeDevice) -> SkuRevealSpikeCapturer:
now = [0.0]
return SkuRevealSpikeCapturer(
_FakeAdb(device),
lambda serial: device,
30,
monotonic_clock=lambda: now[0],
sleep_function=lambda seconds: now.__setitem__(0, now[0] + seconds),
)
def test_success_publishes_before_after_and_exactly_one_reveal(self) -> None:
device = _FakeDevice()
with TemporaryDirectory() as directory:
target = Path(directory) / "evidence"
result = self._capturer(device).capture("192.168.0.173:5555", "937122477375", target)
self.assertEqual(result.output_directory, target)
self.assertEqual(len(_swipes(device)), 1)
self.assertFalse(any(call[1] == "pressKey" for call in device.calls if call[0] == "jsonrpc"))
for relative in (
"before/screenshot.png",
"before/hierarchy.xml",
"after/screenshot.png",
"after/hierarchy.xml",
"manifest.json",
):
self.assertTrue((target / relative).is_file(), relative)
manifest_text = result.manifest_path.read_text(encoding="utf-8")
manifest = json.loads(manifest_text)
self.assertEqual(manifest["reveal_attempts"], 1)
self.assertEqual(manifest["rpc_outcome"], "completed")
self.assertNotIn("192.168.0.173:5555", manifest_text)
self.assertNotIn("gesture", manifest)
self.assertNotIn("coordinates", manifest)
self.assertNotIn("start", manifest)
self.assertNotIn("end", manifest)
def test_ambiguous_rpc_is_read_only_reconciled_without_retry(self) -> None:
device = _FakeDevice()
device.swipe_error = True
with TemporaryDirectory() as directory:
target = Path(directory) / "evidence"
result = self._capturer(device).capture("192.168.0.173:5555", "937122477375", target)
self.assertEqual(len(_swipes(device)), 1)
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
self.assertEqual(manifest["rpc_outcome"], "ambiguous_reconciled")
def test_selected_m_or_precondition_drift_never_publishes_or_retries(self) -> None:
for after in (
_M_SELECTED,
_candidate_unselected().replace("请选择: 尺码", "已选: 尺码"),
):
with self.subTest():
device = _FakeDevice()
device.after_hierarchy = after
with TemporaryDirectory() as directory:
target = Path(directory) / "evidence"
with self.assertRaises(SkuRevealSpikeError):
self._capturer(device).capture("192.168.0.173:5555", "937122477375", target)
self.assertEqual(len(_swipes(device)), 1)
self.assertFalse(target.exists())
self.assertEqual(list(Path(directory).glob(".*.staging-*")), [])
device = _FakeDevice()
device.hierarchy = "<hierarchy />"
invalid_color = _COLOR_ONLY.replace("请选择: 尺码", "请选择: 颜色分类 尺码")
original_call = device.jsonrpc_call
def drift(method: str, params: object = None, timeout: float = 10) -> str:
value = original_call(method, params, timeout)
if method == "click" and params == [528, 1387]:
device.hierarchy = invalid_color
return value
device.jsonrpc_call = drift # type: ignore[method-assign]
with TemporaryDirectory() as directory:
with self.assertRaises((SkuSelectionError, SkuRevealSpikeError)):
self._capturer(device).capture(
"192.168.0.173:5555",
"937122477375",
Path(directory) / "evidence",
)
self.assertEqual(_swipes(device), [])
def test_invalid_goods_and_existing_target_are_zero_action(self) -> None:
device = _FakeDevice()
with TemporaryDirectory() as directory:
target = Path(directory) / "existing"
target.mkdir()
with self.assertRaises(SkuRevealSpikeError):
self._capturer(device).capture("wifi", "1", Path(directory) / "new")
with self.assertRaises(SkuRevealSpikeError):
self._capturer(device).capture("wifi", "937122477375", target)
self.assertEqual(device.calls, [])
def test_before_screenshot_drift_is_rechecked_before_zero_swipe(self) -> None:
device = _FakeDevice()
original_call = device.jsonrpc_call
def drift(method: str, params: object = None, timeout: float = 10) -> str:
value = original_call(method, params, timeout)
if method == "takeScreenshot":
device.hierarchy = _COLOR_ONLY.replace("请选择: 尺码", "请选择: 颜色分类 尺码")
return value
device.jsonrpc_call = drift # type: ignore[method-assign]
with TemporaryDirectory() as directory:
target = Path(directory) / "evidence"
with self.assertRaises(SkuSelectionError):
self._capturer(device).capture(
"192.168.0.173:5555",
"937122477375",
target,
)
self.assertFalse(target.exists())
self.assertEqual(_swipes(device), [])
def test_complete_reveal_segment_rejects_narrow_impostor_and_invalid_bounds(self) -> None:
for hierarchy in (
_with_clickable_overlay(
"com.xunmeng.pinduoduo",
"android.view.ViewGroup",
"[350,1450][370,1460]",
),
_with_clickable_overlay(
"com.android.systemui",
"android.view.ViewGroup",
"[0,366][1080,2328]",
),
_with_clickable_overlay(
"com.xunmeng.pinduoduo",
"android.view.ViewGroup",
"not-a-bound",
),
):
with self.subTest(), self.assertRaises(SkuRevealSpikeError):
_require_safe_reveal_path(_parse_nodes(hierarchy))
class SkuRevealSpikeCliTests(unittest.TestCase):
def test_cli_has_no_gesture_or_task_specification_parameters(self) -> None:
script = _load_reveal_script()
arguments = script.parse_arguments(
[
"--serial", "device-1",
"--goods-id", "937122477375",
"--output-dir", "evidence",
]
)
self.assertEqual(
set(vars(arguments)),
{"serial", "goods_id", "output_dir", "timeout", "adb"},
)
script.validate_arguments(arguments)
for field, value in (
("serial", ""),
("goods_id", "1"),
("timeout", 0),
("timeout", float("inf")),
):
with self.subTest(field=field), self.assertRaises(ValueError):
script.validate_arguments(
type("Arguments", (), vars(arguments) | {field: value})()
)
def test_cli_failure_is_redacted(self) -> None:
script = _load_reveal_script()
secret = "SERIAL=192.168.0.173:5555 <hierarchy>private</hierarchy>"
class FailingCapturer:
def __init__(self, *args: object, **kwargs: object) -> None:
return None
def capture(self, *args: object, **kwargs: object) -> object:
raise SkuRevealSpikeError(secret)
stderr = io.StringIO()
with patch.object(script, "SkuRevealSpikeCapturer", FailingCapturer), redirect_stderr(stderr):
status = script.main(
[
"--serial", "192.168.0.173:5555",
"--goods-id", "937122477375",
"--output-dir", "evidence",
]
)
output = stderr.getvalue()
self.assertEqual(status, 1)
self.assertNotIn("Traceback", output)
self.assertNotIn("192.168.0.173:5555", output)
self.assertNotIn("private", output)
def _load_reveal_script() -> object:
path = Path(__file__).resolve().parents[2] / "scripts" / "capture_sku_reveal_spike.py"
specification = importlib.util.spec_from_file_location("capture_sku_reveal_spike_test", path)
if specification is None or specification.loader is None:
raise RuntimeError("无法加载 T-103 reveal 取证脚本。")
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)
return module
if __name__ == "__main__":
unittest.main()
+591 -94
View File
@@ -15,18 +15,30 @@ from PIL import Image
import cmbuyer_client.pdd as pdd
import cmbuyer_client.pdd.sku_selection_runner as runner_module
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
from cmbuyer_client.device.adb import AdbDevice, DeviceConnectionError, DeviceInspection
from cmbuyer_client.pdd import SkuSelectionError, SkuSelectionFlow, SkuSelectionRunner
from cmbuyer_client.pdd.sku_selection import SkuPanelDevice, _action_bounds, resolve_task_selection
from cmbuyer_client.pdd.sku_selection import (
SkuPanelDevice,
_action_bounds,
_classify_panel,
_parse_nodes,
resolve_task_selection,
)
from cmbuyer_client.pdd.sku_selection_runner import (
SkuSelectionDeviceAdapterError,
SkuSelectionRunError,
SkuSelectionScreenshotError,
UiautomatorSkuPanelAdapter,
safe_failure_stage,
)
_FIXTURE = Path(__file__).with_name("fixtures") / "sku_panel_8_17_0.xml"
_FIXTURES = Path(__file__).with_name("fixtures")
_EMPTY_FIXTURE = _FIXTURES / "sku_panel_empty_8_17_0.xml"
_COLOR_FIXTURE = _FIXTURES / "sku_panel_color_selected_size_hidden_8_17_0.xml"
_S_FIXTURE = _FIXTURES / "sku_panel_size_s_selected_8_17_0.xml"
_M_FIXTURE = _FIXTURES / "sku_panel_size_m_restored_8_17_0.xml"
_FIXTURE = _M_FIXTURE
_ENTRY_FIXTURE = Path(__file__).with_name("fixtures") / "product_entry_8_17_0.xml"
_TARGET_URL = "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"
_TASK_COLOR = "黑色CHA(纯棉)"
@@ -44,7 +56,7 @@ def _png_base64() -> str:
class _RawDevice:
def __init__(self, hierarchy: str = _PRODUCT_PAGE, screenshot: str | None = None) -> None:
self.hierarchy = hierarchy
self.panel_hierarchy = _FIXTURE.read_text(encoding="utf-8")
self.panel_hierarchy = _EMPTY_FIXTURE.read_text(encoding="utf-8")
self.version = "8.17.0"
self.package = "com.xunmeng.pinduoduo"
self.screenshot = _png_base64() if screenshot is None else screenshot
@@ -76,33 +88,31 @@ class _RawDevice:
raise AssertionError(method)
def _apply_tap(self, x: int, y: int) -> None:
if "快要抢光" in self.hierarchy and "[396,498][895,570]" not in self.hierarchy:
if (
(x, y) == (865, 2218)
and "快要抢光 ¥ 12.88" in self.hierarchy
and "[396,498][895,570]" not in self.hierarchy
):
self.hierarchy = self.panel_hierarchy
return
root = ElementTree.fromstring(self.hierarchy)
target = next(node for node in root.iter("node") if _center(node.get("bounds", "")) == (x, y))
color = target.get("bounds", "").endswith("][438,1172]")
for node in root.iter("node"):
if node.get("selected") is not None and ((color and ",1000]" in node.get("bounds", "")) or (not color and ",1730]" in node.get("bounds", ""))):
node.set("selected", "false")
if color and self.fail_color_readback:
next(node for node in root.iter("node") if node.get("content-desc") == "粉红").set("selected", "true")
else:
target.set("selected", "true")
self.hierarchy = ElementTree.tostring(root, encoding="unicode")
if (x, y) == (528, 1387):
self.hierarchy = (
_EMPTY_FIXTURE.read_text(encoding="utf-8")
if self.fail_color_readback
else _COLOR_FIXTURE.read_text(encoding="utf-8")
)
return
if (x, y) == (635, 1624):
self.hierarchy = _M_FIXTURE.read_text(encoding="utf-8")
return
raise AssertionError((x, y))
def window_size(self) -> tuple[int, int]:
self.calls.append(("window_size",))
return 1080, 2376
def select_alternates(self) -> None:
root = ElementTree.fromstring(self.panel_hierarchy)
for node in root.iter("node"):
if node.get("selected") is not None:
node.set("selected", "false")
next(node for node in root.iter("node") if node.get("content-desc") == "粉红").set("selected", "true")
next(node for node in root.iter("node") if node.get("text") == "L(建议115-130)").set("selected", "true")
self.panel_hierarchy = ElementTree.tostring(root, encoding="unicode")
self.panel_hierarchy = _S_FIXTURE.read_text(encoding="utf-8")
if self.hierarchy != _PRODUCT_PAGE:
self.hierarchy = self.panel_hierarchy
@@ -114,13 +124,67 @@ def _center(bounds: str) -> tuple[int, int]:
return left + (right - left) // 2, top + (bottom - top) // 2
def _flow(device: _RawDevice, timeout_seconds: float = 0.02) -> SkuSelectionFlow:
now = [0.0]
return SkuSelectionFlow(
UiautomatorSkuPanelAdapter(device, 10),
timeout_seconds,
0.01,
lambda: now[0],
lambda seconds: now.__setitem__(0, now[0] + seconds),
)
def _fast_runner_flow(
device: SkuPanelDevice,
entry_wait_timeout_seconds: float = 0.02,
) -> SkuSelectionFlow:
now = [0.0]
return SkuSelectionFlow(
device,
entry_wait_timeout_seconds,
0.01,
lambda: now[0],
lambda seconds: now.__setitem__(0, now[0] + seconds),
)
def _mutate_unique_node(
hierarchy: str,
*,
attribute: str,
value: str | None,
text: str | None = None,
desc: str | None = None,
class_name: str | None = None,
bounds: str | None = None,
) -> str:
root = ElementTree.fromstring(hierarchy)
matches = [
node
for node in root.iter("node")
if (text is None or node.get("text") == text)
and (desc is None or node.get("content-desc") == desc)
and (class_name is None or node.get("class") == class_name)
and (bounds is None or node.get("bounds") == bounds)
]
if len(matches) != 1:
raise AssertionError(f"fixture node match count: {len(matches)}")
if value is None:
matches[0].attrib.pop(attribute, None)
else:
matches[0].set(attribute, value)
return ElementTree.tostring(root, encoding="unicode")
def _entry_chain(root: ElementTree.Element) -> list[ElementTree.Element]:
parents = {child: parent for parent in root.iter() for child in parent}
child = next(node for node in root.iter("node") if node.get("text") == "快要抢光")
chain = [child]
for _ in range(4):
chain.append(parents[chain[-1]])
return chain
child = next(node for node in root.iter("node") if node.get("text") == "快要抢光 ¥ 12.88")
return [child, parents[child]]
def _entry_sibling(root: ElementTree.Element) -> ElementTree.Element:
return next(node for node in root.iter("node") if node.get("text") == "免拼购买")
def _mutate_entry(depth: int, attribute: str, value: str) -> str:
@@ -131,17 +195,115 @@ def _mutate_entry(depth: int, attribute: str, value: str) -> str:
def _without_entry() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
root.remove(_entry_chain(root)[4])
root.remove(_entry_chain(root)[1])
return ElementTree.tostring(root, encoding="unicode")
def _duplicate_entry() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
entry_root = _entry_chain(root)[4]
entry_root = _entry_chain(root)[1]
root.append(ElementTree.fromstring(ElementTree.tostring(entry_root, encoding="unicode")))
return ElementTree.tostring(root, encoding="unicode")
def _extra_entry_action_ancestor() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
action = _entry_chain(root)[1]
root.append(ElementTree.Element("node", dict(action.attrib)))
return ElementTree.tostring(root, encoding="unicode")
def _entry_with_panel_price_marker() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
root.append(ElementTree.Element("node", {"bounds": "[396,498][895,570]"}))
return ElementTree.tostring(root, encoding="unicode")
def _dynamic_product_page(value: str, action_desc: str = "") -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
root.set("dynamic-page-value", value)
_entry_chain(root)[1].set("content-desc", action_desc or "快要抢光¥12.88")
return ElementTree.tostring(root, encoding="unicode")
def _with_overlapping_clickable(package: str, class_name: str, bounds: str) -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
root.append(
ElementTree.Element(
"node",
{
"package": package,
"class": class_name,
"bounds": bounds,
"clickable": "true",
"enabled": "true",
"visible-to-user": "true",
},
)
)
return ElementTree.tostring(root, encoding="unicode")
def _with_action_subtree_child(
text: str,
*,
bounds: str = "[500,2170][600,2200]",
) -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
action = _entry_chain(root)[1]
ElementTree.SubElement(
action,
"node",
{
"text": text,
"package": "com.xunmeng.pinduoduo",
"class": "android.widget.TextView",
"bounds": bounds,
"clickable": "false",
"enabled": "true",
"visible-to-user": "true",
},
)
return ElementTree.tostring(root, encoding="unicode")
def _mutate_entry_sibling(attribute: str, value: str) -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
_entry_sibling(root).set(attribute, value)
return ElementTree.tostring(root, encoding="unicode")
def _without_entry_sibling() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
_entry_chain(root)[1].remove(_entry_sibling(root))
return ElementTree.tostring(root, encoding="unicode")
def _entry_sibling_elsewhere() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
root.append(ElementTree.fromstring(ElementTree.tostring(_entry_sibling(root), encoding="unicode")))
return ElementTree.tostring(root, encoding="unicode")
def _home_with_unverified_entry_labels() -> str:
root = ElementTree.Element("hierarchy")
for index in range(2):
ElementTree.SubElement(
root,
"node",
{
"text": "快要抢光",
"package": "com.xunmeng.pinduoduo",
"class": "android.widget.TextView",
"clickable": "false",
"enabled": "true",
"visible-to-user": "true",
"bounds": f"[{index},{index}][{index + 1},{index + 1}]",
},
)
return ElementTree.tostring(root, encoding="unicode")
def _actions(device: _RawDevice, method: str) -> list[tuple[object, ...]]:
return [call for call in device.calls if call[0] == "jsonrpc" and call[1] == method]
@@ -182,28 +344,104 @@ class SkuSelectionFlowTests(unittest.TestCase):
flow.open_sku_panel(_TARGET_URL)
self.assertEqual(_actions(device, "click"), [])
def test_target_mapping_is_exact_and_success_path_restores_target(self) -> None:
def test_target_mapping_stops_at_unproven_reveal_then_s_to_m_is_exact(self) -> None:
device = _RawDevice()
adapter = UiautomatorSkuPanelAdapter(device, 10)
flow = SkuSelectionFlow(adapter)
flow = _flow(device)
flow.open_sku_panel(_TARGET_URL)
with self.assertRaisesRegex(SkuSelectionError, "受控显示动作尚未取证"):
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(flow.read_sku_unit_price(), "12.88")
flow.exit_sku_panel_safely()
self.assertEqual(_tap_centers(device), [(978, 1333)])
self.assertEqual(_actions(device, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
self.assertEqual(_tap_centers(device), [(865, 2218), (528, 1387)])
self.assertEqual(_actions(device, "pressKey"), [])
restored = _RawDevice(_S_FIXTURE.read_text(encoding="utf-8"))
restored_flow = _flow(restored)
restored_flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(restored_flow.read_sku_unit_price(), "12.88")
restored_flow.exit_sku_panel_safely()
self.assertEqual(_tap_centers(restored), [(635, 1624)])
self.assertEqual(_actions(restored, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
def test_full_verified_entry_structure_taps_exact_text_child_once(self) -> None:
device = _RawDevice()
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
_flow(device).open_sku_panel(_TARGET_URL)
self.assertEqual(_tap_centers(device), [(978, 1333)])
self.assertEqual(_tap_centers(device), [(865, 2218)])
self.assertNotIn((763, 2247), _tap_centers(device)) # 父容器中心不是获准目标。
self.assertNotIn((772, 2280), _tap_centers(device)) # 第二行“免拼购买”永不点击。
def test_entry_stability_uses_verified_chain_projection_not_whole_xml(self) -> None:
class DynamicProductDevice(_RawDevice):
def __init__(self) -> None:
super().__init__()
self.frame = 0
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "dumpWindowHierarchy" and "快要抢光" in self.hierarchy:
self.frame += 1
self.hierarchy = _dynamic_product_page(str(self.frame))
return super().jsonrpc_call(method, params, timeout)
now = [0.0]
device = DynamicProductDevice()
flow = SkuSelectionFlow(
UiautomatorSkuPanelAdapter(device, 10),
0.03,
0.01,
lambda: now[0],
lambda seconds: now.__setitem__(0, now[0] + seconds),
)
flow.open_sku_panel(_TARGET_URL, "<hierarchy />")
self.assertEqual(device.frame, 2)
self.assertEqual(_tap_centers(device), [(865, 2218)])
def test_entry_action_description_must_be_stable_across_frames(self) -> None:
class ChangingActionDescriptionDevice(_RawDevice):
def __init__(self) -> None:
super().__init__()
self.frame = 0
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "dumpWindowHierarchy":
self.frame += 1
self.hierarchy = _dynamic_product_page(
str(self.frame),
f"活动{self.frame}快要抢光",
)
return super().jsonrpc_call(method, params, timeout)
now = [0.0]
device = ChangingActionDescriptionDevice()
flow = SkuSelectionFlow(
UiautomatorSkuPanelAdapter(device, 10),
0.02,
0.01,
lambda: now[0],
lambda seconds: now.__setitem__(0, now[0] + seconds),
)
with self.assertRaises(SkuSelectionError):
flow.open_sku_panel(_TARGET_URL, "<hierarchy />")
self.assertEqual(_actions(device, "click"), [])
def test_unverified_home_entry_labels_do_not_trigger_old_product_rejection(self) -> None:
device = _RawDevice()
_flow(device).open_sku_panel(
_TARGET_URL,
_home_with_unverified_entry_labels(),
)
self.assertEqual(_tap_centers(device), [(865, 2218)])
def test_entry_child_and_every_ancestor_attribute_drift_never_clicks(self) -> None:
expected_clickable = ("false", "false", "false", "false", "true")
for depth in range(5):
expected_clickable = ("false", "true")
for depth in range(2):
changes = {
"package": "other.package",
"class": "android.widget.Button",
@@ -216,11 +454,100 @@ class SkuSelectionFlowTests(unittest.TestCase):
with self.subTest(depth=depth, attribute=attribute):
self._assert_entry_rejected_without_click(_mutate_entry(depth, attribute, value))
def test_entry_chain_text_and_description_drift_never_clicks(self) -> None:
cases = [
_mutate_entry(1, "text", "祖先文字漂移")
] + [
_mutate_entry(0, "content-desc", "祖先描述漂移"),
_mutate_entry(1, "content-desc", "祖先描述漂移"),
] + [
_mutate_entry(1, "content-desc", forbidden + "快要抢光")
for forbidden in (
"免拼购买", "单独购买", "直接拼成", "提交订单", "支付", "先用后付", "0元下单",
"立即购买", "确认下单", "立即付款", "订单详情",
)
]
for hierarchy in cases:
with self.subTest():
self._assert_entry_rejected_without_click(hierarchy)
def test_any_live_clickable_covering_entry_center_blocks_click(self) -> None:
cases = (
_with_overlapping_clickable(
"com.xunmeng.pinduoduo",
"android.widget.Button",
"[850,2200][900,2230]",
),
_with_overlapping_clickable(
"com.android.systemui",
"android.view.ViewGroup",
"[800,2100][1000,2300]",
),
_with_overlapping_clickable(
"com.android.systemui",
"android.view.ViewGroup",
"not-a-bound",
),
)
for hierarchy in cases:
with self.subTest():
self._assert_entry_rejected_without_click(hierarchy)
def test_pre_intent_old_entry_is_rejected_even_with_extra_action_node(self) -> None:
for old_page in (
_extra_entry_action_ancestor(),
_entry_with_panel_price_marker(),
_mutate_entry(1, "content-desc", "立即购买快要抢光"),
):
with self.subTest():
device = _RawDevice()
flow = _flow(device)
with self.assertRaises(SkuSelectionError):
flow.open_sku_panel(_TARGET_URL, old_page)
self.assertEqual(_actions(device, "click"), [])
def test_action_subtree_dangerous_or_ambiguous_children_never_click(self) -> None:
cases = (
_with_action_subtree_child("提交订单"),
_with_action_subtree_child("免拼购买"),
_with_action_subtree_child("快要抢光"),
)
for hierarchy in cases:
with self.subTest():
self._assert_entry_rejected_without_click(hierarchy)
def test_duplicate_entry_and_forbidden_sibling_entry_never_click(self) -> None:
self._assert_entry_rejected_without_click(_duplicate_entry())
self._assert_entry_rejected_without_click(_extra_entry_action_ancestor())
self._assert_entry_rejected_without_click(_without_entry())
self._assert_entry_rejected_without_click(_mutate_entry(0, "clickable", "true"))
def test_header_promotion_label_alone_never_becomes_entry(self) -> None:
self._assert_entry_rejected_without_click(_without_entry())
def test_entry_leaf_and_parent_amount_identity_drift_never_clicks(self) -> None:
for hierarchy in (
_PRODUCT_PAGE.replace('text="快要抢光 ¥ 12.88"', 'text="快要抢光 ¥ 13.88"'),
_mutate_entry(1, "content-desc", "快要抢光¥13.88"),
):
with self.subTest():
self._assert_entry_rejected_without_click(hierarchy)
def test_entry_sibling_is_exact_inert_unique_and_in_same_parent(self) -> None:
cases = (
_without_entry_sibling(),
_mutate_entry_sibling("text", "免拼购买 "),
_mutate_entry_sibling("bounds", "[688,2255][856,2305]"),
_mutate_entry_sibling("clickable", "true"),
_with_action_subtree_child("免拼购买", bounds="[500,2260][650,2300]"),
_entry_sibling_elsewhere(),
)
for hierarchy in cases:
with self.subTest():
self._assert_entry_rejected_without_click(hierarchy)
def test_unknown_task_or_ui_variants_are_rejected_without_action(self) -> None:
for color, size in (("黑色 CHA (纯棉)", _TASK_SIZE), (_TASK_COLOR, "M(建议100-115)"), ("黑色CHA(纯棉)", _TASK_SIZE)):
with self.subTest(color=color, size=size), self.assertRaises(SkuSelectionError):
@@ -228,7 +555,7 @@ class SkuSelectionFlowTests(unittest.TestCase):
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8"))
with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).select_sku_options(
_flow(device).select_sku_options(
resolve_task_selection(_TASK_COLOR, _TASK_SIZE).__class__("粉红", "L(建议115-130)")
)
self.assertEqual(_actions(device, "click"), [])
@@ -236,44 +563,73 @@ class SkuSelectionFlowTests(unittest.TestCase):
def test_option_selected_and_container_drift_fail_closed_before_click(self) -> None:
base = _FIXTURE.read_text(encoding="utf-8")
cases = (
base.replace('selected="true" clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"', 'clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"'),
base.replace('selected="true" clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"', 'selected="maybe" clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"'),
base.replace('bounds="[126,1000][438,1172]"', 'bounds="[1,1][20,20]"'),
base.replace('enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"', 'enabled="false" visible-to-user="true" bounds="[126,1000][438,1172]"'),
_mutate_unique_node(base, desc="黑色 CHA (纯棉)", class_name="android.view.ViewGroup", attribute="selected", value=None),
_mutate_unique_node(base, desc="黑色 CHA (纯棉)", class_name="android.view.ViewGroup", attribute="selected", value="maybe"),
_mutate_unique_node(base, desc="黑色 CHA (纯棉)", class_name="android.view.ViewGroup", attribute="bounds", value="[1,1][20,20]"),
_mutate_unique_node(base, desc="黑色 CHA (纯棉)", class_name="android.view.ViewGroup", attribute="enabled", value="false"),
)
for hierarchy in cases:
with self.subTest(), self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(_RawDevice(hierarchy), 10)).select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
_flow(_RawDevice(hierarchy)).select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
def test_unknown_clickable_action_ancestor_is_rejected_by_profile(self) -> None:
root = ElementTree.fromstring(_EMPTY_FIXTURE.read_text(encoding="utf-8"))
parents = {child: parent for parent in root.iter() for child in parent}
target = next(
node
for node in root.iter("node")
if node.get("content-desc") == "黑色 CHA (纯棉)"
and node.get("class") == "android.view.ViewGroup"
)
region = parents[target]
region.remove(target)
unknown = ElementTree.SubElement(
region,
"node",
{
"package": "com.xunmeng.pinduoduo",
"class": "android.view.ViewGroup",
"bounds": "[0,1188][1080,2046]",
"clickable": "true",
"enabled": "true",
"visible-to-user": "true",
"selected": "false",
"scrollable": "false",
},
)
unknown.append(target)
with self.assertRaises(SkuSelectionError):
_classify_panel(_parse_nodes(ElementTree.tostring(root, encoding="unicode")))
def test_invalid_bounds_stop_before_action(self) -> None:
for bounds in ("", "[1,2][1,3]", "[1,2][3,2]", "[0,0][1081,1]", "[0,0][1,2377]", "[a,0][1,1]"):
with self.subTest(bounds=bounds), self.assertRaises(SkuSelectionError):
_action_bounds(bounds)
device = _RawDevice(_PRODUCT_PAGE.replace("[900,1312][1056,1355]", "[0,0][1081,1]"))
device = _RawDevice(_PRODUCT_PAGE.replace("[688,2184][1042,2253]", "[0,0][1081,1]"))
with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
_flow(device).open_sku_panel(_TARGET_URL)
self.assertEqual(_actions(device, "click"), [])
def test_color_readback_failure_never_attempts_second_option(self) -> None:
device = _RawDevice()
device.fail_color_readback = True
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10))
flow = _flow(device)
flow.open_sku_panel(_TARGET_URL)
device.select_alternates()
with self.assertRaises(SkuSelectionError):
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(_tap_centers(device), [(978, 1333), (282, 1086)])
self.assertEqual(_tap_centers(device), [(865, 2218), (528, 1387)])
def test_non_target_selection_restores_each_dimension_once(self) -> None:
device = _RawDevice()
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10))
flow = _flow(device)
flow.open_sku_panel(_TARGET_URL)
device.select_alternates()
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
self.assertEqual(
_tap_centers(device),
[(978, 1333), (282, 1086), (635, 1772)],
[(865, 2218), (635, 1624)],
)
def test_price_rejects_coupon_prefix_extra_amount_and_bottom_action(self) -> None:
@@ -281,16 +637,19 @@ class SkuSelectionFlowTests(unittest.TestCase):
with self.subTest(replacement=replacement):
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8").replace("快卖完 ¥12.88", replacement))
with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).read_sku_unit_price()
_flow(device).read_sku_unit_price()
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8").replace("快卖完 ¥12.88", "提交订单 ¥12.88"))
with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).read_sku_unit_price()
clickable_parent = _FIXTURE.read_text(encoding="utf-8").replace(
'<node package="" class="android.view.ViewGroup" bounds="[396,498][895,570]">',
'<node package="" class="android.view.ViewGroup" clickable="true" bounds="[396,498][895,570]">',
_flow(device).read_sku_unit_price()
clickable_parent = _mutate_unique_node(
_FIXTURE.read_text(encoding="utf-8"),
class_name="android.widget.LinearLayout",
bounds="[396,498][895,570]",
attribute="clickable",
value="true",
)
with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(_RawDevice(clickable_parent), 10)).read_sku_unit_price()
_flow(_RawDevice(clickable_parent)).read_sku_unit_price()
def test_public_api_and_protocol_have_no_broad_or_order_operations(self) -> None:
forbidden = {"quantity", "confirm", "authorization", "fence", "submit", "payment", "click"}
@@ -304,6 +663,8 @@ class SkuSelectionFlowTests(unittest.TestCase):
root / "src" / "cmbuyer_client" / "pdd" / "sku_selection.py",
root / "src" / "cmbuyer_client" / "pdd" / "sku_selection_runner.py",
root / "scripts" / "run_t103_sku_selection.py",
root / "src" / "cmbuyer_client" / "pdd" / "sku_reveal_spike.py",
root / "scripts" / "capture_sku_reveal_spike.py",
)
forbidden = ("quantity", "confirm", "authorization", "fence", "submit_order", "payment")
for path in files:
@@ -334,40 +695,70 @@ class SkuSelectionFlowTests(unittest.TestCase):
device = NoPanelAfterEntry()
with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
self.assertEqual(_tap_centers(device), [(978, 1333)])
_flow(device).open_sku_panel(_TARGET_URL)
self.assertEqual(_tap_centers(device), [(865, 2218)])
duplicate = _duplicate_entry()
device = _RawDevice(duplicate)
with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
_flow(device).open_sku_panel(_TARGET_URL)
self.assertEqual(_actions(device, "click"), [])
def test_fixture_contains_no_address_phone_or_payment_credentials(self) -> None:
for fixture in (_FIXTURE, _ENTRY_FIXTURE):
for fixture in (
_EMPTY_FIXTURE,
_COLOR_FIXTURE,
_S_FIXTURE,
_M_FIXTURE,
_ENTRY_FIXTURE,
):
content = fixture.read_text(encoding="utf-8")
with self.subTest(fixture=fixture.name):
self.assertNotRegex(content, r"1[3-9]\d{9}")
for forbidden in ("地址", "收货", "支付", "银行卡", "身份证"):
self.assertNotIn(forbidden, content)
content = _FIXTURE.read_text(encoding="utf-8")
root = ElementTree.fromstring(content)
leaf = next(node for node in root.iter("node") if node.get("text") == "提交订单 ¥12.88")
self.assertEqual(leaf.get("clickable"), "false")
self.assertEqual(leaf.get("bounds"), "[369,2225][710,2284]")
self.assertNotIn("提交订单", content)
class _CompletedFlow:
"""仅隔离 runner 文件发布测试;生产 Flow 在 reveal 取证前仍必须停止。"""
def __init__(self, device: object, *args: object, **kwargs: object) -> None:
self.device = device
def open_sku_panel(self, product_url: str, pre_intent_hierarchy: str | None = None) -> None:
return None
def select_sku_options(self, selection: object) -> None:
return None
def verify_target_selection_and_read_price(self, selection: object) -> str:
return "12.88"
def exit_sku_panel_safely(self) -> None:
return None
def reconcile_pending_action(self) -> None:
return None
class SkuSelectionRunnerTests(unittest.TestCase):
def setUp(self) -> None:
flow_patch = patch.object(runner_module, "SkuSelectionFlow", _fast_runner_flow)
flow_patch.start()
self.addCleanup(flow_patch.stop)
def _runner(self, adb: _FakeAdb, device: _RawDevice) -> SkuSelectionRunner:
device.hierarchy = "<hierarchy />"
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE.replace("<hierarchy>", '<hierarchy post-intent="1">'))
return SkuSelectionRunner(adb, lambda serial: device, 10)
return SkuSelectionRunner(adb, lambda serial: device, 0.03)
def test_runner_atomically_publishes_screenshot_and_redacted_manifest(self) -> None:
adb = _FakeAdb()
device = _RawDevice()
with TemporaryDirectory() as temporary:
target = Path(temporary) / "result"
with patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow):
result = self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertEqual(result.unit_price, "12.88")
@@ -381,7 +772,81 @@ class SkuSelectionRunnerTests(unittest.TestCase):
self.assertIn('"panel_status": "verified"', manifest)
self.assertIn('"safe_exit": "completed"', manifest)
self.assertFalse((target / "hierarchy.xml").exists())
self.assertEqual(_actions(device, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
self.assertEqual(_actions(device, "pressKey"), [])
def test_failure_stage_is_fixed_control_flow_metadata_without_error_text(self) -> None:
class NoPanelAfterEntry(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "click":
self.calls.append(("jsonrpc", method, params, timeout))
return ""
return super().jsonrpc_call(method, params, timeout)
adb = _FakeAdb()
device = NoPanelAfterEntry("<hierarchy />")
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE)
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError) as raised:
SkuSelectionRunner(adb, lambda serial: device, 0.03).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
)
self.assertEqual(safe_failure_stage(raised.exception), "sku_entry_panel_verify")
forged = SkuSelectionError("<hierarchy>private</hierarchy>")
setattr(forged, "_cmbuyer_failure_stage", "sku_entry_click")
self.assertEqual(safe_failure_stage(forged), "unknown")
class HostileSetterError(DeviceConnectionError):
def __setattr__(self, name: str, value: object) -> None:
raise KeyboardInterrupt("SERIAL=private <hierarchy>secret</hierarchy>")
class FailingAdb(_FakeAdb):
def inspect(self, serial: str) -> DeviceInspection:
raise HostileSetterError("private")
with TemporaryDirectory() as temporary, self.assertRaises(HostileSetterError) as hostile:
SkuSelectionRunner(FailingAdb(), lambda serial: _RawDevice(), 10).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
)
self.assertEqual(safe_failure_stage(hostile.exception), "unknown")
def test_failure_stage_identifies_sku_entry_pre_intent(self) -> None:
device = _RawDevice(_PRODUCT_PAGE)
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError) as raised:
SkuSelectionRunner(_FakeAdb(), lambda serial: device, 0.03).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
)
self.assertEqual(safe_failure_stage(raised.exception), "sku_entry_pre_intent")
self.assertEqual(_actions(device, "click"), [])
def test_failure_stage_identifies_sku_entry_discovery(self) -> None:
device = _RawDevice("<hierarchy />")
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError) as raised:
SkuSelectionRunner(_FakeAdb(), lambda serial: device, 0.01).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
)
self.assertEqual(safe_failure_stage(raised.exception), "sku_entry_discovery")
self.assertEqual(_actions(device, "click"), [])
def test_failure_stage_identifies_sku_entry_click(self) -> None:
class ClickFailureDevice(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "click":
self.calls.append(("jsonrpc", method, params, timeout))
raise TimeoutError("private device detail")
return super().jsonrpc_call(method, params, timeout)
adb = _FakeAdb()
device = ClickFailureDevice("<hierarchy />")
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE)
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionRunError) as raised:
SkuSelectionRunner(adb, lambda serial: device, 0.03).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
)
self.assertEqual(safe_failure_stage(raised.exception), "sku_entry_click")
self.assertEqual(len(_actions(device, "click")), 1)
def test_target_created_during_publish_is_preserved_without_staging_residue(self) -> None:
with TemporaryDirectory() as temporary:
@@ -393,7 +858,11 @@ class SkuSelectionRunnerTests(unittest.TestCase):
(Path(destination) / "sentinel").write_text("keep", encoding="utf-8")
original_rename(source, destination)
with patch.object(runner_module.os, "rename", side_effect=create_target_then_rename), self.assertRaises(SkuSelectionRunError):
with (
patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow),
patch.object(runner_module.os, "rename", side_effect=create_target_then_rename),
self.assertRaises(SkuSelectionRunError),
):
self._runner(_FakeAdb(), _RawDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertEqual((target / "sentinel").read_text(encoding="utf-8"), "keep")
self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
@@ -401,13 +870,20 @@ class SkuSelectionRunnerTests(unittest.TestCase):
def test_bad_screenshot_or_existing_target_never_publishes_manifest(self) -> None:
with TemporaryDirectory() as temporary:
target = Path(temporary) / "result"
with self.assertRaises(SkuSelectionScreenshotError):
with (
patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow),
self.assertRaises(SkuSelectionScreenshotError) as screenshot_failure,
):
self._runner(_FakeAdb(), _RawDevice(screenshot="not-image")).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertEqual(safe_failure_stage(screenshot_failure.exception), "screenshot_capture")
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
target = Path(temporary) / "write-failure"
with patch.object(runner_module, "_save_base64_screenshot", side_effect=OSError("private path")):
with (
patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow),
patch.object(runner_module, "_save_base64_screenshot", side_effect=OSError("private path")),
):
with self.assertRaises(SkuSelectionScreenshotError):
self._runner(_FakeAdb(), _RawDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertFalse(target.exists())
@@ -418,8 +894,9 @@ class SkuSelectionRunnerTests(unittest.TestCase):
target.mkdir()
sentinel = target / "keep"
sentinel.write_text("keep", encoding="utf-8")
with self.assertRaises(SkuSelectionRunError):
with self.assertRaises(SkuSelectionRunError) as existing_target_failure:
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertEqual(safe_failure_stage(existing_target_failure.exception), "precheck")
self.assertEqual(adb.calls, [])
self.assertEqual(device.calls, [])
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
@@ -454,7 +931,11 @@ class SkuSelectionRunnerTests(unittest.TestCase):
def test_small_but_valid_png_is_not_accepted(self) -> None:
image = Image.new("RGB", (1, 1), "white")
raw = BytesIO(); image.save(raw, format="PNG")
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionScreenshotError):
with (
TemporaryDirectory() as temporary,
patch.object(runner_module, "SkuSelectionFlow", _CompletedFlow),
self.assertRaises(SkuSelectionScreenshotError),
):
self._runner(_FakeAdb(), _RawDevice(screenshot=base64.b64encode(raw.getvalue()).decode("ascii"))).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
)
@@ -512,15 +993,19 @@ class SkuSelectionRunnerTests(unittest.TestCase):
adapter = UiautomatorSkuPanelAdapter(TimeoutTapDevice(), 10)
with self.assertRaises(SkuSelectionRunError):
adapter.tap_sku_entry("[900,1312][1056,1355]")
adapter.tap_sku_entry("[688,2184][1042,2253]")
self.assertTrue(adapter.entry_was_tapped)
self.assertEqual(_actions(adapter._device, "click"), [("jsonrpc", "click", [978, 1333], 10)])
self.assertEqual(_actions(adapter._device, "click"), [("jsonrpc", "click", [865, 2218], 10)])
def test_entry_stability_interruptions_never_click(self) -> None:
now = [0.0]
class SequenceDevice(_RawDevice):
def __init__(self) -> None:
super().__init__(); self.frames = [_PRODUCT_PAGE, "<hierarchy />", _PRODUCT_PAGE]
super().__init__(); self.frames = [
_dynamic_product_page("first"),
"<hierarchy />",
_dynamic_product_page("second"),
]
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "dumpWindowHierarchy" and self.frames:
self.hierarchy = self.frames.pop(0)
@@ -562,17 +1047,11 @@ class SkuSelectionRunnerTests(unittest.TestCase):
device = _RawDevice(); device.select_alternates()
device.package = "other"
with self.assertRaises(SkuSelectionError): SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).exit_sku_panel_safely()
with self.assertRaises(SkuSelectionError): _flow(device).exit_sku_panel_safely()
self.assertEqual(_actions(device, "pressKey"), [])
def test_screenshot_then_foreground_drift_publishes_nothing_and_never_back(self) -> None:
class ForegroundDriftDevice(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
value = super().jsonrpc_call(method, params, timeout)
if method == "takeScreenshot": self.package = "other"
return value
device = ForegroundDriftDevice()
def test_unproven_reveal_publishes_nothing_and_safely_exits_once(self) -> None:
device = _RawDevice()
with TemporaryDirectory() as temporary:
target = Path(temporary) / "out"
with self.assertRaises(SkuSelectionError):
@@ -580,7 +1059,7 @@ class SkuSelectionRunnerTests(unittest.TestCase):
self.assertFalse(target.exists())
self.assertFalse((target / "manifest.json").exists())
self.assertEqual(list(Path(temporary).glob(".out.staging-*")), [])
self.assertEqual(_actions(device, "pressKey"), [])
self.assertEqual(len(_actions(device, "pressKey")), 1)
def test_option_timeout_reconciliation_controls_back_once(self) -> None:
class OptionTimeoutDevice(_RawDevice):
@@ -597,7 +1076,7 @@ class SkuSelectionRunnerTests(unittest.TestCase):
for delivered, expected_back in ((False, 0), (True, 1)):
with self.subTest(delivered=delivered), TemporaryDirectory() as temporary:
device = OptionTimeoutDevice(delivered); device.select_alternates()
device = OptionTimeoutDevice(delivered)
adb = _FakeAdb(); device.hierarchy = "<hierarchy />"
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE.replace("<hierarchy>", '<hierarchy post-intent="1">'))
runner = SkuSelectionRunner(adb, lambda serial: device, .03)
@@ -614,9 +1093,9 @@ class SkuSelectionRunnerTests(unittest.TestCase):
raise TimeoutError("back uncertain")
return super().jsonrpc_call(method, params, timeout)
device = BackTimeoutDevice()
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionRunError):
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "out")
device = BackTimeoutDevice(_M_FIXTURE.read_text(encoding="utf-8"))
with self.assertRaises(SkuSelectionRunError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 0.03)).exit_sku_panel_safely()
self.assertEqual(len(_actions(device, "pressKey")), 1)
def test_entry_click_timeout_reconciles_only_through_verified_flow_exit(self) -> None:
@@ -654,10 +1133,26 @@ class SkuSelectionCliTests(unittest.TestCase):
def test_cli_main_catches_flow_error_without_traceback_or_page_body(self) -> None:
script = _load_runner_script()
secret = "SERIAL=192.168.0.173:5555 PATH=C:/private <hierarchy>page-body</hierarchy>"
class HostileGetterError(SkuSelectionError):
def __getattribute__(self, name: str) -> object:
if name == "_cmbuyer_failure_stage":
raise RuntimeError(secret)
return super().__getattribute__(name)
class HostileStage(str):
def __hash__(self) -> int:
raise RuntimeError(secret)
hostile_string = SkuSelectionError(secret)
setattr(hostile_string, "_cmbuyer_failure_stage", HostileStage("sku_entry"))
for failure in (SkuSelectionError(secret), HostileGetterError(secret), hostile_string):
class FlowFailingRunner:
def __init__(self, *args: object, **kwargs: object) -> None: pass
def run(self, *args: object, **kwargs: object) -> object:
raise SkuSelectionError("<hierarchy>page-body</hierarchy>")
raise failure
stderr = BytesIO()
# TextIOWrapper keeps the assertion independent from host console encoding.
@@ -671,7 +1166,9 @@ class SkuSelectionCliTests(unittest.TestCase):
text_stderr.flush()
output = stderr.getvalue().decode("utf-8")
self.assertEqual(status, 1)
self.assertIn("stage=unknown", output)
self.assertNotIn("Traceback", output)
self.assertNotIn(secret, output)
self.assertNotIn("page-body", output)
+1
View File
@@ -0,0 +1 @@
"""轮询协调器测试。"""
+380
View File
@@ -0,0 +1,380 @@
from __future__ import annotations
import os
import threading
import unittest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from cmbuyer_client.core.errors import (
AmbiguousRemoteError,
CredentialRemoteError,
ManualRemoteError,
ProtocolRemoteError,
StateError,
)
from cmbuyer_client.core.models import ClaimRequest, ClaimedTask
from cmbuyer_client.localstate.models import PollingSession, ProfileSettings, RecoverySnapshot
from cmbuyer_client.polling.coordinator import (
ClaimedTaskView,
PollingCoordinator,
PollingState,
StartReadiness,
)
from tests.core.test_models import claim_wire
PROFILE = "default"
SESSION_ID = "13c9f507-7473-4fa6-8d71-8786c34c6301"
REQUEST_ID = "23c9f507-7473-4fa6-8d71-8786c34c6301"
SENTINEL_TOKEN = "e" * 64
def profile(http_timeout: int = 10, failure_threshold: int = 3) -> ProfileSettings:
return ProfileSettings(
PROFILE,
"http://127.0.0.1:8080",
"f3c9f507-7473-4fa6-8d71-8786c34c6301",
"D:/Portable/adb/adb.exe",
"device-serial",
"usb",
failure_threshold=failure_threshold,
http_timeout_seconds=http_timeout,
)
def claimed_task() -> ClaimedTask:
wire = claim_wire()
wire["attempt"]["claim_token"] = SENTINEL_TOKEN
return ClaimedTask.from_wire(wire)
def snapshot(
*,
accept_new: bool | None = None,
pending: bool = False,
active: bool = False,
) -> RecoverySnapshot:
session = None if accept_new is None else PollingSession(PROFILE, SESSION_ID, accept_new)
request = ClaimRequest(SESSION_ID, REQUEST_ID) if pending else None
return RecoverySnapshot(session, request, claimed_task() if active else None, None, ())
class FakeStore:
def __init__(self, current: RecoverySnapshot) -> None:
self.current = current
self.recovery_calls = 0
self.start_calls = 0
self.stop_calls = 0
def recovery_snapshot(self, profile_id: str) -> RecoverySnapshot:
self.recovery_calls += 1
return self.current
def start_or_resume_polling(self, profile_id: str) -> PollingSession:
self.start_calls += 1
session = self.current.session or PollingSession(PROFILE, SESSION_ID, True)
session = PollingSession(PROFILE, session.session_id, True)
self.current = RecoverySnapshot(
session,
self.current.pending_claim,
self.current.active_claim,
self.current.pending_renew,
self.current.pending_evidence,
)
return session
def request_stop(self, profile_id: str) -> PollingSession:
self.stop_calls += 1
if self.current.session is None:
raise StateError("polling_session_not_found")
session = PollingSession(PROFILE, self.current.session.session_id, False)
self.current = RecoverySnapshot(
session,
self.current.pending_claim,
self.current.active_claim,
self.current.pending_renew,
self.current.pending_evidence,
)
return session
class FakeGateway:
def __init__(self, outcomes: list[object] | None = None, gate: threading.Event | None = None) -> None:
self.outcomes = list(outcomes or [None])
self.gate = gate
self.entered = threading.Event()
self.calls = 0
def claim_next(self, profile_id: str):
self.calls += 1
self.entered.set()
if self.gate is not None:
if not self.gate.wait(5):
raise RuntimeError("test_gateway_release_timeout")
outcome = self.outcomes.pop(0) if self.outcomes else None
if isinstance(outcome, Exception):
raise outcome
return outcome
class FakeConsumer:
def __init__(self) -> None:
self.claims: list[ClaimedTask] = []
self.profiles: list[ProfileSettings] = []
def accept_claim(self, claimed: ClaimedTask, profile: ProfileSettings) -> None:
self.claims.append(claimed)
self.profiles.append(profile)
def wait_until(predicate, timeout_ms: int = 2000) -> None:
elapsed = 0
while not predicate() and elapsed < timeout_ms:
# worker 仍通过真实 Qt 队列信号推进,因此测试必须持续处理事件;
# 1 ms 量子既会让出 QThread,又不会把每次状态转换放大成 10 ms 等待。
QTest.qWait(1)
elapsed += 1
if not predicate():
raise AssertionError("condition_not_reached")
class PollingCoordinatorTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.app = QApplication.instance() or QApplication([])
def make_coordinator(
self,
store: FakeStore,
gateway: FakeGateway | None,
consumer: FakeConsumer | None,
*,
readiness: StartReadiness | None = StartReadiness(True, "ready"),
settings: ProfileSettings | None = None,
threshold: int = 3,
interval_ms: int = 5,
) -> tuple[PollingCoordinator, list[ProfileSettings]]:
frozen: list[ProfileSettings] = []
def factory(value: ProfileSettings):
frozen.append(value)
assert gateway is not None
return gateway
coordinator = PollingCoordinator(
profile_id=PROFILE,
store=store,
gateway_factory=None if gateway is None else factory,
consumer=consumer,
profile_settings=settings or profile(failure_threshold=threshold),
readiness=readiness,
failure_threshold=threshold,
timer_interval_ms=interval_ms,
)
self.addCleanup(lambda: self.assertTrue(coordinator.shutdown()))
wait_until(lambda: not coordinator.operation_in_flight)
return coordinator, frozen
def test_restart_normalizes_waiting_pending_and_active_without_http(self) -> None:
for name, current, expected in (
("waiting", snapshot(accept_new=True), PollingState.STOPPED),
("pending", snapshot(accept_new=True, pending=True), PollingState.STOPPED),
("active", snapshot(accept_new=True, active=True), PollingState.RECOVERY_REQUIRED),
):
with self.subTest(name=name):
store = FakeStore(current)
gateway = FakeGateway()
consumer = FakeConsumer()
coordinator, frozen = self.make_coordinator(store, gateway, consumer)
self.assertEqual(coordinator.state, expected)
self.assertFalse(store.current.session.accept_new)
self.assertEqual(store.stop_calls, 1)
self.assertEqual(gateway.calls, 0)
self.assertEqual(frozen, [])
def test_missing_consumer_direct_start_is_zero_gateway_and_zero_session_start(self) -> None:
store = FakeStore(snapshot())
gateway = FakeGateway()
coordinator, frozen = self.make_coordinator(store, gateway, None)
coordinator.start()
self.assertEqual(coordinator.state, PollingState.BLOCKED)
self.assertIn("尚未接入", coordinator.reason)
self.assertEqual(store.start_calls, 0)
self.assertEqual(gateway.calls, 0)
self.assertEqual(frozen, [])
def test_readiness_is_rechecked_inside_start_handler(self) -> None:
store = FakeStore(snapshot())
gateway = FakeGateway()
coordinator, frozen = self.make_coordinator(
store,
gateway,
FakeConsumer(),
readiness=StartReadiness(False, "设备尚未就绪"),
)
coordinator.start()
self.assertEqual(coordinator.state, PollingState.BLOCKED)
self.assertEqual(store.start_calls, 0)
self.assertEqual(gateway.calls, 0)
self.assertEqual(frozen, [])
def test_empty_waits_then_stop_persists_accept_new_false(self) -> None:
store = FakeStore(snapshot())
gateway = FakeGateway([None])
coordinator, frozen = self.make_coordinator(store, gateway, FakeConsumer(), interval_ms=1000)
coordinator.start()
wait_until(lambda: coordinator.state == PollingState.WAITING and gateway.calls == 1)
self.assertEqual(frozen[0].http_timeout_seconds, 10)
coordinator.stop()
wait_until(lambda: coordinator.state == PollingState.STOPPED and not coordinator.operation_in_flight)
self.assertFalse(store.current.session.accept_new)
self.assertEqual(gateway.calls, 1)
def test_explicit_start_that_observes_stale_accept_new_stops_without_http(self) -> None:
store = FakeStore(snapshot())
gateway = FakeGateway([None])
coordinator, frozen = self.make_coordinator(store, gateway, FakeConsumer())
store.current = snapshot(accept_new=True)
coordinator.start()
wait_until(lambda: coordinator.state == PollingState.STOPPED and not coordinator.operation_in_flight)
self.assertFalse(store.current.session.accept_new)
self.assertEqual(gateway.calls, 0)
self.assertEqual(len(frozen), 1)
self.assertIn("再次显式开始", coordinator.reason)
def test_stop_during_claim_commits_then_requires_recovery_without_consumer_delivery(self) -> None:
gate = threading.Event()
store = FakeStore(snapshot())
gateway = FakeGateway([claimed_task()], gate)
consumer = FakeConsumer()
coordinator, _ = self.make_coordinator(store, gateway, consumer)
visible: list[object] = []
coordinator.claim_visible.connect(visible.append)
coordinator.start()
# CLAIMING 状态先于 queued worker 实际进入 claim_next;必须等待
# worker 入口,避免与 FakeGateway 的超时边界竞争而产生套件级偶发失败。
wait_until(lambda: coordinator.state == PollingState.CLAIMING and gateway.entered.is_set())
coordinator._begin_claim(coordinator._epoch)
self.assertEqual(gateway.calls, 1)
coordinator.stop()
old_epoch = coordinator._epoch - 1
coordinator._begin_claim(old_epoch)
gate.set()
wait_until(lambda: coordinator.state == PollingState.RECOVERY_REQUIRED and not coordinator.operation_in_flight)
self.assertEqual(consumer.claims, [])
self.assertEqual(gateway.calls, 1)
self.assertFalse(store.current.session.accept_new)
self.assertEqual(len(visible), 1)
self.assertIsInstance(visible[0], ClaimedTaskView)
self.assertNotIn(SENTINEL_TOKEN, repr(visible[0]))
def test_only_reason_whitelist_gets_automatic_same_gateway_retry(self) -> None:
store = FakeStore(snapshot(accept_new=False, pending=True))
gateway = FakeGateway(
[AmbiguousRemoteError("http_result_unknown"), AmbiguousRemoteError("truncated_response")]
)
coordinator, _ = self.make_coordinator(store, gateway, FakeConsumer(), threshold=2)
coordinator.start()
wait_until(lambda: coordinator.state == PollingState.BLOCKED and not coordinator.operation_in_flight)
self.assertEqual(gateway.calls, 2)
self.assertEqual(store.current.pending_claim.claim_request_id, REQUEST_ID)
self.assertFalse(store.current.session.accept_new)
def test_schema_ambiguity_blocks_after_one_call_and_preserves_pending(self) -> None:
for reason in (
"unknown_success_status",
"invalid_claim_success_response",
"invalid_claim_lease",
"ambiguous_response_framing",
"invalid_content_length",
"response_too_large",
):
with self.subTest(reason=reason):
store = FakeStore(snapshot(accept_new=False, pending=True))
gateway = FakeGateway([AmbiguousRemoteError(reason)])
coordinator, _ = self.make_coordinator(store, gateway, FakeConsumer())
coordinator.start()
wait_until(lambda: coordinator.state == PollingState.BLOCKED and not coordinator.operation_in_flight)
self.assertEqual(gateway.calls, 1)
self.assertEqual(store.current.pending_claim.claim_request_id, REQUEST_ID)
def test_credential_manual_and_local_failures_do_not_enter_timer_retry(self) -> None:
for error, expected_frozen in (
(CredentialRemoteError("invalid_device_credentials"), True),
(ManualRemoteError("claim_requires_manual"), False),
(ProtocolRemoteError("invalid_protocol"), False),
(StateError("localstate_integrity_failed"), True),
):
with self.subTest(error=error.reason):
store = FakeStore(snapshot(accept_new=False, pending=True))
gateway = FakeGateway([error])
coordinator, _ = self.make_coordinator(store, gateway, FakeConsumer())
freeze_events: list[bool] = []
coordinator.configuration_freeze_changed.connect(freeze_events.append)
coordinator.start()
wait_until(lambda: coordinator.state == PollingState.BLOCKED and not coordinator.operation_in_flight)
self.assertEqual(gateway.calls, 1)
self.assertFalse(coordinator._timer.isActive())
self.assertIsNone(coordinator._scheduled_epoch)
self.assertFalse(store.current.session.accept_new)
self.assertEqual(freeze_events[-1], expected_frozen)
def test_stop_latched_bootstrap_active_emits_settled_for_pending_close(self) -> None:
store = FakeStore(snapshot())
gateway = FakeGateway()
coordinator, _ = self.make_coordinator(store, gateway, FakeConsumer())
store.current = snapshot(accept_new=False, active=True)
settled: list[bool] = []
coordinator.settled.connect(lambda: settled.append(True))
coordinator.start()
coordinator.stop()
wait_until(lambda: coordinator.state == PollingState.RECOVERY_REQUIRED)
self.assertTrue(settled)
self.assertEqual(gateway.calls, 0)
def test_each_explicit_start_freezes_profile_for_gateway_factory(self) -> None:
store = FakeStore(snapshot())
gateway = FakeGateway([None])
coordinator, frozen = self.make_coordinator(store, gateway, FakeConsumer(), interval_ms=1000)
changed = profile(http_timeout=27)
coordinator.update_profile_settings(changed)
coordinator.start()
wait_until(lambda: gateway.calls == 1)
self.assertEqual(frozen, [changed])
coordinator.stop()
wait_until(lambda: not coordinator.operation_in_flight)
def test_consumer_receives_start_snapshot_even_if_profile_changes_while_claiming(self) -> None:
gate = threading.Event()
store = FakeStore(snapshot())
gateway = FakeGateway([claimed_task()], gate)
consumer = FakeConsumer()
original = profile(http_timeout=10)
coordinator, frozen = self.make_coordinator(
store,
gateway,
consumer,
settings=original,
)
coordinator.start()
wait_until(lambda: coordinator.state == PollingState.CLAIMING and gateway.calls == 1)
changed = profile(http_timeout=27)
coordinator.update_profile_settings(changed)
gate.set()
wait_until(lambda: coordinator.state == PollingState.ACTIVE)
self.assertEqual(frozen, [original])
self.assertEqual(consumer.profiles, [original])
self.assertIsNot(consumer.profiles[0], changed)
def test_claim_view_redacts_sentinel_even_if_title_contains_it(self) -> None:
wire = claim_wire()
wire["task"]["title"] = "标题 " + SENTINEL_TOKEN
wire["attempt"]["claim_token"] = SENTINEL_TOKEN
claimed = ClaimedTask.from_wire(wire)
view = ClaimedTaskView.from_claim(claimed)
self.assertNotIn(SENTINEL_TOKEN, repr(view))
self.assertIn("已隐藏", view.title)
+1
View File
@@ -0,0 +1 @@
"""remote tests。"""
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
import hashlib
import json
import base64
import re
import unittest
from cmbuyer_client.core.models import DeviceCredentials, EvidenceUpload, SecretToken
from cmbuyer_client.core.errors import AmbiguousRemoteError, ValidationError
from cmbuyer_client.remote.evidence_sink import HttpEvidenceSink
from cmbuyer_client.remote.http_transport import HttpResponse
from tests.core.test_models import ATTEMPT_ID, TASK_ID, TOKEN
from tests.remote.test_task_source import DEVICE_ID, FakeTransport
UPLOAD_ID = "43c9f507-7473-4fa6-8d71-8786c34c6301"
ASSET_ID = "63c9f507-7473-4fa6-8d71-8786c34c6301"
PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
)
class EvidenceSinkTests(unittest.TestCase):
def test_upload_has_fixed_fields_and_never_contains_local_path(self) -> None:
digest = hashlib.sha256(PNG).hexdigest()
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
payload = {
"asset_id": ASSET_ID,
"task_id": TASK_ID,
"attempt_id": ATTEMPT_ID,
"kind": "SKU_PANEL_GATE_1",
"privacy_tier": "INTERNAL_RAW",
"sha256": digest,
"byte_size": len(PNG),
"content_type": "image/png",
"width_px": 1,
"height_px": 1,
"captured_at": "2026-08-04T09:01:00Z",
}
raw = json.dumps(payload, separators=(",", ":")).encode()
transport = FakeTransport(HttpResponse(201, (("Content-Type", "application/json"),), raw))
receipt = HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
self.assertEqual(receipt.asset_id, ASSET_ID)
self.assertEqual(len(transport.calls), 1)
body = transport.calls[0][3]
self.assertIn(b'filename="evidence.png"', body)
self.assertNotIn(b"C:\\", body)
self.assertNotIn(b"manifest", body)
self.assertNotIn(b".xml", body)
names = re.findall(br'Content-Disposition: form-data; name="([^"]+)"', body)
self.assertEqual(
names,
[b"upload_key", b"attempt_id", b"kind", b"privacy_tier", b"sha256", b"captured_at", b"file"],
)
self.assertEqual(body.count(b'filename="evidence.png"'), 1)
self.assertNotIn(b"claim_token", body)
self.assertNotIn(b"session_id", body)
boundary = dict(transport.calls[0][2])["Content-Type"].split("boundary=", 1)[1]
self.assertEqual(boundary, "cmbuyer-" + UPLOAD_ID.replace("-", ""))
def test_captured_at_equivalent_trailing_zeros_are_accepted(self) -> None:
digest = hashlib.sha256(PNG).hexdigest()
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00.120000Z", PNG)
payload = {
"asset_id": ASSET_ID,
"task_id": TASK_ID,
"attempt_id": ATTEMPT_ID,
"kind": "SKU_PANEL_GATE_1",
"privacy_tier": "INTERNAL_RAW",
"sha256": digest,
"byte_size": len(PNG),
"content_type": "image/png",
"width_px": 1,
"height_px": 1,
"captured_at": "2026-08-04T09:01:00.12Z",
}
raw = json.dumps(payload, separators=(",", ":")).encode()
for status in (200, 201):
with self.subTest(status=status):
transport = FakeTransport(HttpResponse(status, (("Content-Type", "application/json"),), raw))
receipt = HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
self.assertEqual(receipt.captured_at, "2026-08-04T09:01:00.12Z")
def test_wrong_content_hash_fails_before_http_object_can_be_built(self) -> None:
transport = FakeTransport(HttpResponse(500, (), b""))
with self.assertRaises(ValidationError):
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, "0" * 64, "2026-08-04T09:01:00Z", PNG)
HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
self.assertEqual(transport.calls, [])
def test_unknown_2xx_is_ambiguous(self) -> None:
digest = hashlib.sha256(PNG).hexdigest()
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
for status in (202, 204, 206):
with self.subTest(status=status):
transport = FakeTransport(HttpResponse(status, (), b""))
with self.assertRaises(AmbiguousRemoteError):
HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload)
self.assertEqual(len(transport.calls), 1)
def test_boundary_collision_and_receipt_mismatch_fail_closed(self) -> None:
marker = ("--cmbuyer-" + UPLOAD_ID.replace("-", "")).encode()
collision_content = PNG + marker
collision = EvidenceUpload(
TASK_ID,
UPLOAD_ID,
ATTEMPT_ID,
hashlib.sha256(collision_content).hexdigest(),
"2026-08-04T09:01:00Z",
collision_content,
)
transport = FakeTransport(HttpResponse(500, (), b""))
from cmbuyer_client.core.errors import ProtocolRemoteError
with self.assertRaises(ProtocolRemoteError):
HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), collision)
self.assertEqual(transport.calls, [])
digest = hashlib.sha256(PNG).hexdigest()
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
mismatch = {
"asset_id": ASSET_ID,
"task_id": TASK_ID,
"attempt_id": ATTEMPT_ID,
"kind": "SKU_PANEL_GATE_1",
"privacy_tier": "INTERNAL_RAW",
"sha256": "f" * 64,
"byte_size": len(PNG),
"content_type": "image/png",
"width_px": 1,
"height_px": 1,
"captured_at": "2026-08-04T09:01:00Z",
}
raw = json.dumps(mismatch, separators=(",", ":")).encode()
with self.assertRaises(AmbiguousRemoteError):
HttpEvidenceSink(FakeTransport(HttpResponse(201, (("Content-Type", "application/json"),), raw))).upload(
DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload
)
dimension_mismatch = dict(mismatch)
dimension_mismatch["sha256"] = digest
dimension_mismatch["width_px"] = 2
raw = json.dumps(dimension_mismatch, separators=(",", ":")).encode()
with self.assertRaises(AmbiguousRemoteError):
HttpEvidenceSink(FakeTransport(HttpResponse(201, (("Content-Type", "application/json"),), raw))).upload(
DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload
)
def test_evidence_error_status_matrix(self) -> None:
digest = hashlib.sha256(PNG).hexdigest()
upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG)
credentials = DeviceCredentials(DEVICE_ID, SecretToken(TOKEN))
from cmbuyer_client.core.errors import CredentialRemoteError, ManualRemoteError, ProtocolRemoteError
cases = (
(401, CredentialRemoteError),
(400, ProtocolRemoteError),
(403, ProtocolRemoteError),
(409, ManualRemoteError),
(413, ProtocolRemoteError),
(415, ProtocolRemoteError),
(500, AmbiguousRemoteError),
(503, AmbiguousRemoteError),
)
for status, expected in cases:
with self.subTest(status=status), self.assertRaises(expected):
HttpEvidenceSink(FakeTransport(HttpResponse(status, (), b""))).upload(credentials, upload)
+184
View File
@@ -0,0 +1,184 @@
from __future__ import annotations
import os
import http.client
import unittest
from unittest import mock
from cmbuyer_client.core.errors import AmbiguousRemoteError, ProtocolRemoteError
from cmbuyer_client.remote.http_transport import HttpTransport
class FakeResponse:
status = 200
def __init__(self, body: bytes = b"{}", headers: list[tuple[str, str]] | None = None) -> None:
self.body = body
self.headers = headers or [("Content-Type", "application/json")]
def getheader(self, name: str) -> str | None:
return str(len(self.body)) if name == "Content-Length" else None
def getheaders(self) -> list[tuple[str, str]]:
return self.headers
def read(self, maximum: int) -> bytes:
return self.body[:maximum]
class FakeConnection:
def __init__(self, host: str, port: int, timeout: int) -> None:
self.created = (host, port, timeout)
self.calls = 0
self.closed = False
self.sent_headers: dict[str, str] = {}
self.response = FakeResponse()
def request(self, method: str, path: str, body: bytes, headers: dict[str, str]) -> None:
self.calls += 1
self.sent_headers = headers
def getresponse(self) -> FakeResponse:
return self.response
def close(self) -> None:
self.closed = True
class HttpTransportTests(unittest.TestCase):
def test_exact_loopback_and_proxy_environment_is_irrelevant(self) -> None:
made: list[FakeConnection] = []
def factory(*args: object, **kwargs: object) -> FakeConnection:
connection = FakeConnection(*args, **kwargs)
made.append(connection)
return connection
with mock.patch.dict(os.environ, {"HTTP_PROXY": "http://example.invalid:9999"}):
result = HttpTransport(connection_factory=factory).request(
"POST", "/api/v1/tasks/claim-next", (("Content-Type", "application/json"),), b"{}", response_limit=10
)
self.assertEqual(result.status, 200)
self.assertEqual(made[0].created, ("127.0.0.1", 8080, 10))
self.assertEqual(made[0].calls, 1)
self.assertTrue(made[0].closed)
for url in ("http://localhost:8080", "http://127.0.0.1:8081", "http://127.0.0.1:8080/", "https://127.0.0.1:8080"):
with self.subTest(url=url), self.assertRaises(ProtocolRemoteError):
HttpTransport(url)
def test_network_failure_is_ambiguous_without_retry(self) -> None:
class Broken(FakeConnection):
def getresponse(self) -> FakeResponse:
raise OSError("offline")
made: list[Broken] = []
def factory(*args: object, **kwargs: object) -> Broken:
connection = Broken(*args, **kwargs)
made.append(connection)
return connection
with self.assertRaises(AmbiguousRemoteError):
HttpTransport(connection_factory=factory).request(
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=10
)
self.assertEqual(made[0].calls, 1)
def test_generator_headers_and_content_length_framing(self) -> None:
made: list[FakeConnection] = []
def factory(*args: object, **kwargs: object) -> FakeConnection:
connection = FakeConnection(*args, **kwargs)
made.append(connection)
return connection
headers = ((name, value) for name, value in (("Content-Type", "application/json"), ("Accept", "application/json")))
HttpTransport(connection_factory=factory).request(
"POST", "/api/v1/tasks/claim-next", headers, b"{}", response_limit=8
)
self.assertEqual(made[0].sent_headers["Content-Type"], "application/json")
self.assertEqual(made[0].sent_headers["Accept"], "application/json")
legal = FakeConnection("127.0.0.1", 8080, 10)
legal.response = FakeResponse(b"{}", [("Content-Length", "2")])
accepted = HttpTransport(connection_factory=lambda *args, **kwargs: legal).request(
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
)
self.assertEqual(accepted.body, b"{}")
chunked = FakeConnection("127.0.0.1", 8080, 10)
chunked.response = FakeResponse(b"{}", [("Transfer-Encoding", "Chunked")])
accepted_chunked = HttpTransport(connection_factory=lambda *args, **kwargs: chunked).request(
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
)
self.assertEqual(accepted_chunked.body, b"{}")
cases = (
([('Transfer-Encoding', 'chunked'), ('Content-Length', '2')], b'{}'),
([('Transfer-Encoding', 'gzip')], b'{}'),
([('Transfer-Encoding', ' chunked ')], b'{}'),
([('Transfer-Encoding', 'chunked,gzip')], b'{}'),
([('Transfer-Encoding', 'chunked'), ('Transfer-Encoding', 'chunked')], b'{}'),
([("Content-Length", "2"), ("Content-Length", "2")], b"{}"),
([("Content-Length", "+2")], b"{}"),
([("Content-Length", "-0")], b""),
([("Content-Length", "2x")], b"{}"),
([("Content-Length", "3")], b"{}"),
([("Content-Length", "1")], b"{}"),
([("Content-Length", "999")], b"{}"),
([], b"0123456789"),
)
for response_headers, body in cases:
with self.subTest(headers=response_headers, body=body):
connection = FakeConnection("127.0.0.1", 8080, 10)
connection.response = FakeResponse(body, response_headers)
with self.assertRaises(AmbiguousRemoteError):
HttpTransport(connection_factory=lambda *args, value=connection, **kwargs: value).request(
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
)
def test_timeout_incomplete_read_and_close_do_not_expose_partial_body(self) -> None:
token = ("a" * 64).encode()
class Incomplete(FakeResponse):
def read(self, maximum: int) -> bytes:
raise http.client.IncompleteRead(token, 1)
class Connection(FakeConnection):
def getresponse(self) -> FakeResponse:
return Incomplete()
def close(self) -> None:
self.closed = True
raise OSError("close failed")
with self.assertRaises(AmbiguousRemoteError) as captured:
HttpTransport(connection_factory=Connection).request(
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=128
)
self.assertNotIn(token.decode(), _exception_graph(captured.exception))
class Timeout(FakeConnection):
def getresponse(self) -> FakeResponse:
raise TimeoutError("timed out")
with self.assertRaises(AmbiguousRemoteError):
HttpTransport(connection_factory=Timeout).request(
"POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8
)
def _exception_graph(error: BaseException) -> str:
seen: set[int] = set()
values: list[str] = []
pending: list[object] = [error]
while pending:
value = pending.pop()
if id(value) in seen:
continue
seen.add(id(value))
values.append(repr(value))
if isinstance(value, BaseException):
pending.extend(item for item in (value.__cause__, value.__context__) if item is not None)
pending.extend(value.__dict__.values())
return "\n".join(values)
+162
View File
@@ -0,0 +1,162 @@
from __future__ import annotations
import json
import unittest
from cmbuyer_client.core.errors import AmbiguousRemoteError, CredentialRemoteError, ManualRemoteError, ProtocolRemoteError
from cmbuyer_client.core.models import ClaimRequest, DeviceCredentials, RenewRequest, SecretToken
from cmbuyer_client.remote.http_transport import HttpResponse
from cmbuyer_client.remote.task_source import HttpTaskSource
from tests.core.test_models import ATTEMPT_ID, TASK_ID, TOKEN, claim_wire
from tests.remote.test_http_transport import _exception_graph
DEVICE_ID = "e3c9f507-7473-4fa6-8d71-8786c34c6301"
SESSION_ID = "23c9f507-7473-4fa6-8d71-8786c34c6301"
REQUEST_ID = "33c9f507-7473-4fa6-8d71-8786c34c6301"
RENEW_ID = "43c9f507-7473-4fa6-8d71-8786c34c6301"
class FakeTransport:
def __init__(self, response: HttpResponse) -> None:
self.response = response
self.calls: list[tuple[object, ...]] = []
def request(self, *args: object, **kwargs: object) -> HttpResponse:
self.calls.append(args + (kwargs,))
return self.response
def response(status: int, value: object | None = None) -> HttpResponse:
body = b"" if value is None else json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()
headers = () if value is None else (("Content-Type", "application/json; charset=utf-8"),)
return HttpResponse(status, headers, body)
class TaskSourceTests(unittest.TestCase):
def setUp(self) -> None:
self.credentials = DeviceCredentials(DEVICE_ID, SecretToken(TOKEN))
def test_claim_success_and_empty_each_send_once_with_exact_headers(self) -> None:
transport = FakeTransport(response(200, claim_wire()))
claimed = HttpTaskSource(transport).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
self.assertEqual(claimed.task.id, TASK_ID)
self.assertEqual(len(transport.calls), 1)
args = transport.calls[0]
self.assertEqual(args[1], "/api/v1/tasks/claim-next")
headers = dict(args[2])
self.assertEqual(headers["Authorization"], "Bearer " + TOKEN)
self.assertEqual(headers["X-CMBuyer-Device-ID"], DEVICE_ID)
empty = FakeTransport(response(204))
self.assertIsNone(HttpTaskSource(empty).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID)))
self.assertEqual(len(empty.calls), 1)
def test_invalid_2xx_is_unknown_and_redirect_is_not_followed(self) -> None:
malformed = FakeTransport(HttpResponse(200, (("Content-Type", "application/json"),), b'{"task":'))
with self.assertRaises(AmbiguousRemoteError):
HttpTaskSource(malformed).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
self.assertEqual(len(malformed.calls), 1)
secret_body = b'{"claim_token":"' + TOKEN.encode()
leaking = FakeTransport(HttpResponse(200, (("Content-Type", "application/json"),), secret_body))
with self.assertRaises(AmbiguousRemoteError) as captured:
HttpTaskSource(leaking).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
self.assertNotIn(TOKEN, _exception_graph(captured.exception))
for status in (201, 202, 206):
with self.subTest(status=status), self.assertRaises(AmbiguousRemoteError):
HttpTaskSource(FakeTransport(response(status, claim_wire()))).claim_next(
self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID)
)
redirect = FakeTransport(HttpResponse(302, (("Location", "http://example.invalid"),), b""))
with self.assertRaises(ProtocolRemoteError):
HttpTaskSource(redirect).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
self.assertEqual(len(redirect.calls), 1)
def test_claim_rejects_service_field_bound_drift_as_ambiguous(self) -> None:
mutations = (
("title", "😀" * 121),
("sku_color", "色" * 81),
("sku_size", "码" * 81),
("max_total_price", "1" * 30 + ".00"),
)
for field, invalid in mutations:
value = claim_wire()
value["task"][field] = invalid
transport = FakeTransport(response(200, value))
with self.subTest(field=field), self.assertRaises(AmbiguousRemoteError):
HttpTaskSource(transport).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
self.assertEqual(len(transport.calls), 1)
goods_id = "1" * 33
value = claim_wire()
value["task"].update(
goods_id=goods_id,
product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + goods_id,
)
with self.assertRaises(AmbiguousRemoteError):
HttpTaskSource(FakeTransport(response(200, value))).claim_next(
self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID)
)
def test_fixed_conflict_and_renew_cas(self) -> None:
conflict = FakeTransport(response(409, {"error": "claim_requires_manual"}))
with self.assertRaises(ManualRemoteError):
HttpTaskSource(conflict).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))
request = RenewRequest(TASK_ID, RENEW_ID, SESSION_ID, ATTEMPT_ID, 1, SecretToken(TOKEN), "2026-08-04T09:05:00Z", "2026-08-04T10:00:00Z")
renewed = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T09:06:00Z"})
result = HttpTaskSource(FakeTransport(renewed)).renew(self.credentials, request)
self.assertEqual(result.claim_generation, 1)
capped = RenewRequest(TASK_ID, RENEW_ID, SESSION_ID, ATTEMPT_ID, 1, SecretToken(TOKEN), "2026-08-04T10:00:00.000000000Z", "2026-08-04T10:00:00Z")
capped_result = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T10:00:00Z"})
self.assertEqual(HttpTaskSource(FakeTransport(capped_result)).renew(self.credentials, capped).lease_expires_at, "2026-08-04T10:00:00Z")
beyond_cap = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T10:00:00.000000001Z"})
with self.assertRaises(AmbiguousRemoteError):
HttpTaskSource(FakeTransport(beyond_cap)).renew(self.credentials, request)
stale = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T09:04:00Z"})
with self.assertRaises(AmbiguousRemoteError):
HttpTaskSource(FakeTransport(stale)).renew(self.credentials, request)
for status in (201, 204):
with self.subTest(status=status), self.assertRaises(AmbiguousRemoteError):
HttpTaskSource(FakeTransport(response(status, None if status == 204 else {
"task_id": TASK_ID,
"attempt_id": ATTEMPT_ID,
"claim_generation": 1,
"lease_expires_at": "2026-08-04T09:06:00Z",
}))).renew(self.credentials, request)
def test_claim_and_renew_error_status_matrix(self) -> None:
claim_request = ClaimRequest(SESSION_ID, REQUEST_ID)
claim_cases = (
(HttpResponse(401, (), b""), CredentialRemoteError),
(response(400, {"error": "invalid_request"}), ProtocolRemoteError),
(HttpResponse(403, (), b""), ProtocolRemoteError),
(response(413, {"error": "request_too_large"}), ProtocolRemoteError),
(response(415, {"error": "unsupported_media_type"}), ProtocolRemoteError),
(HttpResponse(500, (), b""), AmbiguousRemoteError),
(HttpResponse(503, (), b""), AmbiguousRemoteError),
(HttpResponse(418, (), b""), ProtocolRemoteError),
)
for wire_response, expected in claim_cases:
with self.subTest(status=wire_response.status), self.assertRaises(expected):
HttpTaskSource(FakeTransport(wire_response)).claim_next(self.credentials, claim_request)
renew_request = RenewRequest(
TASK_ID,
RENEW_ID,
SESSION_ID,
ATTEMPT_ID,
1,
SecretToken(TOKEN),
"2026-08-04T09:05:00Z",
"2026-08-04T10:00:00Z",
)
for code in ("idempotency_conflict", "claim_not_current"):
with self.subTest(code=code), self.assertRaises(ManualRemoteError):
HttpTaskSource(FakeTransport(response(409, {"error": code}))).renew(self.credentials, renew_request)
+33
View File
@@ -22,3 +22,36 @@ class ApplicationArgumentsTests(unittest.TestCase):
def test_none_uses_process_arguments(self) -> None:
with mock.patch("cmbuyer_client.app.sys.argv", ["process-name", "--process-option"]):
self.assertEqual(["process-name", "--process-option"], select_application_argv(None))
def test_standalone_entry_does_not_construct_http_or_device_capabilities(self) -> None:
source = (CLIENT_ROOT / "src" / "cmbuyer_client" / "app.py").read_text(encoding="utf-8")
for forbidden in (
"HttpTaskSource",
"HttpTransport",
"DurableClientGateway",
"import uiautomator2",
".device",
".pdd",
):
with self.subTest(forbidden=forbidden):
self.assertNotIn(forbidden, source)
self.assertIn("gateway_factory=None", source)
self.assertIn("consumer=None", source)
def test_t304_ui_has_no_hidden_connection_probe_or_irreversible_capability_import(self) -> None:
paths = [
*(CLIENT_ROOT / "src" / "cmbuyer_client" / "ui").glob("*.py"),
*(CLIENT_ROOT / "src" / "cmbuyer_client" / "polling").glob("*.py"),
]
source = "\n".join(path.read_text(encoding="utf-8") for path in paths)
for forbidden in (
"uiautomator2",
"cmbuyer_client.pdd",
"cmbuyer_client.device",
"submit_order",
"payment",
"http.client",
"subprocess",
):
with self.subTest(forbidden=forbidden):
self.assertNotIn(forbidden, source)
+21
View File
@@ -47,3 +47,24 @@ class LoggingPolicyTests(unittest.TestCase):
self.assertNotIn("not-for-log", content)
self.assertNotIn("13900139000", content)
self.assertIn("[已隐藏]", content)
def test_bearer_bare_token_and_traceback_are_redacted(self) -> None:
token = "a" * 64
with tempfile.TemporaryDirectory() as directory:
paths = RuntimePaths.from_root(Path(directory))
logger = configure_application_logger(paths)
try:
try:
raise RuntimeError("credential=" + token)
except RuntimeError:
logger.exception("Authorization: Bearer %s bare=%s", token, token)
for handler in logger.handlers:
handler.flush()
content = (paths.logs / "client.log").read_text(encoding="utf-8")
finally:
for handler in tuple(logger.handlers):
logger.removeHandler(handler)
handler.close()
self.assertNotIn(token, content)
self.assertIn("[已隐藏]", content)
+58 -1
View File
@@ -2,16 +2,18 @@
from __future__ import annotations
import os
import sys
from pathlib import Path
import tempfile
import unittest
from unittest import mock
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.runtime import RuntimePaths
from cmbuyer_client.runtime import LocalStateRuntime, RuntimePaths
class RuntimePathsTests(unittest.TestCase):
@@ -23,3 +25,58 @@ class RuntimePathsTests(unittest.TestCase):
self.assertTrue(paths.logs.is_dir())
self.assertTrue(paths.artifacts.is_dir())
self.assertTrue(paths.state.is_dir())
self.assertEqual(paths.database, paths.state / "client-state.sqlite3")
def test_localstate_runtime_acquires_mutex_before_protector_and_store(self) -> None:
events: list[str] = []
class Mutex:
def __init__(self, path: Path) -> None:
events.append("mutex")
def close(self) -> None:
events.append("close")
with tempfile.TemporaryDirectory() as directory:
runtime = LocalStateRuntime.open(
RuntimePaths.from_root(Path(directory)),
mutex_factory=Mutex,
protector_factory=lambda: events.append("protector") or object(),
store_factory=lambda path, protector: events.append("store") or object(),
)
runtime.close()
self.assertEqual(events, ["mutex", "protector", "store", "close"])
def test_localstate_runtime_releases_mutex_if_open_fails(self) -> None:
events: list[str] = []
class Mutex:
def __init__(self, path: Path) -> None:
events.append("mutex")
def close(self) -> None:
events.append("close")
def fail() -> object:
raise RuntimeError("failed")
with tempfile.TemporaryDirectory() as directory, self.assertRaises(RuntimeError):
LocalStateRuntime.open(
RuntimePaths.from_root(Path(directory)),
mutex_factory=Mutex,
protector_factory=fail,
)
self.assertEqual(events, ["mutex", "close"])
def test_windows_without_localappdata_fails_instead_of_creating_second_database(self) -> None:
with mock.patch("cmbuyer_client.runtime.os.name", "nt"), mock.patch.dict(os.environ, {}, clear=True):
with self.assertRaisesRegex(RuntimeError, "local_app_data_required"):
RuntimePaths.default()
def test_runtime_root_is_frozen_absolute_and_relative_localappdata_is_rejected(self) -> None:
paths = RuntimePaths.from_root(Path("relative-runtime"))
self.assertTrue(paths.root.is_absolute())
with mock.patch.dict(os.environ, {"LOCALAPPDATA": "relative-local-app-data"}, clear=True):
with self.assertRaisesRegex(RuntimeError, "local_app_data_must_be_absolute"):
RuntimePaths.default()
+1
View File
@@ -0,0 +1 @@
"""原生 Qt Widgets UI 测试。"""
+228
View File
@@ -0,0 +1,228 @@
from __future__ import annotations
import os
import tempfile
import unittest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtCore import QObject, Qt, Signal
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication
from cmbuyer_client.polling.coordinator import PollingState, RecoveryStatus
from cmbuyer_client.ui.execution import ExecutionPage
from cmbuyer_client.ui.main_window import PurchaseToolWindow
from cmbuyer_client.ui.records import PurchaseRecord
class FakeCoordinator(QObject):
state_changed = Signal(object, str, int)
claim_visible = Signal(object)
settled = Signal()
recovery_status_changed = Signal(object)
configuration_freeze_changed = Signal(bool)
def __init__(self) -> None:
super().__init__()
self.state = PollingState.BLOCKED
self.reason = "单趟执行能力尚未接入,不能领取真实任务。"
self.consecutive_failures = 0
self.can_start = False
self.operation_in_flight = False
self.recovery_status = RecoveryStatus(False, False, False, False, False)
self.starts = 0
self.stops = 0
def start(self) -> None:
self.starts += 1
def stop(self) -> None:
self.stops += 1
def update_profile_settings(self, settings) -> None:
pass
class FakeStore:
def __init__(self) -> None:
self.calls = 0
def save_profile(self, settings, token) -> None:
self.calls += 1
def records() -> list[PurchaseRecord]:
return [
PurchaseRecord("old", "旧记录", "失败", "2026-08-04T09:00:00Z", "旧文字", "旧结果"),
PurchaseRecord("new", "新记录", "待付款", "2026-08-04T10:00:00Z", "新文字", "新结果"),
]
class ExecutionPageTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.app = QApplication.instance() or QApplication([])
def setUp(self) -> None:
self.coordinator = FakeCoordinator()
self.page = ExecutionPage(self.coordinator)
self.page.resize(1100, 700)
self.page.show()
QTest.qWait(20)
def tearDown(self) -> None:
self.page.close()
def test_standalone_start_is_disabled_with_nearby_reason_and_empty_real_model(self) -> None:
self.assertFalse(self.page.poll_button.isEnabled())
self.assertIn("尚未接入", self.page.banner.text())
self.assertEqual(self.page.record_model.rowCount(), 0)
self.assertEqual(self.page.record_model.columnCount(), 2)
self.assertEqual(self.coordinator.starts, 0)
def test_records_are_newest_first_and_open_via_action_then_escape_restores_focus(self) -> None:
self.page.set_records(records())
self.assertEqual(self.page.record_model.data(self.page.record_model.index(0, 0)), "新记录")
index = self.page.record_model.index(0, 0)
self.page.record_view.setCurrentIndex(index)
self.page.view_record_action.trigger()
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
self.assertEqual(self.page.detail_title.text(), "新记录")
QTest.keyClick(self.page.return_button, Qt.Key.Key_Escape)
QTest.qWait(20)
self.assertEqual(self.page.left_stack.currentIndex(), self.page.LIVE_PAGE)
self.assertEqual(self.page.record_view.currentIndex().row(), 0)
self.assertTrue(self.page.record_view.hasFocus())
def test_detail_selection_updates_in_place_without_modal(self) -> None:
self.page.set_records(records())
self.page.record_view.setCurrentIndex(self.page.record_model.index(0, 0))
self.page.open_selected_record()
second = self.page.record_model.index(1, 0)
self.page.record_view.setCurrentIndex(second)
self.page._on_record_selected(second)
self.assertEqual(self.page.detail_title.text(), "旧记录")
self.assertEqual(self.page.detail_result.toPlainText(), "旧结果")
def test_double_click_enter_and_visible_action_share_inline_detail_command(self) -> None:
self.page.set_records(records())
index = self.page.record_model.index(0, 0)
self.page.record_view.setCurrentIndex(index)
rect = self.page.record_view.visualRect(index)
QTest.mouseDClick(self.page.record_view.viewport(), Qt.MouseButton.LeftButton, pos=rect.center())
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
self.assertIsNone(QApplication.activeModalWidget())
self.page.return_to_live()
self.page.record_view.setFocus()
QTest.keyClick(self.page.record_view, Qt.Key.Key_Return)
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
self.page.return_to_live()
self.page.view_record_action.trigger()
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
def test_responsive_resize_keeps_model_selection_and_inline_detail(self) -> None:
self.page.set_records(records())
model = self.page.record_model
self.page.record_view.setCurrentIndex(model.index(0, 0))
self.page.open_selected_record()
for width in (700, 900, 1200):
self.page.resize(width, 700)
QTest.qWait(10)
self.assertIs(self.page.record_view.model(), model)
self.assertEqual(self.page.record_view.currentIndex().row(), 0)
self.assertEqual(self.page.left_stack.currentIndex(), self.page.DETAIL_PAGE)
def test_record_model_detail_and_log_redact_bare_token_sentinel(self) -> None:
token = "e" * 64
record = PurchaseRecord("secret", "标题 " + token, "失败", "2026-08-04T10:00:00Z", token, token)
self.page.set_records([record])
self.page.record_view.setCurrentIndex(self.page.record_model.index(0, 0))
self.page.open_selected_record()
self.page.log_view.append_event("Bearer " + token)
visible = "\n".join(
(
str(self.page.record_model.data(self.page.record_model.index(0, 0))),
self.page.detail_original.toPlainText(),
self.page.detail_result.toPlainText(),
self.page.log_view.toPlainText(),
)
)
self.assertNotIn(token, visible)
self.assertIn("已隐藏", visible)
class MainWindowTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.app = QApplication.instance() or QApplication([])
def test_fixed_default_tabs_and_close_do_not_start_or_stop_blocked_session(self) -> None:
coordinator = FakeCoordinator()
window = PurchaseToolWindow(
store=FakeStore(),
coordinator=coordinator,
profile_settings=None,
has_stored_device_token=False,
)
window.show()
QTest.qWait(10)
self.assertEqual(window.tabs.count(), 2)
self.assertEqual([window.tabs.tabText(i) for i in range(2)], ["采购执行", "配置"])
self.assertIs(window.tabs.currentWidget(), window.execution_page)
self.assertFalse(window.tabs.tabsClosable())
window.close()
self.assertEqual(coordinator.starts, 0)
self.assertEqual(coordinator.stops, 0)
def test_close_waits_for_inflight_settlement_instead_of_terminating(self) -> None:
coordinator = FakeCoordinator()
coordinator.state = PollingState.CLAIMING
coordinator.operation_in_flight = True
window = PurchaseToolWindow(
store=FakeStore(),
coordinator=coordinator,
profile_settings=None,
has_stored_device_token=False,
)
window.show()
self.assertFalse(window.close())
self.assertTrue(window.isVisible())
self.assertEqual(coordinator.stops, 1)
coordinator.operation_in_flight = False
coordinator.state = PollingState.STOPPED
coordinator.settled.emit()
QTest.qWait(20)
self.assertFalse(window.isVisible())
def test_recovery_status_freezes_and_unfreezes_non_token_settings(self) -> None:
coordinator = FakeCoordinator()
window = PurchaseToolWindow(
store=FakeStore(),
coordinator=coordinator,
profile_settings=None,
has_stored_device_token=False,
)
coordinator.recovery_status_changed.emit(RecoveryStatus(True, False, True, False, False))
self.assertFalse(window.settings_page.device_id.isEnabled())
self.assertTrue(window.settings_page.device_token.isEnabled())
coordinator.recovery_status_changed.emit(RecoveryStatus(False, False, False, False, False))
self.assertTrue(window.settings_page.device_id.isEnabled())
window.close()
class PurchaseRecordTimestampTests(unittest.TestCase):
def test_records_sort_by_real_rfc3339_nanoseconds_not_raw_text(self) -> None:
precise = [
PurchaseRecord("later", "稍后", "完成", "2026-08-04T10:00:00.9Z"),
PurchaseRecord("earlier", "稍早", "完成", "2026-08-04T10:00:00.11Z"),
]
from cmbuyer_client.ui.records import PurchaseRecordModel
model = PurchaseRecordModel(precise)
self.assertEqual(model.record_at(0).record_id, "later")
def test_record_timestamp_rejects_offset_and_noncanonical_trailing_zero(self) -> None:
for timestamp in ("2026-08-04T10:00:00+08:00", "2026-08-04T10:00:00.10Z"):
with self.subTest(timestamp=timestamp), self.assertRaises(ValueError):
PurchaseRecord("id", "标题", "完成", timestamp)
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
import os
from pathlib import Path
import tempfile
import unittest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QPushButton
from cmbuyer_client.localstate.models import ProfileSettings
from cmbuyer_client.ui.settings import SettingsPage
DEVICE_ID = "f3c9f507-7473-4fa6-8d71-8786c34c6301"
TOKEN = "b" * 64
class FakeStore:
def __init__(self, error: Exception | None = None) -> None:
self.calls: list[tuple[ProfileSettings, object]] = []
self.error = error
def save_profile(self, settings: ProfileSettings, token: object) -> None:
self.calls.append((settings, token))
if self.error is not None:
raise self.error
class SettingsPageTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.app = QApplication.instance() or QApplication([])
def setUp(self) -> None:
self.directory = tempfile.TemporaryDirectory()
self.adb = Path(self.directory.name) / "adb.exe"
self.adb.touch()
def tearDown(self) -> None:
self.directory.cleanup()
def fill(self, page: SettingsPage) -> None:
page.device_id.setText(DEVICE_ID)
page.adb_path.setText(str(self.adb))
page.adb_serial.setText("device-serial")
def existing_settings(self) -> ProfileSettings:
return ProfileSettings(
"default",
"http://127.0.0.1:8080",
DEVICE_ID,
str(self.adb),
"device-serial",
"usb",
)
def test_first_save_requires_token_and_performs_zero_store_write(self) -> None:
store = FakeStore()
page = SettingsPage(store)
page.show()
QTest.qWait(10)
self.fill(page)
page.save()
self.assertEqual(store.calls, [])
self.assertTrue(page.device_token.hasFocus())
self.assertIn("首次保存", page.feedback.text())
def test_first_save_rejects_65_character_token_without_silent_truncation(self) -> None:
store = FakeStore()
page = SettingsPage(store)
page.show()
QTest.qWait(10)
self.fill(page)
invalid_token = TOKEN + "b"
page.device_token.setText(invalid_token)
page.save()
self.assertEqual(store.calls, [])
self.assertEqual(page.device_token.text(), invalid_token)
self.assertTrue(page.device_token.hasFocus())
self.assertIn("64 位", page.feedback.text())
def test_visual_order_focuses_invalid_device_before_missing_token_or_adb(self) -> None:
store = FakeStore()
page = SettingsPage(store)
page.show()
QTest.qWait(10)
page.device_id.setText("not-a-uuid")
page.save()
self.assertEqual(store.calls, [])
self.assertTrue(page.device_id.hasFocus())
self.assertIn("UUIDv4", page.feedback.text())
def test_existing_blank_token_passes_none_and_success_never_refills(self) -> None:
store = FakeStore()
page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True)
page.save()
self.assertEqual(len(store.calls), 1)
self.assertIsNone(store.calls[0][1])
self.assertEqual(page.device_token.text(), "")
self.assertEqual(page.token_status.text(), "已保存")
def test_nonempty_token_replaces_once_then_is_cleared(self) -> None:
store = FakeStore()
page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True)
page.device_token.setText(TOKEN)
page.save()
self.assertEqual(len(store.calls), 1)
self.assertEqual(store.calls[0][1].value, TOKEN)
self.assertEqual(page.device_token.text(), "")
self.assertNotIn(TOKEN, page.feedback.text())
self.assertNotIn(TOKEN, page.token_status.text())
def test_existing_token_replacement_rejects_65_characters_without_store_write(self) -> None:
store = FakeStore()
page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True)
page.show()
QTest.qWait(10)
invalid_token = TOKEN + "b"
page.device_token.setText(invalid_token)
page.save()
# 零写入即表示已保存的原 token 未被替换。
self.assertEqual(store.calls, [])
self.assertEqual(page.device_token.text(), invalid_token)
self.assertTrue(page.device_token.hasFocus())
self.assertEqual(page.token_status.text(), "已保存")
def test_failed_save_preserves_token_and_focuses_secret_field_without_echo(self) -> None:
store = FakeStore(RuntimeError("must-not-appear-" + TOKEN))
page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True)
page.show()
QTest.qWait(10)
page.device_token.setText(TOKEN)
page.save()
self.assertEqual(page.device_token.text(), TOKEN)
self.assertTrue(page.device_token.hasFocus())
self.assertNotIn(TOKEN, page.feedback.text())
def test_pending_or_active_freezes_every_non_token_field_and_preserves_exact_settings(self) -> None:
original = self.existing_settings()
store = FakeStore()
page = SettingsPage(
store,
settings=original,
has_stored_device_token=True,
identity_frozen=True,
)
for field in (
page.device_id,
page.adb_path,
page.adb_serial,
page.transport,
page.poll_interval,
page.failure_threshold,
page.http_timeout,
page.step_timeout,
):
self.assertFalse(field.isEnabled())
self.assertTrue(page.device_token.isEnabled())
page.device_token.setText(TOKEN)
page.save()
self.assertEqual(store.calls[0][0], original)
def test_page_has_no_connection_probe_command_and_explains_deferred_validation(self) -> None:
page = SettingsPage(FakeStore())
button_texts = [button.text() for button in page.findChildren(QPushButton)]
self.assertEqual(button_texts, ["保存配置"])
self.assertIn("首次真实领取", page.validation_hint.text())
self.assertEqual(page.service_url.text(), "http://127.0.0.1:8080")
self.assertTrue(page.service_url.isReadOnly())
+9 -7
View File
@@ -87,13 +87,15 @@ T-103 的 `SkuSelectionFlow` 只允许证据绑定的受控入口、精确选择
执行按任务依赖驱动,**不按 Phase 整段串行等待**。当前优先路径:
1. T-111 先冻结“开始采购授权”的单趟契约;同时主审并合入只创建 `DRAFT` 的 T-202。
2. T-111 完成后恢复 **T-103 真机取证**;按依赖推进 T-104 → T-107 的分段安全判据。
3. admin agent 先做 T-209 把旧两趟 schema / 状态机迁到单趟,再推进 T-203 服务端批量开始采购 /
一次性授权;两者不依赖页面选择器。T-205 起的 attempt 与证据字段等待 T-103。
4. Phase 3:双端打通与单趟围栏前 dry-run。
5. Phase 4:单趟真实提交、待付款、调和与收尾。
6. V2 及以后:图搜、Excel、ERP、订单自动核对、AI 辅助。
1. 完成 **T-103 人工真机验收**,再按依赖推进 T-104 → T-107 的分段安全判据。
2. admin agent 在 T-107 后推进 T-210 → T-205 → T-206 → T-207 → T-208,冻结三闸门证据、
attempt 事件、围栏、一次性结果与人工调和。
3. client agent 先做当前可领取的 T-303,再推进 T-304 / T-306 / T-307 / T-308;与 admin 写路径
不重叠的任务继续并行。
4. T-305 完成围栏前真机 dry-run;T-400 只用 fixture/fake port 建立离线一次性提交安全闭包。
5. T-403 → T-402 先准备围栏后调和与人工付款事实记录;T-401 才在新真实单趟中首次点击一次,
点击后固定报 `UNKNOWN` 并由人调和,绝不付款。
6. T-405 生成可核验 Windows 包,T-404 对同一候选 commit 与产物做最终只读验收;V2 能力继续后置。
> **M2 是本项目的生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、
> **读到该 SKU 的单价**(T-103)。前序项目正是卡在选规格和读价。
+35 -30
View File
@@ -31,7 +31,10 @@
| 传输 | ADB(USB 或 WiFi) | 已定 | `uiautomator2` 3.x 走 adb 通道,`ip:port` 与 USB serial 同等对待 |
| 桌面 GUI | `PySide6` | 已定 | 前序项目已验证;执行员需要看设备状态和批次进度 |
| 截图处理 | `Pillow` | 已定 | 判断页面是否渲染完成,避免保存白屏壳层 |
| HTTP 客户端 | 标准库 `urllib` 或 `httpx` | **待定** | 先用标准库;确有重试/连接池需求再评估 |
| HTTP 客户端 | 标准库 `http.client` | 已定 | 只直连 `127.0.0.1:8080`;不读代理、不跟随重定向、不做隐藏重试或连接池 |
| 可恢复状态 | 标准库 `sqlite3`(WAL / FULL) | 已定 | request/slot 先落库再 HTTP;每个操作独立连接,恢复读取使用单一事务快照 |
| 秘密保护 | Windows Current User DPAPI | 已定 | token 原始 32 字节只以绑定 profile+device/attempt context 的密文 BLOB 入库;非 Windows 不降级 |
| 单实例 | Windows `Global\` named mutex | 已定 | 以规范数据库路径 hash 命名,先于 DPAPI/SQLite 取得,覆盖同用户跨 session |
| Excel | 不引入 | 已定 | Excel 解析移到采购服务;采购工具不再直接读表 |
| 测试 | `unittest`(标准库) | 已定 | 前序项目 171 项测试均用标准库,无需 pytest |
| 打包 | `pyinstaller` | 已定 | 交付给运营电脑;开发期依赖 |
@@ -55,6 +58,10 @@
Go 侧重写表头校验和行级报错,不能直接复用前序项目的 Python 实现。
- **采购工具不持有业务权威。** 金额上限、授权有效性、任务状态流转的判定权在采购服务;
采购工具本地校验只作为第二道防线,两边不一致时一律转人工。
- **HTTP 不做自动重试。** claim/renew/evidence 的重放权属于持有 durable 幂等槽的恢复门面;底层每次
方法最多一个请求。401 修复 Bearer 后、结果不明或重启恢复都必须复用原 key 与原 body/file。
- **客户端 SQLite 只保存恢复事实。** append-only session/claim/renew/evidence history 不是服务端任务
权威;它的作用是阻止崩溃、并发或状态损坏导致第二次领取、换图或换 key。
- **不引入 pytest / 不引入 ORM。** 同一职责不并存两套方案。
## 五、构建与运行命令
@@ -125,48 +132,46 @@ D:\Portable\adb\adb.exe devices -l
证据不得提交 Git。T-102 已由人确认 goods_id `958756616606` 对应目标商品并完成截图/XML 隐私检查,
设备、版本、证据路径与 SHA-256 已记录到任务执行记录。
### T-103 规格面板三状态只读取证(T-110 边界调整后待重新验证)
### T-103 规格面板状态取证与受控选择
`client/scripts/capture_sku_panel_spike.py` 只采集人已在手机上准备好的规格面板截图和完整 XML。
它不打开链接或规格面板,不识别面板,不点击、滑动、输入或选择规格,也不读取价格;接口只暴露
`app_info`、`app_current` 和两个只读 JSON-RPC 方法。`panel-opened-target-preselected`、
`alternate-all-dimensions-selected`、`target-selection-restored` 三种值写入 manifest 的字段名是
`human_declared_state`,明确表示人工声明,不得将其当作自动识别结果。旧的“未选 / 单维度 / 全选”
状态假设已被真机事实推翻,旧枚举会被采集器和脱敏器明确拒绝。运行拼多多版本必须精确为 `8.17.0`,
前台 package 必须是拼多多,否则 fail closed;目标目录已存在、截图/XML 无效或超时均不得覆盖已有内容
或发布半成品。
`client/scripts/capture_sku_panel_spike.py` 是历史人工声明状态的只读取证工具:只采集人已准备好的截图和
完整 XML,不打开链接或面板,不点击、滑动、输入、选择规格或读取价格。它保留的
`panel-opened-target-preselected` 等枚举只描述旧证据目录,不能作为当前生产 classifier 的事实来源。
运行拼多多版本必须精确为 `8.17.0`,前台 package 必须是拼多多;目标目录已存在、截图/XML 无效或
超时均不得覆盖已有内容或发布半成品。
T-103 已证明当前衣服商品没有独立「规格/已选」入口。T-110 只批准拼多多 `8.17.0`、goods_id
`937122477375` 上经真机确认的精确唯一 `快要抢光` 作为受控规格面板入口;“免拼购买 / 单独购买 /
直接拼成”等其他文案不能凭人工经验复用,必须分别重新取证。项目所有者已确认面板刚打开时目标颜色
“黑色CHA(纯棉)”和尺码“M(建议100-115)”均已自动选中;取证不强行取消选择,而是记录刚打开状态、
人工把两个维度都改成非目标值、再恢复目标值三个真实状态。只读取证 CLI 本身仍不执行点击;三种状态
分别使用一个全新原始证据目录:
`937122477375` 上经真机确认的底部购买区第一行精确唯一 `快要抢光 + 金额` 作为受控规格面板入口;
商品内容区同名小字必须零点击,“免拼购买 / 单独购买 / 直接拼成”等其他文案不能凭经验复用。
```powershell
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state panel-opened-target-preselected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-opened-target-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state alternate-all-dimensions-selected --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-alternate-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
.\client\.venv\Scripts\python.exe client\scripts\capture_sku_panel_spike.py --serial <SERIAL> --url "https://mobile.yangkeduo.com/goods.html?goods_id=<GOODS_ID>" --state target-selection-restored --output-dir "$env:LOCALAPPDATA\cmbuyer\artifacts\T-103\sku-panel-target-restored-<GOODS_ID>-v2\raw" --timeout 10 --adb D:\Portable\adb\adb.exe
```
2026-08-05 五组 live 证据纠正了旧的“面板打开即自动预选”假设:真实初态颜色与尺码均未选,选择
目标颜色后尺码仍未选且不在视口;S 非目标态与恢复 M 目标态的 exact selected 切换已由人确认。
当前实现必须分别匹配未选初态、仅目标颜色态、S 态和 M 态的完整 profile,不得跨状态拼装价格、摘要、
容器或坐标。显示尺码只能由证据绑定、私有、一次且不重试的窄 reveal 能力完成;在取得 reveal 前后
M 均未选的动作证据前,不得实现通用或猜测性滑动。
该动作证据使用独立 `client/scripts/capture_sku_reveal_spike.py`,不改造历史只读采集器,也不接入
生产 Flow。CLI 不接收颜色、尺码、坐标或手势参数;它只在严格命中“目标颜色已选、尺码隐藏且未选”
后执行一次证据绑定的固定手势,随后只读采集 before/after。M 意外选中、尺码仍隐藏、颜色/摘要漂移、
RPC 结果不明且无法调和或提交区风险均不得发布,成功后保留面板等待人工核对。
PDD 规格面板不可避免显示收货区域和手机号。项目所有者确认 cmbuyer 是内部系统,正式流程允许把
页面原始截图上传采购服务供已登录管理员查看,不做遮罩或裁剪。完整 XML 仍只留在
`%LOCALAPPDATA%\cmbuyer\artifacts\...\raw`;采购工具运行时在内存中读取当前页面树,但只返回规格、
选中态、价格和页面状态摘要,不把地址或手机号解析为业务字段,也不写入日志、Git 或 Vikunja。
T-103 已完成 `t103-privacy-v5` 的一次真实派生:人确认 XML 无实际地址/手机号、目标颜色
“黑色CHA(纯棉)”与尺码“M(建议100-115)”保留;安全节点显示顶部当前价“快卖完 ¥12.88”与原价
“¥29.88”,底部“提交订单 ¥12.88”明确属于 T-103 隔离能力的硬拒绝区。派生截图的整宽黑色隐私带
遮住了顶部价格文字的一半,这只说明旧脱敏实验不适合展示,不否定已确认的规格面板价格事实。
旧 `t103-privacy-v5` 派生物只保留为历史证据,不能覆盖上述 live 状态。闸门一只在目标颜色与 M
均精确读回后,从已取证滚动态面板的只读当前价角色读取十进制 `12.88`;原价 `29.88`、未选初态价格、
详情页数字和底部“提交订单 ¥12.88”都不得成为价格候选。
为加快 MVP,T-103 从此不再修改或调用截图遮罩器。现有 v5 派生 XML 只用于提取不含页面个人信息的
最小 fixture;T-111 完成后的 `SkuSelectionFlow` 在本机实时页面树上按拼多多 8.17.0 与面板结构读取允许字段,完整
页面树不落日志、不上传。T-204 直接接入原始截图上传,资产标记 `privacy_tier=INTERNAL_RAW`,只允许
为加快 MVP,T-103 不再修改或调用截图遮罩器。最小 fixture 从上述本机 live 证据中只提取获准判据节点,
不含地址、手机号或支付凭据;`SkuSelectionFlow` 在本机实时页面树上读取允许字段,完整页面树不落日志、
不上传。T-204 直接接入原始截图上传,资产标记 `privacy_tier=INTERNAL_RAW`,只允许
已认证设备写入、已登录管理员读取;不上传 XML,不允许外部支付页或支付凭据。
这个范围调整不授权 T-103 调整数量、进入确认页、点击“提交订单”或触碰支付控件。T-111 完成后,
T-103 以 `SkuSelectionFlow` 继续:由人把两个维度改成明确的非目标值,再运行受控入口、精确选择、
读价和安全退出脚本完成真机验收。生产业务最终为单趟,但能力仍按 T-103 / T-105 / T-107 分段取证。
这个范围调整不授权 T-103 调整数量、进入确认页、点击“提交订单”或触碰支付控件。T-103 最终由人
运行受控入口、目标颜色、一次 reveal、目标 M、读价和一次 Back 的完整真机验收;生产业务最终为单趟,
但能力仍按 T-103 / T-105 / T-107 分段取证。
Windows 的标准入口是仓库根 `./init.ps1`。它要求 Go、两端目录及其哨兵文件存在;已有合规
`client/.venv` 时,所有采购工具检查与 validator 都使用该解释器。只有 venv 不存在时,才从 `py -0p`
+122 -10
View File
@@ -16,8 +16,8 @@
│ · 提交围栏、结果调和、内部证据与审计 │
│ · 服务端渲染管理页面 │
└───────────────────┬─────────────────────┘
│ HTTPS / JSON
│ Bearer + 设备绑定
│ 本机回环 HTTP / JSON(MVP)
│ Bearer + 设备绑定;非回环前必须先上 TLS
v
┌─────────────────────────────────────────┐
│ 采购工具(client/,Python + PySide6) │
@@ -98,15 +98,38 @@ RECONCILIATION_REQUIRED ──人工核查同一提交──> WAITING_PAYMENT /
### 受控规格面板入口
T-103 真机证据表明:拼多多 `8.17.0`、goods_id `937122477375` 通过精确文本“快要抢光”打开
规格面板。T-110 已批准把该**特定证据、版本和页面状态**绑定的点击定义为受控导航。
T-103 最新真机证据表明:拼多多 `8.17.0`、goods_id `937122477375`、1080×2376 的规格面板
入口是屏幕底部购买区第一行 `快要抢光 + 金额`,不是商品内容区的同名促销小字。T-110 已批准把
该**特定证据、版本和页面状态**绑定的点击定义为受控导航。
- 只能精确唯一匹配;缺失、重复、版本失配或打开后面板不唯一时零后续点击。
- “免拼购买 / 单独购买 / 直接拼成”等其他文案不能用包含、前缀、同义或坐标兜底。
- 只能精确唯一匹配底部非点击文本叶节点、其直接可点击 PDD 父容器及证据绑定结构,并且只点击
第一行文本叶节点中心;缺失、重复、版本失配、结构漂移或打开后面板不唯一时零后续点击。
- 商品内容区同名促销小字明确禁止作为入口。底部按钮中的金额只参与结构识别,不得解析、返回或
充当价格来源;价格仍只能从规格面板和订单确认页读取。
- `免拼购买` 只允许作为已取证、不可点击的精确兄弟节点验证底部结构,不能作为选择器、目标或兜底;
它变为可点击、位置漂移或出现在其他结构时必须拒绝。“单独购买 / 直接拼成”等其他文案同样不能
用包含、前缀、同义或坐标兜底。
- 受控入口只负责进入已取证面板,不等于支付授权,也不能暴露通用任意点击能力。
- T-103 的隔离验证 capability 只包含开商品、开面板、选规格、读价和安全退出;数量、确认页、
提交和支付仍由后续真机任务分别取证后才能接入生产单趟执行器。
2026-08-05 的五组 8.17.0 真机证据进一步证明,面板刚打开时颜色与尺码均未选中,尺码选项尚未
进入视口;选择目标颜色后,摘要只剩“请选择:尺码”,但尺码仍不可见。实现必须把未选初态、
仅颜色已选态、非目标尺码态和目标尺码态作为各自完整的证据 profile 匹配,禁止把不同状态的价格、
摘要、容器或坐标拼成一个宽松判据。
- 显示尺码只允许命名的私有 `reveal_size_options()` 窄能力:仅从已证明的“目标颜色已选、尺码未选
且隐藏”状态执行,绑定证据确认的嵌套滚动容器与列间安全通道,最多一次且不重试;不得公开通用
`swipe`、`scroll`、任意坐标或把底部提交区纳入动作范围。
- 生产 reveal 落地前,必须补齐“动作前后目标尺码均未选、尺码由隐藏变为可见、提交区未触发”的
同版本真机动作证据。现有“滚动后 M 已选中”快照不能证明滚动没有误点规格,不得据此猜测手势。
- 这组证据只能由独立 spike 在严格前置成立后执行一次固定、无参数手势采集;生产 Flow 不得调用该
spike。RPC 超时或响应不明时只允许只读调和,不得重试;候选后置必须再由人核对后才能成为生产判据。
- 尺码 action 的真实节点类型、精确文案和 selected 读回必须随 profile 一起验证;当前证据中的 S/M
是可点击 `TextView`。只有目标颜色与目标尺码双重读回一致后才能读取闸门一价格。
- 一次 Back 在调用前即封存且不得重试;仅“面板消失”不能作为生产安全退出的充分条件。同商品详情页
的严格退出判据由 T-104 取得独立真机证据后开放。
### 三道价格闸门
| 闸门 | 当前页面 | 判据 | 拒绝条件 |
@@ -141,6 +164,8 @@ T-103 真机证据表明:拼多多 `8.17.0`、goods_id `937122477375` 通过
| 外部支付页 | 检测到外部支付交接立即停止,不读取、保存或输入凭据 | 凭据泄露 |
| 安全校验 | 验证码、风控、人脸、短信出现即停止,不绕过 | 封号 / 违规 |
| 内部截图 | 可上传页面已显示的地址/手机号;不解析成字段或日志,完整 XML 不上传 | 非必要扩散 |
| 身份隔离 | 管理 session+CSRF 与设备 Bearer 分属不同路由域,混合凭据不叠加权限 | 设备越权 / 会话冒充 |
| Bearer 传输 | MVP 仅绑定 IPv4 回环 `127.0.0.1:8080`;非回环访问先建立 HTTPS/TLS 终止 | 明文局域网泄露 token |
| 授权一次性 | 一条任务版本只有一份有效授权;幂等重放不生成第二份 | 重复采购 |
| 服务端提交围栏 | 点击前原子创建唯一提交记录;失败或响应不明不得点击 | 并发 / 断网重复下单 |
| App 版本绑定 | 运行版本不同于证据版本时停止并重新取证 | 旧判据误点 |
@@ -227,9 +252,42 @@ CREATE TABLE purchase_attempts (
failure_code TEXT,
started_at TEXT NOT NULL,
finished_at TEXT,
UNIQUE (task_id, claim_generation)
UNIQUE (task_id, claim_generation),
UNIQUE (task_id, authorization_id, id, claim_generation)
);
-- attempt 的设备/session 所有权与可恢复租约;token 明文永不入库
CREATE TABLE purchase_attempt_claims (
attempt_id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
authorization_id TEXT NOT NULL UNIQUE,
claimed_by_device_id TEXT NOT NULL REFERENCES device_credentials(device_id),
session_id TEXT NOT NULL,
claim_generation INTEGER NOT NULL,
task_version INTEGER NOT NULL,
task_title TEXT NOT NULL,
authorization_task_version INTEGER NOT NULL,
goods_id TEXT NOT NULL,
sku_color TEXT NOT NULL,
sku_size TEXT NOT NULL,
quantity INTEGER NOT NULL,
total_price_cap TEXT NOT NULL,
authorization_expires_at TEXT NOT NULL,
claim_nonce BLOB NOT NULL, -- 32 字节随机 nonce
claim_token_sha256 BLOB NOT NULL, -- 32 字节 hash,不是 token 明文
lease_expires_at TEXT NOT NULL,
claimed_at TEXT NOT NULL,
closed_at TEXT,
FOREIGN KEY (task_id, authorization_id, attempt_id, claim_generation)
REFERENCES purchase_attempts(task_id, authorization_id, id, claim_generation)
);
CREATE UNIQUE INDEX purchase_attempt_claims_one_open_per_device_idx
ON purchase_attempt_claims (claimed_by_device_id) WHERE closed_at IS NULL;
-- claim-next 的 CLAIMED / EMPTY / BLOCKED 与 renew CAS 都持久化,保证跨重启幂等。
-- 复合外键同时绑定 attempt、设备、session、generation 与 token hash,应用 bug 不能跨归属写事实。
-- 真机真实点击前建立;一份授权最多一条
CREATE TABLE order_submissions (
id TEXT PRIMARY KEY,
@@ -247,6 +305,18 @@ CREATE TABLE order_submissions (
UNIQUE (attempt_id)
);
-- 设备 token 明文只在签发完成后显示一次;数据库仅保存原始 32 字节 token 的 SHA-256
CREATE TABLE device_credentials (
device_id TEXT PRIMARY KEY, -- 规范小写 UUIDv4
display_name TEXT NOT NULL, -- 非秘密运维名称
token_sha256 BLOB NOT NULL UNIQUE, -- 恰好 32 字节
status TEXT NOT NULL, -- ACTIVE | REVOKED
created_at TEXT NOT NULL,
revoked_at TEXT,
CHECK ((status = 'ACTIVE' AND revoked_at IS NULL)
OR (status = 'REVOKED' AND revoked_at IS NOT NULL AND revoked_at >= created_at))
);
-- INTERNAL_RAW 原始截图;原文件名和客户端路径不进入数据库
CREATE TABLE evidence_assets (
id TEXT PRIMARY KEY,
@@ -273,6 +343,12 @@ MVP 不再用 `spec_trials` 作为审批记录,也不存在 `authorized_unit_p
`purchase_attempts` / `order_submissions` 的执行与审计事实;管理员授权的资金边界始终是
`total_price_cap`。
设备凭据由本机管理 CLI 签发、列出和撤销。token 是 32 字节加密随机值,以 64 位小写十六进制
只显示一次;服务端把 token 解码回原始字节后计算 SHA-256,并与按设备 id 查出的 32 字节 BLOB
恒定时间比较。未知 id 也执行固定宽度 dummy compare。认证逐请求查库,因此撤销事务提交后才开始的
请求全部拒绝;提交前已经完成认证的在途请求不追溯取消。格式/未知/错配/撤销统一空 401,存储故障
空 503,两类都在读取业务请求体前失败闭合。
### 5.2 状态机
```text
@@ -307,6 +383,20 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
### 5.3 授权、租约与恢复
- claim/renew 使用独立 32 字节 HMAC secret;配置为 64 位小写十六进制,不得等于 session secret 或
任一设备 token。每条 claim 以版本域分隔 HMAC 绑定 device/task/authorization/attempt/generation/
32 字节随机 nonce,SQLite 只保存 nonce 和 token SHA-256。启动会重建并恒定时核对所有 open/closed
claim;secret 错误时失败闭合,不轮换 token。
- claim 与 renew 的 SQLite 事务必须先通过条件 no-op UPDATE 取得写入线性化位置并复核设备 ACTIVE,
才能读取或重放 request。撤销先提交则请求失败;claim/renew 先取得写位置则该事务可完成,随后撤销
不会自动释放已建立的 claim。
- 同一授权最多一个 attempt,同一设备最多一个未关闭 claim。EMPTY、人工恢复冲突和续租响应都持久化;
同幂等键只能重放原结果,不能因候选变化、续租或服务重启递增 generation、轮换 token 或延长第二次。
- claim 行同时冻结成功响应所需的 task 标题/版本以及 authorization 版本、规格、数量、总价上限和到期
时间。旧 request 跨重启只从该快照重建;源 task/authorization 后续漂移不能改变历史响应,并会让新
恢复或续租失败闭合。进入 `ORDERING` 的同 attempt 仅接受 task version 恰好比 claim 快照加一。
- 租约 TTL 显式配置为正且严格短于授权 TTL;新到期时间不得超过授权到期。租约与授权边界相等即过期,
没有宽限;过期、撤销、停止轮询和进程退出均不关闭 claim、不释放授权、不允许另一设备接管。
- 授权带 `expires_at`,只有围栏前可转 `EXPIRED` / `ABANDONED`;任务回到 `DRAFT`,必须重新点击
开始采购。旧授权永不复活。
- 设备租约丢失不等于授权可安全重用。只有服务端确认该 attempt 未建立围栏,才能关闭 attempt 并
@@ -314,6 +404,24 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
- 围栏建立后即使租约过期也只恢复同一 `order_submission` 的调和,不能回到可领取队列。
- 每种非终态都必须给出安全下一步,不能出现隐藏表单导致任务永久锁死。
#### 采购工具本地恢复状态
- 生产入口先取得基于规范数据库路径的 Windows `Global\` named mutex,再构造 DPAPI 和 SQLite;不能
以服务端“单设备最多一个 claim”替代本机单实例。
- profile、polling session、claim/renew request、open/historical claim、evidence marker/slot 使用 WAL、
`synchronous=FULL` 与 append-only/单调关闭约束。当前 session/claim 由 `closed_at IS NULL` partial
unique 保证唯一,历史关闭后不阻塞下一条,但不得删除或复活。
- `DurableClientGateway` 是轮询和截图接入的唯一顺序入口:先 durable prepare,再一次 HTTP,最后原子
commit。停止只把当前 session 的 `accept_new` 设为 false;飞行中响应仍提交,pending/open 不清除。
- 每次发送前校验 profile/session/request/active immutable business snapshot、renew history、evidence marker/slot/
receipt 的完整状态图;snapshot、renew response 与 evidence receipt 还保存不可变摘要。任一冗余事实不一致、
DPAPI context 不匹配、数据库缺行或文件 identity 变化时零 HTTP 停止,不能自行“修复”。
- 设备 Bearer 的 401 发生在服务端读取 body/写幂等事实前,因此槽保持 `PENDING`;只允许同 device id
更新 Bearer,并在用户再次开始后用原 key/body/file 尝试。claim token 和其他 frozen 配置不变。
- 原始 token 以 DPAPI current-user context 密文保存:device token 绑定 profile+device,claim/renew token
绑定 profile+attempt,密文不能跨行复用;日志 formatter 对 Bearer、裸 64 位 token 和
traceback 做最终脱敏。异常对象也只保留固定 reason,不挂接含响应 body/partial/path 的异常上下文。
### 5.4 证据分层
| 数据 | 位置 | 边界 |
@@ -331,8 +439,11 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
- 单个 PNG 最大 10 MiB、单边最大 8192 px、总像素最大 16,777,216;同时验证 multipart MIME、
PNG 魔数、完整解码、字节数、尺寸和调用方声明的 SHA-256。
- 上传 handler 必须先通过设备认证,再解析 Content-Type 或读取 body。T-301 前生产认证器固定拒绝,
不创建临时 token,也不把管理员 session 当设备身份。
- 上传 handler 必须先通过逐请求 SQLite 设备认证并再次校验规范 principal,再解析 Content-Type 或
读取 body。空库、无效或已撤销凭据拒绝,认证存储故障返回 503;不把管理员 session 当设备身份。
- 首次 evidence INSERT 必须由 store 校验和 SQLite trigger 双重证明 `(task, attempt, authenticated device)`
对应未关闭 claim。历史同设备/upload key 重放先于该检查,因此人工关闭 claim 不会破坏已提交资产的
幂等读取;关闭后禁止新 upload key,且不为此增加 token/session 字段或放宽截图 kind。
- 文件写入显式配置的私有证据根目录:同目录随机临时文件 → 流式 hash → 校验 → `fsync` → 原子
rename 到 SHA-256 内容地址 → 最后事务写数据库。数据库永远不指向半文件或缺失文件。
- SQLite 与文件系统不能组成跨资源事务;极端故障最多留下不可达孤儿文件。不得为清理孤儿而删除
@@ -388,7 +499,8 @@ cmbuyer/
└── scripts/
```
执行器依赖 `TaskSource` / `ResultSink`,不直接读取 Excel 或拼接 HTTP。来源变化不得改变安全执行器。
执行器依赖核心端口,不直接读取 Excel 或拼接 HTTP。T-303 只提供 `TaskSource` / `EvidenceSink`;完整
`ResultSink` 在服务端 events/fail/fence/result 契约落地后分阶段组合,来源变化不得改变安全执行器。
## 九、架构纪律
+25 -20
View File
@@ -19,16 +19,17 @@ Phase 是里程碑分组,不是整段串行栅栏。推荐波次:
| --- | --- | --- | --- |
| 1 | T-001 → T-004 → T-201 → T-202 | T-002 → T-101 → T-102 | T-003、原型与契约 |
| 2 | T-209 → T-203 → T-204 | T-103 → T-104 → T-105 → T-107 | T-111 单趟重构;逐任务主审 |
| 3 | T-205 → T-207 → T-208 | T-301 → T-304 → T-306 | 双端契约完整门禁 |
| 4 | - | T-305 | 围栏前端到端 dry-run 主审 |
| 5 | T-402 / T-403 | T-401 | 真机人工授权、提交与调和验收 |
| 6 | - | T-405 | T-404 完整验收 |
| 3 | T-210 → T-205 → T-206 → T-207 → T-208 | T-301 → T-302 → T-303,再并行 T-304 / T-306 / T-307 | 双端契约完整门禁 |
| 4 | T-403 → T-402 | T-305;T-208 / T-303 / T-307 后做 T-308;再汇合到 T-400 | 围栏前 dry-run、客户端围栏恢复与离线单次提交主审 |
| 5 | - | T-401 | 新真实单趟、首次一次性提交与人工调和验收 |
| 6 | - | T-405 | T-404 完整只读验收 |
并行门禁:
- T-202 只创建/展示 `DRAFT`,可与 T-103 并行;T-203 只实现管理员授权与任务状态,不依赖页面
选择器,也可在 T-103 完成前推进。
- T-205 起涉及实际 attempt/证据字段,必须等待 T-103 确认规格和价格最小事实。
- T-210 必须等待 T-107 真机确认三闸门证据类型;T-205 再基于三类真实 asset 冻结事件与摘要,
不允许用 mock 或缺证据事件绕过生产 schema。
- T-103、T-105、T-106、T-107 的 capability 相互隔离;未经取证的后续动作不可提前暴露。
- T-401 前任何任务都不得真实点击“提交订单”;支付能力永远不实现。
- 主 agent 独立审阅每个子 agent 的 diff 并重跑验证,不以其自报完成作为证据。
@@ -59,9 +60,9 @@ Phase 是里程碑分组,不是整段串行栅栏。推荐波次:
| T-102 | 验证按链接打开商品详情页 | T-101 | canonical goods 链接进入对应商品;前台包与 goods_id 有证据 |
| T-103 | 验证受控规格面板、精确选择与 SKU 单价 | T-102, T-110, T-111 | 精确唯一入口、维度内等值选择/读回、防前缀碰撞、当前价唯一读取;隔离 capability 不含数量/确认/提交 |
| T-104 | 验证规格选择能力安全退出 | T-103 | 完成选择/读价/截图后可关闭面板或退出;不进入确认页、不提交 |
| T-105 | 验证数量设置、读回与闸门二 | T-103 | 设置数量并精确读回;再次读规格/单价等于闸门一;不一致即停 |
| T-106 | dry-run:确认页与闸门三取证 | T-105 | 进入确认页读规格、数量、应付总额并验证提交控件唯一;绝不点击 |
| T-107 | 固化确认页与提交控件安全判据 | T-106 | 只使用本项目证据;dry-run 静态不可达提交;版本失配 fail closed |
| T-105 | 验证数量设置、读回与闸门二 | T-104 | 先只读取证并由人确认,再设置数量、精确读回并复核规格/单价;不一致即停 |
| T-106 | dry-run:确认页与闸门三取证 | T-105 | 只读采集导航前、确认页、最终控件及一次人工 Back 状态;agent 不执行页面动作 |
| T-107 | 固化确认页与提交控件安全判据 | T-106 | 只点已取证导航控件一次;最终控件只读观察;一次 Back;静态不可达提交 |
| T-110 | 批准证据绑定的受控规格入口 | T-103 no-go 决策 | 仅批准拼多多 8.17.0 已取证的精确唯一“快要抢光”,不泛化 |
| T-111 | 调整为开始采购授权的单趟流程 | T-004, T-010, T-110 | 状态/API/原型/任务依赖一致;删除试选后确认但不放宽三闸门和围栏 |
@@ -77,10 +78,11 @@ T-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次
| T-209 | 把核心 schema / 状态机迁移为单趟模型 | T-004, T-111 | 新 migration 安全替换旧 `spec_trials` / 授权锁价结构;新增 `purchase_attempts`;删除旧 MVP 状态;无业务数据事实写入迁移前置,遇未知旧数据 fail closed;领域/迁移测试同步 |
| T-203 | 表格查询与批量开始采购(F-004、F-008、F-018) | T-202, T-209 | 只选 DRAFT;按钮明确“不付款”;事务内创建锁定任务字段的一次性授权并全批 `DRAFT → PENDING`;重放/冲突测试 |
| T-204 | 路由化任务详情与内部截图资产(F-007、F-011) | T-203 | 抽屉/完整页共享 URL;状态详情;受保护 `INTERNAL_RAW` screenshot 上传/读取、SHA-256 与 no-store;不接收 XML |
| T-205 | purchase attempt、事件与执行摘要 API | T-204, T-103 | 记录实际规格、两次单价、数量、确认页金额、固定 reason;不再创建 `spec_trials` 审批记录 |
| T-210 | 扩展闸门二与闸门三证据类型 | T-107, T-204, T-302 | 先精确扩展 Gate2/Gate3 kind;保留 Gate1、归属/FK/trigger/幂等;有新事实时拒绝降级 |
| T-205 | purchase attempt、事件与执行摘要 API | T-210 | 三条成功事件分别绑定正确 Gate1/2/3 asset,冻结摘要与围栏前 reason;不再创建 `spec_trials` 审批记录 |
| T-206 | 单趟状态视图与安全主动作 | T-205 | PENDING/CLAIMED/ORDERING/NEEDS_MANUAL/WAITING_PAYMENT/RECONCILIATION_REQUIRED 显示和允许动作一致;无中途确认 |
| T-207 | 授权有效期与围栏前重置(F-010) | T-206 | 围栏前过期/关闭旧授权后回 DRAFT;围栏后拒绝释放、取消、重新授权 |
| T-208 | 提交围栏与结果调和 API(F-017) | T-207, T-107 | 原子复核授权/attempt/三闸门/唯一控件;首次许可一次;重放不许可点击;结果不明永久围栏且只调和 |
| T-208 | 提交围栏与结果调和 API(F-017) | T-207, T-210 | 原子复核授权/attempt/三闸门/唯一控件;首次许可一次;重放不许可点击;结果不明永久围栏且只调和 |
### Phase 3 · 双端打通(单趟围栏前)
@@ -88,20 +90,23 @@ T-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次
| --- | --- | --- | --- |
| T-301 | 设备凭据与身份隔离(F-013) | T-201 | Bearer 不能建单/授权;管理会话不能领任务;凭据可撤销 |
| T-302 | 已授权任务原子领取与租约(F-005) | T-301, T-203 | 只领 PENDING+有效授权;并发唯一;重放同一 attempt;claim token/generation 有效 |
| T-303 | `HttpTaskSource` / `HttpResultSink` | T-302, T-002 | 执行器依赖抽象;幂等重放;线协议严格校验;不扩大 Excel 范围 |
| T-303 | HTTP 任务源、证据 sink 与可恢复本地状态 | T-002, T-204, T-302 | `http.client` 单次回环请求、DPAPI/SQLite append-only 状态图、Global 单实例和 durable gateway;不伪造完整 ResultSink |
| T-304 | 定时轮询与会话边界 | T-303, T-006 | 人启动后轮询;停止只阻止下次领取;连续失败停;当前任务/记录详情双视图 |
| T-306 | 单趟截图与证据上传 | T-304, T-204 | 显式上传原始 screenshot;可含页面地址/手机号;完整 XML、本机路径、外部支付页不上传 |
| T-305 | **单趟围栏前端到端 dry-run** | T-306, T-107, T-205 | 从领取连续跑到闸门三与提交控件验证,随后安全停止;全程事件/证据齐全,不申请真实许可、不点击提交 |
| T-306 | 规格面板原始截图与可靠证据上传 | T-104, T-303 | 只发布 Gate1 的显式原始 PNG;唯一恢复槽;可含页面地址/手机号;不上传 XML/路径或扩 kind |
| T-307 | 客户端 attempt 事件与失败 sink | T-205, T-303 | 窄 events/fail HTTP 与同键恢复;不实现 ResultSink、围栏、结果、PDD 或 UI |
| T-308 | 客户端提交围栏与结果 sink | T-208, T-303, T-307 | 复用同一 transport/localstate;首次明确且 durable 的 CREATE_ONCE 才产生当前进程 opaque permit;重启不可重建 |
| T-305 | **单趟围栏前端到端 dry-run** | T-107, T-304, T-306, T-307, T-210 | 连续跑到三闸门及最终控件只读观察,随后一次安全 Back 并以固定 reason 停在围栏前;不申请许可、不点击提交 |
### Phase 4 · 真实提交与收尾
| ID | 任务 | 依赖 | 验收要点 |
| --- | --- | --- | --- |
| T-401 | **单趟采购与提交围栏端到端** | T-305, T-208 | 同一设备会话通过三闸门、首次明确围栏许可后点击一次;结果不明只调和;明确创建转待付款;绝不付款 |
| T-402 | 待付款收口与手工完成 | T-401 | 详情展示订单证据;人付款后手工标记;待付款不等于成功 |
| T-403 | 失败分类与证据归档 | T-401 | 覆盖需求 reason;终态原子回写;围栏后无重试 |
| T-404 | 完整 MVP 验收 | T-402, T-403 | P0 验收、双端门禁、真机记录和 current-state 全部通过 |
| T-405 | 采购工具打包与运行文档 | T-404 | 运营电脑可安装运行,记录产物 SHA-256 |
| T-400 | 离线单次提交安全编排 | T-305, T-308 | 只用 fixture/fake port 证明四条件、点击意图先落盘、调用恰好一次和崩溃后只调和;生产接线仍不可达 |
| T-403 | 失败分类、证据归档与管理员调和视图 | T-208 | 只展示已有事实并复用同一 submission 调和事务;围栏后无重试、释放或重新授权 |
| T-402 | 待付款人工收口与付款事实记录 | T-403 | 只记录人已在外部付款的事实;三组确认、完整 lineage 与追加式审计;系统不付款 |
| T-401 | **首次真实提交与生产接线** | T-400, T-402 | 新真实单趟重新通过三闸门;首次明确许可后点击一次;首次运行固定 UNKNOWN 并人工调和;绝不付款 |
| T-405 | 采购工具 Windows 打包与运行文档 | T-401 | PyInstaller onedir ZIP;拒绝秘密/状态/证据/tests/XML;记录 commit 与 SHA-256 |
| T-404 | 完整 MVP 只读验收 | T-401, T-402, T-403, T-405 | 冻结同一候选 commit/产物做 P0、双端门禁和真机记录复核;缺陷退回原任务,不在验收任务修复 |
### V2 及以后
@@ -123,8 +128,8 @@ T-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次
- **M2**:真机按链接打开商品、精确选择颜色/尺码并读 SKU 单价。(T-103)
- **M3**:真机设置数量、闸门二、确认页闸门三和唯一提交控件 dry-run 通过,不点击。(T-107)
- **M4**:管理员建单并通过批量开始采购创建一次性授权;设备可安全领取。(T-203、T-302)
- **M5**:单趟从领取到围栏前 dry-run 跑通,事件与证据齐全。(T-305)
- **M6**:单趟真实提交只创建待付款订单,围栏/调和/人工收口闭环。(T-401~T-404)
- **M5**:围栏前 dry-run 跑通,客户端围栏恢复和离线一次性提交安全闭包成立。(T-305、T-308、T-400)
- **M6**:新真实单趟只创建可能的待付款订单,围栏/调和/人工收口、Windows 交付和只读验收闭环。(T-401~T-405)
**M2 仍是生死线。** M2 前可以并行管理会话、DRAFT 建单和开始采购的服务端授权事务,但不能编写
依赖未取证页面字段的生产执行逻辑。提速来自两端并行、窄任务和主审复用门禁,不来自删除安全闸门。
+5 -3
View File
@@ -19,7 +19,7 @@
| IX-004 | US-002、US-004 | web 列表行 / `/tasks/{id}` | 双击行、浏览器前进后退 | 路由化详情抽屉或完整页,显示执行证据 | P0 | 已定 |
| IX-005 | US-005 | web 批量操作条 | 点击开始采购 | 创建一次性授权并进入 `PENDING` | P0 | 已定 |
| IX-006 | US-006 | web 待付款详情 | 查看后手工标记 | 只记录人工付款完成,不执行支付 | P0 | 已定 |
| IX-007 | US-003 | desk 配置 tab | 保存并检查连接 | 服务、ADB、App 版本就绪 | P0 | 已定 |
| IX-007 | US-003 | desk 配置 tab | 保存并校验配置 | 本地字段/路径有效;服务身份与真机能力在真实流程中验证 | P0 | 已定 |
| IX-008 | US-003、US-008 | desk 采购执行 tab | 开始 / 停止轮询、选择记录、Esc | 单趟执行和安全人工接管 | P0 | 已定 |
| IX-009 | US-008 | web / desk 通用错误反馈 | 请求或流程失败 | 显示阶段、原因和安全下一步 | P0 | 已定 |
| IX-010 | US-005 | web 围栏前人工处理 | 重置 / 取消 | 关闭旧授权后回 `DRAFT` 或取消 | P0 | 已定 |
@@ -57,7 +57,8 @@
### IX-006 待付款核对与标记完成
- 页面显示商品、目标与实际规格、数量、授权上限、两次单价、确认页总额和订单截图。
- 页面显示商品、目标与实际规格、数量、授权上限、两次单价、确认页总额和三闸门内部证据;MVP
没有提交后结果截图类型,不把确认页图片冒充订单结果图。
- 首屏警示:“订单已创建,系统尚未付款。请在拼多多人工核对并付款。”
- “标记为已付款并完成”只写系统状态,不打开支付页面、不触发手机操作。
- 提交前用简短确认对话框说明这是人工事实记录;成功转 `SUCCEEDED`,重复提交幂等。
@@ -105,7 +106,8 @@
1. 当前任务在同一趟内完成闸门二和闸门三,并确认提交控件精确唯一。
2. 客户端显示“正在申请提交围栏”,提交当前验证摘要。
3. 只有服务端首次明确返回 `click_permitted=true`,客户端才先持久化唯一点击意图,再精确点击一次。
4. 明确创建转待付款;外部支付、安全校验、超时、断连或未知页面转待调和。
4. MVP 首次真实点击后因没有已取证的提交后页面判据,无条件上报 `UNKNOWN` 并转待调和;只有管理员
对同一 submission 明确调和为已创建,才转待付款。
5. 围栏后的 web 详情只显示同一 `submission_id` 的证据、调和记录与人工结论。
| 状态 | 允许动作 | 禁止动作 |
+167 -46
View File
@@ -16,11 +16,29 @@
| 身份 | 凭据 | 能力 |
| --- | --- | --- |
| 管理员 | `HttpOnly; Secure; SameSite=Lax` 会话 cookie + CSRF | 建单、开始采购、查看内部证据、人工调和 |
| 设备 | `Authorization: Bearer <device-token>` + 设备 id | 心跳、领取、事件、截图、围栏与结果 |
| 管理员 | `HttpOnly; SameSite=Lax` 会话 cookie + CSRF;HTTPS 部署设 `Secure=true` | 建单、开始采购、查看内部证据、人工调和 |
| 设备 | `Authorization: Bearer <device-token>` + `X-CMBuyer-Device-ID` | 心跳、领取、事件、截图、围栏与结果 |
| ERP(V2) | 独立凭据 | 只读来源同步,不访问采购结果 |
设备凭据不能建单或开始采购;管理会话不能调用设备接口。未认证统一返回 `401`,无权返回 `403`。
设备凭据不能建单或开始采购;管理会话不能调用设备接口。凭据缺失或无效返回 `401`,已认证但无权
或管理写请求缺少有效 CSRF 返回 `403`;认证存储故障按下述规则返回 `503`。
设备请求的认证头采用以下固定格式:
- `Authorization` 和 `X-CMBuyer-Device-ID` 必须各出现且只出现一次;代理合并出的逗号列表也拒绝。
- Authorization scheme 按 HTTP 规则大小写不敏感,但 scheme 后只允许一个 ASCII 空格;token 必须是
加密随机生成的 32 字节值对应的 64 位小写十六进制文本。
- 设备 id 必须是规范小写 UUIDv4。token 与设备 id 同时绑定,未知、错配、格式错误和已撤销均返回
空 `401`,可带 `WWW-Authenticate: Bearer`,不区分具体原因。
- 认证器逐请求读取 SQLite,不缓存 ACTIVE 结论。SQLite 查询或连接故障返回空 `503`;401 与 503
都必须发生在 Content-Type 解析和 body 读取之前。
- 管理 cookie 不替代设备凭据;Bearer 也不替代管理 session/CSRF。两类凭据同时出现时,各路由仍只
采用自己的身份域,不把权限相加。
设备 token 由本机管理 CLI 签发,只在签发事务提交后向操作者显示一次;SQLite 仅保存 token 原始
32 字节的 SHA-256(32 字节 BLOB),list/revoke、日志、错误和 HTTP 响应均不显示 token 或 hash。
撤销幂等且不会恢复旧 token。MVP 服务只绑定 IPv4 回环 `127.0.0.1:8080`,设备 Bearer 只经过本机回环 HTTP;
未来非回环访问必须先建立 HTTPS/TLS 终止与代理信任边界。
### 错误响应
@@ -30,7 +48,7 @@
"code": "version_conflict",
"message": "任务已变化,请刷新后重选",
"retryable": false,
"request_id": "018f..."
"request_id": "c3c9f507-7473-4fa6-8d71-8786c34c6301"
}
}
```
@@ -67,7 +85,7 @@
```json
{
"create_key": "018f...",
"create_key": "d3c9f507-7473-4fa6-8d71-8786c34c6301",
"title": "纯棉短袖",
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
"sku_color": "黑色CHA(纯棉)",
@@ -78,6 +96,11 @@
```
- 服务端解析并保存 canonical URL 与 `goods_id`;URL 非拼多多商品页、`goods_id` 缺失或含歧义则拒绝。
- `title` 以 Go `strings.TrimSpace`(Unicode White_Space)后的持久化值计,最多 120 个 Unicode code
point;`sku_color`、`sku_size` 同样按持久化值计,各最多 80 个 Unicode code point。非法 UTF-8
必须先拒绝,不能把替换字符当作合法 code point;为消除 Go 与 Python 默认 trim 差异,U+001C--U+001F
四个 C0 分隔符无论位置一律拒绝;超限不得截断。
- `goods_id` 只允许 1--32 位 ASCII 数字;规范金额只允许 1--32 个 ASCII 字符。
- `max_total_price` 是本任务允许创建待付款订单的总额上限,不是参考单价。
- 成功只产生 `DRAFT`;不得创建授权、开放设备领取或触发真机。
@@ -85,10 +108,10 @@
```json
{
"start_key": "018f...",
"start_key": "63c9f507-7473-4fa6-8d71-8786c34c6301",
"tasks": [
{"task_id": "018f-task-1", "expected_task_version": 1},
{"task_id": "018f-task-2", "expected_task_version": 1}
{"task_id": "83c9f507-7473-4fa6-8d71-8786c34c6301", "expected_task_version": 1},
{"task_id": "93c9f507-7473-4fa6-8d71-8786c34c6301", "expected_task_version": 1}
]
}
```
@@ -107,11 +130,11 @@
```json
{
"start_key": "018f...",
"start_key": "63c9f507-7473-4fa6-8d71-8786c34c6301",
"authorized_count": 2,
"tasks": [
{"task_id": "018f-task-1", "task_version": 2, "authorization_id": "018f-auth-1"},
{"task_id": "018f-task-2", "task_version": 2, "authorization_id": "018f-auth-2"}
{"task_id": "83c9f507-7473-4fa6-8d71-8786c34c6301", "task_version": 2, "authorization_id": "a3c9f507-7473-4fa6-8d71-8786c34c6301"},
{"task_id": "93c9f507-7473-4fa6-8d71-8786c34c6301", "task_version": 2, "authorization_id": "b3c9f507-7473-4fa6-8d71-8786c34c6301"}
],
"payment_automated": false
}
@@ -142,7 +165,7 @@
```json
{
"device_id": "desk-01",
"device_id": "e3c9f507-7473-4fa6-8d71-8786c34c6301",
"client_version": "0.1.0",
"adb_serial": "192.168.0.173:5555",
"android_release": "16",
@@ -155,12 +178,22 @@
### `POST /api/v1/tasks/claim-next`
请求携带 `device_id`、`session_id`、`claim_request_id`。领取与授权绑定且具租约:
设备 id 只来自已经认证的 `X-CMBuyer-Device-ID`,不得放进 JSON。请求体上限 4096 字节,只接受
以下两个字段;二者都必须是规范小写 UUIDv4,未知字段、重复字段和额外 JSON 均拒绝:
```json
{
"session_id": "23c9f507-7473-4fa6-8d71-8786c34c6301",
"claim_request_id": "33c9f507-7473-4fa6-8d71-8786c34c6301"
}
```
成功领取或同一会话恢复返回 `200`:
```json
{
"task": {
"id": "018f-task",
"id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
"version": 3,
"title": "纯棉短袖",
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375",
@@ -171,24 +204,78 @@
"max_total_price": "30.00"
},
"authorization": {
"id": "018f-auth",
"id": "73c9f507-7473-4fa6-8d71-8786c34c6301",
"task_version": 2,
"expires_at": "2026-08-04T10:00:00Z"
},
"attempt": {
"id": "018f-attempt",
"claim_token": "opaque-single-claim-token",
"id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
"claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"claim_generation": 1,
"lease_expires_at": "2026-08-04T09:05:00Z"
}
}
```
- 只返回有 `ACTIVE` 授权的 `PENDING`;服务端在一个事务中转为 `CLAIMED` 并创建 attempt。
- 同一 `claim_request_id` 同载荷重放同一结果;并发设备只有一个成功。
- 一个设备有未结束领取时优先重放该领取,不能悄悄领第二条。
- 设备认证发生在 Content-Type 解析和 body 读取前。事务的第一条数据库业务语句取得 SQLite 写入
位置并再次条件确认设备仍为 `ACTIVE`;之后才允许查幂等记录、候选或返回 EMPTY/冲突。
- 只返回 `PENDING + ACTIVE + 严格未过期` 且 task/version/规格/数量/总价快照完全一致的最早授权;
服务端在同一事务中创建唯一 attempt/claim/request,并转为 `CLAIMED`。
- 同一 `claim_request_id` 同设备、同 session 稳定重放原结果;同键异载荷返回
`409 {"error":"idempotency_conflict"}`。没有候选返回空 `204`,且 EMPTY 也持久化稳定重放。
- claim 持久化完整成功响应快照;领取后的 task/authorization 源行变化不得让旧 request 的标题、规格、
数量、金额、版本或到期时间漂移。每次首次构造和旧 request 重放都重新校验持久化响应快照的字段
上限;源快照不一致时,新恢复/续租失败闭合。
- 响应内 `title` 最多 120 个 Unicode code point,`sku_color` / `sku_size` 各最多 80 个;`goods_id`
为 1--32 位 ASCII 数字,`max_total_price` 为最多 32 个 ASCII 字符的规范金额。创建、授权快照、
candidate、持久化 claim snapshot 和 HTTP 输出共用同一合法域;既有畸形行只失败闭合,不迁移、
截断或改写。candidate 查询必须先读取 task 与 authorization 两侧字段并分别验证;任一侧畸形必须
回滚且不得持久化 `EMPTY`,只有两侧均合法但快照不一致时才跳过。最坏合法字段组合编码后必须明确
小于既有 32 KiB claim 响应上限。
- 一个设备最多有一个未关闭 claim。同 session 且租约有效时重放原 attempt;同一 attempt 已按服务端
首事件原子进入 `ORDERING` 时也只在 task version 恰好为 claim 版本 +1 时恢复。不同 session、租约
过期或业务状态异常固定返回 `409 {"error":"claim_requires_manual"}`,不释放、不转领、不新建 attempt。
- `claim_token` 是 32 字节 HMAC 的 64 位小写十六进制表示,只证明一个 attempt 的归属,不是提交许可。
SQLite 仅保存随机 nonce 与 token SHA-256;同一 secret 重启后重建相同 token,错误 secret 拒绝启动。
- 响应不得包含自由动作脚本、CSS/XPath、通用坐标或支付能力。
### `POST /api/v1/tasks/{id}/lease/renew`
请求体同样限 4096 字节并执行严格 JSON 校验:
```json
{
"renew_request_id": "43c9f507-7473-4fa6-8d71-8786c34c6301",
"session_id": "23c9f507-7473-4fa6-8d71-8786c34c6301",
"attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
"claim_generation": 1,
"claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"expected_lease_expires_at": "2026-08-04T09:05:00Z"
}
```
成功返回 `200`;响应不回显 token:
```json
{
"task_id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
"attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
"claim_generation": 1,
"lease_expires_at": "2026-08-04T09:06:00Z"
}
```
- 原设备/session/attempt/generation/token 必须同时匹配,当前租约与授权都必须严格晚于服务端 UTC
当前时间,`expected_lease_expires_at` 必须逐字等于数据库当前值;边界相等即过期且不能复活。
- 新到期时间是 `min(server_now + CMBUYER_CLAIM_LEASE_TTL, authorization.expires_at)`。续租不改变
token、generation、任务版本或业务状态。
- 成功续租先持久化 request 与响应;同一 `renew_request_id` 同载荷只重放旧响应,不再次 CAS 或延长。
同键异载荷返回 `idempotency_conflict`;非当前 claim 固定返回 `claim_not_current`,两者均为 `409`。
claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限为
`413 {"error":"request_too_large"}`,Content-Type 错误为
`415 {"error":"unsupported_media_type"}`;设备认证/事务内撤销为无诊断 `401`,存储故障为无诊断 `503`。
### 事件与证据
事件只包含固定 `step` / `outcome` / `reason_code` 和非敏感摘要。禁止把完整 XML、地址、手机号、
@@ -216,12 +303,14 @@
- 恰好一个带 `Content-Type: image/png` 的显式文件;除上述六个元数据字段外,未知或重复字段均拒绝。
- 单文件最多 10 MiB、单边最多 8192 px、总像素最多 16,777,216;服务端校验 PNG 魔数、完整解码、
字节数、尺寸与调用方声明的 64 位小写 SHA-256。
- `attempt_id` 必须由数据库复合外键证明属于 URL 中的 task。认证必须先于 Content-Type 解析和请求体读取。
- 同一设备主体和 `upload_key` 的同载荷重放返回原资产;任务、attempt、截图或元数据变化返回 `409`。
- `attempt_id` 必须由数据库复合外键证明属于 URL 中的 task,且首次写入必须存在由认证设备持有的
未关闭 claim;设备 A 不能向设备 B 的 attempt 上传。认证必须先于 Content-Type 解析和请求体读取。
- 同一设备主体和 `upload_key` 的同载荷重放返回原资产;即使 claim 后续由人工关闭,已成功资产仍先
重放历史结果。关闭后不得用新 upload key 写新证据;任务、attempt、截图或元数据变化返回 `409`。
- 首次成功返回 `201`,幂等重放返回 `200`。响应只含资产 id、关联 id、kind/tier、hash、字节数、
MIME、宽高和采集时间,不含设备 token、原文件名或存储路径。
- T-301 接入真实设备 Bearer 身份前,生产 `DeviceAuthenticator` 固定拒绝全部上传;不得使用管理员
session、临时 token 或共享密钥代替设备身份。
- 生产上传使用逐请求 SQLite 设备认证;空凭据库、未知或已撤销设备均拒绝。不得使用管理员 session、
临时共享密钥或其他身份代替设备凭据。
### `POST /api/v1/purchase-attempts/{aid}/submission-fence`
@@ -229,11 +318,11 @@
```json
{
"fence_key": "018f-fence-request",
"task_id": "018f-task",
"fence_key": "e3c9f507-7473-4fa6-8d71-8786c34c6301",
"task_id": "13c9f507-7473-4fa6-8d71-8786c34c6301",
"expected_task_version": 3,
"authorization_id": "018f-auth",
"claim_token": "opaque-single-claim-token",
"authorization_id": "73c9f507-7473-4fa6-8d71-8786c34c6301",
"claim_token": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"selected_color": "黑色CHA(纯棉)",
"selected_size": "M(建议100-115)",
"gate1_unit_price": "12.88",
@@ -253,7 +342,7 @@
```json
{
"submission_id": "018f-submission",
"submission_id": "f3c9f507-7473-4fa6-8d71-8786c34c6301",
"status": "FENCED",
"click_permitted": true,
"submit_text": "提交订单"
@@ -270,10 +359,10 @@
```json
{
"result_key": "018f-result",
"attempt_id": "018f-attempt",
"result_key": "03c9f507-7473-4fa6-8d71-8786c34c6301",
"attempt_id": "53c9f507-7473-4fa6-8d71-8786c34c6301",
"observation": "SUBMITTED",
"evidence_asset_id": "018f-asset"
"evidence_asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301"
}
```
@@ -288,30 +377,62 @@
### 文本和金额校验
- 规格字段:Unicode 规范化后精确相等;不得包含、前缀、编辑距离或 AI 猜测。
- `goods_id`:仅 ASCII 十进制数字,canonical URL 中唯一。
- 金额:`0.01` 到系统配置上限,至多两位小数;规范化后再比较和持久化。
- 标题/规格字段:输入按 Go `strings.TrimSpace` 的 Unicode White_Space 集形成实际持久化值;title 最多
120 个 Unicode code point,颜色与尺码各最多 80 个。服务端先拒绝非法 UTF-8,再计 code point;
U+001C--U+001F 四个 C0 分隔符在任意位置均拒绝,客户端使用同一固定空白集而不依赖 Python
`str.strip()` 默认语义。不按 UTF-8 字节或视觉 grapheme 计数,不截断超限值。规格比较仍为规范化后
精确相等;不得包含、前缀、编辑距离或 AI 猜测。
- `goods_id`:仅 1--32 位 ASCII 十进制数字,canonical URL 中唯一。
- 金额:`0.01` 到系统配置上限,至多两位小数;规范化后必须是最多 32 个 ASCII 字符,再比较和持久化。
- 数量:正整数,服务端与设备均设置合理上限;不能从字符串静默截断。
## 三、采购工具本地模块合约
### `TaskSource` / `ResultSink`
### T-303 已实现端口
```python
class TaskSource(Protocol):
def claim_next(self, session: Session) -> ClaimedPurchase | None: ...
def renew_lease(self, claim: Claim) -> Lease: ...
def claim_next(self, credentials: DeviceCredentials, request: ClaimRequest) -> ClaimedTask | None: ...
def renew(self, credentials: DeviceCredentials, request: RenewRequest) -> RenewResult: ...
class ResultSink(Protocol):
def append_events(self, claim: Claim, events: list[TaskEvent]) -> None: ...
def upload_screenshot(self, claim: Claim, asset: ScreenshotAsset) -> AssetRef: ...
def fail_attempt(self, claim: Claim, failure: AttemptFailure) -> None: ...
def create_submission_fence(self, claim: Claim, proof: SubmissionProof) -> SubmissionPermit: ...
def report_submission_result(self, permit: SubmissionPermit, result: SubmissionResult) -> None: ...
class EvidenceSink(Protocol):
def upload(self, credentials: DeviceCredentials, evidence: EvidenceUpload) -> AssetReceipt: ...
```
执行器不能依赖具体 HTTP 或 Excel 实现。`SubmissionPermit` 只能由 `ResultSink` 的服务端成功响应构造,
业务代码不能手工 new 一个许可。
T-303 只实现 claim/renew 与 `SKU_PANEL_GATE_1` 单张 PNG;不得用运行时 `NotImplementedError` 伪造
events/fail/fence/result 或完整 `ResultSink`。T-304/T-306 必须通过 `DurableClientGateway` 调用:它先把
同一个 request/upload key 与完整载荷写入 SQLite,再最多发送一次 HTTP;401 保留 `PENDING`,由用户修复
同一 device id 的 Bearer 后显式重放;网络、超时、503、截断、非法/未知 2xx 同样只保留原槽。协议/409
终止槽但不换 key。成功响应落库失败时,重启仍用原 key 向服务端恢复事实。
金额按服务端合法域接受规范 ASCII 十进制正数字符串(最低 `0.01`,恰好两位小数、无前导零,最多
32 个 ASCII 字符);`goods_id` 只接受 1--32 位 ASCII 数字,title 最多 120 个 Unicode code point,
颜色与尺码各最多 80 个。客户端用与 Go `strings.TrimSpace` 相同的固定 Unicode White_Space 集校验
持久化文本,并与服务端共同拒绝任意位置的 U+001C--U+001F;不得截断或修复漂移响应。wire 整数为正 int64,拒绝 bool。claim 成功
响应总上限仍为 32 KiB,最坏合法字段组合由双端契约测试证明严格小于该值。RFC3339Nano 按 0--9 位
小数的纳秒时间轴比较,不能用 Python 微秒精度截断。
### 本地恢复合约
- 数据库固定为 `%LOCALAPPDATA%\cmbuyer\state\client-state.sqlite3`,WAL + `synchronous=FULL`;Windows
缺少 `LOCALAPPDATA` 时失败,不回退到 home 创建第二套状态。
- device token 与 claim token 的原始 32 字节只以当前用户 DPAPI 密文 BLOB 入库;前者 context 绑定
profile+device,后者绑定 profile+attempt,跨行交换密文会解密失败。claim token 不可更换;有
pending/open 状态时冻结 service/device/ADB/transport/轮询与超时配置,仅允许同 device id 修复 Bearer;
idle 时切换 device id 也必须同时提供新 token。
- `Global\cmbuyer-<db-path-hash>` named mutex 在任何 SQLite/DPAPI 打开前取得;同一状态库跨 Windows
session 只允许一个采购工具进程。
- 运行根目录与数据库路径在构造时固化为绝对路径;之后 cwd 改变不得打开第二套库或绕过原 mutex。
- polling session、claim history、renew request 和 evidence marker/slot 都是 append-only 历史;当前行用
`closed_at IS NULL` partial unique 表示。恢复或发送前在同一 SQLite 读快照校验整张状态图,冗余列、
snapshot、request、claim token、marker、slot、receipt 任一不一致都零 HTTP 失败闭合。
- evidence 槽唯一键是 `(attempt_id, kind)`。首次发送前固定显式路径的 regular/non-reparse 文件 identity、
size、mtime、SHA-256、IHDR 尺寸与字节;pending 时变化即停,并始终用首次保存的 exact metadata 重放
相同 multipart。成功后 receipt 成为事实,源文件变化或删除只返回原 receipt,不再次上传;receipt
尺寸必须与本地 IHDR 一致。客户端做签名/IHDR/尺寸/hash 防御;采购服务仍负责完整 PNG 解码权威校验。
完整 `ResultSink` 只有在 T-205/T-208 服务端契约完成后才由后续任务组合;`SubmissionPermit` 只能由
服务端首次明确成功响应构造,业务代码不能手工创建。
### 真机能力分层
+55 -31
View File
@@ -11,7 +11,7 @@
## 当前快照
- 日期:2026-08-04
- 日期:2026-08-05
- 阶段:**Phase 1 · T-103 规格选择/读价与 Phase 2 安全服务端任务并行**
- MVP 形态:手工填链接建单 → 批量“开始采购”并签发一次性授权 → 定时领取 → 同一趟精确
选规格 / 三道价格闸门 / 提交围栏 / 点击一次 → 待付款。管理员点击“开始采购”即授权桌面端
@@ -19,15 +19,21 @@
- 技术栈:已定。采购服务(`admin/`)使用 Go 1.23+ / gin / SQLite;采购工具(`client/`)
使用 Python 3.11+ / uiautomator2 / PySide6。
详见 [`03-tech-stack.md`](03-tech-stack.md)
- 生产代码:`admin/` 已有最小 Go 服务、健康检查、核心领域模型、SQLite 迁移与任务状态机;
- 生产代码:`admin/` 已有登录/会话、手工 DRAFT 建单、批量开始采购与一次性授权、任务详情、内部
原始截图、设备身份,以及已授权任务的原子领取/幂等重放/租约续期;
`client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB
连接边界、本地基线取证 CLI、受限商品链接打开取证 CLI、人工声明规格面板状态的只读取证 CLI,
以及绑定 PKG110 / Android 16 / 拼多多 8.17.0 的规格证据确定性脱敏 CLI;尚无规格选择、价格读取或下单流程
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 80 项离线单元测试
(全部 mock,不连接真机)
- 数据:SQLite v2 单趟核心表与领域状态机已落成,旧 `spec_trials`、`authorized_unit_price` 和
两趟状态已由受保护迁移移除;无业务实例数据。T-203 可以基于新模型实现批量“开始采购”与
一次性授权事务。
绑定 PKG110 / Android 16 / 拼多多 8.17.0 的证据工具,以及受控规格入口、精确规格选择、SKU 当前价
读取、原始截图和一次安全退出能力;并已具备严格 localhost claim/renew/evidence HTTP、Windows DPAPI
凭据保护、SQLite append-only 恢复状态图、Global named mutex 与 durable gateway。原生桌面主界面、
显式本地配置、停止/重启会话边界和安全轮询协调器已实现;未注入单趟执行 consumer 时保持零领取。
独立的一次性尺码 reveal 取证脚本已就绪,但尚未成为生产 Flow;真机能力与桌面界面仍待最终人工
验收,数量/确认页/提交仍未开放。
- 测试:采购服务已覆盖登录、建单、授权、详情/证据、设备身份、迁移、原子 claim/renew 与竞态;
采购工具 255 项离线单元测试(全部 mock,不连接真机)。
- 数据:受保护迁移已落到 `00005_task_claims.sql`。旧两趟模型已移除;开始采购会锁定任务快照并签发
一次性授权,领取会创建唯一 attempt/claim、HMAC claim token 与有界租约。T-211 已统一 title、SKU、
goods_id 与金额的双端 wire 上限,并证明最坏合法 claim 响应小于 32 KiB。仓库不含业务实例数据。
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
优先使用合规的既有 venv;仅在其缺失时才从 Python Launcher 已安装版本中选择最高的 Python 3.11+,
并且不覆盖低版本环境;成功后打印真实启动命令。
@@ -36,23 +42,31 @@
- 当前设备门禁:人工已确认拼多多 8.17.0、goods_id `937122477375` 的衣服商品只能通过“快要抢光”
打开规格面板;T-110 已获项目所有者批准,只把该证据/版本绑定的精确唯一入口作为受控导航。
T-103 的隔离 capability 仍不得包含数量、确认页、提交订单、付款或通用点击;这些动作由后续任务
分别取证后才接入生产单趟流程。T-103 的 v5 派生 XML 已由人确认无实际地址/
手机号,并保留目标预选规格、顶部当前价“快卖完 ¥12.88”和原价“¥29.88”;底部“提交订单 ¥12.88”
继续属于硬拒绝区。派生截图虽然把顶部价格遮住一半,但项目已停止遮罩器开发,视觉完整性不再阻塞
规格选择与读价;T-204 将直接上传内部原始截图供管理员查看。T-010 已允许不依赖真机字段的 T-201
和只创建 `DRAFT` 的 T-202 并行。T-209 已完成 schema / 状态机单趟迁移;T-203 服务端
“开始采购”授权事务可立即推进。T-205 起实际 attempt / 真机字段继续等待 T-103。
分别取证后才接入生产单趟流程。T-103 旧 v5 派生 XML 中的“打开即目标预选”已被 2026-08-05
五组最新 live 证据纠正:真实初态为颜色与尺码均未选,目标颜色选中后尺码仍未选且不在视口;
S 非目标值与恢复目标 M 的前后态已由人确认。底部“提交订单 ¥12.88”继续属于硬拒绝区。项目已停止
遮罩器开发,T-204 已允许认证设备上传内部原始截图供管理员查看。入口父链证据已由人确认并
最新失败证据已纠正入口绑定:此前实现误点商品内容区同名促销小字;真实入口是底部购买区第一行
`快要抢光 + 金额` 的精确文本叶节点中心。第二行“免拼购买”只能作为不可点击的证据绑定兄弟节点
校验结构,绝不作为选择器或目标;按钮金额不作为价格来源。提交 `2f1380d` 已把入口改为只点击
第一行中心 `(865,2218)`,旧内容区小字为零点击反例并保留一次点击无重试。T-103 还缺一组窄动作
证据:颜色已选且尺码未选/隐藏时执行一次受控 reveal,动作后 M 仍未选但已可见。提交 `e8ca738`
已增加与生产 Flow 隔离的固定一次 reveal 证据脚本:只在完整前置与安全通道两次重证后发送一次,
RPC 结果不明只读调和且不重试,成功不 Back 并等待人工核对;在该证据获人确认前仍不把 reveal
接入生产 Flow。之后才进行入口、颜色、reveal、目标 M、读取 `12.88` 和一次 Back 的最终人工真机验收。
完整事件链必须等待 T-107 与 T-210:先让 Gate2/Gate3 证据进入真实 schema,再实现 T-205 事件,
不用 mock 或无证据成功事件抢跑。
## 当前目录要点
| 路径 | 状态 | 说明 |
| --- | --- | --- |
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-203、T-209) | T-111 单趟契约、T-202 手工 DRAFT 建单及 T-209 单趟 schema 已完成;T-103、T-203 并行推进 |
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-211、T-301~T-308、T-400~T-405) | T-303、T-211 已完成;T-304 代码完成后保持 DOING 等人工 UI 复核;T-103 等最终真机验收;双端围栏、真实提交、调和、打包与只读验收均已落成任务 |
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 |
| `client/` | 已初始化 | Python 3.11+ 包、依赖源、PySide6 最小入口、显式 serial 的基线/商品打开/规格面板只读取证、确定性证据脱敏、离线测试与 wheel 元数据检查;无规格选择、价格读取或下单流程 |
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含建单/授权/详情/证据/设备身份与原子 claim/renew;不执行真机动作 |
| `client/` | 已初始化 | Python 3.11+、PySide6/uiautomator2、固定双 Tab 主界面、安全轮询、严格 HTTP/DPAPI/SQLite 恢复底座及受控规格选择/读价/安全退出;数量、确认页和提交未开放 |
| `init.ps1` / `init.sh` | 已完成 | 统一安装与离线验证入口;PowerShell 优先复用合规 venv,缺失时自动选择最高的 Python 3.11+,Unix 缺工具链明确失败 |
## 任务状态
@@ -68,11 +82,21 @@
- 已完成 T-010(安全并行门禁)、T-201(管理员登录与会话)与 T-202(手工 DRAFT 建单和
基础列表)。T-202 已通过主 agent 独立审查、竞态测试与完整门禁并合入主线,仍只创建/展示
`DRAFT`,未实现授权、设备领取或采购执行。
- 已完成 T-110(受控规格入口边界)与 T-111(开始采购授权的单趟契约)。T-103 已恢复为
`DOING`,以 `SkuSelectionFlow` 继续最小 fixture、精确规格和读价,不实现数量、确认页或提交;
内部原始截图上传交给 T-204。admin 方向已完成 T-209,正在转入 T-203“开始采购”授权事务。
- 已完成 T-209:SQLite v2 已迁移为单趟授权、采购尝试和提交围栏模型,并删除旧试选领域模型;
迁移和状态机护栏已通过完整门禁。下一项采购服务任务为 T-203 批量“开始采购”与一次性授权。
- 已完成 T-110、T-111、T-203、T-204、T-301 与 T-302。服务端已能授权、认证设备、原子领取、
稳定重放和续租;claim token 不是提交许可,尚无事件、提交围栏或结果 API。
- T-103 四态 classifier、精确颜色/S→M 选择、滚动态读价与独立一次性 reveal 取证脚本已实现并通过
独立审计,仍为 `DOING` 等人工真机验收;T-104~T-107 按数量/确认页风险逐段开放。真机运行要求
同一物理手机只保留一个 ADB 通道;CLI 已增加不回显页面
正文的固定白名单失败阶段码;`sku_entry` 动态页面误判已按最新真机证据修复,覆盖点击中心的浮窗
会保持零点击,入口失败也已细分为前置、发现、点击和面板后置验证四个固定子阶段。服务端后续顺序已收紧为
T-210 → T-205 → T-206 → T-207 → T-208。
- T-303 已完成并通过三重审查;T-304 的轮询会话、配置页和固定双 Tab 主界面已通过两轮独立代码审计,
当前 255 项客户端测试与根门禁通过,继续保持 `DOING` 等原生 Windows DPI、主题、高对比度、
Narrator 和纯键盘人工验收。客户端后续按依赖推进 T-306/T-307/T-308,再与 T-305 汇合到
T-400 离线一次性提交安全编排。围栏后管理链为 T-403 → T-402;T-401 必须在新的真实单趟重新
通过三闸门,首次点击后固定进入 `UNKNOWN`/人工调和,T-405 打包后由 T-404 只读验收。
- T-211 已完成 claim wire 字段上限闭合:创建、授权、candidate、持久化快照、重放、HTTP 输出和
客户端解析使用同一合法域;单侧畸形历史数据失败闭合且不写 EMPTY,最坏合法响应严格小于 32 KiB。
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
可读字段时必须先回修原型与交互清单。
@@ -152,16 +176,16 @@ D:\Portable\adb\adb.exe devices -l
不得提交 Git。T-102 已由人确认 goods_id `958756616606` 的目标商品及截图/XML 隐私,并完成验收。
T-103 已确认当前衣服商品只能从精确文案“快要抢光”进入规格面板;T-110 只批准该证据/版本绑定入口,
不授权“免拼购买 / 单独购买 / 直接拼成”等其他文案。面板刚打开时目标颜色“黑色CHA(纯棉)”与尺码
“M(建议100-115)”已经自动选中。v5 派生 XML 与人工观察已证明顶部当前价为“快卖完 ¥12.88”、
并列原价为“¥29.88”;促销前缀会变化,正式读价不能把该文案作为主判据,必须以面板结构、价格角色、
严格金额格式和唯一性组合判断,冲突时返回 unreadable。
不授权“免拼购买 / 单独购买 / 直接拼成”等其他文案。2026-08-05 五组 live 证据证明面板刚打开时两维
均未选,选择目标颜色后尺码仍未选且在视口外;S 与恢复 M 的 exact selected 切换已经人工确认。
初态与滚动态的价格文案、坐标和容器会整体变化,必须按完整 profile 判断,禁止跨 profile 拼装。
正式闸门一只在目标颜色和 M 均精确选中后,从已取证滚动态的只读价格角色读取 `12.88`;原价
`29.88`、详情页数字和底部提交按钮价均不得成为候选,冲突时返回 unreadable。
本轮不再运行或修改截图遮罩器。T-103 先用本机证据验证真机能力;T-204 再把原始规格页截图上传采购
服务,允许页面中已显示的地址和手机号,完整 XML 不上传。代码从已确认的派生 XML 提取最小 fixture,
并在真机运行时只返回获准摘要。T-111 完成后,下一步实现 `SkuSelectionFlow` 的受控入口、
按维度精确选择、选中态读回、SKU 单价读取与安全退出;完成离线主审后,由人先把两个维度改成明确
记录的非目标值,再执行真机脚本确认恢复目标规格与 `12.88` 单价。生产原始截图展示/上传由 T-204 处理。
本轮不再运行或修改截图遮罩器。T-103 已从五组本机 live 证据提取不含个人信息的最小 fixture,
并在真机运行时只返回获准摘要;原始截图展示/上传由 T-204 处理,完整 XML 不上传。面板四态
classifier、颜色与 S→M 精确选择、滚动态价格判据和独立一次性 reveal 证据脚本已经落地;生产 Flow
中的受控 reveal 仍必须等待动作前后 M 均未选的真机证据和人工确认,最终完整链路也必须由人验收。
## 关键背景

Some files were not shown because too many files have changed in this diff Show More