From c5d3b215ff575e5ee02581361f24e6bf0e526372 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sun, 26 Jul 2026 14:03:32 +0800 Subject: [PATCH] feat(backend): implement task creation and admin web --- backend-api/README.md | 18 +- backend-api/cmd/api/main.go | 103 ++- backend-api/cmd/api/main_test.go | 51 ++ backend-api/go.mod | 1 + backend-api/go.sum | 2 + backend-api/internal/config/config.go | 43 +- backend-api/internal/config/config_test.go | 23 +- backend-api/internal/domain/asset.go | 19 + backend-api/internal/domain/task.go | 221 +++++ backend-api/internal/domain/task_test.go | 137 +++ .../internal/platform/assetstore/store.go | 407 +++++++++ .../platform/assetstore/store_test.go | 255 ++++++ .../platform/migration/runner_test.go | 43 +- .../repository/sqlite/asset_repository.go | 100 ++ .../internal/repository/sqlite/helpers.go | 270 ++++++ .../internal/repository/sqlite/store.go | 17 + .../internal/repository/sqlite/store_test.go | 436 +++++++++ .../repository/sqlite/task_repository.go | 383 ++++++++ .../internal/transport/httpapi/admin_guard.go | 36 + .../transport/httpapi/admin_guard_test.go | 64 ++ .../transport/httpapi/admin_handlers.go | 512 +++++++++++ .../transport/httpapi/admin_handlers_test.go | 421 +++++++++ .../internal/transport/httpapi/router.go | 52 +- .../internal/transport/httpapi/router_test.go | 41 +- .../internal/transport/webui/handler.go | 788 ++++++++++++++++ .../internal/transport/webui/handler_test.go | 792 ++++++++++++++++ .../internal/transport/webui/renderer.go | 85 ++ .../internal/transport/webui/static/admin.css | 863 ++++++++++++++++++ .../internal/transport/webui/static/admin.js | 109 +++ .../transport/webui/templates/error.gohtml | 19 + .../transport/webui/templates/partials.gohtml | 22 + .../webui/templates/task-detail.gohtml | 106 +++ .../transport/webui/templates/task-new.gohtml | 126 +++ .../transport/webui/templates/tasks.gohtml | 93 ++ backend-api/internal/transport/webui/types.go | 87 ++ .../transport/webui/usecase_adapter.go | 217 +++++ backend-api/internal/usecase/asset_service.go | 275 ++++++ .../internal/usecase/asset_service_test.go | 201 ++++ backend-api/internal/usecase/errors.go | 126 +++ backend-api/internal/usecase/ports.go | 93 ++ backend-api/internal/usecase/runtime.go | 38 + backend-api/internal/usecase/task_service.go | 459 ++++++++++ .../internal/usecase/task_service_test.go | 232 +++++ .../migrations/00002_tasks_and_assets.sql | 144 +++ docs/00-ai-start-here.md | 5 +- docs/02-requirements.md | 19 +- docs/03-tech-stack.md | 9 +- docs/04-architecture.md | 15 +- docs/07-user-stories.md | 6 +- docs/08-interaction-checklist.md | 11 +- docs/api.md | 33 +- docs/current-state.md | 28 +- docs/design/admin-task-create.html | 17 +- docs/design/admin-task-detail.html | 3 +- docs/design/android-candidate-confirm.html | 6 +- docs/design/android-task-preview.html | 4 +- docs/tasks/T-203.md | 162 ++++ progress.md | 8 + 58 files changed, 8773 insertions(+), 83 deletions(-) create mode 100644 backend-api/internal/domain/asset.go create mode 100644 backend-api/internal/domain/task.go create mode 100644 backend-api/internal/domain/task_test.go create mode 100644 backend-api/internal/platform/assetstore/store.go create mode 100644 backend-api/internal/platform/assetstore/store_test.go create mode 100644 backend-api/internal/repository/sqlite/asset_repository.go create mode 100644 backend-api/internal/repository/sqlite/helpers.go create mode 100644 backend-api/internal/repository/sqlite/store.go create mode 100644 backend-api/internal/repository/sqlite/store_test.go create mode 100644 backend-api/internal/repository/sqlite/task_repository.go create mode 100644 backend-api/internal/transport/httpapi/admin_guard.go create mode 100644 backend-api/internal/transport/httpapi/admin_guard_test.go create mode 100644 backend-api/internal/transport/httpapi/admin_handlers.go create mode 100644 backend-api/internal/transport/httpapi/admin_handlers_test.go create mode 100644 backend-api/internal/transport/webui/handler.go create mode 100644 backend-api/internal/transport/webui/handler_test.go create mode 100644 backend-api/internal/transport/webui/renderer.go create mode 100644 backend-api/internal/transport/webui/static/admin.css create mode 100644 backend-api/internal/transport/webui/static/admin.js create mode 100644 backend-api/internal/transport/webui/templates/error.gohtml create mode 100644 backend-api/internal/transport/webui/templates/partials.gohtml create mode 100644 backend-api/internal/transport/webui/templates/task-detail.gohtml create mode 100644 backend-api/internal/transport/webui/templates/task-new.gohtml create mode 100644 backend-api/internal/transport/webui/templates/tasks.gohtml create mode 100644 backend-api/internal/transport/webui/types.go create mode 100644 backend-api/internal/transport/webui/usecase_adapter.go create mode 100644 backend-api/internal/usecase/asset_service.go create mode 100644 backend-api/internal/usecase/asset_service_test.go create mode 100644 backend-api/internal/usecase/errors.go create mode 100644 backend-api/internal/usecase/ports.go create mode 100644 backend-api/internal/usecase/runtime.go create mode 100644 backend-api/internal/usecase/task_service.go create mode 100644 backend-api/internal/usecase/task_service_test.go create mode 100644 backend-api/migrations/00002_tasks_and_assets.sql create mode 100644 docs/tasks/T-203.md diff --git a/backend-api/README.md b/backend-api/README.md index dbc9754..958b080 100644 --- a/backend-api/README.md +++ b/backend-api/README.md @@ -1,7 +1,8 @@ # cmroubao backend API -Go 1.23.0、Gin 1.11.0 和 SQLite 构成的最小后端骨架。当前只提供健康检查和数据库 -迁移生命周期,不包含任务、鉴权或管理页面业务。 +Go 1.23.0、Gin 1.11.0 和 SQLite 构成的单进程采购任务服务。当前提供参考图上传与 +规范化、任务创建/列表/详情/取消 API、服务端渲染管理页面、健康检查和显式数据库 +迁移。T-204 正式管理登录完成前,除 `/healthz` 外的业务路由只接受 loopback 请求。 ## 环境 @@ -15,6 +16,7 @@ Go 1.23.0、Gin 1.11.0 和 SQLite 构成的最小后端骨架。当前只提供 | --- | --- | --- | | `CMROUBAO_HTTP_ADDR` | `127.0.0.1:8080` | HTTP 监听地址;局域网监听必须显式配置 | | `CMROUBAO_DATABASE_PATH` | `var/cmroubao.db` | SQLite 文件路径 | +| `CMROUBAO_ASSET_DIR` | `var/assets` | 规范化参考图片的受控本地目录 | 不会自动读取 `.env`。本地配置和 `var/` 运行数据不得提交。 @@ -31,5 +33,15 @@ go run ./cmd/migrate status go run ./cmd/api ``` +API 启动前会检查全部 migration 已应用;发现 pending migration 会拒绝启动并提示先 +执行 `go run ./cmd/migrate up`,不会在服务进程内自动改表。 + 服务启动后,`GET /healthz` 在数据库可用时返回 `200` 和 -`{"status":"ok"}`,不可用时返回 `503` 和 `{"status":"unavailable"}`。 +`{"status":"ok"}`,不可用时返回 `503` 和 `{"status":"unavailable"}`。管理页面: + +- `GET /tasks`:任务列表、搜索和状态筛选。 +- `GET /tasks/new`:上传参考图并创建任务。 +- `GET /tasks/{id}`:查看原始约束和任务状态,待领取任务可取消。 + +API 合约见 [`../docs/api.md`](../docs/api.md)。当前 loopback 门禁不是身份认证,不能 +通过修改监听地址把管理页面直接开放到局域网。 diff --git a/backend-api/cmd/api/main.go b/backend-api/cmd/api/main.go index 0d09b0f..14d6417 100644 --- a/backend-api/cmd/api/main.go +++ b/backend-api/cmd/api/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "database/sql" "errors" "log" "net/http" @@ -11,8 +12,13 @@ import ( "time" "cmroubao/backend-api/internal/config" + "cmroubao/backend-api/internal/platform/assetstore" "cmroubao/backend-api/internal/platform/database" + "cmroubao/backend-api/internal/platform/migration" + repository "cmroubao/backend-api/internal/repository/sqlite" "cmroubao/backend-api/internal/transport/httpapi" + "cmroubao/backend-api/internal/transport/webui" + "cmroubao/backend-api/internal/usecase" ) func main() { @@ -43,10 +49,11 @@ func run() error { } }() - router, err := httpapi.NewRouter(db, func(event string) { - log.Print(event) - }) + router, err := buildRouter(startupContext, cfg, db) if err != nil { + if errors.Is(err, errPendingMigrations) { + return err + } return errors.New("HTTP router setup failed") } server := httpapi.NewServer(cfg, router) @@ -111,3 +118,93 @@ func shutdownServer( } return nil } + +type migrationStatusReader interface { + Status(context.Context) ([]migration.Status, error) +} + +var errPendingMigrations = errors.New( + "database migrations are pending; run migrate up", +) + +func buildRouter( + ctx context.Context, + cfg config.Config, + db *sql.DB, +) (http.Handler, error) { + runner, err := migration.New(db) + if err != nil { + return nil, err + } + if err := requireCurrentMigrations(ctx, runner); err != nil { + return nil, err + } + + store, err := repository.New(db) + if err != nil { + return nil, err + } + files, err := assetstore.New(cfg.AssetDirectory) + if err != nil { + return nil, err + } + clock := usecase.SystemClock{} + ids := usecase.UUIDGenerator{} + assets, err := usecase.NewAssetService(store, files, clock, ids) + if err != nil { + return nil, err + } + tasks, err := usecase.NewTaskService(store, clock, ids) + if err != nil { + return nil, err + } + + webService, err := webui.NewUsecaseAdapter(tasks, assets) + if err != nil { + return nil, err + } + renderer, err := webui.NewRenderer() + if err != nil { + return nil, err + } + webHandler, err := webui.NewHandler(webService, renderer) + if err != nil { + return nil, err + } + registerAdminRoutes, err := httpapi.NewAdminRouteRegistrar( + httpapi.AdminServices{ + Assets: assets, + Tasks: tasks, + }, + webHandler, + ) + if err != nil { + return nil, err + } + return httpapi.NewRouter(httpapi.RouterDependencies{ + Database: db, + RegisterAdminRoutes: registerAdminRoutes, + LogEvent: func(event string) { + log.Print(event) + }, + }) +} + +func requireCurrentMigrations( + ctx context.Context, + reader migrationStatusReader, +) error { + statuses, err := reader.Status(ctx) + if err != nil { + return errors.New("database migration status failed") + } + if len(statuses) == 0 { + return errors.New("database has no known migrations") + } + for _, status := range statuses { + if !status.Applied { + return errPendingMigrations + } + } + return nil +} diff --git a/backend-api/cmd/api/main_test.go b/backend-api/cmd/api/main_test.go index 9d19468..e604d79 100644 --- a/backend-api/cmd/api/main_test.go +++ b/backend-api/cmd/api/main_test.go @@ -1,11 +1,14 @@ package main import ( + "context" "errors" "net" "net/http" "testing" "time" + + "cmroubao/backend-api/internal/platform/migration" ) func TestShutdownServerForceClosesAfterGracefulTimeout(t *testing.T) { @@ -73,3 +76,51 @@ func TestShutdownServerAcceptsNormalServerClose(t *testing.T) { t.Fatalf("shutdownServer() error = %v", err) } } + +func TestRequireCurrentMigrationsAcceptsAppliedVersions(t *testing.T) { + reader := stubMigrationStatusReader{ + statuses: []migration.Status{ + {Version: 1, Applied: true}, + {Version: 2, Applied: true}, + }, + } + + if err := requireCurrentMigrations(context.Background(), reader); err != nil { + t.Fatalf("requireCurrentMigrations() error = %v", err) + } +} + +func TestRequireCurrentMigrationsRejectsPendingVersion(t *testing.T) { + reader := stubMigrationStatusReader{ + statuses: []migration.Status{ + {Version: 1, Applied: true}, + {Version: 2, Applied: false}, + }, + } + + err := requireCurrentMigrations(context.Background(), reader) + if err == nil || err.Error() != + "database migrations are pending; run migrate up" { + t.Fatalf("requireCurrentMigrations() error = %v", err) + } +} + +func TestRequireCurrentMigrationsHidesStatusFailure(t *testing.T) { + reader := stubMigrationStatusReader{err: errors.New("database details")} + + err := requireCurrentMigrations(context.Background(), reader) + if err == nil || err.Error() != "database migration status failed" { + t.Fatalf("requireCurrentMigrations() error = %v", err) + } +} + +type stubMigrationStatusReader struct { + statuses []migration.Status + err error +} + +func (reader stubMigrationStatusReader) Status( + context.Context, +) ([]migration.Status, error) { + return reader.statuses, reader.err +} diff --git a/backend-api/go.mod b/backend-api/go.mod index 4962a53..9844afb 100644 --- a/backend-api/go.mod +++ b/backend-api/go.mod @@ -6,6 +6,7 @@ require ( github.com/gin-gonic/gin v1.11.0 github.com/mattn/go-sqlite3 v1.14.48 github.com/pressly/goose/v3 v3.26.0 + golang.org/x/image v0.28.0 ) require ( diff --git a/backend-api/go.sum b/backend-api/go.sum index bcbc2d6..547469b 100644 --- a/backend-api/go.sum +++ b/backend-api/go.sum @@ -87,6 +87,8 @@ golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= +golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= diff --git a/backend-api/internal/config/config.go b/backend-api/internal/config/config.go index e1d8369..6b71f32 100644 --- a/backend-api/internal/config/config.go +++ b/backend-api/internal/config/config.go @@ -10,11 +10,13 @@ import ( ) const ( - HTTPAddressEnvironment = "CMROUBAO_HTTP_ADDR" - DatabasePathEnvironment = "CMROUBAO_DATABASE_PATH" + HTTPAddressEnvironment = "CMROUBAO_HTTP_ADDR" + DatabasePathEnvironment = "CMROUBAO_DATABASE_PATH" + AssetDirectoryEnvironment = "CMROUBAO_ASSET_DIR" - defaultHTTPAddress = "127.0.0.1:8080" - defaultDatabasePath = "var/cmroubao.db" + defaultHTTPAddress = "127.0.0.1:8080" + defaultDatabasePath = "var/cmroubao.db" + defaultAssetDirectory = "var/assets" ) type LookupEnvironment func(string) (string, bool) @@ -22,6 +24,7 @@ type LookupEnvironment func(string) (string, bool) type Config struct { HTTPAddress string DatabasePath string + AssetDirectory string ReadHeaderTimeout time.Duration ReadTimeout time.Duration WriteTimeout time.Duration @@ -53,9 +56,23 @@ func Load(lookup LookupEnvironment) (Config, error) { return Config{}, err } + assetDirectory, err := environmentValue( + lookup, + AssetDirectoryEnvironment, + defaultAssetDirectory, + ) + if err != nil { + return Config{}, err + } + assetDirectory, err = validatedAssetDirectory(assetDirectory) + if err != nil { + return Config{}, err + } + return Config{ HTTPAddress: httpAddress, DatabasePath: filepath.Clean(databasePath), + AssetDirectory: assetDirectory, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, @@ -65,6 +82,24 @@ func Load(lookup LookupEnvironment) (Config, error) { }, nil } +func validatedAssetDirectory(path string) (string, error) { + if strings.ContainsRune(path, '\x00') { + return "", errors.New( + AssetDirectoryEnvironment + " contains an invalid character", + ) + } + cleanPath := filepath.Clean(path) + volumeRoot := filepath.VolumeName(cleanPath) + string(filepath.Separator) + if cleanPath == "." || + cleanPath == string(filepath.Separator) || + cleanPath == volumeRoot { + return "", errors.New( + AssetDirectoryEnvironment + " must be a dedicated directory", + ) + } + return cleanPath, nil +} + func LoadDatabasePath(lookup LookupEnvironment) (string, error) { databasePath, err := environmentValue( lookup, diff --git a/backend-api/internal/config/config_test.go b/backend-api/internal/config/config_test.go index d8810e2..baad9fc 100644 --- a/backend-api/internal/config/config_test.go +++ b/backend-api/internal/config/config_test.go @@ -18,6 +18,9 @@ func TestLoadUsesSafeDefaults(t *testing.T) { if cfg.DatabasePath != filepath.FromSlash("var/cmroubao.db") { t.Fatalf("DatabasePath = %q", cfg.DatabasePath) } + if cfg.AssetDirectory != filepath.FromSlash("var/assets") { + t.Fatalf("AssetDirectory = %q", cfg.AssetDirectory) + } if cfg.ReadHeaderTimeout <= 0 || cfg.ReadTimeout <= 0 || cfg.WriteTimeout <= 0 || @@ -33,8 +36,9 @@ func TestLoadUsesSafeDefaults(t *testing.T) { func TestLoadAcceptsExplicitConfiguration(t *testing.T) { values := map[string]string{ - HTTPAddressEnvironment: "192.0.2.10:9090", - DatabasePathEnvironment: "tmp/test.db", + HTTPAddressEnvironment: "192.0.2.10:9090", + DatabasePathEnvironment: "tmp/test.db", + AssetDirectoryEnvironment: "tmp/assets", } cfg, err := Load(mapEnvironment(values)) @@ -48,6 +52,9 @@ func TestLoadAcceptsExplicitConfiguration(t *testing.T) { if cfg.DatabasePath != filepath.Clean(values[DatabasePathEnvironment]) { t.Fatalf("DatabasePath = %q", cfg.DatabasePath) } + if cfg.AssetDirectory != filepath.Clean(values[AssetDirectoryEnvironment]) { + t.Fatalf("AssetDirectory = %q", cfg.AssetDirectory) + } } func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) { @@ -97,6 +104,18 @@ func TestLoadRejectsUnsafeOrInvalidValues(t *testing.T) { DatabasePathEnvironment: "var/database.txt", }, }, + { + name: "asset root directory", + values: map[string]string{ + AssetDirectoryEnvironment: string(filepath.Separator), + }, + }, + { + name: "asset current directory", + values: map[string]string{ + AssetDirectoryEnvironment: ".", + }, + }, } for _, test := range tests { diff --git a/backend-api/internal/domain/asset.go b/backend-api/internal/domain/asset.go new file mode 100644 index 0000000..a67cbfe --- /dev/null +++ b/backend-api/internal/domain/asset.go @@ -0,0 +1,19 @@ +package domain + +import "time" + +const ( + AssetPurposeTaskReference = "TASK_REFERENCE" + NormalizedImageMediaType = "image/jpeg" +) + +type Asset struct { + ID string + CreatorSubject string + Purpose string + MediaType string + SizeBytes int64 + SHA256 string + StorageKey string + CreatedAt time.Time +} diff --git a/backend-api/internal/domain/task.go b/backend-api/internal/domain/task.go new file mode 100644 index 0000000..c9169e8 --- /dev/null +++ b/backend-api/internal/domain/task.go @@ -0,0 +1,221 @@ +package domain + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +type TaskStatus string + +const ( + TaskStatusPending TaskStatus = "PENDING" + TaskStatusClaimed TaskStatus = "CLAIMED" + TaskStatusRunning TaskStatus = "RUNNING" + TaskStatusWaitingConfirmation TaskStatus = "WAITING_CONFIRMATION" + TaskStatusSucceeded TaskStatus = "SUCCEEDED" + TaskStatusFailed TaskStatus = "FAILED" + TaskStatusCanceled TaskStatus = "CANCELED" + + CurrencyCNY = "CNY" +) + +const ( + MaxTitleRunes = 120 + MaxTitleBytes = 2048 + MaxSKUBytes = 512 + MaxDescriptionBytes = 8192 + MaxSourceRefBytes = 256 + MaxCancelReasonBytes = 500 +) + +type PurchaseTask struct { + ID string + CreatorSubject string + SourceRef *string + Title string + Description string + SKU string + ImageAssetID string + Quantity int + MaxBudgetCents *int64 + Currency string + Status TaskStatus + Version int64 + CancelReason *string + CanceledAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type TaskEvent struct { + ID string + TaskID string + Type string + Message string + OccurredAt time.Time +} + +type TaskDetail struct { + Task PurchaseTask + Asset Asset + Events []TaskEvent +} + +type TaskValidationError struct { + Fields map[string]string +} + +func (e *TaskValidationError) Error() string { + return "purchase task validation failed" +} + +func ValidateTaskInput( + creatorSubject string, + sourceRef *string, + title string, + description string, + sku string, + imageAssetID string, + quantity int, +) error { + fields := make(map[string]string) + if strings.TrimSpace(creatorSubject) == "" { + fields["creator_subject"] = "required" + } + if sourceRef != nil { + if strings.TrimSpace(*sourceRef) == "" { + fields["source_ref"] = "must not be blank" + } else if len([]byte(*sourceRef)) > MaxSourceRefBytes { + fields["source_ref"] = fmt.Sprintf( + "must not exceed %d UTF-8 bytes", + MaxSourceRefBytes, + ) + } + } + if strings.TrimSpace(title) == "" { + fields["title"] = "required" + } else { + if utf8.RuneCountInString(title) > MaxTitleRunes { + fields["title"] = fmt.Sprintf( + "must not exceed %d characters", + MaxTitleRunes, + ) + } + if len([]byte(title)) > MaxTitleBytes { + fields["title"] = fmt.Sprintf( + "must not exceed %d UTF-8 bytes", + MaxTitleBytes, + ) + } + } + if len([]byte(description)) > MaxDescriptionBytes { + fields["description"] = fmt.Sprintf( + "must not exceed %d UTF-8 bytes", + MaxDescriptionBytes, + ) + } + if strings.TrimSpace(sku) == "" { + fields["sku"] = "required" + } else if len([]byte(sku)) > MaxSKUBytes { + fields["sku"] = fmt.Sprintf( + "must not exceed %d UTF-8 bytes", + MaxSKUBytes, + ) + } + if strings.TrimSpace(imageAssetID) == "" { + fields["image_asset_id"] = "required" + } + if quantity <= 0 { + fields["quantity"] = "must be a positive integer" + } + if len(fields) > 0 { + return &TaskValidationError{Fields: fields} + } + return nil +} + +func ParseOptionalCNY(value *string) (*int64, error) { + if value == nil { + return nil, nil + } + trimmed := strings.TrimSpace(*value) + if trimmed == "" { + return nil, errors.New("budget must be omitted or positive") + } + if strings.HasPrefix(trimmed, "+") || strings.HasPrefix(trimmed, "-") { + return nil, errors.New("budget must be positive") + } + parts := strings.Split(trimmed, ".") + if len(parts) > 2 || parts[0] == "" || len(parts[0]) > 16 { + return nil, errors.New("budget format is invalid") + } + if !allDigits(parts[0]) { + return nil, errors.New("budget format is invalid") + } + fraction := "" + if len(parts) == 2 { + fraction = parts[1] + if fraction == "" || len(fraction) > 2 || !allDigits(fraction) { + return nil, errors.New("budget format is invalid") + } + } + for len(fraction) < 2 { + fraction += "0" + } + whole, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil || whole > (int64(^uint64(0)>>1)-99)/100 { + return nil, errors.New("budget is too large") + } + centsPart, err := strconv.ParseInt(fraction, 10, 64) + if err != nil { + return nil, errors.New("budget format is invalid") + } + cents := whole*100 + centsPart + if cents <= 0 { + return nil, errors.New("budget must be positive") + } + return ¢s, nil +} + +func FormatOptionalCNY(cents *int64) *string { + if cents == nil { + return nil + } + value := fmt.Sprintf("%d.%02d", *cents/100, *cents%100) + return &value +} + +func CanCancel(status TaskStatus) bool { + return status == TaskStatusPending +} + +func IsValidTaskStatus(status TaskStatus) bool { + switch status { + case TaskStatusPending, + TaskStatusClaimed, + TaskStatusRunning, + TaskStatusWaitingConfirmation, + TaskStatusSucceeded, + TaskStatusFailed, + TaskStatusCanceled: + return true + default: + return false + } +} + +func allDigits(value string) bool { + if value == "" { + return false + } + for _, char := range value { + if char < '0' || char > '9' { + return false + } + } + return true +} diff --git a/backend-api/internal/domain/task_test.go b/backend-api/internal/domain/task_test.go new file mode 100644 index 0000000..bdc0602 --- /dev/null +++ b/backend-api/internal/domain/task_test.go @@ -0,0 +1,137 @@ +package domain + +import ( + "strings" + "testing" +) + +func TestValidateTaskInputRequiresTitleSKUImageAndPositiveQuantity(t *testing.T) { + err := ValidateTaskInput( + " ", + nil, + "", + "", + "", + "", + 0, + ) + var validation *TaskValidationError + if !asTaskValidationError(err, &validation) { + t.Fatalf("ValidateTaskInput() error = %v", err) + } + for _, field := range []string{ + "creator_subject", + "title", + "sku", + "image_asset_id", + "quantity", + } { + if validation.Fields[field] == "" { + t.Fatalf("missing validation for %s", field) + } + } +} + +func TestValidateTaskInputEnforcesUTF8AndCharacterLimits(t *testing.T) { + sourceRef := strings.Repeat("a", MaxSourceRefBytes+1) + err := ValidateTaskInput( + "local-admin", + &sourceRef, + strings.Repeat("商", MaxTitleRunes+1), + strings.Repeat("a", MaxDescriptionBytes+1), + strings.Repeat("货", MaxSKUBytes/3+1), + "00000000-0000-4000-8000-000000000001", + 1, + ) + var validation *TaskValidationError + if !asTaskValidationError(err, &validation) { + t.Fatalf("ValidateTaskInput() error = %v", err) + } + for _, field := range []string{ + "source_ref", + "title", + "description", + "sku", + } { + if validation.Fields[field] == "" { + t.Fatalf("missing limit validation for %s", field) + } + } +} + +func TestParseOptionalCNYUsesExactCents(t *testing.T) { + tests := []struct { + input string + want int64 + }{ + {input: "0.01", want: 1}, + {input: "1", want: 100}, + {input: "19.9", want: 1990}, + {input: "200.00", want: 20000}, + } + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + got, err := ParseOptionalCNY(&test.input) + if err != nil { + t.Fatalf("ParseOptionalCNY() error = %v", err) + } + if got == nil || *got != test.want { + t.Fatalf("ParseOptionalCNY() = %v, want %d", got, test.want) + } + formatted := FormatOptionalCNY(got) + if formatted == nil { + t.Fatal("FormatOptionalCNY() = nil") + } + }) + } +} + +func TestParseOptionalCNYRejectsInvalidAndOverflow(t *testing.T) { + for _, value := range []string{ + "", + "0", + "0.00", + "-1", + "+1", + ".5", + "1.", + "1.001", + "1e2", + "99999999999999999.99", + } { + t.Run(value, func(t *testing.T) { + if _, err := ParseOptionalCNY(&value); err == nil { + t.Fatalf("ParseOptionalCNY(%q) error = nil", value) + } + }) + } +} + +func TestCanCancelOnlyPending(t *testing.T) { + for _, status := range []TaskStatus{ + TaskStatusClaimed, + TaskStatusRunning, + TaskStatusWaitingConfirmation, + TaskStatusSucceeded, + TaskStatusFailed, + TaskStatusCanceled, + } { + if CanCancel(status) { + t.Fatalf("CanCancel(%s) = true", status) + } + } + if !CanCancel(TaskStatusPending) { + t.Fatal("CanCancel(PENDING) = false") + } +} + +func asTaskValidationError( + err error, + target **TaskValidationError, +) bool { + value, ok := err.(*TaskValidationError) + if ok { + *target = value + } + return ok +} diff --git a/backend-api/internal/platform/assetstore/store.go b/backend-api/internal/platform/assetstore/store.go new file mode 100644 index 0000000..841b0d5 --- /dev/null +++ b/backend-api/internal/platform/assetstore/store.go @@ -0,0 +1,407 @@ +package assetstore + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "image" + "image/color" + stddraw "image/draw" + "image/jpeg" + _ "image/png" + "io" + "mime" + "os" + "path/filepath" + "strings" + + "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/usecase" + + xdraw "golang.org/x/image/draw" + _ "golang.org/x/image/webp" +) + +const ( + MaxInputBytes int64 = 20 << 20 + MaxSourceDimension = 10000 + MaxSourcePixels int64 = 25_000_000 + MaxOutputDimension = 2048 + JPEGQuality = 90 +) + +type Store struct { + root string +} + +func New(root string) (*Store, error) { + absoluteRoot, err := filepath.Abs(root) + if err != nil { + return nil, errors.New("resolve asset storage root") + } + if err := os.MkdirAll(absoluteRoot, 0o700); err != nil { + return nil, errors.New("create asset storage root") + } + info, err := os.Stat(absoluteRoot) + if err != nil || !info.IsDir() { + return nil, errors.New("asset storage root is not a directory") + } + return &Store{root: absoluteRoot}, nil +} + +func (s *Store) Put( + ctx context.Context, + _ string, + declaredMediaType string, + content io.Reader, +) (usecase.NormalizedReferenceImage, error) { + if content == nil { + return usecase.NormalizedReferenceImage{}, storeError( + usecase.ImageStoreErrorInvalid, + errors.New("image content is required"), + ) + } + inputFile, err := os.CreateTemp(s.root, ".input-*") + if err != nil { + return usecase.NormalizedReferenceImage{}, storeError( + usecase.ImageStoreErrorUnavailable, + err, + ) + } + inputPath := inputFile.Name() + defer func() { + _ = inputFile.Close() + _ = os.Remove(inputPath) + }() + + inputHash := sha256.New() + written, err := io.Copy( + io.MultiWriter(inputFile, inputHash), + io.LimitReader(&contextReader{ctx: ctx, reader: content}, MaxInputBytes+1), + ) + if err != nil { + return usecase.NormalizedReferenceImage{}, storeError( + usecase.ImageStoreErrorUnavailable, + err, + ) + } + if written > MaxInputBytes { + return usecase.NormalizedReferenceImage{}, storeError( + usecase.ImageStoreErrorTooLarge, + errors.New("input image exceeds byte limit"), + ) + } + if written == 0 { + return usecase.NormalizedReferenceImage{}, storeError( + usecase.ImageStoreErrorInvalid, + errors.New("input image is empty"), + ) + } + if err := inputFile.Sync(); err != nil { + return usecase.NormalizedReferenceImage{}, storeError( + usecase.ImageStoreErrorUnavailable, + err, + ) + } + if _, err := inputFile.Seek(0, io.SeekStart); err != nil { + return usecase.NormalizedReferenceImage{}, storeError( + usecase.ImageStoreErrorUnavailable, + err, + ) + } + + config, format, err := image.DecodeConfig(inputFile) + if err != nil { + return usecase.NormalizedReferenceImage{}, storeError( + usecase.ImageStoreErrorInvalid, + err, + ) + } + inputMediaType, ok := mediaTypeForFormat(format) + if !ok { + return usecase.NormalizedReferenceImage{}, storeError( + usecase.ImageStoreErrorUnsupported, + errors.New("unsupported decoded image format"), + ) + } + if !declaredTypeMatches(declaredMediaType, inputMediaType) { + return usecase.NormalizedReferenceImage{}, storeError( + usecase.ImageStoreErrorUnsupported, + errors.New("declared and decoded media types differ"), + ) + } + if config.Width <= 0 || config.Height <= 0 || + config.Width > MaxSourceDimension || + config.Height > MaxSourceDimension || + int64(config.Width)*int64(config.Height) > MaxSourcePixels { + return usecase.NormalizedReferenceImage{}, storeError( + usecase.ImageStoreErrorInvalid, + errors.New("input image dimensions are invalid"), + ) + } + + if _, err := inputFile.Seek(0, io.SeekStart); err != nil { + return usecase.NormalizedReferenceImage{}, storeError( + usecase.ImageStoreErrorUnavailable, + err, + ) + } + decoded, decodedFormat, err := image.Decode(inputFile) + if err != nil || decodedFormat != format { + return usecase.NormalizedReferenceImage{}, storeError( + usecase.ImageStoreErrorInvalid, + errors.New("input image cannot be fully decoded"), + ) + } + normalized := normalizeToWhiteBackground(decoded) + + storageKey, err := randomStorageKey() + if err != nil { + return usecase.NormalizedReferenceImage{}, storeError( + usecase.ImageStoreErrorUnavailable, + err, + ) + } + finalPath, err := s.resolve(storageKey) + if err != nil { + return usecase.NormalizedReferenceImage{}, storeError( + usecase.ImageStoreErrorUnavailable, + err, + ) + } + if err := os.MkdirAll(filepath.Dir(finalPath), 0o700); err != nil { + return usecase.NormalizedReferenceImage{}, storeError( + usecase.ImageStoreErrorUnavailable, + err, + ) + } + outputFile, err := os.CreateTemp(filepath.Dir(finalPath), ".normalized-*") + if err != nil { + return usecase.NormalizedReferenceImage{}, storeError( + usecase.ImageStoreErrorUnavailable, + err, + ) + } + outputPath := outputFile.Name() + outputClosed := false + defer func() { + if !outputClosed { + _ = outputFile.Close() + } + _ = os.Remove(outputPath) + }() + outputHash := sha256.New() + if err := jpeg.Encode( + io.MultiWriter(outputFile, outputHash), + normalized, + &jpeg.Options{Quality: JPEGQuality}, + ); err != nil { + return usecase.NormalizedReferenceImage{}, storeError( + usecase.ImageStoreErrorUnavailable, + err, + ) + } + if err := outputFile.Sync(); err != nil { + return usecase.NormalizedReferenceImage{}, storeError( + usecase.ImageStoreErrorUnavailable, + err, + ) + } + if err := outputFile.Close(); err != nil { + return usecase.NormalizedReferenceImage{}, storeError( + usecase.ImageStoreErrorUnavailable, + err, + ) + } + outputClosed = true + info, err := os.Stat(outputPath) + if err != nil { + return usecase.NormalizedReferenceImage{}, storeError( + usecase.ImageStoreErrorUnavailable, + err, + ) + } + if err := os.Rename(outputPath, finalPath); err != nil { + return usecase.NormalizedReferenceImage{}, storeError( + usecase.ImageStoreErrorUnavailable, + err, + ) + } + return usecase.NormalizedReferenceImage{ + StorageKey: storageKey, + InputMediaType: inputMediaType, + InputSHA256: hex.EncodeToString(inputHash.Sum(nil)), + MediaType: domain.NormalizedImageMediaType, + SizeBytes: info.Size(), + SHA256: hex.EncodeToString(outputHash.Sum(nil)), + }, nil +} + +func (s *Store) Open( + ctx context.Context, + storageKey string, +) (io.ReadCloser, error) { + if err := ctx.Err(); err != nil { + return nil, storeError(usecase.ImageStoreErrorUnavailable, err) + } + path, err := s.resolve(storageKey) + if err != nil { + return nil, storeError(usecase.ImageStoreErrorNotFound, err) + } + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return nil, storeError(usecase.ImageStoreErrorNotFound, err) + } + if err != nil { + return nil, storeError(usecase.ImageStoreErrorUnavailable, err) + } + return file, nil +} + +func (s *Store) Delete( + ctx context.Context, + storageKey string, +) error { + if err := ctx.Err(); err != nil { + return storeError(usecase.ImageStoreErrorUnavailable, err) + } + path, err := s.resolve(storageKey) + if err != nil { + return storeError(usecase.ImageStoreErrorNotFound, err) + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return storeError(usecase.ImageStoreErrorUnavailable, err) + } + return nil +} + +func normalizeToWhiteBackground(source image.Image) *image.RGBA { + sourceBounds := source.Bounds() + width := sourceBounds.Dx() + height := sourceBounds.Dy() + outputWidth, outputHeight := boundedDimensions(width, height) + output := image.NewRGBA(image.Rect(0, 0, outputWidth, outputHeight)) + stddraw.Draw( + output, + output.Bounds(), + &image.Uniform{C: color.White}, + image.Point{}, + stddraw.Src, + ) + if outputWidth == width && outputHeight == height { + stddraw.Draw( + output, + output.Bounds(), + source, + sourceBounds.Min, + stddraw.Over, + ) + return output + } + xdraw.CatmullRom.Scale( + output, + output.Bounds(), + source, + sourceBounds, + stddraw.Over, + nil, + ) + return output +} + +func boundedDimensions(width, height int) (int, int) { + longest := width + if height > longest { + longest = height + } + if longest <= MaxOutputDimension { + return width, height + } + if width >= height { + return MaxOutputDimension, maxInt( + 1, + int((int64(height)*MaxOutputDimension)/int64(width)), + ) + } + return maxInt( + 1, + int((int64(width)*MaxOutputDimension)/int64(height)), + ), MaxOutputDimension +} + +func declaredTypeMatches(declared string, actual string) bool { + mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(declared)) + return err == nil && strings.EqualFold(mediaType, actual) +} + +func mediaTypeForFormat(format string) (string, bool) { + switch strings.ToLower(format) { + case "jpeg": + return "image/jpeg", true + case "png": + return "image/png", true + case "webp": + return "image/webp", true + default: + return "", false + } +} + +func randomStorageKey() (string, error) { + var value [16]byte + if _, err := rand.Read(value[:]); err != nil { + return "", err + } + encoded := hex.EncodeToString(value[:]) + return encoded[:2] + "/" + encoded + ".jpg", nil +} + +func (s *Store) resolve(storageKey string) (string, error) { + if storageKey == "" || + strings.Contains(storageKey, `\`) || + strings.HasPrefix(storageKey, "/") { + return "", errors.New("unsafe storage key") + } + cleanKey := filepath.Clean(filepath.FromSlash(storageKey)) + if cleanKey == "." || filepath.IsAbs(cleanKey) { + return "", errors.New("unsafe storage key") + } + resolved := filepath.Join(s.root, cleanKey) + relative, err := filepath.Rel(s.root, resolved) + if err != nil || + relative == ".." || + strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return "", errors.New("unsafe storage key") + } + return resolved, nil +} + +func storeError( + kind usecase.ImageStoreErrorKind, + cause error, +) error { + return &usecase.ImageStoreError{Kind: kind, Cause: cause} +} + +type contextReader struct { + ctx context.Context + reader io.Reader +} + +func (r *contextReader) Read(buffer []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.reader.Read(buffer) +} + +func maxInt(first, second int) int { + if first > second { + return first + } + return second +} + +var _ usecase.ReferenceImageStore = (*Store)(nil) diff --git a/backend-api/internal/platform/assetstore/store_test.go b/backend-api/internal/platform/assetstore/store_test.go new file mode 100644 index 0000000..58fb8aa --- /dev/null +++ b/backend-api/internal/platform/assetstore/store_test.go @@ -0,0 +1,255 @@ +package assetstore + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "image" + "image/color" + "image/jpeg" + "image/png" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "cmroubao/backend-api/internal/usecase" +) + +func TestPutNormalizesPNGToBoundedWhiteJPEG(t *testing.T) { + root := t.TempDir() + store := mustStore(t, root) + source := image.NewNRGBA(image.Rect(0, 0, 3000, 1200)) + for y := 0; y < source.Bounds().Dy(); y++ { + for x := 0; x < source.Bounds().Dx(); x++ { + source.SetNRGBA(x, y, color.NRGBA{R: 20, G: 80, B: 140, A: 0}) + } + } + var input bytes.Buffer + if err := png.Encode(&input, source); err != nil { + t.Fatalf("png.Encode() error = %v", err) + } + + result, err := store.Put( + context.Background(), + "ignored-object-id", + "image/png", + bytes.NewReader(input.Bytes()), + ) + if err != nil { + t.Fatalf("Put() error = %v", err) + } + if result.InputMediaType != "image/png" || + result.MediaType != "image/jpeg" || + len(result.InputSHA256) != 64 || + len(result.SHA256) != 64 || + result.SizeBytes <= 0 || + strings.Contains(result.StorageKey, `\`) || + strings.Contains(result.StorageKey, "..") { + t.Fatalf("Put() result = %+v", result) + } + + content, err := store.Open(context.Background(), result.StorageKey) + if err != nil { + t.Fatalf("Open() error = %v", err) + } + defer content.Close() + normalized, format, err := image.Decode(content) + if err != nil { + t.Fatalf("image.Decode() error = %v", err) + } + if format != "jpeg" || + normalized.Bounds().Dx() != MaxOutputDimension || + normalized.Bounds().Dy() != 819 { + t.Fatalf("normalized bounds/format = %v/%s", normalized.Bounds(), format) + } + red, green, blue, _ := normalized.At(20, 20).RGBA() + if red < 0xf000 || green < 0xf000 || blue < 0xf000 { + t.Fatalf( + "transparent pixel was not composited on white: %x %x %x", + red, + green, + blue, + ) + } +} + +func TestPutAcceptsDecodableJPEGAndWebP(t *testing.T) { + store := mustStore(t, t.TempDir()) + var jpegInput bytes.Buffer + if err := jpeg.Encode( + &jpegInput, + image.NewRGBA(image.Rect(0, 0, 4, 3)), + &jpeg.Options{Quality: 80}, + ); err != nil { + t.Fatalf("jpeg.Encode() error = %v", err) + } + if _, err := store.Put( + context.Background(), + "jpeg", + "image/jpeg", + bytes.NewReader(jpegInput.Bytes()), + ); err != nil { + t.Fatalf("JPEG Put() error = %v", err) + } + + webpBytes, err := base64.StdEncoding.DecodeString( + "UklGRrIBAABXRUJQVlA4TKUBAAAvSsAYAA8w//M///MfeJAkbXvaSG7m8Q3GfYSB" + + "JekwQztm/IcZlgwnmWImn2BK7aFmBtnVir6q//8VOkFE/xm4baTIu8c48ArEo6+B" + + "3zFKYln3pqClSCKX0begFTAXFOLXHSyF8cCNcZEG4OywuA4KVVfJCiArU7GAgJI8" + + "+lJP/OKMT/fBAjevg1cYB7YVkFuWga2lyPi5I0HFy5YTpWIHg0RZpkniRVW9odHA" + + "KOwosWuOGdxIyn2OvaCDvhg/we6TwadPBPbqBV58MsLmMJ8yZnOWk8SRz4N+QoyP" + + "L+MnamzMvcE1rHNEr91F9GKZPVUcS9w7PhhH36suB9qPeYb/oLk6cuTiJ0wOK3m" + + "5h1cKjW6EVZCYMK7dxcKCBdgP9HkKr9gkAO2P8GKZGWVdIAatQa+1IDpt6qyorVw" + + "dy01xdW8Jkfk6xjEXmVQQ+HQdFr6OKhIN34dXWq0+0qr6EJSCeeVLH9+gvGTLyqM" + + "65PQ44ihzlTXxQKjKbAvshXgir7Lil9w4L2bvMycmjQcqXaMCO6BlY28i+FOLzbf" + + "I1vEqxAhotocAAA==", + ) + if err != nil { + t.Fatalf("decode WebP fixture: %v", err) + } + result, err := store.Put( + context.Background(), + "webp", + "image/webp", + bytes.NewReader(webpBytes), + ) + if err != nil { + t.Fatalf("WebP Put() error = %v", err) + } + if result.InputMediaType != "image/webp" { + t.Fatalf("WebP input type = %q", result.InputMediaType) + } +} + +func TestPutRejectsMismatchCorruptionAndOversizeAndCleansTemps(t *testing.T) { + root := t.TempDir() + store := mustStore(t, root) + var valid bytes.Buffer + if err := png.Encode( + &valid, + image.NewRGBA(image.Rect(0, 0, 2, 2)), + ); err != nil { + t.Fatalf("png.Encode() error = %v", err) + } + + _, err := store.Put( + context.Background(), + "mismatch", + "image/jpeg", + bytes.NewReader(valid.Bytes()), + ) + assertStoreError(t, err, usecase.ImageStoreErrorUnsupported) + + _, err = store.Put( + context.Background(), + "corrupt", + "image/png", + bytes.NewReader([]byte("not an image")), + ) + assertStoreError(t, err, usecase.ImageStoreErrorInvalid) + + _, err = store.Put( + context.Background(), + "oversize", + "image/png", + io.LimitReader(zeroReader{}, MaxInputBytes+1), + ) + assertStoreError(t, err, usecase.ImageStoreErrorTooLarge) + + entries, err := os.ReadDir(root) + if err != nil { + t.Fatalf("ReadDir() error = %v", err) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".input-") || + strings.HasPrefix(entry.Name(), ".normalized-") { + t.Fatalf("temporary file remained: %s", entry.Name()) + } + } +} + +func TestOpenDeleteRejectTraversalAndDeleteIsIdempotent(t *testing.T) { + store := mustStore(t, t.TempDir()) + for _, key := range []string{ + "../secret", + "/absolute", + `nested\file`, + } { + if _, err := store.Open(context.Background(), key); err == nil { + t.Fatalf("Open(%q) error = nil", key) + } + } + + var input bytes.Buffer + if err := jpeg.Encode( + &input, + image.NewRGBA(image.Rect(0, 0, 2, 2)), + nil, + ); err != nil { + t.Fatalf("jpeg.Encode() error = %v", err) + } + result, err := store.Put( + context.Background(), + "delete", + "image/jpeg", + bytes.NewReader(input.Bytes()), + ) + if err != nil { + t.Fatalf("Put() error = %v", err) + } + if err := store.Delete(context.Background(), result.StorageKey); err != nil { + t.Fatalf("Delete() error = %v", err) + } + if err := store.Delete(context.Background(), result.StorageKey); err != nil { + t.Fatalf("second Delete() error = %v", err) + } + if _, err := store.Open(context.Background(), result.StorageKey); err == nil { + t.Fatal("Open() after Delete() error = nil") + } +} + +func TestNewRejectsFileAsRoot(t *testing.T) { + path := filepath.Join(t.TempDir(), "root-file") + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + if _, err := New(path); err == nil { + t.Fatal("New(file) error = nil") + } +} + +func mustStore(t *testing.T, root string) *Store { + t.Helper() + store, err := New(root) + if err != nil { + t.Fatalf("New() error = %v", err) + } + return store +} + +func assertStoreError( + t *testing.T, + err error, + kind usecase.ImageStoreErrorKind, +) { + t.Helper() + var typed *usecase.ImageStoreError + if !errors.As(err, &typed) { + t.Fatalf("error = %v, want ImageStoreError", err) + } + if typed.Kind != kind { + t.Fatalf("error kind = %s, want %s", typed.Kind, kind) + } +} + +type zeroReader struct{} + +func (zeroReader) Read(buffer []byte) (int, error) { + for index := range buffer { + buffer[index] = 0 + } + return len(buffer), nil +} diff --git a/backend-api/internal/platform/migration/runner_test.go b/backend-api/internal/platform/migration/runner_test.go index 130b09c..c80c86c 100644 --- a/backend-api/internal/platform/migration/runner_test.go +++ b/backend-api/internal/platform/migration/runner_test.go @@ -27,10 +27,13 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { if err != nil { t.Fatalf("Up() error = %v", err) } - if applied != 1 { - t.Fatalf("Up() applied = %d, want 1", applied) + if applied != 2 { + t.Fatalf("Up() applied = %d, want 2", applied) } - assertStatus(t, runner, true) + assertStatuses(t, runner, map[int64]bool{ + 1: true, + 2: true, + }) applied, err = runner.Up(context.Background()) if err != nil { @@ -43,7 +46,10 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { if err := runner.Down(context.Background()); err != nil { t.Fatalf("Down() error = %v", err) } - assertStatus(t, runner, false) + assertStatuses(t, runner, map[int64]bool{ + 1: true, + 2: false, + }) applied, err = runner.Up(context.Background()) if err != nil { @@ -52,18 +58,37 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) { if applied != 1 { t.Fatalf("final Up() applied = %d, want 1", applied) } + assertStatuses(t, runner, map[int64]bool{ + 1: true, + 2: true, + }) } -func assertStatus(t *testing.T, runner *Runner, applied bool) { +func assertStatuses( + t *testing.T, + runner *Runner, + want map[int64]bool, +) { t.Helper() statuses, err := runner.Status(context.Background()) if err != nil { t.Fatalf("Status() error = %v", err) } - if len(statuses) != 1 { - t.Fatalf("Status() count = %d, want 1", len(statuses)) + if len(statuses) != len(want) { + t.Fatalf("Status() count = %d, want %d", len(statuses), len(want)) } - if statuses[0].Version != 1 || statuses[0].Applied != applied { - t.Fatalf("Status() = %+v", statuses[0]) + for _, status := range statuses { + applied, ok := want[status.Version] + if !ok { + t.Fatalf("unexpected migration status = %+v", status) + } + if status.Applied != applied { + t.Fatalf( + "migration %d applied = %t, want %t", + status.Version, + status.Applied, + applied, + ) + } } } diff --git a/backend-api/internal/repository/sqlite/asset_repository.go b/backend-api/internal/repository/sqlite/asset_repository.go new file mode 100644 index 0000000..39f4395 --- /dev/null +++ b/backend-api/internal/repository/sqlite/asset_repository.go @@ -0,0 +1,100 @@ +package sqlite + +import ( + "context" + "database/sql" + + "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/usecase" +) + +const assetUploadOperation = "UPLOAD_TASK_REFERENCE" + +func (s *Store) CreateAssetIdempotent( + ctx context.Context, + candidate domain.Asset, + idempotencyKey string, + requestHash string, +) (domain.Asset, bool, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return domain.Asset{}, false, repositoryFailure(err) + } + defer func() { _ = tx.Rollback() }() + + existingHash, resourceID, found, err := lookupIdempotency( + ctx, + tx, + candidate.CreatorSubject, + assetUploadOperation, + idempotencyKey, + ) + if err != nil { + return domain.Asset{}, false, err + } + if found { + if existingHash != requestHash { + return domain.Asset{}, false, usecase.ErrIdempotencyConflict + } + existing, err := getAssetByID( + ctx, + tx, + candidate.CreatorSubject, + resourceID, + ) + if err != nil { + return domain.Asset{}, false, err + } + if err := tx.Commit(); err != nil { + return domain.Asset{}, false, repositoryFailure(err) + } + return existing, false, nil + } + + _, err = tx.ExecContext( + ctx, + `INSERT INTO assets ( + id, creator_subject, purpose, media_type, size_bytes, + sha256, storage_key, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + candidate.ID, + candidate.CreatorSubject, + candidate.Purpose, + candidate.MediaType, + candidate.SizeBytes, + candidate.SHA256, + candidate.StorageKey, + formatTimestamp(candidate.CreatedAt), + ) + if err != nil { + return domain.Asset{}, false, repositoryFailure(err) + } + if err := insertIdempotency( + ctx, + tx, + candidate.CreatorSubject, + assetUploadOperation, + idempotencyKey, + requestHash, + "ASSET", + candidate.ID, + candidate.CreatedAt, + ); err != nil { + return domain.Asset{}, false, err + } + if err := tx.Commit(); err != nil { + return domain.Asset{}, false, repositoryFailure(err) + } + return candidate, true, nil +} + +func (s *Store) GetAsset( + ctx context.Context, + creatorSubject string, + assetID string, +) (domain.Asset, error) { + return getAssetByID(ctx, s.db, creatorSubject, assetID) +} + +var _ usecase.AssetRepository = (*Store)(nil) +var _ queryRower = (*sql.Tx)(nil) diff --git a/backend-api/internal/repository/sqlite/helpers.go b/backend-api/internal/repository/sqlite/helpers.go new file mode 100644 index 0000000..773cbc5 --- /dev/null +++ b/backend-api/internal/repository/sqlite/helpers.go @@ -0,0 +1,270 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/usecase" + + sqlite3 "github.com/mattn/go-sqlite3" +) + +const timestampLayout = time.RFC3339Nano + +type queryRower interface { + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +type rowScanner interface { + Scan(...any) error +} + +func scanAsset(scanner rowScanner) (domain.Asset, error) { + var asset domain.Asset + var createdAt string + err := scanner.Scan( + &asset.ID, + &asset.CreatorSubject, + &asset.Purpose, + &asset.MediaType, + &asset.SizeBytes, + &asset.SHA256, + &asset.StorageKey, + &createdAt, + ) + if err != nil { + return domain.Asset{}, err + } + asset.CreatedAt, err = parseTimestamp(createdAt) + if err != nil { + return domain.Asset{}, err + } + return asset, nil +} + +func scanTask(scanner rowScanner) (domain.PurchaseTask, error) { + var task domain.PurchaseTask + var sourceRef sql.NullString + var maxBudget sql.NullInt64 + var cancelReason sql.NullString + var canceledAt sql.NullString + var createdAt string + var updatedAt string + err := scanner.Scan( + &task.ID, + &task.CreatorSubject, + &sourceRef, + &task.Title, + &task.Description, + &task.SKU, + &task.ImageAssetID, + &task.Quantity, + &maxBudget, + &task.Currency, + &task.Status, + &task.Version, + &cancelReason, + &canceledAt, + &createdAt, + &updatedAt, + ) + if err != nil { + return domain.PurchaseTask{}, err + } + if sourceRef.Valid { + task.SourceRef = &sourceRef.String + } + if maxBudget.Valid { + task.MaxBudgetCents = &maxBudget.Int64 + } + if cancelReason.Valid { + task.CancelReason = &cancelReason.String + } + if canceledAt.Valid { + value, err := parseTimestamp(canceledAt.String) + if err != nil { + return domain.PurchaseTask{}, err + } + task.CanceledAt = &value + } + task.CreatedAt, err = parseTimestamp(createdAt) + if err != nil { + return domain.PurchaseTask{}, err + } + task.UpdatedAt, err = parseTimestamp(updatedAt) + if err != nil { + return domain.PurchaseTask{}, err + } + return task, nil +} + +func getAssetByID( + ctx context.Context, + queryer queryRower, + creatorSubject string, + assetID string, +) (domain.Asset, error) { + asset, err := scanAsset(queryer.QueryRowContext( + ctx, + `SELECT + id, creator_subject, purpose, media_type, size_bytes, + sha256, storage_key, created_at + FROM assets + WHERE creator_subject = ? AND id = ?`, + creatorSubject, + assetID, + )) + if errors.Is(err, sql.ErrNoRows) { + return domain.Asset{}, usecase.ErrRepositoryNotFound + } + if err != nil { + return domain.Asset{}, repositoryFailure(err) + } + return asset, nil +} + +func getTaskByID( + ctx context.Context, + queryer queryRower, + creatorSubject string, + taskID string, +) (domain.PurchaseTask, error) { + task, err := scanTask(queryer.QueryRowContext( + ctx, + `SELECT + id, creator_subject, source_ref, title, description, sku, + image_asset_id, quantity, max_budget_cents, currency, status, + version, cancel_reason, canceled_at, created_at, updated_at + FROM purchase_tasks + WHERE creator_subject = ? AND id = ?`, + creatorSubject, + taskID, + )) + if errors.Is(err, sql.ErrNoRows) { + return domain.PurchaseTask{}, usecase.ErrRepositoryNotFound + } + if err != nil { + return domain.PurchaseTask{}, repositoryFailure(err) + } + return task, nil +} + +func lookupIdempotency( + ctx context.Context, + tx *sql.Tx, + creatorSubject string, + operation string, + idempotencyKey string, +) (requestHash string, resourceID string, found bool, err error) { + err = tx.QueryRowContext( + ctx, + `SELECT request_sha256, resource_id + FROM idempotency_records + WHERE creator_subject = ? + AND operation = ? + AND idempotency_key = ?`, + creatorSubject, + operation, + idempotencyKey, + ).Scan(&requestHash, &resourceID) + if errors.Is(err, sql.ErrNoRows) { + return "", "", false, nil + } + if err != nil { + return "", "", false, repositoryFailure(err) + } + return requestHash, resourceID, true, nil +} + +func insertIdempotency( + ctx context.Context, + tx *sql.Tx, + creatorSubject string, + operation string, + idempotencyKey string, + requestHash string, + resourceType string, + resourceID string, + createdAt time.Time, +) error { + _, err := tx.ExecContext( + ctx, + `INSERT INTO idempotency_records ( + creator_subject, operation, idempotency_key, request_sha256, + resource_type, resource_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?)`, + creatorSubject, + operation, + idempotencyKey, + requestHash, + resourceType, + resourceID, + formatTimestamp(createdAt), + ) + if err != nil { + return repositoryFailure(err) + } + return nil +} + +func formatTimestamp(value time.Time) string { + return value.UTC().Format(timestampLayout) +} + +func parseTimestamp(value string) (time.Time, error) { + parsed, err := time.Parse(timestampLayout, value) + if err != nil { + return time.Time{}, fmt.Errorf( + "%w: invalid stored timestamp", + usecase.ErrRepositoryInvariant, + ) + } + return parsed.UTC(), nil +} + +func nullableString(value *string) any { + if value == nil { + return nil + } + return *value +} + +func nullableInt64(value *int64) any { + if value == nil { + return nil + } + return *value +} + +func repositoryFailure(err error) error { + if err == nil { + return nil + } + var sqliteError sqlite3.Error + if errors.As(err, &sqliteError) { + switch sqliteError.Code { + case sqlite3.ErrBusy, sqlite3.ErrLocked, sqlite3.ErrIoErr, + sqlite3.ErrCantOpen, sqlite3.ErrFull: + return fmt.Errorf("%w", usecase.ErrRepositoryUnavailable) + } + } + if errors.Is(err, context.Canceled) || + errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("%w", usecase.ErrRepositoryUnavailable) + } + return fmt.Errorf("%w", usecase.ErrRepositoryInvariant) +} + +func isUniqueConstraint(err error, fragment string) bool { + var sqliteError sqlite3.Error + if !errors.As(err, &sqliteError) || + sqliteError.ExtendedCode != sqlite3.ErrConstraintUnique { + return false + } + return strings.Contains(err.Error(), fragment) +} diff --git a/backend-api/internal/repository/sqlite/store.go b/backend-api/internal/repository/sqlite/store.go new file mode 100644 index 0000000..a621ad3 --- /dev/null +++ b/backend-api/internal/repository/sqlite/store.go @@ -0,0 +1,17 @@ +package sqlite + +import ( + "database/sql" + "errors" +) + +type Store struct { + db *sql.DB +} + +func New(db *sql.DB) (*Store, error) { + if db == nil { + return nil, errors.New("SQLite database is required") + } + return &Store{db: db}, nil +} diff --git a/backend-api/internal/repository/sqlite/store_test.go b/backend-api/internal/repository/sqlite/store_test.go new file mode 100644 index 0000000..36c42e6 --- /dev/null +++ b/backend-api/internal/repository/sqlite/store_test.go @@ -0,0 +1,436 @@ +package sqlite_test + +import ( + "context" + "database/sql" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + "cmroubao/backend-api/internal/domain" + platformdatabase "cmroubao/backend-api/internal/platform/database" + "cmroubao/backend-api/internal/platform/migration" + repository "cmroubao/backend-api/internal/repository/sqlite" + "cmroubao/backend-api/internal/usecase" +) + +func TestMigrationRejectsTaskFieldLimitViolations(t *testing.T) { + db := openDatabase(t) + ctx := context.Background() + asset := testAsset(1, time.Now().UTC()) + _, err := db.ExecContext( + ctx, + `INSERT INTO assets ( + id, creator_subject, purpose, media_type, size_bytes, + sha256, storage_key, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + asset.ID, + asset.CreatorSubject, + asset.Purpose, + asset.MediaType, + asset.SizeBytes, + asset.SHA256, + asset.StorageKey, + asset.CreatedAt.Format(time.RFC3339Nano), + ) + if err != nil { + t.Fatalf("insert asset: %v", err) + } + + tests := []struct { + name string + title string + description string + sku string + sourceRef string + }{ + { + name: "title characters", + title: repeatText("a", domain.MaxTitleRunes+1), + description: "", + sku: "sku", + sourceRef: "source-1", + }, + { + name: "SKU bytes", + title: "title", + description: "", + sku: repeatText("a", domain.MaxSKUBytes+1), + sourceRef: "source-1", + }, + { + name: "description bytes", + title: "title", + description: repeatText("a", domain.MaxDescriptionBytes+1), + sku: "sku", + sourceRef: "source-1", + }, + { + name: "source reference bytes", + title: "title", + description: "", + sku: "sku", + sourceRef: repeatText("a", domain.MaxSourceRefBytes+1), + }, + } + for index, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := db.ExecContext( + ctx, + `INSERT INTO purchase_tasks ( + id, creator_subject, source_ref, title, description, sku, + image_asset_id, quantity, max_budget_cents, currency, + status, version, created_at, updated_at + ) VALUES (?, 'local-admin', ?, ?, ?, ?, ?, 1, NULL, 'CNY', + 'PENDING', 1, ?, ?)`, + uuid(500+index), + test.sourceRef, + test.title, + test.description, + test.sku, + asset.ID, + asset.CreatedAt.Format(time.RFC3339Nano), + asset.CreatedAt.Format(time.RFC3339Nano), + ) + if err == nil { + t.Fatal("constraint violation error = nil") + } + }) + } +} + +func TestStoreAssetAndTaskLifecycleIsTransactionalAndIdempotent( + t *testing.T, +) { + store := openStore(t) + ctx := context.Background() + now := time.Date(2026, 7, 26, 2, 3, 4, 5, time.UTC) + asset := testAsset(1, now) + + createdAsset, created, err := store.CreateAssetIdempotent( + ctx, + asset, + "asset-key-1", + repeatHex("1"), + ) + if err != nil || !created || createdAsset.ID != asset.ID { + t.Fatalf( + "CreateAssetIdempotent() = %+v, %t, %v", + createdAsset, + created, + err, + ) + } + replayedAsset, created, err := store.CreateAssetIdempotent( + ctx, + testAsset(2, now.Add(time.Second)), + "asset-key-1", + repeatHex("1"), + ) + if err != nil || created || replayedAsset.ID != asset.ID { + t.Fatalf( + "asset replay = %+v, %t, %v", + replayedAsset, + created, + err, + ) + } + _, _, err = store.CreateAssetIdempotent( + ctx, + testAsset(3, now.Add(2*time.Second)), + "asset-key-1", + repeatHex("2"), + ) + if !errors.Is(err, usecase.ErrIdempotencyConflict) { + t.Fatalf("different asset replay error = %v", err) + } + + task := testTask(1, asset.ID, "source-1", now) + event := testEvent(1, task.ID, "TASK_CREATED", now) + createdTask, created, err := store.CreateTaskIdempotent( + ctx, + task, + event, + "task-key-1", + repeatHex("3"), + ) + if err != nil || !created || createdTask.Status != domain.TaskStatusPending { + t.Fatalf( + "CreateTaskIdempotent() = %+v, %t, %v", + createdTask, + created, + err, + ) + } + replayedTask, created, err := store.CreateTaskIdempotent( + ctx, + testTask(2, asset.ID, "different", now.Add(time.Second)), + testEvent(2, uuid(2), "TASK_CREATED", now.Add(time.Second)), + "task-key-1", + repeatHex("3"), + ) + if err != nil || created || replayedTask.ID != task.ID { + t.Fatalf( + "task replay = %+v, %t, %v", + replayedTask, + created, + err, + ) + } + + detail, err := store.GetTaskDetail(ctx, "local-admin", task.ID) + if err != nil { + t.Fatalf("GetTaskDetail() error = %v", err) + } + if detail.Asset.ID != asset.ID || + len(detail.Events) != 1 || + detail.Events[0].Type != "TASK_CREATED" { + t.Fatalf("detail = %+v", detail) + } + + canceled, err := store.CancelPendingTask( + ctx, + "local-admin", + task.ID, + "no longer needed", + now.Add(time.Minute), + testEvent(3, task.ID, "TASK_CANCELED", now.Add(time.Minute)), + ) + if err != nil { + t.Fatalf("CancelPendingTask() error = %v", err) + } + if canceled.Status != domain.TaskStatusCanceled || + canceled.Version != 2 || + canceled.CancelReason == nil || + *canceled.CancelReason != "no longer needed" { + t.Fatalf("canceled task = %+v", canceled) + } + _, err = store.CancelPendingTask( + ctx, + "local-admin", + task.ID, + "again", + now.Add(2*time.Minute), + testEvent(4, task.ID, "TASK_CANCELED", now.Add(2*time.Minute)), + ) + if !errors.Is(err, usecase.ErrTaskStateConflict) { + t.Fatalf("repeat cancel error = %v", err) + } + detail, err = store.GetTaskDetail(ctx, "local-admin", task.ID) + if err != nil || len(detail.Events) != 2 { + t.Fatalf("canceled detail events = %d, error = %v", len(detail.Events), err) + } +} + +func TestStoreEnforcesAssetOwnershipSourceReferenceAndStableCursor( + t *testing.T, +) { + store := openStore(t) + ctx := context.Background() + now := time.Date(2026, 7, 26, 3, 4, 5, 0, time.UTC) + + for index := 1; index <= 4; index++ { + asset := testAsset(index, now) + if _, _, err := store.CreateAssetIdempotent( + ctx, + asset, + "asset-key-"+string(rune('0'+index)), + repeatHex(string(rune('0'+index))), + ); err != nil { + t.Fatalf("create asset %d: %v", index, err) + } + if index <= 3 { + task := testTask(index, asset.ID, "source-"+string(rune('0'+index)), now) + if _, _, err := store.CreateTaskIdempotent( + ctx, + task, + testEvent(index, task.ID, "TASK_CREATED", now), + "task-key-"+string(rune('0'+index)), + repeatHex(string(rune('4'+index))), + ); err != nil { + t.Fatalf("create task %d: %v", index, err) + } + } + } + + first, err := store.ListTasks(ctx, usecase.TaskListFilter{ + CreatorSubject: "local-admin", + Query: "title", + Limit: 2, + }) + if err != nil { + t.Fatalf("first ListTasks() error = %v", err) + } + if len(first) != 2 || first[0].ID <= first[1].ID { + t.Fatalf("first page order = %+v", first) + } + second, err := store.ListTasks(ctx, usecase.TaskListFilter{ + CreatorSubject: "local-admin", + Limit: 2, + After: &usecase.TaskCursor{ + CreatedAt: first[1].CreatedAt, + ID: first[1].ID, + }, + }) + if err != nil { + t.Fatalf("second ListTasks() error = %v", err) + } + if len(second) != 1 || second[0].ID == first[0].ID || + second[0].ID == first[1].ID { + t.Fatalf("second page = %+v", second) + } + + conflicting := testTask(4, testAsset(4, now).ID, "source-1", now) + _, _, err = store.CreateTaskIdempotent( + ctx, + conflicting, + testEvent(4, conflicting.ID, "TASK_CREATED", now), + "task-key-conflict", + repeatHex("a"), + ) + if !errors.Is(err, usecase.ErrSourceReferenceConflict) { + t.Fatalf("source conflict error = %v", err) + } + + otherAsset := testAsset(8, now) + otherAsset.CreatorSubject = "other-admin" + if _, _, err := store.CreateAssetIdempotent( + ctx, + otherAsset, + "other-asset", + repeatHex("b"), + ); err != nil { + t.Fatalf("create other asset: %v", err) + } + foreignTask := testTask(8, otherAsset.ID, "foreign-source", now) + foreignTask.CreatorSubject = "local-admin" + _, _, err = store.CreateTaskIdempotent( + ctx, + foreignTask, + testEvent(8, foreignTask.ID, "TASK_CREATED", now), + "foreign-task", + repeatHex("c"), + ) + if !errors.Is(err, usecase.ErrAssetUnavailable) { + t.Fatalf("foreign asset error = %v", err) + } +} + +func openStore(t *testing.T) *repository.Store { + t.Helper() + db := openDatabase(t) + store, err := repository.New(db) + if err != nil { + t.Fatalf("repository.New() error = %v", err) + } + return store +} + +func openDatabase(t *testing.T) *sql.DB { + t.Helper() + db, err := platformdatabase.Open( + context.Background(), + filepath.Join(t.TempDir(), "store.db"), + ) + if err != nil { + t.Fatalf("database.Open() error = %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + runner, err := migration.New(db) + if err != nil { + t.Fatalf("migration.New() error = %v", err) + } + if _, err := runner.Up(context.Background()); err != nil { + t.Fatalf("migration Up() error = %v", err) + } + return db +} + +func testAsset(index int, createdAt time.Time) domain.Asset { + return domain.Asset{ + ID: uuid(100 + index), + CreatorSubject: "local-admin", + Purpose: domain.AssetPurposeTaskReference, + MediaType: domain.NormalizedImageMediaType, + SizeBytes: int64(1000 + index), + SHA256: repeatHex("d"), + StorageKey: "aa/" + uuid(200+index) + ".jpg", + CreatedAt: createdAt, + } +} + +func testTask( + index int, + assetID string, + source string, + createdAt time.Time, +) domain.PurchaseTask { + budget := int64(2000 + index) + return domain.PurchaseTask{ + ID: uuid(index), + CreatorSubject: "local-admin", + SourceRef: &source, + Title: "title " + string(rune('0'+index)), + Description: "description", + SKU: "SKU-" + string(rune('0'+index)), + ImageAssetID: assetID, + Quantity: index, + MaxBudgetCents: &budget, + Currency: domain.CurrencyCNY, + Status: domain.TaskStatusPending, + Version: 1, + CreatedAt: createdAt, + UpdatedAt: createdAt, + } +} + +func testEvent( + index int, + taskID string, + eventType string, + occurredAt time.Time, +) domain.TaskEvent { + return domain.TaskEvent{ + ID: uuid(300 + index), + TaskID: taskID, + Type: eventType, + Message: "event", + OccurredAt: occurredAt, + } +} + +func uuid(index int) string { + return "00000000-0000-4000-8000-" + twelveDigits(index) +} + +func twelveDigits(value int) string { + result := "000000000000" + digits := "" + for value > 0 { + digits = string(rune('0'+value%10)) + digits + value /= 10 + } + if digits == "" { + digits = "0" + } + return result[:len(result)-len(digits)] + digits +} + +func repeatHex(value string) string { + result := "" + for len(result) < 64 { + result += value + } + return result[:64] +} + +func repeatText(value string, count int) string { + var result strings.Builder + result.Grow(len(value) * count) + for index := 0; index < count; index++ { + result.WriteString(value) + } + return result.String() +} diff --git a/backend-api/internal/repository/sqlite/task_repository.go b/backend-api/internal/repository/sqlite/task_repository.go new file mode 100644 index 0000000..bf6fb0a --- /dev/null +++ b/backend-api/internal/repository/sqlite/task_repository.go @@ -0,0 +1,383 @@ +package sqlite + +import ( + "context" + "database/sql" + "strings" + "time" + + "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/usecase" +) + +const createTaskOperation = "CREATE_PURCHASE_TASK" + +func (s *Store) CreateTaskIdempotent( + ctx context.Context, + candidate domain.PurchaseTask, + event domain.TaskEvent, + idempotencyKey string, + requestHash string, +) (domain.PurchaseTask, bool, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return domain.PurchaseTask{}, false, repositoryFailure(err) + } + defer func() { _ = tx.Rollback() }() + + existingHash, resourceID, found, err := lookupIdempotency( + ctx, + tx, + candidate.CreatorSubject, + createTaskOperation, + idempotencyKey, + ) + if err != nil { + return domain.PurchaseTask{}, false, err + } + if found { + if existingHash != requestHash { + return domain.PurchaseTask{}, false, usecase.ErrIdempotencyConflict + } + existing, err := getTaskByID( + ctx, + tx, + candidate.CreatorSubject, + resourceID, + ) + if err != nil { + return domain.PurchaseTask{}, false, err + } + if err := tx.Commit(); err != nil { + return domain.PurchaseTask{}, false, repositoryFailure(err) + } + return existing, false, nil + } + + var available int + err = tx.QueryRowContext( + ctx, + `SELECT EXISTS ( + SELECT 1 + FROM assets AS asset + WHERE asset.id = ? + AND asset.creator_subject = ? + AND asset.purpose = 'TASK_REFERENCE' + AND NOT EXISTS ( + SELECT 1 + FROM purchase_tasks AS task + WHERE task.image_asset_id = asset.id + ) + )`, + candidate.ImageAssetID, + candidate.CreatorSubject, + ).Scan(&available) + if err != nil { + return domain.PurchaseTask{}, false, repositoryFailure(err) + } + if available != 1 { + return domain.PurchaseTask{}, false, usecase.ErrAssetUnavailable + } + + _, err = tx.ExecContext( + ctx, + `INSERT INTO purchase_tasks ( + id, creator_subject, source_ref, title, description, sku, + image_asset_id, quantity, max_budget_cents, currency, status, + version, cancel_reason, canceled_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?)`, + candidate.ID, + candidate.CreatorSubject, + nullableString(candidate.SourceRef), + candidate.Title, + candidate.Description, + candidate.SKU, + candidate.ImageAssetID, + candidate.Quantity, + nullableInt64(candidate.MaxBudgetCents), + candidate.Currency, + candidate.Status, + candidate.Version, + formatTimestamp(candidate.CreatedAt), + formatTimestamp(candidate.UpdatedAt), + ) + if err != nil { + switch { + case isUniqueConstraint( + err, + "purchase_tasks.creator_subject, purchase_tasks.source_ref", + ): + return domain.PurchaseTask{}, false, usecase.ErrSourceReferenceConflict + case isUniqueConstraint(err, "purchase_tasks.image_asset_id"): + return domain.PurchaseTask{}, false, usecase.ErrAssetUnavailable + default: + return domain.PurchaseTask{}, false, repositoryFailure(err) + } + } + if err := insertTaskEvent(ctx, tx, event); err != nil { + return domain.PurchaseTask{}, false, err + } + if err := insertIdempotency( + ctx, + tx, + candidate.CreatorSubject, + createTaskOperation, + idempotencyKey, + requestHash, + "PURCHASE_TASK", + candidate.ID, + candidate.CreatedAt, + ); err != nil { + return domain.PurchaseTask{}, false, err + } + if err := tx.Commit(); err != nil { + return domain.PurchaseTask{}, false, repositoryFailure(err) + } + return candidate, true, nil +} + +func (s *Store) ListTasks( + ctx context.Context, + filter usecase.TaskListFilter, +) ([]domain.PurchaseTask, error) { + var query strings.Builder + query.WriteString(`SELECT + id, creator_subject, source_ref, title, description, sku, + image_asset_id, quantity, max_budget_cents, currency, status, + version, cancel_reason, canceled_at, created_at, updated_at + FROM purchase_tasks + WHERE creator_subject = ?`) + arguments := []any{filter.CreatorSubject} + if filter.Status != nil { + query.WriteString(" AND status = ?") + arguments = append(arguments, *filter.Status) + } + if filter.Query != "" { + query.WriteString(` AND ( + id LIKE ? ESCAPE '\' + OR COALESCE(source_ref, '') LIKE ? ESCAPE '\' + OR title LIKE ? ESCAPE '\' + OR sku LIKE ? ESCAPE '\' + )`) + pattern := "%" + escapeLike(filter.Query) + "%" + arguments = append( + arguments, + pattern, + pattern, + pattern, + pattern, + ) + } + if filter.CreatedFrom != nil { + query.WriteString(" AND created_at >= ?") + arguments = append( + arguments, + formatTimestamp(*filter.CreatedFrom), + ) + } + if filter.CreatedTo != nil { + query.WriteString(" AND created_at <= ?") + arguments = append( + arguments, + formatTimestamp(*filter.CreatedTo), + ) + } + if filter.After != nil { + query.WriteString( + " AND (created_at < ? OR (created_at = ? AND id < ?))", + ) + createdAt := formatTimestamp(filter.After.CreatedAt) + arguments = append( + arguments, + createdAt, + createdAt, + filter.After.ID, + ) + } + query.WriteString(" ORDER BY created_at DESC, id DESC LIMIT ?") + arguments = append(arguments, filter.Limit) + + rows, err := s.db.QueryContext(ctx, query.String(), arguments...) + if err != nil { + return nil, repositoryFailure(err) + } + defer rows.Close() + tasks := make([]domain.PurchaseTask, 0) + for rows.Next() { + task, err := scanTask(rows) + if err != nil { + return nil, repositoryFailure(err) + } + tasks = append(tasks, task) + } + if err := rows.Err(); err != nil { + return nil, repositoryFailure(err) + } + return tasks, nil +} + +func (s *Store) GetTaskDetail( + ctx context.Context, + creatorSubject string, + taskID string, +) (domain.TaskDetail, error) { + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return domain.TaskDetail{}, repositoryFailure(err) + } + defer func() { _ = tx.Rollback() }() + task, err := getTaskByID(ctx, tx, creatorSubject, taskID) + if err != nil { + return domain.TaskDetail{}, err + } + asset, err := getAssetByID( + ctx, + tx, + creatorSubject, + task.ImageAssetID, + ) + if err != nil { + return domain.TaskDetail{}, err + } + rows, err := tx.QueryContext( + ctx, + `SELECT id, task_id, event_type, message, occurred_at + FROM task_events + WHERE task_id = ? + ORDER BY occurred_at ASC, id ASC`, + taskID, + ) + if err != nil { + return domain.TaskDetail{}, repositoryFailure(err) + } + defer rows.Close() + events := make([]domain.TaskEvent, 0) + for rows.Next() { + var event domain.TaskEvent + var occurredAt string + if err := rows.Scan( + &event.ID, + &event.TaskID, + &event.Type, + &event.Message, + &occurredAt, + ); err != nil { + return domain.TaskDetail{}, repositoryFailure(err) + } + event.OccurredAt, err = parseTimestamp(occurredAt) + if err != nil { + return domain.TaskDetail{}, err + } + events = append(events, event) + } + if err := rows.Err(); err != nil { + return domain.TaskDetail{}, repositoryFailure(err) + } + if err := rows.Close(); err != nil { + return domain.TaskDetail{}, repositoryFailure(err) + } + detail := domain.TaskDetail{ + Task: task, + Asset: asset, + Events: events, + } + if err := tx.Commit(); err != nil { + return domain.TaskDetail{}, repositoryFailure(err) + } + return detail, nil +} + +func (s *Store) CancelPendingTask( + ctx context.Context, + creatorSubject string, + taskID string, + reason string, + canceledAt time.Time, + event domain.TaskEvent, +) (domain.PurchaseTask, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return domain.PurchaseTask{}, repositoryFailure(err) + } + defer func() { _ = tx.Rollback() }() + task, err := getTaskByID(ctx, tx, creatorSubject, taskID) + if err != nil { + return domain.PurchaseTask{}, err + } + if !domain.CanCancel(task.Status) { + return domain.PurchaseTask{}, usecase.ErrTaskStateConflict + } + result, err := tx.ExecContext( + ctx, + `UPDATE purchase_tasks + SET status = 'CANCELED', + version = version + 1, + cancel_reason = NULLIF(?, ''), + canceled_at = ?, + updated_at = ? + WHERE id = ? + AND creator_subject = ? + AND status = 'PENDING' + AND version = ?`, + reason, + formatTimestamp(canceledAt), + formatTimestamp(canceledAt), + taskID, + creatorSubject, + task.Version, + ) + if err != nil { + return domain.PurchaseTask{}, repositoryFailure(err) + } + affected, err := result.RowsAffected() + if err != nil { + return domain.PurchaseTask{}, repositoryFailure(err) + } + if affected != 1 { + return domain.PurchaseTask{}, usecase.ErrTaskStateConflict + } + if err := insertTaskEvent(ctx, tx, event); err != nil { + return domain.PurchaseTask{}, err + } + updated, err := getTaskByID(ctx, tx, creatorSubject, taskID) + if err != nil { + return domain.PurchaseTask{}, err + } + if err := tx.Commit(); err != nil { + return domain.PurchaseTask{}, repositoryFailure(err) + } + return updated, nil +} + +func insertTaskEvent( + ctx context.Context, + tx *sql.Tx, + event domain.TaskEvent, +) error { + _, err := tx.ExecContext( + ctx, + `INSERT INTO task_events ( + id, task_id, event_type, message, occurred_at + ) VALUES (?, ?, ?, ?, ?)`, + event.ID, + event.TaskID, + event.Type, + event.Message, + formatTimestamp(event.OccurredAt), + ) + if err != nil { + return repositoryFailure(err) + } + return nil +} + +func escapeLike(value string) string { + replacer := strings.NewReplacer( + `\`, `\\`, + `%`, `\%`, + `_`, `\_`, + ) + return replacer.Replace(value) +} + +var _ usecase.TaskRepository = (*Store)(nil) diff --git a/backend-api/internal/transport/httpapi/admin_guard.go b/backend-api/internal/transport/httpapi/admin_guard.go new file mode 100644 index 0000000..851083a --- /dev/null +++ b/backend-api/internal/transport/httpapi/admin_guard.go @@ -0,0 +1,36 @@ +package httpapi + +import ( + "net" + "net/http" + + "github.com/gin-gonic/gin" +) + +func loopbackAdminOnly() gin.HandlerFunc { + return func(ctx *gin.Context) { + host, _, err := net.SplitHostPort(ctx.Request.RemoteAddr) + if err != nil { + denyNonLocalAdmin(ctx) + return + } + address := net.ParseIP(host) + if address == nil || !address.IsLoopback() { + denyNonLocalAdmin(ctx) + return + } + ctx.Next() + } +} + +func denyNonLocalAdmin(ctx *gin.Context) { + ctx.Header("Cache-Control", "no-store") + ctx.AbortWithStatusJSON( + http.StatusForbidden, + errorResponse( + ctx, + "ADMIN_SESSION_REQUIRED", + "admin session required", + ), + ) +} diff --git a/backend-api/internal/transport/httpapi/admin_guard_test.go b/backend-api/internal/transport/httpapi/admin_guard_test.go new file mode 100644 index 0000000..6619a28 --- /dev/null +++ b/backend-api/internal/transport/httpapi/admin_guard_test.go @@ -0,0 +1,64 @@ +package httpapi + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestLoopbackAdminOnlyAllowsLoopbackAddresses(t *testing.T) { + for _, remoteAddress := range []string{ + "127.0.0.1:12345", + "[::1]:12345", + } { + t.Run(remoteAddress, func(t *testing.T) { + router := gin.New() + router.Use(requestIDMiddleware(), loopbackAdminOnly()) + router.GET("/tasks", func(ctx *gin.Context) { + ctx.Status(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodGet, "/tasks", nil) + request.RemoteAddr = remoteAddress + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusNoContent { + t.Fatalf("status = %d", response.Code) + } + }) + } +} + +func TestLoopbackAdminOnlyRejectsRemoteOrMalformedAddresses(t *testing.T) { + for _, remoteAddress := range []string{ + "192.0.2.1:12345", + "not-an-address", + } { + t.Run(remoteAddress, func(t *testing.T) { + router := gin.New() + router.Use(requestIDMiddleware(), loopbackAdminOnly()) + router.GET("/tasks", func(ctx *gin.Context) { + ctx.Status(http.StatusNoContent) + }) + request := httptest.NewRequest(http.MethodGet, "/tasks", nil) + request.RemoteAddr = remoteAddress + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusForbidden { + t.Fatalf("status = %d", response.Code) + } + assertErrorCode(t, response, "ADMIN_SESSION_REQUIRED") + if response.Header().Get("Cache-Control") != "no-store" { + t.Fatalf( + "Cache-Control = %q", + response.Header().Get("Cache-Control"), + ) + } + }) + } +} diff --git a/backend-api/internal/transport/httpapi/admin_handlers.go b/backend-api/internal/transport/httpapi/admin_handlers.go new file mode 100644 index 0000000..9445cb5 --- /dev/null +++ b/backend-api/internal/transport/httpapi/admin_handlers.go @@ -0,0 +1,512 @@ +package httpapi + +import ( + "encoding/json" + "errors" + "io" + "mime" + "net/http" + "strconv" + "strings" + "time" + + "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/usecase" + + "github.com/gin-gonic/gin" +) + +const ( + localAdminSubject = "local-admin" + maxJSONBodyBytes = 64 << 10 + maxMultipartBytes = 21 << 20 +) + +type AdminServices struct { + Assets *usecase.AssetService + Tasks *usecase.TaskService +} + +func (s AdminServices) validate() error { + if s.Assets == nil || s.Tasks == nil { + return errors.New("admin services are required") + } + return nil +} + +type adminHandlers struct { + services AdminServices +} + +func registerAdminAPI(routes gin.IRoutes, services AdminServices) error { + if err := services.validate(); err != nil { + return err + } + handler := &adminHandlers{services: services} + routes.POST("/api/v1/assets", handler.uploadAsset) + routes.GET("/api/v1/assets/:id/content", handler.assetContent) + routes.POST("/api/v1/tasks", handler.createTask) + routes.GET("/api/v1/tasks", handler.listTasks) + routes.GET("/api/v1/tasks/:id", handler.taskDetail) + routes.POST("/api/v1/tasks/:id/cancel", handler.cancelTask) + return nil +} + +func (h *adminHandlers) uploadAsset(ctx *gin.Context) { + if !hasMediaType(ctx, "multipart/form-data") { + writePublicError( + ctx, + http.StatusUnsupportedMediaType, + "UNSUPPORTED_MEDIA_TYPE", + "multipart/form-data is required", + false, + gin.H{}, + ) + return + } + ctx.Request.Body = http.MaxBytesReader( + ctx.Writer, + ctx.Request.Body, + maxMultipartBytes, + ) + if err := ctx.Request.ParseMultipartForm(maxMultipartBytes); err != nil { + writePublicError( + ctx, + http.StatusRequestEntityTooLarge, + "ASSET_TOO_LARGE", + "reference image exceeds the allowed size", + false, + gin.H{}, + ) + return + } + if ctx.Request.MultipartForm != nil { + defer ctx.Request.MultipartForm.RemoveAll() + } + purpose := strings.TrimSpace(ctx.PostForm("purpose")) + if purpose != domain.AssetPurposeTaskReference { + writePublicError( + ctx, + http.StatusUnprocessableEntity, + "ASSET_PURPOSE_INVALID", + "asset purpose is not supported", + false, + fieldDetails("purpose", "must be TASK_REFERENCE"), + ) + return + } + if strings.TrimSpace(ctx.PostForm("task_id")) != "" { + writePublicError( + ctx, + http.StatusUnprocessableEntity, + "ASSET_TASK_ID_INVALID", + "task_id must be empty for a task reference", + false, + fieldDetails("task_id", "must be empty"), + ) + return + } + files := ctx.Request.MultipartForm.File["file"] + if len(files) != 1 { + writePublicError( + ctx, + http.StatusBadRequest, + "ASSET_FILE_REQUIRED", + "exactly one reference image is required", + false, + fieldDetails("file", "exactly one file is required"), + ) + return + } + content, err := files[0].Open() + if err != nil { + writePublicError( + ctx, + http.StatusUnprocessableEntity, + "ASSET_IMAGE_INVALID", + "reference image is invalid", + false, + fieldDetails("file", "cannot be read"), + ) + return + } + defer content.Close() + + result, err := h.services.Assets.UploadTaskReference( + ctx.Request.Context(), + usecase.UploadTaskReferenceCommand{ + CreatorSubject: localAdminSubject, + IdempotencyKey: ctx.GetHeader("Idempotency-Key"), + DeclaredMediaType: files[0].Header.Get("Content-Type"), + Content: content, + }, + ) + if err != nil { + writeUsecaseError(ctx, err) + return + } + ctx.JSON(http.StatusCreated, assetResponse(result.Asset)) +} + +func (h *adminHandlers) assetContent(ctx *gin.Context) { + result, err := h.services.Assets.OpenTaskReference( + ctx.Request.Context(), + localAdminSubject, + ctx.Param("id"), + ) + if err != nil { + writeUsecaseError(ctx, err) + return + } + defer result.Content.Close() + + ctx.Header("Cache-Control", "private, no-store") + ctx.Header("Content-Type", result.Asset.MediaType) + ctx.Header("Content-Length", strconv.FormatInt(result.Asset.SizeBytes, 10)) + ctx.Header("ETag", `"`+result.Asset.SHA256+`"`) + ctx.Header("X-Content-Type-Options", "nosniff") + ctx.Header( + "Content-Disposition", + `inline; filename="`+result.Asset.ID+`.jpg"`, + ) + ctx.Status(http.StatusOK) + _, _ = io.Copy(ctx.Writer, result.Content) +} + +func (h *adminHandlers) createTask(ctx *gin.Context) { + if !hasMediaType(ctx, "application/json") { + writePublicError( + ctx, + http.StatusUnsupportedMediaType, + "UNSUPPORTED_MEDIA_TYPE", + "application/json is required", + false, + gin.H{}, + ) + return + } + var request struct { + SourceRef *string `json:"source_ref"` + Title string `json:"title"` + Description string `json:"description"` + SKU string `json:"sku"` + ImageAssetID string `json:"image_asset_id"` + Quantity int `json:"quantity"` + MaxBudget *string `json:"max_budget"` + } + if err := decodeJSON(ctx, &request); err != nil { + writePublicError( + ctx, + http.StatusBadRequest, + "INVALID_JSON", + "request body must be valid JSON", + false, + gin.H{}, + ) + return + } + result, err := h.services.Tasks.Create( + ctx.Request.Context(), + usecase.CreateTaskCommand{ + CreatorSubject: localAdminSubject, + IdempotencyKey: ctx.GetHeader("Idempotency-Key"), + SourceRef: request.SourceRef, + Title: request.Title, + Description: request.Description, + SKU: request.SKU, + ImageAssetID: request.ImageAssetID, + Quantity: request.Quantity, + MaxBudget: request.MaxBudget, + }, + ) + if err != nil { + writeUsecaseError(ctx, err) + return + } + ctx.JSON(http.StatusCreated, taskSummaryResponse(result.Task)) +} + +func (h *adminHandlers) listTasks(ctx *gin.Context) { + query := usecase.ListTasksQuery{ + CreatorSubject: localAdminSubject, + Query: ctx.Query("q"), + Cursor: ctx.Query("cursor"), + } + if value := strings.TrimSpace(ctx.Query("status")); value != "" { + query.Status = &value + } + if value := strings.TrimSpace(ctx.Query("created_from")); value != "" { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + writeFilterError(ctx, "created_from", "must be RFC3339") + return + } + query.CreatedFrom = &parsed + } + if value := strings.TrimSpace(ctx.Query("created_to")); value != "" { + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + writeFilterError(ctx, "created_to", "must be RFC3339") + return + } + query.CreatedTo = &parsed + } + if value := strings.TrimSpace(ctx.Query("limit")); value != "" { + parsed, err := strconv.Atoi(value) + if err != nil { + writeFilterError(ctx, "limit", "must be an integer") + return + } + query.Limit = parsed + } + page, err := h.services.Tasks.List(ctx.Request.Context(), query) + if err != nil { + writeUsecaseError(ctx, err) + return + } + items := make([]gin.H, 0, len(page.Items)) + for _, task := range page.Items { + items = append(items, taskListItemResponse(task)) + } + var nextCursor any + if page.NextCursor != "" { + nextCursor = page.NextCursor + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, gin.H{ + "items": items, + "next_cursor": nextCursor, + }) +} + +func (h *adminHandlers) taskDetail(ctx *gin.Context) { + detail, err := h.services.Tasks.Get( + ctx.Request.Context(), + localAdminSubject, + ctx.Param("id"), + ) + if err != nil { + writeUsecaseError(ctx, err) + return + } + events := make([]gin.H, 0, len(detail.Events)) + for _, event := range detail.Events { + events = append(events, gin.H{ + "id": event.ID, + "type": event.Type, + "message": event.Message, + "occurred_at": formatTime(event.OccurredAt), + }) + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(http.StatusOK, gin.H{ + "id": detail.Task.ID, + "status": detail.Task.Status, + "version": detail.Task.Version, + "created_at": formatTime(detail.Task.CreatedAt), + "updated_at": formatTime(detail.Task.UpdatedAt), + "original_requirement": gin.H{ + "source_ref": detail.Task.SourceRef, + "title": detail.Task.Title, + "description": detail.Task.Description, + "sku": detail.Task.SKU, + "image_asset_id": detail.Task.ImageAssetID, + "quantity": detail.Task.Quantity, + "max_budget": domain.FormatOptionalCNY(detail.Task.MaxBudgetCents), + "currency": detail.Task.Currency, + }, + "derived_requirement": nil, + "claim": nil, + "execution": nil, + "events": events, + "assets": []gin.H{ + assetResponse(detail.Asset), + }, + }) +} + +func (h *adminHandlers) cancelTask(ctx *gin.Context) { + if !hasMediaType(ctx, "application/json") { + writePublicError( + ctx, + http.StatusUnsupportedMediaType, + "UNSUPPORTED_MEDIA_TYPE", + "application/json is required", + false, + gin.H{}, + ) + return + } + var request struct { + Reason string `json:"reason"` + } + if err := decodeJSON(ctx, &request); err != nil { + writePublicError( + ctx, + http.StatusBadRequest, + "INVALID_JSON", + "request body must be valid JSON", + false, + gin.H{}, + ) + return + } + task, err := h.services.Tasks.Cancel( + ctx.Request.Context(), + usecase.CancelTaskCommand{ + CreatorSubject: localAdminSubject, + TaskID: ctx.Param("id"), + Reason: request.Reason, + }, + ) + if err != nil { + writeUsecaseError(ctx, err) + return + } + ctx.JSON(http.StatusOK, taskSummaryResponse(task)) +} + +func decodeJSON(ctx *gin.Context, target any) error { + ctx.Request.Body = http.MaxBytesReader( + ctx.Writer, + ctx.Request.Body, + maxJSONBodyBytes, + ) + decoder := json.NewDecoder(ctx.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + return errors.New("request must contain one JSON value") + } + return nil +} + +func hasMediaType(ctx *gin.Context, expected string) bool { + mediaType, _, err := mime.ParseMediaType(ctx.GetHeader("Content-Type")) + return err == nil && strings.EqualFold(mediaType, expected) +} + +func writeFilterError(ctx *gin.Context, field, message string) { + writePublicError( + ctx, + http.StatusBadRequest, + "TASK_LIST_FILTER_INVALID", + "task list filter is invalid", + false, + fieldDetails(field, message), + ) +} + +func writeUsecaseError(ctx *gin.Context, err error) { + var typed *usecase.Error + if !errors.As(err, &typed) { + writePublicError( + ctx, + http.StatusInternalServerError, + "INTERNAL_ERROR", + "internal server error", + false, + gin.H{}, + ) + return + } + status := http.StatusInternalServerError + switch typed.Kind { + case usecase.ErrorKindInvalid: + status = http.StatusUnprocessableEntity + switch typed.Code { + case "REQUEST_VALIDATION_FAILED", + "ASSET_FILE_REQUIRED", + "IDEMPOTENCY_KEY_REQUIRED": + status = http.StatusBadRequest + case "ASSET_TOO_LARGE": + status = http.StatusRequestEntityTooLarge + case "ASSET_MEDIA_TYPE_UNSUPPORTED": + status = http.StatusUnsupportedMediaType + } + case usecase.ErrorKindNotFound: + status = http.StatusNotFound + case usecase.ErrorKindConflict: + status = http.StatusConflict + case usecase.ErrorKindUnavailable: + status = http.StatusServiceUnavailable + } + details := gin.H{} + if len(typed.Fields) > 0 { + details["fields"] = typed.Fields + } + writePublicError( + ctx, + status, + typed.Code, + typed.Message, + typed.Retryable, + details, + ) +} + +func writePublicError( + ctx *gin.Context, + status int, + code string, + message string, + retryable bool, + details gin.H, +) { + requestID, _ := ctx.Get(requestIDContextKey) + if details == nil { + details = gin.H{} + } + ctx.Header("Cache-Control", "no-store") + ctx.JSON(status, gin.H{ + "error": gin.H{ + "code": code, + "message": message, + "retryable": retryable, + "details": details, + }, + "request_id": requestID, + }) +} + +func fieldDetails(field, message string) gin.H { + return gin.H{"fields": gin.H{field: message}} +} + +func assetResponse(asset domain.Asset) gin.H { + return gin.H{ + "id": asset.ID, + "purpose": asset.Purpose, + "media_type": asset.MediaType, + "size_bytes": asset.SizeBytes, + "sha256": asset.SHA256, + "created_at": formatTime(asset.CreatedAt), + } +} + +func taskSummaryResponse(task domain.PurchaseTask) gin.H { + return gin.H{ + "id": task.ID, + "status": task.Status, + "title": task.Title, + "sku": task.SKU, + "quantity": task.Quantity, + "max_budget": domain.FormatOptionalCNY(task.MaxBudgetCents), + "created_at": formatTime(task.CreatedAt), + "updated_at": formatTime(task.UpdatedAt), + "version": task.Version, + } +} + +func taskListItemResponse(task domain.PurchaseTask) gin.H { + response := taskSummaryResponse(task) + response["device_name"] = nil + return response +} + +func formatTime(value time.Time) string { + return value.UTC().Format(time.RFC3339Nano) +} diff --git a/backend-api/internal/transport/httpapi/admin_handlers_test.go b/backend-api/internal/transport/httpapi/admin_handlers_test.go new file mode 100644 index 0000000..30e4dff --- /dev/null +++ b/backend-api/internal/transport/httpapi/admin_handlers_test.go @@ -0,0 +1,421 @@ +package httpapi + +import ( + "bytes" + "context" + "encoding/json" + "image" + "image/color" + "image/jpeg" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/textproto" + "path/filepath" + "strings" + "testing" + + "cmroubao/backend-api/internal/platform/assetstore" + "cmroubao/backend-api/internal/platform/database" + "cmroubao/backend-api/internal/platform/migration" + repository "cmroubao/backend-api/internal/repository/sqlite" + "cmroubao/backend-api/internal/usecase" + + "github.com/gin-gonic/gin" +) + +func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) { + router := newAdminIntegrationRouter(t) + imageBody, imageContentType := referenceUpload(t, "asset-key-1") + assetResponse := performAdminRequest( + t, + router, + http.MethodPost, + "/api/v1/assets", + imageContentType, + imageBody, + "asset-key-1", + ) + if assetResponse.Code != http.StatusCreated { + t.Fatalf( + "asset upload status = %d, body = %s", + assetResponse.Code, + assetResponse.Body.String(), + ) + } + var asset map[string]any + decodeResponse(t, assetResponse, &asset) + assetID, _ := asset["id"].(string) + if assetID == "" || asset["media_type"] != "image/jpeg" { + t.Fatalf("asset response = %#v", asset) + } + if responseContainsKey(asset, "storage_key") || + strings.Contains(strings.ToLower(assetResponse.Body.String()), "temp") { + t.Fatalf("asset response exposes storage details: %#v", asset) + } + + replayBody, replayContentType := referenceUpload(t, "asset-key-1") + replayResponse := performAdminRequest( + t, + router, + http.MethodPost, + "/api/v1/assets", + replayContentType, + replayBody, + "asset-key-1", + ) + var replayedAsset map[string]any + decodeResponse(t, replayResponse, &replayedAsset) + if replayResponse.Code != http.StatusCreated || + replayedAsset["id"] != assetID { + t.Fatalf( + "asset replay status/body = %d / %#v", + replayResponse.Code, + replayedAsset, + ) + } + + taskJSON := `{ + "source_ref":"external-10001", + "title":"黑色双肩包", + "sku":"BLACK-20L", + "description":"容量约20L", + "image_asset_id":"` + assetID + `", + "quantity":2, + "max_budget":"200.00" + }` + taskResponse := performAdminRequest( + t, + router, + http.MethodPost, + "/api/v1/tasks", + "application/json", + strings.NewReader(taskJSON), + "task-key-1", + ) + if taskResponse.Code != http.StatusCreated { + t.Fatalf( + "task create status = %d, body = %s", + taskResponse.Code, + taskResponse.Body.String(), + ) + } + var task map[string]any + decodeResponse(t, taskResponse, &task) + taskID, _ := task["id"].(string) + if taskID == "" || task["status"] != "PENDING" || + task["sku"] != "BLACK-20L" || + task["max_budget"] != "200.00" { + t.Fatalf("task response = %#v", task) + } + + taskReplay := performAdminRequest( + t, + router, + http.MethodPost, + "/api/v1/tasks", + "application/json", + strings.NewReader(taskJSON), + "task-key-1", + ) + var replayedTask map[string]any + decodeResponse(t, taskReplay, &replayedTask) + if taskReplay.Code != http.StatusCreated || + replayedTask["id"] != taskID { + t.Fatalf( + "task replay status/body = %d / %#v", + taskReplay.Code, + replayedTask, + ) + } + + listResponse := performAdminRequest( + t, + router, + http.MethodGet, + "/api/v1/tasks?q=BLACK-20L&limit=20", + "", + nil, + "", + ) + var list map[string]any + decodeResponse(t, listResponse, &list) + items, _ := list["items"].([]any) + if listResponse.Code != http.StatusOK || len(items) != 1 { + t.Fatalf( + "task list status/body = %d / %#v", + listResponse.Code, + list, + ) + } + + detailResponse := performAdminRequest( + t, + router, + http.MethodGet, + "/api/v1/tasks/"+taskID, + "", + nil, + "", + ) + var detail map[string]any + decodeResponse(t, detailResponse, &detail) + requirement, _ := detail["original_requirement"].(map[string]any) + if detailResponse.Code != http.StatusOK || + requirement["sku"] != "BLACK-20L" || + requirement["quantity"] != float64(2) { + t.Fatalf( + "task detail status/body = %d / %#v", + detailResponse.Code, + detail, + ) + } + + contentResponse := performAdminRequest( + t, + router, + http.MethodGet, + "/api/v1/assets/"+assetID+"/content", + "", + nil, + "", + ) + if contentResponse.Code != http.StatusOK || + contentResponse.Header().Get("Content-Type") != "image/jpeg" || + !bytes.HasPrefix(contentResponse.Body.Bytes(), []byte{0xff, 0xd8}) { + t.Fatalf( + "asset content status/headers = %d / %#v", + contentResponse.Code, + contentResponse.Header(), + ) + } + + cancelResponse := performAdminRequest( + t, + router, + http.MethodPost, + "/api/v1/tasks/"+taskID+"/cancel", + "application/json", + strings.NewReader(`{"reason":"需求已撤销"}`), + "", + ) + var canceled map[string]any + decodeResponse(t, cancelResponse, &canceled) + if cancelResponse.Code != http.StatusOK || + canceled["status"] != "CANCELED" { + t.Fatalf( + "task cancel status/body = %d / %#v", + cancelResponse.Code, + canceled, + ) + } + + secondCancel := performAdminRequest( + t, + router, + http.MethodPost, + "/api/v1/tasks/"+taskID+"/cancel", + "application/json", + strings.NewReader(`{"reason":"再次取消"}`), + "", + ) + if secondCancel.Code != http.StatusConflict { + t.Fatalf( + "second cancel status = %d, body = %s", + secondCancel.Code, + secondCancel.Body.String(), + ) + } +} + +func TestAdminRoutesRejectNonLoopbackRequests(t *testing.T) { + router := newAdminIntegrationRouter(t) + request := httptest.NewRequest(http.MethodGet, "/api/v1/tasks", nil) + request.RemoteAddr = "192.0.2.10:3210" + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusForbidden { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + var body map[string]any + decodeResponse(t, response, &body) + publicError, _ := body["error"].(map[string]any) + if publicError["code"] != "ADMIN_SESSION_REQUIRED" { + t.Fatalf("error response = %#v", body) + } +} + +func TestAdminAssetUploadRequiresIdempotencyKey(t *testing.T) { + router := newAdminIntegrationRouter(t) + imageBody, imageContentType := referenceUpload(t, "missing-key") + + response := performAdminRequest( + t, + router, + http.MethodPost, + "/api/v1/assets", + imageContentType, + imageBody, + "", + ) + + if response.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + var body map[string]any + decodeResponse(t, response, &body) + publicError, _ := body["error"].(map[string]any) + if publicError["code"] != "IDEMPOTENCY_KEY_REQUIRED" { + t.Fatalf("error response = %#v", body) + } +} + +type emptyAdminWeb struct{} + +func (emptyAdminWeb) Register(gin.IRoutes) {} + +func newAdminIntegrationRouter(t *testing.T) http.Handler { + t.Helper() + ctx := context.Background() + db, err := database.Open(ctx, filepath.Join(t.TempDir(), "admin.db")) + if err != nil { + t.Fatalf("database.Open() error = %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + runner, err := migration.New(db) + if err != nil { + t.Fatalf("migration.New() error = %v", err) + } + if _, err := runner.Up(ctx); err != nil { + t.Fatalf("migration.Up() error = %v", err) + } + repositories, err := repository.New(db) + if err != nil { + t.Fatalf("repository.New() error = %v", err) + } + files, err := assetstore.New(filepath.Join(t.TempDir(), "assets")) + if err != nil { + t.Fatalf("assetstore.New() error = %v", err) + } + clock := usecase.SystemClock{} + ids := usecase.UUIDGenerator{} + assets, err := usecase.NewAssetService(repositories, files, clock, ids) + if err != nil { + t.Fatalf("usecase.NewAssetService() error = %v", err) + } + tasks, err := usecase.NewTaskService(repositories, clock, ids) + if err != nil { + t.Fatalf("usecase.NewTaskService() error = %v", err) + } + registrar, err := NewAdminRouteRegistrar( + AdminServices{Assets: assets, Tasks: tasks}, + emptyAdminWeb{}, + ) + if err != nil { + t.Fatalf("NewAdminRouteRegistrar() error = %v", err) + } + router, err := NewRouter(RouterDependencies{ + Database: db, + RegisterAdminRoutes: registrar, + LogEvent: discardEvent, + }) + if err != nil { + t.Fatalf("NewRouter() error = %v", err) + } + return router +} + +func referenceUpload(t *testing.T, key string) (io.Reader, string) { + t.Helper() + var imageBytes bytes.Buffer + source := image.NewRGBA(image.Rect(0, 0, 8, 6)) + for y := 0; y < 6; y++ { + for x := 0; x < 8; x++ { + source.Set(x, y, color.RGBA{R: uint8(x * 20), G: 80, B: 160, A: 255}) + } + } + if err := jpeg.Encode(&imageBytes, source, &jpeg.Options{Quality: 85}); err != nil { + t.Fatalf("jpeg.Encode() error = %v", err) + } + var body bytes.Buffer + writer := multipart.NewWriter(&body) + if err := writer.WriteField("purpose", "TASK_REFERENCE"); err != nil { + t.Fatalf("WriteField(purpose) error = %v", err) + } + if err := writer.WriteField("task_id", ""); err != nil { + t.Fatalf("WriteField(task_id) error = %v", err) + } + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", `form-data; name="file"; filename="`+key+`.jpg"`) + header.Set("Content-Type", "image/jpeg") + part, err := writer.CreatePart(header) + if err != nil { + t.Fatalf("CreatePart() error = %v", err) + } + if _, err := part.Write(imageBytes.Bytes()); err != nil { + t.Fatalf("part.Write() error = %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("multipart.Close() error = %v", err) + } + return bytes.NewReader(body.Bytes()), writer.FormDataContentType() +} + +func performAdminRequest( + t *testing.T, + router http.Handler, + method string, + target string, + contentType string, + body io.Reader, + idempotencyKey string, +) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(method, target, body) + request.RemoteAddr = "127.0.0.1:3210" + if contentType != "" { + request.Header.Set("Content-Type", contentType) + } + if idempotencyKey != "" { + request.Header.Set("Idempotency-Key", idempotencyKey) + } + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response +} + +func decodeResponse( + t *testing.T, + response *httptest.ResponseRecorder, + target any, +) { + t.Helper() + if err := json.Unmarshal(response.Body.Bytes(), target); err != nil { + t.Fatalf( + "json.Unmarshal() error = %v, body = %s", + err, + response.Body.String(), + ) + } +} + +func responseContainsKey(value any, key string) bool { + switch typed := value.(type) { + case map[string]any: + for candidate, child := range typed { + if candidate == key || responseContainsKey(child, key) { + return true + } + } + case []any: + for _, child := range typed { + if responseContainsKey(child, key) { + return true + } + } + } + return false +} diff --git a/backend-api/internal/transport/httpapi/router.go b/backend-api/internal/transport/httpapi/router.go index 05ce45a..cd827ec 100644 --- a/backend-api/internal/transport/httpapi/router.go +++ b/backend-api/internal/transport/httpapi/router.go @@ -19,26 +19,62 @@ type DatabasePinger interface { type EventLogger func(string) -func NewRouter( - database DatabasePinger, - logEvent EventLogger, -) (http.Handler, error) { - if database == nil { +type RouteRegistrar func(gin.IRoutes) error + +type RouterDependencies struct { + Database DatabasePinger + RegisterAdminRoutes RouteRegistrar + LogEvent EventLogger +} + +type AdminWeb interface { + Register(gin.IRoutes) +} + +func NewAdminRouteRegistrar( + services AdminServices, + web AdminWeb, +) (RouteRegistrar, error) { + if err := services.validate(); err != nil { + return nil, err + } + if web == nil { + return nil, errors.New("admin web handler is required") + } + return func(routes gin.IRoutes) error { + if err := registerAdminAPI(routes, services); err != nil { + return err + } + web.Register(routes) + return nil + }, nil +} + +func NewRouter(dependencies RouterDependencies) (http.Handler, error) { + if dependencies.Database == nil { return nil, errors.New("database pinger is required") } - if logEvent == nil { + if dependencies.RegisterAdminRoutes == nil { + return nil, errors.New("admin route registrar is required") + } + if dependencies.LogEvent == nil { return nil, errors.New("event logger is required") } gin.SetMode(gin.ReleaseMode) router := gin.New() router.Use(requestIDMiddleware()) - router.Use(safeRecovery(logEvent)) + router.Use(safeRecovery(dependencies.LogEvent)) router.HandleMethodNotAllowed = true if err := router.SetTrustedProxies(nil); err != nil { return nil, err } - router.GET("/healthz", healthHandler(database)) + router.GET("/healthz", healthHandler(dependencies.Database)) + adminRoutes := router.Group("") + adminRoutes.Use(loopbackAdminOnly()) + if err := dependencies.RegisterAdminRoutes(adminRoutes); err != nil { + return nil, err + } router.NoRoute(func(ctx *gin.Context) { ctx.JSON(http.StatusNotFound, errorResponse( ctx, diff --git a/backend-api/internal/transport/httpapi/router_test.go b/backend-api/internal/transport/httpapi/router_test.go index f8fb304..c1d8ac2 100644 --- a/backend-api/internal/transport/httpapi/router_test.go +++ b/backend-api/internal/transport/httpapi/router_test.go @@ -9,6 +9,8 @@ import ( "regexp" "strings" "testing" + + "github.com/gin-gonic/gin" ) type fakePinger struct { @@ -20,7 +22,7 @@ func (p fakePinger) PingContext(context.Context) error { } func TestHealthzReturnsStableHealthyResponse(t *testing.T) { - router, err := NewRouter(fakePinger{}, discardEvent) + router, err := newTestRouter(fakePinger{}, discardEvent) if err != nil { t.Fatalf("NewRouter() error = %v", err) } @@ -40,7 +42,7 @@ func TestHealthzReturnsStableHealthyResponse(t *testing.T) { } func TestHealthzReturns503WithoutLeakingDatabaseError(t *testing.T) { - router, err := NewRouter(fakePinger{ + router, err := newTestRouter(fakePinger{ err: errors.New("private database path and driver details"), }, discardEvent) if err != nil { @@ -59,7 +61,7 @@ func TestHealthzReturns503WithoutLeakingDatabaseError(t *testing.T) { } func TestUnknownRouteAndMethodUseStableErrors(t *testing.T) { - router, err := NewRouter(fakePinger{}, discardEvent) + router, err := newTestRouter(fakePinger{}, discardEvent) if err != nil { t.Fatalf("NewRouter() error = %v", err) } @@ -79,7 +81,7 @@ func TestUnknownRouteAndMethodUseStableErrors(t *testing.T) { func TestSafeRecoveryReturnsStableErrorWithoutLoggingRequestHeaders(t *testing.T) { var events []string - router, err := NewRouter( + router, err := newTestRouter( panicPinger{}, func(event string) { events = append(events, event) @@ -111,14 +113,39 @@ func TestSafeRecoveryReturnsStableErrorWithoutLoggingRequestHeaders(t *testing.T } func TestRouterRequiresDependencies(t *testing.T) { - if _, err := NewRouter(nil, discardEvent); err == nil { + valid := RouterDependencies{ + Database: fakePinger{}, + RegisterAdminRoutes: discardRoutes, + LogEvent: discardEvent, + } + missingDatabase := valid + missingDatabase.Database = nil + if _, err := NewRouter(missingDatabase); err == nil { t.Fatal("NewRouter(nil database) error = nil") } - if _, err := NewRouter(fakePinger{}, nil); err == nil { + missingRoutes := valid + missingRoutes.RegisterAdminRoutes = nil + if _, err := NewRouter(missingRoutes); err == nil { + t.Fatal("NewRouter(nil routes) error = nil") + } + missingLogger := valid + missingLogger.LogEvent = nil + if _, err := NewRouter(missingLogger); err == nil { t.Fatal("NewRouter(nil logger) error = nil") } } +func newTestRouter( + database DatabasePinger, + logEvent EventLogger, +) (http.Handler, error) { + return NewRouter(RouterDependencies{ + Database: database, + RegisterAdminRoutes: discardRoutes, + LogEvent: logEvent, + }) +} + func performRequest( t *testing.T, handler http.Handler, @@ -194,6 +221,8 @@ func (panicPinger) PingContext(context.Context) error { func discardEvent(string) {} +func discardRoutes(gin.IRoutes) error { return nil } + var requestIDPattern = regexp.MustCompile( `^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`, ) diff --git a/backend-api/internal/transport/webui/handler.go b/backend-api/internal/transport/webui/handler.go new file mode 100644 index 0000000..1028e39 --- /dev/null +++ b/backend-api/internal/transport/webui/handler.go @@ -0,0 +1,788 @@ +package webui + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "io" + "net/http" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/gin-gonic/gin" +) + +const ( + defaultListLimit = 20 + maxRequestBytes = 21 << 20 + maxTitleRunes = 120 + maxTitleBytes = 2048 + maxSKUBytes = 512 + maxDescriptionBytes = 8192 + csrfCookieName = "cmroubao_admin_csrf" + csrfFormField = "csrf_token" + formContentType = "text/html; charset=utf-8" + cssContentType = "text/css; charset=utf-8" + javascriptContentType = "text/javascript; charset=utf-8" +) + +type Handler struct { + service Service + renderer *Renderer +} + +func NewHandler(service Service, renderer *Renderer) (*Handler, error) { + if service == nil { + return nil, errors.New("admin web service is required") + } + if renderer == nil { + return nil, errors.New("admin web renderer is required") + } + return &Handler{ + service: service, + renderer: renderer, + }, nil +} + +func (h *Handler) Register(routes gin.IRoutes) { + routes.GET("/static/admin.css", SecurityHeaders(), h.Stylesheet) + routes.GET("/static/admin.js", SecurityHeaders(), h.Script) + routes.GET("/tasks", SecurityHeaders(), h.ListTasks) + routes.GET("/tasks/new", SecurityHeaders(), h.NewTask) + routes.POST("/tasks", SecurityHeaders(), h.CreateTask) + routes.GET("/tasks/:id", SecurityHeaders(), h.TaskDetail) + routes.POST("/tasks/:id/cancel", SecurityHeaders(), h.CancelTask) +} + +func SecurityHeaders() gin.HandlerFunc { + return func(ctx *gin.Context) { + ctx.Header( + "Content-Security-Policy", + "default-src 'none'; base-uri 'none'; connect-src 'self'; "+ + "form-action 'self'; frame-ancestors 'none'; img-src 'self' blob: data:; "+ + "script-src 'self'; style-src 'self'", + ) + ctx.Header("Cache-Control", "no-store") + ctx.Header("Referrer-Policy", "no-referrer") + ctx.Header("X-Content-Type-Options", "nosniff") + ctx.Header("X-Frame-Options", "DENY") + ctx.Next() + } +} + +func (h *Handler) Stylesheet(ctx *gin.Context) { + h.serveStatic(ctx, "admin.css", cssContentType) +} + +func (h *Handler) Script(ctx *gin.Context) { + h.serveStatic(ctx, "admin.js", javascriptContentType) +} + +func (h *Handler) serveStatic( + ctx *gin.Context, + name string, + contentType string, +) { + content, err := staticFile(name) + if err != nil { + ctx.Status(http.StatusNotFound) + return + } + ctx.Data(http.StatusOK, contentType, content) +} + +func (h *Handler) ListTasks(ctx *gin.Context) { + input := ListTasksInput{ + Query: strings.TrimSpace(ctx.Query("q")), + Status: strings.TrimSpace(ctx.Query("status")), + Cursor: strings.TrimSpace(ctx.Query("cursor")), + Limit: defaultListLimit, + } + result, err := h.service.ListTasks(ctx.Request.Context(), input) + if err != nil { + h.renderServiceError(ctx, err, "无法加载任务列表,请稍后重试。") + return + } + + items := make([]taskSummaryView, 0, len(result.Items)) + for _, item := range result.Items { + items = append(items, taskSummaryView{ + ID: item.ID, + Title: item.Title, + SKU: item.SKU, + Status: item.Status, + StatusLabel: statusLabel(item.Status), + StatusClass: statusClass(item.Status), + DeviceName: fallback(item.DeviceName, "尚未分配"), + UpdatedAt: item.UpdatedAt, + }) + } + statusOptions := newStatusOptions(input.Status) + page := tasksPage{ + Page: pageView{ + Title: "采购任务", + TasksCurrent: true, + }, + Query: input.Query, + Status: input.Status, + StatusOptions: statusOptions, + Items: items, + NextCursor: result.NextCursor, + } + h.render(ctx, http.StatusOK, "tasks", page) +} + +func (h *Handler) NewTask(ctx *gin.Context) { + token, err := h.csrfToken(ctx) + if err != nil { + h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。") + return + } + page, err := newTaskPage(token) + if err != nil { + h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。") + return + } + h.render(ctx, http.StatusOK, "task-new", page) +} + +func (h *Handler) CreateTask(ctx *gin.Context) { + ctx.Request.Body = http.MaxBytesReader( + ctx.Writer, + ctx.Request.Body, + maxRequestBytes, + ) + if err := ctx.Request.ParseMultipartForm(maxRequestBytes); err != nil { + page := h.createPageFromRequest(ctx) + page.Notice = "提交内容过大或格式不正确,请检查参考图片。" + page.Errors.Image = "请选择符合大小限制的 JPG、PNG 或 WebP 图片。" + h.render(ctx, http.StatusRequestEntityTooLarge, "task-new", page) + return + } + if ctx.Request.MultipartForm != nil { + defer ctx.Request.MultipartForm.RemoveAll() + } + if !h.validCSRF(ctx) { + h.renderError( + ctx, + http.StatusForbidden, + "请求已失效", + "请返回新建任务页面后重新提交。", + ) + return + } + + page := h.createPageFromRequest(ctx) + if validateCreateForm(&page) { + h.render(ctx, http.StatusUnprocessableEntity, "task-new", page) + return + } + + asset := UploadedAsset{ + ID: strings.TrimSpace(ctx.PostForm("image_asset_id")), + } + if asset.ID == "" { + uploaded, uploadErr := h.uploadReference(ctx, page.UploadKey) + if uploadErr != nil { + page.Notice = "参考图片未通过校验,请重新选择。" + page.Errors.Image = "图片格式、大小或内容不符合要求。" + status := http.StatusUnprocessableEntity + if !errors.Is(uploadErr, ErrInvalidFile) && + !errors.Is(uploadErr, ErrValidation) { + page.Notice = "参考图片上传失败,请稍后重试。" + status = http.StatusServiceUnavailable + } + h.render(ctx, status, "task-new", page) + return + } + asset = uploaded + page.UploadedAsset = uploadedAssetView{ + ID: uploaded.ID, + } + } + + task, err := h.service.CreateTask( + ctx.Request.Context(), + CreateTaskInput{ + IdempotencyKey: page.CreateKey, + Title: page.Form.Title, + SKU: page.Form.SKU, + Description: page.Form.Description, + Quantity: page.Form.QuantityValue, + MaxBudget: page.Form.MaxBudget, + ImageAssetID: asset.ID, + }, + ) + if err != nil { + page.UploadedAsset = uploadedAssetView{ + ID: asset.ID, + } + page.Notice = createErrorMessage(err) + status := serviceErrorStatus(err) + h.render(ctx, status, "task-new", page) + return + } + ctx.Redirect(http.StatusSeeOther, "/tasks/"+pathEscape(task.ID)) +} + +func (h *Handler) TaskDetail(ctx *gin.Context) { + task, err := h.service.GetTask( + ctx.Request.Context(), + strings.TrimSpace(ctx.Param("id")), + ) + if err != nil { + h.renderServiceError(ctx, err, "无法加载任务详情,请稍后重试。") + return + } + token, tokenErr := h.csrfToken(ctx) + if tokenErr != nil { + h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。") + return + } + cancelKey, keyErr := newToken() + if keyErr != nil { + h.renderError(ctx, http.StatusInternalServerError, "页面暂时无法打开", "请稍后重试。") + return + } + page := taskDetailPage{ + Page: pageView{ + Title: "任务详情", + TasksCurrent: true, + }, + Task: taskDetailViewFrom(task), + CSRFToken: token, + CancelKey: cancelKey, + Notice: detailNotice(ctx.Query("notice")), + } + h.render(ctx, http.StatusOK, "task-detail", page) +} + +func (h *Handler) CancelTask(ctx *gin.Context) { + if !h.validCSRF(ctx) { + h.renderError( + ctx, + http.StatusForbidden, + "请求已失效", + "请返回任务详情后重新操作。", + ) + return + } + taskID := strings.TrimSpace(ctx.Param("id")) + cancelKey := strings.TrimSpace(ctx.PostForm("cancel_key")) + if !validToken(cancelKey) { + h.renderError( + ctx, + http.StatusForbidden, + "请求已失效", + "请返回任务详情后重新操作。", + ) + return + } + _, err := h.service.CancelPending( + ctx.Request.Context(), + CancelPendingInput{ + TaskID: taskID, + IdempotencyKey: cancelKey, + }, + ) + if err != nil { + if errors.Is(err, ErrConflict) { + ctx.Redirect( + http.StatusSeeOther, + "/tasks/"+pathEscape(taskID)+"?notice=cancel-conflict", + ) + return + } + h.renderServiceError(ctx, err, "取消失败,请稍后重试。") + return + } + ctx.Redirect( + http.StatusSeeOther, + "/tasks/"+pathEscape(taskID)+"?notice=canceled", + ) +} + +func (h *Handler) uploadReference( + ctx *gin.Context, + idempotencyKey string, +) (UploadedAsset, error) { + file, header, err := ctx.Request.FormFile("image") + if err != nil { + return UploadedAsset{}, ErrInvalidFile + } + defer file.Close() + return h.service.UploadReference( + ctx.Request.Context(), + UploadReferenceInput{ + IdempotencyKey: idempotencyKey, + DeclaredType: header.Header.Get("Content-Type"), + DeclaredSize: header.Size, + Content: file, + }, + ) +} + +func (h *Handler) createPageFromRequest(ctx *gin.Context) newTaskPageView { + token, err := h.csrfToken(ctx) + if err != nil { + token = "" + } + quantityText := strings.TrimSpace(ctx.PostForm("quantity")) + quantity, _ := strconv.ParseInt(quantityText, 10, 64) + return newTaskPageView{ + Page: pageView{ + Title: "新建采购任务", + NewCurrent: true, + }, + CSRFToken: token, + UploadKey: strings.TrimSpace(ctx.PostForm("upload_key")), + CreateKey: strings.TrimSpace(ctx.PostForm("create_key")), + Form: createFormView{ + Title: strings.TrimSpace(ctx.PostForm("title")), + SKU: strings.TrimSpace(ctx.PostForm("sku")), + Description: strings.TrimSpace(ctx.PostForm("description")), + Quantity: quantityText, + QuantityValue: quantity, + MaxBudget: strings.TrimSpace(ctx.PostForm("max_budget")), + }, + } +} + +func validateCreateForm(page *newTaskPageView) bool { + if !validToken(page.UploadKey) { + page.Errors.Form = "提交标识缺失,请刷新页面后重试。" + } + if !validToken(page.CreateKey) { + page.Errors.Form = "提交标识缺失,请刷新页面后重试。" + } + if page.CSRFToken == "" { + page.Errors.Form = "页面已失效,请刷新后重试。" + } + if page.Form.Title == "" { + page.Errors.Title = "请输入商品标题。" + } else if !utf8.ValidString(page.Form.Title) || + utf8.RuneCountInString(page.Form.Title) > maxTitleRunes || + len([]byte(page.Form.Title)) > maxTitleBytes { + page.Errors.Title = "商品标题不能超过 120 个字符。" + } + if page.Form.SKU == "" { + page.Errors.SKU = "请输入 SKU。" + } else if !utf8.ValidString(page.Form.SKU) || + len([]byte(page.Form.SKU)) > maxSKUBytes { + page.Errors.SKU = "SKU 不能超过 512 个 UTF-8 字节。" + } + if !utf8.ValidString(page.Form.Description) || + len([]byte(page.Form.Description)) > maxDescriptionBytes { + page.Errors.Description = "商品描述不能超过 8192 个 UTF-8 字节。" + } + if page.Form.Quantity == "" || page.Form.QuantityValue <= 0 { + page.Errors.Quantity = "数量必须是大于 0 的整数。" + } else if strconv.FormatInt(page.Form.QuantityValue, 10) != page.Form.Quantity { + page.Errors.Quantity = "数量必须是大于 0 的整数。" + } + if !validBudget(page.Form.MaxBudget) { + page.Errors.MaxBudget = "最高总预算必须大于 0,且最多两位小数。" + } + if page.UploadedAsset.ID == "" { + // The file itself is validated by the asset service. This only gives + // immediate feedback for a completely missing multipart field. + } + if page.Errors.any() { + page.Notice = "请检查表单中的错误后再创建。" + return true + } + return false +} + +func validBudget(value string) bool { + if value == "" { + return true + } + if len(value) > 20 || strings.HasPrefix(value, "+") || + strings.HasPrefix(value, "-") { + return false + } + parts := strings.Split(value, ".") + if len(parts) > 2 || parts[0] == "" || len(parts[0]) > 16 { + return false + } + if len(parts) == 2 && (len(parts[1]) == 0 || len(parts[1]) > 2) { + return false + } + for _, part := range parts { + for _, character := range part { + if character < '0' || character > '9' { + return false + } + } + } + units, err := strconv.ParseUint(parts[0], 10, 64) + if err != nil { + return false + } + fraction := uint64(0) + if len(parts) == 2 { + fraction, err = strconv.ParseUint(parts[1], 10, 64) + if err != nil { + return false + } + if len(parts[1]) == 1 { + fraction *= 10 + } + } + return units > 0 || fraction > 0 +} + +func (h *Handler) csrfToken(ctx *gin.Context) (string, error) { + if cookie, err := ctx.Request.Cookie(csrfCookieName); err == nil && + validToken(cookie.Value) { + return cookie.Value, nil + } + token, err := newToken() + if err != nil { + return "", err + } + http.SetCookie(ctx.Writer, &http.Cookie{ + Name: csrfCookieName, + Value: token, + Path: "/tasks", + MaxAge: 3600, + HttpOnly: true, + Secure: ctx.Request.TLS != nil, + SameSite: http.SameSiteStrictMode, + }) + return token, nil +} + +func (h *Handler) validCSRF(ctx *gin.Context) bool { + cookie, err := ctx.Request.Cookie(csrfCookieName) + if err != nil || !validToken(cookie.Value) { + return false + } + formToken := strings.TrimSpace(ctx.PostForm(csrfFormField)) + if len(cookie.Value) != len(formToken) { + return false + } + return subtle.ConstantTimeCompare( + []byte(cookie.Value), + []byte(formToken), + ) == 1 +} + +func validToken(value string) bool { + decoded, err := base64.RawURLEncoding.DecodeString(value) + return err == nil && len(decoded) == 32 +} + +func newToken() (string, error) { + value := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, value); err != nil { + return "", errors.New("generate form token") + } + return base64.RawURLEncoding.EncodeToString(value), nil +} + +func newTaskPage(token string) (newTaskPageView, error) { + uploadKey, err := newToken() + if err != nil { + return newTaskPageView{}, err + } + createKey, err := newToken() + if err != nil { + return newTaskPageView{}, err + } + return newTaskPageView{ + Page: pageView{ + Title: "新建采购任务", + NewCurrent: true, + }, + CSRFToken: token, + UploadKey: uploadKey, + CreateKey: createKey, + Form: createFormView{ + Quantity: "1", + QuantityValue: 1, + }, + }, nil +} + +func (h *Handler) render( + ctx *gin.Context, + status int, + name string, + data any, +) { + var output bytes.Buffer + if err := h.renderer.Execute(&output, name, data); err != nil { + ctx.Data( + http.StatusInternalServerError, + formContentType, + []byte("页面暂时无法显示,请稍后重试。"), + ) + return + } + ctx.Data(status, formContentType, output.Bytes()) +} + +func (h *Handler) renderServiceError( + ctx *gin.Context, + err error, + fallbackMessage string, +) { + switch { + case errors.Is(err, ErrNotFound), errors.Is(err, ErrForbidden): + h.renderError( + ctx, + http.StatusNotFound, + "任务不存在", + "该任务不存在或当前不可访问。", + ) + case errors.Is(err, ErrValidation): + h.renderError(ctx, http.StatusBadRequest, "请求条件不正确", "请检查输入后重试。") + case errors.Is(err, ErrConflict): + h.renderError(ctx, http.StatusConflict, "任务状态已变化", "请返回任务列表刷新状态。") + case errors.Is(err, context.DeadlineExceeded): + h.renderError(ctx, http.StatusServiceUnavailable, "请求超时", "请稍后重试。") + case errors.Is(err, ErrUnavailable): + h.renderError(ctx, http.StatusServiceUnavailable, "服务暂时不可用", "请稍后重试。") + default: + h.renderError(ctx, http.StatusInternalServerError, "操作失败", fallbackMessage) + } +} + +func (h *Handler) renderError( + ctx *gin.Context, + status int, + title string, + message string, +) { + h.render(ctx, status, "error", errorPage{ + Page: pageView{ + Title: title, + }, + Heading: title, + Message: message, + }) +} + +func serviceErrorStatus(err error) int { + switch { + case errors.Is(err, ErrValidation), errors.Is(err, ErrInvalidFile): + return http.StatusUnprocessableEntity + case errors.Is(err, ErrConflict): + return http.StatusConflict + case errors.Is(err, context.DeadlineExceeded): + return http.StatusServiceUnavailable + case errors.Is(err, ErrUnavailable): + return http.StatusServiceUnavailable + default: + return http.StatusInternalServerError + } +} + +func createErrorMessage(err error) string { + switch { + case errors.Is(err, ErrValidation): + return "任务内容未通过校验,请检查后重试。已上传的参考图片会被复用。" + case errors.Is(err, ErrConflict): + return "提交标识与原请求不一致,请刷新页面后重试。已上传的参考图片会被保留。" + case errors.Is(err, context.DeadlineExceeded): + return "创建结果暂时无法确认。请保留当前页面并使用相同提交标识重试。" + case errors.Is(err, ErrUnavailable): + return "服务暂时不可用。已上传的参考图片会被保留,请稍后重试。" + default: + return "任务创建失败,请稍后重试。已上传的参考图片会被复用。" + } +} + +func detailNotice(value string) string { + switch value { + case "canceled": + return "任务已取消,不会自动恢复。" + case "cancel-conflict": + return "任务状态已变化,当前不能取消。" + default: + return "" + } +} + +func fallback(value string, fallbackValue string) string { + if strings.TrimSpace(value) == "" { + return fallbackValue + } + return value +} + +type pageView struct { + Title string + TasksCurrent bool + NewCurrent bool +} + +type statusOption struct { + Value string + Label string + Selected bool +} + +type tasksPage struct { + Page pageView + Query string + Status string + StatusOptions []statusOption + Items []taskSummaryView + NextCursor string +} + +type taskSummaryView struct { + ID string + Title string + SKU string + Status string + StatusLabel string + StatusClass string + DeviceName string + UpdatedAt time.Time +} + +type createFormView struct { + Title string + SKU string + Description string + Quantity string + QuantityValue int64 + MaxBudget string +} + +type createFormErrors struct { + Form string + Title string + SKU string + Description string + Quantity string + MaxBudget string + Image string +} + +func (errors createFormErrors) any() bool { + return errors.Form != "" || + errors.Title != "" || + errors.SKU != "" || + errors.Description != "" || + errors.Quantity != "" || + errors.MaxBudget != "" || + errors.Image != "" +} + +type uploadedAssetView struct { + ID string +} + +type newTaskPageView struct { + Page pageView + CSRFToken string + UploadKey string + CreateKey string + Form createFormView + Errors createFormErrors + Notice string + UploadedAsset uploadedAssetView +} + +type taskDetailView struct { + ID string + Title string + SKU string + Description string + Quantity int64 + MaxBudget string + Status string + StatusLabel string + StatusClass string + ReferenceAssetID string + CreatedAt time.Time + UpdatedAt time.Time + CanCancel bool +} + +type taskDetailPage struct { + Page pageView + Task taskDetailView + CSRFToken string + CancelKey string + Notice string +} + +type errorPage struct { + Page pageView + Heading string + Message string +} + +func taskDetailViewFrom(task Task) taskDetailView { + return taskDetailView{ + ID: task.ID, + Title: task.Title, + SKU: task.SKU, + Description: task.Description, + Quantity: task.Quantity, + MaxBudget: task.MaxBudget, + Status: task.Status, + StatusLabel: statusLabel(task.Status), + StatusClass: statusClass(task.Status), + ReferenceAssetID: task.ReferenceAssetID, + CreatedAt: task.CreatedAt, + UpdatedAt: task.UpdatedAt, + CanCancel: task.Status == "PENDING", + } +} + +func newStatusOptions(selected string) []statusOption { + values := []statusOption{ + {Label: "全部状态"}, + {Value: "PENDING", Label: "待领取"}, + {Value: "CLAIMED", Label: "已领取"}, + {Value: "RUNNING", Label: "执行中"}, + {Value: "WAITING_CONFIRMATION", Label: "等待人工确认"}, + {Value: "SUCCEEDED", Label: "验证完成"}, + {Value: "FAILED", Label: "失败"}, + {Value: "CANCELED", Label: "已取消"}, + } + for index := range values { + values[index].Selected = values[index].Value == selected + } + return values +} + +func statusLabel(status string) string { + labels := map[string]string{ + "PENDING": "待领取", + "CLAIMED": "已领取", + "RUNNING": "执行中", + "WAITING_CONFIRMATION": "等待人工确认", + "SUCCEEDED": "验证完成", + "FAILED": "失败", + "CANCELED": "已取消", + } + return fallback(labels[status], "未知状态") +} + +func statusClass(status string) string { + switch status { + case "PENDING", "CLAIMED", "WAITING_CONFIRMATION": + return "status-warn" + case "RUNNING": + return "status-info" + case "SUCCEEDED": + return "status-success" + case "FAILED", "CANCELED": + return "status-danger" + default: + return "status-neutral" + } +} diff --git a/backend-api/internal/transport/webui/handler_test.go b/backend-api/internal/transport/webui/handler_test.go new file mode 100644 index 0000000..e10078f --- /dev/null +++ b/backend-api/internal/transport/webui/handler_test.go @@ -0,0 +1,792 @@ +package webui + +import ( + "bytes" + "context" + "errors" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" + "time" + + "cmroubao/backend-api/internal/usecase" + + "github.com/gin-gonic/gin" +) + +const testTaskID = "00000000-0000-4000-8000-000000000001" + +func TestListTasksRendersRealRowsEscapedWithSecurityHeaders(t *testing.T) { + now := time.Date(2026, 7, 26, 3, 4, 5, 0, time.UTC) + service := &fakeService{ + listResult: TaskList{Items: []TaskSummary{{ + ID: testTaskID, + Title: ``, + SKU: "SKU-1", + Status: "PENDING", + UpdatedAt: now, + }}}, + } + router := newTestRouter(t, service) + + response := performRequest( + t, + router, + http.MethodGet, + "/tasks?q=%3Cquery%3E&status=PENDING", + nil, + "", + ) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body) + } + body := response.Body.String() + if strings.Contains(body, ``) || + !strings.Contains(body, "<script>") { + t.Fatalf("task title was not safely escaped: %s", body) + } + for _, text := range []string{ + "SKU-1", + "待领取", + "/tasks/" + testTaskID, + "value=\"<query>\"", + } { + if !strings.Contains(body, text) { + t.Fatalf("body does not contain %q", text) + } + } + assertSecurityHeaders(t, response) + if service.listInput.Query != "" || + service.listInput.Status != "PENDING" || + service.listInput.Limit != defaultListLimit { + t.Fatalf("list input = %+v", service.listInput) + } +} + +func TestListTasksRendersHonestEmptyState(t *testing.T) { + router := newTestRouter(t, &fakeService{}) + response := performRequest( + t, + router, + http.MethodGet, + "/tasks", + nil, + "", + ) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d", response.Code) + } + body := response.Body.String() + if !strings.Contains(body, "没有符合条件的任务") || + !strings.Contains(body, "创建第一条采购任务") { + t.Fatalf("empty state missing: %s", body) + } + for _, fake := range []string{"RB-DEMO", "演示设备", "演示任务"} { + if strings.Contains(body, fake) { + t.Fatalf("empty page contains fake data %q", fake) + } + } +} + +func TestNewTaskIssuesReusableStrictCSRFCookie(t *testing.T) { + router := newTestRouter(t, &fakeService{}) + response := performRequest( + t, + router, + http.MethodGet, + "/tasks/new", + nil, + "", + ) + + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body) + } + cookie := csrfCookie(t, response) + if !cookie.HttpOnly || + cookie.SameSite != http.SameSiteStrictMode || + cookie.Path != "/tasks" { + t.Fatalf("CSRF cookie = %+v", cookie) + } + body := response.Body.String() + if !strings.Contains( + body, + `name="csrf_token" value="`+cookie.Value+`"`, + ) { + t.Fatal("form CSRF token does not match the cookie") + } + for _, required := range []string{ + `name="title"`, + `name="sku"`, + `name="quantity"`, + `name="max_budget"`, + `name="image"`, + "最高总预算", + } { + if !strings.Contains(body, required) { + t.Fatalf("new task form missing %q", required) + } + } + if strings.Contains(body, "") { + t.Fatal("page contains inline style or script incompatible with CSP") + } + assertSecurityHeaders(t, response) +} + +func TestCreateTaskRejectsCSRFBeforeCallingService(t *testing.T) { + service := &fakeService{} + router := newTestRouter(t, service) + body, contentType := multipartBody(t, map[string]string{ + "csrf_token": "invalid", + "title": "标题", + "sku": "SKU-1", + "quantity": "1", + "upload_key": mustToken(t), + "create_key": mustToken(t), + }, "image", "reference.jpg", []byte("not inspected")) + + response := performRequest( + t, + router, + http.MethodPost, + "/tasks", + body, + contentType, + ) + + if response.Code != http.StatusForbidden { + t.Fatalf("status = %d, body = %s", response.Code, response.Body) + } + if service.uploadCalls != 0 || service.createCalls != 0 { + t.Fatalf( + "service calls = upload %d, create %d", + service.uploadCalls, + service.createCalls, + ) + } +} + +func TestCreateTaskValidationRetainsEscapedSafeFields(t *testing.T) { + service := &fakeService{} + router := newTestRouter(t, service) + cookie := getCSRFCookie(t, router) + body, contentType := multipartBody(t, map[string]string{ + "csrf_token": cookie.Value, + "title": "", + "sku": "", + "description": ``, + "quantity": "0", + "max_budget": "1.001", + "upload_key": mustToken(t), + "create_key": mustToken(t), + }, "", "", nil) + request := httptest.NewRequest(http.MethodPost, "/tasks", body) + request.Header.Set("Content-Type", contentType) + request.AddCookie(cookie) + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusUnprocessableEntity { + t.Fatalf("status = %d, body = %s", response.Code, response.Body) + } + rendered := response.Body.String() + for _, message := range []string{ + "请输入商品标题", + "请输入 SKU", + "数量必须是大于 0 的整数", + "最多两位小数", + "<img src=x onerror="alert(1)">", + } { + if !strings.Contains(rendered, message) { + t.Fatalf("response missing %q", message) + } + } + if strings.Contains(rendered, ``) { + t.Fatal("description was rendered as active HTML") + } + if service.uploadCalls != 0 || service.createCalls != 0 { + t.Fatal("invalid form reached the service") + } +} + +func TestCreateValidationUsesContractUTF8ByteLimits(t *testing.T) { + valid := func() newTaskPageView { + token := mustToken(t) + return newTaskPageView{ + CSRFToken: token, + UploadKey: mustToken(t), + CreateKey: mustToken(t), + Form: createFormView{ + Title: "标题", + SKU: "SKU-1", + Quantity: "1", + QuantityValue: 1, + }, + } + } + + skuPage := valid() + skuPage.Form.SKU = strings.Repeat("货", maxSKUBytes/3+1) + if !validateCreateForm(&skuPage) || + !strings.Contains(skuPage.Errors.SKU, "512") { + t.Fatalf("SKU errors = %+v", skuPage.Errors) + } + + descriptionPage := valid() + descriptionPage.Form.Description = strings.Repeat( + "说", + maxDescriptionBytes/3+1, + ) + if !validateCreateForm(&descriptionPage) || + !strings.Contains(descriptionPage.Errors.Description, "8192") { + t.Fatalf("description errors = %+v", descriptionPage.Errors) + } + + titlePage := valid() + titlePage.Form.Title = strings.Repeat("题", maxTitleRunes+1) + if !validateCreateForm(&titlePage) || + !strings.Contains(titlePage.Errors.Title, "120") { + t.Fatalf("title errors = %+v", titlePage.Errors) + } + + if maxRequestBytes < (20<<20)+(1<<20) { + t.Fatalf("maxRequestBytes = %d, does not cover a 20 MiB image", maxRequestBytes) + } +} + +func TestCreateTaskUploadsThenRedirectsWithPRG(t *testing.T) { + service := &fakeService{ + uploadResult: UploadedAsset{ + ID: "00000000-0000-4000-8000-000000000099", + }, + createResult: Task{ID: testTaskID}, + } + router := newTestRouter(t, service) + cookie := getCSRFCookie(t, router) + body, contentType := multipartBody(t, map[string]string{ + "csrf_token": cookie.Value, + "title": " 桌面收纳盒 ", + "sku": " SKU-1 ", + "description": "浅灰色", + "quantity": "2", + "max_budget": "60.00", + "upload_key": mustToken(t), + "create_key": mustToken(t), + }, "image", "reference.jpg", []byte("image bytes")) + request := httptest.NewRequest(http.MethodPost, "/tasks", body) + request.Header.Set("Content-Type", contentType) + request.AddCookie(cookie) + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusSeeOther || + response.Header().Get("Location") != "/tasks/"+testTaskID { + t.Fatalf( + "status/location = %d/%q, body = %s", + response.Code, + response.Header().Get("Location"), + response.Body, + ) + } + if service.uploadCalls != 1 || service.createCalls != 1 { + t.Fatalf( + "service calls = upload %d, create %d", + service.uploadCalls, + service.createCalls, + ) + } + if string(service.uploadBody) != "image bytes" || + service.createInput.Title != "桌面收纳盒" || + service.createInput.SKU != "SKU-1" || + service.createInput.Quantity != 2 || + service.createInput.MaxBudget != "60.00" || + service.createInput.ImageAssetID != service.uploadResult.ID { + t.Fatalf( + "upload/create input = %q / %+v", + service.uploadBody, + service.createInput, + ) + } +} + +func TestCreateTaskFailureRetainsUploadedAssetForRetry(t *testing.T) { + assetID := "00000000-0000-4000-8000-000000000099" + service := &fakeService{ + uploadResult: UploadedAsset{ + ID: assetID, + }, + createErr: ErrUnavailable, + } + router := newTestRouter(t, service) + cookie := getCSRFCookie(t, router) + body, contentType := validCreateBody(t, cookie.Value, nil) + request := httptest.NewRequest(http.MethodPost, "/tasks", body) + request.Header.Set("Content-Type", contentType) + request.AddCookie(cookie) + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, body = %s", response.Code, response.Body) + } + rendered := response.Body.String() + for _, value := range []string{ + `name="image_asset_id" value="` + assetID + `"`, + "再次提交会复用该图片", + } { + if !strings.Contains(rendered, value) { + t.Fatalf("response missing %q", value) + } + } + if strings.Contains(rendered, "reference.jpg") { + t.Fatal("server response leaked the client file name") + } +} + +func TestCreateTaskRetryReusesAssetWithoutUpload(t *testing.T) { + assetID := "00000000-0000-4000-8000-000000000099" + service := &fakeService{ + createResult: Task{ID: testTaskID}, + } + router := newTestRouter(t, service) + cookie := getCSRFCookie(t, router) + body, contentType := validCreateBody(t, cookie.Value, map[string]string{ + "image_asset_id": assetID, + }) + request := httptest.NewRequest(http.MethodPost, "/tasks", body) + request.Header.Set("Content-Type", contentType) + request.AddCookie(cookie) + response := httptest.NewRecorder() + + router.ServeHTTP(response, request) + + if response.Code != http.StatusSeeOther { + t.Fatalf("status = %d, body = %s", response.Code, response.Body) + } + if service.uploadCalls != 0 || + service.createInput.ImageAssetID != assetID { + t.Fatalf( + "upload calls / asset = %d / %q", + service.uploadCalls, + service.createInput.ImageAssetID, + ) + } +} + +func TestTaskDetailPendingCancelUsesCSRFAndPRG(t *testing.T) { + service := &fakeService{ + getResult: Task{ + ID: testTaskID, + Title: "桌面收纳盒", + SKU: "SKU-1", + Description: "浅灰色", + Quantity: 2, + MaxBudget: "60.00", + Status: "PENDING", + ReferenceAssetID: "00000000-0000-4000-8000-000000000009", + CreatedAt: time.Date(2026, 7, 26, 3, 4, 5, 0, time.UTC), + UpdatedAt: time.Date(2026, 7, 26, 3, 5, 5, 0, time.UTC), + }, + } + router := newTestRouter(t, service) + detail := performRequest( + t, + router, + http.MethodGet, + "/tasks/"+testTaskID, + nil, + "", + ) + if detail.Code != http.StatusOK { + t.Fatalf("detail status = %d, body = %s", detail.Code, detail.Body) + } + cookie := csrfCookie(t, detail) + cancelKey := hiddenValue(t, detail.Body.String(), "cancel_key") + for _, value := range []string{ + "桌面收纳盒", + "最高总预算", + "/api/v1/assets/00000000-0000-4000-8000-000000000009/content", + "取消任务", + "确认取消任务", + } { + if !strings.Contains(detail.Body.String(), value) { + t.Fatalf("detail missing %q", value) + } + } + + form := url.Values{ + "csrf_token": {cookie.Value}, + "cancel_key": {cancelKey}, + } + request := httptest.NewRequest( + http.MethodPost, + "/tasks/"+testTaskID+"/cancel", + strings.NewReader(form.Encode()), + ) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.AddCookie(cookie) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + + if response.Code != http.StatusSeeOther || + response.Header().Get("Location") != + "/tasks/"+testTaskID+"?notice=canceled" { + t.Fatalf( + "status/location = %d/%q", + response.Code, + response.Header().Get("Location"), + ) + } + if service.cancelInput.TaskID != testTaskID || + service.cancelInput.IdempotencyKey != cancelKey { + t.Fatalf("cancel input = %+v", service.cancelInput) + } +} + +func TestTaskDetailDoesNotLeakForbiddenResource(t *testing.T) { + service := &fakeService{getErr: ErrForbidden} + router := newTestRouter(t, service) + response := performRequest( + t, + router, + http.MethodGet, + "/tasks/"+testTaskID, + nil, + "", + ) + + if response.Code != http.StatusNotFound { + t.Fatalf("status = %d", response.Code) + } + if !strings.Contains(response.Body.String(), "不存在或当前不可访问") { + t.Fatalf("safe not-found message missing: %s", response.Body) + } +} + +func TestStaticFilesAreEmbeddedAndProtected(t *testing.T) { + router := newTestRouter(t, &fakeService{}) + for _, route := range []string{"/static/admin.css", "/static/admin.js"} { + response := performRequest( + t, + router, + http.MethodGet, + route, + nil, + "", + ) + if response.Code != http.StatusOK || response.Body.Len() == 0 { + t.Fatalf("%s status/bytes = %d/%d", route, response.Code, response.Body.Len()) + } + assertSecurityHeaders(t, response) + } +} + +func TestRendererUsesMissingKeyErrors(t *testing.T) { + renderer, err := NewRenderer() + if err != nil { + t.Fatalf("NewRenderer() error = %v", err) + } + var output bytes.Buffer + if err := renderer.Execute(&output, "tasks", struct{}{}); err == nil { + t.Fatal("Execute() with incomplete data error = nil") + } +} + +type fakeService struct { + listInput ListTasksInput + listResult TaskList + listErr error + getResult Task + getErr error + uploadResult UploadedAsset + uploadErr error + uploadInput UploadReferenceInput + uploadBody []byte + uploadCalls int + createResult Task + createErr error + createInput CreateTaskInput + createCalls int + cancelResult Task + cancelErr error + cancelInput CancelPendingInput +} + +func (service *fakeService) ListTasks( + _ context.Context, + input ListTasksInput, +) (TaskList, error) { + service.listInput = input + return service.listResult, service.listErr +} + +func (service *fakeService) GetTask( + context.Context, + string, +) (Task, error) { + return service.getResult, service.getErr +} + +func (service *fakeService) UploadReference( + _ context.Context, + input UploadReferenceInput, +) (UploadedAsset, error) { + service.uploadCalls++ + service.uploadInput = input + content, err := io.ReadAll(input.Content) + if err != nil { + return UploadedAsset{}, err + } + service.uploadBody = content + return service.uploadResult, service.uploadErr +} + +func (service *fakeService) CreateTask( + _ context.Context, + input CreateTaskInput, +) (Task, error) { + service.createCalls++ + service.createInput = input + return service.createResult, service.createErr +} + +func (service *fakeService) CancelPending( + _ context.Context, + input CancelPendingInput, +) (Task, error) { + service.cancelInput = input + return service.cancelResult, service.cancelErr +} + +func newTestRouter(t *testing.T, service Service) http.Handler { + t.Helper() + gin.SetMode(gin.TestMode) + renderer, err := NewRenderer() + if err != nil { + t.Fatalf("NewRenderer() error = %v", err) + } + handler, err := NewHandler(service, renderer) + if err != nil { + t.Fatalf("NewHandler() error = %v", err) + } + router := gin.New() + handler.Register(router) + return router +} + +func performRequest( + t *testing.T, + handler http.Handler, + method string, + path string, + body io.Reader, + contentType string, +) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(method, path, body) + if contentType != "" { + request.Header.Set("Content-Type", contentType) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + return response +} + +func getCSRFCookie(t *testing.T, handler http.Handler) *http.Cookie { + t.Helper() + response := performRequest( + t, + handler, + http.MethodGet, + "/tasks/new", + nil, + "", + ) + return csrfCookie(t, response) +} + +func csrfCookie( + t *testing.T, + response *httptest.ResponseRecorder, +) *http.Cookie { + t.Helper() + for _, cookie := range response.Result().Cookies() { + if cookie.Name == csrfCookieName { + return cookie + } + } + t.Fatal("CSRF cookie not found") + return nil +} + +func multipartBody( + t *testing.T, + fields map[string]string, + fileField string, + fileName string, + content []byte, +) (*bytes.Buffer, string) { + t.Helper() + var body bytes.Buffer + writer := multipart.NewWriter(&body) + for name, value := range fields { + if err := writer.WriteField(name, value); err != nil { + t.Fatalf("WriteField(%s): %v", name, err) + } + } + if fileField != "" { + part, err := writer.CreateFormFile(fileField, fileName) + if err != nil { + t.Fatalf("CreateFormFile(): %v", err) + } + if _, err := part.Write(content); err != nil { + t.Fatalf("write file: %v", err) + } + } + if err := writer.Close(); err != nil { + t.Fatalf("close multipart: %v", err) + } + return &body, writer.FormDataContentType() +} + +func validCreateBody( + t *testing.T, + csrfToken string, + overrides map[string]string, +) (*bytes.Buffer, string) { + t.Helper() + fields := map[string]string{ + "csrf_token": csrfToken, + "title": "桌面收纳盒", + "sku": "SKU-1", + "description": "浅灰色", + "quantity": "2", + "max_budget": "60.00", + "upload_key": mustToken(t), + "create_key": mustToken(t), + } + for name, value := range overrides { + fields[name] = value + } + fileField := "image" + fileName := "reference.jpg" + content := []byte("image bytes") + if fields["image_asset_id"] != "" { + fileField = "" + fileName = "" + content = nil + } + return multipartBody(t, fields, fileField, fileName, content) +} + +func mustToken(t *testing.T) string { + t.Helper() + token, err := newToken() + if err != nil { + t.Fatalf("newToken() error = %v", err) + } + return token +} + +func hiddenValue(t *testing.T, body string, name string) string { + t.Helper() + pattern := regexp.MustCompile( + `name="` + regexp.QuoteMeta(name) + `" value="([^"]+)"`, + ) + match := pattern.FindStringSubmatch(body) + if len(match) != 2 { + t.Fatalf("hidden field %q not found", name) + } + return match[1] +} + +func assertSecurityHeaders( + t *testing.T, + response *httptest.ResponseRecorder, +) { + t.Helper() + required := map[string]string{ + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + } + for name, want := range required { + if got := response.Header().Get(name); got != want { + t.Fatalf("%s = %q, want %q", name, got, want) + } + } + csp := response.Header().Get("Content-Security-Policy") + for _, directive := range []string{ + "default-src 'none'", + "form-action 'self'", + "frame-ancestors 'none'", + "script-src 'self'", + "style-src 'self'", + } { + if !strings.Contains(csp, directive) { + t.Fatalf("CSP missing %q: %s", directive, csp) + } + } +} + +func TestUsecaseErrorMappingKeepsPublicSentinels(t *testing.T) { + tests := []struct { + kind usecase.ErrorKind + code string + want error + }{ + { + kind: usecase.ErrorKindInvalid, + code: "ASSET_IMAGE_INVALID", + want: ErrInvalidFile, + }, + { + kind: usecase.ErrorKindInvalid, + code: "TASK_VALIDATION_FAILED", + want: ErrValidation, + }, + { + kind: usecase.ErrorKindInvalid, + code: "TASK_CANCEL_INVALID", + want: ErrNotFound, + }, + { + kind: usecase.ErrorKindNotFound, + code: "TASK_NOT_FOUND", + want: ErrNotFound, + }, + { + kind: usecase.ErrorKindConflict, + code: "TASK_STATE_CONFLICT", + want: ErrConflict, + }, + { + kind: usecase.ErrorKindUnavailable, + code: "STORAGE_UNAVAILABLE", + want: ErrUnavailable, + }, + } + for _, test := range tests { + t.Run(test.code, func(t *testing.T) { + mapped := mapUsecaseError(&usecase.Error{ + Kind: test.kind, + Code: test.code, + }) + if !errors.Is(mapped, test.want) { + t.Fatalf("mapped error = %v, want %v", mapped, test.want) + } + }) + } +} diff --git a/backend-api/internal/transport/webui/renderer.go b/backend-api/internal/transport/webui/renderer.go new file mode 100644 index 0000000..48cc9b8 --- /dev/null +++ b/backend-api/internal/transport/webui/renderer.go @@ -0,0 +1,85 @@ +package webui + +import ( + "embed" + "errors" + "html/template" + "io" + "path" + "strings" + "time" +) + +//go:embed templates/*.gohtml static/admin.css static/admin.js +var embeddedFiles embed.FS + +type Renderer struct { + templates *template.Template +} + +func NewRenderer() (*Renderer, error) { + templates, err := template.New("admin"). + Option("missingkey=error"). + Funcs(template.FuncMap{ + "displayTime": displayTime, + "machineTime": machineTime, + "pathPart": pathPart, + }). + ParseFS(embeddedFiles, "templates/*.gohtml") + if err != nil { + return nil, errors.New("parse admin templates") + } + return &Renderer{templates: templates}, nil +} + +func (r *Renderer) Execute( + writer io.Writer, + name string, + data any, +) error { + if r == nil || r.templates == nil { + return errors.New("admin renderer is not configured") + } + if err := r.templates.ExecuteTemplate(writer, name, data); err != nil { + return errors.New("render admin template") + } + return nil +} + +func staticFile(name string) ([]byte, error) { + if name != "admin.css" && name != "admin.js" { + return nil, errors.New("unknown admin static file") + } + content, err := embeddedFiles.ReadFile("static/" + name) + if err != nil { + return nil, errors.New("read admin static file") + } + return content, nil +} + +func displayTime(value time.Time) string { + if value.IsZero() { + return "尚未记录" + } + return value.Local().Format("2006-01-02 15:04") +} + +func machineTime(value time.Time) string { + if value.IsZero() { + return "" + } + return value.UTC().Format(time.RFC3339) +} + +func pathPart(value string) string { + return pathEscape(strings.TrimSpace(value)) +} + +func pathEscape(value string) string { + // Keep IDs as one opaque path component. + if value == "" || value == "." || value == ".." || + path.Base(value) != value || strings.ContainsAny(value, `/\`) { + return "invalid" + } + return value +} diff --git a/backend-api/internal/transport/webui/static/admin.css b/backend-api/internal/transport/webui/static/admin.css new file mode 100644 index 0000000..166b5fb --- /dev/null +++ b/backend-api/internal/transport/webui/static/admin.css @@ -0,0 +1,863 @@ +* { + box-sizing: border-box; +} + +:root { + color-scheme: light; + --canvas: #f3f5f4; + --surface: #ffffff; + --surface-soft: #f8faf9; + --ink: #18201d; + --muted: #5b6862; + --line: #cfd7d3; + --line-strong: #9eaaa4; + --brand: #0b6b50; + --brand-dark: #07513d; + --danger: #9b3030; + --danger-dark: #762222; + --danger-soft: #fff1f1; + --info: #185b8f; + --info-soft: #eef7fd; + --warn: #76520d; + --warn-soft: #fff8e5; + --success: #07513d; + --success-soft: #eaf7f1; + --focus: #1a73b8; +} + +html { + min-width: 280px; +} + +body { + margin: 0; + min-width: 280px; + color: var(--ink); + background: var(--canvas); + font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; + letter-spacing: 0; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button, +input, +select { + min-height: 44px; +} + +a, +button, +input, +select, +textarea, +summary { + outline-offset: 2px; +} + +a:focus-visible, +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +summary:focus-visible { + outline: 3px solid rgba(26, 115, 184, 0.3); +} + +a { + color: var(--brand-dark); +} + +.skip-link { + position: fixed; + top: 8px; + left: 8px; + z-index: 100; + padding: 8px 12px; + color: #fff; + background: #18201d; + transform: translateY(-150%); +} + +.skip-link:focus { + transform: translateY(0); +} + +.site-header { + min-height: 60px; + display: flex; + align-items: center; + gap: 28px; + padding: 0 clamp(16px, 4vw, 40px); + border-bottom: 1px solid var(--line); + background: var(--surface); +} + +.brand { + display: inline-flex; + min-height: 44px; + align-items: center; + gap: 10px; + color: var(--ink); + text-decoration: none; + font-size: 16px; + font-weight: 750; + white-space: nowrap; +} + +.brand-mark { + width: 28px; + height: 28px; + display: grid; + flex: 0 0 auto; + place-items: center; + border-radius: 5px; + color: #fff; + background: var(--brand); + font-size: 13px; +} + +.main-nav { + align-self: stretch; + display: flex; + gap: 4px; +} + +.main-nav a { + min-height: 44px; + display: grid; + place-items: center; + padding: 0 13px; + border-bottom: 3px solid transparent; + color: #3d4b45; + text-decoration: none; +} + +.main-nav a[aria-current="page"] { + border-color: var(--brand); + color: var(--brand-dark); + font-weight: 700; +} + +.page { + width: min(calc(100% - 32px), 1180px); + margin: 0 auto; + padding: 28px 0 60px; +} + +.narrow-page { + max-width: 720px; +} + +.title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-bottom: 20px; +} + +h1, +h2, +p { + overflow-wrap: anywhere; +} + +h1 { + margin: 0; + font-size: 26px; + line-height: 1.3; +} + +h2 { + margin: 0 0 8px; + font-size: 18px; + line-height: 1.35; +} + +.subtitle, +.section-note, +.secondary, +.field-help { + color: var(--muted); +} + +.subtitle { + margin: 4px 0 0; +} + +.button { + min-height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 14px; + border: 1px solid var(--line-strong); + border-radius: 5px; + color: #26352f; + background: var(--surface); + cursor: pointer; + text-decoration: none; + font-weight: 650; +} + +.button:hover { + border-color: #68766f; + background: var(--surface-soft); +} + +.button:disabled { + cursor: wait; + opacity: 0.68; +} + +.button.primary { + border-color: var(--brand); + color: #fff; + background: var(--brand); +} + +.button.primary:hover { + background: var(--brand-dark); +} + +.button.danger { + border-color: var(--danger); + color: #fff; + background: var(--danger); +} + +.button.danger:hover { + background: var(--danger-dark); +} + +.button.danger-outline { + border-color: #c78484; + color: var(--danger-dark); +} + +.filters { + display: grid; + grid-template-columns: minmax(200px, 1fr) 220px auto; + align-items: end; + gap: 12px; + padding: 16px; + border: 1px solid var(--line); + border-bottom: 0; + border-radius: 8px 8px 0 0; + background: var(--surface); +} + +.filter-button { + min-width: 112px; +} + +.field label { + display: block; + margin-bottom: 6px; + font-weight: 700; +} + +.field.compact label { + color: #45534d; + font-size: 13px; +} + +input, +select, +textarea { + width: 100%; + padding: 9px 11px; + border: 1px solid var(--line-strong); + border-radius: 5px; + color: var(--ink); + background: var(--surface); +} + +textarea { + min-height: 112px; + resize: vertical; +} + +input[aria-invalid="true"], +textarea[aria-invalid="true"], +select[aria-invalid="true"] { + border-color: var(--danger); + box-shadow: inset 0 0 0 1px var(--danger); +} + +.table-region { + border: 1px solid var(--line); + border-radius: 0 0 8px 8px; + background: var(--surface); + overflow: hidden; +} + +table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +th, +td { + padding: 13px 12px; + border-bottom: 1px solid #e1e6e3; + text-align: left; + vertical-align: middle; + overflow-wrap: anywhere; +} + +th { + color: #45534d; + background: var(--surface-soft); + font-size: 13px; + font-weight: 700; +} + +th:first-child { + width: 32%; +} + +th:nth-child(2) { + width: 16%; +} + +th:nth-child(3) { + width: 16%; +} + +th:nth-child(4) { + width: 22%; +} + +th:last-child { + width: 14%; +} + +tbody tr:hover { + background: #f8fbfa; +} + +tbody tr:last-child td { + border-bottom: 0; +} + +.task-title, +.secondary { + display: block; +} + +.task-title { + margin-bottom: 2px; +} + +.secondary { + font-size: 12px; +} + +.status { + min-height: 28px; + display: inline-flex; + align-items: center; + padding: 3px 8px; + border: 1px solid var(--line-strong); + border-radius: 5px; + background: var(--surface-soft); + font-size: 12px; + font-weight: 700; +} + +.status-warn { + border-color: #d6b970; + color: var(--warn); + background: var(--warn-soft); +} + +.status-info { + border-color: #8eb7d4; + color: var(--info); + background: var(--info-soft); +} + +.status-success { + border-color: #7db6a3; + color: var(--success); + background: var(--success-soft); +} + +.status-danger { + border-color: #d99c9c; + color: var(--danger-dark); + background: var(--danger-soft); +} + +.detail-link { + min-height: 44px; + display: inline-flex; + align-items: center; + font-weight: 700; +} + +.empty-state { + min-height: 260px; + display: grid; + place-items: center; + align-content: center; + padding: 36px 20px; + text-align: center; +} + +.empty-state p { + margin: 0 0 18px; + color: var(--muted); +} + +.pagination { + display: flex; + justify-content: flex-end; + margin-top: 16px; +} + +.notice { + margin: 0 0 18px; + padding: 12px 14px; + border-left: 4px solid var(--info); + background: var(--info-soft); +} + +.notice p { + margin: 0; +} + +.notice p + p { + margin-top: 5px; +} + +.notice-error { + border-color: var(--danger); + color: var(--danger-dark); + background: var(--danger-soft); +} + +.task-form { + display: grid; + gap: 18px; +} + +.form-section, +.content-section { + padding: 22px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 15px 18px; +} + +.span-2 { + grid-column: 1 / -1; +} + +.required-text { + color: var(--danger-dark); + font-size: 12px; +} + +.field-help, +.field-error { + min-height: 20px; + margin: 4px 0 0; + font-size: 13px; +} + +.field-error { + color: var(--danger-dark); +} + +.upload-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 240px; + gap: 22px; + align-items: start; +} + +.image-preview { + margin: 0; +} + +.image-placeholder, +.image-preview img { + width: 100%; + aspect-ratio: 4 / 3; + border: 1px dashed var(--line-strong); + border-radius: 5px; + background: var(--surface-soft); +} + +.image-placeholder { + display: grid; + place-items: center; + padding: 16px; + color: var(--muted); + text-align: center; +} + +.image-preview img { + display: block; + object-fit: contain; +} + +.image-preview figcaption { + margin-top: 6px; + color: var(--muted); + font-size: 12px; + overflow-wrap: anywhere; +} + +.retained-file { + margin: 0; + padding: 10px 12px; + border: 1px solid #7db6a3; + border-radius: 5px; + background: var(--success-soft); + overflow-wrap: anywhere; +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.back-link { + min-height: 44px; + display: inline-flex; + align-items: center; + margin-bottom: 10px; + font-weight: 700; +} + +.detail-title { + align-items: flex-start; +} + +.title-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +.success-banner { + display: flex; + gap: 13px; + align-items: center; + margin-bottom: 18px; + padding: 16px 18px; + border-left: 5px solid var(--success); + background: var(--success-soft); +} + +.success-banner h2, +.success-banner p { + margin: 0; +} + +.success-mark { + width: 34px; + height: 34px; + display: grid; + flex: 0 0 auto; + place-items: center; + border-radius: 50%; + color: #fff; + background: var(--success); + font-weight: 800; +} + +.detail-layout { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr); + gap: 18px; + align-items: start; +} + +.section-note { + margin: 0 0 16px; +} + +.requirement-layout { + display: grid; + grid-template-columns: minmax(170px, 220px) minmax(0, 1fr); + gap: 20px; +} + +.reference-figure { + margin: 0; +} + +.reference-image { + width: 100%; + aspect-ratio: 4 / 3; + display: block; + object-fit: contain; + border: 1px solid var(--line-strong); + border-radius: 5px; + background: var(--surface-soft); +} + +.reference-figure figcaption { + margin-top: 6px; + color: var(--muted); + font-size: 12px; + text-align: center; +} + +.definition-list { + display: grid; + grid-template-columns: minmax(100px, auto) minmax(0, 1fr); + gap: 10px 16px; + margin: 0; +} + +.definition-list dt { + color: var(--muted); + font-weight: 650; +} + +.definition-list dd { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.error-page { + margin-top: 50px; + padding: 28px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); +} + +.error-page p { + margin: 8px 0 22px; + color: var(--muted); +} + +.confirm-dialog { + width: min(calc(100% - 32px), 480px); + padding: 22px; + border: 1px solid var(--line); + border-radius: 8px; + color: var(--ink); + background: var(--surface); +} + +.confirm-dialog::backdrop { + background: rgba(24, 32, 29, 0.45); +} + +.confirm-dialog p { + color: var(--muted); +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 20px; +} + +.dialog-actions form { + margin: 0; +} + +.noscript-cancel { + margin-top: 18px; + padding: 18px; + border: 1px solid #d99c9c; + background: var(--danger-soft); +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +[aria-busy="true"] { + cursor: wait; +} + +@media (max-width: 760px) { + .page { + width: min(calc(100% - 20px), 1180px); + padding-top: 20px; + } + + .filters { + grid-template-columns: minmax(0, 1fr) minmax(160px, 0.6fr); + } + + .filter-button { + grid-column: 1 / -1; + } + + table, + tbody { + display: block; + } + + thead { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + } + + tr { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + padding: 12px; + border-bottom: 1px solid var(--line); + } + + tbody tr:last-child { + border-bottom: 0; + } + + td { + display: block; + padding: 6px; + border: 0; + } + + td::before { + content: attr(data-label); + display: block; + margin-bottom: 2px; + color: var(--muted); + font-size: 11px; + font-weight: 700; + } + + td:first-child, + td:last-child { + grid-column: 1 / -1; + } + + .upload-layout, + .detail-layout, + .requirement-layout { + grid-template-columns: 1fr; + } + + .image-preview { + max-width: 320px; + } +} + +@media (max-width: 520px) { + .site-header { + gap: 8px; + padding-inline: 10px; + } + + .brand { + font-size: 14px; + } + + .brand-mark { + display: none; + } + + .main-nav { + margin-left: auto; + } + + .main-nav a { + padding-inline: 8px; + font-size: 13px; + } + + .title-row, + .detail-title { + align-items: stretch; + flex-direction: column; + } + + .title-row > .button { + width: 100%; + } + + .title-actions { + justify-content: flex-start; + flex-wrap: wrap; + } + + .filters, + .form-grid { + grid-template-columns: 1fr; + } + + .filter-button, + .span-2 { + grid-column: auto; + } + + tr { + grid-template-columns: 1fr; + } + + td, + td:first-child, + td:last-child { + grid-column: auto; + } + + .form-section, + .content-section { + padding: 18px 14px; + } + + .form-actions, + .dialog-actions { + flex-direction: column-reverse; + } + + .form-actions .button, + .dialog-actions .button, + .dialog-actions form { + width: 100%; + } + + .dialog-actions form .button { + width: 100%; + } + + .definition-list { + grid-template-columns: 1fr; + gap: 3px; + } + + .definition-list dd { + margin-bottom: 10px; + } +} diff --git a/backend-api/internal/transport/webui/static/admin.js b/backend-api/internal/transport/webui/static/admin.js new file mode 100644 index 0000000..dfe3482 --- /dev/null +++ b/backend-api/internal/transport/webui/static/admin.js @@ -0,0 +1,109 @@ +(() => { + "use strict"; + + const focusError = document.querySelector("[data-error-field]"); + if (focusError) { + focusError.focus(); + } else { + const summary = document.querySelector("[data-error-summary]"); + if (summary) summary.focus(); + } + + document.querySelectorAll("[data-loading-form]").forEach((form) => { + form.addEventListener("submit", () => { + form.setAttribute("aria-busy", "true"); + const button = form.querySelector("button[type='submit']"); + if (!button) return; + button.disabled = true; + button.textContent = button.dataset.loadingLabel || "正在加载…"; + }); + }); + + document.querySelectorAll("[data-submit-form]").forEach((form) => { + form.addEventListener("submit", () => { + form.dataset.submitting = "true"; + const button = form.querySelector("[data-submit-button]"); + if (!button) return; + button.disabled = true; + button.textContent = button.dataset.loadingLabel || "正在提交…"; + }); + }); + + const fileInput = document.querySelector("input[type='file'][name='image']"); + const image = document.querySelector("[data-image-preview]"); + const placeholder = document.querySelector("[data-image-placeholder]"); + const imageName = document.querySelector("[data-image-name]"); + let previewURL = ""; + + if (fileInput && image && placeholder && imageName) { + fileInput.addEventListener("change", () => { + if (previewURL) URL.revokeObjectURL(previewURL); + const file = fileInput.files && fileInput.files[0]; + if (!file) { + image.hidden = true; + placeholder.hidden = false; + imageName.textContent = "选择后在本地预览"; + return; + } + previewURL = URL.createObjectURL(file); + image.src = previewURL; + image.hidden = false; + placeholder.hidden = true; + imageName.textContent = `${file.name} · ${(file.size / 1024).toFixed(1)} KB`; + }); + window.addEventListener("pagehide", () => { + if (previewURL) URL.revokeObjectURL(previewURL); + }); + } + + const dirtyForm = document.querySelector("[data-dirty-form]"); + const leaveDialog = document.querySelector("[data-leave-dialog]"); + let dirty = false; + let destination = ""; + + if (dirtyForm && leaveDialog) { + dirtyForm.addEventListener("input", () => { + dirty = true; + }); + dirtyForm.addEventListener("change", () => { + dirty = true; + }); + dirtyForm.addEventListener("submit", () => { + dirty = false; + }); + + document.querySelectorAll("a[href]").forEach((link) => { + link.addEventListener("click", (event) => { + if (!dirty || link.hasAttribute("data-discard")) return; + const target = new URL(link.href, window.location.href); + if (target.origin !== window.location.origin) return; + event.preventDefault(); + destination = target.href; + leaveDialog.showModal(); + leaveDialog.querySelector("[data-stay]").focus(); + }); + }); + + leaveDialog.querySelector("[data-stay]").addEventListener("click", () => { + leaveDialog.close(); + }); + leaveDialog.querySelector("[data-discard]").addEventListener("click", (event) => { + event.preventDefault(); + dirty = false; + window.location.assign(destination || "/tasks"); + }); + } + + const openCancel = document.querySelector("[data-open-cancel]"); + const cancelDialog = document.querySelector("[data-cancel-dialog]"); + if (openCancel && cancelDialog) { + openCancel.addEventListener("click", () => { + cancelDialog.showModal(); + cancelDialog.querySelector("[data-keep-task]").focus(); + }); + cancelDialog.querySelector("[data-keep-task]").addEventListener("click", () => { + cancelDialog.close(); + openCancel.focus(); + }); + } +})(); diff --git a/backend-api/internal/transport/webui/templates/error.gohtml b/backend-api/internal/transport/webui/templates/error.gohtml new file mode 100644 index 0000000..096de31 --- /dev/null +++ b/backend-api/internal/transport/webui/templates/error.gohtml @@ -0,0 +1,19 @@ +{{define "error"}} + + + + {{.Page.Title}} - 采购任务管理 + {{template "document-head" .}} + + + {{template "site-header" .}} +
+ +
+ + +{{end}} diff --git a/backend-api/internal/transport/webui/templates/partials.gohtml b/backend-api/internal/transport/webui/templates/partials.gohtml new file mode 100644 index 0000000..85479f0 --- /dev/null +++ b/backend-api/internal/transport/webui/templates/partials.gohtml @@ -0,0 +1,22 @@ +{{define "document-head"}} + + + + + + +{{end}} + +{{define "site-header"}} + + +{{end}} diff --git a/backend-api/internal/transport/webui/templates/task-detail.gohtml b/backend-api/internal/transport/webui/templates/task-detail.gohtml new file mode 100644 index 0000000..605990d --- /dev/null +++ b/backend-api/internal/transport/webui/templates/task-detail.gohtml @@ -0,0 +1,106 @@ +{{define "task-detail"}} + + + + {{.Task.Title}} - 采购任务管理 + {{template "document-head" .}} + + + {{template "site-header" .}} +
+ 返回任务列表 + + {{if .Notice}} +
{{.Notice}}
+ {{end}} + +
+
+

