Compare commits

..
40 changed files with 5062 additions and 62 deletions
+15
View File
@@ -9,6 +9,10 @@
| `CMBUYER_SESSION_SECRET` | 至少 32 字节的会话签名密钥。 | | `CMBUYER_SESSION_SECRET` | 至少 32 字节的会话签名密钥。 |
| `CMBUYER_COOKIE_SECURE` | 可选;存在时只能精确为 `true` 或 `false`。HTTPS 部署应设为 `true`。 | | `CMBUYER_COOKIE_SECURE` | 可选;存在时只能精确为 `true` 或 `false`。HTTPS 部署应设为 `true`。 |
| `CMBUYER_DATABASE_SOURCE` | 已迁移 SQLite 的显式 data source。 | | `CMBUYER_DATABASE_SOURCE` | 已迁移 SQLite 的显式 data source。 |
| `CMBUYER_AUTHORIZATION_TTL` | 一次性授权的正 Go duration,例如 `10m`。 |
| `CMBUYER_MAX_TASK_QUANTITY` | 每条任务允许的正整数数量上限。 |
| `CMBUYER_MAX_TOTAL_PRICE` | 每条任务允许的规范正数总价上限,例如 `999.99`。 |
| `CMBUYER_EVIDENCE_DIR` | 内部原始截图的绝对私有目录;不得指向仓库或公开静态目录。 |
示例仅展示变量名,不提供可运行凭据: 示例仅展示变量名,不提供可运行凭据:
@@ -18,8 +22,19 @@ $env:CMBUYER_ADMIN_PASSWORD_BCRYPT = '<bcrypt 密码哈希>'
$env:CMBUYER_SESSION_SECRET = '<至少 32 字节的随机密钥>' $env:CMBUYER_SESSION_SECRET = '<至少 32 字节的随机密钥>'
$env:CMBUYER_COOKIE_SECURE = 'true' $env:CMBUYER_COOKIE_SECURE = 'true'
$env:CMBUYER_DATABASE_SOURCE = '<SQLite data source>' $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/migrate -database $env:CMBUYER_DATABASE_SOURCE up
go run ./cmd/server go run ./cmd/server
``` ```
采购服务会话仅保存在当前进程内;进程重启后既有登录会话会安全失效。 采购服务会话仅保存在当前进程内;进程重启后既有登录会话会安全失效。
管理员的“开始采购(只创建待付款订单)”只签发一次性授权并创建待付款订单的资格;服务不会自动付款,也不包含任何支付操作。
`GET /tasks/{id}` 直接访问时渲染完整详情页,任务列表以同一 URL 加载详情抽屉。内部截图只通过
`GET /evidence/{asset_id}` 向有效管理员会话提供,并始终返回 `no-store`;文件不在静态目录中。
T-301 接入真实设备凭据之前,`POST /api/v1/tasks/{id}/evidence` 的生产认证器固定拒绝全部请求。
测试可以注入 fake 设备主体验证上传契约,但不得用管理员会话、临时 token 或共享密钥绕过该边界。
+15
View File
@@ -7,8 +7,11 @@ import (
"cmbuyer/admin/internal/auth" "cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/config" "cmbuyer/admin/internal/config"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/server" "cmbuyer/admin/internal/server"
evidencestorage "cmbuyer/admin/internal/storage/evidence"
"cmbuyer/admin/internal/storage/sqlite" "cmbuyer/admin/internal/storage/sqlite"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks" "cmbuyer/admin/internal/tasks"
) )
@@ -34,12 +37,24 @@ func run() error {
if err != nil { if err != nil {
return err 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{ router, err := server.NewRouter(server.Options{
AdminUsername: configuration.AdminUsername, AdminUsername: configuration.AdminUsername,
AdminPasswordBcrypt: configuration.AdminPasswordBcrypt, AdminPasswordBcrypt: configuration.AdminPasswordBcrypt,
Sessions: auth.NewManager(configuration.SessionSecret, configuration.CookieSecure), Sessions: auth.NewManager(configuration.SessionSecret, configuration.CookieSecure),
Tasks: taskStore, Tasks: taskStore,
TaskDetails: detailStore,
Evidence: evidenceStore,
DeviceAuthenticator: evidence.RejectAllDeviceAuthenticator{},
}) })
if err != nil { if err != nil {
return err return err
+6
View File
@@ -62,6 +62,12 @@ func (manager *Manager) Ensure(writer http.ResponseWriter, request *http.Request
return current.csrfToken, false return current.csrfToken, false
} }
// IsAuthenticated 只读检查当前请求是否持有有效管理会话;它不会像 Ensure 一样创建匿名会话。
func (manager *Manager) IsAuthenticated(request *http.Request) bool {
_, current, found := manager.current(request)
return found && current.authenticated
}
// VerifyCSRF 只接受当前未过期会话中以恒定时间比较匹配的 token。 // VerifyCSRF 只接受当前未过期会话中以恒定时间比较匹配的 token。
func (manager *Manager) VerifyCSRF(request *http.Request, token string) (authenticated bool, ok bool) { func (manager *Manager) VerifyCSRF(request *http.Request, token string) (authenticated bool, ok bool) {
_, current, found := manager.current(request) _, current, found := manager.current(request)
+45
View File
@@ -37,6 +37,51 @@ func TestManagerRejectsTamperedAndExpiredCookies(t *testing.T) {
} }
} }
func TestIsAuthenticatedDoesNotCreateOrDependOnCSRFValidation(t *testing.T) {
manager := NewManager([]byte(strings.Repeat("s", 32)), false)
missingSession := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil)
if manager.IsAuthenticated(missingSession) {
t.Fatal("missing session was treated as authenticated")
}
if len(manager.sessions) != 0 {
t.Fatalf("read-only authentication check created %d sessions", len(manager.sessions))
}
anonymousRequest := httptest.NewRequest(http.MethodGet, "/login", nil)
anonymousResponse := httptest.NewRecorder()
manager.Ensure(anonymousResponse, anonymousRequest)
anonymousCookie := anonymousResponse.Result().Cookies()[0]
anonymousCheck := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil)
anonymousCheck.AddCookie(anonymousCookie)
if manager.IsAuthenticated(anonymousCheck) {
t.Fatal("anonymous CSRF session was treated as authenticated")
}
loginRequest := httptest.NewRequest(http.MethodPost, "/login", nil)
loginRequest.AddCookie(anonymousCookie)
authenticatedResponse := httptest.NewRecorder()
csrf := manager.RotateAuthenticated(authenticatedResponse, loginRequest)
authenticatedCookie := authenticatedResponse.Result().Cookies()[0]
authenticatedCheck := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil)
authenticatedCheck.AddCookie(authenticatedCookie)
if !manager.IsAuthenticated(authenticatedCheck) {
t.Fatal("valid authenticated session was not recognized")
}
if authenticated, csrfOK := manager.VerifyCSRF(authenticatedCheck, "wrong-token"); authenticated || csrfOK {
t.Fatalf("wrong token result = (%t, %t), want (false, false)", authenticated, csrfOK)
}
validRequest := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil)
validRequest.AddCookie(authenticatedCookie)
if authenticated, csrfOK := manager.VerifyCSRF(validRequest, csrf); !authenticated || !csrfOK {
t.Fatalf("valid token result = (%t, %t), want (true, true)", authenticated, csrfOK)
}
if authenticated, csrfOK := manager.VerifyCSRF(httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", nil), csrf); authenticated || csrfOK {
t.Fatalf("missing session result = (%t, %t), want (false, false)", authenticated, csrfOK)
}
}
func flipCookieValue(t *testing.T, value string) string { func flipCookieValue(t *testing.T, value string) string {
t.Helper() t.Helper()
if value == "" { if value == "" {
+58
View File
@@ -5,7 +5,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strconv"
"strings" "strings"
"time"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -16,6 +19,10 @@ const (
sessionSecretEnv = "CMBUYER_SESSION_SECRET" sessionSecretEnv = "CMBUYER_SESSION_SECRET"
cookieSecureEnv = "CMBUYER_COOKIE_SECURE" cookieSecureEnv = "CMBUYER_COOKIE_SECURE"
databaseSourceEnv = "CMBUYER_DATABASE_SOURCE" databaseSourceEnv = "CMBUYER_DATABASE_SOURCE"
authorizationTTLEnv = "CMBUYER_AUTHORIZATION_TTL"
maxTaskQuantityEnv = "CMBUYER_MAX_TASK_QUANTITY"
maxTotalPriceEnv = "CMBUYER_MAX_TOTAL_PRICE"
evidenceDirectoryEnv = "CMBUYER_EVIDENCE_DIR"
minimumSecretLength = 32 minimumSecretLength = 32
) )
@@ -26,6 +33,10 @@ type Config struct {
SessionSecret []byte SessionSecret []byte
CookieSecure bool CookieSecure bool
DatabaseSource string DatabaseSource string
AuthorizationTTL time.Duration
MaxTaskQuantity int
MaxTotalPrice string
EvidenceDirectory string
} }
// LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。 // LoadFromEnv 从进程环境读取配置。错误只指出缺失或非法的变量名,绝不回显秘密。
@@ -71,6 +82,36 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
if err != nil { if err != nil {
return Config{}, err return Config{}, err
} }
ttlText, err := required(lookup, authorizationTTLEnv)
if err != nil {
return Config{}, err
}
ttl, err := time.ParseDuration(ttlText)
if err != nil || ttl <= 0 {
return Config{}, fmt.Errorf("%s must be a positive duration", authorizationTTLEnv)
}
quantityText, err := required(lookup, maxTaskQuantityEnv)
if err != nil {
return Config{}, err
}
maxQuantity, err := strconv.Atoi(quantityText)
if err != nil || maxQuantity < 1 {
return Config{}, fmt.Errorf("%s must be a positive integer", maxTaskQuantityEnv)
}
maxPrice, err := required(lookup, maxTotalPriceEnv)
if err != nil {
return Config{}, err
}
if !canonicalMoney(maxPrice) {
return Config{}, fmt.Errorf("%s must be a canonical positive decimal", maxTotalPriceEnv)
}
evidenceDirectory, err := required(lookup, evidenceDirectoryEnv)
if err != nil {
return Config{}, err
}
if strings.TrimSpace(evidenceDirectory) != evidenceDirectory || !filepath.IsAbs(evidenceDirectory) {
return Config{}, fmt.Errorf("%s must be an absolute path without surrounding whitespace", evidenceDirectoryEnv)
}
return Config{ return Config{
AdminUsername: username, AdminUsername: username,
@@ -78,9 +119,26 @@ func Load(lookup func(string) (string, bool)) (Config, error) {
SessionSecret: []byte(secret), SessionSecret: []byte(secret),
CookieSecure: cookieSecure, CookieSecure: cookieSecure,
DatabaseSource: databaseSource, DatabaseSource: databaseSource,
AuthorizationTTL: ttl, MaxTaskQuantity: maxQuantity, MaxTotalPrice: maxPrice,
EvidenceDirectory: evidenceDirectory,
}, nil }, nil
} }
func canonicalMoney(value string) bool {
parts := strings.Split(value, ".")
if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) != 2 || (len(parts[0]) > 1 && parts[0][0] == '0') {
return false
}
for _, part := range parts {
for _, ch := range part {
if ch < '0' || ch > '9' {
return false
}
}
}
return strings.Trim(parts[0]+parts[1], "0") != ""
}
func required(lookup func(string) (string, bool), name string) (string, error) { func required(lookup func(string) (string, bool), name string) (string, error) {
value, present := lookup(name) value, present := lookup(name)
if !present || strings.TrimSpace(value) == "" { if !present || strings.TrimSpace(value) == "" {
+13
View File
@@ -21,6 +21,10 @@ func TestLoad(t *testing.T) {
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32), "CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
"CMBUYER_COOKIE_SECURE": "true", "CMBUYER_COOKIE_SECURE": "true",
"CMBUYER_DATABASE_SOURCE": ":memory:", "CMBUYER_DATABASE_SOURCE": ":memory:",
"CMBUYER_AUTHORIZATION_TTL": "10m",
"CMBUYER_MAX_TASK_QUANTITY": "99",
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
} }
got, err := config.Load(lookup(values)) got, err := config.Load(lookup(values))
@@ -43,6 +47,10 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
"CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash), "CMBUYER_ADMIN_PASSWORD_BCRYPT": string(hash),
"CMBUYER_SESSION_SECRET": strings.Repeat("s", 32), "CMBUYER_SESSION_SECRET": strings.Repeat("s", 32),
"CMBUYER_DATABASE_SOURCE": ":memory:", "CMBUYER_DATABASE_SOURCE": ":memory:",
"CMBUYER_AUTHORIZATION_TTL": "10m",
"CMBUYER_MAX_TASK_QUANTITY": "99",
"CMBUYER_MAX_TOTAL_PRICE": "999.99",
"CMBUYER_EVIDENCE_DIR": t.TempDir(),
} }
tests := []struct { tests := []struct {
@@ -55,6 +63,11 @@ func TestLoadRejectsMissingOrInvalidConfiguration(t *testing.T) {
{"short secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = "short" }, "CMBUYER_SESSION_SECRET"}, {"short secret", func(values map[string]string) { values["CMBUYER_SESSION_SECRET"] = "short" }, "CMBUYER_SESSION_SECRET"},
{"invalid secure flag", func(values map[string]string) { values["CMBUYER_COOKIE_SECURE"] = "1" }, "CMBUYER_COOKIE_SECURE"}, {"invalid secure flag", func(values map[string]string) { values["CMBUYER_COOKIE_SECURE"] = "1" }, "CMBUYER_COOKIE_SECURE"},
{"missing database", func(values map[string]string) { delete(values, "CMBUYER_DATABASE_SOURCE") }, "CMBUYER_DATABASE_SOURCE"}, {"missing database", func(values map[string]string) { delete(values, "CMBUYER_DATABASE_SOURCE") }, "CMBUYER_DATABASE_SOURCE"},
{"invalid authorization ttl", func(values map[string]string) { values["CMBUYER_AUTHORIZATION_TTL"] = "0s" }, "CMBUYER_AUTHORIZATION_TTL"},
{"invalid maximum quantity", func(values map[string]string) { values["CMBUYER_MAX_TASK_QUANTITY"] = "0" }, "CMBUYER_MAX_TASK_QUANTITY"},
{"invalid maximum total price", func(values map[string]string) { values["CMBUYER_MAX_TOTAL_PRICE"] = "1" }, "CMBUYER_MAX_TOTAL_PRICE"},
{"missing evidence directory", func(values map[string]string) { delete(values, "CMBUYER_EVIDENCE_DIR") }, "CMBUYER_EVIDENCE_DIR"},
{"relative evidence directory", func(values map[string]string) { values["CMBUYER_EVIDENCE_DIR"] = "evidence" }, "CMBUYER_EVIDENCE_DIR"},
} }
for _, test := range tests { for _, test := range tests {
+88
View File
@@ -0,0 +1,88 @@
// Package evidence defines the narrow internal screenshot contract shared by HTTP and storage.
package evidence
import (
"context"
"errors"
"io"
"net/http"
"time"
)
const (
KindSKUPanelGate1 = "SKU_PANEL_GATE_1"
PrivacyInternalRaw = "INTERNAL_RAW"
PNGContentType = "image/png"
MaxFileBytes int64 = 10 << 20
MaxImageSide = 8192
MaxImagePixels = 16_777_216
)
var (
ErrInvalid = errors.New("invalid evidence")
ErrConflict = errors.New("evidence upload key conflict")
ErrNotFound = errors.New("evidence not found")
ErrTooLarge = errors.New("evidence file too large")
)
// DevicePrincipal is the already-authenticated device identity used only for audit and idempotency.
type DevicePrincipal struct {
ID string
}
// DeviceAuthenticator deliberately has no token implementation in T-204. T-301 will supply one.
type DeviceAuthenticator interface {
Authenticate(*http.Request) (DevicePrincipal, bool)
}
// RejectAllDeviceAuthenticator keeps the production upload route fail closed until T-301 wires credentials.
type RejectAllDeviceAuthenticator struct{}
func (RejectAllDeviceAuthenticator) Authenticate(*http.Request) (DevicePrincipal, bool) {
return DevicePrincipal{}, false
}
type UploadMetadata struct {
UploadKey string
TaskID string
AttemptID string
Kind string
PrivacyTier string
SHA256 string
CapturedAt time.Time
}
// StagedFile contains only server-generated state. Multipart filenames and client paths never enter this type.
type StagedFile struct {
Path string
SHA256 string
ByteSize int64
ContentType string
Width int
Height int
}
type Asset struct {
ID string `json:"asset_id"`
TaskID string `json:"task_id"`
AttemptID string `json:"attempt_id"`
Kind string `json:"kind"`
PrivacyTier string `json:"privacy_tier"`
SHA256 string `json:"sha256"`
ByteSize int64 `json:"byte_size"`
ContentType string `json:"content_type"`
Width int `json:"width_px"`
Height int `json:"height_px"`
CapturedAt time.Time `json:"captured_at"`
UploadedByDeviceID string `json:"-"`
StorageKey string `json:"-"`
CreatedAt time.Time `json:"-"`
}
// Store separates bounded multipart staging from metadata commit so field order cannot weaken validation.
type Store interface {
Stage(io.Reader, string) (StagedFile, error)
Discard(StagedFile)
Commit(context.Context, DevicePrincipal, UploadMetadata, StagedFile) (Asset, bool, error)
Open(context.Context, string) (Asset, io.ReadSeekCloser, error)
}
+80 -13
View File
@@ -26,18 +26,25 @@ func TestUpDownAndIdempotence(t *testing.T) {
if err := migrations.Up(context, database, directory); err != nil { if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("apply migrations: %v", err) t.Fatalf("apply migrations: %v", err)
} }
assertVersion(t, database, 2) assertVersion(t, database, 3)
assertTableExists(t, database, "tasks", true) assertTableExists(t, database, "tasks", true)
assertTableExists(t, database, "spec_trials", false) assertTableExists(t, database, "spec_trials", false)
assertTableExists(t, database, "order_authorizations", true) assertTableExists(t, database, "order_authorizations", true)
assertTableExists(t, database, "purchase_attempts", true) assertTableExists(t, database, "purchase_attempts", true)
assertTableExists(t, database, "order_submissions", true) assertTableExists(t, database, "order_submissions", true)
assertTableExists(t, database, "evidence_assets", true)
assertTableExists(t, database, "single_pass_upgrade_guard", false) assertTableExists(t, database, "single_pass_upgrade_guard", false)
if err := migrations.Up(context, database, directory); err != nil { if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("reapply migrations: %v", err) t.Fatalf("reapply migrations: %v", err)
} }
assertVersion(t, database, 3)
if err := migrations.Down(context, database, directory); err != nil {
t.Fatalf("roll back evidence migration: %v", err)
}
assertVersion(t, database, 2) assertVersion(t, database, 2)
assertTableExists(t, database, "evidence_assets", false)
if err := migrations.Down(context, database, directory); err != nil { if err := migrations.Down(context, database, directory); err != nil {
t.Fatalf("roll back v2 migration: %v", err) 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 { if err := migrations.Up(context, database, directory); err != nil {
t.Fatalf("reapply v2 after rollback: %v", err) t.Fatalf("reapply v2 after rollback: %v", err)
} }
assertVersion(t, database, 2) assertVersion(t, database, 3)
} }
func TestUpgradePreservesManualDraftLosslessly(t *testing.T) { 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 { if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil {
t.Fatalf("upgrade v1 draft: %v", err) t.Fatalf("upgrade v1 draft: %v", err)
} }
assertVersion(t, database, 2) assertVersion(t, database, 3)
var got struct { var got struct {
id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string id, source, sourceRef, title, goodsID, color, size, maxPrice, assetID, status, created, updated string
quantity, version int 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) { func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -244,9 +300,7 @@ func TestDowngradeRejectsV2BusinessDataAtomically(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
database := openTestDatabase(t) database := openTestDatabase(t)
if err := migrations.Up(context.Background(), database, migrationDirectory(t)); err != nil { migrateToV2(t, database)
t.Fatalf("apply migrations: %v", err)
}
test.setup(t, database) test.setup(t, database)
before := v2RowCount(t, database) before := v2RowCount(t, database)
if err := migrations.Down(context.Background(), database, migrationDirectory(t)); err == nil { 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) 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) { func insertV1Task(t *testing.T, database *sql.DB, id, source, status, price string) {
t.Helper() 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 { 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) { func TestMigrationsDoNotDisableForeignKeys(t *testing.T) {
contents, err := os.ReadFile(filepath.Join(migrationDirectory(t), "00002_single_pass_model.sql")) for _, name := range []string{"00002_single_pass_model.sql", "00003_evidence_assets.sql"} {
if err != nil { contents, err := os.ReadFile(filepath.Join(migrationDirectory(t), name))
t.Fatalf("read migration: %v", err) if err != nil {
} t.Fatalf("read %s: %v", name, err)
if strings.Contains(strings.ToUpper(string(contents)), "PRAGMA FOREIGN_KEYS = OFF") { }
t.Fatal("migration disables foreign keys") if strings.Contains(strings.ToUpper(string(contents)), "PRAGMA FOREIGN_KEYS = OFF") {
t.Fatalf("%s disables foreign keys", name)
}
} }
} }
+193
View File
@@ -0,0 +1,193 @@
package server
import (
"errors"
"io"
"mime"
"mime/multipart"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
"cmbuyer/admin/internal/evidence"
"github.com/gin-gonic/gin"
)
const (
maxEvidenceRequestBytes = evidence.MaxFileBytes + 64<<10
maxEvidenceFieldBytes = 4 << 10
)
var evidenceFieldNames = map[string]struct{}{
"upload_key": {}, "attempt_id": {}, "kind": {}, "privacy_tier": {}, "sha256": {}, "captured_at": {},
}
func uploadEvidence(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
// Authentication deliberately precedes content-type parsing and every body read. A rejected
// device must not make the service spool or inspect a potentially sensitive upload.
principal, authenticated := options.DeviceAuthenticator.Authenticate(context.Request)
if !authenticated {
context.Status(http.StatusUnauthorized)
return
}
boundary, ok := multipartBoundary(context.GetHeader("Content-Type"))
if !ok {
context.Status(http.StatusUnsupportedMediaType)
return
}
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxEvidenceRequestBytes)
reader := multipart.NewReader(context.Request.Body, boundary)
fields := make(map[string]string, len(evidenceFieldNames))
var staged evidence.StagedFile
hasFile := false
discard := func() {
if hasFile {
options.Evidence.Discard(staged)
}
}
for {
part, err := reader.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
discard()
writeMultipartError(context, err)
return
}
name := part.FormName()
if name == "file" {
if hasFile || part.FileName() == "" || !exactPNGContentType(part.Header.Get("Content-Type")) {
_ = part.Close()
discard()
context.Status(http.StatusUnsupportedMediaType)
return
}
staged, err = options.Evidence.Stage(part, evidence.PNGContentType)
_ = part.Close()
if err != nil {
writeEvidenceStoreError(context, err)
return
}
hasFile = true
continue
}
if _, allowed := evidenceFieldNames[name]; !allowed || part.FileName() != "" {
_ = part.Close()
discard()
context.Status(http.StatusBadRequest)
return
}
if _, duplicate := fields[name]; duplicate {
_ = part.Close()
discard()
context.Status(http.StatusBadRequest)
return
}
value, err := io.ReadAll(io.LimitReader(part, maxEvidenceFieldBytes+1))
_ = part.Close()
if err != nil || len(value) == 0 || len(value) > maxEvidenceFieldBytes || !utf8.Valid(value) {
discard()
context.Status(http.StatusBadRequest)
return
}
fields[name] = string(value)
}
if !hasFile || len(fields) != len(evidenceFieldNames) {
discard()
context.Status(http.StatusBadRequest)
return
}
captured, err := time.Parse(time.RFC3339Nano, fields["captured_at"])
if err != nil || !strings.HasSuffix(fields["captured_at"], "Z") {
discard()
context.Status(http.StatusBadRequest)
return
}
asset, replayed, err := options.Evidence.Commit(context.Request.Context(), principal, evidence.UploadMetadata{
UploadKey: fields["upload_key"], TaskID: context.Param("id"), AttemptID: fields["attempt_id"],
Kind: fields["kind"], PrivacyTier: fields["privacy_tier"], SHA256: fields["sha256"], CapturedAt: captured.UTC(),
}, staged)
if err != nil {
writeEvidenceStoreError(context, err)
return
}
status := http.StatusCreated
if replayed {
status = http.StatusOK
}
context.JSON(status, asset)
}
}
func readEvidence(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
if !options.Sessions.IsAuthenticated(context.Request) {
context.Status(http.StatusUnauthorized)
return
}
asset, file, err := options.Evidence.Open(context.Request.Context(), context.Param("asset_id"))
if errors.Is(err, evidence.ErrNotFound) {
context.Status(http.StatusNotFound)
return
}
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
defer file.Close()
context.Header("Content-Type", evidence.PNGContentType)
context.Header("Content-Length", strconv.FormatInt(asset.ByteSize, 10))
context.Header("Content-Disposition", `inline; filename="evidence.png"`)
context.Header("Cache-Control", "no-store")
context.Header("X-Content-Type-Options", "nosniff")
context.Status(http.StatusOK)
if _, err := io.Copy(context.Writer, file); err != nil {
_ = context.Error(err)
}
}
}
func multipartBoundary(value string) (string, bool) {
mediaType, parameters, err := mime.ParseMediaType(value)
if err != nil || mediaType != "multipart/form-data" || len(parameters) != 1 || parameters["boundary"] == "" {
return "", false
}
return parameters["boundary"], true
}
func exactPNGContentType(value string) bool {
mediaType, parameters, err := mime.ParseMediaType(value)
return err == nil && mediaType == evidence.PNGContentType && len(parameters) == 0
}
func writeMultipartError(context *gin.Context, err error) {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
context.Status(http.StatusRequestEntityTooLarge)
return
}
context.Status(http.StatusBadRequest)
}
func writeEvidenceStoreError(context *gin.Context, err error) {
var tooLarge *http.MaxBytesError
switch {
case errors.As(err, &tooLarge):
context.Status(http.StatusRequestEntityTooLarge)
case errors.Is(err, evidence.ErrTooLarge):
context.Status(http.StatusRequestEntityTooLarge)
case errors.Is(err, evidence.ErrInvalid):
context.Status(http.StatusBadRequest)
case errors.Is(err, evidence.ErrConflict):
context.Status(http.StatusConflict)
default:
context.Status(http.StatusInternalServerError)
}
}
+290
View File
@@ -0,0 +1,290 @@
package server_test
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"image"
"image/png"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/textproto"
"path/filepath"
"runtime"
"strings"
"testing"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/migrations"
evidencestorage "cmbuyer/admin/internal/storage/evidence"
"cmbuyer/admin/internal/storage/sqlite"
)
const (
evidenceTaskID = "63c9f507-7473-4fa6-8d71-8786c34c6301"
evidenceAuthID = "73c9f507-7473-4fa6-8d71-8786c34c6301"
evidenceAttemptID = "83c9f507-7473-4fa6-8d71-8786c34c6301"
evidenceUploadKey = "93c9f507-7473-4fa6-8d71-8786c34c6301"
)
func TestEvidenceUploadAuthenticatesBeforeReadingBody(t *testing.T) {
authenticator := &fakeDeviceAuthenticator{}
router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, emptyEvidenceStore{}, authenticator)
poison := &poisonBody{}
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil)
request.Body = poison
request.Header.Set("Content-Type", "text/plain")
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized || poison.reads != 0 || authenticator.calls != 1 {
t.Fatalf("status/reads/auth calls = %d/%d/%d, want 401/0/1", response.Code, poison.reads, authenticator.calls)
}
assertSecurityHeaders(t, response)
}
func TestAdminSessionCannotActAsDeviceUploader(t *testing.T) {
router, _ := newRouter(t)
cookie := authenticate(t, router)
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+evidenceTaskID+"/evidence", nil)
request.Body = &poisonBody{}
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("admin upload status = %d, want 401", response.Code)
}
}
func TestEvidenceUploadReplayConflictAndProtectedRead(t *testing.T) {
router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{allowed: true, principal: evidence.DevicePrincipal{ID: "device-one"}})
pngBytes := serverTestPNG(t, 6, 4)
fields := validEvidenceFields(pngBytes)
first := serveEvidenceUpload(t, router, evidenceTaskID, fields, pngBytes, evidence.PNGContentType, `..\private\original.png`, nil)
if first.Code != http.StatusCreated {
t.Fatalf("first upload status/body = %d/%q", first.Code, first.Body.String())
}
var asset evidence.Asset
if err := json.Unmarshal(first.Body.Bytes(), &asset); err != nil {
t.Fatalf("decode upload response: %v", err)
}
if asset.TaskID != evidenceTaskID || asset.AttemptID != evidenceAttemptID || asset.SHA256 != fields["sha256"] || strings.Contains(first.Body.String(), "private") || strings.Contains(first.Body.String(), "original.png") {
t.Fatalf("unsafe upload response = %s", first.Body.String())
}
replay := serveEvidenceUpload(t, router, evidenceTaskID, fields, pngBytes, evidence.PNGContentType, "again.png", nil)
if replay.Code != http.StatusOK {
t.Fatalf("replay status = %d, want 200", replay.Code)
}
var replayed evidence.Asset
if err := json.Unmarshal(replay.Body.Bytes(), &replayed); err != nil || replayed.ID != asset.ID {
t.Fatalf("replay asset = %#v, err %v", replayed, err)
}
conflicting := copyStringMap(fields)
conflicting["captured_at"] = "2026-08-04T09:01:01Z"
if response := serveEvidenceUpload(t, router, evidenceTaskID, conflicting, pngBytes, evidence.PNGContentType, "same.png", nil); response.Code != http.StatusConflict {
t.Fatalf("conflicting replay status = %d, want 409", response.Code)
}
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 1 {
t.Fatalf("asset count = %d, err %v", count, err)
}
if response := serve(router, http.MethodGet, "/evidence/"+asset.ID, nil, nil); response.Code != http.StatusUnauthorized || response.Body.Len() != 0 {
t.Fatalf("anonymous read = %d/%q", response.Code, response.Body.String())
}
adminCookie := authenticate(t, router)
read := serve(router, http.MethodGet, "/evidence/"+asset.ID, nil, adminCookie)
if read.Code != http.StatusOK || !bytes.Equal(read.Body.Bytes(), pngBytes) {
t.Fatalf("admin read = %d, bytes equal %t", read.Code, bytes.Equal(read.Body.Bytes(), pngBytes))
}
for header, want := range map[string]string{"Content-Type": "image/png", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Content-Disposition": `inline; filename="evidence.png"`} {
if got := read.Header().Get(header); got != want {
t.Fatalf("%s = %q, want %q", header, got, want)
}
}
missing := serve(router, http.MethodGet, "/evidence/not-a-uuid", nil, adminCookie)
if missing.Code != http.StatusNotFound || missing.Body.Len() != 0 {
t.Fatalf("missing evidence = %d/%q", missing.Code, missing.Body.String())
}
}
func TestEvidenceUploadRejectsStrictMultipartViolations(t *testing.T) {
router, database := newEvidenceRouter(t, &fakeDeviceAuthenticator{allowed: true, principal: evidence.DevicePrincipal{ID: "device-one"}})
pngBytes := serverTestPNG(t, 2, 2)
base := validEvidenceFields(pngBytes)
wrongHash := copyStringMap(base)
wrongHash["sha256"] = strings.Repeat("b", 64)
uppercaseHash := copyStringMap(base)
uppercaseHash["sha256"] = strings.ToUpper(uppercaseHash["sha256"])
wrongPrivacy := copyStringMap(base)
wrongPrivacy["privacy_tier"] = "PUBLIC"
wrongKind := copyStringMap(base)
wrongKind["kind"] = "ORDER_CONFIRM"
tests := []struct {
name string
fields map[string]string
file []byte
contentType string
extra func(*multipart.Writer) error
want int
}{
{name: "attempt belongs to another task", fields: base, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "xml file", fields: base, file: []byte("<hierarchy/>"), contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "wrong hash", fields: wrongHash, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "uppercase hash", fields: uppercaseHash, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "wrong privacy", fields: wrongPrivacy, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "unapproved kind", fields: wrongKind, file: pngBytes, contentType: evidence.PNGContentType, want: http.StatusBadRequest},
{name: "too large", fields: base, file: make([]byte, evidence.MaxFileBytes+1), contentType: evidence.PNGContentType, want: http.StatusRequestEntityTooLarge},
{name: "wrong file content type", fields: base, file: pngBytes, contentType: "application/xml", want: http.StatusUnsupportedMediaType},
{name: "unknown path field", fields: base, file: pngBytes, contentType: evidence.PNGContentType, extra: func(writer *multipart.Writer) error { return writer.WriteField("path", `C:\secret.xml`) }, want: http.StatusBadRequest},
{name: "duplicate metadata", fields: base, file: pngBytes, contentType: evidence.PNGContentType, extra: func(writer *multipart.Writer) error { return writer.WriteField("sha256", base["sha256"]) }, want: http.StatusBadRequest},
{name: "second file", fields: base, file: pngBytes, contentType: evidence.PNGContentType, extra: func(writer *multipart.Writer) error {
part, err := writer.CreateFormFile("file", "second.png")
if err == nil {
_, err = part.Write(pngBytes)
}
return err
}, want: http.StatusUnsupportedMediaType},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
taskID := evidenceTaskID
if test.name == "attempt belongs to another task" {
taskID = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
}
response := serveEvidenceUpload(t, router, taskID, copyStringMap(test.fields), test.file, test.contentType, "file.png", test.extra)
if response.Code != test.want || response.Body.Len() != 0 {
t.Fatalf("status/body = %d/%q, want %d/empty", response.Code, response.Body.String(), test.want)
}
})
}
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 0 {
t.Fatalf("invalid requests created %d assets, err %v", count, err)
}
}
type fakeDeviceAuthenticator struct {
allowed bool
principal evidence.DevicePrincipal
calls int
}
func (authenticator *fakeDeviceAuthenticator) Authenticate(*http.Request) (evidence.DevicePrincipal, bool) {
authenticator.calls++
return authenticator.principal, authenticator.allowed
}
type poisonBody struct{ reads int }
func (body *poisonBody) Read([]byte) (int, error) {
body.reads++
return 0, io.ErrUnexpectedEOF
}
func (*poisonBody) Close() error { return nil }
func newEvidenceRouter(t *testing.T, authenticator evidence.DeviceAuthenticator) (http.Handler, *sql.DB) {
t.Helper()
database, err := sqlite.Open(filepath.Join(t.TempDir(), "server-evidence.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate migration directory")
}
if err := migrations.Up(context.Background(), database, filepath.Join(filepath.Dir(file), "..", "..", "migrations")); err != nil {
t.Fatalf("migrate database: %v", err)
}
insertEvidenceAttempt(t, database)
store, err := evidencestorage.NewStore(database, filepath.Join(t.TempDir(), "assets"))
if err != nil {
t.Fatalf("new evidence store: %v", err)
}
router, _ := newRouterWithDependencies(t, &memoryStore{}, emptyDetailStore{}, store, authenticator)
return router, database
}
func insertEvidenceAttempt(t *testing.T, database *sql.DB) {
t.Helper()
timestamp := "2026-08-04T00:00:00Z"
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'task', '123', 'black', 'M', 1, '1.00', 'DRAFT', 1, ?, ?)`, evidenceTaskID, timestamp, timestamp); err != nil {
t.Fatalf("insert task: %v", err)
}
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, 1, 'start', '123', 'black', 'M', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, evidenceAuthID, evidenceTaskID, timestamp, timestamp); err != nil {
t.Fatalf("insert authorization: %v", err)
}
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, evidenceAttemptID, evidenceTaskID, evidenceAuthID, timestamp); err != nil {
t.Fatalf("insert attempt: %v", err)
}
}
func serveEvidenceUpload(t *testing.T, router http.Handler, taskID string, fields map[string]string, file []byte, fileContentType, filename string, extra func(*multipart.Writer) error) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
for _, name := range []string{"upload_key", "attempt_id", "kind", "privacy_tier", "sha256", "captured_at"} {
if err := writer.WriteField(name, fields[name]); err != nil {
t.Fatalf("write field %s: %v", name, err)
}
}
header := make(textproto.MIMEHeader)
header.Set("Content-Disposition", `form-data; name="file"; filename="`+filename+`"`)
header.Set("Content-Type", fileContentType)
part, err := writer.CreatePart(header)
if err != nil {
t.Fatalf("create file part: %v", err)
}
if _, err := part.Write(file); err != nil {
t.Fatalf("write file: %v", err)
}
if extra != nil {
if err := extra(writer); err != nil {
t.Fatalf("write extra part: %v", err)
}
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart: %v", err)
}
request := httptest.NewRequest(http.MethodPost, "/api/v1/tasks/"+taskID+"/evidence", bytes.NewReader(body.Bytes()))
request.Header.Set("Content-Type", writer.FormDataContentType())
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
}
func validEvidenceFields(pngBytes []byte) map[string]string {
hash := sha256.Sum256(pngBytes)
return map[string]string{
"upload_key": evidenceUploadKey, "attempt_id": evidenceAttemptID,
"kind": evidence.KindSKUPanelGate1, "privacy_tier": evidence.PrivacyInternalRaw,
"sha256": hex.EncodeToString(hash[:]), "captured_at": "2026-08-04T09:01:00Z",
}
}
func serverTestPNG(t *testing.T, width, height int) []byte {
t.Helper()
var buffer bytes.Buffer
if err := png.Encode(&buffer, image.NewNRGBA(image.Rect(0, 0, width, height))); err != nil {
t.Fatalf("encode PNG: %v", err)
}
return buffer.Bytes()
}
func copyStringMap(values map[string]string) map[string]string {
copy := make(map[string]string, len(values))
for key, value := range values {
copy[key] = value
}
return copy
}
+151 -14
View File
@@ -2,13 +2,20 @@
package server package server
import ( import (
"bytes"
"crypto/subtle" "crypto/subtle"
"encoding/json"
"errors" "errors"
"io"
"mime"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"unicode/utf8"
"cmbuyer/admin/internal/auth" "cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks" "cmbuyer/admin/internal/tasks"
"cmbuyer/admin/internal/transport/webui" "cmbuyer/admin/internal/transport/webui"
@@ -17,6 +24,7 @@ import (
) )
const maxFormBytes = 8 << 10 const maxFormBytes = 8 << 10
const maxJSONBytes = 64 << 10
// Options 是路由层需要的安全依赖。凭据由启动配置注入,不能在路由中设置默认值。 // Options 是路由层需要的安全依赖。凭据由启动配置注入,不能在路由中设置默认值。
type Options struct { type Options struct {
@@ -24,11 +32,14 @@ type Options struct {
AdminPasswordBcrypt string AdminPasswordBcrypt string
Sessions *auth.Manager Sessions *auth.Manager
Tasks tasks.Store Tasks tasks.Store
TaskDetails taskdetail.Store
Evidence evidence.Store
DeviceAuthenticator evidence.DeviceAuthenticator
} }
// NewRouter 返回当前服务范围内的完整 HTTP 路由。 // NewRouter 返回当前服务范围内的完整 HTTP 路由。
func NewRouter(options Options) (*gin.Engine, error) { 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") return nil, errors.New("server authentication options are incomplete")
} }
@@ -40,12 +51,89 @@ func NewRouter(options Options) (*gin.Engine, error) {
router.POST("/login", login(options)) router.POST("/login", login(options))
router.POST("/logout", logout(options)) router.POST("/logout", logout(options))
router.GET("/tasks", tasksPage(options)) router.GET("/tasks", tasksPage(options))
router.GET("/tasks/:id", taskDetailPage(options))
router.GET("/tasks/new", newTaskPage(options)) router.GET("/tasks/new", newTaskPage(options))
router.POST("/tasks", createTask(options)) router.POST("/tasks", createTask(options))
router.POST("/tasks/start-purchases", startPurchases(options))
router.POST("/api/v1/tasks/:id/evidence", uploadEvidence(options))
router.GET("/evidence/:asset_id", readEvidence(options))
router.GET("/static/tasks.js", func(context *gin.Context) {
context.Data(http.StatusOK, "application/javascript; charset=utf-8", webui.TasksScript())
})
return router, nil return router, nil
} }
func startPurchases(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
if !options.Sessions.IsAuthenticated(context.Request) {
context.Status(http.StatusUnauthorized)
return
}
authenticated, csrfOK := options.Sessions.VerifyCSRF(context.Request, context.GetHeader("X-CSRF-Token"))
if !authenticated || !csrfOK {
context.Status(http.StatusForbidden)
return
}
if !isJSONContentType(context.GetHeader("Content-Type")) {
context.Status(http.StatusUnsupportedMediaType)
return
}
context.Request.Body = http.MaxBytesReader(context.Writer, context.Request.Body, maxJSONBytes)
raw, err := io.ReadAll(context.Request.Body)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
context.Status(http.StatusRequestEntityTooLarge)
} else {
context.Status(http.StatusBadRequest)
}
return
}
if !utf8.Valid(raw) {
context.Status(http.StatusBadRequest)
return
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
var command tasks.StartCommand
if err := decoder.Decode(&command); err != nil {
context.Status(http.StatusBadRequest)
return
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
context.Status(http.StatusBadRequest)
return
}
result, err := options.Tasks.StartPurchases(context.Request.Context(), command, options.AdminUsername)
if err != nil {
if errors.Is(err, tasks.ErrInvalidStart) {
context.Status(http.StatusBadRequest)
} else if errors.Is(err, tasks.ErrStartConflict) {
context.Status(http.StatusConflict)
} else {
context.Status(http.StatusInternalServerError)
}
return
}
context.JSON(http.StatusOK, result)
}
}
func isJSONContentType(value string) bool {
mediaType, parameters, err := mime.ParseMediaType(value)
if err != nil || mediaType != "application/json" {
return false
}
for name, value := range parameters {
if name != "charset" || !strings.EqualFold(value, "utf-8") {
return false
}
}
return true
}
func healthz(context *gin.Context) { func healthz(context *gin.Context) {
context.JSON(http.StatusOK, gin.H{"status": "ok"}) context.JSON(http.StatusOK, gin.H{"status": "ok"})
} }
@@ -55,7 +143,7 @@ func securityHeaders() gin.HandlerFunc {
context.Header("Cache-Control", "no-store") context.Header("Cache-Control", "no-store")
context.Header("X-Content-Type-Options", "nosniff") context.Header("X-Content-Type-Options", "nosniff")
context.Header("Referrer-Policy", "no-referrer") context.Header("Referrer-Policy", "no-referrer")
context.Header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'") context.Header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
context.Next() context.Next()
} }
} }
@@ -126,14 +214,23 @@ func tasksPage(options Options) gin.HandlerFunc {
return return
} }
drafts, err := options.Tasks.ListDrafts(context.Request.Context()) filter := tasks.TaskFilter{Keyword: context.Query("keyword"), Status: context.Query("status"), CreatedFrom: context.Query("created_from"), CreatedTo: context.Query("created_to")}
if validation := tasks.ValidateTaskFilter(filter); !validation.Valid() {
startKey, err := tasks.NewCreateKey()
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
renderTasks(context, http.StatusBadRequest, webui.TasksData{CSRFToken: csrfToken, Filter: filter, FilterErrors: validation, HasFilter: true, StartKey: startKey})
return
}
data, err := taskListData(context, options, csrfToken, filter)
if err != nil { if err != nil {
context.Status(http.StatusInternalServerError) context.Status(http.StatusInternalServerError)
return return
} }
data := webui.TasksData{CSRFToken: csrfToken, Drafts: drafts} for _, row := range data.Tasks {
for _, draft := range drafts { if row.ID == context.Query("created") {
if draft.ID == context.Query("created") {
data.Success = true data.Success = true
break break
} }
@@ -185,24 +282,32 @@ func createTask(options Options) gin.HandlerFunc {
} }
fullPage := requestForm.Get("form_mode") == "full" fullPage := requestForm.Get("form_mode") == "full"
if !validation.Valid() { if !validation.Valid() {
drafts, err := options.Tasks.ListDrafts(context.Request.Context()) data, ok := createErrorData(context, options, fullPage)
if err != nil { if !ok {
context.Status(http.StatusInternalServerError)
return return
} }
renderTasks(context, http.StatusBadRequest, webui.TasksData{CSRFToken: csrfFor(context, options), Drafts: drafts, Form: form, Errors: validation, OpenForm: !fullPage, FullPage: fullPage, FocusField: firstError(validation)}) data.Form = form
data.Errors = validation
data.OpenForm = !fullPage
data.FullPage = fullPage
data.FocusField = firstError(validation)
renderTasks(context, http.StatusBadRequest, data)
return return
} }
created, err := options.Tasks.CreateDraft(context.Request.Context(), draft) created, err := options.Tasks.CreateDraft(context.Request.Context(), draft)
if err != nil { if err != nil {
if errors.Is(err, tasks.ErrCreateKeyConflict) { if errors.Is(err, tasks.ErrCreateKeyConflict) {
validation["create_key"] = "该创建请求已用于另一条任务,请重新打开表单。" validation["create_key"] = "该创建请求已用于另一条任务,请重新打开表单。"
drafts, listErr := options.Tasks.ListDrafts(context.Request.Context()) data, ok := createErrorData(context, options, fullPage)
if listErr != nil { if !ok {
context.Status(http.StatusInternalServerError)
return return
} }
renderTasks(context, http.StatusConflict, webui.TasksData{CSRFToken: csrfFor(context, options), Drafts: drafts, Form: form, Errors: validation, OpenForm: !fullPage, FullPage: fullPage, FocusField: firstError(validation)}) data.Form = form
data.Errors = validation
data.OpenForm = !fullPage
data.FullPage = fullPage
data.FocusField = firstError(validation)
renderTasks(context, http.StatusConflict, data)
return return
} }
context.Status(http.StatusInternalServerError) context.Status(http.StatusInternalServerError)
@@ -226,6 +331,38 @@ func csrfFor(context *gin.Context, options Options) string {
csrf, _ := options.Sessions.Ensure(context.Writer, context.Request) csrf, _ := options.Sessions.Ensure(context.Writer, context.Request)
return csrf return csrf
} }
func taskListData(context *gin.Context, options Options, csrfToken string, filter tasks.TaskFilter) (webui.TasksData, error) {
rows, err := options.Tasks.ListTasks(context.Request.Context(), filter)
if err != nil {
return webui.TasksData{}, err
}
startKey, err := tasks.NewCreateKey()
if err != nil {
return webui.TasksData{}, err
}
return webui.TasksData{
CSRFToken: csrfToken,
Tasks: rows,
Filter: filter,
HasFilter: filter.Keyword != "" || filter.Status != "" || filter.CreatedFrom != "" || filter.CreatedTo != "",
StartKey: startKey,
}, nil
}
func createErrorData(context *gin.Context, options Options, fullPage bool) (webui.TasksData, bool) {
csrfToken := csrfFor(context, options)
if fullPage {
return webui.TasksData{CSRFToken: csrfToken}, true
}
data, err := taskListData(context, options, csrfToken, tasks.TaskFilter{})
if err != nil {
context.Status(http.StatusInternalServerError)
return webui.TasksData{}, false
}
return data, true
}
func renderTasks(context *gin.Context, status int, data webui.TasksData) { func renderTasks(context *gin.Context, status int, data webui.TasksData) {
context.Header("Content-Type", "text/html; charset=utf-8") context.Header("Content-Type", "text/html; charset=utf-8")
context.Status(status) context.Status(status)
+158 -5
View File
@@ -2,15 +2,19 @@ package server_test
import ( import (
"context" "context"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"regexp" "regexp"
"strings" "strings"
"testing" "testing"
"time"
"cmbuyer/admin/internal/auth" "cmbuyer/admin/internal/auth"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/server" "cmbuyer/admin/internal/server"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks" "cmbuyer/admin/internal/tasks"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -190,7 +194,7 @@ func TestTaskCreationRendersSharedFormsAndPersistsOnlyDraft(t *testing.T) {
if fullPage.Code != http.StatusOK { if fullPage.Code != http.StatusOK {
t.Fatalf("GET full form status = %d, want 200", fullPage.Code) t.Fatalf("GET full form status = %d, want 200", fullPage.Code)
} }
for _, want := range []string{`<div class="modal-scrim"`, `<dialog open`, `aria-modal="true"`, `name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `type="url" inputmode="url" maxlength="2048"`, `type="number" inputmode="numeric" min="1" step="1"`, `inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?"`, `maxlength="120"`, `maxlength="80"`, `required`, `autofocus`, `导入</button><a class="button primary"`, `type="search" disabled`, `disabled>筛选</button>`, `disabled>清除</button>`, `min-height:44px`, `overflow-x:auto`, `prefers-reduced-motion`} { for _, want := range []string{`<div class="modal-scrim"`, `<dialog open`, `aria-modal="true"`, `name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `type="url" inputmode="url" maxlength="2048"`, `type="number" inputmode="numeric" min="1" step="1"`, `inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?"`, `maxlength="120"`, `maxlength="80"`, `required`, `autofocus`, `导入</button><a class="button primary"`, `type="search"`, `data-start-purchases`, `data-select-all`, `最高总额`, `min-height:44px`, `:focus-visible`, `overflow-x:auto`, `prefers-reduced-motion`} {
if !strings.Contains(modal.Body.String(), want) { if !strings.Contains(modal.Body.String(), want) {
t.Fatalf("dialog form is missing %q", want) t.Fatalf("dialog form is missing %q", want)
} }
@@ -277,13 +281,112 @@ func TestTaskCreationRendersSharedFormsAndPersistsOnlyDraft(t *testing.T) {
t.Fatalf("task list is missing %q", want) t.Fatalf("task list is missing %q", want)
} }
} }
for _, forbidden := range []string{"utm_source", "试选", "PENDING", "支付", "订单确认", "真机", "提交订单"} { for _, forbidden := range []string{"utm_source", "试选", "订单确认", "真机", "提交订单"} {
if strings.Contains(body, forbidden) { if strings.Contains(body, forbidden) {
t.Fatalf("task list exposed deferred scope %q", forbidden) t.Fatalf("task list exposed deferred scope %q", forbidden)
} }
} }
} }
func TestTasksPageKeepsOriginalShellAndRendersFilteredWorkbench(t *testing.T) {
store := &memoryStore{rows: []tasks.TaskRow{
{ID: "b3c9f507-7473-4fa6-8d71-8786c34c6301", Title: "待开始衬衫", GoodsID: "937122477375", SKUColor: "黑色", SKUSize: "M", Quantity: 2, MaxTotalPrice: "12.80", Status: "DRAFT", Version: 3, CreatedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)},
{ID: "c3c9f507-7473-4fa6-8d71-8786c34c6301", Title: "等待领取衬衫", GoodsID: "958756616606", SKUColor: "白色", SKUSize: "L", Quantity: 1, MaxTotalPrice: "20.00", Status: "PENDING", Version: 4, CreatedAt: time.Date(2026, 8, 4, 2, 3, 4, 0, time.UTC)},
}}
router, _ := newRouterWithStore(t, store)
cookie := authenticate(t, router)
query := url.Values{"keyword": {"衬衫"}, "created_from": {"2026-08-04"}, "created_to": {"2026-08-04"}}
response := serve(router, http.MethodGet, "/tasks?"+query.Encode(), nil, cookie)
if response.Code != http.StatusOK {
t.Fatalf("filtered tasks status = %d, want 200", response.Code)
}
body := response.Body.String()
for _, want := range []string{
`<a class="skip" href="#main">`,
`:focus-visible`,
`min-height:44px`,
`@media(max-width:420px)`,
`prefers-reduced-motion`,
`<button class="button" type="button" disabled>导入</button><a class="button primary" href="/tasks?create=1">创建任务</a>`,
`name="keyword" type="search" value="衬衫"`,
`name="created_from" type="date" value="2026-08-04"`,
`name="created_to" type="date" value="2026-08-04"`,
`data-start-purchases`,
`data-selection-summary aria-live="polite"`,
`系统不会付款`,
`开始采购(只创建待付款订单)`,
`采购结果`,
`创建时间(上海)`,
`https://mobile.yangkeduo.com/goods.html?goods_id=937122477375`,
`target="_blank" rel="noopener noreferrer"`,
`data-task-row data-detail-url="/tasks/b3c9f507-7473-4fa6-8d71-8786c34c6301" tabindex="0"`,
`data-open-detail>查看详情</button>`,
`.detail-link-button{display:block;min-height:44px`,
`data-detail-drawer aria-modal="true"`,
`待开始`,
`已授权待领取`,
`datetime="2026-08-04T09:02:03&#43;08:00">2026-08-04 09:02`,
`<script src="/static/tasks.js" defer></script>`,
} {
if !strings.Contains(body, want) {
t.Fatalf("workbench is missing %q", want)
}
}
if strings.Index(body, `name="keyword"`) > strings.Index(body, `data-start-purchases`) || strings.Index(body, `data-start-purchases`) > strings.Index(body, `<div class="table-wrap">`) {
t.Fatal("workbench rows are not ordered as toolbar, filters, batch actions, table")
}
if count := strings.Count(body, `data-task-id=`); count != 1 {
t.Fatalf("selectable row count = %d, want only the DRAFT row", count)
}
for _, forbidden := range []string{`<th scope="col">操作</th>`, `确认开始采购`, `确认机器选对了吗`} {
if strings.Contains(body, forbidden) {
t.Fatalf("workbench exposed forbidden per-row or confirmation UI %q", forbidden)
}
}
if store.listTasksCalls != 1 || store.listDraftsCalls != 0 {
t.Fatalf("GET /tasks calls = (ListTasks %d, ListDrafts %d), want (1, 0)", store.listTasksCalls, store.listDraftsCalls)
}
}
func TestTasksPageRerendersAccessibleFilterErrorsAndKeepsValues(t *testing.T) {
store := &memoryStore{}
router, _ := newRouterWithStore(t, store)
cookie := authenticate(t, router)
query := url.Values{
"keyword": {`保留%_\`},
"status": {"UNKNOWN"},
"created_from": {"2026-02-30"},
"created_to": {"not-a-date"},
}
response := serve(router, http.MethodGet, "/tasks?"+query.Encode(), nil, cookie)
if response.Code != http.StatusBadRequest {
t.Fatalf("invalid filter status = %d, want 400", response.Code)
}
body := response.Body.String()
for _, want := range []string{
`role="alert" aria-live="assertive"`,
`href="#filter-status"`,
`href="#filter-created-from"`,
`href="#filter-created-to"`,
`name="keyword" type="search" value="保留%_\"`,
`<option value="UNKNOWN" selected>无效状态:UNKNOWN</option>`,
`name="created_from" type="date" value="2026-02-30" aria-invalid="true" aria-describedby="filter-created-from-error"`,
`name="created_to" type="date" value="not-a-date" aria-invalid="true" aria-describedby="filter-created-to-error"`,
`id="filter-status-error"`,
`id="filter-created-from-error"`,
`id="filter-created-to-error"`,
`筛选条件有误`,
} {
if !strings.Contains(body, want) {
t.Fatalf("invalid filter page is missing %q", want)
}
}
if store.listTasksCalls != 0 || store.listDraftsCalls != 0 {
t.Fatalf("invalid filter queried stores: ListTasks=%d ListDrafts=%d", store.listTasksCalls, store.listDraftsCalls)
}
assertSecurityHeaders(t, response)
}
func TestTaskCreationRequiresAuthenticationAndCSRF(t *testing.T) { func TestTaskCreationRequiresAuthenticationAndCSRF(t *testing.T) {
router, _ := newRouter(t) router, _ := newRouter(t)
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, nil); response.Code != http.StatusForbidden { if response := serve(router, http.MethodPost, "/tasks", url.Values{}, nil); response.Code != http.StatusForbidden {
@@ -327,7 +430,7 @@ func assertSecurityHeaders(t *testing.T, response *httptest.ResponseRecorder) {
"Cache-Control": "no-store", "Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer", "Referrer-Policy": "no-referrer",
"Content-Security-Policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", "Content-Security-Policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
} }
for name, expected := range want { for name, expected := range want {
if got := response.Header().Get(name); got != expected { if got := response.Header().Get(name); got != expected {
@@ -381,6 +484,14 @@ func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
} }
func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) { func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
return newRouterWithStore(t, &memoryStore{})
}
func newRouterWithStore(t *testing.T, store tasks.Store) (*gin.Engine, *auth.Manager) {
return newRouterWithDependencies(t, store, emptyDetailStore{}, emptyEvidenceStore{}, evidence.RejectAllDeviceAuthenticator{})
}
func newRouterWithDependencies(t *testing.T, store tasks.Store, details taskdetail.Store, evidenceStore evidence.Store, deviceAuthenticator evidence.DeviceAuthenticator) (*gin.Engine, *auth.Manager) {
t.Helper() t.Helper()
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost) hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
@@ -392,7 +503,10 @@ func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
AdminUsername: "admin", AdminUsername: "admin",
AdminPasswordBcrypt: string(hash), AdminPasswordBcrypt: string(hash),
Sessions: manager, Sessions: manager,
Tasks: &memoryStore{}, Tasks: store,
TaskDetails: details,
Evidence: evidenceStore,
DeviceAuthenticator: deviceAuthenticator,
}) })
if err != nil { if err != nil {
t.Fatalf("NewRouter: %v", err) t.Fatalf("NewRouter: %v", err)
@@ -400,7 +514,31 @@ func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
return router, manager return router, manager
} }
type memoryStore struct{ drafts []tasks.Draft } type emptyDetailStore struct{}
func (emptyDetailStore) Get(context.Context, string) (taskdetail.Detail, error) {
return taskdetail.Detail{}, taskdetail.ErrNotFound
}
type emptyEvidenceStore struct{}
func (emptyEvidenceStore) Stage(io.Reader, string) (evidence.StagedFile, error) {
return evidence.StagedFile{}, evidence.ErrInvalid
}
func (emptyEvidenceStore) Discard(evidence.StagedFile) {}
func (emptyEvidenceStore) Commit(context.Context, evidence.DevicePrincipal, evidence.UploadMetadata, evidence.StagedFile) (evidence.Asset, bool, error) {
return evidence.Asset{}, false, evidence.ErrInvalid
}
func (emptyEvidenceStore) Open(context.Context, string) (evidence.Asset, io.ReadSeekCloser, error) {
return evidence.Asset{}, nil, evidence.ErrNotFound
}
type memoryStore struct {
drafts []tasks.Draft
rows []tasks.TaskRow
listDraftsCalls int
listTasksCalls int
}
func (store *memoryStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) { func (store *memoryStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) {
for _, existing := range store.drafts { for _, existing := range store.drafts {
@@ -415,8 +553,23 @@ func (store *memoryStore) CreateDraft(_ context.Context, draft tasks.Draft) (tas
return draft, nil return draft, nil
} }
func (store *memoryStore) ListDrafts(_ context.Context) ([]tasks.Draft, error) { func (store *memoryStore) ListDrafts(_ context.Context) ([]tasks.Draft, error) {
store.listDraftsCalls++
return append([]tasks.Draft(nil), store.drafts...), nil return append([]tasks.Draft(nil), store.drafts...), nil
} }
func (store *memoryStore) ListTasks(_ context.Context, _ tasks.TaskFilter) ([]tasks.TaskRow, error) {
store.listTasksCalls++
if store.rows != nil {
return append([]tasks.TaskRow(nil), store.rows...), nil
}
result := make([]tasks.TaskRow, 0, len(store.drafts))
for _, draft := range store.drafts {
result = append(result, tasks.TaskRow{ID: draft.ID, Title: draft.Title, GoodsID: draft.GoodsID, SKUColor: draft.SKUColor, SKUSize: draft.SKUSize, Quantity: draft.Quantity, MaxTotalPrice: draft.MaxTotalPrice, Status: "DRAFT", Version: 1, CreatedAt: draft.CreatedAt})
}
return result, nil
}
func (store *memoryStore) StartPurchases(_ context.Context, _ tasks.StartCommand, _ string) (tasks.StartResult, error) {
return tasks.StartResult{}, tasks.ErrInvalidStart
}
func serve(router http.Handler, method, target string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder { func serve(router http.Handler, method, target string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
var body *strings.Reader var body *strings.Reader
@@ -0,0 +1,328 @@
package server_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"cmbuyer/admin/internal/tasks"
)
const (
startKeyForHTTP = "c3c9f507-7473-4fa6-8d71-8786c34c6301"
taskIDForHTTP = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
)
func TestStartPurchasesAuthenticatesBeforeInspectingRequestBody(t *testing.T) {
store := &startRecordingStore{}
router, _ := newRouterWithStore(t, store)
hugeMalformed := `{"start_key":"` + strings.Repeat("x", 70<<10)
for name, request := range map[string]*http.Request{
"anonymous malformed": newStartRequest(t, hugeMalformed, "text/plain", "", nil),
"device bearer": newStartRequest(t, validStartBody(), "application/json", "", nil),
} {
t.Run(name, func(t *testing.T) {
if name == "device bearer" {
request.Header.Set("Authorization", "Bearer device-token")
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", response.Code)
}
})
}
cookie, csrf := authenticatedStartSession(t, router)
for name, token := range map[string]string{"missing CSRF": "", "wrong CSRF": "wrong-csrf"} {
request := newStartRequest(t, hugeMalformed, "text/plain", token, cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("%s status = %d, want 403", name, response.Code)
}
}
if csrf == "" {
t.Fatal("authenticated page did not contain a CSRF token")
}
if store.startCalls != 0 {
t.Fatalf("unauthorized requests called store %d times", store.startCalls)
}
}
func TestStartPurchasesRejectsInvalidUTF8BeforeJSONDecoding(t *testing.T) {
validPrefix := []byte(`{"start_key":"` + startKeyForHTTP + `","tasks":[],"start_key":"`)
duplicateKeyBypass := append(append([]byte(nil), validPrefix...), 0xff)
duplicateKeyBypass = append(duplicateKeyBypass, []byte(`"}`)...)
invalidWhitespace := append([]byte(validStartBody()), 0xfe)
for name, body := range map[string][]byte{
"invalid byte after JSON": invalidWhitespace,
"invalid duplicate-key value": duplicateKeyBypass,
} {
t.Run(name, func(t *testing.T) {
store := &startRecordingStore{}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartBytes(t, router, body, "application/json", csrf, cookie)
if response.Code != http.StatusBadRequest || store.startCalls != 0 {
t.Fatalf("status/calls = %d/%d, want 400/0", response.Code, store.startCalls)
}
if response.Body.Len() != 0 {
t.Fatalf("invalid UTF-8 response leaked body %q", response.Body.String())
}
})
}
}
func TestStartPurchasesEnforcesExact64KiBBodyBoundary(t *testing.T) {
const limit = 64 << 10
base := validStartBody()
for name, test := range map[string]struct {
body string
want int
wantCalls int
}{
"exact limit": {body: base + strings.Repeat(" ", limit-len(base)), want: http.StatusOK, wantCalls: 1},
"one over": {body: base + strings.Repeat(" ", limit-len(base)+1), want: http.StatusRequestEntityTooLarge},
} {
t.Run(name, func(t *testing.T) {
store := &startRecordingStore{startResult: successfulStartResult()}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartRequest(t, router, test.body, "application/json", csrf, cookie)
if response.Code != test.want || store.startCalls != test.wantCalls {
t.Fatalf("status/calls = %d/%d, want %d/%d", response.Code, store.startCalls, test.want, test.wantCalls)
}
})
}
}
func TestStartPurchasesContentTypeContract(t *testing.T) {
for _, contentType := range []string{
"application/json",
"application/json; charset=utf-8",
"application/json;charset=UTF-8",
} {
t.Run("accept "+contentType, func(t *testing.T) {
store := &startRecordingStore{startResult: successfulStartResult()}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartRequest(t, router, validStartBody(), contentType, csrf, cookie)
if response.Code != http.StatusOK || store.startCalls != 1 {
t.Fatalf("status/calls = %d/%d, want 200/1", response.Code, store.startCalls)
}
})
}
for _, contentType := range []string{
"",
"text/plain",
"application/json-patch+json",
"application/json; charset=gbk",
"application/json; profile=unapproved",
"application/json; charset",
} {
t.Run("reject "+contentType, func(t *testing.T) {
store := &startRecordingStore{}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartRequest(t, router, validStartBody(), contentType, csrf, cookie)
if response.Code != http.StatusUnsupportedMediaType || store.startCalls != 0 {
t.Fatalf("status/calls = %d/%d, want 415/0", response.Code, store.startCalls)
}
})
}
}
func TestStartPurchasesRejectsMalformedAndOversizedJSON(t *testing.T) {
tests := []struct {
name string
body string
want int
storeErr error
wantCalls int
}{
{name: "empty", body: "", want: http.StatusBadRequest},
{name: "empty object", body: `{}`, want: http.StatusBadRequest, storeErr: tasks.ErrInvalidStart, wantCalls: 1},
{name: "null object", body: `null`, want: http.StatusBadRequest, storeErr: tasks.ErrInvalidStart, wantCalls: 1},
{name: "malformed", body: `{`, want: http.StatusBadRequest},
{name: "wrong top-level type", body: `[]`, want: http.StatusBadRequest},
{name: "unknown field", body: `{"start_key":"` + startKeyForHTTP + `","tasks":[],"created_by":"attacker"}`, want: http.StatusBadRequest},
{name: "wrong field type", body: `{"start_key":"` + startKeyForHTTP + `","tasks":[{"task_id":"` + taskIDForHTTP + `","expected_task_version":"1"}]}`, want: http.StatusBadRequest},
{name: "second JSON value", body: validStartBody() + `{}`, want: http.StatusBadRequest},
{name: "duplicate task ids", body: `{"start_key":"` + startKeyForHTTP + `","tasks":[{"task_id":"` + taskIDForHTTP + `","expected_task_version":1},{"task_id":"` + taskIDForHTTP + `","expected_task_version":1}]}`, want: http.StatusBadRequest, storeErr: tasks.ErrInvalidStart, wantCalls: 1},
{name: "oversized first value", body: `{"start_key":"` + strings.Repeat("x", 70<<10), want: http.StatusRequestEntityTooLarge},
{name: "oversized trailing whitespace", body: validStartBody() + strings.Repeat(" ", 70<<10), want: http.StatusRequestEntityTooLarge},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
store := &startRecordingStore{startErr: test.storeErr}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartRequest(t, router, test.body, "application/json", csrf, cookie)
if response.Code != test.want || store.startCalls != test.wantCalls {
t.Fatalf("status/calls = %d/%d, want %d/%d", response.Code, store.startCalls, test.want, test.wantCalls)
}
if response.Body.Len() != 0 {
t.Fatalf("error response leaked body %q", response.Body.String())
}
assertSecurityHeaders(t, response)
})
}
}
func TestStartPurchasesUsesAuthenticatedAdminAndReturnsStableSafeResult(t *testing.T) {
result := successfulStartResult()
store := &startRecordingStore{startResult: result}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
first := serveStartRequest(t, router, validStartBody(), "application/json; charset=utf-8", csrf, cookie)
second := serveStartRequest(t, router, validStartBody(), "application/json", csrf, cookie)
for index, response := range []*httptest.ResponseRecorder{first, second} {
if response.Code != http.StatusOK {
t.Fatalf("response %d status = %d, want 200", index, response.Code)
}
if got := response.Header().Get("Content-Type"); got != "application/json; charset=utf-8" {
t.Fatalf("response content type = %q", got)
}
var decoded tasks.StartResult
if err := json.Unmarshal(response.Body.Bytes(), &decoded); err != nil {
t.Fatalf("decode response: %v", err)
}
if decoded.PaymentAutomated || decoded.AuthorizedCount != 1 || decoded.Tasks[0].AuthorizationID != result.Tasks[0].AuthorizationID {
t.Fatalf("unsafe or unstable response = %#v", decoded)
}
assertSecurityHeaders(t, response)
}
if store.startCalls != 2 || len(store.createdBy) != 2 || store.createdBy[0] != "admin" || store.createdBy[1] != "admin" {
t.Fatalf("store calls/created_by = %d/%#v", store.startCalls, store.createdBy)
}
for _, command := range store.commands {
if command.StartKey != startKeyForHTTP || len(command.Tasks) != 1 || command.Tasks[0].TaskID != taskIDForHTTP || command.Tasks[0].ExpectedTaskVersion != 7 {
t.Fatalf("decoded command = %#v", command)
}
}
}
func TestStartPurchasesMapsStoreErrorsWithoutLeakingDetails(t *testing.T) {
for name, test := range map[string]struct {
err error
want int
}{
"invalid": {err: tasks.ErrInvalidStart, want: http.StatusBadRequest},
"conflict": {err: tasks.ErrStartConflict, want: http.StatusConflict},
"internal": {err: errors.New("sqlite secret path and query"), want: http.StatusInternalServerError},
} {
t.Run(name, func(t *testing.T) {
store := &startRecordingStore{startErr: test.err}
router, _ := newRouterWithStore(t, store)
cookie, csrf := authenticatedStartSession(t, router)
response := serveStartRequest(t, router, validStartBody(), "application/json", csrf, cookie)
if response.Code != test.want || store.startCalls != 1 {
t.Fatalf("status/calls = %d/%d, want %d/1", response.Code, store.startCalls, test.want)
}
if response.Body.Len() != 0 || strings.Contains(response.Body.String(), "sqlite") {
t.Fatalf("error leaked details: %q", response.Body.String())
}
})
}
}
type startRecordingStore struct {
startResult tasks.StartResult
startErr error
startCalls int
commands []tasks.StartCommand
createdBy []string
}
func (store *startRecordingStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) {
return draft, nil
}
func (store *startRecordingStore) ListDrafts(context.Context) ([]tasks.Draft, error) {
return nil, nil
}
func (store *startRecordingStore) ListTasks(context.Context, tasks.TaskFilter) ([]tasks.TaskRow, error) {
return nil, nil
}
func (store *startRecordingStore) StartPurchases(_ context.Context, command tasks.StartCommand, createdBy string) (tasks.StartResult, error) {
store.startCalls++
store.commands = append(store.commands, command)
store.createdBy = append(store.createdBy, createdBy)
return store.startResult, store.startErr
}
func authenticatedStartSession(t *testing.T, router http.Handler) (*http.Cookie, string) {
t.Helper()
cookie := authenticate(t, router)
page := serve(router, http.MethodGet, "/tasks", nil, cookie)
if page.Code != http.StatusOK {
t.Fatalf("GET /tasks status = %d", page.Code)
}
return cookie, csrfToken(t, page.Body.String())
}
func newStartRequest(t *testing.T, body, contentType, csrf string, cookie *http.Cookie) *http.Request {
t.Helper()
return newStartByteRequest(t, []byte(body), contentType, csrf, cookie)
}
func newStartByteRequest(t *testing.T, body []byte, contentType, csrf string, cookie *http.Cookie) *http.Request {
t.Helper()
request := httptest.NewRequest(http.MethodPost, "/tasks/start-purchases", bytes.NewReader(body))
if contentType != "" {
request.Header.Set("Content-Type", contentType)
}
if csrf != "" {
request.Header.Set("X-CSRF-Token", csrf)
}
if cookie != nil {
request.AddCookie(cookie)
}
return request
}
func serveStartBytes(t *testing.T, router http.Handler, body []byte, contentType, csrf string, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
response := httptest.NewRecorder()
router.ServeHTTP(response, newStartByteRequest(t, body, contentType, csrf, cookie))
return response
}
func serveStartRequest(t *testing.T, router http.Handler, body, contentType, csrf string, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
response := httptest.NewRecorder()
router.ServeHTTP(response, newStartRequest(t, body, contentType, csrf, cookie))
return response
}
func validStartBody() string {
return `{"start_key":"` + startKeyForHTTP + `","tasks":[{"task_id":"` + taskIDForHTTP + `","expected_task_version":7}]}`
}
func successfulStartResult() tasks.StartResult {
expires := time.Date(2026, 8, 4, 2, 3, 4, 0, time.UTC)
return tasks.StartResult{
StartKey: startKeyForHTTP,
AuthorizedCount: 1,
PaymentAutomated: false,
Tasks: []tasks.AuthorizedTask{{
TaskID: taskIDForHTTP,
TaskVersion: 8,
AuthorizationID: "d3c9f507-7473-4fa6-8d71-8786c34c6301",
ExpiresAt: expires,
}},
}
}
+84
View File
@@ -0,0 +1,84 @@
package server
import (
"errors"
"mime"
"net/http"
"net/url"
"strconv"
"strings"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/transport/webui"
"github.com/gin-gonic/gin"
)
const detailViewHeader = "X-CMBuyer-View"
const detailVaryHeader = "X-CMBuyer-View, Accept, Sec-Fetch-Site"
func taskDetailPage(options Options) gin.HandlerFunc {
return func(context *gin.Context) {
context.Header("Vary", detailVaryHeader)
if !options.Sessions.IsAuthenticated(context.Request) {
context.Redirect(http.StatusSeeOther, "/login?return_to="+url.QueryEscape(context.Request.URL.RequestURI()))
return
}
view := context.GetHeader(detailViewHeader)
if view != "" && view != "drawer" {
context.Status(http.StatusBadRequest)
return
}
if view == "drawer" {
if context.GetHeader("Sec-Fetch-Site") != "same-origin" {
context.Status(http.StatusForbidden)
return
}
if !acceptsHTML(context.GetHeader("Accept")) {
context.Status(http.StatusNotAcceptable)
return
}
}
detail, err := options.TaskDetails.Get(context.Request.Context(), context.Param("id"))
if errors.Is(err, taskdetail.ErrNotFound) {
context.Status(http.StatusNotFound)
return
}
if err != nil {
context.Status(http.StatusInternalServerError)
return
}
context.Header("Content-Type", "text/html; charset=utf-8")
context.Status(http.StatusOK)
data := webui.TaskDetailData{Detail: detail}
if view == "drawer" {
if err := webui.RenderTaskDetailFragment(context.Writer, data); err != nil {
_ = context.Error(err)
}
return
}
if err := webui.RenderTaskDetailPage(context.Writer, data); err != nil {
_ = context.Error(err)
}
}
}
func acceptsHTML(header string) bool {
for _, value := range strings.Split(header, ",") {
mediaType, parameters, err := mime.ParseMediaType(strings.TrimSpace(value))
if err != nil || !strings.EqualFold(mediaType, "text/html") {
continue
}
quality := 1.0
if rawQuality, exists := parameters["q"]; exists {
quality, err = strconv.ParseFloat(rawQuality, 64)
if err != nil || quality < 0 || quality > 1 {
continue
}
}
if quality > 0 {
return true
}
}
return false
}
+110
View File
@@ -0,0 +1,110 @@
package server_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/taskdetail"
)
const detailTaskID = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
func TestTaskDetailRequiresAdminBeforeLookup(t *testing.T) {
details := &recordingDetailStore{detail: taskDetailFixture()}
router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, evidence.RejectAllDeviceAuthenticator{})
response := serve(router, http.MethodGet, "/tasks/"+detailTaskID, nil, nil)
if response.Code != http.StatusSeeOther || !strings.HasPrefix(response.Header().Get("Location"), "/login?return_to=") || details.calls != 0 {
t.Fatalf("anonymous detail = %d/%q, calls=%d", response.Code, response.Header().Get("Location"), details.calls)
}
}
func TestTaskDetailFullPageAndDrawerShareAuditContent(t *testing.T) {
details := &recordingDetailStore{detail: taskDetailFixture()}
router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, evidence.RejectAllDeviceAuthenticator{})
cookie := authenticate(t, router)
full := serve(router, http.MethodGet, "/tasks/"+detailTaskID, nil, cookie)
if full.Code != http.StatusOK || !strings.Contains(full.Body.String(), "<!doctype html>") || !strings.Contains(full.Body.String(), `data-task-detail-content`) {
t.Fatalf("full detail = %d/%q", full.Code, full.Body.String())
}
request := httptest.NewRequest(http.MethodGet, "/tasks/"+detailTaskID, nil)
request.AddCookie(cookie)
request.Header.Set("X-CMBuyer-View", "drawer")
request.Header.Set("Accept", "text/html")
request.Header.Set("Sec-Fetch-Site", "same-origin")
fragment := httptest.NewRecorder()
router.ServeHTTP(fragment, request)
if fragment.Code != http.StatusOK || strings.Contains(fragment.Body.String(), "<!doctype html>") || !strings.Contains(fragment.Body.String(), `data-task-detail-content`) {
t.Fatalf("fragment detail = %d/%q", fragment.Code, fragment.Body.String())
}
for _, text := range []string{"测试&lt;script&gt;", "订单已创建,系统尚未付款", "SKU_PANEL_GATE_1", "/evidence/b3c9f507-7473-4fa6-8d71-8786c34c6301", "暂无规格、价格或数量读数", "本页没有重试、再次提交或付款动作"} {
if !strings.Contains(full.Body.String(), text) || !strings.Contains(fragment.Body.String(), text) {
t.Fatalf("shared detail missing %q", text)
}
}
if strings.Contains(full.Body.String(), "<script>") || strings.Contains(fragment.Body.String(), "<script>") {
t.Fatal("task title was not HTML escaped")
}
if got := fragment.Header().Get("Vary"); got != "X-CMBuyer-View, Accept, Sec-Fetch-Site" {
t.Fatalf("fragment Vary = %q", got)
}
if details.calls != 2 {
t.Fatalf("detail store calls = %d, want 2", details.calls)
}
}
func TestTaskDetailRejectsForgedFragmentAndMissingTask(t *testing.T) {
details := &recordingDetailStore{err: taskdetail.ErrNotFound}
router, _ := newRouterWithDependencies(t, &memoryStore{}, details, emptyEvidenceStore{}, evidence.RejectAllDeviceAuthenticator{})
cookie := authenticate(t, router)
for name, headers := range map[string]map[string]string{
"unknown view": {"X-CMBuyer-View": "xml", "Accept": "text/html"},
"missing fetch site": {"X-CMBuyer-View": "drawer", "Accept": "text/html"},
"cross-site drawer": {"X-CMBuyer-View": "drawer", "Accept": "text/html", "Sec-Fetch-Site": "cross-site"},
"wrong accept": {"X-CMBuyer-View": "drawer", "Accept": "application/json", "Sec-Fetch-Site": "same-origin"},
"html quality zero": {"X-CMBuyer-View": "drawer", "Accept": "text/html;q=0, application/json", "Sec-Fetch-Site": "same-origin"},
"html substring mime": {"X-CMBuyer-View": "drawer", "Accept": "application/nottext/html", "Sec-Fetch-Site": "same-origin"},
} {
t.Run(name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/tasks/"+detailTaskID, nil)
request.AddCookie(cookie)
for key, value := range headers {
request.Header.Set(key, value)
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code < 400 || response.Code >= 500 || response.Body.Len() != 0 {
t.Fatalf("forged fragment = %d/%q", response.Code, response.Body.String())
}
})
}
missing := serve(router, http.MethodGet, "/tasks/not-a-uuid", nil, cookie)
if missing.Code != http.StatusNotFound || missing.Body.Len() != 0 {
t.Fatalf("missing detail = %d/%q", missing.Code, missing.Body.String())
}
}
type recordingDetailStore struct {
detail taskdetail.Detail
err error
calls int
}
func (store *recordingDetailStore) Get(context.Context, string) (taskdetail.Detail, error) {
store.calls++
return store.detail, store.err
}
func taskDetailFixture() taskdetail.Detail {
started := time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)
return taskdetail.Detail{
Task: taskdetail.Task{ID: detailTaskID, Source: "MANUAL", Title: "测试<script>", GoodsID: "937122477375", SKUColor: "黑色", SKUSize: "M", Quantity: 2, MaxTotalPrice: "30.00", Status: "WAITING_PAYMENT", Version: 3, CreatedAt: started, UpdatedAt: started},
Authorizations: []taskdetail.Authorization{{ID: "c3c9f507-7473-4fa6-8d71-8786c34c6301", Status: "FENCED", CreatedBy: "admin", TotalPriceCap: "30.00", TaskVersion: 2, CreatedAt: started, ExpiresAt: started.Add(time.Hour)}},
Attempts: []taskdetail.Attempt{{ID: "d3c9f507-7473-4fa6-8d71-8786c34c6301", AuthorizationID: "c3c9f507-7473-4fa6-8d71-8786c34c6301", Status: "CLAIMED", ClaimGeneration: 1, StartedAt: started}},
Evidence: []taskdetail.Evidence{{ID: "b3c9f507-7473-4fa6-8d71-8786c34c6301", AttemptID: "d3c9f507-7473-4fa6-8d71-8786c34c6301", Kind: "SKU_PANEL_GATE_1", PrivacyTier: "INTERNAL_RAW", SHA256: strings.Repeat("a", 64), ByteSize: 100, ContentType: "image/png", Width: 100, Height: 200, CapturedAt: started}},
}
}
@@ -0,0 +1,20 @@
//go:build !windows
package evidence
import (
"fmt"
"os"
)
func syncDirectory(path string) error {
directory, err := os.Open(path)
if err != nil {
return fmt.Errorf("open directory for durability sync: %w", err)
}
defer directory.Close()
if err := directory.Sync(); err != nil {
return fmt.Errorf("sync directory metadata: %w", err)
}
return nil
}
@@ -0,0 +1,35 @@
//go:build windows
package evidence
import (
"fmt"
"syscall"
)
// syncDirectory uses an explicit directory handle because os.Open(...).Sync is not a portable
// Windows directory durability boundary. Any unsupported filesystem or access failure is fatal:
// callers must not make the corresponding evidence row visible in SQLite.
func syncDirectory(path string) error {
pathPointer, err := syscall.UTF16PtrFromString(path)
if err != nil {
return fmt.Errorf("encode directory path for durability sync: %w", err)
}
handle, err := syscall.CreateFile(
pathPointer,
syscall.GENERIC_WRITE,
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE,
nil,
syscall.OPEN_EXISTING,
syscall.FILE_FLAG_BACKUP_SEMANTICS,
0,
)
if err != nil {
return fmt.Errorf("open directory for durability sync: %w", err)
}
defer syscall.CloseHandle(handle)
if err := syscall.FlushFileBuffers(handle); err != nil {
return fmt.Errorf("flush directory metadata: %w", err)
}
return nil
}
+553
View File
@@ -0,0 +1,553 @@
// Package evidence stores INTERNAL_RAW PNG assets outside the public web tree.
package evidence
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"image/png"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"unicode"
core "cmbuyer/admin/internal/evidence"
)
var pngSignature = []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}
type Store struct {
database *sql.DB
root string
now func() time.Time
random io.Reader
syncDirectory func(string) error
syncFile func(*os.File) error
renameFile func(string, string) error
commitTx func(*sql.Tx) error
mu sync.Mutex
}
func NewStore(database *sql.DB, root string) (*Store, error) {
return newStore(database, root, syncDirectory)
}
func newStore(database *sql.DB, root string, directorySync func(string) error) (*Store, error) {
if database == nil {
return nil, errors.New("evidence database is required")
}
if directorySync == nil {
return nil, errors.New("evidence directory sync is required")
}
if root == "" || !filepath.IsAbs(root) {
return nil, errors.New("evidence root must be an absolute path")
}
absolute, err := filepath.Abs(filepath.Clean(root))
if err != nil {
return nil, fmt.Errorf("resolve evidence root: %w", err)
}
if filepath.Dir(absolute) == absolute {
return nil, errors.New("evidence root cannot be a filesystem root")
}
if err := ensureDurableDirectory(absolute, 0o700, directorySync); err != nil {
return nil, fmt.Errorf("create evidence root: %w", err)
}
// A prior startup may have created the root and then failed its parent sync.
// Existence is therefore never accepted as proof that the directory entry is durable.
if err := directorySync(filepath.Dir(absolute)); err != nil {
return nil, fmt.Errorf("persist evidence root directory: %w", err)
}
if err := os.Chmod(absolute, 0o700); err != nil {
return nil, fmt.Errorf("protect evidence root: %w", err)
}
staging := filepath.Join(absolute, ".staging")
if err := ensureDurableDirectory(staging, 0o700, directorySync); err != nil {
return nil, fmt.Errorf("create evidence staging directory: %w", err)
}
if err := directorySync(absolute); err != nil {
return nil, fmt.Errorf("persist evidence staging directory: %w", err)
}
if err := os.Chmod(staging, 0o700); err != nil {
return nil, fmt.Errorf("protect evidence staging directory: %w", err)
}
if _, err := database.Exec("SELECT storage_key FROM evidence_assets LIMIT 1"); err != nil {
return nil, fmt.Errorf("evidence migration is not available: %w", err)
}
return &Store{
database: database,
root: absolute,
now: time.Now,
random: rand.Reader,
syncDirectory: directorySync,
syncFile: func(file *os.File) error { return file.Sync() },
renameFile: os.Rename,
commitTx: func(transaction *sql.Tx) error { return transaction.Commit() },
}, nil
}
func (store *Store) Stage(reader io.Reader, contentType string) (staged core.StagedFile, resultErr error) {
if reader == nil || contentType != core.PNGContentType {
return core.StagedFile{}, core.ErrInvalid
}
temporary, err := os.CreateTemp(filepath.Join(store.root, ".staging"), "upload-*.png")
if err != nil {
return core.StagedFile{}, err
}
staged.Path = temporary.Name()
defer func() {
if resultErr != nil {
_ = temporary.Close()
_ = os.Remove(staged.Path)
}
}()
if err := temporary.Chmod(0o600); err != nil {
return core.StagedFile{}, err
}
hasher := sha256.New()
written, err := io.Copy(io.MultiWriter(temporary, hasher), io.LimitReader(reader, core.MaxFileBytes+1))
if err != nil {
return core.StagedFile{}, err
}
if written > core.MaxFileBytes {
return core.StagedFile{}, core.ErrTooLarge
}
if written == 0 {
return core.StagedFile{}, core.ErrInvalid
}
if err := temporary.Sync(); err != nil {
return core.StagedFile{}, err
}
if err := temporary.Close(); err != nil {
return core.StagedFile{}, err
}
imageFile, err := os.Open(staged.Path)
if err != nil {
return core.StagedFile{}, err
}
defer imageFile.Close()
width, height, err := validatePNG(imageFile)
if err != nil {
return core.StagedFile{}, err
}
staged.SHA256 = hex.EncodeToString(hasher.Sum(nil))
staged.ByteSize = written
staged.ContentType = core.PNGContentType
staged.Width = width
staged.Height = height
return staged, nil
}
func (store *Store) Discard(staged core.StagedFile) {
if store.isStagedPath(staged.Path) {
_ = os.Remove(staged.Path)
}
}
func (store *Store) Commit(ctx context.Context, principal core.DevicePrincipal, metadata core.UploadMetadata, staged core.StagedFile) (core.Asset, bool, error) {
if !store.isStagedPath(staged.Path) || !validPrincipal(principal) || !validMetadata(metadata) || metadata.SHA256 != staged.SHA256 || staged.ContentType != core.PNGContentType || staged.ByteSize < 1 || staged.ByteSize > core.MaxFileBytes || staged.Width < 1 || staged.Height < 1 || staged.Width > core.MaxImageSide || staged.Height > core.MaxImageSide || int64(staged.Width)*int64(staged.Height) > core.MaxImagePixels {
store.Discard(staged)
return core.Asset{}, false, core.ErrInvalid
}
defer store.Discard(staged)
store.mu.Lock()
defer store.mu.Unlock()
transaction, err := store.database.BeginTx(ctx, nil)
if err != nil {
return core.Asset{}, false, err
}
defer transaction.Rollback()
existing, found, err := findByUploadKey(ctx, transaction, principal.ID, metadata.UploadKey)
if err != nil {
return core.Asset{}, false, err
}
if found {
if !sameUpload(existing, principal, metadata, staged) {
return core.Asset{}, false, core.ErrConflict
}
if err := store.verifyStoredFile(existing); err != nil {
return core.Asset{}, false, err
}
if err := store.commitTx(transaction); err != nil {
return core.Asset{}, false, err
}
return existing, true, nil
}
var attemptCount int
if err := transaction.QueryRowContext(ctx, "SELECT COUNT(*) FROM purchase_attempts WHERE task_id = ? AND id = ?", metadata.TaskID, metadata.AttemptID).Scan(&attemptCount); err != nil {
return core.Asset{}, false, err
}
if attemptCount != 1 {
return core.Asset{}, false, core.ErrInvalid
}
storageKey := storageKey(metadata.SHA256)
finalPath, err := store.pathForKey(storageKey)
if err != nil {
return core.Asset{}, false, err
}
finalDirectory := filepath.Dir(finalPath)
if err := ensureDurableDirectory(finalDirectory, 0o700, store.syncDirectory); err != nil {
return core.Asset{}, false, err
}
// Always repeat the shard-parent boundary. If an earlier attempt created this
// directory and its parent sync failed, a retry must not trust mere existence.
if err := store.syncDirectory(store.root); err != nil {
return core.Asset{}, false, fmt.Errorf("persist evidence shard directory: %w", err)
}
if err := os.Chmod(finalDirectory, 0o700); err != nil {
return core.Asset{}, false, err
}
if info, statErr := os.Stat(finalPath); statErr == nil {
if !info.Mode().IsRegular() || info.Size() != staged.ByteSize || fileSHA256(finalPath) != staged.SHA256 {
return core.Asset{}, false, errors.New("stored evidence content does not match its key")
}
} else if !errors.Is(statErr, os.ErrNotExist) {
return core.Asset{}, false, statErr
} else {
publishPath, err := store.preparePublishFile(staged, finalDirectory)
if err != nil {
return core.Asset{}, false, err
}
defer os.Remove(publishPath)
if err := store.renameFile(publishPath, finalPath); err != nil {
return core.Asset{}, false, err
}
}
// The publication file was fsynced in this shard before its same-directory rename.
// Persist the final directory entry before SQLite can expose a referencing row.
// A directory sync failure is deliberately fatal; the unreachable file may remain
// as an orphan, but no evidence_assets row may be committed for it.
if err := store.syncDirectory(finalDirectory); err != nil {
return core.Asset{}, false, fmt.Errorf("persist evidence directory entry: %w", err)
}
id, err := newUUID(store.random)
if err != nil {
return core.Asset{}, false, err
}
now := store.now().UTC()
asset := core.Asset{
ID: id, TaskID: metadata.TaskID, AttemptID: metadata.AttemptID,
Kind: metadata.Kind, PrivacyTier: metadata.PrivacyTier, SHA256: staged.SHA256,
ByteSize: staged.ByteSize, ContentType: staged.ContentType, Width: staged.Width, Height: staged.Height,
CapturedAt: metadata.CapturedAt.UTC(), UploadedByDeviceID: principal.ID,
StorageKey: storageKey, CreatedAt: now,
}
_, err = transaction.ExecContext(ctx, `INSERT INTO evidence_assets
(id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
asset.ID, metadata.UploadKey, asset.TaskID, asset.AttemptID, asset.Kind, asset.PrivacyTier,
asset.SHA256, asset.ByteSize, asset.ContentType, asset.Width, asset.Height, asset.StorageKey,
asset.UploadedByDeviceID, asset.CapturedAt.Format(time.RFC3339Nano), asset.CreatedAt.Format(time.RFC3339Nano))
if err != nil {
return core.Asset{}, false, err
}
if err := store.commitTx(transaction); err != nil {
return core.Asset{}, false, err
}
return asset, false, nil
}
func (store *Store) Open(ctx context.Context, id string) (core.Asset, io.ReadSeekCloser, error) {
if !validUUID(id) {
return core.Asset{}, nil, core.ErrNotFound
}
asset, found, err := findByID(ctx, store.database, id)
if err != nil {
return core.Asset{}, nil, err
}
if !found || asset.StorageKey != storageKey(asset.SHA256) {
return core.Asset{}, nil, core.ErrNotFound
}
path, err := store.pathForKey(asset.StorageKey)
if err != nil {
return core.Asset{}, nil, core.ErrNotFound
}
file, err := os.Open(path)
if errors.Is(err, os.ErrNotExist) {
return core.Asset{}, nil, core.ErrNotFound
}
if err != nil {
return core.Asset{}, nil, err
}
info, err := file.Stat()
if err != nil || !info.Mode().IsRegular() || info.Size() != asset.ByteSize {
_ = file.Close()
if err != nil {
return core.Asset{}, nil, err
}
return core.Asset{}, nil, core.ErrNotFound
}
return asset, file, nil
}
func (store *Store) verifyStoredFile(asset core.Asset) error {
path, err := store.pathForKey(asset.StorageKey)
if err != nil {
return err
}
info, err := os.Stat(path)
if err != nil || !info.Mode().IsRegular() || info.Size() != asset.ByteSize || fileSHA256(path) != asset.SHA256 {
if err != nil {
return err
}
return errors.New("stored evidence file is invalid")
}
return nil
}
func (store *Store) isStagedPath(path string) bool {
if path == "" {
return false
}
relative, err := filepath.Rel(filepath.Join(store.root, ".staging"), filepath.Clean(path))
return err == nil && relative != "." && relative != "" && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) && !filepath.IsAbs(relative)
}
func (store *Store) pathForKey(key string) (string, error) {
path := filepath.Join(store.root, filepath.FromSlash(key))
relative, err := filepath.Rel(store.root, path)
if err != nil || relative == "." || relative == "" || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
return "", errors.New("invalid evidence storage key")
}
return path, nil
}
func (store *Store) preparePublishFile(staged core.StagedFile, directory string) (path string, resultErr error) {
source, err := os.Open(staged.Path)
if err != nil {
return "", err
}
defer source.Close()
temporary, err := os.CreateTemp(directory, ".publish-*.png")
if err != nil {
return "", err
}
temporaryPath := temporary.Name()
path = temporaryPath
defer func() {
if resultErr != nil {
_ = temporary.Close()
_ = os.Remove(temporaryPath)
}
}()
if err := temporary.Chmod(0o600); err != nil {
return "", err
}
hasher := sha256.New()
written, err := io.Copy(io.MultiWriter(temporary, hasher), source)
if err != nil {
return "", err
}
if written != staged.ByteSize || hex.EncodeToString(hasher.Sum(nil)) != staged.SHA256 {
return "", errors.New("staged evidence changed before publication")
}
width, height, err := validatePNG(temporary)
if err != nil {
return "", err
}
if width != staged.Width || height != staged.Height {
return "", errors.New("staged evidence dimensions changed before publication")
}
if err := store.syncFile(temporary); err != nil {
return "", fmt.Errorf("sync evidence publication file: %w", err)
}
if err := temporary.Close(); err != nil {
return "", err
}
return path, nil
}
func validatePNG(reader io.ReadSeeker) (int, int, error) {
if _, err := reader.Seek(0, io.SeekStart); err != nil {
return 0, 0, err
}
signature := make([]byte, len(pngSignature))
if _, err := io.ReadFull(reader, signature); err != nil || string(signature) != string(pngSignature) {
return 0, 0, core.ErrInvalid
}
if _, err := reader.Seek(0, io.SeekStart); err != nil {
return 0, 0, err
}
configuration, err := png.DecodeConfig(reader)
if err != nil || configuration.Width < 1 || configuration.Height < 1 || configuration.Width > core.MaxImageSide || configuration.Height > core.MaxImageSide || int64(configuration.Width)*int64(configuration.Height) > core.MaxImagePixels {
return 0, 0, core.ErrInvalid
}
if _, err := reader.Seek(0, io.SeekStart); err != nil {
return 0, 0, err
}
if _, err := png.Decode(reader); err != nil {
return 0, 0, core.ErrInvalid
}
var trailing [1]byte
if count, err := reader.Read(trailing[:]); count != 0 || !errors.Is(err, io.EOF) {
return 0, 0, core.ErrInvalid
}
return configuration.Width, configuration.Height, nil
}
func ensureDurableDirectory(path string, mode os.FileMode, syncParent func(string) error) error {
info, err := os.Stat(path)
if err == nil {
if !info.IsDir() {
return fmt.Errorf("path exists but is not a directory: %s", path)
}
return nil
}
if !errors.Is(err, os.ErrNotExist) {
return err
}
parent := filepath.Dir(path)
if parent == path {
return fmt.Errorf("cannot create filesystem root as a managed directory: %s", path)
}
if err := ensureDurableDirectory(parent, mode, syncParent); err != nil {
return err
}
if err := os.Mkdir(path, mode); err != nil && !errors.Is(err, os.ErrExist) {
return err
}
info, err = os.Stat(path)
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("path exists but is not a directory: %s", path)
}
if err := os.Chmod(path, mode); err != nil {
return err
}
// Syncing the parent makes creation of this directory durable. This also covers
// a concurrent creator: returning success without the parent sync could otherwise
// allow the following database transaction to outrun the directory entry.
if err := syncParent(parent); err != nil {
return fmt.Errorf("persist directory creation for %s: %w", path, err)
}
return nil
}
func storageKey(hash string) string { return hash[:2] + "/" + hash + ".png" }
func validMetadata(metadata core.UploadMetadata) bool {
return validUUID(metadata.UploadKey) && validUUID(metadata.TaskID) && validUUID(metadata.AttemptID) && metadata.Kind == core.KindSKUPanelGate1 && metadata.PrivacyTier == core.PrivacyInternalRaw && validSHA256(metadata.SHA256) && !metadata.CapturedAt.IsZero() && metadata.CapturedAt.Location() == time.UTC
}
func validPrincipal(principal core.DevicePrincipal) bool {
if principal.ID == "" || strings.TrimSpace(principal.ID) != principal.ID || len(principal.ID) > 128 {
return false
}
for _, character := range principal.ID {
if unicode.IsControl(character) {
return false
}
}
return true
}
func validSHA256(value string) bool {
if len(value) != 64 {
return false
}
for _, character := range value {
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
return false
}
}
return true
}
func validUUID(value string) bool {
if len(value) != 36 {
return false
}
for index, character := range value {
if index == 8 || index == 13 || index == 18 || index == 23 {
if character != '-' {
return false
}
continue
}
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
return false
}
}
return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b')
}
func newUUID(reader io.Reader) (string, error) {
bytes := make([]byte, 16)
if _, err := io.ReadFull(reader, bytes); err != nil {
return "", err
}
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
encoded := hex.EncodeToString(bytes)
return encoded[:8] + "-" + encoded[8:12] + "-" + encoded[12:16] + "-" + encoded[16:20] + "-" + encoded[20:], nil
}
func fileSHA256(path string) string {
file, err := os.Open(path)
if err != nil {
return ""
}
defer file.Close()
hasher := sha256.New()
if _, err := io.Copy(hasher, file); err != nil {
return ""
}
return hex.EncodeToString(hasher.Sum(nil))
}
type rowScanner interface{ Scan(...any) error }
func findByUploadKey(ctx context.Context, query interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}, deviceID, uploadKey string) (core.Asset, bool, error) {
return scanAsset(query.QueryRowContext(ctx, `SELECT id, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at FROM evidence_assets WHERE uploaded_by_device_id = ? AND upload_key = ?`, deviceID, uploadKey))
}
func findByID(ctx context.Context, query interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}, id string) (core.Asset, bool, error) {
return scanAsset(query.QueryRowContext(ctx, `SELECT id, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at FROM evidence_assets WHERE id = ?`, id))
}
func scanAsset(row rowScanner) (core.Asset, bool, error) {
var asset core.Asset
var captured, created string
err := row.Scan(&asset.ID, &asset.TaskID, &asset.AttemptID, &asset.Kind, &asset.PrivacyTier, &asset.SHA256, &asset.ByteSize, &asset.ContentType, &asset.Width, &asset.Height, &asset.StorageKey, &asset.UploadedByDeviceID, &captured, &created)
if errors.Is(err, sql.ErrNoRows) {
return core.Asset{}, false, nil
}
if err != nil {
return core.Asset{}, false, err
}
asset.CapturedAt, err = time.Parse(time.RFC3339Nano, captured)
if err != nil {
return core.Asset{}, false, err
}
asset.CreatedAt, err = time.Parse(time.RFC3339Nano, created)
if err != nil {
return core.Asset{}, false, err
}
return asset, true, nil
}
func sameUpload(asset core.Asset, principal core.DevicePrincipal, metadata core.UploadMetadata, staged core.StagedFile) bool {
return asset.TaskID == metadata.TaskID && asset.AttemptID == metadata.AttemptID && asset.Kind == metadata.Kind && asset.PrivacyTier == metadata.PrivacyTier && asset.SHA256 == metadata.SHA256 && asset.ByteSize == staged.ByteSize && asset.ContentType == staged.ContentType && asset.Width == staged.Width && asset.Height == staged.Height && asset.UploadedByDeviceID == principal.ID && asset.CapturedAt.Equal(metadata.CapturedAt)
}
@@ -0,0 +1,552 @@
package evidence
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"image"
"image/color"
"image/png"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
core "cmbuyer/admin/internal/evidence"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
)
const (
testTaskID = "13c9f507-7473-4fa6-8d71-8786c34c6301"
testAuthID = "23c9f507-7473-4fa6-8d71-8786c34c6301"
testAttemptID = "33c9f507-7473-4fa6-8d71-8786c34c6301"
testUploadKey = "43c9f507-7473-4fa6-8d71-8786c34c6301"
)
func TestStageCommitReplayAndOpen(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 8, 6)
hash := sha256Hex(pngBytes)
metadata := core.UploadMetadata{UploadKey: testUploadKey, TaskID: testTaskID, AttemptID: testAttemptID, Kind: core.KindSKUPanelGate1, PrivacyTier: core.PrivacyInternalRaw, SHA256: hash, CapturedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)}
principal := core.DevicePrincipal{ID: "device-one"}
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage: %v", err)
}
asset, replayed, err := store.Commit(context.Background(), principal, metadata, staged)
if err != nil || replayed {
t.Fatalf("Commit = replayed %t, err %v", replayed, err)
}
if asset.SHA256 != hash || asset.ByteSize != int64(len(pngBytes)) || asset.Width != 8 || asset.Height != 6 || asset.StorageKey != hash[:2]+"/"+hash+".png" {
t.Fatalf("asset = %#v", asset)
}
opened, reader, err := store.Open(context.Background(), asset.ID)
if err != nil {
t.Fatalf("Open: %v", err)
}
got, err := io.ReadAll(reader)
_ = reader.Close()
if err != nil || !bytes.Equal(got, pngBytes) || opened.ID != asset.ID {
t.Fatalf("opened asset changed: bytes=%t asset=%#v err=%v", bytes.Equal(got, pngBytes), opened, err)
}
replayStage, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("stage replay: %v", err)
}
replayedAsset, replayed, err := store.Commit(context.Background(), principal, metadata, replayStage)
if err != nil || !replayed || replayedAsset.ID != asset.ID {
t.Fatalf("replay = %#v, %t, %v", replayedAsset, replayed, err)
}
conflictStage, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("stage conflict: %v", err)
}
conflicting := metadata
conflicting.CapturedAt = conflicting.CapturedAt.Add(time.Second)
if _, _, err := store.Commit(context.Background(), principal, conflicting, conflictStage); !errors.Is(err, core.ErrConflict) {
t.Fatalf("conflicting replay error = %v", err)
}
}
func TestConcurrentReplayCreatesOneAsset(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 3, 2)
metadata := core.UploadMetadata{UploadKey: testUploadKey, TaskID: testTaskID, AttemptID: testAttemptID, Kind: core.KindSKUPanelGate1, PrivacyTier: core.PrivacyInternalRaw, SHA256: sha256Hex(pngBytes), CapturedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)}
staged := make([]core.StagedFile, 2)
for index := range staged {
var err error
staged[index], err = store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage %d: %v", index, err)
}
}
var wait sync.WaitGroup
wait.Add(2)
assets := make([]core.Asset, 2)
replays := make([]bool, 2)
errorsSeen := make([]error, 2)
for index := range staged {
go func(index int) {
defer wait.Done()
assets[index], replays[index], errorsSeen[index] = store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, metadata, staged[index])
}(index)
}
wait.Wait()
if errorsSeen[0] != nil || errorsSeen[1] != nil || assets[0].ID != assets[1].ID || replays[0] == replays[1] {
t.Fatalf("concurrent commits assets=%#v replays=%#v errors=%#v", assets, replays, errorsSeen)
}
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 1 {
t.Fatalf("asset count = %d, err %v", count, err)
}
}
func TestPlatformDirectorySync(t *testing.T) {
if err := syncDirectory(t.TempDir()); err != nil {
t.Fatalf("syncDirectory must either establish the durability boundary or fail closed: %v", err)
}
}
func TestNewStoreRetriesRootParentSyncWhenRootAlreadyExists(t *testing.T) {
database, _ := newTestStore(t)
parent := t.TempDir()
root := filepath.Join(parent, "retry-root")
injected := errors.New("injected root parent sync failure")
if _, err := newStore(database, root, func(string) error { return injected }); !errors.Is(err, injected) {
t.Fatalf("first newStore error = %v, want injected root sync failure", err)
}
if info, err := os.Stat(root); err != nil || !info.IsDir() {
t.Fatalf("failed parent sync must leave root for retry: info=%v err=%v", info, err)
}
var paths []string
store, err := newStore(database, root, func(path string) error {
paths = append(paths, path)
return syncDirectory(path)
})
if err != nil {
t.Fatalf("retry newStore: %v", err)
}
if store == nil || len(paths) == 0 || paths[0] != parent {
t.Fatalf("retry sync paths = %#v, want root parent %q first", paths, parent)
}
}
func TestCommitSyncsShardAndRenameBeforeDatabaseWrite(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 4, 3)
hash := sha256Hex(pngBytes)
metadata := testMetadata(hash)
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage: %v", err)
}
finalPath, err := store.pathForKey(storageKey(hash))
if err != nil {
t.Fatalf("final path: %v", err)
}
finalDirectory := filepath.Dir(finalPath)
var events []string
store.syncDirectory = func(path string) error {
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil {
t.Fatalf("count evidence before directory sync: %v", err)
}
if count != 0 {
t.Fatalf("database row became visible before directory sync: %d", count)
}
switch path {
case store.root:
events = append(events, "sync-root")
if path != store.root {
t.Fatalf("shard parent sync path = %q, want evidence root %q", path, store.root)
}
if info, err := os.Stat(finalDirectory); err != nil || !info.IsDir() {
t.Fatalf("shard directory must exist before parent sync: info=%v err=%v", info, err)
}
if _, err := os.Stat(finalPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("final file exists before publication: %v", err)
}
case finalDirectory:
events = append(events, "sync-shard")
if info, err := os.Stat(finalPath); err != nil || !info.Mode().IsRegular() {
t.Fatalf("renamed file must exist before shard sync: info=%v err=%v", info, err)
}
default:
t.Fatalf("unexpected extra directory sync: %q", path)
}
return syncDirectory(path)
}
store.syncFile = func(file *os.File) error {
if filepath.Dir(file.Name()) != finalDirectory || !strings.HasPrefix(filepath.Base(file.Name()), ".publish-") {
t.Fatalf("publication temp is not inside shard: %q", file.Name())
}
events = append(events, "sync-file")
return file.Sync()
}
store.renameFile = func(oldPath, newPath string) error {
if filepath.Dir(oldPath) != filepath.Dir(newPath) || newPath != finalPath {
t.Fatalf("rename is not same-directory publication: %q -> %q", oldPath, newPath)
}
events = append(events, "rename")
return os.Rename(oldPath, newPath)
}
if _, replayed, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, metadata, staged); err != nil || replayed {
t.Fatalf("Commit = replayed %t, err %v", replayed, err)
}
if got, want := strings.Join(events, ","), "sync-root,sync-root,sync-file,rename,sync-shard"; got != want {
t.Fatalf("durability order = %q, want %q", got, want)
}
assertEvidenceCount(t, database, 1)
assertNoPublishTemps(t, finalDirectory)
}
func TestCommitDirectorySyncFailuresNeverWriteDatabase(t *testing.T) {
for _, failAt := range []int{1, 2, 3} {
t.Run(map[int]string{1: "new shard parent", 2: "unconditional shard parent", 3: "rename target"}[failAt], func(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 4, 3)
hash := sha256Hex(pngBytes)
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage: %v", err)
}
finalPath, err := store.pathForKey(storageKey(hash))
if err != nil {
t.Fatalf("final path: %v", err)
}
injected := errors.New("injected directory sync failure")
calls := 0
store.syncDirectory = func(path string) error {
calls++
if calls == failAt {
return injected
}
return syncDirectory(path)
}
if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, testMetadata(hash), staged); !errors.Is(err, injected) {
t.Fatalf("Commit error = %v, want injected sync failure", err)
}
if calls != failAt {
t.Fatalf("sync calls = %d, want %d", calls, failAt)
}
assertEvidenceCount(t, database, 0)
_, statErr := os.Stat(finalPath)
if failAt < 3 && !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("file exists before rename durability boundary: %v", statErr)
}
if failAt == 3 && statErr != nil {
t.Fatalf("post-rename sync failure may leave an orphan file, stat error = %v", statErr)
}
assertNoPublishTemps(t, filepath.Dir(finalPath))
})
}
}
func TestCommitRetriesShardParentSyncAfterPriorFailure(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 4, 3)
hash := sha256Hex(pngBytes)
finalPath, err := store.pathForKey(storageKey(hash))
if err != nil {
t.Fatalf("final path: %v", err)
}
firstStage, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("first Stage: %v", err)
}
injected := errors.New("injected first shard parent sync failure")
store.syncDirectory = func(string) error { return injected }
if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, testMetadata(hash), firstStage); !errors.Is(err, injected) {
t.Fatalf("first Commit error = %v", err)
}
if info, err := os.Stat(filepath.Dir(finalPath)); err != nil || !info.IsDir() {
t.Fatalf("failed first sync must leave the created shard for retry: info=%v err=%v", info, err)
}
assertEvidenceCount(t, database, 0)
secondStage, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("second Stage: %v", err)
}
var paths []string
store.syncDirectory = func(path string) error {
paths = append(paths, path)
return syncDirectory(path)
}
if _, replayed, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, testMetadata(hash), secondStage); err != nil || replayed {
t.Fatalf("retry Commit = replayed %t, err %v", replayed, err)
}
if len(paths) != 2 || paths[0] != store.root || paths[1] != filepath.Dir(finalPath) {
t.Fatalf("retry sync paths = %#v, want root then shard", paths)
}
assertEvidenceCount(t, database, 1)
assertNoPublishTemps(t, filepath.Dir(finalPath))
}
func TestCommitPublicationFailuresCleanTempAndNeverWriteDatabase(t *testing.T) {
for _, name := range []string{"file sync", "rename"} {
t.Run(name, func(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 4, 3)
hash := sha256Hex(pngBytes)
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage: %v", err)
}
finalPath, err := store.pathForKey(storageKey(hash))
if err != nil {
t.Fatalf("final path: %v", err)
}
injected := errors.New("injected publication failure")
if name == "file sync" {
store.syncFile = func(*os.File) error { return injected }
} else {
store.renameFile = func(string, string) error { return injected }
}
if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, testMetadata(hash), staged); !errors.Is(err, injected) {
t.Fatalf("Commit error = %v, want injected publication failure", err)
}
if _, err := os.Stat(finalPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("final file exists after failed publication: %v", err)
}
assertNoPublishTemps(t, filepath.Dir(finalPath))
assertEvidenceCount(t, database, 0)
})
}
}
func TestCommitDatabaseFailuresAfterDurableRenameLeaveOnlyOrphan(t *testing.T) {
for _, test := range []struct {
name string
inject func(*testing.T, *sql.DB, *Store, error)
}{
{
name: "insert",
inject: func(t *testing.T, database *sql.DB, _ *Store, _ error) {
t.Helper()
if _, err := database.Exec(`CREATE TRIGGER fail_evidence_insert BEFORE INSERT ON evidence_assets BEGIN SELECT RAISE(ABORT, 'injected insert failure'); END`); err != nil {
t.Fatalf("create insert failure trigger: %v", err)
}
},
},
{
name: "commit",
inject: func(_ *testing.T, _ *sql.DB, store *Store, injected error) {
store.commitTx = func(*sql.Tx) error { return injected }
},
},
} {
t.Run(test.name, func(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 4, 3)
hash := sha256Hex(pngBytes)
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage: %v", err)
}
finalPath, err := store.pathForKey(storageKey(hash))
if err != nil {
t.Fatalf("final path: %v", err)
}
injected := errors.New("injected database failure")
test.inject(t, database, store, injected)
syncCalls := 0
store.syncDirectory = func(path string) error {
syncCalls++
return syncDirectory(path)
}
if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, testMetadata(hash), staged); err == nil {
t.Fatal("Commit unexpectedly succeeded")
}
if syncCalls != 3 {
t.Fatalf("database failure occurred before both durability syncs: sync calls = %d", syncCalls)
}
if info, err := os.Stat(finalPath); err != nil || !info.Mode().IsRegular() {
t.Fatalf("durable rename may leave only an orphan file: info=%v err=%v", info, err)
}
assertNoPublishTemps(t, filepath.Dir(finalPath))
assertEvidenceCount(t, database, 0)
})
}
}
func TestStageRejectsUnsafeContent(t *testing.T) {
_, store := newTestStore(t)
largePNG := makePNG(t, core.MaxImageSide+1, 1)
pngWithXML := append(makePNG(t, 1, 1), []byte("<hierarchy/>")...)
for name, test := range map[string]struct {
reader io.Reader
contentType string
}{
"wrong content type": {reader: bytes.NewReader(makePNG(t, 1, 1)), contentType: "application/octet-stream"},
"xml": {reader: bytes.NewBufferString("<hierarchy/>"), contentType: core.PNGContentType},
"png with xml tail": {reader: bytes.NewReader(pngWithXML), contentType: core.PNGContentType},
"truncated png": {reader: bytes.NewReader(pngSignature), contentType: core.PNGContentType},
"too wide": {reader: bytes.NewReader(largePNG), contentType: core.PNGContentType},
"too many bytes": {reader: io.LimitReader(zeroReader{}, core.MaxFileBytes+1), contentType: core.PNGContentType},
} {
t.Run(name, func(t *testing.T) {
staged, err := store.Stage(test.reader, test.contentType)
if !errors.Is(err, core.ErrInvalid) && !errors.Is(err, core.ErrTooLarge) {
store.Discard(staged)
t.Fatalf("Stage error = %v", err)
}
})
}
}
func TestCommitRequiresAttemptOwnedByTaskAndLowercaseHash(t *testing.T) {
database, store := newTestStore(t)
insertAttemptFixture(t, database)
pngBytes := makePNG(t, 2, 2)
base := core.UploadMetadata{UploadKey: testUploadKey, TaskID: testTaskID, AttemptID: testAttemptID, Kind: core.KindSKUPanelGate1, PrivacyTier: core.PrivacyInternalRaw, SHA256: sha256Hex(pngBytes), CapturedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)}
for name, mutate := range map[string]func(*core.UploadMetadata){
"unknown attempt": func(value *core.UploadMetadata) { value.AttemptID = "53c9f507-7473-4fa6-8d71-8786c34c6301" },
"uppercase hash": func(value *core.UploadMetadata) {
value.SHA256 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
},
"wrong kind": func(value *core.UploadMetadata) { value.Kind = "ORDER_CONFIRM" },
} {
t.Run(name, func(t *testing.T) {
staged, err := store.Stage(bytes.NewReader(pngBytes), core.PNGContentType)
if err != nil {
t.Fatalf("Stage: %v", err)
}
metadata := base
mutate(&metadata)
if _, _, err := store.Commit(context.Background(), core.DevicePrincipal{ID: "device"}, metadata, staged); !errors.Is(err, core.ErrInvalid) {
t.Fatalf("Commit error = %v", err)
}
})
}
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil || count != 0 {
t.Fatalf("invalid commits created %d assets, err %v", count, err)
}
}
func TestNewStoreRejectsRelativeAndFilesystemRootPaths(t *testing.T) {
database, _ := newTestStore(t)
if _, err := NewStore(database, "relative-evidence"); err == nil {
t.Fatal("relative evidence root succeeded")
}
volumeRoot := filepath.VolumeName(t.TempDir()) + string(filepath.Separator)
if _, err := NewStore(database, volumeRoot); err == nil {
t.Fatal("filesystem root succeeded")
}
}
type zeroReader struct{}
func (zeroReader) Read(buffer []byte) (int, error) {
for index := range buffer {
buffer[index] = 0
}
return len(buffer), nil
}
func newTestStore(t *testing.T) (*sql.DB, *Store) {
t.Helper()
database, err := sqlite.Open(filepath.Join(t.TempDir(), "evidence.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate test")
}
directory := filepath.Join(filepath.Dir(file), "..", "..", "..", "migrations")
if err := migrations.Up(context.Background(), database, directory); err != nil {
t.Fatalf("migrate database: %v", err)
}
store, err := NewStore(database, filepath.Join(t.TempDir(), "assets"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
return database, store
}
func insertAttemptFixture(t *testing.T, database *sql.DB) {
t.Helper()
timestamp := "2026-08-04T00:00:00Z"
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'task', '123', 'black', 'M', 1, '1.00', 'DRAFT', 1, ?, ?)`, testTaskID, timestamp, timestamp); err != nil {
t.Fatalf("insert task: %v", err)
}
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, 1, 'start', '123', 'black', 'M', 1, '1.00', 'ACTIVE', 'admin', ?, ?)`, testAuthID, testTaskID, timestamp, timestamp); err != nil {
t.Fatalf("insert authorization: %v", err)
}
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, testAttemptID, testTaskID, testAuthID, timestamp); err != nil {
t.Fatalf("insert attempt: %v", err)
}
}
func testMetadata(hash string) core.UploadMetadata {
return core.UploadMetadata{
UploadKey: testUploadKey, TaskID: testTaskID, AttemptID: testAttemptID,
Kind: core.KindSKUPanelGate1, PrivacyTier: core.PrivacyInternalRaw, SHA256: hash,
CapturedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC),
}
}
func assertEvidenceCount(t *testing.T, database *sql.DB, want int) {
t.Helper()
var count int
if err := database.QueryRow("SELECT COUNT(*) FROM evidence_assets").Scan(&count); err != nil {
t.Fatalf("count evidence assets: %v", err)
}
if count != want {
t.Fatalf("evidence asset count = %d, want %d", count, want)
}
}
func assertNoPublishTemps(t *testing.T, directory string) {
t.Helper()
entries, err := os.ReadDir(directory)
if err != nil {
t.Fatalf("read shard directory: %v", err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".publish-") {
t.Fatalf("publication temp leaked: %q", entry.Name())
}
}
}
func makePNG(t *testing.T, width, height int) []byte {
t.Helper()
imageData := image.NewNRGBA(image.Rect(0, 0, width, height))
imageData.Set(0, 0, color.NRGBA{R: 12, G: 34, B: 56, A: 255})
var buffer bytes.Buffer
if err := png.Encode(&buffer, imageData); err != nil {
t.Fatalf("encode PNG: %v", err)
}
return buffer.Bytes()
}
func sha256Hex(value []byte) string {
hash := sha256.Sum256(value)
return hex.EncodeToString(hash[:])
}
+60
View File
@@ -0,0 +1,60 @@
// Package taskdetail provides a read-only audit projection for one task.
package taskdetail
import (
"context"
"errors"
"time"
)
var ErrNotFound = errors.New("task detail not found")
type Store interface {
Get(context.Context, string) (Detail, error)
}
type Detail struct {
Task Task
Authorizations []Authorization
Attempts []Attempt
Submissions []Submission
Evidence []Evidence
}
type Task struct {
ID, Source, Title, GoodsID, SKUColor, SKUSize, MaxTotalPrice, Status string
Quantity, Version int
CreatedAt, UpdatedAt time.Time
}
type Authorization struct {
ID, Status, CreatedBy, TotalPriceCap string
TaskVersion int
CreatedAt, ExpiresAt time.Time
}
type Attempt struct {
ID, AuthorizationID, Status string
ClaimGeneration int
Gate1UnitPrice *string
Gate2UnitPrice *string
QuantityRead *int
ConfirmAmount *string
FailureCode *string
StartedAt time.Time
FinishedAt *time.Time
}
type Submission struct {
ID, AuthorizationID, AttemptID, Status string
Gate1UnitPrice, Gate2UnitPrice, ConfirmAmount string
QuantityRead int
CreatedAt time.Time
ResolvedAt *time.Time
}
type Evidence struct {
ID, AttemptID, Kind, PrivacyTier, SHA256, ContentType string
ByteSize, Width, Height int64
CapturedAt time.Time
}
+204
View File
@@ -0,0 +1,204 @@
package taskdetail
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
type SQLiteStore struct{ database *sql.DB }
func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
if database == nil {
return nil, errors.New("task detail database is required")
}
if _, err := database.Exec("SELECT storage_key FROM evidence_assets LIMIT 1"); err != nil {
return nil, fmt.Errorf("task detail migration is not available: %w", err)
}
return &SQLiteStore{database: database}, nil
}
func (store *SQLiteStore) Get(ctx context.Context, id string) (Detail, error) {
if !validUUID(id) {
return Detail{}, ErrNotFound
}
tx, err := store.database.BeginTx(ctx, &sql.TxOptions{ReadOnly: true})
if err != nil {
return Detail{}, err
}
defer tx.Rollback()
var detail Detail
var created, updated string
err = tx.QueryRowContext(ctx, `SELECT id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at FROM tasks WHERE id = ?`, id).Scan(
&detail.Task.ID, &detail.Task.Source, &detail.Task.Title, &detail.Task.GoodsID, &detail.Task.SKUColor, &detail.Task.SKUSize,
&detail.Task.Quantity, &detail.Task.MaxTotalPrice, &detail.Task.Status, &detail.Task.Version, &created, &updated,
)
if errors.Is(err, sql.ErrNoRows) {
return Detail{}, ErrNotFound
}
if err != nil {
return Detail{}, err
}
if detail.Task.CreatedAt, err = parseTime(created); err != nil {
return Detail{}, err
}
if detail.Task.UpdatedAt, err = parseTime(updated); err != nil {
return Detail{}, err
}
if detail.Authorizations, err = readAuthorizations(ctx, tx, id); err != nil {
return Detail{}, err
}
if detail.Attempts, err = readAttempts(ctx, tx, id); err != nil {
return Detail{}, err
}
if detail.Submissions, err = readSubmissions(ctx, tx, id); err != nil {
return Detail{}, err
}
if detail.Evidence, err = readEvidence(ctx, tx, id); err != nil {
return Detail{}, err
}
if err := tx.Commit(); err != nil {
return Detail{}, err
}
return detail, nil
}
func readAuthorizations(ctx context.Context, tx *sql.Tx, taskID string) ([]Authorization, error) {
rows, err := tx.QueryContext(ctx, `SELECT id, task_version, total_price_cap, status, created_by, created_at, expires_at FROM order_authorizations WHERE task_id = ? ORDER BY created_at DESC, id DESC`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []Authorization{}
for rows.Next() {
var item Authorization
var created, expires string
if err := rows.Scan(&item.ID, &item.TaskVersion, &item.TotalPriceCap, &item.Status, &item.CreatedBy, &created, &expires); err != nil {
return nil, err
}
if item.CreatedAt, err = parseTime(created); err != nil {
return nil, err
}
if item.ExpiresAt, err = parseTime(expires); err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
func readAttempts(ctx context.Context, tx *sql.Tx, taskID string) ([]Attempt, error) {
rows, err := tx.QueryContext(ctx, `SELECT id, authorization_id, claim_generation, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, failure_code, started_at, finished_at FROM purchase_attempts WHERE task_id = ? ORDER BY started_at DESC, id DESC`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []Attempt{}
for rows.Next() {
var item Attempt
var gate1, gate2, confirm, failure, finished sql.NullString
var quantity sql.NullInt64
var started string
if err := rows.Scan(&item.ID, &item.AuthorizationID, &item.ClaimGeneration, &item.Status, &gate1, &gate2, &quantity, &confirm, &failure, &started, &finished); err != nil {
return nil, err
}
item.Gate1UnitPrice, item.Gate2UnitPrice, item.ConfirmAmount, item.FailureCode = stringPointer(gate1), stringPointer(gate2), stringPointer(confirm), stringPointer(failure)
if quantity.Valid {
value := int(quantity.Int64)
item.QuantityRead = &value
}
if item.StartedAt, err = parseTime(started); err != nil {
return nil, err
}
if finished.Valid {
value, parseErr := parseTime(finished.String)
if parseErr != nil {
return nil, parseErr
}
item.FinishedAt = &value
}
result = append(result, item)
}
return result, rows.Err()
}
func readSubmissions(ctx context.Context, tx *sql.Tx, taskID string) ([]Submission, error) {
rows, err := tx.QueryContext(ctx, `SELECT id, authorization_id, attempt_id, status, gate1_unit_price, gate2_unit_price, quantity_read, confirm_amount, created_at, resolved_at FROM order_submissions WHERE task_id = ? ORDER BY created_at DESC, id DESC`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []Submission{}
for rows.Next() {
var item Submission
var created string
var resolved sql.NullString
if err := rows.Scan(&item.ID, &item.AuthorizationID, &item.AttemptID, &item.Status, &item.Gate1UnitPrice, &item.Gate2UnitPrice, &item.QuantityRead, &item.ConfirmAmount, &created, &resolved); err != nil {
return nil, err
}
if item.CreatedAt, err = parseTime(created); err != nil {
return nil, err
}
if resolved.Valid {
value, parseErr := parseTime(resolved.String)
if parseErr != nil {
return nil, parseErr
}
item.ResolvedAt = &value
}
result = append(result, item)
}
return result, rows.Err()
}
func readEvidence(ctx context.Context, tx *sql.Tx, taskID string) ([]Evidence, error) {
rows, err := tx.QueryContext(ctx, `SELECT id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, captured_at FROM evidence_assets WHERE task_id = ? ORDER BY captured_at, created_at, id`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
result := []Evidence{}
for rows.Next() {
var item Evidence
var captured string
if err := rows.Scan(&item.ID, &item.AttemptID, &item.Kind, &item.PrivacyTier, &item.SHA256, &item.ByteSize, &item.ContentType, &item.Width, &item.Height, &captured); err != nil {
return nil, err
}
if item.CapturedAt, err = parseTime(captured); err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
func parseTime(value string) (time.Time, error) { return time.Parse(time.RFC3339Nano, value) }
func stringPointer(value sql.NullString) *string {
if !value.Valid {
return nil
}
copy := value.String
return &copy
}
func validUUID(value string) bool {
if len(value) != 36 {
return false
}
for index, character := range value {
if index == 8 || index == 13 || index == 18 || index == 23 {
if character != '-' {
return false
}
continue
}
if !(character >= '0' && character <= '9' || character >= 'a' && character <= 'f') {
return false
}
}
return value[14] == '4' && (value[19] == '8' || value[19] == '9' || value[19] == 'a' || value[19] == 'b')
}
+82
View File
@@ -0,0 +1,82 @@
package taskdetail
import (
"context"
"database/sql"
"errors"
"path/filepath"
"runtime"
"strings"
"testing"
"cmbuyer/admin/internal/migrations"
"cmbuyer/admin/internal/storage/sqlite"
)
const (
detailTask = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
detailAuth = "b3c9f507-7473-4fa6-8d71-8786c34c6301"
detailTry = "c3c9f507-7473-4fa6-8d71-8786c34c6301"
)
func TestSQLiteStoreReturnsOnlyPersistedAuditFacts(t *testing.T) {
database := openDetailDatabase(t)
timestamp := "2026-08-04T00:00:00Z"
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'shirt', '123', 'black', 'M', 2, '30.00', 'CLAIMED', 3, ?, ?)`, detailTask, timestamp, timestamp); err != nil {
t.Fatalf("insert task: %v", err)
}
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, 2, 'start', '123', 'black', 'M', 2, '30.00', 'CLAIMED', 'admin', ?, ?)`, detailAuth, detailTask, timestamp, timestamp); err != nil {
t.Fatalf("insert authorization: %v", err)
}
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, detailTry, detailTask, detailAuth, timestamp); err != nil {
t.Fatalf("insert attempt: %v", err)
}
hash := strings.Repeat("a", 64)
if _, err := database.Exec(`INSERT INTO evidence_assets (id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at) VALUES ('d3c9f507-7473-4fa6-8d71-8786c34c6301', 'upload', ?, ?, 'SKU_PANEL_GATE_1', 'INTERNAL_RAW', ?, 100, 'image/png', 10, 20, ?, 'device', ?, ?)`, detailTask, detailTry, hash, "aa/"+hash+".png", timestamp, timestamp); err != nil {
t.Fatalf("insert evidence: %v", err)
}
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
detail, err := store.Get(context.Background(), detailTask)
if err != nil {
t.Fatalf("Get: %v", err)
}
if detail.Task.ID != detailTask || detail.Task.Status != "CLAIMED" || len(detail.Authorizations) != 1 || len(detail.Attempts) != 1 || len(detail.Evidence) != 1 || len(detail.Submissions) != 0 {
t.Fatalf("detail = %#v", detail)
}
if detail.Attempts[0].Gate1UnitPrice != nil || detail.Attempts[0].FailureCode != nil {
t.Fatalf("missing attempt facts were fabricated: %#v", detail.Attempts[0])
}
}
func TestSQLiteStoreFailsClosedForMalformedAndMissingIDs(t *testing.T) {
database := openDetailDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
for _, id := range []string{"../database", "not-a-uuid", "a3c9f507-7473-1fa6-8d71-8786c34c6301"} {
if _, err := store.Get(context.Background(), id); !errors.Is(err, ErrNotFound) {
t.Fatalf("Get(%q) error = %v", id, err)
}
}
}
func openDetailDatabase(t *testing.T) *sql.DB {
t.Helper()
database, err := sqlite.Open(filepath.Join(t.TempDir(), "details.db"))
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate migration directory")
}
if err := migrations.Up(context.Background(), database, filepath.Join(filepath.Dir(file), "..", "..", "migrations")); err != nil {
t.Fatalf("migrate database: %v", err)
}
return database
}
+128
View File
@@ -0,0 +1,128 @@
package tasks
import (
"errors"
"math"
"math/big"
"sort"
"strings"
"time"
_ "time/tzdata"
)
const maxStartItems = 100
var (
ErrStartConflict = errors.New("purchase start conflicts with current task state")
ErrInvalidStart = errors.New("invalid purchase start request")
)
type StartPolicy struct {
AuthorizationTTL time.Duration
MaxQuantity int
MaxTotalPrice string
}
type StartItem struct {
TaskID string `json:"task_id"`
ExpectedTaskVersion int `json:"expected_task_version"`
}
type StartCommand struct {
StartKey string `json:"start_key"`
Tasks []StartItem `json:"tasks"`
}
type AuthorizedTask struct {
TaskID string `json:"task_id"`
TaskVersion int `json:"task_version"`
AuthorizationID string `json:"authorization_id"`
ExpiresAt time.Time `json:"expires_at"`
}
type StartResult struct {
StartKey string `json:"start_key"`
AuthorizedCount int `json:"authorized_count"`
Tasks []AuthorizedTask `json:"tasks"`
PaymentAutomated bool `json:"payment_automated"`
}
type TaskFilter struct{ Keyword, Status, CreatedFrom, CreatedTo string }
type TaskRow struct {
ID, Title, GoodsID, SKUColor, SKUSize, MaxTotalPrice, Status string
Quantity, Version int
CreatedAt time.Time
}
func normalizeCents(value string) (string, *big.Int, bool) {
if value == "" || strings.TrimSpace(value) != value {
return "", nil, false
}
parts := strings.Split(value, ".")
if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) != 2 || (len(parts[0]) > 1 && parts[0][0] == '0') {
return "", nil, false
}
for _, part := range parts {
for _, ch := range part {
if ch < '0' || ch > '9' {
return "", nil, false
}
}
}
cents := new(big.Int)
if _, ok := cents.SetString(parts[0]+parts[1], 10); !ok || cents.Sign() <= 0 {
return "", nil, false
}
return value, cents, true
}
func startItems(command StartCommand) ([]StartItem, error) {
if !validUUID(command.StartKey) || len(command.Tasks) == 0 || len(command.Tasks) > maxStartItems {
return nil, ErrInvalidStart
}
items := append([]StartItem(nil), command.Tasks...)
sort.Slice(items, func(i, j int) bool { return items[i].TaskID < items[j].TaskID })
for i, item := range items {
if !validUUID(item.TaskID) || item.ExpectedTaskVersion <= 0 || item.ExpectedTaskVersion == math.MaxInt || (i > 0 && item.TaskID == items[i-1].TaskID) {
return nil, ErrInvalidStart
}
}
return items, nil
}
func validTaskStatus(value string) bool {
if value == "" {
return true
}
for _, status := range []string{"DRAFT", "PENDING", "CLAIMED", "ORDERING", "NEEDS_MANUAL", "WAITING_PAYMENT", "RECONCILIATION_REQUIRED", "SUCCEEDED", "FAILED", "CANCELED"} {
if value == status {
return true
}
}
return false
}
func ShanghaiRange(from, to string) (time.Time, time.Time, error) {
if from == "" && to == "" {
return time.Time{}, time.Time{}, nil
}
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
return time.Time{}, time.Time{}, err
}
parse := func(value string) (time.Time, error) { return time.ParseInLocation("2006-01-02", value, location) }
var start, end time.Time
if from != "" {
start, err = parse(from)
if err != nil {
return time.Time{}, time.Time{}, ErrInvalidStart
}
start = start.UTC()
}
if to != "" {
end, err = parse(to)
if err != nil {
return time.Time{}, time.Time{}, ErrInvalidStart
}
end = end.AddDate(0, 0, 1).UTC()
}
if !start.IsZero() && !end.IsZero() && !start.Before(end) {
return time.Time{}, time.Time{}, ErrInvalidStart
}
return start, end, nil
}
@@ -0,0 +1,424 @@
package tasks
import (
"context"
"database/sql"
"errors"
"fmt"
"math"
"reflect"
"sort"
"sync"
"testing"
"time"
"cmbuyer/admin/internal/migrations"
)
var fixedStartTime = time.Date(2026, 8, 4, 9, 2, 3, 456000000, time.FixedZone("UTC+8", 8*60*60))
func TestStartPurchasesPersistsCompleteSnapshotsForOneAndHundredTasks(t *testing.T) {
for _, count := range []int{1, 100} {
t.Run(fmt.Sprintf("%d tasks", count), func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
store.now = func() time.Time { return fixedStartTime }
items := make([]StartItem, 0, count)
wantDrafts := make(map[string]Draft, count)
for index := 1; index <= count; index++ {
id := startTestUUID(index)
draft := Draft{
ID: id,
Title: fmt.Sprintf("task-%03d", index),
GoodsID: fmt.Sprintf("937122%06d", index),
SKUColor: fmt.Sprintf("color-%03d", index),
SKUSize: fmt.Sprintf("size-%03d", index),
Quantity: index%10 + 1,
MaxTotalPrice: fmt.Sprintf("%d.%02d", index+10, index%100),
}
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
t.Fatalf("create draft %d: %v", index, err)
}
items = append(items, StartItem{TaskID: id, ExpectedTaskVersion: 1})
wantDrafts[id] = draft
}
sort.Slice(items, func(i, j int) bool { return items[i].TaskID > items[j].TaskID })
command := StartCommand{StartKey: startTestUUID(1001 + count), Tasks: items}
result, err := store.StartPurchases(context.Background(), command, "authenticated-admin")
if err != nil {
t.Fatalf("StartPurchases: %v", err)
}
if result.StartKey != command.StartKey || result.AuthorizedCount != count || result.PaymentAutomated || len(result.Tasks) != count {
t.Fatalf("result = %#v", result)
}
wantCreated := fixedStartTime.UTC()
wantExpires := wantCreated.Add(15 * time.Minute)
seenAuthorizationIDs := map[string]bool{}
for index, authorized := range result.Tasks {
if index > 0 && result.Tasks[index-1].TaskID >= authorized.TaskID {
t.Fatalf("result is not in canonical task order: %#v", result.Tasks)
}
if authorized.TaskVersion != 2 || !authorized.ExpiresAt.Equal(wantExpires) || !validUUID(authorized.AuthorizationID) || seenAuthorizationIDs[authorized.AuthorizationID] {
t.Fatalf("authorized task = %#v", authorized)
}
seenAuthorizationIDs[authorized.AuthorizationID] = true
want := wantDrafts[authorized.TaskID]
var taskStatus, taskUpdated, authTaskID, authStartKey, goodsID, color, size, priceCap, authStatus, createdBy, createdAt, expiresAt string
var taskVersion, authTaskVersion, quantity int
err := database.QueryRow(`
SELECT t.status,t.version,t.updated_at,
a.task_id,a.task_version,a.start_key,a.goods_id,a.sku_color,a.sku_size,a.quantity,a.total_price_cap,a.status,a.created_by,a.created_at,a.expires_at
FROM tasks t JOIN order_authorizations a ON a.task_id=t.id WHERE a.id=?`, authorized.AuthorizationID).
Scan(&taskStatus, &taskVersion, &taskUpdated, &authTaskID, &authTaskVersion, &authStartKey, &goodsID, &color, &size, &quantity, &priceCap, &authStatus, &createdBy, &createdAt, &expiresAt)
if err != nil {
t.Fatalf("read authorization snapshot: %v", err)
}
if taskStatus != "PENDING" || taskVersion != 2 || taskUpdated != wantCreated.Format(time.RFC3339Nano) ||
authTaskID != want.ID || authTaskVersion != 2 || authStartKey != command.StartKey ||
goodsID != want.GoodsID || color != want.SKUColor || size != want.SKUSize || quantity != want.Quantity || priceCap != want.MaxTotalPrice ||
authStatus != "ACTIVE" || createdBy != "authenticated-admin" || createdAt != wantCreated.Format(time.RFC3339Nano) || expiresAt != wantExpires.Format(time.RFC3339Nano) {
t.Fatalf("stored task/authorization mismatch for %s", want.ID)
}
}
var distinctCreated, distinctExpires int
if err := database.QueryRow(`SELECT COUNT(DISTINCT created_at), COUNT(DISTINCT expires_at) FROM order_authorizations WHERE start_key=?`, command.StartKey).Scan(&distinctCreated, &distinctExpires); err != nil {
t.Fatal(err)
}
if distinctCreated != 1 || distinctExpires != 1 {
t.Fatalf("batch timestamps are not shared: created=%d expires=%d", distinctCreated, distinctExpires)
}
})
}
}
func TestStartPurchasesRejectsInvalidCommandsAndPolicyWithoutWrites(t *testing.T) {
validItem := StartItem{TaskID: startTestUUID(1), ExpectedTaskVersion: 1}
hundredOne := make([]StartItem, 101)
for index := range hundredOne {
hundredOne[index] = StartItem{TaskID: startTestUUID(index + 1), ExpectedTaskVersion: 1}
}
for name, command := range map[string]StartCommand{
"invalid start key": {StartKey: "not-a-uuid", Tasks: []StartItem{validItem}},
"empty tasks": {StartKey: startTestUUID(1001)},
"over batch limit": {StartKey: startTestUUID(1001), Tasks: hundredOne},
"invalid task id": {StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: "1", ExpectedTaskVersion: 1}}},
"duplicate task": {StartKey: startTestUUID(1001), Tasks: []StartItem{validItem, validItem}},
"zero version": {StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: validItem.TaskID}}},
"overflow version": {StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: validItem.TaskID, ExpectedTaskVersion: math.MaxInt}}},
} {
t.Run(name, func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
_, err := store.StartPurchases(context.Background(), command, "admin")
if !errors.Is(err, ErrInvalidStart) {
t.Fatalf("error = %v, want ErrInvalidStart", err)
}
assertAuthorizationCount(t, database, 0)
})
}
for name, mutate := range map[string]func(*SQLiteStore){
"zero ttl": func(store *SQLiteStore) { store.policy.AuthorizationTTL = 0 },
"zero quantity": func(store *SQLiteStore) { store.policy.MaxQuantity = 0 },
"bad max price": func(store *SQLiteStore) { store.policy.MaxTotalPrice = "999" },
} {
t.Run(name, func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
createStartDraft(t, store, validItem.TaskID)
mutate(store)
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{validItem}}, "admin")
if !errors.Is(err, ErrInvalidStart) {
t.Fatalf("error = %v, want ErrInvalidStart", err)
}
assertDraftUnchanged(t, database, validItem.TaskID)
assertAuthorizationCount(t, database, 0)
})
}
database := migratedDatabase(t)
store := configuredStartStore(t, database)
createStartDraft(t, store, validItem.TaskID)
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{validItem}}, "")
if !errors.Is(err, ErrInvalidStart) {
t.Fatalf("empty created_by error = %v", err)
}
assertDraftUnchanged(t, database, validItem.TaskID)
}
func TestStartPurchasesRejectsEveryTaskConflictWithoutAuthorization(t *testing.T) {
for name, mutate := range map[string]func(*testing.T, *SQLiteStore, string, *StartItem){
"missing": func(_ *testing.T, _ *SQLiteStore, _ string, item *StartItem) {
item.TaskID = startTestUUID(99)
},
"not draft": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET status='PENDING' WHERE id=?`, id)
},
"version mismatch": func(_ *testing.T, _ *SQLiteStore, _ string, item *StartItem) {
item.ExpectedTaskVersion = 2
},
"empty goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET goods_id='' WHERE id=?`, id)
},
"nondigit goods id": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET goods_id='937x' WHERE id=?`, id)
},
"empty color": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_color='' WHERE id=?`, id)
},
"empty size": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_size='' WHERE id=?`, id)
},
"quantity over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
store.policy.MaxQuantity = 1
},
"noncanonical price one decimal": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET max_total_price='12.8' WHERE id=?`, id)
},
"noncanonical leading zero": func(t *testing.T, store *SQLiteStore, id string, _ *StartItem) {
execTestSQL(t, store.database, `UPDATE tasks SET max_total_price='012.80' WHERE id=?`, id)
},
"price over policy": func(_ *testing.T, store *SQLiteStore, _ string, _ *StartItem) {
store.policy.MaxTotalPrice = "12.79"
},
} {
t.Run(name, func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
id := startTestUUID(1)
createStartDraft(t, store, id)
item := StartItem{TaskID: id, ExpectedTaskVersion: 1}
mutate(t, store, id, &item)
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{item}}, "admin")
if !errors.Is(err, ErrStartConflict) {
t.Fatalf("error = %v, want ErrStartConflict", err)
}
assertAuthorizationCount(t, database, 0)
})
}
}
func TestStartPurchasesRollsBackWholeBatchForLateConflictAndSQLFailure(t *testing.T) {
for _, test := range []struct {
name string
breakBatch func(*testing.T, *SQLiteStore, string)
}{
{name: "late validation conflict", breakBatch: func(t *testing.T, store *SQLiteStore, secondID string) {
execTestSQL(t, store.database, `UPDATE tasks SET sku_size='' WHERE id=?`, secondID)
}},
{name: "late SQL failure", breakBatch: func(t *testing.T, store *SQLiteStore, secondID string) {
statement := fmt.Sprintf(`CREATE TRIGGER reject_second_authorization BEFORE INSERT ON order_authorizations WHEN NEW.task_id='%s' BEGIN SELECT RAISE(ABORT, 'test failure'); END`, secondID)
execTestSQL(t, store.database, statement)
}},
} {
t.Run(test.name, func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
firstID, secondID := startTestUUID(1), startTestUUID(2)
createStartDraft(t, store, firstID)
createStartDraft(t, store, secondID)
test.breakBatch(t, store, secondID)
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: firstID, ExpectedTaskVersion: 1}, {TaskID: secondID, ExpectedTaskVersion: 1}}}, "admin")
if err == nil {
t.Fatal("StartPurchases unexpectedly succeeded")
}
assertDraftUnchanged(t, database, firstID)
var secondStatus string
var secondVersion int
if err := database.QueryRow(`SELECT status,version FROM tasks WHERE id=?`, secondID).Scan(&secondStatus, &secondVersion); err != nil {
t.Fatal(err)
}
if secondStatus != "DRAFT" || secondVersion != 1 {
t.Fatalf("second task = %s/v%d, want DRAFT/v1", secondStatus, secondVersion)
}
assertAuthorizationCount(t, database, 0)
})
}
}
func TestStartPurchasesReplayIsStableAndRejectsDifferentOrIncompleteSets(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
firstID, secondID, thirdID := startTestUUID(1), startTestUUID(2), startTestUUID(3)
for _, id := range []string{firstID, secondID, thirdID} {
createStartDraft(t, store, id)
}
command := StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: secondID, ExpectedTaskVersion: 1}, {TaskID: firstID, ExpectedTaskVersion: 1}}}
first, err := store.StartPurchases(context.Background(), command, "admin")
if err != nil {
t.Fatal(err)
}
command.Tasks[0], command.Tasks[1] = command.Tasks[1], command.Tasks[0]
replay, err := store.StartPurchases(context.Background(), command, "admin")
if err != nil || !reflect.DeepEqual(replay, first) {
t.Fatalf("replay = (%#v, %v), want %#v", replay, err, first)
}
assertAuthorizationCount(t, database, 2)
conflicting := []StartCommand{
{StartKey: command.StartKey, Tasks: command.Tasks[:1]},
{StartKey: command.StartKey, Tasks: []StartItem{{TaskID: firstID, ExpectedTaskVersion: 2}, {TaskID: secondID, ExpectedTaskVersion: 1}}},
{StartKey: command.StartKey, Tasks: []StartItem{{TaskID: firstID, ExpectedTaskVersion: 1}, {TaskID: secondID, ExpectedTaskVersion: 1}, {TaskID: thirdID, ExpectedTaskVersion: 1}}},
}
for _, changed := range conflicting {
if _, err := store.StartPurchases(context.Background(), changed, "admin"); !errors.Is(err, ErrStartConflict) {
t.Fatalf("different payload error = %v", err)
}
}
assertAuthorizationCount(t, database, 2)
assertDraftUnchanged(t, database, thirdID)
execTestSQL(t, database, `DELETE FROM order_authorizations WHERE task_id=?`, secondID)
if _, err := store.StartPurchases(context.Background(), command, "admin"); !errors.Is(err, ErrStartConflict) {
t.Fatalf("incomplete replay error = %v", err)
}
assertAuthorizationCount(t, database, 1)
}
func TestStartPurchasesConcurrentReplayAndVersionRace(t *testing.T) {
t.Run("same key replays one stable result", func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
id := startTestUUID(1)
createStartDraft(t, store, id)
command := StartCommand{StartKey: startTestUUID(1001), Tasks: []StartItem{{TaskID: id, ExpectedTaskVersion: 1}}}
const callers = 16
start := make(chan struct{})
results := make(chan StartResult, callers)
errorsChannel := make(chan error, callers)
var group sync.WaitGroup
for range callers {
group.Add(1)
go func() {
defer group.Done()
<-start
result, err := store.StartPurchases(context.Background(), command, "admin")
if err != nil {
errorsChannel <- err
return
}
results <- result
}()
}
close(start)
group.Wait()
close(results)
close(errorsChannel)
for err := range errorsChannel {
t.Fatalf("concurrent replay: %v", err)
}
var want StartResult
for result := range results {
if want.StartKey == "" {
want = result
} else if !reflect.DeepEqual(result, want) {
t.Fatalf("unstable replay: %#v != %#v", result, want)
}
}
assertAuthorizationCount(t, database, 1)
var version int
if err := database.QueryRow(`SELECT version FROM tasks WHERE id=?`, id).Scan(&version); err != nil || version != 2 {
t.Fatalf("task version = %d, err=%v", version, err)
}
})
t.Run("different keys race one expected version", func(t *testing.T) {
database := migratedDatabase(t)
store := configuredStartStore(t, database)
id := startTestUUID(1)
createStartDraft(t, store, id)
start := make(chan struct{})
errorsChannel := make(chan error, 2)
var group sync.WaitGroup
for _, key := range []string{startTestUUID(1001), startTestUUID(1002)} {
group.Add(1)
go func(startKey string) {
defer group.Done()
<-start
_, err := store.StartPurchases(context.Background(), StartCommand{StartKey: startKey, Tasks: []StartItem{{TaskID: id, ExpectedTaskVersion: 1}}}, "admin")
errorsChannel <- err
}(key)
}
close(start)
group.Wait()
close(errorsChannel)
successes, conflicts := 0, 0
for err := range errorsChannel {
switch {
case err == nil:
successes++
case errors.Is(err, ErrStartConflict):
conflicts++
default:
t.Fatalf("unexpected race error: %v", err)
}
}
if successes != 1 || conflicts != 1 {
t.Fatalf("success/conflict = %d/%d, want 1/1", successes, conflicts)
}
assertAuthorizationCount(t, database, 1)
})
}
func TestSQLiteStoreRejectsV1SchemaAtStartup(t *testing.T) {
database := openDatabase(t)
if err := migrations.Run(context.Background(), database, migrationDirectory(t), "up-by-one"); err != nil {
t.Fatalf("migrate to v1: %v", err)
}
if _, err := NewSQLiteStore(database); err == nil {
t.Fatal("NewSQLiteStore accepted the v1 two-pass schema")
}
}
func configuredStartStore(t *testing.T, database *sql.DB) *SQLiteStore {
t.Helper()
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatalf("NewSQLiteStore: %v", err)
}
store.SetStartPolicy(StartPolicy{AuthorizationTTL: 15 * time.Minute, MaxQuantity: 10, MaxTotalPrice: "999.99"})
return store
}
func createStartDraft(t *testing.T, store *SQLiteStore, id string) {
t.Helper()
draft := Draft{ID: id, Title: "test", GoodsID: "937122477375", SKUColor: "黑色", SKUSize: "M", Quantity: 2, MaxTotalPrice: "12.80"}
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
t.Fatalf("CreateDraft: %v", err)
}
}
func startTestUUID(number int) string {
return fmt.Sprintf("%08x-1234-4abc-a123-%012x", number, number)
}
func assertAuthorizationCount(t *testing.T, database *sql.DB, want int) {
t.Helper()
var got int
if err := database.QueryRow(`SELECT COUNT(*) FROM order_authorizations`).Scan(&got); err != nil {
t.Fatal(err)
}
if got != want {
t.Fatalf("authorization count = %d, want %d", got, want)
}
}
func assertDraftUnchanged(t *testing.T, database *sql.DB, id string) {
t.Helper()
var status string
var version int
if err := database.QueryRow(`SELECT status,version FROM tasks WHERE id=?`, id).Scan(&status, &version); err != nil {
t.Fatal(err)
}
if status != "DRAFT" || version != 1 {
t.Fatalf("task %s = %s/v%d, want DRAFT/v1", id, status, version)
}
}
func execTestSQL(t *testing.T, database *sql.DB, statement string, arguments ...any) {
t.Helper()
if _, err := database.Exec(statement, arguments...); err != nil {
t.Fatalf("execute test SQL: %v", err)
}
}
+229
View File
@@ -0,0 +1,229 @@
package tasks
import (
"context"
"database/sql"
"errors"
"strings"
"time"
"cmbuyer/admin/internal/domain"
)
// ErrInvalidFilter 表示任务筛选值无效,路由应按字段重新渲染而不是泄露内部错误。
var ErrInvalidFilter = errors.New("invalid task filter")
// SetStartPolicy is called during startup; policy is explicit because authorization limits must not be implicit defaults.
func (store *SQLiteStore) SetStartPolicy(policy StartPolicy) { store.policy = policy }
func (store *SQLiteStore) ListTasks(ctx context.Context, filter TaskFilter) ([]TaskRow, error) {
if !ValidateTaskFilter(filter).Valid() {
return nil, ErrInvalidFilter
}
from, to, err := ShanghaiRange(filter.CreatedFrom, filter.CreatedTo)
if err != nil {
return nil, ErrInvalidFilter
}
clauses, args := []string{"1=1"}, []any{}
if filter.Status != "" {
clauses = append(clauses, "status = ?")
args = append(args, filter.Status)
}
if filter.Keyword != "" {
escaped := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(filter.Keyword)
clauses = append(clauses, "(title LIKE ? ESCAPE '\\' OR goods_id LIKE ? ESCAPE '\\')")
args = append(args, "%"+escaped+"%", "%"+escaped+"%")
}
if !from.IsZero() {
clauses = append(clauses, "julianday(created_at) >= julianday(?)")
args = append(args, from.Format(time.RFC3339Nano))
}
if !to.IsZero() {
clauses = append(clauses, "julianday(created_at) < julianday(?)")
args = append(args, to.Format(time.RFC3339Nano))
}
rows, err := store.database.QueryContext(ctx, "SELECT id,title,goods_id,sku_color,sku_size,quantity,max_total_price,status,version,created_at FROM tasks WHERE "+strings.Join(clauses, " AND ")+" ORDER BY julianday(created_at) DESC,rowid DESC", args...)
if err != nil {
return nil, err
}
defer rows.Close()
result := []TaskRow{}
for rows.Next() {
var item TaskRow
var created string
if err := rows.Scan(&item.ID, &item.Title, &item.GoodsID, &item.SKUColor, &item.SKUSize, &item.Quantity, &item.MaxTotalPrice, &item.Status, &item.Version, &created); err != nil {
return nil, err
}
item.CreatedAt, err = time.Parse(time.RFC3339Nano, created)
if err != nil {
return nil, err
}
result = append(result, item)
}
return result, rows.Err()
}
// ValidateTaskFilter 返回可关联到字段的错误,使服务端页面拒绝篡改参数时仍能保留输入值。
func ValidateTaskFilter(filter TaskFilter) Errors {
validation := Errors{}
if !validTaskStatus(filter.Status) {
validation["status"] = "请选择有效的任务状态。"
}
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
validation["created_from"] = "日期筛选暂不可用,请稍后重试。"
validation["created_to"] = "日期筛选暂不可用,请稍后重试。"
return validation
}
parseDate := func(field, value string) (time.Time, bool) {
if value == "" {
return time.Time{}, true
}
parsed, parseErr := time.ParseInLocation("2006-01-02", value, location)
if parseErr != nil {
validation[field] = "请输入有效日期。"
return time.Time{}, false
}
return parsed, true
}
from, fromOK := parseDate("created_from", filter.CreatedFrom)
to, toOK := parseDate("created_to", filter.CreatedTo)
if fromOK && toOK && !from.IsZero() && !to.IsZero() && from.After(to) {
validation["created_to"] = "结束日期不能早于开始日期。"
}
return validation
}
func (store *SQLiteStore) StartPurchases(ctx context.Context, command StartCommand, createdBy string) (StartResult, error) {
items, err := startItems(command)
if err != nil || createdBy == "" {
return StartResult{}, ErrInvalidStart
}
if store.policy.AuthorizationTTL <= 0 || store.policy.MaxQuantity <= 0 {
return StartResult{}, ErrInvalidStart
}
_, ceiling, ok := normalizeCents(store.policy.MaxTotalPrice)
if !ok {
return StartResult{}, ErrInvalidStart
}
writeCtx, cancel := context.WithTimeout(ctx, sqliteWriteTimeout)
defer cancel()
select {
case store.writeGate <- struct{}{}:
defer func() { <-store.writeGate }()
case <-writeCtx.Done():
return StartResult{}, writeCtx.Err()
}
tx, err := store.database.BeginTx(writeCtx, nil)
if err != nil {
return StartResult{}, err
}
defer tx.Rollback()
// Replay precedes any DRAFT check. One service process serializes this check with creation; SQLite uniqueness remains the cross-transaction backstop.
result, found, err := replayStart(writeCtx, tx, command.StartKey, items)
if err != nil {
return StartResult{}, err
}
if found {
if err := tx.Commit(); err != nil {
return StartResult{}, err
}
return result, nil
}
now := store.now().UTC()
expires := now.Add(store.policy.AuthorizationTTL)
result = StartResult{StartKey: command.StartKey, AuthorizedCount: len(items), Tasks: make([]AuthorizedTask, 0, len(items)), PaymentAutomated: false}
for _, item := range items {
var title, goods, color, size, price, status string
var quantity, version int
if err := tx.QueryRowContext(writeCtx, "SELECT title,goods_id,sku_color,sku_size,quantity,max_total_price,status,version FROM tasks WHERE id=?", item.TaskID).Scan(&title, &goods, &color, &size, &quantity, &price, &status, &version); err != nil {
if err == sql.ErrNoRows {
return StartResult{}, ErrStartConflict
}
return StartResult{}, err
}
if status != "DRAFT" || version != item.ExpectedTaskVersion || !goodsIDValid(goods) || color == "" || size == "" || quantity < 1 || quantity > store.policy.MaxQuantity {
return StartResult{}, ErrStartConflict
}
canonical, cents, ok := normalizeCents(price)
if !ok || canonical != price || cents.Cmp(ceiling) > 0 {
return StartResult{}, ErrStartConflict
}
if _, err := domain.TransitionTask(domain.TaskStatusDraft, domain.TaskStatusPending); err != nil {
return StartResult{}, err
}
id, err := NewCreateKey()
if err != nil {
return StartResult{}, err
}
next := version + 1
if _, err = tx.ExecContext(writeCtx, "INSERT INTO order_authorizations (id,task_id,task_version,start_key,goods_id,sku_color,sku_size,quantity,total_price_cap,status,created_by,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?,'ACTIVE',?,?,?)", id, item.TaskID, next, command.StartKey, goods, color, size, quantity, price, createdBy, now.Format(time.RFC3339Nano), expires.Format(time.RFC3339Nano)); err != nil {
return StartResult{}, err
}
updated, err := tx.ExecContext(writeCtx, "UPDATE tasks SET status='PENDING',version=version+1,updated_at=? WHERE id=? AND status='DRAFT' AND version=?", now.Format(time.RFC3339Nano), item.TaskID, version)
if err != nil {
return StartResult{}, err
}
affected, err := updated.RowsAffected()
if err != nil {
return StartResult{}, err
}
if affected != 1 {
return StartResult{}, ErrStartConflict
}
result.Tasks = append(result.Tasks, AuthorizedTask{TaskID: item.TaskID, TaskVersion: next, AuthorizationID: id, ExpiresAt: expires})
}
if err := tx.Commit(); err != nil {
return StartResult{}, err
}
return result, nil
}
func goodsIDValid(value string) bool {
if value == "" {
return false
}
for _, ch := range value {
if ch < '0' || ch > '9' {
return false
}
}
return true
}
func replayStart(ctx context.Context, tx *sql.Tx, startKey string, items []StartItem) (StartResult, bool, error) {
rows, err := tx.QueryContext(ctx, "SELECT id,task_id,task_version,expires_at FROM order_authorizations WHERE start_key=? ORDER BY task_id", startKey)
if err != nil {
return StartResult{}, false, err
}
defer rows.Close()
result := StartResult{StartKey: startKey, PaymentAutomated: false}
for rows.Next() {
var item AuthorizedTask
var expires string
if err := rows.Scan(&item.AuthorizationID, &item.TaskID, &item.TaskVersion, &expires); err != nil {
return StartResult{}, false, err
}
item.ExpiresAt, err = time.Parse(time.RFC3339Nano, expires)
if err != nil {
return StartResult{}, false, err
}
result.Tasks = append(result.Tasks, item)
}
if err := rows.Err(); err != nil {
return StartResult{}, false, err
}
if len(result.Tasks) == 0 {
return StartResult{}, false, nil
}
if len(result.Tasks) != len(items) {
return StartResult{}, false, ErrStartConflict
}
for i := range items {
if result.Tasks[i].TaskID != items[i].TaskID || result.Tasks[i].TaskVersion-1 != items[i].ExpectedTaskVersion {
return StartResult{}, false, ErrStartConflict
}
}
result.AuthorizedCount = len(result.Tasks)
return result, true, nil
}
+195
View File
@@ -0,0 +1,195 @@
package tasks
import (
"context"
"database/sql"
"errors"
"testing"
"time"
)
func TestListTasksTreatsLikeMetacharactersLiterally(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
created := "2026-08-04T01:00:00Z"
insertTaskRow(t, database, "percent", "100%纯棉", "100", "DRAFT", created)
insertTaskRow(t, database, "underscore", "尺码_A", "101", "DRAFT", created)
insertTaskRow(t, database, "backslash", `路径\名称`, "102", "DRAFT", created)
insertTaskRow(t, database, "plain", "普通商品", "103", "DRAFT", created)
for _, test := range []struct {
keyword string
wantID string
}{
{keyword: "%", wantID: "percent"},
{keyword: "_", wantID: "underscore"},
{keyword: `\`, wantID: "backslash"},
} {
t.Run(test.wantID, func(t *testing.T) {
rows, err := store.ListTasks(context.Background(), TaskFilter{Keyword: test.keyword})
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].ID != test.wantID {
t.Fatalf("keyword %q rows = %#v, want only %q", test.keyword, rows, test.wantID)
}
})
}
}
func TestListTasksSupportsEveryStatusAndEmptyMeansAll(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
statuses := []string{"DRAFT", "PENDING", "CLAIMED", "ORDERING", "NEEDS_MANUAL", "WAITING_PAYMENT", "RECONCILIATION_REQUIRED", "SUCCEEDED", "FAILED", "CANCELED"}
for index, status := range statuses {
insertTaskRow(t, database, status, status, "200", status, time.Date(2026, 8, 4, 1, 0, index, 0, time.UTC).Format(time.RFC3339Nano))
}
all, err := store.ListTasks(context.Background(), TaskFilter{})
if err != nil {
t.Fatal(err)
}
if len(all) != len(statuses) {
t.Fatalf("all-status rows = %d, want %d", len(all), len(statuses))
}
for _, status := range statuses {
rows, err := store.ListTasks(context.Background(), TaskFilter{Status: status})
if err != nil {
t.Fatalf("status %s: %v", status, err)
}
if len(rows) != 1 || rows[0].Status != status {
t.Fatalf("status %s rows = %#v", status, rows)
}
}
}
func TestListTasksUsesShanghaiHalfOpenDateRange(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
insertTaskRow(t, database, "before", "before", "300", "DRAFT", "2026-08-03T15:59:59Z")
insertTaskRow(t, database, "at-start", "at-start", "301", "DRAFT", "2026-08-03T16:00:00Z")
insertTaskRow(t, database, "before-end", "before-end", "302", "DRAFT", "2026-08-04T15:59:59Z")
insertTaskRow(t, database, "at-end", "at-end", "303", "DRAFT", "2026-08-04T16:00:00Z")
rows, err := store.ListTasks(context.Background(), TaskFilter{CreatedFrom: "2026-08-04", CreatedTo: "2026-08-04"})
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 || rows[0].ID != "before-end" || rows[1].ID != "at-start" {
t.Fatalf("Shanghai day rows = %#v, want [before-end at-start]", rows)
}
}
func TestListTasksBreaksEqualTimestampsByDescendingRowID(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
created := "2026-08-04T01:02:03Z"
insertTaskRow(t, database, "first", "first", "400", "DRAFT", created)
insertTaskRow(t, database, "second", "second", "401", "DRAFT", created)
rows, err := store.ListTasks(context.Background(), TaskFilter{})
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 || rows[0].ID != "second" || rows[1].ID != "first" {
t.Fatalf("equal-time rows = %#v, want descending rowid", rows)
}
}
func TestListTasksRejectsInvalidStatusAndDates(t *testing.T) {
store, err := NewSQLiteStore(migratedDatabase(t))
if err != nil {
t.Fatal(err)
}
for name, filter := range map[string]TaskFilter{
"status": {Status: "UNKNOWN"},
"from date": {CreatedFrom: "2026-02-30"},
"to date": {CreatedTo: "04/08/2026"},
"reverse range": {CreatedFrom: "2026-08-05", CreatedTo: "2026-08-04"},
} {
t.Run(name, func(t *testing.T) {
rows, err := store.ListTasks(context.Background(), filter)
if !errors.Is(err, ErrInvalidFilter) || rows != nil {
t.Fatalf("ListTasks(%#v) = (%#v, %v), want ErrInvalidFilter", filter, rows, err)
}
})
}
}
func TestStartPurchasesIsAtomicAndReplaysSameSet(t *testing.T) {
database := migratedDatabase(t)
store, err := NewSQLiteStore(database)
if err != nil {
t.Fatal(err)
}
store.SetStartPolicy(StartPolicy{AuthorizationTTL: time.Hour, MaxQuantity: 10, MaxTotalPrice: "999.99"})
store.now = func() time.Time { return time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC) }
for _, draft := range []Draft{testDraft(testKey, "one"), testDraft("b3c9f507-7473-4fa6-8d71-8786c34c6301", "two")} {
if _, err := store.CreateDraft(context.Background(), draft); err != nil {
t.Fatal(err)
}
}
command := StartCommand{StartKey: "c3c9f507-7473-4fa6-8d71-8786c34c6301", Tasks: []StartItem{{TaskID: "b3c9f507-7473-4fa6-8d71-8786c34c6301", ExpectedTaskVersion: 1}, {TaskID: testKey, ExpectedTaskVersion: 1}}}
first, err := store.StartPurchases(context.Background(), command, "admin")
if err != nil {
t.Fatal(err)
}
if first.AuthorizedCount != 2 || first.PaymentAutomated {
t.Fatalf("start result=%#v", first)
}
command.Tasks[0], command.Tasks[1] = command.Tasks[1], command.Tasks[0]
replay, err := store.StartPurchases(context.Background(), command, "admin")
if err != nil {
t.Fatal(err)
}
if replay.Tasks[0].AuthorizationID != first.Tasks[0].AuthorizationID || replay.Tasks[1].AuthorizationID != first.Tasks[1].AuthorizationID {
t.Fatalf("replay=%#v first=%#v", replay, first)
}
var pending, auths int
if err := database.QueryRow(`SELECT COUNT(*) FROM tasks WHERE status='PENDING' AND version=2`).Scan(&pending); err != nil || pending != 2 {
t.Fatalf("pending=%d err=%v", pending, err)
}
if err := database.QueryRow(`SELECT COUNT(*) FROM order_authorizations WHERE status='ACTIVE' AND created_by='admin'`).Scan(&auths); err != nil || auths != 2 {
t.Fatalf("auths=%d err=%v", auths, err)
}
_, err = store.StartPurchases(context.Background(), StartCommand{StartKey: command.StartKey, Tasks: command.Tasks[:1]}, "admin")
if !errors.Is(err, ErrStartConflict) {
t.Fatalf("subset err=%v", err)
}
}
func TestShanghaiRangeAndMoneyAreFailClosed(t *testing.T) {
start, end, err := ShanghaiRange("2026-08-04", "2026-08-04")
if err != nil || start.Format(time.RFC3339) != "2026-08-03T16:00:00Z" || end.Format(time.RFC3339) != "2026-08-04T16:00:00Z" {
t.Fatalf("range=(%s,%s,%v)", start, end, err)
}
for _, value := range []string{"0.01", "12.80", "999999999999999999999999.99"} {
if _, _, ok := normalizeCents(value); !ok {
t.Fatalf("money %q rejected", value)
}
}
for _, value := range []string{"1", "01.20", "0.00", "1.234", "1.", " 1.00", "1e2"} {
if _, _, ok := normalizeCents(value); ok {
t.Fatalf("money %q accepted", value)
}
}
}
func insertTaskRow(t *testing.T, database *sql.DB, id, title, goodsID, status, createdAt string) {
t.Helper()
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', ?, ?, '黑色', 'M', 2, '12.80', ?, 1, ?, ?)`, id, title, goodsID, status, createdAt, createdAt); err != nil {
t.Fatalf("insert task %s: %v", id, err)
}
}
+13 -7
View File
@@ -13,21 +13,27 @@ const sqliteWriteTimeout = 2 * time.Second
type Store interface { type Store interface {
CreateDraft(context.Context, Draft) (Draft, error) CreateDraft(context.Context, Draft) (Draft, error)
ListDrafts(context.Context) ([]Draft, error) ListDrafts(context.Context) ([]Draft, error)
ListTasks(context.Context, TaskFilter) ([]TaskRow, error)
StartPurchases(context.Context, StartCommand, string) (StartResult, error)
} }
type SQLiteStore struct { type SQLiteStore struct {
database *sql.DB database *sql.DB
now func() time.Time now func() time.Time
createGate chan struct{} writeGate chan struct{}
policy StartPolicy
} }
func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) { func NewSQLiteStore(database *sql.DB) (*SQLiteStore, error) {
if database == nil { if database == nil {
return nil, errors.New("database is required") return nil, errors.New("database is required")
} }
if _, err := database.Exec("SELECT 1 FROM tasks LIMIT 1"); err != nil { if _, err := database.Exec("SELECT task_version, start_key, total_price_cap FROM order_authorizations LIMIT 1"); err != nil {
return nil, fmt.Errorf("tasks migration is not available: %w", err) return nil, fmt.Errorf("tasks migration is not available: %w", err)
} }
return &SQLiteStore{database: database, now: time.Now, createGate: make(chan struct{}, 1)}, nil if _, err := database.Exec("SELECT 1 FROM purchase_attempts LIMIT 1"); err != nil {
return nil, fmt.Errorf("single-pass migration is not available: %w", err)
}
return &SQLiteStore{database: database, now: time.Now, writeGate: make(chan struct{}, 1)}, nil
} }
func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) { func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft, error) {
@@ -36,8 +42,8 @@ func (store *SQLiteStore) CreateDraft(ctx context.Context, draft Draft) (Draft,
// SQLite permits one writer at a time. Serializing this store's short create // SQLite permits one writer at a time. Serializing this store's short create
// transaction prevents concurrent retries of one create key from surfacing as busy. // transaction prevents concurrent retries of one create key from surfacing as busy.
select { select {
case store.createGate <- struct{}{}: case store.writeGate <- struct{}{}:
defer func() { <-store.createGate }() defer func() { <-store.writeGate }()
case <-writeContext.Done(): case <-writeContext.Done():
return Draft{}, writeContext.Err() return Draft{}, writeContext.Err()
} }
@@ -0,0 +1,211 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const source = fs.readFileSync(path.join(__dirname, "tasks.js"), "utf8");
test("visible button opens the same routed detail and close restores list state", async () => {
const harness = createDrawerHarness();
harness.button.listeners.click();
await harness.flush();
assert.equal(harness.requests.length, 1);
assert.equal(harness.requests[0].url, "/tasks/a3c9f507-7473-4fa6-8d71-8786c34c6301");
assert.equal(harness.requests[0].options.headers["X-CMBuyer-View"], "drawer");
assert.equal(harness.drawer.open, true);
assert.equal(harness.closeButton.focused, true);
assert.equal(harness.history.pushes.length, 1);
assert.equal(harness.history.pushes[0].url, harness.requests[0].url);
assert.equal(harness.history.pushes[0].state.focusTarget, "button");
harness.closeButton.listeners.click();
assert.equal(harness.history.backCalls, 1);
harness.popstate({state: {cmbuyerList: true}});
assert.equal(harness.drawer.open, false);
assert.equal(harness.button.focused, true);
assert.equal(harness.row.focused, false);
assert.equal(harness.scrolls.length, 1);
assert.equal(harness.scrolls[0].top, 275);
assert.equal(harness.scrolls[0].behavior, "auto");
});
test("button focus target survives back, forward, and back again", async () => {
const harness = createDrawerHarness();
harness.button.listeners.click();
await harness.flush();
const drawerState = harness.history.pushes[0].state;
harness.popstate({state: {cmbuyerList: true}});
assert.equal(harness.button.focusCalls, 1);
assert.equal(harness.row.focusCalls, 0);
harness.popstate({state: drawerState});
await harness.flush();
assert.equal(harness.drawer.open, true);
assert.equal(harness.history.pushes.length, 1);
harness.popstate({state: {cmbuyerList: true}});
assert.equal(harness.drawer.open, false);
assert.equal(harness.button.focusCalls, 2);
assert.equal(harness.row.focusCalls, 0);
});
test("failed forward retry reuses history and closes back to button in one step", async () => {
const harness = createDrawerHarness();
harness.button.listeners.click();
await harness.flush();
const drawerState = harness.history.pushes[0].state;
harness.popstate({state: {cmbuyerList: true}});
harness.failNextRequest();
harness.popstate({state: drawerState});
await harness.flush();
const retry = harness.body.children[1].children[0];
retry.listeners.click();
await harness.flush();
assert.equal(harness.history.pushes.length, 1);
assert.equal(harness.drawer.open, true);
harness.closeButton.listeners.click();
assert.equal(harness.history.backCalls, 1);
harness.popstate({state: {cmbuyerList: true}});
assert.equal(harness.drawer.open, false);
assert.equal(harness.button.focusCalls, 2);
assert.equal(harness.row.focusCalls, 0);
});
test("double click and Enter open rows but nested controls never do", async () => {
const harness = createDrawerHarness();
const ignored = {closest: () => ({})};
const rowTarget = {closest: () => null};
harness.row.listeners.dblclick({target: ignored});
harness.row.listeners.dblclick({target: rowTarget});
await harness.flush();
assert.equal(harness.requests.length, 1);
harness.popstate({state: {cmbuyerList: true}});
let prevented = false;
harness.row.listeners.keydown({key: "Enter", target: harness.row, preventDefault: () => { prevented = true; }});
await harness.flush();
assert.equal(prevented, true);
assert.equal(harness.requests.length, 2);
harness.row.listeners.keydown({key: "Enter", target: ignored, preventDefault: () => assert.fail("nested control Enter was intercepted")});
assert.equal(harness.requests.length, 2);
});
test("browser back and forward close and reopen without duplicating history", async () => {
const harness = createDrawerHarness();
harness.row.listeners.keydown({key: "Enter", target: harness.row, preventDefault() {}});
await harness.flush();
assert.equal(harness.history.pushes.length, 1);
harness.popstate({state: {cmbuyerList: true}});
assert.equal(harness.drawer.open, false);
harness.popstate({state: {cmbuyerDrawer: true, detailURL: harness.row.dataset.detailUrl}});
await harness.flush();
assert.equal(harness.drawer.open, true);
assert.equal(harness.requests.length, 2);
assert.equal(harness.history.pushes.length, 1);
});
test("Escape follows browser history and does not mutate list URL", async () => {
const harness = createDrawerHarness();
harness.button.listeners.click();
await harness.flush();
let prevented = false;
harness.drawer.listeners.cancel({preventDefault: () => { prevented = true; }});
assert.equal(prevented, true);
assert.equal(harness.history.backCalls, 1);
assert.equal(harness.history.replaces[0].url, "/tasks?status=DRAFT");
});
function createDrawerHarness() {
class FakeElement {
constructor() {
this.listeners = {};
this.dataset = {};
this.open = false;
this.focused = false;
this.focusCalls = 0;
this.children = [];
this._innerHTML = "";
}
addEventListener(type, listener) { this.listeners[type] = listener; }
focus() { this.focused = true; this.focusCalls++; }
showModal() { this.open = true; }
close() { this.open = false; }
replaceChildren(...children) { this.children = children; this._innerHTML = ""; }
append(...children) { this.children.push(...children); }
setAttribute() {}
closest() { return null; }
set innerHTML(value) { this._innerHTML = value; }
get innerHTML() { return this._innerHTML; }
}
const body = new FakeElement();
const closeButton = new FakeElement();
const button = new FakeElement();
const row = new FakeElement();
row.dataset.detailUrl = "/tasks/a3c9f507-7473-4fa6-8d71-8786c34c6301";
row.querySelector = (selector) => selector === "[data-open-detail]" ? button : null;
const drawer = new FakeElement();
drawer.querySelector = (selector) => ({"[data-detail-body]": body, "[data-close-detail]": closeButton})[selector] || null;
const requests = [];
const popstateListeners = [];
const scrolls = [];
let failNext = false;
const history = {
state: null,
pushes: [],
replaces: [],
backCalls: 0,
pushState(state, _title, url) { this.state = state; this.pushes.push({state, url}); },
replaceState(state, _title, url) { this.state = state; this.replaces.push({state, url}); },
back() { this.backCalls++; },
};
const document = {
querySelector: (selector) => selector === "[data-start-purchases]" ? null : selector === "[data-detail-drawer]" ? drawer : null,
querySelectorAll: (selector) => selector === "[data-task-row]" ? [row] : [],
createElement: () => new FakeElement(),
contains: (element) => element === row || element === button,
};
const window = {
location: {pathname: "/tasks", search: "?status=DRAFT"},
history,
scrollY: 275,
scrollTo: (value) => scrolls.push(value),
addEventListener(type, listener) { if (type === "popstate") popstateListeners.push(listener); },
};
const context = {
AbortController,
document,
window,
fetch: async (url, options) => {
requests.push({url, options});
if (failNext) {
failNext = false;
return {ok: false, headers: {get: () => "text/html"}, text: async () => ""};
}
return {ok: true, headers: {get: () => "text/html; charset=utf-8"}, text: async () => '<article data-task-detail-content>详情</article>'};
},
};
vm.runInNewContext(source, context, {filename: "tasks.js"});
return {
body, button, closeButton, drawer, history, requests, row, scrolls,
failNextRequest: () => { failNext = true; },
popstate: (event) => { history.state = event.state; popstateListeners.forEach((listener) => listener(event)); },
flush: () => new Promise((resolve) => setImmediate(resolve)),
};
}
@@ -0,0 +1,167 @@
(() => {
"use strict";
const form = document.querySelector("[data-start-purchases]");
if (!form) return;
const all = form.querySelector("[data-select-all]");
const summary = form.querySelector("[data-selection-summary]");
const button = form.querySelector("[data-start-button]");
const feedback = form.querySelector("[data-start-feedback]");
const boxes = () => [...form.querySelectorAll("input[data-task-id]")];
let selectionFrozen = false;
const parseCents = (value) => {
const match = /^(0|[1-9]\d*)\.(\d{2})$/.exec(value);
return match ? BigInt(match[1] + match[2]) : null;
};
const refresh = () => {
const available = boxes();
const selected = available.filter((box) => box.checked);
let cents = 0n;
let pricesValid = true;
selected.forEach((box) => {
const price = parseCents(box.dataset.price);
if (price === null) pricesValid = false;
else cents += price;
});
summary.textContent = `已选 ${selected.length} 条,最高总额 ¥${cents / 100n}.${(cents % 100n).toString().padStart(2, "0")}`;
button.disabled = !selected.length || !pricesValid;
if (!pricesValid) feedback.textContent = "所选任务金额无法安全汇总,请刷新后重选。";
if (all) {
all.checked = selected.length > 0 && selected.length === available.length;
all.indeterminate = selected.length > 0 && selected.length < available.length;
all.disabled = selectionFrozen || available.length === 0;
}
};
const freezeSelection = (frozen) => {
selectionFrozen = frozen;
boxes().forEach((box) => { box.disabled = frozen; });
refresh();
};
boxes().forEach((box) => box.addEventListener("change", refresh));
if (all) all.addEventListener("change", () => { boxes().forEach((box) => { box.checked = all.checked; }); refresh(); });
let frozenPayload = null;
let inFlight = false;
form.addEventListener("submit", async (event) => {
event.preventDefault();
const selected = boxes().filter((box) => box.checked);
if (!selected.length || inFlight) return;
const tasks = selected.map((box) => ({task_id: box.dataset.taskId, expected_task_version: Number(box.dataset.taskVersion)}));
if (tasks.some((item) => !Number.isSafeInteger(item.expected_task_version) || item.expected_task_version < 1)) { feedback.textContent = "任务版本无效,请刷新后重选。"; return; }
frozenPayload = frozenPayload || JSON.stringify({start_key: form.dataset.startKey, tasks});
inFlight = true; freezeSelection(true); button.disabled = true; button.textContent = "正在授权…";
try { const response = await fetch("/tasks/start-purchases", {method:"POST", headers:{"Content-Type":"application/json", "X-CSRF-Token":form.dataset.csrf}, body:frozenPayload});
if (response.ok) { window.location.reload(); return; }
if (response.status === 409) { feedback.textContent = "任务已变化,请刷新后重选。"; frozenPayload = null; freezeSelection(false); boxes().forEach((box) => { box.checked = false; }); refresh(); return; }
if (response.status === 400 || response.status === 401 || response.status === 403) { feedback.textContent = "请求未被接受,请刷新页面后重试。"; frozenPayload = null; freezeSelection(false); return; }
feedback.textContent = "结果暂时不明确,只能使用同一按钮原样重放。";
} catch (_) { feedback.textContent = "网络结果不明确,请使用同一按钮原样重试。"; }
finally { inFlight = false; button.textContent = "开始采购(只创建待付款订单)"; if (frozenPayload) button.disabled = false; }
});
refresh();
})();
(() => {
"use strict";
const drawer = document.querySelector("[data-detail-drawer]");
if (!drawer) return;
const body = drawer.querySelector("[data-detail-body]");
const closeButton = drawer.querySelector("[data-close-detail]");
const rows = [...document.querySelectorAll("[data-task-row]")];
const initialURL = window.location.pathname + window.location.search;
let focusTrigger = null;
let scrollPosition = window.scrollY;
let activeRequest = null;
const isInteractive = (target) => Boolean(target && typeof target.closest === "function" && target.closest("a,button,input,select,textarea,label,[contenteditable=true]"));
const showDrawer = () => {
if (!drawer.open) drawer.showModal();
};
const restoreList = () => {
if (activeRequest) {
activeRequest.abort();
activeRequest = null;
}
if (drawer.open) drawer.close();
window.scrollTo({top: scrollPosition, behavior: "auto"});
if (focusTrigger && document.contains(focusTrigger)) focusTrigger.focus({preventScroll: true});
};
const showError = (url, row, requestedFocus, pushHistory) => {
body.replaceChildren();
const message = document.createElement("p");
message.className = "drawer-feedback";
message.setAttribute("role", "alert");
message.textContent = "任务详情加载失败。请重试,或在完整页打开。";
const actions = document.createElement("p");
const retry = document.createElement("button");
retry.className = "button primary";
retry.type = "button";
retry.textContent = "重试";
retry.addEventListener("click", () => loadDetail(url, row, requestedFocus, pushHistory));
const fallback = document.createElement("a");
fallback.className = "button";
fallback.href = url;
fallback.textContent = "在完整页打开";
actions.className = "actions";
actions.append(retry, fallback);
body.append(message, actions);
};
const loadDetail = async (url, row, requestedFocus, pushHistory) => {
if (activeRequest) activeRequest.abort();
const requestController = new AbortController();
activeRequest = requestController;
focusTrigger = requestedFocus || focusTrigger;
if (pushHistory) scrollPosition = window.scrollY;
body.innerHTML = '<p class="drawer-feedback" role="status">正在加载任务详情…</p>';
showDrawer();
try {
const response = await fetch(url, {headers: {"X-CMBuyer-View": "drawer", "Accept": "text/html"}, credentials: "same-origin", signal: requestController.signal});
if (!response.ok || !String(response.headers.get("Content-Type") || "").toLowerCase().startsWith("text/html")) throw new Error("detail request rejected");
const fragment = await response.text();
if (!fragment.includes("data-task-detail-content")) throw new Error("detail fragment missing");
body.innerHTML = fragment;
if (pushHistory) window.history.pushState({cmbuyerDrawer: true, detailURL: url, focusTarget: requestedFocus === row ? "row" : "button"}, "", url);
closeButton.focus();
} catch (error) {
if (error.name !== "AbortError") showError(url, row, requestedFocus, pushHistory);
} finally {
if (activeRequest === requestController) activeRequest = null;
}
};
const requestClose = () => {
if (window.history.state && window.history.state.cmbuyerDrawer) window.history.back();
else restoreList();
};
window.history.replaceState({cmbuyerList: true, listURL: initialURL}, "", initialURL);
rows.forEach((row) => {
const url = row.dataset.detailUrl;
row.addEventListener("dblclick", (event) => {
if (!isInteractive(event.target)) loadDetail(url, row, row, true);
});
row.addEventListener("keydown", (event) => {
if (event.key === "Enter" && event.target === row) {
event.preventDefault();
loadDetail(url, row, row, true);
}
});
const button = row.querySelector("[data-open-detail]");
if (button) button.addEventListener("click", () => loadDetail(url, row, button, true));
});
closeButton.addEventListener("click", requestClose);
drawer.addEventListener("cancel", (event) => {
event.preventDefault();
requestClose();
});
window.addEventListener("popstate", (event) => {
if (event.state && event.state.cmbuyerDrawer) {
const row = rows.find((candidate) => candidate.dataset.detailUrl === event.state.detailURL);
if (!row) {
restoreList();
return;
}
const requestedFocus = event.state.focusTarget === "button" ? row.querySelector("[data-open-detail]") || row : row;
loadDetail(event.state.detailURL, row, requestedFocus, false);
return;
}
restoreList();
});
})();
@@ -0,0 +1,138 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const source = fs.readFileSync(path.join(__dirname, "tasks.js"), "utf8");
test("successful authorization sends numeric version and reloads", async () => {
const requests = [];
const harness = createHarness(async (_url, options) => {
requests.push(options);
return {ok: true, status: 200};
});
await harness.submit();
assert.equal(requests.length, 1);
assert.equal(requests[0].headers["Content-Type"], "application/json");
assert.equal(requests[0].headers["X-CSRF-Token"], "csrf-token");
const payload = JSON.parse(requests[0].body);
assert.equal(payload.start_key, "start-key");
assert.equal(typeof payload.tasks[0].expected_task_version, "number");
assert.equal(payload.tasks[0].expected_task_version, 7);
assert.equal(harness.reloads(), 1);
});
test("409 clears stale selection and requires a fresh choice", async () => {
const harness = createHarness(async () => ({ok: false, status: 409}));
await harness.submit();
assert.equal(harness.box.checked, false);
assert.equal(harness.box.disabled, false);
assert.equal(harness.button.disabled, true);
assert.match(harness.feedback.textContent, /任务已变化/);
});
for (const status of [400, 401, 403]) {
test(`${status} releases the frozen payload for a page refresh`, async () => {
const harness = createHarness(async () => ({ok: false, status}));
await harness.submit();
assert.equal(harness.box.checked, true);
assert.equal(harness.box.disabled, false);
assert.equal(harness.button.disabled, false);
assert.match(harness.feedback.textContent, /刷新页面后重试/);
});
}
test("5xx retries the byte-identical frozen payload", async () => {
const bodies = [];
const harness = createHarness(async (_url, options) => {
bodies.push(options.body);
return {ok: false, status: 503};
});
await harness.submit();
assert.equal(harness.box.disabled, true);
assert.equal(harness.button.disabled, false);
assert.match(harness.feedback.textContent, /原样重放/);
await harness.submit();
assert.equal(bodies.length, 2);
assert.equal(bodies[1], bodies[0]);
});
test("network ambiguity retries the same payload and can finish", async () => {
const bodies = [];
let call = 0;
const harness = createHarness(async (_url, options) => {
bodies.push(options.body);
call++;
if (call === 1) throw new Error("network result unknown");
return {ok: true, status: 200};
});
await harness.submit();
assert.equal(harness.box.disabled, true);
assert.match(harness.feedback.textContent, /原样重试/);
await harness.submit();
assert.deepEqual(bodies, [bodies[0], bodies[0]]);
assert.equal(harness.reloads(), 1);
});
function createHarness(fetchImplementation) {
class FakeElement {
constructor() {
this.dataset = {};
this.checked = false;
this.disabled = false;
this.indeterminate = false;
this.textContent = "";
this.listeners = {};
}
addEventListener(type, listener) {
this.listeners[type] = listener;
}
}
const box = new FakeElement();
box.checked = true;
box.dataset = {taskId: "task-id", taskVersion: "7", price: "12.80"};
const selectAll = new FakeElement();
const summary = new FakeElement();
const button = new FakeElement();
const feedback = new FakeElement();
const form = new FakeElement();
form.dataset = {startKey: "start-key", csrf: "csrf-token"};
form.querySelector = (selector) => ({
"[data-select-all]": selectAll,
"[data-selection-summary]": summary,
"[data-start-button]": button,
"[data-start-feedback]": feedback,
})[selector] || null;
form.querySelectorAll = (selector) => selector === "input[data-task-id]" ? [box] : [];
let reloadCount = 0;
const context = {
document: {querySelector: (selector) => selector === "[data-start-purchases]" ? form : null},
fetch: fetchImplementation,
window: {location: {reload: () => { reloadCount++; }}},
};
vm.runInNewContext(source, context, {filename: "tasks.js"});
return {
box,
button,
feedback,
reloads: () => reloadCount,
submit: () => form.listeners.submit({preventDefault() {}}),
};
}
@@ -0,0 +1,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
+127 -10
View File
@@ -3,16 +3,37 @@ package webui
import ( import (
"embed" "embed"
"fmt"
"html/template" "html/template"
"io" "io"
"time"
"cmbuyer/admin/internal/taskdetail"
"cmbuyer/admin/internal/tasks" "cmbuyer/admin/internal/tasks"
) )
//go:embed templates/*.html //go:embed templates/*.html
var templateFiles embed.FS var templateFiles embed.FS
var templates = template.Must(template.New("webui").Funcs(template.FuncMap{"list": func(values ...any) []any { return values }}).ParseFS(templateFiles, "templates/*.html")) //go:embed static/tasks.js
var tasksScript []byte
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
var templates = template.Must(template.New("webui").Funcs(template.FuncMap{
"list": func(values ...any) []any { return values },
"statusLabel": statusLabel,
"shanghaiDateTime": func(value time.Time) string { return value.In(shanghaiLocation).Format(time.RFC3339) },
"shanghaiTime": func(value time.Time) string { return value.In(shanghaiLocation).Format("2006-01-02 15:04") },
"canonicalURL": tasks.CanonicalURL,
"formatBytes": formatBytes,
"taskSafetyTitle": taskSafetyTitle,
"taskSafetyText": taskSafetyText,
"authorizationStatusLabel": authorizationStatusLabel,
"attemptStatusLabel": attemptStatusLabel,
"submissionStatusLabel": submissionStatusLabel,
"evidenceKindLabel": evidenceKindLabel,
}).ParseFS(templateFiles, "templates/*.html"))
// LoginData 是登录页面所需的非敏感展示数据。 // LoginData 是登录页面所需的非敏感展示数据。
type LoginData struct { type LoginData struct {
@@ -22,18 +43,24 @@ type LoginData struct {
Error string Error string
} }
// TasksData 是受保护的 DRAFT 建单与列表页面所需数据。 // TasksData 是受保护的建单与任务工作台页面所需数据。
type TasksData struct { type TasksData struct {
CSRFToken string CSRFToken string
Drafts []tasks.Draft Tasks []tasks.TaskRow
Form tasks.Form Filter tasks.TaskFilter
Errors tasks.Errors FilterErrors tasks.Errors
OpenForm bool HasFilter bool
FullPage bool StartKey string
FocusField string Form tasks.Form
Success bool Errors tasks.Errors
OpenForm bool
FullPage bool
FocusField string
Success bool
} }
type TaskDetailData struct{ Detail taskdetail.Detail }
// RenderLogin 写入登录页。 // RenderLogin 写入登录页。
func RenderLogin(writer io.Writer, data LoginData) error { func RenderLogin(writer io.Writer, data LoginData) error {
return templates.ExecuteTemplate(writer, "login.html", data) return templates.ExecuteTemplate(writer, "login.html", data)
@@ -43,3 +70,93 @@ func RenderLogin(writer io.Writer, data LoginData) error {
func RenderTasks(writer io.Writer, data TasksData) error { func RenderTasks(writer io.Writer, data TasksData) error {
return templates.ExecuteTemplate(writer, "tasks.html", data) return templates.ExecuteTemplate(writer, "tasks.html", data)
} }
func RenderTaskDetailPage(writer io.Writer, data TaskDetailData) error {
return templates.ExecuteTemplate(writer, "task-detail-page.html", data)
}
func RenderTaskDetailFragment(writer io.Writer, data TaskDetailData) error {
return templates.ExecuteTemplate(writer, "task-detail-content", data)
}
func TasksScript() []byte { return tasksScript }
func statusLabel(status string) string {
labels := map[string]string{
"DRAFT": "待开始",
"PENDING": "已授权待领取",
"CLAIMED": "已领取",
"ORDERING": "执行中",
"NEEDS_MANUAL": "待人工处理",
"WAITING_PAYMENT": "待付款",
"RECONCILIATION_REQUIRED": "围栏后待调和",
"SUCCEEDED": "已完成",
"FAILED": "失败",
"CANCELED": "已取消",
}
if label, ok := labels[status]; ok {
return label
}
return "未知状态"
}
func taskSafetyTitle(status string) string {
if status == "WAITING_PAYMENT" {
return "订单已创建,系统尚未付款。"
}
if status == "RECONCILIATION_REQUIRED" {
return "订单可能已创建,只能调和同一提交。"
}
return "系统只创建待付款订单,不会自动付款。"
}
func taskSafetyText(status string) string {
if status == "DRAFT" {
return "创建任务不构成授权;请回到列表勾选后开始采购。"
}
if status == "RECONCILIATION_REQUIRED" {
return "围栏保持占用,禁止重新授权、再次提交或释放。"
}
return "截图只供内部审计,不替代实时价格闸门,也不会触发设备动作。"
}
func authorizationStatusLabel(status string) string {
labels := map[string]string{"ACTIVE": "授权有效", "CLAIMED": "已被领取", "FENCED": "提交围栏已建立", "CONSUMED": "授权已消费", "EXPIRED": "授权已过期", "ABANDONED": "授权已关闭"}
if value, ok := labels[status]; ok {
return value
}
return "未知授权状态"
}
func attemptStatusLabel(status string) string {
labels := map[string]string{"CLAIMED": "已领取", "ORDERING": "执行中", "FAILED": "围栏前失败", "FENCED": "已建立围栏", "ABANDONED": "已安全停止"}
if value, ok := labels[status]; ok {
return value
}
return "未知执行状态"
}
func submissionStatusLabel(status string) string {
labels := map[string]string{"FENCED": "围栏已建立", "SUBMITTED": "已创建待付款订单", "RECONCILIATION_REQUIRED": "结果待调和", "MANUAL_RESOLVED": "已人工调和"}
if value, ok := labels[status]; ok {
return value
}
return "未知提交状态"
}
func evidenceKindLabel(kind string) string {
if kind == "SKU_PANEL_GATE_1" {
return "规格面板 · 闸门一"
}
return "内部截图"
}
func formatBytes(value int64) string {
if value >= 1<<20 {
return fmt.Sprintf("%.1f MiB", float64(value)/(1<<20))
}
if value >= 1<<10 {
return fmt.Sprintf("%.1f KiB", float64(value)/(1<<10))
}
return fmt.Sprintf("%d B", value)
}
@@ -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;
+34
View File
@@ -246,6 +246,27 @@ CREATE TABLE order_submissions (
UNIQUE (authorization_id), UNIQUE (authorization_id),
UNIQUE (attempt_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`。实际读价属于 MVP 不再用 `spec_trials` 作为审批记录,也不存在 `authorized_unit_price`。实际读价属于
@@ -306,6 +327,19 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED
截图上传器只能接收调用方显式指定的截图,不能枚举证据目录或顺带上传 XML/manifest。证据响应 截图上传器只能接收调用方显式指定的截图,不能枚举证据目录或顺带上传 XML/manifest。证据响应
使用 `Cache-Control: no-store`,不能暴露为免登录静态目录。 使用 `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` 同载荷重放原资产,任一规范字段变化即冲突。
## 六、关键技术难点 ## 六、关键技术难点
| 难点 | 风险 | 应对 | | 难点 | 风险 | 应对 |
+25 -4
View File
@@ -53,6 +53,14 @@
| `POST` | `/tasks/{id}/mark-paid` | 人工确认已付款并完成核对 | | `POST` | `/tasks/{id}/mark-paid` | 人工确认已付款并完成核对 |
| `GET` | `/evidence/{asset_id}` | 登录后读取内部截图;`Cache-Control: no-store` | | `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` ### `POST /tasks`
核心字段: 核心字段:
@@ -190,17 +198,30 @@
```json ```json
{ {
"attempt_id": "018f-attempt", "upload_key": "43c9f507-7473-4fa6-8d71-8786c34c6301",
"attempt_id": "33c9f507-7473-4fa6-8d71-8786c34c6301",
"kind": "SKU_PANEL_GATE_1", "kind": "SKU_PANEL_GATE_1",
"privacy_tier": "INTERNAL_RAW", "privacy_tier": "INTERNAL_RAW",
"sha256": "64-lowercase-hex", "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"captured_at": "2026-08-04T09:01:00Z" "captured_at": "2026-08-04T09:01:00Z"
} }
``` ```
- 允许规格面板和确认页截图保留页面已显示的地址/手机号;不要求遮罩或裁剪。 - 允许规格面板和确认页截图保留页面已显示的地址/手机号;不要求遮罩或裁剪。
- 不接受 XML、目录、manifest、本机绝对路径、外部支付页截图或支付凭据。 - 不接受 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` ### `POST /api/v1/purchase-attempts/{aid}/submission-fence`
@@ -310,6 +331,6 @@ T-103 只实现隔离的 `SkuSelectionFlow`:前四项加安全退出。它的
## 四、实现前仍需定值 ## 四、实现前仍需定值
- 授权有效期、领取租约时长、心跳/轮询间隔和连续失败停止阈值; - 授权有效期、领取租约时长、心跳/轮询间隔和连续失败停止阈值;
- 截图大小上限和内部保留期限; - 内部截图保留期限;截图大小上限已固定为 10 MiB / 8192 px 单边 / 16,777,216 像素;
- 可配置单任务数量与最高总价系统上限; - 可配置单任务数量与最高总价系统上限;
- 首次真实提交真机任务的人工授权和待付款订单处置步骤。 - 首次真实提交真机任务的人工授权和待付款订单处置步骤。
+11 -1
View File
@@ -47,7 +47,9 @@
| 创建时间 | 本地时区显示,数据按 UTC 保存 | | 创建时间 | 本地时区显示,数据按 UTC 保存 |
没有操作列。双击非控件区域或键盘 Enter 打开 `/tasks/{id}` 路由化详情抽屉;新 tab 直接访问同 URL 没有操作列。双击非控件区域或键盘 Enter 打开 `/tasks/{id}` 路由化详情抽屉;新 tab 直接访问同 URL
则显示完整详情页。关闭抽屉或浏览器返回恢复筛选、滚动和触发行焦点。 则显示完整详情页。标题下方同时提供可见“查看详情”按钮,双击不是唯一入口。商品外链、checkbox、
输入和按钮本身不触发行双击。抽屉成功加载后才把 URL 推进 `/tasks/{id}`;关闭、Esc 或浏览器返回
恢复筛选、滚动和触发行焦点,浏览器前进重新打开同一详情且不重复写 history。
### 批量开始采购 ### 批量开始采购
@@ -91,6 +93,14 @@
详情中不出现 `WAITING_CONFIRMATION`、“确认机器选对了吗”、“签发第二趟授权”或“重新试选”。 详情中不出现 `WAITING_CONFIRMATION`、“确认机器选对了吗”、“签发第二趟授权”或“重新试选”。
完整页与抽屉执行同一 `task-detail-content` 模板和只读查询。直接导航返回完整 SSR 文档;列表 JS 对
同一 URL 发出同源 `X-CMBuyer-View: drawer` 请求,只取得 HTML fragment。加载失败时抽屉提供重试和
“在完整页打开”,不会把失败请求伪装成已打开详情。
T-204 只显示数据库中当前实际存在的任务、授权、attempt、submission 和内部截图,缺少事实就显示
明确空态;它不创建 attempt/event,不计算闸门,也不提供重置、调和、标记付款或任何设备动作。
截图以服务端记录的宽高预留布局并延迟加载,alt 只描述证据种类和采集时间,不转录截图中的地址或手机号。
## 四、采购工具界面结构 ## 四、采购工具界面结构
应用名:**采购工具**。顶部固定 tab: 应用名:**采购工具**。顶部固定 tab:
-4
View File
@@ -19,10 +19,6 @@ write_paths:
- client/scripts/capture_sku_panel_spike.py - client/scripts/capture_sku_panel_spike.py
- client/scripts/run_t103_sku_selection.py - client/scripts/run_t103_sku_selection.py
- client/scripts/sanitize_sku_panel_evidence.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 - docs/current-state.md
--- ---
+11 -2
View File
@@ -3,7 +3,7 @@ id: T-203
title: 表格查询与批量开始采购授权 title: 表格查询与批量开始采购授权
phase: 2 phase: 2
deps: [T-202, T-209] deps: [T-202, T-209]
status: DOING status: DONE
created: 2026-08-04 created: 2026-08-04
vikunja_task_id: 30 vikunja_task_id: 30
context_ref: 1f20271 context_ref: 1f20271
@@ -12,6 +12,7 @@ needs_device: false
needs_human_review: false needs_human_review: false
write_paths: write_paths:
- docs/tasks/T-203.md - docs/tasks/T-203.md
- admin/internal/auth/**
- admin/internal/tasks/** - admin/internal/tasks/**
- admin/internal/server/** - admin/internal/server/**
- admin/internal/transport/webui/** - admin/internal/transport/webui/**
@@ -20,7 +21,7 @@ write_paths:
- admin/README.md - 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 任务。 T-202 已完成手工 DRAFT 建单;T-209 将生产 schema/领域状态机迁移到单趟模型。项目所有者明确:管理员点击“开始采购(只创建待付款订单)”本身就是授权,不再增加试选后确认。T-203 负责采购服务查询和批量授权事务,使设备后续只能领取显式授权的 PENDING 任务。
@@ -53,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-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-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 --> <!-- END VIKUNJA EXPORT -->
## 边界 ## 边界
+101
View File
@@ -0,0 +1,101 @@
---
id: T-204
title: 路由化任务详情与内部截图资产
phase: 2
deps: [T-203]
status: DOING
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-04T10:42:09Z sha256=2bd6675f2249b461d72b5e0a066da90e33af8023c4b97b52f0d77a8eb391c034 -->
## 问题 / 背景
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。
<!-- 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()`、支付、免密支付、先用后付或任何扣款能力;
既有三道价格闸门、服务端提交围栏、唯一点击一次且不重试的规则不得放宽。