feat(backend): implement task creation and admin web
This commit is contained in:
@@ -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}$`,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user