{{.Task.Title}}

+

SKU:{{.Task.SKU}} · 任务编号:{{.Task.ID}}

+
+
+ + {{.Task.StatusLabel}} + + {{if .Task.CanCancel}} + + {{end}} +
+
+ + {{if eq .Task.Status "SUCCEEDED"}} +
+ +
+

验证完成,未提交订单

+

采购人员已完成候选确认,系统只记录验证结果。

+
+
+ {{end}} + +
+
+

原始采购要求

+

以下内容来自管理员输入,不会被 AI 派生结果覆盖。

+
+
+ 商品参考图 +
已规范化的参考图片
+
+
+
商品标题
{{.Task.Title}}
+
SKU
{{.Task.SKU}}
+
描述
{{if .Task.Description}}{{.Task.Description}}{{else}}未填写{{end}}
+
数量
{{.Task.Quantity}}
+
最高总预算
+
{{if .Task.MaxBudget}}{{.Task.MaxBudget}} 元{{else}}未设置{{end}}
+
+
+
+ + +
+ + {{if .Task.CanCancel}} + + + +

确认取消任务?

+

任务取消后不会自动恢复,采购执行员将不能再领取。

+
+ +
+ + + +
+
+
+ {{end}} +
+ + +{{end}} diff --git a/backend-api/internal/transport/webui/templates/task-new.gohtml b/backend-api/internal/transport/webui/templates/task-new.gohtml new file mode 100644 index 0000000..5308f95 --- /dev/null +++ b/backend-api/internal/transport/webui/templates/task-new.gohtml @@ -0,0 +1,126 @@ +{{define "task-new"}} + + + + {{.Page.Title}} - 采购任务管理 + {{template "document-head" .}} + + + {{template "site-header" .}} +
+
+
+

