package httpapi import ( "encoding/json" "errors" "io" "mime" "net/http" "strconv" "strings" "time" "cmroubao/backend-api/internal/domain" "cmroubao/backend-api/internal/transport/authcommon" "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, ActorUserID: adminActorUserID(ctx), 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 adminActorUserID(ctx *gin.Context) string { principal, ok := authcommon.Principal(ctx.Request.Context()) if !ok || principal.Role != domain.UserRoleAdmin { return "" } return principal.UserID } 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, "actor_user_id": event.ActorUserID, "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, ActorUserID: adminActorUserID(ctx), 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) }