feat(t214): persist candidate observation identities
This commit is contained in:
@@ -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()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -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)
|
||||||
|
|||||||
+11
@@ -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,
|
||||||
|
|||||||
+30
-2
@@ -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))
|
||||||
|
|||||||
+4
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
+19
@@ -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",
|
||||||
|
|||||||
+4
-3
@@ -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"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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
|
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;
|
||||||
@@ -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 配置。
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,采购无障碍
|
||||||
|
仍在启用列表。
|
||||||
|
|||||||
Reference in New Issue
Block a user