新建采购任务

+

填写商品要求和不可覆盖的采购约束

+
+
+ + {{if or .Notice .Errors.Form}} + + {{end}} + +
+ + + + + +
+

商品要求

+
+
+ + +

{{.Errors.Title}}

+
+ +
+ + +

填写采购时用于核对的商品规格编号。

+

{{.Errors.SKU}}

+
+ +
+ + +

只填写采购判断所需信息,不填写订单、地址或个人信息。

+

{{.Errors.Description}}

+
+ +
+ + +

{{.Errors.Quantity}}

+
+ +
+ + +

数量合计的最高预算,不是单件价格。

+

{{.Errors.MaxBudget}}

+
+
+
+ +
+

参考图片

+
+
+ + {{if .UploadedAsset.ID}} +

已保留上传成功的参考图片

+

再次提交会复用该图片,不会重复上传。

+ {{else}} + +

支持 JPG、PNG、WebP;服务端会校验实际内容。

+ {{end}} +

{{.Errors.Image}}

+
+
+
尚未选择图片
+ +
+ {{if .UploadedAsset.ID}}参考图片已保留{{else}}选择后仅在本地预览{{end}} +
+
+
+
+ +
+ 取消 + +
+
+
+ + +

放弃未提交内容?

+

已填写的商品要求不会保存。

