feat(t207): audit local procurement results
This commit is contained in:
@@ -24,12 +24,13 @@ const (
|
||||
)
|
||||
|
||||
type AdminServices struct {
|
||||
Assets *usecase.AssetService
|
||||
Tasks *usecase.TaskService
|
||||
Assets *usecase.AssetService
|
||||
Tasks *usecase.TaskService
|
||||
Results *usecase.ExecutionResultService
|
||||
}
|
||||
|
||||
func (s AdminServices) validate() error {
|
||||
if s.Assets == nil || s.Tasks == nil {
|
||||
if s.Assets == nil || s.Tasks == nil || s.Results == nil {
|
||||
return errors.New("admin services are required")
|
||||
}
|
||||
return nil
|
||||
@@ -49,10 +50,38 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
|
||||
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)
|
||||
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(
|
||||
@@ -317,6 +346,10 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
|
||||
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,
|
||||
@@ -337,6 +370,7 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
|
||||
"derived_requirement": nil,
|
||||
"claim": claim,
|
||||
"execution": execution,
|
||||
"execution_report": executionReport,
|
||||
"events": events,
|
||||
"assets": []gin.H{
|
||||
assetResponse(detail.Asset),
|
||||
@@ -344,6 +378,78 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
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 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 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(
|
||||
|
||||
@@ -349,8 +349,12 @@ func newAdminIntegrationRouter(t *testing.T) http.Handler {
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewTaskService() error = %v", err)
|
||||
}
|
||||
results, err := usecase.NewExecutionResultService(repositories, files, clock, ids)
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewExecutionResultService() error = %v", err)
|
||||
}
|
||||
registrar, err := NewAdminRouteRegistrar(
|
||||
AdminServices{Assets: assets, Tasks: tasks},
|
||||
AdminServices{Assets: assets, Tasks: tasks, Results: results},
|
||||
emptyAdminWeb{},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -478,6 +479,141 @@ func TestDeviceStartAndTaskHeartbeatUseClaimContract(t *testing.T) {
|
||||
assertNoClaimSecret(t, taskHeartbeat, testOpaqueToken)
|
||||
}
|
||||
|
||||
func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
|
||||
fixture := newDeviceHTTPFixture(t)
|
||||
requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK)
|
||||
taskID := fixture.createPendingTask(t)
|
||||
claimedResponse := fixture.claimNext(t, "claim-for-results", testOpaqueToken)
|
||||
requireDeviceStatus(t, claimedResponse, http.StatusOK)
|
||||
var claimed deviceLifecycleResponse
|
||||
decodeResponse(t, claimedResponse, &claimed)
|
||||
|
||||
startResponse := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/start",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(fmt.Sprintf(
|
||||
`{"claim_generation":%d,"expected_version":%d}`,
|
||||
claimed.Task.ClaimGeneration,
|
||||
claimed.Task.Version,
|
||||
)),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "start-for-results",
|
||||
})
|
||||
requireDeviceStatus(t, startResponse, http.StatusOK)
|
||||
var started deviceLifecycleResponse
|
||||
decodeResponse(t, startResponse, &started)
|
||||
|
||||
occurredAt := time.Now().UTC().Format(time.RFC3339Nano)
|
||||
events := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/events",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(fmt.Sprintf(
|
||||
`{"execution_id":%q,"claim_generation":%d,"events":[{"event_id":"00000000-0000-4000-8000-000000000701","step":"SEARCH","type":"SEARCH_STARTED","message":"开始采集候选","occurred_at":%q}]}`,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
occurredAt,
|
||||
)),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "result-events-1",
|
||||
})
|
||||
requireDeviceStatus(t, events, http.StatusOK)
|
||||
|
||||
evidence := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/evidence",
|
||||
contentType: "image/jpeg",
|
||||
body: deviceReferenceImage(t, 701),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "result-evidence-1",
|
||||
executionID: started.Execution.ID,
|
||||
claimGeneration: started.Task.ClaimGeneration,
|
||||
})
|
||||
requireDeviceStatus(t, evidence, http.StatusCreated)
|
||||
var evidenceResponse struct {
|
||||
Evidence struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"evidence"`
|
||||
}
|
||||
decodeResponse(t, evidence, &evidenceResponse)
|
||||
if evidenceResponse.Evidence.ID == "" {
|
||||
t.Fatalf("evidence response = %s", evidence.Body.String())
|
||||
}
|
||||
|
||||
detail, err := fixture.tasks.Get(context.Background(), "local-admin", taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("get task for content hash: %v", err)
|
||||
}
|
||||
taskHash := usecase.TaskContentSHA256(detail.Task)
|
||||
candidatePayload := fmt.Sprintf(
|
||||
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"execution_mode":"MANUAL_FIRST","search_query":"TEST-SKU","candidates":[{"ordinal":1,"title":"手动候选","sku_text":"TEST-SKU","price":"12.00","product_url":"https://example.test/product/1","image_url":"https://example.test/image/1.jpg","evidence_asset_ids":[%q],"evaluation":null}]}`,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
taskHash,
|
||||
evidenceResponse.Evidence.ID,
|
||||
)
|
||||
candidates := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/candidates",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(candidatePayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "result-candidates-1",
|
||||
})
|
||||
requireDeviceStatus(t, candidates, http.StatusOK)
|
||||
|
||||
completePayload := fmt.Sprintf(
|
||||
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"execution_mode":"MANUAL_FIRST","outcome":"CANDIDATE_ACCEPTED","operator_reason":"人工核对标题、SKU和截图后接受","candidate":{"ordinal":1,"title":"手动候选","sku_text":"TEST-SKU","price":"12.00","product_url":"https://example.test/product/1","image_url":"https://example.test/image/1.jpg","evidence_asset_ids":[%q],"evaluation":null},"order_submitted":false}`,
|
||||
started.Execution.ID,
|
||||
started.Task.ClaimGeneration,
|
||||
taskHash,
|
||||
evidenceResponse.Evidence.ID,
|
||||
)
|
||||
complete := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/complete",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(completePayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "result-complete-1",
|
||||
})
|
||||
requireDeviceStatus(t, complete, http.StatusOK)
|
||||
assertNoClaimSecret(t, complete, testOpaqueToken)
|
||||
replayed := performDeviceRequest(t, fixture.router, deviceRequest{
|
||||
method: http.MethodPost,
|
||||
target: "/api/v1/tasks/" + taskID + "/complete",
|
||||
contentType: "application/json",
|
||||
body: strings.NewReader(completePayload),
|
||||
bearerToken: testOpaqueToken,
|
||||
claimToken: testOpaqueToken,
|
||||
idempotencyKey: "result-complete-1",
|
||||
})
|
||||
requireDeviceStatus(t, replayed, http.StatusOK)
|
||||
if !strings.Contains(replayed.Body.String(), `"replayed":true`) {
|
||||
t.Fatalf("terminal replay response = %s", replayed.Body.String())
|
||||
}
|
||||
|
||||
detail, err = fixture.tasks.Get(context.Background(), "local-admin", taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("get task result detail: %v", err)
|
||||
}
|
||||
if detail.Task.Status != domain.TaskStatusSucceeded ||
|
||||
detail.Report == nil ||
|
||||
detail.Report.Outcome == nil ||
|
||||
detail.Report.Outcome.OrderSubmitted ||
|
||||
len(detail.Report.Events) != 1 ||
|
||||
len(detail.Report.EvidenceAssets) != 1 ||
|
||||
detail.Report.CandidateBatch == nil {
|
||||
t.Fatalf("execution report = %+v", detail.Report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceReleaseReturnsClaimedTaskToPending(t *testing.T) {
|
||||
fixture := newDeviceHTTPFixture(t)
|
||||
requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK)
|
||||
@@ -710,10 +846,15 @@ func newDeviceHTTPFixture(t *testing.T) *deviceHTTPFixture {
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewLifecycleService() error = %v", err)
|
||||
}
|
||||
results, err := usecase.NewExecutionResultService(store, files, clock, ids)
|
||||
if err != nil {
|
||||
t.Fatalf("usecase.NewExecutionResultService() error = %v", err)
|
||||
}
|
||||
deviceRoutes, err := NewDeviceRouteRegistrar(
|
||||
DeviceServices{
|
||||
Lifecycle: lifecycle,
|
||||
Assets: assets,
|
||||
Results: results,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -880,13 +1021,15 @@ func deviceReferenceImage(t *testing.T, index int) io.Reader {
|
||||
}
|
||||
|
||||
type deviceRequest struct {
|
||||
method string
|
||||
target string
|
||||
contentType string
|
||||
body io.Reader
|
||||
bearerToken string
|
||||
claimToken string
|
||||
idempotencyKey string
|
||||
method string
|
||||
target string
|
||||
contentType string
|
||||
body io.Reader
|
||||
bearerToken string
|
||||
claimToken string
|
||||
idempotencyKey string
|
||||
executionID string
|
||||
claimGeneration int64
|
||||
}
|
||||
|
||||
func performDeviceRequest(
|
||||
@@ -911,6 +1054,13 @@ func performDeviceRequest(
|
||||
if spec.idempotencyKey != "" {
|
||||
request.Header.Set("Idempotency-Key", spec.idempotencyKey)
|
||||
}
|
||||
if spec.executionID != "" {
|
||||
request.Header.Set("X-Execution-ID", spec.executionID)
|
||||
request.Header.Set(
|
||||
"X-Claim-Generation",
|
||||
strconv.FormatInt(spec.claimGeneration, 10),
|
||||
)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
router.ServeHTTP(response, request)
|
||||
return response
|
||||
|
||||
@@ -756,6 +756,7 @@ type taskDetailView struct {
|
||||
UpdatedAt time.Time
|
||||
CanCancel bool
|
||||
CancelRequiresAck bool
|
||||
ExecutionReport *ExecutionReport
|
||||
}
|
||||
|
||||
type taskDetailPage struct {
|
||||
@@ -789,6 +790,7 @@ func taskDetailViewFrom(task Task) taskDetailView {
|
||||
CanCancel: canCancelTaskStatus(task.Status),
|
||||
CancelRequiresAck: task.Status == "RUNNING" ||
|
||||
task.Status == "WAITING_CONFIRMATION",
|
||||
ExecutionReport: task.ExecutionReport,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,62 @@ textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.execution-audit {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.audit-summary {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.audit-list {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.audit-list li {
|
||||
margin: 6px 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.audit-evidence-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.audit-evidence {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.audit-evidence img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 240px;
|
||||
object-fit: contain;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.audit-evidence figcaption {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.audit-json {
|
||||
max-height: 360px;
|
||||
margin: 8px 0 16px;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface-soft);
|
||||
font: 12px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
|
||||
@@ -73,6 +73,39 @@
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{{with .Task.ExecutionReport}}
|
||||
<section class="content-section execution-audit" aria-labelledby="execution-audit-heading">
|
||||
<h2 id="execution-audit-heading">执行审计</h2>
|
||||
{{if .Outcome}}
|
||||
<dl class="definition-list audit-summary">
|
||||
<dt>结果类型</dt><dd>{{.Outcome.ResultType}}</dd>
|
||||
<dt>人工理由</dt><dd>{{if .Outcome.OperatorReason}}{{.Outcome.OperatorReason}}{{else}}未提供{{end}}</dd>
|
||||
<dt>订单提交</dt><dd>{{if .Outcome.OrderSubmitted}}是{{else}}否{{end}}</dd>
|
||||
{{if .Outcome.ErrorCode}}<dt>失败信息</dt><dd>{{.Outcome.ErrorCode}}:{{.Outcome.ErrorMessage}}</dd>{{end}}
|
||||
</dl>
|
||||
{{end}}
|
||||
{{if .Mode}}<p class="section-note">模式:{{.Mode}} · 搜索词:{{.SearchQuery}}</p>{{end}}
|
||||
{{if .Provenance}}<h3>本地模型出处</h3><pre class="audit-json">{{.Provenance}}</pre>{{end}}
|
||||
{{if .Candidates}}<h3>候选与评估</h3><pre class="audit-json">{{.Candidates}}</pre>{{end}}
|
||||
{{if .Recommendation}}<h3>本地推荐</h3><pre class="audit-json">{{.Recommendation}}</pre>{{end}}
|
||||
{{if .Evidence}}
|
||||
<h3>证据截图</h3>
|
||||
<div class="audit-evidence-grid">
|
||||
{{range .Evidence}}<figure class="audit-evidence">
|
||||
<img src="{{.ContentURL}}" alt="执行证据截图">
|
||||
<figcaption>SHA-256 {{.SHA256}}({{.SizeBytes}} bytes){{if .ReceivedAfterExecutionExpiry}},授权到期后补报{{end}}</figcaption>
|
||||
</figure>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Events}}
|
||||
<h3>执行事件</h3>
|
||||
<ul class="audit-list">
|
||||
{{range .Events}}<li><time datetime="{{machineTime .OccurredAt}}">{{displayTime .OccurredAt}}</time> · {{.Step}} · {{.Message}}{{if .ReceivedAfterExecutionExpiry}}(授权到期后补报){{end}}</li>{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .Task.CanCancel}}
|
||||
<noscript>
|
||||
<section class="noscript-cancel" aria-labelledby="noscript-cancel-heading">
|
||||
|
||||
@@ -58,6 +58,47 @@ type Task struct {
|
||||
ReferenceAssetID string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ExecutionReport *ExecutionReport
|
||||
}
|
||||
|
||||
type ExecutionReport struct {
|
||||
Events []ExecutionReportEvent
|
||||
Evidence []ExecutionReportEvidence
|
||||
Mode string
|
||||
SearchQuery string
|
||||
Provenance string
|
||||
Candidates string
|
||||
Recommendation string
|
||||
Outcome *ExecutionReportOutcome
|
||||
}
|
||||
|
||||
type ExecutionReportEvent struct {
|
||||
Step string
|
||||
Type string
|
||||
Message string
|
||||
OccurredAt time.Time
|
||||
ReceivedAfterExecutionExpiry bool
|
||||
}
|
||||
|
||||
type ExecutionReportEvidence struct {
|
||||
ID string
|
||||
ContentURL string
|
||||
SHA256 string
|
||||
SizeBytes int64
|
||||
CreatedAt time.Time
|
||||
ReceivedAfterExecutionExpiry bool
|
||||
}
|
||||
|
||||
type ExecutionReportOutcome struct {
|
||||
ResultType string
|
||||
Outcome string
|
||||
OperatorReason string
|
||||
ErrorCode string
|
||||
ErrorMessage string
|
||||
ErrorStep string
|
||||
OrderSubmitted bool
|
||||
ReceivedAt time.Time
|
||||
ReceivedAfterExecutionExpiry bool
|
||||
}
|
||||
|
||||
type UploadReferenceInput struct {
|
||||
|
||||
@@ -2,7 +2,9 @@ package webui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/transport/authcommon"
|
||||
@@ -176,9 +178,82 @@ func taskFromPurchase(task domain.PurchaseTask) Task {
|
||||
func taskFromDetail(detail domain.TaskDetail) Task {
|
||||
task := taskFromPurchase(detail.Task)
|
||||
task.ReferenceAssetID = detail.Asset.ID
|
||||
task.ExecutionReport = executionReportFrom(detail.Report)
|
||||
return task
|
||||
}
|
||||
|
||||
func executionReportFrom(report *domain.ExecutionReport) *ExecutionReport {
|
||||
if report == nil {
|
||||
return nil
|
||||
}
|
||||
result := &ExecutionReport{
|
||||
Events: make([]ExecutionReportEvent, 0, len(report.Events)),
|
||||
Evidence: make([]ExecutionReportEvidence, 0, len(report.EvidenceAssets)),
|
||||
}
|
||||
for _, event := range report.Events {
|
||||
result.Events = append(result.Events, ExecutionReportEvent{
|
||||
Step: event.Step,
|
||||
Type: event.Type,
|
||||
Message: event.Message,
|
||||
OccurredAt: event.OccurredAt,
|
||||
ReceivedAfterExecutionExpiry: event.ReceivedAfterExecutionExpiry,
|
||||
})
|
||||
}
|
||||
for _, evidence := range report.EvidenceAssets {
|
||||
result.Evidence = append(result.Evidence, ExecutionReportEvidence{
|
||||
ID: evidence.ID,
|
||||
ContentURL: "/api/v1/tasks/" + evidence.TaskID + "/evidence/" + evidence.ID + "/content",
|
||||
SHA256: evidence.SHA256,
|
||||
SizeBytes: evidence.SizeBytes,
|
||||
CreatedAt: evidence.CreatedAt,
|
||||
ReceivedAfterExecutionExpiry: evidence.ReceivedAfterExecutionExpiry,
|
||||
})
|
||||
}
|
||||
if batch := report.CandidateBatch; batch != nil {
|
||||
result.Mode = batch.ExecutionMode
|
||||
result.SearchQuery = batch.SearchQuery
|
||||
result.Provenance = prettyAuditJSON(batch.ProvenanceJSON)
|
||||
result.Candidates = prettyAuditJSON(&batch.CandidatesJSON)
|
||||
result.Recommendation = prettyAuditJSON(batch.RecommendationJSON)
|
||||
}
|
||||
if outcome := report.Outcome; outcome != nil {
|
||||
result.Outcome = &ExecutionReportOutcome{
|
||||
ResultType: outcome.ResultType,
|
||||
Outcome: stringValue(outcome.Outcome),
|
||||
OperatorReason: stringValue(outcome.OperatorReason),
|
||||
ErrorCode: stringValue(outcome.ErrorCode),
|
||||
ErrorMessage: stringValue(outcome.ErrorMessage),
|
||||
ErrorStep: stringValue(outcome.ErrorStep),
|
||||
OrderSubmitted: outcome.OrderSubmitted,
|
||||
ReceivedAt: outcome.ReceivedAt,
|
||||
ReceivedAfterExecutionExpiry: outcome.ReceivedAfterExecutionExpiry,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func prettyAuditJSON(value *string) string {
|
||||
if value == nil || strings.TrimSpace(*value) == "" {
|
||||
return ""
|
||||
}
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(*value), &decoded); err != nil {
|
||||
return ""
|
||||
}
|
||||
formatted, err := json.MarshalIndent(decoded, "", " ")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(formatted)
|
||||
}
|
||||
|
||||
func stringValue(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func mapUsecaseError(err error) error {
|
||||
var typed *usecase.Error
|
||||
if !errors.As(err, &typed) {
|
||||
|
||||
Reference in New Issue
Block a user