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, "