feat(t214): persist candidate observation identities
This commit is contained in:
@@ -168,6 +168,19 @@ type CandidateObservation struct {
|
||||
EvidenceAssetIDs []string
|
||||
CollectionStatus string
|
||||
ObservedAt time.Time
|
||||
Identity *CandidateObservationIdentity
|
||||
}
|
||||
|
||||
type CandidateObservationIdentity struct {
|
||||
CandidateKey string
|
||||
ExecutionID string
|
||||
CandidateOrdinal int
|
||||
CardSignature string
|
||||
DetailSignature string
|
||||
DetailEvidenceSHA256 string
|
||||
SpecificationEvidenceSHA256 string
|
||||
IdentityVersion int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type CandidateModelRun struct {
|
||||
|
||||
@@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("initial Up() error = %v", err)
|
||||
} else if applied != 6 {
|
||||
t.Fatalf("initial Up() applied = %d, want 6", applied)
|
||||
} else if applied != 7 {
|
||||
t.Fatalf("initial Up() applied = %d, want 7", applied)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("initial Down(v7) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("initial Down(v6) error = %v", err)
|
||||
@@ -47,9 +50,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
seedClaimsHistoricalFixture(t, db)
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("Up(v5-v6) over historical data error = %v", err)
|
||||
} else if applied != 2 {
|
||||
t.Fatalf("Up(v5-v6) applied = %d, want 2", applied)
|
||||
t.Fatalf("Up(v5-v7) over historical data error = %v", err)
|
||||
} else if applied != 3 {
|
||||
t.Fatalf("Up(v5-v7) applied = %d, want 3", applied)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v7) with compatible history error = %v", err)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
|
||||
@@ -69,9 +77,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
|
||||
assertClaimsHistory(t, db, false)
|
||||
|
||||
if applied, err := runner.Up(ctx); err != nil {
|
||||
t.Fatalf("final Up(v4-v6) error = %v", err)
|
||||
} else if applied != 3 {
|
||||
t.Fatalf("final Up(v4-v6) applied = %d, want 3", applied)
|
||||
t.Fatalf("final Up(v4-v7) error = %v", err)
|
||||
} else if applied != 4 {
|
||||
t.Fatalf("final Up(v4-v7) applied = %d, want 4", applied)
|
||||
}
|
||||
assertClaimsHistory(t, db, true)
|
||||
}
|
||||
@@ -307,6 +315,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
|
||||
t.Fatalf("insert v4 audit event: %v", err)
|
||||
}
|
||||
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v7) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(ctx); err != nil {
|
||||
t.Fatalf("Down(v6) error = %v", err)
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Up() error = %v", err)
|
||||
}
|
||||
if applied != 6 {
|
||||
t.Fatalf("Up() applied = %d, want 6", applied)
|
||||
if applied != 7 {
|
||||
t.Fatalf("Up() applied = %d, want 7", applied)
|
||||
}
|
||||
assertStatuses(t, runner, map[int64]bool{
|
||||
1: true,
|
||||
@@ -37,6 +37,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
4: true,
|
||||
5: true,
|
||||
6: true,
|
||||
7: true,
|
||||
})
|
||||
|
||||
applied, err = runner.Up(context.Background())
|
||||
@@ -56,7 +57,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
3: true,
|
||||
4: true,
|
||||
5: true,
|
||||
6: false,
|
||||
6: true,
|
||||
7: false,
|
||||
})
|
||||
|
||||
applied, err = runner.Up(context.Background())
|
||||
@@ -73,6 +75,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
|
||||
4: true,
|
||||
5: true,
|
||||
6: true,
|
||||
7: true,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
|
||||
if err != nil {
|
||||
t.Fatalf("migration.New() error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("Down(v7) error = %v", err)
|
||||
}
|
||||
if err := runner.Down(context.Background()); err != nil {
|
||||
t.Fatalf("Down(v6) error = %v", err)
|
||||
}
|
||||
@@ -408,9 +411,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
|
||||
t.Fatal("purchase_tasks was lost during auth migration rollback")
|
||||
}
|
||||
if applied, err := runner.Up(context.Background()); err != nil {
|
||||
t.Fatalf("Up(v3-v6) error = %v", err)
|
||||
} else if applied != 4 {
|
||||
t.Fatalf("Up(v3-v6) applied = %d, want 4", applied)
|
||||
t.Fatalf("Up(v3-v7) error = %v", err)
|
||||
} else if applied != 5 {
|
||||
t.Fatalf("Up(v3-v7) applied = %d, want 5", applied)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"cmroubao/backend-api/internal/domain"
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
@@ -93,6 +94,25 @@ func storeCandidateDecisionDataset(
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
_, err = tx.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO candidate_observation_identities (
|
||||
candidate_key, execution_id, candidate_ordinal,
|
||||
card_signature, detail_signature, detail_evidence_sha256,
|
||||
specification_evidence_sha256, identity_version, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)`,
|
||||
candidateObservationKey(write.ExecutionID, candidate),
|
||||
write.ExecutionID,
|
||||
candidate.Ordinal,
|
||||
candidate.CardSignature,
|
||||
candidate.DetailSignature,
|
||||
candidate.DetailEvidenceSHA256,
|
||||
candidate.SpecificationEvidenceSHA256,
|
||||
formatTimestamp(write.Now),
|
||||
)
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
}
|
||||
if batch.ProvenanceJSON != nil {
|
||||
var provenance usecase.ExecutionProvenance
|
||||
@@ -567,12 +587,21 @@ func getCandidateDecisionDataset(
|
||||
}
|
||||
rows, err := queryer.QueryContext(
|
||||
ctx,
|
||||
`SELECT task_id, execution_id, ordinal, title, sku_text, price_text,
|
||||
product_url, image_url, evidence_asset_ids_json,
|
||||
collection_status, observed_at
|
||||
FROM candidate_observations
|
||||
WHERE task_id = ? AND execution_id = ?
|
||||
ORDER BY ordinal ASC`,
|
||||
`SELECT observation.task_id, observation.execution_id,
|
||||
observation.ordinal, observation.title, observation.sku_text,
|
||||
observation.price_text, observation.product_url,
|
||||
observation.image_url, observation.evidence_asset_ids_json,
|
||||
observation.collection_status, observation.observed_at,
|
||||
identity.candidate_key, identity.card_signature,
|
||||
identity.detail_signature, identity.detail_evidence_sha256,
|
||||
identity.specification_evidence_sha256,
|
||||
identity.identity_version, identity.created_at
|
||||
FROM candidate_observations AS observation
|
||||
LEFT JOIN candidate_observation_identities AS identity
|
||||
ON identity.execution_id = observation.execution_id
|
||||
AND identity.candidate_ordinal = observation.ordinal
|
||||
WHERE observation.task_id = ? AND observation.execution_id = ?
|
||||
ORDER BY observation.ordinal ASC`,
|
||||
taskID,
|
||||
executionID,
|
||||
)
|
||||
@@ -582,6 +611,9 @@ func getCandidateDecisionDataset(
|
||||
for rows.Next() {
|
||||
var observation domain.CandidateObservation
|
||||
var evidenceJSON, observedAt string
|
||||
var candidateKey, cardSignature, detailSignature sql.NullString
|
||||
var detailHash, specificationHash, identityAt sql.NullString
|
||||
var identityVersion sql.NullInt64
|
||||
if err := rows.Scan(
|
||||
&observation.TaskID,
|
||||
&observation.ExecutionID,
|
||||
@@ -594,6 +626,13 @@ func getCandidateDecisionDataset(
|
||||
&evidenceJSON,
|
||||
&observation.CollectionStatus,
|
||||
&observedAt,
|
||||
&candidateKey,
|
||||
&cardSignature,
|
||||
&detailSignature,
|
||||
&detailHash,
|
||||
&specificationHash,
|
||||
&identityVersion,
|
||||
&identityAt,
|
||||
); err != nil {
|
||||
_ = rows.Close()
|
||||
return nil, repositoryFailure(err)
|
||||
@@ -610,6 +649,24 @@ func getCandidateDecisionDataset(
|
||||
_ = rows.Close()
|
||||
return nil, repositoryFailure(err)
|
||||
}
|
||||
if candidateKey.Valid {
|
||||
identityCreatedAt, parseErr := parseTimestamp(identityAt.String)
|
||||
if parseErr != nil {
|
||||
_ = rows.Close()
|
||||
return nil, repositoryFailure(parseErr)
|
||||
}
|
||||
observation.Identity = &domain.CandidateObservationIdentity{
|
||||
CandidateKey: candidateKey.String,
|
||||
ExecutionID: observation.ExecutionID,
|
||||
CandidateOrdinal: observation.Ordinal,
|
||||
CardSignature: cardSignature.String,
|
||||
DetailSignature: detailSignature.String,
|
||||
DetailEvidenceSHA256: detailHash.String,
|
||||
SpecificationEvidenceSHA256: specificationHash.String,
|
||||
IdentityVersion: int(identityVersion.Int64),
|
||||
CreatedAt: identityCreatedAt,
|
||||
}
|
||||
}
|
||||
dataset.Observations = append(dataset.Observations, observation)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -823,6 +880,23 @@ func hashCandidateResult(candidates string, recommendation *string) string {
|
||||
return hex.EncodeToString(digest.Sum(nil))
|
||||
}
|
||||
|
||||
func candidateObservationKey(
|
||||
executionID string,
|
||||
candidate usecase.ExecutionCandidate,
|
||||
) string {
|
||||
digest := sha256.New()
|
||||
_, _ = digest.Write([]byte("cmroubao-candidate-v1"))
|
||||
_, _ = digest.Write([]byte{0})
|
||||
_, _ = digest.Write([]byte(executionID))
|
||||
_, _ = digest.Write([]byte{0})
|
||||
_, _ = digest.Write([]byte(strconv.Itoa(candidate.Ordinal)))
|
||||
_, _ = digest.Write([]byte{0})
|
||||
_, _ = digest.Write([]byte(candidate.DetailSignature))
|
||||
_, _ = digest.Write([]byte{0})
|
||||
_, _ = digest.Write([]byte(candidate.SpecificationEvidenceSHA256))
|
||||
return hex.EncodeToString(digest.Sum(nil))
|
||||
}
|
||||
|
||||
func nullableSQLString(value sql.NullString) any {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cmroubao/backend-api/internal/usecase"
|
||||
)
|
||||
|
||||
func TestCandidateObservationKeyIsStableAndObservationScoped(t *testing.T) {
|
||||
candidate := usecase.ExecutionCandidate{
|
||||
Ordinal: 1,
|
||||
DetailSignature: strings.Repeat("c", 64),
|
||||
SpecificationEvidenceSHA256: strings.Repeat("d", 64),
|
||||
}
|
||||
|
||||
first := candidateObservationKey("execution-1", candidate)
|
||||
replayed := candidateObservationKey("execution-1", candidate)
|
||||
otherOrdinal := candidate
|
||||
otherOrdinal.Ordinal = 2
|
||||
|
||||
if len(first) != 64 {
|
||||
t.Fatalf("candidate key length = %d", len(first))
|
||||
}
|
||||
if replayed != first {
|
||||
t.Fatalf("replayed key = %q, want %q", replayed, first)
|
||||
}
|
||||
if candidateObservationKey("execution-1", otherOrdinal) == first {
|
||||
t.Fatal("different observation ordinal produced the same key")
|
||||
}
|
||||
if candidateObservationKey("execution-2", candidate) == first {
|
||||
t.Fatal("different execution produced the same key")
|
||||
}
|
||||
}
|
||||
@@ -557,9 +557,7 @@ func validateCandidateEvidence(
|
||||
write usecase.ExecutionResultWrite,
|
||||
candidatesJSON string,
|
||||
) error {
|
||||
var candidates []struct {
|
||||
EvidenceAssetIDs []string `json:"evidence_asset_ids"`
|
||||
}
|
||||
var candidates []usecase.ExecutionCandidate
|
||||
if err := json.Unmarshal([]byte(candidatesJSON), &candidates); err != nil {
|
||||
return usecase.ErrRepositoryInvariant
|
||||
}
|
||||
@@ -569,6 +567,30 @@ func validateCandidateEvidence(
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(candidate.EvidenceAssetIDs) != 2 {
|
||||
return usecase.ErrTaskStateConflict
|
||||
}
|
||||
expectedHashes := []string{
|
||||
candidate.DetailEvidenceSHA256,
|
||||
candidate.SpecificationEvidenceSHA256,
|
||||
}
|
||||
for index, evidenceID := range candidate.EvidenceAssetIDs {
|
||||
var actualHash string
|
||||
err := tx.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT sha256 FROM execution_evidence_assets
|
||||
WHERE id = ? AND task_id = ? AND execution_id = ?`,
|
||||
evidenceID,
|
||||
write.TaskID,
|
||||
write.ExecutionID,
|
||||
).Scan(&actualHash)
|
||||
if err != nil {
|
||||
return repositoryFailure(err)
|
||||
}
|
||||
if actualHash != expectedHashes[index] {
|
||||
return usecase.ErrTaskStateConflict
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -90,14 +90,18 @@ type CandidateHardConstraintEvaluation struct {
|
||||
}
|
||||
|
||||
type ExecutionCandidate struct {
|
||||
Ordinal int `json:"ordinal"`
|
||||
Title string `json:"title"`
|
||||
SKUText string `json:"sku_text"`
|
||||
Price string `json:"price"`
|
||||
ProductURL string `json:"product_url"`
|
||||
ImageURL string `json:"image_url"`
|
||||
EvidenceAssetIDs []string `json:"evidence_asset_ids"`
|
||||
Evaluation *CandidateEvaluation `json:"evaluation"`
|
||||
Ordinal int `json:"ordinal"`
|
||||
Title string `json:"title"`
|
||||
SKUText string `json:"sku_text"`
|
||||
Price string `json:"price"`
|
||||
ProductURL string `json:"product_url"`
|
||||
ImageURL string `json:"image_url"`
|
||||
CardSignature string `json:"card_signature"`
|
||||
DetailSignature string `json:"detail_signature"`
|
||||
DetailEvidenceSHA256 string `json:"detail_evidence_sha256"`
|
||||
SpecificationEvidenceSHA256 string `json:"specification_evidence_sha256"`
|
||||
EvidenceAssetIDs []string `json:"evidence_asset_ids"`
|
||||
Evaluation *CandidateEvaluation `json:"evaluation"`
|
||||
}
|
||||
|
||||
type CandidateRecommendation struct {
|
||||
@@ -603,7 +607,11 @@ func validCandidate(candidate ExecutionCandidate, mode string) bool {
|
||||
!validOptionalAuditText(candidate.Price, 64) ||
|
||||
!validObservationURL(candidate.ProductURL) ||
|
||||
!validObservationURL(candidate.ImageURL) ||
|
||||
len(candidate.EvidenceAssetIDs) > 5 {
|
||||
!sha256Pattern.MatchString(candidate.CardSignature) ||
|
||||
!sha256Pattern.MatchString(candidate.DetailSignature) ||
|
||||
!sha256Pattern.MatchString(candidate.DetailEvidenceSHA256) ||
|
||||
!sha256Pattern.MatchString(candidate.SpecificationEvidenceSHA256) ||
|
||||
len(candidate.EvidenceAssetIDs) != 2 {
|
||||
return false
|
||||
}
|
||||
for _, id := range candidate.EvidenceAssetIDs {
|
||||
@@ -611,6 +619,9 @@ func validCandidate(candidate ExecutionCandidate, mode string) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if candidate.EvidenceAssetIDs[0] == candidate.EvidenceAssetIDs[1] {
|
||||
return false
|
||||
}
|
||||
if mode == manualFirstMode {
|
||||
return candidate.Evaluation == nil
|
||||
}
|
||||
|
||||
@@ -98,8 +98,16 @@ func validAIExecutionCandidateCommand() StoreExecutionCandidatesCommand {
|
||||
},
|
||||
Candidates: []ExecutionCandidate{
|
||||
{
|
||||
Ordinal: 1,
|
||||
Title: "拼多多图片候选 1",
|
||||
Ordinal: 1,
|
||||
Title: "拼多多图片候选 1",
|
||||
CardSignature: strings.Repeat("b", 64),
|
||||
DetailSignature: strings.Repeat("c", 64),
|
||||
DetailEvidenceSHA256: strings.Repeat("d", 64),
|
||||
SpecificationEvidenceSHA256: strings.Repeat("e", 64),
|
||||
EvidenceAssetIDs: []string{
|
||||
"00000000-0000-4000-8000-000000000001",
|
||||
"00000000-0000-4000-8000-000000000002",
|
||||
},
|
||||
Evaluation: &CandidateEvaluation{
|
||||
Decision: "REVIEW",
|
||||
Score: 0.9,
|
||||
@@ -129,3 +137,13 @@ func validAIExecutionCandidateCommand() StoreExecutionCandidatesCommand {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreCandidatesRejectsDuplicateEvidenceReferences(t *testing.T) {
|
||||
command := validAIExecutionCandidateCommand()
|
||||
command.Candidates[0].EvidenceAssetIDs[1] =
|
||||
command.Candidates[0].EvidenceAssetIDs[0]
|
||||
|
||||
if err := validateCandidateCommand(command); err == nil {
|
||||
t.Fatal("expected duplicate evidence references to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE candidate_observation_identities (
|
||||
candidate_key TEXT PRIMARY KEY NOT NULL
|
||||
CHECK (
|
||||
length(candidate_key) = 64
|
||||
AND candidate_key NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
execution_id TEXT NOT NULL,
|
||||
candidate_ordinal INTEGER NOT NULL,
|
||||
card_signature TEXT NOT NULL
|
||||
CHECK (
|
||||
length(card_signature) = 64
|
||||
AND card_signature NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
detail_signature TEXT NOT NULL
|
||||
CHECK (
|
||||
length(detail_signature) = 64
|
||||
AND detail_signature NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
detail_evidence_sha256 TEXT NOT NULL
|
||||
CHECK (
|
||||
length(detail_evidence_sha256) = 64
|
||||
AND detail_evidence_sha256 NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
specification_evidence_sha256 TEXT NOT NULL
|
||||
CHECK (
|
||||
length(specification_evidence_sha256) = 64
|
||||
AND specification_evidence_sha256 NOT GLOB '*[^0-9a-f]*'
|
||||
),
|
||||
identity_version INTEGER NOT NULL
|
||||
CHECK (identity_version = 1),
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (execution_id, candidate_ordinal),
|
||||
FOREIGN KEY (execution_id, candidate_ordinal)
|
||||
REFERENCES candidate_observations(execution_id, ordinal)
|
||||
ON UPDATE RESTRICT ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
CREATE TEMP TABLE candidate_identities_v7_down_guard (
|
||||
allowed INTEGER NOT NULL
|
||||
CHECK (allowed = 1)
|
||||
);
|
||||
|
||||
INSERT INTO candidate_identities_v7_down_guard (allowed)
|
||||
SELECT CASE
|
||||
WHEN EXISTS (SELECT 1 FROM candidate_observation_identities)
|
||||
THEN 0
|
||||
ELSE 1
|
||||
END;
|
||||
|
||||
DROP TABLE candidate_identities_v7_down_guard;
|
||||
DROP TABLE candidate_observation_identities;
|
||||
Reference in New Issue
Block a user