+
+ + 放弃并离开 +
+
+ + +{{end}} diff --git a/backend-api/internal/transport/webui/templates/tasks.gohtml b/backend-api/internal/transport/webui/templates/tasks.gohtml new file mode 100644 index 0000000..0d1c7e5 --- /dev/null +++ b/backend-api/internal/transport/webui/templates/tasks.gohtml @@ -0,0 +1,93 @@ +{{define "tasks"}} + + + + {{.Page.Title}} - 采购任务管理 + {{template "document-head" .}} + + + {{template "site-header" .}} +
+
+
+

采购任务

+

按最近创建时间查看采购任务状态

+
+ 新建任务 +
+ +
+
+ + +
+
+ + +
+ +
+ +
+

任务列表结果

+ {{if .Items}} + + + + + + + + + + + + {{range .Items}} + + + + + + + + {{end}} + +
任务状态执行设备更新时间操作
+ {{.Title}} + SKU:{{.SKU}} + 编号:{{.ID}} + + {{.StatusLabel}} + {{.DeviceName}} + + + 查看详情 +
+ {{else}} +
+

没有符合条件的任务

+ {{if or .Query .Status}} +

请调整搜索或状态筛选后重试。

+ 清除筛选 + {{else}} +

创建第一条采购任务后,它会显示在这里。

