Compare commits

..
Author SHA1 Message Date
QiuSW 66355a7f89 feat(admin): add device credential isolation 2026-08-04 20:57:33 +08:00
QiuSW 41881e81f3 docs(tasks): define T-301 auth failure semantics 2026-08-04 20:06:23 +08:00
QiuSW e3c87fdec5 docs(tasks): secure T-301 transport boundary 2026-08-04 20:05:03 +08:00
QiuSW 871cd24d68 fix(tasks): isolate T-301 write paths 2026-08-04 20:01:18 +08:00
QiuSW 4f8e71b256 chore(tasks): start T-301 2026-08-04 20:01:05 +08:00
QiuSW a6ad560f5d docs(tasks): define T-301 device identity 2026-08-04 20:00:29 +08:00
QiuSW 6a5547b323 docs(tasks): complete T-204 2026-08-04 19:51:58 +08:00
QiuSW 46fccc2120 merge: T-204 routed task evidence details 2026-08-04 19:43:20 +08:00
QiuSW 89648880bc feat(admin): add routed task evidence details 2026-08-04 19:42:35 +08:00
QiuSW 4829be972c docs(tasks): record T-103 entry audit 2026-08-04 18:47:47 +08:00
QiuSW 3b2fa3536e docs(tasks): define T-104 safe exit evidence 2026-08-04 18:47:05 +08:00
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 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 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
47 changed files with 6558 additions and 43 deletions
+32
View File
@@ -12,6 +12,7 @@
| `CMBUYER_AUTHORIZATION_TTL` | 一次性授权的正 Go duration,例如 `10m`。 |
| `CMBUYER_MAX_TASK_QUANTITY` | 每条任务允许的正整数数量上限。 |
| `CMBUYER_MAX_TOTAL_PRICE` | 每条任务允许的规范正数总价上限,例如 `999.99`。 |
| `CMBUYER_EVIDENCE_DIR` | 内部原始截图的绝对私有目录;不得指向仓库或公开静态目录。 |
示例仅展示变量名,不提供可运行凭据:
@@ -24,9 +25,40 @@ $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
```
MVP 服务固定绑定 IPv4 回环 `127.0.0.1:8080`,只供同一运营电脑上的采购服务和采购工具使用。不要把监听地址
改成 `0.0.0.0` 或局域网地址;未来若需要非回环访问,必须先单独建立并验收 HTTPS/TLS 终止与代理
信任边界,设备 Bearer 不得经过明文局域网。
数据库迁移完成后,用同一个显式 SQLite data source 管理设备凭据:
```powershell
# 签发:token 只在本次成功输出中显示一次,请立即放入采购工具的受控本机配置。
go run ./cmd/device-credentials -database $env:CMBUYER_DATABASE_SOURCE issue -name '<非秘密设备名称>'
# 仅显示设备 id、名称、状态和时间,不显示 token/hash。
go run ./cmd/device-credentials -database $env:CMBUYER_DATABASE_SOURCE list
# 撤销立即影响之后开始的每次设备请求;重复执行保持 REVOKED,不恢复旧 token。
go run ./cmd/device-credentials -database $env:CMBUYER_DATABASE_SOURCE revoke -device-id '<签发时的设备UUID>'
```
该 CLI 只接受已存在、已迁移的文件型 SQLite 普通路径或 `file:` URI,并强制以 `mode=rw` 打开;
路径拼错或文件缺失时 SQLite 原子拒绝且不会留下空数据库,也不接受内存、只读或可创建模式。CLI
不自动迁移。签发 token 是 32 字节加密随机值的 64 位小写十六进制表示;
SQLite 只保存原始 token 的 32 字节 SHA-256 BLOB。签发输出以外的 list/revoke、日志、错误和 HTTP
响应都不会显示 token 或 hash。
采购服务会话仅保存在当前进程内;进程重启后既有登录会话会安全失效。
管理员的“开始采购(只创建待付款订单)”只签发一次性授权并创建待付款订单的资格;服务不会自动付款,也不包含任何支付操作。
`GET /tasks/{id}` 直接访问时渲染完整详情页,任务列表以同一 URL 加载详情抽屉。内部截图只通过
`GET /evidence/{asset_id}` 向有效管理员会话提供,并始终返回 `no-store`;文件不在静态目录中。
`POST /api/v1/tasks/{id}/evidence` 使用 `Authorization: Bearer <token>` 和
`X-CMBuyer-Device-ID: <小写UUIDv4>` 逐请求查库认证;管理员会话不能代替设备身份。凭据错误统一空
401,SQLite 认证故障为空 503,且两者都发生在上传 body 被读取之前。
+200
View File
@@ -0,0 +1,200 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/storage/sqlite"
)
func main() {
if err := run(context.Background(), os.Args[1:], os.Stdout, os.Stderr); err != nil {
log.Print(err)
os.Exit(1)
}
}
func run(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("device-credentials", flag.ContinueOnError)
flags.SetOutput(stderr)
databaseSource := flags.String("database", "", "explicit migrated SQLite data source")
if err := flags.Parse(args); err != nil {
return err
}
if *databaseSource == "" {
return errors.New("-database is required")
}
if flags.NArg() < 1 {
return errors.New("usage: device-credentials -database <sqlite-data-source> <issue|list|revoke> [options]")
}
command := flags.Arg(0)
commandArgs := flags.Args()[1:]
var issueName, revokeDeviceID string
switch command {
case "issue":
commandFlags := flag.NewFlagSet("issue", flag.ContinueOnError)
commandFlags.SetOutput(stderr)
commandFlags.StringVar(&issueName, "name", "", "non-secret device display name")
if err := commandFlags.Parse(commandArgs); err != nil {
return err
}
if issueName == "" || commandFlags.NArg() != 0 {
return errors.New("usage: device-credentials -database <sqlite-data-source> issue -name <display-name>")
}
case "list":
if len(commandArgs) != 0 {
return errors.New("usage: device-credentials -database <sqlite-data-source> list")
}
case "revoke":
commandFlags := flag.NewFlagSet("revoke", flag.ContinueOnError)
commandFlags.SetOutput(stderr)
commandFlags.StringVar(&revokeDeviceID, "device-id", "", "canonical device UUID")
if err := commandFlags.Parse(commandArgs); err != nil {
return err
}
if revokeDeviceID == "" || commandFlags.NArg() != 0 {
return errors.New("usage: device-credentials -database <sqlite-data-source> revoke -device-id <uuid>")
}
default:
return fmt.Errorf("unsupported device credential command %q", command)
}
if command == "issue" && !deviceauth.ValidDisplayName(issueName) {
return deviceauth.ErrInvalidCredential
}
if command == "revoke" && !deviceauth.ValidDeviceID(revokeDeviceID) {
return deviceauth.ErrInvalidCredential
}
existingSource, err := existingSQLiteDataSource(*databaseSource)
if err != nil {
return err
}
database, err := sqlite.Open(existingSource)
if err != nil {
return fmt.Errorf("open SQLite database: %w", err)
}
defer database.Close()
store, err := deviceauth.NewCredentialStore(database)
if err != nil {
return err
}
switch command {
case "issue":
issued, err := store.Issue(ctx, issueName)
if err != nil {
return err
}
// The token has json:"-" and is printed only by this explicit post-commit path. Generic
// serialization, list, revoke, errors, and server responses therefore cannot disclose it.
if _, err := fmt.Fprintf(stdout, "device_id=%s\ndisplay_name=%s\ntoken=%s\ncreated_at=%s\n",
issued.DeviceID, issued.DisplayName, issued.Token, issued.CreatedAt.Format(time.RFC3339Nano)); err != nil {
return errors.New("write issued device credential")
}
return nil
case "list":
credentials, err := store.List(ctx)
if err != nil {
return err
}
return writeJSON(stdout, credentials)
case "revoke":
credential, changed, err := store.Revoke(ctx, revokeDeviceID)
if err != nil {
return err
}
return writeJSON(stdout, struct {
Credential deviceauth.Credential `json:"credential"`
RevokedNow bool `json:"revoked_now"`
}{Credential: credential, RevokedNow: changed})
}
return errors.New("unreachable device credential command")
}
func existingSQLiteDataSource(value string) (string, error) {
if value == "" || strings.TrimSpace(value) != value {
return "", errors.New("-database must name an existing file-backed SQLite database")
}
var parsed *url.URL
var query url.Values
if strings.HasPrefix(strings.ToLower(value), "file:") {
var err error
parsed, err = url.Parse(value)
if err != nil || !strings.EqualFold(parsed.Scheme, "file") || parsed.User != nil || parsed.Host != "" || parsed.Fragment != "" {
return "", errors.New("-database file URI is invalid")
}
// go-sqlite3 recognizes URI filenames only with the exact lowercase file: prefix.
// Canonicalize accepted scheme casing before mode=rw reaches the driver, otherwise a
// mixed-case input could be treated as a plain filename and recreate a missing database.
parsed.Scheme = "file"
query, err = url.ParseQuery(parsed.RawQuery)
if err != nil {
return "", errors.New("-database query parameters are invalid")
}
fileName := parsed.Path
if parsed.Opaque != "" {
fileName = parsed.Opaque
}
decodedName, err := url.PathUnescape(fileName)
if err != nil || fileName == "" || strings.EqualFold(decodedName, ":memory:") {
return "", errors.New("-database must name an existing file-backed SQLite database")
}
} else {
pathPart, rawQuery, hasQuery := strings.Cut(value, "?")
if pathPart == "" || strings.EqualFold(pathPart, ":memory:") || strings.Contains(pathPart, "://") {
return "", errors.New("-database must name an existing file-backed SQLite database")
}
var err error
query, err = url.ParseQuery(rawQuery)
if err != nil {
return "", errors.New("-database query parameters are invalid")
}
normalizedPath := filepath.ToSlash(pathPart)
if filepath.VolumeName(pathPart) != "" && !strings.HasPrefix(normalizedPath, "/") {
normalizedPath = "/" + normalizedPath
}
parsed = &url.URL{Scheme: "file", Path: normalizedPath}
if !hasQuery {
query = make(url.Values)
}
}
modes := query["mode"]
if len(modes) > 1 || len(modes) == 1 && modes[0] != "rw" {
return "", errors.New("-database only permits SQLite mode=rw")
}
if len(modes) == 0 {
query.Set("mode", "rw")
}
for _, name := range []string{"immutable", "_query_only"} {
for _, setting := range query[name] {
if setting != "0" && !strings.EqualFold(setting, "false") {
return "", errors.New("-database contains a read-only SQLite option")
}
}
}
parsed.RawQuery = query.Encode()
parsed.ForceQuery = false
return parsed.String(), nil
}
func writeJSON(writer io.Writer, value any) error {
encoder := json.NewEncoder(writer)
encoder.SetEscapeHTML(true)
if err := encoder.Encode(value); err != nil {
return errors.New("write device credential metadata")
}
return nil
}
+293
View File
@@ -0,0 +1,293 @@
package main
import (
"bytes"
"context"
"database/sql"
"errors"
"io"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
)
func TestIssueListAndIdempotentRevokeNeverRediscloseSecret(t *testing.T) {
databaseSource := migratedDatabase(t)
var issued bytes.Buffer
if err := run(context.Background(), []string{"-database", databaseSource, "issue", "-name", "采购工具一号"}, &issued, io.Discard); err != nil {
t.Fatalf("issue: %v", err)
}
fields := outputFields(t, issued.String())
deviceID, token := fields["device_id"], fields["token"]
if len(token) != 64 || strings.Count(issued.String(), token) != 1 {
t.Fatalf("issue token occurrence/length = %d/%d", strings.Count(issued.String(), token), len(token))
}
var listed bytes.Buffer
if err := run(context.Background(), []string{"-database", databaseSource, "list"}, &listed, io.Discard); err != nil {
t.Fatalf("list: %v", err)
}
assertNoSecretMetadata(t, listed.String(), token)
if !strings.Contains(listed.String(), deviceID) || !strings.Contains(listed.String(), "采购工具一号") {
t.Fatalf("list omitted safe metadata: %s", listed.String())
}
var revoked bytes.Buffer
if err := run(context.Background(), []string{"-database", databaseSource, "revoke", "-device-id", deviceID}, &revoked, io.Discard); err != nil {
t.Fatalf("revoke: %v", err)
}
assertNoSecretMetadata(t, revoked.String(), token)
if !strings.Contains(revoked.String(), `"revoked_now":true`) {
t.Fatalf("first revoke output = %s", revoked.String())
}
var repeated bytes.Buffer
if err := run(context.Background(), []string{"-database", databaseSource, "revoke", "-device-id", deviceID}, &repeated, io.Discard); err != nil {
t.Fatalf("repeat revoke: %v", err)
}
assertNoSecretMetadata(t, repeated.String(), token)
if !strings.Contains(repeated.String(), `"revoked_now":false`) {
t.Fatalf("repeat revoke output = %s", repeated.String())
}
}
func TestIssueOutputFailureLeavesCommittedCredentialWithoutSecretInError(t *testing.T) {
databaseSource := migratedDatabase(t)
writer := &recordingFailureWriter{}
err := run(context.Background(), []string{"-database", databaseSource, "issue", "-name", "output failure"}, writer, io.Discard)
if err == nil || err.Error() != "write issued device credential" {
t.Fatalf("issue output failure error = %v", err)
}
fields := outputFields(t, writer.contents.String())
if strings.Contains(err.Error(), fields["token"]) {
t.Fatal("output error disclosed token")
}
database, err := sql.Open("sqlite3", databaseSource)
if err != nil {
t.Fatalf("open database: %v", err)
}
defer database.Close()
var count int
if err := database.QueryRow(`SELECT COUNT(*) FROM device_credentials`).Scan(&count); err != nil || count != 1 {
t.Fatalf("committed credential count = %d, err=%v", count, err)
}
}
func TestCLIRequiresPreMigratedExplicitDatabase(t *testing.T) {
if err := run(context.Background(), []string{"list"}, io.Discard, io.Discard); err == nil {
t.Fatal("command without -database succeeded")
}
missing := filepath.Join(t.TempDir(), "missing.db")
if err := run(context.Background(), []string{"-database", missing, "list"}, io.Discard, io.Discard); err == nil {
t.Fatal("list opened a missing database")
}
if _, err := os.Stat(missing); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing database was created: %v", err)
}
unmigrated := filepath.Join(t.TempDir(), "unmigrated.db")
unmigratedDatabase, err := sqlite.Open(unmigrated)
if err != nil {
t.Fatalf("create unmigrated database: %v", err)
}
if _, err := unmigratedDatabase.Exec(`CREATE TABLE unrelated (id INTEGER)`); err != nil {
_ = unmigratedDatabase.Close()
t.Fatalf("initialize unmigrated database: %v", err)
}
if err := unmigratedDatabase.Close(); err != nil {
t.Fatalf("close unmigrated database: %v", err)
}
if err := run(context.Background(), []string{"-database", unmigrated, "list"}, io.Discard, io.Discard); err == nil {
t.Fatal("list accepted an unmigrated database")
}
database, err := sql.Open("sqlite3", unmigrated)
if err != nil {
t.Fatalf("open unmigrated database: %v", err)
}
defer database.Close()
var count int
if err := database.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='device_credentials'`).Scan(&count); err != nil || count != 0 {
t.Fatalf("device_credentials table count = %d, err=%v", count, err)
}
undeclared := filepath.Join(t.TempDir(), "undeclared.db")
if err := run(context.Background(), []string{"-database", undeclared, "rotate"}, io.Discard, io.Discard); err == nil {
t.Fatal("undeclared command succeeded")
}
if _, err := os.Stat(undeclared); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("undeclared command opened database: %v", err)
}
invalidIssue := filepath.Join(t.TempDir(), "invalid-issue.db")
if err := run(context.Background(), []string{"-database", invalidIssue, "issue", "-name", " padded"}, io.Discard, io.Discard); !errors.Is(err, deviceauth.ErrInvalidCredential) {
t.Fatalf("invalid issue error = %v", err)
}
if _, err := os.Stat(invalidIssue); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("invalid issue opened database: %v", err)
}
invalidRevoke := filepath.Join(t.TempDir(), "invalid-revoke.db")
if err := run(context.Background(), []string{"-database", invalidRevoke, "revoke", "-device-id", "not-a-uuid"}, io.Discard, io.Discard); !errors.Is(err, deviceauth.ErrInvalidCredential) {
t.Fatalf("invalid revoke error = %v", err)
}
if _, err := os.Stat(invalidRevoke); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("invalid revoke opened database: %v", err)
}
migrated := migratedDatabase(t)
unknownID := "13c9f507-7473-4fa6-8d71-8786c34c6301"
var unknownOutput bytes.Buffer
err = run(context.Background(), []string{"-database", migrated, "revoke", "-device-id", unknownID}, &unknownOutput, io.Discard)
if !errors.Is(err, deviceauth.ErrCredentialNotFound) || unknownOutput.Len() != 0 || strings.Contains(err.Error(), unknownID) {
t.Fatalf("unknown revoke = output %q, error %v", unknownOutput.String(), err)
}
}
func TestExistingSQLiteDataSourcePreservesSafeOptionsAndRejectsCreationModes(t *testing.T) {
databaseSource := migratedDatabase(t)
fileURI := (&url.URL{
Scheme: "file",
Path: sqliteURIPath(databaseSource),
RawQuery: "_busy_timeout=5000&cache=shared",
}).String()
normalized, err := existingSQLiteDataSource(fileURI)
if err != nil {
t.Fatalf("normalize file URI: %v", err)
}
parsed, err := url.Parse(normalized)
if err != nil {
t.Fatalf("parse normalized URI: %v", err)
}
query := parsed.Query()
if query.Get("mode") != "rw" || query.Get("_busy_timeout") != "5000" || query.Get("cache") != "shared" {
t.Fatalf("normalized query = %v", query)
}
if err := run(context.Background(), []string{"-database", fileURI, "list"}, io.Discard, io.Discard); err != nil {
t.Fatalf("list existing file URI: %v", err)
}
plainNormalized, err := existingSQLiteDataSource(databaseSource + "?_foreign_keys=on")
if err != nil {
t.Fatalf("normalize ordinary path: %v", err)
}
plainURI, err := url.Parse(plainNormalized)
if err != nil || plainURI.Scheme != "file" || plainURI.Query().Get("mode") != "rw" || plainURI.Query().Get("_foreign_keys") != "on" {
t.Fatalf("ordinary path normalization = %q, err=%v", plainNormalized, err)
}
missing := filepath.Join(t.TempDir(), "missing-uri.db")
missingURI := (&url.URL{Scheme: "file", Path: sqliteURIPath(missing)}).String()
if err := run(context.Background(), []string{"-database", missingURI, "list"}, io.Discard, io.Discard); err == nil {
t.Fatal("missing file URI succeeded")
}
if _, err := os.Stat(missing); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing file URI created a file: %v", err)
}
for _, scheme := range []string{"FILE", "File"} {
mixedMissing := filepath.Join(t.TempDir(), strings.ToLower(scheme)+"-missing.db")
canonical := (&url.URL{Scheme: "file", Path: sqliteURIPath(mixedMissing)}).String()
mixedURI := scheme + canonical[len("file"):]
normalized, err := existingSQLiteDataSource(mixedURI)
if err != nil || !strings.HasPrefix(normalized, "file:") {
t.Fatalf("normalize %s URI = %q, err=%v", scheme, normalized, err)
}
if err := run(context.Background(), []string{"-database", mixedURI, "list"}, io.Discard, io.Discard); err == nil {
t.Fatalf("missing %s URI succeeded", scheme)
}
if _, err := os.Stat(mixedMissing); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing %s URI created a file: %v", scheme, err)
}
}
for name, source := range map[string]string{
"plain memory": ":memory:",
"URI memory": "file::memory:?cache=shared",
"memory mode": fileURI + "&mode=memory",
"read only mode": fileURI + "&mode=ro",
"create mode": fileURI + "&mode=rwc",
"duplicate mode": fileURI + "&mode=rw&mode=rw",
"immutable": fileURI + "&immutable=1",
"query only": fileURI + "&_query_only=1",
"remote authority": "file://server/share/database.db?mode=rw",
} {
t.Run(name, func(t *testing.T) {
if _, err := existingSQLiteDataSource(source); err == nil {
t.Fatalf("unsafe source accepted: %q", source)
}
})
}
}
func sqliteURIPath(path string) string {
normalized := filepath.ToSlash(path)
if filepath.VolumeName(path) != "" && !strings.HasPrefix(normalized, "/") {
return "/" + normalized
}
return normalized
}
type recordingFailureWriter struct {
contents bytes.Buffer
}
func (writer *recordingFailureWriter) Write(value []byte) (int, error) {
_, _ = writer.contents.Write(value)
return 0, errors.New("injected stdout failure")
}
func assertNoSecretMetadata(t *testing.T, output, token string) {
t.Helper()
if strings.Contains(output, token) || strings.Contains(output, "token") || strings.Contains(output, "hash") || strings.Contains(output, "sha256") {
t.Fatalf("metadata output disclosed secret material: %s", output)
}
}
func outputFields(t *testing.T, output string) map[string]string {
t.Helper()
fields := make(map[string]string)
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
name, value, found := strings.Cut(line, "=")
if !found || name == "" || value == "" {
t.Fatalf("invalid issue output line %q", line)
}
fields[name] = value
}
for _, required := range []string{"device_id", "display_name", "token", "created_at"} {
if fields[required] == "" {
t.Fatalf("issue output missing %s: %q", required, output)
}
}
return fields
}
func migratedDatabase(t *testing.T) string {
t.Helper()
databaseSource := filepath.Join(t.TempDir(), "credentials.db")
database, err := sqlite.Open(databaseSource)
if err != nil {
t.Fatalf("open database: %v", err)
}
if err := migrations.Up(context.Background(), database, commandMigrationDirectory(t)); err != nil {
_ = database.Close()
t.Fatalf("migrate database: %v", err)
}
if err := database.Close(); err != nil {
t.Fatalf("close migrated database: %v", err)
}
return databaseSource
}
func commandMigrationDirectory(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate migrations")
}
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
}
+21 -1
View File
@@ -7,12 +7,17 @@ import (
"cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/config"
"cmbuyer/admin/internal/deviceauth"
"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"
// Device Bearer credentials must not cross a plaintext LAN. The MVP is a same-computer
// deployment, so widening this address requires a separately reviewed TLS boundary first.
const listenAddress = "127.0.0.1:8080"
func main() {
if err := run(); err != nil {
@@ -35,12 +40,27 @@ func run() error {
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
}
deviceAuthenticator, err := deviceauth.NewSQLiteAuthenticator(database)
if err != nil {
return err
}
router, err := server.NewRouter(server.Options{
AdminUsername: configuration.AdminUsername,
AdminPasswordBcrypt: configuration.AdminPasswordBcrypt,
Sessions: auth.NewManager(configuration.SessionSecret, configuration.CookieSecure),
Tasks: taskStore,
TaskDetails: detailStore,
Evidence: evidenceStore,
DeviceAuthenticator: deviceAuthenticator,
})
if err != nil {
return err
+9
View File
@@ -0,0 +1,9 @@
package main
import "testing"
func TestListenAddressIsIPv4LoopbackOnly(t *testing.T) {
if listenAddress != "127.0.0.1:8080" {
t.Fatalf("listenAddress = %q, want loopback-only endpoint", listenAddress)
}
}
+11
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -21,6 +22,7 @@ const (
authorizationTTLEnv = "CMBUYER_AUTHORIZATION_TTL"
maxTaskQuantityEnv = "CMBUYER_MAX_TASK_QUANTITY"
maxTotalPriceEnv = "CMBUYER_MAX_TOTAL_PRICE"
evidenceDirectoryEnv = "CMBUYER_EVIDENCE_DIR"
minimumSecretLength = 32
)
@@ -34,6 +36,7 @@ type Config struct {
AuthorizationTTL time.Duration
MaxTaskQuantity int
MaxTotalPrice string
EvidenceDirectory string
}
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
@@ -102,6 +105,13 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
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,
@@ -110,6 +120,7 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
CookieSecure: cookieSecure,
DatabaseSource: databaseSource,
AuthorizationTTL: ttl, MaxTaskQuantity: maxQuantity, MaxTotalPrice: maxPrice,
EvidenceDirectory: evidenceDirectory,
}, nil
}
+4
View File
@@ -24,6 +24,7 @@ func TestLoad(t *testing.T) {
"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))
@@ -49,6 +50,7 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
"CMBUYER_AUTHORIZATION_TTL": "10m",
"CMBUYER_MAX_TASK_QUANTITY": "99",
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
}
tests := []struct {
@@ -64,6 +66,8 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
{"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 {
+212
View File
@@ -0,0 +1,212 @@
package deviceauth
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"io"
"strings"
"sync"
"time"
"unicode"
)
const (
StatusActive = "ACTIVE"
StatusRevoked = "REVOKED"
)
var (
ErrInvalidCredential = errors.New("invalid device credential input")
ErrCredentialNotFound = errors.New("device credential not found")
)
type Credential struct {
DeviceID string `json:"device_id"`
DisplayName string `json:"display_name"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
// IssuedCredential is the only value that can carry the plaintext token. It is returned only
// after SQLite has committed the hash and is intended for the management CLI's one stdout write.
type IssuedCredential struct {
Credential
Token string `json:"-"`
}
type CredentialStore struct {
database *sql.DB
now func() time.Time
random io.Reader
randomMu sync.Mutex
}
func NewCredentialStore(database *sql.DB) (*CredentialStore, error) {
if database == nil {
return nil, errors.New("device credential database is required")
}
if _, err := database.Exec("SELECT device_id FROM device_credentials LIMIT 1"); err != nil {
return nil, errors.New("device credential migration is not available")
}
return &CredentialStore{database: database, now: time.Now, random: rand.Reader}, nil
}
func (store *CredentialStore) Issue(ctx context.Context, displayName string) (IssuedCredential, error) {
if !ValidDisplayName(displayName) {
return IssuedCredential{}, ErrInvalidCredential
}
randomBytes := make([]byte, 16+32)
store.randomMu.Lock()
_, randomErr := io.ReadFull(store.random, randomBytes)
store.randomMu.Unlock()
if randomErr != nil {
return IssuedCredential{}, fmt.Errorf("generate device credential: %w", randomErr)
}
deviceID := formatUUIDv4(randomBytes[:16])
token := hex.EncodeToString(randomBytes[16:])
tokenHash := sha256.Sum256(randomBytes[16:])
createdAt := store.now().UTC()
if createdAt.IsZero() {
return IssuedCredential{}, errors.New("device credential clock is invalid")
}
_, err := store.database.ExecContext(ctx, `INSERT INTO device_credentials
(device_id, display_name, token_sha256, status, created_at, revoked_at)
VALUES (?, ?, ?, ?, ?, NULL)`,
deviceID, displayName, tokenHash[:], StatusActive, createdAt.Format(time.RFC3339Nano))
if err != nil {
return IssuedCredential{}, fmt.Errorf("persist device credential: %w", err)
}
return IssuedCredential{Credential: Credential{
DeviceID: deviceID, DisplayName: displayName, Status: StatusActive, CreatedAt: createdAt,
}, Token: token}, nil
}
func (store *CredentialStore) List(ctx context.Context) ([]Credential, error) {
rows, err := store.database.QueryContext(ctx, `SELECT device_id, display_name, status, created_at, revoked_at
FROM device_credentials ORDER BY created_at, device_id`)
if err != nil {
return nil, fmt.Errorf("list device credentials: %w", err)
}
defer rows.Close()
credentials := make([]Credential, 0)
for rows.Next() {
credential, err := scanCredential(rows)
if err != nil {
return nil, err
}
credentials = append(credentials, credential)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list device credentials: %w", err)
}
return credentials, nil
}
func (store *CredentialStore) Revoke(ctx context.Context, deviceID string) (Credential, bool, error) {
if !ValidDeviceID(deviceID) {
return Credential{}, false, ErrInvalidCredential
}
revokedAt := store.now().UTC()
if revokedAt.IsZero() {
return Credential{}, false, errors.New("device credential clock is invalid")
}
transaction, err := store.database.BeginTx(ctx, nil)
if err != nil {
return Credential{}, false, fmt.Errorf("begin device credential revocation: %w", err)
}
defer transaction.Rollback()
result, err := transaction.ExecContext(ctx, `UPDATE device_credentials
SET status = ?, revoked_at = ? WHERE device_id = ? AND status = ?`,
StatusRevoked, revokedAt.Format(time.RFC3339Nano), deviceID, StatusActive)
if err != nil {
return Credential{}, false, fmt.Errorf("revoke device credential: %w", err)
}
changedRows, err := result.RowsAffected()
if err != nil {
return Credential{}, false, fmt.Errorf("inspect device credential revocation: %w", err)
}
credential, err := scanCredential(transaction.QueryRowContext(ctx, `SELECT device_id, display_name, status, created_at, revoked_at
FROM device_credentials WHERE device_id = ?`, deviceID))
if errors.Is(err, sql.ErrNoRows) {
return Credential{}, false, ErrCredentialNotFound
}
if err != nil {
return Credential{}, false, err
}
if err := transaction.Commit(); err != nil {
return Credential{}, false, fmt.Errorf("commit device credential revocation: %w", err)
}
return credential, changedRows == 1, nil
}
type rowScanner interface {
Scan(...any) error
}
func scanCredential(row rowScanner) (Credential, error) {
var credential Credential
var created string
var revoked sql.NullString
if err := row.Scan(&credential.DeviceID, &credential.DisplayName, &credential.Status, &created, &revoked); err != nil {
return Credential{}, err
}
if !ValidDeviceID(credential.DeviceID) || !ValidDisplayName(credential.DisplayName) || (credential.Status != StatusActive && credential.Status != StatusRevoked) {
return Credential{}, errors.New("stored device credential metadata is invalid")
}
createdAt, err := parseStoredTime(created)
if err != nil {
return Credential{}, err
}
credential.CreatedAt = createdAt
if revoked.Valid {
revokedAt, err := parseStoredTime(revoked.String)
if err != nil {
return Credential{}, err
}
if revokedAt.Before(createdAt) {
return Credential{}, errors.New("stored device credential status is invalid")
}
credential.RevokedAt = &revokedAt
}
if (credential.Status == StatusActive) != (credential.RevokedAt == nil) {
return Credential{}, errors.New("stored device credential status is invalid")
}
return credential, nil
}
func ValidDisplayName(value string) bool {
if value == "" || len([]rune(value)) > 128 || strings.TrimSpace(value) != value {
return false
}
for _, character := range value {
if unicode.IsControl(character) {
return false
}
}
return true
}
func parseStoredTime(value string) (time.Time, error) {
if strings.TrimSpace(value) != value || !strings.HasSuffix(value, "Z") {
return time.Time{}, errors.New("stored device credential time is invalid")
}
parsed, err := time.Parse(time.RFC3339Nano, value)
if err != nil || parsed.Location() != time.UTC {
return time.Time{}, errors.New("stored device credential time is invalid")
}
return parsed, nil
}
func formatUUIDv4(bytes []byte) string {
copyBytes := append([]byte(nil), bytes...)
copyBytes[6] = (copyBytes[6] & 0x0f) | 0x40
copyBytes[8] = (copyBytes[8] & 0x3f) | 0x80
encoded := hex.EncodeToString(copyBytes)
return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:]
}
+168
View File
@@ -0,0 +1,168 @@
// Package deviceauth owns the machine identity boundary shared by all device routes.
package deviceauth
import (
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"errors"
"net/http"
"strings"
)
const (
AuthorizationHeader = "Authorization"
DeviceIDHeader = "X-CMBuyer-Device-ID"
tokenHexLength = 64
)
var (
// ErrUnauthenticated deliberately covers every credential defect. Callers must not reveal
// whether a device exists, is revoked, or supplied a mismatched token.
ErrUnauthenticated = errors.New("device authentication failed")
// ErrUnavailable is distinct so a storage outage is not disguised as a bad credential.
// HTTP callers still return no diagnostic body because database details are server-only.
ErrUnavailable = errors.New("device authentication unavailable")
)
type Principal struct {
ID string
}
type Authenticator interface {
Authenticate(*http.Request) (Principal, error)
}
// RejectAllAuthenticator is useful for tests and for fail-closed wiring where no credential
// store is available. Production startup uses SQLiteAuthenticator.
type RejectAllAuthenticator struct{}
func (RejectAllAuthenticator) Authenticate(*http.Request) (Principal, error) {
return Principal{}, ErrUnauthenticated
}
type SQLiteAuthenticator struct {
database *sql.DB
}
func NewSQLiteAuthenticator(database *sql.DB) (*SQLiteAuthenticator, error) {
if database == nil {
return nil, errors.New("device credential database is required")
}
if _, err := database.Exec("SELECT device_id FROM device_credentials LIMIT 1"); err != nil {
return nil, errors.New("device credential migration is not available")
}
return &SQLiteAuthenticator{database: database}, nil
}
func (authenticator *SQLiteAuthenticator) Authenticate(request *http.Request) (Principal, error) {
if request == nil {
return Principal{}, ErrUnauthenticated
}
deviceID, token, ok := requestCredentials(request)
if !ok {
return Principal{}, ErrUnauthenticated
}
candidateHash := sha256.Sum256(token)
var storedHash []byte
var hashType string
var hashLength sql.NullInt64
var status sql.NullString
var revokedAt sql.NullString
var found bool
err := authenticator.database.QueryRowContext(
request.Context(),
`SELECT CASE WHEN credentials.device_id IS NULL THEN zeroblob(32) ELSE credentials.token_sha256 END,
typeof(credentials.token_sha256),
length(credentials.token_sha256),
credentials.status,
credentials.revoked_at,
credentials.device_id IS NOT NULL
FROM (SELECT 1) AS singleton
LEFT JOIN device_credentials AS credentials ON credentials.device_id = ?`,
deviceID,
).Scan(&storedHash, &hashType, &hashLength, &status, &revokedAt, &found)
if err != nil {
return Principal{}, ErrUnavailable
}
if len(storedHash) != sha256.Size {
return Principal{}, ErrUnavailable
}
matched := subtle.ConstantTimeCompare(candidateHash[:], storedHash) == 1
if !found {
// The LEFT JOIN supplies a 32-byte dummy hash, so unknown ids take the same compare path
// as known credentials without requiring a plaintext token lookup.
return Principal{}, ErrUnauthenticated
}
if hashType != "blob" || !hashLength.Valid || hashLength.Int64 != sha256.Size || len(storedHash) != sha256.Size || !status.Valid {
return Principal{}, ErrUnavailable
}
switch status.String {
case StatusActive:
if revokedAt.Valid {
return Principal{}, ErrUnavailable
}
case StatusRevoked:
if !revokedAt.Valid {
return Principal{}, ErrUnavailable
}
if _, err := parseStoredTime(revokedAt.String); err != nil {
return Principal{}, ErrUnavailable
}
default:
return Principal{}, ErrUnavailable
}
if !matched || status.String == StatusRevoked {
return Principal{}, ErrUnauthenticated
}
return Principal{ID: deviceID}, nil
}
func requestCredentials(request *http.Request) (string, []byte, bool) {
authorizations := request.Header.Values(AuthorizationHeader)
deviceIDs := request.Header.Values(DeviceIDHeader)
if len(authorizations) != 1 || len(deviceIDs) != 1 {
return "", nil, false
}
authorization := authorizations[0]
if len(authorization) != len("Bearer ")+tokenHexLength || !strings.EqualFold(authorization[:len("Bearer")], "Bearer") || authorization[len("Bearer")] != ' ' {
return "", nil, false
}
tokenHex := authorization[len("Bearer "):]
if !validLowerHex(tokenHex, tokenHexLength) || !ValidDeviceID(deviceIDs[0]) {
return "", nil, false
}
token, err := hex.DecodeString(tokenHex)
if err != nil {
return "", nil, false
}
return deviceIDs[0], token, true
}
func ValidDeviceID(value string) bool {
if len(value) != 36 {
return false
}
for index, character := range value {
if index == 8 || index == 13 || index == 18 || index == 23 {
if character != '-' {
return false
}
continue
}
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
return false
}
}
return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b')
}
func validLowerHex(value string, length int) bool {
if len(value) != length {
return false
}
decoded, err := hex.DecodeString(value)
return err == nil && hex.EncodeToString(decoded) == value
}
@@ -0,0 +1,407 @@
package deviceauth
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
)
func TestIssueStoresOnlyRawTokenHashAndGenericJSONOmitsSecret(t *testing.T) {
database, store := newCredentialStore(t)
first, err := store.Issue(context.Background(), "采购工具一号")
if err != nil {
t.Fatalf("Issue first: %v", err)
}
second, err := store.Issue(context.Background(), "采购工具二号")
if err != nil {
t.Fatalf("Issue second: %v", err)
}
if first.DeviceID == second.DeviceID || first.Token == second.Token || !ValidDeviceID(first.DeviceID) || !validLowerHex(first.Token, tokenHexLength) {
t.Fatalf("issued identifiers are not independent canonical values")
}
rawToken, err := hex.DecodeString(first.Token)
if err != nil {
t.Fatalf("decode issued token: %v", err)
}
wantHash := sha256.Sum256(rawToken)
var storedHash []byte
var storageType string
if err := database.QueryRow(`SELECT token_sha256, typeof(token_sha256) FROM device_credentials WHERE device_id = ?`, first.DeviceID).Scan(&storedHash, &storageType); err != nil {
t.Fatalf("read stored hash: %v", err)
}
if storageType != "blob" || len(storedHash) != sha256.Size || !equalBytes(storedHash, wantHash[:]) {
t.Fatalf("stored hash type/length/value = %q/%d/%t", storageType, len(storedHash), equalBytes(storedHash, wantHash[:]))
}
var leakedCopies int
if err := database.QueryRow(`SELECT COUNT(*) FROM device_credentials WHERE CAST(token_sha256 AS TEXT) IN (?, ?)`, first.Token, hex.EncodeToString(wantHash[:])).Scan(&leakedCopies); err != nil {
t.Fatalf("search token copies: %v", err)
}
if leakedCopies != 0 {
t.Fatal("database stored a plaintext or hex-encoded token/hash copy")
}
encoded, err := json.Marshal(first)
if err != nil {
t.Fatalf("marshal issued credential: %v", err)
}
if strings.Contains(string(encoded), first.Token) || strings.Contains(string(encoded), "token") {
t.Fatalf("generic serialization disclosed token field: %s", encoded)
}
}
func TestAuthenticateStrictHeaderMatrixAndBinding(t *testing.T) {
_, store := newCredentialStore(t)
first, err := store.Issue(context.Background(), "one")
if err != nil {
t.Fatalf("issue first: %v", err)
}
second, err := store.Issue(context.Background(), "two")
if err != nil {
t.Fatalf("issue second: %v", err)
}
authenticator := authenticatorForStore(t, store)
for _, scheme := range []string{"Bearer", "bearer", "BEARER"} {
request := credentialRequest(first.DeviceID, scheme+" "+first.Token)
principal, err := authenticator.Authenticate(request)
if err != nil || principal.ID != first.DeviceID {
t.Fatalf("scheme %q Authenticate = (%q, %v)", scheme, principal.ID, err)
}
}
unknownID := newRuntimeUUID(t)
wrongToken := newRuntimeToken(t)
tests := []struct {
name string
mutate func(*http.Request)
}{
{"missing authorization", func(request *http.Request) { request.Header.Del(AuthorizationHeader) }},
{"missing device", func(request *http.Request) { request.Header.Del(DeviceIDHeader) }},
{"empty authorization", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "") }},
{"empty device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, "") }},
{"duplicate authorization", func(request *http.Request) { request.Header.Add(AuthorizationHeader, "Bearer "+first.Token) }},
{"duplicate device", func(request *http.Request) { request.Header.Add(DeviceIDHeader, first.DeviceID) }},
{"combined authorization", func(request *http.Request) {
request.Header.Set(AuthorizationHeader, "Bearer "+first.Token+", Bearer "+first.Token)
}},
{"combined device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, first.DeviceID+", "+first.DeviceID) }},
{"extra separator", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token) }},
{"tab separator", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer\t"+first.Token) }},
{"uppercase token", func(request *http.Request) {
request.Header.Set(AuthorizationHeader, "Bearer "+strings.ToUpper(first.Token))
}},
{"short token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token[:62]) }},
{"long token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token+"00") }},
{"non hex token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+first.Token[:63]+"g") }},
{"token separator", func(request *http.Request) {
request.Header.Set(AuthorizationHeader, "Bearer "+first.Token[:32]+"-"+first.Token[33:])
}},
{"uppercase device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, strings.ToUpper(first.DeviceID)) }},
{"padded device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, " "+first.DeviceID) }},
{"wrong uuid version", func(request *http.Request) {
request.Header.Set(DeviceIDHeader, first.DeviceID[:14]+"3"+first.DeviceID[15:])
}},
{"wrong uuid variant", func(request *http.Request) {
request.Header.Set(DeviceIDHeader, first.DeviceID[:19]+"7"+first.DeviceID[20:])
}},
{"unknown device", func(request *http.Request) { request.Header.Set(DeviceIDHeader, unknownID) }},
{"wrong token", func(request *http.Request) { request.Header.Set(AuthorizationHeader, "Bearer "+wrongToken) }},
{"token device mismatch", func(request *http.Request) { request.Header.Set(DeviceIDHeader, second.DeviceID) }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
request := credentialRequest(first.DeviceID, "Bearer "+first.Token)
test.mutate(request)
principal, err := authenticator.Authenticate(request)
if !errors.Is(err, ErrUnauthenticated) || principal != (Principal{}) {
t.Fatalf("Authenticate = (%#v, %v), want empty unauthenticated", principal, err)
}
})
}
if principal, err := authenticator.Authenticate(nil); !errors.Is(err, ErrUnauthenticated) || principal != (Principal{}) {
t.Fatalf("Authenticate(nil) = (%#v, %v)", principal, err)
}
}
func TestRevokeIsImmediateAndIdempotent(t *testing.T) {
_, store := newCredentialStore(t)
issued, err := store.Issue(context.Background(), "device")
if err != nil {
t.Fatalf("Issue: %v", err)
}
authenticator := authenticatorForStore(t, store)
request := credentialRequest(issued.DeviceID, "Bearer "+issued.Token)
if _, err := authenticator.Authenticate(request); err != nil {
t.Fatalf("Authenticate before revoke: %v", err)
}
first, changed, err := store.Revoke(context.Background(), issued.DeviceID)
if err != nil || !changed || first.Status != StatusRevoked || first.RevokedAt == nil {
t.Fatalf("first Revoke = (%#v, %t, %v)", first, changed, err)
}
if principal, err := authenticator.Authenticate(request); !errors.Is(err, ErrUnauthenticated) || principal != (Principal{}) {
t.Fatalf("Authenticate after committed revoke = (%#v, %v)", principal, err)
}
second, changed, err := store.Revoke(context.Background(), issued.DeviceID)
if err != nil || changed || second.RevokedAt == nil || !second.RevokedAt.Equal(*first.RevokedAt) {
t.Fatalf("second Revoke = (%#v, %t, %v)", second, changed, err)
}
listed, err := store.List(context.Background())
if err != nil || len(listed) != 1 || listed[0].Status != StatusRevoked {
t.Fatalf("List = (%#v, %v)", listed, err)
}
}
func TestConcurrentAuthenticationAndRevocation(t *testing.T) {
_, store := newCredentialStore(t)
issued, err := store.Issue(context.Background(), "concurrent")
if err != nil {
t.Fatalf("Issue: %v", err)
}
authenticator := authenticatorForStore(t, store)
request := func() *http.Request { return credentialRequest(issued.DeviceID, "Bearer "+issued.Token) }
start := make(chan struct{})
results := make(chan error, 16)
var wait sync.WaitGroup
for index := 0; index < 16; index++ {
wait.Add(1)
go func() {
defer wait.Done()
<-start
_, err := authenticator.Authenticate(request())
results <- err
}()
}
close(start)
if _, _, err := store.Revoke(context.Background(), issued.DeviceID); err != nil {
t.Fatalf("Revoke: %v", err)
}
wait.Wait()
close(results)
for err := range results {
if err != nil && !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("concurrent Authenticate error = %v", err)
}
}
for index := 0; index < 16; index++ {
if _, err := authenticator.Authenticate(request()); !errors.Is(err, ErrUnauthenticated) {
t.Fatalf("post-commit Authenticate %d error = %v", index, err)
}
}
}
func TestAuthenticationDatabaseFaultAndCorruptionAreUnavailable(t *testing.T) {
database, store := newCredentialStore(t)
issued, err := store.Issue(context.Background(), "device")
if err != nil {
t.Fatalf("Issue: %v", err)
}
authenticator := authenticatorForStore(t, store)
if err := database.Close(); err != nil {
t.Fatalf("close database: %v", err)
}
if _, err := authenticator.Authenticate(credentialRequest(issued.DeviceID, "Bearer "+issued.Token)); !errors.Is(err, ErrUnavailable) {
t.Fatalf("closed database Authenticate error = %v", err)
}
corruptDB, err := sqlite.Open(filepath.Join(t.TempDir(), "corrupt.db"))
if err != nil {
t.Fatalf("open corrupt database: %v", err)
}
t.Cleanup(func() { _ = corruptDB.Close() })
if _, err := corruptDB.Exec(`CREATE TABLE device_credentials (device_id TEXT PRIMARY KEY, token_sha256 BLOB, status TEXT, revoked_at TEXT)`); err != nil {
t.Fatalf("create corrupt table: %v", err)
}
corruptAuthenticator, err := NewSQLiteAuthenticator(corruptDB)
if err != nil {
t.Fatalf("new corrupt authenticator: %v", err)
}
tests := []struct {
name string
hashValue func([sha256.Size]byte) any
status string
revokedAt any
}{
{name: "null hash", hashValue: func([sha256.Size]byte) any { return nil }, status: StatusActive},
{name: "matching text hash", hashValue: func(hash [sha256.Size]byte) any { return string(hash[:]) }, status: StatusActive},
{name: "unknown status", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: "BROKEN"},
{name: "active with revoked time", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: StatusActive, revokedAt: "2026-08-04T00:00:00Z"},
{name: "revoked without time", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: StatusRevoked},
{name: "revoked with invalid time", hashValue: func(hash [sha256.Size]byte) any { return hash[:] }, status: StatusRevoked, revokedAt: "not-a-time"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
rawToken, token := newRuntimeTokenPair(t)
hash := sha256.Sum256(rawToken)
deviceID := newRuntimeUUID(t)
if _, err := corruptDB.Exec(`INSERT INTO device_credentials VALUES (?, ?, ?, ?)`, deviceID, test.hashValue(hash), test.status, test.revokedAt); err != nil {
t.Fatalf("insert corrupt row: %v", err)
}
principal, err := corruptAuthenticator.Authenticate(credentialRequest(deviceID, "Bearer "+token))
if !errors.Is(err, ErrUnavailable) || principal != (Principal{}) {
t.Fatalf("corrupt Authenticate = (%#v, %v), want unavailable", principal, err)
}
})
}
}
func TestCredentialAuthenticationSurvivesDatabaseReopen(t *testing.T) {
databaseSource := filepath.Join(t.TempDir(), "reopen.db")
database, err := sqlite.Open(databaseSource)
if err != nil {
t.Fatalf("open database: %v", err)
}
if err := migrations.Up(context.Background(), database, deviceMigrationDirectory(t)); err != nil {
_ = database.Close()
t.Fatalf("migrate database: %v", err)
}
store, err := NewCredentialStore(database)
if err != nil {
_ = database.Close()
t.Fatalf("new store: %v", err)
}
issued, err := store.Issue(context.Background(), "reopen")
if err != nil {
_ = database.Close()
t.Fatalf("issue: %v", err)
}
if err := database.Close(); err != nil {
t.Fatalf("close database: %v", err)
}
reopened, err := sqlite.Open(databaseSource)
if err != nil {
t.Fatalf("reopen database: %v", err)
}
defer reopened.Close()
authenticator, err := NewSQLiteAuthenticator(reopened)
if err != nil {
t.Fatalf("new reopened authenticator: %v", err)
}
principal, err := authenticator.Authenticate(credentialRequest(issued.DeviceID, "Bearer "+issued.Token))
if err != nil || principal.ID != issued.DeviceID {
t.Fatalf("Authenticate after reopen = (%#v, %v)", principal, err)
}
}
func TestCredentialInputAndMigrationAreRequired(t *testing.T) {
database, err := sqlite.Open(filepath.Join(t.TempDir(), "unmigrated.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
if _, err := NewCredentialStore(database); err == nil {
t.Fatal("NewCredentialStore accepted an unmigrated database")
}
if _, err := NewSQLiteAuthenticator(database); err == nil {
t.Fatal("NewSQLiteAuthenticator accepted an unmigrated database")
}
_, store := newCredentialStore(t)
for _, name := range []string{"", " leading", "trailing ", "line\nbreak", strings.Repeat("名", 129)} {
if _, err := store.Issue(context.Background(), name); !errors.Is(err, ErrInvalidCredential) {
t.Fatalf("Issue(%q) error = %v", name, err)
}
}
if _, _, err := store.Revoke(context.Background(), "not-a-uuid"); !errors.Is(err, ErrInvalidCredential) {
t.Fatalf("Revoke invalid id error = %v", err)
}
if _, _, err := store.Revoke(context.Background(), newRuntimeUUID(t)); !errors.Is(err, ErrCredentialNotFound) {
t.Fatalf("Revoke unknown id error = %v", err)
}
}
func newCredentialStore(t *testing.T) (*sql.DB, *CredentialStore) {
t.Helper()
databaseSource := filepath.Join(t.TempDir(), "device-auth.db") + "?_busy_timeout=5000&_journal_mode=WAL"
database, err := sqlite.Open(databaseSource)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
if err := migrations.Up(context.Background(), database, deviceMigrationDirectory(t)); err != nil {
t.Fatalf("migrate database: %v", err)
}
store, err := NewCredentialStore(database)
if err != nil {
t.Fatalf("NewCredentialStore: %v", err)
}
store.now = func() time.Time { return time.Date(2026, 8, 4, 12, 0, 0, 123, time.UTC) }
return database, store
}
func authenticatorForStore(t *testing.T, store *CredentialStore) *SQLiteAuthenticator {
t.Helper()
authenticator, err := NewSQLiteAuthenticator(store.database)
if err != nil {
t.Fatalf("NewSQLiteAuthenticator: %v", err)
}
return authenticator
}
func credentialRequest(deviceID, authorization string) *http.Request {
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/id/evidence", nil)
request.Header.Set(DeviceIDHeader, deviceID)
request.Header.Set(AuthorizationHeader, authorization)
return request
}
func newRuntimeToken(t *testing.T) string {
t.Helper()
_, token := newRuntimeTokenPair(t)
return token
}
func newRuntimeTokenPair(t *testing.T) ([]byte, string) {
t.Helper()
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
t.Fatalf("generate runtime token: %v", err)
}
return raw, hex.EncodeToString(raw)
}
func newRuntimeUUID(t *testing.T) string {
t.Helper()
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
t.Fatalf("generate runtime UUID: %v", err)
}
return formatUUIDv4(raw)
}
func equalBytes(left, right []byte) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
func deviceMigrationDirectory(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate migrations")
}
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
}
+72
View File
@@ -0,0 +1,72 @@
// Package evidence defines the narrow internal screenshot contract shared by HTTP and storage.
package evidence
import (
"context"
"errors"
"io"
"time"
"cmbuyer/admin/internal/deviceauth"
)
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")
)
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, deviceauth.Principal, UploadMetadata, StagedFile) (Asset, bool, error)
Open(context.Context, string) (Asset, io.ReadSeekCloser, error)
}
+160 -13
View File
@@ -26,18 +26,33 @@ func TestUpDownAndIdempotence(t *testing.T) {
if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("apply migrations: %v", err)
}
assertVersion(t, database, 2)
assertVersion(t, database, 4)
assertTableExists(t, database, "tasks", true)
assertTableExists(t, database, "spec_trials", false)
assertTableExists(t, database, "order_authorizations", true)
assertTableExists(t, database, "purchase_attempts", true)
assertTableExists(t, database, "order_submissions", true)
assertTableExists(t, database, "evidence_assets", true)
assertTableExists(t, database, "device_credentials", true)
assertTableExists(t, database, "single_pass_upgrade_guard", false)
if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("reapply migrations: %v", err)
}
assertVersion(t, database, 4)
if err := migrations.Down(context, database, directory); err != nil {
t.Fatalf("roll back device credential migration: %v", err)
}
assertVersion(t, database, 3)
assertTableExists(t, database, "device_credentials", false)
assertTableExists(t, database, "evidence_assets", true)
if err := migrations.Down(context, database, directory); err != nil {
t.Fatalf("roll back evidence migration: %v", err)
}
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)
@@ -50,7 +65,7 @@ func TestUpDownAndIdempotence(t *testing.T) {
if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("reapply v2 after rollback: %v", err)
}
assertVersion(t, database, 2)
assertVersion(t, database, 4)
}
func TestUpgradePreservesManualDraftLosslessly(t *testing.T) {
@@ -69,7 +84,7 @@ func TestUpgradePreservesManualDraftLosslessly(t *testing.T) {
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("upgrade v1 draft: %v", err)
}
assertVersion(t, database, 2)
assertVersion(t, database, 4)
var got struct {
id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string
quantity, version int
@@ -218,6 +233,127 @@ func TestV2SchemaConstraintsAndRelationships(t *testing.T) {
}
}
func TestEvidenceSchemaConstraintsAndDowngradeGuard(t *testing.T) {
database := openTestDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("apply migrations: %v", err)
}
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.Fatalf("roll back empty device credential migration: %v", err)
}
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 TestDeviceCredentialSchemaConstraintsAndDowngradeGuard(t *testing.T) {
database := openTestDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("apply migrations: %v", err)
}
deviceID := "13c9f507-7473-4fa6-8d71-8786c34c6301"
hash := make([]byte, 32)
for index := range hash {
hash[index] = byte(index + 1)
}
insert := `INSERT INTO device_credentials (device_id, display_name, token_sha256, status, created_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?)`
valid := []any{deviceID, "采购工具一号", hash, "ACTIVE", migrationTime, nil}
if _, err := database.Exec(insert, valid...); err != nil {
t.Fatalf("insert valid credential: %v", err)
}
for name, mutate := range map[string]func([]any){
"uppercase uuid": func(values []any) { values[0], values[2] = strings.ToUpper(deviceID), append([]byte(nil), hash...) },
"wrong uuid version": func(values []any) {
values[0], values[2] = "23c9f507-7473-3fa6-8d71-8786c34c6301", append([]byte(nil), hash...)
},
"blank display name": func(values []any) {
values[0], values[1], values[2] = "33c9f507-7473-4fa6-8d71-8786c34c6301", "", append([]byte(nil), hash...)
},
"padded display name": func(values []any) {
values[0], values[1], values[2] = "43c9f507-7473-4fa6-8d71-8786c34c6301", " padded", append([]byte(nil), hash...)
},
"text hash": func(values []any) {
values[0], values[2] = "53c9f507-7473-4fa6-8d71-8786c34c6301", strings.Repeat("a", 32)
},
"short blob hash": func(values []any) { values[0], values[2] = "63c9f507-7473-4fa6-8d71-8786c34c6301", make([]byte, 31) },
"unknown status": func(values []any) {
values[0], values[2], values[3] = "73c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "UNKNOWN"
},
"active with revoke time": func(values []any) {
values[0], values[2], values[5] = "83c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), migrationTime
},
"revoked without time": func(values []any) {
values[0], values[2], values[3] = "93c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "REVOKED"
},
"revoke before creation": func(values []any) {
values[0], values[2], values[3], values[4], values[5] = "b3c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "REVOKED", "2026-08-04T01:00:00Z", "2026-08-04T00:00:00Z"
},
"non UTC created time": func(values []any) {
values[0], values[2], values[4] = "a3c9f507-7473-4fa6-8d71-8786c34c6301", append([]byte(nil), hash...), "2026-08-04T08:00:00+08:00"
},
} {
t.Run(name, func(t *testing.T) {
values := append([]any(nil), valid...)
mutate(values)
if bytesValue, ok := values[2].([]byte); ok && len(bytesValue) == 32 {
bytesValue[0]++
}
if _, err := database.Exec(insert, values...); err == nil {
t.Fatal("invalid device credential row succeeded")
}
})
}
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
t.Fatal("credential-bearing schema downgraded successfully")
}
assertVersion(t, database, 4)
assertTableExists(t, database, "device_credentials", true)
var count int
if err := database.QueryRow(`SELECT COUNT(*) FROM device_credentials`).Scan(&count); err != nil || count != 1 {
t.Fatalf("credentials after rejected downgrade = %d, err=%v", count, err)
}
}
func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) {
tests := []struct {
name string
@@ -244,9 +380,7 @@ func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
database := openTestDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("apply migrations: %v", err)
}
migrateToV2(t, database)
test.setup(t, database)
before := v2RowCount(t, database)
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil {
@@ -271,6 +405,17 @@ func migrateToV1(t *testing.T, database *sql.DB) {
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 {
@@ -390,12 +535,14 @@ func assertColumnType(t *testing.T, database *sql.DB, table, column, want string
}
}
func TestV2MigrationSQLDoesNotDisableForeignKeys(t *testing.T) {
contents, err := os.ReadFile(filepath.Join(migrationDirectory(t), "00002_single_pass_model.sql"))
if err != nil {
t.Fatalf("read migration: %v", err)
}
if strings.Contains(strings.ToUpper(string(contents)), "PRAGMA FOREIGN_KEYS = OFF") {
t.Fatal("migration disables foreign keys")
func TestMigrationsDoNotDisableForeignKeys(t *testing.T) {
for _, name := range []string{"00002_single_pass_model.sql", "00003_evidence_assets.sql", "00004_device_credentials.sql"} {
contents, err := os.ReadFile(filepath.Join(migrationDirectory(t), name))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
if strings.Contains(strings.ToUpper(string(contents)), "PRAGMA FOREIGN_KEYS = OFF") {
t.Fatalf("%s disables foreign keys", name)
}
}
}
+205
View File
@@ -0,0 +1,205 @@
package server
import (
"errors"
"io"
"mime"
"mime/multipart"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
"cmbuyer/admin/internal/deviceauth"
"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, err := options.DeviceAuthenticator.Authenticate(context.Request)
if errors.Is(err, deviceauth.ErrUnauthenticated) {
context.Header("WWW-Authenticate", "Bearer")
context.Status(http.StatusUnauthorized)
return
}
if err != nil {
context.Status(http.StatusServiceUnavailable)
return
}
if !deviceauth.ValidDeviceID(principal.ID) {
// A custom authenticator is still an untrusted boundary. Do not defer principal
// validation until Commit because multipart bytes would already have been read.
context.Status(http.StatusServiceUnavailable)
return
}
boundary, ok := multipartBoundary(context.GetHeader("Content-Type"))
if !ok {
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)
}
}
+451
View File
@@ -0,0 +1,451 @@
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/deviceauth"
"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"
evidenceDeviceID = "13c9f507-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 || response.Body.Len() != 0 || response.Header().Get("WWW-Authenticate") != "Bearer" || poison.reads != 0 || authenticator.calls != 1 {
t.Fatalf("status/reads/auth calls = %d/%d/%d, want 401/0/1", response.Code, poison.reads, authenticator.calls)
}
assertSecurityHeaders(t, response)
}
func TestEvidenceUploadAuthenticationStorageFailureBeforeReadingBody(t *testing.T) {
database, err := sqlite.Open(filepath.Join(t.TempDir(), "authentication-failure.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
if err := migrations.Up(context.Background(), database, testMigrationDirectory(t)); err != nil {
t.Fatalf("migrate database: %v", err)
}
authenticator, err := deviceauth.NewSQLiteAuthenticator(database)
if err != nil {
t.Fatalf("new authenticator: %v", err)
}
credentialStore, err := deviceauth.NewCredentialStore(database)
if err != nil {
t.Fatalf("new credential store: %v", err)
}
issued, err := credentialStore.Issue(context.Background(), "test device")
if err != nil {
t.Fatalf("issue credential: %v", err)
}
if err := database.Close(); err != nil {
t.Fatalf("close database: %v", err)
}
router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator)
poison := &poisonBody{}
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil)
request.Body = poison
request.Header.Set(deviceauth.AuthorizationHeader, "Bearer "+issued.Token)
request.Header.Set(deviceauth.DeviceIDHeader, issued.DeviceID)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable || response.Body.Len() != 0 || poison.reads != 0 {
t.Fatalf("storage failure status/body/reads = %d/%q/%d, want 503/empty/0", response.Code, response.Body.String(), poison.reads)
}
}
func TestEvidenceUploadRejectsInvalidSuccessfulPrincipalBeforeReadingBody(t *testing.T) {
router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, uncheckedDeviceAuthenticator{})
poison := &poisonBody{}
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil)
request.Body = poison
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable || response.Body.Len() != 0 || poison.reads != 0 {
t.Fatalf("invalid principal status/body/reads = %d/%q/%d, want 503/empty/0", response.Code, response.Body.String(), poison.reads)
}
}
func TestAdminSessionCannotActAsDeviceUploader(t *testing.T) {
router, _ := newRouter(t)
cookie := authenticate(t, router)
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 TestRealDeviceCredentialIdentityIsolationAndMixedCredentials(t *testing.T) {
database, err := sqlite.Open(filepath.Join(t.TempDir(), "identity-isolation.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
if err := migrations.Up(context.Background(), database, testMigrationDirectory(t)); err != nil {
t.Fatalf("migrate database: %v", err)
}
insertEvidenceAttempt(t, database)
assetStore, err := evidencestorage.NewStore(database, filepath.Join(t.TempDir(), "assets"))
if err != nil {
t.Fatalf("new evidence store: %v", err)
}
credentialStore, err := deviceauth.NewCredentialStore(database)
if err != nil {
t.Fatalf("new credential store: %v", err)
}
issued, err := credentialStore.Issue(context.Background(), "采购工具一号")
if err != nil {
t.Fatalf("issue credential: %v", err)
}
authenticator, err := deviceauth.NewSQLiteAuthenticator(database)
if err != nil {
t.Fatalf("new authenticator: %v", err)
}
taskStore := &memoryStore{}
router, _ := newRouterWithDependencies(t, taskStore, emptyDetailStore{}, assetStore, authenticator)
addDeviceHeaders := func(request *http.Request) {
request.Header.Set(deviceauth.AuthorizationHeader, "Bearer "+issued.Token)
request.Header.Set(deviceauth.DeviceIDHeader, issued.DeviceID)
}
start := newStartRequest(t, validStartBody(), "application/json", "", nil)
addDeviceHeaders(start)
startResponse := httptest.NewRecorder()
router.ServeHTTP(startResponse, start)
create := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader("title=device"))
create.Header.Set("Content-Type", "application/x-www-form-urlencoded")
addDeviceHeaders(create)
createResponse := httptest.NewRecorder()
router.ServeHTTP(createResponse, create)
if startResponse.Code != http.StatusUnauthorized || createResponse.Code != http.StatusUnauthorized || taskStore.startCalls != 0 || len(taskStore.drafts) != 0 {
t.Fatalf("device management isolation = start %d/create %d/calls %d/drafts %d", startResponse.Code, createResponse.Code, taskStore.startCalls, len(taskStore.drafts))
}
adminCookie, csrf := authenticatedStartSession(t, router)
mixedWithoutCSRF := newStartRequest(t, validStartBody(), "application/json", "", adminCookie)
addDeviceHeaders(mixedWithoutCSRF)
mixedWithoutCSRFResponse := httptest.NewRecorder()
router.ServeHTTP(mixedWithoutCSRFResponse, mixedWithoutCSRF)
if mixedWithoutCSRFResponse.Code != http.StatusForbidden || taskStore.startCalls != 0 {
t.Fatalf("mixed request bypassed admin CSRF: status/calls=%d/%d", mixedWithoutCSRFResponse.Code, taskStore.startCalls)
}
mixedAdmin := newStartRequest(t, validStartBody(), "application/json", csrf, adminCookie)
addDeviceHeaders(mixedAdmin)
mixedAdminResponse := httptest.NewRecorder()
router.ServeHTTP(mixedAdminResponse, mixedAdmin)
if mixedAdminResponse.Code != http.StatusBadRequest || taskStore.startCalls != 1 {
t.Fatalf("mixed admin request changed identity domain: status/calls=%d/%d", mixedAdminResponse.Code, taskStore.startCalls)
}
pngBytes := serverTestPNG(t, 3, 2)
upload := newEvidenceUploadRequest(t, evidenceTaskID, validEvidenceFields(pngBytes), pngBytes, evidence.PNGContentType, "raw.png", nil)
addDeviceHeaders(upload)
upload.AddCookie(adminCookie)
uploadResponse := httptest.NewRecorder()
router.ServeHTTP(uploadResponse, upload)
if uploadResponse.Code != http.StatusCreated {
t.Fatalf("mixed upload status/body = %d/%q", uploadResponse.Code, uploadResponse.Body.String())
}
var uploadedBy string
if err := database.QueryRow(`SELECT uploaded_by_device_id FROM evidence_assets`).Scan(&uploadedBy); err != nil || uploadedBy != issued.DeviceID {
t.Fatalf("uploaded principal = %q, err=%v", uploadedBy, err)
}
if _, _, err := credentialStore.Revoke(context.Background(), issued.DeviceID); err != nil {
t.Fatalf("revoke credential: %v", err)
}
revokedUpload := newEvidenceUploadRequest(t, evidenceTaskID, validEvidenceFields(pngBytes), pngBytes, evidence.PNGContentType, "raw.png", nil)
addDeviceHeaders(revokedUpload)
revokedResponse := httptest.NewRecorder()
router.ServeHTTP(revokedResponse, revokedUpload)
if revokedResponse.Code != http.StatusUnauthorized || revokedResponse.Body.Len() != 0 {
t.Fatalf("revoked upload = %d/%q", revokedResponse.Code, revokedResponse.Body.String())
}
}
func TestEvidenceUploadReplayConflictAndProtectedRead(t *testing.T) {
router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{principal: deviceauth.Principal{ID: evidenceDeviceID}})
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{principal: deviceauth.Principal{ID: evidenceDeviceID}})
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 {
principal deviceauth.Principal
err error
calls int
}
func (authenticator *fakeDeviceAuthenticator) Authenticate(*http.Request) (deviceauth.Principal, error) {
authenticator.calls++
if authenticator.err != nil {
return deviceauth.Principal{}, authenticator.err
}
if authenticator.principal.ID == "" {
return deviceauth.Principal{}, deviceauth.ErrUnauthenticated
}
return authenticator.principal, nil
}
type poisonBody struct{ reads int }
type uncheckedDeviceAuthenticator struct{}
func (uncheckedDeviceAuthenticator) Authenticate(*http.Request) (deviceauth.Principal, error) {
return deviceauth.Principal{}, nil
}
func (body *poisonBody) Read([]byte) (int, error) {
body.reads++
return 0, io.ErrUnexpectedEOF
}
func (*poisonBody) Close() error { return nil }
func newEvidenceRouter(t *testing.T, authenticator deviceauth.Authenticator) (http.Handler, *sql.DB) {
t.Helper()
database, err := sqlite.Open(filepath.Join(t.TempDir(), "server-evidence.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
if err := migrations.Up(context.Background(), database, testMigrationDirectory(t)); 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 testMigrationDirectory(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate migration directory")
}
return filepath.Join(filepath.Dir(file), "..", "..", "migrations")
}
func insertEvidenceAttempt(t *testing.T, database *sql.DB) {
t.Helper()
timestamp := "2026-08-04T00:00:00Z"
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()
request := newEvidenceUploadRequest(t, taskID, fields, file, fileContentType, filename, extra)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func newEvidenceUploadRequest(t *testing.T, taskID string, fields map[string]string, file []byte, fileContentType, filename string, extra func(*multipart.Writer) error) *http.Request {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
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())
return request
}
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
}
+14 -1
View File
@@ -14,6 +14,9 @@ import (
"unicode/utf8"
"cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks"
"cmbuyer/admin/internal/transport/webui"
@@ -30,11 +33,14 @@ type Options struct {
AdminPasswordBcrypt string
Sessions *auth.Manager
Tasks tasks.Store
TaskDetails taskdetail.Store
Evidence evidence.Store
DeviceAuthenticator deviceauth.Authenticator
}
// NewRouter 返回当前服务范围内的完整 HTTP 路由。
func NewRouter(options Options) (*gin.Engine, error) {
if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil {
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")
}
@@ -46,9 +52,12 @@ func NewRouter(options Options) (*gin.Engine, error) {
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())
})
@@ -258,6 +267,10 @@ func newTaskPage(options Options) gin.HandlerFunc {
}
func createTask(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
if !options.Sessions.IsAuthenticated(context.Request) {
context.Status(http.StatusUnauthorized)
return
}
if !parseForm(context) {
return
}
+38 -2
View File
@@ -2,6 +2,7 @@ package server_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"net/url"
@@ -11,7 +12,10 @@ import (
"time"
"cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/deviceauth"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/server"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks"
"github.com/gin-gonic/gin"
@@ -316,6 +320,10 @@ func TestTasksPageKeepsOriginalShellAndRendersFilteredWorkbench(t *testing.T) {
`创建时间(上海)`,
`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`,
@@ -382,8 +390,8 @@ func TestTasksPageRerendersAccessibleFilterErrorsAndKeepsValues(t *testing.T) {
func TestTaskCreationRequiresAuthenticationAndCSRF(t *testing.T) {
router, _ := newRouter(t)
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, nil); response.Code != http.StatusForbidden {
t.Fatalf("anonymous POST /tasks = %d, want 403", response.Code)
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, nil); response.Code != http.StatusUnauthorized {
t.Fatalf("anonymous POST /tasks = %d, want 401", response.Code)
}
cookie := authenticate(t, router)
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, cookie); response.Code != http.StatusForbidden {
@@ -481,6 +489,10 @@ func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
}
func newRouterWithStore(t *testing.T, store tasks.Store) (*gin.Engine, *auth.Manager) {
return newRouterWithDependencies(t, store, emptyDetailStore{}, emptyEvidenceStore{}, deviceauth.RejectAllAuthenticator{})
}
func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator deviceauth.Authenticator) (*gin.Engine, *auth.Manager) {
t.Helper()
gin.SetMode(gin.TestMode)
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
@@ -493,6 +505,9 @@ func newRouterWithStore(t *testing.T, store tasks.Store) (*gin.Engine, *auth.Man
AdminPasswordBcrypt: string(hash),
Sessions: manager,
Tasks: store,
TaskDetails: details,
Evidence: evidenceStore,
DeviceAuthenticator: deviceAuthenticator,
})
if err != nil {
t.Fatalf("NewRouter: %v", err)
@@ -500,11 +515,31 @@ func newRouterWithStore(t *testing.T, store tasks.Store) (*gin.Engine, *auth.Man
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, deviceauth.Principal, evidence.UploadMetadata, evidence.StagedFile) (evidence.Asset, bool, error) {
return evidence.Asset{}, false, evidence.ErrInvalid
}
func (emptyEvidenceStore) Open(context.Context, string) (evidence.Asset, io.ReadSeekCloser, error) {
return evidence.Asset{}, nil, evidence.ErrNotFound
}
type memoryStore struct {
drafts []tasks.Draft
rows []tasks.TaskRow
listDraftsCalls int
listTasksCalls int
startCalls int
}
func (store *memoryStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) {
@@ -535,6 +570,7 @@ func (store *memoryStore) ListTasks(_ context.Context, _ tasks.TaskFilter) ([]ta
return result, nil
}
func (store *memoryStore) StartPurchases(_ context.Context, _ tasks.StartCommand, _ string) (tasks.StartResult, error) {
store.startCalls++
return tasks.StartResult{}, tasks.ErrInvalidStart
}
+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/deviceauth"
"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{}, deviceauth.RejectAllAuthenticator{})
response := serve(router, http.MethodGet, "/tasks/"+detailTaskID, nil, nil)
if response.Code != http.StatusSeeOther || !strings.HasPrefix(response.Header().Get("Location"), "/login?return_to=") || details.calls != 0 {
t.Fatalf("anonymous detail = %d/%q, calls=%d", response.Code, response.Header().Get("Location"), details.calls)
}
}
func TestTaskDetailFullPageAndDrawerShareAuditContent(t *testing.T) {
details := &recordingDetailStore{detail: taskDetailFixture()}
router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, deviceauth.RejectAllAuthenticator{})
cookie := authenticate(t, router)
full := serve(router, http.MethodGet, "/tasks/"+detailTaskID, nil, cookie)
if full.Code != http.StatusOK || !strings.Contains(full.Body.String(), "<!doctype html>") || !strings.Contains(full.Body.String(), `data-task-detail-content`) {
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{}, deviceauth.RejectAllAuthenticator{})
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
}
+545
View File
@@ -0,0 +1,545 @@
// 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"
"cmbuyer/admin/internal/deviceauth"
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 deviceauth.Principal, metadata core.UploadMetadata, staged core.StagedFile) (core.Asset, bool, error) {
if !store.isStagedPath(staged.Path) || !validPrincipal(principal) || !validMetadata(metadata) || metadata.SHA256 != staged.SHA256 || staged.ContentType != core.PNGContentType || staged.ByteSize < 1 || staged.ByteSize > core.MaxFileBytes || staged.Width < 1 || staged.Height < 1 || staged.Width > core.MaxImageSide || staged.Height > core.MaxImageSide || int64(staged.Width)*int64(staged.Height) > core.MaxImagePixels {
store.Discard(staged)
return core.Asset{}, false, core.ErrInvalid
}
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 deviceauth.Principal) bool {
return deviceauth.ValidDeviceID(principal.ID)
}
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 deviceauth.Principal, metadata core.UploadMetadata, staged core.StagedFile) bool {
return asset.TaskID == metadata.TaskID && asset.AttemptID == metadata.AttemptID && asset.Kind == metadata.Kind && asset.PrivacyTier == metadata.PrivacyTier && asset.SHA256 == metadata.SHA256 && asset.ByteSize == staged.ByteSize && asset.ContentType == staged.ContentType && asset.Width == staged.Width && asset.Height == staged.Height && asset.UploadedByDeviceID == principal.ID && asset.CapturedAt.Equal(metadata.CapturedAt)
}
@@ -0,0 +1,554 @@
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"
"cmbuyer/admin/internal/deviceauth"
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"
testDeviceID = "53c9f507-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 := deviceauth.Principal{ID: testDeviceID}
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(), deviceauth.Principal{ID: testDeviceID}, 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(), deviceauth.Principal{ID: testDeviceID}, metadata, staged); err != nil || replayed {
t.Fatalf("Commit = replayed %t, err %v", replayed, err)
}
if got, want := strings.Join(events, ","), "sync-root,sync-root,sync-file,rename,sync-shard"; got != want {
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(), deviceauth.Principal{ID: testDeviceID}, 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(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), firstStage); !errors.Is(err, injected) {
t.Fatalf("first Commit error = %v", err)
}
if info, err := os.Stat(filepath.Dir(finalPath)); err != nil || !info.IsDir() {
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(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), secondStage); err != nil || replayed {
t.Fatalf("retry Commit = replayed %t, err %v", replayed, err)
}
if len(paths) != 2 || paths[0] != store.root || paths[1] != filepath.Dir(finalPath) {
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(), deviceauth.Principal{ID: testDeviceID}, testMetadata(hash), staged); !errors.Is(err, injected) {
t.Fatalf("Commit error = %v, want injected publication failure", err)
}
if _, err := os.Stat(finalPath); !errors.Is(err, os.ErrNotExist) {
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(), deviceauth.Principal{ID: testDeviceID}, 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(), deviceauth.Principal{ID: testDeviceID}, 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[:])
}
+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
}
@@ -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)),
};
}
@@ -58,3 +58,110 @@
});
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,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
+85 -4
View File
@@ -3,10 +3,12 @@ package webui
import (
"embed"
"fmt"
"html/template"
"io"
"time"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks"
)
@@ -19,10 +21,18 @@ 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") },
"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 是登录页面所需的非敏感展示数据。
@@ -49,6 +59,8 @@ type TasksData struct {
Success bool
}
type TaskDetailData struct{ Detail taskdetail.Detail }
// RenderLogin 写入登录页。
func RenderLogin(writer io.Writer, data LoginData) error {
return templates.ExecuteTemplate(writer, "login.html", data)
@@ -59,6 +71,14 @@ 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 {
@@ -79,3 +99,64 @@ func statusLabel(status string) string {
}
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)
}
@@ -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;
@@ -0,0 +1,62 @@
-- +goose Up
CREATE TABLE device_credentials (
device_id TEXT PRIMARY KEY CHECK (
length(device_id) = 36
AND substr(device_id, 9, 1) = '-'
AND substr(device_id, 14, 1) = '-'
AND substr(device_id, 19, 1) = '-'
AND substr(device_id, 24, 1) = '-'
AND length(replace(device_id, '-', '')) = 32
AND replace(device_id, '-', '') NOT GLOB '*[^0-9a-f]*'
AND substr(device_id, 15, 1) = '4'
AND substr(device_id, 20, 1) IN ('8', '9', 'a', 'b')
),
display_name TEXT NOT NULL CHECK (
display_name = trim(display_name)
AND length(display_name) BETWEEN 1 AND 128
),
token_sha256 BLOB NOT NULL UNIQUE CHECK (
typeof(token_sha256) = 'blob'
AND length(token_sha256) = 32
),
status TEXT NOT NULL CHECK (status IN ('ACTIVE', 'REVOKED')),
created_at TEXT NOT NULL CHECK (
created_at = trim(created_at)
AND length(created_at) >= 20
AND substr(created_at, 11, 1) = 'T'
AND substr(created_at, -1, 1) = 'Z'
AND julianday(created_at) IS NOT NULL
),
revoked_at TEXT CHECK (
revoked_at IS NULL OR (
revoked_at = trim(revoked_at)
AND length(revoked_at) >= 20
AND substr(revoked_at, 11, 1) = 'T'
AND substr(revoked_at, -1, 1) = 'Z'
AND julianday(revoked_at) IS NOT NULL
)
),
CHECK (
(status = 'ACTIVE' AND revoked_at IS NULL)
OR (
status = 'REVOKED'
AND revoked_at IS NOT NULL
AND julianday(revoked_at) >= julianday(created_at)
)
)
);
CREATE INDEX device_credentials_status_created_idx
ON device_credentials (status, created_at, device_id);
-- +goose Down
-- 已签发凭据是安全配置;回滚不得静默删除并让设备身份审计链消失。
CREATE TABLE device_credentials_downgrade_guard (
valid INTEGER NOT NULL CHECK (valid = 1)
);
INSERT INTO device_credentials_downgrade_guard (valid)
SELECT CASE WHEN (SELECT COUNT(*) FROM device_credentials) = 0 THEN 1 ELSE 0 END;
DROP TABLE device_credentials_downgrade_guard;
DROP TABLE device_credentials;
+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())
+10 -2
View File
@@ -1,15 +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,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)}],
}
@@ -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>
+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()
+56 -2
View File
@@ -16,8 +16,8 @@
│ · 提交围栏、结果调和、内部证据与审计 │
│ · 服务端渲染管理页面 │
└───────────────────┬─────────────────────┘
│ HTTPS / JSON
│ Bearer + 设备绑定
│ 本机回环 HTTP / JSON(MVP)
│ Bearer + 设备绑定;非回环前必须先上 TLS
v
┌─────────────────────────────────────────┐
│ 采购工具(client/,Python + PySide6) │
@@ -141,6 +141,8 @@ T-103 真机证据表明:拼多多 `8.17.0`、goods_id `937122477375` 通过
| 外部支付页 | 检测到外部支付交接立即停止,不读取、保存或输入凭据 | 凭据泄露 |
| 安全校验 | 验证码、风控、人脸、短信出现即停止,不绕过 | 封号 / 违规 |
| 内部截图 | 可上传页面已显示的地址/手机号;不解析成字段或日志,完整 XML 不上传 | 非必要扩散 |
| 身份隔离 | 管理 session+CSRF 与设备 Bearer 分属不同路由域,混合凭据不叠加权限 | 设备越权 / 会话冒充 |
| Bearer 传输 | MVP 仅绑定 IPv4 回环 `127.0.0.1:8080`;非回环访问先建立 HTTPS/TLS 终止 | 明文局域网泄露 token |
| 授权一次性 | 一条任务版本只有一份有效授权;幂等重放不生成第二份 | 重复采购 |
| 服务端提交围栏 | 点击前原子创建唯一提交记录;失败或响应不明不得点击 | 并发 / 断网重复下单 |
| App 版本绑定 | 运行版本不同于证据版本时停止并重新取证 | 旧判据误点 |
@@ -246,12 +248,51 @@ CREATE TABLE order_submissions (
UNIQUE (authorization_id),
UNIQUE (attempt_id)
);
-- 设备 token 明文只在签发完成后显示一次;数据库仅保存原始 32 字节 token 的 SHA-256
CREATE TABLE device_credentials (
device_id TEXT PRIMARY KEY, -- 规范小写 UUIDv4
display_name TEXT NOT NULL, -- 非秘密运维名称
token_sha256 BLOB NOT NULL UNIQUE, -- 恰好 32 字节
status TEXT NOT NULL, -- ACTIVE | REVOKED
created_at TEXT NOT NULL,
revoked_at TEXT,
CHECK ((status = 'ACTIVE' AND revoked_at IS NULL)
OR (status = 'REVOKED' AND revoked_at IS NOT NULL AND revoked_at >= created_at))
);
-- INTERNAL_RAW 原始截图;原文件名和客户端路径不进入数据库
CREATE TABLE evidence_assets (
id TEXT PRIMARY KEY,
upload_key TEXT NOT NULL,
task_id TEXT NOT NULL,
attempt_id TEXT NOT NULL,
kind TEXT NOT NULL, -- T-204 仅 SKU_PANEL_GATE_1
privacy_tier TEXT NOT NULL, -- 仅 INTERNAL_RAW
sha256 TEXT NOT NULL, -- 64 位小写十六进制
byte_size INTEGER NOT NULL,
content_type TEXT NOT NULL, -- 仅 image/png
width_px INTEGER NOT NULL,
height_px INTEGER NOT NULL,
storage_key TEXT NOT NULL, -- 由 SHA-256 唯一派生
uploaded_by_device_id TEXT NOT NULL,
captured_at TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (uploaded_by_device_id, upload_key),
FOREIGN KEY (task_id, attempt_id) REFERENCES purchase_attempts(task_id, id)
);
```
MVP 不再用 `spec_trials` 作为审批记录,也不存在 `authorized_unit_price`。实际读价属于
`purchase_attempts` / `order_submissions` 的执行与审计事实;管理员授权的资金边界始终是
`total_price_cap`。
设备凭据由本机管理 CLI 签发、列出和撤销。token 是 32 字节加密随机值,以 64 位小写十六进制
只显示一次;服务端把 token 解码回原始字节后计算 SHA-256,并与按设备 id 查出的 32 字节 BLOB
恒定时间比较。未知 id 也执行固定宽度 dummy compare。认证逐请求查库,因此撤销事务提交后才开始的
请求全部拒绝;提交前已经完成认证的在途请求不追溯取消。格式/未知/错配/撤销统一空 401,存储故障
空 503,两类都在读取业务请求体前失败闭合。
### 5.2 状态机
```text
@@ -306,6 +347,19 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
截图上传器只能接收调用方显式指定的截图,不能枚举证据目录或顺带上传 XML/manifest。证据响应
使用 `Cache-Control: no-store`,不能暴露为免登录静态目录。
内部截图存储采用以下固定边界:
- 单个 PNG 最大 10 MiB、单边最大 8192 px、总像素最大 16,777,216;同时验证 multipart MIME、
PNG 魔数、完整解码、字节数、尺寸和调用方声明的 SHA-256。
- 上传 handler 必须先通过逐请求 SQLite 设备认证并再次校验规范 principal,再解析 Content-Type 或
读取 body。空库、无效或已撤销凭据拒绝,认证存储故障返回 503;不把管理员 session 当设备身份。
- 文件写入显式配置的私有证据根目录:同目录随机临时文件 → 流式 hash → 校验 → `fsync` → 原子
rename 到 SHA-256 内容地址 → 最后事务写数据库。数据库永远不指向半文件或缺失文件。
- SQLite 与文件系统不能组成跨资源事务;极端故障最多留下不可达孤儿文件。不得为清理孤儿而删除
可能被其他资产记录并发复用的内容文件,自动保留/删除策略留给部署任务。
- SHA-256 只用于物理内容寻址,不是业务资产唯一键;不同合法证据可以引用相同内容。同设备主体与
`upload_key` 同载荷重放原资产,任一规范字段变化即冲突。
## 六、关键技术难点
| 难点 | 风险 | 应对 |
+46 -7
View File
@@ -16,11 +16,29 @@
| 身份 | 凭据 | 能力 |
| --- | --- | --- |
| 管理员 | `HttpOnly; Secure; SameSite=Lax` 会话 cookie + CSRF | 建单、开始采购、查看内部证据、人工调和 |
| 设备 | `Authorization: Bearer <device-token>` + 设备 id | 心跳、领取、事件、截图、围栏与结果 |
| 管理员 | `HttpOnly; SameSite=Lax` 会话 cookie + CSRF;HTTPS 部署设 `Secure=true` | 建单、开始采购、查看内部证据、人工调和 |
| 设备 | `Authorization: Bearer <device-token>` + `X-CMBuyer-Device-ID` | 心跳、领取、事件、截图、围栏与结果 |
| ERP(V2) | 独立凭据 | 只读来源同步,不访问采购结果 |
设备凭据不能建单或开始采购;管理会话不能调用设备接口。未认证统一返回 `401`,无权返回 `403`。
设备凭据不能建单或开始采购;管理会话不能调用设备接口。凭据缺失或无效返回 `401`,已认证但无权
或管理写请求缺少有效 CSRF 返回 `403`;认证存储故障按下述规则返回 `503`。
设备请求的认证头采用以下固定格式:
- `Authorization` 和 `X-CMBuyer-Device-ID` 必须各出现且只出现一次;代理合并出的逗号列表也拒绝。
- Authorization scheme 按 HTTP 规则大小写不敏感,但 scheme 后只允许一个 ASCII 空格;token 必须是
加密随机生成的 32 字节值对应的 64 位小写十六进制文本。
- 设备 id 必须是规范小写 UUIDv4。token 与设备 id 同时绑定,未知、错配、格式错误和已撤销均返回
空 `401`,可带 `WWW-Authenticate: Bearer`,不区分具体原因。
- 认证器逐请求读取 SQLite,不缓存 ACTIVE 结论。SQLite 查询或连接故障返回空 `503`;401 与 503
都必须发生在 Content-Type 解析和 body 读取之前。
- 管理 cookie 不替代设备凭据;Bearer 也不替代管理 session/CSRF。两类凭据同时出现时,各路由仍只
采用自己的身份域,不把权限相加。
设备 token 由本机管理 CLI 签发,只在签发事务提交后向操作者显示一次;SQLite 仅保存 token 原始
32 字节的 SHA-256(32 字节 BLOB),list/revoke、日志、错误和 HTTP 响应均不显示 token 或 hash。
撤销幂等且不会恢复旧 token。MVP 服务只绑定 IPv4 回环 `127.0.0.1:8080`,设备 Bearer 只经过本机回环 HTTP;
未来非回环访问必须先建立 HTTPS/TLS 终止与代理信任边界。
### 错误响应
@@ -53,6 +71,14 @@
| `POST` | `/tasks/{id}/mark-paid` | 人工确认已付款并完成核对 |
| `GET` | `/evidence/{asset_id}` | 登录后读取内部截图;`Cache-Control: no-store` |
`GET /tasks/{id}` 的完整页与列表抽屉共享同一服务端数据模型和详情模板。列表只可用同源请求携带
`X-CMBuyer-View: drawer` 获取 HTML fragment;其他非空 view、跨站 fragment 请求或不接受
`text/html` 的 fragment 请求均拒绝。直接导航同一 URL 始终返回完整页。
`GET /evidence/{asset_id}` 不经静态目录:未登录先返回 `401`,不查询和泄露资产是否存在;登录后
缺失或畸形 id 返回空 `404`。成功只返回存储的 PNG,包含 `Content-Length`、固定安全文件名、
`Cache-Control: no-store` 与 `X-Content-Type-Options: nosniff`,不返回原文件名或服务端路径。
### `POST /tasks`
核心字段:
@@ -190,17 +216,30 @@
```json
{
"attempt_id": "018f-attempt",
"upload_key": "43c9f507-7473-4fa6-8d71-8786c34c6301",
"attempt_id": "33c9f507-7473-4fa6-8d71-8786c34c6301",
"kind": "SKU_PANEL_GATE_1",
"privacy_tier": "INTERNAL_RAW",
"sha256": "64-lowercase-hex",
"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"captured_at": "2026-08-04T09:01:00Z"
}
```
- 允许规格面板和确认页截图保留页面已显示的地址/手机号;不要求遮罩或裁剪。
- 不接受 XML、目录、manifest、本机绝对路径、外部支付页截图或支付凭据。
- MIME、尺寸、字节数和 SHA-256 必须校验;资产只经管理员鉴权端点读取。
- T-204 只开放 `kind=SKU_PANEL_GATE_1`;后续 kind 必须由对应真机证据任务收紧扩展。
- `privacy_tier` 只能是 `INTERNAL_RAW`;时间必须是以 `Z` 结尾的 UTC RFC 3339。
- URL 中的 task id、`upload_key` 与 `attempt_id` 都必须是规范的小写 UUIDv4;`sha256` 必须是
恰好 64 位小写十六进制字符。
- 恰好一个带 `Content-Type: image/png` 的显式文件;除上述六个元数据字段外,未知或重复字段均拒绝。
- 单文件最多 10 MiB、单边最多 8192 px、总像素最多 16,777,216;服务端校验 PNG 魔数、完整解码、
字节数、尺寸与调用方声明的 64 位小写 SHA-256。
- `attempt_id` 必须由数据库复合外键证明属于 URL 中的 task。认证必须先于 Content-Type 解析和请求体读取。
- 同一设备主体和 `upload_key` 的同载荷重放返回原资产;任务、attempt、截图或元数据变化返回 `409`。
- 首次成功返回 `201`,幂等重放返回 `200`。响应只含资产 id、关联 id、kind/tier、hash、字节数、
MIME、宽高和采集时间,不含设备 token、原文件名或存储路径。
- 生产上传使用逐请求 SQLite 设备认证;空凭据库、未知或已撤销设备均拒绝。不得使用管理员 session、
临时共享密钥或其他身份代替设备凭据。
### `POST /api/v1/purchase-attempts/{aid}/submission-fence`
@@ -310,6 +349,6 @@ T-103 只实现隔离的 `SkuSelectionFlow`:前四项加安全退出。它的
## 四、实现前仍需定值
- 授权有效期、领取租约时长、心跳/轮询间隔和连续失败停止阈值;
- 截图大小上限和内部保留期限;
- 内部截图保留期限;截图大小上限已固定为 10 MiB / 8192 px 单边 / 16,777,216 像素;
- 可配置单任务数量与最高总价系统上限;
- 首次真实提交真机任务的人工授权和待付款订单处置步骤。
+11 -1
View File
@@ -47,7 +47,9 @@
| 创建时间 | 本地时区显示,数据按 UTC 保存 |
没有操作列。双击非控件区域或键盘 Enter 打开 `/tasks/{id}` 路由化详情抽屉;新 tab 直接访问同 URL
则显示完整详情页。关闭抽屉或浏览器返回恢复筛选、滚动和触发行焦点。
则显示完整详情页。标题下方同时提供可见“查看详情”按钮,双击不是唯一入口。商品外链、checkbox、
输入和按钮本身不触发行双击。抽屉成功加载后才把 URL 推进 `/tasks/{id}`;关闭、Esc 或浏览器返回
恢复筛选、滚动和触发行焦点,浏览器前进重新打开同一详情且不重复写 history。
### 批量开始采购
@@ -91,6 +93,14 @@
详情中不出现 `WAITING_CONFIRMATION`、“确认机器选对了吗”、“签发第二趟授权”或“重新试选”。
完整页与抽屉执行同一 `task-detail-content` 模板和只读查询。直接导航返回完整 SSR 文档;列表 JS 对
同一 URL 发出同源 `X-CMBuyer-View: drawer` 请求,只取得 HTML fragment。加载失败时抽屉提供重试和
“在完整页打开”,不会把失败请求伪装成已打开详情。
T-204 只显示数据库中当前实际存在的任务、授权、attempt、submission 和内部截图,缺少事实就显示
明确空态;它不创建 attempt/event,不计算闸门,也不提供重置、调和、标记付款或任何设备动作。
截图以服务端记录的宽高预留布局并延迟加载,alt 只描述证据种类和采集时间,不转录截图中的地址或手机号。
## 四、采购工具界面结构
应用名:**采购工具**。顶部固定 tab:
+14 -5
View File
@@ -17,15 +17,12 @@ write_paths:
- client/tests/pdd/**
- client/tests/device/**
- client/scripts/capture_sku_panel_spike.py
- client/scripts/run_t103_sku_selection.py
- client/scripts/sanitize_sku_panel_evidence.py
- docs/02-requirements.md
- docs/03-tech-stack.md
- docs/04-architecture.md
- docs/api.md
- docs/current-state.md
---
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-04T08:24:57Z sha256=19a0864d83c5e41e57a29002b69f7767b50429dcf0fbace02f8303f92b71bac6 -->
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-04T10:47:47Z sha256=5471bdfeed1fffe4f0abd25201b9d5bf7bf55fa9500c11aa297eb4c422da5f02 -->
## 问题 / 背景
T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:规格面板由详情页精确唯一的“快要抢光”打开;T-110 已把该证据/版本绑定入口批准为受控导航。面板刚打开时目标颜色“黑色CHA(纯棉)”和尺码“M(建议100-115)”自动选中。
@@ -173,6 +170,18 @@ T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space
### 2026-08-04T08:24:12Z · ila
2026-08-04:T-111 单趟契约主审通过,T-103 解除架构阻塞。恢复后只实现 SkuSelectionFlow:受控入口、维度内精确选择、选中态读回、SKU 当前价唯一读取、原始截图和安全退出;不得包含数量、确认页、授权、提交围栏、提交订单或支付。客户端工作树现有未提交 v6 遮罩测试残留必须先撤销到已提交 v5 基线,再开始新实现。needs_device=true,离线实现后仍等待人工真机验收。
### 2026-08-04T09:50:25Z · ila
2026-08-04 离线实现与主审通过:提交 b49a9b4 实现仅限已取证 PDD 8.17.0 / goods_id 937122477375 的受控规格入口、颜色/尺码精确恢复、SKU 当前价读取、本机原始截图和单次安全退出;不包含数量、确认页、授权、提交围栏、提交订单或付款能力。独立审计 PASS,合入主分支 5f650f1;完整 init、109 项测试、Go vet/build、compileall、上下文校验及 diff-check 全部通过。needs_device=true,任务继续 DOING,等待项目所有者先把已记忆规格改为非目标值并返回 PDD 首页后运行真机验收。
### 2026-08-04T10:01:50Z · ila
2026-08-04 T-103 入口父容器追加真机证据:只读取证目录 C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\entry-parent-evidence-937122477375-20260804-175737;截图 screenshot.png,XML hierarchy.xml,manifest.json。设备 PKG110 / Android 16 / 拼多多 8.17.0 / Wi-Fi,goods_id 937122477375。项目所有者人工确认截图为目标商品详情页,且“快要抢光 12.88”与“免拼购买”的位置和手机当前画面一致。只读结构核对显示:精确“快要抢光”文本节点本身不可点击,但位于唯一、可见、启用、可点击的 PDD ViewGroup 祖先内;“免拼购买”属于另一底部可点击容器。后续只允许把入口判据收紧调整为“精确唯一快要抢光子节点 + 证据绑定唯一可点击祖先”,不得允许或点击“免拼购买”,必须先补 fixture/反例/超时不重试测试再真机运行。
### 2026-08-04T10:47:38Z · ila
2026-08-04 T-103 入口父链收紧完成:提交 44586fe,合入主分支 ce9d6ca。入口只允许精确唯一“快要抢光”子节点及五层已取证可点击 PDD 祖先链,实际点击仍取子节点中心;“免拼购买”独立容器为硬拒绝。独立审计复算证据 hash、验证 focused 32 / full 112 tests 和完整 init 均 PASS。最终真机运行曾安全失败且未发布输出目录;随后只读诊断发现系统通知栏覆盖,app_current 虽仍报告 PDD,但节点树 64 个节点全部属于 SystemUI。下一次必须由人先完全收起通知栏并停在 PDD 首页再运行;T-103 保持 DOING,不把这次失败或入口截图确认误记为完整验收。
<!-- END VIKUNJA EXPORT -->
## 边界
+82
View File
@@ -0,0 +1,82 @@
---
id: T-104
title: 验证规格选择能力安全退出
phase: 1
deps: [T-103]
status: TODO
created: 2026-08-04
vikunja_task_id: 32
context_ref: 9a4d11f
work_branch: task/t-104-safe-exit
needs_device: true
needs_human_review: true
write_paths:
- docs/tasks/T-104.md
- client/src/cmbuyer_client/pdd/sku_selection.py
- client/src/cmbuyer_client/pdd/sku_selection_runner.py
- client/tests/pdd/test_sku_selection.py
- client/tests/pdd/fixtures/product_exit_8_17_0.xml
- client/scripts/capture_sku_exit_spike.py
- client/scripts/run_t103_sku_selection.py
- docs/current-state.md
---
<!-- BEGIN VIKUNJA EXPORT id=32 synced=2026-08-04T10:47:05Z sha256=d0fe6a30708d16440962b8ab9ec81ce33065f96363597bd55e0a1fb4b442b6fd -->
## 问题 / 背景
T-103 已实现受控规格选择、读价、截图和单次 Back,但当前 `exit_sku_panel_safely()` 在 hierarchy 变化且规格面板判据不再成立时就返回成功。2026-08-04 的只读诊断已证明:通知栏覆盖时 `app_current` 仍可能报告拼多多,而节点树全部属于 SystemUI。因此“已不在面板”不是安全退出的充分条件。T-104 用独立真机证据把后置条件收紧为“稳定回到同一目标商品详情页”。
## 关联需求与交互
- 功能:F-006 单趟流程中的安全退出边界。
- 架构/API:复用 `SkuSelectionFlow.exit_sku_panel_safely()`;不改变服务端 API。
- 依赖:T-103 完成真机规格选择/读价/人眼安全退出验收后才开工。
## 方案
1. 先新增窄取证脚本:只在已验证 PDD 8.17.0 目标规格面板上发送一次 Back,随后采集本机截图/XML/App/设备/goods_id 元数据;不得打开确认页、设置数量或点击任何页面控件。
2. 由人确认 post-exit 截图确为 goods_id `937122477375` 商品详情、规格面板已关闭、未进入确认页/外部支付页,并确认截图/XML 对应;`needs_device=true`,agent 保持 DOING。
3. 从本项目 post-exit XML 提取只含判据节点的最小 fixture。成功判据必须稳定命中该商品详情的证据绑定正结构;可复用 T-103 的“快要抢光”五层入口链,但必须由新的 post-exit 证据再次验证。
4. 收紧 `exit_sku_panel_safely()`:版本和前台包正确、节点树仅含 PDD 受控页面、目标商品详情正判据精确唯一并连续稳定;面板仍在、SystemUI/锁屏、PDD 其他页、确认/提交/支付语义、入口缺失/重复或结构漂移均不得记成功。
5. Back 最多发送一次。超时或响应不明不重试;故障调和也不能产生第二次 Back。失败不得发布 `safe_exit=completed` manifest。
6. 复用 T-103 runner/flow,不新建第二套采购流程;可增加 post-exit 本机原始截图和无页面正文摘要,完整 XML 仍只留本机,不上传、Git 或日志。
## 验收要点
- 真机证据记录路径、截图/XML hash、PKG110、Android 16、Wi-Fi/USB、拼多多 8.17.0 和 goods_id;只有人能完成页面对应性与无订单创建确认。
- 离线测试覆盖成功稳定详情页,以及面板未退、SystemUI/锁屏、PDD 其他页、确认/提交/支付页、入口缺失/重复/漂移、版本/前台漂移。
- 所有失败分支 Back 总数最多 1;超时不重试;失败不发布 completed manifest。
- 静态证明没有数量、确认页导航、提交订单、围栏或支付能力。
- client 全量单测、compileall、完整 init、上下文校验与 diff-check 通过。
## 执行记录
等待 T-103 完成后认领。
## 执行记录
(暂无)
<!-- END VIKUNJA EXPORT -->
## 边界
- T-104 必须等待 T-103 完成人工真机验收后才可转 `DOING`。本任务先取证、后写退出成功判据;不得从
当前实现、前序项目、旧 XML、Activity 名或推理直接声明商品详情页。
- 取证动作只允许在已证明为拼多多 8.17.0、goods_id `937122477375` 的目标规格面板上发送一次
Android Back,随后只读采集截图/XML/App/设备摘要。不得点击关闭坐标、空白处、购买、数量、确认、
提交或支付控件,也不得在 Back 失败或结果不明时重试。
- “规格面板已消失”不是退出成功。成功必须由本项目 post-exit 真机证据证明:版本和前台包正确、节点树
仅属于 PDD 受控页面、同一目标商品详情正判据精确唯一并连续稳定;SystemUI/锁屏、PDD 其他页面、
确认页、提交页、外部支付页、入口缺失/重复或结构漂移一律停止且不得发布 completed manifest。
- 可以复用 T-103 已取证的“快要抢光”五层入口链作为候选结构,但必须在新的 post-exit 截图/XML 上
重新验证并由人确认页面对应性;不得使用相似文本、包含/前缀、OCR、裸坐标或其他购买文案兜底。
- 只收紧现有 `SkuSelectionFlow` 与 runner 的退出后置条件,不新建第二套采购流程,不新增通用 `click`、
数量、确认页导航、授权、提交围栏、创建待付款订单或支付能力。第一趟与任何真实提交函数继续静态隔离。
- 原始完整 XML 只保存在 `%LOCALAPPDATA%\cmbuyer\artifacts\T-104\...`,不得上传、提交 Git、写入
Vikunja 或日志。Git 中最小 fixture 只能保留退出判据所需结构,并由人确认不含地址、手机号或支付凭据。
- `needs_device: true`:agent 不得自行标 `DONE`。只有人确认 post-exit 截图为同一目标商品详情、面板
已关闭、未进入确认/提交/支付页、未创建订单,且截图/XML 对应后,任务才可完成。
- 本任务不实现、不调用 `set_quantity_and_readback()`、`go_to_order_confirm()`、
`create_submission_fence()`、`submit_order_once()`、支付、免密支付、先用后付或任何扣款能力;既有
三道价格闸门、服务端围栏、只点一次且不重试的约束不得放宽。
+10 -2
View File
@@ -3,7 +3,7 @@ id: T-203
title: 表格查询与批量开始采购授权
phase: 2
deps: [T-202, T-209]
status: DOING
status: DONE
created: 2026-08-04
vikunja_task_id: 30
context_ref: 1f20271
@@ -21,7 +21,7 @@ write_paths:
- admin/README.md
---
<!-- BEGIN VIKUNJA EXPORT id=30 synced=2026-08-04T09:16:48Z sha256=6c62ba368a760e96b8feb06f3f3ced8a2a40d10f7754a99471f7429c3862a80c -->
<!-- BEGIN VIKUNJA EXPORT id=30 synced=2026-08-04T10:27:15Z sha256=540fb01c9da32148898851f6450b713103f1db40222e1ac0d6dbcc74b007bcb3 -->
## 问题 / 背景
T-202 已完成手工 DRAFT 建单;T-209 将生产 schema/领域状态机迁移到单趟模型。项目所有者明确:管理员点击“开始采购(只创建待付款订单)”本身就是授权,不再增加试选后确认。T-203 负责采购服务查询和批量授权事务,使设备后续只能领取显式授权的 PENDING 任务。
@@ -54,6 +54,14 @@ F-004、F-008、F-018;US-003、US-005;IX-005;GET /tasks、POST /tasks/star
### 2026-08-04T09:16:39Z · ila
2026-08-04 开始 T-203:依赖 T-209 已完成并合入 main。主 agent 已完成开工前只读审计,冻结 v2 schema 启动校验、单进程 writeGate、start_key 规范集合重放、Asia/Shanghai 到 UTC 半开区间、julianday 查询及 big.Int 分金额边界;本地状态转 DOING,分支 task/t-203-start-purchases。
### 2026-08-04T10:18:10Z · ila
2026-08-04 T-203 独立终审退回两项:未认证须按 docs/api.md 返回 401,已认证但 CSRF 错误才返回 403;原始 JSON 在 64KiB 边界内必须先做严格 UTF-8 校验。现有 auth.Manager 无法只读区分“有效管理会话 + 错 CSRF”与“无会话”,因此任务所有者批准把 write_paths 最小扩展为 admin/internal/auth/**,仅允许增加只读认证状态 API 及测试;不得创建/旋转会话或放宽 CSRF。修复、复审和完整门禁通过前 T-203 保持 DOING。
### 2026-08-04T10:26:57Z · ila
2026-08-04 T-203 完成:提交 5dcff4b,合入主分支 03a067e。实现传统任务表格、筛选、DRAFT 批量勾选和“开始采购(只创建待付款订单)”授权;1/100 条在单一事务内生成 ACTIVE 快照并原子转 PENDING,相同 start_key 原集合稳定重放。独立终审两轮后修复未认证 401/CSRF 403 与原始 UTF-8 严格校验。主 agent 合入后完整 init(client 112 tests)、Go 全量/竞态/vet/build、Node 7 tests、上下文和 diff-check 全部通过;未实现领取、真机、提交订单或付款。
<!-- END VIKUNJA EXPORT -->
## 边界
+107
View File
@@ -0,0 +1,107 @@
---
id: T-204
title: 路由化任务详情与内部截图资产
phase: 2
deps: [T-203]
status: DONE
created: 2026-08-04
vikunja_task_id: 31
context_ref: 7928457
work_branch: task/t-204-details-evidence
needs_device: false
needs_human_review: false
write_paths:
- docs/tasks/T-204.md
- admin/migrations/00003_evidence_assets.sql
- admin/internal/migrations/migrations_test.go
- admin/internal/evidence/**
- admin/internal/storage/evidence/**
- admin/internal/taskdetail/**
- admin/internal/config/**
- admin/internal/server/**
- admin/internal/transport/webui/**
- admin/cmd/server/**
- admin/README.md
- docs/api.md
- docs/routes.md
- docs/04-architecture.md
---
<!-- BEGIN VIKUNJA EXPORT id=31 synced=2026-08-04T11:49:57Z sha256=e09d813fb2ef353a1e2b10b7b2aefd0acb3f605ac5a0a4ddac136ca9dcf2cecc -->
## 问题 / 背景
T-203 已完成传统任务表格与批量“开始采购”授权。采购管理员还缺少可复制、可返回的任务详情,以及只供内部审计的规格面板/确认页原始截图。T-204 提供详情与截图资产底座;不实现 T-205 的 attempt/event 写入,不实现 T-306 的客户端上传调用。
## 关联需求与交互
- 功能:F-007、F-011。
- 用户故事:US-002、US-004、US-007。
- 交互:IX-003、IX-004;双击非控件区域或 Enter 打开同一 `/tasks/{id}`,直达显示完整页,列表增强为抽屉。
- 架构/API:`GET /tasks/{id}`、`POST /api/v1/tasks/{id}/evidence`、`GET /evidence/{asset_id}`。
## 方案
1. 新增任务详情只读查询,返回任务要求、状态、版本、授权摘要和当前已有的 attempt/submission/evidence 摘要;缺少后续事实时显示明确空态,不伪造数据。
2. `GET /tasks/{id}` 以同一数据模型渲染完整页或同源 HTML fragment。列表行双击非交互区域、键盘 Enter 打开抽屉并把 URL 推到同一路径;Esc、关闭或浏览器返回恢复筛选、滚动与触发行焦点。外部商品链接、复选框和批量按钮不得触发行详情。
3. 详情是审计视图,不提供试选后确认、再次提交、付款自动化或围栏后重试。状态文案始终区分“订单已创建,系统尚未付款”。
4. 新建 `evidence_assets` 迁移和存储边界:只接收明确的单个 PNG screenshot;attempt_id 必填且必须属于 URL 中的 task。元数据仅允许 upload_key、attempt_id、kind、privacy_tier=INTERNAL_RAW、sha256、captured_at;记录 MIME、字节数、宽高、存储键和上传主体。
5. 上传先经注入的 DeviceAuthenticator;T-301 前生产默认拒绝,测试可用 fake principal 验证契约,不创建临时 token 或共享密钥。T-301 完成后再接真实 Bearer 身份。
6. multipart 设严格总大小、字段/单文件限制;拒绝未知字段、重复字段、XML/manifest/目录/本机路径、非 PNG、魔数/DecodeConfig/尺寸/hash 不一致以及未批准的截图 kind。服务端文件名只由资产 id/存储键生成,不使用上传名。
7. 文件同目录临时写、流式 SHA-256、校验、fsync、原子 rename 后才写 DB;故障只允许产生不可达孤儿,不允许 DB 指向缺失或半文件。upload_key 同任务同载荷幂等,载荷变化冲突,并发只生成一条可读资产。
8. `GET /evidence/{asset_id}` 仅有效管理员会话可读,返回 `image/png`、`Cache-Control: no-store`、`X-Content-Type-Options: nosniff`;未登录不泄露资产是否存在,不经静态目录暴露。
9. UI 采用现有采购服务高密度表格风格:可见关闭按钮、焦点圈、44px 交互目标、截图 width/height 预留和 lazy loading、响应式布局、reduced-motion;不得只有双击一种入口。
10. T-204 明确定值单文件最大 10 MiB、解码后最大 16,777,216 像素;证据目录由显式配置提供。保留期限仍由后续部署任务定值,不在本任务自动删除资产。
## 验收要点
- 详情完整页与抽屉使用同一路由/数据;刷新/复制 URL、浏览器返回、Esc、焦点和滚动/筛选恢复均有测试。
- 不存在、畸形 id、未登录、fragment 伪造请求均 fail closed,不泄露内部错误。
- 上传覆盖认证矩阵、multipart 负例、PNG 魔数/解码/大小/尺寸/hash、未知/重复字段、任务/attempt 归属、路径穿越、幂等冲突/并发及原子故障注入。
- 截图读取覆盖管理员会话、未登录、缺失资产和 no-store/nosniff;完整 XML、支付页和凭据不会进入接口或 Git。
- 运行 Go 全量/竞态/vet/build、Node 测试与语法检查、完整 init、上下文校验和 diff-check。
## 执行记录
待认领。
## 执行记录
### 2026-08-04T10:42:01Z · ila
2026-08-04 开始 T-204:依赖 T-203 已完成,任务定义提交 7928457。采用生产默认拒绝的 DeviceAuthenticator 接口,T-301 前不发明临时设备凭据;先实现同 URL 详情抽屉/完整页、INTERNAL_RAW PNG 原子存储与管理员 no-store 读取。工作分支 task/t-204-details-evidence。
### 2026-08-04T11:49:31Z · ila
2026-08-04 T-204 完成:实现同一路由 `/tasks/{id}` 的完整页/详情抽屉、内部 `INTERNAL_RAW` PNG 证据资产、设备认证前置与管理员 no-store 读取。生产上传在 T-301 前固定拒绝。证据文件采用分片目录内临时文件、hash/PNG 复核、file fsync、同目录原子 rename、root/shard 目录持久化后才写 SQLite;故障注入证明 file sync、rename、目录 sync、INSERT、COMMIT 失败均无可见 DB 行,最多留下不可达孤儿。UI 覆盖 44px、可见详情按钮、双击/Enter、Esc、返回/前进、筛选滚动与精确触发焦点恢复;fragment 严格 same-origin/Accept/Vary。
实现提交 `8964888`,主线合并 `46fccc2`。实现 agent 完整 init 通过;主 agent 独立复跑 `go test ./...`、`go test -race ./...`、`go vet ./...`、`go build ./...`、Node 13 项测试、语法检查、上下文校验、diff-check 和主线完整 `init.ps1`(client 112 tests)全部通过。独立审计最终 PASS,无剩余 P0/P1。系统仍只创建待付款订单,绝不自动付款。
<!-- END VIKUNJA EXPORT -->
## 边界
- 本任务只实现管理员任务详情、内部 PNG 证据资产底座和受保护读取;不实现 T-205 的 attempt/event
写接口、闸门计算、失败分类、提交围栏或结果调和,不实现 T-306 的客户端截图与上传调用。
- T-301 完成前不得发明临时设备 token、共享密钥或把管理员会话当设备身份。上传 handler 必须先调用窄
`DeviceAuthenticator`;生产默认实现一律拒绝,只有测试可以注入 fake principal。
- 上传只接受一个调用方显式选择的 PNG;不得枚举目录,不接收 XML、manifest、本机路径、原文件名、
任意备注、Cookie、token、支付凭据或未批准的截图 kind。MVP 本任务只开放
`SKU_PANEL_GATE_1`,后续 kind 必须在对应任务中收紧扩展。
- `attempt_id` 必填且必须以复合外键证明属于 URL 中的 task。`privacy_tier` 只能是
`INTERNAL_RAW`;截图可含页面已显示的地址/手机号,但服务端不得 OCR、提取、索引、搜索或写日志。
- 单文件最多 10 MiB,单边最多 8192 px,解码后最多 16,777,216 像素;必须同时校验 multipart 类型、
PNG 魔数、解码结果、字节数、尺寸与调用方声明的 64 位小写 SHA-256,任一不符零发布。
- 文件必须在显式配置的证据根目录内以服务端生成的内容地址落盘,不进入公开静态目录。先同目录临时写、
流式 hash、`fsync`、原子 rename,再写数据库;故障最多留下不可达孤儿,不得留下指向半文件或缺失
文件的可见数据库记录,也不得为清理孤儿而删除并发复用文件。
- 同一设备主体与 `upload_key` 的相同规范请求只返回原资产;内容、任务、attempt 或元数据变化一律
`409`。不得把 SHA-256 当业务记录唯一键,因为不同合法证据可以复用相同物理内容。
- `GET /evidence/{asset_id}` 只允许有效管理员会话,必须返回 `Cache-Control: no-store` 和
`X-Content-Type-Options: nosniff`;匿名请求先拒绝,不泄露资产是否存在,不提供公开 URL、目录浏览、
批量导出、删除或预签名链接。
- `/tasks/{id}` 的抽屉与完整页必须共享同一数据和详情模板。双击不是唯一入口;Enter、可见关闭按钮、
Esc、浏览器前进/后退、焦点与列表滚动/筛选恢复均可用,外部商品链接、复选框、输入框和按钮不得误触
行详情。截图必须预留尺寸、响应式缩放、延迟加载,alt 不转录地址或手机号。
- 详情是只读审计视图,不制造不存在的 attempt/event/闸门/提交事实,不出现“机器选对了吗”、围栏后
重试、再次提交或自动付款动作。截图不作为价格闸门通过或人工审批的唯一依据。
- 本任务不实现、不调用通用真机点击、`submit_order_once()`、支付、免密支付、先用后付或任何扣款能力;
既有三道价格闸门、服务端提交围栏、唯一点击一次且不重试的规则不得放宽。
+91
View File
@@ -0,0 +1,91 @@
---
id: T-301
title: 设备凭据与身份隔离(F-013)
phase: 3
deps: [T-201, T-204]
status: DOING
created: 2026-08-04
vikunja_task_id: 33
context_ref: a6ad560
work_branch: task/t-301-device-auth
needs_device: false
needs_human_review: false
write_paths:
- docs/tasks/T-301.md
- admin/migrations/00004_device_credentials.sql
- admin/internal/migrations/migrations_test.go
- admin/internal/deviceauth/**
- admin/internal/evidence/**
- admin/internal/storage/evidence/**
- admin/internal/server/**
- admin/cmd/device-credentials/**
- admin/cmd/server/**
- admin/README.md
- docs/api.md
- docs/04-architecture.md
---
<!-- BEGIN VIKUNJA EXPORT id=33 synced=2026-08-04T12:06:22Z sha256=1d572a2bf01628ca25aa153baa6d51443535eb399c006f1f0c7cc2b99edc1d6b -->
## 问题 / 背景
T-204 已提供设备认证注入点,但生产实现固定拒绝全部设备请求。T-301 为采购工具建立可签发、可撤销、服务端逐请求校验的设备凭据,并把管理员会话与设备 Bearer 能力严格隔离;不提前实现领取、租约或客户端 HTTP 适配。
## 关联需求与交互
- 功能:F-013。
- 用户故事:US-007。
- 依赖:T-201、T-204;复用 T-204 的证据上传认证入口。
- 后续消费者:T-302 领取/租约、T-303 客户端 HTTP 适配。
## 方案
1. 新增通用 `deviceauth` 包,提供设备主体和认证接口;把 T-204 位于 evidence 包内的临时接口迁出,证据上传改用通用主体,避免后续设备路由重复定义身份。
2. SQLite 新增设备凭据表:设备 id 为规范小写 UUID,显示名为非秘密元数据,token 只保存 32 字节 SHA-256 BLOB,状态仅 ACTIVE/REVOKED,并用时间字段约束撤销状态一致性。
3. 凭据签发生成 32 字节加密随机 token,对外只显示一次 64 位小写十六进制明文;数据库、日志、错误、HTTP 响应均不得保存或回显 token 明文。提供显式数据库路径的管理 CLI,支持 issue、list、revoke;list 不显示 token/hash,重复 revoke 不得恢复凭据。
4. 设备请求必须同时提供且只提供一个 `Authorization: Bearer <64位小写十六进制token>` 与一个 `X-CMBuyer-Device-ID: <小写UUID>`;格式、重复头、空白、未知、token/device 不匹配和已撤销统一 401,不区分原因。
5. 认证器每次请求查询 SQLite,不缓存 ACTIVE 结果,确保撤销立即生效;比较使用常量时间。请求头非法、未知、错配或已撤销统一返回空 401;SQLite 查询/连接故障返回空 503。两类都在读取请求体前失败闭合且不泄露内部信息。
6. 服务启动改用真实 SQLite 设备认证器;空凭据库仍拒绝全部。管理员 cookie 单独不能调用设备上传;Bearer 单独或与 cookie 并存均不能调用管理建单/开始采购,因为管理端仍只接受管理员 session + CSRF。
7. MVP 的采购服务与采购工具部署在同一运营电脑,服务进程只监听 127.0.0.1:8080;本机 HTTP 不经过网络。未来若开放非回环访问,必须先建立并验收 HTTPS/TLS 终止与代理信任边界,设备 Bearer 不得经过明文局域网。
8. 本任务只接通现有证据上传认证,不新增 heartbeat、claim、lease、event、fence、result 路由,不实现客户端保存/发送凭据,不接触真机选择器、提交订单或付款。
## 验收要点
- 迁移升级/重开/约束/回滚安全测试通过;数据库中不存在 token 明文。
- 覆盖签发随机性与一次显示、规范格式、错误头矩阵、token/device 绑定、撤销即时生效、重复撤销、并发认证/撤销,以及数据库故障在 body 零读取下返回空 503。
- 覆盖身份隔离:管理员 cookie 不能上传证据;设备 Bearer 不能建单或开始采购;混合凭据不扩大任一身份权限。
- 现有证据上传在有效设备身份下保持原幂等/归属语义,认证仍先于 Content-Type 和 body 读取。
- `go test ./...`、`go test -race ./...`、`go vet ./...`、`go build ./...`、完整 init、上下文校验与 diff-check 全部通过。
## 执行记录
### 2026-08-04T12:00:56Z · ila
2026-08-04 开始 T-301:依赖 T-201 已完成,任务定义提交 a6ad560。实现范围严格限于设备凭据签发/撤销、逐请求 Bearer+设备 id 认证、身份隔离及接通 T-204 证据上传;不新增领取/租约/事件/围栏/结果接口,不接触真机或提交/付款。工作分支 task/t-301-device-auth。
### 2026-08-04T12:04:54Z · ila
2026-08-04 编码前安全收紧:T-301 实际复用 T-204 认证注入点和 00003 后续迁移,依赖补为 T-201、T-204。MVP 已定为单机部署,生产服务改为仅监听 127.0.0.1:8080;未来非回环访问必须先建立 HTTPS/TLS 终止与代理信任边界,设备 Bearer 不得经过明文局域网。
### 2026-08-04T12:06:14Z · ila
2026-08-04 编码前错误语义定值:请求头非法、未知、token/device 错配和已撤销统一空 401;SQLite 查询/连接故障统一空 503。两类都必须在读取 body 和调用业务处理器前失败闭合,响应不含内部原因。token_sha256 采用 32 字节 BLOB。
<!-- END VIKUNJA EXPORT -->
## 边界
- 设备身份与管理员身份必须保持能力隔离:设备 Bearer 不得建单、开始采购、重置或调和;管理员
session 不得代替设备领取、上传或调用后续设备接口。两类凭据同时出现也不得扩大任何一方权限。
- 设备 token 必须由加密安全随机源生成,只在签发成功时向操作者显示一次;SQLite、日志、错误响应、
HTTP 响应、Git、Vikunja 和测试 fixture 均不得保存或回显明文 token。数据库只保存 token SHA-256。
- 认证必须同时绑定规范设备 id 与 token,并在解析请求体之前完成。格式错误、重复头、未知凭据、
token/device 不匹配和已撤销统一返回空 `401`;认证存储异常返回空 `503`。两类失败都必须
fail closed、不得调用业务处理器或读取请求体,也不得泄露具体原因。
- MVP 的采购服务与采购工具部署在同一运营电脑,生产服务必须只监听回环地址;只允许
`http://127.0.0.1` / `http://localhost` 的本机通信。未来若开放非回环访问,必须先单独建立并验收
HTTPS/TLS 终止与代理信任边界,不得让设备 Bearer 经过明文局域网。
- 撤销必须逐请求立即生效,不得缓存已认证结果,不得通过重复签发或重复撤销恢复旧 token。轮换属于
后续任务,本任务不提供会让旧 token 重新生效的路径。
- 本任务只把真实设备认证接到 T-204 已有截图上传入口;不得新增 heartbeat、claim、lease、event、
submission-fence 或 result 路由,不实现客户端凭据持久化和 HTTP 适配。
- 不接触拼多多页面判据、规格选择、数量、确认页或真机流程;不编写点击“提交订单”、支付、免密支付、
先用后付或任何扣款控件的代码。