Compare commits

..
Author SHA1 Message Date
QiuSW 89648880bc feat(admin): add routed task evidence details 2026-08-04 19:42:35 +08:00
QiuSW 9a4d11f74b chore(tasks): start T-204 2026-08-04 18:42:09 +08:00
QiuSW 79284576ef docs(tasks): define T-204 detail evidence scope 2026-08-04 18:41:21 +08:00
QiuSW 20aaba0919 docs(tasks): complete T-203 2026-08-04 18:27:21 +08:00
QiuSW 03a067e29d merge: T-203 batch purchase authorization 2026-08-04 18:24:44 +08:00
QiuSW 5dcff4b15a feat(admin): authorize batch purchase starts 2026-08-04 18:24:39 +08:00
QiuSW ce9d6ca285 merge: T-103 verified entry parent chain 2026-08-04 18:21:38 +08:00
QiuSW 44586fef38 fix(client): bind SKU entry to verified parent chain 2026-08-04 18:21:30 +08:00
QiuSW e379d50101 chore(tasks): extend T-203 auth review scope 2026-08-04 18:18:19 +08:00
QiuSW 13547728fc docs(tasks): record T-103 entry parent evidence 2026-08-04 18:01:59 +08:00
QiuSW 0b5c561ed6 docs(tasks): record T-103 offline review 2026-08-04 17:50:46 +08:00
QiuSW 5f650f18b1 merge: T-103 offline SKU selection implementation 2026-08-04 17:48:29 +08:00
QiuSW b49a9b4abe feat(client): implement verified SKU selection flow 2026-08-04 17:47:58 +08:00
QiuSW 96f774ed96 docs(tasks): start T-203 purchase authorization 2026-08-04 17:16:48 +08:00
QiuSW 1f20271366 docs(tasks): sync T-209 completion 2026-08-04 17:15:12 +08:00
QiuSW 27726f4dde docs(state): record T-209 completion 2026-08-04 17:03:33 +08:00
QiuSW f85ef5f714 merge: T-209 single-pass schema 2026-08-04 17:02:29 +08:00
QiuSW e04f05b20b feat(admin): migrate core schema to single-pass model 2026-08-04 17:01:35 +08:00
QiuSW e20457b6db docs(tasks): define T-203 purchase authorization 2026-08-04 16:58:37 +08:00
QiuSW b5f45b87a5 docs(state): record T-202 completion 2026-08-04 16:41:22 +08:00
QiuSW 64a7468cab merge: T-202 draft task creation 2026-08-04 16:38:36 +08:00
QiuSW 442ab88fd7 docs(tasks): define T-209 single-pass schema migration 2026-08-04 16:38:29 +08:00
QiuSW d38cfb61af feat(admin): add draft task creation 2026-08-04 16:33:22 +08:00
QiuSW da540bfdf6 merge: authorized single-pass purchase contract 2026-08-04 16:25:42 +08:00
QiuSW cfd5440ac0 docs(architecture): adopt authorized single-pass purchase 2026-08-04 16:25:34 +08:00
QiuSW 8ba9b231f4 docs(tasks): start single-pass purchase redesign 2026-08-04 15:53:16 +08:00
QiuSW efeb2d958a docs(architecture): allow internal raw evidence screenshots 2026-08-04 15:47:47 +08:00
QiuSW 549099ad24 merge: T-103 observed price prefix 2026-08-04 15:23:10 +08:00
QiuSW 76a522f13f fix(client): bind T-103 observed price prefix 2026-08-04 15:23:02 +08:00
QiuSW 5088d544f2 merge: T-103 refined prefix diagnostics 2026-08-04 15:16:44 +08:00
QiuSW 6e001b2729 fix(client): refine T-103 prefix diagnostics 2026-08-04 15:16:30 +08:00
QiuSW cea27ff7ef docs(tasks): define T-202 draft creation 2026-08-04 15:08:13 +08:00
QiuSW 1c35155c2d merge: T-201 administrator sessions 2026-08-04 15:04:38 +08:00
QiuSW babb530b99 docs(tasks): complete T-201 admin session 2026-08-04 15:04:25 +08:00
QiuSW 55d9e09bda merge: main into T-201 2026-08-04 14:59:28 +08:00
QiuSW 47c0844f9c feat(admin): add administrator sessions 2026-08-04 14:57:40 +08:00
QiuSW 88d8f77417 merge: T-103 safe price diagnostics 2026-08-04 14:56:50 +08:00
QiuSW f7efa4a161 docs(tasks): record T-103 safe diagnostics 2026-08-04 14:56:41 +08:00
QiuSW ef1ac60ac5 fix(client): diagnose T-103 price text safely 2026-08-04 14:55:14 +08:00
QiuSW 5332d67b5e merge: T-103 sanitizer v4 2026-08-04 14:48:44 +08:00
QiuSW 5f6b3ce01a docs(tasks): record T-103 sanitizer v4 2026-08-04 14:48:36 +08:00
QiuSW 824a628733 Merge branch 'main' into task/t-103-sku-panel 2026-08-04 14:42:57 +08:00
QiuSW 1e69d274b8 feat(client): project sanitized SKU price candidates 2026-08-04 14:42:49 +08:00
QiuSW 8ee26be95a docs(tasks): define T-201 admin session 2026-08-04 14:40:59 +08:00
QiuSW 80ed9b71e8 docs(tasks): complete T-010 parallel gate 2026-08-04 14:39:27 +08:00
QiuSW 3f2e0e5598 docs: allow safe admin parallelism 2026-08-04 14:38:20 +08:00
QiuSW a45a1b5afc docs(tasks): review first T-103 derived evidence 2026-08-04 14:20:23 +08:00
QiuSW d27fda6be3 docs(tasks): record T-103 sanitizer v3 2026-08-04 14:08:10 +08:00
QiuSW acf7e11114 fix(client): bind T-103 XML coordinates 2026-08-04 14:04:49 +08:00
QiuSW 946b064470 docs(tasks): record T-103 sanitizer v2 2026-08-04 11:59:48 +08:00
QiuSW 44c027a18f fix(client): separate screenshot and XML coordinates 2026-08-04 11:57:45 +08:00
QiuSW cf3692112c docs(tasks): record preselected SKU evidence flow 2026-08-04 11:41:54 +08:00
QiuSW dbb69a7d5e fix(client): reflect preselected SKU evidence states 2026-08-04 11:38:15 +08:00
QiuSW 3d063b9785 docs(tasks): revise T-103 evidence states 2026-08-04 11:35:18 +08:00
QiuSW 932ca8c7f3 docs(tasks): record T-103 sanitizer milestone 2026-08-04 11:25:40 +08:00
QiuSW 9b2eb74478 feat(client): sanitize T-103 device evidence 2026-08-04 11:16:11 +08:00
QiuSW 17e295e99d docs(tasks): authorize T-103 sanitizer CLI 2026-08-04 11:03:39 +08:00
QiuSW d80e4393b4 docs(tasks): record T-110 architecture validation 2026-08-04 11:00:20 +08:00
QiuSW 82f57c7233 docs(architecture): approve controlled trial SKU entry 2026-08-04 10:59:40 +08:00
QiuSW d81fc722dc docs(tasks): define controlled SKU entry boundary 2026-08-04 10:49:05 +08:00
QiuSW abeefbbea8 docs(architecture): record T-103 purchase-entry no-go 2026-08-04 10:42:28 +08:00
QiuSW b66417d80b docs(tasks): reject unsafe T-103 evidence set 2026-08-04 10:36:53 +08:00
QiuSW 5b56161fd7 docs(tasks): record T-103 evidence scaffold audit 2026-08-04 10:10:11 +08:00
QiuSW a92b8f6be0 feat(client): add T-103 manual SKU evidence capture 2026-08-04 10:08:49 +08:00
QiuSW ec8257ba8b docs(tasks): start T-103 SKU panel spike 2026-08-04 09:58:13 +08:00
QiuSW 27999c8c85 docs(tasks): define T-103 SKU panel spike 2026-08-04 09:57:07 +08:00
QiuSW 1dc83086a0 docs(tasks): complete T-102 product link validation 2026-08-04 09:51:00 +08:00
QiuSW ad55e77bda docs(tasks): record T-102 successful capture 2026-08-04 09:46:52 +08:00
QiuSW 652eca7953 docs(tasks): record T-102 foreground wait fix 2026-08-04 09:41:03 +08:00
QiuSW cb646b4974 fix(client): wait for PDD foreground after intent 2026-08-04 09:40:05 +08:00
QiuSW 9452debf66 docs(tasks): record T-102 foreground evidence 2026-08-04 09:34:53 +08:00
QiuSW 4adeb1b37f docs(tasks): record T-102 foreground mismatch 2026-08-04 09:32:54 +08:00
QiuSW 2ea28c2626 docs(tasks): record T-102 offline implementation 2026-08-04 09:27:23 +08:00
QiuSW 7040bb61d8 feat(client): add guarded product link capture 2026-08-04 09:26:37 +08:00
QiuSW e7a4be1b9b docs(tasks): start T-102 product link spike 2026-08-04 09:10:00 +08:00
QiuSW 4281b06711 docs(tasks): define T-102 product link spike 2026-08-04 09:07:44 +08:00
QiuSW 393f26de53 docs(tasks): complete T-101 device validation 2026-08-04 09:03:26 +08:00
QiuSW 71c66a074a docs(tasks): record T-101 wifi evidence 2026-08-04 08:55:31 +08:00
QiuSW 7905fa0b70 fix(client): accept whitespace in screenshot base64 2026-08-04 08:51:00 +08:00
QiuSW c4cf19fd55 docs(tasks): record T-101 wifi connectivity 2026-08-04 08:36:26 +08:00
QiuSW de8187eb5b feat(client): add T-101 device baseline 2026-08-03 19:28:39 +08:00
QiuSW f3294633c2 docs(tasks): allow T-101 adb dependency update 2026-08-03 19:07:01 +08:00
QiuSW 0fbf66836b docs(tasks): start T-101 device connectivity spike 2026-08-03 18:56:39 +08:00
QiuSW 4f4a95a55e build: establish unified project entry 2026-08-03 18:55:32 +08:00
QiuSW 637c341b95 docs(tasks): define T-101 device connectivity spike 2026-08-03 18:40:19 +08:00
QiuSW 87591e84f9 feat(admin): establish core data model 2026-08-03 18:37:48 +08:00
QiuSW 8be6d206dd docs(tasks): define T-003 unified entry 2026-08-03 18:35:12 +08:00
QiuSW bcec012c8b feat(client): initialize procurement tool skeleton 2026-08-03 18:32:00 +08:00
QiuSW 2ab5baa776 docs(tasks): expand T-002 shared document paths 2026-08-03 18:16:48 +08:00
QiuSW cf293d2382 docs(tasks): define T-004 core data model 2026-08-03 18:16:08 +08:00
QiuSW c72371ce90 feat(admin): initialize procurement service skeleton 2026-08-03 18:13:30 +08:00
QiuSW b7559a9451 docs(tasks): define parallel T-001 and T-002 2026-08-03 17:57:53 +08:00
QiuSW a63bdb8c37 docs(plan): prioritize MVP critical path 2026-08-03 17:47:22 +08:00
QiuSW 4948afc393 docs(tasks): define T-009 MVP critical path plan 2026-08-03 17:41:58 +08:00
134 changed files with 17597 additions and 1850 deletions
+7 -4
View File
@@ -10,7 +10,8 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
**系统只创建待付款订单,任何情况下都不自动付款。**
当前状态:仓库只有文档,尚未开始编码。阶段为 Phase 0。
当前状态:两端骨架、基础模型、登录和真机取证脚手架已落地;Phase 1 真机取证与不依赖页面判据的
Phase 2 服务端任务并行。实时快照见 [`docs/current-state.md`](docs/current-state.md)。
## 必读顺序
@@ -33,7 +34,9 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
系统**会**点击「提交订单」创建待付款订单,但必须满足四个前置条件(授权未消费且服务端
提交围栏已建立、闸门二通过、闸门三通过、控件唯一),且**只点一次**、点击后无论结果
都不重试。围栏申请失败或响应不明时不得点击;围栏建立后只能调和同一提交记录。
**第一趟试选的代码路径不得引用任何下单函数**,必须有测试证明不可达。
管理员点击“开始采购(只创建待付款订单)”是唯一的人类授权动作。T-103 的规格选择/读价隔离
验证路径不得引用数量、确认页、提交或付款函数;后续能力必须按真机取证任务逐段开放,并有静态
调用链测试证明未获准能力不可达。
### 2. 安全边界只能收紧
@@ -141,8 +144,8 @@ python scripts/validate_agent_context.py
**跨端契约改动必跑完整门禁**——两端会同时坏。
代码尚未初始化,上述命令在 T-001 / T-002 完成前不可运行;届时由对应任务替换为真实命令
并同步文档。
两端已初始化;跨端契约改动还需在仓库根运行 `./init.ps1`(或 `./init.sh`)完成安装、测试、
vet/build、compileall 和上下文门禁。
## 风格
+12 -20
View File
@@ -8,13 +8,14 @@
## 它做什么
一笔外部订单进来,采购人员需要去拼多多找到同款、选对颜色尺码、下单、把订单号抄回系统。
cmbuyer 把这个过程自动化,人只在两个点介入:**机器选对了吗**和**付不付款**。
cmbuyer 把这个过程自动化:管理员明确“买什么、买多少、最多多少钱”并点击开始采购,系统只
创建待付款订单,**是否付款始终由人决定**。
```text
手工填链接(MVP) / Excel · ERP(V2)
│
v
采购服务(admin/,Go) 建单 · 试选确认 · 下单授权 · 审计
采购服务(admin/,Go) 建单 · 开始采购授权 · 围栏 · 审计
│ HTTP
v
采购工具(client/,Python) 领任务 · 跑流程 · 回传
@@ -23,31 +24,22 @@ cmbuyer 把这个过程自动化,人只在两个点介入:**机器选对了
Android 手机(拼多多 App)
```
## 两趟执行
## 单趟执行
MVP 只做**任务自带商品链接**的情形,分两趟跑完:
MVP 只做**任务自带商品链接**的情形:管理员先把任务保存为 `DRAFT`,再在表格中勾选并点击
“开始采购(只创建待付款订单)”。该点击创建一次性授权,锁定商品、颜色、尺码、数量和最高总价。
| 趟次 | 做什么 |
| --- | --- |
| **第一趟 · 试选** | 开商品 → 精确勾选颜色分类和尺码 → 读单价 → 截图 → **退出释放手机** → 回传 |
| **人工确认** | 人在网页端看「机器选对了吗」→ 确认并**锁定单价** |
| **第二趟 · 下单** | 重新开商品 → 重新选同一规格 → **三道价格闸门** → 提交订单一次 → 转「待付款」 |
为什么分两趟:一台手机是瓶颈,不能停在规格面板上等人。代价是走两遍,换来手机不空闲,
且第二趟能抓住价格变动。
**三道价格闸门**:① 第一趟规格面板读价 ② 第二趟重读必须与授权价一致
③ 订单确认页「实付款」不超上限。任一道读不到或不通过即停,转人工。
采购工具领取后在同一次设备会话中完成:打开商品 → 精确选择规格 → 闸门一读 SKU 单价并校验
上限 → 设置并复核数量 → 闸门二重读规格与同价 → 进入确认页 → 闸门三校验应付总额 → 服务端
原子建立提交围栏 → 精确点击一次“提交订单” → 转待付款。中间不再回网页端等“机器选对了吗”。
价格**只在规格面板和订单确认页读**——别处的价格文本被拆成多个节点、带券后前缀、
实付价与原价混在一起,不可靠。
## 现在处于什么阶段
**Phase 0 · 地基。仓库目前只有文档,尚未开始编码。**
下一步:T-005(网页端 MVP 原型)与 T-006(桌面端 MVP 原型)的文件和自动检查已完成,
请先人工确认 6 个 HTML 原型,再进入 T-001 / T-002 骨架和生产实现。
**Phase 1 真机取证与 Phase 2 的安全服务端工作并行。** 两端骨架、基础模型、登录和真机基线已
落地;T-111 正在冻结单趟采购契约,T-103 随后继续规格精确选择与读价,T-202 等待主审合入。
详见 [`docs/current-state.md`](docs/current-state.md)。
**生死线是 M2**:真机能按链接打开商品、精确勾选颜色分类和尺码、**读到该 SKU 单价**。
@@ -74,7 +66,7 @@ MVP 只做**任务自带商品链接**的情形,分两趟跑完:
4. 规格按维度精确匹配,防前缀碰撞,找不到即停
5. 数量设置后必须读回复核
6. **三道价格闸门**,任一道读不到或不通过即停
7. **第一趟绝不下单**——试选路径不得引用下单函数
7. **能力分层**——T-103 规格验证路径不得引用数量、确认页或下单函数;后续能力逐段取证
8. 检测到外部支付交接立即停止,不读取不保存凭据
9. 检测到验证码 / 风控 / 人脸 / 短信校验立即停止,不绕过
10. 只读非敏感摘要,不提取收货地址原文、手机号、支付凭据
+40
View File
@@ -0,0 +1,40 @@
# 采购服务
启动前必须显式设置下列环境变量;服务不提供默认管理员账号、密码或会话密钥。
| 变量 | 要求 |
| --- | --- |
| `CMBUYER_ADMIN_USERNAME` | 非空管理员账号。 |
| `CMBUYER_ADMIN_PASSWORD_BCRYPT` | 非空 bcrypt 密码哈希,不接受明文密码。 |
| `CMBUYER_SESSION_SECRET` | 至少 32 字节的会话签名密钥。 |
| `CMBUYER_COOKIE_SECURE` | 可选;存在时只能精确为 `true` 或 `false`。HTTPS 部署应设为 `true`。 |
| `CMBUYER_DATABASE_SOURCE` | 已迁移 SQLite 的显式 data source。 |
| `CMBUYER_AUTHORIZATION_TTL` | 一次性授权的正 Go duration,例如 `10m`。 |
| `CMBUYER_MAX_TASK_QUANTITY` | 每条任务允许的正整数数量上限。 |
| `CMBUYER_MAX_TOTAL_PRICE` | 每条任务允许的规范正数总价上限,例如 `999.99`。 |
| `CMBUYER_EVIDENCE_DIR` | 内部原始截图的绝对私有目录;不得指向仓库或公开静态目录。 |
示例仅展示变量名,不提供可运行凭据:
```powershell
$env:CMBUYER_ADMIN_USERNAME = '<管理员账号>'
$env:CMBUYER_ADMIN_PASSWORD_BCRYPT = '<bcrypt 密码哈希>'
$env:CMBUYER_SESSION_SECRET = '<至少 32 字节的随机密钥>'
$env:CMBUYER_COOKIE_SECURE = 'true'
$env:CMBUYER_DATABASE_SOURCE = '<SQLite data source>'
$env:CMBUYER_AUTHORIZATION_TTL = '10m'
$env:CMBUYER_MAX_TASK_QUANTITY = '99'
$env:CMBUYER_MAX_TOTAL_PRICE = '999.99'
$env:CMBUYER_EVIDENCE_DIR = '<内部截图绝对目录>'
go run ./cmd/migrate -database $env:CMBUYER_DATABASE_SOURCE up
go run ./cmd/server
```
采购服务会话仅保存在当前进程内;进程重启后既有登录会话会安全失效。
管理员的“开始采购(只创建待付款订单)”只签发一次性授权并创建待付款订单的资格;服务不会自动付款,也不包含任何支付操作。
`GET /tasks/{id}` 直接访问时渲染完整详情页,任务列表以同一 URL 加载详情抽屉。内部截图只通过
`GET /evidence/{asset_id}` 向有效管理员会话提供,并始终返回 `no-store`;文件不在静态目录中。
T-301 接入真实设备凭据之前,`POST /api/v1/tasks/{id}/evidence` 的生产认证器固定拒绝全部请求。
测试可以注入 fake 设备主体验证上传契约,但不得用管理员会话、临时 token 或共享密钥绕过该边界。
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
)
func main() {
if err := run(context.Background(), os.Args[1:], os.Stderr); err != nil {
log.Print(err)
os.Exit(1)
}
}
func run(ctx context.Context, args []string, stderr io.Writer) error {
flags := flag.NewFlagSet("migrate", flag.ContinueOnError)
flags.SetOutput(stderr)
databaseSource := flags.String("database", "", "SQLite data source")
migrationDirectory := flags.String("dir", "migrations", "migration directory")
if err := flags.Parse(args); err != nil {
return err
}
if *databaseSource == "" {
return errors.New("-database is required")
}
if flags.NArg() != 1 {
return fmt.Errorf("usage: migrate -database <sqlite-data-source> [-dir <migration-directory>] <up|down|status>")
}
command := flags.Arg(0)
if command != "up" && command != "down" && command != "status" {
return fmt.Errorf("unsupported migration command %q", command)
}
database, err := sqlite.Open(*databaseSource)
if err != nil {
return fmt.Errorf("open SQLite database: %w", err)
}
defer func() {
if err := database.Close(); err != nil {
log.Printf("close SQLite database: %v", err)
}
}()
if err := migrations.Run(ctx, database, *migrationDirectory, command); err != nil {
return fmt.Errorf("run migrations: %w", err)
}
return nil
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"context"
"database/sql"
"errors"
"io"
"os"
"path/filepath"
"runtime"
"testing"
)
func TestRunUp(t *testing.T) {
databaseSource := filepath.Join(t.TempDir(), "migrate.db")
if err := run(context.Background(), []string{
"-database", databaseSource,
"-dir", migrationDirectory(t),
"up",
}, io.Discard); err != nil {
t.Fatalf("run up migration command: %v", err)
}
database, err := sql.Open("sqlite3", databaseSource)
if err != nil {
t.Fatalf("open migrated database: %v", err)
}
t.Cleanup(func() {
if err := database.Close(); err != nil {
t.Errorf("close migrated database: %v", err)
}
})
var count int
if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'tasks'`).Scan(&count); err != nil {
t.Fatalf("look up tasks table: %v", err)
}
if count != 1 {
t.Fatalf("tasks table count = %d, want 1", count)
}
}
func TestRunRequiresDatabase(t *testing.T) {
if err := run(context.Background(), []string{"up"}, io.Discard); err == nil {
t.Fatal("run without database source succeeded")
}
}
func TestRunRejectsUndeclaredCommand(t *testing.T) {
databaseSource := filepath.Join(t.TempDir(), "migrate.db")
err := run(context.Background(), []string{"-database", databaseSource, "reset"}, io.Discard)
if err == nil {
t.Fatal("run with undeclared command succeeded")
}
if _, err := os.Stat(databaseSource); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("undeclared command opened database source: stat error = %v, want not exist", err)
}
}
func migrationDirectory(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate migration command test source")
}
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
}
+69
View File
@@ -0,0 +1,69 @@
package main
import (
"errors"
"log"
"net/http"
"cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/config"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/server"
evidencestorage "cmbuyer/admin/internal/storage/evidence"
"cmbuyer/admin/internal/storage/sqlite"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks"
)
const listenAddress = ":8080"
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
configuration, err := config.LoadFromEnv()
if err != nil {
return err
}
database, err := sqlite.Open(configuration.DatabaseSource)
if err != nil {
return err
}
defer database.Close()
taskStore, err := tasks.NewSQLiteStore(database)
if err != nil {
return err
}
taskStore.SetStartPolicy(tasks.StartPolicy{AuthorizationTTL: configuration.AuthorizationTTL, MaxQuantity: configuration.MaxTaskQuantity, MaxTotalPrice: configuration.MaxTotalPrice})
detailStore, err := taskdetail.NewSQLiteStore(database)
if err != nil {
return err
}
evidenceStore, err := evidencestorage.NewStore(database, configuration.EvidenceDirectory)
if err != nil {
return err
}
router, err := server.NewRouter(server.Options{
AdminUsername: configuration.AdminUsername,
AdminPasswordBcrypt: configuration.AdminPasswordBcrypt,
Sessions: auth.NewManager(configuration.SessionSecret, configuration.CookieSecure),
Tasks: taskStore,
TaskDetails: detailStore,
Evidence: evidenceStore,
DeviceAuthenticator: evidence.RejectAllDeviceAuthenticator{},
})
if err != nil {
return err
}
err = http.ListenAndServe(listenAddress, router)
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
+46
View File
@@ -0,0 +1,46 @@
module cmbuyer/admin
go 1.23.0
require (
github.com/gin-gonic/gin v1.11.0
github.com/mattn/go-sqlite3 v1.14.49
github.com/pressly/goose/v3 v3.24.0
golang.org/x/crypto v0.40.0
)
require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
)
+122
View File
@@ -0,0 +1,122 @@
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.24.0 h1:sFbNms7Bd++2VMq6HSgDHDLWa7kHz1qXzPb3ZIU72VU=
github.com/pressly/goose/v3 v3.24.0/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk=
modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+204
View File
@@ -0,0 +1,204 @@
// Package auth 提供内存会话与 CSRF 防护。会话不落库,服务重启会安全地使所有登录失效。
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
const (
CookieName = "cmbuyer_session"
SessionLifetime = 8 * time.Hour
csrfTokenByteSize = 32
)
type session struct {
csrfToken string
authenticated bool
expiresAt time.Time
}
// Manager 签发、验证并撤销进程内会话。cookie 仅承载经过 HMAC 签名的随机 session ID。
type Manager struct {
secret []byte
cookieSecure bool
now func() time.Time
random io.Reader
mu sync.Mutex
sessions map[string]session
}
// NewManager 创建会话管理器。secret 在启动时已由 config 验证为足够长度。
func NewManager(secret []byte, cookieSecure bool) *Manager {
return &Manager{
secret: append([]byte(nil), secret...),
cookieSecure: cookieSecure,
now: time.Now,
random: rand.Reader,
sessions: make(map[string]session),
}
}
// Ensure 返回当前有效会话;不存在或过期时签发匿名会话,以保护登录表单本身的 POST。
func (manager *Manager) Ensure(writer http.ResponseWriter, request *http.Request) (csrfToken string, authenticated bool) {
if id, current, ok := manager.current(request); ok {
return current.csrfToken, current.authenticated
} else if id != "" {
manager.delete(id)
}
id, current := manager.create(false)
manager.writeCookie(writer, id, current.expiresAt)
return current.csrfToken, false
}
// IsAuthenticated 只读检查当前请求是否持有有效管理会话;它不会像 Ensure 一样创建匿名会话。
func (manager *Manager) IsAuthenticated(request *http.Request) bool {
_, current, found := manager.current(request)
return found && current.authenticated
}
// VerifyCSRF 只接受当前未过期会话中以恒定时间比较匹配的 token。
func (manager *Manager) VerifyCSRF(request *http.Request, token string) (authenticated bool, ok bool) {
_, current, found := manager.current(request)
if !found || token == "" {
return false, false
}
if subtle.ConstantTimeCompare([]byte(current.csrfToken), []byte(token)) != 1 {
return false, false
}
return current.authenticated, true
}
// RotateAuthenticated 在登录成功后撤销旧会话并签发全新认证会话,避免 session fixation 与 CSRF 复用。
func (manager *Manager) RotateAuthenticated(writer http.ResponseWriter, request *http.Request) string {
if id, _, ok := manager.current(request); ok {
manager.delete(id)
}
id, current := manager.create(true)
manager.writeCookie(writer, id, current.expiresAt)
return current.csrfToken
}
// Logout 撤销当前会话并立即清除浏览器 cookie。
func (manager *Manager) Logout(writer http.ResponseWriter, request *http.Request) {
if id, _, ok := manager.current(request); ok {
manager.delete(id)
}
http.SetCookie(writer, &http.Cookie{
Name: CookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: manager.cookieSecure,
SameSite: http.SameSiteLaxMode,
})
}
func (manager *Manager) current(request *http.Request) (string, session, bool) {
cookie, err := request.Cookie(CookieName)
if err != nil {
return "", session{}, false
}
id, expiresAt, ok := manager.verifyCookie(cookie.Value)
if !ok || !manager.now().Before(expiresAt) {
return id, session{}, false
}
manager.mu.Lock()
defer manager.mu.Unlock()
current, found := manager.sessions[id]
if !found || !manager.now().Before(current.expiresAt) {
return id, session{}, false
}
return id, current, true
}
func (manager *Manager) create(authenticated bool) (string, session) {
id := manager.randomToken()
current := session{
csrfToken: manager.randomToken(),
authenticated: authenticated,
expiresAt: manager.now().Add(SessionLifetime),
}
manager.mu.Lock()
manager.sessions[id] = current
manager.mu.Unlock()
return id, current
}
func (manager *Manager) delete(id string) {
manager.mu.Lock()
delete(manager.sessions, id)
manager.mu.Unlock()
}
func (manager *Manager) randomToken() string {
bytes := make([]byte, csrfTokenByteSize)
if _, err := io.ReadFull(manager.random, bytes); err != nil {
panic("crypto/rand failed while creating a session token")
}
return base64.RawURLEncoding.EncodeToString(bytes)
}
func (manager *Manager) writeCookie(writer http.ResponseWriter, id string, expiresAt time.Time) {
http.SetCookie(writer, &http.Cookie{
Name: CookieName,
Value: manager.signCookie(id, expiresAt),
Path: "/",
MaxAge: int(expiresAt.Sub(manager.now()).Seconds()),
Expires: expiresAt,
HttpOnly: true,
Secure: manager.cookieSecure,
SameSite: http.SameSiteLaxMode,
})
}
func (manager *Manager) signCookie(id string, expiresAt time.Time) string {
payload := id + "." + strconv.FormatInt(expiresAt.Unix(), 10)
mac := hmac.New(sha256.New, manager.secret)
_, _ = mac.Write([]byte(payload))
return payload + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (manager *Manager) verifyCookie(value string) (string, time.Time, bool) {
parts := strings.Split(value, ".")
if len(parts) != 3 || parts[0] == "" {
return "", time.Time{}, false
}
expiresUnix, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return "", time.Time{}, false
}
provided, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return "", time.Time{}, false
}
payload := parts[0] + "." + parts[1]
mac := hmac.New(sha256.New, manager.secret)
_, _ = mac.Write([]byte(payload))
if !hmac.Equal(provided, mac.Sum(nil)) {
return "", time.Time{}, false
}
return parts[0], time.Unix(expiresUnix, 0), true
}
+94
View File
@@ -0,0 +1,94 @@
package auth
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestManagerRejectsTamperedAndExpiredCookies(t *testing.T) {
manager := NewManager([]byte(strings.Repeat("s", 32)), true)
request := httptest.NewRequest(http.MethodGet, "/login", nil)
response := httptest.NewRecorder()
csrf, authenticated := manager.Ensure(response, request)
if csrf == "" || authenticated {
t.Fatalf("Ensure = (%q, %t), want anonymous CSRF session", csrf, authenticated)
}
cookie := response.Result().Cookies()[0]
if !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteLaxMode || cookie.Path != "/" {
t.Fatalf("session cookie is missing security attributes: %#v", cookie)
}
tampered := *cookie
tampered.Value = flipCookieValue(t, cookie.Value)
tamperedRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
tamperedRequest.AddCookie(&tampered)
if _, ok := manager.VerifyCSRF(tamperedRequest, csrf); ok {
t.Fatal("tampered signed cookie passed CSRF verification")
}
manager.now = func() time.Time { return time.Now().Add(9 * time.Hour) }
expiredRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
expiredRequest.AddCookie(cookie)
if _, ok := manager.VerifyCSRF(expiredRequest, csrf); ok {
t.Fatal("expired cookie passed CSRF verification")
}
}
func TestIsAuthenticatedDoesNotCreateOrDependOnCSRFValidation(t *testing.T) {
manager := NewManager([]byte(strings.Repeat("s", 32)), false)
missingSession := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil)
if manager.IsAuthenticated(missingSession) {
t.Fatal("missing session was treated as authenticated")
}
if len(manager.sessions) != 0 {
t.Fatalf("read-only authentication check created %d sessions", len(manager.sessions))
}
anonymousRequest := httptest.NewRequest(http.MethodGet, "/login", nil)
anonymousResponse := httptest.NewRecorder()
manager.Ensure(anonymousResponse, anonymousRequest)
anonymousCookie := anonymousResponse.Result().Cookies()[0]
anonymousCheck := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil)
anonymousCheck.AddCookie(anonymousCookie)
if manager.IsAuthenticated(anonymousCheck) {
t.Fatal("anonymous CSRF session was treated as authenticated")
}
loginRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
loginRequest.AddCookie(anonymousCookie)
authenticatedResponse := httptest.NewRecorder()
csrf := manager.RotateAuthenticated(authenticatedResponse, loginRequest)
authenticatedCookie := authenticatedResponse.Result().Cookies()[0]
authenticatedCheck := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil)
authenticatedCheck.AddCookie(authenticatedCookie)
if !manager.IsAuthenticated(authenticatedCheck) {
t.Fatal("valid authenticated session was not recognized")
}
if authenticated, csrfOK := manager.VerifyCSRF(authenticatedCheck, "wrong-token"); authenticated || csrfOK {
t.Fatalf("wrong token result = (%t, %t), want (false, false)", authenticated, csrfOK)
}
validRequest := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil)
validRequest.AddCookie(authenticatedCookie)
if authenticated, csrfOK := manager.VerifyCSRF(validRequest, csrf); !authenticated || !csrfOK {
t.Fatalf("valid token result = (%t, %t), want (true, true)", authenticated, csrfOK)
}
if authenticated, csrfOK := manager.VerifyCSRF(httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil), csrf); authenticated || csrfOK {
t.Fatalf("missing session result = (%t, %t), want (false, false)", authenticated, csrfOK)
}
}
func flipCookieValue(t *testing.T, value string) string {
t.Helper()
if value == "" {
t.Fatal("cannot tamper with an empty cookie")
}
if value[0] == 'A' {
return "B" + value[1:]
}
return "A" + value[1:]
}
+149
View File
@@ -0,0 +1,149 @@
// Package config 读取采购服务的启动配置。凭据只允许来自显式环境变量,避免把秘密写入代码或仓库。
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
const (
adminUsernameEnv = "CMBUYER_ADMIN_USERNAME"
adminPasswordBcryptEnv = "CMBUYER_ADMIN_PASSWORD_BCRYPT"
sessionSecretEnv = "CMBUYER_SESSION_SECRET"
cookieSecureEnv = "CMBUYER_COOKIE_SECURE"
databaseSourceEnv = "CMBUYER_DATABASE_SOURCE"
authorizationTTLEnv = "CMBUYER_AUTHORIZATION_TTL"
maxTaskQuantityEnv = "CMBUYER_MAX_TASK_QUANTITY"
maxTotalPriceEnv = "CMBUYER_MAX_TOTAL_PRICE"
evidenceDirectoryEnv = "CMBUYER_EVIDENCE_DIR"
minimumSecretLength = 32
)
// Config 是启动采购服务所需的最小安全配置。
type Config struct {
AdminUsername string
AdminPasswordBcrypt string
SessionSecret []byte
CookieSecure bool
DatabaseSource string
AuthorizationTTL time.Duration
MaxTaskQuantity int
MaxTotalPrice string
EvidenceDirectory string
}
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
func LoadFromEnv() (Config, error) {
return Load(os.LookupEnv)
}
// Load 使用 lookup 读取配置,以便在不污染进程环境的情况下测试启动边界。
func Load(lookup func(string) (string, bool)) (Config, error) {
username, err := required(lookup, adminUsernameEnv)
if err != nil {
return Config{}, err
}
passwordHash, err := required(lookup, adminPasswordBcryptEnv)
if err != nil {
return Config{}, err
}
if _, err := bcrypt.Cost([]byte(passwordHash)); err != nil {
return Config{}, fmt.Errorf("%s is not a valid bcrypt hash", adminPasswordBcryptEnv)
}
secret, err := required(lookup, sessionSecretEnv)
if err != nil {
return Config{}, err
}
if len([]byte(secret)) < minimumSecretLength {
return Config{}, fmt.Errorf("%s must be at least %d bytes", sessionSecretEnv, minimumSecretLength)
}
cookieSecure := false
if value, present := lookup(cookieSecureEnv); present {
switch value {
case "true":
cookieSecure = true
case "false":
cookieSecure = false
default:
return Config{}, fmt.Errorf("%s must be exactly true or false", cookieSecureEnv)
}
}
databaseSource, err := required(lookup, databaseSourceEnv)
if err != nil {
return Config{}, err
}
ttlText, err := required(lookup, authorizationTTLEnv)
if err != nil {
return Config{}, err
}
ttl, err := time.ParseDuration(ttlText)
if err != nil || ttl <= 0 {
return Config{}, fmt.Errorf("%s must be a positive duration", authorizationTTLEnv)
}
quantityText, err := required(lookup, maxTaskQuantityEnv)
if err != nil {
return Config{}, err
}
maxQuantity, err := strconv.Atoi(quantityText)
if err != nil || maxQuantity < 1 {
return Config{}, fmt.Errorf("%s must be a positive integer", maxTaskQuantityEnv)
}
maxPrice, err := required(lookup, maxTotalPriceEnv)
if err != nil {
return Config{}, err
}
if !canonicalMoney(maxPrice) {
return Config{}, fmt.Errorf("%s must be a canonical positive decimal", maxTotalPriceEnv)
}
evidenceDirectory, err := required(lookup, evidenceDirectoryEnv)
if err != nil {
return Config{}, err
}
if strings.TrimSpace(evidenceDirectory) != evidenceDirectory || !filepath.IsAbs(evidenceDirectory) {
return Config{}, fmt.Errorf("%s must be an absolute path without surrounding whitespace", evidenceDirectoryEnv)
}
return Config{
AdminUsername: username,
AdminPasswordBcrypt: passwordHash,
SessionSecret: []byte(secret),
CookieSecure: cookieSecure,
DatabaseSource: databaseSource,
AuthorizationTTL: ttl, MaxTaskQuantity: maxQuantity, MaxTotalPrice: maxPrice,
EvidenceDirectory: evidenceDirectory,
}, nil
}
func canonicalMoney(value string) bool {
parts := strings.Split(value, ".")
if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) != 2 || (len(parts[0]) > 1 && parts[0][0] == '0') {
return false
}
for _, part := range parts {
for _, ch := range part {
if ch < '0' || ch > '9' {
return false
}
}
}
return strings.Trim(parts[0]+parts[1], "0") != ""
}
func required(lookup func(string) (string, bool), name string) (string, error) {
value, present := lookup(name)
if !present || strings.TrimSpace(value) == "" {
return "", errors.New(name + " must be set")
}
return value, nil
}
+98
View File
@@ -0,0 +1,98 @@
package config_test
import (
"strings"
"testing"
"cmbuyer/admin/internal/config"
"golang.org/x/crypto/bcrypt"
)
func TestLoad(t *testing.T) {
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
if err != nil {
t.Fatalf("generate bcrypt hash: %v", err)
}
values := map[string]string{
"CMBUYER_ADMIN_USERNAME": "admin",
"CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash),
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
"CMBUYER_COOKIE_SECURE": "true",
"CMBUYER_DATABASE_SOURCE": ":memory:",
"CMBUYER_AUTHORIZATION_TTL": "10m",
"CMBUYER_MAX_TASK_QUANTITY": "99",
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
}
got, err := config.Load(lookup(values))
if err != nil {
t.Fatalf("Load: %v", err)
}
if got.AdminUsername != "admin" || !got.CookieSecure {
t.Fatalf("Load returned unexpected public configuration: %#v", got)
}
}
func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
if err != nil {
t.Fatalf("generate bcrypt hash: %v", err)
}
base := map[string]string{
"CMBUYER_ADMIN_USERNAME": "admin",
"CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash),
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
"CMBUYER_DATABASE_SOURCE": ":memory:",
"CMBUYER_AUTHORIZATION_TTL": "10m",
"CMBUYER_MAX_TASK_QUANTITY": "99",
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
}
tests := []struct {
name string
mutate func(map[string]string)
want string
}{
{"missing username", func(values map[string]string) { delete(values, "CMBUYER_ADMIN_USERNAME") }, "CMBUYER_ADMIN_USERNAME"},
{"invalid bcrypt", func(values map[string]string) { values["CMBUYER_ADMIN_PASSWORD_BCRYPT"] = "not-a-bcrypt-hash" }, "CMBUYER_ADMIN_PASSWORD_BCRYPT"},
{"short secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = "short" }, "CMBUYER_SESSION_SECRET"},
{"invalid secure flag", func(values map[string]string) { values["CMBUYER_COOKIE_SECURE"] = "1" }, "CMBUYER_COOKIE_SECURE"},
{"missing database", func(values map[string]string) { delete(values, "CMBUYER_DATABASE_SOURCE") }, "CMBUYER_DATABASE_SOURCE"},
{"invalid authorization ttl", func(values map[string]string) { values["CMBUYER_AUTHORIZATION_TTL"] = "0s" }, "CMBUYER_AUTHORIZATION_TTL"},
{"invalid maximum quantity", func(values map[string]string) { values["CMBUYER_MAX_TASK_QUANTITY"] = "0" }, "CMBUYER_MAX_TASK_QUANTITY"},
{"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"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
values := copyValues(base)
test.mutate(values)
_, err := config.Load(lookup(values))
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Load error = %v, want mention of %s", err, test.want)
}
})
}
}
func lookup(values map[string]string) func(string) (string, bool) {
return func(key string) (string, bool) {
value, ok := values[key]
return value, ok
}
}
func copyValues(values map[string]string) map[string]string {
copy := make(map[string]string, len(values))
for key, value := range values {
copy[key] = value
}
return copy
}
+66
View File
@@ -0,0 +1,66 @@
package domain
import (
"errors"
"time"
)
var ErrInvalidAuthorizationTransition = errors.New("invalid authorization status transition")
type AuthorizationStatus string
const (
AuthorizationStatusActive AuthorizationStatus = "ACTIVE"
AuthorizationStatusClaimed AuthorizationStatus = "CLAIMED"
AuthorizationStatusFenced AuthorizationStatus = "FENCED"
AuthorizationStatusConsumed AuthorizationStatus = "CONSUMED"
AuthorizationStatusExpired AuthorizationStatus = "EXPIRED"
AuthorizationStatusAbandoned AuthorizationStatus = "ABANDONED"
)
type OrderAuthorization struct {
ID string
TaskID string
TaskVersion int
StartKey string
GoodsID string
SKUColor string
SKUSize string
Quantity int
TotalPriceCap string
Status AuthorizationStatus
CreatedBy string
CreatedAt time.Time
ExpiresAt time.Time
}
// CanTransitionTo 围栏后的授权只能消费,不能回到可领取或可过期状态,以防重复采购。
func (status AuthorizationStatus) CanTransitionTo(next AuthorizationStatus) bool {
_, allowed := authorizationTransitions[status][next]
return allowed
}
// TransitionAuthorization 返回下一状态;未定义的授权状态转移一律失败。
func TransitionAuthorization(current, next AuthorizationStatus) (AuthorizationStatus, error) {
if !current.CanTransitionTo(next) {
return current, ErrInvalidAuthorizationTransition
}
return next, nil
}
var authorizationTransitions = map[AuthorizationStatus]map[AuthorizationStatus]struct{}{
AuthorizationStatusActive: {
AuthorizationStatusClaimed: {},
AuthorizationStatusExpired: {},
AuthorizationStatusAbandoned: {},
},
AuthorizationStatusClaimed: {
AuthorizationStatusFenced: {},
AuthorizationStatusExpired: {},
AuthorizationStatusAbandoned: {},
},
AuthorizationStatusFenced: {
AuthorizationStatusConsumed: {},
},
}
@@ -0,0 +1,52 @@
package domain_test
import (
"errors"
"testing"
"cmbuyer/admin/internal/domain"
)
func TestAuthorizationTransitions(t *testing.T) {
tests := []struct {
name string
current domain.AuthorizationStatus
next domain.AuthorizationStatus
allowed bool
}{
{"claim", domain.AuthorizationStatusActive, domain.AuthorizationStatusClaimed, true},
{"fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusFenced, true},
{"consume fenced authorization", domain.AuthorizationStatusFenced, domain.AuthorizationStatusConsumed, true},
{"expire active", domain.AuthorizationStatusActive, domain.AuthorizationStatusExpired, true},
{"abandon active", domain.AuthorizationStatusActive, domain.AuthorizationStatusAbandoned, true},
{"expire claimed before fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusExpired, true},
{"abandon claimed before fence", domain.AuthorizationStatusClaimed, domain.AuthorizationStatusAbandoned, true},
{"fenced authorization cannot expire", domain.AuthorizationStatusFenced, domain.AuthorizationStatusExpired, false},
{"fenced authorization cannot be abandoned", domain.AuthorizationStatusFenced, domain.AuthorizationStatusAbandoned, false},
{"fenced authorization cannot be claimed again", domain.AuthorizationStatusFenced, domain.AuthorizationStatusClaimed, false},
{"consumed authorization cannot restart", domain.AuthorizationStatusConsumed, domain.AuthorizationStatusClaimed, false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := test.current.CanTransitionTo(test.next); got != test.allowed {
t.Fatalf("CanTransitionTo(%s, %s) = %t, want %t", test.current, test.next, got, test.allowed)
}
result, err := domain.TransitionAuthorization(test.current, test.next)
if test.allowed {
if err != nil {
t.Fatalf("TransitionAuthorization(%s, %s): %v", test.current, test.next, err)
}
if result != test.next {
t.Fatalf("TransitionAuthorization(%s, %s) = %s, want %s", test.current, test.next, result, test.next)
}
return
}
if !errors.Is(err, domain.ErrInvalidAuthorizationTransition) {
t.Fatalf("TransitionAuthorization(%s, %s) error = %v, want ErrInvalidAuthorizationTransition", test.current, test.next, err)
}
})
}
}
+74
View File
@@ -0,0 +1,74 @@
package domain
import (
"errors"
"time"
)
var ErrInvalidAttemptTransition = errors.New("invalid purchase attempt status transition")
// AttemptStatus 只描述单趟领取的可恢复执行。真实提交结果独立由唯一围栏记录调和。
type AttemptStatus string
const (
AttemptStatusClaimed AttemptStatus = "CLAIMED"
AttemptStatusOrdering AttemptStatus = "ORDERING"
AttemptStatusFailed AttemptStatus = "FAILED"
AttemptStatusFenced AttemptStatus = "FENCED"
AttemptStatusAbandoned AttemptStatus = "ABANDONED"
)
// AttemptFailureCode 是服务端可审计的固定失败摘要,不能承载页面正文或其他自由文本。
type AttemptFailureCode string
const (
AttemptFailureAuthorizationExpired AttemptFailureCode = "AUTHORIZATION_EXPIRED"
AttemptFailureLeaseLost AttemptFailureCode = "LEASE_LOST"
AttemptFailureGate1Rejected AttemptFailureCode = "GATE_1_REJECTED"
AttemptFailureQuantityMismatch AttemptFailureCode = "QUANTITY_MISMATCH"
AttemptFailureGate2Rejected AttemptFailureCode = "GATE_2_REJECTED"
AttemptFailureGate3Rejected AttemptFailureCode = "GATE_3_REJECTED"
AttemptFailureFenceRejected AttemptFailureCode = "FENCE_REJECTED"
AttemptFailureSafeAborted AttemptFailureCode = "SAFE_ABORTED"
)
type PurchaseAttempt struct {
ID string
TaskID string
AuthorizationID string
ClaimGeneration int
Status AttemptStatus
Gate1UnitPrice *string
Gate2UnitPrice *string
QuantityRead *int
ConfirmAmount *string
FailureCode *AttemptFailureCode
StartedAt time.Time
FinishedAt *time.Time
}
// CanTransitionTo 只允许围栏前的领取恢复为安全失败;围栏后不再提供回退或重试路径。
func (status AttemptStatus) CanTransitionTo(next AttemptStatus) bool {
_, allowed := attemptTransitions[status][next]
return allowed
}
func TransitionAttempt(current, next AttemptStatus) (AttemptStatus, error) {
if !current.CanTransitionTo(next) {
return current, ErrInvalidAttemptTransition
}
return next, nil
}
var attemptTransitions = map[AttemptStatus]map[AttemptStatus]struct{}{
AttemptStatusClaimed: {
AttemptStatusOrdering: {},
AttemptStatusFailed: {},
AttemptStatusAbandoned: {},
},
AttemptStatusOrdering: {
AttemptStatusFenced: {},
AttemptStatusFailed: {},
AttemptStatusAbandoned: {},
},
}
@@ -0,0 +1,34 @@
package domain_test
import (
"errors"
"testing"
"cmbuyer/admin/internal/domain"
)
func TestPurchaseAttemptTransitions(t *testing.T) {
for _, test := range []struct {
current domain.AttemptStatus
next domain.AttemptStatus
allowed bool
}{
{domain.AttemptStatusClaimed, domain.AttemptStatusOrdering, true},
{domain.AttemptStatusOrdering, domain.AttemptStatusFenced, true},
{domain.AttemptStatusOrdering, domain.AttemptStatusFailed, true},
{domain.AttemptStatusFenced, domain.AttemptStatusOrdering, false},
{domain.AttemptStatusFenced, domain.AttemptStatusAbandoned, false},
{domain.AttemptStatus("UNKNOWN"), domain.AttemptStatusOrdering, false},
} {
got, err := domain.TransitionAttempt(test.current, test.next)
if test.allowed {
if err != nil || got != test.next {
t.Fatalf("TransitionAttempt(%s, %s) = (%s, %v)", test.current, test.next, got, err)
}
continue
}
if !errors.Is(err, domain.ErrInvalidAttemptTransition) || got != test.current {
t.Fatalf("invalid TransitionAttempt(%s, %s) = (%s, %v)", test.current, test.next, got, err)
}
}
}
+56
View File
@@ -0,0 +1,56 @@
package domain
import (
"errors"
"time"
)
var ErrInvalidSubmissionTransition = errors.New("invalid submission status transition")
type SubmissionStatus string
const (
SubmissionStatusFenced SubmissionStatus = "FENCED"
SubmissionStatusSubmitted SubmissionStatus = "SUBMITTED"
SubmissionStatusReconciliationRequired SubmissionStatus = "RECONCILIATION_REQUIRED"
SubmissionStatusManualResolved SubmissionStatus = "MANUAL_RESOLVED"
)
type OrderSubmission struct {
ID string
TaskID string
AuthorizationID string
AttemptID string
Status SubmissionStatus
Gate1UnitPrice string
Gate2UnitPrice string
QuantityRead int
ConfirmAmount string
CreatedAt time.Time
ResolvedAt *time.Time
}
// CanTransitionTo 只允许围栏记录向最终观察结果调和,拒绝回退以防触发第二次真实动作。
func (status SubmissionStatus) CanTransitionTo(next SubmissionStatus) bool {
_, allowed := submissionTransitions[status][next]
return allowed
}
// TransitionSubmission 返回下一状态;未定义的提交记录状态转移一律失败。
func TransitionSubmission(current, next SubmissionStatus) (SubmissionStatus, error) {
if !current.CanTransitionTo(next) {
return current, ErrInvalidSubmissionTransition
}
return next, nil
}
var submissionTransitions = map[SubmissionStatus]map[SubmissionStatus]struct{}{
SubmissionStatusFenced: {
SubmissionStatusSubmitted: {},
SubmissionStatusReconciliationRequired: {},
},
SubmissionStatusReconciliationRequired: {
SubmissionStatusManualResolved: {},
},
}
+48
View File
@@ -0,0 +1,48 @@
package domain_test
import (
"errors"
"testing"
"cmbuyer/admin/internal/domain"
)
func TestSubmissionTransitions(t *testing.T) {
tests := []struct {
name string
current domain.SubmissionStatus
next domain.SubmissionStatus
allowed bool
}{
{"submitted", domain.SubmissionStatusFenced, domain.SubmissionStatusSubmitted, true},
{"uncertain requires reconciliation", domain.SubmissionStatusFenced, domain.SubmissionStatusReconciliationRequired, true},
{"manual review resolves reconciliation", domain.SubmissionStatusReconciliationRequired, domain.SubmissionStatusManualResolved, true},
{"cannot reopen fenced submission", domain.SubmissionStatusSubmitted, domain.SubmissionStatusFenced, false},
{"submitted cannot require reconciliation", domain.SubmissionStatusSubmitted, domain.SubmissionStatusReconciliationRequired, false},
{"cannot skip reconciliation", domain.SubmissionStatusFenced, domain.SubmissionStatusManualResolved, false},
{"manual resolution cannot create a second submission", domain.SubmissionStatusManualResolved, domain.SubmissionStatusFenced, false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := test.current.CanTransitionTo(test.next); got != test.allowed {
t.Fatalf("CanTransitionTo(%s, %s) = %t, want %t", test.current, test.next, got, test.allowed)
}
result, err := domain.TransitionSubmission(test.current, test.next)
if test.allowed {
if err != nil {
t.Fatalf("TransitionSubmission(%s, %s): %v", test.current, test.next, err)
}
if result != test.next {
t.Fatalf("TransitionSubmission(%s, %s) = %s, want %s", test.current, test.next, result, test.next)
}
return
}
if !errors.Is(err, domain.ErrInvalidSubmissionTransition) {
t.Fatalf("TransitionSubmission(%s, %s) error = %v, want ErrInvalidSubmissionTransition", test.current, test.next, err)
}
})
}
}
+96
View File
@@ -0,0 +1,96 @@
// Package domain 定义采购服务的业务实体与不依赖外部系统的状态规则。
package domain
import (
"errors"
"time"
)
var ErrInvalidTaskTransition = errors.New("invalid task status transition")
type TaskStatus string
const (
TaskStatusDraft TaskStatus = "DRAFT"
TaskStatusPending TaskStatus = "PENDING"
TaskStatusClaimed TaskStatus = "CLAIMED"
TaskStatusOrdering TaskStatus = "ORDERING"
TaskStatusWaitingPayment TaskStatus = "WAITING_PAYMENT"
TaskStatusReconciliationRequired TaskStatus = "RECONCILIATION_REQUIRED"
TaskStatusNeedsManual TaskStatus = "NEEDS_MANUAL"
TaskStatusSucceeded TaskStatus = "SUCCEEDED"
TaskStatusFailed TaskStatus = "FAILED"
TaskStatusCanceled TaskStatus = "CANCELED"
)
type Source string
const (
SourceManual Source = "MANUAL"
SourceExcel Source = "EXCEL"
SourceERP Source = "ERP"
)
type Task struct {
ID string
Source Source
SourceRef *string
Title string
GoodsID string
SKUColor string
SKUSize string
Quantity int
MaxTotalPrice string
ReferenceAssetID *string
Status TaskStatus
Version int
CreatedAt time.Time
UpdatedAt time.Time
}
// CanTransitionTo 只接受架构已定义的任务状态转移,未知状态或未列出的动作均拒绝。
func (status TaskStatus) CanTransitionTo(next TaskStatus) bool {
_, allowed := taskTransitions[status][next]
return allowed
}
// TransitionTask 返回下一状态;非法转移必须在写库前失败,不能由调用方猜测补救路径。
func TransitionTask(current, next TaskStatus) (TaskStatus, error) {
if !current.CanTransitionTo(next) {
return current, ErrInvalidTaskTransition
}
return next, nil
}
var taskTransitions = map[TaskStatus]map[TaskStatus]struct{}{
TaskStatusDraft: {
TaskStatusPending: {},
TaskStatusCanceled: {},
},
TaskStatusPending: {
TaskStatusClaimed: {},
TaskStatusDraft: {},
TaskStatusCanceled: {},
},
TaskStatusClaimed: {
TaskStatusOrdering: {},
TaskStatusDraft: {},
},
TaskStatusOrdering: {
TaskStatusNeedsManual: {},
TaskStatusWaitingPayment: {},
TaskStatusReconciliationRequired: {},
},
TaskStatusNeedsManual: {
TaskStatusDraft: {},
TaskStatusCanceled: {},
},
TaskStatusWaitingPayment: {
TaskStatusSucceeded: {},
},
TaskStatusReconciliationRequired: {
TaskStatusWaitingPayment: {},
TaskStatusFailed: {},
},
}
+64
View File
@@ -0,0 +1,64 @@
package domain_test
import (
"errors"
"testing"
"cmbuyer/admin/internal/domain"
)
func TestTaskTransitions(t *testing.T) {
tests := []struct {
name string
current domain.TaskStatus
next domain.TaskStatus
allowed bool
}{
{"start purchase", domain.TaskStatusDraft, domain.TaskStatusPending, true},
{"cancel draft before fence", domain.TaskStatusDraft, domain.TaskStatusCanceled, true},
{"claim purchase", domain.TaskStatusPending, domain.TaskStatusClaimed, true},
{"release expired authorization", domain.TaskStatusPending, domain.TaskStatusDraft, true},
{"start ordering", domain.TaskStatusClaimed, domain.TaskStatusOrdering, true},
{"release unstarted claim", domain.TaskStatusClaimed, domain.TaskStatusDraft, true},
{"ordering needs manual review", domain.TaskStatusOrdering, domain.TaskStatusNeedsManual, true},
{"order reaches payment", domain.TaskStatusOrdering, domain.TaskStatusWaitingPayment, true},
{"order needs manual review before fence", domain.TaskStatusOrdering, domain.TaskStatusNeedsManual, true},
{"order needs reconciliation", domain.TaskStatusOrdering, domain.TaskStatusReconciliationRequired, true},
{"manual review resets draft", domain.TaskStatusNeedsManual, domain.TaskStatusDraft, true},
{"manual review cancels before fence", domain.TaskStatusNeedsManual, domain.TaskStatusCanceled, true},
{"payment verified", domain.TaskStatusWaitingPayment, domain.TaskStatusSucceeded, true},
{"reconcile confirms waiting payment", domain.TaskStatusReconciliationRequired, domain.TaskStatusWaitingPayment, true},
{"reconcile confirms failed", domain.TaskStatusReconciliationRequired, domain.TaskStatusFailed, true},
{"cannot skip authorization", domain.TaskStatusDraft, domain.TaskStatusOrdering, false},
{"ordering cannot return pending", domain.TaskStatusOrdering, domain.TaskStatusPending, false},
{"ordering cannot bypass manual review to draft", domain.TaskStatusOrdering, domain.TaskStatusDraft, false},
{"terminal task cannot restart", domain.TaskStatusSucceeded, domain.TaskStatusPending, false},
{"unknown status is rejected", domain.TaskStatus("UNKNOWN"), domain.TaskStatusPending, false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := test.current.CanTransitionTo(test.next); got != test.allowed {
t.Fatalf("CanTransitionTo(%s, %s) = %t, want %t", test.current, test.next, got, test.allowed)
}
result, err := domain.TransitionTask(test.current, test.next)
if test.allowed {
if err != nil {
t.Fatalf("TransitionTask(%s, %s): %v", test.current, test.next, err)
}
if result != test.next {
t.Fatalf("TransitionTask(%s, %s) = %s, want %s", test.current, test.next, result, test.next)
}
return
}
if !errors.Is(err, domain.ErrInvalidTaskTransition) {
t.Fatalf("TransitionTask(%s, %s) error = %v, want ErrInvalidTaskTransition", test.current, test.next, err)
}
if result != test.current {
t.Fatalf("TransitionTask(%s, %s) result = %s, want unchanged status", test.current, test.next, result)
}
})
}
}
+88
View File
@@ -0,0 +1,88 @@
// Package evidence defines the narrow internal screenshot contract shared by HTTP and storage.
package evidence
import (
"context"
"errors"
"io"
"net/http"
"time"
)
const (
KindSKUPanelGate1 = "SKU_PANEL_GATE_1"
PrivacyInternalRaw = "INTERNAL_RAW"
PNGContentType = "image/png"
MaxFileBytes int64 = 10 << 20
MaxImageSide = 8192
MaxImagePixels = 16_777_216
)
var (
ErrInvalid = errors.New("invalid evidence")
ErrConflict = errors.New("evidence upload key conflict")
ErrNotFound = errors.New("evidence not found")
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
AttemptID string
Kind string
PrivacyTier string
SHA256 string
CapturedAt time.Time
}
// StagedFile contains only server-generated state. Multipart filenames and client paths never enter this type.
type StagedFile struct {
Path string
SHA256 string
ByteSize int64
ContentType string
Width int
Height int
}
type Asset struct {
ID string `json:"asset_id"`
TaskID string `json:"task_id"`
AttemptID string `json:"attempt_id"`
Kind string `json:"kind"`
PrivacyTier string `json:"privacy_tier"`
SHA256 string `json:"sha256"`
ByteSize int64 `json:"byte_size"`
ContentType string `json:"content_type"`
Width int `json:"width_px"`
Height int `json:"height_px"`
CapturedAt time.Time `json:"captured_at"`
UploadedByDeviceID string `json:"-"`
StorageKey string `json:"-"`
CreatedAt time.Time `json:"-"`
}
// Store separates bounded multipart staging from metadata commit so field order cannot weaken validation.
type Store interface {
Stage(io.Reader, string) (StagedFile, error)
Discard(StagedFile)
Commit(context.Context, DevicePrincipal, UploadMetadata, StagedFile) (Asset, bool, error)
Open(context.Context, string) (Asset, io.ReadSeekCloser, error)
}
+28
View File
@@ -0,0 +1,28 @@
// Package migrations 通过 goose 执行采购服务的版本化数据库迁移。
package migrations
import (
"context"
"database/sql"
"github.com/pressly/goose/v3"
)
// Run 执行指定的 goose 命令。迁移目录由调用方显式传入,避免把运行目录当作隐式配置。
func Run(ctx context.Context, database *sql.DB, directory, command string) error {
if err := goose.SetDialect("sqlite3"); err != nil {
return err
}
return goose.RunContext(ctx, command, database, directory)
}
// Up 将数据库迁移到当前版本。
func Up(ctx context.Context, database *sql.DB, directory string) error {
return Run(ctx, database, directory, "up")
}
// Down 回退一个已应用的迁移版本。
func Down(ctx context.Context, database *sql.DB, directory string) error {
return Run(ctx, database, directory, "down")
}
@@ -0,0 +1,468 @@
package migrations_test
import (
"context"
"database/sql"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
"github.com/pressly/goose/v3"
)
const migrationTime = "2026-08-04T00:00:00Z"
func TestUpDownAndIdempotence(t *testing.T) {
database := openTestDatabase(t)
directory := migrationDirectory(t)
context := context.Background()
if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("apply migrations: %v", err)
}
assertVersion(t, database, 3)
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, "single_pass_upgrade_guard", false)
if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("reapply migrations: %v", err)
}
assertVersion(t, database, 3)
if err := migrations.Down(context, database, directory); err != nil {
t.Fatalf("roll back evidence migration: %v", err)
}
assertVersion(t, database, 2)
assertTableExists(t, database, "evidence_assets", false)
if err := migrations.Down(context, database, directory); err != nil {
t.Fatalf("roll back v2 migration: %v", err)
}
assertVersion(t, database, 1)
assertTableExists(t, database, "spec_trials", true)
assertTableExists(t, database, "purchase_attempts", false)
assertTableExists(t, database, "single_pass_downgrade_guard", false)
if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("reapply v2 after rollback: %v", err)
}
assertVersion(t, database, 3)
}
func TestUpgradePreservesManualDraftLosslessly(t *testing.T) {
database := openTestDatabase(t)
migrateToV1(t, database)
if _, err := database.Exec(`
INSERT INTO tasks (
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
reference_asset_id, status, version, created_at, updated_at
) VALUES ('draft-one', 'MANUAL', 'source-ref', 'title', 'goods', 'white', 'XL', 2, '80.50',
'asset-id', 'DRAFT', 7, '2026-08-03T00:00:00Z', '2026-08-03T01:00:00Z')
`); err != nil {
t.Fatalf("insert v1 draft: %v", err)
}
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("upgrade v1 draft: %v", err)
}
assertVersion(t, database, 3)
var got struct {
id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string
quantity, version int
}
if err := database.QueryRow(`SELECT id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price, reference_asset_id, status, version, created_at, updated_at FROM tasks WHERE id = 'draft-one'`).Scan(
&got.id, &got.source, &got.sourceRef, &got.title, &got.goodsID, &got.color, &got.size, &got.quantity, &got.maxPrice, &got.assetID, &got.status, &got.version, &got.created, &got.updated,
); err != nil {
t.Fatalf("read upgraded draft: %v", err)
}
if got != (struct {
id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string
quantity, version int
}{"draft-one", "MANUAL", "source-ref", "title", "goods", "white", "XL", "80.50", "asset-id", "DRAFT", "2026-08-03T00:00:00Z", "2026-08-03T01:00:00Z", 2, 7}) {
t.Fatalf("upgraded draft changed: %#v", got)
}
}
func TestUpgradeRejectsLegacyExecutionDataAtomically(t *testing.T) {
tests := []struct {
name string
setup func(*testing.T, *sql.DB)
}{
{"non-draft task", func(t *testing.T, database *sql.DB) {
insertV1Task(t, database, "pending", "MANUAL", "PENDING", "1.00")
}},
{"non-manual task", func(t *testing.T, database *sql.DB) { insertV1Task(t, database, "excel", "EXCEL", "DRAFT", "1.00") }},
{"invalid v2 money", func(t *testing.T, database *sql.DB) { insertV1Task(t, database, "zero", "MANUAL", "DRAFT", "0.00") }},
{"third decimal place", func(t *testing.T, database *sql.DB) {
insertV1Task(t, database, "third-decimal", "MANUAL", "DRAFT", "1.234")
}},
{"spec trial", func(t *testing.T, database *sql.DB) {
insertV1Task(t, database, "task", "MANUAL", "DRAFT", "1.00")
insertV1SpecTrial(t, database, "trial", "task")
}},
{"authorization", func(t *testing.T, database *sql.DB) {
insertV1Task(t, database, "task", "MANUAL", "DRAFT", "1.00")
insertV1SpecTrial(t, database, "trial", "task")
insertV1Authorization(t, database, "auth", "task", "trial")
}},
{"submission", func(t *testing.T, database *sql.DB) {
insertV1Task(t, database, "task", "MANUAL", "DRAFT", "1.00")
insertV1SpecTrial(t, database, "trial", "task")
insertV1Authorization(t, database, "auth", "task", "trial")
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, command_id, dry_run_id, status, verified_unit_price, quantity_read, confirm_page_amount, created_at) VALUES ('submission', 'task', 'auth', 'command', 'dry-run', 'FENCED', '1.00', 1, '1.00', ? )`, migrationTime); err != nil {
t.Fatalf("insert v1 submission: %v", err)
}
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
database := openTestDatabase(t)
migrateToV1(t, database)
test.setup(t, database)
before := v1RowCount(t, database)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("unsafe legacy data upgraded successfully")
}
assertVersion(t, database, 1)
assertTableExists(t, database, "spec_trials", true)
assertTableExists(t, database, "purchase_attempts", false)
assertTableExists(t, database, "single_pass_upgrade_guard", false)
if after := v1RowCount(t, database); after != before {
t.Fatalf("v1 data changed after rejection: before=%d after=%d", before, after)
}
})
}
}
func TestV2SchemaConstraintsAndRelationships(t *testing.T) {
database := openTestDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("apply migrations: %v", err)
}
for _, column := range []struct{ table, name string }{
{"tasks", "max_total_price"},
{"order_authorizations", "total_price_cap"},
{"purchase_attempts", "gate1_unit_price"},
{"purchase_attempts", "gate2_unit_price"},
{"purchase_attempts", "confirm_amount"},
{"order_submissions", "gate1_unit_price"},
{"order_submissions", "gate2_unit_price"},
{"order_submissions", "confirm_amount"},
} {
assertColumnType(t, database, column.table, column.name, "TEXT")
}
for _, legacy := range []string{"spec_trials", "authorized_unit_price", "spec_trial_id", "command_id", "dry_run_id"} {
var count int
if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE sql LIKE '%' || ? || '%'`, legacy).Scan(&count); err != nil {
t.Fatalf("search schema for %s: %v", legacy, err)
}
if count != 0 {
t.Fatalf("legacy identifier %q remains in v2 schema", legacy)
}
}
insertV2Task(t, database, "task-one", "MANUAL", "DRAFT")
insertV2Task(t, database, "task-two", "MANUAL", "DRAFT")
for index, value := range []string{"", "0", "0.00", "-1.00", "1e2", "1.", "1.234", " 1.00", "one"} {
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, 'MANUAL', 'title', 'goods', 'white', 'XL', 1, ?, 'DRAFT', ?, ?)`, "bad-price-"+strconv.Itoa(index), value, migrationTime, migrationTime); err == nil {
t.Fatalf("invalid total price %q succeeded", value)
}
}
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES ('bad-status', 'MANUAL', 'title', 'goods', 'white', 'XL', 1, '1.00', 'UNKNOWN', ?, ?)`, migrationTime, migrationTime); err == nil {
t.Fatal("unknown task status succeeded")
}
insertV2Authorization(t, database, "auth-one", "task-one", 1, "start-one")
insertV2Authorization(t, database, "auth-two", "task-two", 1, "start-two")
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('bad-auth-price', 'task-one', 2, 'bad-price', 'goods', 'white', 'XL', 1, '1.234', 'ACTIVE', 'admin', ?, ?)`, migrationTime, migrationTime); err == nil {
t.Fatal("third decimal authorization cap succeeded")
}
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('bad-auth-status', 'task-one', 2, 'bad-status', 'goods', 'white', 'XL', 1, '1.00', 'UNKNOWN', 'admin', ?, ?)`, migrationTime, migrationTime); err == nil {
t.Fatal("unknown authorization status succeeded")
}
insertV2Authorization(t, database, "auth-one-b", "task-one", 2, "start-one-b")
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES ('duplicate-version', 'task-one', 1, 'different-start', 'goods', 'white', 'XL', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, migrationTime, migrationTime); err == nil {
t.Fatal("duplicate task version authorization succeeded")
}
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES ('cross-attempt', 'task-one', 'auth-two', 1, 'CLAIMED', ?)`, migrationTime); err == nil {
t.Fatal("attempt using another task authorization succeeded")
}
insertV2Attempt(t, database, "attempt-one", "task-one", "auth-one", 1)
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, gate1_unit_price, started_at) VALUES ('bad-attempt-price', 'task-one', 'auth-one', 2, 'ORDERING', '1.234', ?)`, migrationTime); err == nil {
t.Fatal("third decimal gate price succeeded")
}
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES ('bad-attempt-status', 'task-one', 'auth-one', 2, 'UNKNOWN', ?)`, migrationTime); err == nil {
t.Fatal("unknown attempt status succeeded")
}
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, failure_code, started_at) VALUES ('bad-code', 'task-one', 'auth-one', 2, 'FAILED', 'FREE_TEXT', ?)`, migrationTime); err == nil {
t.Fatal("unknown failure code succeeded")
}
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('cross-submission', 'task-one', 'auth-two', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
t.Fatal("submission using another task authorization succeeded")
}
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('cross-authorization-submission', 'task-one', 'auth-one-b', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
t.Fatal("submission combining another same-task authorization and attempt succeeded")
}
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('bad-submission-status', 'task-one', 'auth-one', 'attempt-one', 'UNKNOWN', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
t.Fatal("unknown submission status succeeded")
}
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('bad-submission-price', 'task-one', 'auth-one', 'attempt-one', 'FENCED', '1.234', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
t.Fatal("third decimal submission price succeeded")
}
insertV2Submission(t, database, "submission-one", "task-one", "auth-one", "attempt-one")
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES ('duplicate-auth', 'task-one', 'auth-one', 'attempt-one', 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, migrationTime); err == nil {
t.Fatal("second submission for fenced authorization succeeded")
}
}
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)
}
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)
insertV2Task(t, database, "task-two", "MANUAL", "DRAFT")
insertV2Authorization(t, database, "auth-two", "task-two", 1, "start-two")
insertV2Attempt(t, database, "attempt-two", "task-two", "auth-two", 1)
hash := strings.Repeat("a", 64)
insert := `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
validArgs := []any{"asset-one", "upload-one", "task-one", "attempt-one", "SKU_PANEL_GATE_1", "INTERNAL_RAW", hash, 100, "image/png", 100, 100, "aa/" + hash + ".png", "device-one", migrationTime, migrationTime}
if _, err := database.Exec(insert, validArgs...); err != nil {
t.Fatalf("insert valid evidence: %v", err)
}
for name, mutate := range map[string]func([]any){
"attempt from another task": func(values []any) { values[0], values[1], values[3] = "bad-task", "upload-bad-task", "attempt-two" },
"unapproved kind": func(values []any) { values[0], values[1], values[4] = "bad-kind", "upload-bad-kind", "ORDER_CONFIRM" },
"wrong privacy": func(values []any) { values[0], values[1], values[5] = "bad-privacy", "upload-bad-privacy", "PUBLIC" },
"uppercase hash": func(values []any) {
values[0], values[1], values[6], values[11] = "bad-hash", "upload-bad-hash", strings.Repeat("A", 64), "AA/"+strings.Repeat("A", 64)+".png"
},
"too many pixels": func(values []any) {
values[0], values[1], values[9], values[10] = "bad-pixels", "upload-bad-pixels", 8192, 8192
},
"client path": func(values []any) { values[0], values[1], values[11] = "bad-path", "upload-bad-path", `..\secret.png` },
} {
t.Run(name, func(t *testing.T) {
values := append([]any(nil), validArgs...)
mutate(values)
if _, err := database.Exec(insert, values...); err == nil {
t.Fatal("invalid evidence row succeeded")
}
})
}
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("evidence-bearing schema downgraded successfully")
}
assertVersion(t, database, 3)
assertTableExists(t, database, "evidence_assets", true)
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 1 {
t.Fatalf("evidence after rejected downgrade = %d, err=%v", count, err)
}
}
func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) {
tests := []struct {
name string
setup func(*testing.T, *sql.DB)
}{
{"authorization", func(t *testing.T, database *sql.DB) {
insertV2Task(t, database, "task", "MANUAL", "DRAFT")
insertV2Authorization(t, database, "auth", "task", 1, "start")
}},
{"attempt", func(t *testing.T, database *sql.DB) {
insertV2Task(t, database, "task", "MANUAL", "DRAFT")
insertV2Authorization(t, database, "auth", "task", 1, "start")
insertV2Attempt(t, database, "attempt", "task", "auth", 1)
}},
{"submission", func(t *testing.T, database *sql.DB) {
insertV2Task(t, database, "task", "MANUAL", "DRAFT")
insertV2Authorization(t, database, "auth", "task", 1, "start")
insertV2Attempt(t, database, "attempt", "task", "auth", 1)
insertV2Submission(t, database, "submission", "task", "auth", "attempt")
}},
{"non-draft task", func(t *testing.T, database *sql.DB) { insertV2Task(t, database, "pending", "MANUAL", "PENDING") }},
{"non-manual task", func(t *testing.T, database *sql.DB) { insertV2Task(t, database, "excel", "EXCEL", "DRAFT") }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
database := openTestDatabase(t)
migrateToV2(t, database)
test.setup(t, database)
before := v2RowCount(t, database)
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("unsafe v2 data downgraded successfully")
}
assertVersion(t, database, 2)
assertTableExists(t, database, "purchase_attempts", true)
assertTableExists(t, database, "spec_trials", false)
assertTableExists(t, database, "single_pass_downgrade_guard", false)
if after := v2RowCount(t, database); after != before {
t.Fatalf("v2 data changed after rejected downgrade: before=%d after=%d", before, after)
}
})
}
}
func migrateToV1(t *testing.T, database *sql.DB) {
t.Helper()
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
t.Fatalf("apply v1: %v", err)
}
assertVersion(t, database, 1)
}
func migrateToV2(t *testing.T, database *sql.DB) {
t.Helper()
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
t.Fatalf("apply v1: %v", err)
}
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
t.Fatalf("apply v2: %v", err)
}
assertVersion(t, database, 2)
}
func insertV1Task(t *testing.T, database *sql.DB, id, source, status, price string) {
t.Helper()
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, ?, ?, ?, ?)`, id, source, price, status, migrationTime, migrationTime); err != nil {
t.Fatalf("insert v1 task: %v", err)
}
}
func insertV1SpecTrial(t *testing.T, database *sql.DB, id, taskID string) {
t.Helper()
if _, err := database.Exec(`INSERT INTO spec_trials (id, task_id, attempt, product_title, selected_color, selected_size, unit_price, total_price, evidence_sha256, created_at) VALUES (?, ?, 1, 'title', 'white', 'XL', '1.00', '1.00', 'hash', ?)`, id, taskID, migrationTime); err != nil {
t.Fatalf("insert v1 spec trial: %v", err)
}
}
func insertV1Authorization(t *testing.T, database *sql.DB, id, taskID, trialID string) {
t.Helper()
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, spec_trial_id, version, goods_id, sku_color, sku_size, quantity, authorized_unit_price, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, ?, 1, 'goods', 'white', 'XL', 1, '1.00', '1.00', 'PENDING_DELIVERY', 'admin', ?, ?)`, id, taskID, trialID, migrationTime, migrationTime); err != nil {
t.Fatalf("insert v1 authorization: %v", err)
}
}
func insertV2Task(t *testing.T, database *sql.DB, id, source, status string) {
t.Helper()
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, created_at, updated_at) VALUES (?, ?, 'title', 'goods', 'white', 'XL', 1, '1.00', ?, ?, ?)`, id, source, status, migrationTime, migrationTime); err != nil {
t.Fatalf("insert v2 task: %v", err)
}
}
func insertV2Authorization(t *testing.T, database *sql.DB, id, taskID string, version int, startKey string) {
t.Helper()
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, 'goods', 'white', 'XL', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, id, taskID, version, startKey, migrationTime, migrationTime); err != nil {
t.Fatalf("insert v2 authorization: %v", err)
}
}
func insertV2Attempt(t *testing.T, database *sql.DB, id, taskID, authorizationID string, generation int) {
t.Helper()
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, ?, 'CLAIMED', ?)`, id, taskID, authorizationID, generation, migrationTime); err != nil {
t.Fatalf("insert v2 attempt: %v", err)
}
}
func insertV2Submission(t *testing.T, database *sql.DB, id, taskID, authorizationID, attemptID string) {
t.Helper()
if _, err := database.Exec(`INSERT INTO order_submissions (id, task_id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at) VALUES (?, ?, ?, ?, 'FENCED', '1.00', '1.00', 1, '1.00', ?)`, id, taskID, authorizationID, attemptID, migrationTime); err != nil {
t.Fatalf("insert v2 submission: %v", err)
}
}
func v1RowCount(t *testing.T, database *sql.DB) int {
t.Helper()
var count int
if err := database.QueryRow(`SELECT (SELECT COUNT(*) FROM tasks) + (SELECT COUNT(*) FROM spec_trials) + (SELECT COUNT(*) FROM order_authorizations) + (SELECT COUNT(*) FROM order_submissions)`).Scan(&count); err != nil {
t.Fatalf("count v1 rows: %v", err)
}
return count
}
func v2RowCount(t *testing.T, database *sql.DB) int {
t.Helper()
var count int
if err := database.QueryRow(`SELECT (SELECT COUNT(*) FROM tasks) + (SELECT COUNT(*) FROM order_authorizations) + (SELECT COUNT(*) FROM purchase_attempts) + (SELECT COUNT(*) FROM order_submissions)`).Scan(&count); err != nil {
t.Fatalf("count v2 rows: %v", err)
}
return count
}
func openTestDatabase(t *testing.T) *sql.DB {
t.Helper()
database, err := sqlite.Open(filepath.Join(t.TempDir(), "migrations.db"))
if err != nil {
t.Fatalf("open test database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
return database
}
func migrationDirectory(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate migration test source")
}
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
}
func assertVersion(t *testing.T, database *sql.DB, want int64) {
t.Helper()
got, err := goose.GetDBVersion(database)
if err != nil {
t.Fatalf("read migration version: %v", err)
}
if got != want {
t.Fatalf("migration version = %d, want %d", got, want)
}
}
func assertTableExists(t *testing.T, database *sql.DB, table string, want bool) {
t.Helper()
var count int
if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?`, table).Scan(&count); err != nil {
t.Fatalf("look up table %s: %v", table, err)
}
if got := count == 1; got != want {
t.Fatalf("table %s exists = %t, want %t", table, got, want)
}
}
func assertColumnType(t *testing.T, database *sql.DB, table, column, want string) {
t.Helper()
var got string
if err := database.QueryRow(`SELECT type FROM pragma_table_info(?) WHERE name = ?`, table, column).Scan(&got); err != nil {
t.Fatalf("read %s.%s type: %v", table, column, err)
}
if got != want {
t.Fatalf("%s.%s type = %s, want %s", table, column, got, want)
}
}
func TestMigrationsDoNotDisableForeignKeys(t *testing.T) {
for _, name := range []string{"00002_single_pass_model.sql", "00003_evidence_assets.sql"} {
contents, err := os.ReadFile(filepath.Join(migrationDirectory(t), name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
if strings.Contains(strings.ToUpper(string(contents)), "PRAGMA FOREIGN_KEYS = OFF") {
t.Fatalf("%s disables foreign keys", name)
}
}
}
+193
View File
@@ -0,0 +1,193 @@
package server
import (
"errors"
"io"
"mime"
"mime/multipart"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
"cmbuyer/admin/internal/evidence"
"github.com/gin-gonic/gin"
)
const (
maxEvidenceRequestBytes = evidence.MaxFileBytes + 64<<10
maxEvidenceFieldBytes = 4 << 10
)
var evidenceFieldNames = map[string]struct{}{
"upload_key": {}, "attempt_id": {}, "kind": {}, "privacy_tier": {}, "sha256": {}, "captured_at": {},
}
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 {
context.Status(http.StatusUnauthorized)
return
}
boundary, ok := multipartBoundary(context.GetHeader("Content-Type"))
if !ok {
context.Status(http.StatusUnsupportedMediaType)
return
}
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxEvidenceRequestBytes)
reader := multipart.NewReader(context.Request.Body, boundary)
fields := make(map[string]string, len(evidenceFieldNames))
var staged evidence.StagedFile
hasFile := false
discard := func() {
if hasFile {
options.Evidence.Discard(staged)
}
}
for {
part, err := reader.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
discard()
writeMultipartError(context, err)
return
}
name := part.FormName()
if name == "file" {
if hasFile || part.FileName() == "" || !exactPNGContentType(part.Header.Get("Content-Type")) {
_ = part.Close()
discard()
context.Status(http.StatusUnsupportedMediaType)
return
}
staged, err = options.Evidence.Stage(part, evidence.PNGContentType)
_ = part.Close()
if err != nil {
writeEvidenceStoreError(context, err)
return
}
hasFile = true
continue
}
if _, allowed := evidenceFieldNames[name]; !allowed || part.FileName() != "" {
_ = part.Close()
discard()
context.Status(http.StatusBadRequest)
return
}
if _, duplicate := fields[name]; duplicate {
_ = part.Close()
discard()
context.Status(http.StatusBadRequest)
return
}
value, err := io.ReadAll(io.LimitReader(part, maxEvidenceFieldBytes+1))
_ = part.Close()
if err != nil || len(value) == 0 || len(value) > maxEvidenceFieldBytes || !utf8.Valid(value) {
discard()
context.Status(http.StatusBadRequest)
return
}
fields[name] = string(value)
}
if !hasFile || len(fields) != len(evidenceFieldNames) {
discard()
context.Status(http.StatusBadRequest)
return
}
captured, err := time.Parse(time.RFC3339Nano, fields["captured_at"])
if err != nil || !strings.HasSuffix(fields["captured_at"], "Z") {
discard()
context.Status(http.StatusBadRequest)
return
}
asset, replayed, err := options.Evidence.Commit(context.Request.Context(), principal, evidence.UploadMetadata{
UploadKey: fields["upload_key"], TaskID: context.Param("id"), AttemptID: fields["attempt_id"],
Kind: fields["kind"], PrivacyTier: fields["privacy_tier"], SHA256: fields["sha256"], CapturedAt: captured.UTC(),
}, staged)
if err != nil {
writeEvidenceStoreError(context, err)
return
}
status := http.StatusCreated
if replayed {
status = http.StatusOK
}
context.JSON(status, asset)
}
}
func readEvidence(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
if !options.Sessions.IsAuthenticated(context.Request) {
context.Status(http.StatusUnauthorized)
return
}
asset, file, err := options.Evidence.Open(context.Request.Context(), context.Param("asset_id"))
if errors.Is(err, evidence.ErrNotFound) {
context.Status(http.StatusNotFound)
return
}
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
defer file.Close()
context.Header("Content-Type", evidence.PNGContentType)
context.Header("Content-Length", strconv.FormatInt(asset.ByteSize, 10))
context.Header("Content-Disposition", `inline; filename="evidence.png"`)
context.Header("Cache-Control", "no-store")
context.Header("X-Content-Type-Options", "nosniff")
context.Status(http.StatusOK)
if _, err := io.Copy(context.Writer, file); err != nil {
_ = context.Error(err)
}
}
}
func multipartBoundary(value string) (string, bool) {
mediaType, parameters, err := mime.ParseMediaType(value)
if err != nil || mediaType != "multipart/form-data" || len(parameters) != 1 || parameters["boundary"] == "" {
return "", false
}
return parameters["boundary"], true
}
func exactPNGContentType(value string) bool {
mediaType, parameters, err := mime.ParseMediaType(value)
return err == nil && mediaType == evidence.PNGContentType && len(parameters) == 0
}
func writeMultipartError(context *gin.Context, err error) {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
context.Status(http.StatusRequestEntityTooLarge)
return
}
context.Status(http.StatusBadRequest)
}
func writeEvidenceStoreError(context *gin.Context, err error) {
var tooLarge *http.MaxBytesError
switch {
case errors.As(err, &tooLarge):
context.Status(http.StatusRequestEntityTooLarge)
case errors.Is(err, evidence.ErrTooLarge):
context.Status(http.StatusRequestEntityTooLarge)
case errors.Is(err, evidence.ErrInvalid):
context.Status(http.StatusBadRequest)
case errors.Is(err, evidence.ErrConflict):
context.Status(http.StatusConflict)
default:
context.Status(http.StatusInternalServerError)
}
}
+290
View File
@@ -0,0 +1,290 @@
package server_test
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"image"
"image/png"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"path/filepath"
"runtime"
"strings"
"testing"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/migrations"
evidencestorage "cmbuyer/admin/internal/storage/evidence"
"cmbuyer/admin/internal/storage/sqlite"
)
const (
evidenceTaskID = "63c9f507-7473-4fa6-8d71-8786c34c6301"
evidenceAuthID = "73c9f507-7473-4fa6-8d71-8786c34c6301"
evidenceAttemptID = "83c9f507-7473-4fa6-8d71-8786c34c6301"
evidenceUploadKey = "93c9f507-7473-4fa6-8d71-8786c34c6301"
)
func TestEvidenceUploadAuthenticatesBeforeReadingBody(t *testing.T) {
authenticator := &fakeDeviceAuthenticator{}
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("Content-Type", "text/plain")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized || 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 TestAdminSessionCannotActAsDeviceUploader(t *testing.T) {
router, _ := newRouter(t)
cookie := authenticate(t, router)
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil)
request.Body = &poisonBody{}
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("admin upload status = %d, want 401", response.Code)
}
}
func TestEvidenceUploadReplayConflictAndProtectedRead(t *testing.T) {
router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{allowed: true, principal: evidence.DevicePrincipal{ID: "device-one"}})
pngBytes := serverTestPNG(t, 6, 4)
fields := validEvidenceFields(pngBytes)
first := serveEvidenceUpload(t, router, evidenceTaskID, fields, pngBytes, evidence.PNGContentType, `..\private\original.png`, nil)
if first.Code != http.StatusCreated {
t.Fatalf("first upload status/body = %d/%q", first.Code, first.Body.String())
}
var asset evidence.Asset
if err := json.Unmarshal(first.Body.Bytes(), &asset); err != nil {
t.Fatalf("decode upload response: %v", err)
}
if asset.TaskID != evidenceTaskID || asset.AttemptID != evidenceAttemptID || asset.SHA256 != fields["sha256"] || strings.Contains(first.Body.String(), "private") || strings.Contains(first.Body.String(), "original.png") {
t.Fatalf("unsafe upload response = %s", first.Body.String())
}
replay := serveEvidenceUpload(t, router, evidenceTaskID, fields, pngBytes, evidence.PNGContentType, "again.png", nil)
if replay.Code != http.StatusOK {
t.Fatalf("replay status = %d, want 200", replay.Code)
}
var replayed evidence.Asset
if err := json.Unmarshal(replay.Body.Bytes(), &replayed); err != nil || replayed.ID != asset.ID {
t.Fatalf("replay asset = %#v, err %v", replayed, err)
}
conflicting := copyStringMap(fields)
conflicting["captured_at"] = "2026-08-04T09:01:01Z"
if response := serveEvidenceUpload(t, router, evidenceTaskID, conflicting, pngBytes, evidence.PNGContentType, "same.png", nil); response.Code != http.StatusConflict {
t.Fatalf("conflicting replay status = %d, want 409", response.Code)
}
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 1 {
t.Fatalf("asset count = %d, err %v", count, err)
}
if response := serve(router, http.MethodGet, "/evidence/"+asset.ID, nil, nil); response.Code != http.StatusUnauthorized || response.Body.Len() != 0 {
t.Fatalf("anonymous read = %d/%q", response.Code, response.Body.String())
}
adminCookie := authenticate(t, router)
read := serve(router, http.MethodGet, "/evidence/"+asset.ID, nil, adminCookie)
if read.Code != http.StatusOK || !bytes.Equal(read.Body.Bytes(), pngBytes) {
t.Fatalf("admin read = %d, bytes equal %t", read.Code, bytes.Equal(read.Body.Bytes(), pngBytes))
}
for header, want := range map[string]string{"Content-Type": "image/png", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Content-Disposition": `inline; filename="evidence.png"`} {
if got := read.Header().Get(header); got != want {
t.Fatalf("%s = %q, want %q", header, got, want)
}
}
missing := serve(router, http.MethodGet, "/evidence/not-a-uuid", nil, adminCookie)
if missing.Code != http.StatusNotFound || missing.Body.Len() != 0 {
t.Fatalf("missing evidence = %d/%q", missing.Code, missing.Body.String())
}
}
func TestEvidenceUploadRejectsStrictMultipartViolations(t *testing.T) {
router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{allowed: true, principal: evidence.DevicePrincipal{ID: "device-one"}})
pngBytes := serverTestPNG(t, 2, 2)
base := validEvidenceFields(pngBytes)
wrongHash := copyStringMap(base)
wrongHash["sha256"] = strings.Repeat("b", 64)
uppercaseHash := copyStringMap(base)
uppercaseHash["sha256"] = strings.ToUpper(uppercaseHash["sha256"])
wrongPrivacy := copyStringMap(base)
wrongPrivacy["privacy_tier"] = "PUBLIC"
wrongKind := copyStringMap(base)
wrongKind["kind"] = "ORDER_CONFIRM"
tests := []struct {
name string
fields map[string]string
file []byte
contentType string
extra func(*multipart.Writer) error
want int
}{
{name: "attempt belongs to another task", fields: base, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "xml file", fields: base, file: []byte("<hierarchy/>"), contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "wrong hash", fields: wrongHash, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "uppercase hash", fields: uppercaseHash, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "wrong privacy", fields: wrongPrivacy, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "unapproved kind", fields: wrongKind, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "too large", fields: base, file: make([]byte, evidence.MaxFileBytes+1), contentType: evidence.PNGContentType, want: http.StatusRequestEntityTooLarge},
{name: "wrong file content type", fields: base, file: pngBytes, contentType: "application/xml", want: http.StatusUnsupportedMediaType},
{name: "unknown path field", fields: base, file: pngBytes, contentType: evidence.PNGContentType, extra: func(writer *multipart.Writer) error { return writer.WriteField("path", `C:\secret.xml`) }, want: http.StatusBadRequest},
{name: "duplicate metadata", fields: base, file: pngBytes, contentType: evidence.PNGContentType, extra: func(writer *multipart.Writer) error { return writer.WriteField("sha256", base["sha256"]) }, want: http.StatusBadRequest},
{name: "second file", fields: base, file: pngBytes, contentType: evidence.PNGContentType, extra: func(writer *multipart.Writer) error {
part, err := writer.CreateFormFile("file", "second.png")
if err == nil {
_, err = part.Write(pngBytes)
}
return err
}, want: http.StatusUnsupportedMediaType},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
taskID := evidenceTaskID
if test.name == "attempt belongs to another task" {
taskID = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
}
response := serveEvidenceUpload(t, router, taskID, copyStringMap(test.fields), test.file, test.contentType, "file.png", test.extra)
if response.Code != test.want || response.Body.Len() != 0 {
t.Fatalf("status/body = %d/%q, want %d/empty", response.Code, response.Body.String(), test.want)
}
})
}
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 0 {
t.Fatalf("invalid requests created %d assets, err %v", count, err)
}
}
type fakeDeviceAuthenticator struct {
allowed bool
principal evidence.DevicePrincipal
calls int
}
func (authenticator *fakeDeviceAuthenticator) Authenticate(*http.Request) (evidence.DevicePrincipal, bool) {
authenticator.calls++
return authenticator.principal, authenticator.allowed
}
type poisonBody struct{ reads int }
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) {
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 {
t.Fatalf("migrate database: %v", err)
}
insertEvidenceAttempt(t, database)
store, err := evidencestorage.NewStore(database, filepath.Join(t.TempDir(), "assets"))
if err != nil {
t.Fatalf("new evidence store: %v", err)
}
router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, store, authenticator)
return router, database
}
func insertEvidenceAttempt(t *testing.T, database *sql.DB) {
t.Helper()
timestamp := "2026-08-04T00:00:00Z"
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, ?, ?)`, evidenceTaskID, timestamp, timestamp); err != nil {
t.Fatalf("insert task: %v", err)
}
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, 1, 'start', '123', 'black', 'M', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, evidenceAuthID, evidenceTaskID, timestamp, timestamp); err != nil {
t.Fatalf("insert authorization: %v", err)
}
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, evidenceAttemptID, evidenceTaskID, evidenceAuthID, timestamp); err != nil {
t.Fatalf("insert attempt: %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()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
for _, name := range []string{"upload_key", "attempt_id", "kind", "privacy_tier", "sha256", "captured_at"} {
if err := writer.WriteField(name, fields[name]); err != nil {
t.Fatalf("write field %s: %v", name, err)
}
}
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", `form-data; name="file"; filename="`+filename+`"`)
header.Set("Content-Type", fileContentType)
part, err := writer.CreatePart(header)
if err != nil {
t.Fatalf("create file part: %v", err)
}
if _, err := part.Write(file); err != nil {
t.Fatalf("write file: %v", err)
}
if extra != nil {
if err := extra(writer); err != nil {
t.Fatalf("write extra part: %v", err)
}
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart: %v", err)
}
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
}
func validEvidenceFields(pngBytes []byte) map[string]string {
hash := sha256.Sum256(pngBytes)
return map[string]string{
"upload_key": evidenceUploadKey, "attempt_id": evidenceAttemptID,
"kind": evidence.KindSKUPanelGate1, "privacy_tier": evidence.PrivacyInternalRaw,
"sha256": hex.EncodeToString(hash[:]), "captured_at": "2026-08-04T09:01:00Z",
}
}
func serverTestPNG(t *testing.T, width, height int) []byte {
t.Helper()
var buffer bytes.Buffer
if err := png.Encode(&buffer, image.NewNRGBA(image.Rect(0, 0, width, height))); err != nil {
t.Fatalf("encode PNG: %v", err)
}
return buffer.Bytes()
}
func copyStringMap(values map[string]string) map[string]string {
copy := make(map[string]string, len(values))
for key, value := range values {
copy[key] = value
}
return copy
}
+431
View File
@@ -0,0 +1,431 @@
// Package server 定义采购服务当前拥有的 HTTP 端点。
package server
import (
"bytes"
"crypto/subtle"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"net/url"
"strings"
"unicode/utf8"
"cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks"
"cmbuyer/admin/internal/transport/webui"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
const maxFormBytes = 8 << 10
const maxJSONBytes = 64 << 10
// Options 是路由层需要的安全依赖。凭据由启动配置注入,不能在路由中设置默认值。
type Options struct {
AdminUsername string
AdminPasswordBcrypt string
Sessions *auth.Manager
Tasks tasks.Store
TaskDetails taskdetail.Store
Evidence evidence.Store
DeviceAuthenticator evidence.DeviceAuthenticator
}
// 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 {
return nil, errors.New("server authentication options are incomplete")
}
router := gin.New()
router.Use(gin.Recovery())
router.Use(securityHeaders())
router.GET("/healthz", healthz)
router.GET("/login", loginPage(options))
router.POST("/login", login(options))
router.POST("/logout", logout(options))
router.GET("/tasks", tasksPage(options))
router.GET("/tasks/:id", taskDetailPage(options))
router.GET("/tasks/new", newTaskPage(options))
router.POST("/tasks", createTask(options))
router.POST("/tasks/start-purchases", startPurchases(options))
router.POST("/api/v1/tasks/:id/evidence", uploadEvidence(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())
})
return router, nil
}
func startPurchases(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
if !options.Sessions.IsAuthenticated(context.Request) {
context.Status(http.StatusUnauthorized)
return
}
authenticated, csrfOK := options.Sessions.VerifyCSRF(context.Request, context.GetHeader("X-CSRF-Token"))
if !authenticated || !csrfOK {
context.Status(http.StatusForbidden)
return
}
if !isJSONContentType(context.GetHeader("Content-Type")) {
context.Status(http.StatusUnsupportedMediaType)
return
}
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxJSONBytes)
raw, err := io.ReadAll(context.Request.Body)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
context.Status(http.StatusRequestEntityTooLarge)
} else {
context.Status(http.StatusBadRequest)
}
return
}
if !utf8.Valid(raw) {
context.Status(http.StatusBadRequest)
return
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
var command tasks.StartCommand
if err := decoder.Decode(&command); err != nil {
context.Status(http.StatusBadRequest)
return
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
context.Status(http.StatusBadRequest)
return
}
result, err := options.Tasks.StartPurchases(context.Request.Context(), command, options.AdminUsername)
if err != nil {
if errors.Is(err, tasks.ErrInvalidStart) {
context.Status(http.StatusBadRequest)
} else if errors.Is(err, tasks.ErrStartConflict) {
context.Status(http.StatusConflict)
} else {
context.Status(http.StatusInternalServerError)
}
return
}
context.JSON(http.StatusOK, result)
}
}
func isJSONContentType(value string) bool {
mediaType, parameters, err := mime.ParseMediaType(value)
if err != nil || mediaType != "application/json" {
return false
}
for name, value := range parameters {
if name != "charset" || !strings.EqualFold(value, "utf-8") {
return false
}
}
return true
}
func healthz(context *gin.Context) {
context.JSON(http.StatusOK, gin.H{"status": "ok"})
}
func securityHeaders() gin.HandlerFunc {
return func(context *gin.Context) {
context.Header("Cache-Control", "no-store")
context.Header("X-Content-Type-Options", "nosniff")
context.Header("Referrer-Policy", "no-referrer")
context.Header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
context.Next()
}
}
func loginPage(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
csrfToken, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
if authenticated {
context.Redirect(http.StatusSeeOther, "/tasks")
return
}
renderLogin(context, http.StatusOK, csrfToken, returnTo(context.Query("return_to")), "", "")
}
}
func login(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
if !parseForm(context) {
return
}
form := context.Request.PostForm
csrfToken := form.Get("csrf_token")
returnPath := returnTo(form.Get("return_to"))
username := form.Get("username")
password := form.Get("password")
if _, ok := options.Sessions.VerifyCSRF(context.Request, csrfToken); !ok {
newCSRF, _ := options.Sessions.Ensure(context.Writer, context.Request)
renderLogin(context, http.StatusForbidden, newCSRF, returnPath, "", "请求已过期,请重新登录。")
return
}
usernameMatches := subtle.ConstantTimeCompare([]byte(options.AdminUsername), []byte(username)) == 1
passwordMatches := bcrypt.CompareHashAndPassword([]byte(options.AdminPasswordBcrypt), []byte(password)) == nil
if !usernameMatches || !passwordMatches {
csrf, _ := options.Sessions.Ensure(context.Writer, context.Request)
renderLogin(context, http.StatusUnauthorized, csrf, returnPath, "", "账号或密码不正确,请检查后重试。")
return
}
options.Sessions.RotateAuthenticated(context.Writer, context.Request)
context.Redirect(http.StatusSeeOther, returnPath)
}
}
func logout(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
if !parseForm(context) {
return
}
authenticated, ok := options.Sessions.VerifyCSRF(context.Request, context.Request.PostForm.Get("csrf_token"))
if !ok || !authenticated {
context.Status(http.StatusForbidden)
return
}
options.Sessions.Logout(context.Writer, context.Request)
context.Redirect(http.StatusSeeOther, "/login")
}
}
func tasksPage(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
csrfToken, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
if !authenticated {
context.Redirect(http.StatusSeeOther, "/login?return_to="+url.QueryEscape(context.Request.URL.RequestURI()))
return
}
filter := tasks.TaskFilter{Keyword: context.Query("keyword"), Status: context.Query("status"), CreatedFrom: context.Query("created_from"), CreatedTo: context.Query("created_to")}
if validation := tasks.ValidateTaskFilter(filter); !validation.Valid() {
startKey, err := tasks.NewCreateKey()
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
renderTasks(context, http.StatusBadRequest, webui.TasksData{CSRFToken: csrfToken, Filter: filter, FilterErrors: validation, HasFilter: true, StartKey: startKey})
return
}
data, err := taskListData(context, options, csrfToken, filter)
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
for _, row := range data.Tasks {
if row.ID == context.Query("created") {
data.Success = true
break
}
}
if context.Query("create") == "1" {
form, err := newTaskForm()
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
data.OpenForm = true
data.Form = form
data.FocusField = "title"
}
renderTasks(context, http.StatusOK, data)
}
}
func newTaskPage(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
csrf, authenticated := options.Sessions.Ensure(context.Writer, context.Request)
if !authenticated {
context.Redirect(http.StatusSeeOther, "/login?return_to=%2Ftasks%2Fnew")
return
}
form, err := newTaskForm()
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
renderTasks(context, http.StatusOK, webui.TasksData{CSRFToken: csrf, Form: form, FullPage: true, FocusField: "title"})
}
}
func createTask(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
if !parseForm(context) {
return
}
requestForm := context.Request.PostForm
authenticated, csrfOK := options.Sessions.VerifyCSRF(context.Request, requestForm.Get("csrf_token"))
if !csrfOK || !authenticated {
context.Status(http.StatusForbidden)
return
}
form := taskForm(requestForm)
draft, validation := tasks.Validate(form)
if draft.GoodsID != "" {
form.ProductURL = tasks.CanonicalURL(draft.GoodsID)
}
fullPage := requestForm.Get("form_mode") == "full"
if !validation.Valid() {
data, ok := createErrorData(context, options, fullPage)
if !ok {
return
}
data.Form = form
data.Errors = validation
data.OpenForm = !fullPage
data.FullPage = fullPage
data.FocusField = firstError(validation)
renderTasks(context, http.StatusBadRequest, data)
return
}
created, err := options.Tasks.CreateDraft(context.Request.Context(), draft)
if err != nil {
if errors.Is(err, tasks.ErrCreateKeyConflict) {
validation["create_key"] = "该创建请求已用于另一条任务,请重新打开表单。"
data, ok := createErrorData(context, options, fullPage)
if !ok {
return
}
data.Form = form
data.Errors = validation
data.OpenForm = !fullPage
data.FullPage = fullPage
data.FocusField = firstError(validation)
renderTasks(context, http.StatusConflict, data)
return
}
context.Status(http.StatusInternalServerError)
return
}
context.Redirect(http.StatusSeeOther, "/tasks?created="+url.QueryEscape(created.ID))
}
}
func newTaskForm() (tasks.Form, error) {
key, err := tasks.NewCreateKey()
if err != nil {
return tasks.Form{}, err
}
return tasks.Form{CreateKey: key}, nil
}
func taskForm(form url.Values) tasks.Form {
return tasks.Form{CreateKey: form.Get("create_key"), Title: form.Get("title"), ProductURL: form.Get("product_url"), SKUColor: form.Get("sku_color"), SKUSize: form.Get("sku_size"), Quantity: form.Get("quantity"), MaxTotalPrice: form.Get("max_total_price")}
}
func csrfFor(context *gin.Context, options Options) string {
csrf, _ := options.Sessions.Ensure(context.Writer, context.Request)
return csrf
}
func taskListData(context *gin.Context, options Options, csrfToken string, filter tasks.TaskFilter) (webui.TasksData, error) {
rows, err := options.Tasks.ListTasks(context.Request.Context(), filter)
if err != nil {
return webui.TasksData{}, err
}
startKey, err := tasks.NewCreateKey()
if err != nil {
return webui.TasksData{}, err
}
return webui.TasksData{
CSRFToken: csrfToken,
Tasks: rows,
Filter: filter,
HasFilter: filter.Keyword != "" || filter.Status != "" || filter.CreatedFrom != "" || filter.CreatedTo != "",
StartKey: startKey,
}, nil
}
func createErrorData(context *gin.Context, options Options, fullPage bool) (webui.TasksData, bool) {
csrfToken := csrfFor(context, options)
if fullPage {
return webui.TasksData{CSRFToken: csrfToken}, true
}
data, err := taskListData(context, options, csrfToken, tasks.TaskFilter{})
if err != nil {
context.Status(http.StatusInternalServerError)
return webui.TasksData{}, false
}
return data, true
}
func renderTasks(context *gin.Context, status int, data webui.TasksData) {
context.Header("Content-Type", "text/html; charset=utf-8")
context.Status(status)
if err := webui.RenderTasks(context.Writer, data); err != nil {
_ = context.Error(err)
}
}
func renderLogin(context *gin.Context, status int, csrfToken, returnPath, username, message string) {
context.Header("Content-Type", "text/html; charset=utf-8")
context.Status(status)
if err := webui.RenderLogin(context.Writer, webui.LoginData{
CSRFToken: csrfToken,
ReturnTo: returnPath,
Username: username,
Error: message,
}); err != nil {
_ = context.Error(err)
}
}
func parseForm(context *gin.Context) bool {
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxFormBytes)
if err := context.Request.ParseForm(); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
context.Status(http.StatusRequestEntityTooLarge)
} else {
context.Status(http.StatusBadRequest)
}
return false
}
return true
}
func firstError(validation tasks.Errors) string {
for _, field := range []string{"title", "product_url", "sku_color", "sku_size", "quantity", "max_total_price"} {
if _, ok := validation[field]; ok {
return field
}
}
return "title"
}
func returnTo(value string) string {
if value == "/tasks" || strings.HasPrefix(value, "/tasks/") || strings.HasPrefix(value, "/tasks?") {
if strings.Contains(value, "\\") || strings.Contains(value, "%") || strings.HasPrefix(value, "//") {
return "/tasks"
}
parsed, err := url.ParseRequestURI(value)
if err == nil && parsed.IsAbs() == false && parsed.Host == "" && hasSafeTaskPath(parsed.Path) {
return value
}
}
return "/tasks"
}
func hasSafeTaskPath(path string) bool {
for _, segment := range strings.Split(path, "/") {
if segment == "." || segment == ".." {
return false
}
}
return true
}
+634
View File
@@ -0,0 +1,634 @@
package server_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"time"
"cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/server"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
)
var csrfPattern = regexp.MustCompile(`name="csrf_token" value="([^"]+)"`)
var createKeyPattern = regexp.MustCompile(`name="create_key" value="([^"]+)"`)
func TestHealthzIsPublic(t *testing.T) {
router, _ := newRouter(t)
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("healthz status = %d, want %d", response.Code, http.StatusOK)
}
if contentType := response.Header().Get("Content-Type"); contentType != "application/json; charset=utf-8" {
t.Fatalf("healthz content type = %q, want application/json; charset=utf-8", contentType)
}
if body := response.Body.String(); body != "{\"status\":\"ok\"}" {
t.Fatalf("healthz body = %q, want {\"status\":\"ok\"}", body)
}
assertSecurityHeaders(t, response)
}
func TestTasksRequiresLoginAndBlocksOpenRedirects(t *testing.T) {
router, _ := newRouter(t)
tasks := serve(router, http.MethodGet, "/tasks", nil, nil)
if tasks.Code != http.StatusSeeOther {
t.Fatalf("GET /tasks status = %d, want %d", tasks.Code, http.StatusSeeOther)
}
if location := tasks.Header().Get("Location"); location != "/login?return_to=%2Ftasks" {
t.Fatalf("GET /tasks location = %q, want login return path", location)
}
for _, target := range []string{"https://example.invalid", "//example.invalid", `\\example.invalid`, "/other", "/tasks/..", "/tasks/../other", "/tasks/%2e%2e", "%2F%2Fevil.invalid", "%252F%252Fevil.invalid"} {
response := serve(router, http.MethodGet, "/login?return_to="+url.QueryEscape(target), nil, nil)
if response.Code != http.StatusOK {
t.Fatalf("GET /login return_to=%q status = %d, want 200", target, response.Code)
}
if strings.Contains(response.Body.String(), target) || !strings.Contains(response.Body.String(), `name="return_to" value="/tasks"`) {
t.Fatalf("GET /login accepted unsafe return_to %q", target)
}
}
encodedPath := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%252F..", nil, nil)
if !strings.Contains(encodedPath.Body.String(), `name="return_to" value="/tasks"`) {
t.Fatal("encoded parent path was accepted as return_to")
}
encodedQuery := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%3Fnext%3D%252Ftasks%252F..", nil, nil)
if !strings.Contains(encodedQuery.Body.String(), `name="return_to" value="/tasks"`) {
t.Fatal("encoded query bypass was accepted as return_to")
}
}
func TestLoginRotatesSessionAndCSRF(t *testing.T) {
router, _ := newRouter(t)
initial := serve(router, http.MethodGet, "/login?return_to=%2Ftasks%3Fview%3Dmine", nil, nil)
oldCookie := sessionCookie(t, initial)
oldCSRF := csrfToken(t, initial.Body.String())
login := serve(router, http.MethodPost, "/login", url.Values{
"csrf_token": {oldCSRF},
"return_to": {"/tasks?view=mine"},
"username": {"admin"},
"password": {"test-password"},
}, oldCookie)
if login.Code != http.StatusSeeOther || login.Header().Get("Location") != "/tasks?view=mine" {
t.Fatalf("successful login = (%d, %q), want 303 /tasks?view=mine", login.Code, login.Header().Get("Location"))
}
newCookie := sessionCookie(t, login)
if newCookie.Value == oldCookie.Value {
t.Fatal("successful login reused the anonymous session cookie")
}
tasks := serve(router, http.MethodGet, "/tasks", nil, newCookie)
if tasks.Code != http.StatusOK {
t.Fatalf("GET /tasks after login status = %d, want 200", tasks.Code)
}
if newCSRF := csrfToken(t, tasks.Body.String()); newCSRF == oldCSRF {
t.Fatal("successful login reused the anonymous CSRF token")
}
for _, forbidden := range []string{"建单", "试选", "拼多多", "规格", "单价", "证据"} {
if strings.Contains(tasks.Body.String(), forbidden) {
t.Fatalf("task shell must not expose deferred feature content %q", forbidden)
}
}
assertSecurityHeaders(t, initial)
assertSecurityHeaders(t, tasks)
}
func TestLoginPageIncludesAccessibleFormBasics(t *testing.T) {
router, _ := newRouter(t)
page := serve(router, http.MethodGet, "/login", nil, nil)
body := page.Body.String()
for _, want := range []string{
`<label for="username">`,
`<label for="password">`,
`autocomplete="username"`,
`autocomplete="current-password"`,
`min-height:44px`,
`:focus-visible`,
`prefers-reduced-motion`,
} {
if !strings.Contains(body, want) {
t.Fatalf("login page is missing %q", want)
}
}
if strings.Contains(body, "http://") || strings.Contains(body, "https://") || strings.Contains(body, "<script") {
t.Fatal("login page must not load external resources or require client-side JavaScript")
}
failure := serve(router, http.MethodPost, "/login", url.Values{
"csrf_token": {csrfToken(t, body)},
"username": {"admin"},
"password": {"wrong"},
}, sessionCookie(t, page))
if !strings.Contains(failure.Body.String(), `role="alert"`) {
t.Fatal("login failure must announce its error")
}
}
func TestLoginCSRFAndCredentialFailuresAreSafe(t *testing.T) {
router, _ := newRouter(t)
page := serve(router, http.MethodGet, "/login", nil, nil)
cookie := sessionCookie(t, page)
withoutCSRF := serve(router, http.MethodPost, "/login", url.Values{
"username": {"admin"},
"password": {"test-password"},
}, cookie)
if withoutCSRF.Code != http.StatusForbidden || !strings.Contains(withoutCSRF.Body.String(), "请求已过期") {
t.Fatalf("login without CSRF = (%d, %q), want rejected form", withoutCSRF.Code, withoutCSRF.Body.String())
}
page = serve(router, http.MethodGet, "/login", nil, cookie)
badCredentials := serve(router, http.MethodPost, "/login", url.Values{
"csrf_token": {csrfToken(t, page.Body.String())},
"username": {"unknown"},
"password": {"wrong"},
}, cookie)
if badCredentials.Code != http.StatusUnauthorized {
t.Fatalf("login with invalid credentials status = %d, want 401", badCredentials.Code)
}
if body := badCredentials.Body.String(); !strings.Contains(body, "账号或密码不正确") || strings.Contains(body, "unknown") {
t.Fatalf("invalid login leaked account detail: %q", body)
}
}
func TestTamperedCookieCannotAccessTasks(t *testing.T) {
router, _ := newRouter(t)
page := serve(router, http.MethodGet, "/login", nil, nil)
cookie := sessionCookie(t, page)
tampered := *cookie
tampered.Value = flipCookieValue(t, cookie.Value)
response := serve(router, http.MethodGet, "/tasks", nil, &tampered)
if response.Code != http.StatusSeeOther {
t.Fatalf("tampered cookie status = %d, want 303", response.Code)
}
}
func TestTaskCreationRendersSharedFormsAndPersistsOnlyDraft(t *testing.T) {
router, _ := newRouter(t)
cookie := authenticate(t, router)
modal := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
if modal.Code != http.StatusOK {
t.Fatalf("GET dialog form status = %d, want 200", modal.Code)
}
fullPage := serve(router, http.MethodGet, "/tasks/new", nil, cookie)
if fullPage.Code != http.StatusOK {
t.Fatalf("GET full form status = %d, want 200", fullPage.Code)
}
for _, want := range []string{`<div class="modal-scrim"`, `<dialog open`, `aria-modal="true"`, `name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `type="url" inputmode="url" maxlength="2048"`, `type="number" inputmode="numeric" min="1" step="1"`, `inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?"`, `maxlength="120"`, `maxlength="80"`, `required`, `autofocus`, `导入</button><a class="button primary"`, `type="search"`, `data-start-purchases`, `data-select-all`, `最高总额`, `min-height:44px`, `:focus-visible`, `overflow-x:auto`, `prefers-reduced-motion`} {
if !strings.Contains(modal.Body.String(), want) {
t.Fatalf("dialog form is missing %q", want)
}
}
for _, want := range []string{`name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `name="form_mode" value="full"`} {
if !strings.Contains(fullPage.Body.String(), want) {
t.Fatalf("full-page form is missing %q", want)
}
}
invalid := serve(router, http.MethodPost, "/tasks", url.Values{
"csrf_token": {csrfToken(t, modal.Body.String())},
"create_key": {createKey(t, modal.Body.String())},
"title": {`<script>alert(1)</script>`},
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&uin=discard"},
"sku_color": {"black"},
"sku_size": {"M"},
"quantity": {"0"},
"max_total_price": {"12.80"},
"form_mode": {"dialog"},
}, cookie)
if invalid.Code != http.StatusBadRequest || !strings.Contains(invalid.Body.String(), `<dialog open`) || !strings.Contains(invalid.Body.String(), "数量必须是正整数") || !strings.Contains(invalid.Body.String(), `role="alert"`) || !strings.Contains(invalid.Body.String(), `href="#quantity"`) || !strings.Contains(invalid.Body.String(), `aria-describedby="quantity-error"`) || !strings.Contains(invalid.Body.String(), `autofocus`) {
t.Fatalf("invalid create = (%d, %q), want dialog validation response", invalid.Code, invalid.Body.String())
}
if strings.Contains(invalid.Body.String(), `<script>alert(1)</script>`) || !strings.Contains(invalid.Body.String(), `&lt;script&gt;alert(1)&lt;/script&gt;`) {
t.Fatalf("invalid create did not safely preserve title: %q", invalid.Body.String())
}
if strings.Contains(invalid.Body.String(), "uin=discard") || !strings.Contains(invalid.Body.String(), `value="https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"`) {
t.Fatalf("invalid create did not canonicalize product URL: %q", invalid.Body.String())
}
createPage := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
key := createKey(t, createPage.Body.String())
created := serve(router, http.MethodPost, "/tasks", url.Values{
"csrf_token": {csrfToken(t, createPage.Body.String())},
"create_key": {key},
"title": {"<b>夏季上衣</b>"},
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=discard"},
"sku_color": {"black"},
"sku_size": {"M"},
"quantity": {"2"},
"max_total_price": {"12.8"},
"form_mode": {"dialog"},
}, cookie)
if created.Code != http.StatusSeeOther || !strings.HasPrefix(created.Header().Get("Location"), "/tasks?created=") {
t.Fatalf("valid create = (%d, %q), want 303 to a created-task acknowledgement", created.Code, created.Header().Get("Location"))
}
replay := serve(router, http.MethodPost, "/tasks", url.Values{
"csrf_token": {csrfToken(t, createPage.Body.String())},
"create_key": {key},
"title": {"<b>夏季上衣</b>"},
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=discard"},
"sku_color": {"black"},
"sku_size": {"M"},
"quantity": {"2"},
"max_total_price": {"12.8"},
"form_mode": {"dialog"},
}, cookie)
if replay.Code != http.StatusSeeOther {
t.Fatalf("idempotent replay status = %d, want 303", replay.Code)
}
conflict := serve(router, http.MethodPost, "/tasks", url.Values{
"csrf_token": {csrfToken(t, createPage.Body.String())},
"create_key": {key},
"title": {"different task"},
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"},
"sku_color": {"black"},
"sku_size": {"M"},
"quantity": {"2"},
"max_total_price": {"12.80"},
"form_mode": {"dialog"},
}, cookie)
if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "该创建请求已用于另一条任务") {
t.Fatalf("conflicting create = (%d, %q), want a 409 form error", conflict.Code, conflict.Body.String())
}
list := serve(router, http.MethodGet, created.Header().Get("Location"), nil, cookie)
if list.Code != http.StatusOK {
t.Fatalf("GET /tasks status = %d, want 200", list.Code)
}
body := list.Body.String()
for _, want := range []string{`任务已创建,已显示在列表首行。`, `&lt;b&gt;夏季上衣&lt;/b&gt;`, `https://mobile.yangkeduo.com/goods.html?goods_id=937122477375`, `target="_blank"`, `rel="noopener noreferrer"`, `¥12.80`, `待开始`, `选择全部任务`, `选择任务`} {
if !strings.Contains(body, want) {
t.Fatalf("task list is missing %q", want)
}
}
for _, forbidden := range []string{"utm_source", "试选", "订单确认", "真机", "提交订单"} {
if strings.Contains(body, forbidden) {
t.Fatalf("task list exposed deferred scope %q", forbidden)
}
}
}
func TestTasksPageKeepsOriginalShellAndRendersFilteredWorkbench(t *testing.T) {
store := &memoryStore{rows: []tasks.TaskRow{
{ID: "b3c9f507-7473-4fa6-8d71-8786c34c6301", Title: "待开始衬衫", GoodsID: "937122477375", SKUColor: "黑色", SKUSize: "M", Quantity: 2, MaxTotalPrice: "12.80", Status: "DRAFT", Version: 3, CreatedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)},
{ID: "c3c9f507-7473-4fa6-8d71-8786c34c6301", Title: "等待领取衬衫", GoodsID: "958756616606", SKUColor: "白色", SKUSize: "L", Quantity: 1, MaxTotalPrice: "20.00", Status: "PENDING", Version: 4, CreatedAt: time.Date(2026, 8, 4, 2, 3, 4, 0, time.UTC)},
}}
router, _ := newRouterWithStore(t, store)
cookie := authenticate(t, router)
query := url.Values{"keyword": {"衬衫"}, "created_from": {"2026-08-04"}, "created_to": {"2026-08-04"}}
response := serve(router, http.MethodGet, "/tasks?"+query.Encode(), nil, cookie)
if response.Code != http.StatusOK {
t.Fatalf("filtered tasks status = %d, want 200", response.Code)
}
body := response.Body.String()
for _, want := range []string{
`<a class="skip" href="#main">`,
`:focus-visible`,
`min-height:44px`,
`@media(max-width:420px)`,
`prefers-reduced-motion`,
`<button class="button" type="button" disabled>导入</button><a class="button primary" href="/tasks?create=1">创建任务</a>`,
`name="keyword" type="search" value="衬衫"`,
`name="created_from" type="date" value="2026-08-04"`,
`name="created_to" type="date" value="2026-08-04"`,
`data-start-purchases`,
`data-selection-summary aria-live="polite"`,
`系统不会付款`,
`开始采购(只创建待付款订单)`,
`采购结果`,
`创建时间(上海)`,
`https://mobile.yangkeduo.com/goods.html?goods_id=937122477375`,
`target="_blank" rel="noopener noreferrer"`,
`data-task-row data-detail-url="/tasks/b3c9f507-7473-4fa6-8d71-8786c34c6301" tabindex="0"`,
`data-open-detail>查看详情</button>`,
`.detail-link-button{display:block;min-height:44px`,
`data-detail-drawer aria-modal="true"`,
`待开始`,
`已授权待领取`,
`datetime="2026-08-04T09:02:03&#43;08:00">2026-08-04 09:02`,
`<script src="/static/tasks.js" defer></script>`,
} {
if !strings.Contains(body, want) {
t.Fatalf("workbench is missing %q", want)
}
}
if strings.Index(body, `name="keyword"`) > strings.Index(body, `data-start-purchases`) || strings.Index(body, `data-start-purchases`) > strings.Index(body, `<div class="table-wrap">`) {
t.Fatal("workbench rows are not ordered as toolbar, filters, batch actions, table")
}
if count := strings.Count(body, `data-task-id=`); count != 1 {
t.Fatalf("selectable row count = %d, want only the DRAFT row", count)
}
for _, forbidden := range []string{`<th scope="col">操作</th>`, `确认开始采购`, `确认机器选对了吗`} {
if strings.Contains(body, forbidden) {
t.Fatalf("workbench exposed forbidden per-row or confirmation UI %q", forbidden)
}
}
if store.listTasksCalls != 1 || store.listDraftsCalls != 0 {
t.Fatalf("GET /tasks calls = (ListTasks %d, ListDrafts %d), want (1, 0)", store.listTasksCalls, store.listDraftsCalls)
}
}
func TestTasksPageRerendersAccessibleFilterErrorsAndKeepsValues(t *testing.T) {
store := &memoryStore{}
router, _ := newRouterWithStore(t, store)
cookie := authenticate(t, router)
query := url.Values{
"keyword": {`保留%_\`},
"status": {"UNKNOWN"},
"created_from": {"2026-02-30"},
"created_to": {"not-a-date"},
}
response := serve(router, http.MethodGet, "/tasks?"+query.Encode(), nil, cookie)
if response.Code != http.StatusBadRequest {
t.Fatalf("invalid filter status = %d, want 400", response.Code)
}
body := response.Body.String()
for _, want := range []string{
`role="alert" aria-live="assertive"`,
`href="#filter-status"`,
`href="#filter-created-from"`,
`href="#filter-created-to"`,
`name="keyword" type="search" value="保留%_\"`,
`<option value="UNKNOWN" selected>无效状态:UNKNOWN</option>`,
`name="created_from" type="date" value="2026-02-30" aria-invalid="true" aria-describedby="filter-created-from-error"`,
`name="created_to" type="date" value="not-a-date" aria-invalid="true" aria-describedby="filter-created-to-error"`,
`id="filter-status-error"`,
`id="filter-created-from-error"`,
`id="filter-created-to-error"`,
`筛选条件有误`,
} {
if !strings.Contains(body, want) {
t.Fatalf("invalid filter page is missing %q", want)
}
}
if store.listTasksCalls != 0 || store.listDraftsCalls != 0 {
t.Fatalf("invalid filter queried stores: ListTasks=%d ListDrafts=%d", store.listTasksCalls, store.listDraftsCalls)
}
assertSecurityHeaders(t, response)
}
func TestTaskCreationRequiresAuthenticationAndCSRF(t *testing.T) {
router, _ := newRouter(t)
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, nil); response.Code != http.StatusForbidden {
t.Fatalf("anonymous POST /tasks = %d, want 403", response.Code)
}
cookie := authenticate(t, router)
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, cookie); response.Code != http.StatusForbidden {
t.Fatalf("POST /tasks without CSRF = %d, want 403", response.Code)
}
}
func TestTaskCreationFailsClosedForMalformedOrOversizedForms(t *testing.T) {
router, _ := newRouter(t)
cookie := authenticate(t, router)
page := serve(router, http.MethodGet, "/tasks?create=1", nil, cookie)
base := url.Values{
"csrf_token": {csrfToken(t, page.Body.String())},
"create_key": {createKey(t, page.Body.String())},
"title": {"title"},
"product_url": {"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=malformed"},
"sku_color": {"black"},
"sku_size": {"M"},
"quantity": {"1"},
"max_total_price": {"1.00"},
"form_mode": {"dialog"},
}
malformed := serve(router, http.MethodPost, "/tasks", base, cookie)
if malformed.Code != http.StatusBadRequest || !strings.Contains(malformed.Body.String(), "canonical 商品链接") {
t.Fatalf("malformed URL create = (%d, %q), want validation failure", malformed.Code, malformed.Body.String())
}
oversized := url.Values{"csrf_token": {csrfToken(t, page.Body.String())}, "title": {strings.Repeat("x", 9<<10)}}
if response := serve(router, http.MethodPost, "/tasks", oversized, cookie); response.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("oversized form status = %d, want 413", response.Code)
}
}
func assertSecurityHeaders(t *testing.T, response *httptest.ResponseRecorder) {
t.Helper()
want := map[string]string{
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"Content-Security-Policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
}
for name, expected := range want {
if got := response.Header().Get(name); got != expected {
t.Fatalf("%s = %q, want %q", name, got, expected)
}
}
}
func flipCookieValue(t *testing.T, value string) string {
t.Helper()
if value == "" {
t.Fatal("cannot tamper with an empty cookie")
}
if value[0] == 'A' {
return "B" + value[1:]
}
return "A" + value[1:]
}
func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
router, _ := newRouter(t)
loginPage := serve(router, http.MethodGet, "/login", nil, nil)
loginCookie := sessionCookie(t, loginPage)
login := serve(router, http.MethodPost, "/login", url.Values{
"csrf_token": {csrfToken(t, loginPage.Body.String())},
"username": {"admin"},
"password": {"test-password"},
}, loginCookie)
authenticatedCookie := sessionCookie(t, login)
missingCSRF := serve(router, http.MethodPost, "/logout", url.Values{}, authenticatedCookie)
if missingCSRF.Code != http.StatusForbidden {
t.Fatalf("logout without CSRF status = %d, want 403", missingCSRF.Code)
}
tasks := serve(router, http.MethodGet, "/tasks", nil, authenticatedCookie)
logout := serve(router, http.MethodPost, "/logout", url.Values{
"csrf_token": {csrfToken(t, tasks.Body.String())},
}, authenticatedCookie)
if logout.Code != http.StatusSeeOther || logout.Header().Get("Location") != "/login" {
t.Fatalf("logout = (%d, %q), want 303 /login", logout.Code, logout.Header().Get("Location"))
}
if cookie := sessionCookie(t, logout); cookie.MaxAge >= 0 {
t.Fatalf("logout cookie MaxAge = %d, want a deletion cookie", cookie.MaxAge)
}
reused := serve(router, http.MethodGet, "/tasks", nil, authenticatedCookie)
if reused.Code != http.StatusSeeOther {
t.Fatalf("revoked session status = %d, want 303", reused.Code)
}
}
func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
return newRouterWithStore(t, &memoryStore{})
}
func newRouterWithStore(t *testing.T, store tasks.Store) (*gin.Engine, *auth.Manager) {
return newRouterWithDependencies(t, store, emptyDetailStore{}, emptyEvidenceStore{}, evidence.RejectAllDeviceAuthenticator{})
}
func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator evidence.DeviceAuthenticator) (*gin.Engine, *auth.Manager) {
t.Helper()
gin.SetMode(gin.TestMode)
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
if err != nil {
t.Fatalf("generate bcrypt hash: %v", err)
}
manager := auth.NewManager([]byte(strings.Repeat("s", 32)), false)
router, err := server.NewRouter(server.Options{
AdminUsername: "admin",
AdminPasswordBcrypt: string(hash),
Sessions: manager,
Tasks: store,
TaskDetails: details,
Evidence: evidenceStore,
DeviceAuthenticator: deviceAuthenticator,
})
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
return router, manager
}
type emptyDetailStore struct{}
func (emptyDetailStore) Get(context.Context, string) (taskdetail.Detail, error) {
return taskdetail.Detail{}, taskdetail.ErrNotFound
}
type emptyEvidenceStore struct{}
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) {
return evidence.Asset{}, false, evidence.ErrInvalid
}
func (emptyEvidenceStore) Open(context.Context, string) (evidence.Asset, io.ReadSeekCloser, error) {
return evidence.Asset{}, nil, evidence.ErrNotFound
}
type memoryStore struct {
drafts []tasks.Draft
rows []tasks.TaskRow
listDraftsCalls int
listTasksCalls int
}
func (store *memoryStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) {
for _, existing := range store.drafts {
if existing.ID == draft.ID {
if existing.Title != draft.Title || existing.GoodsID != draft.GoodsID || existing.SKUColor != draft.SKUColor || existing.SKUSize != draft.SKUSize || existing.Quantity != draft.Quantity || existing.MaxTotalPrice != draft.MaxTotalPrice {
return tasks.Draft{}, tasks.ErrCreateKeyConflict
}
return existing, nil
}
}
store.drafts = append(store.drafts, draft)
return draft, nil
}
func (store *memoryStore) ListDrafts(_ context.Context) ([]tasks.Draft, error) {
store.listDraftsCalls++
return append([]tasks.Draft(nil), store.drafts...), nil
}
func (store *memoryStore) ListTasks(_ context.Context, _ tasks.TaskFilter) ([]tasks.TaskRow, error) {
store.listTasksCalls++
if store.rows != nil {
return append([]tasks.TaskRow(nil), store.rows...), nil
}
result := make([]tasks.TaskRow, 0, len(store.drafts))
for _, draft := range store.drafts {
result = append(result, tasks.TaskRow{ID: draft.ID, Title: draft.Title, GoodsID: draft.GoodsID, SKUColor: draft.SKUColor, SKUSize: draft.SKUSize, Quantity: draft.Quantity, MaxTotalPrice: draft.MaxTotalPrice, Status: "DRAFT", Version: 1, CreatedAt: draft.CreatedAt})
}
return result, nil
}
func (store *memoryStore) StartPurchases(_ context.Context, _ tasks.StartCommand, _ string) (tasks.StartResult, error) {
return tasks.StartResult{}, tasks.ErrInvalidStart
}
func serve(router http.Handler, method, target string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
var body *strings.Reader
if form == nil {
body = strings.NewReader("")
} else {
body = strings.NewReader(form.Encode())
}
request := httptest.NewRequest(method, target, body)
if form != nil {
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if cookie != nil {
request.AddCookie(cookie)
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func sessionCookie(t *testing.T, response *httptest.ResponseRecorder) *http.Cookie {
t.Helper()
for _, cookie := range response.Result().Cookies() {
if cookie.Name == auth.CookieName {
return cookie
}
}
t.Fatalf("response did not set %s cookie", auth.CookieName)
return nil
}
func csrfToken(t *testing.T, body string) string {
t.Helper()
matches := csrfPattern.FindStringSubmatch(body)
if len(matches) != 2 || matches[1] == "" {
t.Fatalf("no CSRF token in response body: %q", body)
}
return matches[1]
}
func createKey(t *testing.T, body string) string {
t.Helper()
matches := createKeyPattern.FindStringSubmatch(body)
if len(matches) != 2 || matches[1] == "" {
t.Fatalf("no create key in response body: %q", body)
}
return matches[1]
}
func authenticate(t *testing.T, router http.Handler) *http.Cookie {
t.Helper()
page := serve(router, http.MethodGet, "/login", nil, nil)
login := serve(router, http.MethodPost, "/login", url.Values{
"csrf_token": {csrfToken(t, page.Body.String())},
"username": {"admin"},
"password": {"test-password"},
}, sessionCookie(t, page))
if login.Code != http.StatusSeeOther {
t.Fatalf("authenticate status = %d, want 303", login.Code)
}
return sessionCookie(t, login)
}
@@ -0,0 +1,328 @@
package server_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"cmbuyer/admin/internal/tasks"
)
const (
startKeyForHTTP = "c3c9f507-7473-4fa6-8d71-8786c34c6301"
taskIDForHTTP = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
)
func TestStartPurchasesAuthenticatesBeforeInspectingRequestBody(t *testing.T) {
store := &startRecordingStore{}
router, _ := newRouterWithStore(t, store)
hugeMalformed := `{"start_key":"` + strings.Repeat("x", 70<<10)
for name, request := range map[string]*http.Request{
"anonymous malformed": newStartRequest(t, hugeMalformed, "text/plain", "", nil),
"device bearer": newStartRequest(t, validStartBody(), "application/json", "", nil),
} {
t.Run(name, func(t *testing.T) {
if name == "device bearer" {
request.Header.Set("Authorization", "Bearer device-token")
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", response.Code)
}
})
}
cookie, csrf := authenticatedStartSession(t, router)
for name, token := range map[string]string{"missing CSRF": "", "wrong CSRF": "wrong-csrf"} {
request := newStartRequest(t, hugeMalformed, "text/plain", token, cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("%s status = %d, want 403", name, response.Code)
}
}
if csrf == "" {
t.Fatal("authenticated page did not contain a CSRF token")
}
if store.startCalls != 0 {
t.Fatalf("unauthorized requests called store %d times", store.startCalls)
}
}
func TestStartPurchasesRejectsInvalidUTF8BeforeJSONDecoding(t *testing.T) {
validPrefix := []byte(`{"start_key":"` + startKeyForHTTP + `","tasks":[],"start_key":"`)
duplicateKeyBypass := append(append([]byte(nil), validPrefix...), 0xff)
duplicateKeyBypass = append(duplicateKeyBypass, []byte(`"}`)...)
invalidWhitespace := append([]byte(validStartBody()), 0xfe)
for name, body := range map[string][]byte{
"invalid byte after JSON": invalidWhitespace,
"invalid duplicate-key value": duplicateKeyBypass,
} {
t.Run(name, func(t *testing.T) {
store := &startRecordingStore{}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartBytes(t, router, body, "application/json", csrf, cookie)
if response.Code != http.StatusBadRequest || store.startCalls != 0 {
t.Fatalf("status/calls = %d/%d, want 400/0", response.Code, store.startCalls)
}
if response.Body.Len() != 0 {
t.Fatalf("invalid UTF-8 response leaked body %q", response.Body.String())
}
})
}
}
func TestStartPurchasesEnforcesExact64KiBBodyBoundary(t *testing.T) {
const limit = 64 << 10
base := validStartBody()
for name, test := range map[string]struct {
body string
want int
wantCalls int
}{
"exact limit": {body: base + strings.Repeat(" ", limit-len(base)), want: http.StatusOK, wantCalls: 1},
"one over": {body: base + strings.Repeat(" ", limit-len(base)+1), want: http.StatusRequestEntityTooLarge},
} {
t.Run(name, func(t *testing.T) {
store := &startRecordingStore{startResult: successfulStartResult()}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartRequest(t, router, test.body, "application/json", csrf, cookie)
if response.Code != test.want || store.startCalls != test.wantCalls {
t.Fatalf("status/calls = %d/%d, want %d/%d", response.Code, store.startCalls, test.want, test.wantCalls)
}
})
}
}
func TestStartPurchasesContentTypeContract(t *testing.T) {
for _, contentType := range []string{
"application/json",
"application/json; charset=utf-8",
"application/json;charset=UTF-8",
} {
t.Run("accept "+contentType, func(t *testing.T) {
store := &startRecordingStore{startResult: successfulStartResult()}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartRequest(t, router, validStartBody(), contentType, csrf, cookie)
if response.Code != http.StatusOK || store.startCalls != 1 {
t.Fatalf("status/calls = %d/%d, want 200/1", response.Code, store.startCalls)
}
})
}
for _, contentType := range []string{
"",
"text/plain",
"application/json-patch+json",
"application/json; charset=gbk",
"application/json; profile=unapproved",
"application/json; charset",
} {
t.Run("reject "+contentType, func(t *testing.T) {
store := &startRecordingStore{}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartRequest(t, router, validStartBody(), contentType, csrf, cookie)
if response.Code != http.StatusUnsupportedMediaType || store.startCalls != 0 {
t.Fatalf("status/calls = %d/%d, want 415/0", response.Code, store.startCalls)
}
})
}
}
func TestStartPurchasesRejectsMalformedAndOversizedJSON(t *testing.T) {
tests := []struct {
name string
body string
want int
storeErr error
wantCalls int
}{
{name: "empty", body: "", want: http.StatusBadRequest},
{name: "empty object", body: `{}`, want: http.StatusBadRequest, storeErr: tasks.ErrInvalidStart, wantCalls: 1},
{name: "null object", body: `null`, want: http.StatusBadRequest, storeErr: tasks.ErrInvalidStart, wantCalls: 1},
{name: "malformed", body: `{`, want: http.StatusBadRequest},
{name: "wrong top-level type", body: `[]`, want: http.StatusBadRequest},
{name: "unknown field", body: `{"start_key":"` + startKeyForHTTP + `","tasks":[],"created_by":"attacker"}`, want: http.StatusBadRequest},
{name: "wrong field type", body: `{"start_key":"` + startKeyForHTTP + `","tasks":[{"task_id":"` + taskIDForHTTP + `","expected_task_version":"1"}]}`, want: http.StatusBadRequest},
{name: "second JSON value", body: validStartBody() + `{}`, want: http.StatusBadRequest},
{name: "duplicate task ids", body: `{"start_key":"` + startKeyForHTTP + `","tasks":[{"task_id":"` + taskIDForHTTP + `","expected_task_version":1},{"task_id":"` + taskIDForHTTP + `","expected_task_version":1}]}`, want: http.StatusBadRequest, storeErr: tasks.ErrInvalidStart, wantCalls: 1},
{name: "oversized first value", body: `{"start_key":"` + strings.Repeat("x", 70<<10), want: http.StatusRequestEntityTooLarge},
{name: "oversized trailing whitespace", body: validStartBody() + strings.Repeat(" ", 70<<10), want: http.StatusRequestEntityTooLarge},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
store := &startRecordingStore{startErr: test.storeErr}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartRequest(t, router, test.body, "application/json", csrf, cookie)
if response.Code != test.want || store.startCalls != test.wantCalls {
t.Fatalf("status/calls = %d/%d, want %d/%d", response.Code, store.startCalls, test.want, test.wantCalls)
}
if response.Body.Len() != 0 {
t.Fatalf("error response leaked body %q", response.Body.String())
}
assertSecurityHeaders(t, response)
})
}
}
func TestStartPurchasesUsesAuthenticatedAdminAndReturnsStableSafeResult(t *testing.T) {
result := successfulStartResult()
store := &startRecordingStore{startResult: result}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
first := serveStartRequest(t, router, validStartBody(), "application/json; charset=utf-8", csrf, cookie)
second := serveStartRequest(t, router, validStartBody(), "application/json", csrf, cookie)
for index, response := range []*httptest.ResponseRecorder{first, second} {
if response.Code != http.StatusOK {
t.Fatalf("response %d status = %d, want 200", index, response.Code)
}
if got := response.Header().Get("Content-Type"); got != "application/json; charset=utf-8" {
t.Fatalf("response content type = %q", got)
}
var decoded tasks.StartResult
if err := json.Unmarshal(response.Body.Bytes(), &decoded); err != nil {
t.Fatalf("decode response: %v", err)
}
if decoded.PaymentAutomated || decoded.AuthorizedCount != 1 || decoded.Tasks[0].AuthorizationID != result.Tasks[0].AuthorizationID {
t.Fatalf("unsafe or unstable response = %#v", decoded)
}
assertSecurityHeaders(t, response)
}
if store.startCalls != 2 || len(store.createdBy) != 2 || store.createdBy[0] != "admin" || store.createdBy[1] != "admin" {
t.Fatalf("store calls/created_by = %d/%#v", store.startCalls, store.createdBy)
}
for _, command := range store.commands {
if command.StartKey != startKeyForHTTP || len(command.Tasks) != 1 || command.Tasks[0].TaskID != taskIDForHTTP || command.Tasks[0].ExpectedTaskVersion != 7 {
t.Fatalf("decoded command = %#v", command)
}
}
}
func TestStartPurchasesMapsStoreErrorsWithoutLeakingDetails(t *testing.T) {
for name, test := range map[string]struct {
err error
want int
}{
"invalid": {err: tasks.ErrInvalidStart, want: http.StatusBadRequest},
"conflict": {err: tasks.ErrStartConflict, want: http.StatusConflict},
"internal": {err: errors.New("sqlite secret path and query"), want: http.StatusInternalServerError},
} {
t.Run(name, func(t *testing.T) {
store := &startRecordingStore{startErr: test.err}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartRequest(t, router, validStartBody(), "application/json", csrf, cookie)
if response.Code != test.want || store.startCalls != 1 {
t.Fatalf("status/calls = %d/%d, want %d/1", response.Code, store.startCalls, test.want)
}
if response.Body.Len() != 0 || strings.Contains(response.Body.String(), "sqlite") {
t.Fatalf("error leaked details: %q", response.Body.String())
}
})
}
}
type startRecordingStore struct {
startResult tasks.StartResult
startErr error
startCalls int
commands []tasks.StartCommand
createdBy []string
}
func (store *startRecordingStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) {
return draft, nil
}
func (store *startRecordingStore) ListDrafts(context.Context) ([]tasks.Draft, error) {
return nil, nil
}
func (store *startRecordingStore) ListTasks(context.Context, tasks.TaskFilter) ([]tasks.TaskRow, error) {
return nil, nil
}
func (store *startRecordingStore) StartPurchases(_ context.Context, command tasks.StartCommand, createdBy string) (tasks.StartResult, error) {
store.startCalls++
store.commands = append(store.commands, command)
store.createdBy = append(store.createdBy, createdBy)
return store.startResult, store.startErr
}
func authenticatedStartSession(t *testing.T, router http.Handler) (*http.Cookie, string) {
t.Helper()
cookie := authenticate(t, router)
page := serve(router, http.MethodGet, "/tasks", nil, cookie)
if page.Code != http.StatusOK {
t.Fatalf("GET /tasks status = %d", page.Code)
}
return cookie, csrfToken(t, page.Body.String())
}
func newStartRequest(t *testing.T, body, contentType, csrf string, cookie *http.Cookie) *http.Request {
t.Helper()
return newStartByteRequest(t, []byte(body), contentType, csrf, cookie)
}
func newStartByteRequest(t *testing.T, body []byte, contentType, csrf string, cookie *http.Cookie) *http.Request {
t.Helper()
request := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", bytes.NewReader(body))
if contentType != "" {
request.Header.Set("Content-Type", contentType)
}
if csrf != "" {
request.Header.Set("X-CSRF-Token", csrf)
}
if cookie != nil {
request.AddCookie(cookie)
}
return request
}
func serveStartBytes(t *testing.T, router http.Handler, body []byte, contentType, csrf string, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
response := httptest.NewRecorder()
router.ServeHTTP(response, newStartByteRequest(t, body, contentType, csrf, cookie))
return response
}
func serveStartRequest(t *testing.T, router http.Handler, body, contentType, csrf string, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
response := httptest.NewRecorder()
router.ServeHTTP(response, newStartRequest(t, body, contentType, csrf, cookie))
return response
}
func validStartBody() string {
return `{"start_key":"` + startKeyForHTTP + `","tasks":[{"task_id":"` + taskIDForHTTP + `","expected_task_version":7}]}`
}
func successfulStartResult() tasks.StartResult {
expires := time.Date(2026, 8, 4, 2, 3, 4, 0, time.UTC)
return tasks.StartResult{
StartKey: startKeyForHTTP,
AuthorizedCount: 1,
PaymentAutomated: false,
Tasks: []tasks.AuthorizedTask{{
TaskID: taskIDForHTTP,
TaskVersion: 8,
AuthorizationID: "d3c9f507-7473-4fa6-8d71-8786c34c6301",
ExpiresAt: expires,
}},
}
}
+84
View File
@@ -0,0 +1,84 @@
package server
import (
"errors"
"mime"
"net/http"
"net/url"
"strconv"
"strings"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/transport/webui"
"github.com/gin-gonic/gin"
)
const detailViewHeader = "X-CMBuyer-View"
const detailVaryHeader = "X-CMBuyer-View, Accept, Sec-Fetch-Site"
func taskDetailPage(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
context.Header("Vary", detailVaryHeader)
if !options.Sessions.IsAuthenticated(context.Request) {
context.Redirect(http.StatusSeeOther, "/login?return_to="+url.QueryEscape(context.Request.URL.RequestURI()))
return
}
view := context.GetHeader(detailViewHeader)
if view != "" && view != "drawer" {
context.Status(http.StatusBadRequest)
return
}
if view == "drawer" {
if context.GetHeader("Sec-Fetch-Site") != "same-origin" {
context.Status(http.StatusForbidden)
return
}
if !acceptsHTML(context.GetHeader("Accept")) {
context.Status(http.StatusNotAcceptable)
return
}
}
detail, err := options.TaskDetails.Get(context.Request.Context(), context.Param("id"))
if errors.Is(err, taskdetail.ErrNotFound) {
context.Status(http.StatusNotFound)
return
}
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
context.Header("Content-Type", "text/html; charset=utf-8")
context.Status(http.StatusOK)
data := webui.TaskDetailData{Detail: detail}
if view == "drawer" {
if err := webui.RenderTaskDetailFragment(context.Writer, data); err != nil {
_ = context.Error(err)
}
return
}
if err := webui.RenderTaskDetailPage(context.Writer, data); err != nil {
_ = context.Error(err)
}
}
}
func acceptsHTML(header string) bool {
for _, value := range strings.Split(header, ",") {
mediaType, parameters, err := mime.ParseMediaType(strings.TrimSpace(value))
if err != nil || !strings.EqualFold(mediaType, "text/html") {
continue
}
quality := 1.0
if rawQuality, exists := parameters["q"]; exists {
quality, err = strconv.ParseFloat(rawQuality, 64)
if err != nil || quality < 0 || quality > 1 {
continue
}
}
if quality > 0 {
return true
}
}
return false
}
+110
View File
@@ -0,0 +1,110 @@
package server_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/taskdetail"
)
const detailTaskID = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
func TestTaskDetailRequiresAdminBeforeLookup(t *testing.T) {
details := &recordingDetailStore{detail: taskDetailFixture()}
router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, evidence.RejectAllDeviceAuthenticator{})
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)
}
}
func TestTaskDetailFullPageAndDrawerShareAuditContent(t *testing.T) {
details := &recordingDetailStore{detail: taskDetailFixture()}
router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, evidence.RejectAllDeviceAuthenticator{})
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`) {
t.Fatalf("full detail = %d/%q", full.Code, full.Body.String())
}
request := httptest.NewRequest(http.MethodGet, "/tasks/"+detailTaskID, nil)
request.AddCookie(cookie)
request.Header.Set("X-CMBuyer-View", "drawer")
request.Header.Set("Accept", "text/html")
request.Header.Set("Sec-Fetch-Site", "same-origin")
fragment := httptest.NewRecorder()
router.ServeHTTP(fragment, request)
if fragment.Code != http.StatusOK || strings.Contains(fragment.Body.String(), "<!doctype html>") || !strings.Contains(fragment.Body.String(), `data-task-detail-content`) {
t.Fatalf("fragment detail = %d/%q", fragment.Code, fragment.Body.String())
}
for _, text := range []string{"测试&lt;script&gt;", "订单已创建,系统尚未付款", "SKU_PANEL_GATE_1", "/evidence/b3c9f507-7473-4fa6-8d71-8786c34c6301", "暂无规格、价格或数量读数", "本页没有重试、再次提交或付款动作"} {
if !strings.Contains(full.Body.String(), text) || !strings.Contains(fragment.Body.String(), text) {
t.Fatalf("shared detail missing %q", text)
}
}
if strings.Contains(full.Body.String(), "<script>") || strings.Contains(fragment.Body.String(), "<script>") {
t.Fatal("task title was not HTML escaped")
}
if got := fragment.Header().Get("Vary"); got != "X-CMBuyer-View, Accept, Sec-Fetch-Site" {
t.Fatalf("fragment Vary = %q", got)
}
if details.calls != 2 {
t.Fatalf("detail store calls = %d, want 2", details.calls)
}
}
func TestTaskDetailRejectsForgedFragmentAndMissingTask(t *testing.T) {
details := &recordingDetailStore{err: taskdetail.ErrNotFound}
router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, evidence.RejectAllDeviceAuthenticator{})
cookie := authenticate(t, router)
for name, headers := range map[string]map[string]string{
"unknown view": {"X-CMBuyer-View": "xml", "Accept": "text/html"},
"missing fetch site": {"X-CMBuyer-View": "drawer", "Accept": "text/html"},
"cross-site drawer": {"X-CMBuyer-View": "drawer", "Accept": "text/html", "Sec-Fetch-Site": "cross-site"},
"wrong accept": {"X-CMBuyer-View": "drawer", "Accept": "application/json", "Sec-Fetch-Site": "same-origin"},
"html quality zero": {"X-CMBuyer-View": "drawer", "Accept": "text/html;q=0, application/json", "Sec-Fetch-Site": "same-origin"},
"html substring mime": {"X-CMBuyer-View": "drawer", "Accept": "application/nottext/html", "Sec-Fetch-Site": "same-origin"},
} {
t.Run(name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/tasks/"+detailTaskID, nil)
request.AddCookie(cookie)
for key, value := range headers {
request.Header.Set(key, value)
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code < 400 || response.Code >= 500 || response.Body.Len() != 0 {
t.Fatalf("forged fragment = %d/%q", response.Code, response.Body.String())
}
})
}
missing := serve(router, http.MethodGet, "/tasks/not-a-uuid", nil, cookie)
if missing.Code != http.StatusNotFound || missing.Body.Len() != 0 {
t.Fatalf("missing detail = %d/%q", missing.Code, missing.Body.String())
}
}
type recordingDetailStore struct {
detail taskdetail.Detail
err error
calls int
}
func (store *recordingDetailStore) Get(context.Context, string) (taskdetail.Detail, error) {
store.calls++
return store.detail, store.err
}
func taskDetailFixture() taskdetail.Detail {
started := time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)
return taskdetail.Detail{
Task: taskdetail.Task{ID: detailTaskID, Source: "MANUAL", Title: "测试<script>", GoodsID: "937122477375", SKUColor: "黑色", SKUSize: "M", Quantity: 2, MaxTotalPrice: "30.00", Status: "WAITING_PAYMENT", Version: 3, CreatedAt: started, UpdatedAt: started},
Authorizations: []taskdetail.Authorization{{ID: "c3c9f507-7473-4fa6-8d71-8786c34c6301", Status: "FENCED", CreatedBy: "admin", TotalPriceCap: "30.00", TaskVersion: 2, CreatedAt: started, ExpiresAt: started.Add(time.Hour)}},
Attempts: []taskdetail.Attempt{{ID: "d3c9f507-7473-4fa6-8d71-8786c34c6301", AuthorizationID: "c3c9f507-7473-4fa6-8d71-8786c34c6301", Status: "CLAIMED", ClaimGeneration: 1, StartedAt: started}},
Evidence: []taskdetail.Evidence{{ID: "b3c9f507-7473-4fa6-8d71-8786c34c6301", AttemptID: "d3c9f507-7473-4fa6-8d71-8786c34c6301", Kind: "SKU_PANEL_GATE_1", PrivacyTier: "INTERNAL_RAW", SHA256: strings.Repeat("a", 64), ByteSize: 100, ContentType: "image/png", Width: 100, Height: 200, CapturedAt: started}},
}
}
@@ -0,0 +1,20 @@
//go:build !windows
package evidence
import (
"fmt"
"os"
)
func syncDirectory(path string) error {
directory, err := os.Open(path)
if err != nil {
return fmt.Errorf("open directory for durability sync: %w", err)
}
defer directory.Close()
if err := directory.Sync(); err != nil {
return fmt.Errorf("sync directory metadata: %w", err)
}
return nil
}
@@ -0,0 +1,35 @@
//go:build windows
package evidence
import (
"fmt"
"syscall"
)
// syncDirectory uses an explicit directory handle because os.Open(...).Sync is not a portable
// Windows directory durability boundary. Any unsupported filesystem or access failure is fatal:
// callers must not make the corresponding evidence row visible in SQLite.
func syncDirectory(path string) error {
pathPointer, err := syscall.UTF16PtrFromString(path)
if err != nil {
return fmt.Errorf("encode directory path for durability sync: %w", err)
}
handle, err := syscall.CreateFile(
pathPointer,
syscall.GENERIC_WRITE,
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE,
nil,
syscall.OPEN_EXISTING,
syscall.FILE_FLAG_BACKUP_SEMANTICS,
0,
)
if err != nil {
return fmt.Errorf("open directory for durability sync: %w", err)
}
defer syscall.CloseHandle(handle)
if err := syscall.FlushFileBuffers(handle); err != nil {
return fmt.Errorf("flush directory metadata: %w", err)
}
return nil
}
+553
View File
@@ -0,0 +1,553 @@
// Package evidence stores INTERNAL_RAW PNG assets outside the public web tree.
package evidence
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"image/png"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"unicode"
core "cmbuyer/admin/internal/evidence"
)
var pngSignature = []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}
type Store struct {
database *sql.DB
root string
now func() time.Time
random io.Reader
syncDirectory func(string) error
syncFile func(*os.File) error
renameFile func(string, string) error
commitTx func(*sql.Tx) error
mu sync.Mutex
}
func NewStore(database *sql.DB, root string) (*Store, error) {
return newStore(database, root, syncDirectory)
}
func newStore(database *sql.DB, root string, directorySync func(string) error) (*Store, error) {
if database == nil {
return nil, errors.New("evidence database is required")
}
if directorySync == nil {
return nil, errors.New("evidence directory sync is required")
}
if root == "" || !filepath.IsAbs(root) {
return nil, errors.New("evidence root must be an absolute path")
}
absolute, err := filepath.Abs(filepath.Clean(root))
if err != nil {
return nil, fmt.Errorf("resolve evidence root: %w", err)
}
if filepath.Dir(absolute) == absolute {
return nil, errors.New("evidence root cannot be a filesystem root")
}
if err := ensureDurableDirectory(absolute, 0o700, directorySync); err != nil {
return nil, fmt.Errorf("create evidence root: %w", err)
}
// A prior startup may have created the root and then failed its parent sync.
// Existence is therefore never accepted as proof that the directory entry is durable.
if err := directorySync(filepath.Dir(absolute)); err != nil {
return nil, fmt.Errorf("persist evidence root directory: %w", err)
}
if err := os.Chmod(absolute, 0o700); err != nil {
return nil, fmt.Errorf("protect evidence root: %w", err)
}
staging := filepath.Join(absolute, ".staging")
if err := ensureDurableDirectory(staging, 0o700, directorySync); err != nil {
return nil, fmt.Errorf("create evidence staging directory: %w", err)
}
if err := directorySync(absolute); err != nil {
return nil, fmt.Errorf("persist evidence staging directory: %w", err)
}
if err := os.Chmod(staging, 0o700); err != nil {
return nil, fmt.Errorf("protect evidence staging directory: %w", err)
}
if _, err := database.Exec("SELECT storage_key FROM evidence_assets LIMIT 1"); err != nil {
return nil, fmt.Errorf("evidence migration is not available: %w", err)
}
return &Store{
database: database,
root: absolute,
now: time.Now,
random: rand.Reader,
syncDirectory: directorySync,
syncFile: func(file *os.File) error { return file.Sync() },
renameFile: os.Rename,
commitTx: func(transaction *sql.Tx) error { return transaction.Commit() },
}, nil
}
func (store *Store) Stage(reader io.Reader, contentType string) (staged core.StagedFile, resultErr error) {
if reader == nil || contentType != core.PNGContentType {
return core.StagedFile{}, core.ErrInvalid
}
temporary, err := os.CreateTemp(filepath.Join(store.root, ".staging"), "upload-*.png")
if err != nil {
return core.StagedFile{}, err
}
staged.Path = temporary.Name()
defer func() {
if resultErr != nil {
_ = temporary.Close()
_ = os.Remove(staged.Path)
}
}()
if err := temporary.Chmod(0o600); err != nil {
return core.StagedFile{}, err
}
hasher := sha256.New()
written, err := io.Copy(io.MultiWriter(temporary, hasher), io.LimitReader(reader, core.MaxFileBytes+1))
if err != nil {
return core.StagedFile{}, err
}
if written > core.MaxFileBytes {
return core.StagedFile{}, core.ErrTooLarge
}
if written == 0 {
return core.StagedFile{}, core.ErrInvalid
}
if err := temporary.Sync(); err != nil {
return core.StagedFile{}, err
}
if err := temporary.Close(); err != nil {
return core.StagedFile{}, err
}
imageFile, err := os.Open(staged.Path)
if err != nil {
return core.StagedFile{}, err
}
defer imageFile.Close()
width, height, err := validatePNG(imageFile)
if err != nil {
return core.StagedFile{}, err
}
staged.SHA256 = hex.EncodeToString(hasher.Sum(nil))
staged.ByteSize = written
staged.ContentType = core.PNGContentType
staged.Width = width
staged.Height = height
return staged, nil
}
func (store *Store) Discard(staged core.StagedFile) {
if store.isStagedPath(staged.Path) {
_ = os.Remove(staged.Path)
}
}
func (store *Store) Commit(ctx context.Context, principal core.DevicePrincipal, 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
}
defer store.Discard(staged)
store.mu.Lock()
defer store.mu.Unlock()
transaction, err := store.database.BeginTx(ctx, nil)
if err != nil {
return core.Asset{}, false, err
}
defer transaction.Rollback()
existing, found, err := findByUploadKey(ctx, transaction, principal.ID, metadata.UploadKey)
if err != nil {
return core.Asset{}, false, err
}
if found {
if !sameUpload(existing, principal, metadata, staged) {
return core.Asset{}, false, core.ErrConflict
}
if err := store.verifyStoredFile(existing); err != nil {
return core.Asset{}, false, err
}
if err := store.commitTx(transaction); err != nil {
return core.Asset{}, false, err
}
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 {
return core.Asset{}, false, err
}
if attemptCount != 1 {
return core.Asset{}, false, core.ErrInvalid
}
storageKey := storageKey(metadata.SHA256)
finalPath, err := store.pathForKey(storageKey)
if err != nil {
return core.Asset{}, false, err
}
finalDirectory := filepath.Dir(finalPath)
if err := ensureDurableDirectory(finalDirectory, 0o700, store.syncDirectory); err != nil {
return core.Asset{}, false, err
}
// Always repeat the shard-parent boundary. If an earlier attempt created this
// directory and its parent sync failed, a retry must not trust mere existence.
if err := store.syncDirectory(store.root); err != nil {
return core.Asset{}, false, fmt.Errorf("persist evidence shard directory: %w", err)
}
if err := os.Chmod(finalDirectory, 0o700); err != nil {
return core.Asset{}, false, err
}
if info, statErr := os.Stat(finalPath); statErr == nil {
if !info.Mode().IsRegular() || info.Size() != staged.ByteSize || fileSHA256(finalPath) != staged.SHA256 {
return core.Asset{}, false, errors.New("stored evidence content does not match its key")
}
} else if !errors.Is(statErr, os.ErrNotExist) {
return core.Asset{}, false, statErr
} else {
publishPath, err := store.preparePublishFile(staged, finalDirectory)
if err != nil {
return core.Asset{}, false, err
}
defer os.Remove(publishPath)
if err := store.renameFile(publishPath, finalPath); err != nil {
return core.Asset{}, false, err
}
}
// The publication file was fsynced in this shard before its same-directory rename.
// Persist the final directory entry before SQLite can expose a referencing row.
// A directory sync failure is deliberately fatal; the unreachable file may remain
// as an orphan, but no evidence_assets row may be committed for it.
if err := store.syncDirectory(finalDirectory); err != nil {
return core.Asset{}, false, fmt.Errorf("persist evidence directory entry: %w", err)
}
id, err := newUUID(store.random)
if err != nil {
return core.Asset{}, false, err
}
now := store.now().UTC()
asset := core.Asset{
ID: id, TaskID: metadata.TaskID, AttemptID: metadata.AttemptID,
Kind: metadata.Kind, PrivacyTier: metadata.PrivacyTier, SHA256: staged.SHA256,
ByteSize: staged.ByteSize, ContentType: staged.ContentType, Width: staged.Width, Height: staged.Height,
CapturedAt: metadata.CapturedAt.UTC(), UploadedByDeviceID: principal.ID,
StorageKey: storageKey, CreatedAt: now,
}
_, err = transaction.ExecContext(ctx, `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
asset.ID, metadata.UploadKey, asset.TaskID, asset.AttemptID, asset.Kind, asset.PrivacyTier,
asset.SHA256, asset.ByteSize, asset.ContentType, asset.Width, asset.Height, asset.StorageKey,
asset.UploadedByDeviceID, asset.CapturedAt.Format(time.RFC3339Nano), asset.CreatedAt.Format(time.RFC3339Nano))
if err != nil {
return core.Asset{}, false, err
}
if err := store.commitTx(transaction); err != nil {
return core.Asset{}, false, err
}
return asset, false, nil
}
func (store *Store) Open(ctx context.Context, id string) (core.Asset, io.ReadSeekCloser, error) {
if !validUUID(id) {
return core.Asset{}, nil, core.ErrNotFound
}
asset, found, err := findByID(ctx, store.database, id)
if err != nil {
return core.Asset{}, nil, err
}
if !found || asset.StorageKey != storageKey(asset.SHA256) {
return core.Asset{}, nil, core.ErrNotFound
}
path, err := store.pathForKey(asset.StorageKey)
if err != nil {
return core.Asset{}, nil, core.ErrNotFound
}
file, err := os.Open(path)
if errors.Is(err, os.ErrNotExist) {
return core.Asset{}, nil, core.ErrNotFound
}
if err != nil {
return core.Asset{}, nil, err
}
info, err := file.Stat()
if err != nil || !info.Mode().IsRegular() || info.Size() != asset.ByteSize {
_ = file.Close()
if err != nil {
return core.Asset{}, nil, err
}
return core.Asset{}, nil, core.ErrNotFound
}
return asset, file, nil
}
func (store *Store) verifyStoredFile(asset core.Asset) error {
path, err := store.pathForKey(asset.StorageKey)
if err != nil {
return err
}
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() || info.Size() != asset.ByteSize || fileSHA256(path) != asset.SHA256 {
if err != nil {
return err
}
return errors.New("stored evidence file is invalid")
}
return nil
}
func (store *Store) isStagedPath(path string) bool {
if path == "" {
return false
}
relative, err := filepath.Rel(filepath.Join(store.root, ".staging"), filepath.Clean(path))
return err == nil && relative != "." && relative != "" && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)
}
func (store *Store) pathForKey(key string) (string, error) {
path := filepath.Join(store.root, filepath.FromSlash(key))
relative, err := filepath.Rel(store.root, path)
if err != nil || relative == "." || relative == "" || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
return "", errors.New("invalid evidence storage key")
}
return path, nil
}
func (store *Store) preparePublishFile(staged core.StagedFile, directory string) (path string, resultErr error) {
source, err := os.Open(staged.Path)
if err != nil {
return "", err
}
defer source.Close()
temporary, err := os.CreateTemp(directory, ".publish-*.png")
if err != nil {
return "", err
}
temporaryPath := temporary.Name()
path = temporaryPath
defer func() {
if resultErr != nil {
_ = temporary.Close()
_ = os.Remove(temporaryPath)
}
}()
if err := temporary.Chmod(0o600); err != nil {
return "", err
}
hasher := sha256.New()
written, err := io.Copy(io.MultiWriter(temporary, hasher), source)
if err != nil {
return "", err
}
if written != staged.ByteSize || hex.EncodeToString(hasher.Sum(nil)) != staged.SHA256 {
return "", errors.New("staged evidence changed before publication")
}
width, height, err := validatePNG(temporary)
if err != nil {
return "", err
}
if width != staged.Width || height != staged.Height {
return "", errors.New("staged evidence dimensions changed before publication")
}
if err := store.syncFile(temporary); err != nil {
return "", fmt.Errorf("sync evidence publication file: %w", err)
}
if err := temporary.Close(); err != nil {
return "", err
}
return path, nil
}
func validatePNG(reader io.ReadSeeker) (int, int, error) {
if _, err := reader.Seek(0, io.SeekStart); err != nil {
return 0, 0, err
}
signature := make([]byte, len(pngSignature))
if _, err := io.ReadFull(reader, signature); err != nil || string(signature) != string(pngSignature) {
return 0, 0, core.ErrInvalid
}
if _, err := reader.Seek(0, io.SeekStart); err != nil {
return 0, 0, err
}
configuration, err := png.DecodeConfig(reader)
if err != nil || configuration.Width < 1 || configuration.Height < 1 || configuration.Width > core.MaxImageSide || configuration.Height > core.MaxImageSide || int64(configuration.Width)*int64(configuration.Height) > core.MaxImagePixels {
return 0, 0, core.ErrInvalid
}
if _, err := reader.Seek(0, io.SeekStart); err != nil {
return 0, 0, err
}
if _, err := png.Decode(reader); err != nil {
return 0, 0, core.ErrInvalid
}
var trailing [1]byte
if count, err := reader.Read(trailing[:]); count != 0 || !errors.Is(err, io.EOF) {
return 0, 0, core.ErrInvalid
}
return configuration.Width, configuration.Height, nil
}
func ensureDurableDirectory(path string, mode os.FileMode, syncParent func(string) error) error {
info, err := os.Stat(path)
if err == nil {
if !info.IsDir() {
return fmt.Errorf("path exists but is not a directory: %s", path)
}
return nil
}
if !errors.Is(err, os.ErrNotExist) {
return err
}
parent := filepath.Dir(path)
if parent == path {
return fmt.Errorf("cannot create filesystem root as a managed directory: %s", path)
}
if err := ensureDurableDirectory(parent, mode, syncParent); err != nil {
return err
}
if err := os.Mkdir(path, mode); err != nil && !errors.Is(err, os.ErrExist) {
return err
}
info, err = os.Stat(path)
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("path exists but is not a directory: %s", path)
}
if err := os.Chmod(path, mode); err != nil {
return err
}
// Syncing the parent makes creation of this directory durable. This also covers
// a concurrent creator: returning success without the parent sync could otherwise
// allow the following database transaction to outrun the directory entry.
if err := syncParent(parent); err != nil {
return fmt.Errorf("persist directory creation for %s: %w", path, err)
}
return nil
}
func storageKey(hash string) string { return hash[:2] + "/" + hash + ".png" }
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 validSHA256(value string) bool {
if len(value) != 64 {
return false
}
for _, character := range value {
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
return false
}
}
return true
}
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 newUUID(reader io.Reader) (string, error) {
bytes := make([]byte, 16)
if _, err := io.ReadFull(reader, bytes); err != nil {
return "", err
}
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
encoded := hex.EncodeToString(bytes)
return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:], nil
}
func fileSHA256(path string) string {
file, err := os.Open(path)
if err != nil {
return ""
}
defer file.Close()
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
return ""
}
return hex.EncodeToString(hasher.Sum(nil))
}
type rowScanner interface{ Scan(...any) error }
func findByUploadKey(ctx context.Context, query interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}, deviceID, uploadKey string) (core.Asset, bool, error) {
return scanAsset(query.QueryRowContext(ctx, `SELECT id, 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 FROM evidence_assets WHERE uploaded_by_device_id = ? AND upload_key = ?`, deviceID, uploadKey))
}
func findByID(ctx context.Context, query interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}, id string) (core.Asset, bool, error) {
return scanAsset(query.QueryRowContext(ctx, `SELECT id, 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 FROM evidence_assets WHERE id = ?`, id))
}
func scanAsset(row rowScanner) (core.Asset, bool, error) {
var asset core.Asset
var captured, created string
err := row.Scan(&asset.ID, &asset.TaskID, &asset.AttemptID, &asset.Kind, &asset.PrivacyTier, &asset.SHA256, &asset.ByteSize, &asset.ContentType, &asset.Width, &asset.Height, &asset.StorageKey, &asset.UploadedByDeviceID, &captured, &created)
if errors.Is(err, sql.ErrNoRows) {
return core.Asset{}, false, nil
}
if err != nil {
return core.Asset{}, false, err
}
asset.CapturedAt, err = time.Parse(time.RFC3339Nano, captured)
if err != nil {
return core.Asset{}, false, err
}
asset.CreatedAt, err = time.Parse(time.RFC3339Nano, created)
if err != nil {
return core.Asset{}, false, err
}
return asset, true, nil
}
func sameUpload(asset core.Asset, principal core.DevicePrincipal, 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)
}
@@ -0,0 +1,552 @@
package evidence
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"image"
"image/color"
"image/png"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
core "cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
)
const (
testTaskID = "13c9f507-7473-4fa6-8d71-8786c34c6301"
testAuthID = "23c9f507-7473-4fa6-8d71-8786c34c6301"
testAttemptID = "33c9f507-7473-4fa6-8d71-8786c34c6301"
testUploadKey = "43c9f507-7473-4fa6-8d71-8786c34c6301"
)
func TestStageCommitReplayAndOpen(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
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"}
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage: %v", err)
}
asset, replayed, err := store.Commit(context.Background(), principal, metadata, staged)
if err != nil || replayed {
t.Fatalf("Commit = replayed %t, err %v", replayed, err)
}
if asset.SHA256 != hash || asset.ByteSize != int64(len(pngBytes)) || asset.Width != 8 || asset.Height != 6 || asset.StorageKey != hash[:2]+"/"+hash+".png" {
t.Fatalf("asset = %#v", asset)
}
opened, reader, err := store.Open(context.Background(), asset.ID)
if err != nil {
t.Fatalf("Open: %v", err)
}
got, err := io.ReadAll(reader)
_ = reader.Close()
if err != nil || !bytes.Equal(got, pngBytes) || opened.ID != asset.ID {
t.Fatalf("opened asset changed: bytes=%t asset=%#v err=%v", bytes.Equal(got, pngBytes), opened, err)
}
replayStage, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("stage replay: %v", err)
}
replayedAsset, replayed, err := store.Commit(context.Background(), principal, metadata, replayStage)
if err != nil || !replayed || replayedAsset.ID != asset.ID {
t.Fatalf("replay = %#v, %t, %v", replayedAsset, replayed, err)
}
conflictStage, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("stage conflict: %v", err)
}
conflicting := metadata
conflicting.CapturedAt = conflicting.CapturedAt.Add(time.Second)
if _, _, err := store.Commit(context.Background(), principal, conflicting, conflictStage); !errors.Is(err, core.ErrConflict) {
t.Fatalf("conflicting replay error = %v", err)
}
}
func TestConcurrentReplayCreatesOneAsset(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 3, 2)
metadata := core.UploadMetadata{UploadKey: testUploadKey, TaskID: testTaskID, AttemptID: testAttemptID, Kind: core.KindSKUPanelGate1, PrivacyTier: core.PrivacyInternalRaw, SHA256: sha256Hex(pngBytes), CapturedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)}
staged := make([]core.StagedFile, 2)
for index := range staged {
var err error
staged[index], err = store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage %d: %v", index, err)
}
}
var wait sync.WaitGroup
wait.Add(2)
assets := make([]core.Asset, 2)
replays := make([]bool, 2)
errorsSeen := make([]error, 2)
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])
}(index)
}
wait.Wait()
if errorsSeen[0] != nil || errorsSeen[1] != nil || assets[0].ID != assets[1].ID || replays[0] == replays[1] {
t.Fatalf("concurrent commits assets=%#v replays=%#v errors=%#v", assets, replays, errorsSeen)
}
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 1 {
t.Fatalf("asset count = %d, err %v", count, err)
}
}
func TestPlatformDirectorySync(t *testing.T) {
if err := syncDirectory(t.TempDir()); err != nil {
t.Fatalf("syncDirectory must either establish the durability boundary or fail closed: %v", err)
}
}
func TestNewStoreRetriesRootParentSyncWhenRootAlreadyExists(t *testing.T) {
database, _ := newTestStore(t)
parent := t.TempDir()
root := filepath.Join(parent, "retry-root")
injected := errors.New("injected root parent sync failure")
if _, err := newStore(database, root, func(string) error { return injected }); !errors.Is(err, injected) {
t.Fatalf("first newStore error = %v, want injected root sync failure", err)
}
if info, err := os.Stat(root); err != nil || !info.IsDir() {
t.Fatalf("failed parent sync must leave root for retry: info=%v err=%v", info, err)
}
var paths []string
store, err := newStore(database, root, func(path string) error {
paths = append(paths, path)
return syncDirectory(path)
})
if err != nil {
t.Fatalf("retry newStore: %v", err)
}
if store == nil || len(paths) == 0 || paths[0] != parent {
t.Fatalf("retry sync paths = %#v, want root parent %q first", paths, parent)
}
}
func TestCommitSyncsShardAndRenameBeforeDatabaseWrite(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 4, 3)
hash := sha256Hex(pngBytes)
metadata := testMetadata(hash)
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage: %v", err)
}
finalPath, err := store.pathForKey(storageKey(hash))
if err != nil {
t.Fatalf("final path: %v", err)
}
finalDirectory := filepath.Dir(finalPath)
var events []string
store.syncDirectory = func(path string) error {
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil {
t.Fatalf("count evidence before directory sync: %v", err)
}
if count != 0 {
t.Fatalf("database row became visible before directory sync: %d", count)
}
switch path {
case store.root:
events = append(events, "sync-root")
if path != store.root {
t.Fatalf("shard parent sync path = %q, want evidence root %q", path, store.root)
}
if info, err := os.Stat(finalDirectory); err != nil || !info.IsDir() {
t.Fatalf("shard directory must exist before parent sync: info=%v err=%v", info, err)
}
if _, err := os.Stat(finalPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("final file exists before publication: %v", err)
}
case finalDirectory:
events = append(events, "sync-shard")
if info, err := os.Stat(finalPath); err != nil || !info.Mode().IsRegular() {
t.Fatalf("renamed file must exist before shard sync: info=%v err=%v", info, err)
}
default:
t.Fatalf("unexpected extra directory sync: %q", path)
}
return syncDirectory(path)
}
store.syncFile = func(file *os.File) error {
if filepath.Dir(file.Name()) != finalDirectory || !strings.HasPrefix(filepath.Base(file.Name()), ".publish-") {
t.Fatalf("publication temp is not inside shard: %q", file.Name())
}
events = append(events, "sync-file")
return file.Sync()
}
store.renameFile = func(oldPath, newPath string) error {
if filepath.Dir(oldPath) != filepath.Dir(newPath) || newPath != finalPath {
t.Fatalf("rename is not same-directory publication: %q -> %q", oldPath, newPath)
}
events = append(events, "rename")
return os.Rename(oldPath, newPath)
}
if _, replayed, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, 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 {
t.Fatalf("durability order = %q, want %q", got, want)
}
assertEvidenceCount(t, database, 1)
assertNoPublishTemps(t, finalDirectory)
}
func TestCommitDirectorySyncFailuresNeverWriteDatabase(t *testing.T) {
for _, failAt := range []int{1, 2, 3} {
t.Run(map[int]string{1: "new shard parent", 2: "unconditional shard parent", 3: "rename target"}[failAt], func(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 4, 3)
hash := sha256Hex(pngBytes)
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage: %v", err)
}
finalPath, err := store.pathForKey(storageKey(hash))
if err != nil {
t.Fatalf("final path: %v", err)
}
injected := errors.New("injected directory sync failure")
calls := 0
store.syncDirectory = func(path string) error {
calls++
if calls == failAt {
return injected
}
return syncDirectory(path)
}
if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, testMetadata(hash), staged); !errors.Is(err, injected) {
t.Fatalf("Commit error = %v, want injected sync failure", err)
}
if calls != failAt {
t.Fatalf("sync calls = %d, want %d", calls, failAt)
}
assertEvidenceCount(t, database, 0)
_, statErr := os.Stat(finalPath)
if failAt < 3 && !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("file exists before rename durability boundary: %v", statErr)
}
if failAt == 3 && statErr != nil {
t.Fatalf("post-rename sync failure may leave an orphan file, stat error = %v", statErr)
}
assertNoPublishTemps(t, filepath.Dir(finalPath))
})
}
}
func TestCommitRetriesShardParentSyncAfterPriorFailure(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 4, 3)
hash := sha256Hex(pngBytes)
finalPath, err := store.pathForKey(storageKey(hash))
if err != nil {
t.Fatalf("final path: %v", err)
}
firstStage, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("first Stage: %v", err)
}
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) {
t.Fatalf("first Commit error = %v", err)
}
if info, err := os.Stat(filepath.Dir(finalPath)); err != nil || !info.IsDir() {
t.Fatalf("failed first sync must leave the created shard for retry: info=%v err=%v", info, err)
}
assertEvidenceCount(t, database, 0)
secondStage, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("second Stage: %v", err)
}
var paths []string
store.syncDirectory = func(path string) error {
paths = append(paths, path)
return syncDirectory(path)
}
if _, replayed, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, 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) {
t.Fatalf("retry sync paths = %#v, want root then shard", paths)
}
assertEvidenceCount(t, database, 1)
assertNoPublishTemps(t, filepath.Dir(finalPath))
}
func TestCommitPublicationFailuresCleanTempAndNeverWriteDatabase(t *testing.T) {
for _, name := range []string{"file sync", "rename"} {
t.Run(name, func(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 4, 3)
hash := sha256Hex(pngBytes)
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage: %v", err)
}
finalPath, err := store.pathForKey(storageKey(hash))
if err != nil {
t.Fatalf("final path: %v", err)
}
injected := errors.New("injected publication failure")
if name == "file sync" {
store.syncFile = func(*os.File) error { return injected }
} 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) {
t.Fatalf("Commit error = %v, want injected publication failure", err)
}
if _, err := os.Stat(finalPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("final file exists after failed publication: %v", err)
}
assertNoPublishTemps(t, filepath.Dir(finalPath))
assertEvidenceCount(t, database, 0)
})
}
}
func TestCommitDatabaseFailuresAfterDurableRenameLeaveOnlyOrphan(t *testing.T) {
for _, test := range []struct {
name string
inject func(*testing.T, *sql.DB, *Store, error)
}{
{
name: "insert",
inject: func(t *testing.T, database *sql.DB, _ *Store, _ error) {
t.Helper()
if _, err := database.Exec(`CREATE TRIGGER fail_evidence_insert BEFORE INSERT ON evidence_assets BEGIN SELECT RAISE(ABORT, 'injected insert failure'); END`); err != nil {
t.Fatalf("create insert failure trigger: %v", err)
}
},
},
{
name: "commit",
inject: func(_ *testing.T, _ *sql.DB, store *Store, injected error) {
store.commitTx = func(*sql.Tx) error { return injected }
},
},
} {
t.Run(test.name, func(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 4, 3)
hash := sha256Hex(pngBytes)
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage: %v", err)
}
finalPath, err := store.pathForKey(storageKey(hash))
if err != nil {
t.Fatalf("final path: %v", err)
}
injected := errors.New("injected database failure")
test.inject(t, database, store, injected)
syncCalls := 0
store.syncDirectory = func(path string) error {
syncCalls++
return syncDirectory(path)
}
if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, testMetadata(hash), staged); err == nil {
t.Fatal("Commit unexpectedly succeeded")
}
if syncCalls != 3 {
t.Fatalf("database failure occurred before both durability syncs: sync calls = %d", syncCalls)
}
if info, err := os.Stat(finalPath); err != nil || !info.Mode().IsRegular() {
t.Fatalf("durable rename may leave only an orphan file: info=%v err=%v", info, err)
}
assertNoPublishTemps(t, filepath.Dir(finalPath))
assertEvidenceCount(t, database, 0)
})
}
}
func TestStageRejectsUnsafeContent(t *testing.T) {
_, store := newTestStore(t)
largePNG := makePNG(t, core.MaxImageSide+1, 1)
pngWithXML := append(makePNG(t, 1, 1), []byte("<hierarchy/>")...)
for name, test := range map[string]struct {
reader io.Reader
contentType string
}{
"wrong content type": {reader: bytes.NewReader(makePNG(t, 1, 1)), contentType: "application/octet-stream"},
"xml": {reader: bytes.NewBufferString("<hierarchy/>"), contentType: core.PNGContentType},
"png with xml tail": {reader: bytes.NewReader(pngWithXML), contentType: core.PNGContentType},
"truncated png": {reader: bytes.NewReader(pngSignature), contentType: core.PNGContentType},
"too wide": {reader: bytes.NewReader(largePNG), contentType: core.PNGContentType},
"too many bytes": {reader: io.LimitReader(zeroReader{}, core.MaxFileBytes+1), contentType: core.PNGContentType},
} {
t.Run(name, func(t *testing.T) {
staged, err := store.Stage(test.reader, test.contentType)
if !errors.Is(err, core.ErrInvalid) && !errors.Is(err, core.ErrTooLarge) {
store.Discard(staged)
t.Fatalf("Stage error = %v", err)
}
})
}
}
func TestCommitRequiresAttemptOwnedByTaskAndLowercaseHash(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 2, 2)
base := core.UploadMetadata{UploadKey: testUploadKey, TaskID: testTaskID, AttemptID: testAttemptID, Kind: core.KindSKUPanelGate1, PrivacyTier: core.PrivacyInternalRaw, SHA256: sha256Hex(pngBytes), CapturedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)}
for name, mutate := range map[string]func(*core.UploadMetadata){
"unknown attempt": func(value *core.UploadMetadata) { value.AttemptID = "53c9f507-7473-4fa6-8d71-8786c34c6301" },
"uppercase hash": func(value *core.UploadMetadata) {
value.SHA256 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
},
"wrong kind": func(value *core.UploadMetadata) { value.Kind = "ORDER_CONFIRM" },
} {
t.Run(name, func(t *testing.T) {
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage: %v", err)
}
metadata := base
mutate(&metadata)
if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, metadata, staged); !errors.Is(err, core.ErrInvalid) {
t.Fatalf("Commit error = %v", err)
}
})
}
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 0 {
t.Fatalf("invalid commits created %d assets, err %v", count, err)
}
}
func TestNewStoreRejectsRelativeAndFilesystemRootPaths(t *testing.T) {
database, _ := newTestStore(t)
if _, err := NewStore(database, "relative-evidence"); err == nil {
t.Fatal("relative evidence root succeeded")
}
volumeRoot := filepath.VolumeName(t.TempDir()) + string(filepath.Separator)
if _, err := NewStore(database, volumeRoot); err == nil {
t.Fatal("filesystem root succeeded")
}
}
type zeroReader struct{}
func (zeroReader) Read(buffer []byte) (int, error) {
for index := range buffer {
buffer[index] = 0
}
return len(buffer), nil
}
func newTestStore(t *testing.T) (*sql.DB, *Store) {
t.Helper()
database, err := sqlite.Open(filepath.Join(t.TempDir(), "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 test")
}
directory := filepath.Join(filepath.Dir(file), "..", "..", "..", "migrations")
if err := migrations.Up(context.Background(), database, directory); err != nil {
t.Fatalf("migrate database: %v", err)
}
store, err := NewStore(database, filepath.Join(t.TempDir(), "assets"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
return database, store
}
func insertAttemptFixture(t *testing.T, database *sql.DB) {
t.Helper()
timestamp := "2026-08-04T00:00:00Z"
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)
}
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, 1, 'start', '123', 'black', 'M', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, testAuthID, testTaskID, timestamp, timestamp); err != nil {
t.Fatalf("insert authorization: %v", err)
}
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)
}
}
func testMetadata(hash string) core.UploadMetadata {
return 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),
}
}
func assertEvidenceCount(t *testing.T, database *sql.DB, want int) {
t.Helper()
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil {
t.Fatalf("count evidence assets: %v", err)
}
if count != want {
t.Fatalf("evidence asset count = %d, want %d", count, want)
}
}
func assertNoPublishTemps(t *testing.T, directory string) {
t.Helper()
entries, err := os.ReadDir(directory)
if err != nil {
t.Fatalf("read shard directory: %v", err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".publish-") {
t.Fatalf("publication temp leaked: %q", entry.Name())
}
}
}
func makePNG(t *testing.T, width, height int) []byte {
t.Helper()
imageData := image.NewNRGBA(image.Rect(0, 0, width, height))
imageData.Set(0, 0, color.NRGBA{R: 12, G: 34, B: 56, A: 255})
var buffer bytes.Buffer
if err := png.Encode(&buffer, imageData); err != nil {
t.Fatalf("encode PNG: %v", err)
}
return buffer.Bytes()
}
func sha256Hex(value []byte) string {
hash := sha256.Sum256(value)
return hex.EncodeToString(hash[:])
}
+35
View File
@@ -0,0 +1,35 @@
// Package sqlite 提供采购服务的 SQLite 驱动注册。
package sqlite
import (
"database/sql"
"fmt"
"github.com/mattn/go-sqlite3"
)
const driverName = "cmbuyer-sqlite3"
func init() {
sql.Register(driverName, &sqlite3.SQLiteDriver{
ConnectHook: func(connection *sqlite3.SQLiteConn) error {
_, err := connection.Exec("PRAGMA foreign_keys = ON", nil)
return err
},
})
}
// Open 打开 SQLite 数据源并逐连接启用外键,避免连接池配置遗漏而绕过授权与任务的引用约束。
func Open(dataSourceName string) (*sql.DB, error) {
database, err := sql.Open(driverName, dataSourceName)
if err != nil {
return nil, err
}
if err := database.Ping(); err != nil {
_ = database.Close()
return nil, fmt.Errorf("ping SQLite database: %w", err)
}
return database, nil
}
@@ -0,0 +1,35 @@
package sqlite_test
import (
"context"
"testing"
"cmbuyer/admin/internal/storage/sqlite"
)
func TestOpen(t *testing.T) {
database, err := sqlite.Open(":memory:")
if err != nil {
t.Fatalf("open SQLite database: %v", err)
}
t.Cleanup(func() {
if err := database.Close(); err != nil {
t.Errorf("close SQLite database: %v", err)
}
})
if err := database.PingContext(context.Background()); err != nil {
t.Fatalf("ping SQLite database: %v", err)
}
database.SetMaxIdleConns(0)
for attempt := 0; attempt < 2; attempt++ {
var foreignKeysEnabled int
if err := database.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeysEnabled); err != nil {
t.Fatalf("read SQLite foreign key setting: %v", err)
}
if foreignKeysEnabled != 1 {
t.Fatalf("SQLite foreign_keys = %d, want 1", foreignKeysEnabled)
}
}
}
+60
View File
@@ -0,0 +1,60 @@
// Package taskdetail provides a read-only audit projection for one task.
package taskdetail
import (
"context"
"errors"
"time"
)
var ErrNotFound = errors.New("task detail not found")
type Store interface {
Get(context.Context, string) (Detail, error)
}
type Detail struct {
Task Task
Authorizations []Authorization
Attempts []Attempt
Submissions []Submission
Evidence []Evidence
}
type Task struct {
ID, Source, Title, GoodsID, SKUColor, SKUSize, MaxTotalPrice, Status string
Quantity, Version int
CreatedAt, UpdatedAt time.Time
}
type Authorization struct {
ID, Status, CreatedBy, TotalPriceCap string
TaskVersion int
CreatedAt, ExpiresAt time.Time
}
type Attempt struct {
ID, AuthorizationID, Status string
ClaimGeneration int
Gate1UnitPrice *string
Gate2UnitPrice *string
QuantityRead *int
ConfirmAmount *string
FailureCode *string
StartedAt time.Time
FinishedAt *time.Time
}
type Submission struct {
ID, AuthorizationID, AttemptID, Status string
Gate1UnitPrice, Gate2UnitPrice, ConfirmAmount string
QuantityRead int
CreatedAt time.Time
ResolvedAt *time.Time
}
type Evidence struct {
ID, AttemptID, Kind, PrivacyTier, SHA256, ContentType string
ByteSize, Width, Height int64
CapturedAt time.Time
}
+204
View File
@@ -0,0 +1,204 @@
package taskdetail
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
type SQLiteStore struct{ database *sql.DB }
func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
if database == nil {
return nil, errors.New("task detail database is required")
}
if _, err := database.Exec("SELECT storage_key FROM evidence_assets LIMIT 1"); err != nil {
return nil, fmt.Errorf("task detail migration is not available: %w", err)
}
return &SQLiteStore{database: database}, nil
}
func (store *SQLiteStore) Get(ctx context.Context, id string) (Detail, error) {
if !validUUID(id) {
return Detail{}, ErrNotFound
}
tx, err := store.database.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
if err != nil {
return Detail{}, err
}
defer tx.Rollback()
var detail Detail
var created, updated string
err = tx.QueryRowContext(ctx, `SELECT id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at FROM tasks WHERE id = ?`, id).Scan(
&detail.Task.ID, &detail.Task.Source, &detail.Task.Title, &detail.Task.GoodsID, &detail.Task.SKUColor, &detail.Task.SKUSize,
&detail.Task.Quantity, &detail.Task.MaxTotalPrice, &detail.Task.Status, &detail.Task.Version, &created, &updated,
)
if errors.Is(err, sql.ErrNoRows) {
return Detail{}, ErrNotFound
}
if err != nil {
return Detail{}, err
}
if detail.Task.CreatedAt, err = parseTime(created); err != nil {
return Detail{}, err
}
if detail.Task.UpdatedAt, err = parseTime(updated); err != nil {
return Detail{}, err
}
if detail.Authorizations, err = readAuthorizations(ctx, tx, id); err != nil {
return Detail{}, err
}
if detail.Attempts, err = readAttempts(ctx, tx, id); err != nil {
return Detail{}, err
}
if detail.Submissions, err = readSubmissions(ctx, tx, id); err != nil {
return Detail{}, err
}
if detail.Evidence, err = readEvidence(ctx, tx, id); err != nil {
return Detail{}, err
}
if err := tx.Commit(); err != nil {
return Detail{}, err
}
return detail, nil
}
func readAuthorizations(ctx context.Context, tx *sql.Tx, taskID string) ([]Authorization, error) {
rows, err := tx.QueryContext(ctx, `SELECT id, task_version, total_price_cap, status, created_by, created_at, expires_at FROM order_authorizations WHERE task_id = ? ORDER BY created_at DESC, id DESC`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []Authorization{}
for rows.Next() {
var item Authorization
var created, expires string
if err := rows.Scan(&item.ID, &item.TaskVersion, &item.TotalPriceCap, &item.Status, &item.CreatedBy, &created, &expires); err != nil {
return nil, err
}
if item.CreatedAt, err = parseTime(created); err != nil {
return nil, err
}
if item.ExpiresAt, err = parseTime(expires); err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
func readAttempts(ctx context.Context, tx *sql.Tx, taskID string) ([]Attempt, error) {
rows, err := tx.QueryContext(ctx, `SELECT id, authorization_id, claim_generation, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, failure_code, started_at, finished_at FROM purchase_attempts WHERE task_id = ? ORDER BY started_at DESC, id DESC`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []Attempt{}
for rows.Next() {
var item Attempt
var gate1, gate2, confirm, failure, finished sql.NullString
var quantity sql.NullInt64
var started string
if err := rows.Scan(&item.ID, &item.AuthorizationID, &item.ClaimGeneration, &item.Status, &gate1, &gate2, &quantity, &confirm, &failure, &started, &finished); err != nil {
return nil, err
}
item.Gate1UnitPrice, item.Gate2UnitPrice, item.ConfirmAmount, item.FailureCode = stringPointer(gate1), stringPointer(gate2), stringPointer(confirm), stringPointer(failure)
if quantity.Valid {
value := int(quantity.Int64)
item.QuantityRead = &value
}
if item.StartedAt, err = parseTime(started); err != nil {
return nil, err
}
if finished.Valid {
value, parseErr := parseTime(finished.String)
if parseErr != nil {
return nil, parseErr
}
item.FinishedAt = &value
}
result = append(result, item)
}
return result, rows.Err()
}
func readSubmissions(ctx context.Context, tx *sql.Tx, taskID string) ([]Submission, error) {
rows, err := tx.QueryContext(ctx, `SELECT id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at, resolved_at FROM order_submissions WHERE task_id = ? ORDER BY created_at DESC, id DESC`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []Submission{}
for rows.Next() {
var item Submission
var created string
var resolved sql.NullString
if err := rows.Scan(&item.ID, &item.AuthorizationID, &item.AttemptID, &item.Status, &item.Gate1UnitPrice, &item.Gate2UnitPrice, &item.QuantityRead, &item.ConfirmAmount, &created, &resolved); err != nil {
return nil, err
}
if item.CreatedAt, err = parseTime(created); err != nil {
return nil, err
}
if resolved.Valid {
value, parseErr := parseTime(resolved.String)
if parseErr != nil {
return nil, parseErr
}
item.ResolvedAt = &value
}
result = append(result, item)
}
return result, rows.Err()
}
func readEvidence(ctx context.Context, tx *sql.Tx, taskID string) ([]Evidence, error) {
rows, err := tx.QueryContext(ctx, `SELECT id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, captured_at FROM evidence_assets WHERE task_id = ? ORDER BY captured_at, created_at, id`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []Evidence{}
for rows.Next() {
var item Evidence
var captured string
if err := rows.Scan(&item.ID, &item.AttemptID, &item.Kind, &item.PrivacyTier, &item.SHA256, &item.ByteSize, &item.ContentType, &item.Width, &item.Height, &captured); err != nil {
return nil, err
}
if item.CapturedAt, err = parseTime(captured); err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
func parseTime(value string) (time.Time, error) { return time.Parse(time.RFC3339Nano, value) }
func stringPointer(value sql.NullString) *string {
if !value.Valid {
return nil
}
copy := value.String
return &copy
}
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')
}
+82
View File
@@ -0,0 +1,82 @@
package taskdetail
import (
"context"
"database/sql"
"errors"
"path/filepath"
"runtime"
"strings"
"testing"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
)
const (
detailTask = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
detailAuth = "b3c9f507-7473-4fa6-8d71-8786c34c6301"
detailTry = "c3c9f507-7473-4fa6-8d71-8786c34c6301"
)
func TestSQLiteStoreReturnsOnlyPersistedAuditFacts(t *testing.T) {
database := openDetailDatabase(t)
timestamp := "2026-08-04T00:00:00Z"
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)
}
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, 2, 'start', '123', 'black', 'M', 2, '30.00', 'CLAIMED', 'admin', ?, ?)`, detailAuth, detailTask, timestamp, timestamp); err != nil {
t.Fatalf("insert authorization: %v", err)
}
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)
}
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 {
t.Fatalf("insert evidence: %v", err)
}
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
detail, err := store.Get(context.Background(), detailTask)
if err != nil {
t.Fatalf("Get: %v", err)
}
if detail.Task.ID != detailTask || detail.Task.Status != "CLAIMED" || len(detail.Authorizations) != 1 || len(detail.Attempts) != 1 || len(detail.Evidence) != 1 || len(detail.Submissions) != 0 {
t.Fatalf("detail = %#v", detail)
}
if detail.Attempts[0].Gate1UnitPrice != nil || detail.Attempts[0].FailureCode != nil {
t.Fatalf("missing attempt facts were fabricated: %#v", detail.Attempts[0])
}
}
func TestSQLiteStoreFailsClosedForMalformedAndMissingIDs(t *testing.T) {
database := openDetailDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
for _, id := range []string{"../database", "not-a-uuid", "a3c9f507-7473-1fa6-8d71-8786c34c6301"} {
if _, err := store.Get(context.Background(), id); !errors.Is(err, ErrNotFound) {
t.Fatalf("Get(%q) error = %v", id, err)
}
}
}
func openDetailDatabase(t *testing.T) *sql.DB {
t.Helper()
database, err := sqlite.Open(filepath.Join(t.TempDir(), "details.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 {
t.Fatalf("migrate database: %v", err)
}
return database
}
+128
View File
@@ -0,0 +1,128 @@
package tasks
import (
"errors"
"math"
"math/big"
"sort"
"strings"
"time"
_ "time/tzdata"
)
const maxStartItems = 100
var (
ErrStartConflict = errors.New("purchase start conflicts with current task state")
ErrInvalidStart = errors.New("invalid purchase start request")
)
type StartPolicy struct {
AuthorizationTTL time.Duration
MaxQuantity int
MaxTotalPrice string
}
type StartItem struct {
TaskID string `json:"task_id"`
ExpectedTaskVersion int `json:"expected_task_version"`
}
type StartCommand struct {
StartKey string `json:"start_key"`
Tasks []StartItem `json:"tasks"`
}
type AuthorizedTask struct {
TaskID string `json:"task_id"`
TaskVersion int `json:"task_version"`
AuthorizationID string `json:"authorization_id"`
ExpiresAt time.Time `json:"expires_at"`
}
type StartResult struct {
StartKey string `json:"start_key"`
AuthorizedCount int `json:"authorized_count"`
Tasks []AuthorizedTask `json:"tasks"`
PaymentAutomated bool `json:"payment_automated"`
}
type TaskFilter struct{ Keyword, Status, CreatedFrom, CreatedTo string }
type TaskRow struct {
ID, Title, GoodsID, SKUColor, SKUSize, MaxTotalPrice, Status string
Quantity, Version int
CreatedAt time.Time
}
func normalizeCents(value string) (string, *big.Int, bool) {
if value == "" || strings.TrimSpace(value) != value {
return "", nil, false
}
parts := strings.Split(value, ".")
if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) != 2 || (len(parts[0]) > 1 && parts[0][0] == '0') {
return "", nil, false
}
for _, part := range parts {
for _, ch := range part {
if ch < '0' || ch > '9' {
return "", nil, false
}
}
}
cents := new(big.Int)
if _, ok := cents.SetString(parts[0]+parts[1], 10); !ok || cents.Sign() <= 0 {
return "", nil, false
}
return value, cents, true
}
func startItems(command StartCommand) ([]StartItem, error) {
if !validUUID(command.StartKey) || len(command.Tasks) == 0 || len(command.Tasks) > maxStartItems {
return nil, ErrInvalidStart
}
items := append([]StartItem(nil), command.Tasks...)
sort.Slice(items, func(i, j int) bool { return items[i].TaskID < items[j].TaskID })
for i, item := range items {
if !validUUID(item.TaskID) || item.ExpectedTaskVersion <= 0 || item.ExpectedTaskVersion == math.MaxInt || (i > 0 && item.TaskID == items[i-1].TaskID) {
return nil, ErrInvalidStart
}
}
return items, nil
}
func validTaskStatus(value string) bool {
if value == "" {
return true
}
for _, status := range []string{"DRAFT", "PENDING", "CLAIMED", "ORDERING", "NEEDS_MANUAL", "WAITING_PAYMENT", "RECONCILIATION_REQUIRED", "SUCCEEDED", "FAILED", "CANCELED"} {
if value == status {
return true
}
}
return false
}
func ShanghaiRange(from, to string) (time.Time, time.Time, error) {
if from == "" && to == "" {
return time.Time{}, time.Time{}, nil
}
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return time.Time{}, time.Time{}, err
}
parse := func(value string) (time.Time, error) { return time.ParseInLocation("2006-01-02", value, location) }
var start, end time.Time
if from != "" {
start, err = parse(from)
if err != nil {
return time.Time{}, time.Time{}, ErrInvalidStart
}
start = start.UTC()
}
if to != "" {
end, err = parse(to)
if err != nil {
return time.Time{}, time.Time{}, ErrInvalidStart
}
end = end.AddDate(0, 0, 1).UTC()
}
if !start.IsZero() && !end.IsZero() && !start.Before(end) {
return time.Time{}, time.Time{}, ErrInvalidStart
}
return start, end, nil
}
@@ -0,0 +1,424 @@
package tasks
import (
"context"
"database/sql"
"errors"
"fmt"
"math"
"reflect"
"sort"
"sync"
"testing"
"time"
"cmbuyer/admin/internal/migrations"
)
var fixedStartTime = time.Date(2026, 8, 4, 9, 2, 3, 456000000, time.FixedZone("UTC+8", 8*60*60))
func TestStartPurchasesPersistsCompleteSnapshotsForOneAndHundredTasks(t *testing.T) {
for _, count := range []int{1, 100} {
t.Run(fmt.Sprintf("%d tasks", count), func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
store.now = func() time.Time { return fixedStartTime }
items := make([]StartItem, 0, count)
wantDrafts := make(map[string]Draft, count)
for index := 1; index <= count; index++ {
id := startTestUUID(index)
draft := Draft{
ID: id,
Title: fmt.Sprintf("task-%03d", index),
GoodsID: fmt.Sprintf("937122%06d", index),
SKUColor: fmt.Sprintf("color-%03d", index),
SKUSize: fmt.Sprintf("size-%03d", index),
Quantity: index%10 + 1,
MaxTotalPrice: fmt.Sprintf("%d.%02d", index+10, index%100),
}
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
t.Fatalf("create draft %d: %v", index, err)
}
items = append(items, StartItem{TaskID: id, ExpectedTaskVersion: 1})
wantDrafts[id] = draft
}
sort.Slice(items, func(i, j int) bool { return items[i].TaskID > items[j].TaskID })
command := StartCommand{StartKey: startTestUUID(1001 + count), Tasks: items}
result, err := store.StartPurchases(context.Background(), command, "authenticated-admin")
if err != nil {
t.Fatalf("StartPurchases: %v", err)
}
if result.StartKey != command.StartKey || result.AuthorizedCount != count || result.PaymentAutomated || len(result.Tasks) != count {
t.Fatalf("result = %#v", result)
}
wantCreated := fixedStartTime.UTC()
wantExpires := wantCreated.Add(15 * time.Minute)
seenAuthorizationIDs := map[string]bool{}
for index, authorized := range result.Tasks {
if index > 0 && result.Tasks[index-1].TaskID >= authorized.TaskID {
t.Fatalf("result is not in canonical task order: %#v", result.Tasks)
}
if authorized.TaskVersion != 2 || !authorized.ExpiresAt.Equal(wantExpires) || !validUUID(authorized.AuthorizationID) || seenAuthorizationIDs[authorized.AuthorizationID] {
t.Fatalf("authorized task = %#v", authorized)
}
seenAuthorizationIDs[authorized.AuthorizationID] = true
want := wantDrafts[authorized.TaskID]
var taskStatus, taskUpdated, authTaskID, authStartKey, goodsID, color, size, priceCap, authStatus, createdBy, createdAt, expiresAt string
var taskVersion, authTaskVersion, quantity int
err := database.QueryRow(`
SELECT t.status,t.version,t.updated_at,
a.task_id,a.task_version,a.start_key,a.goods_id,a.sku_color,a.sku_size,a.quantity,a.total_price_cap,a.status,a.created_by,a.created_at,a.expires_at
FROM tasks t JOIN order_authorizations a ON a.task_id=t.id WHERE a.id=?`, authorized.AuthorizationID).
Scan(&taskStatus, &taskVersion, &taskUpdated, &authTaskID, &authTaskVersion, &authStartKey, &goodsID, &color, &size, &quantity, &priceCap, &authStatus, &createdBy, &createdAt, &expiresAt)
if err != nil {
t.Fatalf("read authorization snapshot: %v", err)
}
if taskStatus != "PENDING" || taskVersion != 2 || taskUpdated != wantCreated.Format(time.RFC3339Nano) ||
authTaskID != want.ID || authTaskVersion != 2 || authStartKey != command.StartKey ||
goodsID != want.GoodsID || color != want.SKUColor || size != want.SKUSize || quantity != want.Quantity || priceCap != want.MaxTotalPrice ||
authStatus != "ACTIVE" || createdBy != "authenticated-admin" || createdAt != wantCreated.Format(time.RFC3339Nano) || expiresAt != wantExpires.Format(time.RFC3339Nano) {
t.Fatalf("stored task/authorization mismatch for %s", want.ID)
}
}
var distinctCreated, distinctExpires int
if err := database.QueryRow(`SELECT COUNT(DISTINCT created_at), COUNT(DISTINCT expires_at) FROM order_authorizations WHERE start_key=?`, command.StartKey).Scan(&distinctCreated, &distinctExpires); err != nil {
t.Fatal(err)
}
if distinctCreated != 1 || distinctExpires != 1 {
t.Fatalf("batch timestamps are not shared: created=%d expires=%d", distinctCreated, distinctExpires)
}
})
}
}
func TestStartPurchasesRejectsInvalidCommandsAndPolicyWithoutWrites(t *testing.T) {
validItem := StartItem{TaskID: startTestUUID(1), ExpectedTaskVersion: 1}
hundredOne := make([]StartItem, 101)
for index := range hundredOne {
hundredOne[index] = StartItem{TaskID: startTestUUID(index + 1), ExpectedTaskVersion: 1}
}
for name, command := range map[string]StartCommand{
"invalid start key": {StartKey: "not-a-uuid", Tasks: []StartItem{validItem}},
"empty tasks": {StartKey: startTestUUID(1001)},
"over batch limit": {StartKey: startTestUUID(1001), Tasks: hundredOne},
"invalid task id": {StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: "1", ExpectedTaskVersion: 1}}},
"duplicate task": {StartKey: startTestUUID(1001), Tasks: []StartItem{validItem, validItem}},
"zero version": {StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: validItem.TaskID}}},
"overflow version": {StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: validItem.TaskID, ExpectedTaskVersion: math.MaxInt}}},
} {
t.Run(name, func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
_, err := store.StartPurchases(context.Background(), command, "admin")
if !errors.Is(err, ErrInvalidStart) {
t.Fatalf("error = %v, want ErrInvalidStart", err)
}
assertAuthorizationCount(t, database, 0)
})
}
for name, mutate := range map[string]func(*SQLiteStore){
"zero ttl": func(store *SQLiteStore) { store.policy.AuthorizationTTL = 0 },
"zero quantity": func(store *SQLiteStore) { store.policy.MaxQuantity = 0 },
"bad max price": func(store *SQLiteStore) { store.policy.MaxTotalPrice = "999" },
} {
t.Run(name, func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
createStartDraft(t, store, validItem.TaskID)
mutate(store)
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{validItem}}, "admin")
if !errors.Is(err, ErrInvalidStart) {
t.Fatalf("error = %v, want ErrInvalidStart", err)
}
assertDraftUnchanged(t, database, validItem.TaskID)
assertAuthorizationCount(t, database, 0)
})
}
database := migratedDatabase(t)
store := configuredStartStore(t, database)
createStartDraft(t, store, validItem.TaskID)
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{validItem}}, "")
if !errors.Is(err, ErrInvalidStart) {
t.Fatalf("empty created_by error = %v", err)
}
assertDraftUnchanged(t, database, validItem.TaskID)
}
func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T) {
for name, mutate := range map[string]func(*testing.T, *SQLiteStore, string, *StartItem){
"missing": func(_ *testing.T, _ *SQLiteStore, _ string, item *StartItem) {
item.TaskID = startTestUUID(99)
},
"not draft": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET status='PENDING' WHERE id=?`, id)
},
"version mismatch": func(_ *testing.T, _ *SQLiteStore, _ string, item *StartItem) {
item.ExpectedTaskVersion = 2
},
"empty goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET goods_id='' WHERE id=?`, id)
},
"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)
},
"empty color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_color='' WHERE id=?`, id)
},
"empty size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_size='' WHERE id=?`, id)
},
"quantity over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
store.policy.MaxQuantity = 1
},
"noncanonical price one decimal": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET max_total_price='12.8' WHERE id=?`, id)
},
"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)
},
"price over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
store.policy.MaxTotalPrice = "12.79"
},
} {
t.Run(name, func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
id := startTestUUID(1)
createStartDraft(t, store, id)
item := StartItem{TaskID: id, ExpectedTaskVersion: 1}
mutate(t, store, id, &item)
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{item}}, "admin")
if !errors.Is(err, ErrStartConflict) {
t.Fatalf("error = %v, want ErrStartConflict", err)
}
assertAuthorizationCount(t, database, 0)
})
}
}
func TestStartPurchasesRollsBackWholeBatchForLateConflictAndSQLFailure(t *testing.T) {
for _, test := range []struct {
name string
breakBatch func(*testing.T, *SQLiteStore, string)
}{
{name: "late validation conflict", breakBatch: func(t *testing.T, store *SQLiteStore, secondID string) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_size='' WHERE id=?`, secondID)
}},
{name: "late SQL failure", breakBatch: func(t *testing.T, store *SQLiteStore, secondID string) {
statement := fmt.Sprintf(`CREATE TRIGGER reject_second_authorization BEFORE INSERT ON order_authorizations WHEN NEW.task_id='%s' BEGIN SELECT RAISE(ABORT, 'test failure'); END`, secondID)
execTestSQL(t, store.database, statement)
}},
} {
t.Run(test.name, func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
firstID, secondID := startTestUUID(1), startTestUUID(2)
createStartDraft(t, store, firstID)
createStartDraft(t, store, secondID)
test.breakBatch(t, store, secondID)
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: firstID, ExpectedTaskVersion: 1}, {TaskID: secondID, ExpectedTaskVersion: 1}}}, "admin")
if err == nil {
t.Fatal("StartPurchases unexpectedly succeeded")
}
assertDraftUnchanged(t, database, firstID)
var secondStatus string
var secondVersion int
if err := database.QueryRow(`SELECT status,version FROM tasks WHERE id=?`, secondID).Scan(&secondStatus, &secondVersion); err != nil {
t.Fatal(err)
}
if secondStatus != "DRAFT" || secondVersion != 1 {
t.Fatalf("second task = %s/v%d, want DRAFT/v1", secondStatus, secondVersion)
}
assertAuthorizationCount(t, database, 0)
})
}
}
func TestStartPurchasesReplayIsStableAndRejectsDifferentOrIncompleteSets(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
firstID, secondID, thirdID := startTestUUID(1), startTestUUID(2), startTestUUID(3)
for _, id := range []string{firstID, secondID, thirdID} {
createStartDraft(t, store, id)
}
command := StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: secondID, ExpectedTaskVersion: 1}, {TaskID: firstID, ExpectedTaskVersion: 1}}}
first, err := store.StartPurchases(context.Background(), command, "admin")
if err != nil {
t.Fatal(err)
}
command.Tasks[0], command.Tasks[1] = command.Tasks[1], command.Tasks[0]
replay, err := store.StartPurchases(context.Background(), command, "admin")
if err != nil || !reflect.DeepEqual(replay, first) {
t.Fatalf("replay = (%#v, %v), want %#v", replay, err, first)
}
assertAuthorizationCount(t, database, 2)
conflicting := []StartCommand{
{StartKey: command.StartKey, Tasks: command.Tasks[:1]},
{StartKey: command.StartKey, Tasks: []StartItem{{TaskID: firstID, ExpectedTaskVersion: 2}, {TaskID: secondID, ExpectedTaskVersion: 1}}},
{StartKey: command.StartKey, Tasks: []StartItem{{TaskID: firstID, ExpectedTaskVersion: 1}, {TaskID: secondID, ExpectedTaskVersion: 1}, {TaskID: thirdID, ExpectedTaskVersion: 1}}},
}
for _, changed := range conflicting {
if _, err := store.StartPurchases(context.Background(), changed, "admin"); !errors.Is(err, ErrStartConflict) {
t.Fatalf("different payload error = %v", err)
}
}
assertAuthorizationCount(t, database, 2)
assertDraftUnchanged(t, database, thirdID)
execTestSQL(t, database, `DELETE FROM order_authorizations WHERE task_id=?`, secondID)
if _, err := store.StartPurchases(context.Background(), command, "admin"); !errors.Is(err, ErrStartConflict) {
t.Fatalf("incomplete replay error = %v", err)
}
assertAuthorizationCount(t, database, 1)
}
func TestStartPurchasesConcurrentReplayAndVersionRace(t *testing.T) {
t.Run("same key replays one stable result", 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}}}
const callers = 16
start := make(chan struct{})
results := make(chan StartResult, callers)
errorsChannel := make(chan error, callers)
var group sync.WaitGroup
for range callers {
group.Add(1)
go func() {
defer group.Done()
<-start
result, err := store.StartPurchases(context.Background(), command, "admin")
if err != nil {
errorsChannel <- err
return
}
results <- result
}()
}
close(start)
group.Wait()
close(results)
close(errorsChannel)
for err := range errorsChannel {
t.Fatalf("concurrent replay: %v", err)
}
var want StartResult
for result := range results {
if want.StartKey == "" {
want = result
} else if !reflect.DeepEqual(result, want) {
t.Fatalf("unstable replay: %#v != %#v", result, want)
}
}
assertAuthorizationCount(t, database, 1)
var version int
if err := database.QueryRow(`SELECT version FROM tasks WHERE id=?`, id).Scan(&version); err != nil || version != 2 {
t.Fatalf("task version = %d, err=%v", version, err)
}
})
t.Run("different keys race one expected version", func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
id := startTestUUID(1)
createStartDraft(t, store, id)
start := make(chan struct{})
errorsChannel := make(chan error, 2)
var group sync.WaitGroup
for _, key := range []string{startTestUUID(1001), startTestUUID(1002)} {
group.Add(1)
go func(startKey string) {
defer group.Done()
<-start
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startKey, Tasks: []StartItem{{TaskID: id, ExpectedTaskVersion: 1}}}, "admin")
errorsChannel <- err
}(key)
}
close(start)
group.Wait()
close(errorsChannel)
successes, conflicts := 0, 0
for err := range errorsChannel {
switch {
case err == nil:
successes++
case errors.Is(err, ErrStartConflict):
conflicts++
default:
t.Fatalf("unexpected race error: %v", err)
}
}
if successes != 1 || conflicts != 1 {
t.Fatalf("success/conflict = %d/%d, want 1/1", successes, conflicts)
}
assertAuthorizationCount(t, database, 1)
})
}
func TestSQLiteStoreRejectsV1SchemaAtStartup(t *testing.T) {
database := openDatabase(t)
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
t.Fatalf("migrate to v1: %v", err)
}
if _, err := NewSQLiteStore(database); err == nil {
t.Fatal("NewSQLiteStore accepted the v1 two-pass schema")
}
}
func configuredStartStore(t *testing.T, database *sql.DB) *SQLiteStore {
t.Helper()
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
store.SetStartPolicy(StartPolicy{AuthorizationTTL: 15 * time.Minute, MaxQuantity: 10, MaxTotalPrice: "999.99"})
return store
}
func createStartDraft(t *testing.T, store *SQLiteStore, id string) {
t.Helper()
draft := Draft{ID: id, Title: "test", GoodsID: "937122477375", SKUColor: "黑色", SKUSize: "M", Quantity: 2, MaxTotalPrice: "12.80"}
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
t.Fatalf("CreateDraft: %v", err)
}
}
func startTestUUID(number int) string {
return fmt.Sprintf("%08x-1234-4abc-a123-%012x", number, number)
}
func assertAuthorizationCount(t *testing.T, database *sql.DB, want int) {
t.Helper()
var got int
if err := database.QueryRow(`SELECT COUNT(*) FROM order_authorizations`).Scan(&got); err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("authorization count = %d, want %d", got, want)
}
}
func assertDraftUnchanged(t *testing.T, database *sql.DB, id string) {
t.Helper()
var status string
var version int
if err := database.QueryRow(`SELECT status,version FROM tasks WHERE id=?`, id).Scan(&status, &version); err != nil {
t.Fatal(err)
}
if status != "DRAFT" || version != 1 {
t.Fatalf("task %s = %s/v%d, want DRAFT/v1", id, status, version)
}
}
func execTestSQL(t *testing.T, database *sql.DB, statement string, arguments ...any) {
t.Helper()
if _, err := database.Exec(statement, arguments...); err != nil {
t.Fatalf("execute test SQL: %v", err)
}
}
+229
View File
@@ -0,0 +1,229 @@
package tasks
import (
"context"
"database/sql"
"errors"
"strings"
"time"
"cmbuyer/admin/internal/domain"
)
// ErrInvalidFilter 表示任务筛选值无效,路由应按字段重新渲染而不是泄露内部错误。
var ErrInvalidFilter = errors.New("invalid task filter")
// SetStartPolicy is called during startup; policy is explicit because authorization limits must not be implicit defaults.
func (store *SQLiteStore) SetStartPolicy(policy StartPolicy) { store.policy = policy }
func (store *SQLiteStore) ListTasks(ctx context.Context, filter TaskFilter) ([]TaskRow, error) {
if !ValidateTaskFilter(filter).Valid() {
return nil, ErrInvalidFilter
}
from, to, err := ShanghaiRange(filter.CreatedFrom, filter.CreatedTo)
if err != nil {
return nil, ErrInvalidFilter
}
clauses, args := []string{"1=1"}, []any{}
if filter.Status != "" {
clauses = append(clauses, "status = ?")
args = append(args, filter.Status)
}
if filter.Keyword != "" {
escaped := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(filter.Keyword)
clauses = append(clauses, "(title LIKE ? ESCAPE '\\' OR goods_id LIKE ? ESCAPE '\\')")
args = append(args, "%"+escaped+"%", "%"+escaped+"%")
}
if !from.IsZero() {
clauses = append(clauses, "julianday(created_at) >= julianday(?)")
args = append(args, from.Format(time.RFC3339Nano))
}
if !to.IsZero() {
clauses = append(clauses, "julianday(created_at) < julianday(?)")
args = append(args, to.Format(time.RFC3339Nano))
}
rows, err := store.database.QueryContext(ctx, "SELECT id,title,goods_id,sku_color,sku_size,quantity,max_total_price,status,version,created_at FROM tasks WHERE "+strings.Join(clauses, " AND ")+" ORDER BY julianday(created_at) DESC,rowid DESC", args...)
if err != nil {
return nil, err
}
defer rows.Close()
result := []TaskRow{}
for rows.Next() {
var item TaskRow
var created string
if err := rows.Scan(&item.ID, &item.Title, &item.GoodsID, &item.SKUColor, &item.SKUSize, &item.Quantity, &item.MaxTotalPrice, &item.Status, &item.Version, &created); err != nil {
return nil, err
}
item.CreatedAt, err = time.Parse(time.RFC3339Nano, created)
if err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
// ValidateTaskFilter 返回可关联到字段的错误,使服务端页面拒绝篡改参数时仍能保留输入值。
func ValidateTaskFilter(filter TaskFilter) Errors {
validation := Errors{}
if !validTaskStatus(filter.Status) {
validation["status"] = "请选择有效的任务状态。"
}
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
validation["created_from"] = "日期筛选暂不可用,请稍后重试。"
validation["created_to"] = "日期筛选暂不可用,请稍后重试。"
return validation
}
parseDate := func(field, value string) (time.Time, bool) {
if value == "" {
return time.Time{}, true
}
parsed, parseErr := time.ParseInLocation("2006-01-02", value, location)
if parseErr != nil {
validation[field] = "请输入有效日期。"
return time.Time{}, false
}
return parsed, true
}
from, fromOK := parseDate("created_from", filter.CreatedFrom)
to, toOK := parseDate("created_to", filter.CreatedTo)
if fromOK && toOK && !from.IsZero() && !to.IsZero() && from.After(to) {
validation["created_to"] = "结束日期不能早于开始日期。"
}
return validation
}
func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartCommand, createdBy string) (StartResult, error) {
items, err := startItems(command)
if err != nil || createdBy == "" {
return StartResult{}, ErrInvalidStart
}
if store.policy.AuthorizationTTL <= 0 || store.policy.MaxQuantity <= 0 {
return StartResult{}, ErrInvalidStart
}
_, ceiling, ok := normalizeCents(store.policy.MaxTotalPrice)
if !ok {
return StartResult{}, ErrInvalidStart
}
writeCtx, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
defer cancel()
select {
case store.writeGate <- struct{}{}:
defer func() { <-store.writeGate }()
case <-writeCtx.Done():
return StartResult{}, writeCtx.Err()
}
tx, err := store.database.BeginTx(writeCtx, nil)
if err != nil {
return StartResult{}, err
}
defer tx.Rollback()
// Replay precedes any DRAFT check. One service process serializes this check with creation; SQLite uniqueness remains the cross-transaction backstop.
result, found, err := replayStart(writeCtx, tx, command.StartKey, items)
if err != nil {
return StartResult{}, err
}
if found {
if err := tx.Commit(); err != nil {
return StartResult{}, err
}
return result, nil
}
now := store.now().UTC()
expires := now.Add(store.policy.AuthorizationTTL)
result = StartResult{StartKey: command.StartKey, AuthorizedCount: len(items), Tasks: make([]AuthorizedTask, 0, len(items)), PaymentAutomated: false}
for _, item := range items {
var title, goods, color, size, price, status string
var quantity, version int
if err := tx.QueryRowContext(writeCtx, "SELECT title,goods_id,sku_color,sku_size,quantity,max_total_price,status,version FROM tasks WHERE id=?", item.TaskID).Scan(&title, &goods, &color, &size, &quantity, &price, &status, &version); err != nil {
if err == sql.ErrNoRows {
return StartResult{}, ErrStartConflict
}
return StartResult{}, err
}
if status != "DRAFT" || version != item.ExpectedTaskVersion || !goodsIDValid(goods) || color == "" || size == "" || quantity < 1 || quantity > store.policy.MaxQuantity {
return StartResult{}, ErrStartConflict
}
canonical, cents, ok := normalizeCents(price)
if !ok || canonical != price || cents.Cmp(ceiling) > 0 {
return StartResult{}, ErrStartConflict
}
if _, err := domain.TransitionTask(domain.TaskStatusDraft, domain.TaskStatusPending); err != nil {
return StartResult{}, err
}
id, err := NewCreateKey()
if err != nil {
return StartResult{}, err
}
next := version + 1
if _, err = tx.ExecContext(writeCtx, "INSERT INTO order_authorizations (id,task_id,task_version,start_key,goods_id,sku_color,sku_size,quantity,total_price_cap,status,created_by,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?,'ACTIVE',?,?,?)", id, item.TaskID, next, command.StartKey, goods, color, size, quantity, price, createdBy, now.Format(time.RFC3339Nano), expires.Format(time.RFC3339Nano)); err != nil {
return StartResult{}, err
}
updated, err := tx.ExecContext(writeCtx, "UPDATE tasks SET status='PENDING',version=version+1,updated_at=? WHERE id=? AND status='DRAFT' AND version=?", now.Format(time.RFC3339Nano), item.TaskID, version)
if err != nil {
return StartResult{}, err
}
affected, err := updated.RowsAffected()
if err != nil {
return StartResult{}, err
}
if affected != 1 {
return StartResult{}, ErrStartConflict
}
result.Tasks = append(result.Tasks, AuthorizedTask{TaskID: item.TaskID, TaskVersion: next, AuthorizationID: id, ExpiresAt: expires})
}
if err := tx.Commit(); err != nil {
return StartResult{}, err
}
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)
if err != nil {
return StartResult{}, false, err
}
defer rows.Close()
result := StartResult{StartKey: startKey, PaymentAutomated: false}
for rows.Next() {
var item AuthorizedTask
var expires string
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &expires); err != nil {
return StartResult{}, false, err
}
item.ExpiresAt, err = time.Parse(time.RFC3339Nano, expires)
if err != nil {
return StartResult{}, false, err
}
result.Tasks = append(result.Tasks, item)
}
if err := rows.Err(); err != nil {
return StartResult{}, false, err
}
if len(result.Tasks) == 0 {
return StartResult{}, false, nil
}
if len(result.Tasks) != len(items) {
return StartResult{}, false, ErrStartConflict
}
for i := range items {
if result.Tasks[i].TaskID != items[i].TaskID || result.Tasks[i].TaskVersion-1 != items[i].ExpectedTaskVersion {
return StartResult{}, false, ErrStartConflict
}
}
result.AuthorizedCount = len(result.Tasks)
return result, true, nil
}
+195
View File
@@ -0,0 +1,195 @@
package tasks
import (
"context"
"database/sql"
"errors"
"testing"
"time"
)
func TestListTasksTreatsLikeMetacharactersLiterally(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
created := "2026-08-04T01:00:00Z"
insertTaskRow(t, database, "percent", "100%纯棉", "100", "DRAFT", created)
insertTaskRow(t, database, "underscore", "尺码_A", "101", "DRAFT", created)
insertTaskRow(t, database, "backslash", `路径\名称`, "102", "DRAFT", created)
insertTaskRow(t, database, "plain", "普通商品", "103", "DRAFT", created)
for _, test := range []struct {
keyword string
wantID string
}{
{keyword: "%", wantID: "percent"},
{keyword: "_", wantID: "underscore"},
{keyword: `\`, wantID: "backslash"},
} {
t.Run(test.wantID, func(t *testing.T) {
rows, err := store.ListTasks(context.Background(), TaskFilter{Keyword: test.keyword})
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].ID != test.wantID {
t.Fatalf("keyword %q rows = %#v, want only %q", test.keyword, rows, test.wantID)
}
})
}
}
func TestListTasksSupportsEveryStatusAndEmptyMeansAll(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
statuses := []string{"DRAFT", "PENDING", "CLAIMED", "ORDERING", "NEEDS_MANUAL", "WAITING_PAYMENT", "RECONCILIATION_REQUIRED", "SUCCEEDED", "FAILED", "CANCELED"}
for index, status := range statuses {
insertTaskRow(t, database, status, status, "200", status, time.Date(2026, 8, 4, 1, 0, index, 0, time.UTC).Format(time.RFC3339Nano))
}
all, err := store.ListTasks(context.Background(), TaskFilter{})
if err != nil {
t.Fatal(err)
}
if len(all) != len(statuses) {
t.Fatalf("all-status rows = %d, want %d", len(all), len(statuses))
}
for _, status := range statuses {
rows, err := store.ListTasks(context.Background(), TaskFilter{Status: status})
if err != nil {
t.Fatalf("status %s: %v", status, err)
}
if len(rows) != 1 || rows[0].Status != status {
t.Fatalf("status %s rows = %#v", status, rows)
}
}
}
func TestListTasksUsesShanghaiHalfOpenDateRange(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
insertTaskRow(t, database, "before", "before", "300", "DRAFT", "2026-08-03T15:59:59Z")
insertTaskRow(t, database, "at-start", "at-start", "301", "DRAFT", "2026-08-03T16:00:00Z")
insertTaskRow(t, database, "before-end", "before-end", "302", "DRAFT", "2026-08-04T15:59:59Z")
insertTaskRow(t, database, "at-end", "at-end", "303", "DRAFT", "2026-08-04T16:00:00Z")
rows, err := store.ListTasks(context.Background(), TaskFilter{CreatedFrom: "2026-08-04", CreatedTo: "2026-08-04"})
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 || rows[0].ID != "before-end" || rows[1].ID != "at-start" {
t.Fatalf("Shanghai day rows = %#v, want [before-end at-start]", rows)
}
}
func TestListTasksBreaksEqualTimestampsByDescendingRowID(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
created := "2026-08-04T01:02:03Z"
insertTaskRow(t, database, "first", "first", "400", "DRAFT", created)
insertTaskRow(t, database, "second", "second", "401", "DRAFT", created)
rows, err := store.ListTasks(context.Background(), TaskFilter{})
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 || rows[0].ID != "second" || rows[1].ID != "first" {
t.Fatalf("equal-time rows = %#v, want descending rowid", rows)
}
}
func TestListTasksRejectsInvalidStatusAndDates(t *testing.T) {
store, err := NewSQLiteStore(migratedDatabase(t))
if err != nil {
t.Fatal(err)
}
for name, filter := range map[string]TaskFilter{
"status": {Status: "UNKNOWN"},
"from date": {CreatedFrom: "2026-02-30"},
"to date": {CreatedTo: "04/08/2026"},
"reverse range": {CreatedFrom: "2026-08-05", CreatedTo: "2026-08-04"},
} {
t.Run(name, func(t *testing.T) {
rows, err := store.ListTasks(context.Background(), filter)
if !errors.Is(err, ErrInvalidFilter) || rows != nil {
t.Fatalf("ListTasks(%#v) = (%#v, %v), want ErrInvalidFilter", filter, rows, err)
}
})
}
}
func TestStartPurchasesIsAtomicAndReplaysSameSet(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
store.SetStartPolicy(StartPolicy{AuthorizationTTL: time.Hour, MaxQuantity: 10, MaxTotalPrice: "999.99"})
store.now = func() time.Time { return time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC) }
for _, draft := range []Draft{testDraft(testKey, "one"), testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "two")} {
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
t.Fatal(err)
}
}
command := StartCommand{StartKey: "c3c9f507-7473-4fa6-8d71-8786c34c6301", Tasks: []StartItem{{TaskID: "b3c9f507-7473-4fa6-8d71-8786c34c6301", ExpectedTaskVersion: 1}, {TaskID: testKey, ExpectedTaskVersion: 1}}}
first, err := store.StartPurchases(context.Background(), command, "admin")
if err != nil {
t.Fatal(err)
}
if first.AuthorizedCount != 2 || first.PaymentAutomated {
t.Fatalf("start result=%#v", first)
}
command.Tasks[0], command.Tasks[1] = command.Tasks[1], command.Tasks[0]
replay, err := store.StartPurchases(context.Background(), command, "admin")
if err != nil {
t.Fatal(err)
}
if replay.Tasks[0].AuthorizationID != first.Tasks[0].AuthorizationID || replay.Tasks[1].AuthorizationID != first.Tasks[1].AuthorizationID {
t.Fatalf("replay=%#v first=%#v", replay, first)
}
var pending, auths int
if err := database.QueryRow(`SELECT COUNT(*) FROM tasks WHERE status='PENDING' AND version=2`).Scan(&pending); err != nil || pending != 2 {
t.Fatalf("pending=%d err=%v", pending, err)
}
if err := database.QueryRow(`SELECT COUNT(*) FROM order_authorizations WHERE status='ACTIVE' AND created_by='admin'`).Scan(&auths); err != nil || auths != 2 {
t.Fatalf("auths=%d err=%v", auths, err)
}
_, err = store.StartPurchases(context.Background(), StartCommand{StartKey: command.StartKey, Tasks: command.Tasks[:1]}, "admin")
if !errors.Is(err, ErrStartConflict) {
t.Fatalf("subset err=%v", err)
}
}
func TestShanghaiRangeAndMoneyAreFailClosed(t *testing.T) {
start, end, err := ShanghaiRange("2026-08-04", "2026-08-04")
if err != nil || start.Format(time.RFC3339) != "2026-08-03T16:00:00Z" || end.Format(time.RFC3339) != "2026-08-04T16:00:00Z" {
t.Fatalf("range=(%s,%s,%v)", start, end, err)
}
for _, value := range []string{"0.01", "12.80", "999999999999999999999999.99"} {
if _, _, ok := normalizeCents(value); !ok {
t.Fatalf("money %q rejected", value)
}
}
for _, value := range []string{"1", "01.20", "0.00", "1.234", "1.", " 1.00", "1e2"} {
if _, _, ok := normalizeCents(value); ok {
t.Fatalf("money %q accepted", value)
}
}
}
func insertTaskRow(t *testing.T, database *sql.DB, id, title, goodsID, status, createdAt string) {
t.Helper()
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', ?, ?, '黑色', 'M', 2, '12.80', ?, 1, ?, ?)`, id, title, goodsID, status, createdAt, createdAt); err != nil {
t.Fatalf("insert task %s: %v", id, err)
}
}
+135
View File
@@ -0,0 +1,135 @@
package tasks
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
const sqliteWriteTimeout = 2 * time.Second
type Store interface {
CreateDraft(context.Context, Draft) (Draft, error)
ListDrafts(context.Context) ([]Draft, error)
ListTasks(context.Context, TaskFilter) ([]TaskRow, error)
StartPurchases(context.Context, StartCommand, string) (StartResult, error)
}
type SQLiteStore struct {
database *sql.DB
now func() time.Time
writeGate chan struct{}
policy StartPolicy
}
func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
if database == nil {
return nil, errors.New("database is required")
}
if _, err := database.Exec("SELECT task_version, start_key, total_price_cap FROM order_authorizations LIMIT 1"); err != nil {
return nil, fmt.Errorf("tasks migration is not available: %w", err)
}
if _, err := database.Exec("SELECT 1 FROM purchase_attempts LIMIT 1"); err != nil {
return nil, fmt.Errorf("single-pass migration is not available: %w", err)
}
return &SQLiteStore{database: database, now: time.Now, writeGate: make(chan struct{}, 1)}, nil
}
func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) {
writeContext, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
defer cancel()
// SQLite permits one writer at a time. Serializing this store's short create
// transaction prevents concurrent retries of one create key from surfacing as busy.
select {
case store.writeGate <- struct{}{}:
defer func() { <-store.writeGate }()
case <-writeContext.Done():
return Draft{}, writeContext.Err()
}
draft.CreatedAt = store.now().UTC()
transaction, err := store.database.BeginTx(writeContext, nil)
if err != nil {
return Draft{}, err
}
defer transaction.Rollback()
_, err = transaction.ExecContext(writeContext, `INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', ?, ?, ?, ?, ?, ?, 'DRAFT', 1, ?, ?)`, draft.ID, draft.Title, draft.GoodsID, draft.SKUColor, draft.SKUSize, draft.Quantity, draft.MaxTotalPrice, draft.CreatedAt.Format(time.RFC3339Nano), draft.CreatedAt.Format(time.RFC3339Nano))
if err == nil {
if err := transaction.Commit(); err != nil {
return Draft{}, err
}
return draft, nil
}
existing, found, currentPhase, lookupErr := findDraft(writeContext, transaction, draft.ID)
if lookupErr != nil {
return Draft{}, lookupErr
}
if found && currentPhase && samePayload(existing, draft) {
if err := transaction.Commit(); err != nil {
return Draft{}, err
}
return existing, nil
}
if found {
return Draft{}, ErrCreateKeyConflict
}
return Draft{}, err
}
func (store *SQLiteStore) ListDrafts(ctx context.Context) ([]Draft, error) {
// rowid makes equal timestamps deterministic: SQLite assigns it in insertion order,
// whereas UUID v4 is deliberately not time-sortable.
rows, err := store.database.QueryContext(ctx, `SELECT id, title, goods_id, sku_color, sku_size, quantity, max_total_price, created_at FROM tasks WHERE source = 'MANUAL' AND status = 'DRAFT' ORDER BY created_at DESC, rowid DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
result := []Draft{}
for rows.Next() {
draft, err := scanDraft(rows)
if err != nil {
return nil, err
}
result = append(result, draft)
}
return result, rows.Err()
}
func findDraft(ctx context.Context, transaction *sql.Tx, id string) (Draft, bool, bool, error) {
row := transaction.QueryRowContext(ctx, `SELECT id, title, goods_id, sku_color, sku_size, quantity, max_total_price, created_at, source, status, version FROM tasks WHERE id = ?`, id)
var draft Draft
var created, source, status string
var version int
err := row.Scan(&draft.ID, &draft.Title, &draft.GoodsID, &draft.SKUColor, &draft.SKUSize, &draft.Quantity, &draft.MaxTotalPrice, &created, &source, &status, &version)
if errors.Is(err, sql.ErrNoRows) {
return Draft{}, false, false, nil
}
if err != nil {
return Draft{}, false, false, err
}
parsed, err := time.Parse(time.RFC3339Nano, created)
if err != nil {
return Draft{}, false, false, err
}
draft.CreatedAt = parsed
return draft, true, source == "MANUAL" && status == "DRAFT" && version == 1, nil
}
type scanner interface{ Scan(...any) error }
func scanDraft(row scanner) (Draft, error) {
var draft Draft
var created string
if err := row.Scan(&draft.ID, &draft.Title, &draft.GoodsID, &draft.SKUColor, &draft.SKUSize, &draft.Quantity, &draft.MaxTotalPrice, &created); err != nil {
return Draft{}, err
}
parsed, err := time.Parse(time.RFC3339Nano, created)
if err != nil {
return Draft{}, err
}
draft.CreatedAt = parsed
return draft, nil
}
func samePayload(left, right Draft) bool {
return left.ID == right.ID && left.Title == right.Title && left.GoodsID == right.GoodsID && left.SKUColor == right.SKUColor && left.SKUSize == right.SKUSize && left.Quantity == right.Quantity && left.MaxTotalPrice == right.MaxTotalPrice
}
+159
View File
@@ -0,0 +1,159 @@
// Package tasks 定义手工 DRAFT 任务的校验与窄仓储边界。
package tasks
import (
"crypto/rand"
"encoding/hex"
"errors"
"net/url"
"strconv"
"strings"
"time"
)
const (
maxTitleLength = 120
maxSKUText = 80
)
var ErrCreateKeyConflict = errors.New("create key conflicts with a different task")
type Draft struct {
ID string
Title string
GoodsID string
SKUColor string
SKUSize string
Quantity int
MaxTotalPrice string
CreatedAt time.Time
}
type Form struct{ CreateKey, Title, ProductURL, SKUColor, SKUSize, Quantity, MaxTotalPrice string }
type Errors map[string]string
func (errors Errors) Valid() bool { return len(errors) == 0 }
// Validate trims and normalizes a user form. It never reads a product page or derives price data.
func Validate(form Form) (Draft, Errors) {
draft := Draft{ID: strings.TrimSpace(form.CreateKey), Title: strings.TrimSpace(form.Title), SKUColor: strings.TrimSpace(form.SKUColor), SKUSize: strings.TrimSpace(form.SKUSize)}
errors := Errors{}
if !validUUID(draft.ID) {
errors["create_key"] = "创建请求已过期,请重新打开表单。"
}
if draft.Title == "" || len([]rune(draft.Title)) > maxTitleLength {
errors["title"] = "任务名称不能为空,且不能超过 120 个字符。"
}
if draft.SKUColor == "" || len([]rune(draft.SKUColor)) > maxSKUText {
errors["sku_color"] = "颜色分类不能为空,且不能超过 80 个字符。"
}
if draft.SKUSize == "" || len([]rune(draft.SKUSize)) > maxSKUText {
errors["sku_size"] = "尺码不能为空,且不能超过 80 个字符。"
}
goodsID, ok := CanonicalGoodsID(strings.TrimSpace(form.ProductURL))
if !ok {
errors["product_url"] = "请输入唯一的 canonical 商品链接。"
} else {
draft.GoodsID = goodsID
}
quantity, err := strconv.ParseInt(strings.TrimSpace(form.Quantity), 10, 0)
if err != nil || quantity < 1 {
errors["quantity"] = "数量必须是正整数。"
} else {
draft.Quantity = int(quantity)
}
money, ok := normalizeMoney(strings.TrimSpace(form.MaxTotalPrice))
if !ok {
errors["max_total_price"] = "价格上限必须大于零,且最多两位小数。"
} else {
draft.MaxTotalPrice = money
}
return draft, errors
}
// CanonicalGoodsID only accepts the one verified manual-entry URL shape; untrusted query data is discarded.
func CanonicalGoodsID(value string) (string, bool) {
if value == "" || strings.Contains(value, "\\") || strings.Contains(value, "%") {
return "", false
}
parsed, err := url.ParseRequestURI(value)
if err != nil || parsed.Scheme != "https" || parsed.Host != "mobile.yangkeduo.com" || parsed.User != nil || parsed.Port() != "" || parsed.Path != "/goods.html" || parsed.Fragment != "" {
return "", false
}
values, err := url.ParseQuery(parsed.RawQuery)
if err != nil {
return "", false
}
goodsIDs := values["goods_id"]
if len(goodsIDs) != 1 || goodsIDs[0] == "" {
return "", false
}
for _, character := range goodsIDs[0] {
if character < '0' || character > '9' {
return "", false
}
}
return goodsIDs[0], true
}
func CanonicalURL(goodsID string) string {
return "https://mobile.yangkeduo.com/goods.html?goods_id=" + goodsID
}
func NewCreateKey() (string, error) {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
hexValue := hex.EncodeToString(bytes)
return hexValue[0:8] + "-" + hexValue[8:12] + "-" + hexValue[12:16] + "-" + hexValue[16:20] + "-" + hexValue[20:32], nil
}
func validUUID(value string) bool {
if len(value) != 36 {
return false
}
for index, character := range value {
if index == 8 || index == 13 || index == 18 || index == 23 {
if character != '-' {
return false
}
continue
}
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
return false
}
}
return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b')
}
func normalizeMoney(value string) (string, bool) {
parts := strings.Split(value, ".")
if len(parts) > 2 || parts[0] == "" || len(parts) == 2 && (len(parts[1]) == 0 || len(parts[1]) > 2) {
return "", false
}
for _, character := range parts[0] {
if character < '0' || character > '9' {
return "", false
}
}
fraction := ""
if len(parts) == 2 {
fraction = parts[1]
for _, character := range fraction {
if character < '0' || character > '9' {
return "", false
}
}
}
whole := strings.TrimLeft(parts[0], "0")
if whole == "" {
whole = "0"
}
if whole == "0" && strings.Trim(fraction, "0") == "" {
return "", false
}
return whole + "." + (fraction + "00")[:2], true
}
+300
View File
@@ -0,0 +1,300 @@
package tasks
import (
"context"
"database/sql"
"errors"
"path/filepath"
"regexp"
"runtime"
"sync"
"testing"
"time"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
)
const testKey = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
func TestValidateNormalizesManualDraft(t *testing.T) {
draft, validation := Validate(Form{
CreateKey: " " + testKey + " ",
Title: " 夏季上衣 ",
ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375&utm_source=untrusted",
SKUColor: " 黑色CHA(纯棉) ",
SKUSize: " M(建议100-115) ",
Quantity: "2",
MaxTotalPrice: "00012.8",
})
if !validation.Valid() {
t.Fatalf("Validate errors = %#v", validation)
}
if draft.ID != testKey || draft.GoodsID != "937122477375" || draft.Title != "夏季上衣" || draft.SKUColor != "黑色CHA(纯棉)" || draft.SKUSize != "M(建议100-115)" || draft.Quantity != 2 || draft.MaxTotalPrice != "12.80" {
t.Fatalf("normalized draft = %#v", draft)
}
}
func TestValidateRejectsInvalidFieldsAndURLs(t *testing.T) {
base := Form{CreateKey: testKey, Title: "title", ProductURL: "https://mobile.yangkeduo.com/goods.html?goods_id=1", SKUColor: "black", SKUSize: "M", Quantity: "1", MaxTotalPrice: "1"}
for name, update := range map[string]func(*Form){
"empty title": func(form *Form) { form.Title = " " },
"long color": func(form *Form) { form.SKUColor = string(make([]rune, maxSKUText+1)) },
"fraction quantity": func(form *Form) { form.Quantity = "1.5" },
"zero quantity": func(form *Form) { form.Quantity = "0" },
"too many decimals": func(form *Form) { form.MaxTotalPrice = "1.234" },
"trailing decimal": func(form *Form) { form.MaxTotalPrice = "1." },
"zero money": func(form *Form) { form.MaxTotalPrice = "0.00" },
} {
t.Run(name, func(t *testing.T) {
form := base
update(&form)
if _, validation := Validate(form); validation.Valid() {
t.Fatal("invalid form was accepted")
}
})
}
for _, value := range []string{
"http://mobile.yangkeduo.com/goods.html?goods_id=1",
"https://yangkeduo.com/goods.html?goods_id=1",
"https://mobile.yangkeduo.com:443/goods.html?goods_id=1",
"https://user@mobile.yangkeduo.com/goods.html?goods_id=1",
"https://mobile.yangkeduo.com/goods.html?goods_id=1#fragment",
"https://mobile.yangkeduo.com/goods.html?goods_id=1&goods_id=2",
"https://mobile.yangkeduo.com/goods.html?goods_id=one",
"https://mobile.yangkeduo.com/goods.html?goods_id=%31",
"https://mobile.yangkeduo.com/goods.html?goods_id=1%26goods_id%3D2",
"https://mobile.yangkeduo.com/goods.html?goods_id=1;uin=bad",
"https://mobile.yangkeduo.com/other.html?goods_id=1",
} {
if _, ok := CanonicalGoodsID(value); ok {
t.Fatalf("CanonicalGoodsID accepted %q", value)
}
}
}
func TestNormalizeMoneyBoundaries(t *testing.T) {
for value, want := range map[string]string{"1": "1.00", "1.2": "1.20", "000.01": "0.01", "999999999999999999": "999999999999999999.00"} {
got, ok := normalizeMoney(value)
if !ok || got != want {
t.Fatalf("normalizeMoney(%q) = (%q, %t), want (%q, true)", value, got, ok, want)
}
}
for _, value := range []string{"0", "0.0", "0.00", "1.", ".1", "1.000", "-1", "1e2", " 1"} {
if got, ok := normalizeMoney(value); ok {
t.Fatalf("normalizeMoney(%q) = %q, want rejection", value, got)
}
}
}
func TestNewCreateKeyIsUUIDv4(t *testing.T) {
key, err := NewCreateKey()
if err != nil {
t.Fatalf("NewCreateKey: %v", err)
}
if !regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`).MatchString(key) {
t.Fatalf("create key %q is not UUID v4", key)
}
}
func TestSQLiteStoreRequiresMigratedDatabase(t *testing.T) {
database := openDatabase(t)
if _, err := NewSQLiteStore(database); err == nil {
t.Fatal("NewSQLiteStore accepted an unmigrated database")
}
}
func TestSQLiteStoreCreatesListsAndHandlesIdempotency(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
baseTime := time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC)
call := 0
store.now = func() time.Time {
result := baseTime.Add(time.Duration(call) * time.Minute)
call++
return result
}
first := testDraft(testKey, "first")
created, err := store.CreateDraft(context.Background(), first)
if err != nil {
t.Fatalf("create first draft: %v", err)
}
replayed, err := store.CreateDraft(context.Background(), first)
if err != nil {
t.Fatalf("replay first draft: %v", err)
}
if replayed.CreatedAt != created.CreatedAt {
t.Fatalf("replayed CreatedAt = %s, want original %s", replayed.CreatedAt, created.CreatedAt)
}
second := testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "second")
if _, err := store.CreateDraft(context.Background(), second); err != nil {
t.Fatalf("create second draft: %v", err)
}
drafts, err := store.ListDrafts(context.Background())
if err != nil {
t.Fatalf("list drafts: %v", err)
}
if len(drafts) != 2 || drafts[0].ID != second.ID || drafts[1].ID != first.ID {
t.Fatalf("draft order = %#v, want second then first", drafts)
}
var source, status string
var version int
if err := database.QueryRow(`SELECT source, status, version FROM tasks WHERE id = ?`, first.ID).Scan(&source, &status, &version); err != nil {
t.Fatalf("read stored task: %v", err)
}
if source != "MANUAL" || status != "DRAFT" || version != 1 {
t.Fatalf("stored metadata = (%q, %q, %d)", source, status, version)
}
conflicting := first
conflicting.Title = "different"
if _, err := store.CreateDraft(context.Background(), conflicting); !errors.Is(err, ErrCreateKeyConflict) {
t.Fatalf("conflicting create error = %v, want ErrCreateKeyConflict", err)
}
}
func TestSQLiteStoreRollsBackFailedCreate(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
if _, err := database.Exec(`CREATE TRIGGER reject_task BEFORE INSERT ON tasks BEGIN SELECT RAISE(ABORT, 'reject test insert'); END`); err != nil {
t.Fatalf("create trigger: %v", err)
}
if _, err := store.CreateDraft(context.Background(), testDraft(testKey, "blocked")); err == nil {
t.Fatal("CreateDraft succeeded despite rejecting trigger")
}
drafts, err := store.ListDrafts(context.Background())
if err != nil {
t.Fatalf("list after failed create: %v", err)
}
if len(drafts) != 0 {
t.Fatalf("failed create persisted drafts: %#v", drafts)
}
}
func TestSQLiteStoreUsesInsertionOrderForEqualTimesAndFiltersPhase(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
store.now = func() time.Time { return time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) }
first := testDraft(testKey, "first")
second := testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "second")
for _, draft := range []Draft{first, second} {
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
t.Fatalf("create %s: %v", draft.Title, err)
}
}
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES ('excel-draft', 'EXCEL', 'other', '1', 'black', 'M', 1, '1.00', 'DRAFT', 1, '2026-08-04T10:00:00Z', '2026-08-04T10:00:00Z'), ('manual-pending', 'MANUAL', 'other', '2', 'black', 'M', 1, '1.00', 'PENDING', 1, '2026-08-04T10:00:00Z', '2026-08-04T10:00:00Z')`); err != nil {
t.Fatalf("insert out-of-scope tasks: %v", err)
}
drafts, err := store.ListDrafts(context.Background())
if err != nil {
t.Fatalf("list drafts: %v", err)
}
if len(drafts) != 2 || drafts[0].ID != second.ID || drafts[1].ID != first.ID {
t.Fatalf("equal-time draft order/filter = %#v, want second then first only", drafts)
}
if _, err := database.Exec(`UPDATE tasks SET status = 'PENDING' WHERE id = ?`, first.ID); err != nil {
t.Fatalf("move draft outside current phase: %v", err)
}
if _, err := store.CreateDraft(context.Background(), first); !errors.Is(err, ErrCreateKeyConflict) {
t.Fatalf("replay of non-DRAFT record error = %v, want conflict", err)
}
third := testDraft("c3c9f507-7473-4fa6-8d71-8786c34c6301", "third")
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'EXCEL', ?, ?, ?, ?, ?, ?, 'DRAFT', 1, '2026-08-04T09:00:00Z', '2026-08-04T09:00:00Z')`, third.ID, third.Title, third.GoodsID, third.SKUColor, third.SKUSize, third.Quantity, third.MaxTotalPrice); err != nil {
t.Fatalf("insert same-payload EXCEL record: %v", err)
}
if _, err := store.CreateDraft(context.Background(), third); !errors.Is(err, ErrCreateKeyConflict) {
t.Fatalf("replay of non-MANUAL record error = %v, want conflict", err)
}
if _, err := database.Exec(`UPDATE tasks SET version = 2, source = 'MANUAL' WHERE id = ?`, third.ID); err != nil {
t.Fatalf("change replay record version: %v", err)
}
if _, err := store.CreateDraft(context.Background(), third); !errors.Is(err, ErrCreateKeyConflict) {
t.Fatalf("replay of non-v1 record error = %v, want conflict", err)
}
}
func TestSQLiteStoreConcurrentIdenticalCreateIsOneDraft(t *testing.T) {
store, err := NewSQLiteStore(migratedDatabase(t))
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
const callers = 20
start := make(chan struct{})
errors := make(chan error, callers)
results := make(chan Draft, callers)
var group sync.WaitGroup
for range callers {
group.Add(1)
go func() {
defer group.Done()
<-start
draft, err := store.CreateDraft(context.Background(), testDraft(testKey, "same"))
if err != nil {
errors <- err
return
}
results <- draft
}()
}
close(start)
group.Wait()
close(errors)
close(results)
for err := range errors {
t.Fatalf("concurrent create: %v", err)
}
for result := range results {
if result.ID != testKey {
t.Fatalf("concurrent result = %#v", result)
}
}
drafts, err := store.ListDrafts(context.Background())
if err != nil {
t.Fatalf("list after concurrent create: %v", err)
}
if len(drafts) != 1 || drafts[0].ID != testKey {
t.Fatalf("concurrent creates persisted %#v, want exactly one", drafts)
}
}
func testDraft(id, title string) Draft {
return Draft{ID: id, Title: title, GoodsID: "937122477375", SKUColor: "black", SKUSize: "M", Quantity: 2, MaxTotalPrice: "12.80"}
}
func openDatabase(t *testing.T) *sql.DB {
t.Helper()
database, err := sqlite.Open(filepath.Join(t.TempDir(), "tasks.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
return database
}
func migratedDatabase(t *testing.T) *sql.DB {
t.Helper()
database := openDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("migrate database: %v", err)
}
return database
}
func migrationDirectory(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate test source")
}
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
}
@@ -0,0 +1,211 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const source = fs.readFileSync(path.join(__dirname, "tasks.js"), "utf8");
test("visible button opens the same routed detail and close restores list state", async () => {
const harness = createDrawerHarness();
harness.button.listeners.click();
await harness.flush();
assert.equal(harness.requests.length, 1);
assert.equal(harness.requests[0].url, "/tasks/a3c9f507-7473-4fa6-8d71-8786c34c6301");
assert.equal(harness.requests[0].options.headers["X-CMBuyer-View"], "drawer");
assert.equal(harness.drawer.open, true);
assert.equal(harness.closeButton.focused, true);
assert.equal(harness.history.pushes.length, 1);
assert.equal(harness.history.pushes[0].url, harness.requests[0].url);
assert.equal(harness.history.pushes[0].state.focusTarget, "button");
harness.closeButton.listeners.click();
assert.equal(harness.history.backCalls, 1);
harness.popstate({state: {cmbuyerList: true}});
assert.equal(harness.drawer.open, false);
assert.equal(harness.button.focused, true);
assert.equal(harness.row.focused, false);
assert.equal(harness.scrolls.length, 1);
assert.equal(harness.scrolls[0].top, 275);
assert.equal(harness.scrolls[0].behavior, "auto");
});
test("button focus target survives back, forward, and back again", async () => {
const harness = createDrawerHarness();
harness.button.listeners.click();
await harness.flush();
const drawerState = harness.history.pushes[0].state;
harness.popstate({state: {cmbuyerList: true}});
assert.equal(harness.button.focusCalls, 1);
assert.equal(harness.row.focusCalls, 0);
harness.popstate({state: drawerState});
await harness.flush();
assert.equal(harness.drawer.open, true);
assert.equal(harness.history.pushes.length, 1);
harness.popstate({state: {cmbuyerList: true}});
assert.equal(harness.drawer.open, false);
assert.equal(harness.button.focusCalls, 2);
assert.equal(harness.row.focusCalls, 0);
});
test("failed forward retry reuses history and closes back to button in one step", async () => {
const harness = createDrawerHarness();
harness.button.listeners.click();
await harness.flush();
const drawerState = harness.history.pushes[0].state;
harness.popstate({state: {cmbuyerList: true}});
harness.failNextRequest();
harness.popstate({state: drawerState});
await harness.flush();
const retry = harness.body.children[1].children[0];
retry.listeners.click();
await harness.flush();
assert.equal(harness.history.pushes.length, 1);
assert.equal(harness.drawer.open, true);
harness.closeButton.listeners.click();
assert.equal(harness.history.backCalls, 1);
harness.popstate({state: {cmbuyerList: true}});
assert.equal(harness.drawer.open, false);
assert.equal(harness.button.focusCalls, 2);
assert.equal(harness.row.focusCalls, 0);
});
test("double click and Enter open rows but nested controls never do", async () => {
const harness = createDrawerHarness();
const ignored = {closest: () => ({})};
const rowTarget = {closest: () => null};
harness.row.listeners.dblclick({target: ignored});
harness.row.listeners.dblclick({target: rowTarget});
await harness.flush();
assert.equal(harness.requests.length, 1);
harness.popstate({state: {cmbuyerList: true}});
let prevented = false;
harness.row.listeners.keydown({key: "Enter", target: harness.row, preventDefault: () => { prevented = true; }});
await harness.flush();
assert.equal(prevented, true);
assert.equal(harness.requests.length, 2);
harness.row.listeners.keydown({key: "Enter", target: ignored, preventDefault: () => assert.fail("nested control Enter was intercepted")});
assert.equal(harness.requests.length, 2);
});
test("browser back and forward close and reopen without duplicating history", async () => {
const harness = createDrawerHarness();
harness.row.listeners.keydown({key: "Enter", target: harness.row, preventDefault() {}});
await harness.flush();
assert.equal(harness.history.pushes.length, 1);
harness.popstate({state: {cmbuyerList: true}});
assert.equal(harness.drawer.open, false);
harness.popstate({state: {cmbuyerDrawer: true, detailURL: harness.row.dataset.detailUrl}});
await harness.flush();
assert.equal(harness.drawer.open, true);
assert.equal(harness.requests.length, 2);
assert.equal(harness.history.pushes.length, 1);
});
test("Escape follows browser history and does not mutate list URL", async () => {
const harness = createDrawerHarness();
harness.button.listeners.click();
await harness.flush();
let prevented = false;
harness.drawer.listeners.cancel({preventDefault: () => { prevented = true; }});
assert.equal(prevented, true);
assert.equal(harness.history.backCalls, 1);
assert.equal(harness.history.replaces[0].url, "/tasks?status=DRAFT");
});
function createDrawerHarness() {
class FakeElement {
constructor() {
this.listeners = {};
this.dataset = {};
this.open = false;
this.focused = false;
this.focusCalls = 0;
this.children = [];
this._innerHTML = "";
}
addEventListener(type, listener) { this.listeners[type] = listener; }
focus() { this.focused = true; this.focusCalls++; }
showModal() { this.open = true; }
close() { this.open = false; }
replaceChildren(...children) { this.children = children; this._innerHTML = ""; }
append(...children) { this.children.push(...children); }
setAttribute() {}
closest() { return null; }
set innerHTML(value) { this._innerHTML = value; }
get innerHTML() { return this._innerHTML; }
}
const body = new FakeElement();
const closeButton = new FakeElement();
const button = new FakeElement();
const row = new FakeElement();
row.dataset.detailUrl = "/tasks/a3c9f507-7473-4fa6-8d71-8786c34c6301";
row.querySelector = (selector) => selector === "[data-open-detail]" ? button : null;
const drawer = new FakeElement();
drawer.querySelector = (selector) => ({"[data-detail-body]": body, "[data-close-detail]": closeButton})[selector] || null;
const requests = [];
const popstateListeners = [];
const scrolls = [];
let failNext = false;
const history = {
state: null,
pushes: [],
replaces: [],
backCalls: 0,
pushState(state, _title, url) { this.state = state; this.pushes.push({state, url}); },
replaceState(state, _title, url) { this.state = state; this.replaces.push({state, url}); },
back() { this.backCalls++; },
};
const document = {
querySelector: (selector) => selector === "[data-start-purchases]" ? null : selector === "[data-detail-drawer]" ? drawer : null,
querySelectorAll: (selector) => selector === "[data-task-row]" ? [row] : [],
createElement: () => new FakeElement(),
contains: (element) => element === row || element === button,
};
const window = {
location: {pathname: "/tasks", search: "?status=DRAFT"},
history,
scrollY: 275,
scrollTo: (value) => scrolls.push(value),
addEventListener(type, listener) { if (type === "popstate") popstateListeners.push(listener); },
};
const context = {
AbortController,
document,
window,
fetch: async (url, options) => {
requests.push({url, options});
if (failNext) {
failNext = false;
return {ok: false, headers: {get: () => "text/html"}, text: async () => ""};
}
return {ok: true, headers: {get: () => "text/html; charset=utf-8"}, text: async () => '<article data-task-detail-content>详情</article>'};
},
};
vm.runInNewContext(source, context, {filename: "tasks.js"});
return {
body, button, closeButton, drawer, history, requests, row, scrolls,
failNextRequest: () => { failNext = true; },
popstate: (event) => { history.state = event.state; popstateListeners.forEach((listener) => listener(event)); },
flush: () => new Promise((resolve) => setImmediate(resolve)),
};
}
@@ -0,0 +1,167 @@
(() => {
"use strict";
const form = document.querySelector("[data-start-purchases]");
if (!form) return;
const all = form.querySelector("[data-select-all]");
const summary = form.querySelector("[data-selection-summary]");
const button = form.querySelector("[data-start-button]");
const feedback = form.querySelector("[data-start-feedback]");
const boxes = () => [...form.querySelectorAll("input[data-task-id]")];
let selectionFrozen = false;
const parseCents = (value) => {
const match = /^(0|[1-9]\d*)\.(\d{2})$/.exec(value);
return match ? BigInt(match[1] + match[2]) : null;
};
const refresh = () => {
const available = boxes();
const selected = available.filter((box) => box.checked);
let cents = 0n;
let pricesValid = true;
selected.forEach((box) => {
const price = parseCents(box.dataset.price);
if (price === null) pricesValid = false;
else cents += price;
});
summary.textContent = `已选 ${selected.length} 条,最高总额 ¥${cents / 100n}.${(cents % 100n).toString().padStart(2, "0")}`;
button.disabled = !selected.length || !pricesValid;
if (!pricesValid) feedback.textContent = "所选任务金额无法安全汇总,请刷新后重选。";
if (all) {
all.checked = selected.length > 0 && selected.length === available.length;
all.indeterminate = selected.length > 0 && selected.length < available.length;
all.disabled = selectionFrozen || available.length === 0;
}
};
const freezeSelection = (frozen) => {
selectionFrozen = frozen;
boxes().forEach((box) => { box.disabled = frozen; });
refresh();
};
boxes().forEach((box) => box.addEventListener("change", refresh));
if (all) all.addEventListener("change", () => { boxes().forEach((box) => { box.checked = all.checked; }); refresh(); });
let frozenPayload = null;
let inFlight = false;
form.addEventListener("submit", async (event) => {
event.preventDefault();
const selected = boxes().filter((box) => box.checked);
if (!selected.length || inFlight) return;
const tasks = selected.map((box) => ({task_id: box.dataset.taskId, expected_task_version: Number(box.dataset.taskVersion)}));
if (tasks.some((item) => !Number.isSafeInteger(item.expected_task_version) || item.expected_task_version < 1)) { feedback.textContent = "任务版本无效,请刷新后重选。"; return; }
frozenPayload = frozenPayload || JSON.stringify({start_key: form.dataset.startKey, tasks});
inFlight = true; freezeSelection(true); button.disabled = true; button.textContent = "正在授权…";
try { const response = await fetch("/tasks/start-purchases", {method:"POST", headers:{"Content-Type":"application/json", "X-CSRF-Token":form.dataset.csrf}, body:frozenPayload});
if (response.ok) { window.location.reload(); return; }
if (response.status === 409) { feedback.textContent = "任务已变化,请刷新后重选。"; frozenPayload = null; freezeSelection(false); boxes().forEach((box) => { box.checked = false; }); refresh(); return; }
if (response.status === 400 || response.status === 401 || response.status === 403) { feedback.textContent = "请求未被接受,请刷新页面后重试。"; frozenPayload = null; freezeSelection(false); return; }
feedback.textContent = "结果暂时不明确,只能使用同一按钮原样重放。";
} catch (_) { feedback.textContent = "网络结果不明确,请使用同一按钮原样重试。"; }
finally { inFlight = false; button.textContent = "开始采购(只创建待付款订单)"; if (frozenPayload) button.disabled = false; }
});
refresh();
})();
(() => {
"use strict";
const drawer = document.querySelector("[data-detail-drawer]");
if (!drawer) return;
const body = drawer.querySelector("[data-detail-body]");
const closeButton = drawer.querySelector("[data-close-detail]");
const rows = [...document.querySelectorAll("[data-task-row]")];
const initialURL = window.location.pathname + window.location.search;
let focusTrigger = null;
let scrollPosition = window.scrollY;
let activeRequest = null;
const isInteractive = (target) => Boolean(target && typeof target.closest === "function" && target.closest("a,button,input,select,textarea,label,[contenteditable=true]"));
const showDrawer = () => {
if (!drawer.open) drawer.showModal();
};
const restoreList = () => {
if (activeRequest) {
activeRequest.abort();
activeRequest = null;
}
if (drawer.open) drawer.close();
window.scrollTo({top: scrollPosition, behavior: "auto"});
if (focusTrigger && document.contains(focusTrigger)) focusTrigger.focus({preventScroll: true});
};
const showError = (url, row, requestedFocus, pushHistory) => {
body.replaceChildren();
const message = document.createElement("p");
message.className = "drawer-feedback";
message.setAttribute("role", "alert");
message.textContent = "任务详情加载失败。请重试,或在完整页打开。";
const actions = document.createElement("p");
const retry = document.createElement("button");
retry.className = "button primary";
retry.type = "button";
retry.textContent = "重试";
retry.addEventListener("click", () => loadDetail(url, row, requestedFocus, pushHistory));
const fallback = document.createElement("a");
fallback.className = "button";
fallback.href = url;
fallback.textContent = "在完整页打开";
actions.className = "actions";
actions.append(retry, fallback);
body.append(message, actions);
};
const loadDetail = async (url, row, requestedFocus, pushHistory) => {
if (activeRequest) activeRequest.abort();
const requestController = new AbortController();
activeRequest = requestController;
focusTrigger = requestedFocus || focusTrigger;
if (pushHistory) scrollPosition = window.scrollY;
body.innerHTML = '<p class="drawer-feedback" role="status">正在加载任务详情…</p>';
showDrawer();
try {
const response = await fetch(url, {headers: {"X-CMBuyer-View": "drawer", "Accept": "text/html"}, credentials: "same-origin", signal: requestController.signal});
if (!response.ok || !String(response.headers.get("Content-Type") || "").toLowerCase().startsWith("text/html")) throw new Error("detail request rejected");
const fragment = await response.text();
if (!fragment.includes("data-task-detail-content")) throw new Error("detail fragment missing");
body.innerHTML = fragment;
if (pushHistory) window.history.pushState({cmbuyerDrawer: true, detailURL: url, focusTarget: requestedFocus === row ? "row" : "button"}, "", url);
closeButton.focus();
} catch (error) {
if (error.name !== "AbortError") showError(url, row, requestedFocus, pushHistory);
} finally {
if (activeRequest === requestController) activeRequest = null;
}
};
const requestClose = () => {
if (window.history.state && window.history.state.cmbuyerDrawer) window.history.back();
else restoreList();
};
window.history.replaceState({cmbuyerList: true, listURL: initialURL}, "", initialURL);
rows.forEach((row) => {
const url = row.dataset.detailUrl;
row.addEventListener("dblclick", (event) => {
if (!isInteractive(event.target)) loadDetail(url, row, row, true);
});
row.addEventListener("keydown", (event) => {
if (event.key === "Enter" && event.target === row) {
event.preventDefault();
loadDetail(url, row, row, true);
}
});
const button = row.querySelector("[data-open-detail]");
if (button) button.addEventListener("click", () => loadDetail(url, row, button, true));
});
closeButton.addEventListener("click", requestClose);
drawer.addEventListener("cancel", (event) => {
event.preventDefault();
requestClose();
});
window.addEventListener("popstate", (event) => {
if (event.state && event.state.cmbuyerDrawer) {
const row = rows.find((candidate) => candidate.dataset.detailUrl === event.state.detailURL);
if (!row) {
restoreList();
return;
}
const requestedFocus = event.state.focusTarget === "button" ? row.querySelector("[data-open-detail]") || row : row;
loadDetail(event.state.detailURL, row, requestedFocus, false);
return;
}
restoreList();
});
})();
@@ -0,0 +1,138 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const source = fs.readFileSync(path.join(__dirname, "tasks.js"), "utf8");
test("successful authorization sends numeric version and reloads", async () => {
const requests = [];
const harness = createHarness(async (_url, options) => {
requests.push(options);
return {ok: true, status: 200};
});
await harness.submit();
assert.equal(requests.length, 1);
assert.equal(requests[0].headers["Content-Type"], "application/json");
assert.equal(requests[0].headers["X-CSRF-Token"], "csrf-token");
const payload = JSON.parse(requests[0].body);
assert.equal(payload.start_key, "start-key");
assert.equal(typeof payload.tasks[0].expected_task_version, "number");
assert.equal(payload.tasks[0].expected_task_version, 7);
assert.equal(harness.reloads(), 1);
});
test("409 clears stale selection and requires a fresh choice", async () => {
const harness = createHarness(async () => ({ok: false, status: 409}));
await harness.submit();
assert.equal(harness.box.checked, false);
assert.equal(harness.box.disabled, false);
assert.equal(harness.button.disabled, true);
assert.match(harness.feedback.textContent, /任务已变化/);
});
for (const status of [400, 401, 403]) {
test(`${status} releases the frozen payload for a page refresh`, async () => {
const harness = createHarness(async () => ({ok: false, status}));
await harness.submit();
assert.equal(harness.box.checked, true);
assert.equal(harness.box.disabled, false);
assert.equal(harness.button.disabled, false);
assert.match(harness.feedback.textContent, /刷新页面后重试/);
});
}
test("5xx retries the byte-identical frozen payload", async () => {
const bodies = [];
const harness = createHarness(async (_url, options) => {
bodies.push(options.body);
return {ok: false, status: 503};
});
await harness.submit();
assert.equal(harness.box.disabled, true);
assert.equal(harness.button.disabled, false);
assert.match(harness.feedback.textContent, /原样重放/);
await harness.submit();
assert.equal(bodies.length, 2);
assert.equal(bodies[1], bodies[0]);
});
test("network ambiguity retries the same payload and can finish", async () => {
const bodies = [];
let call = 0;
const harness = createHarness(async (_url, options) => {
bodies.push(options.body);
call++;
if (call === 1) throw new Error("network result unknown");
return {ok: true, status: 200};
});
await harness.submit();
assert.equal(harness.box.disabled, true);
assert.match(harness.feedback.textContent, /原样重试/);
await harness.submit();
assert.deepEqual(bodies, [bodies[0], bodies[0]]);
assert.equal(harness.reloads(), 1);
});
function createHarness(fetchImplementation) {
class FakeElement {
constructor() {
this.dataset = {};
this.checked = false;
this.disabled = false;
this.indeterminate = false;
this.textContent = "";
this.listeners = {};
}
addEventListener(type, listener) {
this.listeners[type] = listener;
}
}
const box = new FakeElement();
box.checked = true;
box.dataset = {taskId: "task-id", taskVersion: "7", price: "12.80"};
const selectAll = new FakeElement();
const summary = new FakeElement();
const button = new FakeElement();
const feedback = new FakeElement();
const form = new FakeElement();
form.dataset = {startKey: "start-key", csrf: "csrf-token"};
form.querySelector = (selector) => ({
"[data-select-all]": selectAll,
"[data-selection-summary]": summary,
"[data-start-button]": button,
"[data-start-feedback]": feedback,
})[selector] || null;
form.querySelectorAll = (selector) => selector === "input[data-task-id]" ? [box] : [];
let reloadCount = 0;
const context = {
document: {querySelector: (selector) => selector === "[data-start-purchases]" ? form : null},
fetch: fetchImplementation,
window: {location: {reload: () => { reloadCount++; }}},
};
vm.runInNewContext(source, context, {filename: "tasks.js"});
return {
box,
button,
feedback,
reloads: () => reloadCount,
submit: () => form.listeners.submit({preventDefault() {}}),
};
}
@@ -0,0 +1,63 @@
{{define "login.html"}}
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>登录 · 采购服务</title>
<style>
:root { color-scheme: light; --bg:#f4f7fb; --surface:#fff; --text:#172033; --muted:#526079; --border:#cfd8e6; --primary:#155eef; --primary-hover:#0b4ed1; --primary-soft:#eaf1ff; --danger:#b42318; --danger-soft:#fef3f2; --focus:#ffbf47; --shadow:0 12px 30px rgba(23,32,51,.1); font-family:"Segoe UI","Microsoft YaHei UI",system-ui,sans-serif; }
* { box-sizing:border-box; }
html { min-width:320px; background:var(--bg); }
body { min-height:100dvh; margin:0; color:var(--text); background:var(--bg); font-size:16px; line-height:1.55; }
button,input { font:inherit; }
:focus-visible { outline:3px solid var(--focus); outline-offset:3px; }
.skip-link { position:fixed; z-index:10; top:8px; left:8px; padding:10px 14px; color:#fff; background:var(--text); transform:translateY(-160%); }
.skip-link:focus { transform:translateY(0); }
main { display:grid; min-height:100dvh; place-items:center; padding:24px 16px; }
.card { width:min(100%,440px); padding:32px; border:1px solid var(--border); border-radius:14px; background:var(--surface); box-shadow:var(--shadow); }
.brand { display:flex; align-items:center; gap:10px; margin:0 0 24px; font-size:1rem; font-weight:700; }
.brand-mark { display:grid; width:32px; height:32px; place-items:center; border-radius:8px; color:#fff; background:var(--primary); font-size:.82rem; }
h1 { margin:0; font-size:clamp(1.6rem,5vw,2rem); line-height:1.25; }
.intro { margin:8px 0 24px; color:var(--muted); }
.field { margin-top:16px; }
label { display:block; margin-bottom:6px; font-weight:700; }
input { width:100%; min-height:44px; padding:10px 12px; border:1px solid #9ba9bc; border-radius:8px; color:var(--text); background:#fff; }
input[aria-invalid="true"] { border-color:var(--danger); box-shadow:0 0 0 1px var(--danger); }
.hint { margin:5px 0 0; color:var(--muted); font-size:.875rem; }
.error { margin:0 0 18px; padding:12px 14px; border-left:4px solid var(--danger); border-radius:6px; color:var(--danger); background:var(--danger-soft); font-weight:650; }
.submit { width:100%; min-height:44px; margin-top:24px; padding:10px 16px; border:1px solid transparent; border-radius:8px; color:#fff; background:var(--primary); font-weight:700; cursor:pointer; transition:background-color 180ms ease-out; }
.submit:hover { background:var(--primary-hover); }
.notice { margin:20px 0 0; padding:12px 14px; border:1px solid #b9cffc; border-radius:8px; color:#29466f; background:var(--primary-soft); font-size:.9rem; }
@media (max-width:420px) { main { padding-inline:12px; } .card { padding:24px 16px; } }
@media (prefers-reduced-motion:reduce) { *,*::before,*::after { transition-duration:.01ms !important; animation-duration:.01ms !important; } }
</style>
</head>
<body>
<a class="skip-link" href="#main">跳到主要内容</a>
<main id="main">
<section class="card" aria-labelledby="login-title">
<p class="brand"><span class="brand-mark" aria-hidden="true">采</span><span>采购服务</span></p>
<h1 id="login-title">管理端登录</h1>
<p class="intro">登录后进入采购任务工作台。设备身份不能使用此入口。</p>
{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
<form method="post" action="/login">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="return_to" value="{{.ReturnTo}}">
<div class="field">
<label for="username">账号</label>
<input id="username" name="username" type="text" value="{{.Username}}" autocomplete="username" required aria-invalid="{{if .Error}}true{{else}}false{{end}}" aria-describedby="username-hint">
<p class="hint" id="username-hint">使用采购管理员账号登录。</p>
</div>
<div class="field">
<label for="password">密码</label>
<input id="password" name="password" type="password" autocomplete="current-password" required aria-invalid="{{if .Error}}true{{else}}false{{end}}">
</div>
<button class="submit" type="submit">登录并继续</button>
</form>
<p class="notice">系统只创建待付款订单,付款始终由人完成。</p>
</section>
</main>
</body>
</html>
{{end}}
@@ -0,0 +1,38 @@
{{define "task-detail-page.html"}}
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Detail.Task.Title}} · 任务详情 · 采购服务</title>
<style>
:root{--bg:#f4f7fb;--surface:#fff;--text:#172033;--muted:#526079;--border:#cfd8e6;--primary:#155eef;--danger:#b42318;--success:#067647;--focus:#ffbf47;font-family:"Segoe UI","Microsoft YaHei UI",system-ui,sans-serif}*{box-sizing:border-box}body{margin:0;color:var(--text);background:var(--bg);font-size:16px;line-height:1.55}a{color:#124cc5;text-underline-offset:3px}:focus-visible{outline:3px solid var(--focus);outline-offset:3px}.skip{position:fixed;z-index:100;top:8px;left:8px;padding:10px;color:#fff;background:#172033;transform:translateY(-160%)}.skip:focus{transform:translateY(0)}.topbar{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:64px;padding:10px clamp(16px,4vw,40px);border-bottom:1px solid var(--border);background:var(--surface)}.brand{color:var(--text);font-weight:700;text-decoration:none}.brand b{display:inline-grid;place-items:center;width:32px;height:32px;margin-right:8px;border-radius:8px;background:var(--primary);color:#fff;font-size:.82rem}.button{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:9px 14px;border:1px solid var(--border);border-radius:8px;color:var(--text);background:#fff;font-weight:700;text-decoration:none}.detail-page{width:min(100% - 32px,1120px);margin:28px auto 48px}.detail-shell{display:grid;gap:16px}.detail-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.detail-head h1{margin:0;font-size:clamp(1.45rem,3vw,2rem)}.detail-head p{margin:4px 0;color:var(--muted)}.status{display:inline-block;padding:4px 10px;border-radius:999px;background:#eaf1ff;color:#173d8f;font-size:.88rem;font-weight:700;white-space:nowrap}.safety{margin:0;padding:13px 15px;border:1px solid #a9c3f7;border-left:5px solid var(--primary);border-radius:10px;background:#edf3ff}.detail-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(250px,320px);gap:16px}.detail-card{overflow:hidden;border:1px solid var(--border);border-radius:12px;background:var(--surface)}.detail-card>header,.detail-card>.detail-body{padding:16px 18px}.detail-card>header{border-bottom:1px solid var(--border)}.detail-card h2,.detail-card h3{margin:0}.detail-card header p,.empty-note{margin:4px 0 0;color:var(--muted)}.facts{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin:0}.facts div{min-width:0;padding:11px;border:1px solid var(--border);border-radius:8px;background:#f8fafc}.facts dt{font-size:.82rem;color:var(--muted);font-weight:700}.facts dd{margin:3px 0 0;overflow-wrap:anywhere;font-weight:650}.audit-list{display:grid;gap:10px;margin:0;padding:0;list-style:none}.audit-list li{padding:12px;border:1px solid var(--border);border-radius:8px}.audit-list p{margin:4px 0}.mono{font-family:Consolas,"SFMono-Regular",monospace;overflow-wrap:anywhere}.evidence-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px}.evidence{margin:0}.evidence img{display:block;width:100%;height:auto;max-height:520px;object-fit:contain;border:1px solid var(--border);border-radius:8px;background:#eef2f7}.evidence figcaption{margin-top:7px;color:var(--muted);font-size:.85rem}.section-stack{display:grid;gap:16px}.privacy-note{margin:12px 0 0;color:var(--muted);font-size:.88rem}@media(max-width:760px){.detail-grid{grid-template-columns:1fr}.detail-head{display:grid}.facts{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){*,*::before,*::after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important}}
</style>
</head>
<body>
<a class="skip" href="#main">跳到主要内容</a>
<header class="topbar"><a class="brand" href="/tasks"><b aria-hidden="true">采</b>采购服务</a><a class="button" href="/tasks">返回任务列表</a></header>
<main class="detail-page" id="main">{{template "task-detail-content" .}}</main>
</body>
</html>
{{end}}
{{define "task-detail-content"}}
<article class="detail-shell" data-task-detail-content data-task-id="{{.Detail.Task.ID}}">
<header class="detail-head"><div><h1>{{.Detail.Task.Title}}</h1><p>任务 <span class="mono">{{.Detail.Task.ID}}</span> · 版本 {{.Detail.Task.Version}}</p></div><span class="status">{{statusLabel .Detail.Task.Status}}</span></header>
<p class="safety"><strong>{{taskSafetyTitle .Detail.Task.Status}}</strong> {{taskSafetyText .Detail.Task.Status}}</p>
<div class="detail-grid">
<div class="section-stack">
<section class="detail-card" aria-labelledby="task-facts-title"><header><h2 id="task-facts-title">任务要求</h2><p>管理员锁定的采购边界;详情页不会触发设备动作。</p></header><div class="detail-body"><dl class="facts"><div><dt>商品</dt><dd><a href="{{canonicalURL .Detail.Task.GoodsID}}" target="_blank" rel="noopener noreferrer">goods_id {{.Detail.Task.GoodsID}}</a></dd></div><div><dt>目标规格</dt><dd>{{.Detail.Task.SKUColor}} / {{.Detail.Task.SKUSize}}</dd></div><div><dt>数量</dt><dd>{{.Detail.Task.Quantity}} 件</dd></div><div><dt>最高总价</dt><dd>¥{{.Detail.Task.MaxTotalPrice}}</dd></div><div><dt>创建时间(上海)</dt><dd><time datetime="{{shanghaiDateTime .Detail.Task.CreatedAt}}">{{shanghaiTime .Detail.Task.CreatedAt}}</time></dd></div><div><dt>更新时间(上海)</dt><dd><time datetime="{{shanghaiDateTime .Detail.Task.UpdatedAt}}">{{shanghaiTime .Detail.Task.UpdatedAt}}</time></dd></div></dl></div></section>
<section class="detail-card" aria-labelledby="execution-title"><header><h2 id="execution-title">设备执行事实</h2><p>只展示数据库中已存在的 attempt;T-204 不创建执行记录。</p></header><div class="detail-body">{{if .Detail.Attempts}}<ol class="audit-list">{{range .Detail.Attempts}}<li><h3>Attempt <span class="mono">{{.ID}}</span></h3><p>状态:{{attemptStatusLabel .Status}} · 领取代次 {{.ClaimGeneration}}</p><p>开始:<time datetime="{{shanghaiDateTime .StartedAt}}">{{shanghaiTime .StartedAt}}</time>{{with .FinishedAt}} · 结束:<time datetime="{{shanghaiDateTime .}}">{{shanghaiTime .}}</time>{{end}}</p>{{with .FailureCode}}<p>失败码:<span class="mono">{{.}}</span></p>{{end}}{{if or .Gate1UnitPrice .Gate2UnitPrice .QuantityRead .ConfirmAmount}}<p>已有读数:{{with .Gate1UnitPrice}}闸门一 ¥{{.}};{{end}}{{with .Gate2UnitPrice}}闸门二 ¥{{.}};{{end}}{{with .QuantityRead}}数量 {{.}};{{end}}{{with .ConfirmAmount}}确认页 ¥{{.}}{{end}}</p>{{else}}<p class="empty-note">暂无规格、价格或数量读数。</p>{{end}}</li>{{end}}</ol>{{else}}<p class="empty-note">暂无设备执行记录。</p>{{end}}</div></section>
<section class="detail-card" aria-labelledby="evidence-title"><header><h2 id="evidence-title">内部截图</h2><p>INTERNAL_RAW 仅供已登录管理员审计,不代表价格闸门通过或人工批准。</p></header><div class="detail-body">{{if .Detail.Evidence}}<div class="evidence-grid">{{range .Detail.Evidence}}<figure class="evidence"><img src="/evidence/{{.ID}}" width="{{.Width}}" height="{{.Height}}" loading="lazy" alt="规格面板内部审计截图,采集于 {{shanghaiTime .CapturedAt}}"><figcaption>{{evidenceKindLabel .Kind}}(<span class="mono">{{.Kind}}</span>)· {{formatBytes .ByteSize}} · <time datetime="{{shanghaiDateTime .CapturedAt}}">{{shanghaiTime .CapturedAt}}</time><br>Attempt <span class="mono">{{.AttemptID}}</span></figcaption></figure>{{end}}</div>{{else}}<p class="empty-note">暂无内部截图。只有已认证设备显式上传的 PNG 会出现在这里。</p>{{end}}<p class="privacy-note">截图可能包含页面已显示的地址或手机号;系统不提取、索引或写入日志。完整 XML、外部支付页和支付凭据不会上传。</p></div></section>
<section class="detail-card" aria-labelledby="submission-title"><header><h2 id="submission-title">提交围栏与结果</h2><p>只读审计;本页没有重试、再次提交或付款动作。</p></header><div class="detail-body">{{if .Detail.Submissions}}<ol class="audit-list">{{range .Detail.Submissions}}<li><h3>Submission <span class="mono">{{.ID}}</span></h3><p>状态:{{submissionStatusLabel .Status}}</p><p>闸门一 ¥{{.Gate1UnitPrice}};闸门二 ¥{{.Gate2UnitPrice}};数量 {{.QuantityRead}};确认页 ¥{{.ConfirmAmount}}</p><p>建立:<time datetime="{{shanghaiDateTime .CreatedAt}}">{{shanghaiTime .CreatedAt}}</time>{{with .ResolvedAt}} · 调和:<time datetime="{{shanghaiDateTime .}}">{{shanghaiTime .}}</time>{{end}}</p></li>{{end}}</ol>{{else}}<p class="empty-note">尚未建立提交围栏;详情页不会创建或释放围栏。</p>{{end}}</div></section>
</div>
<aside class="section-stack" aria-label="任务状态摘要"><section class="detail-card"><header><h2>开始采购授权</h2><p>锁定任务字段和最高总价,不授权付款。</p></header><div class="detail-body">{{if .Detail.Authorizations}}<ol class="audit-list">{{range .Detail.Authorizations}}<li><h3>{{authorizationStatusLabel .Status}}</h3><p class="mono">{{.ID}}</p><p>任务版本 {{.TaskVersion}} · 上限 ¥{{.TotalPriceCap}}</p><p>授权人:{{.CreatedBy}}</p><p><time datetime="{{shanghaiDateTime .CreatedAt}}">{{shanghaiTime .CreatedAt}}</time> 至 <time datetime="{{shanghaiDateTime .ExpiresAt}}">{{shanghaiTime .ExpiresAt}}</time></p></li>{{end}}</ol>{{else}}<p class="empty-note">尚未开始采购,没有授权记录。</p>{{end}}</div></section><section class="detail-card"><header><h2>固定边界</h2></header><div class="detail-body"><ul><li>系统只创建待付款订单,不自动付款。</li><li>截图仅供审计,不替代实时三道价格闸门。</li><li>围栏后只能调和同一提交,禁止再次点击。</li></ul></div></section></aside>
</div>
</article>
{{end}}
File diff suppressed because one or more lines are too long
+162
View File
@@ -0,0 +1,162 @@
// Package webui 渲染采购服务当前可用的服务端页面。
package webui
import (
"embed"
"fmt"
"html/template"
"io"
"time"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks"
)
//go:embed templates/*.html
var templateFiles embed.FS
//go:embed static/tasks.js
var tasksScript []byte
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
var templates = template.Must(template.New("webui").Funcs(template.FuncMap{
"list": func(values ...any) []any { return values },
"statusLabel": statusLabel,
"shanghaiDateTime": func(value time.Time) string { return value.In(shanghaiLocation).Format(time.RFC3339) },
"shanghaiTime": func(value time.Time) string { return value.In(shanghaiLocation).Format("2006-01-02 15:04") },
"canonicalURL": tasks.CanonicalURL,
"formatBytes": formatBytes,
"taskSafetyTitle": taskSafetyTitle,
"taskSafetyText": taskSafetyText,
"authorizationStatusLabel": authorizationStatusLabel,
"attemptStatusLabel": attemptStatusLabel,
"submissionStatusLabel": submissionStatusLabel,
"evidenceKindLabel": evidenceKindLabel,
}).ParseFS(templateFiles, "templates/*.html"))
// LoginData 是登录页面所需的非敏感展示数据。
type LoginData struct {
CSRFToken string
ReturnTo string
Username string
Error string
}
// TasksData 是受保护的建单与任务工作台页面所需数据。
type TasksData struct {
CSRFToken string
Tasks []tasks.TaskRow
Filter tasks.TaskFilter
FilterErrors tasks.Errors
HasFilter bool
StartKey string
Form tasks.Form
Errors tasks.Errors
OpenForm bool
FullPage bool
FocusField string
Success bool
}
type TaskDetailData struct{ Detail taskdetail.Detail }
// RenderLogin 写入登录页。
func RenderLogin(writer io.Writer, data LoginData) error {
return templates.ExecuteTemplate(writer, "login.html", data)
}
// RenderTasks 写入登录后的受保护任务页。
func RenderTasks(writer io.Writer, data TasksData) error {
return templates.ExecuteTemplate(writer, "tasks.html", data)
}
func RenderTaskDetailPage(writer io.Writer, data TaskDetailData) error {
return templates.ExecuteTemplate(writer, "task-detail-page.html", data)
}
func RenderTaskDetailFragment(writer io.Writer, data TaskDetailData) error {
return templates.ExecuteTemplate(writer, "task-detail-content", data)
}
func TasksScript() []byte { return tasksScript }
func statusLabel(status string) string {
labels := map[string]string{
"DRAFT": "待开始",
"PENDING": "已授权待领取",
"CLAIMED": "已领取",
"ORDERING": "执行中",
"NEEDS_MANUAL": "待人工处理",
"WAITING_PAYMENT": "待付款",
"RECONCILIATION_REQUIRED": "围栏后待调和",
"SUCCEEDED": "已完成",
"FAILED": "失败",
"CANCELED": "已取消",
}
if label, ok := labels[status]; ok {
return label
}
return "未知状态"
}
func taskSafetyTitle(status string) string {
if status == "WAITING_PAYMENT" {
return "订单已创建,系统尚未付款。"
}
if status == "RECONCILIATION_REQUIRED" {
return "订单可能已创建,只能调和同一提交。"
}
return "系统只创建待付款订单,不会自动付款。"
}
func taskSafetyText(status string) string {
if status == "DRAFT" {
return "创建任务不构成授权;请回到列表勾选后开始采购。"
}
if status == "RECONCILIATION_REQUIRED" {
return "围栏保持占用,禁止重新授权、再次提交或释放。"
}
return "截图只供内部审计,不替代实时价格闸门,也不会触发设备动作。"
}
func authorizationStatusLabel(status string) string {
labels := map[string]string{"ACTIVE": "授权有效", "CLAIMED": "已被领取", "FENCED": "提交围栏已建立", "CONSUMED": "授权已消费", "EXPIRED": "授权已过期", "ABANDONED": "授权已关闭"}
if value, ok := labels[status]; ok {
return value
}
return "未知授权状态"
}
func attemptStatusLabel(status string) string {
labels := map[string]string{"CLAIMED": "已领取", "ORDERING": "执行中", "FAILED": "围栏前失败", "FENCED": "已建立围栏", "ABANDONED": "已安全停止"}
if value, ok := labels[status]; ok {
return value
}
return "未知执行状态"
}
func submissionStatusLabel(status string) string {
labels := map[string]string{"FENCED": "围栏已建立", "SUBMITTED": "已创建待付款订单", "RECONCILIATION_REQUIRED": "结果待调和", "MANUAL_RESOLVED": "已人工调和"}
if value, ok := labels[status]; ok {
return value
}
return "未知提交状态"
}
func evidenceKindLabel(kind string) string {
if kind == "SKU_PANEL_GATE_1" {
return "规格面板 · 闸门一"
}
return "内部截图"
}
func formatBytes(value int64) string {
if value >= 1<<20 {
return fmt.Sprintf("%.1f MiB", float64(value)/(1<<20))
}
if value >= 1<<10 {
return fmt.Sprintf("%.1f KiB", float64(value)/(1<<10))
}
return fmt.Sprintf("%d B", value)
}
+148
View File
@@ -0,0 +1,148 @@
-- +goose Up
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
source TEXT NOT NULL CHECK (source IN ('MANUAL', 'EXCEL', 'ERP')),
source_ref TEXT,
title TEXT NOT NULL,
goods_id TEXT NOT NULL,
sku_color TEXT NOT NULL,
sku_size TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
max_total_price TEXT NOT NULL CHECK (
max_total_price <> ''
AND max_total_price NOT GLOB '*[^0-9.]*'
AND length(max_total_price) - length(replace(max_total_price, '.', '')) <= 1
AND max_total_price <> '.'
AND (instr(max_total_price, '.') = 0 OR (
instr(max_total_price, '.') > 1
AND length(max_total_price) > instr(max_total_price, '.')
))
),
reference_asset_id TEXT,
status TEXT NOT NULL CHECK (status IN (
'DRAFT', 'PENDING', 'CLAIMED', 'RUNNING', 'WAITING_CONFIRMATION',
'PENDING_RETRIAL', 'AUTHORIZED', 'ORDERING', 'WAITING_PAYMENT',
'RECONCILIATION_REQUIRED', 'NEEDS_MANUAL', 'SUCCEEDED', 'CANCELED'
)),
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0 AND typeof(version) = 'integer'),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE spec_trials (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
attempt INTEGER NOT NULL CHECK (attempt > 0 AND typeof(attempt) = 'integer'),
product_title TEXT NOT NULL,
selected_color TEXT NOT NULL,
selected_size TEXT NOT NULL,
unit_price TEXT NOT NULL CHECK (
unit_price <> ''
AND unit_price NOT GLOB '*[^0-9.]*'
AND length(unit_price) - length(replace(unit_price, '.', '')) <= 1
AND unit_price <> '.'
AND (instr(unit_price, '.') = 0 OR (
instr(unit_price, '.') > 1
AND length(unit_price) > instr(unit_price, '.')
))
),
total_price TEXT NOT NULL CHECK (
total_price <> ''
AND total_price NOT GLOB '*[^0-9.]*'
AND length(total_price) - length(replace(total_price, '.', '')) <= 1
AND total_price <> '.'
AND (instr(total_price, '.') = 0 OR (
instr(total_price, '.') > 1
AND length(total_price) > instr(total_price, '.')
))
),
evidence_sha256 TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (task_id, attempt),
UNIQUE (task_id, id)
);
CREATE TABLE order_authorizations (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
spec_trial_id TEXT NOT NULL REFERENCES spec_trials(id),
version INTEGER NOT NULL CHECK (version > 0 AND typeof(version) = 'integer'),
goods_id TEXT NOT NULL,
sku_color TEXT NOT NULL,
sku_size TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
authorized_unit_price TEXT NOT NULL CHECK (
authorized_unit_price <> ''
AND authorized_unit_price NOT GLOB '*[^0-9.]*'
AND length(authorized_unit_price) - length(replace(authorized_unit_price, '.', '')) <= 1
AND authorized_unit_price <> '.'
AND (instr(authorized_unit_price, '.') = 0 OR (
instr(authorized_unit_price, '.') > 1
AND length(authorized_unit_price) > instr(authorized_unit_price, '.')
))
),
total_price_cap TEXT NOT NULL CHECK (
total_price_cap <> ''
AND total_price_cap NOT GLOB '*[^0-9.]*'
AND length(total_price_cap) - length(replace(total_price_cap, '.', '')) <= 1
AND total_price_cap <> '.'
AND (instr(total_price_cap, '.') = 0 OR (
instr(total_price_cap, '.') > 1
AND length(total_price_cap) > instr(total_price_cap, '.')
))
),
note TEXT,
status TEXT NOT NULL CHECK (status IN (
'PENDING_DELIVERY', 'DELIVERED', 'ACKNOWLEDGED', 'EXECUTING', 'FENCED',
'CONSUMED', 'SUPERSEDED', 'EXPIRED'
)),
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
UNIQUE (task_id, version),
UNIQUE (task_id, id),
FOREIGN KEY (task_id, spec_trial_id) REFERENCES spec_trials(task_id, id)
);
CREATE TABLE order_submissions (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
authorization_id TEXT NOT NULL REFERENCES order_authorizations(id),
command_id TEXT NOT NULL,
dry_run_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN (
'FENCED', 'SUBMITTED', 'RECONCILIATION_REQUIRED', 'MANUAL_RESOLVED'
)),
verified_unit_price TEXT NOT NULL CHECK (
verified_unit_price <> ''
AND verified_unit_price NOT GLOB '*[^0-9.]*'
AND length(verified_unit_price) - length(replace(verified_unit_price, '.', '')) <= 1
AND verified_unit_price <> '.'
AND (instr(verified_unit_price, '.') = 0 OR (
instr(verified_unit_price, '.') > 1
AND length(verified_unit_price) > instr(verified_unit_price, '.')
))
),
quantity_read INTEGER NOT NULL CHECK (quantity_read > 0 AND typeof(quantity_read) = 'integer'),
confirm_page_amount TEXT NOT NULL CHECK (
confirm_page_amount <> ''
AND confirm_page_amount NOT GLOB '*[^0-9.]*'
AND length(confirm_page_amount) - length(replace(confirm_page_amount, '.', '')) <= 1
AND confirm_page_amount <> '.'
AND (instr(confirm_page_amount, '.') = 0 OR (
instr(confirm_page_amount, '.') > 1
AND length(confirm_page_amount) > instr(confirm_page_amount, '.')
))
),
created_at TEXT NOT NULL,
resolved_at TEXT,
UNIQUE (authorization_id),
UNIQUE (command_id),
FOREIGN KEY (task_id, authorization_id) REFERENCES order_authorizations(task_id, id)
);
-- +goose Down
DROP TABLE order_submissions;
DROP TABLE order_authorizations;
DROP TABLE spec_trials;
DROP TABLE tasks;
@@ -0,0 +1,333 @@
-- +goose Up
-- v1 的试选/锁旧价记录无法安全推断为单趟执行事实。先在同一事务中拒绝它们,
-- 避免删除审计数据后再尝试猜测映射。
CREATE TABLE single_pass_upgrade_guard (
valid INTEGER NOT NULL CHECK (valid = 1)
);
INSERT INTO single_pass_upgrade_guard (valid)
SELECT CASE WHEN
(SELECT COUNT(*) FROM spec_trials) = 0
AND (SELECT COUNT(*) FROM order_authorizations) = 0
AND (SELECT COUNT(*) FROM order_submissions) = 0
AND (SELECT COUNT(*) FROM tasks WHERE source <> 'MANUAL' OR status <> 'DRAFT') = 0
-- v2 的金额边界是严格正数;不把 v1 中不能无损纳入该边界的数据悄悄改写。
AND (SELECT COUNT(*) FROM tasks WHERE
max_total_price = ''
OR max_total_price GLOB '*[^0-9.]*'
OR length(max_total_price) - length(replace(max_total_price, '.', '')) > 1
OR max_total_price = '.'
OR (instr(max_total_price, '.') > 0 AND (
instr(max_total_price, '.') = 1
OR length(max_total_price) = instr(max_total_price, '.')
OR length(max_total_price) - instr(max_total_price, '.') > 2
))
OR replace(replace(max_total_price, '.', ''), '0', '') = ''
) = 0
THEN 1 ELSE 0 END;
DROP TABLE single_pass_upgrade_guard;
ALTER TABLE tasks RENAME TO tasks_v1;
DROP TABLE order_submissions;
DROP TABLE order_authorizations;
DROP TABLE spec_trials;
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
source TEXT NOT NULL CHECK (source IN ('MANUAL', 'EXCEL', 'ERP')),
source_ref TEXT,
title TEXT NOT NULL,
goods_id TEXT NOT NULL,
sku_color TEXT NOT NULL,
sku_size TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
max_total_price TEXT NOT NULL CHECK (
max_total_price <> ''
AND max_total_price NOT GLOB '*[^0-9.]*'
AND length(max_total_price) - length(replace(max_total_price, '.', '')) <= 1
AND max_total_price <> '.'
AND (instr(max_total_price, '.') = 0 OR (
instr(max_total_price, '.') > 1
AND length(max_total_price) > instr(max_total_price, '.')
AND length(max_total_price) - instr(max_total_price, '.') <= 2
))
AND replace(replace(max_total_price, '.', ''), '0', '') <> ''
),
reference_asset_id TEXT,
status TEXT NOT NULL CHECK (status IN (
'DRAFT', 'PENDING', 'CLAIMED', 'ORDERING', 'NEEDS_MANUAL', 'WAITING_PAYMENT',
'RECONCILIATION_REQUIRED', 'SUCCEEDED', 'FAILED', 'CANCELED'
)),
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0 AND typeof(version) = 'integer'),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO tasks (
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
reference_asset_id, status, version, created_at, updated_at
)
SELECT
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
reference_asset_id, status, version, created_at, updated_at
FROM tasks_v1;
DROP TABLE tasks_v1;
CREATE TABLE order_authorizations (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
task_version INTEGER NOT NULL CHECK (task_version > 0 AND typeof(task_version) = 'integer'),
start_key TEXT NOT NULL,
goods_id TEXT NOT NULL,
sku_color TEXT NOT NULL,
sku_size TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
total_price_cap TEXT NOT NULL CHECK (
total_price_cap <> ''
AND total_price_cap NOT GLOB '*[^0-9.]*'
AND length(total_price_cap) - length(replace(total_price_cap, '.', '')) <= 1
AND total_price_cap <> '.'
AND (instr(total_price_cap, '.') = 0 OR (
instr(total_price_cap, '.') > 1
AND length(total_price_cap) > instr(total_price_cap, '.')
AND length(total_price_cap) - instr(total_price_cap, '.') <= 2
))
AND replace(replace(total_price_cap, '.', ''), '0', '') <> ''
),
status TEXT NOT NULL CHECK (status IN ('ACTIVE', 'CLAIMED', 'FENCED', 'CONSUMED', 'EXPIRED', 'ABANDONED')),
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
UNIQUE (task_id, task_version),
UNIQUE (start_key, task_id),
UNIQUE (task_id, id)
);
CREATE TABLE purchase_attempts (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
authorization_id TEXT NOT NULL,
claim_generation INTEGER NOT NULL CHECK (claim_generation > 0 AND typeof(claim_generation) = 'integer'),
status TEXT NOT NULL CHECK (status IN ('CLAIMED', 'ORDERING', 'FAILED', 'FENCED', 'ABANDONED')),
gate1_unit_price TEXT CHECK (
gate1_unit_price IS NULL OR (
gate1_unit_price <> ''
AND gate1_unit_price NOT GLOB '*[^0-9.]*'
AND length(gate1_unit_price) - length(replace(gate1_unit_price, '.', '')) <= 1
AND gate1_unit_price <> '.'
AND (instr(gate1_unit_price, '.') = 0 OR (
instr(gate1_unit_price, '.') > 1
AND length(gate1_unit_price) > instr(gate1_unit_price, '.')
AND length(gate1_unit_price) - instr(gate1_unit_price, '.') <= 2
))
AND replace(replace(gate1_unit_price, '.', ''), '0', '') <> ''
)
),
gate2_unit_price TEXT CHECK (
gate2_unit_price IS NULL OR (
gate2_unit_price <> ''
AND gate2_unit_price NOT GLOB '*[^0-9.]*'
AND length(gate2_unit_price) - length(replace(gate2_unit_price, '.', '')) <= 1
AND gate2_unit_price <> '.'
AND (instr(gate2_unit_price, '.') = 0 OR (
instr(gate2_unit_price, '.') > 1
AND length(gate2_unit_price) > instr(gate2_unit_price, '.')
AND length(gate2_unit_price) - instr(gate2_unit_price, '.') <= 2
))
AND replace(replace(gate2_unit_price, '.', ''), '0', '') <> ''
)
),
quantity_read INTEGER CHECK (quantity_read IS NULL OR (quantity_read > 0 AND typeof(quantity_read) = 'integer')),
confirm_amount TEXT CHECK (
confirm_amount IS NULL OR (
confirm_amount <> ''
AND confirm_amount NOT GLOB '*[^0-9.]*'
AND length(confirm_amount) - length(replace(confirm_amount, '.', '')) <= 1
AND confirm_amount <> '.'
AND (instr(confirm_amount, '.') = 0 OR (
instr(confirm_amount, '.') > 1
AND length(confirm_amount) > instr(confirm_amount, '.')
AND length(confirm_amount) - instr(confirm_amount, '.') <= 2
))
AND replace(replace(confirm_amount, '.', ''), '0', '') <> ''
)
),
failure_code TEXT CHECK (failure_code IS NULL OR failure_code IN (
'AUTHORIZATION_EXPIRED', 'LEASE_LOST', 'GATE_1_REJECTED', 'QUANTITY_MISMATCH',
'GATE_2_REJECTED', 'GATE_3_REJECTED', 'FENCE_REJECTED', 'SAFE_ABORTED'
)),
started_at TEXT NOT NULL,
finished_at TEXT,
UNIQUE (task_id, claim_generation),
UNIQUE (task_id, id),
UNIQUE (task_id, authorization_id, id),
FOREIGN KEY (task_id, authorization_id) REFERENCES order_authorizations(task_id, id)
);
CREATE TABLE order_submissions (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
authorization_id TEXT NOT NULL,
attempt_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('FENCED', 'SUBMITTED', 'RECONCILIATION_REQUIRED', 'MANUAL_RESOLVED')),
gate1_unit_price TEXT NOT NULL CHECK (
gate1_unit_price <> ''
AND gate1_unit_price NOT GLOB '*[^0-9.]*'
AND length(gate1_unit_price) - length(replace(gate1_unit_price, '.', '')) <= 1
AND gate1_unit_price <> '.'
AND (instr(gate1_unit_price, '.') = 0 OR (
instr(gate1_unit_price, '.') > 1
AND length(gate1_unit_price) > instr(gate1_unit_price, '.')
AND length(gate1_unit_price) - instr(gate1_unit_price, '.') <= 2
))
AND replace(replace(gate1_unit_price, '.', ''), '0', '') <> ''
),
gate2_unit_price TEXT NOT NULL CHECK (
gate2_unit_price <> ''
AND gate2_unit_price NOT GLOB '*[^0-9.]*'
AND length(gate2_unit_price) - length(replace(gate2_unit_price, '.', '')) <= 1
AND gate2_unit_price <> '.'
AND (instr(gate2_unit_price, '.') = 0 OR (
instr(gate2_unit_price, '.') > 1
AND length(gate2_unit_price) > instr(gate2_unit_price, '.')
AND length(gate2_unit_price) - instr(gate2_unit_price, '.') <= 2
))
AND replace(replace(gate2_unit_price, '.', ''), '0', '') <> ''
),
quantity_read INTEGER NOT NULL CHECK (quantity_read > 0 AND typeof(quantity_read) = 'integer'),
confirm_amount TEXT NOT NULL CHECK (
confirm_amount <> ''
AND confirm_amount NOT GLOB '*[^0-9.]*'
AND length(confirm_amount) - length(replace(confirm_amount, '.', '')) <= 1
AND confirm_amount <> '.'
AND (instr(confirm_amount, '.') = 0 OR (
instr(confirm_amount, '.') > 1
AND length(confirm_amount) > instr(confirm_amount, '.')
AND length(confirm_amount) - instr(confirm_amount, '.') <= 2
))
AND replace(replace(confirm_amount, '.', ''), '0', '') <> ''
),
created_at TEXT NOT NULL,
resolved_at TEXT,
UNIQUE (authorization_id),
UNIQUE (attempt_id),
FOREIGN KEY (task_id, authorization_id, attempt_id) REFERENCES purchase_attempts(task_id, authorization_id, id)
);
-- +goose Down
-- 只有尚未产生任何单趟授权或执行事实的纯 MANUAL/DRAFT 数据才能无损回到 v1。
CREATE TABLE single_pass_downgrade_guard (
valid INTEGER NOT NULL CHECK (valid = 1)
);
INSERT INTO single_pass_downgrade_guard (valid)
SELECT CASE WHEN
(SELECT COUNT(*) FROM order_authorizations) = 0
AND (SELECT COUNT(*) FROM purchase_attempts) = 0
AND (SELECT COUNT(*) FROM order_submissions) = 0
AND (SELECT COUNT(*) FROM tasks WHERE source <> 'MANUAL' OR status <> 'DRAFT') = 0
THEN 1 ELSE 0 END;
DROP TABLE single_pass_downgrade_guard;
ALTER TABLE tasks RENAME TO tasks_v2;
DROP TABLE order_submissions;
DROP TABLE purchase_attempts;
DROP TABLE order_authorizations;
CREATE TABLE tasks (
id TEXT PRIMARY KEY,
source TEXT NOT NULL CHECK (source IN ('MANUAL', 'EXCEL', 'ERP')),
source_ref TEXT,
title TEXT NOT NULL,
goods_id TEXT NOT NULL,
sku_color TEXT NOT NULL,
sku_size TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
max_total_price TEXT NOT NULL CHECK (
max_total_price <> ''
AND max_total_price NOT GLOB '*[^0-9.]*'
AND length(max_total_price) - length(replace(max_total_price, '.', '')) <= 1
AND max_total_price <> '.'
AND (instr(max_total_price, '.') = 0 OR (
instr(max_total_price, '.') > 1
AND length(max_total_price) > instr(max_total_price, '.')
AND length(max_total_price) - instr(max_total_price, '.') <= 2
))
),
reference_asset_id TEXT,
status TEXT NOT NULL CHECK (status IN (
'DRAFT', 'PENDING', 'CLAIMED', 'RUNNING', 'WAITING_CONFIRMATION',
'PENDING_RETRIAL', 'AUTHORIZED', 'ORDERING', 'WAITING_PAYMENT',
'RECONCILIATION_REQUIRED', 'NEEDS_MANUAL', 'SUCCEEDED', 'CANCELED'
)),
version INTEGER NOT NULL DEFAULT 1 CHECK (version > 0 AND typeof(version) = 'integer'),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO tasks (
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
reference_asset_id, status, version, created_at, updated_at
)
SELECT
id, source, source_ref, title, goods_id, sku_color, sku_size, quantity, max_total_price,
reference_asset_id, status, version, created_at, updated_at
FROM tasks_v2;
DROP TABLE tasks_v2;
CREATE TABLE spec_trials (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
attempt INTEGER NOT NULL CHECK (attempt > 0 AND typeof(attempt) = 'integer'),
product_title TEXT NOT NULL,
selected_color TEXT NOT NULL,
selected_size TEXT NOT NULL,
unit_price TEXT NOT NULL CHECK (unit_price <> '' AND unit_price NOT GLOB '*[^0-9.]*' AND length(unit_price) - length(replace(unit_price, '.', '')) <= 1 AND unit_price <> '.' AND (instr(unit_price, '.') = 0 OR (instr(unit_price, '.') > 1 AND length(unit_price) > instr(unit_price, '.') AND length(unit_price) - instr(unit_price, '.') <= 2))),
total_price TEXT NOT NULL CHECK (total_price <> '' AND total_price NOT GLOB '*[^0-9.]*' AND length(total_price) - length(replace(total_price, '.', '')) <= 1 AND total_price <> '.' AND (instr(total_price, '.') = 0 OR (instr(total_price, '.') > 1 AND length(total_price) > instr(total_price, '.') AND length(total_price) - instr(total_price, '.') <= 2))),
evidence_sha256 TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (task_id, attempt),
UNIQUE (task_id, id)
);
CREATE TABLE order_authorizations (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
spec_trial_id TEXT NOT NULL REFERENCES spec_trials(id),
version INTEGER NOT NULL CHECK (version > 0 AND typeof(version) = 'integer'),
goods_id TEXT NOT NULL,
sku_color TEXT NOT NULL,
sku_size TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0 AND typeof(quantity) = 'integer'),
authorized_unit_price TEXT NOT NULL CHECK (authorized_unit_price <> '' AND authorized_unit_price NOT GLOB '*[^0-9.]*' AND length(authorized_unit_price) - length(replace(authorized_unit_price, '.', '')) <= 1 AND authorized_unit_price <> '.' AND (instr(authorized_unit_price, '.') = 0 OR (instr(authorized_unit_price, '.') > 1 AND length(authorized_unit_price) > instr(authorized_unit_price, '.') AND length(authorized_unit_price) - instr(authorized_unit_price, '.') <= 2))),
total_price_cap TEXT NOT NULL CHECK (total_price_cap <> '' AND total_price_cap NOT GLOB '*[^0-9.]*' AND length(total_price_cap) - length(replace(total_price_cap, '.', '')) <= 1 AND total_price_cap <> '.' AND (instr(total_price_cap, '.') = 0 OR (instr(total_price_cap, '.') > 1 AND length(total_price_cap) > instr(total_price_cap, '.') AND length(total_price_cap) - instr(total_price_cap, '.') <= 2))),
note TEXT,
status TEXT NOT NULL CHECK (status IN ('PENDING_DELIVERY', 'DELIVERED', 'ACKNOWLEDGED', 'EXECUTING', 'FENCED', 'CONSUMED', 'SUPERSEDED', 'EXPIRED')),
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
UNIQUE (task_id, version),
UNIQUE (task_id, id),
FOREIGN KEY (task_id, spec_trial_id) REFERENCES spec_trials(task_id, id)
);
CREATE TABLE order_submissions (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL REFERENCES tasks(id),
authorization_id TEXT NOT NULL REFERENCES order_authorizations(id),
command_id TEXT NOT NULL,
dry_run_id TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('FENCED', 'SUBMITTED', 'RECONCILIATION_REQUIRED', 'MANUAL_RESOLVED')),
verified_unit_price TEXT NOT NULL CHECK (verified_unit_price <> '' AND verified_unit_price NOT GLOB '*[^0-9.]*' AND length(verified_unit_price) - length(replace(verified_unit_price, '.', '')) <= 1 AND verified_unit_price <> '.' AND (instr(verified_unit_price, '.') = 0 OR (instr(verified_unit_price, '.') > 1 AND length(verified_unit_price) > instr(verified_unit_price, '.') AND length(verified_unit_price) - instr(verified_unit_price, '.') <= 2))),
quantity_read INTEGER NOT NULL CHECK (quantity_read > 0 AND typeof(quantity_read) = 'integer'),
confirm_page_amount TEXT NOT NULL CHECK (confirm_page_amount <> '' AND confirm_page_amount NOT GLOB '*[^0-9.]*' AND length(confirm_page_amount) - length(replace(confirm_page_amount, '.', '')) <= 1 AND confirm_page_amount <> '.' AND (instr(confirm_page_amount, '.') = 0 OR (instr(confirm_page_amount, '.') > 1 AND length(confirm_page_amount) > instr(confirm_page_amount, '.') AND length(confirm_page_amount) - instr(confirm_page_amount, '.') <= 2))),
created_at TEXT NOT NULL,
resolved_at TEXT,
UNIQUE (authorization_id),
UNIQUE (command_id),
FOREIGN KEY (task_id, authorization_id) REFERENCES order_authorizations(task_id, id)
);
@@ -0,0 +1,53 @@
-- +goose Up
CREATE TABLE evidence_assets (
id TEXT PRIMARY KEY,
upload_key TEXT NOT NULL,
task_id TEXT NOT NULL,
attempt_id TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind = 'SKU_PANEL_GATE_1'),
privacy_tier TEXT NOT NULL CHECK (privacy_tier = 'INTERNAL_RAW'),
sha256 TEXT NOT NULL CHECK (
length(sha256) = 64
AND sha256 NOT GLOB '*[^0-9a-f]*'
),
byte_size INTEGER NOT NULL CHECK (
typeof(byte_size) = 'integer'
AND byte_size > 0
AND byte_size <= 10485760
),
content_type TEXT NOT NULL CHECK (content_type = 'image/png'),
width_px INTEGER NOT NULL CHECK (
typeof(width_px) = 'integer'
AND width_px > 0
AND width_px <= 8192
),
height_px INTEGER NOT NULL CHECK (
typeof(height_px) = 'integer'
AND height_px > 0
AND height_px <= 8192
),
storage_key TEXT NOT NULL CHECK (
storage_key = substr(sha256, 1, 2) || '/' || sha256 || '.png'
),
uploaded_by_device_id TEXT NOT NULL CHECK (trim(uploaded_by_device_id) <> ''),
captured_at TEXT NOT NULL CHECK (trim(captured_at) <> ''),
created_at TEXT NOT NULL CHECK (trim(created_at) <> ''),
CHECK (width_px * height_px <= 16777216),
UNIQUE (uploaded_by_device_id, upload_key),
FOREIGN KEY (task_id, attempt_id) REFERENCES purchase_attempts(task_id, id)
);
CREATE INDEX evidence_assets_task_time_idx
ON evidence_assets (task_id, captured_at, created_at, id);
-- +goose Down
-- 已写入的内部原图是审计事实,回滚迁移不得静默删除它们。
CREATE TABLE evidence_downgrade_guard (
valid INTEGER NOT NULL CHECK (valid = 1)
);
INSERT INTO evidence_downgrade_guard (valid)
SELECT CASE WHEN (SELECT COUNT(*) FROM evidence_assets) = 0 THEN 1 ELSE 0 END;
DROP TABLE evidence_downgrade_guard;
DROP TABLE evidence_assets;
+23
View File
@@ -0,0 +1,23 @@
# 本机开发环境与解释器缓存
.venv/
venv/
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
# 运行时生成的日志、截图和其他证据产物不得进入版本库。
logs/
artifacts/
runtime/
*.log
# 本机凭据或环境覆盖仅可保存在未跟踪文件中。
.env
.env.*
secrets/
# 打包工具生成的本机产物
build/
dist/
*.egg-info/
+19
View File
@@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "cmbuyer-client"
version = "0.1.0"
description = "cmbuyer 采购工具桌面端"
requires-python = ">=3.11"
dynamic = ["dependencies"]
[project.scripts]
cmbuyer-client = "cmbuyer_client.app:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.dynamic]
dependencies = { file = ["requirements.txt"] }
+9
View File
@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# 桌面界面(Qt 官方 Python 绑定)。
PySide6
# T-101 基线取证使用;只连接显式 serial,不打开或操作拼多多页面。
uiautomator2
# T-101 直接使用当前 ADB server 的已列出设备对象交给 uiautomator2,禁止 WiFi 自动重连。
adbutils>=2.11,<3
# 后续截图完整性检查会使用。
Pillow
+76
View File
@@ -0,0 +1,76 @@
"""采集指定 Android 设备的本地基线证据;不打开或操作拼多多页面。"""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import (
BaselineCaptureError,
DeviceBaselineCapturer,
NoReconnectUiautomatorConnector,
)
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="采集显式指定 Android 设备的本地基线证据。")
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
parser.add_argument("--output-dir", required=True, type=Path, help="新建的本地证据目录;不得覆盖已有目录。")
parser.add_argument("--timeout", type=float, default=10.0, help="ADB、uiautomator2 RPC 与 ADB socket 超时(秒)。")
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
return parser.parse_args(argv)
def validate_arguments(arguments: argparse.Namespace) -> None:
"""在导入设备库前拒绝危险或无效输入,便于离线测试。"""
if not arguments.serial.strip():
raise ValueError("必须显式提供非空 --serial。")
if arguments.timeout <= 0:
raise ValueError("--timeout 必须大于 0。")
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
validate_arguments(arguments)
except ValueError as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
return 2
client = AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout)
connector = NoReconnectUiautomatorConnector(
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
u2.connect,
)
capturer = DeviceBaselineCapturer(client, connector, timeout_seconds=arguments.timeout)
try:
result = capturer.capture(arguments.serial, arguments.output_dir)
except (DeviceConnectionError, BaselineCaptureError) as error:
# 错误类型只表达状态,不打印 ADB 输出、serial、XML 或页面正文。
print(f"基线取证失败:{error}", file=sys.stderr)
return 1
except OSError:
print("基线取证失败:无法创建或发布本地证据目录。", file=sys.stderr)
return 1
print(f"基线取证完成:{result.output_directory}")
print(f"manifest:{result.manifest_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+75
View File
@@ -0,0 +1,75 @@
"""打开已验证的拼多多商品直链并采集只读本地证据。"""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
from cmbuyer_client.pdd.product_open import ProductOpenCapturer, ProductOpenError
from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="打开 canonical 拼多多商品链接并采集只读证据。")
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
parser.add_argument("--url", required=True, help="唯一允许的 goods.html?goods_id= 直链。")
parser.add_argument("--output-dir", required=True, type=Path, help="新建的本地证据目录;不得覆盖已有目录。")
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 和只读 RPC 超时(秒)。")
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
return parser.parse_args(argv)
def validate_arguments(arguments: argparse.Namespace) -> None:
if not arguments.serial.strip():
raise ValueError("必须显式提供非空 --serial。")
if arguments.timeout <= 0:
raise ValueError("--timeout 必须大于 0。")
parse_product_url(arguments.url)
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
validate_arguments(arguments)
link = parse_product_url(arguments.url)
except (ValueError, ProductUrlError) as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
return 2
client = AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout)
connector = NoReconnectUiautomatorConnector(
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
u2.connect,
)
capturer = ProductOpenCapturer(client, connector, timeout_seconds=arguments.timeout)
try:
result = capturer.open_and_capture(arguments.serial, link.canonical_url, arguments.output_dir)
except (DeviceConnectionError, ProductOpenError) as error:
# 不打印 ADB 输出、serial、Activity、XML 或页面正文。
print(f"商品打开取证失败:{error}", file=sys.stderr)
return 1
except OSError:
print("商品打开取证失败:无法创建或发布本地证据目录。", file=sys.stderr)
return 1
print(f"商品打开取证完成:{result.output_directory}")
print(f"manifest:{result.manifest_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+103
View File
@@ -0,0 +1,103 @@
"""采集人工已停在规格面板的三种状态证据;不执行任何页面操作。"""
from __future__ import annotations
import argparse
from math import isfinite
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
from cmbuyer_client.pdd.product_url import ProductUrl, ProductUrlError, parse_product_url
from cmbuyer_client.pdd.sku_panel_spike import (
HUMAN_DECLARED_STATES,
SkuPanelEvidenceCapturer,
SkuPanelEvidenceError,
)
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="采集人工已打开的拼多多规格面板状态证据。")
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
product_source = parser.add_mutually_exclusive_group(required=True)
product_source.add_argument("--url", help="唯一 canonical goods.html?goods_id= 直链。")
product_source.add_argument("--goods-id", help="纯数字商品标识;仅用于记录证据归属。")
parser.add_argument("--state", required=True, choices=sorted(HUMAN_DECLARED_STATES), help="人工声明的面板状态。")
parser.add_argument("--output-dir", required=True, type=Path, help="新建的本地证据目录;不得覆盖已有目录。")
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 和只读 RPC 超时(秒)。")
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
return parser.parse_args(argv)
def resolve_product_url(arguments: argparse.Namespace) -> ProductUrl:
if isinstance(arguments.url, str):
return parse_product_url(arguments.url)
if isinstance(arguments.goods_id, str):
# 仅使用严格 parser 重新验证并构建,不把输入交给 ADB 或页面。
return parse_product_url(f"https://mobile.yangkeduo.com/goods.html?goods_id={arguments.goods_id}")
raise ValueError("必须提供 --url 或 --goods-id。")
def validate_arguments(arguments: argparse.Namespace) -> ProductUrl:
if not isinstance(arguments.serial, str) or not arguments.serial.strip():
raise ValueError("必须显式提供非空 --serial。")
if (
not isinstance(arguments.timeout, (int, float))
or isinstance(arguments.timeout, bool)
or arguments.timeout <= 0
or not isfinite(arguments.timeout)
):
raise ValueError("--timeout 必须是大于 0 的有限数值。")
if arguments.state not in HUMAN_DECLARED_STATES:
raise ValueError("--state 必须是允许的人工声明状态。")
return resolve_product_url(arguments)
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
link = validate_arguments(arguments)
except (ValueError, ProductUrlError) as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
return 2
client = AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout)
connector = NoReconnectUiautomatorConnector(
adbutils.AdbClient(socket_timeout=arguments.timeout).device_list,
u2.connect,
)
capturer = SkuPanelEvidenceCapturer(client, connector, timeout_seconds=arguments.timeout)
try:
result = capturer.capture(
arguments.serial,
link.canonical_url,
arguments.state,
arguments.output_dir,
)
except (DeviceConnectionError, SkuPanelEvidenceError) as error:
# 不打印 ADB 输出、serial、Activity、XML 或页面正文。
print(f"规格面板证据采集失败:{error}", file=sys.stderr)
return 1
except OSError:
print("规格面板证据采集失败:无法创建或发布本地证据目录。", file=sys.stderr)
return 1
print(f"规格面板证据采集完成:{result.output_directory}")
print(f"manifest:{result.manifest_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+84
View File
@@ -0,0 +1,84 @@
"""恢复 T-103 已取证目标规格、验证现价并保存本地原始截图。"""
from __future__ import annotations
import argparse
from math import isfinite
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
from cmbuyer_client.pdd.product_url import 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
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="恢复 T-103 已取证规格并保存本地原始截图。")
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
parser.add_argument("--url", required=True, help="唯一 canonical goods.html?goods_id= 直链。")
parser.add_argument("--color", required=True, help="T-103 任务颜色值。")
parser.add_argument("--size", required=True, help="T-103 任务尺码值。")
parser.add_argument("--output-dir", required=True, type=Path, help="新建本地目录;不得覆盖已有目录。")
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 与设备 RPC 超时(秒)。")
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
return parser.parse_args(argv)
def validate_arguments(arguments: argparse.Namespace) -> None:
if not isinstance(arguments.serial, str) or not arguments.serial.strip():
raise ValueError("必须显式提供非空 --serial。")
if not isinstance(arguments.timeout, (int, float)) or isinstance(arguments.timeout, bool) or arguments.timeout <= 0 or not isfinite(arguments.timeout):
raise ValueError("--timeout 必须是大于 0 的有限数值。")
link = parse_product_url(arguments.url)
if link.goods_id != EXPECTED_GOODS_ID:
raise ValueError("--url 不是 T-103 已取证商品。")
if (arguments.color, arguments.size) not in TASK_TO_UI_SELECTION:
raise ValueError("--color 与 --size 必须是 T-103 已取证任务值。")
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
validate_arguments(arguments)
except (ValueError, ProductUrlError) as error:
print(f"失败:{error}", file=sys.stderr)
return 2
try:
import adbutils
import uiautomator2 as u2
except ImportError:
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
return 2
runner = SkuSelectionRunner(
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 = 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)
return 1
except OSError:
print("规格恢复失败:无法创建或发布本地证据目录。", file=sys.stderr)
return 1
print(f"规格恢复完成:{result.output_directory}")
print(f"manifest:{result.manifest_path}")
print(f"目标规格:{arguments.color} / {arguments.size}")
print(f"确认单价:{result.unit_price}")
print("页面对应性:请人工核对本地原始截图。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,41 @@
"""本机脱敏 T-103 raw 证据到同级 derived;不连接设备或解析页面语义。"""
from __future__ import annotations
import argparse
from pathlib import Path
import sys
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.sku_evidence_sanitizer import (
SkuEvidenceSanitizationError,
sanitize_sku_panel_evidence,
)
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="将本机 raw 规格面板证据确定性脱敏到同级 derived。")
parser.add_argument("--raw-dir", required=True, type=Path, help="仅允许名为 raw 的本机原始证据目录。")
parser.add_argument("--output-dir", required=True, type=Path, help="仅允许 raw 同级且名为 derived 的新目录。")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
arguments = parse_arguments(argv)
try:
result = sanitize_sku_panel_evidence(arguments.raw_dir, arguments.output_dir)
except SkuEvidenceSanitizationError as error:
# 错误不回显 raw 路径、manifest/XML、地址、手机号或 serial。
print(f"证据脱敏失败:{error}", file=sys.stderr)
return 1
print(f"派生证据脱敏完成:{result.output_directory}")
print(f"manifest:{result.manifest_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+80
View File
@@ -0,0 +1,80 @@
"""验证 wheel 元数据从 requirements.txt 声明了全部运行时依赖。"""
from __future__ import annotations
import argparse
from email import policy
from email.parser import BytesParser
from pathlib import Path
import re
import sys
import zipfile
def normalize_project_name(name: str) -> str:
"""使用足以比较 requirements 与 Core Metadata 的项目名规范化规则。"""
return re.sub(r"[-_.]+", "-", name).lower()
def dependencies_from_requirements(requirements_file: Path) -> set[str]:
"""从唯一依赖来源读取项目名;当前 requirements 不允许间接或可编辑依赖。"""
dependencies: set[str] = set()
for line in requirements_file.read_text(encoding="utf-8").splitlines():
requirement = line.partition("#")[0].strip()
if not requirement:
continue
match = re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*", requirement)
if match is None:
raise ValueError(f"requirements.txt 包含不支持的依赖声明:{requirement}")
dependencies.add(normalize_project_name(match.group()))
return dependencies
def dependencies_from_wheel(wheel_file: Path) -> set[str]:
"""读取 wheel 的 Core Metadata 中声明的 Requires-Dist 项目名。"""
with zipfile.ZipFile(wheel_file) as wheel:
metadata_members = [name for name in wheel.namelist() if name.endswith(".dist-info/METADATA")]
if len(metadata_members) != 1:
raise ValueError("wheel 中必须恰有一个 .dist-info/METADATA 文件")
metadata = BytesParser(policy=policy.default).parsebytes(wheel.read(metadata_members[0]))
dependencies = set()
for requirement in metadata.get_all("Requires-Dist", []):
match = re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*", requirement)
if match is None:
raise ValueError(f"wheel METADATA 包含无效的 Requires-Dist:{requirement}")
dependencies.add(normalize_project_name(match.group()))
return dependencies
def verify_wheel_metadata(wheel_file: Path, requirements_file: Path) -> set[str]:
"""返回没有被 wheel 元数据声明的 requirements 项目名。"""
return dependencies_from_requirements(requirements_file) - dependencies_from_wheel(wheel_file)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="检查 wheel 是否包含 requirements.txt 的依赖元数据")
parser.add_argument("wheel", type=Path, help="待检查的 wheel 文件")
parser.add_argument(
"--requirements",
type=Path,
default=Path(__file__).resolve().parents[1] / "requirements.txt",
help="唯一依赖来源 requirements.txt 的路径",
)
arguments = parser.parse_args(argv)
missing = verify_wheel_metadata(arguments.wheel, arguments.requirements)
if missing:
print(f"wheel METADATA 缺少依赖:{', '.join(sorted(missing))}", file=sys.stderr)
return 1
print("wheel METADATA 已声明 requirements.txt 中的全部依赖。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+6
View File
@@ -0,0 +1,6 @@
"""采购工具桌面端包。
本包当前只提供应用骨架和安全的本地运行基础设施;不包含真机操作或采购流程。
"""
__version__ = "0.1.0"
+7
View File
@@ -0,0 +1,7 @@
"""支持通过 ``python -m cmbuyer_client`` 启动应用。"""
from .app import main
if __name__ == "__main__":
raise SystemExit(main())
+57
View File
@@ -0,0 +1,57 @@
"""采购工具的最小桌面应用入口。"""
from __future__ import annotations
import sys
from collections.abc import Sequence
from .logging_policy import configure_application_logger
from .runtime import RuntimePaths
def select_application_argv(argv: Sequence[str] | None) -> list[str]:
"""保留调用方明确给出的空参数列表,避免改变测试或打包入口的语义。"""
return list(sys.argv if argv is None else argv)
def main(argv: Sequence[str] | None = None) -> int:
"""启动只表达当前工程状态的桌面外壳。
真机控制和采购执行必须在完成取证并实现后才可接入,因此此入口不导入
uiautomator2,也不提供任何会影响采购或支付状态的命令。
"""
try:
paths = RuntimePaths.default()
logger = configure_application_logger(paths)
except OSError as error:
print(f"无法创建采购工具运行目录:{error}", file=sys.stderr)
return 1
try:
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QApplication, QLabel, QMainWindow
except ImportError:
logger.error("缺少 PySide6,无法启动桌面界面。")
print("无法启动采购工具:缺少 PySide6。请先安装 requirements.txt 中的依赖。", file=sys.stderr)
return 2
application = QApplication.instance() or QApplication(select_application_argv(argv))
application.setApplicationName("采购工具")
window = QMainWindow()
window.setWindowTitle("采购工具")
window.setAccessibleName("采购工具")
window.setMinimumSize(420, 240)
window.resize(560, 320)
message = QLabel("应用骨架已初始化。\n采购执行功能尚未启用。")
message.setAlignment(Qt.AlignmentFlag.AlignCenter)
message.setWordWrap(True)
message.setAccessibleName("当前状态")
window.setCentralWidget(message)
logger.info("应用已启动;采购执行功能尚未启用。")
window.show()
return application.exec()
@@ -0,0 +1,15 @@
"""设备连接与基线取证边界。
本包只提供显式设备选择、非敏感身份核验和本地基线采集;不包含任何采购页面或订单操作。
"""
from .adb import AdbClient, AdbDevice, CommandResult
from .baseline import BaselineCaptureResult, DeviceBaselineCapturer
__all__ = [
"AdbClient",
"AdbDevice",
"BaselineCaptureResult",
"CommandResult",
"DeviceBaselineCapturer",
]
+282
View File
@@ -0,0 +1,282 @@
"""ADB 设备清单与物理设备冲突的 fail-closed 边界。"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import subprocess
from typing import Protocol, Sequence
class DeviceConnectionError(RuntimeError):
"""显式设备连接边界的基础错误,不携带命令输出或设备敏感内容。"""
class SerialRequiredError(DeviceConnectionError):
"""调用方没有明确指定设备 serial。"""
class DeviceNotFoundError(DeviceConnectionError):
"""指定 serial 不在 ADB 当前清单中。"""
class DeviceOfflineError(DeviceConnectionError):
"""指定设备处于 offline 状态。"""
class DeviceUnauthorizedError(DeviceConnectionError):
"""指定设备尚未授权此电脑。"""
class DeviceStateError(DeviceConnectionError):
"""指定设备处于其他不可用状态。"""
class DeviceCommandTimeoutError(DeviceConnectionError):
"""ADB 命令超过调用方指定的超时。"""
class DeviceCommandError(DeviceConnectionError):
"""ADB 命令失败;错误文本刻意不回显设备输出。"""
class DeviceIdentityUnconfirmedError(DeviceConnectionError):
"""多条在线通道无法完成同机身份判断,必须由人处理。"""
class DuplicatePhysicalDeviceError(DeviceConnectionError):
"""同一物理手机通过多个 ADB 通道同时在线。"""
class IntentLaunchUnconfirmedError(DeviceConnectionError):
"""`am start -W` 没有给出可确认的启动成功结果。"""
@dataclass(frozen=True)
class CommandResult:
"""可注入命令执行器的最小、可离线构造结果。"""
stdout: str
stderr: str = ""
returncode: int = 0
@dataclass(frozen=True)
class IntentLaunchSummary:
"""不含 Activity、页面内容或 ADB 输出的受限启动摘要。"""
status: str
returncode: int
class CommandRunner(Protocol):
"""运行 ADB 子命令的可替换边界。"""
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
"""运行参数,不得通过 shell 拼接。"""
class SubprocessAdbRunner:
"""使用 subprocess 的生产执行器,所有调用必须带超时。"""
def __init__(self, executable: str | Path = "adb") -> None:
self._executable = str(executable)
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
try:
completed = subprocess.run(
[self._executable, *arguments],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout_seconds,
)
except subprocess.TimeoutExpired as error:
raise DeviceCommandTimeoutError("ADB 命令超时,请检查设备连接后由人工重试。") from error
except OSError as error:
raise DeviceCommandError("无法启动 ADB,请检查 adb 路径与本机工具链。") from error
return CommandResult(
stdout=completed.stdout,
stderr=completed.stderr,
returncode=completed.returncode,
)
@dataclass(frozen=True)
class AdbDevice:
"""`adb devices -l` 的单行非敏感传输元数据。"""
serial: str
state: str
product: str | None = None
model: str | None = None
device: str | None = None
@dataclass(frozen=True)
class DeviceInspection:
"""选定通道的只读身份结果,原始硬件标识只在内存中参与比较。"""
device: AdbDevice
model: str
android_version: str
def parse_adb_devices(output: str) -> list[AdbDevice]:
"""解析 `adb devices -l`,忽略标题、空行和 adb 附加提示。"""
devices: list[AdbDevice] = []
for raw_line in output.splitlines():
line = raw_line.strip()
if not line or line.startswith("List of devices attached") or line.startswith("*"):
continue
fields = line.split()
if len(fields) < 2:
continue
details = {
key: value
for field in fields[2:]
if ":" in field
for key, value in [field.split(":", 1)]
}
devices.append(
AdbDevice(
serial=fields[0],
state=fields[1],
product=details.get("product"),
model=details.get("model"),
device=details.get("device"),
)
)
return devices
class AdbClient:
"""显式 serial 的 ADB 只读查询。
多个在线通道必须完成硬件身份比对。比对失败时不能用相同 model/product 猜测同一台手机,
因为那会把不确定性隐藏成错误的安全结论。
"""
def __init__(self, runner: CommandRunner, timeout_seconds: float = 10.0) -> None:
if timeout_seconds <= 0:
raise ValueError("timeout_seconds 必须大于 0")
self._runner = runner
self._timeout_seconds = timeout_seconds
def inspect(self, serial: str) -> DeviceInspection:
"""确认指定通道在线且不与另一在线通道指向同一物理设备。"""
selected_serial = _require_serial(serial)
devices = self.devices()
selected = next((device for device in devices if device.serial == selected_serial), None)
if selected is None:
raise DeviceNotFoundError("指定设备不在 ADB 清单中,请显式检查 serial。")
_raise_for_state(selected.state)
online_devices = [device for device in devices if device.state == "device"]
if len(online_devices) > 1:
identities: dict[str, frozenset[str]] = {}
for candidate in online_devices:
try:
identities[candidate.serial] = self._physical_identity(candidate)
except DeviceConnectionError as error:
raise DeviceIdentityUnconfirmedError(
"存在多个在线 ADB 通道且身份无法确认,已拒绝选择设备。"
) from error
selected_identity = identities[selected.serial]
if any(
candidate_serial != selected.serial and selected_identity.intersection(candidate_identity)
for candidate_serial, candidate_identity in identities.items()
):
raise DuplicatePhysicalDeviceError(
"同一物理手机的多个 ADB 通道同时在线,已拒绝继续;请仅保留一个通道。"
)
model = self._getprop(selected.serial, "ro.product.model") or selected.model or "unknown"
android_version = self._getprop(selected.serial, "ro.build.version.release") or "unknown"
return DeviceInspection(device=selected, model=model, android_version=android_version)
def devices(self) -> list[AdbDevice]:
"""读取并解析 ADB 设备清单。"""
result = self._run_checked(("devices", "-l"))
return parse_adb_devices(result.stdout)
def start_pdd_view_intent(self, serial: str, goods_id: str) -> IntentLaunchSummary:
"""以参数数组启动唯一允许的拼多多 ACTION_VIEW Intent。
这里刻意不提供任意 shell 或任意 package 的执行接口。调用方必须先完成
``inspect`` 和应用版本核验;本方法在本层从纯数字 ``goods_id`` 重建 URL,调用方不能
把另一个 URL 直接交给 ADB。本方法既不点击控件,也不解析 Activity 或页面文本。
"""
selected_serial = _require_serial(serial)
if (
not isinstance(goods_id, str)
or not goods_id
or any(character < "0" or character > "9" for character in goods_id)
):
raise ValueError("goods_id 必须是纯数字")
canonical_url = f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}"
result = self._run_checked(
(
"-s",
selected_serial,
"shell",
"am",
"start",
"-W",
"-a",
"android.intent.action.VIEW",
"-d",
canonical_url,
"-p",
"com.xunmeng.pinduoduo",
)
)
if not any(line.strip() == "Status: ok" for line in result.stdout.splitlines()):
raise IntentLaunchUnconfirmedError("商品链接启动结果无法确认,已停止后续取证。")
return IntentLaunchSummary(status="ok", returncode=result.returncode)
def _physical_identity(self, device: AdbDevice) -> frozenset[str]:
serialno = self._getprop(device.serial, "ro.serialno")
boot_serialno = self._getprop(device.serial, "ro.boot.serialno")
identifiers = frozenset(value for value in (serialno, boot_serialno) if value)
if identifiers:
return identifiers
# model/product/device 只能作为展示元数据,不能证明两台同型号设备是同一物理机。
raise DeviceIdentityUnconfirmedError("无法读取设备硬件身份摘要。")
def _getprop(self, serial: str, property_name: str) -> str:
result = self._run_checked(("-s", serial, "shell", "getprop", property_name))
return result.stdout.strip()
def _run_checked(self, arguments: Sequence[str]) -> CommandResult:
try:
result = self._runner.run(arguments, self._timeout_seconds)
except subprocess.TimeoutExpired as error:
raise DeviceCommandTimeoutError("ADB 命令超时,请检查设备连接后由人工重试。") from error
if result.returncode != 0:
raise DeviceCommandError("ADB 命令失败,请检查设备连接或授权状态。")
return result
def _require_serial(serial: str) -> str:
if not isinstance(serial, str) or not serial.strip():
raise SerialRequiredError("必须显式提供设备 serial,禁止自动选择设备。")
return serial.strip()
def _raise_for_state(state: str) -> None:
if state == "device":
return
if state == "offline":
raise DeviceOfflineError("指定设备处于 offline 状态。")
if state == "unauthorized":
raise DeviceUnauthorizedError("指定设备尚未授权此电脑。")
raise DeviceStateError("指定设备不处于可用状态。")
@@ -0,0 +1,238 @@
"""只读设备基线取证,严格限制在元数据、截图和完整节点树。"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from hashlib import sha256
import base64
import binascii
from io import BytesIO
import json
import os
from pathlib import Path
import shutil
from typing import Any, Protocol
from uuid import uuid4
from xml.etree import ElementTree
from adbutils.errors import AdbTimeout
from PIL import Image, UnidentifiedImageError
from uiautomator2.exceptions import HTTPTimeoutError
from .adb import AdbClient, DeviceInspection
PDD_PACKAGE = "com.xunmeng.pinduoduo"
SCREENSHOT_PARAMS = [1, 80]
HIERARCHY_PARAMS = [False, 50]
_BASE64_ASCII_WHITESPACE = " \t\r\n"
class BaselineCaptureError(RuntimeError):
"""基线取证无法完整落盘时的失败,不会伪造成功产物。"""
class BaselineCaptureTimeoutError(BaselineCaptureError):
"""设备基线取证超时;底层异常文本不向 CLI 或日志泄露。"""
class UiAutomatorDevice(Protocol):
"""本任务唯一需要的 uiautomator2 只读能力。"""
def app_info(self, package_name: str) -> dict[str, Any]:
"""读取已安装应用元数据。"""
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any:
"""调用公开 uiautomator2 JSON-RPC 接口。"""
class NoReconnectUiautomatorConnector:
"""只把当前 ADB server 已列出的设备对象交给 uiautomator2。
uiautomator2 直接接收 IP serial 时会在内部尝试 adb disconnect/connect。这里先从已列出设备中
取对象再调用 ``u2.connect(device_object)``,避免连接阶段隐式重连已经掉线的 WiFi 通道。
"""
def __init__(self, list_devices: Callable[[], list[Any]], connect: Callable[[Any], UiAutomatorDevice]) -> None:
self._list_devices = list_devices
self._connect = connect
def __call__(self, serial: str) -> UiAutomatorDevice:
device = next((item for item in self._list_devices() if item.serial == serial), None)
if device is None:
raise BaselineCaptureError("设备在连接前已从 ADB 清单消失,已拒绝自动重连。")
return self._connect(device)
@dataclass(frozen=True)
class BaselineCaptureResult:
"""已原子发布的基线取证摘要,不包含页面正文或原始 serial。"""
output_directory: Path
manifest_path: Path
screenshot_path: Path
hierarchy_path: Path
class DeviceBaselineCapturer:
"""以先校验通道、后连接、最后原子发布的顺序采集基线。
截图和 XML 可能包含页面敏感内容,因此仅落在调用方明确指定的本地目录;manifest 只写
哈希、设备非敏感元数据和脱敏后的 serial 摘要,绝不嵌入 XML 或页面文本。
"""
def __init__(
self,
adb_client: AdbClient,
connector: Callable[[str], UiAutomatorDevice],
timeout_seconds: float,
) -> None:
if timeout_seconds <= 0:
raise ValueError("timeout_seconds 必须大于 0")
self._adb_client = adb_client
self._connector = connector
self._timeout_seconds = timeout_seconds
def capture(self, serial: str, output_directory: Path) -> BaselineCaptureResult:
"""采集完整基线,任何一步失败均不发布 output_directory。"""
inspection = self._adb_client.inspect(serial)
target = Path(output_directory)
if target.exists():
raise BaselineCaptureError("输出目录已存在;为防止混入旧证据,拒绝覆盖。")
if not target.name:
raise BaselineCaptureError("输出目录必须是明确的新目录。")
target.parent.mkdir(parents=True, exist_ok=True)
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
staging.mkdir()
try:
device = self._connector(serial)
app_info = device.app_info(PDD_PACKAGE)
version = _extract_version(app_info)
screenshot_path = staging / "screenshot.png"
screenshot_base64 = device.jsonrpc_call(
"takeScreenshot",
SCREENSHOT_PARAMS,
timeout=self._timeout_seconds,
)
_save_base64_screenshot(screenshot_base64, screenshot_path)
hierarchy = device.jsonrpc_call(
"dumpWindowHierarchy",
HIERARCHY_PARAMS,
timeout=self._timeout_seconds,
)
_validate_hierarchy(hierarchy)
hierarchy_path = staging / "hierarchy.xml"
hierarchy_path.write_text(hierarchy, encoding="utf-8")
manifest_path = staging / "manifest.json"
manifest_path.write_text(
json.dumps(
_manifest(inspection, serial, version, screenshot_path, hierarchy_path),
ensure_ascii=False,
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
os.replace(staging, target)
except BaselineCaptureError:
# 仅删除本次创建、名称带随机标识的暂存目录,绝不触碰调用方原有输出目录。
if staging.exists():
shutil.rmtree(staging)
raise
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
if staging.exists():
shutil.rmtree(staging)
raise BaselineCaptureTimeoutError("设备基线取证超时,未发布任何证据产物。") from error
except Exception as error:
if staging.exists():
shutil.rmtree(staging)
# uiautomator2/adbutils 可能把 serial、路径或远端响应放入异常文本,不能直接传播到 CLI。
raise BaselineCaptureError("设备基线取证未完成,未发布任何证据产物。") from error
return BaselineCaptureResult(
output_directory=target,
manifest_path=target / "manifest.json",
screenshot_path=target / "screenshot.png",
hierarchy_path=target / "hierarchy.xml",
)
def _extract_version(app_info: dict[str, Any]) -> str:
version = app_info.get("versionName") or app_info.get("version_name")
if not isinstance(version, str) or not version.strip():
raise BaselineCaptureError("无法读取拼多多版本,拒绝发布不完整取证。")
return version.strip()
def _save_base64_screenshot(value: Any, target: Path) -> None:
"""规范化常见 ASCII Base64 空白后严格解码;没有 adb screenshot fallback。"""
if not isinstance(value, str) or not value:
raise BaselineCaptureError("截图 RPC 未返回 base64 数据,拒绝发布不完整取证。")
try:
normalized = value.translate({ord(character): None for character in _BASE64_ASCII_WHITESPACE})
raw_image = base64.b64decode(normalized.encode("ascii"), validate=True)
except (UnicodeEncodeError, ValueError, binascii.Error) as error:
raise BaselineCaptureError("截图 RPC Base64 语法无效,拒绝发布不完整取证。") from error
try:
with Image.open(BytesIO(raw_image)) as image:
image.load()
image.save(target, format="PNG")
except (UnidentifiedImageError, OSError) as error:
raise BaselineCaptureError("截图 RPC 图像数据无效,拒绝发布不完整取证。") from error
def _validate_hierarchy(value: Any) -> None:
"""确认 RPC 返回的是完整节点树,不把原始 XML 放进错误或日志。"""
if not isinstance(value, str) or not value:
raise BaselineCaptureError("节点树导出为空,拒绝发布不完整取证。")
try:
root = ElementTree.fromstring(value)
except ElementTree.ParseError as error:
raise BaselineCaptureError("节点树不是有效 XML,拒绝发布不完整取证。") from error
if root.tag != "hierarchy":
raise BaselineCaptureError("节点树根节点无效,拒绝发布不完整取证。")
def _manifest(
inspection: DeviceInspection,
serial: str,
pdd_version: str,
screenshot_path: Path,
hierarchy_path: Path,
) -> dict[str, Any]:
"""只序列化审计摘要;页面内容留在 XML 文件,不进入日志或 manifest。"""
return {
"schema_version": 1,
"captured_at": datetime.now(UTC).isoformat(),
"channel": "wifi" if ":" in serial else "usb",
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
"device": {
"model": inspection.model,
"android_version": inspection.android_version,
"pdd_package": PDD_PACKAGE,
"pdd_version": pdd_version,
},
"artifacts": [
{"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)},
{"path": hierarchy_path.name, "sha256": _sha256_file(hierarchy_path)},
],
}
def _sha256_file(path: Path) -> str:
digest = sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
@@ -0,0 +1,580 @@
"""T-103 原始规格面板证据的本机确定性隐私脱敏。
此模块只处理人工采集的本地文件:不连接设备、不识别规格;仅可按已取证的固定
几何和严格格式,将跨隐私边界的价格叶节点投影到派生 XML。
"""
from __future__ import annotations
from dataclasses import dataclass
from hashlib import sha256
import json
from pathlib import Path
import re
import shutil
from typing import Any
from uuid import uuid4
from xml.etree import ElementTree
from PIL import Image, ImageDraw, UnidentifiedImageError
from ..pdd.product_url import ProductUrl, ProductUrlError, parse_product_url
from ..pdd.sku_panel_state import HUMAN_DECLARED_STATES
SANITIZER_VERSION = "t103-privacy-v5"
EXPECTED_GOODS_ID = "937122477375"
EXPECTED_PDD_VERSION = "8.17.0"
EXPECTED_DEVICE_MODEL = "PKG110"
EXPECTED_ANDROID_VERSION = "16"
EXPECTED_SCREENSHOT_WIDTH = 1080
EXPECTED_SCREENSHOT_HEIGHT = 2376
EXPECTED_XML_WIDTH = 1080
EXPECTED_XML_HEIGHT = 2376
_ARTIFACT_FILES = ("screenshot.png", "hierarchy.xml")
_SHA256_RE = re.compile(r"[0-9a-f]{64}\Z")
_BOUNDS_RE = re.compile(r"\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]\Z")
_FULL_PHONE_RE = re.compile(r"(?:\+?86)?1[3-9]\d{9}")
_MASKED_PHONE_RE = re.compile(r"1[3-9]\d\*{4}\d{4}")
_MASK_TRANSLATION = str.maketrans({"*": "*", "•": "*", "·": "*", "×": "*", "x": "*", "X": "*"})
_SEPARATOR_RE = re.compile(r"[\s\-‐‑‒–—―()()]+")
# 这两个槽位来自 T-103 当前第一态、1080×2376 XML 坐标的人工审查。它们不是通用
# 页面判据;坐标、文本或结构任何变化都停止发布,交由人重新取证。
_CROSSING_PRICE_SLOTS = {
(396, 503, 712, 570): "[396,503][712,570]",
(730, 503, 895, 570): "[730,503][895,570]",
}
_CROSSING_PRICE_BOUNDS = frozenset(_CROSSING_PRICE_SLOTS)
_PRICE_PROJECTION_ATTRIBUTES = (
"bounds",
"text",
"package",
"class",
"clickable",
"enabled",
"visible-to-user",
)
# 仅接受普通 ASCII 空格,且每个可分隔位置最多一个;禁止换行、折扣、支付/提交文案和
# 任何其它字符。前缀捕获组用于区分当前价与至多一个划线/原价候选。
# T-103 人工在 live 规格面板确认当前价槽的完整非敏感前缀仅为“快卖完”;不得兼容
# 未取证的“快卖光”或其它相近文案。
_CROSSING_PRICE_TEXT_RE = re.compile(r" {0,1}(?:(快卖完) {0,1})?[¥¥] {0,1}[1-9]\d*\.\d{2} {0,1}\Z")
_CROSSING_PRICE_PREFIX_RE = re.compile(r" {0,1}(?:快卖完 {0,1})?[¥¥] {0,1}[1-9]\d*\.\d{2} {0,1}")
_CROSSING_PRICE_ALLOWED_CHARACTERS = frozenset(" 快卖完¥¥0123456789.")
class SkuEvidenceSanitizationError(RuntimeError):
"""原始证据不能被安全地发布为派生证据。"""
@dataclass
class _CleanupStats:
"""仅记录节点数量,供派生 manifest 审计;不记录任何页面文本。"""
removed_nodes: int = 0
cleared_crossing_nodes: int = 0
preserved_crossing_price_nodes: int = 0
retained_below_nodes: int = 0
max_right: int = 0
max_bottom: int = 0
current_price_candidates: int = 0
original_price_candidates: int = 0
@dataclass(frozen=True)
class PrivacyMaskConfig:
"""仅描述已人工确认的隐私几何区域,绝不承担页面或规格判据。"""
version: str
screenshot_width: int
screenshot_height: int
xml_width: int
xml_height: int
privacy_top: int
PRIVACY_MASK_CONFIG = PrivacyMaskConfig(
version=SANITIZER_VERSION,
screenshot_width=EXPECTED_SCREENSHOT_WIDTH,
screenshot_height=EXPECTED_SCREENSHOT_HEIGHT,
xml_width=EXPECTED_XML_WIDTH,
xml_height=EXPECTED_XML_HEIGHT,
# 主审在原始截图确认 y < 540 为收货/手机号区域;整宽遮罩优先保护隐私而非保留版面。
privacy_top=540,
)
@dataclass(frozen=True)
class SkuEvidenceSanitizationResult:
"""已经原子发布的派生证据位置。"""
output_directory: Path
manifest_path: Path
screenshot_path: Path
hierarchy_path: Path
def sanitize_sku_panel_evidence(raw_directory: Path, output_directory: Path) -> SkuEvidenceSanitizationResult:
"""校验 raw 三文件,并发布同级 ``derived`` 的脱敏副本。
目标已存在时在读取任何输入前拒绝,避免混入旧派生物或覆盖人工保留文件。
"""
raw = Path(raw_directory)
target = Path(output_directory)
_validate_directories(raw, target)
if target.exists():
raise SkuEvidenceSanitizationError("派生证据目录已存在,拒绝覆盖。")
staging: Path | None = None
try:
source_manifest_path = _required_file(raw, "manifest.json")
source_screenshot_path = _required_file(raw, "screenshot.png")
source_hierarchy_path = _required_file(raw, "hierarchy.xml")
manifest = _read_source_manifest(source_manifest_path)
link, state, source_hashes = _validate_source_manifest(manifest)
_verify_source_hashes(source_screenshot_path, source_hierarchy_path, source_hashes)
staging = raw.parent / f".derived.staging-{uuid4().hex}"
staging.mkdir()
derived_screenshot_path = staging / "screenshot.png"
_sanitize_screenshot(source_screenshot_path, derived_screenshot_path)
derived_hierarchy_path = staging / "hierarchy.xml"
cleanup_stats = _sanitize_hierarchy(source_hierarchy_path, derived_hierarchy_path)
derived_manifest_path = staging / "manifest.json"
derived_manifest_path.write_text(
json.dumps(
_derived_manifest(
manifest,
link,
state,
source_manifest_path,
source_screenshot_path,
source_hierarchy_path,
derived_screenshot_path,
derived_hierarchy_path,
cleanup_stats,
),
ensure_ascii=False,
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
_publish_staging(staging, target)
except SkuEvidenceSanitizationError:
_clean_staging(staging)
raise
except (OSError, ValueError, ElementTree.ParseError, UnidentifiedImageError) as error:
_clean_staging(staging)
# 原始异常可能含文件路径、JSON/XML 文本或其他敏感内容,不能向 CLI/日志传播。
raise SkuEvidenceSanitizationError("原始证据无法安全脱敏,未发布任何派生产物。") from error
except Exception as error:
_clean_staging(staging)
raise SkuEvidenceSanitizationError("原始证据脱敏未完成,未发布任何派生产物。") from error
return SkuEvidenceSanitizationResult(
output_directory=target,
manifest_path=target / "manifest.json",
screenshot_path=target / "screenshot.png",
hierarchy_path=target / "hierarchy.xml",
)
def _validate_directories(raw: Path, target: Path) -> None:
if raw.name != "raw" or not raw.is_dir():
raise SkuEvidenceSanitizationError("原始证据目录必须是存在的 raw 目录。")
if target.name != "derived" or target.parent != raw.parent:
raise SkuEvidenceSanitizationError("派生证据目录必须是 raw 同级的 derived 目录。")
def _required_file(raw: Path, filename: str) -> Path:
candidate = raw / filename
if not candidate.is_file():
raise SkuEvidenceSanitizationError("原始证据文件集合不完整。")
return candidate
def _read_source_manifest(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
raise SkuEvidenceSanitizationError("原始证据 manifest 无效。") from error
if not isinstance(value, dict):
raise SkuEvidenceSanitizationError("原始证据 manifest 结构无效。")
return value
def _validate_source_manifest(manifest: dict[str, Any]) -> tuple[ProductUrl, str, dict[str, str]]:
product = manifest.get("product")
device = manifest.get("device")
state = manifest.get("human_declared_state")
if manifest.get("schema_version") != 1 or not isinstance(product, dict) or not isinstance(device, dict):
raise SkuEvidenceSanitizationError("原始证据 manifest 缺少必要元数据。")
canonical_url = product.get("canonical_url")
goods_id = product.get("goods_id")
try:
link = parse_product_url(canonical_url)
except ProductUrlError as error:
raise SkuEvidenceSanitizationError("原始证据商品元数据不匹配。") from error
if (
link.goods_id != EXPECTED_GOODS_ID
or goods_id != EXPECTED_GOODS_ID
or device.get("model") != EXPECTED_DEVICE_MODEL
or device.get("android_version") != EXPECTED_ANDROID_VERSION
or device.get("pdd_version") != EXPECTED_PDD_VERSION
or device.get("pdd_package") != "com.xunmeng.pinduoduo"
or not isinstance(state, str)
or state not in HUMAN_DECLARED_STATES
):
raise SkuEvidenceSanitizationError("原始证据元数据与脱敏配置不匹配。")
return link, state, _artifact_hashes(manifest)
def _artifact_hashes(manifest: dict[str, Any]) -> dict[str, str]:
artifacts = manifest.get("artifacts")
if not isinstance(artifacts, list):
raise SkuEvidenceSanitizationError("原始证据 manifest 缺少文件校验信息。")
hashes: dict[str, str] = {}
for artifact in artifacts:
if not isinstance(artifact, dict):
raise SkuEvidenceSanitizationError("原始证据 manifest 文件校验信息无效。")
path = artifact.get("path")
digest = artifact.get("sha256")
if path not in _ARTIFACT_FILES or path in hashes or not isinstance(digest, str) or not _SHA256_RE.fullmatch(digest):
raise SkuEvidenceSanitizationError("原始证据 manifest 文件校验信息无效。")
hashes[path] = digest
if set(hashes) != set(_ARTIFACT_FILES):
raise SkuEvidenceSanitizationError("原始证据 manifest 文件校验信息不完整。")
return hashes
def _verify_source_hashes(screenshot_path: Path, hierarchy_path: Path, expected: dict[str, str]) -> None:
if (
_sha256_file(screenshot_path) != expected["screenshot.png"]
or _sha256_file(hierarchy_path) != expected["hierarchy.xml"]
):
raise SkuEvidenceSanitizationError("原始证据文件校验失败。")
def _sanitize_screenshot(source: Path, target: Path) -> None:
try:
with Image.open(source) as image:
image.load()
if image.format != "PNG" or image.size != (
PRIVACY_MASK_CONFIG.screenshot_width,
PRIVACY_MASK_CONFIG.screenshot_height,
):
raise SkuEvidenceSanitizationError("原始截图分辨率或格式与脱敏配置不匹配。")
sanitized = image.convert("RGBA")
except SkuEvidenceSanitizationError:
raise
except (OSError, UnidentifiedImageError) as error:
raise SkuEvidenceSanitizationError("原始截图无效。") from error
# 用不透明黑色覆盖 y < 540,保证截图与 XML 使用相同的隐私几何边界。
ImageDraw.Draw(sanitized).rectangle(
(0, 0, PRIVACY_MASK_CONFIG.screenshot_width - 1, PRIVACY_MASK_CONFIG.privacy_top - 1),
fill=(0, 0, 0, 255),
)
sanitized.save(target, format="PNG", optimize=False, compress_level=9)
def _sanitize_hierarchy(source: Path, target: Path) -> _CleanupStats:
try:
root = ElementTree.parse(source).getroot()
except (OSError, ElementTree.ParseError) as error:
raise SkuEvidenceSanitizationError("原始节点树无效。") from error
if root.tag != "hierarchy":
raise SkuEvidenceSanitizationError("原始节点树结构不匹配。")
if not list(root):
raise SkuEvidenceSanitizationError("原始节点树结构不匹配。")
stats = _CleanupStats()
_clear_node_text(root)
for child in list(root):
_sanitize_node(root, child, stats)
_require_expected_xml_coordinate_space(stats)
if stats.removed_nodes < 1 or stats.retained_below_nodes < 1:
raise SkuEvidenceSanitizationError("原始节点树未满足隐私几何结构。")
_require_safe_crossing_price_projection(stats)
if _contains_phone(root):
raise SkuEvidenceSanitizationError("派生节点树仍包含手机号,拒绝发布。")
ElementTree.ElementTree(root).write(target, encoding="utf-8", xml_declaration=True)
return stats
def _sanitize_node(parent: ElementTree.Element, node: ElementTree.Element, stats: _CleanupStats) -> None:
if node.tag != "node":
raise SkuEvidenceSanitizationError("原始节点树结构不匹配。")
bounds = _parse_bounds(node.get("bounds"))
_observe_bounds(stats, bounds)
position = _vertical_position(bounds)
if position == "private":
# 私有带内的父节点不可以悄然包含下方子节点,否则会把仍需审计的下方内容一起丢失。
for descendant in node.iter("node"):
descendant_bounds = _parse_bounds(descendant.get("bounds"))
_observe_bounds(stats, descendant_bounds)
if _vertical_position(descendant_bounds) != "private":
raise SkuEvidenceSanitizationError("原始节点树 bounds 结构不匹配。")
stats.removed_nodes += sum(1 for _ in node.iter("node"))
parent.remove(node)
return
if position == "crossing":
if bounds in _CROSSING_PRICE_BOUNDS and node.get("text"):
_project_crossing_price_node(node, bounds, stats)
else:
# 全屏/跨界容器可保留其下方子节点,但自身所有属性和文本都可能含地址或手机号。
_clear_node_text(node)
stats.cleared_crossing_nodes += 1
else:
stats.retained_below_nodes += 1
for child in list(node):
_sanitize_node(node, child, stats)
def _parse_bounds(value: object) -> tuple[int, int, int, int]:
if not isinstance(value, str):
raise SkuEvidenceSanitizationError("原始节点树 bounds 缺失或无效。")
match = _BOUNDS_RE.fullmatch(value)
if match is None:
raise SkuEvidenceSanitizationError("原始节点树 bounds 缺失或无效。")
left, top, right, bottom = (int(group) for group in match.groups())
if not (0 <= left < right and 0 <= top < bottom):
raise SkuEvidenceSanitizationError("原始节点树 bounds 缺失或无效。")
return left, top, right, bottom
def _observe_bounds(stats: _CleanupStats, bounds: tuple[int, int, int, int]) -> None:
_, _, right, bottom = bounds
stats.max_right = max(stats.max_right, right)
stats.max_bottom = max(stats.max_bottom, bottom)
def _require_expected_xml_coordinate_space(stats: _CleanupStats) -> None:
if (
stats.max_right != PRIVACY_MASK_CONFIG.xml_width
or stats.max_bottom != PRIVACY_MASK_CONFIG.xml_height
):
raise SkuEvidenceSanitizationError(
f"原始节点树坐标范围不匹配(observed {stats.max_right}x{stats.max_bottom})。"
)
def _vertical_position(bounds: tuple[int, int, int, int]) -> str:
_, top, _, bottom = bounds
if bottom <= PRIVACY_MASK_CONFIG.privacy_top:
return "private"
if top >= PRIVACY_MASK_CONFIG.privacy_top:
return "below"
return "crossing"
def _project_crossing_price_node(
node: ElementTree.Element,
bounds: tuple[int, int, int, int],
stats: _CleanupStats,
) -> None:
"""投影唯一允许的跨界价格叶节点;任何结构漂移一律拒绝发布。"""
if (
len(node) != 0
or node.get("package") != "com.xunmeng.pinduoduo"
or node.get("class") != "android.widget.TextView"
or node.get("clickable") != "false"
or node.get("enabled") != "true"
or node.get("visible-to-user") != "true"
):
raise SkuEvidenceSanitizationError("跨界价格节点结构不匹配,拒绝发布。")
text = node.get("text")
if text is None:
raise SkuEvidenceSanitizationError("跨界价格节点文本不匹配,拒绝发布。")
match = _CROSSING_PRICE_TEXT_RE.fullmatch(text)
if match is None:
raise _crossing_price_text_mismatch_error(bounds, text)
# 只有这七项经上述检查后可进入派生 XML;尤其不复制 content-desc、resource-id 等原始属性。
node.attrib = {attribute: node.attrib[attribute] for attribute in _PRICE_PROJECTION_ATTRIBUTES}
node.text = None
node.tail = None
stats.preserved_crossing_price_nodes += 1
if match.group(1) is not None:
stats.current_price_candidates += 1
else:
stats.original_price_candidates += 1
def _crossing_price_text_mismatch_error(
bounds: tuple[int, int, int, int],
text: str,
) -> SkuEvidenceSanitizationError:
"""仅输出固定槽位与 reason,避免将任意 raw 正文带入 CLI 或日志。"""
reason = _crossing_price_text_mismatch_reason(text)
slot = _CROSSING_PRICE_SLOTS[bounds]
return SkuEvidenceSanitizationError(f"跨界价格节点文本不匹配:slot={slot};reason={reason}。")
def _crossing_price_text_mismatch_reason(text: str) -> str:
"""将未匹配文本归类为受控枚举;返回值绝不包含原始片段。"""
if "\r" in text or "\n" in text:
return "newline"
if any(character.isspace() and character != " " for character in text):
return "non_ascii_whitespace"
if any(marker in text for marker in ("提交订单", "支付", "下单", "优惠")):
return "extra_or_order"
without_one_leading_space = text[1:] if text.startswith(" ") else text
if without_one_leading_space.startswith("快要抢光"):
return "observed_prefix_kuaiyaoqiangguang"
if without_one_leading_space.startswith("快") and not without_one_leading_space.startswith("快卖完"):
return "known_prefix_missing"
if "¥" not in text and "¥" not in text:
return "currency_missing"
if _CROSSING_PRICE_PREFIX_RE.match(text) is not None:
return "extra_or_order"
if any(character not in _CROSSING_PRICE_ALLOWED_CHARACTERS for character in text):
return "forbidden_characters"
return "amount_shape"
def _require_safe_crossing_price_projection(stats: _CleanupStats) -> None:
"""当前价必须唯一;原价仅可选且唯一,避免把任意金额释放为价格证据。"""
if (
stats.current_price_candidates != 1
or stats.original_price_candidates > 1
or stats.preserved_crossing_price_nodes != stats.current_price_candidates + stats.original_price_candidates
):
raise SkuEvidenceSanitizationError("跨界价格候选不唯一或缺失,拒绝发布。")
def _clear_node_text(node: ElementTree.Element) -> None:
node.attrib = {"bounds": node.attrib["bounds"]} if "bounds" in node.attrib else {}
node.text = None
node.tail = None
def _contains_phone(root: ElementTree.Element) -> bool:
"""逐项与跨节点复检电话,避免分隔符、遮罩字符或节点切分绕过。"""
all_values: list[str] = []
content_values: list[str] = []
for element in root.iter():
if element.text:
all_values.append(element.text)
content_values.append(element.text)
for attribute, value in element.attrib.items():
all_values.append(value)
if attribute != "bounds":
content_values.append(value)
if element.tail:
all_values.append(element.tail)
content_values.append(element.tail)
normalized_values = [_normalize_phone_value(value) for value in all_values]
normalized_all_document = "".join(normalized_values)
normalized_document = "".join(_normalize_phone_value(value) for value in content_values)
return (
any(_matches_phone(value) for value in normalized_values)
or _matches_phone(normalized_all_document)
or _matches_phone(normalized_document)
)
def _normalize_phone_value(value: str) -> str:
return _SEPARATOR_RE.sub("", value.translate(_MASK_TRANSLATION))
def _matches_phone(value: str) -> bool:
return _FULL_PHONE_RE.search(value) is not None or _MASKED_PHONE_RE.search(value) is not None
def _derived_manifest(
source_manifest: dict[str, Any],
link: ProductUrl,
state: str,
source_manifest_path: Path,
source_screenshot_path: Path,
source_hierarchy_path: Path,
derived_screenshot_path: Path,
derived_hierarchy_path: Path,
cleanup_stats: _CleanupStats,
) -> dict[str, Any]:
device = source_manifest["device"]
return {
"schema_version": 1,
"privacy_tier": "SANITIZED",
"sanitizer_version": PRIVACY_MASK_CONFIG.version,
"screenshot_space": {
"width": PRIVACY_MASK_CONFIG.screenshot_width,
"height": PRIVACY_MASK_CONFIG.screenshot_height,
"privacy_mask_rectangle": [
0,
0,
PRIVACY_MASK_CONFIG.screenshot_width,
PRIVACY_MASK_CONFIG.privacy_top,
],
},
"xml_coordinate_space": {
"width": PRIVACY_MASK_CONFIG.xml_width,
"height": PRIVACY_MASK_CONFIG.xml_height,
"privacy_mask_rectangle": [0, 0, PRIVACY_MASK_CONFIG.xml_width, PRIVACY_MASK_CONFIG.privacy_top],
"observed_max": {"right": cleanup_stats.max_right, "bottom": cleanup_stats.max_bottom},
},
"privacy_cleanup": {
"removed_nodes": cleanup_stats.removed_nodes,
"cleared_crossing_nodes": cleanup_stats.cleared_crossing_nodes,
"preserved_crossing_price_nodes": cleanup_stats.preserved_crossing_price_nodes,
"retained_below_nodes": cleanup_stats.retained_below_nodes,
"max_right": cleanup_stats.max_right,
"max_bottom": cleanup_stats.max_bottom,
},
"product": {"goods_id": link.goods_id},
"human_declared_state": state,
"device": {
"model": device["model"],
"android_version": device.get("android_version"),
"pdd_package": device["pdd_package"],
"pdd_version": device["pdd_version"],
},
# source hashes stay only in the local derived manifest; no raw path, serial or body is retained.
"source": {
"manifest_sha256": _sha256_file(source_manifest_path),
"artifacts": [
{"path": "screenshot.png", "sha256": _sha256_file(source_screenshot_path)},
{"path": "hierarchy.xml", "sha256": _sha256_file(source_hierarchy_path)},
],
},
"derived": {
"artifacts": [
{"path": "screenshot.png", "sha256": _sha256_file(derived_screenshot_path)},
{"path": "hierarchy.xml", "sha256": _sha256_file(derived_hierarchy_path)},
]
},
}
def _sha256_file(path: Path) -> str:
digest = sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _clean_staging(staging: Path | None) -> None:
if staging is not None and staging.exists():
shutil.rmtree(staging)
def _publish_staging(staging: Path, target: Path) -> None:
"""发布前二次检查,并使用目录 rename 而不是会覆盖目标的 replace。"""
if target.exists():
raise SkuEvidenceSanitizationError("派生证据目录已存在,拒绝覆盖。")
try:
staging.rename(target)
except OSError as error:
# 竞态中新目标出现或文件系统拒绝 rename 时一律不尝试覆盖或重试。
raise SkuEvidenceSanitizationError("派生证据目录发布失败,未覆盖已有目录。") from error
@@ -0,0 +1,66 @@
"""采购工具日志的最小脱敏策略。"""
from __future__ import annotations
import logging
from pathlib import Path
import re
from .runtime import RuntimePaths
LOGGER_NAME = "cmbuyer_client"
REDACTED = "[已隐藏]"
_SENSITIVE_KEY_PATTERN = (
r"token|authorization|password|secret|api[_-]?key|"
r"address|phone|mobile|payment|pay|card|bank[_-]?account|"
r"令牌|授权|密码|密钥|地址|手机号|电话|支付|银行卡"
)
_KEY_VALUE_PATTERN = re.compile(
rf"(?P<key>{_SENSITIVE_KEY_PATTERN})\s*(?P<separator>[:=])\s*"
r"(?P<value>\"[^\"]*\"|'[^']*'|[^\s,;]+)",
flags=re.IGNORECASE,
)
_PHONE_PATTERN = re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")
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)
return _PHONE_PATTERN.sub(REDACTED, redacted)
class SensitiveDataFilter(logging.Filter):
"""在任何 handler 格式化记录前,清除敏感字段。
该过滤器在日志写入前替换 ``msg`` 与 ``args``,确保文件 handler 不会得到原始值。
"""
def filter(self, record: logging.LogRecord) -> bool:
record.msg = redact_text(record.getMessage())
record.args = ()
return True
def configure_application_logger(paths: RuntimePaths) -> logging.Logger:
"""配置唯一的 UTF-8 文件日志,并确保其先经过脱敏过滤。"""
paths.ensure_exists()
logger = logging.getLogger(LOGGER_NAME)
logger.setLevel(logging.INFO)
logger.propagate = False
for handler in tuple(logger.handlers):
logger.removeHandler(handler)
handler.close()
handler = logging.FileHandler(Path(paths.logs) / "client.log", encoding="utf-8")
handler.addFilter(SensitiveDataFilter())
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
logger.addHandler(handler)
return logger
+23
View File
@@ -0,0 +1,23 @@
"""拼多多链接的受限打开、只读取证与经取证的规格面板选择。
此包不提供通用页面选择器、输入、滑动或任何订单动作。
"""
from .product_open import ProductOpenCapturer, ProductOpenResult
from .product_url import ProductUrl, ProductUrlError, parse_product_url
from .sku_selection import SkuSelection, SkuSelectionError, SkuSelectionFlow
from .sku_selection_runner import SkuSelectionRunError, SkuSelectionRunResult, SkuSelectionRunner
__all__ = [
"ProductOpenCapturer",
"ProductOpenResult",
"ProductUrl",
"ProductUrlError",
"SkuSelection",
"SkuSelectionError",
"SkuSelectionFlow",
"SkuSelectionRunError",
"SkuSelectionRunResult",
"SkuSelectionRunner",
"parse_product_url",
]
@@ -0,0 +1,262 @@
"""安全打开 canonical 商品链接后的只读取证。"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from hashlib import sha256
import json
from math import isfinite
import os
from pathlib import Path
import shutil
from time import monotonic, sleep
from typing import Any, Protocol
from uuid import uuid4
from adbutils.errors import AdbTimeout
from uiautomator2.exceptions import HTTPTimeoutError
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection, IntentLaunchSummary
from ..device.baseline import (
HIERARCHY_PARAMS,
PDD_PACKAGE,
SCREENSHOT_PARAMS,
_save_base64_screenshot,
_sha256_file,
_validate_hierarchy,
)
from .product_url import ProductUrl, parse_product_url
EXPECTED_PDD_VERSION = "8.17.0"
class ProductOpenError(RuntimeError):
"""商品打开或证据发布未完整完成。"""
class ProductVersionMismatchError(ProductOpenError):
"""运行时拼多多版本不是经取证允许的版本。"""
class ProductPackageMismatchError(ProductOpenError):
"""Intent 后在有限时间内未观察到拼多多前台包。"""
class ProductOpenTimeoutError(ProductOpenError):
"""商品打开后的只读取证超时。"""
class ProductScreenshotCaptureError(ProductOpenError):
"""Intent 后截图不能作为完整 PNG 证据保存。"""
class ProductHierarchyCaptureError(ProductOpenError):
"""Intent 后完整节点树不能作为有效 XML 证据保存。"""
class ProductOpenUiDevice(Protocol):
"""本任务所需的只读 uiautomator2 接口;故意没有任何 UI 操作方法。"""
def app_info(self, package_name: str) -> dict[str, Any]:
"""读取应用元数据。"""
def app_current(self) -> dict[str, Any]:
"""读取当前前台应用元数据。"""
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any:
"""调用只读取证所需的公开 JSON-RPC 方法。"""
@dataclass(frozen=True)
class ProductOpenResult:
"""已原子发布的商品打开证据位置。"""
output_directory: Path
manifest_path: Path
screenshot_path: Path
hierarchy_path: Path
class ProductOpenCapturer:
"""以 fail-closed 顺序打开已重建链接,并在打开后只读留证。
本类不判断商品页、Activity、文案或控件;打开后只确认当前 package,随后采集截图与
完整节点树。任何失败都不会发布半成品证据目录。
"""
def __init__(
self,
adb_client: AdbClient,
connector: Callable[[str], ProductOpenUiDevice],
timeout_seconds: float,
foreground_poll_interval_seconds: float = 0.2,
monotonic_clock: Callable[[], float] = monotonic,
sleep_function: Callable[[float], None] = sleep,
) -> None:
if not _is_positive_finite(timeout_seconds):
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
if not _is_positive_finite(foreground_poll_interval_seconds):
raise ValueError("foreground_poll_interval_seconds 必须是大于 0 的有限数值")
self._adb_client = adb_client
self._connector = connector
self._timeout_seconds = timeout_seconds
self._foreground_poll_interval_seconds = foreground_poll_interval_seconds
self._monotonic_clock = monotonic_clock
self._sleep_function = sleep_function
def open_and_capture(self, serial: str, product_url: str, output_directory: Path) -> ProductOpenResult:
"""完成唯一允许的 Intent 打开及其后的只读取证。"""
# 公共入口只接收原始字符串并每次重新解析,不能由调用方构造不一致的值对象伪造 manifest。
link = parse_product_url(product_url)
target = Path(output_directory)
_validate_new_target(target)
staging: Path | None = None
try:
# inspect 必须先于连接和 Intent,复用 T-101 的显式 serial、重复物理设备拒绝逻辑。
inspection = self._adb_client.inspect(serial)
device = self._connector(serial)
pdd_version = _require_expected_version(device.app_info(PDD_PACKAGE))
# 版本精确匹配是 Intent 的前置条件,失败时绝不调用 start_pdd_view_intent。
intent = self._adb_client.start_pdd_view_intent(serial, link.goods_id)
self._wait_for_pdd_foreground(device)
target.parent.mkdir(parents=True, exist_ok=True)
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
staging.mkdir()
screenshot_path = staging / "screenshot.png"
try:
_save_base64_screenshot(
device.jsonrpc_call("takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds),
screenshot_path,
)
except (AdbTimeout, HTTPTimeoutError, TimeoutError):
raise
except Exception as error:
raise ProductScreenshotCaptureError("商品打开后截图取证失败,未发布任何证据产物。") from error
try:
hierarchy = device.jsonrpc_call(
"dumpWindowHierarchy",
HIERARCHY_PARAMS,
timeout=self._timeout_seconds,
)
_validate_hierarchy(hierarchy)
except (AdbTimeout, HTTPTimeoutError, TimeoutError):
raise
except Exception as error:
raise ProductHierarchyCaptureError("商品打开后节点树取证失败,未发布任何证据产物。") from error
hierarchy_path = staging / "hierarchy.xml"
hierarchy_path.write_text(hierarchy, encoding="utf-8")
manifest_path = staging / "manifest.json"
manifest_path.write_text(
json.dumps(
_manifest(inspection, serial, link, pdd_version, intent, screenshot_path, hierarchy_path),
ensure_ascii=False,
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
os.replace(staging, target)
except (ProductOpenError, DeviceConnectionError):
_clean_staging(staging)
raise
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
_clean_staging(staging)
raise ProductOpenTimeoutError("商品打开后的只读取证超时,未发布任何证据产物。") from error
except Exception as error:
_clean_staging(staging)
# 底层异常可能含 serial、路径或远端页面内容,不能直接向 CLI 或日志传播。
raise ProductOpenError("商品打开或只读取证未完成,未发布任何证据产物。") from error
return ProductOpenResult(
output_directory=target,
manifest_path=target / "manifest.json",
screenshot_path=target / "screenshot.png",
hierarchy_path=target / "hierarchy.xml",
)
def _wait_for_pdd_foreground(self, device: ProductOpenUiDevice) -> None:
"""只轮询当前 package,直到 deadline;Activity 和节点树均不参与本判据。"""
deadline = self._monotonic_clock() + self._timeout_seconds
while True:
if _is_pdd_foreground(device.app_current()):
return
remaining = deadline - self._monotonic_clock()
if remaining <= 0:
raise ProductPackageMismatchError(
"商品链接打开后未在限定时间内进入拼多多,已停止后续取证。"
)
# 每个失败观察后都等待正的、受 deadline 约束的时长,避免 busy-loop。
self._sleep_function(min(self._foreground_poll_interval_seconds, remaining))
def _validate_new_target(target: Path) -> None:
if target.exists():
raise ProductOpenError("输出目录已存在;为防止混入旧证据,拒绝覆盖。")
if not target.name:
raise ProductOpenError("输出目录必须是明确的新目录。")
def _clean_staging(staging: Path | None) -> None:
if staging is not None and staging.exists():
# staging 仅在本次调用中创建,删除前不解析或扩展任何调用方提供的路径。
shutil.rmtree(staging)
def _require_expected_version(app_info: dict[str, Any]) -> str:
if not isinstance(app_info, dict):
raise ProductVersionMismatchError("拼多多版本与已取证版本不一致,已停止打开商品链接。")
version = app_info.get("versionName") or app_info.get("version_name")
if not isinstance(version, str) or version != EXPECTED_PDD_VERSION:
raise ProductVersionMismatchError("拼多多版本与已取证版本不一致,已停止打开商品链接。")
return version
def _is_positive_finite(value: object) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
def _is_pdd_foreground(current: object) -> bool:
return isinstance(current, dict) and current.get("package") == PDD_PACKAGE
def _manifest(
inspection: DeviceInspection,
serial: str,
link: ProductUrl,
pdd_version: str,
intent: IntentLaunchSummary,
screenshot_path: Path,
hierarchy_path: Path,
) -> dict[str, Any]:
"""只写审计摘要;原始 serial、Activity、ADB 输出和页面正文均不进入 manifest。"""
return {
"schema_version": 1,
"captured_at": datetime.now(UTC).isoformat(),
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
"channel": "wifi" if ":" in serial else "usb",
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
"device": {
"model": inspection.model,
"android_version": inspection.android_version,
"pdd_package": PDD_PACKAGE,
"pdd_version": pdd_version,
},
"intent": {"status": intent.status, "returncode": intent.returncode},
"current_package": PDD_PACKAGE,
"artifacts": [
{"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)},
{"path": hierarchy_path.name, "sha256": _sha256_file(hierarchy_path)},
],
}
@@ -0,0 +1,61 @@
"""唯一允许交给 Android Intent 的商品链接。"""
from __future__ import annotations
from dataclasses import dataclass
from urllib.parse import parse_qsl, urlsplit
_SCHEME = "https"
_HOST = "mobile.yangkeduo.com"
_PATH = "/goods.html"
class ProductUrlError(ValueError):
"""输入不是可安全重建的 canonical 商品链接。"""
@dataclass(frozen=True)
class ProductUrl:
"""经验证的商品标识及由它重建的 canonical URL。"""
goods_id: str
canonical_url: str
def parse_product_url(value: str) -> ProductUrl:
"""只接受一个 ASCII 数字 ``goods_id`` 的拼多多商品直链。
解析结果绝不原样透传:Intent 使用的 URL 必须从 ``goods_id`` 重新构建,以排除
短链、额外参数、userinfo、fragment 和 URL 解析器的边缘表示。
"""
if not isinstance(value, str):
raise ProductUrlError("商品链接必须是字符串。")
try:
parsed = urlsplit(value)
port = parsed.port
query_pairs = parse_qsl(parsed.query, keep_blank_values=True, strict_parsing=True)
except ValueError as error:
raise ProductUrlError("商品链接格式无效。") from error
if (
parsed.scheme != _SCHEME
or parsed.hostname != _HOST
or parsed.username is not None
or parsed.password is not None
or port is not None
or parsed.path != _PATH
or parsed.fragment
):
raise ProductUrlError("商品链接不是允许的拼多多商品直链。")
if len(query_pairs) != 1 or query_pairs[0][0] != "goods_id":
raise ProductUrlError("商品链接必须且只能包含一个 goods_id 参数。")
goods_id = query_pairs[0][1]
if not goods_id or any(character < "0" or character > "9" for character in goods_id):
raise ProductUrlError("goods_id 必须是纯数字。")
canonical_url = f"{_SCHEME}://{_HOST}{_PATH}?goods_id={goods_id}"
if value != canonical_url:
raise ProductUrlError("商品链接必须使用唯一 canonical 表示。")
return ProductUrl(goods_id=goods_id, canonical_url=canonical_url)
@@ -0,0 +1,252 @@
"""人工停留在规格面板后的只读取证。
本模块不识别规格面板,不打开商品链接,也不读取价格;三种面板状态完全由现场人员声明。
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime
from hashlib import sha256
import json
from math import isfinite
import os
from pathlib import Path
import shutil
from typing import Any, Protocol
from uuid import uuid4
from adbutils.errors import AdbTimeout
from uiautomator2.exceptions import HTTPTimeoutError
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
from ..device.baseline import (
HIERARCHY_PARAMS,
PDD_PACKAGE,
SCREENSHOT_PARAMS,
_save_base64_screenshot,
_sha256_file,
_validate_hierarchy,
)
from .product_open import EXPECTED_PDD_VERSION
from .product_url import ProductUrl, parse_product_url
from .sku_panel_state import HUMAN_DECLARED_STATES
class SkuPanelEvidenceError(RuntimeError):
"""人工规格面板证据无法完整发布。"""
class SkuPanelDeclaredStateError(SkuPanelEvidenceError):
"""调用方没有提供允许的人工声明状态。"""
class SkuPanelVersionMismatchError(SkuPanelEvidenceError):
"""运行时拼多多版本不是已取证版本。"""
class SkuPanelPackageMismatchError(SkuPanelEvidenceError):
"""人工声明前台不是拼多多时仍试图留证。"""
class SkuPanelEvidenceTimeoutError(SkuPanelEvidenceError):
"""只读截图或节点树取证超时。"""
class SkuPanelScreenshotError(SkuPanelEvidenceError):
"""截图不能保存为严格有效的 PNG。"""
class SkuPanelHierarchyError(SkuPanelEvidenceError):
"""节点树不能保存为严格有效的 XML。"""
class SkuPanelUiDevice(Protocol):
"""人工面板证据所需的只读接口,故意没有任何页面操作方法。"""
def app_info(self, package_name: str) -> dict[str, Any]:
"""读取应用元数据。"""
def app_current(self) -> dict[str, Any]:
"""读取当前前台应用元数据。"""
def jsonrpc_call(self, method: str, params: Any = None, timeout: float = 10) -> Any:
"""调用公开 JSON-RPC 的只读取证方法。"""
@dataclass(frozen=True)
class SkuPanelEvidenceResult:
"""已原子发布的本地证据目录。"""
output_directory: Path
manifest_path: Path
screenshot_path: Path
hierarchy_path: Path
class SkuPanelEvidenceCapturer:
"""把人工已停留的面板状态留证,不对页面作任何自动结论。
状态字段命名为 ``human_declared_state``,防止消费者把本模块误解为自动面板/规格/价格识别。
"""
def __init__(
self,
adb_client: AdbClient,
connector: Callable[[str], SkuPanelUiDevice],
timeout_seconds: float,
) -> None:
if not _is_positive_finite(timeout_seconds):
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
self._adb_client = adb_client
self._connector = connector
self._timeout_seconds = timeout_seconds
def capture(
self,
serial: str,
product_url: str,
human_declared_state: str,
output_directory: Path,
) -> SkuPanelEvidenceResult:
"""采集人工已准备的状态;不会打开链接、面板或执行任何 UI 操作。"""
link = parse_product_url(product_url)
state = _validate_human_declared_state(human_declared_state)
target = Path(output_directory)
_validate_new_target(target)
staging: Path | None = None
try:
# 沿用 T-101 的显式 serial、在线状态与重复物理设备 fail-closed 核验。
inspection = self._adb_client.inspect(serial)
device = self._connector(serial)
pdd_version = _require_expected_version(device.app_info(PDD_PACKAGE))
_require_pdd_foreground(device.app_current())
target.parent.mkdir(parents=True, exist_ok=True)
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
staging.mkdir()
screenshot_path = staging / "screenshot.png"
try:
_save_base64_screenshot(
device.jsonrpc_call("takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds),
screenshot_path,
)
except (AdbTimeout, HTTPTimeoutError, TimeoutError):
raise
except Exception as error:
raise SkuPanelScreenshotError("规格面板截图取证失败,未发布任何证据产物。") from error
try:
hierarchy = device.jsonrpc_call(
"dumpWindowHierarchy",
HIERARCHY_PARAMS,
timeout=self._timeout_seconds,
)
_validate_hierarchy(hierarchy)
except (AdbTimeout, HTTPTimeoutError, TimeoutError):
raise
except Exception as error:
raise SkuPanelHierarchyError("规格面板节点树取证失败,未发布任何证据产物。") from error
hierarchy_path = staging / "hierarchy.xml"
hierarchy_path.write_text(hierarchy, encoding="utf-8")
manifest_path = staging / "manifest.json"
manifest_path.write_text(
json.dumps(
_manifest(inspection, serial, link, state, pdd_version, screenshot_path, hierarchy_path),
ensure_ascii=False,
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
os.replace(staging, target)
except (SkuPanelEvidenceError, DeviceConnectionError):
_clean_staging(staging)
raise
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
_clean_staging(staging)
raise SkuPanelEvidenceTimeoutError("规格面板只读取证超时,未发布任何证据产物。") from error
except Exception as error:
_clean_staging(staging)
# 第三方异常可能含 serial、Activity 或页面正文,不能直接向 CLI/日志传播。
raise SkuPanelEvidenceError("规格面板只读取证未完成,未发布任何证据产物。") from error
return SkuPanelEvidenceResult(
output_directory=target,
manifest_path=target / "manifest.json",
screenshot_path=target / "screenshot.png",
hierarchy_path=target / "hierarchy.xml",
)
def _is_positive_finite(value: object) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
def _validate_human_declared_state(value: object) -> str:
if not isinstance(value, str) or value not in HUMAN_DECLARED_STATES:
raise SkuPanelDeclaredStateError("必须提供允许的人工声明规格面板状态。")
return value
def _validate_new_target(target: Path) -> None:
if target.exists():
raise SkuPanelEvidenceError("输出目录已存在;为防止混入旧证据,拒绝覆盖。")
if not target.name:
raise SkuPanelEvidenceError("输出目录必须是明确的新目录。")
def _clean_staging(staging: Path | None) -> None:
if staging is not None and staging.exists():
# staging 仅在本次调用中创建,绝不删除调用方已存在的目录。
shutil.rmtree(staging)
def _require_expected_version(app_info: object) -> str:
if not isinstance(app_info, dict):
raise SkuPanelVersionMismatchError("拼多多版本与已取证版本不一致,已停止取证。")
version = app_info.get("versionName") or app_info.get("version_name")
if not isinstance(version, str) or version != EXPECTED_PDD_VERSION:
raise SkuPanelVersionMismatchError("拼多多版本与已取证版本不一致,已停止取证。")
return version
def _require_pdd_foreground(current: object) -> None:
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
raise SkuPanelPackageMismatchError("当前前台应用不是拼多多,已停止取证。")
def _manifest(
inspection: DeviceInspection,
serial: str,
link: ProductUrl,
human_declared_state: str,
pdd_version: str,
screenshot_path: Path,
hierarchy_path: Path,
) -> dict[str, Any]:
"""仅记录人工声明与非敏感审计摘要,不写入 Activity 或页面内容。"""
return {
"schema_version": 1,
"captured_at": datetime.now(UTC).isoformat(),
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
"human_declared_state": human_declared_state,
"channel": "wifi" if ":" in serial else "usb",
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
"device": {
"model": inspection.model,
"android_version": inspection.android_version,
"pdd_package": PDD_PACKAGE,
"pdd_version": pdd_version,
},
"artifacts": [
{"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)},
{"path": hierarchy_path.name, "sha256": _sha256_file(hierarchy_path)},
],
}
@@ -0,0 +1,13 @@
"""T-103 的人工声明证据状态;不含任何页面识别或规格语义。"""
from __future__ import annotations
# 这些值由现场人员填写到 manifest,不能被解释为自动检测出的页面或选择状态。
HUMAN_DECLARED_STATES = frozenset(
{
"panel-opened-target-preselected",
"alternate-all-dimensions-selected",
"target-selection-restored",
}
)
@@ -0,0 +1,420 @@
"""T-103:仅限已取证 PDD 8.17.0 的规格面板恢复。"""
from __future__ import annotations
from dataclasses import dataclass
import re
from time import monotonic, sleep
from typing import Any, Callable, Protocol
from xml.etree import ElementTree
from ..device.baseline import PDD_PACKAGE
from .product_open import EXPECTED_PDD_VERSION
from .product_url import parse_product_url
EXPECTED_GOODS_ID = "937122477375"
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]"
_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]"
_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}$")
_BAD_PRICE_ROLE = ("提交订单", "支付", "优惠", "券", "会员", "补贴", "区间", "实付", "到手", "原价", "划线价", "最低", "低至", "起价", "下单", "先用后付", "预估")
class SkuSelectionError(RuntimeError):
"""已取证判据不成立时的脱敏停止。"""
class SkuPanelDevice(Protocol):
def app_info(self, package_name: str) -> dict[str, Any]: ...
def app_current(self) -> dict[str, Any]: ...
def dump_window_hierarchy(self) -> str: ...
def tap_sku_entry(self, bounds: str) -> None: ...
def tap_sku_option(self, bounds: str) -> None: ...
def leave_sku_panel(self) -> None: ...
@dataclass(frozen=True)
class SkuSelection:
color: str
size: str
def resolve_task_selection(color: str, size: str) -> SkuSelection:
mapped = TASK_TO_UI_SELECTION.get((color, size))
if mapped is None:
raise SkuSelectionError("规格任务值不是已取证的唯一目标,已停止操作。")
return SkuSelection(*mapped)
@dataclass(frozen=True)
class _Node:
element: ElementTree.Element
parent: "_Node | None"
@property
def text(self) -> str: return self.element.get("text", "")
@property
def desc(self) -> str: return self.element.get("content-desc", "")
@property
def bounds(self) -> str: return self.element.get("bounds", "")
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,
sleep_function: Callable[[float], None] = sleep) -> None:
if entry_wait_timeout_seconds < 0 or entry_poll_interval_seconds <= 0:
raise ValueError("入口等待参数无效。")
self._device, self._entry_timeout, self._poll = device, entry_wait_timeout_seconds, entry_poll_interval_seconds
self._clock, self._sleep = monotonic_clock, sleep_function
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:
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):
raise SkuSelectionError("intent 前页面已出现规格入口,已拒绝旧商品误点。")
entry, before = self._wait_for_entry(pre_intent_hierarchy)
_action_bounds(entry.bounds)
self._pending = (before, _panel)
self._device.tap_sku_entry(entry.bounds)
self._wait_after_action(before, _panel)
def select_sku_options(self, selection: SkuSelection) -> None:
if selection not in {SkuSelection(*item) for item in TASK_TO_UI_SELECTION.values()}:
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)
def read_sku_unit_price(self) -> str:
return _unit_price(self._verified_nodes())
def verify_target_selection_and_read_price(self, selection: SkuSelection) -> str:
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))
self._device.leave_sku_panel()
deadline = self._clock() + self._entry_timeout
while True:
self._require_foreground()
raw = self._read_hierarchy()
if raw != before:
try:
_panel(_parse_nodes(raw))
except SkuSelectionError:
return
remaining = deadline - self._clock()
if remaining <= 0:
raise SkuSelectionError("安全退出后未确认离开规格面板,未重试返回。")
self._sleep(min(self._poll, remaining))
def reconcile_pending_action(self) -> None:
"""仅只读调和一次已发出但尚未得到后置条件确认的动作。"""
if self._pending is None:
return
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:
self._require_version()
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))
if len(entries) > 1:
raise SkuSelectionError("商品页规格入口不唯一,已停止操作。")
if len(entries) == 1 and raw != previous:
if stable == raw:
return entries[0], raw
stable = raw
else:
stable = None
else:
stable = None
remaining = deadline - self._clock()
if remaining <= 0:
raise SkuSelectionError("等待已取证规格入口超时,未执行点击。")
self._sleep(min(self._poll, remaining))
def _wait_after_action(self, previous: str, condition: Callable[[list[_Node]], Any]) -> list[_Node]:
deadline = self._clock() + self._entry_timeout
while True:
self._require_foreground()
raw = self._read_hierarchy()
if raw != previous:
nodes = _parse_nodes(raw)
try:
condition(nodes)
self._pending = None
return nodes
except SkuSelectionError:
pass
remaining = deadline - self._clock()
if remaining <= 0:
raise SkuSelectionError("动作后页面未在限定时间内满足已取证后置条件,未重试动作。")
self._sleep(min(self._poll, remaining))
def _verified_nodes(self) -> list[_Node]:
self._require_foreground()
return _panel(self._read_nodes())
def _require_version(self) -> None:
info = self._device.app_info(PDD_PACKAGE)
version = (info.get("versionName") or info.get("version_name")) if isinstance(info, dict) else None
if version != EXPECTED_PDD_VERSION:
raise SkuSelectionError("拼多多版本与已取证版本不一致,已停止操作。")
def _require_foreground(self) -> None:
self._require_version()
current = self._device.app_current()
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
raise SkuSelectionError("拼多多不在前台,已停止操作。")
def _read_hierarchy(self) -> str:
try: raw = self._device.dump_window_hierarchy()
except Exception as error: raise SkuSelectionError("节点树读取失败,已停止操作。") from error
if not isinstance(raw, str) or not raw: raise SkuSelectionError("节点树不可用,已停止操作。")
return raw
def _read_nodes(self) -> list[_Node]: return _parse_nodes(self._read_hierarchy())
def _parse_nodes(raw: str) -> list[_Node]:
try: root = ElementTree.fromstring(raw)
except ElementTree.ParseError as error: raise SkuSelectionError("节点树格式无效,已停止操作。") from error
if root.tag != "hierarchy": raise SkuSelectionError("节点树根节点无效,已停止操作。")
result: list[_Node] = []
def visit(element: ElementTree.Element, parent: _Node | None) -> None:
node = _Node(element, parent); result.append(node)
for child in element: visit(child, node)
visit(root, None)
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 _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)
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 _selected(nodes: list[_Node], dimension: str, expected: str) -> None:
if _selected_label(nodes, dimension) != expected:
raise SkuSelectionError("规格选择后读回的 selected 文案不一致,已停止操作。")
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 _post_color(nodes: list[_Node], expected: str) -> None:
_panel(nodes)
_selected(nodes, "color", expected)
_selected_label(nodes, "size")
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 _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 _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 _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 _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 _labeled_ancestor(node: _Node, candidates: list[_Node]) -> bool:
ids, parent = {id(n.element) for n in candidates}, node.parent
while parent is not None:
if id(parent.element) in ids and _label(parent) is not None: return True
parent = parent.parent
return False
def _descendant(node: _Node, ancestor: _Node) -> bool:
parent = node.parent
while parent is not None:
if parent.element is ancestor.element: return True
parent = parent.parent
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"
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 []
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
):
entries.append(node)
return entries
def _exact_entry_node(node: _Node, class_name: str, bounds: str, clickable: 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"
)
def _action_bounds(bounds: str) -> tuple[int, int, int, int]:
match = _BOUNDS.fullmatch(bounds)
if match is None: raise SkuSelectionError("规格节点坐标格式无效,已停止操作。")
left, top, right, bottom = (int(item) for item in match.groups())
if not (0 <= left < right <= _W and 0 <= top < bottom <= _H):
raise SkuSelectionError("规格节点坐标不在已取证屏幕范围内,已停止操作。")
return left, top, right, bottom
def _one(nodes: list[_Node], message: str) -> _Node:
if len(nodes) != 1: raise SkuSelectionError(message)
return nodes[0]
@@ -0,0 +1,348 @@
"""T-103 真机运行边界:窄适配器、原始截图和无页面正文的摘要。"""
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
from typing import Any
from uuid import uuid4
from PIL import Image, UnidentifiedImageError
from adbutils.errors import AdbTimeout
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 .sku_selection import (
EXPECTED_GOODS_ID,
EXPECTED_UNIT_PRICE,
SkuPanelDevice,
SkuSelectionError,
SkuSelectionFlow,
_action_bounds,
resolve_task_selection,
)
EXPECTED_DEVICE_MODEL = "PKG110"
EXPECTED_ANDROID_VERSION = "16"
EXPECTED_SCREEN_SIZE = (1080, 2376)
class SkuSelectionRunError(RuntimeError):
"""T-103 运行未完整完成;错误文本不携带设备或页面原文。"""
class SkuSelectionRunTimeoutError(SkuSelectionRunError):
"""设备 RPC 或操作超时。"""
class SkuSelectionScreenshotError(SkuSelectionRunError):
"""原始截图无法作为完整 PNG 原子发布。"""
class SkuSelectionUnexpectedPriceError(SkuSelectionRunError):
"""取证面板现价不是本任务已确认值。"""
class SkuSelectionDeviceAdapterError(SkuSelectionRunError):
"""第三方设备接口失败的脱敏映射。"""
@dataclass(frozen=True)
class SkuSelectionRunResult:
"""已发布的截图和无页面正文 manifest 摘要。"""
output_directory: Path
screenshot_path: Path
manifest_path: Path
unit_price: str
class UiautomatorSkuPanelAdapter(SkuPanelDevice):
"""把 uiautomator2 缩为 T-103 所需的读取与三种命名操作。
``tap_sku_entry``、``tap_sku_option`` 和 ``leave_sku_panel`` 是仅有的状态改变方法;
坐标由 Flow 和本类双重检查后才计算中心点,每次调用只执行一次底层动作。
"""
def __init__(self, device: Any, timeout_seconds: float) -> None:
if not _is_positive_finite(timeout_seconds):
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
self._device = device
self._timeout_seconds = timeout_seconds
self._entry_was_tapped = False
self._left_panel = False
@property
def entry_was_tapped(self) -> bool:
"""仅供运行器决定故障后的单次尽力返回,不是页面操作。"""
return self._entry_was_tapped
@property
def left_panel(self) -> bool:
return self._left_panel
def app_info(self, package_name: str) -> dict[str, Any]:
value = self._call("app_info", package_name)
if not isinstance(value, dict):
raise SkuSelectionDeviceAdapterError("无法读取应用版本,已停止操作。")
return value
def app_current(self) -> dict[str, Any]:
value = self._call("app_current")
if not isinstance(value, dict):
raise SkuSelectionDeviceAdapterError("无法读取前台应用,已停止操作。")
return value
def dump_window_hierarchy(self) -> str:
value = self._call("jsonrpc_call", "dumpWindowHierarchy", [False, 50], timeout=self._timeout_seconds)
if not isinstance(value, str):
raise SkuSelectionDeviceAdapterError("节点树读取失败,已停止操作。")
return value
def tap_sku_entry(self, bounds: str) -> None:
# 超时也可能表示底层事件已经送达;必须先封存 attempt,后续绝不重试该入口。
self._entry_was_tapped = True
self._tap_bounds_once(bounds)
def tap_sku_option(self, bounds: str) -> None:
self._tap_bounds_once(bounds)
def leave_sku_panel(self) -> None:
if self._left_panel:
raise SkuSelectionDeviceAdapterError("规格面板已经执行过返回,已停止操作。")
# 底层调用即使报错也可能已把返回事件送达;先封存本次机会,finally 不得再次返回。
self._left_panel = True
self._call("jsonrpc_call", "pressKey", ["back"], timeout=self._timeout_seconds)
def capture_screenshot(self) -> str:
value = self._call("jsonrpc_call", "takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds)
if not isinstance(value, str):
raise SkuSelectionScreenshotError("规格面板原始截图读取失败,未发布任何证据产物。")
return value
def display_size(self) -> tuple[int, int]:
value = self._call("window_size")
if not isinstance(value, tuple) or len(value) != 2 or any(not isinstance(item, int) for item in value):
raise SkuSelectionDeviceAdapterError("无法读取屏幕坐标空间,已停止操作。")
return value
def _tap_bounds_once(self, bounds: str) -> None:
left, top, right, bottom = _action_bounds(bounds)
center_x = left + (right - left) // 2
center_y = top + (bottom - top) // 2
self._call("jsonrpc_call", "click", [center_x, center_y], timeout=self._timeout_seconds)
def _call(self, method: str, *args: Any, **kwargs: Any) -> Any:
try:
operation = getattr(self._device, method)
return operation(*args, **kwargs)
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
raise SkuSelectionRunTimeoutError("规格面板设备操作超时,已停止操作。") from error
except SkuSelectionRunError:
raise
except Exception as error:
raise SkuSelectionDeviceAdapterError("规格面板设备操作失败,已停止操作。") from error
class SkuSelectionRunner:
"""只运行 T-103 目标规格恢复、价格确认、原始截图和一次安全退出。"""
def __init__(
self,
adb_client: AdbClient,
connector: Callable[[str], Any],
timeout_seconds: float,
monotonic_clock: Callable[[], float] = monotonic,
) -> None:
if not _is_positive_finite(timeout_seconds):
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
self._adb_client = adb_client
self._connector = connector
self._timeout_seconds = timeout_seconds
self._monotonic_clock = monotonic_clock
def run(
self,
serial: str,
product_url: str,
task_color: str,
task_size: str,
output_directory: Path,
) -> SkuSelectionRunResult:
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:
inspection = self._adb_client.inspect(serial)
_require_expected_device(inspection)
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。
self._adb_client.start_pdd_view_intent(serial, link.goods_id)
remaining = deadline - self._monotonic_clock()
if remaining <= 0:
raise SkuSelectionRunTimeoutError("等待规格入口超时,未执行点击。")
flow = SkuSelectionFlow(adapter, entry_wait_timeout_seconds=remaining)
flow.open_sku_panel(link.canonical_url, pre_intent_hierarchy)
flow.select_sku_options(selection)
unit_price = flow.verify_target_selection_and_read_price(selection)
if unit_price != EXPECTED_UNIT_PRICE:
raise SkuSelectionUnexpectedPriceError("规格面板现价不是本任务已确认值,已停止操作。")
screenshot_path = staging / "screenshot.png"
try:
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
_require_screenshot_size(screenshot_path)
except SkuSelectionRunError:
raise
except Exception as error:
raise SkuSelectionScreenshotError("规格面板原始截图保存失败,未发布任何证据产物。") from error
manifest_path = staging / "manifest.json"
# 截图可能落在动态页面切换边界;发布前必须用一棵更新节点树同时重证两维和现价。
final_price = flow.verify_target_selection_and_read_price(selection)
if final_price != EXPECTED_UNIT_PRICE:
raise SkuSelectionUnexpectedPriceError("截图后规格面板现价不是本任务已确认值,已停止操作。")
# 正常路径仍经 Flow 做最后一次前台和面板判定;返回操作只发生一次。
flow.exit_sku_panel_safely()
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",
)
# Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。
os.rename(staging, target)
staging = None
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError):
_clean_staging(staging)
raise
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
_clean_staging(staging)
raise SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。") from error
except OSError as error:
_clean_staging(staging)
raise SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。") from error
except Exception as error:
_clean_staging(staging)
raise SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。") from error
finally:
# 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。
if flow is not None and adapter is not None and adapter.entry_was_tapped and not adapter.left_panel:
try:
flow.reconcile_pending_action()
flow.exit_sku_panel_safely()
except (SkuSelectionRunError, SkuSelectionError):
pass
return SkuSelectionRunResult(
output_directory=target,
screenshot_path=target / "screenshot.png",
manifest_path=target / "manifest.json",
unit_price=EXPECTED_UNIT_PRICE,
)
def _is_positive_finite(value: object) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
def _validate_new_target(target: Path) -> None:
if target.exists():
raise SkuSelectionRunError("输出目录已存在;为防止覆盖旧证据,已停止操作。")
if not target.name:
raise SkuSelectionRunError("输出目录必须是明确的新目录。")
def _prepare_staging(target: Path) -> Path:
staging: Path | None = None
try:
target.parent.mkdir(parents=True, exist_ok=True)
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
staging.mkdir()
probe = staging / ".write-probe"
probe.write_bytes(b"ok")
probe.unlink()
return staging
except OSError as error:
_clean_staging(staging)
raise SkuSelectionRunError("输出目录不可写,已停止操作。") from error
def _clean_staging(staging: Path | None) -> None:
if staging is not None and staging.exists():
shutil.rmtree(staging)
def _require_expected_version(app_info: object) -> str:
version = (app_info.get("versionName") or app_info.get("version_name")) if isinstance(app_info, dict) else None
if version != EXPECTED_PDD_VERSION:
raise SkuSelectionRunError("拼多多版本与已取证版本不一致,已停止操作。")
return version
def _require_expected_device(inspection: DeviceInspection) -> None:
if inspection.model != EXPECTED_DEVICE_MODEL or inspection.android_version != EXPECTED_ANDROID_VERSION:
raise SkuSelectionRunError("设备型号或 Android 版本不是已取证组合,已停止操作。")
def _require_screenshot_size(screenshot_path: Path) -> None:
try:
with Image.open(screenshot_path) as image:
image.load()
if image.size != EXPECTED_SCREEN_SIZE:
raise SkuSelectionScreenshotError("原始截图坐标空间不是已取证尺寸,未发布任何证据产物。")
except SkuSelectionRunError:
raise
except (UnidentifiedImageError, OSError) as error:
raise SkuSelectionScreenshotError("原始截图无效,未发布任何证据产物。") from error
def _manifest(inspection: DeviceInspection, serial: str, link: ProductUrl, screenshot_path: Path, task_color: str, task_size: str) -> dict[str, Any]:
"""仅写可审计摘要;原始 serial、节点树、页面文案和实际截图内容均不写入 manifest。"""
return {
"schema_version": 1,
"captured_at": datetime.now(UTC).isoformat(),
"operation": "t103-sku-selection",
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
"target_selection": {"color": task_color, "size": task_size},
"unit_price": EXPECTED_UNIT_PRICE,
"selection_status": "restored",
"panel_status": "verified",
"safe_exit": "completed",
"page_identity": "human_review_required",
"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": EXPECTED_PDD_VERSION,
},
"artifacts": [{"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)}],
}
+42
View File
@@ -0,0 +1,42 @@
"""采购工具的本地运行目录策略。"""
from __future__ import annotations
from dataclasses import dataclass
import os
from pathlib import Path
@dataclass(frozen=True)
class RuntimePaths:
"""仅包含应用生成物的本地目录。
日志和证据产物不落在源码树中,避免把含有现场信息的运行数据误提交到 Git。
"""
root: Path
logs: Path
artifacts: Path
@classmethod
def from_root(cls, root: Path) -> "RuntimePaths":
resolved_root = root.expanduser()
return cls(
root=resolved_root,
logs=resolved_root / "logs",
artifacts=resolved_root / "artifacts",
)
@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")
return cls.from_root(Path.home() / ".local" / "share" / "cmbuyer")
def ensure_exists(self) -> None:
"""创建运行目录;调用方负责向用户呈现无法创建目录的错误。"""
self.logs.mkdir(parents=True, exist_ok=True)
self.artifacts.mkdir(parents=True, exist_ok=True)
+1
View File
@@ -0,0 +1 @@
"""采购工具的离线单元测试。"""
+1
View File
@@ -0,0 +1 @@
"""设备连接与基线取证的离线测试。"""
+273
View File
@@ -0,0 +1,273 @@
"""ADB 设备边界测试:所有命令执行器均为 mock,不连接真机。"""
from __future__ import annotations
from collections.abc import Sequence
from pathlib import Path
import subprocess
import sys
import unittest
CLIENT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import (
AdbClient,
CommandResult,
DeviceIdentityUnconfirmedError,
DeviceCommandError,
DeviceCommandTimeoutError,
DeviceNotFoundError,
DeviceOfflineError,
DeviceStateError,
DeviceUnauthorizedError,
DuplicatePhysicalDeviceError,
IntentLaunchUnconfirmedError,
SerialRequiredError,
)
USB_SERIAL = "3B65BD02H7F00000"
WIFI_SERIAL = "192.168.0.173:5555"
class FakeRunner:
def __init__(self, devices_output: str, properties: dict[tuple[str, str], CommandResult | str]) -> None:
self.devices_output = devices_output
self.properties = properties
self.calls: list[tuple[str, ...]] = []
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
self.calls.append(tuple(arguments))
if tuple(arguments) == ("devices", "-l"):
return CommandResult(stdout=self.devices_output)
key = (arguments[1], arguments[-1])
value = self.properties.get(key, "")
return value if isinstance(value, CommandResult) else CommandResult(stdout=value)
def _properties(serials: tuple[str, ...]) -> dict[tuple[str, str], str]:
values: dict[tuple[str, str], str] = {}
for serial in serials:
values[(serial, "ro.serialno")] = "physical-phone-1"
values[(serial, "ro.boot.serialno")] = "physical-phone-1"
values[(serial, "ro.product.model")] = "PKG110"
values[(serial, "ro.product.name")] = "PKG110"
values[(serial, "ro.product.device")] = "OP5D2BL1"
values[(serial, "ro.build.version.release")] = "16"
return values
class AdbClientTests(unittest.TestCase):
def test_requires_explicit_serial(self) -> None:
runner = FakeRunner("List of devices attached\n", {})
with self.assertRaises(SerialRequiredError):
AdbClient(runner).inspect(" ")
self.assertEqual(runner.calls, [])
def test_missing_offline_and_unauthorized_are_distinct(self) -> None:
missing = AdbClient(FakeRunner("List of devices attached\n", {}))
with self.assertRaises(DeviceNotFoundError):
missing.inspect(USB_SERIAL)
offline = AdbClient(FakeRunner(f"List of devices attached\n{USB_SERIAL}\toffline\n", {}))
with self.assertRaises(DeviceOfflineError):
offline.inspect(USB_SERIAL)
unauthorized = AdbClient(FakeRunner(f"List of devices attached\n{USB_SERIAL}\tunauthorized\n", {}))
with self.assertRaises(DeviceUnauthorizedError):
unauthorized.inspect(USB_SERIAL)
def test_two_channels_with_same_physical_identity_fail_closed(self) -> None:
output = (
"List of devices attached\n"
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
)
runner = FakeRunner(output, _properties((WIFI_SERIAL, USB_SERIAL)))
with self.assertRaises(DuplicatePhysicalDeviceError):
AdbClient(runner).inspect(USB_SERIAL)
self.assertIn(("-s", WIFI_SERIAL, "shell", "getprop", "ro.serialno"), runner.calls)
self.assertIn(("-s", USB_SERIAL, "shell", "getprop", "ro.serialno"), runner.calls)
def test_multiple_online_devices_with_failed_identity_fail_closed(self) -> None:
output = (
"List of devices attached\n"
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
)
properties = _properties((WIFI_SERIAL, USB_SERIAL))
properties[(WIFI_SERIAL, "ro.serialno")] = CommandResult(stdout="", returncode=1)
runner = FakeRunner(output, properties)
with self.assertRaises(DeviceIdentityUnconfirmedError):
AdbClient(runner).inspect(USB_SERIAL)
def test_online_explicit_serial_reads_non_sensitive_metadata(self) -> None:
output = f"List of devices attached\n{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
inspection = AdbClient(FakeRunner(output, _properties((USB_SERIAL,)))).inspect(USB_SERIAL)
self.assertEqual(inspection.device.serial, USB_SERIAL)
self.assertEqual(inspection.model, "PKG110")
self.assertEqual(inspection.android_version, "16")
def test_single_online_device_does_not_require_hardware_identity(self) -> None:
output = f"List of devices attached\n{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
properties = _properties((USB_SERIAL,))
properties[(USB_SERIAL, "ro.serialno")] = ""
properties[(USB_SERIAL, "ro.boot.serialno")] = ""
inspection = AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
self.assertEqual(inspection.model, "PKG110")
def test_multiple_online_devices_without_hardware_identity_are_unconfirmed(self) -> None:
output = (
"List of devices attached\n"
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
)
properties = _properties((WIFI_SERIAL, USB_SERIAL))
properties[(WIFI_SERIAL, "ro.serialno")] = ""
properties[(WIFI_SERIAL, "ro.boot.serialno")] = ""
with self.assertRaises(DeviceIdentityUnconfirmedError):
AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
def test_multiple_online_devices_with_different_identity_keep_explicit_selection(self) -> None:
output = (
"List of devices attached\n"
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
)
properties = _properties((WIFI_SERIAL, USB_SERIAL))
properties[(WIFI_SERIAL, "ro.serialno")] = "physical-phone-2"
properties[(WIFI_SERIAL, "ro.boot.serialno")] = "physical-phone-2"
inspection = AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
self.assertEqual(inspection.device.serial, USB_SERIAL)
def test_shared_boot_serial_is_duplicate_even_when_ro_serial_differs(self) -> None:
output = (
"List of devices attached\n"
f"{WIFI_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
f"{USB_SERIAL}\tdevice product:PKG110 model:PKG110 device:OP5D2BL1\n"
)
properties = _properties((WIFI_SERIAL, USB_SERIAL))
properties[(WIFI_SERIAL, "ro.serialno")] = "wifi-transport-serial"
properties[(USB_SERIAL, "ro.serialno")] = "usb-transport-serial"
properties[(WIFI_SERIAL, "ro.boot.serialno")] = "shared-hardware-serial"
properties[(USB_SERIAL, "ro.boot.serialno")] = "shared-hardware-serial"
with self.assertRaises(DuplicatePhysicalDeviceError):
AdbClient(FakeRunner(output, properties)).inspect(USB_SERIAL)
def test_unknown_adb_state_is_rejected(self) -> None:
client = AdbClient(FakeRunner(f"List of devices attached\n{USB_SERIAL}\trecovery\n", {}))
with self.assertRaises(DeviceStateError):
client.inspect(USB_SERIAL)
def test_runner_timeout_is_a_distinct_connection_error(self) -> None:
class TimeoutRunner:
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
raise subprocess.TimeoutExpired(arguments, timeout_seconds)
with self.assertRaises(DeviceCommandTimeoutError):
AdbClient(TimeoutRunner()).inspect(USB_SERIAL)
def test_product_intent_is_fixed_to_action_view_and_pdd_package(self) -> None:
class IntentRunner:
def __init__(self) -> None:
self.calls: list[tuple[str, ...]] = []
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
self.calls.append(tuple(arguments))
return CommandResult(stdout="Status: ok\n")
runner = IntentRunner()
summary = AdbClient(runner).start_pdd_view_intent(
USB_SERIAL,
"123",
)
self.assertEqual(summary.status, "ok")
self.assertEqual(
runner.calls,
[
(
"-s",
USB_SERIAL,
"shell",
"am",
"start",
"-W",
"-a",
"android.intent.action.VIEW",
"-d",
"https://mobile.yangkeduo.com/goods.html?goods_id=123",
"-p",
"com.xunmeng.pinduoduo",
)
],
)
def test_product_intent_without_explicit_success_is_rejected(self) -> None:
class UnknownIntentRunner:
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
return CommandResult(stdout="Starting: Intent { ... }\n")
with self.assertRaises(IntentLaunchUnconfirmedError):
AdbClient(UnknownIntentRunner()).start_pdd_view_intent(
USB_SERIAL,
"123",
)
def test_product_intent_rejects_invalid_goods_id_before_runner(self) -> None:
class RecordingRunner:
def __init__(self) -> None:
self.calls: list[tuple[str, ...]] = []
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
self.calls.append(tuple(arguments))
return CommandResult(stdout="Status: ok\n")
invalid_values: tuple[object, ...] = (
"",
"12a",
"123",
" 123",
"123 ",
"https://mobile.yangkeduo.com/goods.html?goods_id=123",
"am start -W -d anything",
123,
None,
)
for value in invalid_values:
with self.subTest(value=repr(value)):
runner = RecordingRunner()
with self.assertRaises(ValueError):
AdbClient(runner).start_pdd_view_intent(USB_SERIAL, value) # type: ignore[arg-type]
self.assertEqual(runner.calls, [])
def test_product_intent_nonzero_and_timeout_remain_distinct(self) -> None:
class FailedIntentRunner:
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
return CommandResult(stdout="sensitive command output", returncode=1)
class TimeoutIntentRunner:
def run(self, arguments: Sequence[str], timeout_seconds: float) -> CommandResult:
raise subprocess.TimeoutExpired(arguments, timeout_seconds)
with self.assertRaises(DeviceCommandError) as command_error:
AdbClient(FailedIntentRunner()).start_pdd_view_intent(USB_SERIAL, "123")
self.assertNotIn("sensitive command output", str(command_error.exception))
with self.assertRaises(DeviceCommandTimeoutError):
AdbClient(TimeoutIntentRunner()).start_pdd_view_intent(USB_SERIAL, "123")
+281
View File
@@ -0,0 +1,281 @@
"""基线取证测试:mock ADB/uiautomator2,不连接手机。"""
from __future__ import annotations
from pathlib import Path
import base64
from io import BytesIO
import sys
import tempfile
import unittest
from PIL import Image
from uiautomator2.exceptions import HTTPTimeoutError
CLIENT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
sys.path.insert(0, str(CLIENT_ROOT / "scripts"))
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
from cmbuyer_client.device.baseline import (
BaselineCaptureError,
BaselineCaptureTimeoutError,
DeviceBaselineCapturer,
NoReconnectUiautomatorConnector,
PDD_PACKAGE,
)
from capture_device_baseline import parse_arguments, validate_arguments
SERIAL = "USB-serial-for-test"
class StaticAdbClient:
def __init__(self) -> None:
self.serials: list[str] = []
def inspect(self, serial: str) -> DeviceInspection:
self.serials.append(serial)
return DeviceInspection(
device=AdbDevice(serial=serial, state="device", model="Test Model"),
model="Test Model",
android_version="16",
)
class FakeUiDevice:
def __init__(self, fail_dump: bool = False) -> None:
self.fail_dump = fail_dump
self.rpc_calls: list[tuple[str, object, float]] = []
self.app_info_calls: list[str] = []
def app_info(self, package_name: str) -> dict[str, str]:
self.app_info_calls.append(package_name)
return {"versionName": "8.17.0"}
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
self.rpc_calls.append((method, params, timeout))
if method == "takeScreenshot":
image_data = BytesIO()
Image.new("RGB", (1, 1), color="white").save(image_data, format="PNG")
return base64.b64encode(image_data.getvalue()).decode("ascii")
if method != "dumpWindowHierarchy":
raise AssertionError(f"unexpected method: {method}")
if self.fail_dump:
raise RuntimeError("mock dump failed")
return "<hierarchy><node text='page body must stay out of manifest'/></hierarchy>"
class BaselineCaptureTests(unittest.TestCase):
def test_capture_writes_hashes_without_xml_or_raw_serial_in_manifest(self) -> None:
adb = StaticAdbClient()
device = FakeUiDevice()
capturer = DeviceBaselineCapturer(adb, lambda serial: device, timeout_seconds=7.5)
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "baseline"
result = capturer.capture(SERIAL, output)
manifest = result.manifest_path.read_text(encoding="utf-8")
self.assertEqual(adb.serials, [SERIAL])
self.assertEqual(device.app_info_calls, [PDD_PACKAGE])
self.assertEqual(
device.rpc_calls,
[
("takeScreenshot", [1, 80], 7.5),
("dumpWindowHierarchy", [False, 50], 7.5),
],
)
self.assertTrue(result.screenshot_path.is_file())
self.assertTrue(result.hierarchy_path.is_file())
self.assertIn('"sha256"', manifest)
self.assertNotIn("page body must stay out of manifest", manifest)
self.assertNotIn(SERIAL, manifest)
self.assertIn('"channel": "usb"', manifest)
def test_capture_failure_cleans_staging_and_does_not_publish_partial_output(self) -> None:
device = FakeUiDevice(fail_dump=True)
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: device, timeout_seconds=5)
with tempfile.TemporaryDirectory() as directory:
parent = Path(directory)
output = parent / "baseline"
with self.assertRaises(BaselineCaptureError) as raised:
capturer.capture(SERIAL, output)
self.assertFalse(output.exists())
self.assertEqual(list(parent.iterdir()), [])
self.assertNotIn("mock dump failed", str(raised.exception))
def test_existing_output_is_never_overwritten(self) -> None:
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "baseline"
output.mkdir()
sentinel = output / "keep.txt"
sentinel.write_text("preserve", encoding="utf-8")
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: FakeUiDevice(), timeout_seconds=5)
with self.assertRaises(BaselineCaptureError):
capturer.capture(SERIAL, output)
self.assertEqual(sentinel.read_text(encoding="utf-8"), "preserve")
def test_no_reconnect_connector_passes_only_current_adb_device_object(self) -> None:
class ListedDevice:
serial = SERIAL
listed = ListedDevice()
connected: list[object] = []
connector = NoReconnectUiautomatorConnector(lambda: [listed], lambda device: connected.append(device) or FakeUiDevice())
connector(SERIAL)
self.assertEqual(connected, [listed])
def test_no_reconnect_connector_refuses_disappeared_serial(self) -> None:
connector = NoReconnectUiautomatorConnector(lambda: [], lambda device: FakeUiDevice())
with self.assertRaises(BaselineCaptureError) as raised:
connector(SERIAL)
self.assertIn("拒绝自动重连", str(raised.exception))
def test_connector_exception_is_redacted_and_publishes_no_partial_output(self) -> None:
def failing_connector(serial: str) -> FakeUiDevice:
raise RuntimeError(f"third party leaked {serial}")
capturer = DeviceBaselineCapturer(StaticAdbClient(), failing_connector, timeout_seconds=5)
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "baseline"
with self.assertRaises(BaselineCaptureError) as raised:
capturer.capture(SERIAL, output)
self.assertNotIn(SERIAL, str(raised.exception))
self.assertFalse(output.exists())
self.assertEqual(list(Path(directory).iterdir()), [])
def test_invalid_screenshot_base64_syntax_fails_closed_without_partial_output(self) -> None:
class InvalidScreenshotDevice(FakeUiDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "takeScreenshot":
valid = super().jsonrpc_call(method, params, timeout)
return valid[:12] + "!" + valid[12:]
return super().jsonrpc_call(method, params, timeout)
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: InvalidScreenshotDevice(), timeout_seconds=5)
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "baseline"
with self.assertRaises(BaselineCaptureError) as raised:
capturer.capture(SERIAL, output)
self.assertIn("Base64 语法无效", str(raised.exception))
self.assertNotIn("!", str(raised.exception))
self.assertFalse(output.exists())
self.assertEqual(list(Path(directory).iterdir()), [])
def test_invalid_padding_and_unapproved_ascii_whitespace_fail_closed(self) -> None:
invalid_insertions = {
"padding": lambda value: value[:-1],
"vertical-tab": lambda value: value[:12] + "\v" + value[12:],
"form-feed": lambda value: value[:12] + "\f" + value[12:],
}
for name, make_invalid in invalid_insertions.items():
with self.subTest(name=name), tempfile.TemporaryDirectory() as directory:
class InvalidScreenshotDevice(FakeUiDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
value = super().jsonrpc_call(method, params, timeout)
if method == "takeScreenshot":
return make_invalid(value)
return value
output = Path(directory) / "baseline"
capturer = DeviceBaselineCapturer(
StaticAdbClient(),
lambda serial: InvalidScreenshotDevice(),
timeout_seconds=5,
)
with self.assertRaises(BaselineCaptureError) as raised:
capturer.capture(SERIAL, output)
self.assertIn("Base64 语法无效", str(raised.exception))
self.assertNotIn(SERIAL, str(raised.exception))
self.assertFalse(output.exists())
self.assertEqual(list(Path(directory).iterdir()), [])
def test_base64_decoded_nonimage_fails_closed_without_partial_output(self) -> None:
class NonImageScreenshotDevice(FakeUiDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "takeScreenshot":
return base64.b64encode(b"not an image").decode("ascii")
return super().jsonrpc_call(method, params, timeout)
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: NonImageScreenshotDevice(), timeout_seconds=5)
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "baseline"
with self.assertRaises(BaselineCaptureError) as raised:
capturer.capture(SERIAL, output)
self.assertIn("图像数据无效", str(raised.exception))
self.assertNotIn("not an image", str(raised.exception))
self.assertFalse(output.exists())
self.assertEqual(list(Path(directory).iterdir()), [])
def test_ascii_base64_whitespace_is_normalized_before_strict_decode(self) -> None:
class WhitespaceScreenshotDevice(FakeUiDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
value = super().jsonrpc_call(method, params, timeout)
if method == "takeScreenshot":
return value[:10] + " \t\r\n" + value[10:30] + "\n" + value[30:]
return value
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: WhitespaceScreenshotDevice(), timeout_seconds=5)
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "baseline"
result = capturer.capture(SERIAL, output)
self.assertTrue(result.screenshot_path.is_file())
with Image.open(result.screenshot_path) as image:
self.assertEqual(image.size, (1, 1))
def test_invalid_or_non_hierarchy_xml_fails_closed_without_partial_output(self) -> None:
class InvalidHierarchyDevice(FakeUiDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "dumpWindowHierarchy":
return "<not-hierarchy/>"
return super().jsonrpc_call(method, params, timeout)
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: InvalidHierarchyDevice(), timeout_seconds=5)
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "baseline"
with self.assertRaises(BaselineCaptureError) as raised:
capturer.capture(SERIAL, output)
self.assertNotIn("not-hierarchy", str(raised.exception))
self.assertFalse(output.exists())
self.assertEqual(list(Path(directory).iterdir()), [])
def test_rpc_timeout_is_distinct_redacted_and_does_not_publish_partial_output(self) -> None:
class TimeoutRpcDevice(FakeUiDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
raise HTTPTimeoutError(f"raw serial={SERIAL} xml=<hierarchy/>")
capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: TimeoutRpcDevice(), timeout_seconds=5)
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "baseline"
with self.assertRaises(BaselineCaptureTimeoutError) as raised:
capturer.capture(SERIAL, output)
self.assertIn("超时", str(raised.exception))
self.assertNotIn(SERIAL, str(raised.exception))
self.assertNotIn("hierarchy", str(raised.exception))
self.assertFalse(output.exists())
self.assertEqual(list(Path(directory).iterdir()), [])
def test_cli_validation_rejects_empty_serial_and_nonpositive_timeout(self) -> None:
empty_serial = parse_arguments(["--serial", "", "--output-dir", "baseline"])
with self.assertRaisesRegex(ValueError, "非空 --serial"):
validate_arguments(empty_serial)
nonpositive_timeout = parse_arguments(["--serial", SERIAL, "--output-dir", "baseline", "--timeout", "0"])
with self.assertRaisesRegex(ValueError, "必须大于 0"):
validate_arguments(nonpositive_timeout)
@@ -0,0 +1,735 @@
"""T-103 脱敏器测试:全部证据为合成数据,绝不读取真实 raw 目录。"""
from __future__ import annotations
from hashlib import sha256
import json
from pathlib import Path
import sys
from tempfile import TemporaryDirectory
import unittest
from unittest.mock import patch
from xml.etree import ElementTree
from PIL import Image
CLIENT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.sku_evidence_sanitizer import (
EXPECTED_GOODS_ID,
EXPECTED_SCREENSHOT_HEIGHT,
EXPECTED_SCREENSHOT_WIDTH,
EXPECTED_XML_HEIGHT,
EXPECTED_XML_WIDTH,
HUMAN_DECLARED_STATES,
SkuEvidenceSanitizationError,
sanitize_sku_panel_evidence,
)
TEST_SERIAL = "synthetic-serial-never-publish"
TEST_ADDRESS = "SYNTHETIC_ADDRESS_NEVER_PUBLISH"
FULL_PHONE = "13800138000"
MASKED_PHONE = "138****0000"
SAFE_TEXT = "synthetic-safe-lower-content"
CURRENT_PRICE = "快卖完 ¥12.88"
ORIGINAL_PRICE = "¥29.00"
PRICE_CURRENT_BOUNDS = "[396,503][712,570]"
PRICE_ORIGINAL_BOUNDS = "[730,503][895,570]"
def _hash(path: Path) -> str:
digest = sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _default_xml() -> str:
return (
"<hierarchy rotation='0'>"
f"<node bounds='[0,0][1080,540]' text='{TEST_ADDRESS}' content-desc='{MASKED_PHONE} {FULL_PHONE}' />"
f"{_price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS)}"
f"{_price_node(ORIGINAL_PRICE, PRICE_ORIGINAL_BOUNDS)}"
f"<node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' />"
"</hierarchy>"
)
def _price_node(
text: str,
bounds: str,
*,
package: str = "com.xunmeng.pinduoduo",
node_class: str = "android.widget.TextView",
clickable: str = "false",
enabled: str = "true",
visible: str = "true",
extra_attributes: str = "",
children: str = "",
) -> str:
attributes = (
f"bounds='{bounds}' text='{text}' package='{package}' class='{node_class}' "
f"clickable='{clickable}' enabled='{enabled}' visible-to-user='{visible}'{extra_attributes}"
)
return f"<node {attributes}>{children}</node>"
def _xml_with_prices(
current: str = CURRENT_PRICE,
original: str = ORIGINAL_PRICE,
*,
current_node: str | None = None,
original_node: str | None = None,
include_original: bool = True,
extra_nodes: str = "",
) -> str:
current_markup = current_node if current_node is not None else _price_node(current, PRICE_CURRENT_BOUNDS)
original_markup = (
original_node if original_node is not None else _price_node(original, PRICE_ORIGINAL_BOUNDS)
) if include_original else ""
return (
"<hierarchy>"
f"<node bounds='[0,0][1080,540]' text='{TEST_ADDRESS}' />"
f"{current_markup}"
f"{original_markup}"
f"{extra_nodes}"
f"<node bounds='[0,570][1080,2376]' text='{SAFE_TEXT}' />"
"</hierarchy>"
)
def _write_raw(
root: Path,
*,
state: str = "panel-opened-target-preselected",
goods_id: str = EXPECTED_GOODS_ID,
model: str = "PKG110",
android_version: str = "16",
pdd_version: str = "8.17.0",
size: tuple[int, int] = (EXPECTED_SCREENSHOT_WIDTH, EXPECTED_SCREENSHOT_HEIGHT),
xml: str | None = None,
) -> Path:
raw = root / "raw"
raw.mkdir(parents=True)
screenshot = raw / "screenshot.png"
image = Image.new("RGB", size, color=(0, 180, 0))
if size == (EXPECTED_SCREENSHOT_WIDTH, EXPECTED_SCREENSHOT_HEIGHT):
for y in range(540):
for x in range(8):
image.putpixel((x, y), (255, 0, 0))
image.save(screenshot, format="PNG")
hierarchy = raw / "hierarchy.xml"
hierarchy.write_text(_default_xml() if xml is None else xml, encoding="utf-8")
manifest = {
"schema_version": 1,
"product": {
"goods_id": goods_id,
"canonical_url": f"https://mobile.yangkeduo.com/goods.html?goods_id={goods_id}",
},
"human_declared_state": state,
"serial": TEST_SERIAL,
"channel": "wifi",
"device": {
"model": model,
"android_version": android_version,
"pdd_package": "com.xunmeng.pinduoduo",
"pdd_version": pdd_version,
},
"artifacts": [
{"path": "screenshot.png", "sha256": _hash(screenshot)},
{"path": "hierarchy.xml", "sha256": _hash(hierarchy)},
],
}
(raw / "manifest.json").write_text(json.dumps(manifest, sort_keys=True), encoding="utf-8")
return raw
class SkuEvidenceSanitizerTests(unittest.TestCase):
def test_all_declared_states_mask_screenshot_and_xml_without_raw_metadata(self) -> None:
for state in sorted(HUMAN_DECLARED_STATES):
with self.subTest(state=state), TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), state=state)
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
with Image.open(result.screenshot_path) as image:
self.assertEqual(image.getpixel((0, 0)), (0, 0, 0, 255))
self.assertEqual(image.getpixel((100, 600)), (0, 180, 0, 255))
derived_xml = result.hierarchy_path.read_text(encoding="utf-8")
manifest = result.manifest_path.read_text(encoding="utf-8")
self.assertIn(SAFE_TEXT, derived_xml)
self.assertNotIn(TEST_ADDRESS, derived_xml)
self.assertNotIn(FULL_PHONE, derived_xml)
self.assertNotIn(MASKED_PHONE, derived_xml)
self.assertIn(f'"human_declared_state": "{state}"', manifest)
self.assertIn('"privacy_tier": "SANITIZED"', manifest)
self.assertIn('"sanitizer_version": "t103-privacy-v5"', manifest)
self.assertIn('"screenshot_space": {', manifest)
self.assertIn('"xml_coordinate_space": {', manifest)
self.assertIn('"height": 2376', manifest)
self.assertIn('"height": 2376', manifest)
self.assertIn('"privacy_mask_rectangle": [', manifest)
self.assertIn('"removed_nodes": 1', manifest)
self.assertIn('"cleared_crossing_nodes": 0', manifest)
self.assertIn('"preserved_crossing_price_nodes": 2', manifest)
self.assertIn('"retained_below_nodes": 1', manifest)
self.assertIn('"max_right": 1080', manifest)
self.assertIn('"max_bottom": 2376', manifest)
self.assertNotIn("canonical_url", manifest)
self.assertNotIn(TEST_SERIAL, manifest)
self.assertNotIn("serial", manifest)
self.assertNotIn("channel", manifest)
self.assertNotIn(TEST_ADDRESS, manifest)
self.assertNotIn(FULL_PHONE, manifest)
def test_crossing_container_keeps_lower_children_but_clears_its_text(self) -> None:
xml = (
"<hierarchy>"
f"<node bounds='[0,0][1080,2376]' text='{TEST_ADDRESS}' content-desc='{MASKED_PHONE}'>"
f"<node bounds='[0,0][1080,540]' text='{FULL_PHONE}' />"
f"{_price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS)}"
f"{_price_node(ORIGINAL_PRICE, PRICE_ORIGINAL_BOUNDS)}"
f"<node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' />"
"</node></hierarchy>"
)
with TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), xml=xml)
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
root = ElementTree.parse(result.hierarchy_path).getroot()
crossing = root.find("node")
self.assertIsNotNone(crossing)
assert crossing is not None
self.assertEqual(crossing.attrib, {"bounds": "[0,0][1080,2376]"})
self.assertEqual(len(list(crossing)), 3)
self.assertEqual(list(crossing)[-1].get("text"), SAFE_TEXT)
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
self.assertEqual(
manifest["privacy_cleanup"],
{
"removed_nodes": 1,
"cleared_crossing_nodes": 1,
"preserved_crossing_price_nodes": 2,
"retained_below_nodes": 1,
"max_right": 1080,
"max_bottom": 2376,
},
)
def test_only_strict_crossing_price_leaves_are_projected_with_whitelisted_attributes(self) -> None:
xml = _xml_with_prices(
current_node=_price_node(
CURRENT_PRICE,
PRICE_CURRENT_BOUNDS,
extra_attributes=" content-desc='discard' resource-id='discard' focused='true'",
),
original_node=_price_node(
ORIGINAL_PRICE,
PRICE_ORIGINAL_BOUNDS,
extra_attributes=" content-desc='discard-too' resource-id='discard-too'",
),
)
with TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), xml=xml)
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
root = ElementTree.parse(result.hierarchy_path).getroot()
prices = [node for node in root.findall("node") if node.get("text") in {CURRENT_PRICE, ORIGINAL_PRICE}]
self.assertEqual(len(prices), 2)
for node in prices:
self.assertEqual(
set(node.attrib),
{"bounds", "text", "package", "class", "clickable", "enabled", "visible-to-user"},
)
self.assertEqual(node.get("package"), "com.xunmeng.pinduoduo")
self.assertEqual(node.get("class"), "android.widget.TextView")
self.assertEqual(node.get("clickable"), "false")
self.assertEqual(node.get("enabled"), "true")
self.assertEqual(node.get("visible-to-user"), "true")
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
self.assertEqual(manifest["privacy_cleanup"]["preserved_crossing_price_nodes"], 2)
self.assertNotIn("discard", result.hierarchy_path.read_text(encoding="utf-8"))
def test_crossing_price_window_rejects_text_and_structure_drift(self) -> None:
bad_texts = (
f"快卖完 ¥12.88 {TEST_ADDRESS}",
f"快卖完 ¥12.88 {FULL_PHONE}",
"快卖完 ¥12.88 使用微信支付",
"快卖完 ¥12.88 提交订单",
"快卖完 ¥12.88 优惠-11元",
"快要抢光 ¥12.88",
"快卖完 ¥0.00",
"快卖完 ¥12.8",
"快卖完 ¥12.880",
"快卖完 ¥12.88",
)
for text in bad_texts:
with self.subTest(text=text), TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=text))
with self.assertRaises(SkuEvidenceSanitizationError):
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
self.assertFalse((raw.parent / "derived").exists())
def test_crossing_price_text_mismatch_reports_only_fixed_slot_and_reason(self) -> None:
newline_node = _price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS).replace(
"快卖完 ¥12.88", "快卖完&#10;¥12.88"
)
cases = (
("newline", _xml_with_prices(current_node=newline_node), "newline", PRICE_CURRENT_BOUNDS),
(
"non-ascii-whitespace",
_xml_with_prices(current="快卖完 ¥12.88"),
"non_ascii_whitespace",
PRICE_CURRENT_BOUNDS,
),
(
"known-prefix-missing",
_xml_with_prices(current="快要抢光 ¥12.88"),
"observed_prefix_kuaiyaoqiangguang",
PRICE_CURRENT_BOUNDS,
),
(
"other-kuai-prefix-remains-generic",
_xml_with_prices(current="快递地址 ¥12.88"),
"known_prefix_missing",
PRICE_CURRENT_BOUNDS,
),
(
"currency-missing",
_xml_with_prices(current="快卖完 12.88"),
"currency_missing",
PRICE_CURRENT_BOUNDS,
),
(
"amount-shape",
_xml_with_prices(current="快卖完 ¥12.8"),
"amount_shape",
PRICE_CURRENT_BOUNDS,
),
(
"extra-or-order",
_xml_with_prices(current="提交订单 ¥12.88"),
"extra_or_order",
PRICE_CURRENT_BOUNDS,
),
(
"forbidden-characters",
_xml_with_prices(current="商品 ¥12.88"),
"forbidden_characters",
PRICE_CURRENT_BOUNDS,
),
(
"right-slot-amount-shape",
_xml_with_prices(original="¥29.0"),
"amount_shape",
PRICE_ORIGINAL_BOUNDS,
),
)
for name, xml, reason, bounds in cases:
with self.subTest(name=name), TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), xml=xml)
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
self.assertEqual(
str(raised.exception),
f"跨界价格节点文本不匹配:slot={bounds};reason={reason}。",
)
self.assertFalse((raw.parent / "derived").exists())
def test_crossing_price_text_mismatch_never_echoes_sensitive_or_order_text(self) -> None:
cases = (
f"快卖完 ¥12.88 {TEST_ADDRESS}",
f"快卖完 ¥12.88 {FULL_PHONE}",
"快卖完 ¥12.88 使用微信支付",
"快卖完 ¥12.88 提交订单",
)
for text in cases:
with self.subTest(text=text), TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=text))
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
message = str(raised.exception)
self.assertIn("slot=[396,503][712,570]", message)
self.assertIn("reason=extra_or_order", message)
for raw_fragment in (TEST_ADDRESS, FULL_PHONE, "使用微信支付", "提交订单", "¥12.88"):
self.assertNotIn(raw_fragment, message)
def test_observed_prefix_diagnostic_does_not_echo_its_suffix(self) -> None:
suffix = "地址和金额都不得回显"
text = f"快要抢光 ¥12.88 {suffix}"
with TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=text))
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
message = str(raised.exception)
self.assertEqual(
message,
"跨界价格节点文本不匹配:slot=[396,503][712,570];"
"reason=observed_prefix_kuaiyaoqiangguang。",
)
self.assertNotIn(suffix, message)
self.assertNotIn("¥12.88", message)
def test_crossing_price_projection_allows_only_limited_ascii_spaces_and_yen_variants(self) -> None:
for current in ("快卖完 ¥12.88", " 快卖完 ¥ 12.88 ", "快卖完 ¥12.88"):
with self.subTest(current=current), TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=current))
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
hierarchy = result.hierarchy_path.read_text(encoding="utf-8")
self.assertIn(current, hierarchy)
def test_unverified_or_missing_current_price_prefixes_fail_closed(self) -> None:
cases = (
("old-prefix", "快卖光 ¥12.88"),
("bottom-button-prefix", "快要抢光 ¥12.88"),
("missing-prefix", "¥12.88"),
)
for name, current in cases:
with self.subTest(name=name), TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current=current))
with self.assertRaises(SkuEvidenceSanitizationError):
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
self.assertFalse((raw.parent / "derived").exists())
def test_unique_current_price_without_original_price_is_published(self) -> None:
with TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), xml=_xml_with_prices(include_original=False))
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
hierarchy = result.hierarchy_path.read_text(encoding="utf-8")
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
self.assertIn(CURRENT_PRICE, hierarchy)
self.assertNotIn(ORIGINAL_PRICE, hierarchy)
self.assertEqual(manifest["privacy_cleanup"]["preserved_crossing_price_nodes"], 1)
def test_crossing_price_projection_rejects_newline_and_structure_drift(self) -> None:
encoded_newline = _price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS).replace(
"快卖完 ¥12.88", "快卖完&#10;¥12.88"
)
with TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), xml=_xml_with_prices(current_node=encoded_newline))
with self.assertRaises(SkuEvidenceSanitizationError):
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
self.assertFalse((raw.parent / "derived").exists())
bad_structure = (
("package", {"package": "com.android.systemui"}),
("class", {"node_class": "android.view.View"}),
("clickable", {"clickable": "true"}),
("disabled", {"enabled": "false"}),
("hidden", {"visible": "false"}),
("children", {"children": "<node bounds='[400,510][500,520]' />"}),
)
for name, kwargs in bad_structure:
with self.subTest(structure=name), TemporaryDirectory() as temporary:
raw = _write_raw(
Path(temporary),
xml=_xml_with_prices(current_node=_price_node(CURRENT_PRICE, PRICE_CURRENT_BOUNDS, **kwargs)),
)
with self.assertRaises(SkuEvidenceSanitizationError):
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
self.assertFalse((raw.parent / "derived").exists())
def test_crossing_price_candidates_require_unique_current_and_at_most_one_original(self) -> None:
scenarios = (
(
"missing-current",
_xml_with_prices(current="¥12.88", original=ORIGINAL_PRICE),
),
(
"duplicate-current",
_xml_with_prices(current=CURRENT_PRICE, original=f"快卖完 {ORIGINAL_PRICE}"),
),
(
"multiple-original",
_xml_with_prices(
extra_nodes=_price_node("¥39.88", PRICE_ORIGINAL_BOUNDS),
),
),
)
for name, xml in scenarios:
with self.subTest(name=name), TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), xml=xml)
with self.assertRaises(SkuEvidenceSanitizationError):
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
self.assertFalse((raw.parent / "derived").exists())
def test_crossing_price_outside_fixed_windows_is_cleared_and_submit_price_is_not_candidate(self) -> None:
outside_crossing = _price_node("快卖光 ¥99.99", "[396,498][712,570]")
submit = (
"<node bounds='[369,2225][710,2284]' text='提交订单 ¥12.88' "
"package='com.xunmeng.pinduoduo' class='android.widget.TextView' clickable='false' "
"enabled='true' visible-to-user='true' resource-id='submit-button' />"
)
with TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), xml=_xml_with_prices(extra_nodes=outside_crossing + submit))
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
hierarchy = result.hierarchy_path.read_text(encoding="utf-8")
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
self.assertNotIn("快卖光 ¥99.99", hierarchy)
self.assertIn("提交订单 ¥12.88", hierarchy)
self.assertIn("submit-button", hierarchy)
self.assertEqual(manifest["privacy_cleanup"]["preserved_crossing_price_nodes"], 2)
def test_same_raw_and_config_produce_identical_derived_files(self) -> None:
with TemporaryDirectory() as temporary:
root = Path(temporary)
left_raw = _write_raw(root / "left")
right_raw = _write_raw(root / "right")
left = sanitize_sku_panel_evidence(left_raw, left_raw.parent / "derived")
right = sanitize_sku_panel_evidence(right_raw, right_raw.parent / "derived")
for left_path, right_path in (
(left.screenshot_path, right.screenshot_path),
(left.hierarchy_path, right.hierarchy_path),
(left.manifest_path, right.manifest_path),
):
self.assertEqual(left_path.read_bytes(), right_path.read_bytes())
def test_manifest_records_distinct_screenshot_and_xml_coordinate_spaces(self) -> None:
with TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary))
result = sanitize_sku_panel_evidence(raw, raw.parent / "derived")
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
self.assertEqual(
manifest["screenshot_space"],
{"width": 1080, "height": 2376, "privacy_mask_rectangle": [0, 0, 1080, 540]},
)
self.assertEqual(
manifest["xml_coordinate_space"],
{
"width": 1080,
"height": 2376,
"privacy_mask_rectangle": [0, 0, 1080, 540],
"observed_max": {"right": 1080, "bottom": 2376},
},
)
def test_hash_and_metadata_mismatch_fail_closed_without_leak(self) -> None:
scenarios = (
("hash", {}, "screenshot"),
("model", {"model": "other"}, None),
("android", {"android_version": "15"}, None),
("version", {"pdd_version": "8.17.1"}, None),
("goods", {"goods_id": "123"}, None),
("state", {"state": "guessed"}, None),
("old-screenshot-space", {"size": (1080, 2400)}, None),
("other-screenshot-space", {"size": (100, 100)}, None),
)
for name, kwargs, corrupt_file in scenarios:
with self.subTest(name=name), TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), **kwargs)
if corrupt_file is not None:
(raw / f"{corrupt_file}.png").write_bytes(b"changed")
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
message = str(raised.exception)
self.assertNotIn(TEST_SERIAL, message)
self.assertNotIn(TEST_ADDRESS, message)
self.assertNotIn(FULL_PHONE, message)
self.assertFalse((raw.parent / "derived").exists())
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
def test_old_and_unknown_human_states_are_rejected(self) -> None:
for state in ("initial", "one-dimension-selected", "all-dimensions-selected", "guessed"):
with self.subTest(state=state), TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), state=state)
with self.assertRaises(SkuEvidenceSanitizationError):
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
self.assertFalse((raw.parent / "derived").exists())
def test_malformed_inputs_and_bounds_or_phone_residue_fail_closed(self) -> None:
malformed = (
("manifest", None),
("png", None),
("xml", None),
("bounds", "<hierarchy><node text='missing bounds' /></hierarchy>"),
(
"private-parent-with-lower-child",
"<hierarchy><node bounds='[0,0][1080,540]'><node bounds='[0,540][1080,2376]' text='x' /></node></hierarchy>",
),
("full-phone-below", f"<hierarchy><node bounds='[0,540][1080,2376]' text='{FULL_PHONE}' /></hierarchy>"),
("masked-phone-below", f"<hierarchy><node bounds='[0,540][1080,2376]' text='{MASKED_PHONE}' /></hierarchy>"),
)
for kind, xml in malformed:
with self.subTest(kind=kind), TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), xml=xml)
if kind == "manifest":
(raw / "manifest.json").write_text("{invalid", encoding="utf-8")
elif kind == "png":
(raw / "screenshot.png").write_bytes(b"not a png")
manifest = json.loads((raw / "manifest.json").read_text(encoding="utf-8"))
manifest["artifacts"][0]["sha256"] = _hash(raw / "screenshot.png")
(raw / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
elif kind == "xml":
(raw / "hierarchy.xml").write_text("<hierarchy>", encoding="utf-8")
manifest = json.loads((raw / "manifest.json").read_text(encoding="utf-8"))
manifest["artifacts"][1]["sha256"] = _hash(raw / "hierarchy.xml")
(raw / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
with self.assertRaises(SkuEvidenceSanitizationError):
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
self.assertFalse((raw.parent / "derived").exists())
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
def test_phone_recheck_rejects_separator_mask_and_cross_node_bypasses(self) -> None:
variants = (
"138 0013-8000",
"138****0000",
"138••••0000",
"138xxxx0000",
"138XXXX0000",
)
for value in variants:
xml = (
"<hierarchy>"
"<node bounds='[0,0][1080,540]' text='private' />"
f"<node bounds='[0,540][1080,2376]' text='{value}' />"
"</hierarchy>"
)
with self.subTest(value=value), TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), xml=xml)
with self.assertRaises(SkuEvidenceSanitizationError):
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
self.assertFalse((raw.parent / "derived").exists())
split_xml = (
"<hierarchy>"
"<node bounds='[0,0][1080,540]' text='private' />"
"<node bounds='[0,540][1080,1000]' text='138' content-desc='0013' />"
"<node bounds='[0,1000][1080,2376]' text='8000' />"
"</hierarchy>"
)
with TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), xml=split_xml)
with self.assertRaises(SkuEvidenceSanitizationError):
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
self.assertFalse((raw.parent / "derived").exists())
def test_privacy_geometry_requires_removed_and_retained_nodes(self) -> None:
scenarios = (
("no-private", f"<hierarchy><node bounds='[0,540][1080,2376]' text='{SAFE_TEXT}' /></hierarchy>"),
("no-below", "<hierarchy><node bounds='[0,0][1080,540]' text='private' /></hierarchy>"),
)
for name, xml in scenarios:
with self.subTest(name=name), TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), xml=xml)
with self.assertRaises(SkuEvidenceSanitizationError):
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
self.assertFalse((raw.parent / "derived").exists())
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
def test_xml_coordinate_space_must_have_exact_configured_maximums(self) -> None:
scenarios = (
(
"short-width",
"<hierarchy><node bounds='[0,0][1079,540]' text='private' />"
"<node bounds='[0,540][1079,2376]' text='safe' /></hierarchy>",
"1079x2376",
),
(
"short-height",
"<hierarchy><node bounds='[0,0][1080,540]' text='private' />"
"<node bounds='[0,540][1080,2375]' text='safe' /></hierarchy>",
"1080x2375",
),
(
"wide-width",
"<hierarchy><node bounds='[0,0][1081,540]' text='private' />"
"<node bounds='[0,540][1081,2376]' text='safe' /></hierarchy>",
"1081x2376",
),
(
"old-v2-xml-height",
"<hierarchy><node bounds='[0,0][1080,540]' text='private' />"
"<node bounds='[0,540][1080,2400]' text='safe' /></hierarchy>",
"1080x2400",
),
)
for name, xml, observed in scenarios:
with self.subTest(name=name), TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary), xml=xml)
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
self.assertIn(observed, str(raised.exception))
self.assertNotIn(TEST_SERIAL, str(raised.exception))
self.assertFalse((raw.parent / "derived").exists())
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
def test_manifest_schema_package_and_artifact_structure_are_required(self) -> None:
def mutate(manifest: dict[str, object], kind: str) -> None:
if kind == "schema":
manifest["schema_version"] = 2
elif kind == "package":
manifest["device"]["pdd_package"] = "com.example.other" # type: ignore[index]
elif kind == "missing":
manifest.pop("artifacts")
elif kind == "duplicate":
manifest["artifacts"].append(manifest["artifacts"][0]) # type: ignore[index]
elif kind == "bad-hash":
manifest["artifacts"][0]["sha256"] = "g" * 64 # type: ignore[index]
for kind in ("schema", "package", "missing", "duplicate", "bad-hash"):
with self.subTest(kind=kind), TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary))
manifest_path = raw / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
mutate(manifest, kind)
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
with self.assertRaises(SkuEvidenceSanitizationError) as raised:
sanitize_sku_panel_evidence(raw, raw.parent / "derived")
self.assertNotIn(TEST_SERIAL, str(raised.exception))
self.assertNotIn(TEST_ADDRESS, str(raised.exception))
self.assertFalse((raw.parent / "derived").exists())
def test_target_created_during_publish_is_preserved_and_staging_is_removed(self) -> None:
with TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary))
target = raw.parent / "derived"
def race_rename(destination: Path) -> None:
destination.mkdir()
(destination / "sentinel.txt").write_text("keep", encoding="utf-8")
raise FileExistsError("simulated publish race")
with patch("cmbuyer_client.device.sku_evidence_sanitizer.Path.rename", side_effect=race_rename):
with self.assertRaises(SkuEvidenceSanitizationError):
sanitize_sku_panel_evidence(raw, target)
self.assertEqual((target / "sentinel.txt").read_text(encoding="utf-8"), "keep")
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
def test_existing_derived_is_preserved_without_reading_or_writing_raw(self) -> None:
with TemporaryDirectory() as temporary:
raw = _write_raw(Path(temporary))
target = raw.parent / "derived"
target.mkdir()
sentinel = target / "sentinel.txt"
sentinel.write_text("keep", encoding="utf-8")
with self.assertRaises(SkuEvidenceSanitizationError):
sanitize_sku_panel_evidence(raw, target)
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
self.assertEqual(list(raw.parent.glob(".derived.staging-*")), [])
def test_directory_contract_rejects_non_sibling_paths(self) -> None:
with TemporaryDirectory() as temporary:
root = Path(temporary)
raw = _write_raw(root)
with self.assertRaises(SkuEvidenceSanitizationError):
sanitize_sku_panel_evidence(raw, root / "not-derived")
with self.assertRaises(SkuEvidenceSanitizationError):
sanitize_sku_panel_evidence(root / "not-raw", root / "derived")
+1
View File
@@ -0,0 +1 @@
"""拼多多受限打开模块的离线测试。"""
@@ -0,0 +1,14 @@
<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">
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true">
<node text="快要抢光" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[900,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true" />
</node>
</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 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,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,474][1080,2328]">
<node package="" class="android.view.ViewGroup" bounds="[396,498][895,570]">
<node text="快卖完 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" clickable="false" enabled="true" visible-to-user="true" bounds="[396,503][712,570]" />
<node text="¥29.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" clickable="false" enabled="true" visible-to-user="true" bounds="[730,503][895,570]" />
</node>
<node text="已选: 黑色 CHA (纯棉) M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" clickable="false" enabled="true" visible-to-user="true" bounds="[396,654][1053,716]" />
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1000][1080,1631]">
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" selected="true" clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]" />
<node content-desc="粉红" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" selected="false" clickable="true" enabled="true" visible-to-user="true" bounds="[456,1000][690,1172]" />
</node>
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1637][1044,2045]">
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1637][1044,1718]">
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" clickable="false" enabled="true" visible-to-user="true" bounds="[36,1654][114,1700]" />
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1730][1044,2045]">
<node text="M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" selected="true" clickable="true" enabled="true" visible-to-user="true" bounds="[439,1730][831,1815]" />
<node text="L(建议115-130)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" selected="false" clickable="true" enabled="true" visible-to-user="true" bounds="[840,1730][1044,1815]" />
</node>
</node>
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" clickable="true" enabled="true" visible-to-user="true" bounds="[357,2181][722,2328]">
<node text="提交订单 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" clickable="false" enabled="true" visible-to-user="true" bounds="[369,2225][710,2284]" />
</node>
</node>
</hierarchy>
+266
View File
@@ -0,0 +1,266 @@
"""商品打开围栏的离线测试;所有设备和命令均为 fake。"""
from __future__ import annotations
import base64
from io import BytesIO
from pathlib import Path
import sys
from tempfile import TemporaryDirectory
import unittest
from PIL import Image
CLIENT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection, IntentLaunchSummary
from cmbuyer_client.pdd.product_open import (
ProductOpenCapturer,
ProductOpenTimeoutError,
ProductOpenUiDevice,
ProductHierarchyCaptureError,
ProductPackageMismatchError,
ProductScreenshotCaptureError,
ProductVersionMismatchError,
)
from cmbuyer_client.pdd.product_url import ProductUrl, ProductUrlError
SERIAL = "192.168.0.173:5555"
URL = "https://mobile.yangkeduo.com/goods.html?goods_id=123"
HIERARCHY = "<?xml version='1.0' encoding='UTF-8'?><hierarchy rotation='0'><node /></hierarchy>"
def _png_base64() -> str:
image_data = BytesIO()
Image.new("RGB", (1, 1), color="white").save(image_data, format="PNG")
return base64.b64encode(image_data.getvalue()).decode("ascii")
class FakeAdbClient:
def __init__(self) -> None:
self.calls: list[tuple[str, str | None]] = []
self.inspection = DeviceInspection(
device=AdbDevice(serial=SERIAL, state="device", model="PKG110"),
model="PKG110",
android_version="16",
)
def inspect(self, serial: str) -> DeviceInspection:
self.calls.append(("inspect", serial))
return self.inspection
def start_pdd_view_intent(self, serial: str, goods_id: str) -> IntentLaunchSummary:
self.calls.append(("intent", goods_id))
return IntentLaunchSummary(status="ok", returncode=0)
class FakeUiDevice:
def __init__(
self,
*,
version: str = "8.17.0",
current_package: str = "com.xunmeng.pinduoduo",
current_packages: list[str] | None = None,
hierarchy: str = HIERARCHY,
timeout_on_screenshot: bool = False,
) -> None:
self.version = version
self.current_package = current_package
self.current_packages = list(current_packages) if current_packages is not None else None
self.hierarchy = hierarchy
self.timeout_on_screenshot = timeout_on_screenshot
self.calls: list[str] = []
def app_info(self, package_name: str) -> dict[str, str]:
self.calls.append("app_info")
return {"versionName": self.version}
def app_current(self) -> dict[str, str]:
self.calls.append("app_current")
if self.current_packages:
package = self.current_packages.pop(0)
self.current_package = package
return {"package": self.current_package, "activity": "sensitive.activity.name"}
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
self.calls.append(method)
if method == "takeScreenshot":
if self.timeout_on_screenshot:
raise TimeoutError("raw remote detail")
return _png_base64()
if method == "dumpWindowHierarchy":
return self.hierarchy
raise AssertionError(f"unexpected RPC {method}")
class ProductOpenTests(unittest.TestCase):
def _capturer(self, adb: FakeAdbClient, device: FakeUiDevice, **kwargs: object) -> ProductOpenCapturer:
return ProductOpenCapturer(adb, lambda serial: device, timeout_seconds=2, **kwargs)
def test_success_uses_canonical_url_and_redacted_atomic_manifest(self) -> None:
adb = FakeAdbClient()
device = FakeUiDevice()
with TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
result = self._capturer(adb, device).open_and_capture(SERIAL, URL, target)
manifest = result.manifest_path.read_text(encoding="utf-8")
self.assertTrue(result.screenshot_path.exists())
self.assertTrue(result.hierarchy_path.exists())
self.assertEqual(adb.calls, [("inspect", SERIAL), ("intent", "123")])
self.assertEqual(device.calls, ["app_info", "app_current", "takeScreenshot", "dumpWindowHierarchy"])
self.assertIn('"goods_id": "123"', manifest)
self.assertIn('"canonical_url": "https://mobile.yangkeduo.com/goods.html?goods_id=123"', manifest)
self.assertNotIn(SERIAL, manifest)
self.assertNotIn("sensitive.activity.name", manifest)
self.assertNotIn(HIERARCHY, manifest)
def test_version_mismatch_halts_before_intent(self) -> None:
adb = FakeAdbClient()
for version in ("8.17.1", " 8.17.0 "):
with self.subTest(version=version), TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
with self.assertRaises(ProductVersionMismatchError):
self._capturer(adb, FakeUiDevice(version=version)).open_and_capture(SERIAL, URL, target)
self.assertEqual(adb.calls[-1:], [("inspect", SERIAL)])
self.assertFalse(target.exists())
def test_public_entry_rejects_caller_constructed_url_value_object(self) -> None:
adb = FakeAdbClient()
with TemporaryDirectory() as temporary:
with self.assertRaises(ProductUrlError):
self._capturer(adb, FakeUiDevice()).open_and_capture(
SERIAL,
ProductUrl(goods_id="123", canonical_url="https://example.invalid/"), # type: ignore[arg-type]
Path(temporary) / "evidence",
)
self.assertEqual(adb.calls, [])
def test_foreground_package_mismatch_halts_before_capture(self) -> None:
adb = FakeAdbClient()
device = FakeUiDevice(current_package="com.example.other")
clock = FakeClock()
with TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
with self.assertRaises(ProductPackageMismatchError):
self._capturer(
adb,
device,
foreground_poll_interval_seconds=0.5,
monotonic_clock=clock.monotonic,
sleep_function=clock.sleep,
).open_and_capture(SERIAL, URL, target)
self.assertEqual(adb.calls, [("inspect", SERIAL), ("intent", "123")])
self.assertEqual(device.calls, ["app_info", "app_current", "app_current", "app_current", "app_current", "app_current"])
self.assertEqual(clock.sleeps, [0.5, 0.5, 0.5, 0.5])
self.assertFalse(target.exists())
def test_foreground_package_poll_waits_for_pdd_before_reading_evidence(self) -> None:
adb = FakeAdbClient()
device = FakeUiDevice(current_packages=["com.example.other", "com.xunmeng.pinduoduo"])
clock = FakeClock()
with TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
result = self._capturer(
adb,
device,
foreground_poll_interval_seconds=0.25,
monotonic_clock=clock.monotonic,
sleep_function=clock.sleep,
).open_and_capture(SERIAL, URL, target)
self.assertTrue(result.manifest_path.exists())
self.assertEqual(clock.sleeps, [0.25])
self.assertEqual(
device.calls,
["app_info", "app_current", "app_current", "takeScreenshot", "dumpWindowHierarchy"],
)
def test_foreground_package_poll_stops_at_deadline_without_evidence(self) -> None:
adb = FakeAdbClient()
device = FakeUiDevice(current_packages=["com.example.other", "", "com.example.other"])
clock = FakeClock()
with TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
with self.assertRaises(ProductPackageMismatchError):
self._capturer(
adb,
device,
foreground_poll_interval_seconds=0.8,
monotonic_clock=clock.monotonic,
sleep_function=clock.sleep,
).open_and_capture(SERIAL, URL, target)
self.assertEqual(len(clock.sleeps), 3)
for actual, expected in zip(clock.sleeps, (0.8, 0.8, 0.4), strict=True):
self.assertAlmostEqual(actual, expected)
self.assertEqual(device.calls, ["app_info", "app_current", "app_current", "app_current", "app_current"])
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
def test_foreground_poll_interval_must_be_positive_and_finite(self) -> None:
for interval in (0, -0.1, float("inf"), float("nan"), True):
with self.subTest(interval=interval):
with self.assertRaises(ValueError):
ProductOpenCapturer(
FakeAdbClient(),
lambda serial: FakeUiDevice(),
timeout_seconds=2,
foreground_poll_interval_seconds=interval, # type: ignore[arg-type]
)
def test_timeout_and_invalid_hierarchy_leave_no_partial_evidence(self) -> None:
scenarios = (
(FakeUiDevice(timeout_on_screenshot=True), ProductOpenTimeoutError),
(FakeUiDevice(hierarchy="<not-hierarchy />"), ProductHierarchyCaptureError),
)
for device, error_type in scenarios:
with self.subTest(error_type=error_type.__name__), TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
with self.assertRaises(error_type):
self._capturer(FakeAdbClient(), device).open_and_capture(SERIAL, URL, target)
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
def test_invalid_screenshot_is_a_distinct_redacted_failure(self) -> None:
class InvalidScreenshotDevice(FakeUiDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "takeScreenshot":
self.calls.append(method)
return "not valid base64!"
return super().jsonrpc_call(method, params, timeout)
with TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
with self.assertRaises(ProductScreenshotCaptureError) as raised:
self._capturer(FakeAdbClient(), InvalidScreenshotDevice()).open_and_capture(SERIAL, URL, target)
self.assertNotIn("base64", str(raised.exception).lower())
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
def test_read_only_protocol_has_no_ui_operation_methods(self) -> None:
forbidden = {"click", "swipe", "send_keys", "set_text", "press", "long_click"}
self.assertTrue(forbidden.isdisjoint(ProductOpenUiDevice.__dict__))
self.assertEqual(base64.b64decode(_png_base64())[:8], b"\x89PNG\r\n\x1a\n")
class FakeClock:
def __init__(self) -> None:
self.value = 0.0
self.sleeps: list[float] = []
def monotonic(self) -> float:
return self.value
def sleep(self, seconds: float) -> None:
self.sleeps.append(seconds)
self.value += seconds
+49
View File
@@ -0,0 +1,49 @@
"""canonical 商品 URL 的离线解析测试。"""
from __future__ import annotations
from pathlib import Path
import sys
import unittest
CLIENT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
class ProductUrlTests(unittest.TestCase):
def test_rebuilds_url_from_goods_id(self) -> None:
link = parse_product_url("https://mobile.yangkeduo.com/goods.html?goods_id=00123")
self.assertEqual(link.goods_id, "00123")
self.assertEqual(
link.canonical_url,
"https://mobile.yangkeduo.com/goods.html?goods_id=00123",
)
def test_rejects_noncanonical_and_ambiguous_urls(self) -> None:
rejected = (
"http://mobile.yangkeduo.com/goods.html?goods_id=123",
"https://other.example/goods.html?goods_id=123",
"https://mobile.yangkeduo.com/other.html?goods_id=123",
"https://user@mobile.yangkeduo.com/goods.html?goods_id=123",
"https://mobile.yangkeduo.com:8443/goods.html?goods_id=123",
"https://mobile.yangkeduo.com:443/goods.html?goods_id=123",
"https://mobile.yangkeduo.com/goods.html?goods_id=123#fragment",
"https://mobile.yangkeduo.com/goods.html",
"https://mobile.yangkeduo.com/goods.html?goods_id=123&goods_id=456",
"https://mobile.yangkeduo.com/goods.html?goods_id=123&source=share",
"https://mobile.yangkeduo.com/goods.html?goods_id=12a",
"https://mobile.yangkeduo.com/goods.html?goods_id=%EF%BC%91%EF%BC%92%EF%BC%93",
"https://mobile.yangkeduo.com/goods.html?goods_id=",
" https://mobile.yangkeduo.com/goods.html?goods_id=123",
"https://MOBILE.YANGKEDUO.COM/goods.html?goods_id=123",
"https://mobile.yangkeduo.com/goods.html?goods_id=%31%32%33",
)
for value in rejected:
with self.subTest(value=value):
with self.assertRaises(ProductUrlError):
parse_product_url(value)
+263
View File
@@ -0,0 +1,263 @@
"""人工声明规格面板状态的离线只读取证测试。"""
from __future__ import annotations
import argparse
import base64
from importlib.util import module_from_spec, spec_from_file_location
from io import BytesIO
from pathlib import Path
import sys
from tempfile import TemporaryDirectory
import unittest
from PIL import Image
CLIENT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
from cmbuyer_client.pdd.product_url import ProductUrlError
from cmbuyer_client.pdd.sku_panel_spike import (
HUMAN_DECLARED_STATES,
SkuPanelDeclaredStateError,
SkuPanelEvidenceCapturer,
SkuPanelEvidenceError,
SkuPanelEvidenceTimeoutError,
SkuPanelHierarchyError,
SkuPanelPackageMismatchError,
SkuPanelScreenshotError,
SkuPanelUiDevice,
SkuPanelVersionMismatchError,
)
SERIAL = "192.168.0.173:5555"
URL = "https://mobile.yangkeduo.com/goods.html?goods_id=123"
HIERARCHY = "<?xml version='1.0' encoding='UTF-8'?><hierarchy><node text='sensitive page text' /></hierarchy>"
def _png_base64() -> str:
image_data = BytesIO()
Image.new("RGB", (1, 1), color="white").save(image_data, format="PNG")
return base64.b64encode(image_data.getvalue()).decode("ascii")
class FakeAdbClient:
def __init__(self) -> None:
self.calls: list[str] = []
self.inspection = DeviceInspection(
device=AdbDevice(serial=SERIAL, state="device", model="PKG110"),
model="PKG110",
android_version="16",
)
def inspect(self, serial: str) -> DeviceInspection:
self.calls.append(serial)
return self.inspection
class FakeUiDevice:
def __init__(
self,
*,
version: str = "8.17.0",
package: str = "com.xunmeng.pinduoduo",
screenshot: str | None = None,
hierarchy: str = HIERARCHY,
) -> None:
self.version = version
self.package = package
self.screenshot = screenshot if screenshot is not None else _png_base64()
self.hierarchy = hierarchy
self.calls: list[str] = []
def app_info(self, package_name: str) -> dict[str, str]:
self.calls.append("app_info")
return {"versionName": self.version}
def app_current(self) -> dict[str, str]:
self.calls.append("app_current")
return {"package": self.package, "activity": "sensitive.activity.name"}
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
self.calls.append(method)
if method == "takeScreenshot":
return self.screenshot
if method == "dumpWindowHierarchy":
return self.hierarchy
raise AssertionError(f"unexpected RPC {method}")
def _load_spike_script() -> object:
script_path = CLIENT_ROOT / "scripts" / "capture_sku_panel_spike.py"
spec = spec_from_file_location("capture_sku_panel_spike_for_test", script_path)
assert spec is not None and spec.loader is not None
module = module_from_spec(spec)
spec.loader.exec_module(module)
return module
class SkuPanelEvidenceTests(unittest.TestCase):
def _capturer(self, adb: FakeAdbClient, device: FakeUiDevice) -> SkuPanelEvidenceCapturer:
return SkuPanelEvidenceCapturer(adb, lambda serial: device, timeout_seconds=2)
def test_all_human_declared_states_publish_redacted_manifest(self) -> None:
for state in sorted(HUMAN_DECLARED_STATES):
with self.subTest(state=state), TemporaryDirectory() as temporary:
adb = FakeAdbClient()
device = FakeUiDevice()
target = Path(temporary) / "evidence"
result = self._capturer(adb, device).capture(SERIAL, URL, state, target)
manifest = result.manifest_path.read_text(encoding="utf-8")
self.assertEqual(adb.calls, [SERIAL])
self.assertEqual(device.calls, ["app_info", "app_current", "takeScreenshot", "dumpWindowHierarchy"])
self.assertIn(f'"human_declared_state": "{state}"', manifest)
self.assertIn('"goods_id": "123"', manifest)
self.assertIn('"canonical_url": "https://mobile.yangkeduo.com/goods.html?goods_id=123"', manifest)
self.assertNotIn("detected_state", manifest)
self.assertNotIn(SERIAL, manifest)
self.assertNotIn("sensitive.activity.name", manifest)
self.assertNotIn(HIERARCHY, manifest)
def test_invalid_state_and_url_fail_before_device_access(self) -> None:
adb = FakeAdbClient()
rejected_states = ("initial", "one-dimension-selected", "all-dimensions-selected", "guessed")
with TemporaryDirectory() as temporary:
for state in rejected_states:
with self.subTest(state=state), self.assertRaises(SkuPanelDeclaredStateError):
self._capturer(adb, FakeUiDevice()).capture(SERIAL, URL, state, Path(temporary) / "state")
with self.assertRaises(ProductUrlError):
self._capturer(adb, FakeUiDevice()).capture(
SERIAL,
"https://mobile.yangkeduo.com/goods.html?goods_id=12x",
"panel-opened-target-preselected",
Path(temporary) / "url",
)
self.assertEqual(adb.calls, [])
def test_version_or_foreground_package_mismatch_stops_before_artifacts(self) -> None:
scenarios = (
(FakeUiDevice(version="8.17.1"), SkuPanelVersionMismatchError, ["app_info"]),
(FakeUiDevice(package="com.example.other"), SkuPanelPackageMismatchError, ["app_info", "app_current"]),
)
for device, error_type, expected_calls in scenarios:
with self.subTest(error_type=error_type.__name__), TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
with self.assertRaises(error_type):
self._capturer(FakeAdbClient(), device).capture(SERIAL, URL, "panel-opened-target-preselected", target)
self.assertEqual(device.calls, expected_calls)
self.assertFalse(target.exists())
def test_screenshot_and_xml_failure_leave_no_partial_evidence(self) -> None:
scenarios = (
(FakeUiDevice(screenshot="not valid base64!"), SkuPanelScreenshotError),
(FakeUiDevice(hierarchy="<not-hierarchy />"), SkuPanelHierarchyError),
)
for device, error_type in scenarios:
with self.subTest(error_type=error_type.__name__), TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
with self.assertRaises(error_type):
self._capturer(FakeAdbClient(), device).capture(SERIAL, URL, "panel-opened-target-preselected", target)
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
def test_screenshot_timeout_is_redacted_and_leaves_no_partial_evidence(self) -> None:
class TimeoutScreenshotDevice(FakeUiDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "takeScreenshot":
self.calls.append(method)
raise TimeoutError("adb 192.168.0.173:5555 raw detail")
return super().jsonrpc_call(method, params, timeout)
with TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
with self.assertRaises(SkuPanelEvidenceTimeoutError) as raised:
self._capturer(FakeAdbClient(), TimeoutScreenshotDevice()).capture(SERIAL, URL, "panel-opened-target-preselected", target)
self.assertNotIn(SERIAL, str(raised.exception))
self.assertNotIn("adb", str(raised.exception).lower())
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".evidence.staging-*")), [])
def test_existing_output_directory_is_not_overwritten_or_connected(self) -> None:
adb = FakeAdbClient()
device = FakeUiDevice()
with TemporaryDirectory() as temporary:
target = Path(temporary) / "evidence"
target.mkdir()
sentinel = target / "sentinel.txt"
sentinel.write_text("keep", encoding="utf-8")
with self.assertRaises(SkuPanelEvidenceError):
self._capturer(adb, device).capture(SERIAL, URL, "panel-opened-target-preselected", target)
self.assertEqual(adb.calls, [])
self.assertEqual(device.calls, [])
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
def test_protocol_has_no_ui_or_purchase_operation_methods(self) -> None:
forbidden = {
"click",
"swipe",
"send_keys",
"set_text",
"press",
"open_product",
"open_sku_panel",
"set_quantity",
"go_to_order_confirm",
"submit_order",
"pay",
}
self.assertTrue(forbidden.isdisjoint(SkuPanelUiDevice.__dict__))
self.assertEqual({name for name in SkuPanelEvidenceCapturer.__dict__ if not name.startswith("_")}, {"capture"})
class SkuPanelSpikeCliTests(unittest.TestCase):
def test_validate_arguments_rejects_invalid_serial_timeout_state_and_url(self) -> None:
script = _load_spike_script()
valid = {
"serial": SERIAL,
"url": URL,
"goods_id": None,
"state": "panel-opened-target-preselected",
"output_dir": Path("evidence"),
"timeout": 10.0,
"adb": "adb",
}
invalid_values = (
("serial", ""),
("timeout", 0),
("timeout", float("inf")),
("state", "not-declared"),
("state", "initial"),
("state", "one-dimension-selected"),
("state", "all-dimensions-selected"),
("url", "https://mobile.yangkeduo.com/goods.html?goods_id=bad"),
)
for field, value in invalid_values:
with self.subTest(field=field, value=value):
arguments = argparse.Namespace(**(valid | {field: value}))
with self.assertRaises((ValueError, ProductUrlError)):
script.validate_arguments(arguments) # type: ignore[attr-defined]
def test_goods_id_is_rebuilt_as_canonical_url(self) -> None:
script = _load_spike_script()
arguments = argparse.Namespace(
serial=SERIAL,
url=None,
goods_id="00123",
state="target-selection-restored",
output_dir=Path("evidence"),
timeout=10.0,
adb="adb",
)
link = script.validate_arguments(arguments) # type: ignore[attr-defined]
self.assertEqual(link.canonical_url, "https://mobile.yangkeduo.com/goods.html?goods_id=00123")
+689
View File
@@ -0,0 +1,689 @@
from __future__ import annotations
import ast
import base64
from contextlib import redirect_stderr
from io import BytesIO
import importlib.util
from pathlib import Path
from tempfile import TemporaryDirectory
import unittest
from unittest.mock import patch
from xml.etree import ElementTree
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.pdd import SkuSelectionError, SkuSelectionFlow, SkuSelectionRunner
from cmbuyer_client.pdd.sku_selection import SkuPanelDevice, _action_bounds, resolve_task_selection
from cmbuyer_client.pdd.sku_selection_runner import (
SkuSelectionDeviceAdapterError,
SkuSelectionRunError,
SkuSelectionScreenshotError,
UiautomatorSkuPanelAdapter,
)
_FIXTURE = Path(__file__).with_name("fixtures") / "sku_panel_8_17_0.xml"
_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(纯棉)"
_TASK_SIZE = "M(建议100-115)"
_PRODUCT_PAGE = _ENTRY_FIXTURE.read_text(encoding="utf-8")
def _png_base64() -> str:
image = Image.new("RGB", (1080, 2376), "white")
raw = BytesIO()
image.save(raw, format="PNG")
return base64.b64encode(raw.getvalue()).decode("ascii")
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.version = "8.17.0"
self.package = "com.xunmeng.pinduoduo"
self.screenshot = _png_base64() if screenshot is None else screenshot
self.calls: list[tuple[object, ...]] = []
self.fail_color_readback = False
def app_info(self, package_name: str) -> dict[str, str]:
self.calls.append(("app_info", package_name))
return {"versionName": self.version}
def app_current(self) -> dict[str, str]:
self.calls.append(("app_current",))
return {"package": self.package}
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 self.screenshot
if method == "pressKey":
self.hierarchy = "<hierarchy />"
return ""
if method == "click":
if not isinstance(params, list) or len(params) != 2:
raise AssertionError(params)
self._apply_tap(int(params[0]), int(params[1]))
return ""
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:
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")
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")
if self.hierarchy != _PRODUCT_PAGE:
self.hierarchy = self.panel_hierarchy
def _center(bounds: str) -> tuple[int, int]:
left_top, right_bottom = bounds.split("][")
left, top = (int(value) for value in left_top.removeprefix("[").split(","))
right, bottom = (int(value) for value in right_bottom.removesuffix("]").split(","))
return left + (right - left) // 2, top + (bottom - top) // 2
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
def _mutate_entry(depth: int, attribute: str, value: str) -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
_entry_chain(root)[depth].set(attribute, value)
return ElementTree.tostring(root, encoding="unicode")
def _without_entry() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
root.remove(_entry_chain(root)[4])
return ElementTree.tostring(root, encoding="unicode")
def _duplicate_entry() -> str:
root = ElementTree.fromstring(_PRODUCT_PAGE)
entry_root = _entry_chain(root)[4]
root.append(ElementTree.fromstring(ElementTree.tostring(entry_root, encoding="unicode")))
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]
def _tap_centers(device: _RawDevice) -> list[tuple[int, int]]:
return [tuple(call[2]) for call in _actions(device, "click")] # type: ignore[misc]
class _FakeAdb:
def __init__(self) -> None:
self.calls: list[tuple[object, ...]] = []
self.inspection = DeviceInspection(AdbDevice(serial="device-1", state="device"), "PKG110", "16")
self.on_intent: callable | None = None
def inspect(self, serial: str) -> DeviceInspection:
self.calls.append(("inspect", serial))
return self.inspection
def start_pdd_view_intent(self, serial: str, goods_id: str) -> object:
self.calls.append(("intent", serial, goods_id))
if self.on_intent is not None:
self.on_intent()
return object()
class SkuSelectionFlowTests(unittest.TestCase):
def _assert_entry_rejected_without_click(self, hierarchy: str) -> None:
now = [0.0]
device = _RawDevice(hierarchy)
flow = SkuSelectionFlow(
UiautomatorSkuPanelAdapter(device, 10),
0.01,
0.01,
lambda: now[0],
lambda seconds: now.__setitem__(0, now[0] + seconds),
)
with self.assertRaises(SkuSelectionError):
flow.open_sku_panel(_TARGET_URL)
self.assertEqual(_actions(device, "click"), [])
def test_target_mapping_is_exact_and_success_path_restores_target(self) -> None:
device = _RawDevice()
adapter = UiautomatorSkuPanelAdapter(device, 10)
flow = SkuSelectionFlow(adapter)
flow.open_sku_panel(_TARGET_URL)
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)])
def test_full_verified_entry_structure_taps_exact_text_child_once(self) -> None:
device = _RawDevice()
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
self.assertEqual(_tap_centers(device), [(978, 1333)])
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):
changes = {
"package": "other.package",
"class": "android.widget.Button",
"bounds": "[1,1][2,2]",
"clickable": "true" if expected_clickable[depth] == "false" else "false",
"enabled": "false",
"visible-to-user": "false",
}
for attribute, value in changes.items():
with self.subTest(depth=depth, attribute=attribute):
self._assert_entry_rejected_without_click(_mutate_entry(depth, attribute, value))
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(_without_entry())
self._assert_entry_rejected_without_click(_mutate_entry(0, "clickable", "true"))
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):
resolve_task_selection(color, size)
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8"))
with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).select_sku_options(
resolve_task_selection(_TASK_COLOR, _TASK_SIZE).__class__("粉红", "L(建议115-130)")
)
self.assertEqual(_actions(device, "click"), [])
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]"'),
)
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))
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]"))
with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).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.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)])
def test_non_target_selection_restores_each_dimension_once(self) -> None:
device = _RawDevice()
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10))
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)],
)
def test_price_rejects_coupon_prefix_extra_amount_and_bottom_action(self) -> None:
for replacement in ("券后 ¥12.88", "会员补贴 ¥12.88", "到手 ¥12.88", "实付 ¥12.88", "区间 ¥12.88", "原价 ¥12.88", "划线价 ¥12.88", "最低 ¥12.88", "低至 ¥12.88", "起价 ¥12.88", "快卖完 1 ¥12.88", "快卖完 ¥12.88 ¥11.88"):
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()
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]">',
)
with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(_RawDevice(clickable_parent), 10)).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"}
self.assertTrue(forbidden.isdisjoint(SkuSelectionFlow.__dict__))
self.assertTrue(forbidden.isdisjoint(SkuPanelDevice.__dict__))
self.assertTrue(forbidden.isdisjoint(pdd.__all__))
def test_static_ast_boundary_limits_flow_runner_adapter_and_cli(self) -> None:
root = Path(__file__).resolve().parents[2]
files = (
root / "src" / "cmbuyer_client" / "pdd" / "sku_selection.py",
root / "src" / "cmbuyer_client" / "pdd" / "sku_selection_runner.py",
root / "scripts" / "run_t103_sku_selection.py",
)
forbidden = ("quantity", "confirm", "authorization", "fence", "submit_order", "payment")
for path in files:
source = path.read_text(encoding="utf-8")
with self.subTest(path=path.name):
self.assertTrue(all(token not in source.lower() for token in forbidden))
tree = ast.parse(source)
self.assertFalse(any(isinstance(node, ast.ImportFrom) and node.module in {"selenium", "requests"} for node in ast.walk(tree)))
runner_tree = ast.parse(files[1].read_text(encoding="utf-8"))
click_calls = [node for node in ast.walk(runner_tree) if isinstance(node, ast.Constant) and node.value == "click"]
self.assertEqual(len(click_calls), 1)
def test_entry_wait_rejects_unchanged_or_duplicate_page_without_click(self) -> None:
now = [0.0]
device = _RawDevice()
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10), 0.01, 0.01, lambda: now[0], lambda seconds: now.__setitem__(0, now[0] + seconds))
with self.assertRaises(SkuSelectionError):
flow.open_sku_panel(_TARGET_URL, _PRODUCT_PAGE)
self.assertEqual(_actions(device, "click"), [])
def test_action_postcondition_wait_never_repeats_entry_click(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)
device = NoPanelAfterEntry()
with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
self.assertEqual(_tap_centers(device), [(978, 1333)])
duplicate = _duplicate_entry()
device = _RawDevice(duplicate)
with self.assertRaises(SkuSelectionError):
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).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):
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]")
class SkuSelectionRunnerTests(unittest.TestCase):
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)
def test_runner_atomically_publishes_screenshot_and_redacted_manifest(self) -> None:
adb = _FakeAdb()
device = _RawDevice()
with TemporaryDirectory() as temporary:
target = Path(temporary) / "result"
result = self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertEqual(result.unit_price, "12.88")
manifest = result.manifest_path.read_text(encoding="utf-8")
self.assertTrue(result.screenshot_path.is_file())
self.assertNotIn("device-1", manifest)
self.assertNotIn("hierarchy", manifest)
self.assertNotIn("已选", manifest)
self.assertIn('"unit_price": "12.88"', manifest)
self.assertIn('"selection_status": "restored"', manifest)
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)])
def test_target_created_during_publish_is_preserved_without_staging_residue(self) -> None:
with TemporaryDirectory() as temporary:
target = Path(temporary) / "result"
original_rename = runner_module.os.rename
def create_target_then_rename(source: str | Path, destination: str | Path) -> None:
Path(destination).mkdir()
(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):
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-*")), [])
def test_bad_screenshot_or_existing_target_never_publishes_manifest(self) -> None:
with TemporaryDirectory() as temporary:
target = Path(temporary) / "result"
with self.assertRaises(SkuSelectionScreenshotError):
self._runner(_FakeAdb(), _RawDevice(screenshot="not-image")).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
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 self.assertRaises(SkuSelectionScreenshotError):
self._runner(_FakeAdb(), _RawDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertFalse(target.exists())
self.assertEqual(list(Path(temporary).glob(".write-failure.staging-*")), [])
adb = _FakeAdb()
device = _RawDevice()
target.mkdir()
sentinel = target / "keep"
sentinel.write_text("keep", encoding="utf-8")
with self.assertRaises(SkuSelectionRunError):
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertEqual(adb.calls, [])
self.assertEqual(device.calls, [])
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
def test_device_screen_and_output_preflight_fail_before_any_click(self) -> None:
with TemporaryDirectory() as temporary:
adb = _FakeAdb()
adb.inspection = DeviceInspection(AdbDevice(serial="device-1", state="device"), "wrong", "16")
device = _RawDevice()
with self.assertRaises(SkuSelectionRunError):
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
self.assertEqual(device.calls, [])
class WrongScreenDevice(_RawDevice):
def window_size(self) -> tuple[int, int]:
return 1080, 1920
device = WrongScreenDevice()
with self.assertRaises(SkuSelectionRunError):
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "screen")
self.assertEqual(_actions(device, "click"), [])
parent_file = Path(temporary) / "not-a-directory"
parent_file.write_text("x", encoding="utf-8")
adb = _FakeAdb()
device = _RawDevice()
with self.assertRaises(SkuSelectionRunError):
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, parent_file / "result")
self.assertEqual(adb.calls, [])
self.assertEqual(device.calls, [])
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):
self._runner(_FakeAdb(), _RawDevice(screenshot=base64.b64encode(raw.getvalue()).decode("ascii"))).run(
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
)
def test_failure_after_entry_attempts_one_safe_exit_and_hides_device_detail(self) -> None:
adb = _FakeAdb()
device = _RawDevice()
device.fail_color_readback = True
device.select_alternates()
with TemporaryDirectory() as temporary:
with self.assertRaises(SkuSelectionError):
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
self.assertEqual(_actions(device, "pressKey"), [])
class FailingRawDevice(_RawDevice):
def app_info(self, package_name: str) -> dict[str, str]:
raise RuntimeError("device-1 <xml>private</xml>")
with self.assertRaises(SkuSelectionDeviceAdapterError) as raised:
UiautomatorSkuPanelAdapter(FailingRawDevice(), 10).app_info("com.xunmeng.pinduoduo")
self.assertNotIn("device-1", str(raised.exception))
self.assertNotIn("private", str(raised.exception))
def test_unverified_failure_never_sends_blind_back(self) -> None:
class InvalidAfterOptionDevice(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
value = super().jsonrpc_call(method, params, timeout)
if method == "click" and "[396,498][895,570]" in self.hierarchy:
self.hierarchy = "<hierarchy />"
return value
device = InvalidAfterOptionDevice()
device.select_alternates()
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError):
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
self.assertEqual(_actions(device, "pressKey"), [])
def test_adapter_timeout_is_mapped_without_third_party_detail(self) -> None:
class TimeoutRawDevice(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
raise TimeoutError("device-1 <hierarchy>private</hierarchy>")
with self.assertRaises(SkuSelectionRunError) as raised:
UiautomatorSkuPanelAdapter(TimeoutRawDevice(), 10).dump_window_hierarchy()
self.assertNotIn("device-1", str(raised.exception))
self.assertNotIn("private", str(raised.exception))
def test_entry_attempt_is_recorded_before_unconfirmed_click_and_not_retried(self) -> None:
class TimeoutTapDevice(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
self.calls.append(("jsonrpc", method, params, timeout))
if method == "click":
raise TimeoutError("device detail")
return super().jsonrpc_call(method, params, timeout)
adapter = UiautomatorSkuPanelAdapter(TimeoutTapDevice(), 10)
with self.assertRaises(SkuSelectionRunError):
adapter.tap_sku_entry("[900,1312][1056,1355]")
self.assertTrue(adapter.entry_was_tapped)
self.assertEqual(_actions(adapter._device, "click"), [("jsonrpc", "click", [978, 1333], 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]
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)
return super().jsonrpc_call(method, params, timeout)
device = SequenceDevice()
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10), .02, .01, lambda: now[0], lambda x: now.__setitem__(0, now[0] + x))
with self.assertRaises(SkuSelectionError): flow.open_sku_panel(_TARGET_URL, "<hierarchy />")
self.assertEqual(_actions(device, "click"), [])
def test_screenshot_drift_and_foreground_drift_publish_nothing_and_never_back(self) -> None:
for drift in ("color", "size", "price"):
class DriftDevice(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
value = super().jsonrpc_call(method, params, timeout)
if method == "takeScreenshot":
if drift == "price":
self.hierarchy = self.hierarchy.replace("快卖完 ¥12.88", "快卖完 ¥13.88")
else:
root = ElementTree.fromstring(self.hierarchy)
if drift == "color":
for node in root.iter("node"):
if node.get("selected") is not None and ",1000]" in node.get("bounds", ""):
node.set("selected", "false")
next(node for node in root.iter("node") if node.get("content-desc") == "粉红").set("selected", "true")
else:
for node in root.iter("node"):
if node.get("selected") is not None and ",1730]" in node.get("bounds", ""):
node.set("selected", "false")
next(node for node in root.iter("node") if node.get("text") == "L(建议115-130)").set("selected", "true")
self.hierarchy = ElementTree.tostring(root, encoding="unicode")
return value
with self.subTest(drift=drift), TemporaryDirectory() as temporary:
target = Path(temporary) / "out"
with self.assertRaises((SkuSelectionError, SkuSelectionRunError)):
self._runner(_FakeAdb(), DriftDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertFalse(target.exists())
self.assertFalse((target / "manifest.json").exists())
self.assertEqual(list(Path(temporary).glob(".out.staging-*")), [])
device = _RawDevice(); device.select_alternates()
device.package = "other"
with self.assertRaises(SkuSelectionError): SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).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()
with TemporaryDirectory() as temporary:
target = Path(temporary) / "out"
with self.assertRaises(SkuSelectionError):
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
self.assertFalse(target.exists())
self.assertFalse((target / "manifest.json").exists())
self.assertEqual(list(Path(temporary).glob(".out.staging-*")), [])
self.assertEqual(_actions(device, "pressKey"), [])
def test_option_timeout_reconciliation_controls_back_once(self) -> None:
class OptionTimeoutDevice(_RawDevice):
def __init__(self, delivered: bool) -> None:
super().__init__(); self.delivered = delivered; self.clicks = 0
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "click":
self.clicks += 1
if self.clicks == 2:
if self.delivered: super().jsonrpc_call(method, params, timeout)
else: self.calls.append(("jsonrpc", method, params, timeout))
raise TimeoutError("uncertain option")
return super().jsonrpc_call(method, params, timeout)
for delivered, expected_back in ((False, 0), (True, 1)):
with self.subTest(delivered=delivered), TemporaryDirectory() as temporary:
device = OptionTimeoutDevice(delivered); device.select_alternates()
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)
with self.assertRaises(SkuSelectionRunError):
runner.run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "out")
self.assertEqual(len(_actions(device, "click")), 2)
self.assertEqual(len(_actions(device, "pressKey")), expected_back)
def test_back_timeout_is_never_retried(self) -> None:
class BackTimeoutDevice(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "pressKey":
super().jsonrpc_call(method, params, timeout)
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")
self.assertEqual(len(_actions(device, "pressKey")), 1)
def test_entry_click_timeout_reconciles_only_through_verified_flow_exit(self) -> None:
class DeliveredThenTimeout(_RawDevice):
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
if method == "click" and self.hierarchy != _FIXTURE.read_text(encoding="utf-8"):
super().jsonrpc_call(method, params, timeout)
raise TimeoutError("delivery uncertain")
return super().jsonrpc_call(method, params, timeout)
device = DeliveredThenTimeout()
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionRunError):
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
self.assertEqual(len(_actions(device, "click")), 1)
self.assertEqual(len(_actions(device, "pressKey")), 1)
class SkuSelectionCliTests(unittest.TestCase):
def test_cli_accepts_only_target_url_and_task_values(self) -> None:
script = _load_runner_script()
valid = {
"serial": "device-1",
"url": _TARGET_URL,
"color": _TASK_COLOR,
"size": _TASK_SIZE,
"output_dir": Path("evidence"),
"timeout": 10.0,
"adb": "adb",
}
script.validate_arguments(type("Arguments", (), valid)())
for field, value in (("serial", ""), ("url", "https://mobile.yangkeduo.com/goods.html?goods_id=1"), ("color", "黑色 CHA (纯棉)"), ("size", "M(建议100-115)"), ("timeout", 0), ("timeout", float("inf"))):
with self.subTest(field=field, value=value), self.assertRaises((ValueError, SkuSelectionError)):
script.validate_arguments(type("Arguments", (), valid | {field: value})())
def test_cli_main_catches_flow_error_without_traceback_or_page_body(self) -> None:
script = _load_runner_script()
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>")
stderr = BytesIO()
# TextIOWrapper keeps the assertion independent from host console encoding.
import io
text_stderr = io.TextIOWrapper(stderr, encoding="utf-8")
with patch.object(script, "SkuSelectionRunner", FlowFailingRunner), redirect_stderr(text_stderr):
status = script.main([
"--serial", "device-1", "--url", _TARGET_URL, "--color", _TASK_COLOR,
"--size", _TASK_SIZE, "--output-dir", "evidence",
])
text_stderr.flush()
output = stderr.getvalue().decode("utf-8")
self.assertEqual(status, 1)
self.assertNotIn("Traceback", output)
self.assertNotIn("page-body", output)
def _load_runner_script() -> object:
path = Path(__file__).resolve().parents[2] / "scripts" / "run_t103_sku_selection.py"
specification = importlib.util.spec_from_file_location("run_t103_sku_selection_test", path)
if specification is None or specification.loader is None:
raise RuntimeError("无法加载 T-103 运行脚本。")
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)
return module
if __name__ == "__main__":
unittest.main()
+24
View File
@@ -0,0 +1,24 @@
"""验证桌面入口的纯参数处理,不启动 PySide6。"""
from __future__ import annotations
import sys
from pathlib import Path
import unittest
from unittest import mock
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.app import select_application_argv
class ApplicationArgumentsTests(unittest.TestCase):
def test_empty_argument_list_is_not_replaced_with_process_arguments(self) -> None:
with mock.patch("cmbuyer_client.app.sys.argv", ["process-name", "--process-option"]):
self.assertEqual([], select_application_argv([]))
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))
+49
View File
@@ -0,0 +1,49 @@
"""验证日志脱敏边界,不需要 PySide6 或真机。"""
from __future__ import annotations
import sys
from pathlib import Path
import tempfile
import unittest
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.logging_policy import configure_application_logger, redact_text
from cmbuyer_client.runtime import RuntimePaths
class LoggingPolicyTests(unittest.TestCase):
def test_redact_text_hides_required_sensitive_values(self) -> None:
message = (
"token=secret-value authorization: Bearer-value "
"address='浙江省杭州市' phone=13800138000 payment=card-value"
)
redacted = redact_text(message)
for raw_value in ("secret-value", "Bearer-value", "浙江省杭州市", "13800138000", "card-value"):
self.assertNotIn(raw_value, redacted)
self.assertIn("token=[已隐藏]", redacted)
def test_file_handler_writes_only_redacted_text(self) -> None:
with tempfile.TemporaryDirectory() as directory:
paths = RuntimePaths.from_root(Path(directory))
logger = configure_application_logger(paths)
try:
logger.info("token=not-for-log phone=13900139000 payment=not-for-log")
for handler in logger.handlers:
handler.flush()
content = (paths.logs / "client.log").read_text(encoding="utf-8")
finally:
# Windows 不允许在 FileHandler 持有文件时删除临时目录。
for handler in tuple(logger.handlers):
logger.removeHandler(handler)
handler.close()
self.assertNotIn("not-for-log", content)
self.assertNotIn("13900139000", content)
self.assertIn("[已隐藏]", content)
+25
View File
@@ -0,0 +1,25 @@
"""验证本地运行目录策略,不需要连接设备。"""
from __future__ import annotations
import sys
from pathlib import Path
import tempfile
import unittest
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "src"))
from cmbuyer_client.runtime import RuntimePaths
class RuntimePathsTests(unittest.TestCase):
def test_ensure_exists_creates_only_runtime_directories(self) -> None:
with tempfile.TemporaryDirectory() as directory:
paths = RuntimePaths.from_root(Path(directory) / "runtime")
paths.ensure_exists()
self.assertTrue(paths.logs.is_dir())
self.assertTrue(paths.artifacts.is_dir())
+29
View File
@@ -0,0 +1,29 @@
"""验证 wheel 元数据检查脚本,不构建 wheel 或安装运行时依赖。"""
from __future__ import annotations
import sys
from pathlib import Path
import tempfile
import unittest
import zipfile
CLIENT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(CLIENT_ROOT / "scripts"))
from verify_wheel_metadata import dependencies_from_requirements, verify_wheel_metadata
class WheelMetadataTests(unittest.TestCase):
def test_metadata_checker_accepts_dependencies_from_the_single_requirements_file(self) -> None:
expected_dependencies = dependencies_from_requirements(CLIENT_ROOT / "requirements.txt")
with tempfile.TemporaryDirectory() as directory:
wheel_file = Path(directory) / "cmbuyer_client-0.1.0-py3-none-any.whl"
metadata = "Metadata-Version: 2.3\n" + "".join(
f"Requires-Dist: {dependency}\n" for dependency in sorted(expected_dependencies)
)
with zipfile.ZipFile(wheel_file, "w") as wheel:
wheel.writestr("cmbuyer_client-0.1.0.dist-info/METADATA", metadata)
self.assertEqual(set(), verify_wheel_metadata(wheel_file, CLIENT_ROOT / "requirements.txt"))
+49 -36
View File
@@ -9,13 +9,10 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
**采购工具**(桌面端,`client/`)驱动 Android 手机在拼多多完成找货和下单,
**付款始终由人完成**。
第一版 MVP 只做**任务自带商品链接**的情形,**分两趟跑**:
1. **第一趟试选**:桌面端定时领取 → 开商品 → 精确勾选颜色分类和尺码 → 读单价 →
截图 → **退出释放手机** → 回传。
2. **人工确认**:管理员在网页端看「机器选对了吗」,确认后签发授权并**锁定单价**。
3. **第二趟下单**:重新开商品 → 重新选同一规格 → **三道价格闸门** → 提交订单一次 →
任务转「待付款」,人在拼多多核对后付款。
第一版 MVP 只做**任务自带商品链接**的情形。创建任务不授权;管理员勾选 `DRAFT` 并点击
“开始采购(只创建待付款订单)”后,服务端锁定商品、规格、数量和最高总价并签发一次性授权。
采购工具领取后在**同一趟**完成精确选规格、三道价格闸门、数量复核、确认页、提交围栏和唯一一次
“提交订单”点击。中间不等待人工确认;订单创建后转待付款,由人核对和付款。
## 上下文读取
@@ -24,7 +21,7 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
1. [`01-vision.md`](01-vision.md):为什么做、为谁做、什么不做。
2. [`02-requirements.md`](02-requirements.md):MVP 要什么、怎么算达成。
3. [`03-tech-stack.md`](03-tech-stack.md):两端各自的技术选型与验证矩阵。
4. [`04-architecture.md`](04-architecture.md):双端职责、**两趟执行**、**三道价格闸门**、安全边界、数据模型。
4. [`04-architecture.md`](04-architecture.md):双端职责、**单趟执行**、**三道价格闸门**、安全边界、数据模型。
5. [`05-coding-rules.md`](05-coding-rules.md):写代码前必须遵守的规则。
6. [`06-tasks.md`](06-tasks.md):阶段路线图与建议拆分。
7. [`tasks/README.md`](tasks/README.md):任务文件约定。
@@ -61,7 +58,7 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
### 2. 安全边界只能收紧,不能放宽
[`04-architecture.md`](04-architecture.md) 第四节的硬约束(不付款、提交订单四条件与服务端围栏、
规格精确匹配、提交控件唯一、数量必须复核、三道价格闸门、第一趟不下单、外部支付停止、
规格精确匹配、提交控件唯一、数量必须复核、三道价格闸门、隔离能力不越界、外部支付停止、
安全校验停止、敏感信息不提取、授权一次性)**不得在任务中顺手放宽**。
确需变更时先改架构文档并说明理由,再动代码。任何「为了让流程跑通先放宽一下」的改动
@@ -77,29 +74,31 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
理由与三道闸门的定义见 [`04-architecture.md`](04-architecture.md) 第三节。
### 4. 第一趟绝不下单
### 4. 真机能力逐段取证、隔离开放
试选阶段只勾选规格和读价,**绝不点击「现在买」或任何进入下单流程的入口**。
第一趟的代码路径不得引用 `go_to_order_confirm()` 与 `submit_order()`,必须有测试
证明它们不可达。
T-103 的 `SkuSelectionFlow` 只允许证据绑定的受控入口、精确选择、读价和安全退出;不得引用
数量、确认页、提交围栏、提交或付款能力。T-105~T-107 分别取证后才可组合。生产单趟只有在全部
能力均获证据、三道闸门通过且服务端围栏明确许可后才能点击一次;支付能力永不开放。
## 当前阶段
**Phase 0 · 地基(尚未开始编码)。** 仓库目前只有文档,没有生产代码。
**Phase 1 · 真机可行性,并行启动采购服务基础能力。** 两端骨架与核心数据模型已完成,T-103
仍是当前真机关键路径;与真机可读字段无关的采购服务能力不再空等。
优先路径:
执行按任务依赖驱动,**不按 Phase 整段串行等待**。当前优先路径:
1. Phase 0:网页端 / 桌面端 MVP 原型人工确认、两端骨架与数据模型。
2. **Phase 1:真机取证(最高风险,生死线)。**
3. Phase 2:采购服务核心(含**授权超时**,不得推后)。
4. Phase 3:双端打通与**第一趟试选**端到端。
5. Phase 4:**第二趟下单**与收尾。
1. T-111 先冻结“开始采购授权”的单趟契约;同时主审并合入只创建 `DRAFT` 的 T-202。
2. T-111 完成后恢复 **T-103 真机取证**;按依赖推进 T-104 → T-107 的分段安全判据。
3. admin agent 先做 T-209 把旧两趟 schema / 状态机迁到单趟,再推进 T-203 服务端批量开始采购 /
一次性授权;两者不依赖页面选择器。T-205 起的 attempt 与证据字段等待 T-103。
4. Phase 3:双端打通与单趟围栏前 dry-run。
5. Phase 4:单趟真实提交、待付款、调和与收尾。
6. V2 及以后:图搜、Excel、ERP、订单自动核对、AI 辅助。
> **M2 是本项目的生死线**:真机能按链接打开商品、精确勾选颜色分类和尺码、
> **读到该 SKU 的单价**(T-103)。前序项目正是卡在选规格和读价。
> M2 不通过之前不要写 Phase 2 的生产页面;Phase 0 原型只确认流程与信息架构,实际可读
> 字段仍以真机证据为准。
> M2 不通过之前不要写**依赖真机可读字段的生产执行逻辑**;管理会话、`DRAFT` 建单和只锁定
> 已有任务字段的服务端授权事务可以并行。实际可读字段仍以真机证据为准。
## 领取任务规则
@@ -120,12 +119,11 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
只做:
- **手工填链接**建单、任务查询、勾选待开始任务后批量开始第一趟试选
- 桌面端**定时轮询**领取(试选与下单两类)
- 第一趟试选:精确勾选规格、读单价、截图、退出释放手机
- 人工确认「机器选对了吗」并签发授权(**锁定单价**)
- 授权超时与放弃,任务不会被永久锁死
- 第二趟下单:三道闸门全过后提交订单一次
- **手工填链接**建单、任务查询、勾选待开始任务后批量“开始采购”
- 点击开始采购即签发锁定商品、规格、数量和最高总价的一次性授权
- 桌面端定时轮询只领取已授权任务,在同一趟精确选规格、两次读价、复核数量和确认页金额
- 围栏前失败/过期可人工回到待开始;围栏后只能调和
- 三道闸门全过、服务端围栏明确许可后提交订单一次
- 真实点击前服务端原子建立提交围栏;围栏失败不点击,围栏后只调和同一提交记录
- 待付款展示订单截图,人核对付款后手工标记完成
- 失败分类与转人工
@@ -165,26 +163,41 @@ cmbuyer 是一个自动化采购系统:**采购服务**(网页端,`admin/`
| 采购服务页面 | `02-requirements.md` 验收 → `07-user-stories.md` → `08-interaction-checklist.md` → `routes.md` → `04-architecture.md` |
| 采购工具界面 | 同上,`routes.md` 看第三节桌面端结构 |
| 设备侧 API | `api.md` → `04-architecture.md` 数据模型与鉴权边界 |
| 真机自动化 | `04-architecture.md` 第三节两趟执行与三道闸门 → 第四节边界 → `api.md` 第三节模块合约 → **先真机取证** |
| 真机自动化 | `04-architecture.md` 第三节单趟执行与三道闸门 → 第四节边界 → `api.md` 第三节模块合约 → **先真机取证** |
| 数据模型 | `04-architecture.md` 第五节;schema 变化必须同步 `api.md` 和 `current-state.md` |
| 部署 / 运行 | `03-tech-stack.md` → `current-state.md` |
## 验证命令
> **占位符。** T-001 / T-002 落地后由该任务替换为真实命令,并同步
> [`03-tech-stack.md`](03-tech-stack.md) 和 [`current-state.md`](current-state.md)。
> Windows 标准入口 `./init.ps1` 已由 T-003 实际验证。它优先使用合规的既有 venv(本机实际为
> Python 3.12),仅在 venv 缺失时才从 Python Launcher 自动选择最高的 Python 3.11+,避免回退到
> 默认 Python 3.10;通过后会输出两端真实启动命令。
```bash
```powershell
# Windows 统一安装、离线验证与启动命令提示
.\init.ps1
# 以下为诊断或单独验证时使用的等价命令
# 采购服务 admin/(改了 Go 代码后)
cd admin
go test ./...
go vet ./...
go build ./...
# 采购工具 client/(改了 Python 代码后)
python -m unittest discover -s tests -t .
python -m compileall -q src tests
# 采购工具 client/(由 init.ps1 创建的 Python 3.11+ 虚拟环境)
cd client
.\.venv\Scripts\python.exe -m unittest discover -s tests -t .
.\.venv\Scripts\python.exe -m compileall -q src tests scripts
$wheelDir = Join-Path $env:TEMP ('cmbuyer-client-wheel-' + [guid]::NewGuid())
New-Item -ItemType Directory -Path $wheelDir | Out-Null
.\.venv\Scripts\python.exe -m pip wheel --no-deps . --wheel-dir $wheelDir
.\.venv\Scripts\python.exe scripts/verify_wheel_metadata.py (Get-ChildItem $wheelDir -Filter '*.whl').FullName
# 跨端契约改动:两端全跑
```
验证层级何时触发见 [`03-tech-stack.md`](03-tech-stack.md) 第六节验证矩阵。
如果命令当前不可运行,必须在回复里如实说明原因。
`requirements.txt` 是采购工具唯一的运行时依赖来源,`pyproject.toml` 动态读取它写入 wheel
元数据。规范安装是 Python 3.11+ 虚拟环境中的 `python -m pip install -e .`,已由 Python 3.12
验证通过;桌面 GUI 和真机流程不属于本次验收。`init.ps1` 使用合规既有 venv,或在缺失时自动选择
Python 3.11+ 创建它;本机现有 venv 实际验证为 3.12。若命令当前不可运行,必须在回复里如实说明原因。
+7 -6
View File
@@ -5,15 +5,15 @@
cmbuyer 要解决:**采购人员为了履约一笔外部订单,必须手工去拼多多找到同款商品、选对
颜色尺码、下单,再把订单号抄回系统——这个过程重复、易错、且无法追溯。**
> 让采购人员把「买什么」一次说清楚,由系统驱动手机完成找货、选规格和下单,人只在
> 两个关键点介入:**挑哪一个**和**付不付款**。
> 让采购人员把“买什么、买多少、最多多少钱”一次说清楚,并明确点击开始采购;系统驱动手机
> 精确选规格并创建待付款订单,**付不付款始终由人决定**。
它不是无人值守的抢购脚本,也不是绕过平台规则的爬虫。它是一个**带人工闸门的采购执行
工具**:机器负责重复劳动,人保留花钱的决定权。
## 二、目标用户
- **采购管理员**:在网页端建单、复核候选商品、签发下单授权、查看执行证据。
- **采购管理员**:在网页端建单、点击开始采购签发一次性授权、查看执行证据。
- **采购执行员**:在桌面端连接手机、启动批次、处理需要人工接管的任务、完成付款。
- **ERP 对接身份**:只读同步第三方系统的货运单与商品明细,不参与采购决策。
- **系统管理员(后续)**:人员、设备、权限和审计策略管理,MVP 不提供完整界面。
@@ -27,7 +27,8 @@ cmbuyer 要解决:**采购人员为了履约一笔外部订单,必须手工
绝不猜测。宁可少做,不可做错。
- **先走确定路径**:MVP 只做已知商品链接的情形。图片搜索推到 V2,且届时其唯一职责
是产出 `goods_id`,不在搜索结果页上做价格或规格判断。
- **人站在不可逆动作正前方**:机器先试选并回传,人看过之后才授权下单。
- **授权点明确且前置**:创建任务不授权;管理员点击“开始采购”才允许创建一笔待付款订单。
- **实时闸门不依赖旧截图**:授权后同一设备会话两次读取规格面板价格,并在确认页校验总额。
- **价格只在可靠位置读**:规格面板和订单确认页。别处的数字一律不信。
- **失败要可诊断**:不能只返回「失败」,必须有步骤、错误码、截图和页面快照。
- **证据分层**:人工决策需要的证据上传服务端,完整执行轨迹留在桌面端本地。
@@ -37,9 +38,9 @@ cmbuyer 要解决:**采购人员为了履约一笔外部订单,必须手工
| 价值点 | 用户得到什么 |
| --- | --- |
| 消除重复劳动 | 不再逐条手工搜索、选规格、抄订单号 |
| 决策集中可控 | 所有「买哪个」的决定收敛到网页端一处,有据可查 |
| 决策集中可控 | 商品、规格、数量、最高总价和开始采购授权收敛到网页端,有据可查 |
| 资金边界清晰 | 系统能下单不能付款,误操作不会直接造成损失 |
| 执行可追溯 | 每笔采购留下候选、截图、授权理由和订单核对记录 |
| 执行可追溯 | 每笔采购留下授权、三道闸门、截图、围栏和订单核对记录 |
| 批量顺序执行 | 一次导入多条,按顺序跑,遇到问题停在该停的地方 |
## 五、不做什么(非目标)
+107 -122
View File
@@ -1,50 +1,48 @@
# 需求
> 本文只描述**要什么**与**怎么算达成**,用产品 / 用户语言表达,**不涉及技术实现**。
> 技术方案、数据结构、字段定义见 [架构设计](04-architecture.md)。
> 本文只描述**要什么**与**怎么算达成**,用产品 / 用户语言表达。技术方案、数据结构与
> 字段定义见[架构设计](04-architecture.md)。
## 一、业务现状
| 项 | 状态 |
| --- | --- |
| 用户 | 采购人员为履约外部订单,逐条手工在拼多多找同款、选颜色尺码、下单、抄回订单号 |
| 任务来源 | 第三方 ERP(顺运宝)货运单与商品明细为主;Excel 批量导入和手工填链接为补充 |
| 数据 | ERP 提供商品标题、规格、数量、参考图;部分明细带拼多多链接,部分只有图 |
| 现有系统 | 两个前序原型(见 [愿景](01-vision.md) 第六节),代码不直接复用 |
| MVP 任务来源 | 管理员手工填写拼多多商品链接、规格、数量和金额上限 |
| 后续任务来源 | Excel 与第三方 ERP(顺运宝)货运明细 |
| 设备 | 一台 Windows 电脑 + 一台已登录拼多多的 Android 手机,USB 或 WiFi ADB 连接 |
| 约束 | 不得绕过平台风控;付款必须人工;手机需保持亮屏解锁 |
| 约束 | 不绕过平台风控;系统只创建待付款订单;付款必须由人完成;手机保持亮屏解锁 |
## 二、用户角色
- **采购管理员**:在网页端建单、查询、复核候选、签发下单授权、查看审计记录。
- **采购执行员**:在桌面端连接设备、启动批次、处理待人工任务、在拼多多完成付款。
- **采购管理员**:在采购服务建单、查询、点击“开始采购”签发一次性采购授权、查看审计记录。
- **采购执行员**:在采购工具连接设备、启动轮询、处理待人工任务、在拼多多完成人工付款。
- **ERP 对接身份**:只读同步货运单与商品明细,不能建单、授权或访问采购结果。
- **设备身份**:一台已授权桌面端实例,用于领取任务和回传结果,不能建单或授权。
- **设备身份**:一台已授权采购工具实例,用于领取已授权任务和回传结果,不能建单或授权。
- **未登录用户**:不能访问任何任务、图片、证据或设备接口。
## 三、功能清单
### 第一版 MVP(最小闭环)
MVP 只做**任务自带商品链接**的情形,分两趟执行:第一趟试选并回传,人确认后第二趟下单。
流程见[架构设计](04-architecture.md)第三节。
MVP 只做**任务自带商品链接**的情形。管理员点击“开始采购”后,采购工具在同一次设备会话中
完成选规格、校验价格与数量、创建待付款订单;中间不再回到采购服务等待“机器选对了吗”。
| ID | 功能 | 用户能做什么 | 优先级 | 关联用户故事 |
| --- | --- | --- | --- | --- |
| F-001 | 手工建单 | 管理员在任务列表内填写任务名称、拼多多链接、颜色分类、尺码、数量、价格上限,保存一条待开始任务 | P0 | US-001 |
| F-004 | 任务查询 | 管理员按关键词、状态、时间范围找到目标任务 | P0 | US-002 |
| F-005 | 桌面端定时领取 | 执行员启动会话后,桌面端定时轮询领取待试选和已授权两类任务 | P0 | US-003 |
| F-006 | 第一趟试选 | 系统打开商品、按维度精确勾选颜色分类和尺码、读单价、截图后退出 | P0 | US-003 |
| F-007 | 试选结果回传 | 管理员看到机器实际选中的规格、单价、合计和规格面板截图 | P0 | US-004 |
| F-008 | 人工确认与授权 | 管理员确认机器选对了,签发一次性授权并锁定价格;或退回不买 | P0 | US-004、US-005 |
| F-009 | 第二趟下单 | 系统重新选同一规格、过三道价格闸门后提交订单,回传订单截图 | P0 | US-005 |
| F-010 | 授权超时与放弃 | 管理员在授权卡住时能放弃并重新确认,任务不会被永久锁死 | P0 | US-005 |
| F-011 | 结果与失败分类回传 | 管理员看到成功、失败、已取消、待人工,以及可区分的失败原因和证据 | P0 | US-002、US-008 |
| F-013 | 登录与身份隔离 | 管理员用账号登录网页端;桌面端用设备凭据接入,两者权限不互通 | P0 | US-007 |
| F-017 | 下单 dry-run、提交围栏与调和 | 真机先只读演练到订单确认页;真实点击前由服务端原子冻结授权;点击后只调和结果、绝不重试 | P0 | US-005、US-008 |
| F-018 | 批量开始试选 | 管理员勾选一条或多条待开始任务,统一放入第一趟试选队列 | P0 | US-010 |
| F-001 | 手工建单 | 填写标题、拼多多链接、颜色分类、尺码、数量、最高总价,保存为待开始任务 | P0 | US-001 |
| F-004 | 任务查询 | 按标题关键词、状态、时间范围找到任务,双击行查看详情 | P0 | US-002 |
| F-005 | 桌面端定时领取 | 执行员启动会话后定时领取已获采购授权的任务 | P0 | US-003 |
| F-006 | 单趟采购执行 | 打开商品、精确选择规格、校验价格、设置数量、进入确认页并创建待付款订单 | P0 | US-003、US-005 |
| F-007 | 执行证据回传 | 管理员查看实际规格、三道闸门、原始页面截图和步骤记录 | P0 | US-002、US-008 |
| F-008 | 开始采购授权 | 管理员批量点击一次,原子锁定每条任务并签发一次性待付款订单授权 | P0 | US-005、US-010 |
| F-010 | 围栏前过期与重新授权 | 未建立提交围栏的失败或过期任务回到待开始,可由管理员重新点击开始采购 | P0 | US-005 |
| F-011 | 结果与失败分类回传 | 查看成功、失败、已取消、待人工及可诊断原因和证据 | P0 | US-002、US-008 |
| F-013 | 登录与身份隔离 | 管理员用账号登录;采购工具用设备凭据接入,权限不互通 | P0 | US-007 |
| F-017 | 提交围栏与调和 | 真机点击前服务端原子冻结授权;点击后只调和同一提交,绝不重试 | P0 | US-005、US-008 |
| F-018 | 批量开始采购 | 勾选一条或多条待开始任务,统一授权采购工具创建待付款订单 | P0 | US-010 |
> F-002、F-003、F-012 已移出 MVP,编号保留不重用,见下表。
> 原 F-009 的“两趟下单”并入 F-006;F-002、F-003、F-012 移出 MVP,编号不重用。
### 后续迭代
@@ -52,141 +50,128 @@ MVP 只做**任务自带商品链接**的情形,分两趟执行:第一趟试
| --- | --- | --- |
| F-002 Excel 批量建单 | 上传固定表头表格批量生成任务 | V2,被表头契约待确认阻塞 |
| F-003 从 ERP 货运明细建单 | 同步顺运宝货运单后复核生成任务 | V2,被字段映射待确认阻塞 |
| F-012 批量顺序编排与人工接管 | 对已开始任务排序、暂停继续、运行中接管 | V2;MVP 仅支持批量开始试选,实际执行仍由定时轮询领取 |
| F-014 图片搜索路径(B 路径) | 任务只有参考图时搜图产出 goods_id 候选 | V2 |
| F-012 批量编排与运行中接管 | 排序、暂停继续、运行中接管 | V2;MVP 只批量授权,设备逐条领取 |
| F-014 图片搜索路径 | 任务只有参考图时搜图产出 goods_id 候选 | V2 |
| F-015 候选对照台 | 多个候选并排对照挑选 | V2,随 F-014 |
| F-016 订单自动核对回读 | 付款后只读读取订单页做五项唯一匹配并回写 | V2;MVP 用截图 + 人眼核对 |
| F-016 订单自动核对回读 | 付款后只读读取订单页做唯一匹配并回写 | V2;MVP 用截图 + 人眼核对 |
| F-101 AI 辅助候选判断 | 用模型看搜索结果页截图判断同款 | V2 之后 |
| F-102 AI 辅助页面理解 | 规则读不到规格或价格时用模型兜底 | V2 之后 |
| F-103 执行轨迹本地留档 | 记录模型输入输出与规则判断的分歧 | V2 之后 |
| F-104 多设备并行 | 一个桌面端驱动多台手机 | V2 之后 |
| F-103 执行轨迹本地留档 | 记录模型输入输出与规则判断分歧 | V2 之后 |
| F-104 多设备并行 | 一个采购工具驱动多台手机 | V2 之后 |
| F-105 完整 RBAC | 管理员、执行员、审核员细粒度权限 | V2 之后 |
| F-106 多平台比价 | 淘宝、1688、京东 | V3 |
| 支付自动化 | **不在规划内** | 未规划 |
## 四、核心用户故事(MVP)
详细故事以[用户故事清单](07-user-stories.md)为准;本文只维护功能、优先级与 US 编号的
索引,避免两处成为相互冲突的权威来源。
详细故事以[用户故事清单](07-user-stories.md)为准。
| 功能 | 用户故事 | 优先级 |
| --- | --- | --- |
| F-001 | US-001 | P0 |
| F-004、F-011 | US-002 | P0 |
| F-004、F-007、F-011 | US-002 | P0 |
| F-005、F-006 | US-003 | P0 |
| F-007、F-008 | US-004 | P0 |
| F-008、F-009、F-010、F-017 | US-005 | P0 |
| F-008、F-010、F-017 | US-005 | P0 |
| F-013 | US-007 | P0 |
| F-011 | US-008 | P0 |
| F-018 | US-010 | P0 |
| F-007、F-011、F-017 | US-008 | P0 |
| F-008、F-018 | US-010 | P0 |
US-006(付款前核对)在 MVP 降级为:系统展示订单截图与授权信息,人在拼多多自行核对
后付款并手工标记完成。自动回读核对是 F-016(V2)。US-009(ERP 建单)随 F-003 移出 MVP。
US-004 的试选后人工确认已从 MVP 删除。US-006 在 MVP 为:系统展示订单截图与授权信息,
人在拼多多核对、付款并手工标记完成;自动回读核对是 F-016(V2)。
## 五、验收标准(MVP)
### 建单
### 建单与授权
- **F-001**:在任务列表点击创建,填入任务名称、合法拼多多链接、颜色分类、尺码、数量、
价格上限后保存,返回任务编号;弹窗关闭,任务按创建时间倒序出现在第一行且状态为待开始。
链接格式非法或无法解析出 `goods_id` 时明确报错并保留已填内容。
- **F-018**:列表只允许勾选待开始任务;全选只覆盖当前筛选结果中可开始的任务。点击
「开始试选」后,被选任务原子转为待领取,并明确提示这只是进入第一趟试选队列,
**不签发下单授权、不创建订单、不付款**。任一任务状态已变化时整批不提交,刷新后重选。
- **F-001**:任务列表点击创建,填入标题、合法拼多多链接、颜色分类、尺码、数量和最高总价后
保存,弹窗关闭;新任务按创建时间倒序出现在第一行,状态为 `DRAFT`。非法链接或无法解析
`goods_id` 时明确报错并保留输入。
- 创建任务**不构成采购授权**,设备不能领取 `DRAFT`。
- **F-018**:只允许勾选当前筛选结果中的 `DRAFT`。点击“开始采购(只创建待付款订单)”前,
界面显示已选数量和最高总额;点击本身即管理员明确授权,不再增加选品确认步骤。
- 服务端在一个事务中校验整批任务仍为预期版本,逐条锁定 `goods_id`、颜色、尺码、数量、最高
总价、授权人和有效期,并把任务转 `PENDING`。任一条冲突时整批不变,刷新后重选。
- 相同 `start_key` 重放返回同一结果;不得产生第二份有效授权。成功后即时提示“已授权 N 条,
采购工具只会创建待付款订单,不会付款”。
### 第一趟:试选
### 单趟采购执行
- **F-005**:执行员启动会话后桌面端定时轮询,同时领取待试选与已授权两类任务。
两个实例并发领取同一条时只有一个成功,另一个得到明确的「无可领任务」而不是报错。
**关闭会话即停止轮询;连续失败达到阈值自动停止并提示原因。**
- **F-006**:手机打开对应商品详情页,打开规格面板,按维度精确匹配颜色分类和尺码。
**任一维度找不到精确值即停止并转人工,不选相近选项。** 勾选后读取该 SKU 单价
(闸门一),读不到即转人工,**不用商品详情页正文或搜索页的数字凑合**。
- **F-006 释放要求**:试选完成后**必须退出商品页释放手机**,不得停在规格面板等待人工。
- **F-006 硬边界**:试选阶段**绝不点击「现在买」或任何进入下单流程的入口**。
必须有测试证明该路径不调用任何下单语义动作。
- **F-007**:回传商品标题、实际勾选到的颜色分类与尺码、单价、合计(单价 × 数量)和
规格面板截图;任务转「等你确认」。
- **F-005**:采购工具只领取 `PENDING` 且授权有效的任务。并发领取同一条时只有一个成功;关闭
会话即停止轮询,连续失败达到阈值自动停止并显示原因。
- **F-006 受控入口**:只点击与本项目真机证据及 App 版本绑定的精确唯一规格面板入口。当前仅确认
拼多多 `8.17.0`、goods_id `937122477375` 的“快要抢光”;其他入口必须分别取证,不按购买语义泛化。
- **F-006 规格与数量**:颜色分类、尺码必须在各自维度内精确唯一匹配并读回;数量设置后必须读回
等于任务值。缺失、重复、禁用、前缀碰撞、读回不一致都停止,不选相近项。
- **F-006 三道价格闸门**:价格只从规格面板和订单确认页读取,金额使用十进制字符串。
1. 选中目标规格后读取当前 SKU 单价,计算 `单价 × 数量`,不得超过授权最高总价。
2. 设置并复核数量后再次读取同一规格与单价;规格必须仍相同,单价必须与闸门一完全相等。
3. 订单确认页核对规格、数量与应付总额;总额不得超过授权最高总价。
任一道读不到、有歧义或不通过都停止并转人工,不从详情页、搜索页或按钮文字凑价格。
- 同一设备会话连续完成上述步骤;不退出商品再等采购服务确认,也不把旧截图或旧页面读数当作
当前提交依据。
### 决策与资金
### 提交围栏与资金边界
- **F-008**:管理员在确认页看到需求与机器所选的对照、单价、合计和截图,点击确认后
签发一次性授权,**授权锁定当次试选的单价**。授权重复提交幂等,不产生第二笔订单。
管理员也可选择「退回,不买」,任务终止。
- **F-009 三道闸门**:第二趟重新打开商品并重新勾选同一规格后,
① 重读单价必须与授权锁定价一致;
② 数量设置后读回必须精确等于要求值;
③ 订单确认页「实付款」不得超过授权总额上限。
**任一道不通过即停止并转人工。**
- **F-009 提交条件**:授权存在且未消费、闸门全过、「提交订单」控件文本精确相等且可
点击祖先唯一——四者同时满足,且服务端已原子建立提交围栏,才允许**点击一次**。点击后
无论超时、跳外部支付还是遇到安全校验,**一律进入结果调和或人工核查且禁止重试**。
- **F-010**:授权带过期时间。建立提交围栏前,超时或主动放弃都会作废授权并让任务进入
「待重新试选」,不得直接复用旧试选再次确认;建立提交围栏后,授权不得过期或放弃,
只能调和结果或转人工核查。**任何状态都必须给出安全且可执行的下一步。**
- **F-017 dry-run**:首次真实下单前必须先完成一次只读演练:到订单确认页读取规格、数量、
「实付款」,验证提交控件唯一,然后停止并退出;**不点击「提交订单」**。演练结果与证据
必须回传,真实提交不得把旧演练当成当前页面事实。
- **F-017 提交围栏**:desk 端在真实点击前向 web 端申请提交围栏;web 端必须在一个原子
事务中复核任务版本、授权未消费、命令与演练关联正确,然后冻结授权并生成唯一
`order_submission`。申请失败或响应不明确时不得点击。围栏成功后只能点击一次;点击结果
不明确时保留金额额度并进入调和,不能重新申请或重新点击。
- **资金硬边界**:系统在任何路径下都不点击支付、免密支付、先用后付或任何扣款控件。
必须有测试证明提交订单之后不调用任何支付动作。
- **F-017**:闸门二、闸门三和提交控件唯一均通过后,采购工具把当前验证摘要发给采购服务并申请
提交围栏。服务端原子复核任务版本、一次性授权未消费、领取身份、规格、数量、闸门值和唯一性,
创建或重放唯一 `order_submission`,才返回 `click_permitted=true`。
- 围栏申请失败、冲突、超时、断网或响应不明确时**不得点击**。围栏成功后只可精确点击一次
“提交订单”;点击后无论结果、超时、外部支付交接或安全校验都禁止重试。
- 明确创建订单后任务转 `WAITING_PAYMENT`;结果不明确转 `RECONCILIATION_REQUIRED`,授权和围栏
保持占用,只能调和同一提交记录。
- 围栏建立前,授权过期、设备失败或人工放弃可使任务回到 `DRAFT`;必须由管理员重新点击“开始采购”
产生新授权。围栏建立后不得释放、重新授权、重新领取或再次点击。
- 系统任何路径都不点击支付、免密支付、先用后付或扣款控件;提交订单后的代码路径不含支付动作。
- 发布真实下单能力前必须用独立真机任务完成只读 dry-run:到订单确认页验证规格、数量、金额和提交
控件唯一但不点击。dry-run 是版本发布门禁,不是每笔订单的第一趟,也不能代替实时三道闸门。
### 结果与异常
### 结果、证据与异常
- **F-011**:失败必须可区分至少这些原因:设备未连接、商品页打不开、规格面板打不开、
规格不匹配、单价读不到、单价与授权价不符、数量设置失败、金额超上限、提交控件不唯一、
页面识别失败、安全校验、外部支付交接、超时。每种都保留截图和页面快照。
- **版本失配**:运行时读取到的拼多多 App 版本与当前已取证版本不一致时,桌面端必须停止
领取真机任务并提示重新取证;不得继续使用旧页面判据。
- **付款收口(MVP 简化版)**:订单创建后任务转「待付款」,页面展示订单截图、商品、
规格、数量和授权金额供人核对。**人在拼多多付款后手工标记完成。** 自动回读核对是
F-016(V2)。
- **F-007**:按步骤回传实际规格、两次规格面板价格、数量读回、确认页总额、判定、时间和截图。
截图是内部审计证据,不是提交前审批前置。
- 内部系统允许上传规格面板与订单确认页的原始截图,截图中可保留页面已显示的地址和手机号;只允许
已认证设备显式上传、已登录管理员查看。完整 XML 只留采购工具本机,不上传;地址/手机号不解析为
业务字段或日志;外部支付页、支付凭据永不上传。
- **F-011**:至少区分设备未连接、版本失配、商品打不开、面板打不开、规格不匹配、价格不可读、
两次价格不一致、数量复核失败、金额超上限、确认页不一致、提交控件不唯一、围栏失败、页面识别
失败、安全校验、外部支付交接和超时。
- 运行时拼多多 App 版本与已取证版本不一致时停止领取真机任务,提示重新取证。
- 订单明确创建后展示截图、商品、规格、数量和授权金额;人在拼多多付款后手工标记完成。
**待付款不等于采购完成。**
### 通用
- 每条 P0 判据关联至少一个 US 编号;有用户界面的判据同时关联相关 IX 编号。
- 任务终态一次原子回写,中间态不落盘产生「看起来在跑其实已死」的记录。
- 每条 P0 判据关联至少一个 US;有用户界面的判据同时关联 IX。
- 状态流转与提交记录原子落盘,不产生“界面在跑但服务端无记录”的悬空事实。
- 失败页面给出可执行下一步;不会把“重试”显示在已建立围栏的提交上。
## 六、范围边界与决策
| 问题 | 决策 |
| --- | --- |
| 第一版平台 | 网页端(管理)+ Windows 桌面端(执行),驱动一台 Android 手机 |
| 是否需要账号 | 是。管理员账号 + 桌面端设备凭据,两套身份分离 |
| 第一版范围 | 手工建单 → 批量开始试选 → 定时领取 → **第一趟试选** → 人工确认 → dry-run / 提交围栏 → **第二趟下单** → 待付款 |
| 任务来源 | **仅手工填链接。** Excel 与 ERP 移出 MVP |
| 找货方式 | **仅按链接。** 图片搜索移出 MVP |
| 第一版平台 | 采购服务(网页端)+ 采购工具(Windows 桌面端)+ 一台 Android 手机 |
| 身份 | 管理员账号 + 设备凭据,两套身份分离 |
| 第一版流程 | 手工建单 → 批量开始采购并授权 → 定时领取 → 单趟选规格 / 三闸门 / 围栏 / 提交一次 → 待付款 |
| 人工授权点 | 管理员点击“开始采购(只创建待付款订单)”;不再有试选后确认 |
| 任务来源 | 仅手工填链接;Excel 与 ERP 移出 MVP |
| 找货方式 | 仅按链接;图片搜索移出 MVP |
| 采购平台 | 仅拼多多 |
| 领取方式 | 定时轮询,只在执行员启动的会话内运行 |
| 任务启动 | 创建后为待开始;管理员在列表勾选一条或多条并统一开始第一趟试选 |
| 设备连接 | ADB over USB 或 WiFi 均支持;同一台手机不得同时以两种方式在线 |
| 付款收口 | 系统展示订单截图,人核对后付款并手工标记完成 |
| 设备连接 | ADB over USB 或 WiFi;同一手机不得以两种方式同时在线 |
| 付款收口 | 系统展示待付款订单证据,人核对、付款并手工标记完成 |
| 暂不支持 | 自动付款、图搜、Excel、ERP、多设备并行、多平台、退款、审批链、AI 辅助 |
## 七、待确认 / 风险点
- **第三方平台风险**:拼多多 App 版本更新会改变页面结构。前序项目已观察到详情页
没有独立规格入口、价格文本被拆成多个节点等变化。**每次页面结构判据都必须有真机
证据,不得从旧版本推断。**
- **资金风险**:涉及创建真实待付款订单。授权、金额上限、一次性围栏的规则由采购管理员
确认;付款始终人工。**下单动作在真机上第一次验证前,必须先取得授权。**
- **账号风险**:手机上是真实拼多多账号。频繁自动化操作有被风控或封号的可能,需要
可配置的动作节奏,并在检测到安全校验时立即停止。
- **自动化边界风险**:会自动点击并创建订单,属不可逆操作。必须支持 dry-run(跑到
订单确认页停止)、服务端提交围栏和点击后调和;任一环节状态不明都不得继续点击。
- **隐私风险**:订单确认页含收货地址和掩码手机号。**只读取非敏感摘要,不提取地址
原文、手机号或支付凭据**;上传服务端的证据需先脱敏。
- **规格面板上的单价位置未取证(阻塞 F-006 闸门一)**:选中 SKU 后价格显示在哪个节点、
是否带「券后」前缀、是否与原价并列,尚无本项目的真机证据。**T-103 必须一并取证。**
若规格面板上无法可靠读到单价,闸门一要改为「只截图不判价」,确认页设计随之调整。
- **两趟之间的状态漂移**:第二趟重新进入时价格可能已变、规格选项可能已改、商品可能
下架。闸门二负责拦截,一律转人工——但这意味着价格波动频繁的类目会产生大量待人工。
需真机观察实际发生率。
- **定时轮询的节奏**:固定间隔的机器节奏比人工节奏更容易被风控识别。间隔需可配置,
并在检测到安全校验时立即停止轮询。具体间隔待真机观察后确定。
- **待确认(阻塞 F-002,已移出 MVP)**:Excel 表头契约的最终字段集与列名。
- **待确认(阻塞 F-003,已移出 MVP)**:ERP 货运明细到任务颜色 / 尺码的字段映射。
- **待确认(V2 之后)**:AI 辅助的模型供应商、调用预算与失败降级策略。
- **第三方平台风险**:拼多多更新会改变页面结构。页面判据必须来自本项目真机证据,并绑定 App
版本;不能从前序项目或旧版本推断。
- **资金风险**:管理员点击开始采购会授权创建真实待付款订单。按钮必须明确影响范围与最高总额,
服务端一次性授权、提交围栏和点击一次共同防止重复订单;付款始终人工。
- **单趟时效风险**:授权到实际执行之间价格可能变化。任务不锁定旧观察价,而是在同一趟内两次读取
规格面板价格并在确认页校验总额;任何变化或歧义都停止。
- **账号与风控风险**:固定机器节奏可能触发平台风控。轮询和动作间隔需可配置,检测到验证码、短信、
人脸或风险校验立即停止,不绕过。
- **隐私风险**:内部原始截图可能含地址和手机号。只允许受控内部链路;不提取结构化隐私字段,完整
XML 不上传,支付凭据与外部支付页截图禁止进入系统。
- **首次真实提交风险**:生产提交能力启用前需独立 dry-run 和明确真机授权;点击后结果不明时只能调和。
- **待确认(移出 MVP)**:Excel 表头、ERP 字段映射、AI 模型与预算。

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