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
@@ -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