feat(t207): audit local procurement results

This commit is contained in:
QiuSW
2026-07-27 12:23:05 +08:00
parent a00c1e3e17
commit 4533df7abc
37 changed files with 4004 additions and 115 deletions
@@ -19,10 +19,11 @@ const claimTokenHeader = "X-Claim-Token"
type DeviceServices struct {
Lifecycle *usecase.LifecycleService
Assets *usecase.AssetService
Results *usecase.ExecutionResultService
}
func (services DeviceServices) validate() error {
if services.Lifecycle == nil || services.Assets == nil {
if services.Lifecycle == nil || services.Assets == nil || services.Results == nil {
return errors.New("device services are required")
}
return nil
@@ -68,6 +69,11 @@ func NewDeviceRouteRegistrar(
"/api/v1/tasks/:id/cancel-ack",
handler.acknowledgeCancellation,
)
routes.POST("/api/v1/tasks/:id/events", handler.appendEvents)
routes.POST("/api/v1/tasks/:id/evidence", handler.uploadEvidence)
routes.POST("/api/v1/tasks/:id/candidates", handler.storeCandidates)
routes.POST("/api/v1/tasks/:id/complete", handler.completeTask)
routes.POST("/api/v1/tasks/:id/fail", handler.failTask)
return nil
}, nil
}
@@ -367,6 +373,257 @@ func (handler *deviceHandlers) acknowledgeCancellation(
})
}
func (handler *deviceHandlers) appendEvents(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
var request struct {
ExecutionID string `json:"execution_id"`
ClaimGeneration int64 `json:"claim_generation"`
Events []usecase.ClientExecutionEvent `json:"events"`
}
if !decodeDeviceJSON(ctx, &request) {
return
}
replayed, err := handler.services.Results.AppendEvents(
ctx.Request.Context(),
usecase.AppendExecutionEventsCommand{
Identity: handler.executionResultIdentity(
ctx, principal, request.ExecutionID, request.ClaimGeneration,
),
Events: request.Events,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{"replayed": replayed})
}
func (handler *deviceHandlers) uploadEvidence(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
if !isEvidenceMediaType(ctx.GetHeader("Content-Type")) {
writePublicError(
ctx,
http.StatusUnsupportedMediaType,
"ASSET_MEDIA_TYPE_UNSUPPORTED",
"JPEG, PNG, or WebP evidence is required",
false,
gin.H{},
)
return
}
generation, err := strconv.ParseInt(
strings.TrimSpace(ctx.GetHeader("X-Claim-Generation")),
10,
64,
)
if err != nil {
writePublicError(
ctx,
http.StatusUnprocessableEntity,
"EXECUTION_RESULT_INVALID",
"execution result request is invalid",
false,
fieldDetails("claim_generation", "must be a positive integer"),
)
return
}
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxMultipartBytes)
result, err := handler.services.Results.UploadEvidence(
ctx.Request.Context(),
usecase.UploadExecutionEvidenceCommand{
Identity: handler.executionResultIdentity(
ctx,
principal,
ctx.GetHeader("X-Execution-ID"),
generation,
),
DeclaredMediaType: ctx.GetHeader("Content-Type"),
Content: ctx.Request.Body,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusCreated, gin.H{
"evidence": deviceEvidenceResponse(result.Evidence),
"replayed": result.Replayed,
})
}
func (handler *deviceHandlers) storeCandidates(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
var request struct {
ExecutionID string `json:"execution_id"`
ClaimGeneration int64 `json:"claim_generation"`
TaskContentSHA256 string `json:"task_content_sha256"`
ExecutionMode string `json:"execution_mode"`
SearchQuery string `json:"search_query"`
Provenance *usecase.ExecutionProvenance `json:"provenance"`
Candidates []usecase.ExecutionCandidate `json:"candidates"`
Recommendation *usecase.CandidateRecommendation `json:"recommendation"`
}
if !decodeDeviceJSON(ctx, &request) {
return
}
replayed, err := handler.services.Results.StoreCandidates(
ctx.Request.Context(),
usecase.StoreExecutionCandidatesCommand{
Identity: handler.executionResultIdentity(
ctx, principal, request.ExecutionID, request.ClaimGeneration,
),
TaskContentSHA256: request.TaskContentSHA256,
ExecutionMode: request.ExecutionMode,
SearchQuery: request.SearchQuery,
Provenance: request.Provenance,
Candidates: request.Candidates,
Recommendation: request.Recommendation,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{"replayed": replayed})
}
func (handler *deviceHandlers) completeTask(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
var request struct {
ExecutionID string `json:"execution_id"`
ClaimGeneration int64 `json:"claim_generation"`
TaskContentSHA256 string `json:"task_content_sha256"`
ExecutionMode string `json:"execution_mode"`
Outcome string `json:"outcome"`
OperatorReason string `json:"operator_reason"`
Candidate *usecase.ExecutionCandidate `json:"candidate"`
OrderSubmitted bool `json:"order_submitted"`
}
if !decodeDeviceJSON(ctx, &request) {
return
}
result, replayed, err := handler.services.Results.Complete(
ctx.Request.Context(),
usecase.CompleteExecutionCommand{
Identity: handler.executionResultIdentity(
ctx, principal, request.ExecutionID, request.ClaimGeneration,
),
TaskContentSHA256: request.TaskContentSHA256,
ExecutionMode: request.ExecutionMode,
Outcome: request.Outcome,
OperatorReason: request.OperatorReason,
Candidate: request.Candidate,
OrderSubmitted: request.OrderSubmitted,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"task": deviceTaskResponse(result),
"replayed": replayed,
})
}
func (handler *deviceHandlers) failTask(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
var request struct {
ExecutionID string `json:"execution_id"`
ClaimGeneration int64 `json:"claim_generation"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Step string `json:"step"`
Retryable bool `json:"retryable"`
} `json:"error"`
EvidenceAssetIDs []string `json:"evidence_asset_ids"`
}
if !decodeDeviceJSON(ctx, &request) {
return
}
result, replayed, err := handler.services.Results.Fail(
ctx.Request.Context(),
usecase.FailExecutionCommand{
Identity: handler.executionResultIdentity(
ctx, principal, request.ExecutionID, request.ClaimGeneration,
),
ErrorCode: request.Error.Code,
ErrorMessage: request.Error.Message,
ErrorStep: request.Error.Step,
Retryable: request.Error.Retryable,
EvidenceAssetIDs: request.EvidenceAssetIDs,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"task": deviceTaskResponse(result),
"replayed": replayed,
})
}
func (handler *deviceHandlers) executionResultIdentity(
ctx *gin.Context,
principal domain.AuthPrincipal,
executionID string,
claimGeneration int64,
) usecase.ExecutionResultIdentity {
return usecase.ExecutionResultIdentity{
UserID: principal.UserID,
DeviceID: principal.DeviceID,
TaskID: ctx.Param("id"),
ExecutionID: executionID,
ClaimGeneration: claimGeneration,
ClaimToken: ctx.GetHeader(claimTokenHeader),
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
}
}
func isEvidenceMediaType(value string) bool {
return hasEvidenceMediaType(value, "image/jpeg") ||
hasEvidenceMediaType(value, "image/png") ||
hasEvidenceMediaType(value, "image/webp")
}
func hasEvidenceMediaType(value string, expected string) bool {
return strings.EqualFold(strings.TrimSpace(strings.Split(value, ";")[0]), expected)
}
func deviceEvidenceResponse(evidence domain.ExecutionEvidenceAsset) gin.H {
return gin.H{
"id": evidence.ID,
"media_type": evidence.MediaType,
"size_bytes": evidence.SizeBytes,
"sha256": evidence.SHA256,
"created_at": formatTime(evidence.CreatedAt),
"received_after_execution_expiry": evidence.ReceivedAfterExecutionExpiry,
}
}
type lifecycleTransitionRequest struct {
DeviceID string `json:"device_id"`
ClaimGeneration int64 `json:"claim_generation"`