+ 新建任务 + {{end}} +
+ {{end}} +
+ + {{if .NextCursor}} + + {{end}} +
+ + +{{end}} diff --git a/backend-api/internal/transport/webui/types.go b/backend-api/internal/transport/webui/types.go new file mode 100644 index 0000000..61628d6 --- /dev/null +++ b/backend-api/internal/transport/webui/types.go @@ -0,0 +1,87 @@ +package webui + +import ( + "context" + "errors" + "io" + "time" +) + +var ( + ErrNotFound = errors.New("resource not found") + ErrForbidden = errors.New("resource forbidden") + ErrConflict = errors.New("resource conflict") + ErrValidation = errors.New("validation failed") + ErrInvalidFile = errors.New("invalid file") + ErrUnavailable = errors.New("service unavailable") +) + +// Service is the application boundary required by the server-rendered admin UI. +// Composition code adapts the task use case to this transport-specific shape. +type Service interface { + ListTasks(context.Context, ListTasksInput) (TaskList, error) + GetTask(context.Context, string) (Task, error) + UploadReference(context.Context, UploadReferenceInput) (UploadedAsset, error) + CreateTask(context.Context, CreateTaskInput) (Task, error) + CancelPending(context.Context, CancelPendingInput) (Task, error) +} + +type ListTasksInput struct { + Query string + Status string + Cursor string + Limit int +} + +type TaskList struct { + Items []TaskSummary + NextCursor string +} + +type TaskSummary struct { + ID string + Title string + SKU string + Status string + DeviceName string + UpdatedAt time.Time +} + +type Task struct { + ID string + Title string + SKU string + Description string + Quantity int64 + MaxBudget string + Status string + ReferenceAssetID string + CreatedAt time.Time + UpdatedAt time.Time +} + +type UploadReferenceInput struct { + IdempotencyKey string + DeclaredType string + DeclaredSize int64 + Content io.Reader +} + +type UploadedAsset struct { + ID string +} + +type CreateTaskInput struct { + IdempotencyKey string + Title string + SKU string + Description string + Quantity int64 + MaxBudget string + ImageAssetID string +} + +type CancelPendingInput struct { + TaskID string + IdempotencyKey string +} diff --git a/backend-api/internal/transport/webui/usecase_adapter.go b/backend-api/internal/transport/webui/usecase_adapter.go new file mode 100644 index 0000000..2e89267 --- /dev/null +++ b/backend-api/internal/transport/webui/usecase_adapter.go @@ -0,0 +1,217 @@ +package webui + +import ( + "context" + "errors" + + "cmroubao/backend-api/internal/domain" + "cmroubao/backend-api/internal/usecase" +) + +const localAdminSubject = "local-admin" + +type UsecaseAdapter struct { + tasks *usecase.TaskService + assets *usecase.AssetService +} + +func NewUsecaseAdapter( + tasks *usecase.TaskService, + assets *usecase.AssetService, +) (*UsecaseAdapter, error) { + if tasks == nil || assets == nil { + return nil, errors.New("admin web use cases are required") + } + return &UsecaseAdapter{ + tasks: tasks, + assets: assets, + }, nil +} + +func (adapter *UsecaseAdapter) ListTasks( + ctx context.Context, + input ListTasksInput, +) (TaskList, error) { + var status *string + if input.Status != "" { + value := input.Status + status = &value + } + page, err := adapter.tasks.List(ctx, usecase.ListTasksQuery{ + CreatorSubject: localAdminSubject, + Status: status, + Query: input.Query, + Limit: input.Limit, + Cursor: input.Cursor, + }) + if err != nil { + return TaskList{}, mapUsecaseError(err) + } + items := make([]TaskSummary, 0, len(page.Items)) + for _, item := range page.Items { + items = append(items, taskSummaryFrom(item)) + } + return TaskList{ + Items: items, + NextCursor: page.NextCursor, + }, nil +} + +func (adapter *UsecaseAdapter) GetTask( + ctx context.Context, + taskID string, +) (Task, error) { + detail, err := adapter.tasks.Get(ctx, localAdminSubject, taskID) + if err != nil { + return Task{}, mapUsecaseError(err) + } + return taskFromDetail(detail), nil +} + +func (adapter *UsecaseAdapter) UploadReference( + ctx context.Context, + input UploadReferenceInput, +) (UploadedAsset, error) { + result, err := adapter.assets.UploadTaskReference( + ctx, + usecase.UploadTaskReferenceCommand{ + CreatorSubject: localAdminSubject, + IdempotencyKey: input.IdempotencyKey, + DeclaredMediaType: input.DeclaredType, + Content: input.Content, + }, + ) + if err != nil { + return UploadedAsset{}, mapUsecaseError(err) + } + return UploadedAsset{ + ID: result.Asset.ID, + }, nil +} + +func (adapter *UsecaseAdapter) CreateTask( + ctx context.Context, + input CreateTaskInput, +) (Task, error) { + maxInt := int64(^uint(0) >> 1) + if input.Quantity > maxInt { + return Task{}, ErrValidation + } + var budget *string + if input.MaxBudget != "" { + value := input.MaxBudget + budget = &value + } + result, err := adapter.tasks.Create(ctx, usecase.CreateTaskCommand{ + CreatorSubject: localAdminSubject, + IdempotencyKey: input.IdempotencyKey, + Title: input.Title, + Description: input.Description, + SKU: input.SKU, + ImageAssetID: input.ImageAssetID, + Quantity: int(input.Quantity), + MaxBudget: budget, + }) + if err != nil { + return Task{}, mapUsecaseError(err) + } + return taskFromPurchase(result.Task), nil +} + +func (adapter *UsecaseAdapter) CancelPending( + ctx context.Context, + input CancelPendingInput, +) (Task, error) { + task, err := adapter.tasks.Cancel(ctx, usecase.CancelTaskCommand{ + CreatorSubject: localAdminSubject, + TaskID: input.TaskID, + Reason: "管理员取消", + }) + if err != nil { + return Task{}, mapUsecaseError(err) + } + return taskFromPurchase(task), nil +} + +func taskSummaryFrom(task domain.PurchaseTask) TaskSummary { + return TaskSummary{ + ID: task.ID, + Title: task.Title, + SKU: task.SKU, + Status: string(task.Status), + UpdatedAt: task.UpdatedAt, + } +} + +func taskFromPurchase(task domain.PurchaseTask) Task { + budget := domain.FormatOptionalCNY(task.MaxBudgetCents) + result := Task{ + ID: task.ID, + Title: task.Title, + SKU: task.SKU, + Description: task.Description, + Quantity: int64(task.Quantity), + Status: string(task.Status), + ReferenceAssetID: task.ImageAssetID, + CreatedAt: task.CreatedAt, + UpdatedAt: task.UpdatedAt, + } + if budget != nil { + result.MaxBudget = *budget + } + return result +} + +func taskFromDetail(detail domain.TaskDetail) Task { + task := taskFromPurchase(detail.Task) + task.ReferenceAssetID = detail.Asset.ID + return task +} + +func mapUsecaseError(err error) error { + var typed *usecase.Error + if !errors.As(err, &typed) { + return err + } + var public error + switch typed.Kind { + case usecase.ErrorKindInvalid: + if typed.Code == "TASK_CANCEL_INVALID" { + public = ErrNotFound + } else if typed.Code == "ASSET_TOO_LARGE" || + typed.Code == "ASSET_MEDIA_TYPE_UNSUPPORTED" || + typed.Code == "ASSET_IMAGE_INVALID" || + typed.Code == "ASSET_FILE_REQUIRED" { + public = ErrInvalidFile + } else { + public = ErrValidation + } + case usecase.ErrorKindNotFound: + public = ErrNotFound + case usecase.ErrorKindConflict: + public = ErrConflict + case usecase.ErrorKindUnavailable: + public = ErrUnavailable + default: + return err + } + return &adapterError{ + public: public, + cause: err, + } +} + +type adapterError struct { + public error + cause error +} + +func (err *adapterError) Error() string { + return err.public.Error() +} + +func (err *adapterError) Unwrap() []error { + return []error{err.public, err.cause} +} + +var _ Service = (*UsecaseAdapter)(nil) diff --git a/backend-api/internal/usecase/asset_service.go b/backend-api/internal/usecase/asset_service.go new file mode 100644 index 0000000..af1c104 --- /dev/null +++ b/backend-api/internal/usecase/asset_service.go @@ -0,0 +1,275 @@ +package usecase + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "strings" + + "cmroubao/backend-api/internal/domain" +) + +const ( + assetUploadOperation = "UPLOAD_TASK_REFERENCE" + maxIdempotencyKeyBytes = 128 +) + +type AssetService struct { + repository AssetRepository + store ReferenceImageStore + clock Clock + ids IDGenerator +} + +type UploadTaskReferenceCommand struct { + CreatorSubject string + IdempotencyKey string + DeclaredMediaType string + Content io.Reader +} + +type UploadTaskReferenceResult struct { + Asset domain.Asset + Replayed bool +} + +type AssetContent struct { + Asset domain.Asset + Content io.ReadCloser +} + +func NewAssetService( + repository AssetRepository, + store ReferenceImageStore, + clock Clock, + ids IDGenerator, +) (*AssetService, error) { + if repository == nil || store == nil || clock == nil || ids == nil { + return nil, errors.New("asset service dependencies are required") + } + return &AssetService{ + repository: repository, + store: store, + clock: clock, + ids: ids, + }, nil +} + +func (s *AssetService) UploadTaskReference( + ctx context.Context, + command UploadTaskReferenceCommand, +) (UploadTaskReferenceResult, error) { + if err := validateWriteIdentity( + command.CreatorSubject, + command.IdempotencyKey, + ); err != nil { + return UploadTaskReferenceResult{}, err + } + if command.Content == nil { + return UploadTaskReferenceResult{}, invalidError( + "ASSET_FILE_REQUIRED", + "reference image is required", + map[string]string{"file": "required"}, + ) + } + assetID, err := s.ids.NewID() + if err != nil { + return UploadTaskReferenceResult{}, newError( + ErrorKindInternal, + "INTERNAL_ERROR", + "internal server error", + err, + ) + } + normalized, err := s.store.Put( + ctx, + assetID, + command.DeclaredMediaType, + command.Content, + ) + if err != nil { + return UploadTaskReferenceResult{}, mapImageStoreError(err) + } + cleanup := func() { + _ = s.store.Delete(context.Background(), normalized.StorageKey) + } + + requestHash := sha256.Sum256([]byte( + domain.AssetPurposeTaskReference + "\x00" + + normalized.InputMediaType + "\x00" + + normalized.InputSHA256, + )) + candidate := domain.Asset{ + ID: assetID, + CreatorSubject: strings.TrimSpace(command.CreatorSubject), + Purpose: domain.AssetPurposeTaskReference, + MediaType: normalized.MediaType, + SizeBytes: normalized.SizeBytes, + SHA256: normalized.SHA256, + StorageKey: normalized.StorageKey, + CreatedAt: s.clock.Now().UTC(), + } + asset, created, err := s.repository.CreateAssetIdempotent( + ctx, + candidate, + strings.TrimSpace(command.IdempotencyKey), + hex.EncodeToString(requestHash[:]), + ) + if err != nil { + cleanup() + return UploadTaskReferenceResult{}, wrapRepositoryError(err) + } + if !created { + cleanup() + } + return UploadTaskReferenceResult{ + Asset: asset, + Replayed: !created, + }, nil +} + +func (s *AssetService) OpenTaskReference( + ctx context.Context, + creatorSubject string, + assetID string, +) (AssetContent, error) { + if strings.TrimSpace(creatorSubject) == "" || + !isUUID(assetID) { + return AssetContent{}, newError( + ErrorKindNotFound, + "ASSET_NOT_FOUND", + "asset not found", + nil, + ) + } + asset, err := s.repository.GetAsset( + ctx, + strings.TrimSpace(creatorSubject), + assetID, + ) + if err != nil { + result := wrapRepositoryError(err) + if typed, ok := result.(*Error); ok && + typed.Kind == ErrorKindNotFound { + typed.Code = "ASSET_NOT_FOUND" + typed.Message = "asset not found" + } + return AssetContent{}, result + } + content, err := s.store.Open(ctx, asset.StorageKey) + if err != nil { + return AssetContent{}, mapImageStoreError(err) + } + return AssetContent{Asset: asset, Content: content}, nil +} + +func validateWriteIdentity(subject, key string) error { + if strings.TrimSpace(subject) == "" { + return invalidError( + "REQUEST_VALIDATION_FAILED", + "request validation failed", + map[string]string{"creator_subject": "required"}, + ) + } + trimmedKey := strings.TrimSpace(key) + if trimmedKey == "" { + return invalidError( + "IDEMPOTENCY_KEY_REQUIRED", + "idempotency key is required", + map[string]string{"idempotency_key": "required"}, + ) + } + if len([]byte(trimmedKey)) > maxIdempotencyKeyBytes || + !isPrintableASCII(trimmedKey) { + return invalidError( + "REQUEST_VALIDATION_FAILED", + "request validation failed", + map[string]string{"idempotency_key": "invalid"}, + ) + } + return nil +} + +func isPrintableASCII(value string) bool { + for _, char := range value { + if char < 0x21 || char > 0x7e { + return false + } + } + return true +} + +type ImageStoreErrorKind string + +const ( + ImageStoreErrorTooLarge ImageStoreErrorKind = "TOO_LARGE" + ImageStoreErrorUnsupported ImageStoreErrorKind = "UNSUPPORTED" + ImageStoreErrorInvalid ImageStoreErrorKind = "INVALID" + ImageStoreErrorUnavailable ImageStoreErrorKind = "UNAVAILABLE" + ImageStoreErrorNotFound ImageStoreErrorKind = "NOT_FOUND" +) + +type ImageStoreError struct { + Kind ImageStoreErrorKind + Cause error +} + +func (e *ImageStoreError) Error() string { + return "reference image store operation failed" +} + +func (e *ImageStoreError) Unwrap() error { + return e.Cause +} + +func mapImageStoreError(err error) error { + var storeError *ImageStoreError + if !errors.As(err, &storeError) { + return newError( + ErrorKindInternal, + "INTERNAL_ERROR", + "internal server error", + err, + ) + } + switch storeError.Kind { + case ImageStoreErrorTooLarge: + return newError( + ErrorKindInvalid, + "ASSET_TOO_LARGE", + "reference image exceeds the allowed size", + err, + ) + case ImageStoreErrorUnsupported: + return newError( + ErrorKindInvalid, + "ASSET_MEDIA_TYPE_UNSUPPORTED", + "reference image media type is not supported", + err, + ) + case ImageStoreErrorInvalid: + return invalidError( + "ASSET_IMAGE_INVALID", + "reference image is invalid", + map[string]string{"file": "must be a decodable image"}, + ) + case ImageStoreErrorNotFound: + return newError( + ErrorKindNotFound, + "ASSET_NOT_FOUND", + "asset not found", + err, + ) + default: + result := newError( + ErrorKindUnavailable, + "ASSET_STORAGE_UNAVAILABLE", + "asset storage is temporarily unavailable", + err, + ) + result.Retryable = true + return result + } +} diff --git a/backend-api/internal/usecase/asset_service_test.go b/backend-api/internal/usecase/asset_service_test.go new file mode 100644 index 0000000..a764fb9 --- /dev/null +++ b/backend-api/internal/usecase/asset_service_test.go @@ -0,0 +1,201 @@ +package usecase + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + + "cmroubao/backend-api/internal/domain" +) + +func TestAssetServiceUploadPersistsNormalizedMetadata(t *testing.T) { + repository := &fakeAssetRepository{} + store := &fakeReferenceImageStore{ + result: NormalizedReferenceImage{ + StorageKey: "aa/file.jpg", + InputMediaType: "image/png", + InputSHA256: strings.Repeat("1", 64), + MediaType: "image/jpeg", + SizeBytes: 123, + SHA256: strings.Repeat("2", 64), + }, + } + service := mustAssetService(t, repository, store) + result, err := service.UploadTaskReference( + context.Background(), + UploadTaskReferenceCommand{ + CreatorSubject: " local-admin ", + IdempotencyKey: " upload-1 ", + DeclaredMediaType: "image/png", + Content: bytes.NewReader([]byte("fixture")), + }, + ) + if err != nil { + t.Fatalf("UploadTaskReference() error = %v", err) + } + if result.Replayed || + result.Asset.CreatorSubject != "local-admin" || + result.Asset.Purpose != domain.AssetPurposeTaskReference || + result.Asset.StorageKey != store.result.StorageKey || + len(repository.hash) != 64 { + t.Fatalf("result/repository = %+v/%+v", result, repository) + } +} + +func TestAssetServiceDeletesNewFileWhenRepositoryReplaysOrFails( + t *testing.T, +) { + for _, test := range []struct { + name string + replay bool + createErr error + wantErr bool + }{ + {name: "replay", replay: true}, + {name: "failure", createErr: ErrRepositoryUnavailable, wantErr: true}, + } { + t.Run(test.name, func(t *testing.T) { + repository := &fakeAssetRepository{ + replay: test.replay, + createErr: test.createErr, + } + store := &fakeReferenceImageStore{ + result: NormalizedReferenceImage{ + StorageKey: "aa/candidate.jpg", + InputMediaType: "image/jpeg", + InputSHA256: strings.Repeat("1", 64), + MediaType: "image/jpeg", + SizeBytes: 123, + SHA256: strings.Repeat("2", 64), + }, + } + service := mustAssetService(t, repository, store) + _, err := service.UploadTaskReference( + context.Background(), + UploadTaskReferenceCommand{ + CreatorSubject: "local-admin", + IdempotencyKey: "upload-1", + DeclaredMediaType: "image/jpeg", + Content: bytes.NewReader([]byte("fixture")), + }, + ) + if (err != nil) != test.wantErr { + t.Fatalf("UploadTaskReference() error = %v", err) + } + if store.deleted != "aa/candidate.jpg" { + t.Fatalf("deleted key = %q", store.deleted) + } + }) + } +} + +func TestAssetServiceMapsImageValidationError(t *testing.T) { + service := mustAssetService( + t, + &fakeAssetRepository{}, + &fakeReferenceImageStore{ + err: &ImageStoreError{ + Kind: ImageStoreErrorInvalid, + Cause: errors.New("private decoder detail"), + }, + }, + ) + _, err := service.UploadTaskReference( + context.Background(), + UploadTaskReferenceCommand{ + CreatorSubject: "local-admin", + IdempotencyKey: "upload-1", + DeclaredMediaType: "image/jpeg", + Content: bytes.NewReader([]byte("bad")), + }, + ) + assertUsecaseError(t, err, ErrorKindInvalid, "ASSET_IMAGE_INVALID") + if strings.Contains(err.Error(), "private") { + t.Fatal("public error leaked decoder detail") + } +} + +type fakeAssetRepository struct { + asset domain.Asset + replay bool + createErr error + hash string +} + +func (repository *fakeAssetRepository) CreateAssetIdempotent( + _ context.Context, + asset domain.Asset, + _ string, + hash string, +) (domain.Asset, bool, error) { + repository.asset = asset + repository.hash = hash + if repository.createErr != nil { + return asset, false, repository.createErr + } + if repository.replay { + return asset, false, nil + } + return asset, true, nil +} + +func (repository *fakeAssetRepository) GetAsset( + context.Context, + string, + string, +) (domain.Asset, error) { + return repository.asset, nil +} + +type fakeReferenceImageStore struct { + result NormalizedReferenceImage + err error + deleted string +} + +func (store *fakeReferenceImageStore) Put( + context.Context, + string, + string, + io.Reader, +) (NormalizedReferenceImage, error) { + return store.result, store.err +} + +func (store *fakeReferenceImageStore) Open( + context.Context, + string, +) (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(nil)), nil +} + +func (store *fakeReferenceImageStore) Delete( + _ context.Context, + key string, +) error { + store.deleted = key + return nil +} + +func mustAssetService( + t *testing.T, + repository AssetRepository, + store ReferenceImageStore, +) *AssetService { + t.Helper() + service, err := NewAssetService( + repository, + store, + fakeClock{}, + &sequenceIDs{}, + ) + if err != nil { + t.Fatalf("NewAssetService() error = %v", err) + } + return service +} + +var _ Clock = fakeClock{} diff --git a/backend-api/internal/usecase/errors.go b/backend-api/internal/usecase/errors.go new file mode 100644 index 0000000..a0140af --- /dev/null +++ b/backend-api/internal/usecase/errors.go @@ -0,0 +1,126 @@ +package usecase + +import ( + "errors" + "fmt" +) + +type ErrorKind string + +const ( + ErrorKindInvalid ErrorKind = "INVALID" + ErrorKindNotFound ErrorKind = "NOT_FOUND" + ErrorKindConflict ErrorKind = "CONFLICT" + ErrorKindUnavailable ErrorKind = "UNAVAILABLE" + ErrorKindInternal ErrorKind = "INTERNAL" +) + +type Error struct { + Kind ErrorKind + Code string + Message string + Retryable bool + Fields map[string]string + Cause error +} + +func (e *Error) Error() string { + if e.Message != "" { + return e.Message + } + return "use case failed" +} + +func (e *Error) Unwrap() error { + return e.Cause +} + +func newError( + kind ErrorKind, + code string, + message string, + cause error, +) *Error { + return &Error{ + Kind: kind, + Code: code, + Message: message, + Cause: cause, + Fields: map[string]string{}, + } +} + +func invalidError(code, message string, fields map[string]string) *Error { + return &Error{ + Kind: ErrorKindInvalid, + Code: code, + Message: message, + Fields: fields, + } +} + +var ( + ErrRepositoryNotFound = errors.New("repository resource not found") + ErrIdempotencyConflict = errors.New("idempotency key payload conflict") + ErrSourceReferenceConflict = errors.New("source reference conflict") + ErrAssetUnavailable = errors.New("asset is unavailable") + ErrTaskStateConflict = errors.New("task state conflict") + ErrRepositoryUnavailable = errors.New("repository unavailable") + ErrRepositoryInvariant = errors.New("repository invariant failed") +) + +func wrapRepositoryError(err error) error { + switch { + case errors.Is(err, ErrRepositoryNotFound): + return newError( + ErrorKindNotFound, + "RESOURCE_NOT_FOUND", + "resource not found", + err, + ) + case errors.Is(err, ErrIdempotencyConflict): + return newError( + ErrorKindConflict, + "IDEMPOTENCY_KEY_CONFLICT", + "idempotency key was already used for a different request", + err, + ) + case errors.Is(err, ErrSourceReferenceConflict): + return newError( + ErrorKindConflict, + "TASK_SOURCE_REF_CONFLICT", + "source reference already exists", + err, + ) + case errors.Is(err, ErrAssetUnavailable): + return newError( + ErrorKindConflict, + "TASK_ASSET_UNAVAILABLE", + "reference image is not available for this task", + err, + ) + case errors.Is(err, ErrTaskStateConflict): + return newError( + ErrorKindConflict, + "TASK_STATE_CONFLICT", + "task state does not allow this operation", + err, + ) + case errors.Is(err, ErrRepositoryUnavailable): + result := newError( + ErrorKindUnavailable, + "STORAGE_UNAVAILABLE", + "storage is temporarily unavailable", + err, + ) + result.Retryable = true + return result + default: + return newError( + ErrorKindInternal, + "INTERNAL_ERROR", + "internal server error", + fmt.Errorf("%w: %v", ErrRepositoryInvariant, err), + ) + } +} diff --git a/backend-api/internal/usecase/ports.go b/backend-api/internal/usecase/ports.go new file mode 100644 index 0000000..7045bed --- /dev/null +++ b/backend-api/internal/usecase/ports.go @@ -0,0 +1,93 @@ +package usecase + +import ( + "context" + "io" + "time" + + "cmroubao/backend-api/internal/domain" +) + +type Clock interface { + Now() time.Time +} + +type IDGenerator interface { + NewID() (string, error) +} + +type NormalizedReferenceImage struct { + StorageKey string + InputMediaType string + InputSHA256 string + MediaType string + SizeBytes int64 + SHA256 string +} + +type ReferenceImageStore interface { + Put( + context.Context, + string, + string, + io.Reader, + ) (NormalizedReferenceImage, error) + Open(context.Context, string) (io.ReadCloser, error) + Delete(context.Context, string) error +} + +type AssetRepository interface { + CreateAssetIdempotent( + context.Context, + domain.Asset, + string, + string, + ) (domain.Asset, bool, error) + GetAsset( + context.Context, + string, + string, + ) (domain.Asset, error) +} + +type TaskCursor struct { + CreatedAt time.Time + ID string +} + +type TaskListFilter struct { + CreatorSubject string + Status *domain.TaskStatus + Query string + CreatedFrom *time.Time + CreatedTo *time.Time + Limit int + After *TaskCursor +} + +type TaskRepository interface { + CreateTaskIdempotent( + context.Context, + domain.PurchaseTask, + domain.TaskEvent, + string, + string, + ) (domain.PurchaseTask, bool, error) + ListTasks( + context.Context, + TaskListFilter, + ) ([]domain.PurchaseTask, error) + GetTaskDetail( + context.Context, + string, + string, + ) (domain.TaskDetail, error) + CancelPendingTask( + context.Context, + string, + string, + string, + time.Time, + domain.TaskEvent, + ) (domain.PurchaseTask, error) +} diff --git a/backend-api/internal/usecase/runtime.go b/backend-api/internal/usecase/runtime.go new file mode 100644 index 0000000..7ec3d78 --- /dev/null +++ b/backend-api/internal/usecase/runtime.go @@ -0,0 +1,38 @@ +package usecase + +import ( + "crypto/rand" + "encoding/binary" + "fmt" + "sync/atomic" + "time" +) + +type SystemClock struct{} + +func (SystemClock) Now() time.Time { + return time.Now().UTC() +} + +type UUIDGenerator struct{} + +func (UUIDGenerator) NewID() (string, error) { + var value [16]byte + if _, err := rand.Read(value[:]); err != nil { + now := uint64(time.Now().UnixNano()) + binary.BigEndian.PutUint64(value[:8], now) + binary.BigEndian.PutUint64(value[8:], fallbackUUID.Add(1)) + } + value[6] = (value[6] & 0x0f) | 0x40 + value[8] = (value[8] & 0x3f) | 0x80 + return fmt.Sprintf( + "%08x-%04x-%04x-%04x-%012x", + value[0:4], + value[4:6], + value[6:8], + value[8:10], + value[10:16], + ), nil +} + +var fallbackUUID atomic.Uint64 diff --git a/backend-api/internal/usecase/task_service.go b/backend-api/internal/usecase/task_service.go new file mode 100644 index 0000000..26163b8 --- /dev/null +++ b/backend-api/internal/usecase/task_service.go @@ -0,0 +1,459 @@ +package usecase + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "io" + "strings" + "time" + + "cmroubao/backend-api/internal/domain" +) + +const ( + createTaskOperation = "CREATE_PURCHASE_TASK" + defaultTaskListLimit = 20 + maxTaskListLimit = 100 + maxTaskQueryBytes = 256 +) + +type TaskService struct { + repository TaskRepository + clock Clock + ids IDGenerator +} + +type CreateTaskCommand struct { + CreatorSubject string + IdempotencyKey string + SourceRef *string + Title string + Description string + SKU string + ImageAssetID string + Quantity int + MaxBudget *string +} + +type CreateTaskResult struct { + Task domain.PurchaseTask + Replayed bool +} + +type ListTasksQuery struct { + CreatorSubject string + Status *string + Query string + CreatedFrom *time.Time + CreatedTo *time.Time + Limit int + Cursor string +} + +type TaskPage struct { + Items []domain.PurchaseTask + NextCursor string +} + +type CancelTaskCommand struct { + CreatorSubject string + TaskID string + Reason string +} + +func NewTaskService( + repository TaskRepository, + clock Clock, + ids IDGenerator, +) (*TaskService, error) { + if repository == nil || clock == nil || ids == nil { + return nil, errors.New("task service dependencies are required") + } + return &TaskService{ + repository: repository, + clock: clock, + ids: ids, + }, nil +} + +func (s *TaskService) Create( + ctx context.Context, + command CreateTaskCommand, +) (CreateTaskResult, error) { + if err := validateWriteIdentity( + command.CreatorSubject, + command.IdempotencyKey, + ); err != nil { + return CreateTaskResult{}, err + } + command.CreatorSubject = strings.TrimSpace(command.CreatorSubject) + command.Title = strings.TrimSpace(command.Title) + command.SKU = strings.TrimSpace(command.SKU) + command.ImageAssetID = strings.TrimSpace(command.ImageAssetID) + if command.SourceRef != nil { + value := strings.TrimSpace(*command.SourceRef) + command.SourceRef = &value + } + if !isUUID(command.ImageAssetID) { + return CreateTaskResult{}, invalidError( + "TASK_VALIDATION_FAILED", + "task validation failed", + map[string]string{"image_asset_id": "must be a UUID"}, + ) + } + if err := domain.ValidateTaskInput( + command.CreatorSubject, + command.SourceRef, + command.Title, + command.Description, + command.SKU, + command.ImageAssetID, + command.Quantity, + ); err != nil { + var validationError *domain.TaskValidationError + if errors.As(err, &validationError) { + return CreateTaskResult{}, invalidError( + "TASK_VALIDATION_FAILED", + "task validation failed", + validationError.Fields, + ) + } + return CreateTaskResult{}, newError( + ErrorKindInternal, + "INTERNAL_ERROR", + "internal server error", + err, + ) + } + budget, err := domain.ParseOptionalCNY(command.MaxBudget) + if err != nil { + return CreateTaskResult{}, invalidError( + "TASK_VALIDATION_FAILED", + "task validation failed", + map[string]string{"max_budget": "must be a positive CNY amount with at most two decimals"}, + ) + } + taskID, err := s.ids.NewID() + if err != nil { + return CreateTaskResult{}, newError( + ErrorKindInternal, + "INTERNAL_ERROR", + "internal server error", + err, + ) + } + eventID, err := s.ids.NewID() + if err != nil { + return CreateTaskResult{}, newError( + ErrorKindInternal, + "INTERNAL_ERROR", + "internal server error", + err, + ) + } + now := s.clock.Now().UTC() + task := domain.PurchaseTask{ + ID: taskID, + CreatorSubject: command.CreatorSubject, + SourceRef: command.SourceRef, + Title: command.Title, + Description: command.Description, + SKU: command.SKU, + ImageAssetID: command.ImageAssetID, + Quantity: command.Quantity, + MaxBudgetCents: budget, + Currency: domain.CurrencyCNY, + Status: domain.TaskStatusPending, + Version: 1, + CreatedAt: now, + UpdatedAt: now, + } + event := domain.TaskEvent{ + ID: eventID, + TaskID: taskID, + Type: "TASK_CREATED", + Message: "task created", + OccurredAt: now, + } + requestHash, err := hashCreateTaskCommand(command, budget) + if err != nil { + return CreateTaskResult{}, newError( + ErrorKindInternal, + "INTERNAL_ERROR", + "internal server error", + err, + ) + } + createdTask, created, err := s.repository.CreateTaskIdempotent( + ctx, + task, + event, + strings.TrimSpace(command.IdempotencyKey), + requestHash, + ) + if err != nil { + return CreateTaskResult{}, wrapRepositoryError(err) + } + return CreateTaskResult{ + Task: createdTask, + Replayed: !created, + }, nil +} + +func (s *TaskService) List( + ctx context.Context, + query ListTasksQuery, +) (TaskPage, error) { + query.CreatorSubject = strings.TrimSpace(query.CreatorSubject) + if query.CreatorSubject == "" { + return TaskPage{}, invalidError( + "REQUEST_VALIDATION_FAILED", + "request validation failed", + map[string]string{"creator_subject": "required"}, + ) + } + filter := TaskListFilter{ + CreatorSubject: query.CreatorSubject, + Query: strings.TrimSpace(query.Query), + CreatedFrom: query.CreatedFrom, + CreatedTo: query.CreatedTo, + Limit: query.Limit, + } + if len([]byte(filter.Query)) > maxTaskQueryBytes { + return TaskPage{}, invalidError( + "TASK_LIST_FILTER_INVALID", + "task list filter is invalid", + map[string]string{"q": "too long"}, + ) + } + if filter.Limit == 0 { + filter.Limit = defaultTaskListLimit + } + if filter.Limit < 1 || filter.Limit > maxTaskListLimit { + return TaskPage{}, invalidError( + "TASK_LIST_FILTER_INVALID", + "task list filter is invalid", + map[string]string{"limit": "must be between 1 and 100"}, + ) + } + if query.Status != nil { + status := domain.TaskStatus(strings.TrimSpace(*query.Status)) + if !domain.IsValidTaskStatus(status) { + return TaskPage{}, invalidError( + "TASK_LIST_FILTER_INVALID", + "task list filter is invalid", + map[string]string{"status": "unknown status"}, + ) + } + filter.Status = &status + } + if query.CreatedFrom != nil && query.CreatedTo != nil && + query.CreatedFrom.After(*query.CreatedTo) { + return TaskPage{}, invalidError( + "TASK_LIST_FILTER_INVALID", + "task list filter is invalid", + map[string]string{"created_from": "must not be after created_to"}, + ) + } + if query.Cursor != "" { + cursor, err := decodeTaskCursor(query.Cursor) + if err != nil { + return TaskPage{}, invalidError( + "TASK_CURSOR_INVALID", + "task cursor is invalid", + map[string]string{"cursor": "invalid"}, + ) + } + filter.After = &cursor + } + filter.Limit++ + items, err := s.repository.ListTasks(ctx, filter) + if err != nil { + return TaskPage{}, wrapRepositoryError(err) + } + page := TaskPage{Items: items} + if len(items) >= filter.Limit { + page.Items = items[:filter.Limit-1] + last := page.Items[len(page.Items)-1] + page.NextCursor = encodeTaskCursor(TaskCursor{ + CreatedAt: last.CreatedAt, + ID: last.ID, + }) + } + return page, nil +} + +func (s *TaskService) Get( + ctx context.Context, + creatorSubject string, + taskID string, +) (domain.TaskDetail, error) { + creatorSubject = strings.TrimSpace(creatorSubject) + if creatorSubject == "" || !isUUID(taskID) { + return domain.TaskDetail{}, newError( + ErrorKindNotFound, + "TASK_NOT_FOUND", + "task not found", + nil, + ) + } + detail, err := s.repository.GetTaskDetail( + ctx, + creatorSubject, + taskID, + ) + if err != nil { + result := wrapRepositoryError(err) + if typed, ok := result.(*Error); ok && + typed.Kind == ErrorKindNotFound { + typed.Code = "TASK_NOT_FOUND" + typed.Message = "task not found" + } + return domain.TaskDetail{}, result + } + return detail, nil +} + +func (s *TaskService) Cancel( + ctx context.Context, + command CancelTaskCommand, +) (domain.PurchaseTask, error) { + command.CreatorSubject = strings.TrimSpace(command.CreatorSubject) + command.TaskID = strings.TrimSpace(command.TaskID) + command.Reason = strings.TrimSpace(command.Reason) + fields := make(map[string]string) + if command.CreatorSubject == "" { + fields["creator_subject"] = "required" + } + if !isUUID(command.TaskID) { + fields["task_id"] = "must be a UUID" + } + if len([]byte(command.Reason)) > domain.MaxCancelReasonBytes { + fields["reason"] = "too long" + } + if len(fields) > 0 { + return domain.PurchaseTask{}, invalidError( + "TASK_CANCEL_INVALID", + "task cancellation is invalid", + fields, + ) + } + eventID, err := s.ids.NewID() + if err != nil { + return domain.PurchaseTask{}, newError( + ErrorKindInternal, + "INTERNAL_ERROR", + "internal server error", + err, + ) + } + now := s.clock.Now().UTC() + event := domain.TaskEvent{ + ID: eventID, + TaskID: command.TaskID, + Type: "TASK_CANCELED", + Message: "task canceled", + OccurredAt: now, + } + task, err := s.repository.CancelPendingTask( + ctx, + command.CreatorSubject, + command.TaskID, + command.Reason, + now, + event, + ) + if err != nil { + return domain.PurchaseTask{}, wrapRepositoryError(err) + } + return task, nil +} + +func hashCreateTaskCommand( + command CreateTaskCommand, + budget *int64, +) (string, error) { + payload := struct { + SourceRef *string `json:"source_ref"` + Title string `json:"title"` + Description string `json:"description"` + SKU string `json:"sku"` + ImageAssetID string `json:"image_asset_id"` + Quantity int `json:"quantity"` + MaxBudgetCents *int64 `json:"max_budget_cents"` + }{ + SourceRef: command.SourceRef, + Title: command.Title, + Description: command.Description, + SKU: command.SKU, + ImageAssetID: command.ImageAssetID, + Quantity: command.Quantity, + MaxBudgetCents: budget, + } + encoded, err := json.Marshal(payload) + if err != nil { + return "", err + } + hash := sha256.Sum256(encoded) + return hex.EncodeToString(hash[:]), nil +} + +func encodeTaskCursor(cursor TaskCursor) string { + payload := struct { + CreatedAt string `json:"created_at"` + ID string `json:"id"` + }{ + CreatedAt: cursor.CreatedAt.UTC().Format(time.RFC3339Nano), + ID: cursor.ID, + } + encoded, _ := json.Marshal(payload) + return base64.RawURLEncoding.EncodeToString(encoded) +} + +func decodeTaskCursor(value string) (TaskCursor, error) { + encoded, err := base64.RawURLEncoding.DecodeString(value) + if err != nil { + return TaskCursor{}, err + } + var payload struct { + CreatedAt string `json:"created_at"` + ID string `json:"id"` + } + decoder := json.NewDecoder(strings.NewReader(string(encoded))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&payload); err != nil { + return TaskCursor{}, err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return TaskCursor{}, errors.New("task cursor has trailing data") + } + createdAt, err := time.Parse(time.RFC3339Nano, payload.CreatedAt) + if err != nil || !isUUID(payload.ID) { + return TaskCursor{}, errors.New("invalid task cursor") + } + return TaskCursor{CreatedAt: createdAt.UTC(), ID: payload.ID}, nil +} + +func isUUID(value string) bool { + if len(value) != 36 { + return false + } + for index, char := range value { + if index == 8 || index == 13 || index == 18 || index == 23 { + if char != '-' { + return false + } + continue + } + if !((char >= '0' && char <= '9') || + (char >= 'a' && char <= 'f') || + (char >= 'A' && char <= 'F')) { + return false + } + } + return true +} diff --git a/backend-api/internal/usecase/task_service_test.go b/backend-api/internal/usecase/task_service_test.go new file mode 100644 index 0000000..d943887 --- /dev/null +++ b/backend-api/internal/usecase/task_service_test.go @@ -0,0 +1,232 @@ +package usecase + +import ( + "context" + "errors" + "testing" + "time" + + "cmroubao/backend-api/internal/domain" +) + +func TestTaskServiceCreateNormalizesAndHashesForIdempotency(t *testing.T) { + repository := &fakeTaskRepository{} + service := mustTaskService(t, repository) + budget := "20.00" + sourceRef := " source-1 " + + result, err := service.Create(context.Background(), CreateTaskCommand{ + CreatorSubject: " local-admin ", + IdempotencyKey: " create-1 ", + SourceRef: &sourceRef, + Title: " Demo title ", + Description: "description", + SKU: " SKU-1 ", + ImageAssetID: "00000000-0000-4000-8000-000000000001", + Quantity: 2, + MaxBudget: &budget, + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + if result.Task.Status != domain.TaskStatusPending || + result.Task.Title != "Demo title" || + result.Task.SKU != "SKU-1" || + result.Task.SourceRef == nil || + *result.Task.SourceRef != "source-1" || + result.Task.MaxBudgetCents == nil || + *result.Task.MaxBudgetCents != 2000 { + t.Fatalf("created task = %+v", result.Task) + } + if repository.key != "create-1" || len(repository.hash) != 64 { + t.Fatalf( + "idempotency key/hash = %q/%q", + repository.key, + repository.hash, + ) + } + if repository.event.Type != "TASK_CREATED" || + repository.event.TaskID != result.Task.ID { + t.Fatalf("event = %+v", repository.event) + } +} + +func TestTaskServiceCreateMapsValidationAndRepositoryErrors(t *testing.T) { + repository := &fakeTaskRepository{createErr: ErrAssetUnavailable} + service := mustTaskService(t, repository) + _, err := service.Create(context.Background(), CreateTaskCommand{ + CreatorSubject: "local-admin", + IdempotencyKey: "create-1", + Title: "title", + SKU: "sku", + ImageAssetID: "00000000-0000-4000-8000-000000000001", + Quantity: 1, + }) + assertUsecaseError(t, err, ErrorKindConflict, "TASK_ASSET_UNAVAILABLE") + + _, err = service.Create(context.Background(), CreateTaskCommand{ + CreatorSubject: "local-admin", + IdempotencyKey: "create-2", + Title: "", + SKU: "", + ImageAssetID: "bad", + Quantity: 0, + }) + assertUsecaseError(t, err, ErrorKindInvalid, "TASK_VALIDATION_FAILED") +} + +func TestTaskServiceListUsesStableOpaqueCursor(t *testing.T) { + createdAt := time.Date(2026, 7, 26, 1, 2, 3, 4, time.UTC) + repository := &fakeTaskRepository{ + listResult: []domain.PurchaseTask{ + {ID: "00000000-0000-4000-8000-000000000003", CreatedAt: createdAt}, + {ID: "00000000-0000-4000-8000-000000000002", CreatedAt: createdAt}, + {ID: "00000000-0000-4000-8000-000000000001", CreatedAt: createdAt}, + }, + } + service := mustTaskService(t, repository) + page, err := service.List(context.Background(), ListTasksQuery{ + CreatorSubject: "local-admin", + Limit: 2, + }) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(page.Items) != 2 || page.NextCursor == "" { + t.Fatalf("page = %+v", page) + } + if repository.filter.Limit != 3 { + t.Fatalf("repository limit = %d", repository.filter.Limit) + } + cursor, err := decodeTaskCursor(page.NextCursor) + if err != nil { + t.Fatalf("decodeTaskCursor() error = %v", err) + } + if cursor.ID != page.Items[1].ID || !cursor.CreatedAt.Equal(createdAt) { + t.Fatalf("cursor = %+v", cursor) + } +} + +func TestTaskServiceCancelMapsStateConflict(t *testing.T) { + repository := &fakeTaskRepository{cancelErr: ErrTaskStateConflict} + service := mustTaskService(t, repository) + _, err := service.Cancel(context.Background(), CancelTaskCommand{ + CreatorSubject: "local-admin", + TaskID: "00000000-0000-4000-8000-000000000001", + Reason: "no longer needed", + }) + assertUsecaseError(t, err, ErrorKindConflict, "TASK_STATE_CONFLICT") +} + +type fakeClock struct{} + +func (fakeClock) Now() time.Time { + return time.Date(2026, 7, 26, 1, 2, 3, 4, time.UTC) +} + +type sequenceIDs struct { + next int +} + +func (generator *sequenceIDs) NewID() (string, error) { + generator.next++ + return "00000000-0000-4000-8000-" + + pad12(generator.next), nil +} + +func pad12(value int) string { + result := "000000000000" + digits := []byte{} + for value > 0 { + digits = append([]byte{byte('0' + value%10)}, digits...) + value /= 10 + } + if len(digits) == 0 { + digits = []byte{'0'} + } + return result[:12-len(digits)] + string(digits) +} + +type fakeTaskRepository struct { + createErr error + cancelErr error + listResult []domain.PurchaseTask + task domain.PurchaseTask + event domain.TaskEvent + key string + hash string + filter TaskListFilter +} + +func (repository *fakeTaskRepository) CreateTaskIdempotent( + _ context.Context, + task domain.PurchaseTask, + event domain.TaskEvent, + key string, + hash string, +) (domain.PurchaseTask, bool, error) { + repository.task = task + repository.event = event + repository.key = key + repository.hash = hash + return task, true, repository.createErr +} + +func (repository *fakeTaskRepository) ListTasks( + _ context.Context, + filter TaskListFilter, +) ([]domain.PurchaseTask, error) { + repository.filter = filter + return repository.listResult, nil +} + +func (repository *fakeTaskRepository) GetTaskDetail( + context.Context, + string, + string, +) (domain.TaskDetail, error) { + return domain.TaskDetail{}, errors.New("not implemented") +} + +func (repository *fakeTaskRepository) CancelPendingTask( + _ context.Context, + _ string, + _ string, + _ string, + _ time.Time, + _ domain.TaskEvent, +) (domain.PurchaseTask, error) { + return domain.PurchaseTask{}, repository.cancelErr +} + +func mustTaskService( + t *testing.T, + repository TaskRepository, +) *TaskService { + t.Helper() + service, err := NewTaskService( + repository, + fakeClock{}, + &sequenceIDs{}, + ) + if err != nil { + t.Fatalf("NewTaskService() error = %v", err) + } + return service +} + +func assertUsecaseError( + t *testing.T, + err error, + kind ErrorKind, + code string, +) { + t.Helper() + var typed *Error + if !errors.As(err, &typed) { + t.Fatalf("error = %v, want *Error", err) + } + if typed.Kind != kind || typed.Code != code { + t.Fatalf("error = %+v, want kind=%s code=%s", typed, kind, code) + } +} diff --git a/backend-api/migrations/00002_tasks_and_assets.sql b/backend-api/migrations/00002_tasks_and_assets.sql new file mode 100644 index 0000000..2ae7fb7 --- /dev/null +++ b/backend-api/migrations/00002_tasks_and_assets.sql @@ -0,0 +1,144 @@ +-- +goose Up +CREATE TABLE assets ( + id TEXT PRIMARY KEY NOT NULL + CHECK (length(id) = 36), + creator_subject TEXT NOT NULL + CHECK (length(trim(creator_subject)) > 0), + purpose TEXT NOT NULL + CHECK (purpose IN ('TASK_REFERENCE')), + media_type TEXT NOT NULL + CHECK (media_type = 'image/jpeg'), + size_bytes INTEGER NOT NULL + CHECK (size_bytes > 0), + sha256 TEXT NOT NULL + CHECK ( + length(sha256) = 64 + AND sha256 NOT GLOB '*[^0-9a-f]*' + ), + storage_key TEXT NOT NULL UNIQUE + CHECK ( + length(storage_key) > 0 + AND substr(storage_key, 1, 1) <> '/' + AND instr(storage_key, '\') = 0 + AND instr(storage_key, '..') = 0 + ), + created_at TEXT NOT NULL +); + +CREATE INDEX assets_creator_created_idx + ON assets (creator_subject, created_at DESC, id DESC); + +CREATE TABLE purchase_tasks ( + id TEXT PRIMARY KEY NOT NULL + CHECK (length(id) = 36), + creator_subject TEXT NOT NULL + CHECK (length(trim(creator_subject)) > 0), + source_ref TEXT + CHECK ( + source_ref IS NULL + OR ( + length(trim(source_ref)) > 0 + AND length(CAST(source_ref AS BLOB)) <= 256 + ) + ), + title TEXT NOT NULL + CHECK ( + length(trim(title)) > 0 + AND length(title) <= 120 + AND length(CAST(title AS BLOB)) <= 2048 + ), + description TEXT NOT NULL + CHECK (length(CAST(description AS BLOB)) <= 8192), + sku TEXT NOT NULL + CHECK ( + length(trim(sku)) > 0 + AND length(CAST(sku AS BLOB)) <= 512 + ), + image_asset_id TEXT NOT NULL UNIQUE + REFERENCES assets(id) ON UPDATE RESTRICT ON DELETE RESTRICT, + quantity INTEGER NOT NULL + CHECK (quantity > 0), + max_budget_cents INTEGER + CHECK (max_budget_cents IS NULL OR max_budget_cents > 0), + currency TEXT NOT NULL DEFAULT 'CNY' + CHECK (currency = 'CNY'), + status TEXT NOT NULL + CHECK ( + status IN ( + 'PENDING', + 'CLAIMED', + 'RUNNING', + 'WAITING_CONFIRMATION', + 'SUCCEEDED', + 'FAILED', + 'CANCELED' + ) + ), + version INTEGER NOT NULL + CHECK (version > 0), + cancel_reason TEXT + CHECK ( + cancel_reason IS NULL + OR length(CAST(cancel_reason AS BLOB)) <= 500 + ), + canceled_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE UNIQUE INDEX purchase_tasks_creator_source_ref_idx + ON purchase_tasks (creator_subject, source_ref) + WHERE source_ref IS NOT NULL; + +CREATE INDEX purchase_tasks_creator_created_idx + ON purchase_tasks (creator_subject, created_at DESC, id DESC); + +CREATE INDEX purchase_tasks_creator_status_created_idx + ON purchase_tasks ( + creator_subject, + status, + created_at DESC, + id DESC + ); + +CREATE TABLE task_events ( + id TEXT PRIMARY KEY NOT NULL + CHECK (length(id) = 36), + task_id TEXT NOT NULL + REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE, + event_type TEXT NOT NULL + CHECK (event_type IN ('TASK_CREATED', 'TASK_CANCELED')), + message TEXT NOT NULL, + occurred_at TEXT NOT NULL +); + +CREATE INDEX task_events_task_occurred_idx + ON task_events (task_id, occurred_at ASC, id ASC); + +CREATE TABLE idempotency_records ( + creator_subject TEXT NOT NULL, + operation TEXT NOT NULL, + idempotency_key TEXT NOT NULL, + request_sha256 TEXT NOT NULL + CHECK ( + length(request_sha256) = 64 + AND request_sha256 NOT GLOB '*[^0-9a-f]*' + ), + resource_type TEXT NOT NULL + CHECK (resource_type IN ('ASSET', 'PURCHASE_TASK')), + resource_id TEXT NOT NULL + CHECK (length(resource_id) = 36), + created_at TEXT NOT NULL, + PRIMARY KEY (creator_subject, operation, idempotency_key) +); + +-- +goose Down +DROP TABLE IF EXISTS idempotency_records; +DROP INDEX IF EXISTS task_events_task_occurred_idx; +DROP TABLE IF EXISTS task_events; +DROP INDEX IF EXISTS purchase_tasks_creator_status_created_idx; +DROP INDEX IF EXISTS purchase_tasks_creator_created_idx; +DROP INDEX IF EXISTS purchase_tasks_creator_source_ref_idx; +DROP TABLE IF EXISTS purchase_tasks; +DROP INDEX IF EXISTS assets_creator_created_idx; +DROP TABLE IF EXISTS assets; diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 773832a..4c0064a 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -53,8 +53,9 @@ 当前已完成 Phase 0 和 Phase 1:Android 可运行、设备就绪、workflow、私有样本导入、 动态词搜索、最多 5 个候选截图采集、结构化需求提取、候选评估和人工确认停止点均已 -验证。T-201 后端骨架和 T-202 P0 Web/App 低保真原型均已完成,原型已通过自动验证 -和人工确认。下一步按编号开始 T-203,实现任务创建 API 与管理 Web。 +验证。T-201 后端骨架、T-202 P0 原型和 T-203 任务 API/管理 Web 均已完成。下一步 +按编号开始 T-204,实现正式管理账号、角色、会话和设备身份;在此之前业务路由只 +允许 loopback,不能部署给局域网用户使用。 严格按以下顺序推进: diff --git a/docs/02-requirements.md b/docs/02-requirements.md index 4bb268e..9e8581c 100644 --- a/docs/02-requirements.md +++ b/docs/02-requirements.md @@ -6,7 +6,7 @@ | 项 | 当前事实 | | --- | --- | -| 任务来源 | 其他管理后台或本项目管理 Web 采集商品标题、描述、图片、数量和预算。 | +| 任务来源 | 其他管理后台或本项目管理 Web 采集商品标题、SKU、描述、图片、数量和预算。 | | 第一层样本来源 | 本机目录中的蝦皮订单文本和参考图;二者以蝦皮订单号作为同名文件名。 | | 执行方式 | 采购人员使用 Android App 操作拼多多;需求提取、动态词搜索、最多 5 个候选证据采集、匹配建议和人工确认停止点已可运行。 | | 核心痛点 | 人工把图片和描述转成搜索词、逐条比较商品并记录结果,耗时且不一致。 | @@ -25,7 +25,7 @@ | ID | 功能 | 用户结果 | 优先级 | 用户故事 | | --- | --- | --- | --- | --- | -| F-001 | 创建采购任务 | 管理员提交标题、描述、图片、数量和可选预算,获得任务编号。 | P0 | US-001 | +| F-001 | 创建采购任务 | 管理员提交标题、SKU、描述、图片、数量和可选预算,获得任务编号。 | P0 | US-001 | | F-002 | 查看任务状态 | 管理员看到任务阶段、候选摘要、截图和失败原因。 | P0 | US-002 | | F-003 | 手动领取任务 | 空闲且就绪的 App 点击后只领取一条待处理任务。 | P0 | US-003 | | F-004 | 解析采购需求 | 系统从图片和文字提取搜索词、属性及约束,并保留原始输入。 | P0 | US-004 | @@ -46,9 +46,10 @@ ## 五、业务规则 -1. 标题、描述和图片至少满足“标题或描述不为空,且图片存在”。 -2. 数量必须是正整数;最高预算如填写,必须大于零。 -3. 数量和最高预算以管理员输入为准,模型不得更改。 +1. 标题、SKU 和图片必填,描述可为空。 +2. 数量必须是正整数;最高商品总预算如填写,必须大于零。总预算表示当前任务全部 + 数量的商品金额上限,不包含尚无法可靠确认的运费、优惠或支付金额。 +3. SKU、数量和最高商品总预算以管理员输入为准,模型不得更改。 4. 一台设备同一时间最多有一条 `CLAIMED` 或 `RUNNING` 任务。 5. 一条任务同一时间只能被一台设备持有;重复点击不能产生重复领取。 6. App 未就绪时不能开始:无障碍未授权、拼多多未安装、设备离线或已有运行任务都 @@ -95,11 +96,11 @@ T-004 已固定首版规则:推荐私有目录为被 Git 忽略的 `private-fi ### 单任务验收 -- F-001/US-001/IX-001:合法输入创建后出现唯一任务编号和 `PENDING` 状态;非法 - 数量、预算或缺失图片时在原表单显示可修复错误。 -- F-002/US-002/IX-002:状态变化后管理页面能看到最新阶段、时间、设备、候选摘要 +- F-001/US-001/IX-002:合法输入创建后出现唯一任务编号和 `PENDING` 状态;非法 + 标题、SKU、数量、预算或缺失图片时在原表单显示可修复错误。 +- F-002/US-002/IX-003:状态变化后管理页面能看到最新阶段、时间、设备、候选摘要 或结构化错误;无权限用户不可访问。 -- F-003/US-003/IX-004:App 点击“获取任务”后原子领取一条任务;重复点击或多请求 +- F-003/US-003/IX-005:App 点击“获取任务”后原子领取一条任务;重复点击或多请求 不得领取第二条或把同一任务分配两次。 - F-004/US-004/IX-006:解析结果包含搜索词、识别属性、预算、数量、置信度和警告; 原始输入保留,硬约束与输入一致。T-103 已实现版本化 schema、0.75 置信阈值、 diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 9584df6..4742d82 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -22,12 +22,12 @@ | 数据迁移 | Goose v3.26.0,使用嵌入式 SQL migration | 已验证 | v3.26.0 是已核实仍声明 Go 1.23.0 的最高版本;v3.27.x 要求 Go 1.25。 | | 管理 Web | Gin + `html/template` + `embed` + 少量原生 JS/CSS | MVP 已定 | 不单独引入 SPA 工程,模板和静态资源随服务构建。 | | 数据库 | SQLite | MVP 已定 | 单服务、单设备验证足够;多实例或并发提升前迁移 PostgreSQL。 | -| 图片/截图 | 后端受控本地文件目录,数据库存元数据 | MVP 已定 | 禁止把二进制直接塞入日志;生产再评估对象存储。 | +| 图片/截图 | 后端受控本地文件目录 + `golang.org/x/image` v0.28.0 | 已验证 | JPEG/PNG/WebP 真解码后白底缩放并编码为 JPEG;数据库只存元数据和随机相对键。 | | 管理鉴权 | 单个种子管理账号 + 服务端会话 Cookie | MVP 已定 | 密码只保存哈希;完整 RBAC 为 V2。 | | App 鉴权 | 采购员登录态 + 设备绑定令牌 | 目标已定,细节待实现 | 人员身份与设备身份分离;令牌只保存哈希。 | | VLM 接入 | 应用内统一适配器,优先兼容 OpenAI 风格多模态接口 | 需求提取与候选评估已实现,供应商待定 | T-103/T-104 使用严格 JSON Schema、单候选单次调用和 2048 px 图片上限;GUI-Owl/MAI-UI 动作模型不具备需求提取能力。 | | 通知 | MVP 不使用推送 | 已定 | 点击“获取任务”调用原子 claim API;V2 再评估厂商推送/WebSocket。 | -| 后端测试 | 标准库 `testing` + `httptest` | 骨架已验证 | 当前 25 个测试覆盖配置、SQLite pragma/外键、迁移、健康检查、安全 recovery、错误合约、路由和有界关闭;后续任务再增加状态机、权限与幂等。 | +| 后端测试 | 标准库 `testing` + `httptest` | T-203 已验证 | 当前 99 个测试覆盖配置、迁移、图片限制、领域校验、事务幂等、分页、HTTP 生命周期、CSRF、安全响应头和有界关闭。 | | Android 测试 | Gradle `test` + `kotlinx-coroutines-test` 1.7.3 + 真实设备 smoke | Phase 1 探针已验证 | 166 次测试覆盖 runner、动态页面分类、受控证据、VLM schema、人工确认策略与隐私;OnePlus PKG110 上完成私有 fixture + 本机 mock 的需求提取和 5 候选评估 smoke。 | | 部署 | 单机局域网 Go 服务;容器化后置 | MVP 已定 | Android 测试机必须能通过 HTTPS 或受控测试网络访问。 | @@ -125,7 +125,8 @@ backend-api/ docs/ ``` -`domain/usecase/repository/sqlite/web` 将在对应业务任务出现真实代码时创建,不提交空包。 +T-203 已建立 `domain/usecase/repository/sqlite` 和 `transport/webui`,handler 不直接 +执行 SQL,Web 与 JSON API 复用同一 usecase。 ## 构建与运行命令 @@ -136,7 +137,7 @@ docs/ | Android 测试任务 | `android-buyer\gradlew.bat test --no-daemon` | 已验证;当前全工程 166 次测试通过 | | Android 安装/启动 | `$env:RUN_START_COMMAND="1"; .\init.ps1` | 已在 Android 16 真机验证 | | 后端依赖 | `$env:GOTOOLCHAIN="local"; go mod download`(在 `backend-api/`) | 已验证 | -| 后端测试 | `$env:GOTOOLCHAIN="local"; go test ./...; go vet ./...` | 已验证;25 个测试 | +| 后端测试 | `$env:GOTOOLCHAIN="local"; go test ./...; go vet ./...` | 已验证;99 个测试 | | 后端构建 | `go build -o bin/cmroubao-api.exe ./cmd/api` 与 `./cmd/migrate` | 已验证 | | 后端启动 | `go run ./cmd/api` | 已完成本机 HTTP smoke | | 数据迁移 | `go run ./cmd/migrate up` | 已完成 `status/up/up/down/up` smoke | diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 8f69b3a..c3e5013 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -69,8 +69,14 @@ Authorization、Cookie 或 panic 内容。 SQLite 默认文件为被忽略的 `backend-api/var/cmroubao.db`,连接启用 foreign keys、 5 秒 busy timeout、WAL 和 immediate transaction,并限制单连接以匹配单进程 MVP。 数据库由 `cmd` 显式打开和关闭,不存在包级单例。Goose SQL 通过 `embed.FS` 加载, -`cmd/migrate` 显式执行 `up/down/status`;T-201 只验证迁移链路,业务表由后续任务 -按 API 和领域模型建立。 +`cmd/migrate` 显式执行 `up/down/status`。API 启动前检查所有迁移均已应用,不在 +服务进程内自动改表;T-203 已用 `00002_tasks_and_assets.sql` 建立任务、资产、事件 +和幂等记录表。 + +T-204 管理会话完成前,T-203 的业务路由只允许 loopback 来源,Web 写操作还校验 +浏览器 Cookie 与表单字段的随机双提交 CSRF token;这不是正式登录,不能据此开放 +局域网监听。模板/API handler 仍调用同一 usecase,T-204 只替换主体和会话门禁, +不重写业务规则。 ### 2.2 Android App @@ -350,9 +356,10 @@ IDLE | `id` | PK | 任务 ID | | `title` | NOT NULL | 原始标题 | | `description` | NOT NULL | 原始说明,可为空字符串 | +| `sku` | NOT NULL | 原始 SKU,不得由模型改写 | | `image_asset_id` | FK, NOT NULL | 原始参考图 | | `quantity` | `> 0` | 权威数量 | -| `max_budget` | `> 0`, nullable | 权威最高预算,币种为 CNY | +| `max_budget` | `> 0`, nullable | 全部数量的权威最高商品总预算,币种为 CNY | | `status` | NOT NULL | 任务状态枚举 | | `created_by` | FK | 创建人 | | `claimed_by_device_id` | FK, nullable | 当前设备 | @@ -380,6 +387,8 @@ IDLE - `execution_events` 只追加,记录 step、事件类型、可读消息和时间;不保存密码或 token。 - `assets` 保存文件相对路径、媒体类型、大小、哈希、创建者和保留时间。 - 原图和截图必须通过鉴权接口读取,文件名不能直接作为公开 URL。 +- 任务参考图上传可接受 JPEG/PNG/WebP,但后端必须先真实解码、限制字节与像素,再 + 统一规范化为匿名 JPEG;未来 Android 读取的资产不能依赖原始扩展名或声明 MIME。 ## 六、API 和并发边界 diff --git a/docs/07-user-stories.md b/docs/07-user-stories.md index e8cf140..3e261b7 100644 --- a/docs/07-user-stories.md +++ b/docs/07-user-stories.md @@ -20,8 +20,8 @@ - 关联页面:管理 Web `/tasks/new` - 前置条件:管理员已登录;参考图片可读取。 -作为采购管理员,我想提交商品标题、描述、参考图、数量和预算,从而让采购设备拿到 -没有歧义的原始采购要求。 +作为采购管理员,我想提交商品标题、SKU、描述、参考图、数量和总预算,从而让采购 +设备拿到没有歧义的原始采购要求。 **范围** @@ -31,7 +31,7 @@ **验收场景** 1. 合法输入提交后,任务列表出现唯一编号、摘要和 `PENDING` 状态。 -2. 数量、预算、图片或文本不满足规则时,保留已填内容并指出修复位置。 +2. 标题、SKU、数量、总预算、图片或描述不满足规则时,保留已填内容并指出修复位置。 3. 网络超时后重复提交同一幂等请求,不产生重复任务。 ## US-002 跟踪任务和查看证据 diff --git a/docs/08-interaction-checklist.md b/docs/08-interaction-checklist.md index aebd34c..8193867 100644 --- a/docs/08-interaction-checklist.md +++ b/docs/08-interaction-checklist.md @@ -52,14 +52,15 @@ **正常路径** -1. 输入标题、描述、数量、可选预算并选择一张图片。 +1. 输入标题、SKU、描述、数量、可选商品总预算并选择一张图片。 2. 浏览器展示图片预览、文件名和大小。 3. 提交期间锁定重复提交并显示“正在创建”。 4. 成功后进入任务详情,显示编号和 `PENDING`。 **状态与异常** -- 数量默认 1,只接受正整数;预算为空表示不设置上限。 +- 标题和 SKU 必填,描述可空;数量默认 1,只接受正整数。 +- 商品总预算为空表示不设置上限;填写时表示当前任务全部数量的商品金额上限。 - 图片类型、大小或解码失败时在图片控件旁提示。 - 上传成功但创建失败时重用已上传 asset,不重复上传。 - 网络状态不确定时使用幂等键查询结果,不直接再创建。 @@ -177,7 +178,8 @@ - 进程重启时查询任务和 execution;无法证明可安全恢复则标记失败。 - 验证码、风控、登录、支付和未知页面立即停止,不展示“自动继续”。 - AI 超时最多按配置重试;输出无效或低置信度转人工。 -- 任意候选达到预算上限校验失败时不能进入接受状态。 +- 候选商品价乘以任务数量达到总预算上限校验失败,或运费/优惠导致总价无法确认时, + 不能进入接受状态。 **可访问性与反馈** @@ -203,7 +205,8 @@ **状态与异常** - 没有候选时只允许确认“无匹配”或“需人工处理”。 -- 存在超预算或关键属性未知时禁用“接受候选”并说明原因。 +- 存在商品总价超预算、最终商品金额无法确认或关键属性未知时禁用“接受候选”并 + 说明原因。 - 回传超时:保留选择和原因,查询最终状态后再重试。 - 返回键不能恢复自动点击;等待确认一旦到达就是硬停止点。 - 所有结果都写入 `order_submitted=false`。 diff --git a/docs/api.md b/docs/api.md index b68540e..8664911 100644 --- a/docs/api.md +++ b/docs/api.md @@ -13,6 +13,11 @@ - 分页使用 `limit` 和不透明 `cursor`;MVP `limit` 最大 100。 - 客户端不得根据 HTTP 超时判断操作失败,必须查询资源最终状态。 +T-204 管理会话完成前,T-203 的管理 Web 和管理 API 只接受来源地址为 loopback 的 +请求,Web 写操作还要求 SameSite Cookie 与表单字段匹配的随机双提交 CSRF token。 +该门禁只用于阻止业务路由被提前暴露,不是正式身份认证;`/login`、管理会话和 +局域网访问仍由 T-204 实现。 + 通用错误: ```json @@ -113,7 +118,8 @@ HTTP 语义: 表单字段: -- `file`:必填,MVP 允许 JPEG/PNG/WebP;实际大小上限在配置中固定。 +- `file`:必填,MVP 允许可解码 JPEG/PNG/WebP;请求最多 20 MiB、最长边最多 + 10000 px、总像素最多 25 MP。 - `purpose`:`TASK_REFERENCE` 或 `EXECUTION_EVIDENCE`。 - `task_id`:证据图片必填;参考图创建时为空。 @@ -129,6 +135,13 @@ HTTP 语义: } ``` +T-203 成功返回 `201`。使用相同 `Idempotency-Key` 和相同图片内容重试时返回同一 +资产;同 key 不同内容返回 `409`。 + +`TASK_REFERENCE` 在后端统一白底合成、缩放到最长边不超过 2048 px,并以质量 90 +编码为匿名 JPEG。响应的 `media_type`、`size_bytes` 和 `sha256` 都描述规范化结果, +不描述原始上传文件。T-203 尚不接受 `EXECUTION_EVIDENCE`。 + ### `GET /api/v1/assets/{asset_id}/content` 受鉴权的文件流。只能读取用户有权查看的任务资产;不返回服务端文件路径。 @@ -143,6 +156,7 @@ HTTP 语义: { "source_ref": "external-admin-task-10001", "title": "黑色双肩包", + "sku": "BLACK-20L", "description": "容量约20L,外观接近参考图", "image_asset_id": "2bbc1bf2-30f7-497e-a52b-bcb4c61d57c3", "quantity": 2, @@ -152,9 +166,12 @@ HTTP 语义: 规则: -- `title` 或 `description` 至少一个非空,`image_asset_id` 必填。 +- `title`、`sku` 和 `image_asset_id` 必填,`description` 可为空。 +- `title` 最多 120 个 Unicode 字符且不超过 2048 个 UTF-8 字节;`sku` 不超过 + 512 个 UTF-8 字节;`description` 不超过 8192 个 UTF-8 字节。 - `quantity` 是正整数。 -- `max_budget` 可为空,否则为大于零、最多两位小数的 CNY 金额。 +- `max_budget` 可为空,否则为大于零、最多两位小数的 CNY 金额,表示当前任务全部 + 数量的最高商品总预算,不含尚无法确认的运费或优惠。 - 同一调用方的 `source_ref` 如填写必须唯一。 成功返回 `201`: @@ -164,6 +181,7 @@ HTTP 语义: "id": "37c9c715-9b51-4ed5-984e-66dad2710c71", "status": "PENDING", "title": "黑色双肩包", + "sku": "BLACK-20L", "quantity": 2, "max_budget": "200.00", "created_at": "2026-07-25T08:30:00Z" @@ -172,7 +190,9 @@ HTTP 语义: ### `GET /api/v1/tasks` -管理端列表。可选参数:`status`、`created_from`、`created_to`、`limit`、`cursor`。 +管理端列表。可选参数:`q`、`status`、`created_from`、`created_to`、`limit`、 +`cursor`。`q` 匹配任务 ID、`source_ref`、标题或 SKU;默认 `limit=20`,最大 100。 +排序固定为 `created_at DESC, id DESC`,不透明 cursor 同时编码这两个字段。 ```json { @@ -180,6 +200,7 @@ HTTP 语义: { "id": "37c9c715-9b51-4ed5-984e-66dad2710c71", "title": "黑色双肩包", + "sku": "BLACK-20L", "status": "RUNNING", "quantity": 2, "device_name": "pdd-phone-01", @@ -212,6 +233,9 @@ HTTP 语义: } ``` +T-203 只实现 `PENDING -> CANCELED`;其他状态返回 `409`。执行中设置取消请求及 App +安全检查点响应属于 T-205。 + ## 设备与领取 ### `POST /api/v1/devices/heartbeat` @@ -249,6 +273,7 @@ App 空闲或运行时上报设备状态;运行时任务续租使用任务专 "id": "37c9c715-9b51-4ed5-984e-66dad2710c71", "status": "CLAIMED", "title": "黑色双肩包", + "sku": "BLACK-20L", "description": "容量约20L,外观接近参考图", "image_asset_id": "2bbc1bf2-30f7-497e-a52b-bcb4c61d57c3", "quantity": 2, diff --git a/docs/current-state.md b/docs/current-state.md index 77c007a..da7ad4a 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,23 +5,25 @@ ## 当前快照 - 日期:2026-07-26 -- 阶段:T-202 P0 Web/App 低保真原型已确认完成,准备开始 T-203 -- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201、T-202 +- 阶段:T-203 任务创建 API 与管理 Web 已完成,准备开始 T-204 +- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-203 均已纳入 Git 历史 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 - Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留 -- 后端:Go 1.23.0 + Gin 1.11.0 + SQLite + Goose 3.26.0 骨架已建立;Go Blueprint - v0.10.11 只作为一次性输入,危险默认与演示逻辑已移除 +- 后端:Go 1.23.0 + Gin 1.11.0 + SQLite + Goose 3.26.0;已实现图片规范化、任务 + 创建/查询/取消 API 和 SSR 管理 Web,Go Blueprint 只作为一次性骨架输入 - 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、 Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 - 测试:`lintDebug test assembleDebug` 成功;App 两个变体、task contract 和导入器 共 26 份报告、166 次测试,0 failure、0 error、0 skipped -- 后端测试:`GOTOOLCHAIN=local go test -count=1 ./...` 共 25 个测试通过; - `go vet ./...`、API/migration Windows 构建和根 `init.ps1` 均通过 +- 后端测试:`GOTOOLCHAIN=local go test -count=1 ./...` 共 99 个测试通过; + 全包 race、`go vet ./...`、API/migration Windows 构建和根 `init.ps1` 均通过 - 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright 以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、 脚本错误或外部请求,Android 可见交互控件均不小于 44px +- 管理 Web:真实 Gin/SQLite 流程已完成图片上传、任务创建、列表、详情参考图和 + 待领取取消;同三种视口无横向溢出,可见操作控件不小于 44px - Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、 用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步 - TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture @@ -56,20 +58,20 @@ | `docs/tasks/T-104.md` | DONE | 动态搜索、候选严格评估、本地建议和人工确认点 | | `docs/tasks/T-201.md` | DONE | Go-Gin、SQLite、Goose migration 和健康检查骨架 | | `docs/tasks/T-202.md` | DONE | 生成并确认 P0 Web/App 低保真原型 | +| `docs/tasks/T-203.md` | DONE | 图片/任务 API、SQLite 业务层和 SSR 管理 Web | | `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | | `android-buyer/task-contract/` | 已有 | Android/CLI 共享 ProbeTask 与 TaskSource | | `android-buyer/tools/shopee-importer/` | 已有 | 开发机私有 fixture 导入 CLI | -| `backend-api/` | 已有 | Go-Gin API、SQLite、migration、健康检查和测试骨架 | +| `backend-api/` | 已有 | Go-Gin 任务 API、SQLite、图片存储、管理 Web 和 migration | | `init.ps1` / `init.sh` | 已验证/待跨平台 | Windows 同时验证 Android/Go;Unix 入口待 Linux/WSL 复核 | ## 任务摘要 -- 已完成:T-001 至 T-004、T-101 至 T-104、T-201、T-202。 +- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-203。 - 正在进行:无。 -- 下一个可领取任务:T-203 实现任务创建 API 与管理 Web;T-204 也已满足依赖, - 但按编号顺序在 T-203 之后处理。 +- 下一个可领取任务:T-204 实现账号、角色、会话和设备身份;完成后 T-205 可开始。 ## 当前可运行内容 @@ -107,6 +109,12 @@ go run ./cmd/api `http.Server.Shutdown` 由集成测试验证在 1 秒预算内结束。当前 Windows 没有可用 WSL 发行版,因此 `init.sh` 未实际运行;Windows 标准入口已验证。 +2026-07-26 完成 T-203:新增两条业务 migration、图片规范化存储、任务领域/用例/ +SQLite 仓储、管理 API 和 SSR Web。创建/上传幂等,列表稳定分页,详情可读取同源 +规范化参考图,只有 `PENDING` 可取消。T-204 前业务路由仅限 loopback,Web 写操作 +使用双提交 CSRF;这不是正式鉴权。真实 HTTP/Playwright 和 migration CLI smoke、 +99 个 Go 测试、全包 race/vet、Windows 构建与根 `init.ps1` 全部通过。 + ## 维护规则 发生以下变化时覆盖更新本文: diff --git a/docs/design/admin-task-create.html b/docs/design/admin-task-create.html index 95a6db7..6e5d69c 100644 --- a/docs/design/admin-task-create.html +++ b/docs/design/admin-task-create.html @@ -210,6 +210,12 @@

