feat(t214): persist candidate observation identities

This commit is contained in:
QiuSW
2026-07-28 12:21:55 +08:00
parent acd3c85b5b
commit 56e7a2be33
29 changed files with 622 additions and 94 deletions
@@ -449,7 +449,7 @@ func candidateDecisionDatasetResponse(
) gin.H {
observations := make([]gin.H, 0, len(dataset.Observations))
for _, observation := range dataset.Observations {
observations = append(observations, gin.H{
item := gin.H{
"ordinal": observation.Ordinal,
"title": observation.Title,
"sku_text": observation.SKUText,
@@ -459,7 +459,19 @@ func candidateDecisionDatasetResponse(
"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 {
@@ -17,6 +17,7 @@ import (
"testing"
"time"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/platform/assetstore"
"cmroubao/backend-api/internal/platform/database"
"cmroubao/backend-api/internal/platform/migration"
@@ -26,6 +27,37 @@ import (
"github.com/gin-gonic/gin"
)
func TestCandidateDecisionDatasetResponseIncludesPersistentIdentity(t *testing.T) {
createdAt := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC)
response := candidateDecisionDatasetResponse(&domain.CandidateDecisionDataset{
Observations: []domain.CandidateObservation{
{
Ordinal: 1,
Identity: &domain.CandidateObservationIdentity{
CandidateKey: strings.Repeat("a", 64),
CardSignature: strings.Repeat("b", 64),
DetailSignature: strings.Repeat("c", 64),
DetailEvidenceSHA256: strings.Repeat("d", 64),
SpecificationEvidenceSHA256: strings.Repeat("e", 64),
IdentityVersion: 1,
CreatedAt: createdAt,
},
},
},
})
observations, ok := response["observations"].([]gin.H)
if !ok || len(observations) != 1 {
t.Fatalf("observations = %#v", response["observations"])
}
identity, ok := observations[0]["identity"].(gin.H)
if !ok ||
identity["candidate_key"] != strings.Repeat("a", 64) ||
identity["identity_version"] != 1 {
t.Fatalf("identity = %#v", observations[0]["identity"])
}
}
func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) {
router := newAdminIntegrationRouter(t)
imageBody, imageContentType := referenceUpload(t, "asset-key-1")
@@ -522,7 +522,7 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
})
requireDeviceStatus(t, events, http.StatusOK)
evidence := performDeviceRequest(t, fixture.router, deviceRequest{
detailEvidence := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/evidence",
contentType: "image/jpeg",
@@ -533,15 +533,42 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
executionID: started.Execution.ID,
claimGeneration: started.Task.ClaimGeneration,
})
requireDeviceStatus(t, evidence, http.StatusCreated)
requireDeviceStatus(t, detailEvidence, http.StatusCreated)
var evidenceResponse struct {
Evidence struct {
ID string `json:"id"`
ID string `json:"id"`
SHA256 string `json:"sha256"`
} `json:"evidence"`
}
decodeResponse(t, evidence, &evidenceResponse)
if evidenceResponse.Evidence.ID == "" {
t.Fatalf("evidence response = %s", evidence.Body.String())
decodeResponse(t, detailEvidence, &evidenceResponse)
if evidenceResponse.Evidence.ID == "" || evidenceResponse.Evidence.SHA256 == "" {
t.Fatalf("detail evidence response = %s", detailEvidence.Body.String())
}
specificationEvidence := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/evidence",
contentType: "image/jpeg",
body: deviceReferenceImage(t, 702),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-evidence-2",
executionID: started.Execution.ID,
claimGeneration: started.Task.ClaimGeneration,
})
requireDeviceStatus(t, specificationEvidence, http.StatusCreated)
var specificationEvidenceResponse struct {
Evidence struct {
ID string `json:"id"`
SHA256 string `json:"sha256"`
} `json:"evidence"`
}
decodeResponse(t, specificationEvidence, &specificationEvidenceResponse)
if specificationEvidenceResponse.Evidence.ID == "" ||
specificationEvidenceResponse.Evidence.SHA256 == "" {
t.Fatalf(
"specification evidence response = %s",
specificationEvidence.Body.String(),
)
}
detail, err := fixture.tasks.Get(context.Background(), "local-admin", taskID)
@@ -549,13 +576,36 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
t.Fatalf("get task for content hash: %v", err)
}
taskHash := usecase.TaskContentSHA256(detail.Task)
cardSignature := strings.Repeat("b", 64)
detailSignature := strings.Repeat("c", 64)
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}]}`,
`{"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":"","image_url":"","card_signature":%q,"detail_signature":%q,"detail_evidence_sha256":%q,"specification_evidence_sha256":%q,"evidence_asset_ids":[%q,%q],"evaluation":null}]}`,
started.Execution.ID,
started.Task.ClaimGeneration,
taskHash,
cardSignature,
detailSignature,
evidenceResponse.Evidence.SHA256,
specificationEvidenceResponse.Evidence.SHA256,
evidenceResponse.Evidence.ID,
specificationEvidenceResponse.Evidence.ID,
)
badCandidatePayload := strings.Replace(
candidatePayload,
evidenceResponse.Evidence.SHA256,
strings.Repeat("0", 64),
1,
)
badCandidates := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/candidates",
contentType: "application/json",
body: strings.NewReader(badCandidatePayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-candidates-bad-hash",
})
requireDeviceStatus(t, badCandidates, http.StatusConflict)
candidates := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/candidates",
@@ -566,6 +616,19 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
idempotencyKey: "result-candidates-1",
})
requireDeviceStatus(t, candidates, http.StatusOK)
candidateReplay := 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, candidateReplay, http.StatusOK)
if !strings.Contains(candidateReplay.Body.String(), `"replayed":true`) {
t.Fatalf("candidate replay response = %s", candidateReplay.Body.String())
}
humanReviewPayload := fmt.Sprintf(
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"reason_schema_version":1,"outcome":"CANDIDATE_ACCEPTED","selected_candidate_ordinal":1,"primary_reason_code":"SELECTED_BEST_MATCH","note":"","items":[{"candidate_ordinal":1,"label":"ACCEPT","primary_reason_code":"SKU_MATCH","reason_codes":["SKU_MATCH"],"note":""}]}`,
@@ -633,15 +696,20 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
t.Fatalf("migration.New() after review error = %v", err)
}
if err := runner.Down(context.Background()); err == nil {
t.Fatal("candidate migration down succeeded with retained review data")
t.Fatal("candidate identity migration down succeeded with retained data")
}
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}`,
`{"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":"","image_url":"","card_signature":%q,"detail_signature":%q,"detail_evidence_sha256":%q,"specification_evidence_sha256":%q,"evidence_asset_ids":[%q,%q],"evaluation":null},"order_submitted":false}`,
started.Execution.ID,
started.Task.ClaimGeneration,
taskHash,
cardSignature,
detailSignature,
evidenceResponse.Evidence.SHA256,
specificationEvidenceResponse.Evidence.SHA256,
evidenceResponse.Evidence.ID,
specificationEvidenceResponse.Evidence.ID,
)
complete := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
@@ -677,7 +745,7 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
detail.Report.Outcome == nil ||
detail.Report.Outcome.OrderSubmitted ||
len(detail.Report.Events) != 1 ||
len(detail.Report.EvidenceAssets) != 1 ||
len(detail.Report.EvidenceAssets) != 2 ||
detail.Report.CandidateBatch == nil ||
detail.Report.DecisionDataset == nil ||
len(detail.Report.DecisionDataset.Observations) != 1 ||
@@ -686,6 +754,16 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
detail.Report.DecisionDataset.HumanReviews[1].Version != 2 {
t.Fatalf("execution report = %+v", detail.Report)
}
identity := detail.Report.DecisionDataset.Observations[0].Identity
if identity == nil ||
len(identity.CandidateKey) != 64 ||
identity.CardSignature != cardSignature ||
identity.DetailSignature != detailSignature ||
identity.DetailEvidenceSHA256 != evidenceResponse.Evidence.SHA256 ||
identity.SpecificationEvidenceSHA256 !=
specificationEvidenceResponse.Evidence.SHA256 {
t.Fatalf("candidate identity = %+v", identity)
}
}
func TestDeviceReleaseReturnsClaimedTaskToPending(t *testing.T) {