package httpapi import ( "encoding/json" "errors" "io" "mime" "net/http" "strconv" "strings" "time" "cmroubao/backend-api/internal/domain" "cmroubao/backend-api/internal/platform/shunyunbao" "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 Results *usecase.ExecutionResultService Authorizations *usecase.OrderAuthorizationService Freight *usecase.FreightService FreightImages *usecase.FreightImageService Procurement *usecase.ProcurementService ERP *shunyunbao.SessionManager } func (s AdminServices) validate() error { if s.Assets == nil || s.Tasks == nil || s.Results == nil || s.Authorizations == 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.GET( "/api/v1/tasks/:id/evidence/:evidence_id/content", handler.evidenceContent, ) routes.POST("/api/v1/tasks/:id/cancel", handler.cancelTask) routes.POST( "/api/v1/tasks/:id/order-authorizations", handler.createOrderAuthorization, ) if services.Freight != nil { routes.POST("/api/v1/freight-syncs", handler.createFreightSync) routes.GET("/api/v1/freight-syncs/:id", handler.freightSyncDetail) routes.GET( "/api/v1/freight-sync-watermark", handler.freightSyncWatermark, ) routes.GET("/api/v1/freight-orders", handler.listFreightOrders) routes.GET("/api/v1/freight-orders/:id", handler.freightOrderDetail) } if services.FreightImages != nil { routes.GET( "/api/v1/freight-items/:id/image", handler.freightItemImage, ) } if services.Procurement != nil { routes.POST( "/api/v1/freight-items/:id/procurement-request", handler.createProcurementRequest, ) routes.PUT( "/api/v1/procurement-requests/:id/reference-asset", handler.bindProcurementReference, ) routes.POST( "/api/v1/procurement-requests/:id/purchase-task", handler.createProcurementTask, ) } return nil } func (h *adminHandlers) freightItemImage(ctx *gin.Context) { result, err := h.services.FreightImages.OpenItemImage( 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.Image.MediaType) ctx.Header( "Content-Length", strconv.FormatInt(result.Image.SizeBytes, 10), ) ctx.Header("ETag", `"`+result.Image.SHA256+`"`) ctx.Header("X-Content-Type-Options", "nosniff") ctx.Status(http.StatusOK) _, _ = io.Copy(ctx.Writer, result.Content) } func (h *adminHandlers) evidenceContent(ctx *gin.Context) { result, err := h.services.Results.OpenEvidence( ctx.Request.Context(), ctx.Param("id"), ctx.Param("evidence_id"), ) if err != nil { writeUsecaseError(ctx, err) return } defer result.Content.Close() ctx.Header("Cache-Control", "private, no-store") ctx.Header("Content-Type", result.Evidence.MediaType) ctx.Header("Content-Length", strconv.FormatInt(result.Evidence.SizeBytes, 10)) ctx.Header("ETag", `"`+result.Evidence.SHA256+`"`) ctx.Header("X-Content-Type-Options", "nosniff") ctx.Header( "Content-Disposition", `inline; filename="`+result.Evidence.ID+`.jpg"`, ) ctx.Status(http.StatusOK) _, _ = io.Copy(ctx.Writer, result.Content) } 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() writeAssetContent(ctx, result) } 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, "actor_device_id": event.ActorDeviceID, "type": event.Type, "message": event.Message, "occurred_at": formatTime(event.OccurredAt), }) } var claim any if detail.Task.ClaimGeneration > 0 { claim = gin.H{ "user_id": detail.Task.ClaimedByUserID, "device_id": detail.Task.ClaimedByDeviceID, "generation": detail.Task.ClaimGeneration, "issued_at": formatOptionalTime(detail.Task.ClaimIssuedAt), "expires_at": formatOptionalTime(detail.Task.ClaimExpiresAt), "cancel_requested_at": formatOptionalTime( detail.Task.CancelRequestedAt, ), "cancel_requested_by_user_id": detail.Task.CancelRequestedByUserID, } } var execution any if detail.Execution != nil { execution = executionResponse(*detail.Execution) } var executionReport any if detail.Report != nil { executionReport = executionReportResponse(detail.Report) } 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": claim, "execution": execution, "execution_report": executionReport, "order_authorizations": orderAuthorizationResponses( detail.OrderAuthorizations, ), "order_submissions": adminOrderSubmissionResponses( detail.OrderSubmissions, ), "events": events, "assets": []gin.H{ assetResponse(detail.Asset), }, }) } func adminOrderSubmissionResponses( submissions []domain.OrderSubmission, ) []gin.H { result := make([]gin.H, 0, len(submissions)) for _, submission := range submissions { result = append(result, adminOrderSubmissionResponse(submission)) } return result } func adminOrderSubmissionResponse( submission domain.OrderSubmission, ) gin.H { return gin.H{ "id": submission.ID, "authorization_id": submission.AuthorizationID, "dry_run_id": submission.DryRunID, "execution_id": submission.ExecutionID, "status": submission.Status, "expected_title": submission.ExpectedTitle, "expected_sku": submission.ExpectedSKU, "expected_quantity": submission.ExpectedQuantity, "expected_unit_price_cents": submission.ExpectedUnitPriceCents, "expected_total_price_cents": submission.ExpectedTotalPriceCents, "platform_order_no": submission.PlatformOrderNo, "platform_ordered_at": formatOptionalTime( submission.PlatformOrderedAt, ), "platform_order_status": submission.PlatformOrderStatus, "reconciliation_evidence_asset_id": submission. ReconciliationEvidenceAssetID, "reconciliation_evidence_sha256": submission. ReconciliationEvidenceSHA256, "manual_reason_code": submission.ManualReasonCode, "fenced_at": formatTime(submission.FencedAt), "reconciled_at": formatOptionalTime( submission.ReconciledAt, ), "manual_review_at": formatOptionalTime( submission.ManualReviewAt, ), } } func (h *adminHandlers) createOrderAuthorization(ctx *gin.Context) { var request struct { ExecutionID string `json:"execution_id"` TaskContentSHA256 string `json:"task_content_sha256"` ExpectedTaskVersion int64 `json:"expected_task_version"` CandidateKey string `json:"candidate_key"` ReasonSchemaVersion int `json:"reason_schema_version"` PrimaryReasonCode string `json:"primary_reason_code"` Note string `json:"note"` SupersedesAuthorizationID *string `json:"supersedes_authorization_id"` Items []usecase.OrderAuthorizationItemInput `json:"items"` } 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.Authorizations.Create( ctx.Request.Context(), usecase.CreateOrderAuthorizationCommand{ ActorUserID: adminActorUserID(ctx), TaskID: ctx.Param("id"), IdempotencyKey: ctx.GetHeader("Idempotency-Key"), ExecutionID: request.ExecutionID, TaskContentSHA256: request.TaskContentSHA256, ExpectedTaskVersion: request.ExpectedTaskVersion, CandidateKey: request.CandidateKey, ReasonSchemaVersion: request.ReasonSchemaVersion, PrimaryReasonCode: request.PrimaryReasonCode, Note: request.Note, SupersedesAuthorizationID: request.SupersedesAuthorizationID, Items: request.Items, }, ) if err != nil { writeUsecaseError(ctx, err) return } ctx.Header("Cache-Control", "no-store") ctx.JSON(http.StatusCreated, gin.H{ "authorization": orderAuthorizationResponse(result.Authorization), "replayed": result.Replayed, }) } func orderAuthorizationResponses( authorizations []domain.OrderAuthorization, ) []gin.H { result := make([]gin.H, 0, len(authorizations)) for _, authorization := range authorizations { result = append(result, orderAuthorizationResponse(authorization)) } return result } func orderAuthorizationResponse( authorization domain.OrderAuthorization, ) gin.H { return gin.H{ "id": authorization.ID, "task_id": authorization.TaskID, "execution_id": authorization.ExecutionID, "authorization_version": authorization.AuthorizationVersion, "candidate_key": authorization.CandidateKey, "task_content_sha256": authorization.TaskContentSHA256, "task_version": authorization.TaskVersion, "review_id": authorization.ReviewID, "review_version": authorization.ReviewVersion, "user_id": authorization.UserID, "device_id": authorization.DeviceID, "claim_generation": authorization.ClaimGeneration, "original_sku": authorization.OriginalSKU, "quantity": authorization.Quantity, "candidate_sku_text": authorization.CandidateSKUText, "candidate_price_text": authorization.CandidatePriceText, "card_signature": authorization.CardSignature, "detail_signature": authorization.DetailSignature, "detail_evidence_sha256": authorization.DetailEvidenceSHA256, "specification_evidence_sha256": authorization.SpecificationEvidenceSHA256, "status": authorization.Status, "supersedes_authorization_id": authorization.SupersedesAuthorizationID, "created_by_user_id": authorization.CreatedByUserID, "created_at": formatTime(authorization.CreatedAt), "delivered_at": formatOptionalTime(authorization.DeliveredAt), "acknowledged_at": formatOptionalTime(authorization.AcknowledgedAt), "execution_started_at": formatOptionalTime(authorization.ExecutionStartedAt), "consumed_at": formatOptionalTime(authorization.ConsumedAt), "failed_at": formatOptionalTime(authorization.FailedAt), "revoked_at": formatOptionalTime(authorization.RevokedAt), "failure_code": authorization.FailureCode, "failure_message": authorization.FailureMessage, "command_sha256": authorization.CommandSHA256, "delivery_attempt_count": authorization.DeliveryAttemptCount, "last_delivered_at": formatOptionalTime(authorization.LastDeliveredAt), } } func executionReportResponse(report *domain.ExecutionReport) gin.H { events := make([]gin.H, 0, len(report.Events)) for _, event := range report.Events { events = append(events, gin.H{ "id": event.ID, "step": event.Step, "type": event.Type, "message": event.Message, "occurred_at": formatTime(event.OccurredAt), "received_at": formatTime(event.ReceivedAt), "received_after_execution_expiry": event.ReceivedAfterExecutionExpiry, }) } evidence := make([]gin.H, 0, len(report.EvidenceAssets)) for _, asset := range report.EvidenceAssets { evidence = append(evidence, gin.H{ "id": asset.ID, "media_type": asset.MediaType, "size_bytes": asset.SizeBytes, "sha256": asset.SHA256, "created_at": formatTime(asset.CreatedAt), "received_after_execution_expiry": asset.ReceivedAfterExecutionExpiry, }) } response := gin.H{ "events": events, "evidence": evidence, } if batch := report.CandidateBatch; batch != nil { response["candidate_batch"] = gin.H{ "task_content_sha256": batch.TaskContentSHA256, "execution_mode": batch.ExecutionMode, "search_query": batch.SearchQuery, "provenance": decodedAuditJSON(batch.ProvenanceJSON), "candidates": decodedAuditJSON(&batch.CandidatesJSON), "recommendation": decodedAuditJSON(batch.RecommendationJSON), "received_at": formatTime(batch.ReceivedAt), "received_after_execution_expiry": batch.ReceivedAfterExecutionExpiry, } } if dataset := report.DecisionDataset; dataset != nil { response["candidate_decision_dataset"] = candidateDecisionDatasetResponse( dataset, ) } if outcome := report.Outcome; outcome != nil { response["outcome"] = gin.H{ "result_type": outcome.ResultType, "execution_mode": outcome.ExecutionMode, "task_content_sha256": outcome.TaskContentSHA256, "outcome": outcome.Outcome, "operator_reason": outcome.OperatorReason, "selected_candidate": decodedAuditJSON(outcome.SelectedCandidateJSON), "evidence_asset_ids": decodedAuditJSON(outcome.EvidenceAssetIDsJSON), "error_code": outcome.ErrorCode, "error_message": outcome.ErrorMessage, "error_step": outcome.ErrorStep, "retryable": outcome.Retryable, "order_submitted": outcome.OrderSubmitted, "received_at": formatTime(outcome.ReceivedAt), "received_after_execution_expiry": outcome.ReceivedAfterExecutionExpiry, } } return response } func candidateDecisionDatasetResponse( dataset *domain.CandidateDecisionDataset, ) gin.H { observations := make([]gin.H, 0, len(dataset.Observations)) for _, observation := range dataset.Observations { item := gin.H{ "ordinal": observation.Ordinal, "title": observation.Title, "sku_text": observation.SKUText, "price_text": observation.PriceText, "product_url": observation.ProductURL, "image_url": observation.ImageURL, "evidence_asset_ids": observation.EvidenceAssetIDs, "collection_status": observation.CollectionStatus, "observed_at": formatTime(observation.ObservedAt), } if identity := observation.Identity; identity != nil { item["identity"] = gin.H{ "candidate_key": identity.CandidateKey, "identity_version": identity.IdentityVersion, "card_signature": identity.CardSignature, "detail_signature": identity.DetailSignature, "detail_evidence_sha256": identity.DetailEvidenceSHA256, "specification_evidence_sha256": identity.SpecificationEvidenceSHA256, "created_at": formatTime(identity.CreatedAt), } } observations = append(observations, item) } evaluations := make([]gin.H, 0, len(dataset.Evaluations)) for _, evaluation := range dataset.Evaluations { evaluations = append(evaluations, gin.H{ "candidate_ordinal": evaluation.CandidateOrdinal, "decision": evaluation.Decision, "score": evaluation.Score, "confidence": evaluation.Confidence, "matched": decodedAuditJSON(&evaluation.MatchedJSON), "missing_or_uncertain": decodedAuditJSON(&evaluation.MissingOrUncertainJSON), "rejection_reasons": decodedAuditJSON(&evaluation.RejectionReasonsJSON), "hard_constraints": decodedAuditJSON(&evaluation.HardConstraintsJSON), "created_at": formatTime(evaluation.CreatedAt), }) } reviews := make([]gin.H, 0, len(dataset.HumanReviews)) for _, review := range dataset.HumanReviews { reviews = append(reviews, candidateHumanReviewResponse(review)) } response := gin.H{ "observations": observations, "evaluations": evaluations, "human_reviews": reviews, } if run := dataset.SearchRun; run != nil { response["search_run"] = gin.H{ "task_content_sha256": run.TaskContentSHA256, "execution_mode": run.ExecutionMode, "search_query": run.SearchQuery, "app_version": run.AppVersion, "android_version": run.AndroidVersion, "pdd_version": run.PDDVersion, "started_at": formatTime(run.StartedAt), "received_at": formatTime(run.ReceivedAt), "observation_count": run.ObservationCount, "collection_complete": run.CollectionComplete, "received_after_execution_expiry": run.ReceivedAfterExecutionExpiry, } } if model := dataset.ModelRun; model != nil { response["model_run"] = gin.H{ "provider_id": model.ProviderID, "model": model.Model, "prompt_version": model.PromptVersion, "schema_version": model.SchemaVersion, "recommendation_threshold": model.RecommendationThreshold, "request_sha256": model.RequestSHA256, "result_sha256": model.ResultSHA256, "created_at": formatTime(model.CreatedAt), } } if recommendation := dataset.Recommendation; recommendation != nil { response["recommendation"] = gin.H{ "candidate_ordinal": recommendation.CandidateOrdinal, "conclusion": recommendation.Conclusion, "policy_version": recommendation.PolicyVersion, "reasons": decodedAuditJSON(&recommendation.ReasonsJSON), "created_at": formatTime(recommendation.CreatedAt), } } return response } func decodedAuditJSON(value *string) any { if value == nil { return nil } var decoded any if err := json.Unmarshal([]byte(*value), &decoded); err != nil { return nil } return decoded } 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.ErrorKindForbidden: status = http.StatusForbidden case usecase.ErrorKindConflict: status = http.StatusConflict case usecase.ErrorKindUnavailable: status = http.StatusServiceUnavailable } switch typed.Code { case "ERP_NOT_CONFIGURED", "ERP_LOGIN_REJECTED": status = http.StatusUnprocessableEntity case "ERP_RESPONSE_INVALID": status = http.StatusBadGateway case "OCR_SERVICE_INVALID", "ERP_UNAVAILABLE": status = http.StatusServiceUnavailable case "FREIGHT_SYNC_TIMEOUT": status = http.StatusGatewayTimeout } message := typed.Message if preflightMessage, ok := freightPreflightPublicMessage(typed.Code); ok { message = preflightMessage } details := gin.H{} if len(typed.Fields) > 0 { details["fields"] = typed.Fields } writePublicError( ctx, status, typed.Code, message, typed.Retryable, details, ) } func freightPreflightPublicMessage(code string) (string, bool) { switch code { case "ERP_NOT_CONFIGURED": return "ERP credentials are not configured", true case "ERP_LOGIN_REJECTED": return "ERP login was rejected", true case "ERP_RESPONSE_INVALID": return "ERP response is invalid", true case "OCR_SERVICE_INVALID": return "OCR service is invalid", true case "ERP_UNAVAILABLE": return "ERP is temporarily unavailable", true case "FREIGHT_SYNC_TIMEOUT": return "freight order sync exceeded 55 seconds", true case "FREIGHT_SYNC_BUSY": return "another freight order sync is already running", true default: return "", false } } 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, "cancel_requested": task.CancelRequestedAt != nil, "cancel_requested_at": formatOptionalTime( task.CancelRequestedAt, ), } } 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) } func formatOptionalTime(value *time.Time) any { if value == nil { return nil } return formatTime(*value) } func executionResponse(execution domain.TaskExecution) gin.H { return gin.H{ "id": execution.ID, "attempt_no": execution.AttemptNo, "claim_generation": execution.ClaimGeneration, "user_id": execution.UserID, "device_id": execution.DeviceID, "current_step": execution.CurrentStep, "order_submitted": execution.OrderSubmitted, "started_at": formatTime(execution.StartedAt), "last_heartbeat_at": formatOptionalTime( execution.LastHeartbeatAt, ), "finished_at": formatOptionalTime(execution.FinishedAt), } } func writeAssetContent(ctx *gin.Context, result usecase.AssetContent) { 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) }