938 lines
29 KiB
Go
938 lines
29 KiB
Go
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
|
|
Results *usecase.ExecutionResultService
|
|
Authorizations *usecase.OrderAuthorizationService
|
|
}
|
|
|
|
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,
|
|
)
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
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,
|
|
"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)
|
|
}
|