feat(t214): persist candidate observation identities
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
+3
-1
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -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)
|
||||
|
||||
+11
@@ -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,
|
||||
|
||||
+30
-2
@@ -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>): 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))
|
||||
|
||||
+4
@@ -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<String>,
|
||||
val evaluation: ExecutionCandidateEvaluation? = null
|
||||
)
|
||||
|
||||
+19
@@ -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",
|
||||
|
||||
+4
-3
@@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+35
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 配置。
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+16
-6
@@ -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 并引用
|
||||
|
||||
+30
-6
@@ -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
|
||||
}
|
||||
|
||||
+16
-12
@@ -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、单次提交对账和
|
||||
付款提醒。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
+18
-8
@@ -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,采购无障碍
|
||||
仍在启用列表。
|
||||
|
||||
Reference in New Issue
Block a user