feat(t213): complete live SKU price verification

This commit is contained in:
QiuSW
2026-07-28 11:20:12 +08:00
parent 73b00b4063
commit 4c3b962dde
15 changed files with 251 additions and 46 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.roubao.autopilot"
minSdk = 26
targetSdk = 34
versionCode = 9
versionName = "1.4.4"
versionCode = 15
versionName = "1.4.10"
vectorDrawables {
useSupportLibrary = true
@@ -292,6 +292,10 @@ class MainActivity : ComponentActivity() {
val evaluationFailure by remember { candidateEvaluationFailureCode }
val procurementState by procurementRepository.uiState.collectAsState()
LaunchedEffect(procurementState.task?.id) {
resetTaskBoundProbeState()
}
// 监听跳转事件
LaunchedEffect(navigateToRecord, recordId) {
if (navigateToRecord && recordId != null) {
@@ -509,6 +513,11 @@ class MainActivity : ComponentActivity() {
if (::readinessChecker.isInitialized) {
refreshReadiness()
lifecycleScope.launch {
if (::procurementRepository.isInitialized) {
procurementRepository.reconcileCurrentClaim(
readinessChecker.snapshot()
)
}
delay(READINESS_RECHECK_DELAY_MS)
refreshReadiness()
}
@@ -594,6 +603,13 @@ class MainActivity : ComponentActivity() {
val procurementTask = procurementRepository.currentProbeTask()
val procurementMode = procurementRepository.activeExecutionMode()
if (
procurementTask != null &&
!procurementRepository.hasRunningExecution
) {
Toast.makeText(this, "请先开始采购任务", Toast.LENGTH_SHORT).show()
return
}
val boundRequirement = requirementExtraction.value?.takeIf {
requirementProbeState.value == RequirementProbeState.READY &&
!it.manualReviewRequired
@@ -1343,6 +1359,22 @@ class MainActivity : ComponentActivity() {
taskCandidateDrafts.value = emptyList()
}
private fun resetTaskBoundProbeState() {
searchProbeRunner?.requestStop()
searchProbeJob?.cancel()
requirementProbeJob?.cancel()
searchProbeReport.value = null
searchProbeState.value = WorkflowState.IDLE
searchProbeStepId.value = null
candidateEvidence.value = emptyList()
candidateSearchKeyword.value = null
candidateRequirementSnapshot.value = null
requirementProbeState.value = RequirementProbeState.IDLE
requirementExtraction.value = null
requirementFailureCode.value = null
clearCandidateEvaluation()
}
private fun checkShizukuPermission(): Boolean {
return try {
val granted = Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED
@@ -18,7 +18,7 @@ class AndroidPinduoduoImageSearchDriver(
) ?: return false
intent.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
Intent.FLAG_ACTIVITY_CLEAR_TASK
)
appContext.startActivity(intent)
return true
@@ -11,6 +11,7 @@ import android.provider.MediaStore
import com.roubao.task.ProbeReferenceImage
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
data class PreparedPinduoduoSearchImage(
@@ -89,6 +90,8 @@ class PinduoduoImageSearchAssetStore(context: Context) {
null,
null
)
resolver.notifyChange(uri, null)
delay(MEDIA_INDEX_SETTLE_MILLIS)
PreparedPinduoduoSearchImage(uri, reference.sha256)
} catch (_: Exception) {
resolver.delete(uri, null, null)
@@ -152,5 +155,6 @@ class PinduoduoImageSearchAssetStore(context: Context) {
private companion object {
const val MEDIA_DIRECTORY = "Pictures/RoubaoSearch"
const val MEDIA_INDEX_SETTLE_MILLIS = 750L
}
}
@@ -149,7 +149,7 @@ object PinduoduoPageClassifier {
element.clickable &&
normalize(element.contentDescription.orEmpty()) == "返回"
}
val detailMarkerCount = setOf("联系客服", "收藏", "店铺")
val detailMarkerCount = setOf("客服", "收藏", "店铺")
.count { marker -> normalized.any { it.contains(marker) } }
val specificationGroupCount = setOf("颜色分类", "颜色", "尺码", "尺寸")
.count { marker -> normalized.any { it == marker } }
@@ -301,7 +301,8 @@ object PinduoduoSpecificationParser {
"关闭",
"确认款式"
)
private val PRICE_PATTERN = Regex("""^[¥¥](\d{1,7})(?:\.(\d{1,2}))?$""")
private val PRICE_PATTERN =
Regex("""^(?:首件)?[¥¥](\d{1,7})(?:\.(\d{1,2}))?$""")
private const val MAX_ELEMENTS = 160
private const val MAX_OPTIONS_PER_GROUP = 30
private const val MAX_OPTION_TEXT_LENGTH = 80
@@ -82,21 +82,36 @@ class ProcurementRepository(
suspend fun claimNext(snapshot: DeviceReadinessSnapshot): Boolean = operation {
val session = requireValidSession()
val existing = persisted.claim
if (existing?.task != null) {
if (existing.referenceImage == null) {
downloadAndPersistReference(session, existing, existing.task)
}
saveAndPublish(
backendOnline = true,
message = "已恢复当前领取任务"
)
return@operation true
}
var existing = persisted.claim
val heartbeat = api.deviceHeartbeat(
session,
snapshot.toHeartbeatInput(persisted.claim?.task?.id)
snapshot.toHeartbeatInput(existing?.task?.id)
)
when (
reconcileClaim(
localTaskId = existing?.task?.id,
serverActiveTaskId = heartbeat.activeTaskId
)
) {
ClaimReconciliationDecision.RESTORE -> {
val current = requireNotNull(existing)
val task = requireNotNull(current.task)
if (current.referenceImage == null) {
downloadAndPersistReference(session, current, task)
}
saveAndPublish(
backendOnline = true,
message = "已恢复当前领取任务"
)
return@operation true
}
ClaimReconciliationDecision.DISCARD_LOCAL -> {
clearCurrentTask()
store.save(persisted)
existing = null
}
ClaimReconciliationDecision.CLAIM_OR_REPLAY -> Unit
}
if (heartbeat.activeTaskId != null &&
existing == null
) {
@@ -209,6 +224,47 @@ class ProcurementRepository(
true
} ?: false
suspend fun reconcileCurrentClaim(
snapshot: DeviceReadinessSnapshot
): Boolean = operation {
if (persisted.execution != null) {
return@operation true
}
val current = persisted.claim ?: return@operation false
val task = current.task ?: return@operation false
val session = requireValidSession()
val heartbeat = api.deviceHeartbeat(
session,
snapshot.toHeartbeatInput(task.id)
)
when (
reconcileClaim(
localTaskId = task.id,
serverActiveTaskId = heartbeat.activeTaskId
)
) {
ClaimReconciliationDecision.RESTORE -> {
if (current.referenceImage == null) {
downloadAndPersistReference(session, current, task)
}
saveAndPublish(
backendOnline = true,
message = "已恢复当前领取任务"
)
true
}
ClaimReconciliationDecision.DISCARD_LOCAL -> {
clearCurrentTask()
saveAndPublish(
backendOnline = true,
message = "后台任务已结束,本地状态已清理"
)
false
}
ClaimReconciliationDecision.CLAIM_OR_REPLAY -> false
}
} ?: false
suspend fun synchronizeRunning(): ExecutionSyncDecision =
synchronize(allowSafetyStoppedSync = false)
@@ -975,6 +1031,21 @@ class ProcurementRepository(
}
}
internal enum class ClaimReconciliationDecision {
RESTORE,
DISCARD_LOCAL,
CLAIM_OR_REPLAY
}
internal fun reconcileClaim(
localTaskId: String?,
serverActiveTaskId: String?
): ClaimReconciliationDecision = when {
localTaskId == null -> ClaimReconciliationDecision.CLAIM_OR_REPLAY
localTaskId == serverActiveTaskId -> ClaimReconciliationDecision.RESTORE
else -> ClaimReconciliationDecision.DISCARD_LOCAL
}
private object UnavailableProcurementStateStore : ProcurementStateStore {
override fun load() = PersistedProcurementState()
@@ -202,6 +202,18 @@ class PinduoduoPageClassifierTest {
assertNull(snapshot.safetyStopReason)
}
@Test
fun `modern detail footer accepts short customer service label`() {
val snapshot = classify(
element(contentDescription = "返回", clickable = true),
element(contentDescription = "客服", clickable = true),
element(contentDescription = "收藏", clickable = true)
)
assertEquals(PinduoduoPage.PRODUCT_DETAIL, snapshot.page)
assertNull(snapshot.safetyStopReason)
}
@Test
fun `specification panel requires color size and options`() {
val snapshot = classify(
@@ -106,6 +106,31 @@ class PinduoduoSpecificationParserTest {
assertFalse(evidence.priceAmbiguous)
}
@Test
fun `reads allowlisted first unit price and ignores comparison price`() {
val evidence = PinduoduoSpecificationParser.parse(
listOf(
element("首件¥16.7", top = 20),
element("大促价¥20.7", top = 30),
element("已选择:XL码/裙长90胸围89 红色", top = 40),
element("颜色分类", top = 100),
element("红色", top = 140, clickable = true, selected = true),
element("尺码", top = 240),
element(
"XL码/裙长90胸围89",
top = 280,
clickable = true,
selected = true
)
)
)
requireNotNull(evidence)
assertEquals(1670L, evidence.price?.cents)
assertEquals("首件¥16.7", evidence.price?.rawText)
assertFalse(evidence.priceAmbiguous)
}
@Test
fun `multiple explicit prices are ambiguous`() {
val evidence = PinduoduoSpecificationParser.parse(
@@ -0,0 +1,38 @@
package com.roubao.autopilot.procurement
import org.junit.Assert.assertEquals
import org.junit.Test
class ClaimReconciliationPolicyTest {
@Test
fun `restores only when local and server task identities match`() {
assertEquals(
ClaimReconciliationDecision.RESTORE,
reconcileClaim("task-1", "task-1")
)
}
@Test
fun `discards local task when backend has ended or replaced it`() {
assertEquals(
ClaimReconciliationDecision.DISCARD_LOCAL,
reconcileClaim("task-1", null)
)
assertEquals(
ClaimReconciliationDecision.DISCARD_LOCAL,
reconcileClaim("task-1", "task-2")
)
}
@Test
fun `continues claim or replay when no local task is materialized`() {
assertEquals(
ClaimReconciliationDecision.CLAIM_OR_REPLAY,
reconcileClaim(null, null)
)
assertEquals(
ClaimReconciliationDecision.CLAIM_OR_REPLAY,
reconcileClaim(null, "task-1")
)
}
}