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