+
+ + +

+

SKU 是不可覆盖的原始采购要求。

+
@@ -221,7 +227,7 @@

- +

@@ -284,7 +290,7 @@ }); function clearFeedback() { - ['title', 'quantity', 'budget', 'image'].forEach((name) => { + ['title', 'sku', 'quantity', 'budget', 'image'].forEach((name) => { document.querySelector(`#${name}-error`).textContent = ''; }); notice.style.display = 'none'; @@ -303,18 +309,23 @@ clearFeedback(); let firstInvalid = null; const title = document.querySelector('#title'); + const sku = document.querySelector('#sku'); const quantity = document.querySelector('#quantity'); const budget = document.querySelector('#budget'); if (!title.value.trim()) { document.querySelector('#title-error').textContent = '请输入商品标题。'; firstInvalid ||= title; } + if (!sku.value.trim()) { + document.querySelector('#sku-error').textContent = '请输入 SKU。'; + firstInvalid ||= sku; + } if (!Number.isInteger(Number(quantity.value)) || Number(quantity.value) < 1) { document.querySelector('#quantity-error').textContent = '数量必须是大于 0 的整数。'; firstInvalid ||= quantity; } if (budget.value && Number(budget.value) <= 0) { - document.querySelector('#budget-error').textContent = '预算必须大于 0,或留空。'; + document.querySelector('#budget-error').textContent = '商品总预算必须大于 0,或留空。'; firstInvalid ||= budget; } if (!image.files.length) { diff --git a/docs/design/admin-task-detail.html b/docs/design/admin-task-detail.html index f677c11..e0181d4 100644 --- a/docs/design/admin-task-detail.html +++ b/docs/design/admin-task-detail.html @@ -289,8 +289,9 @@

桌面用分格收纳盒,浅灰色,塑料材质。 需要带可拆分隔板,外观接近参考图。

+ SKU:浅灰色 · 中号 数量:2 - 最高总预算:60.00 元 + 最高商品总预算:60.00 元 关键属性:浅灰 / 分隔板
diff --git a/docs/design/android-candidate-confirm.html b/docs/design/android-candidate-confirm.html index 0ff846d..729c0d6 100644 --- a/docs/design/android-candidate-confirm.html +++ b/docs/design/android-candidate-confirm.html @@ -38,14 +38,14 @@

管理员原始要求

-
商品
透明抽屉式收纳盒
SKU
白色 · 中号
数量
2 件
最高预算
¥ 48.00 / 件
+
商品
透明抽屉式收纳盒
SKU
白色 · 中号
数量
2 件
最高商品总预算
¥ 96.00

候选 1 / 3

候选标题
透明可叠放抽屉收纳盒
单价
¥ 39.90

匹配与风险

-
  • 符合 颜色:白色
  • 符合 规格:中号
  • 符合 单价未超过预算
+
  • 符合 颜色:白色
  • 符合 规格:中号
  • 符合 商品总价 ¥ 79.80 未超过预算

人员结论

@@ -65,7 +65,7 @@ diff --git a/docs/design/android-task-preview.html b/docs/design/android-task-preview.html index 6ede97e..182a327 100644 --- a/docs/design/android-task-preview.html +++ b/docs/design/android-task-preview.html @@ -31,13 +31,13 @@
商品标题
透明抽屉式桌面收纳盒
SKU
白色 · 中号
数量
2 件
-
最高预算
¥ 48.00 / 件
+
最高商品总预算
¥ 96.00
描述
带把手,可叠放,优先选择相同外观。

AI 辅助理解 · 待验证

-

可能关键词:透明、抽屉式、中号、可叠放。最终数量和预算始终以原始要求为准。

+

可能关键词:透明、抽屉式、中号、可叠放。最终 SKU、数量和商品总预算始终以原始要求为准。

diff --git a/docs/tasks/T-203.md b/docs/tasks/T-203.md new file mode 100644 index 0000000..97b355d --- /dev/null +++ b/docs/tasks/T-203.md @@ -0,0 +1,162 @@ +--- +id: T-203 +title: 实现任务创建 API 与管理 Web +phase: 2 +deps: + - T-201 + - T-202 +status: DONE +created: 2026-07-26 +context_ref: 2b265c9 +work_branch: main +write_paths: + - backend-api/** + - init.ps1 + - init.sh + - docs/00-ai-start-here.md + - docs/02-requirements.md + - docs/03-tech-stack.md + - docs/04-architecture.md + - docs/05-coding-rules.md + - docs/07-user-stories.md + - docs/08-interaction-checklist.md + - docs/api.md + - docs/current-state.md + - docs/design/admin-task-create.html + - docs/design/admin-task-detail.html + - docs/design/android-task-preview.html + - docs/design/android-candidate-confirm.html + - docs/tasks/T-203.md + - progress.md +--- + +## 问题 / 背景 + +T-201 只有健康检查、SQLite 和 migration 骨架,T-202 只确认了页面信息结构。当前还 +不能从管理端安全上传参考图、创建 `PENDING` 任务、查看真实列表/详情或取消待领取 +任务,因此 T-205 的原子领取没有可消费的数据。 + +审计还发现正式合约存在四个会阻断后续 App 接入的问题: + +- 现有 Android `ProbeTask` 强制标题和 SKU 非空,但任务创建 API/数据表遗漏 SKU。 +- 已确认 Web 使用“最高总预算”,部分 Android 原型误写成每件预算。 +- API/原型允许 JPEG/PNG/WebP,但 Android 需求提取当前只接受 JPEG。 +- 管理会话属于 T-204,T-203 不能伪造登录或永久开放匿名管理接口。 + +## 关联需求与交互 + +- 功能:F-001、F-002,以及 F-007 的最小可见错误。 +- 用户故事:US-001、US-002。 +- 交互:IX-002、IX-003;登录 IX-001 留给 T-204。 +- 页面:`/tasks`、`/tasks/new`、`/tasks/{id}`。 +- API:资产上传、任务创建/列表/详情、待领取任务取消。 + +## 已定合约 + +1. 标题和 SKU 必填;描述可空。标题最多 120 个 Unicode 字符且不超过 2048 个 + UTF-8 字节,SKU 不超过 512 个 UTF-8 字节,描述不超过 8192 个 UTF-8 字节。 +2. `max_budget` 表示当前任务全部数量的最高商品总预算,币种 CNY,不含无法确认的 + 运费或优惠;数据库用整数分保存,API 固定输出两位小数字符串。 +3. 参考图上传接受可解码 JPEG/PNG/WebP,原始请求最多 20 MiB、最长边最多 + 10000 px、总像素最多 25 MP;后端统一缩放到最长边不超过 2048 px、白底合成并 + 编码为匿名 JPEG。响应的媒体类型、大小和 SHA-256 都指向规范化结果。 +4. SKU、数量和总预算属于不可变原始事实,任何派生结果都不能覆盖。 +5. 列表增加 `q`,匹配任务 ID、来源引用、标题或 SKU;分页固定按 + `created_at DESC, id DESC`,cursor 同时包含两个字段。 +6. T-203 只提前实现事务化 `PENDING -> CANCELED`;其他状态取消返回冲突,完整取消 + 请求和状态机属于 T-205。 +7. T-204 前,管理 Web/API 业务路由只接受 loopback 来源;Web 写操作还必须携带 + SameSite Cookie 与表单字段匹配的随机双提交 CSRF token,API 创建/上传必须使用 + JSON 或 multipart 与自定义幂等头。`/login` 不提供假实现,局域网部署仍被明确 + 阻塞。 +8. 创建、上传均按调用主体、操作和 `Idempotency-Key` 幂等;同 key 同请求返回原 + 资源,同 key 不同请求返回 `409`。 + +## 方案 + +1. 增加 assets、purchase_tasks、task_events 和 idempotency_records migration, + 包含外键、CHECK、唯一约束和 rollback。 +2. 建立 domain/usecase/repository/sqlite 分层;handler 和模板不得直接执行 SQL。 +3. 建立受控文件存储与图片规范化器,随机相对路径、同目录临时文件原子改名,失败时 + 清理;数据库和响应永不暴露绝对路径。 +4. 实现 JSON/multipart API 和稳定错误映射,保持无默认 CORS、request ID 与 + recovery 安全约束。 +5. 用 `html/template`、`embed`、原生 CSS/JS 实现列表、新建和详情;真实数据为空时 + 显示空状态,不把原型假数据写入数据库。 +6. Web 表单无 JS 也可完成,使用 PRG `303`;JS 只负责图片预览、防重复、离开提醒 + 和取消确认。 + +## 验收要点 + +- [x] migration 可 up/down/up,约束和外键有效,启动前 migration 门禁明确。 +- [x] JPEG/PNG/WebP 被真实解码并规范化为 JPEG;伪 MIME、损坏、超限和像素炸弹拒绝。 +- [x] 合法任务创建为唯一 `PENDING`,原始 title/SKU/quantity/budget/image 不可变。 +- [x] 上传和创建幂等;同 key 不同请求冲突,并发重试不产生重复资源。 +- [x] 列表筛选、搜索、稳定 cursor 和详情返回真实数据,404 不泄露内部信息。 +- [x] 只有 `PENDING` 可立即取消;终态或不支持状态返回稳定冲突。 +- [x] Web 覆盖空列表、创建字段错误、图片错误、成功跳转、详情和取消二次确认。 +- [x] 非 loopback 业务请求、缺失/错误 CSRF、错误 Content-Type 被拒绝。 +- [x] HTML 自动转义,日志/响应不含 Cookie、文件绝对路径、私有样本或请求正文。 +- [x] Go test/race/vet/gofmt、迁移 smoke、真实 HTTP/Web smoke 和 Playwright 通过。 + +## 边界 + +- 不实现管理登录、Cookie 会话、用户表/密码哈希或设备鉴权;属于 T-204。 +- 不实现 claim、租约、执行状态机、候选、执行证据或 App HTTP TaskSource。 +- 不开放非 loopback 管理使用;T-204 完成前不能部署到局域网供其他用户访问。 +- 不实现对象存储、ORM、SPA、WebSocket、推送或自动下单。 +- 不提交运行时数据库、上传文件、密钥或真实订单/店铺/商品数据。 + +## 执行记录 + +### 2026-07-26:任务开始 + +- 基于提交 `2b265c9` 开始,工作区干净。 +- codebase-memory MCP 本轮未暴露 graph 工具,按项目规则回退到 `rg` 和定点文件读取。 +- 已审计 T-201 现有 config/database/migration/httpapi/cmd 骨架、T-202 原型以及 + requirements/API/architecture/IX,先固定上述跨层合约再编码。 + +### 2026-07-26:合约和实现 + +- 修正正式需求和原型中的 SKU、最高总预算、图片格式与验收编号;总预算统一指当前 + 任务全部数量的商品预算,模型不得覆盖 SKU、数量或预算。 +- 新增 `00002_tasks_and_assets.sql`,建立 assets、purchase_tasks、task_events 和 + idempotency_records;字段长度、UTF-8 字节、外键、状态、金额和唯一性均有数据库 + 约束。 +- 建立 domain/usecase/repository/sqlite 分层,上传和创建事务化幂等,列表按 + `created_at DESC, id DESC` 稳定分页,待领取取消使用状态与 version 条件更新。 +- 文件层真实解码 JPEG/PNG/WebP,限制 20 MiB、10000 px 和 25 MP,白底缩放至最长 + 边 2048 px 后以质量 90 编码为随机相对键 JPEG;绝对路径不进入数据库外部响应。 +- Gin API 实现上传/读取资产、创建/列表/详情/取消任务;业务路由在 T-204 前只接受 + loopback,错误使用稳定 code、request ID 和 no-store 响应。 +- 管理 Web 使用嵌入模板/CSS/JS,实现真实空列表、搜索筛选、创建、图片预览与复用、 + 详情参考图和取消确认;使用双提交 CSRF、PRG 303、HTML 自动转义和固定 CSP。 +- API 启动前检查全部 migration;存在 pending 版本时拒绝启动并明确提示执行 + `migrate up`,服务进程不自动修改数据库。 + +### 2026-07-26:自动验证 + +- `GOTOOLCHAIN=local go test -count=1 ./...`:99 个测试通过;覆盖领域校验、金额、 + 图片限制、路径防护、迁移约束、事务幂等、并发、分页、HTTP 生命周期、CSRF 和 + 模板安全。 +- `go test -race -count=1 ./...`、`go vet ./...`、`gofmt -l cmd internal migrations` + 和 API/migration Windows 构建全部通过。 +- 真实 migration CLI 完成 `status -> up -> up -> down -> API gate -> up`:首次 + `applied=2`、重复为 0,回滚 v2 后 API 以退出码 1 拒绝旧库,恢复后 v1/v2 均 + `applied`。 +- `httptest` 使用临时真实 SQLite/asset 目录跑通:上传及幂等重放、创建及重放、SKU + 搜索、详情、JPEG 内容读取、取消和重复取消;非 loopback 和缺失幂等键被拒绝。 +- Playwright 连接真实 Gin/SQLite 进程完成创建任务和详情读取;1440x900、 + 390x844、360x800 均无横向溢出,可见操作控件最小 44 px,规范化参考图 + `naturalWidth=64`、`naturalHeight=48` 且成功加载,只有同源 HTML/CSS/JS/资产请求, + 新页面 console 为 0 error、0 warning;取消对话框默认聚焦“保留任务”。 +- 根 `init.ps1` 通过:Android `test assembleDebug` 共 76 个 Gradle task 成功, + 现有 166 次测试保持通过;随后 Go 测试、vet、格式检查和两个入口构建再次通过。 + +### 2026-07-26:隐私与收尾 + +- 测试只使用代码生成的脱敏小图、标题和 SKU;未读取、复制或提交本地蝦皮订单、 + 店铺名、真实商品文字或图片。 +- Playwright、数据库、资产和 exe smoke 产物只保存在被忽略的 `.local/`、`bin/` + 或 `var/`,未纳入 Git;业务响应和管理页面不显示客户端文件名或服务端存储路径。 +- T-203 验收全部满足,状态改为 `DONE`;T-204 可开始实现正式管理会话和角色门禁。 diff --git a/progress.md b/progress.md index 536a8f5..f248d54 100644 --- a/progress.md +++ b/progress.md @@ -119,3 +119,11 @@ 表单、二次确认、安全停止、人工结论、结果同步和设备状态交互。 - 影响:页面区块、控件集合、文案和主要动作已获得人工确认;T-203、T-204 和 T-206 可按依赖使用该原型作为实现输入,但不得复制低保真原型代码到生产界面。 + +## 2026-07-26 任务 API 与管理 Web + +- 类型:阶段完成 +- 内容:完成 T-203;建立任务/资产/事件/幂等数据模型、图片规范化、任务 API 和 + SSR 管理 Web,并用 loopback 与双提交 CSRF 作为 T-204 前的临时暴露门禁。 +- 影响:管理人员已能在本机创建、搜索、查看和取消待领取任务;T-204 下一步补正式 + 账号、角色、会话和设备身份,完成后 T-205 可接入 App 原子领取与租约状态机。