feat(backend): implement task creation and admin web

This commit is contained in:
QiuSW
2026-07-26 14:03:32 +08:00
parent 2b265c92fc
commit c5d3b215ff
58 changed files with 8773 additions and 83 deletions
+39 -4
View File
@@ -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,
+21 -2
View File
@@ -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 {
+19
View File
@@ -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
}
+221
View File
@@ -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 &cents, 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
}
+137
View File
@@ -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, "&lt;script&gt;") {
t.Fatalf("task title was not safely escaped: %s", body)
}
for _, text := range []string{
"SKU-1",
"待领取",
"/tasks/" + testTaskID,
"value=\"&lt;query&gt;\"",
} {
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 的整数",
"最多两位小数",
"&lt;img src=x onerror=&#34;alert(1)&#34;&gt;",
} {
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}}&amp;status={{.Status}}&amp;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{}
+126
View File
@@ -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),
)
}
}
+93
View File
@@ -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)
}
+38
View File
@@ -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)
}
}