Files
cmroubao/backend-api/internal/transport/httpapi/admin_handlers.go
T

1032 lines
32 KiB
Go

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)
routes.DELETE("/api/v1/freight-orders/:id", handler.deleteFreightOrder)
}
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)
}