feat(t214): persist candidate observation identities

This commit is contained in:
QiuSW
2026-07-28 12:21:55 +08:00
parent acd3c85b5b
commit 56e7a2be33
29 changed files with 622 additions and 94 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.roubao.autopilot" applicationId = "com.roubao.autopilot"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 16 versionCode = 17
versionName = "1.4.11" versionName = "1.4.12"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@@ -1045,9 +1045,8 @@ class MainActivity : ComponentActivity() {
) )
ExecutionCandidateDraft( ExecutionCandidateDraft(
ordinal = assessment.ordinal, ordinal = assessment.ordinal,
title = title = candidateEvidence.observedTitle
"拼多多图片候选 " + ?: "拼多多图片候选 ${assessment.ordinal}",
assessment.ordinal,
skuText = skuText =
candidateEvidence.specification candidateEvidence.specification
.selectedSummary .selectedSummary
@@ -1059,6 +1058,14 @@ class MainActivity : ComponentActivity() {
}, },
price = candidateEvidence.specification price = candidateEvidence.specification
.price?.rawText.orEmpty(), .price?.rawText.orEmpty(),
cardSignature =
candidateEvidence.cardSignature,
detailSignature =
candidateEvidence.detailSignature,
detailEvidenceSha256 =
candidateEvidence.detailSha256,
specificationEvidenceSha256 =
candidateEvidence.specificationSha256,
evidenceLocalIDs = emptyList(), evidenceLocalIDs = emptyList(),
evaluation = ExecutionCandidateEvaluation( evaluation = ExecutionCandidateEvaluation(
decision = assessment.decision.name, decision = assessment.decision.name,
@@ -1222,11 +1229,17 @@ class MainActivity : ComponentActivity() {
candidates = validated.map { candidate -> candidates = validated.map { candidate ->
ExecutionCandidateDraft( ExecutionCandidateDraft(
ordinal = candidate.ordinal, ordinal = candidate.ordinal,
title = "$taskTitle 候选 ${candidate.ordinal}", title = candidate.observedTitle
?: "$taskTitle 候选 ${candidate.ordinal}",
skuText = candidate.specification skuText = candidate.specification
.selectedSummary.orEmpty(), .selectedSummary.orEmpty(),
price = candidate.specification price = candidate.specification
.price?.rawText.orEmpty(), .price?.rawText.orEmpty(),
cardSignature = candidate.cardSignature,
detailSignature = candidate.detailSignature,
detailEvidenceSha256 = candidate.detailSha256,
specificationEvidenceSha256 =
candidate.specificationSha256,
evidenceLocalIDs = emptyList() evidenceLocalIDs = emptyList()
) )
} }
@@ -14,6 +14,7 @@ import androidx.annotation.RequiresApi
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateCard import com.roubao.autopilot.pinduoduo.PinduoduoCandidateCard
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateDetailEvidence import com.roubao.autopilot.pinduoduo.PinduoduoCandidateDetailEvidence
import com.roubao.autopilot.pinduoduo.PinduoduoEvidenceHash import com.roubao.autopilot.pinduoduo.PinduoduoEvidenceHash
import com.roubao.autopilot.pinduoduo.PinduoduoObservedTitlePolicy
import com.roubao.autopilot.readiness.DeviceObservationStore import com.roubao.autopilot.readiness.DeviceObservationStore
import com.roubao.autopilot.readiness.LoginBlockerDetector import com.roubao.autopilot.readiness.LoginBlockerDetector
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
@@ -320,7 +321,8 @@ class BuyerAccessibilityService : AccessibilityService() {
signature = PinduoduoEvidenceHash.sha256( signature = PinduoduoEvidenceHash.sha256(
semanticTexts.joinToString(TEXT_SIGNATURE_SEPARATOR) semanticTexts.joinToString(TEXT_SIGNATURE_SEPARATOR)
), ),
semanticTextCount = semanticTexts.size semanticTextCount = semanticTexts.size,
observedTitle = PinduoduoObservedTitlePolicy.select(semanticTexts)
) )
} }
@@ -134,7 +134,8 @@ private class CandidateEvidenceStore(context: Context) {
detailSemanticTextCount = detail.detail.semanticTextCount, detailSemanticTextCount = detail.detail.semanticTextCount,
detailAsset = detailAsset, detailAsset = detailAsset,
specification = specifications.evidence, specification = specifications.evidence,
specificationAsset = specificationAsset specificationAsset = specificationAsset,
observedTitle = detail.detail.observedTitle
) )
evidenceByOrdinal[ordinal] = evidence evidenceByOrdinal[ordinal] = evidence
writeManifest() writeManifest()
@@ -172,6 +173,7 @@ private class CandidateEvidenceStore(context: Context) {
evidence.cardSemanticTextCount evidence.cardSemanticTextCount
) )
.put("detail_signature", evidence.detailSignature) .put("detail_signature", evidence.detailSignature)
.put("observed_title", evidence.observedTitle)
.put( .put(
"detail_semantic_text_count", "detail_semantic_text_count",
evidence.detailSemanticTextCount evidence.detailSemanticTextCount
@@ -208,7 +210,7 @@ private class CandidateEvidenceStore(context: Context) {
) )
} }
val manifest = JSONObject() val manifest = JSONObject()
.put("schema_version", 3) .put("schema_version", 4)
.put("candidate_count", evidenceByOrdinal.size) .put("candidate_count", evidenceByOrdinal.size)
.put("candidates", candidates) .put("candidates", candidates)
.toString(2) .toString(2)
@@ -9,6 +9,9 @@ import kotlinx.coroutines.withContext
data class ValidatedCandidateEvidence( data class ValidatedCandidateEvidence(
val ordinal: Int, val ordinal: Int,
val cardSignature: String,
val detailSignature: String,
val observedTitle: String?,
val detailPngBytes: ByteArray, val detailPngBytes: ByteArray,
val detailSha256: String, val detailSha256: String,
val specification: PinduoduoSpecificationEvidence, val specification: PinduoduoSpecificationEvidence,
@@ -46,6 +49,11 @@ class CandidateEvidenceSource(
?.length ?.length
?.let { it in 1..160 } != false ?.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( require(
metadata.specification.price?.let { price -> metadata.specification.price?.let { price ->
price.rawText.length in 1..32 && price.rawText.length in 1..32 &&
@@ -69,6 +77,9 @@ class CandidateEvidenceSource(
require(totalBytes <= MAX_TOTAL_PNG_BYTES) require(totalBytes <= MAX_TOTAL_PNG_BYTES)
ValidatedCandidateEvidence( ValidatedCandidateEvidence(
ordinal = metadata.ordinal, ordinal = metadata.ordinal,
cardSignature = metadata.cardSignature,
detailSignature = metadata.detailSignature,
observedTitle = metadata.observedTitle,
detailPngBytes = detail, detailPngBytes = detail,
detailSha256 = metadata.detailAsset.sha256, detailSha256 = metadata.detailAsset.sha256,
specification = metadata.specification, specification = metadata.specification,
@@ -14,7 +14,8 @@ data class PinduoduoCandidateCard(
data class PinduoduoCandidateDetailEvidence( data class PinduoduoCandidateDetailEvidence(
val signature: String, val signature: String,
val semanticTextCount: Int val semanticTextCount: Int,
val observedTitle: String? = null
) )
data class PinduoduoScreenshotCapture( data class PinduoduoScreenshotCapture(
@@ -39,9 +40,36 @@ data class PinduoduoCandidateEvidence(
val detailSemanticTextCount: Int, val detailSemanticTextCount: Int,
val detailAsset: PinduoduoEvidenceAsset, val detailAsset: PinduoduoEvidenceAsset,
val specification: PinduoduoSpecificationEvidence, 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 { object PinduoduoEvidenceHash {
fun sha256(value: String): String = fun sha256(value: String): String =
sha256(value.toByteArray(Charsets.UTF_8)) sha256(value.toByteArray(Charsets.UTF_8))
@@ -43,6 +43,10 @@ data class ExecutionCandidateDraft(
val price: String = "", val price: String = "",
val productUrl: String = "", val productUrl: String = "",
val imageUrl: String = "", val imageUrl: String = "",
val cardSignature: String = "",
val detailSignature: String = "",
val detailEvidenceSha256: String = "",
val specificationEvidenceSha256: String = "",
val evidenceLocalIDs: List<String>, val evidenceLocalIDs: List<String>,
val evaluation: ExecutionCandidateEvaluation? = null val evaluation: ExecutionCandidateEvaluation? = null
) )
@@ -404,6 +404,14 @@ class ProcurementRepository(
require(batch.candidates.map { it.ordinal } == (1..batch.candidates.size).toList()) { 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) { "执行模式不能在回传时变更" } require(batch.mode == execution.provenance?.mode) { "执行模式不能在回传时变更" }
if (batch.mode == ExecutionMode.AI_ASSISTED) { if (batch.mode == ExecutionMode.AI_ASSISTED) {
require(batch.provenance != null && batch.provenance.providerId != null) { require(batch.provenance != null && batch.provenance.providerId != null) {
@@ -883,6 +891,16 @@ class ProcurementRepository(
.put("price", candidate.price) .put("price", candidate.price)
.put("product_url", candidate.productUrl) .put("product_url", candidate.productUrl)
.put("image_url", candidate.imageUrl) .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)) .put("evidence_asset_ids", JSONArray(candidate.evidenceLocalIDs))
.also { json -> .also { json ->
candidate.evaluation?.let { evaluation -> candidate.evaluation?.let { evaluation ->
@@ -1054,6 +1072,7 @@ class ProcurementRepository(
private const val MAX_REFERENCE_DIMENSION = 4_096 private const val MAX_REFERENCE_DIMENSION = 4_096
private const val MAX_REFERENCE_PIXELS = 20_000_000L private const val MAX_REFERENCE_PIXELS = 20_000_000L
private const val MAX_OUTBOX_EVIDENCE_BYTES = 8L * 1024L * 1024L private const val MAX_OUTBOX_EVIDENCE_BYTES = 8L * 1024L * 1024L
private val SHA256_PATTERN = Regex("^[0-9a-f]{64}$")
private val COMPLETE_OUTCOMES = setOf( private val COMPLETE_OUTCOMES = setOf(
"CANDIDATE_ACCEPTED", "CANDIDATE_ACCEPTED",
"CANDIDATE_REJECTED", "CANDIDATE_REJECTED",
@@ -150,13 +150,14 @@ class CandidateEvidenceSourceTest {
) )
return PinduoduoCandidateEvidence( return PinduoduoCandidateEvidence(
ordinal = ordinal, ordinal = ordinal,
cardSignature = "card-$ordinal", cardSignature = PinduoduoEvidenceHash.sha256("card-$ordinal"),
cardSemanticTextCount = 3, cardSemanticTextCount = 3,
detailSignature = "detail-$ordinal", detailSignature = PinduoduoEvidenceHash.sha256("detail-$ordinal"),
detailSemanticTextCount = 4, detailSemanticTextCount = 4,
detailAsset = asset, detailAsset = asset,
specification = specification(), specification = specification(),
specificationAsset = asset.copy(fileName = specificationFileName) specificationAsset = asset.copy(fileName = specificationFileName),
observedTitle = "候选商品标题 $ordinal"
) )
} }
@@ -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)
}
}
+13
View File
@@ -168,6 +168,19 @@ type CandidateObservation struct {
EvidenceAssetIDs []string EvidenceAssetIDs []string
CollectionStatus string CollectionStatus string
ObservedAt time.Time 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 { type CandidateModelRun struct {
@@ -34,8 +34,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
if applied, err := runner.Up(ctx); err != nil { if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("initial Up() error = %v", err) t.Fatalf("initial Up() error = %v", err)
} else if applied != 6 { } else if applied != 7 {
t.Fatalf("initial Up() applied = %d, want 6", applied) 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 { if err := runner.Down(ctx); err != nil {
t.Fatalf("initial Down(v6) error = %v", err) t.Fatalf("initial Down(v6) error = %v", err)
@@ -47,9 +50,14 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
seedClaimsHistoricalFixture(t, db) seedClaimsHistoricalFixture(t, db)
if applied, err := runner.Up(ctx); err != nil { if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("Up(v5-v6) over historical data error = %v", err) t.Fatalf("Up(v5-v7) over historical data error = %v", err)
} else if applied != 2 { } else if applied != 3 {
t.Fatalf("Up(v5-v6) applied = %d, want 2", applied) 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) assertClaimsHistory(t, db, true)
@@ -69,9 +77,9 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
assertClaimsHistory(t, db, false) assertClaimsHistory(t, db, false)
if applied, err := runner.Up(ctx); err != nil { if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("final Up(v4-v6) error = %v", err) t.Fatalf("final Up(v4-v7) error = %v", err)
} else if applied != 3 { } else if applied != 4 {
t.Fatalf("final Up(v4-v6) applied = %d, want 3", applied) t.Fatalf("final Up(v4-v7) applied = %d, want 4", applied)
} }
assertClaimsHistory(t, db, true) assertClaimsHistory(t, db, true)
} }
@@ -307,6 +315,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
t.Fatalf("insert v4 audit event: %v", err) 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 { if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v6) error = %v", err) t.Fatalf("Down(v6) error = %v", err)
} }
@@ -27,8 +27,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Up() error = %v", err) t.Fatalf("Up() error = %v", err)
} }
if applied != 6 { if applied != 7 {
t.Fatalf("Up() applied = %d, want 6", applied) t.Fatalf("Up() applied = %d, want 7", applied)
} }
assertStatuses(t, runner, map[int64]bool{ assertStatuses(t, runner, map[int64]bool{
1: true, 1: true,
@@ -37,6 +37,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
4: true, 4: true,
5: true, 5: true,
6: true, 6: true,
7: true,
}) })
applied, err = runner.Up(context.Background()) applied, err = runner.Up(context.Background())
@@ -56,7 +57,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
3: true, 3: true,
4: true, 4: true,
5: true, 5: true,
6: false, 6: true,
7: false,
}) })
applied, err = runner.Up(context.Background()) applied, err = runner.Up(context.Background())
@@ -73,6 +75,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
4: true, 4: true,
5: true, 5: true,
6: true, 6: true,
7: true,
}) })
} }
@@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
if err != nil { if err != nil {
t.Fatalf("migration.New() error = %v", err) 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 { if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down(v6) error = %v", err) t.Fatalf("Down(v6) error = %v", err)
} }
@@ -408,9 +411,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
t.Fatal("purchase_tasks was lost during auth migration rollback") t.Fatal("purchase_tasks was lost during auth migration rollback")
} }
if applied, err := runner.Up(context.Background()); err != nil { if applied, err := runner.Up(context.Background()); err != nil {
t.Fatalf("Up(v3-v6) error = %v", err) t.Fatalf("Up(v3-v7) error = %v", err)
} else if applied != 4 { } else if applied != 5 {
t.Fatalf("Up(v3-v6) applied = %d, want 4", applied) t.Fatalf("Up(v3-v7) applied = %d, want 5", applied)
} }
} }
@@ -7,6 +7,7 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"strconv"
"cmroubao/backend-api/internal/domain" "cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/usecase" "cmroubao/backend-api/internal/usecase"
@@ -93,6 +94,25 @@ func storeCandidateDecisionDataset(
if err != nil { if err != nil {
return repositoryFailure(err) 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 { if batch.ProvenanceJSON != nil {
var provenance usecase.ExecutionProvenance var provenance usecase.ExecutionProvenance
@@ -567,12 +587,21 @@ func getCandidateDecisionDataset(
} }
rows, err := queryer.QueryContext( rows, err := queryer.QueryContext(
ctx, ctx,
`SELECT task_id, execution_id, ordinal, title, sku_text, price_text, `SELECT observation.task_id, observation.execution_id,
product_url, image_url, evidence_asset_ids_json, observation.ordinal, observation.title, observation.sku_text,
collection_status, observed_at observation.price_text, observation.product_url,
FROM candidate_observations observation.image_url, observation.evidence_asset_ids_json,
WHERE task_id = ? AND execution_id = ? observation.collection_status, observation.observed_at,
ORDER BY ordinal ASC`, 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, taskID,
executionID, executionID,
) )
@@ -582,6 +611,9 @@ func getCandidateDecisionDataset(
for rows.Next() { for rows.Next() {
var observation domain.CandidateObservation var observation domain.CandidateObservation
var evidenceJSON, observedAt string var evidenceJSON, observedAt string
var candidateKey, cardSignature, detailSignature sql.NullString
var detailHash, specificationHash, identityAt sql.NullString
var identityVersion sql.NullInt64
if err := rows.Scan( if err := rows.Scan(
&observation.TaskID, &observation.TaskID,
&observation.ExecutionID, &observation.ExecutionID,
@@ -594,6 +626,13 @@ func getCandidateDecisionDataset(
&evidenceJSON, &evidenceJSON,
&observation.CollectionStatus, &observation.CollectionStatus,
&observedAt, &observedAt,
&candidateKey,
&cardSignature,
&detailSignature,
&detailHash,
&specificationHash,
&identityVersion,
&identityAt,
); err != nil { ); err != nil {
_ = rows.Close() _ = rows.Close()
return nil, repositoryFailure(err) return nil, repositoryFailure(err)
@@ -610,6 +649,24 @@ func getCandidateDecisionDataset(
_ = rows.Close() _ = rows.Close()
return nil, repositoryFailure(err) 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) dataset.Observations = append(dataset.Observations, observation)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@@ -823,6 +880,23 @@ func hashCandidateResult(candidates string, recommendation *string) string {
return hex.EncodeToString(digest.Sum(nil)) 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 { func nullableSQLString(value sql.NullString) any {
if !value.Valid { if !value.Valid {
return nil 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, write usecase.ExecutionResultWrite,
candidatesJSON string, candidatesJSON string,
) error { ) error {
var candidates []struct { var candidates []usecase.ExecutionCandidate
EvidenceAssetIDs []string `json:"evidence_asset_ids"`
}
if err := json.Unmarshal([]byte(candidatesJSON), &candidates); err != nil { if err := json.Unmarshal([]byte(candidatesJSON), &candidates); err != nil {
return usecase.ErrRepositoryInvariant return usecase.ErrRepositoryInvariant
} }
@@ -569,6 +567,30 @@ func validateCandidateEvidence(
); err != nil { ); err != nil {
return err 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 return nil
} }
@@ -449,7 +449,7 @@ func candidateDecisionDatasetResponse(
) gin.H { ) gin.H {
observations := make([]gin.H, 0, len(dataset.Observations)) observations := make([]gin.H, 0, len(dataset.Observations))
for _, observation := range dataset.Observations { for _, observation := range dataset.Observations {
observations = append(observations, gin.H{ item := gin.H{
"ordinal": observation.Ordinal, "ordinal": observation.Ordinal,
"title": observation.Title, "title": observation.Title,
"sku_text": observation.SKUText, "sku_text": observation.SKUText,
@@ -459,7 +459,19 @@ func candidateDecisionDatasetResponse(
"evidence_asset_ids": observation.EvidenceAssetIDs, "evidence_asset_ids": observation.EvidenceAssetIDs,
"collection_status": observation.CollectionStatus, "collection_status": observation.CollectionStatus,
"observed_at": formatTime(observation.ObservedAt), "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)) evaluations := make([]gin.H, 0, len(dataset.Evaluations))
for _, evaluation := range dataset.Evaluations { for _, evaluation := range dataset.Evaluations {
@@ -17,6 +17,7 @@ import (
"testing" "testing"
"time" "time"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/platform/assetstore" "cmroubao/backend-api/internal/platform/assetstore"
"cmroubao/backend-api/internal/platform/database" "cmroubao/backend-api/internal/platform/database"
"cmroubao/backend-api/internal/platform/migration" "cmroubao/backend-api/internal/platform/migration"
@@ -26,6 +27,37 @@ import (
"github.com/gin-gonic/gin" "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) { func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) {
router := newAdminIntegrationRouter(t) router := newAdminIntegrationRouter(t)
imageBody, imageContentType := referenceUpload(t, "asset-key-1") imageBody, imageContentType := referenceUpload(t, "asset-key-1")
@@ -522,7 +522,7 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
}) })
requireDeviceStatus(t, events, http.StatusOK) requireDeviceStatus(t, events, http.StatusOK)
evidence := performDeviceRequest(t, fixture.router, deviceRequest{ detailEvidence := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost, method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/evidence", target: "/api/v1/tasks/" + taskID + "/evidence",
contentType: "image/jpeg", contentType: "image/jpeg",
@@ -533,15 +533,42 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
executionID: started.Execution.ID, executionID: started.Execution.ID,
claimGeneration: started.Task.ClaimGeneration, claimGeneration: started.Task.ClaimGeneration,
}) })
requireDeviceStatus(t, evidence, http.StatusCreated) requireDeviceStatus(t, detailEvidence, http.StatusCreated)
var evidenceResponse struct { var evidenceResponse struct {
Evidence struct { Evidence struct {
ID string `json:"id"` ID string `json:"id"`
SHA256 string `json:"sha256"`
} `json:"evidence"` } `json:"evidence"`
} }
decodeResponse(t, evidence, &evidenceResponse) decodeResponse(t, detailEvidence, &evidenceResponse)
if evidenceResponse.Evidence.ID == "" { if evidenceResponse.Evidence.ID == "" || evidenceResponse.Evidence.SHA256 == "" {
t.Fatalf("evidence response = %s", evidence.Body.String()) 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) 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) t.Fatalf("get task for content hash: %v", err)
} }
taskHash := usecase.TaskContentSHA256(detail.Task) taskHash := usecase.TaskContentSHA256(detail.Task)
cardSignature := strings.Repeat("b", 64)
detailSignature := strings.Repeat("c", 64)
candidatePayload := fmt.Sprintf( 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.Execution.ID,
started.Task.ClaimGeneration, started.Task.ClaimGeneration,
taskHash, taskHash,
cardSignature,
detailSignature,
evidenceResponse.Evidence.SHA256,
specificationEvidenceResponse.Evidence.SHA256,
evidenceResponse.Evidence.ID, 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{ candidates := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost, method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/candidates", target: "/api/v1/tasks/" + taskID + "/candidates",
@@ -566,6 +616,19 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
idempotencyKey: "result-candidates-1", idempotencyKey: "result-candidates-1",
}) })
requireDeviceStatus(t, candidates, http.StatusOK) 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( 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":""}]}`, `{"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) t.Fatalf("migration.New() after review error = %v", err)
} }
if err := runner.Down(context.Background()); err == nil { 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( 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.Execution.ID,
started.Task.ClaimGeneration, started.Task.ClaimGeneration,
taskHash, taskHash,
cardSignature,
detailSignature,
evidenceResponse.Evidence.SHA256,
specificationEvidenceResponse.Evidence.SHA256,
evidenceResponse.Evidence.ID, evidenceResponse.Evidence.ID,
specificationEvidenceResponse.Evidence.ID,
) )
complete := performDeviceRequest(t, fixture.router, deviceRequest{ complete := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost, method: http.MethodPost,
@@ -677,7 +745,7 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
detail.Report.Outcome == nil || detail.Report.Outcome == nil ||
detail.Report.Outcome.OrderSubmitted || detail.Report.Outcome.OrderSubmitted ||
len(detail.Report.Events) != 1 || len(detail.Report.Events) != 1 ||
len(detail.Report.EvidenceAssets) != 1 || len(detail.Report.EvidenceAssets) != 2 ||
detail.Report.CandidateBatch == nil || detail.Report.CandidateBatch == nil ||
detail.Report.DecisionDataset == nil || detail.Report.DecisionDataset == nil ||
len(detail.Report.DecisionDataset.Observations) != 1 || len(detail.Report.DecisionDataset.Observations) != 1 ||
@@ -686,6 +754,16 @@ func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
detail.Report.DecisionDataset.HumanReviews[1].Version != 2 { detail.Report.DecisionDataset.HumanReviews[1].Version != 2 {
t.Fatalf("execution report = %+v", detail.Report) 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) { func TestDeviceReleaseReturnsClaimedTaskToPending(t *testing.T) {
@@ -90,14 +90,18 @@ type CandidateHardConstraintEvaluation struct {
} }
type ExecutionCandidate struct { type ExecutionCandidate struct {
Ordinal int `json:"ordinal"` Ordinal int `json:"ordinal"`
Title string `json:"title"` Title string `json:"title"`
SKUText string `json:"sku_text"` SKUText string `json:"sku_text"`
Price string `json:"price"` Price string `json:"price"`
ProductURL string `json:"product_url"` ProductURL string `json:"product_url"`
ImageURL string `json:"image_url"` ImageURL string `json:"image_url"`
EvidenceAssetIDs []string `json:"evidence_asset_ids"` CardSignature string `json:"card_signature"`
Evaluation *CandidateEvaluation `json:"evaluation"` 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 { type CandidateRecommendation struct {
@@ -603,7 +607,11 @@ func validCandidate(candidate ExecutionCandidate, mode string) bool {
!validOptionalAuditText(candidate.Price, 64) || !validOptionalAuditText(candidate.Price, 64) ||
!validObservationURL(candidate.ProductURL) || !validObservationURL(candidate.ProductURL) ||
!validObservationURL(candidate.ImageURL) || !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 return false
} }
for _, id := range candidate.EvidenceAssetIDs { for _, id := range candidate.EvidenceAssetIDs {
@@ -611,6 +619,9 @@ func validCandidate(candidate ExecutionCandidate, mode string) bool {
return false return false
} }
} }
if candidate.EvidenceAssetIDs[0] == candidate.EvidenceAssetIDs[1] {
return false
}
if mode == manualFirstMode { if mode == manualFirstMode {
return candidate.Evaluation == nil return candidate.Evaluation == nil
} }
@@ -98,8 +98,16 @@ func validAIExecutionCandidateCommand() StoreExecutionCandidatesCommand {
}, },
Candidates: []ExecutionCandidate{ Candidates: []ExecutionCandidate{
{ {
Ordinal: 1, Ordinal: 1,
Title: "拼多多图片候选 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{ Evaluation: &CandidateEvaluation{
Decision: "REVIEW", Decision: "REVIEW",
Score: 0.9, 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;
+2 -2
View File
@@ -57,8 +57,8 @@
T-205 原子领取/租约状态机、T-206 Android 登录/有限离线、T-207 本地 VLM/候选/ T-205 原子领取/租约状态机、T-206 Android 登录/有限离线、T-207 本地 VLM/候选/
证据回传、T-211 参考图召回和 SKU 硬匹配、T-212 候选身份映射,以及 T-213 受控 证据回传、T-211 参考图召回和 SKU 硬匹配、T-212 候选身份映射,以及 T-213 受控
规格组合/价格核验均已完成。T-208 的原始候选观测、模型评估、确定性推荐、逐候选 规格组合/价格核验均已完成。T-208 的原始候选观测、模型评估、确定性推荐、逐候选
结构化人工理由和修订历史也已完成。当前正在实现 T-214 商品持久身份和重新定位 结构化人工理由和修订历史也已完成。T-214 商品持久身份和重新定位指纹也已完成;
指纹;之后才能实现 Admin 下单授权,不得直接把候选链接或列表 ordinal 当成授权。 下一步实现 T-215 Admin 下单授权,不得直接把候选链接或列表 ordinal 当成授权。
手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。 手机从管理后端领取任务并回传结果,VLM、拼多多自动化和人工确认在 App 本地完成。
T-206 增加有限离线执行;T-207 已复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。 T-206 增加有限离线执行;T-207 已复用 Roubao 端上 OpenAI 兼容适配器并加密本地 Key。
管理后端不保存/代理 VLM,后台任务不能覆盖手机 provider 配置。 管理后端不保存/代理 VLM,后台任务不能覆盖手机 provider 配置。
+1
View File
@@ -43,6 +43,7 @@
| 默认分支 | `main` | | 默认分支 | `main` |
| 核实时 `main` commit | `c8a6d7f03422eb01744b01f3ee77bf7757741f7e` | | 核实时 `main` commit | `c8a6d7f03422eb01744b01f3ee77bf7757741f7e` |
| `main` 应用版本 | 1.4.2(`versionCode 7`) | | `main` 应用版本 | 1.4.2(`versionCode 7`) |
| 当前采购验证版 | 1.4.12(`versionCode 17`) |
| 开发 IDE | Android Studio Hedgehog 2023.1.1 或更高 | | 开发 IDE | Android Studio Hedgehog 2023.1.1 或更高 |
| JDK / JVM target | 17 / 17 | | JDK / JVM target | 17 / 17 |
| Android SDK | compileSdk 34、targetSdk 34、minSdk 26 | | Android SDK | compileSdk 34、targetSdk 34、minSdk 26 |
+16 -6
View File
@@ -267,12 +267,13 @@ T-102/T-104 在搜索结果后追加一个有界候选步骤:
-> 一次全局返回并复核固定词结果页 -> 一次全局返回并复核固定词结果页
``` ```
候选卡只保留语义指纹和计数,详情/规格截图保存在 App 内部 cache。manifest v3 记录 候选卡只保留语义指纹和计数,详情/规格截图保存在 App 内部 cache。manifest v4 记录
匿名文件名、截图 SHA-256、字节数、尺寸、有界规格语义、已选摘要和严格组合价格, 匿名文件名、卡片/详情语义 SHA-256、截图 SHA-256、字节数、尺寸、有界规格语义、
不保存商品标题或完整页面 已选摘要、严格组合价格和 best-effort 页面可见标题;不保存完整页面原文。标题最多
原文。T-104/T-213 使用这些证据时必须通过受控 evidence 边界读取,不能让 VLM adapter 160 字,动作、价格和纯数字文本不会作为标题。T-104/T-213/T-214 使用这些证据时
自行遍历 cache。Android 10/API 29 及以下不能运行当前截图探针,应在预检时明确 必须通过受控 evidence 边界读取,不能让 VLM adapter 自行遍历 cache。Android
不支持,不使用媒体投影或 shell 绕过。 10/API 29 及以下不能运行当前截图探针,应在预检时明确不支持,不使用媒体投影或
shell 绕过。
`CandidateEvidenceSource` 只接受当前 workflow 内存中的连续 ordinal 元数据,文件名 `CandidateEvidenceSource` 只接受当前 workflow 内存中的连续 ordinal 元数据,文件名
固定为 `candidate-01-detail.png`/`candidate-01-specification.png` 至第五组;每张 固定为 `candidate-01-detail.png`/`candidate-01-specification.png` 至第五组;每张
@@ -539,6 +540,7 @@ T-208 已在 T-207 的候选、事件、截图和最小结果回传基础上,
| --- | --- | | --- | --- |
| `candidate_search_runs` | execution、需求快照 hash、搜索词、App/拼多多版本、开始/结束时间 | | `candidate_search_runs` | execution、需求快照 hash、搜索词、App/拼多多版本、开始/结束时间 |
| `candidate_observations` | run、ordinal、可选平台商品 ID/规范化 URL、可见标题/规格/价格、采集状态和截图 asset | | `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 | | `model_runs` | provider/model、prompt/schema/阈值版本、耗时、token/成本和请求/结果 hash |
| `candidate_evaluations` | observation/model run、decision、score/confidence、matched/missing/rejection reasons | | `candidate_evaluations` | observation/model run、decision、score/confidence、matched/missing/rejection reasons |
| `candidate_recommendations` | run、推荐 observation、conclusion、确定性策略版本和简短理由 | | `candidate_recommendations` | run、推荐 observation、conclusion、确定性策略版本和简短理由 |
@@ -558,6 +560,14 @@ T-208 已在 T-207 的候选、事件、截图和最小结果回传基础上,
图片 URL 只保存为长度受限、规范化的辅助观测值;后端不得盲目请求该 URL。主要证据 图片 URL 只保存为长度受限、规范化的辅助观测值;后端不得盲目请求该 URL。主要证据
必须是 App 上传后由 assetstore 校验、脱敏和受鉴权访问的时间点快照。 必须是 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。接受或拒绝至少有一个理由且指定主要理由; 人工理由使用版本化 allowlist。接受或拒绝至少有一个理由且指定主要理由;
`OTHER` 才要求 4-200 字备注。拒绝推荐后改选必须同时产生一条原推荐项负标签和一条 `OTHER` 才要求 4-200 字备注。拒绝推荐后改选必须同时产生一条原推荐项负标签和一条
替代项正标签;全部无匹配时每个曝光候选都有负标签。人工修正追加新 review 并引用 替代项正标签;全部无匹配时每个曝光候选都有负标签。人工修正追加新 review 并引用
+30 -6
View File
@@ -498,9 +498,16 @@ SKU 伪装成图片检索词:
"title": "页面可见标题", "title": "页面可见标题",
"sku_text": "BLACK-L", "sku_text": "BLACK-L",
"price": "189.00", "price": "189.00",
"product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=example", "product_url": "",
"image_url": "https://example.invalid/short-lived-image", "image_url": "",
"evidence_asset_ids": ["7b733922-f90f-4bc4-a9ad-3e8ec4769122"], "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": { "evaluation": {
"decision": "REVIEW", "decision": "REVIEW",
"score": 0.82, "score": 0.82,
@@ -539,8 +546,14 @@ SKU 伪装成图片检索词:
`recommendation` 只能指向颜色和尺码均为 `MATCH`、分数和置信度均不低于 `0.75` `recommendation` 只能指向颜色和尺码均为 `MATCH`、分数和置信度均不低于 `0.75`
且没有拒绝原因的原始 ordinal;没有满足项时只省略 recommendation,不能删除原始 且没有拒绝原因的原始 ordinal;没有满足项时只省略 recommendation,不能删除原始
候选。后端在同一事务内写入 search run、observation、model evaluation 和 候选。后端在同一事务内写入 search run、observation、model evaluation 和
recommendation,并保留旧 JSON 审计副本;不请求 `product_url` 或 `image_url`, recommendation,并保留旧 JSON 审计副本。每个非空候选必须按
主要证据必须是已鉴权 asset。 `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` ### `POST /api/v1/tasks/{task_id}/human-reviews`
@@ -598,8 +611,19 @@ recommendation,并保留旧 JSON 审计副本;不请求 `product_url` 或 `i
"candidate": { "candidate": {
"ordinal": 1, "ordinal": 1,
"title": "页面可见标题", "title": "页面可见标题",
"sku_text": "BLACK-L",
"price": "189.00", "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 "order_submitted": false
} }
+16 -12
View File
@@ -5,9 +5,9 @@
## 当前快照 ## 当前快照
- 日期:2026-07-28 - 日期: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、 - 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-buyer/` 已接入 Roubao Android 源码
- Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留 - Android:固定 `main@c8a6d7f03422eb01744b01f3ee77bf7757741f7e`;MIT 许可证已保留
@@ -17,10 +17,10 @@
- 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、 - 本机 Android 工具:JDK 17.0.13、Command-line Tools 22.0、SDK 34、
Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置 Build Tools 34.0.0、Platform Tools/ADB 37.0.0;用户级 SDK 环境变量已设置
- Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建 - Android Studio:未安装;`winget` 静默安装卡住后已终止,不阻塞命令行构建
- 测试:T-208 Android 单元测试、Debug/Release 构建和根 `init.ps1` 通过; - 测试:T-214 Android 单元测试、Debug/Release 构建和根 `init.ps1` 通过;
Debug APK `1.4.11 (16)` 已安装并启动于 PKG110 Debug APK `1.4.12 (17)` 已安装并启动于 PKG110
- 后端测试:T-208 运行 `go test ./...`、`go test -race ./...`、migration - 后端测试:T-214 运行 `go test ./...`、`go test -race ./...`、`go vet ./...`、
`up/down/up` 和带 review 数据的降级保护均通过 migration `up/down/up` 和带 identity 数据的降级保护均通过
- 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright - 原型:4 个管理 Web 页面和 7 个 Android 页面均可离线独立打开;Playwright
以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、 以 1440×900、390×844、360×800 验证 36 个页面/视口组合,无页面横向溢出、
脚本错误或外部请求,Android 可见交互控件均不小于 44px 脚本错误或外部请求,Android 可见交互控件均不小于 44px
@@ -60,6 +60,10 @@
人民币组合价和详情/规格双证据,并允许明确的“首件¥金额”当前单件价。区间、 人民币组合价和详情/规格双证据,并允许明确的“首件¥金额”当前单件价。区间、
券后、对照促销、冲突或缺失价格降级 `UNKNOWN`,直达订单确认页只系统返回。候选 券后、对照促销、冲突或缺失价格降级 `UNKNOWN`,直达订单确认页只系统返回。候选
探查不能加入购物车、提交订单或触发支付。 探查不能加入购物车、提交订单或触发支付。
- T-214 候选身份:App 回传卡片/详情语义签名和详情/规格双证据 hash;后端逐一核对
已上传 asset 后,在 v7 表中生成 execution-scoped `candidate_key`。Admin API/Web
展示 key、指纹、原始 observation、模型/人工结论和截图;缺少真实平台 URL 时保持
空值,后端不请求第三方 URL。
- Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、 - Workflow:纯 Kotlin runner 已支持步骤 timeout、最多 3 次 retry、安全阻塞、
用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步 用户停止和单 runner 并发拒绝;T-102 已接入搜索加有界候选采集五步
- TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture - TaskSource:严格 CLI 已生成并验证真实私有 ProbeTask;默认 APK 不含私有 fixture
@@ -73,7 +77,7 @@
- 离线执行:默认 30 分钟有限授权和 30 秒 best-effort heartbeat 已实现;PKG110 - 离线执行:默认 30 分钟有限授权和 30 秒 best-effort heartbeat 已实现;PKG110
真机断开临时后端 95 秒后保持同一 execution,重连后滑动续期;到期持久安全停止, 真机断开临时后端 95 秒后保持同一 execution,重连后滑动续期;到期持久安全停止,
RUNNING 不自动重新分配 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 (81700)`
- 设备就绪:拼多多首页、文字/图片搜索、双列候选卡、详情截图和返回均已通过 8.17.0 - 设备就绪:拼多多首页、文字/图片搜索、双列候选卡、详情截图和返回均已通过 8.17.0
真机验证;采购员已在 ColorOS 设置中手动启用肉包采购无障碍,APK 覆盖安装后授权 真机验证;采购员已在 ColorOS 设置中手动启用肉包采购无障碍,APK 覆盖安装后授权
@@ -88,7 +92,7 @@
已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/` 已用 CLI 真实导入并逐字段/图片哈希验证,生成物位于被忽略的 `.local/`
- 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1` - 标准启动路径:`$env:RUN_START_COMMAND="1"; .\init.ps1`
- 标准验证路径:`.\init.ps1` - 标准验证路径:`.\init.ps1`
- 当前 blocker:T-208 无阻塞。真实 VLM 服务地址、模型、设备级测试凭证、成本上限 - 当前 blocker:T-214 无阻塞。真实 VLM 服务地址、模型、设备级测试凭证、成本上限
和数据留存尚未确认;当前只支持单 SKU/JPEG;候选探针截图要求 Android 11/API 30+ 和数据留存尚未确认;当前只支持单 SKU/JPEG;候选探针截图要求 Android 11/API 30+
## 当前目录 ## 当前目录
@@ -118,7 +122,7 @@
| `docs/tasks/T-212.md` | DONE | 修复重排候选、推荐、证据与人工接受的身份映射 | | `docs/tasks/T-212.md` | DONE | 修复重排候选、推荐、证据与人工接受的身份映射 |
| `docs/tasks/T-213.md` | DONE | 真机选择目标 SKU、读取组合价并安全返回 | | `docs/tasks/T-213.md` | DONE | 真机选择目标 SKU、读取组合价并安全返回 |
| `docs/tasks/T-208.md` | DONE | 归一化候选决策数据并增加结构化人工 review | | `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 页面 | | `docs/design/` | 已确认 | T-202 原型索引、4 个管理页和 7 个 Android 页面 |
| `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 | | `deepseek总结.txt` | 已有 | 历史讨论摘要,不是正式需求权威 |
| `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 | | `android-buyer/` | 已有 | Roubao `main` 固定 commit 的 Android 基线 |
@@ -129,9 +133,9 @@
## 任务摘要 ## 任务摘要
- 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-213。 - 已完成:T-001 至 T-004、T-101 至 T-104、T-201 至 T-214。
- 正在进行:T-214 商品持久身份与重新定位指纹。 - 正在进行:无。
- 下一步:依次实现 Admin 下单授权、设备命令、订单 dry-run、单次提交对账和 - 下一步:依次实现 T-215 Admin 下单授权、设备命令、订单 dry-run、单次提交对账和
付款提醒。 付款提醒。
## 当前可运行内容 ## 当前可运行内容
+18 -8
View File
@@ -5,13 +5,14 @@ phase: 2
deps: deps:
- T-208 - T-208
- T-213 - T-213
status: DOING status: DONE
created: 2026-07-28 created: 2026-07-28
context_ref: e7f4c3e context_ref: e7f4c3e
work_branch: null work_branch: null
write_paths: write_paths:
- docs/tasks/T-214.md - docs/tasks/T-214.md
- docs/00-ai-start-here.md - docs/00-ai-start-here.md
- docs/03-tech-stack.md
- docs/04-architecture.md - docs/04-architecture.md
- docs/06-tasks.md - docs/06-tasks.md
- docs/api.md - docs/api.md
@@ -71,13 +72,13 @@ T-208 已能保存原始候选 observation 和人工结论,但 Admin 与后续
## 验收要点 ## 验收要点
- [ ] v7 migration `up/down/up` 可重复;有 identity 数据时破坏性 down 失败。 - [x] v7 migration `up/down/up` 可重复;有 identity 数据时破坏性 down 失败。
- [ ] App 回传全部 observation 的卡片/详情/双证据 SHA-256,不因模型拒绝而缺失。 - [x] App 回传全部 observation 的卡片/详情/双证据 SHA-256,不因模型拒绝而缺失。
- [ ] 后端拒绝格式错误、跨 execution、与 evidence 实际哈希不一致的指纹。 - [x] 后端拒绝格式错误、跨 execution、与 evidence 实际哈希不一致的指纹。
- [ ] 同一请求重放 candidate key 稳定,不同 observation key 不同。 - [x] 同一请求重放 candidate key 稳定,不同 observation key 不同。
- [ ] Admin API/Web 可按 candidate key 查看 observation、模型判断、人工结论和截图。 - [x] Admin API/Web 可按 candidate key 查看 observation、模型判断、人工结论和截图。
- [ ] 后端不会请求候选 URL;没有真实 URL 时保持空值而不是生成伪链接。 - [x] 后端不会请求候选 URL;没有真实 URL 时保持空值而不是生成伪链接。
- [ ] Android/Go 测试、race、migration、Debug/Release 和根验证通过。 - [x] Android/Go 测试、race、migration、Debug/Release 和根验证通过。
## 边界 ## 边界
@@ -91,3 +92,12 @@ T-208 已能保存原始候选 observation 和人工结论,但 Admin 与后续
- 2026-07-28:T-208 实现提交 `e7f4c3e` 后领取。确认当前 observation 已有可选 - 2026-07-28:T-208 实现提交 `e7f4c3e` 后领取。确认当前 observation 已有可选
product/image URL 和受控 evidence,但 App 实际无法从拼多多无障碍树取得可信 URL, product/image URL 和受控 evidence,但 App 实际无法从拼多多无障碍树取得可信 URL,
因此冻结 execution-scoped candidate key 与多指纹方案。 因此冻结 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,采购无障碍
仍在启用列表。