From 89648880bc33ffcf45be9f86061aeb5b5811da1e Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 4 Aug 2026 19:42:35 +0800 Subject: [PATCH] feat(admin): add routed task evidence details --- admin/README.md | 8 + admin/cmd/server/main.go | 14 + admin/internal/config/config.go | 11 + admin/internal/config/config_test.go | 4 + admin/internal/evidence/evidence.go | 88 +++ admin/internal/migrations/migrations_test.go | 93 ++- admin/internal/server/evidence.go | 193 ++++++ admin/internal/server/evidence_test.go | 290 +++++++++ admin/internal/server/router.go | 10 +- admin/internal/server/router_test.go | 33 ++ admin/internal/server/task_detail.go | 84 +++ admin/internal/server/task_detail_test.go | 110 ++++ .../storage/evidence/durability_unix.go | 20 + .../storage/evidence/durability_windows.go | 35 ++ admin/internal/storage/evidence/store.go | 553 ++++++++++++++++++ admin/internal/storage/evidence/store_test.go | 552 +++++++++++++++++ admin/internal/taskdetail/detail.go | 60 ++ admin/internal/taskdetail/store.go | 204 +++++++ admin/internal/taskdetail/store_test.go | 82 +++ .../transport/webui/static/drawer.test.js | 211 +++++++ .../internal/transport/webui/static/tasks.js | 107 ++++ .../webui/templates/task-detail.html | 38 ++ .../transport/webui/templates/tasks.html | 6 +- admin/internal/transport/webui/webui.go | 89 ++- admin/migrations/00003_evidence_assets.sql | 53 ++ docs/04-architecture.md | 34 ++ docs/api.md | 29 +- docs/routes.md | 12 +- 28 files changed, 2997 insertions(+), 26 deletions(-) create mode 100644 admin/internal/evidence/evidence.go create mode 100644 admin/internal/server/evidence.go create mode 100644 admin/internal/server/evidence_test.go create mode 100644 admin/internal/server/task_detail.go create mode 100644 admin/internal/server/task_detail_test.go create mode 100644 admin/internal/storage/evidence/durability_unix.go create mode 100644 admin/internal/storage/evidence/durability_windows.go create mode 100644 admin/internal/storage/evidence/store.go create mode 100644 admin/internal/storage/evidence/store_test.go create mode 100644 admin/internal/taskdetail/detail.go create mode 100644 admin/internal/taskdetail/store.go create mode 100644 admin/internal/taskdetail/store_test.go create mode 100644 admin/internal/transport/webui/static/drawer.test.js create mode 100644 admin/internal/transport/webui/templates/task-detail.html create mode 100644 admin/migrations/00003_evidence_assets.sql diff --git a/admin/README.md b/admin/README.md index 7273cd2..c4a3a25 100644 --- a/admin/README.md +++ b/admin/README.md @@ -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,16 @@ $env:CMBUYER_DATABASE_SOURCE = '' $env:CMBUYER_AUTHORIZATION_TTL = '10m' $env:CMBUYER_MAX_TASK_QUANTITY = '99' $env:CMBUYER_MAX_TOTAL_PRICE = '999.99' +$env:CMBUYER_EVIDENCE_DIR = '<内部截图绝对目录>' go run ./cmd/migrate -database $env:CMBUYER_DATABASE_SOURCE up go run ./cmd/server ``` 采购服务会话仅保存在当前进程内;进程重启后既有登录会话会安全失效。 管理员的“开始采购(只创建待付款订单)”只签发一次性授权并创建待付款订单的资格;服务不会自动付款,也不包含任何支付操作。 + +`GET /tasks/{id}` 直接访问时渲染完整详情页,任务列表以同一 URL 加载详情抽屉。内部截图只通过 +`GET /evidence/{asset_id}` 向有效管理员会话提供,并始终返回 `no-store`;文件不在静态目录中。 + +T-301 接入真实设备凭据之前,`POST /api/v1/tasks/{id}/evidence` 的生产认证器固定拒绝全部请求。 +测试可以注入 fake 设备主体验证上传契约,但不得用管理员会话、临时 token 或共享密钥绕过该边界。 diff --git a/admin/cmd/server/main.go b/admin/cmd/server/main.go index e203e25..aa331c1 100644 --- a/admin/cmd/server/main.go +++ b/admin/cmd/server/main.go @@ -7,8 +7,11 @@ import ( "cmbuyer/admin/internal/auth" "cmbuyer/admin/internal/config" + "cmbuyer/admin/internal/evidence" "cmbuyer/admin/internal/server" + evidencestorage "cmbuyer/admin/internal/storage/evidence" "cmbuyer/admin/internal/storage/sqlite" + "cmbuyer/admin/internal/taskdetail" "cmbuyer/admin/internal/tasks" ) @@ -35,12 +38,23 @@ 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 + } router, err := server.NewRouter(server.Options{ AdminUsername: configuration.AdminUsername, AdminPasswordBcrypt: configuration.AdminPasswordBcrypt, Sessions: auth.NewManager(configuration.SessionSecret, configuration.CookieSecure), Tasks: taskStore, + TaskDetails: detailStore, + Evidence: evidenceStore, + DeviceAuthenticator: evidence.RejectAllDeviceAuthenticator{}, }) if err != nil { return err diff --git a/admin/internal/config/config.go b/admin/internal/config/config.go index d604c38..a60cc84 100644 --- a/admin/internal/config/config.go +++ b/admin/internal/config/config.go @@ -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 } diff --git a/admin/internal/config/config_test.go b/admin/internal/config/config_test.go index 8c53190..1251b6d 100644 --- a/admin/internal/config/config_test.go +++ b/admin/internal/config/config_test.go @@ -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 { diff --git a/admin/internal/evidence/evidence.go b/admin/internal/evidence/evidence.go new file mode 100644 index 0000000..91f08c1 --- /dev/null +++ b/admin/internal/evidence/evidence.go @@ -0,0 +1,88 @@ +// Package evidence defines the narrow internal screenshot contract shared by HTTP and storage. +package evidence + +import ( + "context" + "errors" + "io" + "net/http" + "time" +) + +const ( + KindSKUPanelGate1 = "SKU_PANEL_GATE_1" + PrivacyInternalRaw = "INTERNAL_RAW" + PNGContentType = "image/png" + MaxFileBytes int64 = 10 << 20 + MaxImageSide = 8192 + MaxImagePixels = 16_777_216 +) + +var ( + ErrInvalid = errors.New("invalid evidence") + ErrConflict = errors.New("evidence upload key conflict") + ErrNotFound = errors.New("evidence not found") + ErrTooLarge = errors.New("evidence file too large") +) + +// DevicePrincipal is the already-authenticated device identity used only for audit and idempotency. +type DevicePrincipal struct { + ID string +} + +// DeviceAuthenticator deliberately has no token implementation in T-204. T-301 will supply one. +type DeviceAuthenticator interface { + Authenticate(*http.Request) (DevicePrincipal, bool) +} + +// RejectAllDeviceAuthenticator keeps the production upload route fail closed until T-301 wires credentials. +type RejectAllDeviceAuthenticator struct{} + +func (RejectAllDeviceAuthenticator) Authenticate(*http.Request) (DevicePrincipal, bool) { + return DevicePrincipal{}, false +} + +type UploadMetadata struct { + UploadKey string + TaskID string + AttemptID string + Kind string + PrivacyTier string + SHA256 string + CapturedAt time.Time +} + +// StagedFile contains only server-generated state. Multipart filenames and client paths never enter this type. +type StagedFile struct { + Path string + SHA256 string + ByteSize int64 + ContentType string + Width int + Height int +} + +type Asset struct { + ID string `json:"asset_id"` + TaskID string `json:"task_id"` + AttemptID string `json:"attempt_id"` + Kind string `json:"kind"` + PrivacyTier string `json:"privacy_tier"` + SHA256 string `json:"sha256"` + ByteSize int64 `json:"byte_size"` + ContentType string `json:"content_type"` + Width int `json:"width_px"` + Height int `json:"height_px"` + CapturedAt time.Time `json:"captured_at"` + UploadedByDeviceID string `json:"-"` + StorageKey string `json:"-"` + CreatedAt time.Time `json:"-"` +} + +// Store separates bounded multipart staging from metadata commit so field order cannot weaken validation. +type Store interface { + Stage(io.Reader, string) (StagedFile, error) + Discard(StagedFile) + Commit(context.Context, DevicePrincipal, UploadMetadata, StagedFile) (Asset, bool, error) + Open(context.Context, string) (Asset, io.ReadSeekCloser, error) +} diff --git a/admin/internal/migrations/migrations_test.go b/admin/internal/migrations/migrations_test.go index 5e1a036..8a59cab 100644 --- a/admin/internal/migrations/migrations_test.go +++ b/admin/internal/migrations/migrations_test.go @@ -26,18 +26,25 @@ 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, 3) assertTableExists(t, database, "tasks", true) assertTableExists(t, database, "spec_trials", false) assertTableExists(t, database, "order_authorizations", true) assertTableExists(t, database, "purchase_attempts", true) assertTableExists(t, database, "order_submissions", true) + assertTableExists(t, database, "evidence_assets", true) assertTableExists(t, database, "single_pass_upgrade_guard", false) if err := migrations.Up(context, database, directory); err != nil { t.Fatalf("reapply migrations: %v", err) } + assertVersion(t, database, 3) + + if err := migrations.Down(context, database, directory); err != nil { + t.Fatalf("roll back evidence migration: %v", err) + } assertVersion(t, database, 2) + assertTableExists(t, database, "evidence_assets", false) if err := migrations.Down(context, database, directory); err != nil { t.Fatalf("roll back v2 migration: %v", err) @@ -50,7 +57,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, 3) } func TestUpgradePreservesManualDraftLosslessly(t *testing.T) { @@ -69,7 +76,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, 3) var got struct { id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string quantity, version int @@ -218,6 +225,55 @@ 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.Fatal("evidence-bearing schema downgraded successfully") + } + assertVersion(t, database, 3) + assertTableExists(t, database, "evidence_assets", true) + var count int + if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 1 { + t.Fatalf("evidence after rejected downgrade = %d, err=%v", count, err) + } +} + func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) { tests := []struct { name string @@ -244,9 +300,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 +325,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 +455,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"} { + 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) + } } } diff --git a/admin/internal/server/evidence.go b/admin/internal/server/evidence.go new file mode 100644 index 0000000..84c59ef --- /dev/null +++ b/admin/internal/server/evidence.go @@ -0,0 +1,193 @@ +package server + +import ( + "errors" + "io" + "mime" + "mime/multipart" + "net/http" + "strconv" + "strings" + "time" + "unicode/utf8" + + "cmbuyer/admin/internal/evidence" + + "github.com/gin-gonic/gin" +) + +const ( + maxEvidenceRequestBytes = evidence.MaxFileBytes + 64<<10 + maxEvidenceFieldBytes = 4 << 10 +) + +var evidenceFieldNames = map[string]struct{}{ + "upload_key": {}, "attempt_id": {}, "kind": {}, "privacy_tier": {}, "sha256": {}, "captured_at": {}, +} + +func uploadEvidence(options Options) gin.HandlerFunc { + return func(context *gin.Context) { + // Authentication deliberately precedes content-type parsing and every body read. A rejected + // device must not make the service spool or inspect a potentially sensitive upload. + principal, authenticated := options.DeviceAuthenticator.Authenticate(context.Request) + if !authenticated { + context.Status(http.StatusUnauthorized) + return + } + + boundary, ok := multipartBoundary(context.GetHeader("Content-Type")) + if !ok { + context.Status(http.StatusUnsupportedMediaType) + return + } + context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxEvidenceRequestBytes) + reader := multipart.NewReader(context.Request.Body, boundary) + fields := make(map[string]string, len(evidenceFieldNames)) + var staged evidence.StagedFile + hasFile := false + discard := func() { + if hasFile { + options.Evidence.Discard(staged) + } + } + + for { + part, err := reader.NextPart() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + discard() + writeMultipartError(context, err) + return + } + name := part.FormName() + if name == "file" { + if hasFile || part.FileName() == "" || !exactPNGContentType(part.Header.Get("Content-Type")) { + _ = part.Close() + discard() + context.Status(http.StatusUnsupportedMediaType) + return + } + staged, err = options.Evidence.Stage(part, evidence.PNGContentType) + _ = part.Close() + if err != nil { + writeEvidenceStoreError(context, err) + return + } + hasFile = true + continue + } + if _, allowed := evidenceFieldNames[name]; !allowed || part.FileName() != "" { + _ = part.Close() + discard() + context.Status(http.StatusBadRequest) + return + } + if _, duplicate := fields[name]; duplicate { + _ = part.Close() + discard() + context.Status(http.StatusBadRequest) + return + } + value, err := io.ReadAll(io.LimitReader(part, maxEvidenceFieldBytes+1)) + _ = part.Close() + if err != nil || len(value) == 0 || len(value) > maxEvidenceFieldBytes || !utf8.Valid(value) { + discard() + context.Status(http.StatusBadRequest) + return + } + fields[name] = string(value) + } + if !hasFile || len(fields) != len(evidenceFieldNames) { + discard() + context.Status(http.StatusBadRequest) + return + } + captured, err := time.Parse(time.RFC3339Nano, fields["captured_at"]) + if err != nil || !strings.HasSuffix(fields["captured_at"], "Z") { + discard() + context.Status(http.StatusBadRequest) + return + } + asset, replayed, err := options.Evidence.Commit(context.Request.Context(), principal, evidence.UploadMetadata{ + UploadKey: fields["upload_key"], TaskID: context.Param("id"), AttemptID: fields["attempt_id"], + Kind: fields["kind"], PrivacyTier: fields["privacy_tier"], SHA256: fields["sha256"], CapturedAt: captured.UTC(), + }, staged) + if err != nil { + writeEvidenceStoreError(context, err) + return + } + status := http.StatusCreated + if replayed { + status = http.StatusOK + } + context.JSON(status, asset) + } +} + +func readEvidence(options Options) gin.HandlerFunc { + return func(context *gin.Context) { + if !options.Sessions.IsAuthenticated(context.Request) { + context.Status(http.StatusUnauthorized) + return + } + asset, file, err := options.Evidence.Open(context.Request.Context(), context.Param("asset_id")) + if errors.Is(err, evidence.ErrNotFound) { + context.Status(http.StatusNotFound) + return + } + if err != nil { + context.Status(http.StatusInternalServerError) + return + } + defer file.Close() + context.Header("Content-Type", evidence.PNGContentType) + context.Header("Content-Length", strconv.FormatInt(asset.ByteSize, 10)) + context.Header("Content-Disposition", `inline; filename="evidence.png"`) + context.Header("Cache-Control", "no-store") + context.Header("X-Content-Type-Options", "nosniff") + context.Status(http.StatusOK) + if _, err := io.Copy(context.Writer, file); err != nil { + _ = context.Error(err) + } + } +} + +func multipartBoundary(value string) (string, bool) { + mediaType, parameters, err := mime.ParseMediaType(value) + if err != nil || mediaType != "multipart/form-data" || len(parameters) != 1 || parameters["boundary"] == "" { + return "", false + } + return parameters["boundary"], true +} + +func exactPNGContentType(value string) bool { + mediaType, parameters, err := mime.ParseMediaType(value) + return err == nil && mediaType == evidence.PNGContentType && len(parameters) == 0 +} + +func writeMultipartError(context *gin.Context, err error) { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + context.Status(http.StatusRequestEntityTooLarge) + return + } + context.Status(http.StatusBadRequest) +} + +func writeEvidenceStoreError(context *gin.Context, err error) { + var tooLarge *http.MaxBytesError + switch { + case errors.As(err, &tooLarge): + context.Status(http.StatusRequestEntityTooLarge) + case errors.Is(err, evidence.ErrTooLarge): + context.Status(http.StatusRequestEntityTooLarge) + case errors.Is(err, evidence.ErrInvalid): + context.Status(http.StatusBadRequest) + case errors.Is(err, evidence.ErrConflict): + context.Status(http.StatusConflict) + default: + context.Status(http.StatusInternalServerError) + } +} diff --git a/admin/internal/server/evidence_test.go b/admin/internal/server/evidence_test.go new file mode 100644 index 0000000..8a0cd90 --- /dev/null +++ b/admin/internal/server/evidence_test.go @@ -0,0 +1,290 @@ +package server_test + +import ( + "bytes" + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "image" + "image/png" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/textproto" + "path/filepath" + "runtime" + "strings" + "testing" + + "cmbuyer/admin/internal/evidence" + "cmbuyer/admin/internal/migrations" + evidencestorage "cmbuyer/admin/internal/storage/evidence" + "cmbuyer/admin/internal/storage/sqlite" +) + +const ( + evidenceTaskID = "63c9f507-7473-4fa6-8d71-8786c34c6301" + evidenceAuthID = "73c9f507-7473-4fa6-8d71-8786c34c6301" + evidenceAttemptID = "83c9f507-7473-4fa6-8d71-8786c34c6301" + evidenceUploadKey = "93c9f507-7473-4fa6-8d71-8786c34c6301" +) + +func TestEvidenceUploadAuthenticatesBeforeReadingBody(t *testing.T) { + authenticator := &fakeDeviceAuthenticator{} + router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator) + poison := &poisonBody{} + request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil) + request.Body = poison + request.Header.Set("Content-Type", "text/plain") + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusUnauthorized || poison.reads != 0 || authenticator.calls != 1 { + t.Fatalf("status/reads/auth calls = %d/%d/%d, want 401/0/1", response.Code, poison.reads, authenticator.calls) + } + assertSecurityHeaders(t, response) +} + +func TestAdminSessionCannotActAsDeviceUploader(t *testing.T) { + router, _ := newRouter(t) + cookie := authenticate(t, router) + request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil) + request.Body = &poisonBody{} + request.AddCookie(cookie) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("admin upload status = %d, want 401", response.Code) + } +} + +func TestEvidenceUploadReplayConflictAndProtectedRead(t *testing.T) { + router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{allowed: true, principal: evidence.DevicePrincipal{ID: "device-one"}}) + pngBytes := serverTestPNG(t, 6, 4) + fields := validEvidenceFields(pngBytes) + + first := serveEvidenceUpload(t, router, evidenceTaskID, fields, pngBytes, evidence.PNGContentType, `..\private\original.png`, nil) + if first.Code != http.StatusCreated { + t.Fatalf("first upload status/body = %d/%q", first.Code, first.Body.String()) + } + var asset evidence.Asset + if err := json.Unmarshal(first.Body.Bytes(), &asset); err != nil { + t.Fatalf("decode upload response: %v", err) + } + if asset.TaskID != evidenceTaskID || asset.AttemptID != evidenceAttemptID || asset.SHA256 != fields["sha256"] || strings.Contains(first.Body.String(), "private") || strings.Contains(first.Body.String(), "original.png") { + t.Fatalf("unsafe upload response = %s", first.Body.String()) + } + + replay := serveEvidenceUpload(t, router, evidenceTaskID, fields, pngBytes, evidence.PNGContentType, "again.png", nil) + if replay.Code != http.StatusOK { + t.Fatalf("replay status = %d, want 200", replay.Code) + } + var replayed evidence.Asset + if err := json.Unmarshal(replay.Body.Bytes(), &replayed); err != nil || replayed.ID != asset.ID { + t.Fatalf("replay asset = %#v, err %v", replayed, err) + } + + conflicting := copyStringMap(fields) + conflicting["captured_at"] = "2026-08-04T09:01:01Z" + if response := serveEvidenceUpload(t, router, evidenceTaskID, conflicting, pngBytes, evidence.PNGContentType, "same.png", nil); response.Code != http.StatusConflict { + t.Fatalf("conflicting replay status = %d, want 409", response.Code) + } + var count int + if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 1 { + t.Fatalf("asset count = %d, err %v", count, err) + } + + if response := serve(router, http.MethodGet, "/evidence/"+asset.ID, nil, nil); response.Code != http.StatusUnauthorized || response.Body.Len() != 0 { + t.Fatalf("anonymous read = %d/%q", response.Code, response.Body.String()) + } + adminCookie := authenticate(t, router) + read := serve(router, http.MethodGet, "/evidence/"+asset.ID, nil, adminCookie) + if read.Code != http.StatusOK || !bytes.Equal(read.Body.Bytes(), pngBytes) { + t.Fatalf("admin read = %d, bytes equal %t", read.Code, bytes.Equal(read.Body.Bytes(), pngBytes)) + } + for header, want := range map[string]string{"Content-Type": "image/png", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Content-Disposition": `inline; filename="evidence.png"`} { + if got := read.Header().Get(header); got != want { + t.Fatalf("%s = %q, want %q", header, got, want) + } + } + missing := serve(router, http.MethodGet, "/evidence/not-a-uuid", nil, adminCookie) + if missing.Code != http.StatusNotFound || missing.Body.Len() != 0 { + t.Fatalf("missing evidence = %d/%q", missing.Code, missing.Body.String()) + } +} + +func TestEvidenceUploadRejectsStrictMultipartViolations(t *testing.T) { + router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{allowed: true, principal: evidence.DevicePrincipal{ID: "device-one"}}) + pngBytes := serverTestPNG(t, 2, 2) + base := validEvidenceFields(pngBytes) + wrongHash := copyStringMap(base) + wrongHash["sha256"] = strings.Repeat("b", 64) + uppercaseHash := copyStringMap(base) + uppercaseHash["sha256"] = strings.ToUpper(uppercaseHash["sha256"]) + wrongPrivacy := copyStringMap(base) + wrongPrivacy["privacy_tier"] = "PUBLIC" + wrongKind := copyStringMap(base) + wrongKind["kind"] = "ORDER_CONFIRM" + tests := []struct { + name string + fields map[string]string + file []byte + contentType string + extra func(*multipart.Writer) error + want int + }{ + {name: "attempt belongs to another task", fields: base, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest}, + {name: "xml file", fields: base, file: []byte(""), contentType: evidence.PNGContentType, want: http.StatusBadRequest}, + {name: "wrong hash", fields: wrongHash, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest}, + {name: "uppercase hash", fields: uppercaseHash, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest}, + {name: "wrong privacy", fields: wrongPrivacy, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest}, + {name: "unapproved kind", fields: wrongKind, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest}, + {name: "too large", fields: base, file: make([]byte, evidence.MaxFileBytes+1), contentType: evidence.PNGContentType, want: http.StatusRequestEntityTooLarge}, + {name: "wrong file content type", fields: base, file: pngBytes, contentType: "application/xml", want: http.StatusUnsupportedMediaType}, + {name: "unknown path field", fields: base, file: pngBytes, contentType: evidence.PNGContentType, extra: func(writer *multipart.Writer) error { return writer.WriteField("path", `C:\secret.xml`) }, want: http.StatusBadRequest}, + {name: "duplicate metadata", fields: base, file: pngBytes, contentType: evidence.PNGContentType, extra: func(writer *multipart.Writer) error { return writer.WriteField("sha256", base["sha256"]) }, want: http.StatusBadRequest}, + {name: "second file", fields: base, file: pngBytes, contentType: evidence.PNGContentType, extra: func(writer *multipart.Writer) error { + part, err := writer.CreateFormFile("file", "second.png") + if err == nil { + _, err = part.Write(pngBytes) + } + return err + }, want: http.StatusUnsupportedMediaType}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + taskID := evidenceTaskID + if test.name == "attempt belongs to another task" { + taskID = "a3c9f507-7473-4fa6-8d71-8786c34c6301" + } + response := serveEvidenceUpload(t, router, taskID, copyStringMap(test.fields), test.file, test.contentType, "file.png", test.extra) + if response.Code != test.want || response.Body.Len() != 0 { + t.Fatalf("status/body = %d/%q, want %d/empty", response.Code, response.Body.String(), test.want) + } + }) + } + var count int + if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 0 { + t.Fatalf("invalid requests created %d assets, err %v", count, err) + } +} + +type fakeDeviceAuthenticator struct { + allowed bool + principal evidence.DevicePrincipal + calls int +} + +func (authenticator *fakeDeviceAuthenticator) Authenticate(*http.Request) (evidence.DevicePrincipal, bool) { + authenticator.calls++ + return authenticator.principal, authenticator.allowed +} + +type poisonBody struct{ reads int } + +func (body *poisonBody) Read([]byte) (int, error) { + body.reads++ + return 0, io.ErrUnexpectedEOF +} +func (*poisonBody) Close() error { return nil } + +func newEvidenceRouter(t *testing.T, authenticator evidence.DeviceAuthenticator) (http.Handler, *sql.DB) { + t.Helper() + database, err := sqlite.Open(filepath.Join(t.TempDir(), "server-evidence.db")) + if err != nil { + t.Fatalf("open database: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate migration directory") + } + if err := migrations.Up(context.Background(), database, filepath.Join(filepath.Dir(file), "..", "..", "migrations")); err != nil { + t.Fatalf("migrate database: %v", err) + } + insertEvidenceAttempt(t, database) + store, err := evidencestorage.NewStore(database, filepath.Join(t.TempDir(), "assets")) + if err != nil { + t.Fatalf("new evidence store: %v", err) + } + router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, store, authenticator) + return router, database +} + +func insertEvidenceAttempt(t *testing.T, database *sql.DB) { + t.Helper() + timestamp := "2026-08-04T00:00:00Z" + if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'task', '123', 'black', 'M', 1, '1.00', 'DRAFT', 1, ?, ?)`, evidenceTaskID, timestamp, timestamp); err != nil { + t.Fatalf("insert task: %v", err) + } + if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, 1, 'start', '123', 'black', 'M', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, evidenceAuthID, evidenceTaskID, timestamp, timestamp); err != nil { + t.Fatalf("insert authorization: %v", err) + } + if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, evidenceAttemptID, evidenceTaskID, evidenceAuthID, timestamp); err != nil { + t.Fatalf("insert attempt: %v", err) + } +} + +func serveEvidenceUpload(t *testing.T, router http.Handler, taskID string, fields map[string]string, file []byte, fileContentType, filename string, extra func(*multipart.Writer) error) *httptest.ResponseRecorder { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + for _, name := range []string{"upload_key", "attempt_id", "kind", "privacy_tier", "sha256", "captured_at"} { + if err := writer.WriteField(name, fields[name]); err != nil { + t.Fatalf("write field %s: %v", name, err) + } + } + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", `form-data; name="file"; filename="`+filename+`"`) + header.Set("Content-Type", fileContentType) + part, err := writer.CreatePart(header) + if err != nil { + t.Fatalf("create file part: %v", err) + } + if _, err := part.Write(file); err != nil { + t.Fatalf("write file: %v", err) + } + if extra != nil { + if err := extra(writer); err != nil { + t.Fatalf("write extra part: %v", err) + } + } + if err := writer.Close(); err != nil { + t.Fatalf("close multipart: %v", err) + } + request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+taskID+"/evidence", bytes.NewReader(body.Bytes())) + request.Header.Set("Content-Type", writer.FormDataContentType()) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response +} + +func validEvidenceFields(pngBytes []byte) map[string]string { + hash := sha256.Sum256(pngBytes) + return map[string]string{ + "upload_key": evidenceUploadKey, "attempt_id": evidenceAttemptID, + "kind": evidence.KindSKUPanelGate1, "privacy_tier": evidence.PrivacyInternalRaw, + "sha256": hex.EncodeToString(hash[:]), "captured_at": "2026-08-04T09:01:00Z", + } +} + +func serverTestPNG(t *testing.T, width, height int) []byte { + t.Helper() + var buffer bytes.Buffer + if err := png.Encode(&buffer, image.NewNRGBA(image.Rect(0, 0, width, height))); err != nil { + t.Fatalf("encode PNG: %v", err) + } + return buffer.Bytes() +} + +func copyStringMap(values map[string]string) map[string]string { + copy := make(map[string]string, len(values)) + for key, value := range values { + copy[key] = value + } + return copy +} diff --git a/admin/internal/server/router.go b/admin/internal/server/router.go index f1beb06..cad9468 100644 --- a/admin/internal/server/router.go +++ b/admin/internal/server/router.go @@ -14,6 +14,8 @@ import ( "unicode/utf8" "cmbuyer/admin/internal/auth" + "cmbuyer/admin/internal/evidence" + "cmbuyer/admin/internal/taskdetail" "cmbuyer/admin/internal/tasks" "cmbuyer/admin/internal/transport/webui" @@ -30,11 +32,14 @@ type Options struct { AdminPasswordBcrypt string Sessions *auth.Manager Tasks tasks.Store + TaskDetails taskdetail.Store + Evidence evidence.Store + DeviceAuthenticator evidence.DeviceAuthenticator } // NewRouter 返回当前服务范围内的完整 HTTP 路由。 func NewRouter(options Options) (*gin.Engine, error) { - if options.AdminUsername == "" || options.AdminPasswordBcrypt == "" || options.Sessions == nil || options.Tasks == nil { + 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 +51,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()) }) diff --git a/admin/internal/server/router_test.go b/admin/internal/server/router_test.go index c85d915..3ede303 100644 --- a/admin/internal/server/router_test.go +++ b/admin/internal/server/router_test.go @@ -2,6 +2,7 @@ package server_test import ( "context" + "io" "net/http" "net/http/httptest" "net/url" @@ -11,7 +12,9 @@ import ( "time" "cmbuyer/admin/internal/auth" + "cmbuyer/admin/internal/evidence" "cmbuyer/admin/internal/server" + "cmbuyer/admin/internal/taskdetail" "cmbuyer/admin/internal/tasks" "github.com/gin-gonic/gin" @@ -316,6 +319,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>查看详情`, + `.detail-link-button{display:block;min-height:44px`, + `data-detail-drawer aria-modal="true"`, `待开始`, `已授权待领取`, `datetime="2026-08-04T09:02:03+08:00">2026-08-04 09:02`, @@ -481,6 +488,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{}, evidence.RejectAllDeviceAuthenticator{}) +} + +func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator evidence.DeviceAuthenticator) (*gin.Engine, *auth.Manager) { t.Helper() gin.SetMode(gin.TestMode) hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost) @@ -493,6 +504,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,6 +514,25 @@ 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, evidence.DevicePrincipal, evidence.UploadMetadata, evidence.StagedFile) (evidence.Asset, bool, error) { + return evidence.Asset{}, false, evidence.ErrInvalid +} +func (emptyEvidenceStore) Open(context.Context, string) (evidence.Asset, io.ReadSeekCloser, error) { + return evidence.Asset{}, nil, evidence.ErrNotFound +} + type memoryStore struct { drafts []tasks.Draft rows []tasks.TaskRow diff --git a/admin/internal/server/task_detail.go b/admin/internal/server/task_detail.go new file mode 100644 index 0000000..47962ea --- /dev/null +++ b/admin/internal/server/task_detail.go @@ -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 +} diff --git a/admin/internal/server/task_detail_test.go b/admin/internal/server/task_detail_test.go new file mode 100644 index 0000000..b250ce8 --- /dev/null +++ b/admin/internal/server/task_detail_test.go @@ -0,0 +1,110 @@ +package server_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "cmbuyer/admin/internal/evidence" + "cmbuyer/admin/internal/taskdetail" +) + +const detailTaskID = "a3c9f507-7473-4fa6-8d71-8786c34c6301" + +func TestTaskDetailRequiresAdminBeforeLookup(t *testing.T) { + details := &recordingDetailStore{detail: taskDetailFixture()} + router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, evidence.RejectAllDeviceAuthenticator{}) + response := serve(router, http.MethodGet, "/tasks/"+detailTaskID, nil, nil) + if response.Code != http.StatusSeeOther || !strings.HasPrefix(response.Header().Get("Location"), "/login?return_to=") || details.calls != 0 { + t.Fatalf("anonymous detail = %d/%q, calls=%d", response.Code, response.Header().Get("Location"), details.calls) + } +} + +func TestTaskDetailFullPageAndDrawerShareAuditContent(t *testing.T) { + details := &recordingDetailStore{detail: taskDetailFixture()} + router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, evidence.RejectAllDeviceAuthenticator{}) + cookie := authenticate(t, router) + full := serve(router, http.MethodGet, "/tasks/"+detailTaskID, nil, cookie) + if full.Code != http.StatusOK || !strings.Contains(full.Body.String(), "") || !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(), "") || !strings.Contains(fragment.Body.String(), `data-task-detail-content`) { + t.Fatalf("fragment detail = %d/%q", fragment.Code, fragment.Body.String()) + } + for _, text := range []string{"测试<script>", "订单已创建,系统尚未付款", "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(), "{{end}} + {{if .OpenForm}}{{template "form" .}}{{end}}

任务详情

{{end}} {{end}} diff --git a/admin/internal/transport/webui/webui.go b/admin/internal/transport/webui/webui.go index 2e6a113..a29a7cc 100644 --- a/admin/internal/transport/webui/webui.go +++ b/admin/internal/transport/webui/webui.go @@ -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) +} diff --git a/admin/migrations/00003_evidence_assets.sql b/admin/migrations/00003_evidence_assets.sql new file mode 100644 index 0000000..0b25f97 --- /dev/null +++ b/admin/migrations/00003_evidence_assets.sql @@ -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; diff --git a/docs/04-architecture.md b/docs/04-architecture.md index e7fbdd2..28beaa1 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -246,6 +246,27 @@ CREATE TABLE order_submissions ( UNIQUE (authorization_id), UNIQUE (attempt_id) ); + +-- 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`。实际读价属于 @@ -306,6 +327,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 必须先通过设备认证,再解析 Content-Type 或读取 body。T-301 前生产认证器固定拒绝, + 不创建临时 token,也不把管理员 session 当设备身份。 +- 文件写入显式配置的私有证据根目录:同目录随机临时文件 → 流式 hash → 校验 → `fsync` → 原子 + rename 到 SHA-256 内容地址 → 最后事务写数据库。数据库永远不指向半文件或缺失文件。 +- SQLite 与文件系统不能组成跨资源事务;极端故障最多留下不可达孤儿文件。不得为清理孤儿而删除 + 可能被其他资产记录并发复用的内容文件,自动保留/删除策略留给部署任务。 +- SHA-256 只用于物理内容寻址,不是业务资产唯一键;不同合法证据可以引用相同内容。同设备主体与 + `upload_key` 同载荷重放原资产,任一规范字段变化即冲突。 + ## 六、关键技术难点 | 难点 | 风险 | 应对 | diff --git a/docs/api.md b/docs/api.md index 3525caa..16ab7aa 100644 --- a/docs/api.md +++ b/docs/api.md @@ -53,6 +53,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 +198,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、原文件名或存储路径。 +- T-301 接入真实设备 Bearer 身份前,生产 `DeviceAuthenticator` 固定拒绝全部上传;不得使用管理员 + session、临时 token 或共享密钥代替设备身份。 ### `POST /api/v1/purchase-attempts/{aid}/submission-fence` @@ -310,6 +331,6 @@ T-103 只实现隔离的 `SkuSelectionFlow`:前四项加安全退出。它的 ## 四、实现前仍需定值 - 授权有效期、领取租约时长、心跳/轮询间隔和连续失败停止阈值; -- 截图大小上限和内部保留期限; +- 内部截图保留期限;截图大小上限已固定为 10 MiB / 8192 px 单边 / 16,777,216 像素; - 可配置单任务数量与最高总价系统上限; - 首次真实提交真机任务的人工授权和待付款订单处置步骤。 diff --git a/docs/routes.md b/docs/routes.md index d742a5d..170cdb4 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -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: