feat(backend): implement task creation and admin web
This commit is contained in:
+15
-3
@@ -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 门禁不是身份认证,不能
|
||||
通过修改监听地址把管理页面直接开放到局域网。
|
||||
|
||||
+100
-3
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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}$`,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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: `<script>alert("private")</script>`,
|
||||
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, `<script>alert("private")</script>`) ||
|
||||
!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 != "<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, "<style") ||
|
||||
strings.Contains(body, "<script>") {
|
||||
t.Fatal("page contains inline style or script incompatible with CSP")
|
||||
}
|
||||
assertSecurityHeaders(t, response)
|
||||
}
|
||||
|
||||
func TestCreateTaskRejectsCSRFBeforeCallingService(t *testing.T) {
|
||||
service := &fakeService{}
|
||||
router := newTestRouter(t, service)
|
||||
body, contentType := multipartBody(t, map[string]string{
|
||||
"csrf_token": "invalid",
|
||||
"title": "标题",
|
||||
"sku": "SKU-1",
|
||||
"quantity": "1",
|
||||
"upload_key": mustToken(t),
|
||||
"create_key": mustToken(t),
|
||||
}, "image", "reference.jpg", []byte("not inspected"))
|
||||
|
||||
response := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodPost,
|
||||
"/tasks",
|
||||
body,
|
||||
contentType,
|
||||
)
|
||||
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body)
|
||||
}
|
||||
if service.uploadCalls != 0 || service.createCalls != 0 {
|
||||
t.Fatalf(
|
||||
"service calls = upload %d, create %d",
|
||||
service.uploadCalls,
|
||||
service.createCalls,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTaskValidationRetainsEscapedSafeFields(t *testing.T) {
|
||||
service := &fakeService{}
|
||||
router := newTestRouter(t, service)
|
||||
cookie := getCSRFCookie(t, router)
|
||||
body, contentType := multipartBody(t, map[string]string{
|
||||
"csrf_token": cookie.Value,
|
||||
"title": "",
|
||||
"sku": "",
|
||||
"description": `<img src=x onerror="alert(1)">`,
|
||||
"quantity": "0",
|
||||
"max_budget": "1.001",
|
||||
"upload_key": mustToken(t),
|
||||
"create_key": mustToken(t),
|
||||
}, "", "", nil)
|
||||
request := httptest.NewRequest(http.MethodPost, "/tasks", body)
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
request.AddCookie(cookie)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body)
|
||||
}
|
||||
rendered := response.Body.String()
|
||||
for _, message := range []string{
|
||||
"请输入商品标题",
|
||||
"请输入 SKU",
|
||||
"数量必须是大于 0 的整数",
|
||||
"最多两位小数",
|
||||
"<img src=x onerror="alert(1)">",
|
||||
} {
|
||||
if !strings.Contains(rendered, message) {
|
||||
t.Fatalf("response missing %q", message)
|
||||
}
|
||||
}
|
||||
if strings.Contains(rendered, `<img src=x onerror="alert(1)">`) {
|
||||
t.Fatal("description was rendered as active HTML")
|
||||
}
|
||||
if service.uploadCalls != 0 || service.createCalls != 0 {
|
||||
t.Fatal("invalid form reached the service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidationUsesContractUTF8ByteLimits(t *testing.T) {
|
||||
valid := func() newTaskPageView {
|
||||
token := mustToken(t)
|
||||
return newTaskPageView{
|
||||
CSRFToken: token,
|
||||
UploadKey: mustToken(t),
|
||||
CreateKey: mustToken(t),
|
||||
Form: createFormView{
|
||||
Title: "标题",
|
||||
SKU: "SKU-1",
|
||||
Quantity: "1",
|
||||
QuantityValue: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
skuPage := valid()
|
||||
skuPage.Form.SKU = strings.Repeat("货", maxSKUBytes/3+1)
|
||||
if !validateCreateForm(&skuPage) ||
|
||||
!strings.Contains(skuPage.Errors.SKU, "512") {
|
||||
t.Fatalf("SKU errors = %+v", skuPage.Errors)
|
||||
}
|
||||
|
||||
descriptionPage := valid()
|
||||
descriptionPage.Form.Description = strings.Repeat(
|
||||
"说",
|
||||
maxDescriptionBytes/3+1,
|
||||
)
|
||||
if !validateCreateForm(&descriptionPage) ||
|
||||
!strings.Contains(descriptionPage.Errors.Description, "8192") {
|
||||
t.Fatalf("description errors = %+v", descriptionPage.Errors)
|
||||
}
|
||||
|
||||
titlePage := valid()
|
||||
titlePage.Form.Title = strings.Repeat("题", maxTitleRunes+1)
|
||||
if !validateCreateForm(&titlePage) ||
|
||||
!strings.Contains(titlePage.Errors.Title, "120") {
|
||||
t.Fatalf("title errors = %+v", titlePage.Errors)
|
||||
}
|
||||
|
||||
if maxRequestBytes < (20<<20)+(1<<20) {
|
||||
t.Fatalf("maxRequestBytes = %d, does not cover a 20 MiB image", maxRequestBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTaskUploadsThenRedirectsWithPRG(t *testing.T) {
|
||||
service := &fakeService{
|
||||
uploadResult: UploadedAsset{
|
||||
ID: "00000000-0000-4000-8000-000000000099",
|
||||
},
|
||||
createResult: Task{ID: testTaskID},
|
||||
}
|
||||
router := newTestRouter(t, service)
|
||||
cookie := getCSRFCookie(t, router)
|
||||
body, contentType := multipartBody(t, map[string]string{
|
||||
"csrf_token": cookie.Value,
|
||||
"title": " 桌面收纳盒 ",
|
||||
"sku": " SKU-1 ",
|
||||
"description": "浅灰色",
|
||||
"quantity": "2",
|
||||
"max_budget": "60.00",
|
||||
"upload_key": mustToken(t),
|
||||
"create_key": mustToken(t),
|
||||
}, "image", "reference.jpg", []byte("image bytes"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/tasks", body)
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
request.AddCookie(cookie)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther ||
|
||||
response.Header().Get("Location") != "/tasks/"+testTaskID {
|
||||
t.Fatalf(
|
||||
"status/location = %d/%q, body = %s",
|
||||
response.Code,
|
||||
response.Header().Get("Location"),
|
||||
response.Body,
|
||||
)
|
||||
}
|
||||
if service.uploadCalls != 1 || service.createCalls != 1 {
|
||||
t.Fatalf(
|
||||
"service calls = upload %d, create %d",
|
||||
service.uploadCalls,
|
||||
service.createCalls,
|
||||
)
|
||||
}
|
||||
if string(service.uploadBody) != "image bytes" ||
|
||||
service.createInput.Title != "桌面收纳盒" ||
|
||||
service.createInput.SKU != "SKU-1" ||
|
||||
service.createInput.Quantity != 2 ||
|
||||
service.createInput.MaxBudget != "60.00" ||
|
||||
service.createInput.ImageAssetID != service.uploadResult.ID {
|
||||
t.Fatalf(
|
||||
"upload/create input = %q / %+v",
|
||||
service.uploadBody,
|
||||
service.createInput,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTaskFailureRetainsUploadedAssetForRetry(t *testing.T) {
|
||||
assetID := "00000000-0000-4000-8000-000000000099"
|
||||
service := &fakeService{
|
||||
uploadResult: UploadedAsset{
|
||||
ID: assetID,
|
||||
},
|
||||
createErr: ErrUnavailable,
|
||||
}
|
||||
router := newTestRouter(t, service)
|
||||
cookie := getCSRFCookie(t, router)
|
||||
body, contentType := validCreateBody(t, cookie.Value, nil)
|
||||
request := httptest.NewRequest(http.MethodPost, "/tasks", body)
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
request.AddCookie(cookie)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body)
|
||||
}
|
||||
rendered := response.Body.String()
|
||||
for _, value := range []string{
|
||||
`name="image_asset_id" value="` + assetID + `"`,
|
||||
"再次提交会复用该图片",
|
||||
} {
|
||||
if !strings.Contains(rendered, value) {
|
||||
t.Fatalf("response missing %q", value)
|
||||
}
|
||||
}
|
||||
if strings.Contains(rendered, "reference.jpg") {
|
||||
t.Fatal("server response leaked the client file name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTaskRetryReusesAssetWithoutUpload(t *testing.T) {
|
||||
assetID := "00000000-0000-4000-8000-000000000099"
|
||||
service := &fakeService{
|
||||
createResult: Task{ID: testTaskID},
|
||||
}
|
||||
router := newTestRouter(t, service)
|
||||
cookie := getCSRFCookie(t, router)
|
||||
body, contentType := validCreateBody(t, cookie.Value, map[string]string{
|
||||
"image_asset_id": assetID,
|
||||
})
|
||||
request := httptest.NewRequest(http.MethodPost, "/tasks", body)
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
request.AddCookie(cookie)
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther {
|
||||
t.Fatalf("status = %d, body = %s", response.Code, response.Body)
|
||||
}
|
||||
if service.uploadCalls != 0 ||
|
||||
service.createInput.ImageAssetID != assetID {
|
||||
t.Fatalf(
|
||||
"upload calls / asset = %d / %q",
|
||||
service.uploadCalls,
|
||||
service.createInput.ImageAssetID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskDetailPendingCancelUsesCSRFAndPRG(t *testing.T) {
|
||||
service := &fakeService{
|
||||
getResult: Task{
|
||||
ID: testTaskID,
|
||||
Title: "桌面收纳盒",
|
||||
SKU: "SKU-1",
|
||||
Description: "浅灰色",
|
||||
Quantity: 2,
|
||||
MaxBudget: "60.00",
|
||||
Status: "PENDING",
|
||||
ReferenceAssetID: "00000000-0000-4000-8000-000000000009",
|
||||
CreatedAt: time.Date(2026, 7, 26, 3, 4, 5, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2026, 7, 26, 3, 5, 5, 0, time.UTC),
|
||||
},
|
||||
}
|
||||
router := newTestRouter(t, service)
|
||||
detail := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/tasks/"+testTaskID,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if detail.Code != http.StatusOK {
|
||||
t.Fatalf("detail status = %d, body = %s", detail.Code, detail.Body)
|
||||
}
|
||||
cookie := csrfCookie(t, detail)
|
||||
cancelKey := hiddenValue(t, detail.Body.String(), "cancel_key")
|
||||
for _, value := range []string{
|
||||
"桌面收纳盒",
|
||||
"最高总预算",
|
||||
"/api/v1/assets/00000000-0000-4000-8000-000000000009/content",
|
||||
"取消任务",
|
||||
"确认取消任务",
|
||||
} {
|
||||
if !strings.Contains(detail.Body.String(), value) {
|
||||
t.Fatalf("detail missing %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
form := url.Values{
|
||||
"csrf_token": {cookie.Value},
|
||||
"cancel_key": {cancelKey},
|
||||
}
|
||||
request := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/tasks/"+testTaskID+"/cancel",
|
||||
strings.NewReader(form.Encode()),
|
||||
)
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
request.AddCookie(cookie)
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != http.StatusSeeOther ||
|
||||
response.Header().Get("Location") !=
|
||||
"/tasks/"+testTaskID+"?notice=canceled" {
|
||||
t.Fatalf(
|
||||
"status/location = %d/%q",
|
||||
response.Code,
|
||||
response.Header().Get("Location"),
|
||||
)
|
||||
}
|
||||
if service.cancelInput.TaskID != testTaskID ||
|
||||
service.cancelInput.IdempotencyKey != cancelKey {
|
||||
t.Fatalf("cancel input = %+v", service.cancelInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskDetailDoesNotLeakForbiddenResource(t *testing.T) {
|
||||
service := &fakeService{getErr: ErrForbidden}
|
||||
router := newTestRouter(t, service)
|
||||
response := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
"/tasks/"+testTaskID,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
|
||||
if response.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "不存在或当前不可访问") {
|
||||
t.Fatalf("safe not-found message missing: %s", response.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaticFilesAreEmbeddedAndProtected(t *testing.T) {
|
||||
router := newTestRouter(t, &fakeService{})
|
||||
for _, route := range []string{"/static/admin.css", "/static/admin.js"} {
|
||||
response := performRequest(
|
||||
t,
|
||||
router,
|
||||
http.MethodGet,
|
||||
route,
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
if response.Code != http.StatusOK || response.Body.Len() == 0 {
|
||||
t.Fatalf("%s status/bytes = %d/%d", route, response.Code, response.Body.Len())
|
||||
}
|
||||
assertSecurityHeaders(t, response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRendererUsesMissingKeyErrors(t *testing.T) {
|
||||
renderer, err := NewRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewRenderer() error = %v", err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := renderer.Execute(&output, "tasks", struct{}{}); err == nil {
|
||||
t.Fatal("Execute() with incomplete data error = nil")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeService struct {
|
||||
listInput ListTasksInput
|
||||
listResult TaskList
|
||||
listErr error
|
||||
getResult Task
|
||||
getErr error
|
||||
uploadResult UploadedAsset
|
||||
uploadErr error
|
||||
uploadInput UploadReferenceInput
|
||||
uploadBody []byte
|
||||
uploadCalls int
|
||||
createResult Task
|
||||
createErr error
|
||||
createInput CreateTaskInput
|
||||
createCalls int
|
||||
cancelResult Task
|
||||
cancelErr error
|
||||
cancelInput CancelPendingInput
|
||||
}
|
||||
|
||||
func (service *fakeService) ListTasks(
|
||||
_ context.Context,
|
||||
input ListTasksInput,
|
||||
) (TaskList, error) {
|
||||
service.listInput = input
|
||||
return service.listResult, service.listErr
|
||||
}
|
||||
|
||||
func (service *fakeService) GetTask(
|
||||
context.Context,
|
||||
string,
|
||||
) (Task, error) {
|
||||
return service.getResult, service.getErr
|
||||
}
|
||||
|
||||
func (service *fakeService) UploadReference(
|
||||
_ context.Context,
|
||||
input UploadReferenceInput,
|
||||
) (UploadedAsset, error) {
|
||||
service.uploadCalls++
|
||||
service.uploadInput = input
|
||||
content, err := io.ReadAll(input.Content)
|
||||
if err != nil {
|
||||
return UploadedAsset{}, err
|
||||
}
|
||||
service.uploadBody = content
|
||||
return service.uploadResult, service.uploadErr
|
||||
}
|
||||
|
||||
func (service *fakeService) CreateTask(
|
||||
_ context.Context,
|
||||
input CreateTaskInput,
|
||||
) (Task, error) {
|
||||
service.createCalls++
|
||||
service.createInput = input
|
||||
return service.createResult, service.createErr
|
||||
}
|
||||
|
||||
func (service *fakeService) CancelPending(
|
||||
_ context.Context,
|
||||
input CancelPendingInput,
|
||||
) (Task, error) {
|
||||
service.cancelInput = input
|
||||
return service.cancelResult, service.cancelErr
|
||||
}
|
||||
|
||||
func newTestRouter(t *testing.T, service Service) http.Handler {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
renderer, err := NewRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewRenderer() error = %v", err)
|
||||
}
|
||||
handler, err := NewHandler(service, renderer)
|
||||
if err != nil {
|
||||
t.Fatalf("NewHandler() error = %v", err)
|
||||
}
|
||||
router := gin.New()
|
||||
handler.Register(router)
|
||||
return router
|
||||
}
|
||||
|
||||
func performRequest(
|
||||
t *testing.T,
|
||||
handler http.Handler,
|
||||
method string,
|
||||
path string,
|
||||
body io.Reader,
|
||||
contentType string,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
request := httptest.NewRequest(method, path, body)
|
||||
if contentType != "" {
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
return response
|
||||
}
|
||||
|
||||
func getCSRFCookie(t *testing.T, handler http.Handler) *http.Cookie {
|
||||
t.Helper()
|
||||
response := performRequest(
|
||||
t,
|
||||
handler,
|
||||
http.MethodGet,
|
||||
"/tasks/new",
|
||||
nil,
|
||||
"",
|
||||
)
|
||||
return csrfCookie(t, response)
|
||||
}
|
||||
|
||||
func csrfCookie(
|
||||
t *testing.T,
|
||||
response *httptest.ResponseRecorder,
|
||||
) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, cookie := range response.Result().Cookies() {
|
||||
if cookie.Name == csrfCookieName {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
t.Fatal("CSRF cookie not found")
|
||||
return nil
|
||||
}
|
||||
|
||||
func multipartBody(
|
||||
t *testing.T,
|
||||
fields map[string]string,
|
||||
fileField string,
|
||||
fileName string,
|
||||
content []byte,
|
||||
) (*bytes.Buffer, string) {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
for name, value := range fields {
|
||||
if err := writer.WriteField(name, value); err != nil {
|
||||
t.Fatalf("WriteField(%s): %v", name, err)
|
||||
}
|
||||
}
|
||||
if fileField != "" {
|
||||
part, err := writer.CreateFormFile(fileField, fileName)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFormFile(): %v", err)
|
||||
}
|
||||
if _, err := part.Write(content); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close multipart: %v", err)
|
||||
}
|
||||
return &body, writer.FormDataContentType()
|
||||
}
|
||||
|
||||
func validCreateBody(
|
||||
t *testing.T,
|
||||
csrfToken string,
|
||||
overrides map[string]string,
|
||||
) (*bytes.Buffer, string) {
|
||||
t.Helper()
|
||||
fields := map[string]string{
|
||||
"csrf_token": csrfToken,
|
||||
"title": "桌面收纳盒",
|
||||
"sku": "SKU-1",
|
||||
"description": "浅灰色",
|
||||
"quantity": "2",
|
||||
"max_budget": "60.00",
|
||||
"upload_key": mustToken(t),
|
||||
"create_key": mustToken(t),
|
||||
}
|
||||
for name, value := range overrides {
|
||||
fields[name] = value
|
||||
}
|
||||
fileField := "image"
|
||||
fileName := "reference.jpg"
|
||||
content := []byte("image bytes")
|
||||
if fields["image_asset_id"] != "" {
|
||||
fileField = ""
|
||||
fileName = ""
|
||||
content = nil
|
||||
}
|
||||
return multipartBody(t, fields, fileField, fileName, content)
|
||||
}
|
||||
|
||||
func mustToken(t *testing.T) string {
|
||||
t.Helper()
|
||||
token, err := newToken()
|
||||
if err != nil {
|
||||
t.Fatalf("newToken() error = %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func hiddenValue(t *testing.T, body string, name string) string {
|
||||
t.Helper()
|
||||
pattern := regexp.MustCompile(
|
||||
`name="` + regexp.QuoteMeta(name) + `" value="([^"]+)"`,
|
||||
)
|
||||
match := pattern.FindStringSubmatch(body)
|
||||
if len(match) != 2 {
|
||||
t.Fatalf("hidden field %q not found", name)
|
||||
}
|
||||
return match[1]
|
||||
}
|
||||
|
||||
func assertSecurityHeaders(
|
||||
t *testing.T,
|
||||
response *httptest.ResponseRecorder,
|
||||
) {
|
||||
t.Helper()
|
||||
required := map[string]string{
|
||||
"Cache-Control": "no-store",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
}
|
||||
for name, want := range required {
|
||||
if got := response.Header().Get(name); got != want {
|
||||
t.Fatalf("%s = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
csp := response.Header().Get("Content-Security-Policy")
|
||||
for _, directive := range []string{
|
||||
"default-src 'none'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self'",
|
||||
} {
|
||||
if !strings.Contains(csp, directive) {
|
||||
t.Fatalf("CSP missing %q: %s", directive, csp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsecaseErrorMappingKeepsPublicSentinels(t *testing.T) {
|
||||
tests := []struct {
|
||||
kind usecase.ErrorKind
|
||||
code string
|
||||
want error
|
||||
}{
|
||||
{
|
||||
kind: usecase.ErrorKindInvalid,
|
||||
code: "ASSET_IMAGE_INVALID",
|
||||
want: ErrInvalidFile,
|
||||
},
|
||||
{
|
||||
kind: usecase.ErrorKindInvalid,
|
||||
code: "TASK_VALIDATION_FAILED",
|
||||
want: ErrValidation,
|
||||
},
|
||||
{
|
||||
kind: usecase.ErrorKindInvalid,
|
||||
code: "TASK_CANCEL_INVALID",
|
||||
want: ErrNotFound,
|
||||
},
|
||||
{
|
||||
kind: usecase.ErrorKindNotFound,
|
||||
code: "TASK_NOT_FOUND",
|
||||
want: ErrNotFound,
|
||||
},
|
||||
{
|
||||
kind: usecase.ErrorKindConflict,
|
||||
code: "TASK_STATE_CONFLICT",
|
||||
want: ErrConflict,
|
||||
},
|
||||
{
|
||||
kind: usecase.ErrorKindUnavailable,
|
||||
code: "STORAGE_UNAVAILABLE",
|
||||
want: ErrUnavailable,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.code, func(t *testing.T) {
|
||||
mapped := mapUsecaseError(&usecase.Error{
|
||||
Kind: test.kind,
|
||||
Code: test.code,
|
||||
})
|
||||
if !errors.Is(mapped, test.want) {
|
||||
t.Fatalf("mapped error = %v, want %v", mapped, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"html/template"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed templates/*.gohtml static/admin.css static/admin.js
|
||||
var embeddedFiles embed.FS
|
||||
|
||||
type Renderer struct {
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
func NewRenderer() (*Renderer, error) {
|
||||
templates, err := template.New("admin").
|
||||
Option("missingkey=error").
|
||||
Funcs(template.FuncMap{
|
||||
"displayTime": displayTime,
|
||||
"machineTime": machineTime,
|
||||
"pathPart": pathPart,
|
||||
}).
|
||||
ParseFS(embeddedFiles, "templates/*.gohtml")
|
||||
if err != nil {
|
||||
return nil, errors.New("parse admin templates")
|
||||
}
|
||||
return &Renderer{templates: templates}, nil
|
||||
}
|
||||
|
||||
func (r *Renderer) Execute(
|
||||
writer io.Writer,
|
||||
name string,
|
||||
data any,
|
||||
) error {
|
||||
if r == nil || r.templates == nil {
|
||||
return errors.New("admin renderer is not configured")
|
||||
}
|
||||
if err := r.templates.ExecuteTemplate(writer, name, data); err != nil {
|
||||
return errors.New("render admin template")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func staticFile(name string) ([]byte, error) {
|
||||
if name != "admin.css" && name != "admin.js" {
|
||||
return nil, errors.New("unknown admin static file")
|
||||
}
|
||||
content, err := embeddedFiles.ReadFile("static/" + name)
|
||||
if err != nil {
|
||||
return nil, errors.New("read admin static file")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func displayTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return "尚未记录"
|
||||
}
|
||||
return value.Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
|
||||
func machineTime(value time.Time) string {
|
||||
if value.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return value.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func pathPart(value string) string {
|
||||
return pathEscape(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func pathEscape(value string) string {
|
||||
// Keep IDs as one opaque path component.
|
||||
if value == "" || value == "." || value == ".." ||
|
||||
path.Base(value) != value || strings.ContainsAny(value, `/\`) {
|
||||
return "invalid"
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,863 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--canvas: #f3f5f4;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f8faf9;
|
||||
--ink: #18201d;
|
||||
--muted: #5b6862;
|
||||
--line: #cfd7d3;
|
||||
--line-strong: #9eaaa4;
|
||||
--brand: #0b6b50;
|
||||
--brand-dark: #07513d;
|
||||
--danger: #9b3030;
|
||||
--danger-dark: #762222;
|
||||
--danger-soft: #fff1f1;
|
||||
--info: #185b8f;
|
||||
--info-soft: #eef7fd;
|
||||
--warn: #76520d;
|
||||
--warn-soft: #fff8e5;
|
||||
--success: #07513d;
|
||||
--success-soft: #eaf7f1;
|
||||
--focus: #1a73b8;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 280px;
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
a,
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
summary {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 3px solid rgba(26, 115, 184, 0.3);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--brand-dark);
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 100;
|
||||
padding: 8px 12px;
|
||||
color: #fff;
|
||||
background: #18201d;
|
||||
transform: translateY(-150%);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.site-header {
|
||||
min-height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 28px;
|
||||
padding: 0 clamp(16px, 4vw, 40px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
font-weight: 750;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 5px;
|
||||
color: #fff;
|
||||
background: var(--brand);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.main-nav a {
|
||||
min-height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0 13px;
|
||||
border-bottom: 3px solid transparent;
|
||||
color: #3d4b45;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.main-nav a[aria-current="page"] {
|
||||
border-color: var(--brand);
|
||||
color: var(--brand-dark);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: min(calc(100% - 32px), 1180px);
|
||||
margin: 0 auto;
|
||||
padding: 28px 0 60px;
|
||||
}
|
||||
|
||||
.narrow-page {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.subtitle,
|
||||
.section-note,
|
||||
.secondary,
|
||||
.field-help {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.button {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 5px;
|
||||
color: #26352f;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
border-color: #68766f;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.button.primary {
|
||||
border-color: var(--brand);
|
||||
color: #fff;
|
||||
background: var(--brand);
|
||||
}
|
||||
|
||||
.button.primary:hover {
|
||||
background: var(--brand-dark);
|
||||
}
|
||||
|
||||
.button.danger {
|
||||
border-color: var(--danger);
|
||||
color: #fff;
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.button.danger:hover {
|
||||
background: var(--danger-dark);
|
||||
}
|
||||
|
||||
.button.danger-outline {
|
||||
border-color: #c78484;
|
||||
color: var(--danger-dark);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(200px, 1fr) 220px auto;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-bottom: 0;
|
||||
border-radius: 8px 8px 0 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
min-width: 112px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.field.compact label {
|
||||
color: #45534d;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 5px;
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 112px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
input[aria-invalid="true"],
|
||||
textarea[aria-invalid="true"],
|
||||
select[aria-invalid="true"] {
|
||||
border-color: var(--danger);
|
||||
box-shadow: inset 0 0 0 1px var(--danger);
|
||||
}
|
||||
|
||||
.table-region {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0 0 8px 8px;
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 13px 12px;
|
||||
border-bottom: 1px solid #e1e6e3;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
th {
|
||||
color: #45534d;
|
||||
background: var(--surface-soft);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
th:first-child {
|
||||
width: 32%;
|
||||
}
|
||||
|
||||
th:nth-child(2) {
|
||||
width: 16%;
|
||||
}
|
||||
|
||||
th:nth-child(3) {
|
||||
width: 16%;
|
||||
}
|
||||
|
||||
th:nth-child(4) {
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
th:last-child {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: #f8fbfa;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.task-title,
|
||||
.secondary {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status {
|
||||
min-height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 5px;
|
||||
background: var(--surface-soft);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-warn {
|
||||
border-color: #d6b970;
|
||||
color: var(--warn);
|
||||
background: var(--warn-soft);
|
||||
}
|
||||
|
||||
.status-info {
|
||||
border-color: #8eb7d4;
|
||||
color: var(--info);
|
||||
background: var(--info-soft);
|
||||
}
|
||||
|
||||
.status-success {
|
||||
border-color: #7db6a3;
|
||||
color: var(--success);
|
||||
background: var(--success-soft);
|
||||
}
|
||||
|
||||
.status-danger {
|
||||
border-color: #d99c9c;
|
||||
color: var(--danger-dark);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.detail-link {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
min-height: 260px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
padding: 36px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0 0 18px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 0 0 18px;
|
||||
padding: 12px 14px;
|
||||
border-left: 4px solid var(--info);
|
||||
background: var(--info-soft);
|
||||
}
|
||||
|
||||
.notice p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.notice p + p {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.notice-error {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger-dark);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.task-form {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.form-section,
|
||||
.content-section {
|
||||
padding: 22px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 15px 18px;
|
||||
}
|
||||
|
||||
.span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.required-text {
|
||||
color: var(--danger-dark);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.field-help,
|
||||
.field-error {
|
||||
min-height: 20px;
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: var(--danger-dark);
|
||||
}
|
||||
|
||||
.upload-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 240px;
|
||||
gap: 22px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.image-placeholder,
|
||||
.image-preview img {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: 5px;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.image-preview figcaption {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.retained-file {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #7db6a3;
|
||||
border-radius: 5px;
|
||||
background: var(--success-soft);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.title-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.success-banner {
|
||||
display: flex;
|
||||
gap: 13px;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
padding: 16px 18px;
|
||||
border-left: 5px solid var(--success);
|
||||
background: var(--success-soft);
|
||||
}
|
||||
|
||||
.success-banner h2,
|
||||
.success-banner p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.success-mark {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: var(--success);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.detail-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.section-note {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.requirement-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(170px, 220px) minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.reference-figure {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.reference-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 5px;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.reference-figure figcaption {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.definition-list {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(100px, auto) minmax(0, 1fr);
|
||||
gap: 10px 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.definition-list dt {
|
||||
color: var(--muted);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.definition-list dd {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.error-page {
|
||||
margin-top: 50px;
|
||||
padding: 28px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.error-page p {
|
||||
margin: 8px 0 22px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
width: min(calc(100% - 32px), 480px);
|
||||
padding: 22px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
color: var(--ink);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.confirm-dialog::backdrop {
|
||||
background: rgba(24, 32, 29, 0.45);
|
||||
}
|
||||
|
||||
.confirm-dialog p {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.dialog-actions form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.noscript-cancel {
|
||||
margin-top: 18px;
|
||||
padding: 18px;
|
||||
border: 1px solid #d99c9c;
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
[aria-busy="true"] {
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.page {
|
||||
width: min(calc(100% - 20px), 1180px);
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(160px, 0.6fr);
|
||||
}
|
||||
|
||||
.filter-button {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
table,
|
||||
tbody {
|
||||
display: block;
|
||||
}
|
||||
|
||||
thead {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
tr {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
tbody tr:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
td {
|
||||
display: block;
|
||||
padding: 6px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
td::before {
|
||||
content: attr(data-label);
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
td:first-child,
|
||||
td:last-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.upload-layout,
|
||||
.detail-layout,
|
||||
.requirement-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
max-width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.site-header {
|
||||
gap: 8px;
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.main-nav a {
|
||||
padding-inline: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.title-row,
|
||||
.detail-title {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.title-row > .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.title-actions {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters,
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filter-button,
|
||||
.span-2 {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
tr {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
td,
|
||||
td:first-child,
|
||||
td:last-child {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.form-section,
|
||||
.content-section {
|
||||
padding: 18px 14px;
|
||||
}
|
||||
|
||||
.form-actions,
|
||||
.dialog-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.form-actions .button,
|
||||
.dialog-actions .button,
|
||||
.dialog-actions form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-actions form .button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.definition-list {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.definition-list dd {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const focusError = document.querySelector("[data-error-field]");
|
||||
if (focusError) {
|
||||
focusError.focus();
|
||||
} else {
|
||||
const summary = document.querySelector("[data-error-summary]");
|
||||
if (summary) summary.focus();
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-loading-form]").forEach((form) => {
|
||||
form.addEventListener("submit", () => {
|
||||
form.setAttribute("aria-busy", "true");
|
||||
const button = form.querySelector("button[type='submit']");
|
||||
if (!button) return;
|
||||
button.disabled = true;
|
||||
button.textContent = button.dataset.loadingLabel || "正在加载…";
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-submit-form]").forEach((form) => {
|
||||
form.addEventListener("submit", () => {
|
||||
form.dataset.submitting = "true";
|
||||
const button = form.querySelector("[data-submit-button]");
|
||||
if (!button) return;
|
||||
button.disabled = true;
|
||||
button.textContent = button.dataset.loadingLabel || "正在提交…";
|
||||
});
|
||||
});
|
||||
|
||||
const fileInput = document.querySelector("input[type='file'][name='image']");
|
||||
const image = document.querySelector("[data-image-preview]");
|
||||
const placeholder = document.querySelector("[data-image-placeholder]");
|
||||
const imageName = document.querySelector("[data-image-name]");
|
||||
let previewURL = "";
|
||||
|
||||
if (fileInput && image && placeholder && imageName) {
|
||||
fileInput.addEventListener("change", () => {
|
||||
if (previewURL) URL.revokeObjectURL(previewURL);
|
||||
const file = fileInput.files && fileInput.files[0];
|
||||
if (!file) {
|
||||
image.hidden = true;
|
||||
placeholder.hidden = false;
|
||||
imageName.textContent = "选择后在本地预览";
|
||||
return;
|
||||
}
|
||||
previewURL = URL.createObjectURL(file);
|
||||
image.src = previewURL;
|
||||
image.hidden = false;
|
||||
placeholder.hidden = true;
|
||||
imageName.textContent = `${file.name} · ${(file.size / 1024).toFixed(1)} KB`;
|
||||
});
|
||||
window.addEventListener("pagehide", () => {
|
||||
if (previewURL) URL.revokeObjectURL(previewURL);
|
||||
});
|
||||
}
|
||||
|
||||
const dirtyForm = document.querySelector("[data-dirty-form]");
|
||||
const leaveDialog = document.querySelector("[data-leave-dialog]");
|
||||
let dirty = false;
|
||||
let destination = "";
|
||||
|
||||
if (dirtyForm && leaveDialog) {
|
||||
dirtyForm.addEventListener("input", () => {
|
||||
dirty = true;
|
||||
});
|
||||
dirtyForm.addEventListener("change", () => {
|
||||
dirty = true;
|
||||
});
|
||||
dirtyForm.addEventListener("submit", () => {
|
||||
dirty = false;
|
||||
});
|
||||
|
||||
document.querySelectorAll("a[href]").forEach((link) => {
|
||||
link.addEventListener("click", (event) => {
|
||||
if (!dirty || link.hasAttribute("data-discard")) return;
|
||||
const target = new URL(link.href, window.location.href);
|
||||
if (target.origin !== window.location.origin) return;
|
||||
event.preventDefault();
|
||||
destination = target.href;
|
||||
leaveDialog.showModal();
|
||||
leaveDialog.querySelector("[data-stay]").focus();
|
||||
});
|
||||
});
|
||||
|
||||
leaveDialog.querySelector("[data-stay]").addEventListener("click", () => {
|
||||
leaveDialog.close();
|
||||
});
|
||||
leaveDialog.querySelector("[data-discard]").addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
dirty = false;
|
||||
window.location.assign(destination || "/tasks");
|
||||
});
|
||||
}
|
||||
|
||||
const openCancel = document.querySelector("[data-open-cancel]");
|
||||
const cancelDialog = document.querySelector("[data-cancel-dialog]");
|
||||
if (openCancel && cancelDialog) {
|
||||
openCancel.addEventListener("click", () => {
|
||||
cancelDialog.showModal();
|
||||
cancelDialog.querySelector("[data-keep-task]").focus();
|
||||
});
|
||||
cancelDialog.querySelector("[data-keep-task]").addEventListener("click", () => {
|
||||
cancelDialog.close();
|
||||
openCancel.focus();
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,19 @@
|
||||
{{define "error"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>{{.Page.Title}} - 采购任务管理</title>
|
||||
{{template "document-head" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "site-header" .}}
|
||||
<main id="main-content" class="page narrow-page">
|
||||
<section class="error-page" role="alert">
|
||||
<h1>{{.Heading}}</h1>
|
||||
<p>{{.Message}}</p>
|
||||
<a class="button primary" href="/tasks">返回任务列表</a>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,22 @@
|
||||
{{define "document-head"}}
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<link rel="icon" href="data:,">
|
||||
<link rel="stylesheet" href="/static/admin.css">
|
||||
<script src="/static/admin.js" defer></script>
|
||||
{{end}}
|
||||
|
||||
{{define "site-header"}}
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/tasks" aria-label="采购任务管理首页">
|
||||
<span class="brand-mark" aria-hidden="true">采</span>
|
||||
<span>采购任务管理</span>
|
||||
</a>
|
||||
<nav class="main-nav" aria-label="主导航">
|
||||
<a href="/tasks" {{if .Page.TasksCurrent}}aria-current="page"{{end}}>任务列表</a>
|
||||
<a href="/tasks/new" {{if .Page.NewCurrent}}aria-current="page"{{end}}>新建任务</a>
|
||||
</nav>
|
||||
</header>
|
||||
{{end}}
|
||||
@@ -0,0 +1,106 @@
|
||||
{{define "task-detail"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>{{.Task.Title}} - 采购任务管理</title>
|
||||
{{template "document-head" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "site-header" .}}
|
||||
<main id="main-content" class="page">
|
||||
<a class="back-link" href="/tasks">返回任务列表</a>
|
||||
|
||||
{{if .Notice}}
|
||||
<div class="notice" role="status" tabindex="-1" data-notice>{{.Notice}}</div>
|
||||
{{end}}
|
||||
|
||||
<div class="title-row detail-title">
|
||||
<div>
|
||||
<h1>{{.Task.Title}}</h1>
|
||||
<p class="subtitle">SKU:{{.Task.SKU}} · 任务编号:{{.Task.ID}}</p>
|
||||
</div>
|
||||
<div class="title-actions">
|
||||
<span class="status {{.Task.StatusClass}}" title="状态码 {{.Task.Status}}">
|
||||
{{.Task.StatusLabel}}
|
||||
</span>
|
||||
{{if .Task.CanCancel}}
|
||||
<button class="button danger-outline" type="button" data-open-cancel>取消任务</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if eq .Task.Status "SUCCEEDED"}}
|
||||
<section class="success-banner" aria-labelledby="success-heading">
|
||||
<div aria-hidden="true" class="success-mark">✓</div>
|
||||
<div>
|
||||
<h2 id="success-heading">验证完成,未提交订单</h2>
|
||||
<p>采购人员已完成候选确认,系统只记录验证结果。</p>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<div class="detail-layout">
|
||||
<section class="content-section" aria-labelledby="original-heading">
|
||||
<h2 id="original-heading">原始采购要求</h2>
|
||||
<p class="section-note">以下内容来自管理员输入,不会被 AI 派生结果覆盖。</p>
|
||||
<div class="requirement-layout">
|
||||
<figure class="reference-figure">
|
||||
<img class="reference-image"
|
||||
src="/api/v1/assets/{{pathPart .Task.ReferenceAssetID}}/content"
|
||||
alt="商品参考图">
|
||||
<figcaption>已规范化的参考图片</figcaption>
|
||||
</figure>
|
||||
<dl class="definition-list">
|
||||
<dt>商品标题</dt><dd>{{.Task.Title}}</dd>
|
||||
<dt>SKU</dt><dd>{{.Task.SKU}}</dd>
|
||||
<dt>描述</dt><dd>{{if .Task.Description}}{{.Task.Description}}{{else}}未填写{{end}}</dd>
|
||||
<dt>数量</dt><dd>{{.Task.Quantity}}</dd>
|
||||
<dt>最高总预算</dt>
|
||||
<dd>{{if .Task.MaxBudget}}{{.Task.MaxBudget}} 元{{else}}未设置{{end}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="content-section summary-section" aria-labelledby="summary-heading">
|
||||
<h2 id="summary-heading">任务摘要</h2>
|
||||
<dl class="definition-list">
|
||||
<dt>状态</dt><dd>{{.Task.StatusLabel}}({{.Task.Status}})</dd>
|
||||
<dt>创建时间</dt>
|
||||
<dd><time datetime="{{machineTime .Task.CreatedAt}}">{{displayTime .Task.CreatedAt}}</time></dd>
|
||||
<dt>最后更新</dt>
|
||||
<dd><time datetime="{{machineTime .Task.UpdatedAt}}">{{displayTime .Task.UpdatedAt}}</time></dd>
|
||||
</dl>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{{if .Task.CanCancel}}
|
||||
<noscript>
|
||||
<section class="noscript-cancel" aria-labelledby="noscript-cancel-heading">
|
||||
<h2 id="noscript-cancel-heading">确认取消任务</h2>
|
||||
<p>取消后任务不会自动恢复。</p>
|
||||
<form method="post" action="/tasks/{{pathPart .Task.ID}}/cancel">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="cancel_key" value="{{.CancelKey}}">
|
||||
<button class="button danger" type="submit">确认取消</button>
|
||||
</form>
|
||||
</section>
|
||||
</noscript>
|
||||
|
||||
<dialog class="confirm-dialog" data-cancel-dialog aria-labelledby="cancel-title">
|
||||
<h2 id="cancel-title">确认取消任务?</h2>
|
||||
<p>任务取消后不会自动恢复,采购执行员将不能再领取。</p>
|
||||
<div class="dialog-actions">
|
||||
<button class="button" type="button" data-keep-task>保留任务</button>
|
||||
<form method="post" action="/tasks/{{pathPart .Task.ID}}/cancel" data-submit-form>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="cancel_key" value="{{.CancelKey}}">
|
||||
<button class="button danger" type="submit" data-submit-button
|
||||
data-loading-label="正在取消…">确认取消</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,126 @@
|
||||
{{define "task-new"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>{{.Page.Title}} - 采购任务管理</title>
|
||||
{{template "document-head" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "site-header" .}}
|
||||
<main id="main-content" class="page form-page">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<h1>新建采购任务</h1>
|
||||
<p class="subtitle">填写商品要求和不可覆盖的采购约束</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if or .Notice .Errors.Form}}
|
||||
<div class="notice notice-error" role="alert" tabindex="-1" data-error-summary>
|
||||
{{if .Notice}}<p>{{.Notice}}</p>{{end}}
|
||||
{{if .Errors.Form}}<p>{{.Errors.Form}}</p>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<form class="task-form" method="post" action="/tasks" enctype="multipart/form-data"
|
||||
novalidate data-dirty-form data-submit-form>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="upload_key" value="{{.UploadKey}}">
|
||||
<input type="hidden" name="create_key" value="{{.CreateKey}}">
|
||||
<input type="hidden" name="image_asset_id" value="{{.UploadedAsset.ID}}">
|
||||
|
||||
<section class="form-section" aria-labelledby="requirement-heading">
|
||||
<h2 id="requirement-heading">商品要求</h2>
|
||||
<div class="form-grid">
|
||||
<div class="field span-2">
|
||||
<label for="title">商品标题 <span class="required-text">必填</span></label>
|
||||
<input id="title" name="title" value="{{.Form.Title}}" maxlength="120" required
|
||||
autocomplete="off" aria-describedby="title-error"
|
||||
{{if .Errors.Title}}aria-invalid="true" data-error-field{{end}}>
|
||||
<p id="title-error" class="field-error">{{.Errors.Title}}</p>
|
||||
</div>
|
||||
|
||||
<div class="field span-2">
|
||||
<label for="sku">SKU <span class="required-text">必填</span></label>
|
||||
<input id="sku" name="sku" value="{{.Form.SKU}}" maxlength="512" required
|
||||
autocomplete="off" aria-describedby="sku-help sku-error"
|
||||
{{if .Errors.SKU}}aria-invalid="true" data-error-field{{end}}>
|
||||
<p id="sku-help" class="field-help">填写采购时用于核对的商品规格编号。</p>
|
||||
<p id="sku-error" class="field-error">{{.Errors.SKU}}</p>
|
||||
</div>
|
||||
|
||||
<div class="field span-2">
|
||||
<label for="description">商品描述</label>
|
||||
<textarea id="description" name="description" maxlength="8192"
|
||||
aria-describedby="description-help description-error"
|
||||
{{if .Errors.Description}}aria-invalid="true" data-error-field{{end}}>{{.Form.Description}}</textarea>
|
||||
<p id="description-help" class="field-help">只填写采购判断所需信息,不填写订单、地址或个人信息。</p>
|
||||
<p id="description-error" class="field-error">{{.Errors.Description}}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="quantity">数量 <span class="required-text">必填</span></label>
|
||||
<input id="quantity" name="quantity" type="number" value="{{.Form.Quantity}}"
|
||||
min="1" step="1" inputmode="numeric" required aria-describedby="quantity-error"
|
||||
{{if .Errors.Quantity}}aria-invalid="true" data-error-field{{end}}>
|
||||
<p id="quantity-error" class="field-error">{{.Errors.Quantity}}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="max-budget">最高总预算(元)</label>
|
||||
<input id="max-budget" name="max_budget" type="text" value="{{.Form.MaxBudget}}"
|
||||
inputmode="decimal" placeholder="不填写则不设上限"
|
||||
aria-describedby="budget-help budget-error"
|
||||
{{if .Errors.MaxBudget}}aria-invalid="true" data-error-field{{end}}>
|
||||
<p id="budget-help" class="field-help">数量合计的最高预算,不是单件价格。</p>
|
||||
<p id="budget-error" class="field-error">{{.Errors.MaxBudget}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section" aria-labelledby="image-heading">
|
||||
<h2 id="image-heading">参考图片</h2>
|
||||
<div class="upload-layout">
|
||||
<div class="field">
|
||||
<label for="image">商品参考图 <span class="required-text">必填</span></label>
|
||||
{{if .UploadedAsset.ID}}
|
||||
<p class="retained-file">已保留上传成功的参考图片</p>
|
||||
<p class="field-help">再次提交会复用该图片,不会重复上传。</p>
|
||||
{{else}}
|
||||
<input id="image" name="image" type="file"
|
||||
accept="image/png,image/jpeg,image/webp" required
|
||||
aria-describedby="image-help image-error"
|
||||
{{if .Errors.Image}}aria-invalid="true" data-error-field{{end}}>
|
||||
<p id="image-help" class="field-help">支持 JPG、PNG、WebP;服务端会校验实际内容。</p>
|
||||
{{end}}
|
||||
<p id="image-error" class="field-error">{{.Errors.Image}}</p>
|
||||
</div>
|
||||
<figure class="image-preview">
|
||||
<div class="image-placeholder" data-image-placeholder>尚未选择图片</div>
|
||||
<img alt="待上传的商品参考图预览" data-image-preview hidden>
|
||||
<figcaption data-image-name>
|
||||
{{if .UploadedAsset.ID}}参考图片已保留{{else}}选择后仅在本地预览{{end}}
|
||||
</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="form-actions">
|
||||
<a class="button" href="/tasks">取消</a>
|
||||
<button class="button primary" type="submit" data-submit-button
|
||||
data-loading-label="正在创建…">创建任务</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<dialog class="confirm-dialog" data-leave-dialog aria-labelledby="leave-title">
|
||||
<h2 id="leave-title">放弃未提交内容?</h2>
|
||||
<p>已填写的商品要求不会保存。</p>
|
||||
<div class="dialog-actions">
|
||||
<button class="button" type="button" data-stay>继续填写</button>
|
||||
<a class="button danger" href="/tasks" data-discard>放弃并离开</a>
|
||||
</div>
|
||||
</dialog>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,93 @@
|
||||
{{define "tasks"}}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<title>{{.Page.Title}} - 采购任务管理</title>
|
||||
{{template "document-head" .}}
|
||||
</head>
|
||||
<body>
|
||||
{{template "site-header" .}}
|
||||
<main id="main-content" class="page">
|
||||
<div class="title-row">
|
||||
<div>
|
||||
<h1>采购任务</h1>
|
||||
<p class="subtitle">按最近创建时间查看采购任务状态</p>
|
||||
</div>
|
||||
<a class="button primary" href="/tasks/new">新建任务</a>
|
||||
</div>
|
||||
|
||||
<form class="filters" method="get" action="/tasks" data-loading-form>
|
||||
<div class="field compact">
|
||||
<label for="task-search">搜索</label>
|
||||
<input id="task-search" name="q" type="search" value="{{.Query}}"
|
||||
placeholder="任务编号、标题或 SKU">
|
||||
</div>
|
||||
<div class="field compact">
|
||||
<label for="task-status">状态</label>
|
||||
<select id="task-status" name="status">
|
||||
{{range .StatusOptions}}
|
||||
<option value="{{.Value}}" {{if .Selected}}selected{{end}}>{{.Label}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<button class="button filter-button" type="submit" data-loading-label="正在加载…">应用筛选</button>
|
||||
</form>
|
||||
|
||||
<section class="table-region" aria-labelledby="task-table-title" data-loading-region>
|
||||
<h2 id="task-table-title" class="visually-hidden">任务列表结果</h2>
|
||||
{{if .Items}}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">任务</th>
|
||||
<th scope="col">状态</th>
|
||||
<th scope="col">执行设备</th>
|
||||
<th scope="col">更新时间</th>
|
||||
<th scope="col"><span class="visually-hidden">操作</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td data-label="任务">
|
||||
<strong class="task-title">{{.Title}}</strong>
|
||||
<span class="secondary">SKU:{{.SKU}}</span>
|
||||
<span class="secondary">编号:{{.ID}}</span>
|
||||
</td>
|
||||
<td data-label="状态">
|
||||
<span class="status {{.StatusClass}}" title="状态码 {{.Status}}">{{.StatusLabel}}</span>
|
||||
</td>
|
||||
<td data-label="执行设备">{{.DeviceName}}</td>
|
||||
<td data-label="更新时间">
|
||||
<time datetime="{{machineTime .UpdatedAt}}">{{displayTime .UpdatedAt}}</time>
|
||||
</td>
|
||||
<td data-label="操作">
|
||||
<a class="detail-link" href="/tasks/{{pathPart .ID}}">查看详情</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<h2>没有符合条件的任务</h2>
|
||||
{{if or .Query .Status}}
|
||||
<p>请调整搜索或状态筛选后重试。</p>
|
||||
<a class="button" href="/tasks">清除筛选</a>
|
||||
{{else}}
|
||||
<p>创建第一条采购任务后,它会显示在这里。</p>
|
||||
<a class="button primary" href="/tasks/new">新建任务</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{if .NextCursor}}
|
||||
<nav class="pagination" aria-label="任务分页">
|
||||
<a class="button" href="/tasks?q={{.Query}}&status={{.Status}}&cursor={{.NextCursor}}">下一页</a>
|
||||
</nav>
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,87 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("resource not found")
|
||||
ErrForbidden = errors.New("resource forbidden")
|
||||
ErrConflict = errors.New("resource conflict")
|
||||
ErrValidation = errors.New("validation failed")
|
||||
ErrInvalidFile = errors.New("invalid file")
|
||||
ErrUnavailable = errors.New("service unavailable")
|
||||
)
|
||||
|
||||
// Service is the application boundary required by the server-rendered admin UI.
|
||||
// Composition code adapts the task use case to this transport-specific shape.
|
||||
type Service interface {
|
||||
ListTasks(context.Context, ListTasksInput) (TaskList, error)
|
||||
GetTask(context.Context, string) (Task, error)
|
||||
UploadReference(context.Context, UploadReferenceInput) (UploadedAsset, error)
|
||||
CreateTask(context.Context, CreateTaskInput) (Task, error)
|
||||
CancelPending(context.Context, CancelPendingInput) (Task, error)
|
||||
}
|
||||
|
||||
type ListTasksInput struct {
|
||||
Query string
|
||||
Status string
|
||||
Cursor string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type TaskList struct {
|
||||
Items []TaskSummary
|
||||
NextCursor string
|
||||
}
|
||||
|
||||
type TaskSummary struct {
|
||||
ID string
|
||||
Title string
|
||||
SKU string
|
||||
Status string
|
||||
DeviceName string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Task struct {
|
||||
ID string
|
||||
Title string
|
||||
SKU string
|
||||
Description string
|
||||
Quantity int64
|
||||
MaxBudget string
|
||||
Status string
|
||||
ReferenceAssetID string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type UploadReferenceInput struct {
|
||||
IdempotencyKey string
|
||||
DeclaredType string
|
||||
DeclaredSize int64
|
||||
Content io.Reader
|
||||
}
|
||||
|
||||
type UploadedAsset struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
type CreateTaskInput struct {
|
||||
IdempotencyKey string
|
||||
Title string
|
||||
SKU string
|
||||
Description string
|
||||
Quantity int64
|
||||
MaxBudget string
|
||||
ImageAssetID string
|
||||
}
|
||||
|
||||
type CancelPendingInput struct {
|
||||
TaskID string
|
||||
IdempotencyKey string
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
)
|
||||
|
||||
const localAdminSubject = "local-admin"
|
||||
|
||||
type UsecaseAdapter struct {
|
||||
tasks *usecase.TaskService
|
||||
assets *usecase.AssetService
|
||||
}
|
||||
|
||||
func NewUsecaseAdapter(
|
||||
tasks *usecase.TaskService,
|
||||
assets *usecase.AssetService,
|
||||
) (*UsecaseAdapter, error) {
|
||||
if tasks == nil || assets == nil {
|
||||
return nil, errors.New("admin web use cases are required")
|
||||
}
|
||||
return &UsecaseAdapter{
|
||||
tasks: tasks,
|
||||
assets: assets,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) ListTasks(
|
||||
ctx context.Context,
|
||||
input ListTasksInput,
|
||||
) (TaskList, error) {
|
||||
var status *string
|
||||
if input.Status != "" {
|
||||
value := input.Status
|
||||
status = &value
|
||||
}
|
||||
page, err := adapter.tasks.List(ctx, usecase.ListTasksQuery{
|
||||
CreatorSubject: localAdminSubject,
|
||||
Status: status,
|
||||
Query: input.Query,
|
||||
Limit: input.Limit,
|
||||
Cursor: input.Cursor,
|
||||
})
|
||||
if err != nil {
|
||||
return TaskList{}, mapUsecaseError(err)
|
||||
}
|
||||
items := make([]TaskSummary, 0, len(page.Items))
|
||||
for _, item := range page.Items {
|
||||
items = append(items, taskSummaryFrom(item))
|
||||
}
|
||||
return TaskList{
|
||||
Items: items,
|
||||
NextCursor: page.NextCursor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) GetTask(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
) (Task, error) {
|
||||
detail, err := adapter.tasks.Get(ctx, localAdminSubject, taskID)
|
||||
if err != nil {
|
||||
return Task{}, mapUsecaseError(err)
|
||||
}
|
||||
return taskFromDetail(detail), nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) UploadReference(
|
||||
ctx context.Context,
|
||||
input UploadReferenceInput,
|
||||
) (UploadedAsset, error) {
|
||||
result, err := adapter.assets.UploadTaskReference(
|
||||
ctx,
|
||||
usecase.UploadTaskReferenceCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
IdempotencyKey: input.IdempotencyKey,
|
||||
DeclaredMediaType: input.DeclaredType,
|
||||
Content: input.Content,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return UploadedAsset{}, mapUsecaseError(err)
|
||||
}
|
||||
return UploadedAsset{
|
||||
ID: result.Asset.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) CreateTask(
|
||||
ctx context.Context,
|
||||
input CreateTaskInput,
|
||||
) (Task, error) {
|
||||
maxInt := int64(^uint(0) >> 1)
|
||||
if input.Quantity > maxInt {
|
||||
return Task{}, ErrValidation
|
||||
}
|
||||
var budget *string
|
||||
if input.MaxBudget != "" {
|
||||
value := input.MaxBudget
|
||||
budget = &value
|
||||
}
|
||||
result, err := adapter.tasks.Create(ctx, usecase.CreateTaskCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
IdempotencyKey: input.IdempotencyKey,
|
||||
Title: input.Title,
|
||||
Description: input.Description,
|
||||
SKU: input.SKU,
|
||||
ImageAssetID: input.ImageAssetID,
|
||||
Quantity: int(input.Quantity),
|
||||
MaxBudget: budget,
|
||||
})
|
||||
if err != nil {
|
||||
return Task{}, mapUsecaseError(err)
|
||||
}
|
||||
return taskFromPurchase(result.Task), nil
|
||||
}
|
||||
|
||||
func (adapter *UsecaseAdapter) CancelPending(
|
||||
ctx context.Context,
|
||||
input CancelPendingInput,
|
||||
) (Task, error) {
|
||||
task, err := adapter.tasks.Cancel(ctx, usecase.CancelTaskCommand{
|
||||
CreatorSubject: localAdminSubject,
|
||||
TaskID: input.TaskID,
|
||||
Reason: "管理员取消",
|
||||
})
|
||||
if err != nil {
|
||||
return Task{}, mapUsecaseError(err)
|
||||
}
|
||||
return taskFromPurchase(task), nil
|
||||
}
|
||||
|
||||
func taskSummaryFrom(task domain.PurchaseTask) TaskSummary {
|
||||
return TaskSummary{
|
||||
ID: task.ID,
|
||||
Title: task.Title,
|
||||
SKU: task.SKU,
|
||||
Status: string(task.Status),
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func taskFromPurchase(task domain.PurchaseTask) Task {
|
||||
budget := domain.FormatOptionalCNY(task.MaxBudgetCents)
|
||||
result := Task{
|
||||
ID: task.ID,
|
||||
Title: task.Title,
|
||||
SKU: task.SKU,
|
||||
Description: task.Description,
|
||||
Quantity: int64(task.Quantity),
|
||||
Status: string(task.Status),
|
||||
ReferenceAssetID: task.ImageAssetID,
|
||||
CreatedAt: task.CreatedAt,
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
}
|
||||
if budget != nil {
|
||||
result.MaxBudget = *budget
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func taskFromDetail(detail domain.TaskDetail) Task {
|
||||
task := taskFromPurchase(detail.Task)
|
||||
task.ReferenceAssetID = detail.Asset.ID
|
||||
return task
|
||||
}
|
||||
|
||||
func mapUsecaseError(err error) error {
|
||||
var typed *usecase.Error
|
||||
if !errors.As(err, &typed) {
|
||||
return err
|
||||
}
|
||||
var public error
|
||||
switch typed.Kind {
|
||||
case usecase.ErrorKindInvalid:
|
||||
if typed.Code == "TASK_CANCEL_INVALID" {
|
||||
public = ErrNotFound
|
||||
} else if typed.Code == "ASSET_TOO_LARGE" ||
|
||||
typed.Code == "ASSET_MEDIA_TYPE_UNSUPPORTED" ||
|
||||
typed.Code == "ASSET_IMAGE_INVALID" ||
|
||||
typed.Code == "ASSET_FILE_REQUIRED" {
|
||||
public = ErrInvalidFile
|
||||
} else {
|
||||
public = ErrValidation
|
||||
}
|
||||
case usecase.ErrorKindNotFound:
|
||||
public = ErrNotFound
|
||||
case usecase.ErrorKindConflict:
|
||||
public = ErrConflict
|
||||
case usecase.ErrorKindUnavailable:
|
||||
public = ErrUnavailable
|
||||
default:
|
||||
return err
|
||||
}
|
||||
return &adapterError{
|
||||
public: public,
|
||||
cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
type adapterError struct {
|
||||
public error
|
||||
cause error
|
||||
}
|
||||
|
||||
func (err *adapterError) Error() string {
|
||||
return err.public.Error()
|
||||
}
|
||||
|
||||
func (err *adapterError) Unwrap() []error {
|
||||
return []error{err.public, err.cause}
|
||||
}
|
||||
|
||||
var _ Service = (*UsecaseAdapter)(nil)
|
||||
@@ -0,0 +1,275 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
assetUploadOperation = "UPLOAD_TASK_REFERENCE"
|
||||
maxIdempotencyKeyBytes = 128
|
||||
)
|
||||
|
||||
type AssetService struct {
|
||||
repository AssetRepository
|
||||
store ReferenceImageStore
|
||||
clock Clock
|
||||
ids IDGenerator
|
||||
}
|
||||
|
||||
type UploadTaskReferenceCommand struct {
|
||||
CreatorSubject string
|
||||
IdempotencyKey string
|
||||
DeclaredMediaType string
|
||||
Content io.Reader
|
||||
}
|
||||
|
||||
type UploadTaskReferenceResult struct {
|
||||
Asset domain.Asset
|
||||
Replayed bool
|
||||
}
|
||||
|
||||
type AssetContent struct {
|
||||
Asset domain.Asset
|
||||
Content io.ReadCloser
|
||||
}
|
||||
|
||||
func NewAssetService(
|
||||
repository AssetRepository,
|
||||
store ReferenceImageStore,
|
||||
clock Clock,
|
||||
ids IDGenerator,
|
||||
) (*AssetService, error) {
|
||||
if repository == nil || store == nil || clock == nil || ids == nil {
|
||||
return nil, errors.New("asset service dependencies are required")
|
||||
}
|
||||
return &AssetService{
|
||||
repository: repository,
|
||||
store: store,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AssetService) UploadTaskReference(
|
||||
ctx context.Context,
|
||||
command UploadTaskReferenceCommand,
|
||||
) (UploadTaskReferenceResult, error) {
|
||||
if err := validateWriteIdentity(
|
||||
command.CreatorSubject,
|
||||
command.IdempotencyKey,
|
||||
); err != nil {
|
||||
return UploadTaskReferenceResult{}, err
|
||||
}
|
||||
if command.Content == nil {
|
||||
return UploadTaskReferenceResult{}, invalidError(
|
||||
"ASSET_FILE_REQUIRED",
|
||||
"reference image is required",
|
||||
map[string]string{"file": "required"},
|
||||
)
|
||||
}
|
||||
assetID, err := s.ids.NewID()
|
||||
if err != nil {
|
||||
return UploadTaskReferenceResult{}, newError(
|
||||
ErrorKindInternal,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
err,
|
||||
)
|
||||
}
|
||||
normalized, err := s.store.Put(
|
||||
ctx,
|
||||
assetID,
|
||||
command.DeclaredMediaType,
|
||||
command.Content,
|
||||
)
|
||||
if err != nil {
|
||||
return UploadTaskReferenceResult{}, mapImageStoreError(err)
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = s.store.Delete(context.Background(), normalized.StorageKey)
|
||||
}
|
||||
|
||||
requestHash := sha256.Sum256([]byte(
|
||||
domain.AssetPurposeTaskReference + "\x00" +
|
||||
normalized.InputMediaType + "\x00" +
|
||||
normalized.InputSHA256,
|
||||
))
|
||||
candidate := domain.Asset{
|
||||
ID: assetID,
|
||||
CreatorSubject: strings.TrimSpace(command.CreatorSubject),
|
||||
Purpose: domain.AssetPurposeTaskReference,
|
||||
MediaType: normalized.MediaType,
|
||||
SizeBytes: normalized.SizeBytes,
|
||||
SHA256: normalized.SHA256,
|
||||
StorageKey: normalized.StorageKey,
|
||||
CreatedAt: s.clock.Now().UTC(),
|
||||
}
|
||||
asset, created, err := s.repository.CreateAssetIdempotent(
|
||||
ctx,
|
||||
candidate,
|
||||
strings.TrimSpace(command.IdempotencyKey),
|
||||
hex.EncodeToString(requestHash[:]),
|
||||
)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return UploadTaskReferenceResult{}, wrapRepositoryError(err)
|
||||
}
|
||||
if !created {
|
||||
cleanup()
|
||||
}
|
||||
return UploadTaskReferenceResult{
|
||||
Asset: asset,
|
||||
Replayed: !created,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *AssetService) OpenTaskReference(
|
||||
ctx context.Context,
|
||||
creatorSubject string,
|
||||
assetID string,
|
||||
) (AssetContent, error) {
|
||||
if strings.TrimSpace(creatorSubject) == "" ||
|
||||
!isUUID(assetID) {
|
||||
return AssetContent{}, newError(
|
||||
ErrorKindNotFound,
|
||||
"ASSET_NOT_FOUND",
|
||||
"asset not found",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
asset, err := s.repository.GetAsset(
|
||||
ctx,
|
||||
strings.TrimSpace(creatorSubject),
|
||||
assetID,
|
||||
)
|
||||
if err != nil {
|
||||
result := wrapRepositoryError(err)
|
||||
if typed, ok := result.(*Error); ok &&
|
||||
typed.Kind == ErrorKindNotFound {
|
||||
typed.Code = "ASSET_NOT_FOUND"
|
||||
typed.Message = "asset not found"
|
||||
}
|
||||
return AssetContent{}, result
|
||||
}
|
||||
content, err := s.store.Open(ctx, asset.StorageKey)
|
||||
if err != nil {
|
||||
return AssetContent{}, mapImageStoreError(err)
|
||||
}
|
||||
return AssetContent{Asset: asset, Content: content}, nil
|
||||
}
|
||||
|
||||
func validateWriteIdentity(subject, key string) error {
|
||||
if strings.TrimSpace(subject) == "" {
|
||||
return invalidError(
|
||||
"REQUEST_VALIDATION_FAILED",
|
||||
"request validation failed",
|
||||
map[string]string{"creator_subject": "required"},
|
||||
)
|
||||
}
|
||||
trimmedKey := strings.TrimSpace(key)
|
||||
if trimmedKey == "" {
|
||||
return invalidError(
|
||||
"IDEMPOTENCY_KEY_REQUIRED",
|
||||
"idempotency key is required",
|
||||
map[string]string{"idempotency_key": "required"},
|
||||
)
|
||||
}
|
||||
if len([]byte(trimmedKey)) > maxIdempotencyKeyBytes ||
|
||||
!isPrintableASCII(trimmedKey) {
|
||||
return invalidError(
|
||||
"REQUEST_VALIDATION_FAILED",
|
||||
"request validation failed",
|
||||
map[string]string{"idempotency_key": "invalid"},
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isPrintableASCII(value string) bool {
|
||||
for _, char := range value {
|
||||
if char < 0x21 || char > 0x7e {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type ImageStoreErrorKind string
|
||||
|
||||
const (
|
||||
ImageStoreErrorTooLarge ImageStoreErrorKind = "TOO_LARGE"
|
||||
ImageStoreErrorUnsupported ImageStoreErrorKind = "UNSUPPORTED"
|
||||
ImageStoreErrorInvalid ImageStoreErrorKind = "INVALID"
|
||||
ImageStoreErrorUnavailable ImageStoreErrorKind = "UNAVAILABLE"
|
||||
ImageStoreErrorNotFound ImageStoreErrorKind = "NOT_FOUND"
|
||||
)
|
||||
|
||||
type ImageStoreError struct {
|
||||
Kind ImageStoreErrorKind
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *ImageStoreError) Error() string {
|
||||
return "reference image store operation failed"
|
||||
}
|
||||
|
||||
func (e *ImageStoreError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
func mapImageStoreError(err error) error {
|
||||
var storeError *ImageStoreError
|
||||
if !errors.As(err, &storeError) {
|
||||
return newError(
|
||||
ErrorKindInternal,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
err,
|
||||
)
|
||||
}
|
||||
switch storeError.Kind {
|
||||
case ImageStoreErrorTooLarge:
|
||||
return newError(
|
||||
ErrorKindInvalid,
|
||||
"ASSET_TOO_LARGE",
|
||||
"reference image exceeds the allowed size",
|
||||
err,
|
||||
)
|
||||
case ImageStoreErrorUnsupported:
|
||||
return newError(
|
||||
ErrorKindInvalid,
|
||||
"ASSET_MEDIA_TYPE_UNSUPPORTED",
|
||||
"reference image media type is not supported",
|
||||
err,
|
||||
)
|
||||
case ImageStoreErrorInvalid:
|
||||
return invalidError(
|
||||
"ASSET_IMAGE_INVALID",
|
||||
"reference image is invalid",
|
||||
map[string]string{"file": "must be a decodable image"},
|
||||
)
|
||||
case ImageStoreErrorNotFound:
|
||||
return newError(
|
||||
ErrorKindNotFound,
|
||||
"ASSET_NOT_FOUND",
|
||||
"asset not found",
|
||||
err,
|
||||
)
|
||||
default:
|
||||
result := newError(
|
||||
ErrorKindUnavailable,
|
||||
"ASSET_STORAGE_UNAVAILABLE",
|
||||
"asset storage is temporarily unavailable",
|
||||
err,
|
||||
)
|
||||
result.Retryable = true
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
func TestAssetServiceUploadPersistsNormalizedMetadata(t *testing.T) {
|
||||
repository := &fakeAssetRepository{}
|
||||
store := &fakeReferenceImageStore{
|
||||
result: NormalizedReferenceImage{
|
||||
StorageKey: "aa/file.jpg",
|
||||
InputMediaType: "image/png",
|
||||
InputSHA256: strings.Repeat("1", 64),
|
||||
MediaType: "image/jpeg",
|
||||
SizeBytes: 123,
|
||||
SHA256: strings.Repeat("2", 64),
|
||||
},
|
||||
}
|
||||
service := mustAssetService(t, repository, store)
|
||||
result, err := service.UploadTaskReference(
|
||||
context.Background(),
|
||||
UploadTaskReferenceCommand{
|
||||
CreatorSubject: " local-admin ",
|
||||
IdempotencyKey: " upload-1 ",
|
||||
DeclaredMediaType: "image/png",
|
||||
Content: bytes.NewReader([]byte("fixture")),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("UploadTaskReference() error = %v", err)
|
||||
}
|
||||
if result.Replayed ||
|
||||
result.Asset.CreatorSubject != "local-admin" ||
|
||||
result.Asset.Purpose != domain.AssetPurposeTaskReference ||
|
||||
result.Asset.StorageKey != store.result.StorageKey ||
|
||||
len(repository.hash) != 64 {
|
||||
t.Fatalf("result/repository = %+v/%+v", result, repository)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetServiceDeletesNewFileWhenRepositoryReplaysOrFails(
|
||||
t *testing.T,
|
||||
) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
replay bool
|
||||
createErr error
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "replay", replay: true},
|
||||
{name: "failure", createErr: ErrRepositoryUnavailable, wantErr: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repository := &fakeAssetRepository{
|
||||
replay: test.replay,
|
||||
createErr: test.createErr,
|
||||
}
|
||||
store := &fakeReferenceImageStore{
|
||||
result: NormalizedReferenceImage{
|
||||
StorageKey: "aa/candidate.jpg",
|
||||
InputMediaType: "image/jpeg",
|
||||
InputSHA256: strings.Repeat("1", 64),
|
||||
MediaType: "image/jpeg",
|
||||
SizeBytes: 123,
|
||||
SHA256: strings.Repeat("2", 64),
|
||||
},
|
||||
}
|
||||
service := mustAssetService(t, repository, store)
|
||||
_, err := service.UploadTaskReference(
|
||||
context.Background(),
|
||||
UploadTaskReferenceCommand{
|
||||
CreatorSubject: "local-admin",
|
||||
IdempotencyKey: "upload-1",
|
||||
DeclaredMediaType: "image/jpeg",
|
||||
Content: bytes.NewReader([]byte("fixture")),
|
||||
},
|
||||
)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("UploadTaskReference() error = %v", err)
|
||||
}
|
||||
if store.deleted != "aa/candidate.jpg" {
|
||||
t.Fatalf("deleted key = %q", store.deleted)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetServiceMapsImageValidationError(t *testing.T) {
|
||||
service := mustAssetService(
|
||||
t,
|
||||
&fakeAssetRepository{},
|
||||
&fakeReferenceImageStore{
|
||||
err: &ImageStoreError{
|
||||
Kind: ImageStoreErrorInvalid,
|
||||
Cause: errors.New("private decoder detail"),
|
||||
},
|
||||
},
|
||||
)
|
||||
_, err := service.UploadTaskReference(
|
||||
context.Background(),
|
||||
UploadTaskReferenceCommand{
|
||||
CreatorSubject: "local-admin",
|
||||
IdempotencyKey: "upload-1",
|
||||
DeclaredMediaType: "image/jpeg",
|
||||
Content: bytes.NewReader([]byte("bad")),
|
||||
},
|
||||
)
|
||||
assertUsecaseError(t, err, ErrorKindInvalid, "ASSET_IMAGE_INVALID")
|
||||
if strings.Contains(err.Error(), "private") {
|
||||
t.Fatal("public error leaked decoder detail")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeAssetRepository struct {
|
||||
asset domain.Asset
|
||||
replay bool
|
||||
createErr error
|
||||
hash string
|
||||
}
|
||||
|
||||
func (repository *fakeAssetRepository) CreateAssetIdempotent(
|
||||
_ context.Context,
|
||||
asset domain.Asset,
|
||||
_ string,
|
||||
hash string,
|
||||
) (domain.Asset, bool, error) {
|
||||
repository.asset = asset
|
||||
repository.hash = hash
|
||||
if repository.createErr != nil {
|
||||
return asset, false, repository.createErr
|
||||
}
|
||||
if repository.replay {
|
||||
return asset, false, nil
|
||||
}
|
||||
return asset, true, nil
|
||||
}
|
||||
|
||||
func (repository *fakeAssetRepository) GetAsset(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.Asset, error) {
|
||||
return repository.asset, nil
|
||||
}
|
||||
|
||||
type fakeReferenceImageStore struct {
|
||||
result NormalizedReferenceImage
|
||||
err error
|
||||
deleted string
|
||||
}
|
||||
|
||||
func (store *fakeReferenceImageStore) Put(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
io.Reader,
|
||||
) (NormalizedReferenceImage, error) {
|
||||
return store.result, store.err
|
||||
}
|
||||
|
||||
func (store *fakeReferenceImageStore) Open(
|
||||
context.Context,
|
||||
string,
|
||||
) (io.ReadCloser, error) {
|
||||
return io.NopCloser(bytes.NewReader(nil)), nil
|
||||
}
|
||||
|
||||
func (store *fakeReferenceImageStore) Delete(
|
||||
_ context.Context,
|
||||
key string,
|
||||
) error {
|
||||
store.deleted = key
|
||||
return nil
|
||||
}
|
||||
|
||||
func mustAssetService(
|
||||
t *testing.T,
|
||||
repository AssetRepository,
|
||||
store ReferenceImageStore,
|
||||
) *AssetService {
|
||||
t.Helper()
|
||||
service, err := NewAssetService(
|
||||
repository,
|
||||
store,
|
||||
fakeClock{},
|
||||
&sequenceIDs{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAssetService() error = %v", err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
var _ Clock = fakeClock{}
|
||||
@@ -0,0 +1,126 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type ErrorKind string
|
||||
|
||||
const (
|
||||
ErrorKindInvalid ErrorKind = "INVALID"
|
||||
ErrorKindNotFound ErrorKind = "NOT_FOUND"
|
||||
ErrorKindConflict ErrorKind = "CONFLICT"
|
||||
ErrorKindUnavailable ErrorKind = "UNAVAILABLE"
|
||||
ErrorKindInternal ErrorKind = "INTERNAL"
|
||||
)
|
||||
|
||||
type Error struct {
|
||||
Kind ErrorKind
|
||||
Code string
|
||||
Message string
|
||||
Retryable bool
|
||||
Fields map[string]string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e.Message != "" {
|
||||
return e.Message
|
||||
}
|
||||
return "use case failed"
|
||||
}
|
||||
|
||||
func (e *Error) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
func newError(
|
||||
kind ErrorKind,
|
||||
code string,
|
||||
message string,
|
||||
cause error,
|
||||
) *Error {
|
||||
return &Error{
|
||||
Kind: kind,
|
||||
Code: code,
|
||||
Message: message,
|
||||
Cause: cause,
|
||||
Fields: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func invalidError(code, message string, fields map[string]string) *Error {
|
||||
return &Error{
|
||||
Kind: ErrorKindInvalid,
|
||||
Code: code,
|
||||
Message: message,
|
||||
Fields: fields,
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
ErrRepositoryNotFound = errors.New("repository resource not found")
|
||||
ErrIdempotencyConflict = errors.New("idempotency key payload conflict")
|
||||
ErrSourceReferenceConflict = errors.New("source reference conflict")
|
||||
ErrAssetUnavailable = errors.New("asset is unavailable")
|
||||
ErrTaskStateConflict = errors.New("task state conflict")
|
||||
ErrRepositoryUnavailable = errors.New("repository unavailable")
|
||||
ErrRepositoryInvariant = errors.New("repository invariant failed")
|
||||
)
|
||||
|
||||
func wrapRepositoryError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, ErrRepositoryNotFound):
|
||||
return newError(
|
||||
ErrorKindNotFound,
|
||||
"RESOURCE_NOT_FOUND",
|
||||
"resource not found",
|
||||
err,
|
||||
)
|
||||
case errors.Is(err, ErrIdempotencyConflict):
|
||||
return newError(
|
||||
ErrorKindConflict,
|
||||
"IDEMPOTENCY_KEY_CONFLICT",
|
||||
"idempotency key was already used for a different request",
|
||||
err,
|
||||
)
|
||||
case errors.Is(err, ErrSourceReferenceConflict):
|
||||
return newError(
|
||||
ErrorKindConflict,
|
||||
"TASK_SOURCE_REF_CONFLICT",
|
||||
"source reference already exists",
|
||||
err,
|
||||
)
|
||||
case errors.Is(err, ErrAssetUnavailable):
|
||||
return newError(
|
||||
ErrorKindConflict,
|
||||
"TASK_ASSET_UNAVAILABLE",
|
||||
"reference image is not available for this task",
|
||||
err,
|
||||
)
|
||||
case errors.Is(err, ErrTaskStateConflict):
|
||||
return newError(
|
||||
ErrorKindConflict,
|
||||
"TASK_STATE_CONFLICT",
|
||||
"task state does not allow this operation",
|
||||
err,
|
||||
)
|
||||
case errors.Is(err, ErrRepositoryUnavailable):
|
||||
result := newError(
|
||||
ErrorKindUnavailable,
|
||||
"STORAGE_UNAVAILABLE",
|
||||
"storage is temporarily unavailable",
|
||||
err,
|
||||
)
|
||||
result.Retryable = true
|
||||
return result
|
||||
default:
|
||||
return newError(
|
||||
ErrorKindInternal,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
fmt.Errorf("%w: %v", ErrRepositoryInvariant, err),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
type Clock interface {
|
||||
Now() time.Time
|
||||
}
|
||||
|
||||
type IDGenerator interface {
|
||||
NewID() (string, error)
|
||||
}
|
||||
|
||||
type NormalizedReferenceImage struct {
|
||||
StorageKey string
|
||||
InputMediaType string
|
||||
InputSHA256 string
|
||||
MediaType string
|
||||
SizeBytes int64
|
||||
SHA256 string
|
||||
}
|
||||
|
||||
type ReferenceImageStore interface {
|
||||
Put(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
io.Reader,
|
||||
) (NormalizedReferenceImage, error)
|
||||
Open(context.Context, string) (io.ReadCloser, error)
|
||||
Delete(context.Context, string) error
|
||||
}
|
||||
|
||||
type AssetRepository interface {
|
||||
CreateAssetIdempotent(
|
||||
context.Context,
|
||||
domain.Asset,
|
||||
string,
|
||||
string,
|
||||
) (domain.Asset, bool, error)
|
||||
GetAsset(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.Asset, error)
|
||||
}
|
||||
|
||||
type TaskCursor struct {
|
||||
CreatedAt time.Time
|
||||
ID string
|
||||
}
|
||||
|
||||
type TaskListFilter struct {
|
||||
CreatorSubject string
|
||||
Status *domain.TaskStatus
|
||||
Query string
|
||||
CreatedFrom *time.Time
|
||||
CreatedTo *time.Time
|
||||
Limit int
|
||||
After *TaskCursor
|
||||
}
|
||||
|
||||
type TaskRepository interface {
|
||||
CreateTaskIdempotent(
|
||||
context.Context,
|
||||
domain.PurchaseTask,
|
||||
domain.TaskEvent,
|
||||
string,
|
||||
string,
|
||||
) (domain.PurchaseTask, bool, error)
|
||||
ListTasks(
|
||||
context.Context,
|
||||
TaskListFilter,
|
||||
) ([]domain.PurchaseTask, error)
|
||||
GetTaskDetail(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.TaskDetail, error)
|
||||
CancelPendingTask(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
string,
|
||||
time.Time,
|
||||
domain.TaskEvent,
|
||||
) (domain.PurchaseTask, error)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SystemClock struct{}
|
||||
|
||||
func (SystemClock) Now() time.Time {
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
type UUIDGenerator struct{}
|
||||
|
||||
func (UUIDGenerator) NewID() (string, error) {
|
||||
var value [16]byte
|
||||
if _, err := rand.Read(value[:]); err != nil {
|
||||
now := uint64(time.Now().UnixNano())
|
||||
binary.BigEndian.PutUint64(value[:8], now)
|
||||
binary.BigEndian.PutUint64(value[8:], fallbackUUID.Add(1))
|
||||
}
|
||||
value[6] = (value[6] & 0x0f) | 0x40
|
||||
value[8] = (value[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf(
|
||||
"%08x-%04x-%04x-%04x-%012x",
|
||||
value[0:4],
|
||||
value[4:6],
|
||||
value[6:8],
|
||||
value[8:10],
|
||||
value[10:16],
|
||||
), nil
|
||||
}
|
||||
|
||||
var fallbackUUID atomic.Uint64
|
||||
@@ -0,0 +1,459 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
createTaskOperation = "CREATE_PURCHASE_TASK"
|
||||
defaultTaskListLimit = 20
|
||||
maxTaskListLimit = 100
|
||||
maxTaskQueryBytes = 256
|
||||
)
|
||||
|
||||
type TaskService struct {
|
||||
repository TaskRepository
|
||||
clock Clock
|
||||
ids IDGenerator
|
||||
}
|
||||
|
||||
type CreateTaskCommand struct {
|
||||
CreatorSubject string
|
||||
IdempotencyKey string
|
||||
SourceRef *string
|
||||
Title string
|
||||
Description string
|
||||
SKU string
|
||||
ImageAssetID string
|
||||
Quantity int
|
||||
MaxBudget *string
|
||||
}
|
||||
|
||||
type CreateTaskResult struct {
|
||||
Task domain.PurchaseTask
|
||||
Replayed bool
|
||||
}
|
||||
|
||||
type ListTasksQuery struct {
|
||||
CreatorSubject string
|
||||
Status *string
|
||||
Query string
|
||||
CreatedFrom *time.Time
|
||||
CreatedTo *time.Time
|
||||
Limit int
|
||||
Cursor string
|
||||
}
|
||||
|
||||
type TaskPage struct {
|
||||
Items []domain.PurchaseTask
|
||||
NextCursor string
|
||||
}
|
||||
|
||||
type CancelTaskCommand struct {
|
||||
CreatorSubject string
|
||||
TaskID string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func NewTaskService(
|
||||
repository TaskRepository,
|
||||
clock Clock,
|
||||
ids IDGenerator,
|
||||
) (*TaskService, error) {
|
||||
if repository == nil || clock == nil || ids == nil {
|
||||
return nil, errors.New("task service dependencies are required")
|
||||
}
|
||||
return &TaskService{
|
||||
repository: repository,
|
||||
clock: clock,
|
||||
ids: ids,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) Create(
|
||||
ctx context.Context,
|
||||
command CreateTaskCommand,
|
||||
) (CreateTaskResult, error) {
|
||||
if err := validateWriteIdentity(
|
||||
command.CreatorSubject,
|
||||
command.IdempotencyKey,
|
||||
); err != nil {
|
||||
return CreateTaskResult{}, err
|
||||
}
|
||||
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
|
||||
command.Title = strings.TrimSpace(command.Title)
|
||||
command.SKU = strings.TrimSpace(command.SKU)
|
||||
command.ImageAssetID = strings.TrimSpace(command.ImageAssetID)
|
||||
if command.SourceRef != nil {
|
||||
value := strings.TrimSpace(*command.SourceRef)
|
||||
command.SourceRef = &value
|
||||
}
|
||||
if !isUUID(command.ImageAssetID) {
|
||||
return CreateTaskResult{}, invalidError(
|
||||
"TASK_VALIDATION_FAILED",
|
||||
"task validation failed",
|
||||
map[string]string{"image_asset_id": "must be a UUID"},
|
||||
)
|
||||
}
|
||||
if err := domain.ValidateTaskInput(
|
||||
command.CreatorSubject,
|
||||
command.SourceRef,
|
||||
command.Title,
|
||||
command.Description,
|
||||
command.SKU,
|
||||
command.ImageAssetID,
|
||||
command.Quantity,
|
||||
); err != nil {
|
||||
var validationError *domain.TaskValidationError
|
||||
if errors.As(err, &validationError) {
|
||||
return CreateTaskResult{}, invalidError(
|
||||
"TASK_VALIDATION_FAILED",
|
||||
"task validation failed",
|
||||
validationError.Fields,
|
||||
)
|
||||
}
|
||||
return CreateTaskResult{}, newError(
|
||||
ErrorKindInternal,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
err,
|
||||
)
|
||||
}
|
||||
budget, err := domain.ParseOptionalCNY(command.MaxBudget)
|
||||
if err != nil {
|
||||
return CreateTaskResult{}, invalidError(
|
||||
"TASK_VALIDATION_FAILED",
|
||||
"task validation failed",
|
||||
map[string]string{"max_budget": "must be a positive CNY amount with at most two decimals"},
|
||||
)
|
||||
}
|
||||
taskID, err := s.ids.NewID()
|
||||
if err != nil {
|
||||
return CreateTaskResult{}, newError(
|
||||
ErrorKindInternal,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
err,
|
||||
)
|
||||
}
|
||||
eventID, err := s.ids.NewID()
|
||||
if err != nil {
|
||||
return CreateTaskResult{}, newError(
|
||||
ErrorKindInternal,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
err,
|
||||
)
|
||||
}
|
||||
now := s.clock.Now().UTC()
|
||||
task := domain.PurchaseTask{
|
||||
ID: taskID,
|
||||
CreatorSubject: command.CreatorSubject,
|
||||
SourceRef: command.SourceRef,
|
||||
Title: command.Title,
|
||||
Description: command.Description,
|
||||
SKU: command.SKU,
|
||||
ImageAssetID: command.ImageAssetID,
|
||||
Quantity: command.Quantity,
|
||||
MaxBudgetCents: budget,
|
||||
Currency: domain.CurrencyCNY,
|
||||
Status: domain.TaskStatusPending,
|
||||
Version: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
event := domain.TaskEvent{
|
||||
ID: eventID,
|
||||
TaskID: taskID,
|
||||
Type: "TASK_CREATED",
|
||||
Message: "task created",
|
||||
OccurredAt: now,
|
||||
}
|
||||
requestHash, err := hashCreateTaskCommand(command, budget)
|
||||
if err != nil {
|
||||
return CreateTaskResult{}, newError(
|
||||
ErrorKindInternal,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
err,
|
||||
)
|
||||
}
|
||||
createdTask, created, err := s.repository.CreateTaskIdempotent(
|
||||
ctx,
|
||||
task,
|
||||
event,
|
||||
strings.TrimSpace(command.IdempotencyKey),
|
||||
requestHash,
|
||||
)
|
||||
if err != nil {
|
||||
return CreateTaskResult{}, wrapRepositoryError(err)
|
||||
}
|
||||
return CreateTaskResult{
|
||||
Task: createdTask,
|
||||
Replayed: !created,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) List(
|
||||
ctx context.Context,
|
||||
query ListTasksQuery,
|
||||
) (TaskPage, error) {
|
||||
query.CreatorSubject = strings.TrimSpace(query.CreatorSubject)
|
||||
if query.CreatorSubject == "" {
|
||||
return TaskPage{}, invalidError(
|
||||
"REQUEST_VALIDATION_FAILED",
|
||||
"request validation failed",
|
||||
map[string]string{"creator_subject": "required"},
|
||||
)
|
||||
}
|
||||
filter := TaskListFilter{
|
||||
CreatorSubject: query.CreatorSubject,
|
||||
Query: strings.TrimSpace(query.Query),
|
||||
CreatedFrom: query.CreatedFrom,
|
||||
CreatedTo: query.CreatedTo,
|
||||
Limit: query.Limit,
|
||||
}
|
||||
if len([]byte(filter.Query)) > maxTaskQueryBytes {
|
||||
return TaskPage{}, invalidError(
|
||||
"TASK_LIST_FILTER_INVALID",
|
||||
"task list filter is invalid",
|
||||
map[string]string{"q": "too long"},
|
||||
)
|
||||
}
|
||||
if filter.Limit == 0 {
|
||||
filter.Limit = defaultTaskListLimit
|
||||
}
|
||||
if filter.Limit < 1 || filter.Limit > maxTaskListLimit {
|
||||
return TaskPage{}, invalidError(
|
||||
"TASK_LIST_FILTER_INVALID",
|
||||
"task list filter is invalid",
|
||||
map[string]string{"limit": "must be between 1 and 100"},
|
||||
)
|
||||
}
|
||||
if query.Status != nil {
|
||||
status := domain.TaskStatus(strings.TrimSpace(*query.Status))
|
||||
if !domain.IsValidTaskStatus(status) {
|
||||
return TaskPage{}, invalidError(
|
||||
"TASK_LIST_FILTER_INVALID",
|
||||
"task list filter is invalid",
|
||||
map[string]string{"status": "unknown status"},
|
||||
)
|
||||
}
|
||||
filter.Status = &status
|
||||
}
|
||||
if query.CreatedFrom != nil && query.CreatedTo != nil &&
|
||||
query.CreatedFrom.After(*query.CreatedTo) {
|
||||
return TaskPage{}, invalidError(
|
||||
"TASK_LIST_FILTER_INVALID",
|
||||
"task list filter is invalid",
|
||||
map[string]string{"created_from": "must not be after created_to"},
|
||||
)
|
||||
}
|
||||
if query.Cursor != "" {
|
||||
cursor, err := decodeTaskCursor(query.Cursor)
|
||||
if err != nil {
|
||||
return TaskPage{}, invalidError(
|
||||
"TASK_CURSOR_INVALID",
|
||||
"task cursor is invalid",
|
||||
map[string]string{"cursor": "invalid"},
|
||||
)
|
||||
}
|
||||
filter.After = &cursor
|
||||
}
|
||||
filter.Limit++
|
||||
items, err := s.repository.ListTasks(ctx, filter)
|
||||
if err != nil {
|
||||
return TaskPage{}, wrapRepositoryError(err)
|
||||
}
|
||||
page := TaskPage{Items: items}
|
||||
if len(items) >= filter.Limit {
|
||||
page.Items = items[:filter.Limit-1]
|
||||
last := page.Items[len(page.Items)-1]
|
||||
page.NextCursor = encodeTaskCursor(TaskCursor{
|
||||
CreatedAt: last.CreatedAt,
|
||||
ID: last.ID,
|
||||
})
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) Get(
|
||||
ctx context.Context,
|
||||
creatorSubject string,
|
||||
taskID string,
|
||||
) (domain.TaskDetail, error) {
|
||||
creatorSubject = strings.TrimSpace(creatorSubject)
|
||||
if creatorSubject == "" || !isUUID(taskID) {
|
||||
return domain.TaskDetail{}, newError(
|
||||
ErrorKindNotFound,
|
||||
"TASK_NOT_FOUND",
|
||||
"task not found",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
detail, err := s.repository.GetTaskDetail(
|
||||
ctx,
|
||||
creatorSubject,
|
||||
taskID,
|
||||
)
|
||||
if err != nil {
|
||||
result := wrapRepositoryError(err)
|
||||
if typed, ok := result.(*Error); ok &&
|
||||
typed.Kind == ErrorKindNotFound {
|
||||
typed.Code = "TASK_NOT_FOUND"
|
||||
typed.Message = "task not found"
|
||||
}
|
||||
return domain.TaskDetail{}, result
|
||||
}
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func (s *TaskService) Cancel(
|
||||
ctx context.Context,
|
||||
command CancelTaskCommand,
|
||||
) (domain.PurchaseTask, error) {
|
||||
command.CreatorSubject = strings.TrimSpace(command.CreatorSubject)
|
||||
command.TaskID = strings.TrimSpace(command.TaskID)
|
||||
command.Reason = strings.TrimSpace(command.Reason)
|
||||
fields := make(map[string]string)
|
||||
if command.CreatorSubject == "" {
|
||||
fields["creator_subject"] = "required"
|
||||
}
|
||||
if !isUUID(command.TaskID) {
|
||||
fields["task_id"] = "must be a UUID"
|
||||
}
|
||||
if len([]byte(command.Reason)) > domain.MaxCancelReasonBytes {
|
||||
fields["reason"] = "too long"
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
return domain.PurchaseTask{}, invalidError(
|
||||
"TASK_CANCEL_INVALID",
|
||||
"task cancellation is invalid",
|
||||
fields,
|
||||
)
|
||||
}
|
||||
eventID, err := s.ids.NewID()
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, newError(
|
||||
ErrorKindInternal,
|
||||
"INTERNAL_ERROR",
|
||||
"internal server error",
|
||||
err,
|
||||
)
|
||||
}
|
||||
now := s.clock.Now().UTC()
|
||||
event := domain.TaskEvent{
|
||||
ID: eventID,
|
||||
TaskID: command.TaskID,
|
||||
Type: "TASK_CANCELED",
|
||||
Message: "task canceled",
|
||||
OccurredAt: now,
|
||||
}
|
||||
task, err := s.repository.CancelPendingTask(
|
||||
ctx,
|
||||
command.CreatorSubject,
|
||||
command.TaskID,
|
||||
command.Reason,
|
||||
now,
|
||||
event,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.PurchaseTask{}, wrapRepositoryError(err)
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func hashCreateTaskCommand(
|
||||
command CreateTaskCommand,
|
||||
budget *int64,
|
||||
) (string, error) {
|
||||
payload := struct {
|
||||
SourceRef *string `json:"source_ref"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
SKU string `json:"sku"`
|
||||
ImageAssetID string `json:"image_asset_id"`
|
||||
Quantity int `json:"quantity"`
|
||||
MaxBudgetCents *int64 `json:"max_budget_cents"`
|
||||
}{
|
||||
SourceRef: command.SourceRef,
|
||||
Title: command.Title,
|
||||
Description: command.Description,
|
||||
SKU: command.SKU,
|
||||
ImageAssetID: command.ImageAssetID,
|
||||
Quantity: command.Quantity,
|
||||
MaxBudgetCents: budget,
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash := sha256.Sum256(encoded)
|
||||
return hex.EncodeToString(hash[:]), nil
|
||||
}
|
||||
|
||||
func encodeTaskCursor(cursor TaskCursor) string {
|
||||
payload := struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID string `json:"id"`
|
||||
}{
|
||||
CreatedAt: cursor.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
ID: cursor.ID,
|
||||
}
|
||||
encoded, _ := json.Marshal(payload)
|
||||
return base64.RawURLEncoding.EncodeToString(encoded)
|
||||
}
|
||||
|
||||
func decodeTaskCursor(value string) (TaskCursor, error) {
|
||||
encoded, err := base64.RawURLEncoding.DecodeString(value)
|
||||
if err != nil {
|
||||
return TaskCursor{}, err
|
||||
}
|
||||
var payload struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(string(encoded)))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
return TaskCursor{}, err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return TaskCursor{}, errors.New("task cursor has trailing data")
|
||||
}
|
||||
createdAt, err := time.Parse(time.RFC3339Nano, payload.CreatedAt)
|
||||
if err != nil || !isUUID(payload.ID) {
|
||||
return TaskCursor{}, errors.New("invalid task cursor")
|
||||
}
|
||||
return TaskCursor{CreatedAt: createdAt.UTC(), ID: payload.ID}, nil
|
||||
}
|
||||
|
||||
func isUUID(value string) bool {
|
||||
if len(value) != 36 {
|
||||
return false
|
||||
}
|
||||
for index, char := range value {
|
||||
if index == 8 || index == 13 || index == 18 || index == 23 {
|
||||
if char != '-' {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !((char >= '0' && char <= '9') ||
|
||||
(char >= 'a' && char <= 'f') ||
|
||||
(char >= 'A' && char <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
)
|
||||
|
||||
func TestTaskServiceCreateNormalizesAndHashesForIdempotency(t *testing.T) {
|
||||
repository := &fakeTaskRepository{}
|
||||
service := mustTaskService(t, repository)
|
||||
budget := "20.00"
|
||||
sourceRef := " source-1 "
|
||||
|
||||
result, err := service.Create(context.Background(), CreateTaskCommand{
|
||||
CreatorSubject: " local-admin ",
|
||||
IdempotencyKey: " create-1 ",
|
||||
SourceRef: &sourceRef,
|
||||
Title: " Demo title ",
|
||||
Description: "description",
|
||||
SKU: " SKU-1 ",
|
||||
ImageAssetID: "00000000-0000-4000-8000-000000000001",
|
||||
Quantity: 2,
|
||||
MaxBudget: &budget,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
if result.Task.Status != domain.TaskStatusPending ||
|
||||
result.Task.Title != "Demo title" ||
|
||||
result.Task.SKU != "SKU-1" ||
|
||||
result.Task.SourceRef == nil ||
|
||||
*result.Task.SourceRef != "source-1" ||
|
||||
result.Task.MaxBudgetCents == nil ||
|
||||
*result.Task.MaxBudgetCents != 2000 {
|
||||
t.Fatalf("created task = %+v", result.Task)
|
||||
}
|
||||
if repository.key != "create-1" || len(repository.hash) != 64 {
|
||||
t.Fatalf(
|
||||
"idempotency key/hash = %q/%q",
|
||||
repository.key,
|
||||
repository.hash,
|
||||
)
|
||||
}
|
||||
if repository.event.Type != "TASK_CREATED" ||
|
||||
repository.event.TaskID != result.Task.ID {
|
||||
t.Fatalf("event = %+v", repository.event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskServiceCreateMapsValidationAndRepositoryErrors(t *testing.T) {
|
||||
repository := &fakeTaskRepository{createErr: ErrAssetUnavailable}
|
||||
service := mustTaskService(t, repository)
|
||||
_, err := service.Create(context.Background(), CreateTaskCommand{
|
||||
CreatorSubject: "local-admin",
|
||||
IdempotencyKey: "create-1",
|
||||
Title: "title",
|
||||
SKU: "sku",
|
||||
ImageAssetID: "00000000-0000-4000-8000-000000000001",
|
||||
Quantity: 1,
|
||||
})
|
||||
assertUsecaseError(t, err, ErrorKindConflict, "TASK_ASSET_UNAVAILABLE")
|
||||
|
||||
_, err = service.Create(context.Background(), CreateTaskCommand{
|
||||
CreatorSubject: "local-admin",
|
||||
IdempotencyKey: "create-2",
|
||||
Title: "",
|
||||
SKU: "",
|
||||
ImageAssetID: "bad",
|
||||
Quantity: 0,
|
||||
})
|
||||
assertUsecaseError(t, err, ErrorKindInvalid, "TASK_VALIDATION_FAILED")
|
||||
}
|
||||
|
||||
func TestTaskServiceListUsesStableOpaqueCursor(t *testing.T) {
|
||||
createdAt := time.Date(2026, 7, 26, 1, 2, 3, 4, time.UTC)
|
||||
repository := &fakeTaskRepository{
|
||||
listResult: []domain.PurchaseTask{
|
||||
{ID: "00000000-0000-4000-8000-000000000003", CreatedAt: createdAt},
|
||||
{ID: "00000000-0000-4000-8000-000000000002", CreatedAt: createdAt},
|
||||
{ID: "00000000-0000-4000-8000-000000000001", CreatedAt: createdAt},
|
||||
},
|
||||
}
|
||||
service := mustTaskService(t, repository)
|
||||
page, err := service.List(context.Background(), ListTasksQuery{
|
||||
CreatorSubject: "local-admin",
|
||||
Limit: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("List() error = %v", err)
|
||||
}
|
||||
if len(page.Items) != 2 || page.NextCursor == "" {
|
||||
t.Fatalf("page = %+v", page)
|
||||
}
|
||||
if repository.filter.Limit != 3 {
|
||||
t.Fatalf("repository limit = %d", repository.filter.Limit)
|
||||
}
|
||||
cursor, err := decodeTaskCursor(page.NextCursor)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeTaskCursor() error = %v", err)
|
||||
}
|
||||
if cursor.ID != page.Items[1].ID || !cursor.CreatedAt.Equal(createdAt) {
|
||||
t.Fatalf("cursor = %+v", cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskServiceCancelMapsStateConflict(t *testing.T) {
|
||||
repository := &fakeTaskRepository{cancelErr: ErrTaskStateConflict}
|
||||
service := mustTaskService(t, repository)
|
||||
_, err := service.Cancel(context.Background(), CancelTaskCommand{
|
||||
CreatorSubject: "local-admin",
|
||||
TaskID: "00000000-0000-4000-8000-000000000001",
|
||||
Reason: "no longer needed",
|
||||
})
|
||||
assertUsecaseError(t, err, ErrorKindConflict, "TASK_STATE_CONFLICT")
|
||||
}
|
||||
|
||||
type fakeClock struct{}
|
||||
|
||||
func (fakeClock) Now() time.Time {
|
||||
return time.Date(2026, 7, 26, 1, 2, 3, 4, time.UTC)
|
||||
}
|
||||
|
||||
type sequenceIDs struct {
|
||||
next int
|
||||
}
|
||||
|
||||
func (generator *sequenceIDs) NewID() (string, error) {
|
||||
generator.next++
|
||||
return "00000000-0000-4000-8000-" +
|
||||
pad12(generator.next), nil
|
||||
}
|
||||
|
||||
func pad12(value int) string {
|
||||
result := "000000000000"
|
||||
digits := []byte{}
|
||||
for value > 0 {
|
||||
digits = append([]byte{byte('0' + value%10)}, digits...)
|
||||
value /= 10
|
||||
}
|
||||
if len(digits) == 0 {
|
||||
digits = []byte{'0'}
|
||||
}
|
||||
return result[:12-len(digits)] + string(digits)
|
||||
}
|
||||
|
||||
type fakeTaskRepository struct {
|
||||
createErr error
|
||||
cancelErr error
|
||||
listResult []domain.PurchaseTask
|
||||
task domain.PurchaseTask
|
||||
event domain.TaskEvent
|
||||
key string
|
||||
hash string
|
||||
filter TaskListFilter
|
||||
}
|
||||
|
||||
func (repository *fakeTaskRepository) CreateTaskIdempotent(
|
||||
_ context.Context,
|
||||
task domain.PurchaseTask,
|
||||
event domain.TaskEvent,
|
||||
key string,
|
||||
hash string,
|
||||
) (domain.PurchaseTask, bool, error) {
|
||||
repository.task = task
|
||||
repository.event = event
|
||||
repository.key = key
|
||||
repository.hash = hash
|
||||
return task, true, repository.createErr
|
||||
}
|
||||
|
||||
func (repository *fakeTaskRepository) ListTasks(
|
||||
_ context.Context,
|
||||
filter TaskListFilter,
|
||||
) ([]domain.PurchaseTask, error) {
|
||||
repository.filter = filter
|
||||
return repository.listResult, nil
|
||||
}
|
||||
|
||||
func (repository *fakeTaskRepository) GetTaskDetail(
|
||||
context.Context,
|
||||
string,
|
||||
string,
|
||||
) (domain.TaskDetail, error) {
|
||||
return domain.TaskDetail{}, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (repository *fakeTaskRepository) CancelPendingTask(
|
||||
_ context.Context,
|
||||
_ string,
|
||||
_ string,
|
||||
_ string,
|
||||
_ time.Time,
|
||||
_ domain.TaskEvent,
|
||||
) (domain.PurchaseTask, error) {
|
||||
return domain.PurchaseTask{}, repository.cancelErr
|
||||
}
|
||||
|
||||
func mustTaskService(
|
||||
t *testing.T,
|
||||
repository TaskRepository,
|
||||
) *TaskService {
|
||||
t.Helper()
|
||||
service, err := NewTaskService(
|
||||
repository,
|
||||
fakeClock{},
|
||||
&sequenceIDs{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewTaskService() error = %v", err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func assertUsecaseError(
|
||||
t *testing.T,
|
||||
err error,
|
||||
kind ErrorKind,
|
||||
code string,
|
||||
) {
|
||||
t.Helper()
|
||||
var typed *Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error = %v, want *Error", err)
|
||||
}
|
||||
if typed.Kind != kind || typed.Code != code {
|
||||
t.Fatalf("error = %+v, want kind=%s code=%s", typed, kind, code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE assets (
|
||||
id TEXT PRIMARY KEY NOT NULL
|
||||
CHECK (length(id) = 36),
|
||||
creator_subject TEXT NOT NULL
|
||||
CHECK (length(trim(creator_subject)) > 0),
|
||||
purpose TEXT NOT NULL
|
||||
CHECK (purpose IN ('TASK_REFERENCE')),
|
||||
media_type TEXT NOT NULL
|
||||
CHECK (media_type = 'image/jpeg'),
|
||||
size_bytes INTEGER NOT NULL
|
||||
CHECK (size_bytes > 0),
|
||||
sha256 TEXT NOT NULL
|
||||
CHECK (
|
||||
length(sha256) = 64
|
||||
AND sha256 NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
storage_key TEXT NOT NULL UNIQUE
|
||||
CHECK (
|
||||
length(storage_key) > 0
|
||||
AND substr(storage_key, 1, 1) <> '/'
|
||||
AND instr(storage_key, '\') = 0
|
||||
AND instr(storage_key, '..') = 0
|
||||
),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX assets_creator_created_idx
|
||||
ON assets (creator_subject, created_at DESC, id DESC);
|
||||
|
||||
CREATE TABLE purchase_tasks (
|
||||
id TEXT PRIMARY KEY NOT NULL
|
||||
CHECK (length(id) = 36),
|
||||
creator_subject TEXT NOT NULL
|
||||
CHECK (length(trim(creator_subject)) > 0),
|
||||
source_ref TEXT
|
||||
CHECK (
|
||||
source_ref IS NULL
|
||||
OR (
|
||||
length(trim(source_ref)) > 0
|
||||
AND length(CAST(source_ref AS BLOB)) <= 256
|
||||
)
|
||||
),
|
||||
title TEXT NOT NULL
|
||||
CHECK (
|
||||
length(trim(title)) > 0
|
||||
AND length(title) <= 120
|
||||
AND length(CAST(title AS BLOB)) <= 2048
|
||||
),
|
||||
description TEXT NOT NULL
|
||||
CHECK (length(CAST(description AS BLOB)) <= 8192),
|
||||
sku TEXT NOT NULL
|
||||
CHECK (
|
||||
length(trim(sku)) > 0
|
||||
AND length(CAST(sku AS BLOB)) <= 512
|
||||
),
|
||||
image_asset_id TEXT NOT NULL UNIQUE
|
||||
REFERENCES assets(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
|
||||
quantity INTEGER NOT NULL
|
||||
CHECK (quantity > 0),
|
||||
max_budget_cents INTEGER
|
||||
CHECK (max_budget_cents IS NULL OR max_budget_cents > 0),
|
||||
currency TEXT NOT NULL DEFAULT 'CNY'
|
||||
CHECK (currency = 'CNY'),
|
||||
status TEXT NOT NULL
|
||||
CHECK (
|
||||
status IN (
|
||||
'PENDING',
|
||||
'CLAIMED',
|
||||
'RUNNING',
|
||||
'WAITING_CONFIRMATION',
|
||||
'SUCCEEDED',
|
||||
'FAILED',
|
||||
'CANCELED'
|
||||
)
|
||||
),
|
||||
version INTEGER NOT NULL
|
||||
CHECK (version > 0),
|
||||
cancel_reason TEXT
|
||||
CHECK (
|
||||
cancel_reason IS NULL
|
||||
OR length(CAST(cancel_reason AS BLOB)) <= 500
|
||||
),
|
||||
canceled_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX purchase_tasks_creator_source_ref_idx
|
||||
ON purchase_tasks (creator_subject, source_ref)
|
||||
WHERE source_ref IS NOT NULL;
|
||||
|
||||
CREATE INDEX purchase_tasks_creator_created_idx
|
||||
ON purchase_tasks (creator_subject, created_at DESC, id DESC);
|
||||
|
||||
CREATE INDEX purchase_tasks_creator_status_created_idx
|
||||
ON purchase_tasks (
|
||||
creator_subject,
|
||||
status,
|
||||
created_at DESC,
|
||||
id DESC
|
||||
);
|
||||
|
||||
CREATE TABLE task_events (
|
||||
id TEXT PRIMARY KEY NOT NULL
|
||||
CHECK (length(id) = 36),
|
||||
task_id TEXT NOT NULL
|
||||
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
|
||||
event_type TEXT NOT NULL
|
||||
CHECK (event_type IN ('TASK_CREATED', 'TASK_CANCELED')),
|
||||
message TEXT NOT NULL,
|
||||
occurred_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX task_events_task_occurred_idx
|
||||
ON task_events (task_id, occurred_at ASC, id ASC);
|
||||
|
||||
CREATE TABLE idempotency_records (
|
||||
creator_subject TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL,
|
||||
request_sha256 TEXT NOT NULL
|
||||
CHECK (
|
||||
length(request_sha256) = 64
|
||||
AND request_sha256 NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
resource_type TEXT NOT NULL
|
||||
CHECK (resource_type IN ('ASSET', 'PURCHASE_TASK')),
|
||||
resource_id TEXT NOT NULL
|
||||
CHECK (length(resource_id) = 36),
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (creator_subject, operation, idempotency_key)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS idempotency_records;
|
||||
DROP INDEX IF EXISTS task_events_task_occurred_idx;
|
||||
DROP TABLE IF EXISTS task_events;
|
||||
DROP INDEX IF EXISTS purchase_tasks_creator_status_created_idx;
|
||||
DROP INDEX IF EXISTS purchase_tasks_creator_created_idx;
|
||||
DROP INDEX IF EXISTS purchase_tasks_creator_source_ref_idx;
|
||||
DROP TABLE IF EXISTS purchase_tasks;
|
||||
DROP INDEX IF EXISTS assets_creator_created_idx;
|
||||
DROP TABLE IF EXISTS assets;
|
||||
@@ -53,8 +53,9 @@
|
||||
|
||||
当前已完成 Phase 0 和 Phase 1:Android 可运行、设备就绪、workflow、私有样本导入、
|
||||
动态词搜索、最多 5 个候选截图采集、结构化需求提取、候选评估和人工确认停止点均已
|
||||
验证。T-201 后端骨架和 T-202 P0 Web/App 低保真原型均已完成,原型已通过自动验证
|
||||
和人工确认。下一步按编号开始 T-203,实现任务创建 API 与管理 Web。
|
||||
验证。T-201 后端骨架、T-202 P0 原型和 T-203 任务 API/管理 Web 均已完成。下一步
|
||||
按编号开始 T-204,实现正式管理账号、角色、会话和设备身份;在此之前业务路由只
|
||||
允许 loopback,不能部署给局域网用户使用。
|
||||
|
||||
严格按以下顺序推进:
|
||||
|
||||
|
||||
+10
-9
@@ -6,7 +6,7 @@
|
||||
|
||||
| 项 | 当前事实 |
|
||||
| --- | --- |
|
||||
| 任务来源 | 其他管理后台或本项目管理 Web 采集商品标题、描述、图片、数量和预算。 |
|
||||
| 任务来源 | 其他管理后台或本项目管理 Web 采集商品标题、SKU、描述、图片、数量和预算。 |
|
||||
| 第一层样本来源 | 本机目录中的蝦皮订单文本和参考图;二者以蝦皮订单号作为同名文件名。 |
|
||||
| 执行方式 | 采购人员使用 Android App 操作拼多多;需求提取、动态词搜索、最多 5 个候选证据采集、匹配建议和人工确认停止点已可运行。 |
|
||||
| 核心痛点 | 人工把图片和描述转成搜索词、逐条比较商品并记录结果,耗时且不一致。 |
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
| ID | 功能 | 用户结果 | 优先级 | 用户故事 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| F-001 | 创建采购任务 | 管理员提交标题、描述、图片、数量和可选预算,获得任务编号。 | P0 | US-001 |
|
||||
| F-001 | 创建采购任务 | 管理员提交标题、SKU、描述、图片、数量和可选预算,获得任务编号。 | P0 | US-001 |
|
||||
| F-002 | 查看任务状态 | 管理员看到任务阶段、候选摘要、截图和失败原因。 | P0 | US-002 |
|
||||
| F-003 | 手动领取任务 | 空闲且就绪的 App 点击后只领取一条待处理任务。 | P0 | US-003 |
|
||||
| F-004 | 解析采购需求 | 系统从图片和文字提取搜索词、属性及约束,并保留原始输入。 | P0 | US-004 |
|
||||
@@ -46,9 +46,10 @@
|
||||
|
||||
## 五、业务规则
|
||||
|
||||
1. 标题、描述和图片至少满足“标题或描述不为空,且图片存在”。
|
||||
2. 数量必须是正整数;最高预算如填写,必须大于零。
|
||||
3. 数量和最高预算以管理员输入为准,模型不得更改。
|
||||
1. 标题、SKU 和图片必填,描述可为空。
|
||||
2. 数量必须是正整数;最高商品总预算如填写,必须大于零。总预算表示当前任务全部
|
||||
数量的商品金额上限,不包含尚无法可靠确认的运费、优惠或支付金额。
|
||||
3. SKU、数量和最高商品总预算以管理员输入为准,模型不得更改。
|
||||
4. 一台设备同一时间最多有一条 `CLAIMED` 或 `RUNNING` 任务。
|
||||
5. 一条任务同一时间只能被一台设备持有;重复点击不能产生重复领取。
|
||||
6. App 未就绪时不能开始:无障碍未授权、拼多多未安装、设备离线或已有运行任务都
|
||||
@@ -95,11 +96,11 @@ T-004 已固定首版规则:推荐私有目录为被 Git 忽略的 `private-fi
|
||||
|
||||
### 单任务验收
|
||||
|
||||
- F-001/US-001/IX-001:合法输入创建后出现唯一任务编号和 `PENDING` 状态;非法
|
||||
数量、预算或缺失图片时在原表单显示可修复错误。
|
||||
- F-002/US-002/IX-002:状态变化后管理页面能看到最新阶段、时间、设备、候选摘要
|
||||
- F-001/US-001/IX-002:合法输入创建后出现唯一任务编号和 `PENDING` 状态;非法
|
||||
标题、SKU、数量、预算或缺失图片时在原表单显示可修复错误。
|
||||
- F-002/US-002/IX-003:状态变化后管理页面能看到最新阶段、时间、设备、候选摘要
|
||||
或结构化错误;无权限用户不可访问。
|
||||
- F-003/US-003/IX-004:App 点击“获取任务”后原子领取一条任务;重复点击或多请求
|
||||
- F-003/US-003/IX-005:App 点击“获取任务”后原子领取一条任务;重复点击或多请求
|
||||
不得领取第二条或把同一任务分配两次。
|
||||
- F-004/US-004/IX-006:解析结果包含搜索词、识别属性、预算、数量、置信度和警告;
|
||||
原始输入保留,硬约束与输入一致。T-103 已实现版本化 schema、0.75 置信阈值、
|
||||
|
||||
@@ -22,12 +22,12 @@
|
||||
| 数据迁移 | Goose v3.26.0,使用嵌入式 SQL migration | 已验证 | v3.26.0 是已核实仍声明 Go 1.23.0 的最高版本;v3.27.x 要求 Go 1.25。 |
|
||||
| 管理 Web | Gin + `html/template` + `embed` + 少量原生 JS/CSS | MVP 已定 | 不单独引入 SPA 工程,模板和静态资源随服务构建。 |
|
||||
| 数据库 | SQLite | MVP 已定 | 单服务、单设备验证足够;多实例或并发提升前迁移 PostgreSQL。 |
|
||||
| 图片/截图 | 后端受控本地文件目录,数据库存元数据 | MVP 已定 | 禁止把二进制直接塞入日志;生产再评估对象存储。 |
|
||||
| 图片/截图 | 后端受控本地文件目录 + `golang.org/x/image` v0.28.0 | 已验证 | JPEG/PNG/WebP 真解码后白底缩放并编码为 JPEG;数据库只存元数据和随机相对键。 |
|
||||
| 管理鉴权 | 单个种子管理账号 + 服务端会话 Cookie | MVP 已定 | 密码只保存哈希;完整 RBAC 为 V2。 |
|
||||
| App 鉴权 | 采购员登录态 + 设备绑定令牌 | 目标已定,细节待实现 | 人员身份与设备身份分离;令牌只保存哈希。 |
|
||||
| VLM 接入 | 应用内统一适配器,优先兼容 OpenAI 风格多模态接口 | 需求提取与候选评估已实现,供应商待定 | T-103/T-104 使用严格 JSON Schema、单候选单次调用和 2048 px 图片上限;GUI-Owl/MAI-UI 动作模型不具备需求提取能力。 |
|
||||
| 通知 | MVP 不使用推送 | 已定 | 点击“获取任务”调用原子 claim API;V2 再评估厂商推送/WebSocket。 |
|
||||
| 后端测试 | 标准库 `testing` + `httptest` | 骨架已验证 | 当前 25 个测试覆盖配置、SQLite pragma/外键、迁移、健康检查、安全 recovery、错误合约、路由和有界关闭;后续任务再增加状态机、权限与幂等。 |
|
||||
| 后端测试 | 标准库 `testing` + `httptest` | T-203 已验证 | 当前 99 个测试覆盖配置、迁移、图片限制、领域校验、事务幂等、分页、HTTP 生命周期、CSRF、安全响应头和有界关闭。 |
|
||||
| Android 测试 | Gradle `test` + `kotlinx-coroutines-test` 1.7.3 + 真实设备 smoke | Phase 1 探针已验证 | 166 次测试覆盖 runner、动态页面分类、受控证据、VLM schema、人工确认策略与隐私;OnePlus PKG110 上完成私有 fixture + 本机 mock 的需求提取和 5 候选评估 smoke。 |
|
||||
| 部署 | 单机局域网 Go 服务;容器化后置 | MVP 已定 | Android 测试机必须能通过 HTTPS 或受控测试网络访问。 |
|
||||
|
||||
@@ -125,7 +125,8 @@ backend-api/
|
||||
docs/
|
||||
```
|
||||
|
||||
`domain/usecase/repository/sqlite/web` 将在对应业务任务出现真实代码时创建,不提交空包。
|
||||
T-203 已建立 `domain/usecase/repository/sqlite` 和 `transport/webui`,handler 不直接
|
||||
执行 SQL,Web 与 JSON API 复用同一 usecase。
|
||||
|
||||
## 构建与运行命令
|
||||
|
||||
@@ -136,7 +137,7 @@ docs/
|
||||
| Android 测试任务 | `android-buyer\gradlew.bat test --no-daemon` | 已验证;当前全工程 166 次测试通过 |
|
||||
| Android 安装/启动 | `$env:RUN_START_COMMAND="1"; .\init.ps1` | 已在 Android 16 真机验证 |
|
||||
| 后端依赖 | `$env:GOTOOLCHAIN="local"; go mod download`(在 `backend-api/`) | 已验证 |
|
||||
| 后端测试 | `$env:GOTOOLCHAIN="local"; go test ./...; go vet ./...` | 已验证;25 个测试 |
|
||||
| 后端测试 | `$env:GOTOOLCHAIN="local"; go test ./...; go vet ./...` | 已验证;99 个测试 |
|
||||
| 后端构建 | `go build -o bin/cmroubao-api.exe ./cmd/api` 与 `./cmd/migrate` | 已验证 |
|
||||
| 后端启动 | `go run ./cmd/api` | 已完成本机 HTTP smoke |
|
||||
| 数据迁移 | `go run ./cmd/migrate up` | 已完成 `status/up/up/down/up` smoke |
|
||||
|
||||
+12
-3
@@ -69,8 +69,14 @@ Authorization、Cookie 或 panic 内容。
|
||||
SQLite 默认文件为被忽略的 `backend-api/var/cmroubao.db`,连接启用 foreign keys、
|
||||
5 秒 busy timeout、WAL 和 immediate transaction,并限制单连接以匹配单进程 MVP。
|
||||
数据库由 `cmd` 显式打开和关闭,不存在包级单例。Goose SQL 通过 `embed.FS` 加载,
|
||||
`cmd/migrate` 显式执行 `up/down/status`;T-201 只验证迁移链路,业务表由后续任务
|
||||
按 API 和领域模型建立。
|
||||
`cmd/migrate` 显式执行 `up/down/status`。API 启动前检查所有迁移均已应用,不在
|
||||
服务进程内自动改表;T-203 已用 `00002_tasks_and_assets.sql` 建立任务、资产、事件
|
||||
和幂等记录表。
|
||||
|
||||
T-204 管理会话完成前,T-203 的业务路由只允许 loopback 来源,Web 写操作还校验
|
||||
浏览器 Cookie 与表单字段的随机双提交 CSRF token;这不是正式登录,不能据此开放
|
||||
局域网监听。模板/API handler 仍调用同一 usecase,T-204 只替换主体和会话门禁,
|
||||
不重写业务规则。
|
||||
|
||||
### 2.2 Android App
|
||||
|
||||
@@ -350,9 +356,10 @@ IDLE
|
||||
| `id` | PK | 任务 ID |
|
||||
| `title` | NOT NULL | 原始标题 |
|
||||
| `description` | NOT NULL | 原始说明,可为空字符串 |
|
||||
| `sku` | NOT NULL | 原始 SKU,不得由模型改写 |
|
||||
| `image_asset_id` | FK, NOT NULL | 原始参考图 |
|
||||
| `quantity` | `> 0` | 权威数量 |
|
||||
| `max_budget` | `> 0`, nullable | 权威最高预算,币种为 CNY |
|
||||
| `max_budget` | `> 0`, nullable | 全部数量的权威最高商品总预算,币种为 CNY |
|
||||
| `status` | NOT NULL | 任务状态枚举 |
|
||||
| `created_by` | FK | 创建人 |
|
||||
| `claimed_by_device_id` | FK, nullable | 当前设备 |
|
||||
@@ -380,6 +387,8 @@ IDLE
|
||||
- `execution_events` 只追加,记录 step、事件类型、可读消息和时间;不保存密码或 token。
|
||||
- `assets` 保存文件相对路径、媒体类型、大小、哈希、创建者和保留时间。
|
||||
- 原图和截图必须通过鉴权接口读取,文件名不能直接作为公开 URL。
|
||||
- 任务参考图上传可接受 JPEG/PNG/WebP,但后端必须先真实解码、限制字节与像素,再
|
||||
统一规范化为匿名 JPEG;未来 Android 读取的资产不能依赖原始扩展名或声明 MIME。
|
||||
|
||||
## 六、API 和并发边界
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
- 关联页面:管理 Web `/tasks/new`
|
||||
- 前置条件:管理员已登录;参考图片可读取。
|
||||
|
||||
作为采购管理员,我想提交商品标题、描述、参考图、数量和预算,从而让采购设备拿到
|
||||
没有歧义的原始采购要求。
|
||||
作为采购管理员,我想提交商品标题、SKU、描述、参考图、数量和总预算,从而让采购
|
||||
设备拿到没有歧义的原始采购要求。
|
||||
|
||||
**范围**
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
**验收场景**
|
||||
|
||||
1. 合法输入提交后,任务列表出现唯一编号、摘要和 `PENDING` 状态。
|
||||
2. 数量、预算、图片或文本不满足规则时,保留已填内容并指出修复位置。
|
||||
2. 标题、SKU、数量、总预算、图片或描述不满足规则时,保留已填内容并指出修复位置。
|
||||
3. 网络超时后重复提交同一幂等请求,不产生重复任务。
|
||||
|
||||
## US-002 跟踪任务和查看证据
|
||||
|
||||
@@ -52,14 +52,15 @@
|
||||
|
||||
**正常路径**
|
||||
|
||||
1. 输入标题、描述、数量、可选预算并选择一张图片。
|
||||
1. 输入标题、SKU、描述、数量、可选商品总预算并选择一张图片。
|
||||
2. 浏览器展示图片预览、文件名和大小。
|
||||
3. 提交期间锁定重复提交并显示“正在创建”。
|
||||
4. 成功后进入任务详情,显示编号和 `PENDING`。
|
||||
|
||||
**状态与异常**
|
||||
|
||||
- 数量默认 1,只接受正整数;预算为空表示不设置上限。
|
||||
- 标题和 SKU 必填,描述可空;数量默认 1,只接受正整数。
|
||||
- 商品总预算为空表示不设置上限;填写时表示当前任务全部数量的商品金额上限。
|
||||
- 图片类型、大小或解码失败时在图片控件旁提示。
|
||||
- 上传成功但创建失败时重用已上传 asset,不重复上传。
|
||||
- 网络状态不确定时使用幂等键查询结果,不直接再创建。
|
||||
@@ -177,7 +178,8 @@
|
||||
- 进程重启时查询任务和 execution;无法证明可安全恢复则标记失败。
|
||||
- 验证码、风控、登录、支付和未知页面立即停止,不展示“自动继续”。
|
||||
- AI 超时最多按配置重试;输出无效或低置信度转人工。
|
||||
- 任意候选达到预算上限校验失败时不能进入接受状态。
|
||||
- 候选商品价乘以任务数量达到总预算上限校验失败,或运费/优惠导致总价无法确认时,
|
||||
不能进入接受状态。
|
||||
|
||||
**可访问性与反馈**
|
||||
|
||||
@@ -203,7 +205,8 @@
|
||||
**状态与异常**
|
||||
|
||||
- 没有候选时只允许确认“无匹配”或“需人工处理”。
|
||||
- 存在超预算或关键属性未知时禁用“接受候选”并说明原因。
|
||||
- 存在商品总价超预算、最终商品金额无法确认或关键属性未知时禁用“接受候选”并
|
||||
说明原因。
|
||||
- 回传超时:保留选择和原因,查询最终状态后再重试。
|
||||
- 返回键不能恢复自动点击;等待确认一旦到达就是硬停止点。
|
||||
- 所有结果都写入 `order_submitted=false`。
|
||||
|
||||
+29
-4
@@ -13,6 +13,11 @@
|
||||
- 分页使用 `limit` 和不透明 `cursor`;MVP `limit` 最大 100。
|
||||
- 客户端不得根据 HTTP 超时判断操作失败,必须查询资源最终状态。
|
||||
|
||||
T-204 管理会话完成前,T-203 的管理 Web 和管理 API 只接受来源地址为 loopback 的
|
||||
请求,Web 写操作还要求 SameSite Cookie 与表单字段匹配的随机双提交 CSRF token。
|
||||
该门禁只用于阻止业务路由被提前暴露,不是正式身份认证;`/login`、管理会话和
|
||||
局域网访问仍由 T-204 实现。
|
||||
|
||||
通用错误:
|
||||
|
||||
```json
|
||||
@@ -113,7 +118,8 @@ HTTP 语义:
|
||||
|
||||
表单字段:
|
||||
|
||||
- `file`:必填,MVP 允许 JPEG/PNG/WebP;实际大小上限在配置中固定。
|
||||
- `file`:必填,MVP 允许可解码 JPEG/PNG/WebP;请求最多 20 MiB、最长边最多
|
||||
10000 px、总像素最多 25 MP。
|
||||
- `purpose`:`TASK_REFERENCE` 或 `EXECUTION_EVIDENCE`。
|
||||
- `task_id`:证据图片必填;参考图创建时为空。
|
||||
|
||||
@@ -129,6 +135,13 @@ HTTP 语义:
|
||||
}
|
||||
```
|
||||
|
||||
T-203 成功返回 `201`。使用相同 `Idempotency-Key` 和相同图片内容重试时返回同一
|
||||
资产;同 key 不同内容返回 `409`。
|
||||
|
||||
`TASK_REFERENCE` 在后端统一白底合成、缩放到最长边不超过 2048 px,并以质量 90
|
||||
编码为匿名 JPEG。响应的 `media_type`、`size_bytes` 和 `sha256` 都描述规范化结果,
|
||||
不描述原始上传文件。T-203 尚不接受 `EXECUTION_EVIDENCE`。
|
||||
|
||||
### `GET /api/v1/assets/{asset_id}/content`
|
||||
|
||||
受鉴权的文件流。只能读取用户有权查看的任务资产;不返回服务端文件路径。
|
||||
@@ -143,6 +156,7 @@ HTTP 语义:
|
||||
{
|
||||
"source_ref": "external-admin-task-10001",
|
||||
"title": "黑色双肩包",
|
||||
"sku": "BLACK-20L",
|
||||
"description": "容量约20L,外观接近参考图",
|
||||
"image_asset_id": "2bbc1bf2-30f7-497e-a52b-bcb4c61d57c3",
|
||||
"quantity": 2,
|
||||
@@ -152,9 +166,12 @@ HTTP 语义:
|
||||
|
||||
规则:
|
||||
|
||||
- `title` 或 `description` 至少一个非空,`image_asset_id` 必填。
|
||||
- `title`、`sku` 和 `image_asset_id` 必填,`description` 可为空。
|
||||
- `title` 最多 120 个 Unicode 字符且不超过 2048 个 UTF-8 字节;`sku` 不超过
|
||||
512 个 UTF-8 字节;`description` 不超过 8192 个 UTF-8 字节。
|
||||
- `quantity` 是正整数。
|
||||
- `max_budget` 可为空,否则为大于零、最多两位小数的 CNY 金额。
|
||||
- `max_budget` 可为空,否则为大于零、最多两位小数的 CNY 金额,表示当前任务全部
|
||||
数量的最高商品总预算,不含尚无法确认的运费或优惠。
|
||||
- 同一调用方的 `source_ref` 如填写必须唯一。
|
||||
|
||||
成功返回 `201`:
|
||||
@@ -164,6 +181,7 @@ HTTP 语义:
|
||||
"id": "37c9c715-9b51-4ed5-984e-66dad2710c71",
|
||||
"status": "PENDING",
|
||||
"title": "黑色双肩包",
|
||||
"sku": "BLACK-20L",
|
||||
"quantity": 2,
|
||||
"max_budget": "200.00",
|
||||
"created_at": "2026-07-25T08:30:00Z"
|
||||
@@ -172,7 +190,9 @@ HTTP 语义:
|
||||
|
||||
### `GET /api/v1/tasks`
|
||||
|
||||
管理端列表。可选参数:`status`、`created_from`、`created_to`、`limit`、`cursor`。
|
||||
管理端列表。可选参数:`q`、`status`、`created_from`、`created_to`、`limit`、
|
||||
`cursor`。`q` 匹配任务 ID、`source_ref`、标题或 SKU;默认 `limit=20`,最大 100。
|
||||
排序固定为 `created_at DESC, id DESC`,不透明 cursor 同时编码这两个字段。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -180,6 +200,7 @@ HTTP 语义:
|
||||
{
|
||||
"id": "37c9c715-9b51-4ed5-984e-66dad2710c71",
|
||||
"title": "黑色双肩包",
|
||||
"sku": "BLACK-20L",
|
||||
"status": "RUNNING",
|
||||
"quantity": 2,
|
||||
"device_name": "pdd-phone-01",
|
||||
@@ -212,6 +233,9 @@ HTTP 语义:
|
||||
}
|
||||
```
|
||||
|
||||
T-203 只实现 `PENDING -> CANCELED`;其他状态返回 `409`。执行中设置取消请求及 App
|
||||
安全检查点响应属于 T-205。
|
||||
|
||||
## 设备与领取
|
||||
|
||||
### `POST /api/v1/devices/heartbeat`
|
||||
@@ -249,6 +273,7 @@ App 空闲或运行时上报设备状态;运行时任务续租使用任务专
|
||||
"id": "37c9c715-9b51-4ed5-984e-66dad2710c71",
|
||||
"status": "CLAIMED",
|
||||
"title": "黑色双肩包",
|
||||
"sku": "BLACK-20L",
|
||||
"description": "容量约20L,外观接近参考图",
|
||||
"image_asset_id": "2bbc1bf2-30f7-497e-a52b-bcb4c61d57c3",
|
||||
"quantity": 2,
|
||||
|
||||
+18
-10
@@ -5,23 +5,25 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-26
|
||||
- 阶段:T-202 P0 Web/App 低保真原型已确认完成,准备开始 T-203
|
||||
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201、T-202
|
||||
- 阶段:T-203 任务创建 API 与管理 Web 已完成,准备开始 T-204
|
||||
- Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-203
|
||||
均已纳入 Git 历史
|
||||
- 生产代码:`android-buyer/` 已接入 Roubao Android 源码
|
||||
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
|
||||
- 后端:Go 1.23.0 + Gin 1.11.0 + SQLite + Goose 3.26.0 骨架已建立;Go Blueprint
|
||||
v0.10.11 只作为一次性输入,危险默认与演示逻辑已移除
|
||||
- 后端:Go 1.23.0 + Gin 1.11.0 + SQLite + Goose 3.26.0;已实现图片规范化、任务
|
||||
创建/查询/取消 API 和 SSR 管理 Web,Go Blueprint 只作为一次性骨架输入
|
||||
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
|
||||
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
|
||||
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
|
||||
- 测试:`lintDebug test assembleDebug` 成功;App 两个变体、task contract 和导入器
|
||||
共 26 份报告、166 次测试,0 failure、0 error、0 skipped
|
||||
- 后端测试:`GOTOOLCHAIN=local go test -count=1 ./...` 共 25 个测试通过;
|
||||
`go vet ./...`、API/migration Windows 构建和根 `init.ps1` 均通过
|
||||
- 后端测试:`GOTOOLCHAIN=local go test -count=1 ./...` 共 99 个测试通过;
|
||||
全包 race、`go vet ./...`、API/migration Windows 构建和根 `init.ps1` 均通过
|
||||
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
|
||||
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
|
||||
脚本错误或外部请求,Android 可见交互控件均不小于 44px
|
||||
- 管理 Web:真实 Gin/SQLite 流程已完成图片上传、任务创建、列表、详情参考图和
|
||||
待领取取消;同三种视口无横向溢出,可见操作控件不小于 44px
|
||||
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
|
||||
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
|
||||
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
|
||||
@@ -56,20 +58,20 @@
|
||||
| `docs/tasks/T-104.md` | DONE | 动态搜索、候选严格评估、本地建议和人工确认点 |
|
||||
| `docs/tasks/T-201.md` | DONE | Go-Gin、SQLite、Goose migration 和健康检查骨架 |
|
||||
| `docs/tasks/T-202.md` | DONE | 生成并确认 P0 Web/App 低保真原型 |
|
||||
| `docs/tasks/T-203.md` | DONE | 图片/任务 API、SQLite 业务层和 SSR 管理 Web |
|
||||
| `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
|
||||
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
|
||||
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
|
||||
| `android-buyer/task-contract/` | 已有 | Android/CLI 共享 ProbeTask 与 TaskSource |
|
||||
| `android-buyer/tools/shopee-importer/` | 已有 | 开发机私有 fixture 导入 CLI |
|
||||
| `backend-api/` | 已有 | Go-Gin API、SQLite、migration、健康检查和测试骨架 |
|
||||
| `backend-api/` | 已有 | Go-Gin 任务 API、SQLite、图片存储、管理 Web 和 migration |
|
||||
| `init.ps1` / `init.sh` | 已验证/待跨平台 | Windows 同时验证 Android/Go;Unix 入口待 Linux/WSL 复核 |
|
||||
|
||||
## 任务摘要
|
||||
|
||||
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201、T-202。
|
||||
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-203。
|
||||
- 正在进行:无。
|
||||
- 下一个可领取任务:T-203 实现任务创建 API 与管理 Web;T-204 也已满足依赖,
|
||||
但按编号顺序在 T-203 之后处理。
|
||||
- 下一个可领取任务:T-204 实现账号、角色、会话和设备身份;完成后 T-205 可开始。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
@@ -107,6 +109,12 @@ go run ./cmd/api
|
||||
`http.Server.Shutdown` 由集成测试验证在 1 秒预算内结束。当前 Windows 没有可用 WSL
|
||||
发行版,因此 `init.sh` 未实际运行;Windows 标准入口已验证。
|
||||
|
||||
2026-07-26 完成 T-203:新增两条业务 migration、图片规范化存储、任务领域/用例/
|
||||
SQLite 仓储、管理 API 和 SSR Web。创建/上传幂等,列表稳定分页,详情可读取同源
|
||||
规范化参考图,只有 `PENDING` 可取消。T-204 前业务路由仅限 loopback,Web 写操作
|
||||
使用双提交 CSRF;这不是正式鉴权。真实 HTTP/Playwright 和 migration CLI smoke、
|
||||
99 个 Go 测试、全包 race/vet、Windows 构建与根 `init.ps1` 全部通过。
|
||||
|
||||
## 维护规则
|
||||
|
||||
发生以下变化时覆盖更新本文:
|
||||
|
||||
@@ -210,6 +210,12 @@
|
||||
<input id="title" name="title" maxlength="120" placeholder="例如:演示用桌面收纳盒">
|
||||
<p id="title-error" class="error"></p>
|
||||
</div>
|
||||
<div class="span-2">
|
||||
<label class="required" for="sku">SKU</label>
|
||||
<input id="sku" name="sku" maxlength="120" placeholder="例如:浅灰色 · 中号">
|
||||
<p id="sku-error" class="error"></p>
|
||||
<p class="help">SKU 是不可覆盖的原始采购要求。</p>
|
||||
</div>
|
||||
<div class="span-2">
|
||||
<label for="description">商品描述</label>
|
||||
<textarea id="description" name="description" placeholder="填写规格、颜色、材质等判断条件"></textarea>
|
||||
@@ -221,7 +227,7 @@
|
||||
<p id="quantity-error" class="error"></p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="budget">最高总预算(元)</label>
|
||||
<label for="budget">最高商品总预算(元)</label>
|
||||
<input id="budget" name="budget" type="number" min="0.01" step="0.01" inputmode="decimal" placeholder="不填写则不设上限">
|
||||
<p id="budget-error" class="error"></p>
|
||||
</div>
|
||||
@@ -284,7 +290,7 @@
|
||||
});
|
||||
|
||||
function clearFeedback() {
|
||||
['title', 'quantity', 'budget', 'image'].forEach((name) => {
|
||||
['title', 'sku', 'quantity', 'budget', 'image'].forEach((name) => {
|
||||
document.querySelector(`#${name}-error`).textContent = '';
|
||||
});
|
||||
notice.style.display = 'none';
|
||||
@@ -303,18 +309,23 @@
|
||||
clearFeedback();
|
||||
let firstInvalid = null;
|
||||
const title = document.querySelector('#title');
|
||||
const sku = document.querySelector('#sku');
|
||||
const quantity = document.querySelector('#quantity');
|
||||
const budget = document.querySelector('#budget');
|
||||
if (!title.value.trim()) {
|
||||
document.querySelector('#title-error').textContent = '请输入商品标题。';
|
||||
firstInvalid ||= title;
|
||||
}
|
||||
if (!sku.value.trim()) {
|
||||
document.querySelector('#sku-error').textContent = '请输入 SKU。';
|
||||
firstInvalid ||= sku;
|
||||
}
|
||||
if (!Number.isInteger(Number(quantity.value)) || Number(quantity.value) < 1) {
|
||||
document.querySelector('#quantity-error').textContent = '数量必须是大于 0 的整数。';
|
||||
firstInvalid ||= quantity;
|
||||
}
|
||||
if (budget.value && Number(budget.value) <= 0) {
|
||||
document.querySelector('#budget-error').textContent = '预算必须大于 0,或留空。';
|
||||
document.querySelector('#budget-error').textContent = '商品总预算必须大于 0,或留空。';
|
||||
firstInvalid ||= budget;
|
||||
}
|
||||
if (!image.files.length) {
|
||||
|
||||
@@ -289,8 +289,9 @@
|
||||
<p class="description">桌面用分格收纳盒,浅灰色,塑料材质。
|
||||
需要带可拆分隔板,外观接近参考图。</p>
|
||||
<div class="constraints">
|
||||
<span class="constraint">SKU:浅灰色 · 中号</span>
|
||||
<span class="constraint">数量:2</span>
|
||||
<span class="constraint">最高总预算:60.00 元</span>
|
||||
<span class="constraint">最高商品总预算:60.00 元</span>
|
||||
<span class="constraint">关键属性:浅灰 / 分隔板</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,14 +38,14 @@
|
||||
</section>
|
||||
<section class="panel" id="requirement" aria-labelledby="requirementTitle">
|
||||
<h2 id="requirementTitle">管理员原始要求</h2>
|
||||
<dl class="facts"><dt>商品</dt><dd>透明抽屉式收纳盒</dd><dt>SKU</dt><dd>白色 · 中号</dd><dt>数量</dt><dd>2 件</dd><dt>最高预算</dt><dd>¥ 48.00 / 件</dd></dl>
|
||||
<dl class="facts"><dt>商品</dt><dd>透明抽屉式收纳盒</dd><dt>SKU</dt><dd>白色 · 中号</dd><dt>数量</dt><dd>2 件</dd><dt>最高商品总预算</dt><dd>¥ 96.00</dd></dl>
|
||||
</section>
|
||||
<section class="panel" id="candidate" aria-labelledby="candidateTitle">
|
||||
<h2 id="candidateTitle">候选 1 / 3</h2>
|
||||
<div class="evidence" role="img" aria-label="脱敏的候选商品截图占位">脱敏候选截图<br>证据占位</div>
|
||||
<dl class="facts"><dt>候选标题</dt><dd>透明可叠放抽屉收纳盒</dd><dt>单价</dt><dd class="price" id="price">¥ 39.90</dd></dl>
|
||||
<h3>匹配与风险</h3>
|
||||
<ul class="checks" id="checks"><li><span class="ok">符合</span> 颜色:白色</li><li><span class="ok">符合</span> 规格:中号</li><li><span class="ok">符合</span> 单价未超过预算</li></ul>
|
||||
<ul class="checks" id="checks"><li><span class="ok">符合</span> 颜色:白色</li><li><span class="ok">符合</span> 规格:中号</li><li><span class="ok">符合</span> 商品总价 ¥ 79.80 未超过预算</li></ul>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<h2>人员结论</h2>
|
||||
@@ -65,7 +65,7 @@
|
||||
</dialog>
|
||||
<script>
|
||||
const scenario=document.querySelector("#scenario"),candidate=document.querySelector("#candidate"),accept=document.querySelector("#accept"),reject=document.querySelector("#reject"),noneAction=document.querySelector("#noneAction"),price=document.querySelector("#price"),checks=document.querySelector("#checks"),dialog=document.querySelector("#confirmDialog"),cancel=document.querySelector("#cancel"),completion=document.querySelector("#completion");let pending="";
|
||||
function render(){const v=scenario.value,missing=v==="none";candidate.hidden=missing;noneAction.disabled=!missing;accept.disabled=v!=="valid";reject.disabled=missing;if(v==="over"){price.textContent="¥ 56.00";checks.innerHTML='<li><span class="ok">符合</span> 颜色:白色</li><li><span class="ok">符合</span> 规格:中号</li><li><span class="risk">不符合</span> 单价超过最高预算,不能接受</li>'}else if(v==="unknown"){price.textContent="¥ 41.00";checks.innerHTML='<li><span class="ok">符合</span> 颜色:白色</li><li><span class="risk">未知</span> 规格无法确认,不能接受</li><li><span class="ok">符合</span> 单价未超过预算</li>'}else{price.textContent="¥ 39.90";checks.innerHTML='<li><span class="ok">符合</span> 颜色:白色</li><li><span class="ok">符合</span> 规格:中号</li><li><span class="ok">符合</span> 单价未超过预算</li>'}}
|
||||
function render(){const v=scenario.value,missing=v==="none";candidate.hidden=missing;noneAction.disabled=!missing;accept.disabled=v!=="valid";reject.disabled=missing;if(v==="over"){price.textContent="¥ 56.00";checks.innerHTML='<li><span class="ok">符合</span> 颜色:白色</li><li><span class="ok">符合</span> 规格:中号</li><li><span class="risk">不符合</span> 商品总价 ¥ 112.00 超过总预算,不能接受</li>'}else if(v==="unknown"){price.textContent="¥ 41.00";checks.innerHTML='<li><span class="ok">符合</span> 颜色:白色</li><li><span class="risk">未知</span> 规格无法确认,不能接受</li><li><span class="ok">符合</span> 商品总价 ¥ 82.00 未超过总预算</li>'}else{price.textContent="¥ 39.90";checks.innerHTML='<li><span class="ok">符合</span> 颜色:白色</li><li><span class="ok">符合</span> 规格:中号</li><li><span class="ok">符合</span> 商品总价 ¥ 79.80 未超过总预算</li>'}}
|
||||
document.querySelectorAll("[data-action]").forEach(button=>button.addEventListener("click",()=>{pending=button.dataset.action;document.querySelector("#dialogText").textContent="选择:"+pending;dialog.showModal();setTimeout(()=>cancel.focus(),0)}));
|
||||
scenario.addEventListener("change",render);cancel.addEventListener("click",()=>dialog.close());dialog.addEventListener("cancel",e=>{e.preventDefault();dialog.close()});document.querySelector("#confirm").addEventListener("click",()=>{dialog.close();completion.classList.add("show");document.querySelector("#completionText").textContent="“"+pending+"”已记录,结果正在同步。";completion.scrollIntoView({behavior:"smooth",block:"start"})});render();
|
||||
</script>
|
||||
|
||||
@@ -31,13 +31,13 @@
|
||||
<dt>商品标题</dt><dd>透明抽屉式桌面收纳盒</dd>
|
||||
<dt>SKU</dt><dd>白色 · 中号</dd>
|
||||
<dt>数量</dt><dd class="constraint">2 件</dd>
|
||||
<dt>最高预算</dt><dd class="constraint">¥ 48.00 / 件</dd>
|
||||
<dt>最高商品总预算</dt><dd class="constraint">¥ 96.00</dd>
|
||||
<dt>描述</dt><dd>带把手,可叠放,优先选择相同外观。</dd>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="panel derived" aria-labelledby="derivedTitle">
|
||||
<h2 id="derivedTitle">AI 辅助理解 · 待验证</h2>
|
||||
<p>可能关键词:透明、抽屉式、中号、可叠放。最终数量和预算始终以原始要求为准。</p>
|
||||
<p>可能关键词:透明、抽屉式、中号、可叠放。最终 SKU、数量和商品总预算始终以原始要求为准。</p>
|
||||
</section>
|
||||
<section class="panel">
|
||||
<label for="scenario">原型状态</label>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
---
|
||||
id: T-203
|
||||
title: 实现任务创建 API 与管理 Web
|
||||
phase: 2
|
||||
deps:
|
||||
- T-201
|
||||
- T-202
|
||||
status: DONE
|
||||
created: 2026-07-26
|
||||
context_ref: 2b265c9
|
||||
work_branch: main
|
||||
write_paths:
|
||||
- backend-api/**
|
||||
- init.ps1
|
||||
- init.sh
|
||||
- docs/00-ai-start-here.md
|
||||
- docs/02-requirements.md
|
||||
- docs/03-tech-stack.md
|
||||
- docs/04-architecture.md
|
||||
- docs/05-coding-rules.md
|
||||
- docs/07-user-stories.md
|
||||
- docs/08-interaction-checklist.md
|
||||
- docs/api.md
|
||||
- docs/current-state.md
|
||||
- docs/design/admin-task-create.html
|
||||
- docs/design/admin-task-detail.html
|
||||
- docs/design/android-task-preview.html
|
||||
- docs/design/android-candidate-confirm.html
|
||||
- docs/tasks/T-203.md
|
||||
- progress.md
|
||||
---
|
||||
|
||||
## 问题 / 背景
|
||||
|
||||
T-201 只有健康检查、SQLite 和 migration 骨架,T-202 只确认了页面信息结构。当前还
|
||||
不能从管理端安全上传参考图、创建 `PENDING` 任务、查看真实列表/详情或取消待领取
|
||||
任务,因此 T-205 的原子领取没有可消费的数据。
|
||||
|
||||
审计还发现正式合约存在四个会阻断后续 App 接入的问题:
|
||||
|
||||
- 现有 Android `ProbeTask` 强制标题和 SKU 非空,但任务创建 API/数据表遗漏 SKU。
|
||||
- 已确认 Web 使用“最高总预算”,部分 Android 原型误写成每件预算。
|
||||
- API/原型允许 JPEG/PNG/WebP,但 Android 需求提取当前只接受 JPEG。
|
||||
- 管理会话属于 T-204,T-203 不能伪造登录或永久开放匿名管理接口。
|
||||
|
||||
## 关联需求与交互
|
||||
|
||||
- 功能:F-001、F-002,以及 F-007 的最小可见错误。
|
||||
- 用户故事:US-001、US-002。
|
||||
- 交互:IX-002、IX-003;登录 IX-001 留给 T-204。
|
||||
- 页面:`/tasks`、`/tasks/new`、`/tasks/{id}`。
|
||||
- API:资产上传、任务创建/列表/详情、待领取任务取消。
|
||||
|
||||
## 已定合约
|
||||
|
||||
1. 标题和 SKU 必填;描述可空。标题最多 120 个 Unicode 字符且不超过 2048 个
|
||||
UTF-8 字节,SKU 不超过 512 个 UTF-8 字节,描述不超过 8192 个 UTF-8 字节。
|
||||
2. `max_budget` 表示当前任务全部数量的最高商品总预算,币种 CNY,不含无法确认的
|
||||
运费或优惠;数据库用整数分保存,API 固定输出两位小数字符串。
|
||||
3. 参考图上传接受可解码 JPEG/PNG/WebP,原始请求最多 20 MiB、最长边最多
|
||||
10000 px、总像素最多 25 MP;后端统一缩放到最长边不超过 2048 px、白底合成并
|
||||
编码为匿名 JPEG。响应的媒体类型、大小和 SHA-256 都指向规范化结果。
|
||||
4. SKU、数量和总预算属于不可变原始事实,任何派生结果都不能覆盖。
|
||||
5. 列表增加 `q`,匹配任务 ID、来源引用、标题或 SKU;分页固定按
|
||||
`created_at DESC, id DESC`,cursor 同时包含两个字段。
|
||||
6. T-203 只提前实现事务化 `PENDING -> CANCELED`;其他状态取消返回冲突,完整取消
|
||||
请求和状态机属于 T-205。
|
||||
7. T-204 前,管理 Web/API 业务路由只接受 loopback 来源;Web 写操作还必须携带
|
||||
SameSite Cookie 与表单字段匹配的随机双提交 CSRF token,API 创建/上传必须使用
|
||||
JSON 或 multipart 与自定义幂等头。`/login` 不提供假实现,局域网部署仍被明确
|
||||
阻塞。
|
||||
8. 创建、上传均按调用主体、操作和 `Idempotency-Key` 幂等;同 key 同请求返回原
|
||||
资源,同 key 不同请求返回 `409`。
|
||||
|
||||
## 方案
|
||||
|
||||
1. 增加 assets、purchase_tasks、task_events 和 idempotency_records migration,
|
||||
包含外键、CHECK、唯一约束和 rollback。
|
||||
2. 建立 domain/usecase/repository/sqlite 分层;handler 和模板不得直接执行 SQL。
|
||||
3. 建立受控文件存储与图片规范化器,随机相对路径、同目录临时文件原子改名,失败时
|
||||
清理;数据库和响应永不暴露绝对路径。
|
||||
4. 实现 JSON/multipart API 和稳定错误映射,保持无默认 CORS、request ID 与
|
||||
recovery 安全约束。
|
||||
5. 用 `html/template`、`embed`、原生 CSS/JS 实现列表、新建和详情;真实数据为空时
|
||||
显示空状态,不把原型假数据写入数据库。
|
||||
6. Web 表单无 JS 也可完成,使用 PRG `303`;JS 只负责图片预览、防重复、离开提醒
|
||||
和取消确认。
|
||||
|
||||
## 验收要点
|
||||
|
||||
- [x] migration 可 up/down/up,约束和外键有效,启动前 migration 门禁明确。
|
||||
- [x] JPEG/PNG/WebP 被真实解码并规范化为 JPEG;伪 MIME、损坏、超限和像素炸弹拒绝。
|
||||
- [x] 合法任务创建为唯一 `PENDING`,原始 title/SKU/quantity/budget/image 不可变。
|
||||
- [x] 上传和创建幂等;同 key 不同请求冲突,并发重试不产生重复资源。
|
||||
- [x] 列表筛选、搜索、稳定 cursor 和详情返回真实数据,404 不泄露内部信息。
|
||||
- [x] 只有 `PENDING` 可立即取消;终态或不支持状态返回稳定冲突。
|
||||
- [x] Web 覆盖空列表、创建字段错误、图片错误、成功跳转、详情和取消二次确认。
|
||||
- [x] 非 loopback 业务请求、缺失/错误 CSRF、错误 Content-Type 被拒绝。
|
||||
- [x] HTML 自动转义,日志/响应不含 Cookie、文件绝对路径、私有样本或请求正文。
|
||||
- [x] Go test/race/vet/gofmt、迁移 smoke、真实 HTTP/Web smoke 和 Playwright 通过。
|
||||
|
||||
## 边界
|
||||
|
||||
- 不实现管理登录、Cookie 会话、用户表/密码哈希或设备鉴权;属于 T-204。
|
||||
- 不实现 claim、租约、执行状态机、候选、执行证据或 App HTTP TaskSource。
|
||||
- 不开放非 loopback 管理使用;T-204 完成前不能部署到局域网供其他用户访问。
|
||||
- 不实现对象存储、ORM、SPA、WebSocket、推送或自动下单。
|
||||
- 不提交运行时数据库、上传文件、密钥或真实订单/店铺/商品数据。
|
||||
|
||||
## 执行记录
|
||||
|
||||
### 2026-07-26:任务开始
|
||||
|
||||
- 基于提交 `2b265c9` 开始,工作区干净。
|
||||
- codebase-memory MCP 本轮未暴露 graph 工具,按项目规则回退到 `rg` 和定点文件读取。
|
||||
- 已审计 T-201 现有 config/database/migration/httpapi/cmd 骨架、T-202 原型以及
|
||||
requirements/API/architecture/IX,先固定上述跨层合约再编码。
|
||||
|
||||
### 2026-07-26:合约和实现
|
||||
|
||||
- 修正正式需求和原型中的 SKU、最高总预算、图片格式与验收编号;总预算统一指当前
|
||||
任务全部数量的商品预算,模型不得覆盖 SKU、数量或预算。
|
||||
- 新增 `00002_tasks_and_assets.sql`,建立 assets、purchase_tasks、task_events 和
|
||||
idempotency_records;字段长度、UTF-8 字节、外键、状态、金额和唯一性均有数据库
|
||||
约束。
|
||||
- 建立 domain/usecase/repository/sqlite 分层,上传和创建事务化幂等,列表按
|
||||
`created_at DESC, id DESC` 稳定分页,待领取取消使用状态与 version 条件更新。
|
||||
- 文件层真实解码 JPEG/PNG/WebP,限制 20 MiB、10000 px 和 25 MP,白底缩放至最长
|
||||
边 2048 px 后以质量 90 编码为随机相对键 JPEG;绝对路径不进入数据库外部响应。
|
||||
- Gin API 实现上传/读取资产、创建/列表/详情/取消任务;业务路由在 T-204 前只接受
|
||||
loopback,错误使用稳定 code、request ID 和 no-store 响应。
|
||||
- 管理 Web 使用嵌入模板/CSS/JS,实现真实空列表、搜索筛选、创建、图片预览与复用、
|
||||
详情参考图和取消确认;使用双提交 CSRF、PRG 303、HTML 自动转义和固定 CSP。
|
||||
- API 启动前检查全部 migration;存在 pending 版本时拒绝启动并明确提示执行
|
||||
`migrate up`,服务进程不自动修改数据库。
|
||||
|
||||
### 2026-07-26:自动验证
|
||||
|
||||
- `GOTOOLCHAIN=local go test -count=1 ./...`:99 个测试通过;覆盖领域校验、金额、
|
||||
图片限制、路径防护、迁移约束、事务幂等、并发、分页、HTTP 生命周期、CSRF 和
|
||||
模板安全。
|
||||
- `go test -race -count=1 ./...`、`go vet ./...`、`gofmt -l cmd internal migrations`
|
||||
和 API/migration Windows 构建全部通过。
|
||||
- 真实 migration CLI 完成 `status -> up -> up -> down -> API gate -> up`:首次
|
||||
`applied=2`、重复为 0,回滚 v2 后 API 以退出码 1 拒绝旧库,恢复后 v1/v2 均
|
||||
`applied`。
|
||||
- `httptest` 使用临时真实 SQLite/asset 目录跑通:上传及幂等重放、创建及重放、SKU
|
||||
搜索、详情、JPEG 内容读取、取消和重复取消;非 loopback 和缺失幂等键被拒绝。
|
||||
- Playwright 连接真实 Gin/SQLite 进程完成创建任务和详情读取;1440x900、
|
||||
390x844、360x800 均无横向溢出,可见操作控件最小 44 px,规范化参考图
|
||||
`naturalWidth=64`、`naturalHeight=48` 且成功加载,只有同源 HTML/CSS/JS/资产请求,
|
||||
新页面 console 为 0 error、0 warning;取消对话框默认聚焦“保留任务”。
|
||||
- 根 `init.ps1` 通过:Android `test assembleDebug` 共 76 个 Gradle task 成功,
|
||||
现有 166 次测试保持通过;随后 Go 测试、vet、格式检查和两个入口构建再次通过。
|
||||
|
||||
### 2026-07-26:隐私与收尾
|
||||
|
||||
- 测试只使用代码生成的脱敏小图、标题和 SKU;未读取、复制或提交本地蝦皮订单、
|
||||
店铺名、真实商品文字或图片。
|
||||
- Playwright、数据库、资产和 exe smoke 产物只保存在被忽略的 `.local/`、`bin/`
|
||||
或 `var/`,未纳入 Git;业务响应和管理页面不显示客户端文件名或服务端存储路径。
|
||||
- T-203 验收全部满足,状态改为 `DONE`;T-204 可开始实现正式管理会话和角色门禁。
|
||||
@@ -119,3 +119,11 @@
|
||||
表单、二次确认、安全停止、人工结论、结果同步和设备状态交互。
|
||||
- 影响:页面区块、控件集合、文案和主要动作已获得人工确认;T-203、T-204 和
|
||||
T-206 可按依赖使用该原型作为实现输入,但不得复制低保真原型代码到生产界面。
|
||||
|
||||
## 2026-07-26 任务 API 与管理 Web
|
||||
|
||||
- 类型:阶段完成
|
||||
- 内容:完成 T-203;建立任务/资产/事件/幂等数据模型、图片规范化、任务 API 和
|
||||
SSR 管理 Web,并用 loopback 与双提交 CSRF 作为 T-204 前的临时暴露门禁。
|
||||
- 影响:管理人员已能在本机创建、搜索、查看和取消待领取任务;T-204 下一步补正式
|
||||
账号、角色、会话和设备身份,完成后 T-205 可接入 App 原子领取与租约状态机。
|
||||
|
||||
Reference in New Issue
Block a user