From 56e7a2be33b4277db56c21930db5be160668e0c1 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 28 Jul 2026 12:21:55 +0800 Subject: [PATCH] feat(t214): persist candidate observation identities --- android-buyer/app/build.gradle.kts | 4 +- .../java/com/roubao/autopilot/MainActivity.kt | 21 +++- .../BuyerAccessibilityService.kt | 4 +- .../AndroidPinduoduoCandidateDriver.kt | 6 +- .../pinduoduo/CandidateEvidenceSource.kt | 11 +++ .../pinduoduo/PinduoduoCandidateModels.kt | 32 +++++- .../procurement/ExecutionResultOutbox.kt | 4 + .../procurement/ProcurementRepository.kt | 19 ++++ .../pinduoduo/CandidateEvidenceSourceTest.kt | 7 +- .../PinduoduoObservedTitlePolicyTest.kt | 35 +++++++ backend-api/internal/domain/task.go | 13 +++ .../migration/claims_migration_test.go | 27 +++-- .../platform/migration/runner_test.go | 9 +- .../repository/sqlite/auth_repository_test.go | 9 +- .../sqlite/candidate_decision_repository.go | 86 ++++++++++++++-- .../candidate_decision_repository_test.go | 34 +++++++ .../sqlite/execution_result_repository.go | 28 +++++- .../transport/httpapi/admin_handlers.go | 16 ++- .../transport/httpapi/admin_handlers_test.go | 32 ++++++ .../transport/httpapi/device_handlers_test.go | 98 +++++++++++++++++-- .../usecase/execution_result_service.go | 29 ++++-- .../usecase/execution_result_service_test.go | 22 ++++- .../migrations/00007_candidate_identities.sql | 53 ++++++++++ docs/00-ai-start-here.md | 4 +- docs/03-tech-stack.md | 1 + docs/04-architecture.md | 22 +++-- docs/api.md | 36 +++++-- docs/current-state.md | 28 +++--- docs/tasks/T-214.md | 26 +++-- 29 files changed, 622 insertions(+), 94 deletions(-) create mode 100644 android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoObservedTitlePolicyTest.kt create mode 100644 backend-api/internal/repository/sqlite/candidate_decision_repository_test.go create mode 100644 backend-api/migrations/00007_candidate_identities.sql diff --git a/android-buyer/app/build.gradle.kts b/android-buyer/app/build.gradle.kts index 4ec5b7e..0fa962e 100644 --- a/android-buyer/app/build.gradle.kts +++ b/android-buyer/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.roubao.autopilot" minSdk = 26 targetSdk = 34 - versionCode = 16 - versionName = "1.4.11" + versionCode = 17 + versionName = "1.4.12" vectorDrawables { useSupportLibrary = true diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt index cd07387..ed95b93 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/MainActivity.kt @@ -1045,9 +1045,8 @@ class MainActivity : ComponentActivity() { ) ExecutionCandidateDraft( ordinal = assessment.ordinal, - title = - "拼多多图片候选 " + - assessment.ordinal, + title = candidateEvidence.observedTitle + ?: "拼多多图片候选 ${assessment.ordinal}", skuText = candidateEvidence.specification .selectedSummary @@ -1059,6 +1058,14 @@ class MainActivity : ComponentActivity() { }, price = candidateEvidence.specification .price?.rawText.orEmpty(), + cardSignature = + candidateEvidence.cardSignature, + detailSignature = + candidateEvidence.detailSignature, + detailEvidenceSha256 = + candidateEvidence.detailSha256, + specificationEvidenceSha256 = + candidateEvidence.specificationSha256, evidenceLocalIDs = emptyList(), evaluation = ExecutionCandidateEvaluation( decision = assessment.decision.name, @@ -1222,11 +1229,17 @@ class MainActivity : ComponentActivity() { candidates = validated.map { candidate -> ExecutionCandidateDraft( ordinal = candidate.ordinal, - title = "$taskTitle 候选 ${candidate.ordinal}", + title = candidate.observedTitle + ?: "$taskTitle 候选 ${candidate.ordinal}", skuText = candidate.specification .selectedSummary.orEmpty(), price = candidate.specification .price?.rawText.orEmpty(), + cardSignature = candidate.cardSignature, + detailSignature = candidate.detailSignature, + detailEvidenceSha256 = candidate.detailSha256, + specificationEvidenceSha256 = + candidate.specificationSha256, evidenceLocalIDs = emptyList() ) } diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt index 2836097..745eaf8 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/accessibility/BuyerAccessibilityService.kt @@ -14,6 +14,7 @@ import androidx.annotation.RequiresApi import com.roubao.autopilot.pinduoduo.PinduoduoCandidateCard import com.roubao.autopilot.pinduoduo.PinduoduoCandidateDetailEvidence import com.roubao.autopilot.pinduoduo.PinduoduoEvidenceHash +import com.roubao.autopilot.pinduoduo.PinduoduoObservedTitlePolicy import com.roubao.autopilot.readiness.DeviceObservationStore import com.roubao.autopilot.readiness.LoginBlockerDetector import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE @@ -320,7 +321,8 @@ class BuyerAccessibilityService : AccessibilityService() { signature = PinduoduoEvidenceHash.sha256( semanticTexts.joinToString(TEXT_SIGNATURE_SEPARATOR) ), - semanticTextCount = semanticTexts.size + semanticTextCount = semanticTexts.size, + observedTitle = PinduoduoObservedTitlePolicy.select(semanticTexts) ) } diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoCandidateDriver.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoCandidateDriver.kt index 453146a..db3f558 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoCandidateDriver.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/AndroidPinduoduoCandidateDriver.kt @@ -134,7 +134,8 @@ private class CandidateEvidenceStore(context: Context) { detailSemanticTextCount = detail.detail.semanticTextCount, detailAsset = detailAsset, specification = specifications.evidence, - specificationAsset = specificationAsset + specificationAsset = specificationAsset, + observedTitle = detail.detail.observedTitle ) evidenceByOrdinal[ordinal] = evidence writeManifest() @@ -172,6 +173,7 @@ private class CandidateEvidenceStore(context: Context) { evidence.cardSemanticTextCount ) .put("detail_signature", evidence.detailSignature) + .put("observed_title", evidence.observedTitle) .put( "detail_semantic_text_count", evidence.detailSemanticTextCount @@ -208,7 +210,7 @@ private class CandidateEvidenceStore(context: Context) { ) } val manifest = JSONObject() - .put("schema_version", 3) + .put("schema_version", 4) .put("candidate_count", evidenceByOrdinal.size) .put("candidates", candidates) .toString(2) diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/CandidateEvidenceSource.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/CandidateEvidenceSource.kt index 7700c64..8c46714 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/CandidateEvidenceSource.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/CandidateEvidenceSource.kt @@ -9,6 +9,9 @@ import kotlinx.coroutines.withContext data class ValidatedCandidateEvidence( val ordinal: Int, + val cardSignature: String, + val detailSignature: String, + val observedTitle: String?, val detailPngBytes: ByteArray, val detailSha256: String, val specification: PinduoduoSpecificationEvidence, @@ -46,6 +49,11 @@ class CandidateEvidenceSource( ?.length ?.let { it in 1..160 } != false ) + require(SHA256_PATTERN.matches(metadata.cardSignature)) + require(SHA256_PATTERN.matches(metadata.detailSignature)) + require( + metadata.observedTitle?.length?.let { it in 1..160 } != false + ) require( metadata.specification.price?.let { price -> price.rawText.length in 1..32 && @@ -69,6 +77,9 @@ class CandidateEvidenceSource( require(totalBytes <= MAX_TOTAL_PNG_BYTES) ValidatedCandidateEvidence( ordinal = metadata.ordinal, + cardSignature = metadata.cardSignature, + detailSignature = metadata.detailSignature, + observedTitle = metadata.observedTitle, detailPngBytes = detail, detailSha256 = metadata.detailAsset.sha256, specification = metadata.specification, diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateModels.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateModels.kt index 2e70420..7b42d6b 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateModels.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/pinduoduo/PinduoduoCandidateModels.kt @@ -14,7 +14,8 @@ data class PinduoduoCandidateCard( data class PinduoduoCandidateDetailEvidence( val signature: String, - val semanticTextCount: Int + val semanticTextCount: Int, + val observedTitle: String? = null ) data class PinduoduoScreenshotCapture( @@ -39,9 +40,36 @@ data class PinduoduoCandidateEvidence( val detailSemanticTextCount: Int, val detailAsset: PinduoduoEvidenceAsset, val specification: PinduoduoSpecificationEvidence, - val specificationAsset: PinduoduoEvidenceAsset + val specificationAsset: PinduoduoEvidenceAsset, + val observedTitle: String? = null ) +object PinduoduoObservedTitlePolicy { + private val excludedFragments = listOf( + "客服", + "收藏", + "店铺", + "免拼购买", + "单独购买", + "发起拼单", + "确认款式", + "已选择", + "退货", + "包邮" + ) + + fun select(semanticTexts: List): String? = + semanticTexts.asSequence() + .map { it.trim().replace(Regex("\\s+"), " ") } + .filter { it.length in 6..160 } + .filterNot { text -> + text.any { it == '¥' || it == '¥' } || + excludedFragments.any(text::contains) || + text.all { it.isDigit() || it in ".,+-/% " } + } + .maxByOrNull(String::length) +} + object PinduoduoEvidenceHash { fun sha256(value: String): String = sha256(value.toByteArray(Charsets.UTF_8)) diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ExecutionResultOutbox.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ExecutionResultOutbox.kt index 0cf0b34..d7df164 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ExecutionResultOutbox.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ExecutionResultOutbox.kt @@ -43,6 +43,10 @@ data class ExecutionCandidateDraft( val price: String = "", val productUrl: String = "", val imageUrl: String = "", + val cardSignature: String = "", + val detailSignature: String = "", + val detailEvidenceSha256: String = "", + val specificationEvidenceSha256: String = "", val evidenceLocalIDs: List, val evaluation: ExecutionCandidateEvaluation? = null ) diff --git a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementRepository.kt b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementRepository.kt index 8ef516f..f987e91 100644 --- a/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementRepository.kt +++ b/android-buyer/app/src/main/java/com/roubao/autopilot/procurement/ProcurementRepository.kt @@ -404,6 +404,14 @@ class ProcurementRepository( require(batch.candidates.map { it.ordinal } == (1..batch.candidates.size).toList()) { "候选编号必须连续" } + require(batch.candidates.all { candidate -> + SHA256_PATTERN.matches(candidate.cardSignature) && + SHA256_PATTERN.matches(candidate.detailSignature) && + SHA256_PATTERN.matches(candidate.detailEvidenceSha256) && + SHA256_PATTERN.matches( + candidate.specificationEvidenceSha256 + ) + }) { "候选持久身份指纹无效" } require(batch.mode == execution.provenance?.mode) { "执行模式不能在回传时变更" } if (batch.mode == ExecutionMode.AI_ASSISTED) { require(batch.provenance != null && batch.provenance.providerId != null) { @@ -883,6 +891,16 @@ class ProcurementRepository( .put("price", candidate.price) .put("product_url", candidate.productUrl) .put("image_url", candidate.imageUrl) + .put("card_signature", candidate.cardSignature) + .put("detail_signature", candidate.detailSignature) + .put( + "detail_evidence_sha256", + candidate.detailEvidenceSha256 + ) + .put( + "specification_evidence_sha256", + candidate.specificationEvidenceSha256 + ) .put("evidence_asset_ids", JSONArray(candidate.evidenceLocalIDs)) .also { json -> candidate.evaluation?.let { evaluation -> @@ -1054,6 +1072,7 @@ class ProcurementRepository( private const val MAX_REFERENCE_DIMENSION = 4_096 private const val MAX_REFERENCE_PIXELS = 20_000_000L private const val MAX_OUTBOX_EVIDENCE_BYTES = 8L * 1024L * 1024L + private val SHA256_PATTERN = Regex("^[0-9a-f]{64}$") private val COMPLETE_OUTCOMES = setOf( "CANDIDATE_ACCEPTED", "CANDIDATE_REJECTED", diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/CandidateEvidenceSourceTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/CandidateEvidenceSourceTest.kt index 56cc67e..f25c647 100644 --- a/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/CandidateEvidenceSourceTest.kt +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/CandidateEvidenceSourceTest.kt @@ -150,13 +150,14 @@ class CandidateEvidenceSourceTest { ) return PinduoduoCandidateEvidence( ordinal = ordinal, - cardSignature = "card-$ordinal", + cardSignature = PinduoduoEvidenceHash.sha256("card-$ordinal"), cardSemanticTextCount = 3, - detailSignature = "detail-$ordinal", + detailSignature = PinduoduoEvidenceHash.sha256("detail-$ordinal"), detailSemanticTextCount = 4, detailAsset = asset, specification = specification(), - specificationAsset = asset.copy(fileName = specificationFileName) + specificationAsset = asset.copy(fileName = specificationFileName), + observedTitle = "候选商品标题 $ordinal" ) } diff --git a/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoObservedTitlePolicyTest.kt b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoObservedTitlePolicyTest.kt new file mode 100644 index 0000000..b3fa357 --- /dev/null +++ b/android-buyer/app/src/test/java/com/roubao/autopilot/pinduoduo/PinduoduoObservedTitlePolicyTest.kt @@ -0,0 +1,35 @@ +package com.roubao.autopilot.pinduoduo + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PinduoduoObservedTitlePolicyTest { + @Test + fun `selects longest product-like semantic text`() { + val title = PinduoduoObservedTitlePolicy.select( + listOf( + "¥12.90", + "店铺客服", + "2026夏季纯棉宽松短袖圆领上衣", + "纯棉短袖" + ) + ) + + assertEquals("2026夏季纯棉宽松短袖圆领上衣", title) + } + + @Test + fun `rejects actions prices and numeric-only text`() { + val title = PinduoduoObservedTitlePolicy.select( + listOf( + "免拼购买", + "已选择 黑色 L", + "¥12.90", + "123456" + ) + ) + + assertNull(title) + } +} diff --git a/backend-api/internal/domain/task.go b/backend-api/internal/domain/task.go index 834fd73..3842fea 100644 --- a/backend-api/internal/domain/task.go +++ b/backend-api/internal/domain/task.go @@ -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 { diff --git a/backend-api/internal/platform/migration/claims_migration_test.go b/backend-api/internal/platform/migration/claims_migration_test.go index ed873c4..0f76fb5 100644 --- a/backend-api/internal/platform/migration/claims_migration_test.go +++ b/backend-api/internal/platform/migration/claims_migration_test.go @@ -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) } diff --git a/backend-api/internal/platform/migration/runner_test.go b/backend-api/internal/platform/migration/runner_test.go index 87cbc84..08edcd1 100644 --- a/backend-api/internal/platform/migration/runner_test.go +++ b/backend-api/internal/platform/migration/runner_test.go @@ -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, }) } diff --git a/backend-api/internal/repository/sqlite/auth_repository_test.go b/backend-api/internal/repository/sqlite/auth_repository_test.go index 6e8e6d9..939a9a5 100644 --- a/backend-api/internal/repository/sqlite/auth_repository_test.go +++ b/backend-api/internal/repository/sqlite/auth_repository_test.go @@ -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) } } diff --git a/backend-api/internal/repository/sqlite/candidate_decision_repository.go b/backend-api/internal/repository/sqlite/candidate_decision_repository.go index e7e05b8..650a67a 100644 --- a/backend-api/internal/repository/sqlite/candidate_decision_repository.go +++ b/backend-api/internal/repository/sqlite/candidate_decision_repository.go @@ -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 diff --git a/backend-api/internal/repository/sqlite/candidate_decision_repository_test.go b/backend-api/internal/repository/sqlite/candidate_decision_repository_test.go new file mode 100644 index 0000000..4bbc62f --- /dev/null +++ b/backend-api/internal/repository/sqlite/candidate_decision_repository_test.go @@ -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") + } +} diff --git a/backend-api/internal/repository/sqlite/execution_result_repository.go b/backend-api/internal/repository/sqlite/execution_result_repository.go index d7585c9..aafff3c 100644 --- a/backend-api/internal/repository/sqlite/execution_result_repository.go +++ b/backend-api/internal/repository/sqlite/execution_result_repository.go @@ -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 } diff --git a/backend-api/internal/transport/httpapi/admin_handlers.go b/backend-api/internal/transport/httpapi/admin_handlers.go index 3401cc6..a00438b 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers.go +++ b/backend-api/internal/transport/httpapi/admin_handlers.go @@ -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 { diff --git a/backend-api/internal/transport/httpapi/admin_handlers_test.go b/backend-api/internal/transport/httpapi/admin_handlers_test.go index 472e60d..a6fdb4c 100644 --- a/backend-api/internal/transport/httpapi/admin_handlers_test.go +++ b/backend-api/internal/transport/httpapi/admin_handlers_test.go @@ -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") diff --git a/backend-api/internal/transport/httpapi/device_handlers_test.go b/backend-api/internal/transport/httpapi/device_handlers_test.go index 9ae8eef..deae26a 100644 --- a/backend-api/internal/transport/httpapi/device_handlers_test.go +++ b/backend-api/internal/transport/httpapi/device_handlers_test.go @@ -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) { diff --git a/backend-api/internal/usecase/execution_result_service.go b/backend-api/internal/usecase/execution_result_service.go index d2425d5..6a8b4c2 100644 --- a/backend-api/internal/usecase/execution_result_service.go +++ b/backend-api/internal/usecase/execution_result_service.go @@ -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 } diff --git a/backend-api/internal/usecase/execution_result_service_test.go b/backend-api/internal/usecase/execution_result_service_test.go index 75847bf..d17c4b2 100644 --- a/backend-api/internal/usecase/execution_result_service_test.go +++ b/backend-api/internal/usecase/execution_result_service_test.go @@ -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") + } +} diff --git a/backend-api/migrations/00007_candidate_identities.sql b/backend-api/migrations/00007_candidate_identities.sql new file mode 100644 index 0000000..40bdf46 --- /dev/null +++ b/backend-api/migrations/00007_candidate_identities.sql @@ -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; diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 6a8186b..a9266a8 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -57,8 +57,8 @@ T-205 原子领取/租约状态机、T-206 Android 登录/有限离线、T-207 本地 VLM/候选/ 证据回传、T-211 参考图召回和 SKU 硬匹配、T-212 候选身份映射,以及 T-213 受控 规格组合/价格核验均已完成。T-208 的原始候选观测、模型评估、确定性推荐、逐候选 -结构化人工理由和修订历史也已完成。当前正在实现 T-214 商品持久身份和重新定位 -指纹;之后才能实现 Admin 下单授权,不得直接把候选链接或列表 ordinal 当成授权。 +结构化人工理由和修订历史也已完成。T-214 商品持久身份和重新定位指纹也已完成; +下一步实现 T-215 Admin 下单授权,不得直接把候选链接或列表 ordinal 当成授权。 手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。 T-206 增加有限离线执行;T-207 已复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。 管理后端不保存/代理 VLM,后台任务不能覆盖手机 provider 配置。 diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 2225aed..2201c4d 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -43,6 +43,7 @@ | 默认分支 | `main` | | 核实时 `main` commit | `c8a6d7f03422eb01744b01f3ee77bf7757741f7e` | | `main` 应用版本 | 1.4.2(`versionCode 7`) | +| 当前采购验证版 | 1.4.12(`versionCode 17`) | | 开发 IDE | Android Studio Hedgehog 2023.1.1 或更高 | | JDK / JVM target | 17 / 17 | | Android SDK | compileSdk 34、targetSdk 34、minSdk 26 | diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 05e8a54..0f8877c 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -267,12 +267,13 @@ T-102/T-104 在搜索结果后追加一个有界候选步骤: -> 一次全局返回并复核固定词结果页 ``` -候选卡只保留语义指纹和计数,详情/规格截图保存在 App 内部 cache。manifest v3 记录 -匿名文件名、截图 SHA-256、字节数、尺寸、有界规格语义、已选摘要和严格组合价格, -不保存商品标题或完整页面 -原文。T-104/T-213 使用这些证据时必须通过受控 evidence 边界读取,不能让 VLM adapter -自行遍历 cache。Android 10/API 29 及以下不能运行当前截图探针,应在预检时明确 -不支持,不使用媒体投影或 shell 绕过。 +候选卡只保留语义指纹和计数,详情/规格截图保存在 App 内部 cache。manifest v4 记录 +匿名文件名、卡片/详情语义 SHA-256、截图 SHA-256、字节数、尺寸、有界规格语义、 +已选摘要、严格组合价格和 best-effort 页面可见标题;不保存完整页面原文。标题最多 +160 字,动作、价格和纯数字文本不会作为标题。T-104/T-213/T-214 使用这些证据时 +必须通过受控 evidence 边界读取,不能让 VLM adapter 自行遍历 cache。Android +10/API 29 及以下不能运行当前截图探针,应在预检时明确不支持,不使用媒体投影或 +shell 绕过。 `CandidateEvidenceSource` 只接受当前 workflow 内存中的连续 ordinal 元数据,文件名 固定为 `candidate-01-detail.png`/`candidate-01-specification.png` 至第五组;每张 @@ -539,6 +540,7 @@ T-208 已在 T-207 的候选、事件、截图和最小结果回传基础上, | --- | --- | | `candidate_search_runs` | execution、需求快照 hash、搜索词、App/拼多多版本、开始/结束时间 | | `candidate_observations` | run、ordinal、可选平台商品 ID/规范化 URL、可见标题/规格/价格、采集状态和截图 asset | +| `candidate_observation_identities` | execution-scoped candidate key、原 ordinal、卡片/详情语义签名、详情/规格证据 hash 和 identity 版本 | | `model_runs` | provider/model、prompt/schema/阈值版本、耗时、token/成本和请求/结果 hash | | `candidate_evaluations` | observation/model run、decision、score/confidence、matched/missing/rejection reasons | | `candidate_recommendations` | run、推荐 observation、conclusion、确定性策略版本和简短理由 | @@ -558,6 +560,14 @@ T-208 已在 T-207 的候选、事件、截图和最小结果回传基础上, 图片 URL 只保存为长度受限、规范化的辅助观测值;后端不得盲目请求该 URL。主要证据 必须是 App 上传后由 assetstore 校验、脱敏和受鉴权访问的时间点快照。 +T-214 后,v7 以后写入的每个 observation 在同一事务内生成 +`candidate_observation_identities`。`candidate_key` 使用版本域、execution ID、原始 +ordinal、详情语义签名和规格证据 hash 生成,只标识该次 execution 的持久 observation, +不冒充拼多多全局商品 ID。App 按 `DETAIL`、`SPECIFICATION` 顺序提交两个不同的 +evidence asset ID;后端逐一核对其实际 SHA-256 后才接受候选。Admin API/Web 展示 +key、四个指纹和受控截图;后续选品授权必须引用 key 并绑定任务内容 hash 和 review +版本,不能只信 URL 或列表 ordinal。 + 人工理由使用版本化 allowlist。接受或拒绝至少有一个理由且指定主要理由; `OTHER` 才要求 4-200 字备注。拒绝推荐后改选必须同时产生一条原推荐项负标签和一条 替代项正标签;全部无匹配时每个曝光候选都有负标签。人工修正追加新 review 并引用 diff --git a/docs/api.md b/docs/api.md index 85fa190..43233ed 100644 --- a/docs/api.md +++ b/docs/api.md @@ -498,9 +498,16 @@ SKU 伪装成图片检索词: "title": "页面可见标题", "sku_text": "BLACK-L", "price": "189.00", - "product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=example", - "image_url": "https://example.invalid/short-lived-image", - "evidence_asset_ids": ["7b733922-f90f-4bc4-a9ad-3e8ec4769122"], + "product_url": "", + "image_url": "", + "card_signature": "64-char-lowercase-hex", + "detail_signature": "64-char-lowercase-hex", + "detail_evidence_sha256": "detail-asset-64-char-lowercase-hex", + "specification_evidence_sha256": "specification-asset-64-char-lowercase-hex", + "evidence_asset_ids": [ + "7b733922-f90f-4bc4-a9ad-3e8ec4769122", + "4095ea37-eb4f-47c7-989b-adf060e45a32" + ], "evaluation": { "decision": "REVIEW", "score": 0.82, @@ -539,8 +546,14 @@ SKU 伪装成图片检索词: `recommendation` 只能指向颜色和尺码均为 `MATCH`、分数和置信度均不低于 `0.75` 且没有拒绝原因的原始 ordinal;没有满足项时只省略 recommendation,不能删除原始 候选。后端在同一事务内写入 search run、observation、model evaluation 和 -recommendation,并保留旧 JSON 审计副本;不请求 `product_url` 或 `image_url`, -主要证据必须是已鉴权 asset。 +recommendation,并保留旧 JSON 审计副本。每个非空候选必须按 +`DETAIL`、`SPECIFICATION` 顺序绑定两个不同的 evidence asset ID,且两个声明哈希 +必须分别等于后端保存的实际 asset 哈希;卡片和详情语义签名也必须是小写 SHA-256。 +后端用版本化的 execution、原 ordinal、详情签名和规格证据哈希生成稳定的 +execution-scoped `candidate_key`。Admin 任务详情在 observation 的 `identity` 中 +返回该 key、四个指纹及 identity 版本;旧 v6 observation 可没有 identity。 +`product_url` 和 `image_url` 只有设备实际取得可信 URL 时才提交,不能伪造;后端不 +请求这些 URL,主要证据必须是已鉴权 asset。 ### `POST /api/v1/tasks/{task_id}/human-reviews` @@ -598,8 +611,19 @@ recommendation,并保留旧 JSON 审计副本;不请求 `product_url` 或 `i "candidate": { "ordinal": 1, "title": "页面可见标题", + "sku_text": "BLACK-L", "price": "189.00", - "evidence_asset_ids": ["7b733922-f90f-4bc4-a9ad-3e8ec4769122"] + "product_url": "", + "image_url": "", + "card_signature": "64-char-lowercase-hex", + "detail_signature": "64-char-lowercase-hex", + "detail_evidence_sha256": "detail-asset-64-char-lowercase-hex", + "specification_evidence_sha256": "specification-asset-64-char-lowercase-hex", + "evidence_asset_ids": [ + "7b733922-f90f-4bc4-a9ad-3e8ec4769122", + "4095ea37-eb4f-47c7-989b-adf060e45a32" + ], + "evaluation": null }, "order_submitted": false } diff --git a/docs/current-state.md b/docs/current-state.md index a5fb0a2..80f1d12 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -5,9 +5,9 @@ ## 当前快照 - 日期:2026-07-28 -- 阶段:T-214 候选商品持久身份与重新定位指纹进行中 +- 阶段:T-214 已完成;下一步 T-215 Admin 候选确认与下单授权 - Git:当前分支为 `main`;T-001 至 T-004、T-101 至 T-104、T-201 至 T-207、T-209、 - T-208、T-210、T-211、T-212、T-213 均已纳入 Git 历史 + T-208、T-210、T-211、T-212、T-213、T-214 均已纳入 Git 历史 - 生产代码:`android-buyer/` 已接入 Roubao Android 源码 - Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留 @@ -17,10 +17,10 @@ - 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、 Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 -- 测试:T-208 Android 单元测试、Debug/Release 构建和根 `init.ps1` 通过; - Debug APK `1.4.11 (16)` 已安装并启动于 PKG110 -- 后端测试:T-208 运行 `go test ./...`、`go test -race ./...`、migration - `up/down/up` 和带 review 数据的降级保护均通过 +- 测试:T-214 Android 单元测试、Debug/Release 构建和根 `init.ps1` 通过; + Debug APK `1.4.12 (17)` 已安装并启动于 PKG110 +- 后端测试:T-214 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`、 + migration `up/down/up` 和带 identity 数据的降级保护均通过 - 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright 以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、 脚本错误或外部请求,Android 可见交互控件均不小于 44px @@ -60,6 +60,10 @@ 人民币组合价和详情/规格双证据,并允许明确的“首件¥金额”当前单件价。区间、 券后、对照促销、冲突或缺失价格降级 `UNKNOWN`,直达订单确认页只系统返回。候选 探查不能加入购物车、提交订单或触发支付。 +- T-214 候选身份:App 回传卡片/详情语义签名和详情/规格双证据 hash;后端逐一核对 + 已上传 asset 后,在 v7 表中生成 execution-scoped `candidate_key`。Admin API/Web + 展示 key、指纹、原始 observation、模型/人工结论和截图;缺少真实平台 URL 时保持 + 空值,后端不请求第三方 URL。 - Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、 用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步 - TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture @@ -73,7 +77,7 @@ - 离线执行:默认 30 分钟有限授权和 30 秒 best-effort heartbeat 已实现;PKG110 真机断开临时后端 95 秒后保持同一 execution,重连后滑动续期;到期持久安全停止, RUNNING 不自动重新分配 -- 测试设备:OnePlus PKG110,Android 16/API 36;肉包 `1.4.11 (16)`;拼多多 +- 测试设备:OnePlus PKG110,Android 16/API 36;肉包 `1.4.12 (17)`;拼多多 `8.17.0 (81700)` - 设备就绪:拼多多首页、文字/图片搜索、双列候选卡、详情截图和返回均已通过 8.17.0 真机验证;采购员已在 ColorOS 设置中手动启用肉包采购无障碍,APK 覆盖安装后授权 @@ -88,7 +92,7 @@ 已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/` - 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1` - 标准验证路径:`.\init.ps1` -- 当前 blocker:T-208 无阻塞。真实 VLM 服务地址、模型、设备级测试凭证、成本上限 +- 当前 blocker:T-214 无阻塞。真实 VLM 服务地址、模型、设备级测试凭证、成本上限 和数据留存尚未确认;当前只支持单 SKU/JPEG;候选探针截图要求 Android 11/API 30+ ## 当前目录 @@ -118,7 +122,7 @@ | `docs/tasks/T-212.md` | DONE | 修复重排候选、推荐、证据与人工接受的身份映射 | | `docs/tasks/T-213.md` | DONE | 真机选择目标 SKU、读取组合价并安全返回 | | `docs/tasks/T-208.md` | DONE | 归一化候选决策数据并增加结构化人工 review | -| `docs/tasks/T-214.md` | DOING | 建立 execution-scoped candidate key 与设备采集指纹 | +| `docs/tasks/T-214.md` | DONE | 建立 execution-scoped candidate key 与设备采集指纹 | | `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | @@ -129,9 +133,9 @@ ## 任务摘要 -- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-213。 -- 正在进行:T-214 商品持久身份与重新定位指纹。 -- 下一步:依次实现 Admin 下单授权、设备命令、订单 dry-run、单次提交对账和 +- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-214。 +- 正在进行:无。 +- 下一步:依次实现 T-215 Admin 下单授权、设备命令、订单 dry-run、单次提交对账和 付款提醒。 ## 当前可运行内容 diff --git a/docs/tasks/T-214.md b/docs/tasks/T-214.md index a5f397c..54c8a62 100644 --- a/docs/tasks/T-214.md +++ b/docs/tasks/T-214.md @@ -5,13 +5,14 @@ phase: 2 deps: - T-208 - T-213 -status: DOING +status: DONE created: 2026-07-28 context_ref: e7f4c3e work_branch: null write_paths: - docs/tasks/T-214.md - docs/00-ai-start-here.md + - docs/03-tech-stack.md - docs/04-architecture.md - docs/06-tasks.md - docs/api.md @@ -71,13 +72,13 @@ T-208 已能保存原始候选 observation 和人工结论,但 Admin 与后续 ## 验收要点 -- [ ] v7 migration `up/down/up` 可重复;有 identity 数据时破坏性 down 失败。 -- [ ] App 回传全部 observation 的卡片/详情/双证据 SHA-256,不因模型拒绝而缺失。 -- [ ] 后端拒绝格式错误、跨 execution、与 evidence 实际哈希不一致的指纹。 -- [ ] 同一请求重放 candidate key 稳定,不同 observation key 不同。 -- [ ] Admin API/Web 可按 candidate key 查看 observation、模型判断、人工结论和截图。 -- [ ] 后端不会请求候选 URL;没有真实 URL 时保持空值而不是生成伪链接。 -- [ ] Android/Go 测试、race、migration、Debug/Release 和根验证通过。 +- [x] v7 migration `up/down/up` 可重复;有 identity 数据时破坏性 down 失败。 +- [x] App 回传全部 observation 的卡片/详情/双证据 SHA-256,不因模型拒绝而缺失。 +- [x] 后端拒绝格式错误、跨 execution、与 evidence 实际哈希不一致的指纹。 +- [x] 同一请求重放 candidate key 稳定,不同 observation key 不同。 +- [x] Admin API/Web 可按 candidate key 查看 observation、模型判断、人工结论和截图。 +- [x] 后端不会请求候选 URL;没有真实 URL 时保持空值而不是生成伪链接。 +- [x] Android/Go 测试、race、migration、Debug/Release 和根验证通过。 ## 边界 @@ -91,3 +92,12 @@ T-208 已能保存原始候选 observation 和人工结论,但 Admin 与后续 - 2026-07-28:T-208 实现提交 `e7f4c3e` 后领取。确认当前 observation 已有可选 product/image URL 和受控 evidence,但 App 实际无法从拼多多无障碍树取得可信 URL, 因此冻结 execution-scoped candidate key 与多指纹方案。 +- 2026-07-28:App manifest 升级到 v4,保存 best-effort 可见标题、卡片/详情语义 + SHA-256 和详情/规格双截图哈希;候选出站固定提交两个不同的 evidence asset。 +- 2026-07-28:后端 migration v7 增加 `candidate_observation_identities`,候选写入 + 同事务生成稳定 key,并按顺序核对两个 evidence 的实际哈希。Admin API/Web 已能 + 查看 identity、原始 observation、模型/人工结论和受控截图。 +- 2026-07-28:`go test ./...`、`go test -race ./...`、`go vet ./...`,migration + CLI `up/down/up`,Android `test assembleDebug assembleRelease` 和根 + `.\init.ps1` 全部通过。Debug APK `1.4.12 (17)` 已覆盖安装到 PKG110,采购无障碍 + 仍在启用列表。