feat(t214): persist candidate observation identities
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user