Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89648880bc | ||
|
|
9a4d11f74b | ||
|
|
79284576ef | ||
|
|
20aaba0919 | ||
|
|
03a067e29d | ||
|
|
5dcff4b15a | ||
|
|
ce9d6ca285 | ||
|
|
44586fef38 | ||
|
|
e379d50101 | ||
|
|
13547728fc | ||
|
|
0b5c561ed6 | ||
|
|
5f650f18b1 | ||
|
|
b49a9b4abe | ||
|
|
96f774ed96 | ||
|
|
1f20271366 | ||
|
|
27726f4dde | ||
|
|
f85ef5f714 | ||
|
|
e20457b6db | ||
|
|
b5f45b87a5 |
@@ -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 或共享密钥绕过该边界。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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) == "" {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"} {
|
||||||
|
contents, err := os.ReadFile(filepath.Join(migrationDirectory(t), name))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read migration: %v", err)
|
t.Fatalf("read %s: %v", name, err)
|
||||||
}
|
}
|
||||||
if strings.Contains(strings.ToUpper(string(contents)), "PRAGMA FOREIGN_KEYS = OFF") {
|
if strings.Contains(strings.ToUpper(string(contents)), "PRAGMA FOREIGN_KEYS = OFF") {
|
||||||
t.Fatal("migration disables foreign keys")
|
t.Fatalf("%s disables foreign keys", name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
if err != nil {
|
||||||
context.Status(http.StatusInternalServerError)
|
context.Status(http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data := webui.TasksData{CSRFToken: csrfToken, Drafts: drafts}
|
renderTasks(context, http.StatusBadRequest, webui.TasksData{CSRFToken: csrfToken, Filter: filter, FilterErrors: validation, HasFilter: true, StartKey: startKey})
|
||||||
for _, draft := range drafts {
|
return
|
||||||
if draft.ID == context.Query("created") {
|
}
|
||||||
|
data, err := taskListData(context, options, csrfToken, filter)
|
||||||
|
if err != nil {
|
||||||
|
context.Status(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, row := range data.Tasks {
|
||||||
|
if row.ID == context.Query("created") {
|
||||||
data.Success = true
|
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)
|
||||||
|
|||||||
@@ -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+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,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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{"测试<script>", "订单已创建,系统尚未付款", "SKU_PANEL_GATE_1", "/evidence/b3c9f507-7473-4fa6-8d71-8786c34c6301", "暂无规格、价格或数量读数", "本页没有重试、再次提交或付款动作"} {
|
||||||
|
if !strings.Contains(full.Body.String(), text) || !strings.Contains(fragment.Body.String(), text) {
|
||||||
|
t.Fatalf("shared detail missing %q", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(full.Body.String(), "<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
|
||||||
|
}
|
||||||
@@ -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[:])
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ©
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package taskdetail
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cmbuyer/admin/internal/migrations"
|
||||||
|
"cmbuyer/admin/internal/storage/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
detailTask = "a3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||||
|
detailAuth = "b3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||||
|
detailTry = "c3c9f507-7473-4fa6-8d71-8786c34c6301"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSQLiteStoreReturnsOnlyPersistedAuditFacts(t *testing.T) {
|
||||||
|
database := openDetailDatabase(t)
|
||||||
|
timestamp := "2026-08-04T00:00:00Z"
|
||||||
|
if _, err := database.Exec(`INSERT INTO tasks (id, source, title, goods_id, sku_color, sku_size, quantity, max_total_price, status, version, created_at, updated_at) VALUES (?, 'MANUAL', 'shirt', '123', 'black', 'M', 2, '30.00', 'CLAIMED', 3, ?, ?)`, detailTask, timestamp, timestamp); err != nil {
|
||||||
|
t.Fatalf("insert task: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := database.Exec(`INSERT INTO order_authorizations (id, task_id, task_version, start_key, goods_id, sku_color, sku_size, quantity, total_price_cap, status, created_by, created_at, expires_at) VALUES (?, ?, 2, 'start', '123', 'black', 'M', 2, '30.00', 'CLAIMED', 'admin', ?, ?)`, detailAuth, detailTask, timestamp, timestamp); err != nil {
|
||||||
|
t.Fatalf("insert authorization: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := database.Exec(`INSERT INTO purchase_attempts (id, task_id, authorization_id, claim_generation, status, started_at) VALUES (?, ?, ?, 1, 'CLAIMED', ?)`, detailTry, detailTask, detailAuth, timestamp); err != nil {
|
||||||
|
t.Fatalf("insert attempt: %v", err)
|
||||||
|
}
|
||||||
|
hash := strings.Repeat("a", 64)
|
||||||
|
if _, err := database.Exec(`INSERT INTO evidence_assets (id, upload_key, task_id, attempt_id, kind, privacy_tier, sha256, byte_size, content_type, width_px, height_px, storage_key, uploaded_by_device_id, captured_at, created_at) VALUES ('d3c9f507-7473-4fa6-8d71-8786c34c6301', 'upload', ?, ?, 'SKU_PANEL_GATE_1', 'INTERNAL_RAW', ?, 100, 'image/png', 10, 20, ?, 'device', ?, ?)`, detailTask, detailTry, hash, "aa/"+hash+".png", timestamp, timestamp); err != nil {
|
||||||
|
t.Fatalf("insert evidence: %v", err)
|
||||||
|
}
|
||||||
|
store, err := NewSQLiteStore(database)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSQLiteStore: %v", err)
|
||||||
|
}
|
||||||
|
detail, err := store.Get(context.Background(), detailTask)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get: %v", err)
|
||||||
|
}
|
||||||
|
if detail.Task.ID != detailTask || detail.Task.Status != "CLAIMED" || len(detail.Authorizations) != 1 || len(detail.Attempts) != 1 || len(detail.Evidence) != 1 || len(detail.Submissions) != 0 {
|
||||||
|
t.Fatalf("detail = %#v", detail)
|
||||||
|
}
|
||||||
|
if detail.Attempts[0].Gate1UnitPrice != nil || detail.Attempts[0].FailureCode != nil {
|
||||||
|
t.Fatalf("missing attempt facts were fabricated: %#v", detail.Attempts[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSQLiteStoreFailsClosedForMalformedAndMissingIDs(t *testing.T) {
|
||||||
|
database := openDetailDatabase(t)
|
||||||
|
store, err := NewSQLiteStore(database)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSQLiteStore: %v", err)
|
||||||
|
}
|
||||||
|
for _, id := range []string{"../database", "not-a-uuid", "a3c9f507-7473-1fa6-8d71-8786c34c6301"} {
|
||||||
|
if _, err := store.Get(context.Background(), id); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Fatalf("Get(%q) error = %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openDetailDatabase(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
database, err := sqlite.Open(filepath.Join(t.TempDir(), "details.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open database: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.Close() })
|
||||||
|
_, file, _, ok := runtime.Caller(0)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("locate migration directory")
|
||||||
|
}
|
||||||
|
if err := migrations.Up(context.Background(), database, filepath.Join(filepath.Dir(file), "..", "..", "migrations")); err != nil {
|
||||||
|
t.Fatalf("migrate database: %v", err)
|
||||||
|
}
|
||||||
|
return database
|
||||||
|
}
|
||||||
@@ -0,0 +1,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,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
@@ -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,10 +43,14 @@ 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
|
||||||
|
Filter tasks.TaskFilter
|
||||||
|
FilterErrors tasks.Errors
|
||||||
|
HasFilter bool
|
||||||
|
StartKey string
|
||||||
Form tasks.Form
|
Form tasks.Form
|
||||||
Errors tasks.Errors
|
Errors tasks.Errors
|
||||||
OpenForm bool
|
OpenForm bool
|
||||||
@@ -34,6 +59,8 @@ type TasksData struct {
|
|||||||
Success bool
|
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;
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""恢复 T-103 已取证目标规格、验证现价并保存本地原始截图。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from math import isfinite
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
CLIENT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(CLIENT_ROOT / "src"))
|
||||||
|
|
||||||
|
from cmbuyer_client.device.adb import AdbClient, DeviceConnectionError, SubprocessAdbRunner
|
||||||
|
from cmbuyer_client.device.baseline import NoReconnectUiautomatorConnector
|
||||||
|
from cmbuyer_client.pdd.product_url import ProductUrlError, parse_product_url
|
||||||
|
from cmbuyer_client.pdd.sku_selection import EXPECTED_GOODS_ID, SkuSelectionError, TASK_TO_UI_SELECTION
|
||||||
|
from cmbuyer_client.pdd.sku_selection_runner import SkuSelectionRunError, SkuSelectionRunner
|
||||||
|
|
||||||
|
|
||||||
|
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="恢复 T-103 已取证规格并保存本地原始截图。")
|
||||||
|
parser.add_argument("--serial", required=True, help="ADB device serial;禁止自动选择。")
|
||||||
|
parser.add_argument("--url", required=True, help="唯一 canonical goods.html?goods_id= 直链。")
|
||||||
|
parser.add_argument("--color", required=True, help="T-103 任务颜色值。")
|
||||||
|
parser.add_argument("--size", required=True, help="T-103 任务尺码值。")
|
||||||
|
parser.add_argument("--output-dir", required=True, type=Path, help="新建本地目录;不得覆盖已有目录。")
|
||||||
|
parser.add_argument("--timeout", type=float, default=10.0, help="ADB 与设备 RPC 超时(秒)。")
|
||||||
|
parser.add_argument("--adb", default="adb", help="adb 可执行文件路径。")
|
||||||
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_arguments(arguments: argparse.Namespace) -> None:
|
||||||
|
if not isinstance(arguments.serial, str) or not arguments.serial.strip():
|
||||||
|
raise ValueError("必须显式提供非空 --serial。")
|
||||||
|
if not isinstance(arguments.timeout, (int, float)) or isinstance(arguments.timeout, bool) or arguments.timeout <= 0 or not isfinite(arguments.timeout):
|
||||||
|
raise ValueError("--timeout 必须是大于 0 的有限数值。")
|
||||||
|
link = parse_product_url(arguments.url)
|
||||||
|
if link.goods_id != EXPECTED_GOODS_ID:
|
||||||
|
raise ValueError("--url 不是 T-103 已取证商品。")
|
||||||
|
if (arguments.color, arguments.size) not in TASK_TO_UI_SELECTION:
|
||||||
|
raise ValueError("--color 与 --size 必须是 T-103 已取证任务值。")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
arguments = parse_arguments(argv)
|
||||||
|
try:
|
||||||
|
validate_arguments(arguments)
|
||||||
|
except (ValueError, ProductUrlError) as error:
|
||||||
|
print(f"失败:{error}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
try:
|
||||||
|
import adbutils
|
||||||
|
import uiautomator2 as u2
|
||||||
|
except ImportError:
|
||||||
|
print("失败:缺少 uiautomator2;请在采购工具虚拟环境中运行。", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
runner = SkuSelectionRunner(
|
||||||
|
AdbClient(SubprocessAdbRunner(arguments.adb), timeout_seconds=arguments.timeout),
|
||||||
|
NoReconnectUiautomatorConnector(adbutils.AdbClient(socket_timeout=arguments.timeout).device_list, u2.connect),
|
||||||
|
timeout_seconds=arguments.timeout,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = runner.run(arguments.serial, arguments.url, arguments.color, arguments.size, arguments.output_dir)
|
||||||
|
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError) as error:
|
||||||
|
# Flow 可能来自测试替身或未来实现;CLI 不回显任何异常正文,避免泄露节点树或页面文本。
|
||||||
|
print("规格恢复失败:已停止,未发布本地证据目录。", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
except OSError:
|
||||||
|
print("规格恢复失败:无法创建或发布本地证据目录。", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"规格恢复完成:{result.output_directory}")
|
||||||
|
print(f"manifest:{result.manifest_path}")
|
||||||
|
print(f"目标规格:{arguments.color} / {arguments.size}")
|
||||||
|
print(f"确认单价:{result.unit_price}")
|
||||||
|
print("页面对应性:请人工核对本地原始截图。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -1,15 +1,23 @@
|
|||||||
"""拼多多链接的受限打开与只读取证。
|
"""拼多多链接的受限打开、只读取证与经取证的规格面板选择。
|
||||||
|
|
||||||
此包不提供页面选择器、输入、滑动、下单或支付能力。
|
此包不提供通用页面选择器、输入、滑动或任何订单动作。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .product_open import ProductOpenCapturer, ProductOpenResult
|
from .product_open import ProductOpenCapturer, ProductOpenResult
|
||||||
from .product_url import ProductUrl, ProductUrlError, parse_product_url
|
from .product_url import ProductUrl, ProductUrlError, parse_product_url
|
||||||
|
from .sku_selection import SkuSelection, SkuSelectionError, SkuSelectionFlow
|
||||||
|
from .sku_selection_runner import SkuSelectionRunError, SkuSelectionRunResult, SkuSelectionRunner
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ProductOpenCapturer",
|
"ProductOpenCapturer",
|
||||||
"ProductOpenResult",
|
"ProductOpenResult",
|
||||||
"ProductUrl",
|
"ProductUrl",
|
||||||
"ProductUrlError",
|
"ProductUrlError",
|
||||||
|
"SkuSelection",
|
||||||
|
"SkuSelectionError",
|
||||||
|
"SkuSelectionFlow",
|
||||||
|
"SkuSelectionRunError",
|
||||||
|
"SkuSelectionRunResult",
|
||||||
|
"SkuSelectionRunner",
|
||||||
"parse_product_url",
|
"parse_product_url",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
"""T-103:仅限已取证 PDD 8.17.0 的规格面板恢复。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import re
|
||||||
|
from time import monotonic, sleep
|
||||||
|
from typing import Any, Callable, Protocol
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
|
from ..device.baseline import PDD_PACKAGE
|
||||||
|
from .product_open import EXPECTED_PDD_VERSION
|
||||||
|
from .product_url import parse_product_url
|
||||||
|
|
||||||
|
EXPECTED_GOODS_ID = "937122477375"
|
||||||
|
EXPECTED_UNIT_PRICE = "12.88"
|
||||||
|
# 任务值不是页面判据;右侧是 v5 取证的唯一 accessibility 文案(空格/全角括号均有意义)。
|
||||||
|
TASK_TO_UI_SELECTION = {("黑色CHA(纯棉)", "M(建议100-115)"): ("黑色 CHA (纯棉)", "M(建议100-115)")}
|
||||||
|
_TARGET_COLOR_UI, _TARGET_SIZE_UI = next(iter(TASK_TO_UI_SELECTION.values()))
|
||||||
|
_ENTRY = "快要抢光"
|
||||||
|
_ENTRY_TEXT_BOUNDS = "[900,1312][1056,1355]"
|
||||||
|
_ENTRY_INNER_BOUNDS = "[712,1312][1056,1355]"
|
||||||
|
_ENTRY_ACTION_BOUNDS = "[0,1256][1080,1355]"
|
||||||
|
_SIZE = "尺码"
|
||||||
|
_W, _H = 1080, 2376
|
||||||
|
_PRICE_PARENT = "[396,498][895,570]"
|
||||||
|
_CURRENT = "[396,503][712,570]"
|
||||||
|
_ORIGINAL = "[730,503][895,570]"
|
||||||
|
_SUMMARY = "[396,654][1053,716]"
|
||||||
|
_COLOR_REGION = "[36,1000][1080,1631]"
|
||||||
|
_SIZE_LABEL = "[36,1654][114,1700]"
|
||||||
|
_SIZE_HEADER = "[36,1637][1044,1718]"
|
||||||
|
_SIZE_OPTIONS = "[36,1730][1044,2045]"
|
||||||
|
_BOUNDS = re.compile(r"^\[(\d+),(\d+)\]\[(\d+),(\d+)\]$")
|
||||||
|
_PRICE = re.compile(r"^[^0-9¥¥]*[¥¥]([1-9][0-9]*\.[0-9]{2})$")
|
||||||
|
_ORIGINAL_PRICE = re.compile(r"^[¥¥][1-9][0-9]*\.[0-9]{2}$")
|
||||||
|
_BAD_PRICE_ROLE = ("提交订单", "支付", "优惠", "券", "会员", "补贴", "区间", "实付", "到手", "原价", "划线价", "最低", "低至", "起价", "下单", "先用后付", "预估")
|
||||||
|
|
||||||
|
|
||||||
|
class SkuSelectionError(RuntimeError):
|
||||||
|
"""已取证判据不成立时的脱敏停止。"""
|
||||||
|
|
||||||
|
|
||||||
|
class SkuPanelDevice(Protocol):
|
||||||
|
def app_info(self, package_name: str) -> dict[str, Any]: ...
|
||||||
|
def app_current(self) -> dict[str, Any]: ...
|
||||||
|
def dump_window_hierarchy(self) -> str: ...
|
||||||
|
def tap_sku_entry(self, bounds: str) -> None: ...
|
||||||
|
def tap_sku_option(self, bounds: str) -> None: ...
|
||||||
|
def leave_sku_panel(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SkuSelection:
|
||||||
|
color: str
|
||||||
|
size: str
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_task_selection(color: str, size: str) -> SkuSelection:
|
||||||
|
mapped = TASK_TO_UI_SELECTION.get((color, size))
|
||||||
|
if mapped is None:
|
||||||
|
raise SkuSelectionError("规格任务值不是已取证的唯一目标,已停止操作。")
|
||||||
|
return SkuSelection(*mapped)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Node:
|
||||||
|
element: ElementTree.Element
|
||||||
|
parent: "_Node | None"
|
||||||
|
@property
|
||||||
|
def text(self) -> str: return self.element.get("text", "")
|
||||||
|
@property
|
||||||
|
def desc(self) -> str: return self.element.get("content-desc", "")
|
||||||
|
@property
|
||||||
|
def bounds(self) -> str: return self.element.get("bounds", "")
|
||||||
|
|
||||||
|
|
||||||
|
class SkuSelectionFlow:
|
||||||
|
def __init__(self, device: SkuPanelDevice, entry_wait_timeout_seconds: float = 0.2,
|
||||||
|
entry_poll_interval_seconds: float = 0.2, monotonic_clock: Callable[[], float] = monotonic,
|
||||||
|
sleep_function: Callable[[float], None] = sleep) -> None:
|
||||||
|
if entry_wait_timeout_seconds < 0 or entry_poll_interval_seconds <= 0:
|
||||||
|
raise ValueError("入口等待参数无效。")
|
||||||
|
self._device, self._entry_timeout, self._poll = device, entry_wait_timeout_seconds, entry_poll_interval_seconds
|
||||||
|
self._clock, self._sleep = monotonic_clock, sleep_function
|
||||||
|
self._pending: tuple[str, Callable[[list[_Node]], Any]] | None = None
|
||||||
|
|
||||||
|
def open_sku_panel(self, product_url: str, pre_intent_hierarchy: str | None = None) -> None:
|
||||||
|
if parse_product_url(product_url).goods_id != EXPECTED_GOODS_ID:
|
||||||
|
raise SkuSelectionError("商品不是已取证目标,已停止操作。")
|
||||||
|
if pre_intent_hierarchy is not None:
|
||||||
|
previous_nodes = _parse_nodes(pre_intent_hierarchy)
|
||||||
|
if _eligible_entries(previous_nodes):
|
||||||
|
raise SkuSelectionError("intent 前页面已出现规格入口,已拒绝旧商品误点。")
|
||||||
|
entry, before = self._wait_for_entry(pre_intent_hierarchy)
|
||||||
|
_action_bounds(entry.bounds)
|
||||||
|
self._pending = (before, _panel)
|
||||||
|
self._device.tap_sku_entry(entry.bounds)
|
||||||
|
self._wait_after_action(before, _panel)
|
||||||
|
|
||||||
|
def select_sku_options(self, selection: SkuSelection) -> None:
|
||||||
|
if selection not in {SkuSelection(*item) for item in TASK_TO_UI_SELECTION.values()}:
|
||||||
|
raise SkuSelectionError("规格 UI 文案不是获准目标,已停止操作。")
|
||||||
|
initial = self._verified_nodes()
|
||||||
|
_option(initial, "color", selection.color); _option(initial, "size", selection.size)
|
||||||
|
_selected_label(initial, "color"); _selected_label(initial, "size")
|
||||||
|
self._restore("color", selection.color)
|
||||||
|
self._restore("size", selection.size)
|
||||||
|
|
||||||
|
def read_sku_unit_price(self) -> str:
|
||||||
|
return _unit_price(self._verified_nodes())
|
||||||
|
|
||||||
|
def verify_target_selection_and_read_price(self, selection: SkuSelection) -> str:
|
||||||
|
nodes = self._verified_nodes()
|
||||||
|
_selected(nodes, "color", selection.color)
|
||||||
|
_selected(nodes, "size", selection.size)
|
||||||
|
return _unit_price(nodes)
|
||||||
|
|
||||||
|
def exit_sku_panel_safely(self) -> None:
|
||||||
|
self._require_foreground()
|
||||||
|
before = self._read_hierarchy()
|
||||||
|
_panel(_parse_nodes(before))
|
||||||
|
self._device.leave_sku_panel()
|
||||||
|
deadline = self._clock() + self._entry_timeout
|
||||||
|
while True:
|
||||||
|
self._require_foreground()
|
||||||
|
raw = self._read_hierarchy()
|
||||||
|
if raw != before:
|
||||||
|
try:
|
||||||
|
_panel(_parse_nodes(raw))
|
||||||
|
except SkuSelectionError:
|
||||||
|
return
|
||||||
|
remaining = deadline - self._clock()
|
||||||
|
if remaining <= 0:
|
||||||
|
raise SkuSelectionError("安全退出后未确认离开规格面板,未重试返回。")
|
||||||
|
self._sleep(min(self._poll, remaining))
|
||||||
|
|
||||||
|
def reconcile_pending_action(self) -> None:
|
||||||
|
"""仅只读调和一次已发出但尚未得到后置条件确认的动作。"""
|
||||||
|
if self._pending is None:
|
||||||
|
return
|
||||||
|
before, condition = self._pending
|
||||||
|
self._wait_after_action(before, condition)
|
||||||
|
|
||||||
|
def _restore(self, dimension: str, expected: str) -> None:
|
||||||
|
self._require_foreground()
|
||||||
|
before = self._read_hierarchy()
|
||||||
|
nodes = _panel(_parse_nodes(before))
|
||||||
|
target = _option(nodes, dimension, expected)
|
||||||
|
if _selected_label(nodes, dimension) == expected:
|
||||||
|
return
|
||||||
|
_action_bounds(target.bounds)
|
||||||
|
condition: Callable[[list[_Node]], Any]
|
||||||
|
if dimension == "color":
|
||||||
|
condition = lambda refreshed: _post_color(refreshed, expected)
|
||||||
|
else:
|
||||||
|
condition = lambda refreshed: _post_all_targets(refreshed, expected)
|
||||||
|
self._pending = (before, condition)
|
||||||
|
self._device.tap_sku_option(target.bounds)
|
||||||
|
if dimension == "color":
|
||||||
|
self._wait_after_action(before, condition)
|
||||||
|
else:
|
||||||
|
self._wait_after_action(before, condition)
|
||||||
|
|
||||||
|
def _wait_for_entry(self, previous: str | None) -> tuple[_Node, str]:
|
||||||
|
deadline, stable = self._clock() + self._entry_timeout, None
|
||||||
|
while True:
|
||||||
|
self._require_version()
|
||||||
|
current = self._device.app_current()
|
||||||
|
if isinstance(current, dict) and current.get("package") == PDD_PACKAGE:
|
||||||
|
raw = self._read_hierarchy()
|
||||||
|
entries = _eligible_entries(_parse_nodes(raw))
|
||||||
|
if len(entries) > 1:
|
||||||
|
raise SkuSelectionError("商品页规格入口不唯一,已停止操作。")
|
||||||
|
if len(entries) == 1 and raw != previous:
|
||||||
|
if stable == raw:
|
||||||
|
return entries[0], raw
|
||||||
|
stable = raw
|
||||||
|
else:
|
||||||
|
stable = None
|
||||||
|
else:
|
||||||
|
stable = None
|
||||||
|
remaining = deadline - self._clock()
|
||||||
|
if remaining <= 0:
|
||||||
|
raise SkuSelectionError("等待已取证规格入口超时,未执行点击。")
|
||||||
|
self._sleep(min(self._poll, remaining))
|
||||||
|
|
||||||
|
def _wait_after_action(self, previous: str, condition: Callable[[list[_Node]], Any]) -> list[_Node]:
|
||||||
|
deadline = self._clock() + self._entry_timeout
|
||||||
|
while True:
|
||||||
|
self._require_foreground()
|
||||||
|
raw = self._read_hierarchy()
|
||||||
|
if raw != previous:
|
||||||
|
nodes = _parse_nodes(raw)
|
||||||
|
try:
|
||||||
|
condition(nodes)
|
||||||
|
self._pending = None
|
||||||
|
return nodes
|
||||||
|
except SkuSelectionError:
|
||||||
|
pass
|
||||||
|
remaining = deadline - self._clock()
|
||||||
|
if remaining <= 0:
|
||||||
|
raise SkuSelectionError("动作后页面未在限定时间内满足已取证后置条件,未重试动作。")
|
||||||
|
self._sleep(min(self._poll, remaining))
|
||||||
|
|
||||||
|
def _verified_nodes(self) -> list[_Node]:
|
||||||
|
self._require_foreground()
|
||||||
|
return _panel(self._read_nodes())
|
||||||
|
|
||||||
|
def _require_version(self) -> None:
|
||||||
|
info = self._device.app_info(PDD_PACKAGE)
|
||||||
|
version = (info.get("versionName") or info.get("version_name")) if isinstance(info, dict) else None
|
||||||
|
if version != EXPECTED_PDD_VERSION:
|
||||||
|
raise SkuSelectionError("拼多多版本与已取证版本不一致,已停止操作。")
|
||||||
|
|
||||||
|
def _require_foreground(self) -> None:
|
||||||
|
self._require_version()
|
||||||
|
current = self._device.app_current()
|
||||||
|
if not isinstance(current, dict) or current.get("package") != PDD_PACKAGE:
|
||||||
|
raise SkuSelectionError("拼多多不在前台,已停止操作。")
|
||||||
|
|
||||||
|
def _read_hierarchy(self) -> str:
|
||||||
|
try: raw = self._device.dump_window_hierarchy()
|
||||||
|
except Exception as error: raise SkuSelectionError("节点树读取失败,已停止操作。") from error
|
||||||
|
if not isinstance(raw, str) or not raw: raise SkuSelectionError("节点树不可用,已停止操作。")
|
||||||
|
return raw
|
||||||
|
|
||||||
|
def _read_nodes(self) -> list[_Node]: return _parse_nodes(self._read_hierarchy())
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_nodes(raw: str) -> list[_Node]:
|
||||||
|
try: root = ElementTree.fromstring(raw)
|
||||||
|
except ElementTree.ParseError as error: raise SkuSelectionError("节点树格式无效,已停止操作。") from error
|
||||||
|
if root.tag != "hierarchy": raise SkuSelectionError("节点树根节点无效,已停止操作。")
|
||||||
|
result: list[_Node] = []
|
||||||
|
def visit(element: ElementTree.Element, parent: _Node | None) -> None:
|
||||||
|
node = _Node(element, parent); result.append(node)
|
||||||
|
for child in element: visit(child, node)
|
||||||
|
visit(root, None)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _panel(nodes: list[_Node]) -> list[_Node]:
|
||||||
|
parent = _one([n for n in nodes if n.bounds == _PRICE_PARENT], "规格面板价格区域不唯一,已停止操作。")
|
||||||
|
_one([n for n in nodes if n.parent is parent and n.bounds == _ORIGINAL and _readonly(n) and _ORIGINAL_PRICE.fullmatch(n.text)], "规格面板原价槽位不唯一,已停止操作。")
|
||||||
|
_one([n for n in nodes if n.bounds == _SUMMARY and _readonly(n) and n.text.startswith("已选:")], "规格面板已选摘要不唯一,已停止操作。")
|
||||||
|
_color_container(nodes); _size_container(nodes)
|
||||||
|
return nodes
|
||||||
|
|
||||||
|
|
||||||
|
def _unit_price(nodes: list[_Node]) -> str:
|
||||||
|
parent = _one([n for n in nodes if n.bounds == _PRICE_PARENT], "规格面板价格区域不唯一,已停止读取。")
|
||||||
|
money = [n for n in nodes if n.parent is parent and _readonly(n) and any(mark in n.text for mark in "¥¥")]
|
||||||
|
if len(money) != 2: raise SkuSelectionError("规格面板金额槽位不唯一,已停止读取。")
|
||||||
|
current = _one([n for n in money if n.bounds == _CURRENT and not _clickable_ancestor(n) and not any(word in n.text for word in _BAD_PRICE_ROLE) and _PRICE.fullmatch(n.text)], "规格面板现价不唯一或不符合已取证槽位,已停止读取。")
|
||||||
|
if not any(n.bounds == _ORIGINAL and _ORIGINAL_PRICE.fullmatch(n.text) for n in money):
|
||||||
|
raise SkuSelectionError("规格面板原价槽位无效,已停止读取。")
|
||||||
|
match = _PRICE.fullmatch(current.text)
|
||||||
|
if match is None: raise SkuSelectionError("规格面板现价格式失效,已停止读取。")
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _option(nodes: list[_Node], dimension: str, expected: str) -> _Node:
|
||||||
|
_panel(nodes)
|
||||||
|
return _one([n for n in _options(nodes, dimension) if _label(n) == expected], "规格选项不唯一或不是精确匹配,已停止操作。")
|
||||||
|
|
||||||
|
|
||||||
|
def _selected(nodes: list[_Node], dimension: str, expected: str) -> None:
|
||||||
|
if _selected_label(nodes, dimension) != expected:
|
||||||
|
raise SkuSelectionError("规格选择后读回的 selected 文案不一致,已停止操作。")
|
||||||
|
|
||||||
|
|
||||||
|
def _selected_label(nodes: list[_Node], dimension: str) -> str:
|
||||||
|
selected = [n for n in _options(nodes, dimension) if n.element.get("selected") == "true"]
|
||||||
|
label = _label(_one(selected, "规格维度没有唯一 selected 状态,已停止操作。"))
|
||||||
|
if label is None: raise SkuSelectionError("规格维度 selected 文案无效,已停止操作。")
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
|
def _post_color(nodes: list[_Node], expected: str) -> None:
|
||||||
|
_panel(nodes)
|
||||||
|
_selected(nodes, "color", expected)
|
||||||
|
_selected_label(nodes, "size")
|
||||||
|
|
||||||
|
|
||||||
|
def _post_all_targets(nodes: list[_Node], expected_size: str) -> None:
|
||||||
|
_panel(nodes)
|
||||||
|
_selected(nodes, "color", _TARGET_COLOR_UI)
|
||||||
|
_selected(nodes, "size", expected_size)
|
||||||
|
|
||||||
|
|
||||||
|
def _options(nodes: list[_Node], dimension: str) -> list[_Node]:
|
||||||
|
container = _color_container(nodes) if dimension == "color" else _size_container(nodes) if dimension == "size" else None
|
||||||
|
if container is None: raise SkuSelectionError("未知规格维度,已停止操作。")
|
||||||
|
candidates = [n for n in nodes if _descendant(n, container) and _contained(n, container) and _choice(n) and _label(n) is not None]
|
||||||
|
return [n for n in candidates if not _labeled_ancestor(n, candidates)]
|
||||||
|
|
||||||
|
|
||||||
|
def _color_container(nodes: list[_Node]) -> _Node:
|
||||||
|
return _one([n for n in nodes if n.element.get("package") == PDD_PACKAGE and n.element.get("class") == "androidx.recyclerview.widget.RecyclerView" and n.bounds == _COLOR_REGION], "规格面板颜色容器不唯一,已停止操作。")
|
||||||
|
|
||||||
|
|
||||||
|
def _size_container(nodes: list[_Node]) -> _Node:
|
||||||
|
label = _one([n for n in nodes if n.text == _SIZE and n.bounds == _SIZE_LABEL and _readonly(n)], "规格面板尺码标签不唯一,已停止操作。")
|
||||||
|
header = label.parent
|
||||||
|
if header is None or header.element.get("package") != PDD_PACKAGE or header.element.get("class") != "android.widget.LinearLayout" or header.bounds != _SIZE_HEADER or header.parent is None:
|
||||||
|
raise SkuSelectionError("规格面板尺码标题容器不符合已取证结构,已停止操作。")
|
||||||
|
return _one([n for n in nodes if n.parent is header.parent and n.element.get("package") == PDD_PACKAGE and n.element.get("class") == "android.widget.LinearLayout" and n.bounds == _SIZE_OPTIONS], "规格面板尺码选项容器不唯一,已停止操作。")
|
||||||
|
|
||||||
|
|
||||||
|
def _label(node: _Node) -> str | None:
|
||||||
|
values = {value for value in (node.text, node.desc) if value}
|
||||||
|
return values.pop() if len(values) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _labeled_ancestor(node: _Node, candidates: list[_Node]) -> bool:
|
||||||
|
ids, parent = {id(n.element) for n in candidates}, node.parent
|
||||||
|
while parent is not None:
|
||||||
|
if id(parent.element) in ids and _label(parent) is not None: return True
|
||||||
|
parent = parent.parent
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _descendant(node: _Node, ancestor: _Node) -> bool:
|
||||||
|
parent = node.parent
|
||||||
|
while parent is not None:
|
||||||
|
if parent.element is ancestor.element: return True
|
||||||
|
parent = parent.parent
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _contained(node: _Node, container: _Node) -> bool:
|
||||||
|
left, top, right, bottom = _action_bounds(node.bounds)
|
||||||
|
outer_left, outer_top, outer_right, outer_bottom = _action_bounds(container.bounds)
|
||||||
|
return outer_left <= left < right <= outer_right and outer_top <= top < bottom <= outer_bottom
|
||||||
|
|
||||||
|
|
||||||
|
def _clickable_ancestor(node: _Node) -> bool:
|
||||||
|
parent = node.parent
|
||||||
|
while parent is not None:
|
||||||
|
if parent.element.get("clickable") == "true": return True
|
||||||
|
parent = parent.parent
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _readonly(node: _Node) -> bool:
|
||||||
|
return node.element.get("package") == PDD_PACKAGE and node.element.get("class") == "android.widget.TextView" and node.element.get("clickable") == "false" and node.element.get("enabled") == "true" and node.element.get("visible-to-user") == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def _live(node: _Node) -> bool:
|
||||||
|
return node.element.get("package") == PDD_PACKAGE and node.element.get("clickable") == "true" and node.element.get("enabled") == "true" and node.element.get("visible-to-user") == "true" and bool(node.bounds)
|
||||||
|
|
||||||
|
|
||||||
|
def _choice(node: _Node) -> bool:
|
||||||
|
return _live(node) and node.element.get("class") == "android.view.ViewGroup" and node.element.get("selected") in {"true", "false"}
|
||||||
|
|
||||||
|
|
||||||
|
def _eligible_entries(nodes: list[_Node]) -> list[_Node]:
|
||||||
|
# 入口文本本身不可点击:必须逐层证明它仍位于已取证的唯一可点击祖先中,但动作坐标继续
|
||||||
|
# 使用文本子节点的窄 bounds,避免把同一祖先内未知区域变成坐标兜底。“免拼购买”等底部
|
||||||
|
# 容器既不属于这条祖先链,也绝不能作为替代入口。
|
||||||
|
if any(node.bounds == _PRICE_PARENT for node in nodes): return []
|
||||||
|
entry_labels = [
|
||||||
|
node for node in nodes
|
||||||
|
if node.text == _ENTRY
|
||||||
|
and node.element.get("package") == PDD_PACKAGE
|
||||||
|
and node.element.get("class") == "android.widget.TextView"
|
||||||
|
]
|
||||||
|
if len(entry_labels) != 1:
|
||||||
|
return entry_labels
|
||||||
|
action_ancestors = [
|
||||||
|
node for node in nodes
|
||||||
|
if _exact_entry_node(node, "android.view.ViewGroup", _ENTRY_ACTION_BOUNDS, "true")
|
||||||
|
]
|
||||||
|
if len(action_ancestors) != 1:
|
||||||
|
return []
|
||||||
|
action_ancestor = action_ancestors[0]
|
||||||
|
entries: list[_Node] = []
|
||||||
|
for node in entry_labels:
|
||||||
|
if not _exact_entry_node(node, "android.widget.TextView", _ENTRY_TEXT_BOUNDS, "false"):
|
||||||
|
return []
|
||||||
|
inner = node.parent
|
||||||
|
switcher = inner.parent if inner is not None else None
|
||||||
|
frame = switcher.parent if switcher is not None else None
|
||||||
|
ancestor = frame.parent if frame is not None else None
|
||||||
|
if (
|
||||||
|
inner is not None
|
||||||
|
and _exact_entry_node(inner, "android.view.ViewGroup", _ENTRY_INNER_BOUNDS, "false")
|
||||||
|
and switcher is not None
|
||||||
|
and _exact_entry_node(switcher, "android.widget.ViewSwitcher", _ENTRY_INNER_BOUNDS, "false")
|
||||||
|
and frame is not None
|
||||||
|
and _exact_entry_node(frame, "android.widget.FrameLayout", _ENTRY_INNER_BOUNDS, "false")
|
||||||
|
and ancestor is action_ancestor
|
||||||
|
):
|
||||||
|
entries.append(node)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
def _exact_entry_node(node: _Node, class_name: str, bounds: str, clickable: str) -> bool:
|
||||||
|
return (
|
||||||
|
node.element.get("package") == PDD_PACKAGE
|
||||||
|
and node.element.get("class") == class_name
|
||||||
|
and node.bounds == bounds
|
||||||
|
and node.element.get("clickable") == clickable
|
||||||
|
and node.element.get("enabled") == "true"
|
||||||
|
and node.element.get("visible-to-user") == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _action_bounds(bounds: str) -> tuple[int, int, int, int]:
|
||||||
|
match = _BOUNDS.fullmatch(bounds)
|
||||||
|
if match is None: raise SkuSelectionError("规格节点坐标格式无效,已停止操作。")
|
||||||
|
left, top, right, bottom = (int(item) for item in match.groups())
|
||||||
|
if not (0 <= left < right <= _W and 0 <= top < bottom <= _H):
|
||||||
|
raise SkuSelectionError("规格节点坐标不在已取证屏幕范围内,已停止操作。")
|
||||||
|
return left, top, right, bottom
|
||||||
|
|
||||||
|
|
||||||
|
def _one(nodes: list[_Node], message: str) -> _Node:
|
||||||
|
if len(nodes) != 1: raise SkuSelectionError(message)
|
||||||
|
return nodes[0]
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
"""T-103 真机运行边界:窄适配器、原始截图和无页面正文的摘要。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from hashlib import sha256
|
||||||
|
import json
|
||||||
|
from math import isfinite
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
from time import monotonic
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
|
from adbutils.errors import AdbTimeout
|
||||||
|
from uiautomator2.exceptions import HTTPTimeoutError
|
||||||
|
|
||||||
|
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
|
||||||
|
from ..device.baseline import PDD_PACKAGE, SCREENSHOT_PARAMS, _save_base64_screenshot, _sha256_file
|
||||||
|
from .product_open import EXPECTED_PDD_VERSION
|
||||||
|
from .product_url import ProductUrl, parse_product_url
|
||||||
|
from .sku_selection import (
|
||||||
|
EXPECTED_GOODS_ID,
|
||||||
|
EXPECTED_UNIT_PRICE,
|
||||||
|
SkuPanelDevice,
|
||||||
|
SkuSelectionError,
|
||||||
|
SkuSelectionFlow,
|
||||||
|
_action_bounds,
|
||||||
|
resolve_task_selection,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
EXPECTED_DEVICE_MODEL = "PKG110"
|
||||||
|
EXPECTED_ANDROID_VERSION = "16"
|
||||||
|
EXPECTED_SCREEN_SIZE = (1080, 2376)
|
||||||
|
|
||||||
|
|
||||||
|
class SkuSelectionRunError(RuntimeError):
|
||||||
|
"""T-103 运行未完整完成;错误文本不携带设备或页面原文。"""
|
||||||
|
|
||||||
|
|
||||||
|
class SkuSelectionRunTimeoutError(SkuSelectionRunError):
|
||||||
|
"""设备 RPC 或操作超时。"""
|
||||||
|
|
||||||
|
|
||||||
|
class SkuSelectionScreenshotError(SkuSelectionRunError):
|
||||||
|
"""原始截图无法作为完整 PNG 原子发布。"""
|
||||||
|
|
||||||
|
|
||||||
|
class SkuSelectionUnexpectedPriceError(SkuSelectionRunError):
|
||||||
|
"""取证面板现价不是本任务已确认值。"""
|
||||||
|
|
||||||
|
|
||||||
|
class SkuSelectionDeviceAdapterError(SkuSelectionRunError):
|
||||||
|
"""第三方设备接口失败的脱敏映射。"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SkuSelectionRunResult:
|
||||||
|
"""已发布的截图和无页面正文 manifest 摘要。"""
|
||||||
|
|
||||||
|
output_directory: Path
|
||||||
|
screenshot_path: Path
|
||||||
|
manifest_path: Path
|
||||||
|
unit_price: str
|
||||||
|
|
||||||
|
|
||||||
|
class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||||
|
"""把 uiautomator2 缩为 T-103 所需的读取与三种命名操作。
|
||||||
|
|
||||||
|
``tap_sku_entry``、``tap_sku_option`` 和 ``leave_sku_panel`` 是仅有的状态改变方法;
|
||||||
|
坐标由 Flow 和本类双重检查后才计算中心点,每次调用只执行一次底层动作。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, device: Any, timeout_seconds: float) -> None:
|
||||||
|
if not _is_positive_finite(timeout_seconds):
|
||||||
|
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||||
|
self._device = device
|
||||||
|
self._timeout_seconds = timeout_seconds
|
||||||
|
self._entry_was_tapped = False
|
||||||
|
self._left_panel = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def entry_was_tapped(self) -> bool:
|
||||||
|
"""仅供运行器决定故障后的单次尽力返回,不是页面操作。"""
|
||||||
|
|
||||||
|
return self._entry_was_tapped
|
||||||
|
|
||||||
|
@property
|
||||||
|
def left_panel(self) -> bool:
|
||||||
|
return self._left_panel
|
||||||
|
|
||||||
|
def app_info(self, package_name: str) -> dict[str, Any]:
|
||||||
|
value = self._call("app_info", package_name)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise SkuSelectionDeviceAdapterError("无法读取应用版本,已停止操作。")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def app_current(self) -> dict[str, Any]:
|
||||||
|
value = self._call("app_current")
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise SkuSelectionDeviceAdapterError("无法读取前台应用,已停止操作。")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def dump_window_hierarchy(self) -> str:
|
||||||
|
value = self._call("jsonrpc_call", "dumpWindowHierarchy", [False, 50], timeout=self._timeout_seconds)
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise SkuSelectionDeviceAdapterError("节点树读取失败,已停止操作。")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def tap_sku_entry(self, bounds: str) -> None:
|
||||||
|
# 超时也可能表示底层事件已经送达;必须先封存 attempt,后续绝不重试该入口。
|
||||||
|
self._entry_was_tapped = True
|
||||||
|
self._tap_bounds_once(bounds)
|
||||||
|
|
||||||
|
def tap_sku_option(self, bounds: str) -> None:
|
||||||
|
self._tap_bounds_once(bounds)
|
||||||
|
|
||||||
|
def leave_sku_panel(self) -> None:
|
||||||
|
if self._left_panel:
|
||||||
|
raise SkuSelectionDeviceAdapterError("规格面板已经执行过返回,已停止操作。")
|
||||||
|
# 底层调用即使报错也可能已把返回事件送达;先封存本次机会,finally 不得再次返回。
|
||||||
|
self._left_panel = True
|
||||||
|
self._call("jsonrpc_call", "pressKey", ["back"], timeout=self._timeout_seconds)
|
||||||
|
|
||||||
|
def capture_screenshot(self) -> str:
|
||||||
|
value = self._call("jsonrpc_call", "takeScreenshot", SCREENSHOT_PARAMS, timeout=self._timeout_seconds)
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise SkuSelectionScreenshotError("规格面板原始截图读取失败,未发布任何证据产物。")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def display_size(self) -> tuple[int, int]:
|
||||||
|
value = self._call("window_size")
|
||||||
|
if not isinstance(value, tuple) or len(value) != 2 or any(not isinstance(item, int) for item in value):
|
||||||
|
raise SkuSelectionDeviceAdapterError("无法读取屏幕坐标空间,已停止操作。")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _tap_bounds_once(self, bounds: str) -> None:
|
||||||
|
left, top, right, bottom = _action_bounds(bounds)
|
||||||
|
center_x = left + (right - left) // 2
|
||||||
|
center_y = top + (bottom - top) // 2
|
||||||
|
self._call("jsonrpc_call", "click", [center_x, center_y], timeout=self._timeout_seconds)
|
||||||
|
|
||||||
|
def _call(self, method: str, *args: Any, **kwargs: Any) -> Any:
|
||||||
|
try:
|
||||||
|
operation = getattr(self._device, method)
|
||||||
|
return operation(*args, **kwargs)
|
||||||
|
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||||
|
raise SkuSelectionRunTimeoutError("规格面板设备操作超时,已停止操作。") from error
|
||||||
|
except SkuSelectionRunError:
|
||||||
|
raise
|
||||||
|
except Exception as error:
|
||||||
|
raise SkuSelectionDeviceAdapterError("规格面板设备操作失败,已停止操作。") from error
|
||||||
|
|
||||||
|
|
||||||
|
class SkuSelectionRunner:
|
||||||
|
"""只运行 T-103 目标规格恢复、价格确认、原始截图和一次安全退出。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
adb_client: AdbClient,
|
||||||
|
connector: Callable[[str], Any],
|
||||||
|
timeout_seconds: float,
|
||||||
|
monotonic_clock: Callable[[], float] = monotonic,
|
||||||
|
) -> None:
|
||||||
|
if not _is_positive_finite(timeout_seconds):
|
||||||
|
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||||
|
self._adb_client = adb_client
|
||||||
|
self._connector = connector
|
||||||
|
self._timeout_seconds = timeout_seconds
|
||||||
|
self._monotonic_clock = monotonic_clock
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
serial: str,
|
||||||
|
product_url: str,
|
||||||
|
task_color: str,
|
||||||
|
task_size: str,
|
||||||
|
output_directory: Path,
|
||||||
|
) -> SkuSelectionRunResult:
|
||||||
|
link = parse_product_url(product_url)
|
||||||
|
if link.goods_id != EXPECTED_GOODS_ID:
|
||||||
|
raise SkuSelectionRunError("商品不是 T-103 已取证目标,已停止操作。")
|
||||||
|
selection = resolve_task_selection(task_color, task_size)
|
||||||
|
target = Path(output_directory)
|
||||||
|
_validate_new_target(target)
|
||||||
|
|
||||||
|
adapter: UiautomatorSkuPanelAdapter | None = None
|
||||||
|
flow: SkuSelectionFlow | None = None
|
||||||
|
staging = _prepare_staging(target)
|
||||||
|
deadline = self._monotonic_clock() + self._timeout_seconds
|
||||||
|
try:
|
||||||
|
inspection = self._adb_client.inspect(serial)
|
||||||
|
_require_expected_device(inspection)
|
||||||
|
adapter = UiautomatorSkuPanelAdapter(self._connector(serial), self._timeout_seconds)
|
||||||
|
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
||||||
|
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
|
||||||
|
raise SkuSelectionRunError("设备不是已取证的竖屏坐标空间,已停止操作。")
|
||||||
|
pre_intent_hierarchy = adapter.dump_window_hierarchy()
|
||||||
|
# 固定 ACTION_VIEW、固定 PDD package 和 canonical goods_id;不接受任意 URL 或 shell。
|
||||||
|
self._adb_client.start_pdd_view_intent(serial, link.goods_id)
|
||||||
|
|
||||||
|
remaining = deadline - self._monotonic_clock()
|
||||||
|
if remaining <= 0:
|
||||||
|
raise SkuSelectionRunTimeoutError("等待规格入口超时,未执行点击。")
|
||||||
|
flow = SkuSelectionFlow(adapter, entry_wait_timeout_seconds=remaining)
|
||||||
|
flow.open_sku_panel(link.canonical_url, pre_intent_hierarchy)
|
||||||
|
flow.select_sku_options(selection)
|
||||||
|
unit_price = flow.verify_target_selection_and_read_price(selection)
|
||||||
|
if unit_price != EXPECTED_UNIT_PRICE:
|
||||||
|
raise SkuSelectionUnexpectedPriceError("规格面板现价不是本任务已确认值,已停止操作。")
|
||||||
|
|
||||||
|
screenshot_path = staging / "screenshot.png"
|
||||||
|
try:
|
||||||
|
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
|
||||||
|
_require_screenshot_size(screenshot_path)
|
||||||
|
except SkuSelectionRunError:
|
||||||
|
raise
|
||||||
|
except Exception as error:
|
||||||
|
raise SkuSelectionScreenshotError("规格面板原始截图保存失败,未发布任何证据产物。") from error
|
||||||
|
|
||||||
|
manifest_path = staging / "manifest.json"
|
||||||
|
# 截图可能落在动态页面切换边界;发布前必须用一棵更新节点树同时重证两维和现价。
|
||||||
|
final_price = flow.verify_target_selection_and_read_price(selection)
|
||||||
|
if final_price != EXPECTED_UNIT_PRICE:
|
||||||
|
raise SkuSelectionUnexpectedPriceError("截图后规格面板现价不是本任务已确认值,已停止操作。")
|
||||||
|
# 正常路径仍经 Flow 做最后一次前台和面板判定;返回操作只发生一次。
|
||||||
|
flow.exit_sku_panel_safely()
|
||||||
|
manifest_path.write_text(
|
||||||
|
json.dumps(_manifest(inspection, serial, link, screenshot_path, task_color, task_size), ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
# Windows 的 rename 不替换既有目标;并发创建 target 时保留其内容并把本次运行判失败。
|
||||||
|
os.rename(staging, target)
|
||||||
|
staging = None
|
||||||
|
except (DeviceConnectionError, SkuSelectionRunError, SkuSelectionError):
|
||||||
|
_clean_staging(staging)
|
||||||
|
raise
|
||||||
|
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||||
|
_clean_staging(staging)
|
||||||
|
raise SkuSelectionRunTimeoutError("规格面板运行超时,未发布任何证据产物。") from error
|
||||||
|
except OSError as error:
|
||||||
|
_clean_staging(staging)
|
||||||
|
raise SkuSelectionRunError("规格面板证据目录无法创建或发布,未发布任何证据产物。") from error
|
||||||
|
except Exception as error:
|
||||||
|
_clean_staging(staging)
|
||||||
|
raise SkuSelectionRunError("规格面板运行未完成,未发布任何证据产物。") from error
|
||||||
|
finally:
|
||||||
|
# 失败路径只能复用 Flow 的版本、前台和面板证明;证明不了便停止,绝不盲目返回。
|
||||||
|
if flow is not None and adapter is not None and adapter.entry_was_tapped and not adapter.left_panel:
|
||||||
|
try:
|
||||||
|
flow.reconcile_pending_action()
|
||||||
|
flow.exit_sku_panel_safely()
|
||||||
|
except (SkuSelectionRunError, SkuSelectionError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return SkuSelectionRunResult(
|
||||||
|
output_directory=target,
|
||||||
|
screenshot_path=target / "screenshot.png",
|
||||||
|
manifest_path=target / "manifest.json",
|
||||||
|
unit_price=EXPECTED_UNIT_PRICE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_positive_finite(value: object) -> bool:
|
||||||
|
return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0 and isfinite(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_new_target(target: Path) -> None:
|
||||||
|
if target.exists():
|
||||||
|
raise SkuSelectionRunError("输出目录已存在;为防止覆盖旧证据,已停止操作。")
|
||||||
|
if not target.name:
|
||||||
|
raise SkuSelectionRunError("输出目录必须是明确的新目录。")
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_staging(target: Path) -> Path:
|
||||||
|
staging: Path | None = None
|
||||||
|
try:
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
staging = target.parent / f".{target.name}.staging-{uuid4().hex}"
|
||||||
|
staging.mkdir()
|
||||||
|
probe = staging / ".write-probe"
|
||||||
|
probe.write_bytes(b"ok")
|
||||||
|
probe.unlink()
|
||||||
|
return staging
|
||||||
|
except OSError as error:
|
||||||
|
_clean_staging(staging)
|
||||||
|
raise SkuSelectionRunError("输出目录不可写,已停止操作。") from error
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_staging(staging: Path | None) -> None:
|
||||||
|
if staging is not None and staging.exists():
|
||||||
|
shutil.rmtree(staging)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_expected_version(app_info: object) -> str:
|
||||||
|
version = (app_info.get("versionName") or app_info.get("version_name")) if isinstance(app_info, dict) else None
|
||||||
|
if version != EXPECTED_PDD_VERSION:
|
||||||
|
raise SkuSelectionRunError("拼多多版本与已取证版本不一致,已停止操作。")
|
||||||
|
return version
|
||||||
|
|
||||||
|
|
||||||
|
def _require_expected_device(inspection: DeviceInspection) -> None:
|
||||||
|
if inspection.model != EXPECTED_DEVICE_MODEL or inspection.android_version != EXPECTED_ANDROID_VERSION:
|
||||||
|
raise SkuSelectionRunError("设备型号或 Android 版本不是已取证组合,已停止操作。")
|
||||||
|
|
||||||
|
|
||||||
|
def _require_screenshot_size(screenshot_path: Path) -> None:
|
||||||
|
try:
|
||||||
|
with Image.open(screenshot_path) as image:
|
||||||
|
image.load()
|
||||||
|
if image.size != EXPECTED_SCREEN_SIZE:
|
||||||
|
raise SkuSelectionScreenshotError("原始截图坐标空间不是已取证尺寸,未发布任何证据产物。")
|
||||||
|
except SkuSelectionRunError:
|
||||||
|
raise
|
||||||
|
except (UnidentifiedImageError, OSError) as error:
|
||||||
|
raise SkuSelectionScreenshotError("原始截图无效,未发布任何证据产物。") from error
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest(inspection: DeviceInspection, serial: str, link: ProductUrl, screenshot_path: Path, task_color: str, task_size: str) -> dict[str, Any]:
|
||||||
|
"""仅写可审计摘要;原始 serial、节点树、页面文案和实际截图内容均不写入 manifest。"""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"captured_at": datetime.now(UTC).isoformat(),
|
||||||
|
"operation": "t103-sku-selection",
|
||||||
|
"product": {"goods_id": link.goods_id, "canonical_url": link.canonical_url},
|
||||||
|
"target_selection": {"color": task_color, "size": task_size},
|
||||||
|
"unit_price": EXPECTED_UNIT_PRICE,
|
||||||
|
"selection_status": "restored",
|
||||||
|
"panel_status": "verified",
|
||||||
|
"safe_exit": "completed",
|
||||||
|
"page_identity": "human_review_required",
|
||||||
|
"channel": "wifi" if ":" in serial else "usb",
|
||||||
|
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||||
|
"device": {
|
||||||
|
"model": inspection.model,
|
||||||
|
"android_version": inspection.android_version,
|
||||||
|
"pdd_package": PDD_PACKAGE,
|
||||||
|
"pdd_version": EXPECTED_PDD_VERSION,
|
||||||
|
},
|
||||||
|
"artifacts": [{"path": screenshot_path.name, "sha256": _sha256_file(screenshot_path)}],
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<hierarchy>
|
||||||
|
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,1256][1080,1355]" clickable="true" enabled="true" visible-to-user="true">
|
||||||
|
<node package="com.xunmeng.pinduoduo" class="android.widget.FrameLayout" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true">
|
||||||
|
<node package="com.xunmeng.pinduoduo" class="android.widget.ViewSwitcher" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true">
|
||||||
|
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[712,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true">
|
||||||
|
<node text="快要抢光" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[900,1312][1056,1355]" clickable="false" enabled="true" visible-to-user="true" />
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[446,2166][1080,2328]" clickable="true" enabled="true" visible-to-user="true">
|
||||||
|
<node text="免拼购买" package="com.xunmeng.pinduoduo" class="android.widget.TextView" bounds="[688,2256][856,2305]" clickable="false" enabled="true" visible-to-user="true" />
|
||||||
|
</node>
|
||||||
|
</hierarchy>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<hierarchy>
|
||||||
|
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[0,474][1080,2328]">
|
||||||
|
<node package="" class="android.view.ViewGroup" bounds="[396,498][895,570]">
|
||||||
|
<node text="快卖完 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" clickable="false" enabled="true" visible-to-user="true" bounds="[396,503][712,570]" />
|
||||||
|
<node text="¥29.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" clickable="false" enabled="true" visible-to-user="true" bounds="[730,503][895,570]" />
|
||||||
|
</node>
|
||||||
|
<node text="已选: 黑色 CHA (纯棉) M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.widget.TextView" clickable="false" enabled="true" visible-to-user="true" bounds="[396,654][1053,716]" />
|
||||||
|
<node package="com.xunmeng.pinduoduo" class="androidx.recyclerview.widget.RecyclerView" bounds="[36,1000][1080,1631]">
|
||||||
|
<node content-desc="黑色 CHA (纯棉)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" selected="true" clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]" />
|
||||||
|
<node content-desc="粉红" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" selected="false" clickable="true" enabled="true" visible-to-user="true" bounds="[456,1000][690,1172]" />
|
||||||
|
</node>
|
||||||
|
<node package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" bounds="[36,1637][1044,2045]">
|
||||||
|
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1637][1044,1718]">
|
||||||
|
<node text="尺码" package="com.xunmeng.pinduoduo" class="android.widget.TextView" clickable="false" enabled="true" visible-to-user="true" bounds="[36,1654][114,1700]" />
|
||||||
|
</node>
|
||||||
|
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" bounds="[36,1730][1044,2045]">
|
||||||
|
<node text="M(建议100-115)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" selected="true" clickable="true" enabled="true" visible-to-user="true" bounds="[439,1730][831,1815]" />
|
||||||
|
<node text="L(建议115-130)" package="com.xunmeng.pinduoduo" class="android.view.ViewGroup" selected="false" clickable="true" enabled="true" visible-to-user="true" bounds="[840,1730][1044,1815]" />
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
<node package="com.xunmeng.pinduoduo" class="android.widget.LinearLayout" clickable="true" enabled="true" visible-to-user="true" bounds="[357,2181][722,2328]">
|
||||||
|
<node text="提交订单 ¥12.88" package="com.xunmeng.pinduoduo" class="android.widget.TextView" clickable="false" enabled="true" visible-to-user="true" bounds="[369,2225][710,2284]" />
|
||||||
|
</node>
|
||||||
|
</node>
|
||||||
|
</hierarchy>
|
||||||
@@ -0,0 +1,689 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import base64
|
||||||
|
from contextlib import redirect_stderr
|
||||||
|
from io import BytesIO
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
import cmbuyer_client.pdd as pdd
|
||||||
|
import cmbuyer_client.pdd.sku_selection_runner as runner_module
|
||||||
|
from cmbuyer_client.device.adb import AdbDevice, DeviceInspection
|
||||||
|
from cmbuyer_client.pdd import SkuSelectionError, SkuSelectionFlow, SkuSelectionRunner
|
||||||
|
from cmbuyer_client.pdd.sku_selection import SkuPanelDevice, _action_bounds, resolve_task_selection
|
||||||
|
from cmbuyer_client.pdd.sku_selection_runner import (
|
||||||
|
SkuSelectionDeviceAdapterError,
|
||||||
|
SkuSelectionRunError,
|
||||||
|
SkuSelectionScreenshotError,
|
||||||
|
UiautomatorSkuPanelAdapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_FIXTURE = Path(__file__).with_name("fixtures") / "sku_panel_8_17_0.xml"
|
||||||
|
_ENTRY_FIXTURE = Path(__file__).with_name("fixtures") / "product_entry_8_17_0.xml"
|
||||||
|
_TARGET_URL = "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375"
|
||||||
|
_TASK_COLOR = "黑色CHA(纯棉)"
|
||||||
|
_TASK_SIZE = "M(建议100-115)"
|
||||||
|
_PRODUCT_PAGE = _ENTRY_FIXTURE.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _png_base64() -> str:
|
||||||
|
image = Image.new("RGB", (1080, 2376), "white")
|
||||||
|
raw = BytesIO()
|
||||||
|
image.save(raw, format="PNG")
|
||||||
|
return base64.b64encode(raw.getvalue()).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
class _RawDevice:
|
||||||
|
def __init__(self, hierarchy: str = _PRODUCT_PAGE, screenshot: str | None = None) -> None:
|
||||||
|
self.hierarchy = hierarchy
|
||||||
|
self.panel_hierarchy = _FIXTURE.read_text(encoding="utf-8")
|
||||||
|
self.version = "8.17.0"
|
||||||
|
self.package = "com.xunmeng.pinduoduo"
|
||||||
|
self.screenshot = _png_base64() if screenshot is None else screenshot
|
||||||
|
self.calls: list[tuple[object, ...]] = []
|
||||||
|
self.fail_color_readback = False
|
||||||
|
|
||||||
|
def app_info(self, package_name: str) -> dict[str, str]:
|
||||||
|
self.calls.append(("app_info", package_name))
|
||||||
|
return {"versionName": self.version}
|
||||||
|
|
||||||
|
def app_current(self) -> dict[str, str]:
|
||||||
|
self.calls.append(("app_current",))
|
||||||
|
return {"package": self.package}
|
||||||
|
|
||||||
|
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
|
self.calls.append(("jsonrpc", method, params, timeout))
|
||||||
|
if method == "dumpWindowHierarchy":
|
||||||
|
return self.hierarchy
|
||||||
|
if method == "takeScreenshot":
|
||||||
|
return self.screenshot
|
||||||
|
if method == "pressKey":
|
||||||
|
self.hierarchy = "<hierarchy />"
|
||||||
|
return ""
|
||||||
|
if method == "click":
|
||||||
|
if not isinstance(params, list) or len(params) != 2:
|
||||||
|
raise AssertionError(params)
|
||||||
|
self._apply_tap(int(params[0]), int(params[1]))
|
||||||
|
return ""
|
||||||
|
raise AssertionError(method)
|
||||||
|
|
||||||
|
def _apply_tap(self, x: int, y: int) -> None:
|
||||||
|
if "快要抢光" in self.hierarchy and "[396,498][895,570]" not in self.hierarchy:
|
||||||
|
self.hierarchy = self.panel_hierarchy
|
||||||
|
return
|
||||||
|
root = ElementTree.fromstring(self.hierarchy)
|
||||||
|
target = next(node for node in root.iter("node") if _center(node.get("bounds", "")) == (x, y))
|
||||||
|
color = target.get("bounds", "").endswith("][438,1172]")
|
||||||
|
for node in root.iter("node"):
|
||||||
|
if node.get("selected") is not None and ((color and ",1000]" in node.get("bounds", "")) or (not color and ",1730]" in node.get("bounds", ""))):
|
||||||
|
node.set("selected", "false")
|
||||||
|
if color and self.fail_color_readback:
|
||||||
|
next(node for node in root.iter("node") if node.get("content-desc") == "粉红").set("selected", "true")
|
||||||
|
else:
|
||||||
|
target.set("selected", "true")
|
||||||
|
self.hierarchy = ElementTree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
def window_size(self) -> tuple[int, int]:
|
||||||
|
self.calls.append(("window_size",))
|
||||||
|
return 1080, 2376
|
||||||
|
|
||||||
|
def select_alternates(self) -> None:
|
||||||
|
root = ElementTree.fromstring(self.panel_hierarchy)
|
||||||
|
for node in root.iter("node"):
|
||||||
|
if node.get("selected") is not None:
|
||||||
|
node.set("selected", "false")
|
||||||
|
next(node for node in root.iter("node") if node.get("content-desc") == "粉红").set("selected", "true")
|
||||||
|
next(node for node in root.iter("node") if node.get("text") == "L(建议115-130)").set("selected", "true")
|
||||||
|
self.panel_hierarchy = ElementTree.tostring(root, encoding="unicode")
|
||||||
|
if self.hierarchy != _PRODUCT_PAGE:
|
||||||
|
self.hierarchy = self.panel_hierarchy
|
||||||
|
|
||||||
|
|
||||||
|
def _center(bounds: str) -> tuple[int, int]:
|
||||||
|
left_top, right_bottom = bounds.split("][")
|
||||||
|
left, top = (int(value) for value in left_top.removeprefix("[").split(","))
|
||||||
|
right, bottom = (int(value) for value in right_bottom.removesuffix("]").split(","))
|
||||||
|
return left + (right - left) // 2, top + (bottom - top) // 2
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_chain(root: ElementTree.Element) -> list[ElementTree.Element]:
|
||||||
|
parents = {child: parent for parent in root.iter() for child in parent}
|
||||||
|
child = next(node for node in root.iter("node") if node.get("text") == "快要抢光")
|
||||||
|
chain = [child]
|
||||||
|
for _ in range(4):
|
||||||
|
chain.append(parents[chain[-1]])
|
||||||
|
return chain
|
||||||
|
|
||||||
|
|
||||||
|
def _mutate_entry(depth: int, attribute: str, value: str) -> str:
|
||||||
|
root = ElementTree.fromstring(_PRODUCT_PAGE)
|
||||||
|
_entry_chain(root)[depth].set(attribute, value)
|
||||||
|
return ElementTree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
|
def _without_entry() -> str:
|
||||||
|
root = ElementTree.fromstring(_PRODUCT_PAGE)
|
||||||
|
root.remove(_entry_chain(root)[4])
|
||||||
|
return ElementTree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
|
def _duplicate_entry() -> str:
|
||||||
|
root = ElementTree.fromstring(_PRODUCT_PAGE)
|
||||||
|
entry_root = _entry_chain(root)[4]
|
||||||
|
root.append(ElementTree.fromstring(ElementTree.tostring(entry_root, encoding="unicode")))
|
||||||
|
return ElementTree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
|
def _actions(device: _RawDevice, method: str) -> list[tuple[object, ...]]:
|
||||||
|
return [call for call in device.calls if call[0] == "jsonrpc" and call[1] == method]
|
||||||
|
|
||||||
|
|
||||||
|
def _tap_centers(device: _RawDevice) -> list[tuple[int, int]]:
|
||||||
|
return [tuple(call[2]) for call in _actions(device, "click")] # type: ignore[misc]
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeAdb:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[object, ...]] = []
|
||||||
|
self.inspection = DeviceInspection(AdbDevice(serial="device-1", state="device"), "PKG110", "16")
|
||||||
|
self.on_intent: callable | None = None
|
||||||
|
|
||||||
|
def inspect(self, serial: str) -> DeviceInspection:
|
||||||
|
self.calls.append(("inspect", serial))
|
||||||
|
return self.inspection
|
||||||
|
|
||||||
|
def start_pdd_view_intent(self, serial: str, goods_id: str) -> object:
|
||||||
|
self.calls.append(("intent", serial, goods_id))
|
||||||
|
if self.on_intent is not None:
|
||||||
|
self.on_intent()
|
||||||
|
return object()
|
||||||
|
|
||||||
|
|
||||||
|
class SkuSelectionFlowTests(unittest.TestCase):
|
||||||
|
def _assert_entry_rejected_without_click(self, hierarchy: str) -> None:
|
||||||
|
now = [0.0]
|
||||||
|
device = _RawDevice(hierarchy)
|
||||||
|
flow = SkuSelectionFlow(
|
||||||
|
UiautomatorSkuPanelAdapter(device, 10),
|
||||||
|
0.01,
|
||||||
|
0.01,
|
||||||
|
lambda: now[0],
|
||||||
|
lambda seconds: now.__setitem__(0, now[0] + seconds),
|
||||||
|
)
|
||||||
|
with self.assertRaises(SkuSelectionError):
|
||||||
|
flow.open_sku_panel(_TARGET_URL)
|
||||||
|
self.assertEqual(_actions(device, "click"), [])
|
||||||
|
|
||||||
|
def test_target_mapping_is_exact_and_success_path_restores_target(self) -> None:
|
||||||
|
device = _RawDevice()
|
||||||
|
adapter = UiautomatorSkuPanelAdapter(device, 10)
|
||||||
|
flow = SkuSelectionFlow(adapter)
|
||||||
|
|
||||||
|
flow.open_sku_panel(_TARGET_URL)
|
||||||
|
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||||
|
self.assertEqual(flow.read_sku_unit_price(), "12.88")
|
||||||
|
flow.exit_sku_panel_safely()
|
||||||
|
|
||||||
|
self.assertEqual(_tap_centers(device), [(978, 1333)])
|
||||||
|
self.assertEqual(_actions(device, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
|
||||||
|
|
||||||
|
def test_full_verified_entry_structure_taps_exact_text_child_once(self) -> None:
|
||||||
|
device = _RawDevice()
|
||||||
|
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
|
||||||
|
|
||||||
|
self.assertEqual(_tap_centers(device), [(978, 1333)])
|
||||||
|
|
||||||
|
def test_entry_child_and_every_ancestor_attribute_drift_never_clicks(self) -> None:
|
||||||
|
expected_clickable = ("false", "false", "false", "false", "true")
|
||||||
|
for depth in range(5):
|
||||||
|
changes = {
|
||||||
|
"package": "other.package",
|
||||||
|
"class": "android.widget.Button",
|
||||||
|
"bounds": "[1,1][2,2]",
|
||||||
|
"clickable": "true" if expected_clickable[depth] == "false" else "false",
|
||||||
|
"enabled": "false",
|
||||||
|
"visible-to-user": "false",
|
||||||
|
}
|
||||||
|
for attribute, value in changes.items():
|
||||||
|
with self.subTest(depth=depth, attribute=attribute):
|
||||||
|
self._assert_entry_rejected_without_click(_mutate_entry(depth, attribute, value))
|
||||||
|
|
||||||
|
def test_duplicate_entry_and_forbidden_sibling_entry_never_click(self) -> None:
|
||||||
|
self._assert_entry_rejected_without_click(_duplicate_entry())
|
||||||
|
self._assert_entry_rejected_without_click(_without_entry())
|
||||||
|
self._assert_entry_rejected_without_click(_mutate_entry(0, "clickable", "true"))
|
||||||
|
|
||||||
|
def test_unknown_task_or_ui_variants_are_rejected_without_action(self) -> None:
|
||||||
|
for color, size in (("黑色 CHA (纯棉)", _TASK_SIZE), (_TASK_COLOR, "M(建议100-115)"), ("黑色CHA(纯棉)", _TASK_SIZE)):
|
||||||
|
with self.subTest(color=color, size=size), self.assertRaises(SkuSelectionError):
|
||||||
|
resolve_task_selection(color, size)
|
||||||
|
|
||||||
|
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8"))
|
||||||
|
with self.assertRaises(SkuSelectionError):
|
||||||
|
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).select_sku_options(
|
||||||
|
resolve_task_selection(_TASK_COLOR, _TASK_SIZE).__class__("粉红", "L(建议115-130)")
|
||||||
|
)
|
||||||
|
self.assertEqual(_actions(device, "click"), [])
|
||||||
|
|
||||||
|
def test_option_selected_and_container_drift_fail_closed_before_click(self) -> None:
|
||||||
|
base = _FIXTURE.read_text(encoding="utf-8")
|
||||||
|
cases = (
|
||||||
|
base.replace('selected="true" clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"', 'clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"'),
|
||||||
|
base.replace('selected="true" clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"', 'selected="maybe" clickable="true" enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"'),
|
||||||
|
base.replace('bounds="[126,1000][438,1172]"', 'bounds="[1,1][20,20]"'),
|
||||||
|
base.replace('enabled="true" visible-to-user="true" bounds="[126,1000][438,1172]"', 'enabled="false" visible-to-user="true" bounds="[126,1000][438,1172]"'),
|
||||||
|
)
|
||||||
|
for hierarchy in cases:
|
||||||
|
with self.subTest(), self.assertRaises(SkuSelectionError):
|
||||||
|
SkuSelectionFlow(UiautomatorSkuPanelAdapter(_RawDevice(hierarchy), 10)).select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||||
|
|
||||||
|
def test_invalid_bounds_stop_before_action(self) -> None:
|
||||||
|
for bounds in ("", "[1,2][1,3]", "[1,2][3,2]", "[0,0][1081,1]", "[0,0][1,2377]", "[a,0][1,1]"):
|
||||||
|
with self.subTest(bounds=bounds), self.assertRaises(SkuSelectionError):
|
||||||
|
_action_bounds(bounds)
|
||||||
|
|
||||||
|
device = _RawDevice(_PRODUCT_PAGE.replace("[900,1312][1056,1355]", "[0,0][1081,1]"))
|
||||||
|
with self.assertRaises(SkuSelectionError):
|
||||||
|
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
|
||||||
|
self.assertEqual(_actions(device, "click"), [])
|
||||||
|
|
||||||
|
def test_color_readback_failure_never_attempts_second_option(self) -> None:
|
||||||
|
device = _RawDevice()
|
||||||
|
device.fail_color_readback = True
|
||||||
|
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10))
|
||||||
|
flow.open_sku_panel(_TARGET_URL)
|
||||||
|
device.select_alternates()
|
||||||
|
with self.assertRaises(SkuSelectionError):
|
||||||
|
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||||
|
self.assertEqual(_tap_centers(device), [(978, 1333), (282, 1086)])
|
||||||
|
|
||||||
|
def test_non_target_selection_restores_each_dimension_once(self) -> None:
|
||||||
|
device = _RawDevice()
|
||||||
|
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10))
|
||||||
|
flow.open_sku_panel(_TARGET_URL)
|
||||||
|
device.select_alternates()
|
||||||
|
flow.select_sku_options(resolve_task_selection(_TASK_COLOR, _TASK_SIZE))
|
||||||
|
self.assertEqual(
|
||||||
|
_tap_centers(device),
|
||||||
|
[(978, 1333), (282, 1086), (635, 1772)],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_price_rejects_coupon_prefix_extra_amount_and_bottom_action(self) -> None:
|
||||||
|
for replacement in ("券后 ¥12.88", "会员补贴 ¥12.88", "到手 ¥12.88", "实付 ¥12.88", "区间 ¥12.88", "原价 ¥12.88", "划线价 ¥12.88", "最低 ¥12.88", "低至 ¥12.88", "起价 ¥12.88", "快卖完 1 ¥12.88", "快卖完 ¥12.88 ¥11.88"):
|
||||||
|
with self.subTest(replacement=replacement):
|
||||||
|
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8").replace("快卖完 ¥12.88", replacement))
|
||||||
|
with self.assertRaises(SkuSelectionError):
|
||||||
|
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).read_sku_unit_price()
|
||||||
|
device = _RawDevice(_FIXTURE.read_text(encoding="utf-8").replace("快卖完 ¥12.88", "提交订单 ¥12.88"))
|
||||||
|
with self.assertRaises(SkuSelectionError):
|
||||||
|
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).read_sku_unit_price()
|
||||||
|
clickable_parent = _FIXTURE.read_text(encoding="utf-8").replace(
|
||||||
|
'<node package="" class="android.view.ViewGroup" bounds="[396,498][895,570]">',
|
||||||
|
'<node package="" class="android.view.ViewGroup" clickable="true" bounds="[396,498][895,570]">',
|
||||||
|
)
|
||||||
|
with self.assertRaises(SkuSelectionError):
|
||||||
|
SkuSelectionFlow(UiautomatorSkuPanelAdapter(_RawDevice(clickable_parent), 10)).read_sku_unit_price()
|
||||||
|
|
||||||
|
def test_public_api_and_protocol_have_no_broad_or_order_operations(self) -> None:
|
||||||
|
forbidden = {"quantity", "confirm", "authorization", "fence", "submit", "payment", "click"}
|
||||||
|
self.assertTrue(forbidden.isdisjoint(SkuSelectionFlow.__dict__))
|
||||||
|
self.assertTrue(forbidden.isdisjoint(SkuPanelDevice.__dict__))
|
||||||
|
self.assertTrue(forbidden.isdisjoint(pdd.__all__))
|
||||||
|
|
||||||
|
def test_static_ast_boundary_limits_flow_runner_adapter_and_cli(self) -> None:
|
||||||
|
root = Path(__file__).resolve().parents[2]
|
||||||
|
files = (
|
||||||
|
root / "src" / "cmbuyer_client" / "pdd" / "sku_selection.py",
|
||||||
|
root / "src" / "cmbuyer_client" / "pdd" / "sku_selection_runner.py",
|
||||||
|
root / "scripts" / "run_t103_sku_selection.py",
|
||||||
|
)
|
||||||
|
forbidden = ("quantity", "confirm", "authorization", "fence", "submit_order", "payment")
|
||||||
|
for path in files:
|
||||||
|
source = path.read_text(encoding="utf-8")
|
||||||
|
with self.subTest(path=path.name):
|
||||||
|
self.assertTrue(all(token not in source.lower() for token in forbidden))
|
||||||
|
tree = ast.parse(source)
|
||||||
|
self.assertFalse(any(isinstance(node, ast.ImportFrom) and node.module in {"selenium", "requests"} for node in ast.walk(tree)))
|
||||||
|
runner_tree = ast.parse(files[1].read_text(encoding="utf-8"))
|
||||||
|
click_calls = [node for node in ast.walk(runner_tree) if isinstance(node, ast.Constant) and node.value == "click"]
|
||||||
|
self.assertEqual(len(click_calls), 1)
|
||||||
|
|
||||||
|
def test_entry_wait_rejects_unchanged_or_duplicate_page_without_click(self) -> None:
|
||||||
|
now = [0.0]
|
||||||
|
device = _RawDevice()
|
||||||
|
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10), 0.01, 0.01, lambda: now[0], lambda seconds: now.__setitem__(0, now[0] + seconds))
|
||||||
|
with self.assertRaises(SkuSelectionError):
|
||||||
|
flow.open_sku_panel(_TARGET_URL, _PRODUCT_PAGE)
|
||||||
|
self.assertEqual(_actions(device, "click"), [])
|
||||||
|
|
||||||
|
def test_action_postcondition_wait_never_repeats_entry_click(self) -> None:
|
||||||
|
class NoPanelAfterEntry(_RawDevice):
|
||||||
|
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
|
if method == "click":
|
||||||
|
self.calls.append(("jsonrpc", method, params, timeout))
|
||||||
|
return ""
|
||||||
|
return super().jsonrpc_call(method, params, timeout)
|
||||||
|
|
||||||
|
device = NoPanelAfterEntry()
|
||||||
|
with self.assertRaises(SkuSelectionError):
|
||||||
|
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
|
||||||
|
self.assertEqual(_tap_centers(device), [(978, 1333)])
|
||||||
|
|
||||||
|
duplicate = _duplicate_entry()
|
||||||
|
device = _RawDevice(duplicate)
|
||||||
|
with self.assertRaises(SkuSelectionError):
|
||||||
|
SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).open_sku_panel(_TARGET_URL)
|
||||||
|
self.assertEqual(_actions(device, "click"), [])
|
||||||
|
|
||||||
|
def test_fixture_contains_no_address_phone_or_payment_credentials(self) -> None:
|
||||||
|
for fixture in (_FIXTURE, _ENTRY_FIXTURE):
|
||||||
|
content = fixture.read_text(encoding="utf-8")
|
||||||
|
with self.subTest(fixture=fixture.name):
|
||||||
|
self.assertNotRegex(content, r"1[3-9]\d{9}")
|
||||||
|
for forbidden in ("地址", "收货", "支付", "银行卡", "身份证"):
|
||||||
|
self.assertNotIn(forbidden, content)
|
||||||
|
content = _FIXTURE.read_text(encoding="utf-8")
|
||||||
|
root = ElementTree.fromstring(content)
|
||||||
|
leaf = next(node for node in root.iter("node") if node.get("text") == "提交订单 ¥12.88")
|
||||||
|
self.assertEqual(leaf.get("clickable"), "false")
|
||||||
|
self.assertEqual(leaf.get("bounds"), "[369,2225][710,2284]")
|
||||||
|
|
||||||
|
|
||||||
|
class SkuSelectionRunnerTests(unittest.TestCase):
|
||||||
|
def _runner(self, adb: _FakeAdb, device: _RawDevice) -> SkuSelectionRunner:
|
||||||
|
device.hierarchy = "<hierarchy />"
|
||||||
|
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE.replace("<hierarchy>", '<hierarchy post-intent="1">'))
|
||||||
|
return SkuSelectionRunner(adb, lambda serial: device, 10)
|
||||||
|
|
||||||
|
def test_runner_atomically_publishes_screenshot_and_redacted_manifest(self) -> None:
|
||||||
|
adb = _FakeAdb()
|
||||||
|
device = _RawDevice()
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
target = Path(temporary) / "result"
|
||||||
|
result = self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||||
|
|
||||||
|
self.assertEqual(result.unit_price, "12.88")
|
||||||
|
manifest = result.manifest_path.read_text(encoding="utf-8")
|
||||||
|
self.assertTrue(result.screenshot_path.is_file())
|
||||||
|
self.assertNotIn("device-1", manifest)
|
||||||
|
self.assertNotIn("hierarchy", manifest)
|
||||||
|
self.assertNotIn("已选", manifest)
|
||||||
|
self.assertIn('"unit_price": "12.88"', manifest)
|
||||||
|
self.assertIn('"selection_status": "restored"', manifest)
|
||||||
|
self.assertIn('"panel_status": "verified"', manifest)
|
||||||
|
self.assertIn('"safe_exit": "completed"', manifest)
|
||||||
|
self.assertFalse((target / "hierarchy.xml").exists())
|
||||||
|
self.assertEqual(_actions(device, "pressKey"), [("jsonrpc", "pressKey", ["back"], 10)])
|
||||||
|
|
||||||
|
def test_target_created_during_publish_is_preserved_without_staging_residue(self) -> None:
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
target = Path(temporary) / "result"
|
||||||
|
original_rename = runner_module.os.rename
|
||||||
|
|
||||||
|
def create_target_then_rename(source: str | Path, destination: str | Path) -> None:
|
||||||
|
Path(destination).mkdir()
|
||||||
|
(Path(destination) / "sentinel").write_text("keep", encoding="utf-8")
|
||||||
|
original_rename(source, destination)
|
||||||
|
|
||||||
|
with patch.object(runner_module.os, "rename", side_effect=create_target_then_rename), self.assertRaises(SkuSelectionRunError):
|
||||||
|
self._runner(_FakeAdb(), _RawDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||||
|
self.assertEqual((target / "sentinel").read_text(encoding="utf-8"), "keep")
|
||||||
|
self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
|
||||||
|
|
||||||
|
def test_bad_screenshot_or_existing_target_never_publishes_manifest(self) -> None:
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
target = Path(temporary) / "result"
|
||||||
|
with self.assertRaises(SkuSelectionScreenshotError):
|
||||||
|
self._runner(_FakeAdb(), _RawDevice(screenshot="not-image")).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
self.assertEqual(list(Path(temporary).glob(".result.staging-*")), [])
|
||||||
|
|
||||||
|
target = Path(temporary) / "write-failure"
|
||||||
|
with patch.object(runner_module, "_save_base64_screenshot", side_effect=OSError("private path")):
|
||||||
|
with self.assertRaises(SkuSelectionScreenshotError):
|
||||||
|
self._runner(_FakeAdb(), _RawDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
self.assertEqual(list(Path(temporary).glob(".write-failure.staging-*")), [])
|
||||||
|
|
||||||
|
adb = _FakeAdb()
|
||||||
|
device = _RawDevice()
|
||||||
|
target.mkdir()
|
||||||
|
sentinel = target / "keep"
|
||||||
|
sentinel.write_text("keep", encoding="utf-8")
|
||||||
|
with self.assertRaises(SkuSelectionRunError):
|
||||||
|
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||||
|
self.assertEqual(adb.calls, [])
|
||||||
|
self.assertEqual(device.calls, [])
|
||||||
|
self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep")
|
||||||
|
|
||||||
|
def test_device_screen_and_output_preflight_fail_before_any_click(self) -> None:
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
adb = _FakeAdb()
|
||||||
|
adb.inspection = DeviceInspection(AdbDevice(serial="device-1", state="device"), "wrong", "16")
|
||||||
|
device = _RawDevice()
|
||||||
|
with self.assertRaises(SkuSelectionRunError):
|
||||||
|
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
|
||||||
|
self.assertEqual(device.calls, [])
|
||||||
|
|
||||||
|
class WrongScreenDevice(_RawDevice):
|
||||||
|
def window_size(self) -> tuple[int, int]:
|
||||||
|
return 1080, 1920
|
||||||
|
|
||||||
|
device = WrongScreenDevice()
|
||||||
|
with self.assertRaises(SkuSelectionRunError):
|
||||||
|
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "screen")
|
||||||
|
self.assertEqual(_actions(device, "click"), [])
|
||||||
|
|
||||||
|
parent_file = Path(temporary) / "not-a-directory"
|
||||||
|
parent_file.write_text("x", encoding="utf-8")
|
||||||
|
adb = _FakeAdb()
|
||||||
|
device = _RawDevice()
|
||||||
|
with self.assertRaises(SkuSelectionRunError):
|
||||||
|
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, parent_file / "result")
|
||||||
|
self.assertEqual(adb.calls, [])
|
||||||
|
self.assertEqual(device.calls, [])
|
||||||
|
|
||||||
|
def test_small_but_valid_png_is_not_accepted(self) -> None:
|
||||||
|
image = Image.new("RGB", (1, 1), "white")
|
||||||
|
raw = BytesIO(); image.save(raw, format="PNG")
|
||||||
|
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionScreenshotError):
|
||||||
|
self._runner(_FakeAdb(), _RawDevice(screenshot=base64.b64encode(raw.getvalue()).decode("ascii"))).run(
|
||||||
|
"device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_failure_after_entry_attempts_one_safe_exit_and_hides_device_detail(self) -> None:
|
||||||
|
adb = _FakeAdb()
|
||||||
|
device = _RawDevice()
|
||||||
|
device.fail_color_readback = True
|
||||||
|
device.select_alternates()
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
with self.assertRaises(SkuSelectionError):
|
||||||
|
self._runner(adb, device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
|
||||||
|
self.assertEqual(_actions(device, "pressKey"), [])
|
||||||
|
|
||||||
|
class FailingRawDevice(_RawDevice):
|
||||||
|
def app_info(self, package_name: str) -> dict[str, str]:
|
||||||
|
raise RuntimeError("device-1 <xml>private</xml>")
|
||||||
|
|
||||||
|
with self.assertRaises(SkuSelectionDeviceAdapterError) as raised:
|
||||||
|
UiautomatorSkuPanelAdapter(FailingRawDevice(), 10).app_info("com.xunmeng.pinduoduo")
|
||||||
|
self.assertNotIn("device-1", str(raised.exception))
|
||||||
|
self.assertNotIn("private", str(raised.exception))
|
||||||
|
|
||||||
|
def test_unverified_failure_never_sends_blind_back(self) -> None:
|
||||||
|
class InvalidAfterOptionDevice(_RawDevice):
|
||||||
|
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
|
value = super().jsonrpc_call(method, params, timeout)
|
||||||
|
if method == "click" and "[396,498][895,570]" in self.hierarchy:
|
||||||
|
self.hierarchy = "<hierarchy />"
|
||||||
|
return value
|
||||||
|
|
||||||
|
device = InvalidAfterOptionDevice()
|
||||||
|
device.select_alternates()
|
||||||
|
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionError):
|
||||||
|
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
|
||||||
|
self.assertEqual(_actions(device, "pressKey"), [])
|
||||||
|
|
||||||
|
def test_adapter_timeout_is_mapped_without_third_party_detail(self) -> None:
|
||||||
|
class TimeoutRawDevice(_RawDevice):
|
||||||
|
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
|
raise TimeoutError("device-1 <hierarchy>private</hierarchy>")
|
||||||
|
|
||||||
|
with self.assertRaises(SkuSelectionRunError) as raised:
|
||||||
|
UiautomatorSkuPanelAdapter(TimeoutRawDevice(), 10).dump_window_hierarchy()
|
||||||
|
self.assertNotIn("device-1", str(raised.exception))
|
||||||
|
self.assertNotIn("private", str(raised.exception))
|
||||||
|
|
||||||
|
def test_entry_attempt_is_recorded_before_unconfirmed_click_and_not_retried(self) -> None:
|
||||||
|
class TimeoutTapDevice(_RawDevice):
|
||||||
|
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
|
self.calls.append(("jsonrpc", method, params, timeout))
|
||||||
|
if method == "click":
|
||||||
|
raise TimeoutError("device detail")
|
||||||
|
return super().jsonrpc_call(method, params, timeout)
|
||||||
|
|
||||||
|
adapter = UiautomatorSkuPanelAdapter(TimeoutTapDevice(), 10)
|
||||||
|
with self.assertRaises(SkuSelectionRunError):
|
||||||
|
adapter.tap_sku_entry("[900,1312][1056,1355]")
|
||||||
|
self.assertTrue(adapter.entry_was_tapped)
|
||||||
|
self.assertEqual(_actions(adapter._device, "click"), [("jsonrpc", "click", [978, 1333], 10)])
|
||||||
|
|
||||||
|
def test_entry_stability_interruptions_never_click(self) -> None:
|
||||||
|
now = [0.0]
|
||||||
|
class SequenceDevice(_RawDevice):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(); self.frames = [_PRODUCT_PAGE, "<hierarchy />", _PRODUCT_PAGE]
|
||||||
|
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
|
if method == "dumpWindowHierarchy" and self.frames:
|
||||||
|
self.hierarchy = self.frames.pop(0)
|
||||||
|
return super().jsonrpc_call(method, params, timeout)
|
||||||
|
device = SequenceDevice()
|
||||||
|
flow = SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10), .02, .01, lambda: now[0], lambda x: now.__setitem__(0, now[0] + x))
|
||||||
|
with self.assertRaises(SkuSelectionError): flow.open_sku_panel(_TARGET_URL, "<hierarchy />")
|
||||||
|
self.assertEqual(_actions(device, "click"), [])
|
||||||
|
|
||||||
|
def test_screenshot_drift_and_foreground_drift_publish_nothing_and_never_back(self) -> None:
|
||||||
|
for drift in ("color", "size", "price"):
|
||||||
|
class DriftDevice(_RawDevice):
|
||||||
|
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
|
value = super().jsonrpc_call(method, params, timeout)
|
||||||
|
if method == "takeScreenshot":
|
||||||
|
if drift == "price":
|
||||||
|
self.hierarchy = self.hierarchy.replace("快卖完 ¥12.88", "快卖完 ¥13.88")
|
||||||
|
else:
|
||||||
|
root = ElementTree.fromstring(self.hierarchy)
|
||||||
|
if drift == "color":
|
||||||
|
for node in root.iter("node"):
|
||||||
|
if node.get("selected") is not None and ",1000]" in node.get("bounds", ""):
|
||||||
|
node.set("selected", "false")
|
||||||
|
next(node for node in root.iter("node") if node.get("content-desc") == "粉红").set("selected", "true")
|
||||||
|
else:
|
||||||
|
for node in root.iter("node"):
|
||||||
|
if node.get("selected") is not None and ",1730]" in node.get("bounds", ""):
|
||||||
|
node.set("selected", "false")
|
||||||
|
next(node for node in root.iter("node") if node.get("text") == "L(建议115-130)").set("selected", "true")
|
||||||
|
self.hierarchy = ElementTree.tostring(root, encoding="unicode")
|
||||||
|
return value
|
||||||
|
with self.subTest(drift=drift), TemporaryDirectory() as temporary:
|
||||||
|
target = Path(temporary) / "out"
|
||||||
|
with self.assertRaises((SkuSelectionError, SkuSelectionRunError)):
|
||||||
|
self._runner(_FakeAdb(), DriftDevice()).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
self.assertFalse((target / "manifest.json").exists())
|
||||||
|
self.assertEqual(list(Path(temporary).glob(".out.staging-*")), [])
|
||||||
|
|
||||||
|
device = _RawDevice(); device.select_alternates()
|
||||||
|
device.package = "other"
|
||||||
|
with self.assertRaises(SkuSelectionError): SkuSelectionFlow(UiautomatorSkuPanelAdapter(device, 10)).exit_sku_panel_safely()
|
||||||
|
self.assertEqual(_actions(device, "pressKey"), [])
|
||||||
|
|
||||||
|
def test_screenshot_then_foreground_drift_publishes_nothing_and_never_back(self) -> None:
|
||||||
|
class ForegroundDriftDevice(_RawDevice):
|
||||||
|
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
|
value = super().jsonrpc_call(method, params, timeout)
|
||||||
|
if method == "takeScreenshot": self.package = "other"
|
||||||
|
return value
|
||||||
|
|
||||||
|
device = ForegroundDriftDevice()
|
||||||
|
with TemporaryDirectory() as temporary:
|
||||||
|
target = Path(temporary) / "out"
|
||||||
|
with self.assertRaises(SkuSelectionError):
|
||||||
|
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, target)
|
||||||
|
self.assertFalse(target.exists())
|
||||||
|
self.assertFalse((target / "manifest.json").exists())
|
||||||
|
self.assertEqual(list(Path(temporary).glob(".out.staging-*")), [])
|
||||||
|
self.assertEqual(_actions(device, "pressKey"), [])
|
||||||
|
|
||||||
|
def test_option_timeout_reconciliation_controls_back_once(self) -> None:
|
||||||
|
class OptionTimeoutDevice(_RawDevice):
|
||||||
|
def __init__(self, delivered: bool) -> None:
|
||||||
|
super().__init__(); self.delivered = delivered; self.clicks = 0
|
||||||
|
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
|
if method == "click":
|
||||||
|
self.clicks += 1
|
||||||
|
if self.clicks == 2:
|
||||||
|
if self.delivered: super().jsonrpc_call(method, params, timeout)
|
||||||
|
else: self.calls.append(("jsonrpc", method, params, timeout))
|
||||||
|
raise TimeoutError("uncertain option")
|
||||||
|
return super().jsonrpc_call(method, params, timeout)
|
||||||
|
|
||||||
|
for delivered, expected_back in ((False, 0), (True, 1)):
|
||||||
|
with self.subTest(delivered=delivered), TemporaryDirectory() as temporary:
|
||||||
|
device = OptionTimeoutDevice(delivered); device.select_alternates()
|
||||||
|
adb = _FakeAdb(); device.hierarchy = "<hierarchy />"
|
||||||
|
adb.on_intent = lambda: setattr(device, "hierarchy", _PRODUCT_PAGE.replace("<hierarchy>", '<hierarchy post-intent="1">'))
|
||||||
|
runner = SkuSelectionRunner(adb, lambda serial: device, .03)
|
||||||
|
with self.assertRaises(SkuSelectionRunError):
|
||||||
|
runner.run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "out")
|
||||||
|
self.assertEqual(len(_actions(device, "click")), 2)
|
||||||
|
self.assertEqual(len(_actions(device, "pressKey")), expected_back)
|
||||||
|
|
||||||
|
def test_back_timeout_is_never_retried(self) -> None:
|
||||||
|
class BackTimeoutDevice(_RawDevice):
|
||||||
|
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
|
if method == "pressKey":
|
||||||
|
super().jsonrpc_call(method, params, timeout)
|
||||||
|
raise TimeoutError("back uncertain")
|
||||||
|
return super().jsonrpc_call(method, params, timeout)
|
||||||
|
|
||||||
|
device = BackTimeoutDevice()
|
||||||
|
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionRunError):
|
||||||
|
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "out")
|
||||||
|
self.assertEqual(len(_actions(device, "pressKey")), 1)
|
||||||
|
|
||||||
|
def test_entry_click_timeout_reconciles_only_through_verified_flow_exit(self) -> None:
|
||||||
|
class DeliveredThenTimeout(_RawDevice):
|
||||||
|
def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str:
|
||||||
|
if method == "click" and self.hierarchy != _FIXTURE.read_text(encoding="utf-8"):
|
||||||
|
super().jsonrpc_call(method, params, timeout)
|
||||||
|
raise TimeoutError("delivery uncertain")
|
||||||
|
return super().jsonrpc_call(method, params, timeout)
|
||||||
|
|
||||||
|
device = DeliveredThenTimeout()
|
||||||
|
with TemporaryDirectory() as temporary, self.assertRaises(SkuSelectionRunError):
|
||||||
|
self._runner(_FakeAdb(), device).run("device-1", _TARGET_URL, _TASK_COLOR, _TASK_SIZE, Path(temporary) / "result")
|
||||||
|
self.assertEqual(len(_actions(device, "click")), 1)
|
||||||
|
self.assertEqual(len(_actions(device, "pressKey")), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class SkuSelectionCliTests(unittest.TestCase):
|
||||||
|
def test_cli_accepts_only_target_url_and_task_values(self) -> None:
|
||||||
|
script = _load_runner_script()
|
||||||
|
valid = {
|
||||||
|
"serial": "device-1",
|
||||||
|
"url": _TARGET_URL,
|
||||||
|
"color": _TASK_COLOR,
|
||||||
|
"size": _TASK_SIZE,
|
||||||
|
"output_dir": Path("evidence"),
|
||||||
|
"timeout": 10.0,
|
||||||
|
"adb": "adb",
|
||||||
|
}
|
||||||
|
script.validate_arguments(type("Arguments", (), valid)())
|
||||||
|
for field, value in (("serial", ""), ("url", "https://mobile.yangkeduo.com/goods.html?goods_id=1"), ("color", "黑色 CHA (纯棉)"), ("size", "M(建议100-115)"), ("timeout", 0), ("timeout", float("inf"))):
|
||||||
|
with self.subTest(field=field, value=value), self.assertRaises((ValueError, SkuSelectionError)):
|
||||||
|
script.validate_arguments(type("Arguments", (), valid | {field: value})())
|
||||||
|
|
||||||
|
def test_cli_main_catches_flow_error_without_traceback_or_page_body(self) -> None:
|
||||||
|
script = _load_runner_script()
|
||||||
|
|
||||||
|
class FlowFailingRunner:
|
||||||
|
def __init__(self, *args: object, **kwargs: object) -> None: pass
|
||||||
|
def run(self, *args: object, **kwargs: object) -> object:
|
||||||
|
raise SkuSelectionError("<hierarchy>page-body</hierarchy>")
|
||||||
|
|
||||||
|
stderr = BytesIO()
|
||||||
|
# TextIOWrapper keeps the assertion independent from host console encoding.
|
||||||
|
import io
|
||||||
|
text_stderr = io.TextIOWrapper(stderr, encoding="utf-8")
|
||||||
|
with patch.object(script, "SkuSelectionRunner", FlowFailingRunner), redirect_stderr(text_stderr):
|
||||||
|
status = script.main([
|
||||||
|
"--serial", "device-1", "--url", _TARGET_URL, "--color", _TASK_COLOR,
|
||||||
|
"--size", _TASK_SIZE, "--output-dir", "evidence",
|
||||||
|
])
|
||||||
|
text_stderr.flush()
|
||||||
|
output = stderr.getvalue().decode("utf-8")
|
||||||
|
self.assertEqual(status, 1)
|
||||||
|
self.assertNotIn("Traceback", output)
|
||||||
|
self.assertNotIn("page-body", output)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_runner_script() -> object:
|
||||||
|
path = Path(__file__).resolve().parents[2] / "scripts" / "run_t103_sku_selection.py"
|
||||||
|
specification = importlib.util.spec_from_file_location("run_t103_sku_selection_test", path)
|
||||||
|
if specification is None or specification.loader is None:
|
||||||
|
raise RuntimeError("无法加载 T-103 运行脚本。")
|
||||||
|
module = importlib.util.module_from_spec(specification)
|
||||||
|
specification.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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
@@ -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 像素;
|
||||||
- 可配置单任务数量与最高总价系统上限;
|
- 可配置单任务数量与最高总价系统上限;
|
||||||
- 首次真实提交真机任务的人工授权和待付款订单处置步骤。
|
- 首次真实提交真机任务的人工授权和待付款订单处置步骤。
|
||||||
|
|||||||
+12
-9
@@ -25,9 +25,9 @@
|
|||||||
以及绑定 PKG110 / Android 16 / 拼多多 8.17.0 的规格证据确定性脱敏 CLI;尚无规格选择、价格读取或下单流程
|
以及绑定 PKG110 / Android 16 / 拼多多 8.17.0 的规格证据确定性脱敏 CLI;尚无规格选择、价格读取或下单流程
|
||||||
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 80 项离线单元测试
|
- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 80 项离线单元测试
|
||||||
(全部 mock,不连接真机)
|
(全部 mock,不连接真机)
|
||||||
- 数据:SQLite v1 核心表与迁移已落成,但仍是旧两趟 schema(含 `spec_trials`、
|
- 数据:SQLite v2 单趟核心表与领域状态机已落成,旧 `spec_trials`、`authorized_unit_price` 和
|
||||||
`authorized_unit_price` 和旧状态);无业务实例数据。T-111 只冻结目标契约,不改生产代码;T-209
|
两趟状态已由受保护迁移移除;无业务实例数据。T-203 可以基于新模型实现批量“开始采购”与
|
||||||
必须先迁移 schema / 领域状态机,T-203 才能实现新“开始采购”事务。
|
一次性授权事务。
|
||||||
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
|
- 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口
|
||||||
优先使用合规的既有 venv;仅在其缺失时才从 Python Launcher 已安装版本中选择最高的 Python 3.11+,
|
优先使用合规的既有 venv;仅在其缺失时才从 Python Launcher 已安装版本中选择最高的 Python 3.11+,
|
||||||
并且不覆盖低版本环境;成功后打印真实启动命令。
|
并且不覆盖低版本环境;成功后打印真实启动命令。
|
||||||
@@ -40,15 +40,15 @@
|
|||||||
手机号,并保留目标预选规格、顶部当前价“快卖完 ¥12.88”和原价“¥29.88”;底部“提交订单 ¥12.88”
|
手机号,并保留目标预选规格、顶部当前价“快卖完 ¥12.88”和原价“¥29.88”;底部“提交订单 ¥12.88”
|
||||||
继续属于硬拒绝区。派生截图虽然把顶部价格遮住一半,但项目已停止遮罩器开发,视觉完整性不再阻塞
|
继续属于硬拒绝区。派生截图虽然把顶部价格遮住一半,但项目已停止遮罩器开发,视觉完整性不再阻塞
|
||||||
规格选择与读价;T-204 将直接上传内部原始截图供管理员查看。T-010 已允许不依赖真机字段的 T-201
|
规格选择与读价;T-204 将直接上传内部原始截图供管理员查看。T-010 已允许不依赖真机字段的 T-201
|
||||||
和只创建 `DRAFT` 的 T-202 并行。T-209 的 schema / 状态机迁移不依赖页面选择器,可在 T-111
|
和只创建 `DRAFT` 的 T-202 并行。T-209 已完成 schema / 状态机单趟迁移;T-203 服务端
|
||||||
完成后推进;随后做 T-203 服务端“开始采购”授权事务。T-205 起实际 attempt / 真机字段继续等待 T-103。
|
“开始采购”授权事务可立即推进。T-205 起实际 attempt / 真机字段继续等待 T-103。
|
||||||
|
|
||||||
## 当前目录要点
|
## 当前目录要点
|
||||||
|
|
||||||
| 路径 | 状态 | 说明 |
|
| 路径 | 状态 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
|
| `docs/` | 已有 | 项目规范化文档,本次已完整生成 |
|
||||||
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-202) | T-111 单趟契约已完成;T-103 已恢复;T-202 在独立工作树待主审提交 |
|
| `docs/tasks/` | 已有(含 T-001~T-111、T-201~T-203、T-209) | T-111 单趟契约、T-202 手工 DRAFT 建单及 T-209 单趟 schema 已完成;T-103、T-203 并行推进 |
|
||||||
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
| `docs/design/` | 已有(6 个原型) | web 登录 / 建单 / 工作台 / 详情,desk 采购执行 / 配置;均已人工确认 |
|
||||||
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
| `scripts/` | 已有 | 上下文门禁、Vikunja 单向导出与 MCP 启动包装 |
|
||||||
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 |
|
| `admin/` | 已初始化 | Go 1.23+ / gin / SQLite,含核心模型、迁移与状态机;无真机采购执行 |
|
||||||
@@ -65,11 +65,14 @@
|
|||||||
- 已完成:T-002(采购工具 Python 骨架)、T-003(双端统一初始化与验证入口)、
|
- 已完成:T-002(采购工具 Python 骨架)、T-003(双端统一初始化与验证入口)、
|
||||||
T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)、T-102(canonical
|
T-004(核心数据模型)、T-101(真机环境盘点与 USB/WiFi 双通道人工验收)、T-102(canonical
|
||||||
链接打开与目标商品/隐私人工验收)。
|
链接打开与目标商品/隐私人工验收)。
|
||||||
- 已完成 T-010(安全并行门禁)与 T-201(管理员登录与会话)。T-202 已由 admin agent 实现且
|
- 已完成 T-010(安全并行门禁)、T-201(管理员登录与会话)与 T-202(手工 DRAFT 建单和
|
||||||
测试通过,仍只创建/展示 `DRAFT`;当前在独立工作树等待主 agent 审阅、提交和推送。
|
基础列表)。T-202 已通过主 agent 独立审查、竞态测试与完整门禁并合入主线,仍只创建/展示
|
||||||
|
`DRAFT`,未实现授权、设备领取或采购执行。
|
||||||
- 已完成 T-110(受控规格入口边界)与 T-111(开始采购授权的单趟契约)。T-103 已恢复为
|
- 已完成 T-110(受控规格入口边界)与 T-111(开始采购授权的单趟契约)。T-103 已恢复为
|
||||||
`DOING`,以 `SkuSelectionFlow` 继续最小 fixture、精确规格和读价,不实现数量、确认页或提交;
|
`DOING`,以 `SkuSelectionFlow` 继续最小 fixture、精确规格和读价,不实现数量、确认页或提交;
|
||||||
内部原始截图上传交给 T-204。admin 方向在 T-202 主审合入后,先落 T-209 迁移旧 schema/状态机。
|
内部原始截图上传交给 T-204。admin 方向已完成 T-209,正在转入 T-203“开始采购”授权事务。
|
||||||
|
- 已完成 T-209:SQLite v2 已迁移为单趟授权、采购尝试和提交围栏模型,并删除旧试选领域模型;
|
||||||
|
迁移和状态机护栏已通过完整门禁。下一项采购服务任务为 T-203 批量“开始采购”与一次性授权。
|
||||||
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
|
- 已确认原型继续只作信息架构依据;原型假数据不调用真实接口、不驱动真机。真机结论改变
|
||||||
可读字段时必须先回修原型与交互清单。
|
可读字段时必须先回修原型与交互清单。
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -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:
|
||||||
|
|||||||
+10
-5
@@ -17,15 +17,12 @@ write_paths:
|
|||||||
- client/tests/pdd/**
|
- client/tests/pdd/**
|
||||||
- client/tests/device/**
|
- client/tests/device/**
|
||||||
- client/scripts/capture_sku_panel_spike.py
|
- client/scripts/capture_sku_panel_spike.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
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-04T08:24:57Z sha256=19a0864d83c5e41e57a29002b69f7767b50429dcf0fbace02f8303f92b71bac6 -->
|
<!-- BEGIN VIKUNJA EXPORT id=23 synced=2026-08-04T10:01:59Z sha256=ed26c948a564efd44a1f3d3336dd4c383cc42acf567357c5c8fedb32d2459a0e -->
|
||||||
## 问题 / 背景
|
## 问题 / 背景
|
||||||
|
|
||||||
T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:规格面板由详情页精确唯一的“快要抢光”打开;T-110 已把该证据/版本绑定入口批准为受控导航。面板刚打开时目标颜色“黑色CHA(纯棉)”和尺码“M(建议100-115)”自动选中。
|
T-102 已证明 canonical 链接可进入目标商品。T-103 在 PKG110 / Android 16 / 拼多多 8.17.0、goods_id `937122477375` 上确认:规格面板由详情页精确唯一的“快要抢光”打开;T-110 已把该证据/版本绑定入口批准为受控导航。面板刚打开时目标颜色“黑色CHA(纯棉)”和尺码“M(建议100-115)”自动选中。
|
||||||
@@ -173,6 +170,14 @@ T-103 sanitizer v2 坐标修正与主审:提交 44c027a 将 screenshot space
|
|||||||
### 2026-08-04T08:24:12Z · ila
|
### 2026-08-04T08:24:12Z · ila
|
||||||
|
|
||||||
2026-08-04:T-111 单趟契约主审通过,T-103 解除架构阻塞。恢复后只实现 SkuSelectionFlow:受控入口、维度内精确选择、选中态读回、SKU 当前价唯一读取、原始截图和安全退出;不得包含数量、确认页、授权、提交围栏、提交订单或支付。客户端工作树现有未提交 v6 遮罩测试残留必须先撤销到已提交 v5 基线,再开始新实现。needs_device=true,离线实现后仍等待人工真机验收。
|
2026-08-04:T-111 单趟契约主审通过,T-103 解除架构阻塞。恢复后只实现 SkuSelectionFlow:受控入口、维度内精确选择、选中态读回、SKU 当前价唯一读取、原始截图和安全退出;不得包含数量、确认页、授权、提交围栏、提交订单或支付。客户端工作树现有未提交 v6 遮罩测试残留必须先撤销到已提交 v5 基线,再开始新实现。needs_device=true,离线实现后仍等待人工真机验收。
|
||||||
|
|
||||||
|
### 2026-08-04T09:50:25Z · ila
|
||||||
|
|
||||||
|
2026-08-04 离线实现与主审通过:提交 b49a9b4 实现仅限已取证 PDD 8.17.0 / goods_id 937122477375 的受控规格入口、颜色/尺码精确恢复、SKU 当前价读取、本机原始截图和单次安全退出;不包含数量、确认页、授权、提交围栏、提交订单或付款能力。独立审计 PASS,合入主分支 5f650f1;完整 init、109 项测试、Go vet/build、compileall、上下文校验及 diff-check 全部通过。needs_device=true,任务继续 DOING,等待项目所有者先把已记忆规格改为非目标值并返回 PDD 首页后运行真机验收。
|
||||||
|
|
||||||
|
### 2026-08-04T10:01:50Z · ila
|
||||||
|
|
||||||
|
2026-08-04 T-103 入口父容器追加真机证据:只读取证目录 C:\Users\ila20\AppData\Local\cmbuyer\artifacts\T-103\entry-parent-evidence-937122477375-20260804-175737;截图 screenshot.png,XML hierarchy.xml,manifest.json。设备 PKG110 / Android 16 / 拼多多 8.17.0 / Wi-Fi,goods_id 937122477375。项目所有者人工确认截图为目标商品详情页,且“快要抢光 12.88”与“免拼购买”的位置和手机当前画面一致。只读结构核对显示:精确“快要抢光”文本节点本身不可点击,但位于唯一、可见、启用、可点击的 PDD ViewGroup 祖先内;“免拼购买”属于另一底部可点击容器。后续只允许把入口判据收紧调整为“精确唯一快要抢光子节点 + 证据绑定唯一可点击祖先”,不得允许或点击“免拼购买”,必须先补 fixture/反例/超时不重试测试再真机运行。
|
||||||
<!-- END VIKUNJA EXPORT -->
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
id: T-203
|
||||||
|
title: 表格查询与批量开始采购授权
|
||||||
|
phase: 2
|
||||||
|
deps: [T-202, T-209]
|
||||||
|
status: DONE
|
||||||
|
created: 2026-08-04
|
||||||
|
vikunja_task_id: 30
|
||||||
|
context_ref: 1f20271
|
||||||
|
work_branch: task/t-203-start-purchases
|
||||||
|
needs_device: false
|
||||||
|
needs_human_review: false
|
||||||
|
write_paths:
|
||||||
|
- docs/tasks/T-203.md
|
||||||
|
- admin/internal/auth/**
|
||||||
|
- admin/internal/tasks/**
|
||||||
|
- admin/internal/server/**
|
||||||
|
- admin/internal/transport/webui/**
|
||||||
|
- admin/internal/config/**
|
||||||
|
- admin/cmd/server/**
|
||||||
|
- admin/README.md
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- BEGIN VIKUNJA EXPORT id=30 synced=2026-08-04T10:27:15Z sha256=540fb01c9da32148898851f6450b713103f1db40222e1ac0d6dbcc74b007bcb3 -->
|
||||||
|
## 问题 / 背景
|
||||||
|
|
||||||
|
T-202 已完成手工 DRAFT 建单;T-209 将生产 schema/领域状态机迁移到单趟模型。项目所有者明确:管理员点击“开始采购(只创建待付款订单)”本身就是授权,不再增加试选后确认。T-203 负责采购服务查询和批量授权事务,使设备后续只能领取显式授权的 PENDING 任务。
|
||||||
|
|
||||||
|
## 关联需求与交互
|
||||||
|
|
||||||
|
F-004、F-008、F-018;US-003、US-005;IX-005;GET /tasks、POST /tasks/start-purchases;依赖 T-202、T-209。
|
||||||
|
|
||||||
|
## 方案
|
||||||
|
|
||||||
|
1. GET /tasks 支持 keyword、status、created_from、created_to;日期按 Asia/Shanghai 转为 UTC 半开区间,非法筛选返回可访问字段错误;默认全部状态并按 created_at DESC,rowid DESC。
|
||||||
|
2. 页面只让 DRAFT 行可勾选;表格上方显示选中数量、最高总额字符串合计、“系统不会付款”和唯一主按钮“开始采购(只创建待付款订单)”,不加逐行操作或重复确认弹窗。JS 只用同源静态文件,金额以分/BigInt 累计,不用浮点。
|
||||||
|
3. POST 使用服务端生成并渲染的 UUID v4 start_key,接收非空去重任务 id + expected_task_version;批量上限 100。created_by 只取已认证管理员,不接受请求字段。
|
||||||
|
4. 在一个有界 SQLite 写事务内先按 start_key 检查重放,再按 task_id 稳定排序读取并复核全部任务:存在、DRAFT、版本相等、锁定字段完整、数量/总额上限满足显式配置。任一失败整批不变。
|
||||||
|
5. 新请求为整批使用同一 created_at/expires_at;逐条创建 ACTIVE 一次性授权,锁定新 task_version、goods_id、颜色、尺码、数量和 total_price_cap;条件更新每条 DRAFT/version 为 PENDING/version+1,任一 RowsAffected != 1 则全批回滚。
|
||||||
|
6. 相同 start_key + 相同规范集合(输入顺序无关)返回原 authorization ids/版本/有效期,不再次改任务;同 key 子集、超集、不同版本或残缺集合返回 409。网络结果不明时前端冻结原 key/载荷,只允许原样重放。
|
||||||
|
7. 显式配置并启动时校验授权 TTL、最大任务数量、最大总额;金额只用规范十进制字符串。T-207 才关闭过期授权,T-203 只写 expires_at。
|
||||||
|
|
||||||
|
## 验收要点
|
||||||
|
|
||||||
|
- 覆盖单条/100条成功,任务版本仅加一次、快照逐字段相等、管理员来自服务端、UTC 过期时间正确。
|
||||||
|
- 任一缺失/非 DRAFT/版本冲突/字段不完整/数量或金额超限/中途 SQL 失败均整批零修改。
|
||||||
|
- 同 key 同集合、倒序集合、并发重放返回同一结果;同 key 不同集合冲突;不同 key 并发抢同一版本仅一个成功。
|
||||||
|
- 未登录、CSRF、空/重复/畸形/超大请求、配置边界和数据库故障 fail closed,不泄露内部错误。
|
||||||
|
- UI 只有 DRAFT 可选;筛选、全选当前可见项、选择反馈、网络不明重放、焦点/aria-live/横向滚动与不付款文案有测试;无同义确认弹窗。
|
||||||
|
- go test ./...、go test -race ./...、go vet ./...、go build ./...、node --check 静态 JS、完整 init.ps1、上下文校验和 diff-check 通过。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
### 2026-08-04T09:16:39Z · ila
|
||||||
|
|
||||||
|
2026-08-04 开始 T-203:依赖 T-209 已完成并合入 main。主 agent 已完成开工前只读审计,冻结 v2 schema 启动校验、单进程 writeGate、start_key 规范集合重放、Asia/Shanghai 到 UTC 半开区间、julianday 查询及 big.Int 分金额边界;本地状态转 DOING,分支 task/t-203-start-purchases。
|
||||||
|
|
||||||
|
### 2026-08-04T10:18:10Z · ila
|
||||||
|
|
||||||
|
2026-08-04 T-203 独立终审退回两项:未认证须按 docs/api.md 返回 401,已认证但 CSRF 错误才返回 403;原始 JSON 在 64KiB 边界内必须先做严格 UTF-8 校验。现有 auth.Manager 无法只读区分“有效管理会话 + 错 CSRF”与“无会话”,因此任务所有者批准把 write_paths 最小扩展为 admin/internal/auth/**,仅允许增加只读认证状态 API 及测试;不得创建/旋转会话或放宽 CSRF。修复、复审和完整门禁通过前 T-203 保持 DOING。
|
||||||
|
|
||||||
|
### 2026-08-04T10:26:57Z · ila
|
||||||
|
|
||||||
|
2026-08-04 T-203 完成:提交 5dcff4b,合入主分支 03a067e。实现传统任务表格、筛选、DRAFT 批量勾选和“开始采购(只创建待付款订单)”授权;1/100 条在单一事务内生成 ACTIVE 快照并原子转 PENDING,相同 start_key 原集合稳定重放。独立终审两轮后修复未认证 401/CSRF 403 与原始 UTF-8 严格校验。主 agent 合入后完整 init(client 112 tests)、Go 全量/竞态/vet/build、Node 7 tests、上下文和 diff-check 全部通过;未实现领取、真机、提交订单或付款。
|
||||||
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 本任务只实现管理员任务查询与批量“开始采购”授权;不实现任务详情/截图、设备 Bearer 身份、领取/
|
||||||
|
租约、purchase attempt API、提交围栏、结果调和、真机自动化、创建订单点击或付款。
|
||||||
|
- “开始采购(只创建待付款订单)”按钮本身就是明确授权,不得再增加同义确认弹窗,也不得把它拆回
|
||||||
|
试选后确认。按钮附近必须持续显示系统不付款;页面不得提供逐行“开始采购”操作列。
|
||||||
|
- 只有 `DRAFT` 行可以勾选;批量事务必须全有或全无。任一任务缺失、状态/版本变化、锁定字段非法、
|
||||||
|
配置超限、授权插入失败或条件更新未命中,都不得留下部分授权或部分 `PENDING`。
|
||||||
|
- 幂等重放必须先于 DRAFT 状态检查:相同 `start_key` 和相同规范任务集合只返回原结果,任务版本不得
|
||||||
|
再增加;同 key 的子集、超集、不同 expected version 或残缺授权集合一律冲突。网络结果不明只能
|
||||||
|
原样重放同一个 key 和载荷,不能生成新 key。
|
||||||
|
- 授权只锁定任务的新版本、goods_id、颜色、尺码、数量和 `total_price_cap`;不得写入观察单价或
|
||||||
|
`authorized_unit_price`。金额校验、配置比较和浏览器合计均使用十进制字符串/整数分,不用浮点。
|
||||||
|
- `created_by` 只能来自已认证管理员会话;POST 必须验证 CSRF。设备凭据不能调用本接口,本任务也不
|
||||||
|
新增设备接口。内部数据库错误不得回显 SQL、路径、配置值或凭据。
|
||||||
|
- 过期授权的关闭/重置属于 T-207;本任务只创建 `expires_at`。授权一旦进入 `FENCED`,本任务没有
|
||||||
|
释放、取消、重新授权或重试入口。
|
||||||
|
- 本任务不实现、不引用通用真机点击、`submit_order_once()` 或任何支付、免密支付、先用后付能力。
|
||||||
@@ -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()`、支付、免密支付、先用后付或任何扣款能力;
|
||||||
|
既有三道价格闸门、服务端提交围栏、唯一点击一次且不重试的规则不得放宽。
|
||||||
+4
-2
@@ -17,7 +17,7 @@ write_paths:
|
|||||||
- admin/internal/domain/**
|
- admin/internal/domain/**
|
||||||
---
|
---
|
||||||
|
|
||||||
<!-- BEGIN VIKUNJA EXPORT id=29 synced=2026-08-04T08:38:26Z sha256=2cac52971eda168b1d17bc4b6930f97876324d5685af26dc26495de07b44cb7d -->
|
<!-- BEGIN VIKUNJA EXPORT id=29 synced=2026-08-04T09:15:04Z sha256=162d329ca4dac4535882e84fa12b73e5023a4c04e75a2c32548e7b3b84be8abb -->
|
||||||
## 问题 / 背景
|
## 问题 / 背景
|
||||||
|
|
||||||
T-111 已把业务契约改为管理员点击“开始采购”即授权桌面端在同一趟创建待付款订单;现有 SQLite v1 与领域模型仍是旧两趟结构,包含 spec_trials、authorized_unit_price 及 WAITING_CONFIRMATION/PENDING_RETRIAL/AUTHORIZED/RUNNING 等旧状态。T-203 不能在旧结构上继续实现。
|
T-111 已把业务契约改为管理员点击“开始采购”即授权桌面端在同一趟创建待付款订单;现有 SQLite v1 与领域模型仍是旧两趟结构,包含 spec_trials、authorized_unit_price 及 WAITING_CONFIRMATION/PENDING_RETRIAL/AUTHORIZED/RUNNING 等旧状态。T-203 不能在旧结构上继续实现。
|
||||||
@@ -45,7 +45,9 @@ T-111;F-004、F-005、F-008、F-017、F-018;docs/04-architecture.md 第四
|
|||||||
|
|
||||||
## 执行记录
|
## 执行记录
|
||||||
|
|
||||||
(暂无)
|
### 2026-08-04T09:14:34Z · ila
|
||||||
|
|
||||||
|
2026-08-04 完成 T-209:SQLite v2 单趟 schema、领域状态机、迁移 up/down 与 fail-closed 护栏已实现。任务提交 e04f05b,合并提交 f85ef5f;主 agent 独立执行完整 init.ps1、Go test/race/vet/build、上下文校验与 diff-check 均通过。本地任务状态已置 DONE,主线已推送。
|
||||||
<!-- END VIKUNJA EXPORT -->
|
<!-- END VIKUNJA EXPORT -->
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|||||||
Reference in New Issue
Block a user