feat(t217): verify authorized order dry runs

This commit is contained in:
QiuSW
2026-07-28 16:52:29 +08:00
parent 913107c28d
commit 1e87b6273a
45 changed files with 4358 additions and 128 deletions
@@ -3,6 +3,7 @@ package com.roubao.autopilot.accessibility
import com.roubao.autopilot.pinduoduo.PinduoduoPage
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateCard
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateDetailEvidence
import com.roubao.autopilot.pinduoduo.PinduoduoOrderConfirmationEvidence
import com.roubao.autopilot.pinduoduo.PinduoduoScreenshotCapture
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind
@@ -44,6 +45,11 @@ object BuyerAccessibilityBridge {
service?.clickPinduoduoImageSearchEntry() == true
}
suspend fun dismissImageSearchRetryDialog(): Boolean =
withContext(Dispatchers.Main.immediate) {
service?.dismissPinduoduoImageSearchRetryDialog() == true
}
suspend fun selectFirstRecentImage(): Boolean =
withContext(Dispatchers.Main.immediate) {
service?.selectFirstPinduoduoRecentImage() == true
@@ -107,6 +113,42 @@ object BuyerAccessibilityBridge {
service?.scrollPinduoduoSpecifications() == true
}
suspend fun specificationQuantity(): Int? =
withContext(Dispatchers.Main.immediate) {
service?.readPinduoduoSpecificationQuantity()
}
suspend fun changeSpecificationQuantity(increase: Boolean): Boolean =
withContext(Dispatchers.Main.immediate) {
service?.changePinduoduoSpecificationQuantity(increase) == true
}
suspend fun confirmSpecifications(): Boolean =
withContext(Dispatchers.Main.immediate) {
service?.confirmPinduoduoSpecifications() == true
}
suspend fun orderConfirmationEvidence():
PinduoduoOrderConfirmationEvidence? =
withContext(Dispatchers.Main.immediate) {
service?.readPinduoduoOrderConfirmationEvidence()
}
suspend fun isCollapsedCheckout(): Boolean =
withContext(Dispatchers.Main.immediate) {
service?.isPinduoduoCollapsedCheckout() == true
}
suspend fun captureOrderConfirmationScreenshot():
PinduoduoScreenshotCapture? =
withTimeoutOrNull(SCREENSHOT_TIMEOUT_MILLIS) {
withContext(Dispatchers.Main.immediate) {
service?.capturePinduoduoScreenshot(
PinduoduoPage.ORDER_CONFIRMATION
)
}
}
suspend fun captureSpecificationScreenshot(): PinduoduoScreenshotCapture? =
withTimeoutOrNull(SCREENSHOT_TIMEOUT_MILLIS) {
withContext(Dispatchers.Main.immediate) {
@@ -141,5 +183,10 @@ object BuyerAccessibilityBridge {
service?.scrollPinduoduoResults() == true
}
suspend fun scrollResultsBackward(): Boolean =
withContext(Dispatchers.Main.immediate) {
service?.scrollPinduoduoResultsBackward() == true
}
private const val SCREENSHOT_TIMEOUT_MILLIS = 8_000L
}
@@ -13,8 +13,10 @@ import android.view.accessibility.AccessibilityNodeInfo
import androidx.annotation.RequiresApi
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateCard
import com.roubao.autopilot.pinduoduo.PinduoduoCandidateDetailEvidence
import com.roubao.autopilot.pinduoduo.PinduoduoEvidenceHash
import com.roubao.autopilot.pinduoduo.PinduoduoCollapsedCheckoutPolicy
import com.roubao.autopilot.pinduoduo.PinduoduoObservedTitlePolicy
import com.roubao.autopilot.pinduoduo.PinduoduoOrderConfirmationEvidence
import com.roubao.autopilot.pinduoduo.PinduoduoOrderConfirmationParser
import com.roubao.autopilot.readiness.DeviceObservationStore
import com.roubao.autopilot.readiness.LoginBlockerDetector
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
@@ -28,6 +30,8 @@ import com.roubao.autopilot.pinduoduo.PinduoduoScreenshotCapture
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationEvidence
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationGroupKind
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationParser
import com.roubao.autopilot.pinduoduo.PinduoduoStableProductIdentityPolicy
import com.roubao.autopilot.workflow.SafetyStopReason
import java.io.ByteArrayOutputStream
import java.util.ArrayDeque
import java.util.concurrent.Executors
@@ -156,6 +160,27 @@ class BuyerAccessibilityService : AccessibilityService() {
?.performAction(AccessibilityNodeInfo.ACTION_CLICK) == true
} ?: false
internal fun dismissPinduoduoImageSearchRetryDialog(): Boolean =
withPinduoduoRoot { root ->
val snapshot = classifyPinduoduoRoot(root)
if (
snapshot.safetyStopReason != null ||
snapshot.page != PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG
) {
return@withPinduoduoRoot false
}
collectNodes(root)
.filter { node ->
node.isVisibleToUser &&
node.isEnabled &&
node.isClickable &&
node.text?.toString()?.trim() ==
IMAGE_SEARCH_RETRY_CANCEL_TEXT
}
.singleOrNull()
?.performAction(AccessibilityNodeInfo.ACTION_CLICK) == true
} ?: false
internal fun selectFirstPinduoduoRecentImage(): Boolean =
withPinduoduoRoot { root ->
val snapshot = classifyPinduoduoRoot(root)
@@ -317,12 +342,14 @@ class BuyerAccessibilityService : AccessibilityService() {
if (semanticTexts.isEmpty()) {
return@withPinduoduoRoot null
}
val observedTitle = PinduoduoObservedTitlePolicy.select(semanticTexts)
?: return@withPinduoduoRoot null
PinduoduoCandidateDetailEvidence(
signature = PinduoduoEvidenceHash.sha256(
semanticTexts.joinToString(TEXT_SIGNATURE_SEPARATOR)
signature = requireNotNull(
PinduoduoStableProductIdentityPolicy.signature(semanticTexts)
),
semanticTextCount = semanticTexts.size,
observedTitle = PinduoduoObservedTitlePolicy.select(semanticTexts)
observedTitle = observedTitle
)
}
@@ -428,6 +455,78 @@ class BuyerAccessibilityService : AccessibilityService() {
) == true
} ?: false
internal fun readPinduoduoSpecificationQuantity(): Int? =
withPinduoduoRoot { root ->
if (
classifyPinduoduoRoot(root).page !=
PinduoduoPage.SPECIFICATION_PANEL
) {
return@withPinduoduoRoot null
}
quantityControl(root)?.quantity
}
internal fun changePinduoduoSpecificationQuantity(
increase: Boolean
): Boolean =
withPinduoduoRoot { root ->
if (
classifyPinduoduoRoot(root).page !=
PinduoduoPage.SPECIFICATION_PANEL
) {
return@withPinduoduoRoot false
}
val control = quantityControl(root)
?: return@withPinduoduoRoot false
val target = if (increase) control.increase else control.decrease
target.isEnabled &&
target.performAction(AccessibilityNodeInfo.ACTION_CLICK)
} ?: false
internal fun confirmPinduoduoSpecifications(): Boolean =
withPinduoduoRoot { root ->
if (
classifyPinduoduoRoot(root).page !=
PinduoduoPage.SPECIFICATION_PANEL
) {
return@withPinduoduoRoot false
}
uniqueClickableTargets(
collectNodes(root).filter { node ->
node.isVisibleToUser &&
node.isEnabled &&
normalizeActionText(semanticText(node)) == "确定"
}
).singleOrNull()?.performAction(
AccessibilityNodeInfo.ACTION_CLICK
) == true
} ?: false
internal fun readPinduoduoOrderConfirmationEvidence():
PinduoduoOrderConfirmationEvidence? =
withPinduoduoRoot { root ->
val snapshot = classifyPinduoduoRoot(root)
if (
snapshot.page != PinduoduoPage.ORDER_CONFIRMATION ||
snapshot.safetyStopReason !in setOf(
null,
SafetyStopReason.PAYMENT_BOUNDARY
)
) {
return@withPinduoduoRoot null
}
PinduoduoOrderConfirmationParser.parse(
collectNodes(root).map(::uiElement)
)
}
internal fun isPinduoduoCollapsedCheckout(): Boolean =
withPinduoduoRoot { root ->
PinduoduoCollapsedCheckoutPolicy.matches(
collectNodes(root).map(::uiElement)
)
} ?: false
internal fun closePinduoduoSpecifications(): Boolean =
withPinduoduoRoot { root ->
val snapshot = classifyPinduoduoRoot(root)
@@ -485,6 +584,20 @@ class BuyerAccessibilityService : AccessibilityService() {
) == true
} ?: false
internal fun scrollPinduoduoResultsBackward(): Boolean =
withPinduoduoRoot { root ->
val snapshot = classifyPinduoduoRoot(root)
if (
snapshot.safetyStopReason != null ||
!snapshot.page.isCandidateResultsPage()
) {
return@withPinduoduoRoot false
}
findMainResultsRecycler(root)?.performAction(
AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD
) == true
} ?: false
internal suspend fun capturePinduoduoScreenshot(
expectedPage: PinduoduoPage
): PinduoduoScreenshotCapture? {
@@ -494,7 +607,8 @@ class BuyerAccessibilityService : AccessibilityService() {
if (
expectedPage !in setOf(
PinduoduoPage.PRODUCT_DETAIL,
PinduoduoPage.SPECIFICATION_PANEL
PinduoduoPage.SPECIFICATION_PANEL,
PinduoduoPage.ORDER_CONFIRMATION
)
) {
return null
@@ -509,10 +623,28 @@ class BuyerAccessibilityService : AccessibilityService() {
PinduoduoScreenshotCapture? {
val root = rootInActiveWindow
val snapshot = root?.let(::classifyPinduoduoRoot)
val collapsedCheckoutExpected =
root != null &&
expectedPage == PinduoduoPage.ORDER_CONFIRMATION &&
snapshot?.page == PinduoduoPage.UNKNOWN &&
snapshot.safetyStopReason == null &&
PinduoduoCollapsedCheckoutPolicy.matches(
collectNodes(root).map(::uiElement)
)
if (
root?.packageName?.toString() != PINDUODUO_PACKAGE ||
snapshot?.safetyStopReason != null ||
snapshot?.page != expectedPage ||
(
snapshot?.safetyStopReason != null &&
!(
expectedPage == PinduoduoPage.ORDER_CONFIRMATION &&
snapshot.safetyStopReason ==
SafetyStopReason.PAYMENT_BOUNDARY
)
) ||
(
snapshot?.page != expectedPage &&
!collapsedCheckoutExpected
) ||
(
expectedPage == PinduoduoPage.PRODUCT_DETAIL &&
!isVerifiedProductDetail(root)
@@ -673,18 +805,19 @@ class BuyerAccessibilityService : AccessibilityService() {
}
val hasPrice = semanticTexts.any(::hasPriceSemantics)
val clickTarget = findSafeCandidateClickTarget(node, descendants)
val stableSignature =
PinduoduoStableProductIdentityPolicy.signature(semanticTexts)
if (
semanticTexts.size < MIN_CANDIDATE_TEXTS ||
!hasImage ||
!hasPrice ||
clickTarget == null
clickTarget == null ||
stableSignature == null
) {
continue
}
val card = PinduoduoCandidateCard(
signature = PinduoduoEvidenceHash.sha256(
semanticTexts.joinToString(TEXT_SIGNATURE_SEPARATOR)
),
signature = stableSignature,
semanticTextCount = semanticTexts.size,
hasImage = true
)
@@ -790,6 +923,60 @@ class BuyerAccessibilityService : AccessibilityService() {
return coveringTargets.singleOrNull()
}
private fun quantityControl(root: AccessibilityNodeInfo): QuantityControl? {
val controls = collectNodes(root).mapNotNull { valueNode ->
val quantity = semanticText(valueNode).trim().toIntOrNull()
?.takeIf { it in 1..99 }
?: return@mapNotNull null
var container: AccessibilityNodeInfo? = valueNode.parent
repeat(MAX_QUANTITY_ANCESTORS) {
val current = container ?: return@repeat
val descendants = collectNodes(current)
val increase = uniqueClickableTargets(
descendants.filter { node ->
node.isVisibleToUser &&
normalizeActionText(semanticText(node)) in
INCREASE_QUANTITY_TEXTS
}
).singleOrNull()
val decrease = uniqueClickableTargets(
descendants.filter { node ->
node.isVisibleToUser &&
normalizeActionText(semanticText(node)) in
DECREASE_QUANTITY_TEXTS
}
).singleOrNull()
if (increase != null && decrease != null) {
val bounds = Rect().also(current::getBoundsInScreen)
return@mapNotNull QuantityControl(
quantity,
increase,
decrease,
area(bounds)
)
}
container = current.parent
}
null
}
return controls
.distinctBy { control ->
val increaseBounds = Rect().also(
control.increase::getBoundsInScreen
)
val decreaseBounds = Rect().also(
control.decrease::getBoundsInScreen
)
listOf(
increaseBounds.left,
increaseBounds.top,
decreaseBounds.right,
decreaseBounds.bottom
)
}
.minByOrNull(QuantityControl::area)
}
private fun hasPriceSemantics(value: String): Boolean =
value.contains('¥') ||
value.contains('¥') ||
@@ -932,10 +1119,18 @@ class BuyerAccessibilityService : AccessibilityService() {
val descendantCount: Int
)
private data class QuantityControl(
val quantity: Int,
val increase: AccessibilityNodeInfo,
val decrease: AccessibilityNodeInfo,
val area: Long
)
companion object {
private const val TAG = "BuyerAccessibility"
private const val MAX_NODES = 300
private const val MAX_CLICK_ANCESTORS = 4
private const val MAX_QUANTITY_ANCESTORS = 4
private const val MAX_CANDIDATE_NODE_SCAN = 20
private const val MIN_CANDIDATE_TEXTS = 2
private const val MIN_MAIN_RECYCLER_NODES = 20
@@ -948,13 +1143,17 @@ class BuyerAccessibilityService : AccessibilityService() {
private const val MIN_CLICK_TARGET_COVER_PERCENT = 85
private const val MAX_EVIDENCE_TEXTS = 100
private const val MAX_EVIDENCE_TEXT_LENGTH = 500
private const val TEXT_SIGNATURE_SEPARATOR = "\u001f"
private const val MIN_SCAN_INTERVAL_MS = 300L
private const val IMAGE_SEARCH_DESCRIPTION = "拍照搜索"
private const val IMAGE_SEARCH_RETRY_CANCEL_TEXT = "取消"
private const val RECENT_PROJECTS_TEXT = "最近项目"
private const val IMAGE_GRID_COLUMNS = 4
private const val IMAGE_GRID_WIDTH_TOLERANCE = 24
private const val SPECIFICATION_PURCHASE_ENTRY = "免拼购买"
private val INCREASE_QUANTITY_TEXTS =
setOf("增加数量", "加", "+")
private val DECREASE_QUANTITY_TEXTS =
setOf("减少数量", "减", "-")
private val DECIMAL_PRICE_PATTERN =
Regex("^\\s*\\d{1,6}\\.\\d{1,2}\\s*$")
}
@@ -30,10 +30,19 @@ class AndroidPinduoduoImageSearchDriver(
override suspend fun openImageSearch(): Boolean =
BuyerAccessibilityBridge.openImageSearch()
override suspend fun dismissImageSearchRetryDialog(): Boolean =
BuyerAccessibilityBridge.dismissImageSearchRetryDialog()
override suspend fun selectPreparedImage(): Boolean =
assetStore.isMostRecent(preparedImage) &&
BuyerAccessibilityBridge.selectFirstRecentImage()
override suspend fun closeSpecifications(): Boolean =
BuyerAccessibilityBridge.closeSpecifications()
override suspend fun returnFromOrderConfirmation(): Boolean =
BuyerAccessibilityBridge.returnFromOrderConfirmation()
override suspend fun returnFromCandidate(): Boolean =
BuyerAccessibilityBridge.returnToResults()
@@ -0,0 +1,103 @@
package com.roubao.autopilot.pinduoduo
import com.roubao.autopilot.accessibility.BuyerAccessibilityBridge
class AndroidPinduoduoOrderDryRunDriver(
private val authorizedTitle: String,
private val expectedColor: String,
private val expectedSize: String,
private val expectedQuantity: Int,
private val ocrReader: PinduoduoCheckoutOcrReader =
MlKitPinduoduoCheckoutOcrReader()
) : PinduoduoOrderDryRunDriver {
private var expectingOrderConfirmation = false
override suspend fun snapshot(): PinduoduoUiSnapshot {
val snapshot = BuyerAccessibilityBridge.snapshot()
if (
expectingOrderConfirmation &&
snapshot.foregroundPackage ==
com.roubao.autopilot.readiness.PINDUODUO_PACKAGE &&
snapshot.page == PinduoduoPage.UNKNOWN &&
snapshot.safetyStopReason == null &&
BuyerAccessibilityBridge.isCollapsedCheckout()
) {
return snapshot.copy(
page = PinduoduoPage.ORDER_CONFIRMATION,
safetyStopReason =
com.roubao.autopilot.workflow.SafetyStopReason.PAYMENT_BOUNDARY
)
}
return snapshot
}
override suspend fun candidateCards(
limit: Int
): List<PinduoduoCandidateCard> =
BuyerAccessibilityBridge.candidateCards(limit)
override suspend fun openCandidate(signature: String): Boolean =
BuyerAccessibilityBridge.openCandidate(signature)
override suspend fun readCandidateDetail():
PinduoduoCandidateDetailEvidence? =
BuyerAccessibilityBridge.candidateDetailEvidence()
override suspend fun openSpecifications(): Boolean =
BuyerAccessibilityBridge.openSpecifications()
override suspend fun readSpecifications(): PinduoduoSpecificationEvidence? =
BuyerAccessibilityBridge.specificationEvidence()
override suspend fun selectSpecificationOption(
kind: PinduoduoSpecificationGroupKind,
optionText: String
): Boolean =
BuyerAccessibilityBridge.selectSpecificationOption(kind, optionText)
override suspend fun scrollSpecifications(): Boolean =
BuyerAccessibilityBridge.scrollSpecifications()
override suspend fun closeSpecifications(): Boolean =
BuyerAccessibilityBridge.closeSpecifications()
override suspend fun returnToResults(): Boolean =
BuyerAccessibilityBridge.returnToResults()
override suspend fun scrollResults(): Boolean =
BuyerAccessibilityBridge.scrollResults()
override suspend fun scrollResultsBackward(): Boolean =
BuyerAccessibilityBridge.scrollResultsBackward()
override suspend fun readQuantity(): Int? =
BuyerAccessibilityBridge.specificationQuantity()
override suspend fun changeQuantity(increase: Boolean): Boolean =
BuyerAccessibilityBridge.changeSpecificationQuantity(increase)
override suspend fun confirmSpecifications(): Boolean =
BuyerAccessibilityBridge.confirmSpecifications().also { confirmed ->
if (confirmed) {
expectingOrderConfirmation = true
}
}
override suspend fun readOrderConfirmation():
PinduoduoOrderConfirmationEvidence? =
BuyerAccessibilityBridge.orderConfirmationEvidence()
?: BuyerAccessibilityBridge.captureOrderConfirmationScreenshot()
?.let { screenshot ->
ocrReader.read(
pngBytes = screenshot.pngBytes,
authorizedTitle = authorizedTitle,
expectedColor = expectedColor,
expectedSize = expectedSize,
expectedQuantity = expectedQuantity
)
}
override suspend fun captureOrderConfirmation():
PinduoduoScreenshotCapture? =
BuyerAccessibilityBridge.captureOrderConfirmationScreenshot()
}
@@ -70,6 +70,20 @@ object PinduoduoObservedTitlePolicy {
.maxByOrNull(String::length)
}
object PinduoduoStableProductIdentityPolicy {
fun signature(semanticTexts: List<String>): String? {
val title = PinduoduoObservedTitlePolicy.select(semanticTexts)
?: return null
val normalized = PinduoduoOrderConfirmationParser.normalize(title)
if (normalized.isBlank()) {
return null
}
return PinduoduoEvidenceHash.sha256(
"pdd-product-title-v1\u001f$normalized"
)
}
}
object PinduoduoEvidenceHash {
fun sha256(value: String): String =
sha256(value.toByteArray(Charsets.UTF_8))
@@ -0,0 +1,328 @@
package com.roubao.autopilot.pinduoduo
import android.graphics.BitmapFactory
import android.util.Log
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.text.TextRecognition
import com.google.mlkit.vision.text.chinese.ChineseTextRecognizerOptions
import java.text.Normalizer
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
data class PinduoduoOcrLine(
val text: String,
val left: Int,
val top: Int,
val right: Int,
val bottom: Int
)
fun interface PinduoduoCheckoutOcrReader {
suspend fun read(
pngBytes: ByteArray,
authorizedTitle: String,
expectedColor: String,
expectedSize: String,
expectedQuantity: Int
): PinduoduoOrderConfirmationEvidence?
}
class MlKitPinduoduoCheckoutOcrReader : PinduoduoCheckoutOcrReader {
override suspend fun read(
pngBytes: ByteArray,
authorizedTitle: String,
expectedColor: String,
expectedSize: String,
expectedQuantity: Int
): PinduoduoOrderConfirmationEvidence? {
val bitmap = BitmapFactory.decodeByteArray(
pngBytes,
0,
pngBytes.size
) ?: return null
val recognizer = TextRecognition.getClient(
ChineseTextRecognizerOptions.Builder().build()
)
val closed = AtomicBoolean(false)
fun close() {
if (closed.compareAndSet(false, true)) {
recognizer.close()
bitmap.recycle()
}
}
return suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation { close() }
recognizer.process(InputImage.fromBitmap(bitmap, 0))
.addOnSuccessListener { result ->
val lines = result.textBlocks.flatMap { block ->
block.lines.mapNotNull { line ->
line.boundingBox?.let { bounds ->
PinduoduoOcrLine(
text = line.text,
left = bounds.left,
top = bounds.top,
right = bounds.right,
bottom = bounds.bottom
)
}
}
}
val evaluation = PinduoduoCheckoutOcrPolicy.evaluate(
lines = lines,
authorizedTitle = authorizedTitle,
expectedColor = expectedColor,
expectedSize = expectedSize,
expectedQuantity = expectedQuantity
)
if (evaluation.evidence == null) {
Log.w(
LOG_TAG,
"Checkout OCR rejected: ${evaluation.failureCode}"
)
}
if (continuation.isActive) {
continuation.resume(evaluation.evidence)
}
close()
}
.addOnFailureListener {
if (continuation.isActive) {
continuation.resume(null)
}
close()
}
}
}
private companion object {
const val LOG_TAG = "PinduoduoCheckoutOcr"
}
}
data class PinduoduoCheckoutOcrEvaluation(
val evidence: PinduoduoOrderConfirmationEvidence?,
val failureCode: String?
)
object PinduoduoCheckoutOcrPolicy {
fun verify(
lines: List<PinduoduoOcrLine>,
authorizedTitle: String,
expectedColor: String,
expectedSize: String,
expectedQuantity: Int
): PinduoduoOrderConfirmationEvidence? =
evaluate(
lines,
authorizedTitle,
expectedColor,
expectedSize,
expectedQuantity
).evidence
fun evaluate(
lines: List<PinduoduoOcrLine>,
authorizedTitle: String,
expectedColor: String,
expectedSize: String,
expectedQuantity: Int
): PinduoduoCheckoutOcrEvaluation {
fun rejected(code: String) =
PinduoduoCheckoutOcrEvaluation(null, code)
if (lines.isEmpty() || lines.size > MAX_LINES) {
return rejected("LINE_COUNT")
}
if (expectedQuantity !in 1..99) {
return rejected("EXPECTED_QUANTITY")
}
val ordered = lines.sortedWith(
compareBy<PinduoduoOcrLine> { it.top }.thenBy { it.left }
)
val joined = ordered.joinToString(separator = "") { it.text }
if (
!approximatelyContains(
normalize(joined),
normalize(authorizedTitle)
)
) {
return rejected("TITLE")
}
val firstDiscountTop = ordered
.filter { line ->
val text = normalize(line.text)
text.startsWith("店铺优惠") ||
text.startsWith("平台和限时优惠")
}
.minOfOrNull(PinduoduoOcrLine::top)
?: return rejected("DISCOUNT_BOUNDARY")
val colorLine = ordered.singleOrNull { line ->
line.top < firstDiscountTop &&
PinduoduoSpecificationTargetMatcher.matches(
kind = PinduoduoSpecificationGroupKind.COLOR,
expected = expectedColor,
observed = line.text
)
} ?: return rejected("COLOR")
val sizeLine = ordered.singleOrNull { line ->
line.top < firstDiscountTop &&
PinduoduoSpecificationTargetMatcher.matches(
kind = PinduoduoSpecificationGroupKind.SIZE,
expected = expectedSize,
observed = line.text
)
} ?: return rejected("SIZE")
val color = valueAfterLabel(colorLine.text)
?: colorLine.text.trim().takeIf(String::isNotEmpty)
?: return rejected("COLOR_VALUE")
val size = valueAfterLabel(sizeLine.text)
?: valueFromExpectedToken(sizeLine.text, expectedSize)
?: return rejected("SIZE_VALUE")
val skuBottom = maxOf(colorLine.bottom, sizeLine.bottom)
val quantityText = expectedQuantity.toString()
val quantityMatches = ordered.filter { line ->
quantityLineMatches(line.text, quantityText) &&
line.top > skuBottom &&
line.bottom < firstDiscountTop
}
if (quantityMatches.size != 1) {
return rejected("QUANTITY_${quantityMatches.size}")
}
val totals = ordered.mapNotNull { parsePayableTotal(it.text) }
.distinct()
val total = totals.singleOrNull()
?: return rejected("TOTAL_${totals.size}")
return PinduoduoCheckoutOcrEvaluation(
evidence = PinduoduoOrderConfirmationEvidence(
observedTitle = authorizedTitle,
selectedSummary = "$color $size",
quantity = expectedQuantity,
totalPriceCents = total
),
failureCode = null
)
}
private fun valueAfterLabel(value: String): String? {
val separator = value.indexOfAny(charArrayOf(':', ':'))
if (separator < 0 || separator == value.lastIndex) return null
return value.substring(separator + 1).trim().takeIf(String::isNotEmpty)
}
private fun valueFromExpectedToken(
value: String,
expected: String
): String? {
val index = value.indexOf(expected, ignoreCase = true)
if (index < 0) return null
return value.substring(index).trim().takeIf(String::isNotEmpty)
}
private fun parsePayableTotal(value: String): Long? {
val normalized = Normalizer.normalize(
value.trim(),
Normalizer.Form.NFKC
).replace(Regex("\\s+"), "")
val match = PAYABLE_PATTERN.find(normalized) ?: return null
val whole = match.groupValues[1].toLongOrNull() ?: return null
val fraction = match.groupValues[2].padEnd(2, '0')
.ifEmpty { "00" }
.toLongOrNull() ?: return null
return (whole * 100L + fraction)
.takeIf { it in 1..MAX_TOTAL_CENTS }
}
private fun quantityLineMatches(
value: String,
expected: String
): Boolean {
val normalized = Normalizer.normalize(
value.trim(),
Normalizer.Form.NFKC
).replace(Regex("\\s+"), "")
return normalized == expected || normalized == "$expected+"
}
private fun normalize(value: String): String =
PinduoduoOrderConfirmationParser.normalize(value)
internal fun approximatelyContains(
observed: String,
expected: String
): Boolean {
if (expected.isBlank() || observed.isBlank()) return false
if (observed.contains(expected)) return true
if (
expected.length >= MIN_VISIBLE_TITLE_PREFIX &&
observed.contains(expected.take(MIN_VISIBLE_TITLE_PREFIX))
) {
return true
}
val maxDistance = maxOf(2, expected.length / 8)
val minLength = maxOf(1, expected.length - maxDistance)
val maxLength = minOf(observed.length, expected.length + maxDistance)
for (length in minLength..maxLength) {
for (start in 0..observed.length - length) {
if (
editDistanceAtMost(
observed.substring(start, start + length),
expected,
maxDistance
)
) {
return true
}
}
}
val anchorLength = 3
val step = maxOf(anchorLength, expected.length / 6)
val anchors = buildList {
var start = 0
while (start + anchorLength <= expected.length) {
add(expected.substring(start, start + anchorLength))
start += step
}
add(expected.takeLast(anchorLength))
}.distinct()
val matchedAnchors = anchors.count(observed::contains)
return anchors.size >= 4 &&
matchedAnchors * 5 >= anchors.size * 3
}
private fun editDistanceAtMost(
left: String,
right: String,
limit: Int
): Boolean {
if (kotlin.math.abs(left.length - right.length) > limit) return false
var previous = IntArray(right.length + 1) { it }
for (leftIndex in left.indices) {
val current = IntArray(right.length + 1)
current[0] = leftIndex + 1
var rowMinimum = current[0]
for (rightIndex in right.indices) {
val substitution = previous[rightIndex] +
if (left[leftIndex] == right[rightIndex]) 0 else 1
current[rightIndex + 1] = minOf(
current[rightIndex] + 1,
previous[rightIndex + 1] + 1,
substitution
)
rowMinimum = minOf(rowMinimum, current[rightIndex + 1])
}
if (rowMinimum > limit) return false
previous = current
}
return previous[right.length] <= limit
}
private val PAYABLE_PATTERN =
Regex("""实付款[::]?[¥¥](\d{1,7})(?:\.(\d{1,2}))?""")
private const val MIN_VISIBLE_TITLE_PREFIX = 16
private const val MAX_LINES = 220
private const val MAX_TOTAL_CENTS = 100_000_000L
}
@@ -14,7 +14,10 @@ interface PinduoduoImageSearchDriver {
suspend fun openApp(): Boolean
suspend fun snapshot(): PinduoduoUiSnapshot
suspend fun openImageSearch(): Boolean
suspend fun dismissImageSearchRetryDialog(): Boolean
suspend fun selectPreparedImage(): Boolean
suspend fun closeSpecifications(): Boolean
suspend fun returnFromOrderConfirmation(): Boolean
suspend fun returnFromCandidate(): Boolean
suspend fun returnFromImageResults(): Boolean
}
@@ -54,8 +57,11 @@ class PinduoduoImageSearchAutomation(
PinduoduoPage.SEARCH_RESULTS,
PinduoduoPage.SEARCH_RESULTS_OTHER_QUERY,
PinduoduoPage.IMAGE_SEARCH,
PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG,
PinduoduoPage.IMAGE_SEARCH_RESULTS,
PinduoduoPage.PRODUCT_DETAIL
PinduoduoPage.PRODUCT_DETAIL,
PinduoduoPage.SPECIFICATION_PANEL,
PinduoduoPage.ORDER_CONFIRMATION
)
} ?: AutomationResult.Success
}
@@ -63,7 +69,13 @@ class PinduoduoImageSearchAutomation(
private suspend fun openImageSearch(): AutomationResult {
repeat(MAX_RECOVERY_ACTIONS) {
val snapshot = driver.snapshot()
safetyResult(snapshot)?.let { return it }
val recoverableOrderConfirmation =
snapshot.page == PinduoduoPage.ORDER_CONFIRMATION &&
snapshot.safetyStopReason ==
SafetyStopReason.PAYMENT_BOUNDARY
if (!recoverableOrderConfirmation) {
safetyResult(snapshot)?.let { return it }
}
when (snapshot.page) {
PinduoduoPage.IMAGE_SEARCH -> {
if (driver.selectPreparedImage()) {
@@ -85,11 +97,28 @@ class PinduoduoImageSearchAutomation(
)
}
}
PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG -> {
if (!driver.dismissImageSearchRetryDialog()) {
return retryableFailure()
}
delay(pollIntervalMillis)
return@repeat
}
PinduoduoPage.PRODUCT_DETAIL -> {
if (!driver.returnFromCandidate()) {
return retryableFailure()
}
}
PinduoduoPage.SPECIFICATION_PANEL -> {
if (!driver.closeSpecifications()) {
return retryableFailure()
}
}
PinduoduoPage.ORDER_CONFIRMATION -> {
if (!driver.returnFromOrderConfirmation()) {
return retryableFailure()
}
}
PinduoduoPage.IMAGE_SEARCH_RESULTS -> {
if (!driver.returnFromImageResults()) {
return retryableFailure()
@@ -106,9 +135,12 @@ class PinduoduoImageSearchAutomation(
}
awaitPage { page ->
page == PinduoduoPage.IMAGE_SEARCH ||
page == PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG ||
page == PinduoduoPage.IMAGE_SEARCH_RESULTS ||
page == PinduoduoPage.SEARCH_RESULTS ||
page == PinduoduoPage.SEARCH_RESULTS_OTHER_QUERY
page == PinduoduoPage.SEARCH_RESULTS_OTHER_QUERY ||
page == PinduoduoPage.PRODUCT_DETAIL ||
page == PinduoduoPage.SPECIFICATION_PANEL
}?.let { return it }
}
return AutomationResult.Blocked(SafetyStopReason.UNKNOWN_PAGE)
@@ -133,8 +165,16 @@ class PinduoduoImageSearchAutomation(
var stableUnknownObservations = 0
while (true) {
val snapshot = driver.snapshot()
safetyResult(snapshot)?.let { return it }
if (expected(snapshot.page)) {
val pageExpected = expected(snapshot.page)
val recoverableOrderConfirmation =
pageExpected &&
snapshot.page == PinduoduoPage.ORDER_CONFIRMATION &&
snapshot.safetyStopReason ==
SafetyStopReason.PAYMENT_BOUNDARY
if (!recoverableOrderConfirmation) {
safetyResult(snapshot)?.let { return it }
}
if (pageExpected) {
return null
}
stableUnknownObservations = if (
@@ -163,7 +203,7 @@ class PinduoduoImageSearchAutomation(
)
private companion object {
const val MAX_RECOVERY_ACTIONS = 4
const val MAX_RECOVERY_ACTIONS = 5
}
}
@@ -0,0 +1,392 @@
package com.roubao.autopilot.pinduoduo
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
import kotlinx.coroutines.delay
interface PinduoduoOrderDryRunDriver {
suspend fun snapshot(): PinduoduoUiSnapshot
suspend fun candidateCards(limit: Int): List<PinduoduoCandidateCard>
suspend fun openCandidate(signature: String): Boolean
suspend fun readCandidateDetail(): PinduoduoCandidateDetailEvidence?
suspend fun openSpecifications(): Boolean
suspend fun readSpecifications(): PinduoduoSpecificationEvidence?
suspend fun selectSpecificationOption(
kind: PinduoduoSpecificationGroupKind,
optionText: String
): Boolean
suspend fun scrollSpecifications(): Boolean
suspend fun closeSpecifications(): Boolean
suspend fun returnToResults(): Boolean
suspend fun scrollResults(): Boolean
suspend fun scrollResultsBackward(): Boolean
suspend fun readQuantity(): Int?
suspend fun changeQuantity(increase: Boolean): Boolean
suspend fun confirmSpecifications(): Boolean
suspend fun readOrderConfirmation(): PinduoduoOrderConfirmationEvidence?
suspend fun captureOrderConfirmation(): PinduoduoScreenshotCapture?
}
class PinduoduoOrderDryRunException(message: String) :
IllegalStateException(message)
class PinduoduoOrderDryRunAutomation(
private val driver: PinduoduoOrderDryRunDriver,
private val authorizedTitle: String,
private val authorizedCardSignature: String,
private val authorizedDetailSignature: String,
private val authorizedPriceText: String,
private val specificationTarget: PinduoduoSpecificationTarget,
private val quantity: Int,
private val maxBudgetCents: Long?,
private val pollIntervalMillis: Long = 250L,
private val pagePollLimit: Int = 24
) {
init {
require(authorizedTitle.isNotBlank())
require(SHA256_PATTERN.matches(authorizedCardSignature))
require(SHA256_PATTERN.matches(authorizedDetailSignature))
require(quantity in 1..99)
require(pollIntervalMillis > 0L)
require(pagePollLimit > 0)
}
suspend fun run(): PinduoduoOrderDryRunResult {
awaitPage(PinduoduoPage.IMAGE_SEARCH_RESULTS)
val matches = scanCandidates()
if (matches.size != 1) {
throw PinduoduoOrderDryRunException(
if (matches.isEmpty()) {
"没有唯一匹配后台授权的商品"
} else {
"多个商品同时匹配后台授权,已停止"
}
)
}
val candidate = matches.single()
relocate(candidate.cardSignature)
val detail = driver.readCandidateDetail()
?: stop("无法读取重新定位后的商品")
requireCandidateIdentity(
cardSignature = candidate.cardSignature,
detail = detail
)
val selected = enterAndVerifySpecifications()
?: stop("重新定位后的规格或价格不再匹配")
if (
selected.selectedColorText != candidate.selectedColorText ||
selected.selectedSizeText != candidate.selectedSizeText ||
selected.unitPriceCents != candidate.unitPriceCents
) {
stop("重新定位前后的规格或价格发生变化")
}
setExactQuantity()
if (!driver.confirmSpecifications()) {
stop("规格弹层中的唯一确定按钮不可用")
}
awaitPage(PinduoduoPage.ORDER_CONFIRMATION)
val confirmation = driver.readOrderConfirmation()
?: stop("订单确认页证据不完整")
verifyConfirmation(candidate, confirmation)
val screenshot = driver.captureOrderConfirmation()
?: stop("无法保存订单确认页截图")
return PinduoduoOrderDryRunResult(candidate, confirmation, screenshot)
}
private suspend fun scanCandidates(): List<PinduoduoAuthorizedCandidate> {
val attempted = linkedSetOf<String>()
val matches = mutableListOf<PinduoduoAuthorizedCandidate>()
var scrolls = 0
while (attempted.size < MAX_CANDIDATES) {
requireResultsPage()
val card = driver.candidateCards(MAX_CANDIDATES * 2)
.firstOrNull { it.signature !in attempted }
if (card == null) {
if (scrolls >= MAX_RESULT_SCROLLS || !driver.scrollResults()) {
break
}
scrolls += 1
delay(pollIntervalMillis)
continue
}
attempted += card.signature
if (!driver.openCandidate(card.signature)) {
stop("候选商品打开失败")
}
awaitPage(PinduoduoPage.PRODUCT_DETAIL)
val detail = driver.readCandidateDetail()
?: stop("候选商品详情证据缺失")
val identityMatches =
(
card.signature == authorizedCardSignature ||
detail.signature == authorizedDetailSignature
) &&
normalizedTitle(detail.observedTitle.orEmpty()) ==
normalizedTitle(authorizedTitle)
if (identityMatches) {
enterAndVerifySpecifications()?.let { selected ->
matches += PinduoduoAuthorizedCandidate(
cardSignature = card.signature,
detailSignature = detail.signature,
observedTitle = requireNotNull(detail.observedTitle),
selectedColorText = selected.selectedColorText,
selectedSizeText = selected.selectedSizeText,
unitPriceCents = selected.unitPriceCents
)
}
if (driver.snapshot().page == PinduoduoPage.SPECIFICATION_PANEL) {
if (!driver.closeSpecifications()) {
stop("无法安全关闭规格弹层")
}
awaitPage(PinduoduoPage.PRODUCT_DETAIL)
}
}
if (!driver.returnToResults()) {
stop("无法安全返回图片搜索结果")
}
awaitPage(PinduoduoPage.IMAGE_SEARCH_RESULTS)
}
return matches
}
private suspend fun enterAndVerifySpecifications(): SelectedSpecification? {
if (!driver.openSpecifications()) {
return null
}
awaitPage(PinduoduoPage.SPECIFICATION_PANEL)
val observations = mutableListOf<PinduoduoSpecificationEvidence>()
var scrolls = 0
val selections = linkedMapOf<PinduoduoSpecificationGroupKind, String>()
for ((kind, expected) in listOf(
PinduoduoSpecificationGroupKind.COLOR to specificationTarget.color,
PinduoduoSpecificationGroupKind.SIZE to specificationTarget.size
)) {
var selected = false
while (!selected) {
val evidence = driver.readSpecifications() ?: return null
observations += evidence
when (
val resolution =
PinduoduoSpecificationSelectionPolicy.resolve(
evidence,
kind,
expected
)
) {
is PinduoduoSpecificationOptionResolution.Ready -> {
if (!resolution.alreadySelected) {
if (
!driver.selectSpecificationOption(
kind,
resolution.optionText
)
) {
return null
}
delay(pollIntervalMillis)
val verified = driver.readSpecifications()
?: return null
observations += verified
val after =
PinduoduoSpecificationSelectionPolicy.resolve(
verified,
kind,
expected
) as? PinduoduoSpecificationOptionResolution.Ready
if (after?.alreadySelected != true) {
return null
}
}
selections[kind] = resolution.optionText
selected = true
}
PinduoduoSpecificationOptionResolution.Absent -> {
if (
scrolls >= MAX_SPECIFICATION_SCROLLS ||
!driver.scrollSpecifications()
) {
return null
}
scrolls += 1
delay(pollIntervalMillis)
}
PinduoduoSpecificationOptionResolution.Ambiguous,
is PinduoduoSpecificationOptionResolution.Disabled ->
return null
}
}
}
val finalEvidence = driver.readSpecifications()?.also(observations::add)
?: return null
val merged = PinduoduoSpecificationParser.merge(observations)
?: return null
val price = merged.price ?: finalEvidence.price ?: return null
if (
merged.priceAmbiguous ||
!PinduoduoAuthorizedPricePolicy.permits(
price.cents,
authorizedPriceText,
quantity,
maxBudgetCents
)
) {
return null
}
val color = requireNotNull(selections[PinduoduoSpecificationGroupKind.COLOR])
val size = requireNotNull(selections[PinduoduoSpecificationGroupKind.SIZE])
val selectedSummary = finalEvidence.selectedSummary
?: merged.selectedSummary
?: return null
if (
!normalizedTitle(selectedSummary).contains(normalizedTitle(color)) ||
!normalizedTitle(selectedSummary).contains(normalizedTitle(size))
) {
return null
}
return SelectedSpecification(color, size, price.cents)
}
private suspend fun setExactQuantity() {
var current = driver.readQuantity() ?: stop("数量控件不可访问")
var actions = 0
while (current != quantity) {
if (actions >= MAX_QUANTITY_ACTIONS) {
stop("数量调整超过安全上限")
}
val increase = current < quantity
if (!driver.changeQuantity(increase)) {
stop("数量控件不可唯一操作")
}
actions += 1
delay(pollIntervalMillis)
val observed = driver.readQuantity() ?: stop("无法复核当前数量")
val expected = current + if (increase) 1 else -1
if (observed != expected) {
stop("数量点击后状态未按一步变化")
}
current = observed
}
}
private suspend fun relocate(cardSignature: String) {
repeat(MAX_RESULT_SCROLLS + 1) { attempt ->
requireResultsPage()
if (
driver.candidateCards(MAX_CANDIDATES * 2)
.count { it.signature == cardSignature } == 1
) {
if (!driver.openCandidate(cardSignature)) {
stop("唯一候选重新打开失败")
}
awaitPage(PinduoduoPage.PRODUCT_DETAIL)
return
}
if (
attempt >= MAX_RESULT_SCROLLS ||
!driver.scrollResultsBackward()
) {
stop("无法重新定位唯一授权商品")
}
delay(pollIntervalMillis)
}
}
private fun requireCandidateIdentity(
cardSignature: String,
detail: PinduoduoCandidateDetailEvidence
) {
if (
(
cardSignature != authorizedCardSignature &&
detail.signature != authorizedDetailSignature
) ||
normalizedTitle(detail.observedTitle.orEmpty()) !=
normalizedTitle(authorizedTitle)
) {
stop("重新定位后的商品身份不一致")
}
}
private fun verifyConfirmation(
candidate: PinduoduoAuthorizedCandidate,
confirmation: PinduoduoOrderConfirmationEvidence
) {
if (
normalizedTitle(confirmation.observedTitle) !=
normalizedTitle(candidate.observedTitle)
) {
stop("订单确认页商品标题不一致")
}
val summary = normalizedTitle(confirmation.selectedSummary)
if (
!summary.contains(normalizedTitle(candidate.selectedColorText)) ||
!summary.contains(normalizedTitle(candidate.selectedSizeText))
) {
stop("订单确认页规格摘要不一致")
}
val expectedTotal = Math.multiplyExact(
candidate.unitPriceCents,
quantity.toLong()
)
if (
confirmation.quantity != quantity ||
confirmation.totalPriceCents != expectedTotal ||
(
maxBudgetCents != null &&
confirmation.totalPriceCents > maxBudgetCents
)
) {
stop("订单确认页数量或金额不一致")
}
}
private suspend fun requireResultsPage() {
awaitPage(PinduoduoPage.IMAGE_SEARCH_RESULTS)
}
private suspend fun awaitPage(expected: PinduoduoPage) {
repeat(pagePollLimit) {
val snapshot = driver.snapshot()
val paymentBoundaryExpected =
expected == PinduoduoPage.ORDER_CONFIRMATION &&
snapshot.page == PinduoduoPage.ORDER_CONFIRMATION &&
snapshot.safetyStopReason ==
com.roubao.autopilot.workflow.SafetyStopReason.PAYMENT_BOUNDARY
if (
snapshot.foregroundPackage == PINDUODUO_PACKAGE &&
snapshot.page == expected &&
(snapshot.safetyStopReason == null || paymentBoundaryExpected)
) {
return
}
if (
snapshot.foregroundPackage == PINDUODUO_PACKAGE &&
snapshot.safetyStopReason != null &&
!paymentBoundaryExpected
) {
stop("拼多多出现登录、风控或交易边界")
}
delay(pollIntervalMillis)
}
stop("拼多多页面在安全时限内未就绪")
}
private fun normalizedTitle(value: String): String =
PinduoduoOrderConfirmationParser.normalize(value)
private fun stop(message: String): Nothing =
throw PinduoduoOrderDryRunException(message)
private data class SelectedSpecification(
val selectedColorText: String,
val selectedSizeText: String,
val unitPriceCents: Long
)
private companion object {
const val MAX_CANDIDATES = 10
const val MAX_RESULT_SCROLLS = 2
const val MAX_SPECIFICATION_SCROLLS = 2
const val MAX_QUANTITY_ACTIONS = 98
val SHA256_PATTERN = Regex("^[0-9a-f]{64}$")
}
}
@@ -0,0 +1,171 @@
package com.roubao.autopilot.pinduoduo
import java.text.Normalizer
data class PinduoduoOrderConfirmationEvidence(
val observedTitle: String,
val selectedSummary: String,
val quantity: Int,
val totalPriceCents: Long
)
data class PinduoduoAuthorizedCandidate(
val cardSignature: String,
val detailSignature: String,
val observedTitle: String,
val selectedColorText: String,
val selectedSizeText: String,
val unitPriceCents: Long
)
data class PinduoduoOrderDryRunResult(
val candidate: PinduoduoAuthorizedCandidate,
val confirmation: PinduoduoOrderConfirmationEvidence,
val screenshot: PinduoduoScreenshotCapture
)
object PinduoduoOrderConfirmationParser {
fun parse(elements: Collection<PinduoduoUiElement>):
PinduoduoOrderConfirmationEvidence? {
val visible = elements.asSequence()
.filter { it.visibleToUser }
.sortedWith(
compareBy<PinduoduoUiElement> { it.boundsTop }
.thenBy { it.boundsLeft }
)
.mapNotNull(::semanticText)
.distinct()
.take(MAX_ELEMENTS + 1)
.toList()
if (visible.isEmpty() || visible.size > MAX_ELEMENTS) {
return null
}
val title = PinduoduoObservedTitlePolicy.select(visible) ?: return null
val selectedSummary = visible.singleOrNull { text ->
val normalized = normalize(text)
normalized.startsWith("已选") ||
normalized.startsWith("规格")
} ?: parseSplitSpecificationSummary(visible)
?: return null
val quantities = visible.mapNotNull(::parseQuantity).distinct()
val quantity = quantities.singleOrNull() ?: return null
val labeledTotals = visible.mapNotNull(::parseLabeledTotal).distinct()
val total = labeledTotals.singleOrNull() ?: return null
return PinduoduoOrderConfirmationEvidence(
observedTitle = title,
selectedSummary = selectedSummary,
quantity = quantity,
totalPriceCents = total
)
}
private fun semanticText(element: PinduoduoUiElement): String? =
sequenceOf(element.text, element.contentDescription)
.filterNotNull()
.map(String::trim)
.firstOrNull(String::isNotEmpty)
private fun parseQuantity(value: String): Int? {
val normalized = normalize(value)
QUANTITY_PATTERN.matchEntire(normalized)?.let { match ->
return match.groupValues[1].toIntOrNull()
?.takeIf { it in 1..99 }
}
val current = CURRENT_QUANTITY_PATTERN.matchEntire(normalized)
?: return null
val leading = current.groupValues[1].toIntOrNull() ?: return null
val labeled = current.groupValues[2].toIntOrNull() ?: return null
return leading.takeIf { it == labeled && it in 1..99 }
}
private fun parseLabeledTotal(value: String): Long? {
val normalized = Normalizer.normalize(
value.trim(),
Normalizer.Form.NFKC
).lowercase().replace(Regex("\\s+"), "")
val match = TOTAL_PATTERN.matchEntire(normalized) ?: return null
val whole = match.groupValues[1].toLongOrNull() ?: return null
val fraction = match.groupValues[2].padEnd(2, '0')
.ifEmpty { "00" }
.toLongOrNull() ?: return null
return (whole * 100L + fraction).takeIf { it in 1..MAX_TOTAL_CENTS }
}
private fun parseSplitSpecificationSummary(
visible: List<String>
): String? {
val colors = visible.mapNotNull { text ->
COLOR_SUMMARY_PATTERN.matchEntire(text.trim())
?.groupValues
?.get(1)
?.trim()
?.takeIf(String::isNotEmpty)
}.distinct()
val sizes = visible.mapNotNull { text ->
SIZE_SUMMARY_PATTERN.matchEntire(text.trim())
?.groupValues
?.get(1)
?.trim()
?.takeIf(String::isNotEmpty)
}.distinct()
val color = colors.singleOrNull() ?: return null
val size = sizes.singleOrNull() ?: return null
return "$color $size"
}
internal fun normalize(value: String): String =
Normalizer.normalize(value.trim(), Normalizer.Form.NFKC)
.lowercase()
.replace(Regex("[\\s\\p{Punct}]+"), "")
private val QUANTITY_PATTERN = Regex("""^(?:x|×)(\d{1,2})$""")
private val CURRENT_QUANTITY_PATTERN =
Regex("""^(\d{1,2})当前数量为(\d{1,2})件$""")
private val TOTAL_PATTERN = Regex(
"""^(?:商品金额|商品总额|应付|合计|实付款)[::]?[¥¥](\d{1,7})(?:\.(\d{1,2}))?(?:已享.*)?$"""
)
private val COLOR_SUMMARY_PATTERN =
Regex("""^(?:颜色分类|颜色|颜色款式)[::]\s*(.+)$""")
private val SIZE_SUMMARY_PATTERN =
Regex("""^(?:尺码|尺寸)[::]\s*(.+)$""")
private const val MAX_ELEMENTS = 180
private const val MAX_TOTAL_CENTS = 100_000_000L
}
object PinduoduoAuthorizedPricePolicy {
fun parseCandidatePrice(value: String): Long? {
val normalized = Normalizer.normalize(
value.trim(),
Normalizer.Form.NFKC
).replace(Regex("\\s+"), "")
val match = PRICE_PATTERN.matchEntire(normalized) ?: return null
val whole = match.groupValues[1].toLongOrNull() ?: return null
val fraction = match.groupValues[2].padEnd(2, '0')
.ifEmpty { "00" }
.toLongOrNull() ?: return null
return (whole * 100L + fraction).takeIf { it in 1..MAX_PRICE_CENTS }
}
fun permits(
currentUnitPriceCents: Long,
authorizedPriceText: String,
quantity: Int,
maxBudgetCents: Long?
): Boolean {
if (currentUnitPriceCents <= 0 || quantity !in 1..99) {
return false
}
val total = runCatching {
Math.multiplyExact(currentUnitPriceCents, quantity.toLong())
}.getOrNull() ?: return false
return if (maxBudgetCents == null) {
parseCandidatePrice(authorizedPriceText) == currentUnitPriceCents
} else {
total <= maxBudgetCents
}
}
private val PRICE_PATTERN =
Regex("""^(?:首件)?[¥¥]?(\d{1,7})(?:\.(\d{1,2}))?$""")
private const val MAX_PRICE_CENTS = 100_000_000L
}
@@ -28,6 +28,7 @@ enum class PinduoduoPage {
SEARCH_RESULTS,
SEARCH_RESULTS_OTHER_QUERY,
IMAGE_SEARCH,
IMAGE_SEARCH_RETRY_DIALOG,
IMAGE_SEARCH_RESULTS,
PRODUCT_DETAIL,
SPECIFICATION_PANEL,
@@ -42,6 +43,30 @@ data class PinduoduoUiSnapshot(
val safetyStopReason: SafetyStopReason?
)
object PinduoduoCollapsedCheckoutPolicy {
fun matches(elements: Collection<PinduoduoUiElement>): Boolean {
val visible = elements.filter { it.visibleToUser && it.enabled }
val hasTopBack = visible.any { element ->
element.contentDescription == "返回" &&
element.boundsTop < MAX_BACK_TOP
}
val fullCheckoutWebViewCount = visible.count { element ->
element.className.endsWith("WebView") &&
element.boundsLeft <= MAX_WEBVIEW_LEFT &&
element.boundsTop <= MAX_WEBVIEW_TOP &&
element.boundsRight >= MIN_WEBVIEW_RIGHT &&
element.boundsBottom >= MIN_WEBVIEW_BOTTOM
}
return hasTopBack && fullCheckoutWebViewCount in 1..2
}
private const val MAX_BACK_TOP = 320
private const val MAX_WEBVIEW_LEFT = 8
private const val MAX_WEBVIEW_TOP = 420
private const val MIN_WEBVIEW_RIGHT = 1_000
private const val MIN_WEBVIEW_BOTTOM = 2_000
}
object PinduoduoPageClassifier {
private val legacyResultSortMarkers = setOf("综合", "销量", "价格", "筛选")
private val modernResultCategoryMarkers = setOf(
@@ -62,7 +87,16 @@ object PinduoduoPageClassifier {
"提交订单",
"确认支付",
"立即支付",
"收银台"
"收银台",
"微信支付",
"找好友支付",
"更多支付方式"
)
private val paymentMethodMarkers = setOf(
"微信支付",
"先用后付",
"找好友支付",
"支付宝"
)
fun classify(
@@ -79,6 +113,9 @@ object PinduoduoPageClassifier {
}
val visibleElements = elements.filter { it.visibleToUser && it.enabled }
val hasCheckoutRoot = visibleElements.any { element ->
element.resourceId == "order_checkout"
}
val visibleTexts = visibleElements.flatMap { element ->
listOfNotNull(element.text, element.contentDescription)
}
@@ -88,7 +125,10 @@ object PinduoduoPageClassifier {
LoginBlocker.RISK_CONTROL -> SafetyStopReason.RISK_CONTROL
LoginBlocker.NONE,
LoginBlocker.UNKNOWN -> {
if (visibleTexts.containsAny(paymentMarkers)) {
if (
hasCheckoutRoot ||
visibleTexts.containsAny(paymentMarkers)
) {
SafetyStopReason.PAYMENT_BOUNDARY
} else {
null
@@ -141,6 +181,10 @@ object PinduoduoPageClassifier {
text == "开启相机权限" ||
text.contains("即可进行自动识别")
}
val hasImageSearchRetryDialog =
normalized.any { it == "请对准商品或码,保持手机稳定" } &&
normalized.any { it == "取消" } &&
normalized.any { it == "再试一次" }
val hasImageResultHeader = normalized.any { it == "搜图片同款" }
val hasExpectedQuery = visibleTexts.any { text ->
matchesExpectedQuery(text, expectedQuery)
@@ -181,7 +225,16 @@ object PinduoduoPageClassifier {
specificationGroupCount >= 2 &&
specificationOptionCount >= 2
)
val hasOrderConfirmation = normalized.any { it == "确认订单" }
val hasOrderConfirmation =
hasCheckoutRoot ||
normalized.any { it == "确认订单" } ||
(
normalized.any { it.contains("收货地址") } &&
paymentMethodMarkers.count { marker ->
normalized.any { it.contains(marker) }
} >= 2 &&
normalized.any { it.startsWith("实付款¥") }
)
val hasOrderList =
normalized.any { it == "我的订单" || it == "全部订单" } &&
setOf("待付款", "待发货", "待收货")
@@ -193,6 +246,8 @@ object PinduoduoPageClassifier {
hasSpecificationPanel -> PinduoduoPage.SPECIFICATION_PANEL
hasImageResultHeader && legacySortControlCount >= 3 ->
PinduoduoPage.IMAGE_SEARCH_RESULTS
hasImageSearchRetryDialog ->
PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG
hasImageSearchPage -> PinduoduoPage.IMAGE_SEARCH
hasExpectedQuery && hasResultSearchHeader && hasResultControls ->
PinduoduoPage.SEARCH_RESULTS
@@ -43,7 +43,8 @@ object OrderCommandIntegrity {
) { "后台订单命令指纹无效" }
require(
command.authorizationStatus == "DELIVERED" ||
command.authorizationStatus == "ACKNOWLEDGED"
command.authorizationStatus == "ACKNOWLEDGED" ||
command.authorizationStatus == "EXECUTING"
) { "后台订单命令状态无效" }
require(sha256(command) == command.commandSha256) {
"后台订单命令完整性校验失败"
@@ -18,7 +18,9 @@ object OrderCommandSynchronization {
val delivered = pull() ?: return null
OrderCommandIntegrity.validate(delivered, task, execution)
command = delivered.copy(
acknowledgementIdempotencyKey = newIdempotencyKey()
acknowledgementIdempotencyKey = newIdempotencyKey(),
acknowledged =
delivered.authorizationStatus == "EXECUTING"
)
persist(command)
} else {
@@ -0,0 +1,97 @@
package com.roubao.autopilot.procurement
import android.content.Context
import com.roubao.autopilot.pinduoduo.AndroidPinduoduoImageSearchDriver
import com.roubao.autopilot.pinduoduo.AndroidPinduoduoOrderDryRunDriver
import com.roubao.autopilot.pinduoduo.PinduoduoEvidenceHash
import com.roubao.autopilot.pinduoduo.PinduoduoImageSearchAssetStore
import com.roubao.autopilot.pinduoduo.PinduoduoImageSearchAutomation
import com.roubao.autopilot.pinduoduo.PinduoduoImageSearchWorkflow
import com.roubao.autopilot.pinduoduo.PinduoduoOrderDryRunAutomation
import com.roubao.autopilot.pinduoduo.PinduoduoSpecificationTarget
import com.roubao.autopilot.vlm.SkuConstraintKind
import com.roubao.autopilot.vlm.SkuHardConstraintExtractor
import com.roubao.autopilot.workflow.WorkflowRunner
import com.roubao.autopilot.workflow.WorkflowState
class OrderDryRunCoordinator(
context: Context,
private val repository: ProcurementRepository
) {
private val appContext = context.applicationContext
suspend fun runOnce(): Boolean {
val context = repository.prepareOrderDryRun() ?: return false
val constraints = SkuHardConstraintExtractor.extract(
context.command.originalSku
)
if (!constraints.readyForAutomaticMatching) {
throw IllegalStateException("SKU 的颜色或尺码无法唯一解析")
}
val values = constraints.constraints.associate { it.kind to it.expected }
val target = PinduoduoSpecificationTarget(
color = requireNotNull(values[SkuConstraintKind.COLOR]),
size = requireNotNull(values[SkuConstraintKind.SIZE])
)
val assetStore = PinduoduoImageSearchAssetStore(appContext)
val prepared = assetStore.prepare(
context.referenceImageBytes,
context.task.toProbeTask(context.referenceImage).referenceImage
) ?: throw IllegalStateException("无法准备拼多多参考图片")
try {
val searchRunner = WorkflowRunner(
PinduoduoImageSearchAutomation(
AndroidPinduoduoImageSearchDriver(
context = appContext,
assetStore = assetStore,
preparedImage = prepared
)
)
)
val searchReport = searchRunner.run(
PinduoduoImageSearchWorkflow.steps()
)
if (searchReport.state != WorkflowState.SUCCEEDED) {
throw IllegalStateException("拼多多图片搜索未到达可信结果页")
}
val result = PinduoduoOrderDryRunAutomation(
driver = AndroidPinduoduoOrderDryRunDriver(
authorizedTitle = context.command.candidate.title,
expectedColor = target.color,
expectedSize = target.size,
expectedQuantity = context.command.quantity
),
authorizedTitle = context.command.candidate.title,
authorizedCardSignature =
context.command.candidate.cardSignature,
authorizedDetailSignature =
context.command.candidate.detailSignature,
authorizedPriceText = context.command.candidate.priceText,
specificationTarget = target,
quantity = context.command.quantity,
maxBudgetCents = context.maxBudgetCents
).run()
val screenshotBytes = result.screenshot.pngBytes
val queued = repository.queueOrderDryRunReady(
OrderDryRunReadyDraft(
cardSignature = result.candidate.cardSignature,
detailSignature = result.candidate.detailSignature,
observedTitle = result.confirmation.observedTitle,
selectedSku = context.command.originalSku,
quantity = result.confirmation.quantity,
unitPriceCents = result.candidate.unitPriceCents,
totalPriceCents = result.confirmation.totalPriceCents,
orderConfirmationPng = screenshotBytes,
evidenceSha256 =
PinduoduoEvidenceHash.sha256(screenshotBytes)
)
)
if (queued) {
repository.synchronizeNow()
}
return queued
} finally {
assetStore.delete(prepared)
}
}
}
@@ -26,7 +26,9 @@ data class DownloadedReferenceImage(
)
data class ExecutionOutboxUploadResult(
val evidenceAssetId: String? = null
val evidenceAssetId: String? = null,
val evidenceSha256: String? = null,
val orderDryRun: RemoteOrderDryRun? = null
)
interface ProcurementRemoteApi {
@@ -100,6 +102,15 @@ interface ProcurementRemoteApi {
idempotencyKey: String
): OrderCommandAcknowledgement
suspend fun startOrderDryRun(
session: ProcurementSession,
task: RemotePurchaseTask,
execution: RunningExecution,
claimToken: String,
command: PendingOrderCommand,
idempotencyKey: String
): RemoteOrderDryRun
suspend fun uploadExecutionOutboxItem(
session: ProcurementSession,
task: RemotePurchaseTask,
@@ -291,6 +302,9 @@ class ProcurementApiClient(
ExecutionOutboxType.EVENTS -> "/api/v1/tasks/${task.id}/events"
ExecutionOutboxType.EVIDENCE -> "/api/v1/tasks/${task.id}/evidence"
ExecutionOutboxType.CANDIDATES -> "/api/v1/tasks/${task.id}/candidates"
ExecutionOutboxType.ORDER_DRY_RUN_READY ->
"/api/v1/tasks/${task.id}/order-dry-runs/" +
"${requireNotNull(taskOrderCommandId(item.payload))}/ready"
ExecutionOutboxType.HUMAN_REVIEW ->
"/api/v1/tasks/${task.id}/human-reviews"
ExecutionOutboxType.COMPLETE -> "/api/v1/tasks/${task.id}/complete"
@@ -309,7 +323,15 @@ class ProcurementApiClient(
.header("X-Claim-Generation", task.claimGeneration.toString())
.post(bytes.toRequestBody(PNG_MEDIA_TYPE))
} else {
val payload = item.payload
val payload = if (
item.type == ExecutionOutboxType.ORDER_DRY_RUN_READY
) {
JSONObject(item.payload)
.apply { remove("command_id") }
.toString()
} else {
item.payload
}
require(payload.toByteArray(Charsets.UTF_8).size <= MAX_JSON_BYTES) {
"离线结果过大"
}
@@ -321,7 +343,18 @@ class ProcurementApiClient(
json.getJSONObject("evidence").getString("id")
} else {
null
}
},
evidenceSha256 = if (item.type == ExecutionOutboxType.EVIDENCE) {
json.getJSONObject("evidence").getString("sha256")
} else {
null
},
orderDryRun =
if (item.type == ExecutionOutboxType.ORDER_DRY_RUN_READY) {
parseOrderDryRun(json.getJSONObject("dry_run"))
} else {
null
}
)
}
@@ -472,6 +505,33 @@ class ProcurementApiClient(
)
}
override suspend fun startOrderDryRun(
session: ProcurementSession,
task: RemotePurchaseTask,
execution: RunningExecution,
claimToken: String,
command: PendingOrderCommand,
idempotencyKey: String
): RemoteOrderDryRun {
val payload = JSONObject()
.put("device_id", session.deviceId)
.put("execution_id", execution.id)
.put("claim_generation", task.claimGeneration)
.put("command_id", command.id)
.put("command_sha256", command.commandSha256)
val json = executeJson(
authorizedRequest(
session,
"/api/v1/tasks/${task.id}/order-dry-runs/start"
)
.header(CLAIM_TOKEN_HEADER, claimToken)
.header(IDEMPOTENCY_HEADER, idempotencyKey)
.post(payload.jsonBody())
.build()
)
return parseOrderDryRun(json.getJSONObject("dry_run"))
}
private suspend fun executeTransition(
session: ProcurementSession,
task: RemotePurchaseTask,
@@ -630,6 +690,17 @@ class ProcurementApiClient(
)
}
private fun parseOrderDryRun(json: JSONObject): RemoteOrderDryRun =
RemoteOrderDryRun(
id = json.getString("id"),
commandId = json.getString("command_id"),
commandSha256 = json.getString("command_sha256"),
status = json.getString("status")
)
private fun taskOrderCommandId(payload: String): String? =
runCatching { JSONObject(payload).getString("command_id") }.getOrNull()
private fun JSONObject.jsonBody() =
toString().toRequestBody(JSON_MEDIA_TYPE)
@@ -8,11 +8,13 @@ import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import com.roubao.autopilot.App
import com.roubao.autopilot.MainActivity
import com.roubao.autopilot.R
import com.roubao.autopilot.readiness.DeviceReadinessChecker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -36,12 +38,30 @@ class ProcurementExecutionService : Service() {
if (heartbeatJob?.isActive != true) {
heartbeatJob = scope.launch {
val repository = (application as App).procurementRepository
val readiness = DeviceReadinessChecker(applicationContext)
val orderDryRun = OrderDryRunCoordinator(
applicationContext,
repository
)
while (isActive) {
val decision = repository.synchronizeRunning()
if (decision == ExecutionSyncDecision.STOP) {
stopSelf()
break
}
if (
repository.shouldRunOrderDryRun &&
readiness.snapshot().canStartProbe
) {
runCatching { orderDryRun.runOnce() }
.onFailure { error ->
Log.w(
TAG,
"Authorized order dry-run stopped safely",
error
)
}
}
delay(HEARTBEAT_INTERVAL_MS)
}
}
@@ -90,6 +110,7 @@ class ProcurementExecutionService : Service() {
}
companion object {
private const val TAG = "ProcurementExecution"
private const val CHANNEL_ID = "procurement_execution"
private const val NOTIFICATION_ID = 206
private const val HEARTBEAT_INTERVAL_MS = 30_000L
@@ -11,6 +11,8 @@ enum class ProcurementPhase {
RUNNING,
WAITING_ADMIN_CONFIRMATION,
ORDER_AUTHORIZED,
ORDER_DRY_RUN_RUNNING,
ORDER_DRY_RUN_READY,
AUTHORIZATION_EXPIRED
}
@@ -87,7 +89,8 @@ data class PersistedProcurementState(
val claim: ClaimContext? = null,
val execution: RunningExecution? = null,
val outbox: List<ExecutionOutboxItem> = emptyList(),
val orderCommand: PendingOrderCommand? = null
val orderCommand: PendingOrderCommand? = null,
val orderDryRun: PendingOrderDryRun? = null
)
data class OrderCommandCandidate(
@@ -125,6 +128,59 @@ data class OrderCommandAcknowledgement(
val replayed: Boolean
)
enum class OrderDryRunStatus {
INTENT_SAVED,
PREPARING,
READY_LOCAL,
READY
}
data class PendingOrderDryRun(
val commandId: String,
val commandSha256: String,
val status: OrderDryRunStatus,
val startIdempotencyKey: String,
val readyIdempotencyKey: String,
val remoteId: String? = null,
val attemptCount: Int = 0,
val lastAttemptAtEpochMillis: Long? = null,
val cardSignature: String? = null,
val detailSignature: String? = null,
val observedTitle: String? = null,
val selectedSku: String? = null,
val quantity: Int? = null,
val unitPriceCents: Long? = null,
val totalPriceCents: Long? = null,
val evidenceSha256: String? = null
)
data class RemoteOrderDryRun(
val id: String,
val commandId: String,
val commandSha256: String,
val status: String
)
data class OrderDryRunReadyDraft(
val cardSignature: String,
val detailSignature: String,
val observedTitle: String,
val selectedSku: String,
val quantity: Int,
val unitPriceCents: Long,
val totalPriceCents: Long,
val orderConfirmationPng: ByteArray,
val evidenceSha256: String
)
data class AuthorizedOrderDryRunContext(
val task: RemotePurchaseTask,
val command: PendingOrderCommand,
val referenceImage: ReferenceImageRecord,
val referenceImageBytes: ByteArray,
val maxBudgetCents: Long?
)
enum class ExecutionMode {
MANUAL_FIRST,
AI_ASSISTED
@@ -143,6 +199,7 @@ enum class ExecutionOutboxType {
EVENTS,
EVIDENCE,
CANDIDATES,
ORDER_DRY_RUN_READY,
HUMAN_REVIEW,
COMPLETE,
FAIL
@@ -154,7 +211,8 @@ data class ExecutionOutboxItem(
val idempotencyKey: String,
val payload: String,
val evidenceRelativePath: String? = null,
val remoteResourceID: String? = null
val remoteResourceID: String? = null,
val remoteSHA256: String? = null
)
data class ProcurementUiState(
@@ -164,6 +222,7 @@ data class ProcurementUiState(
val referenceImagePath: String? = null,
val execution: RunningExecution? = null,
val orderCommand: PendingOrderCommand? = null,
val orderDryRun: PendingOrderDryRun? = null,
val authenticationRequired: Boolean = false,
val busy: Boolean = false,
val backendOnline: Boolean? = null,
@@ -3,6 +3,7 @@ package com.roubao.autopilot.procurement
import android.content.Context
import android.graphics.BitmapFactory
import android.os.Build
import android.util.Log
import com.roubao.autopilot.BuildConfig
import com.roubao.autopilot.readiness.DeviceReadinessSnapshot
import kotlinx.coroutines.flow.MutableStateFlow
@@ -57,6 +58,20 @@ class ProcurementRepository(
persisted.orderCommand == null
}
val shouldRunOrderDryRun: Boolean
get() {
val execution = persisted.execution ?: return false
val command = persisted.orderCommand ?: return false
val dryRun = persisted.orderDryRun
return storageFailure == null &&
command.acknowledged &&
!execution.safetyStopped &&
!execution.isExpired() &&
dryRun?.status != OrderDryRunStatus.READY &&
dryRun?.status != OrderDryRunStatus.READY_LOCAL &&
(dryRun?.attemptCount ?: 0) < MAX_ORDER_DRY_RUN_ATTEMPTS
}
suspend fun login(input: LoginInput): Boolean = operation {
require(input.password.isNotEmpty()) { "请输入采购员密码" }
val normalizedUrl = BackendEndpointPolicy.normalize(
@@ -387,6 +402,14 @@ class ProcurementRepository(
}
}
} catch (error: Exception) {
val diagnostic = if (error is ProcurementApiException) {
"Procurement synchronization failed: " +
"code=${error.code}, status=${error.statusCode}, " +
"retryable=${error.retryable}"
} else {
"Procurement synchronization failed"
}
Log.w(LOG_TAG, diagnostic, error)
val localFailure =
error is IllegalArgumentException ||
error is IllegalStateException
@@ -428,6 +451,209 @@ class ProcurementRepository(
?.readBytes()
}
suspend fun prepareOrderDryRun(): AuthorizedOrderDryRunContext? = operation {
val session = requireValidSession()
val claim = requireNotNull(persisted.claim) { "没有可执行的采购任务" }
val task = requireNotNull(claim.task) { "任务详情尚未下载" }
val execution = requireActiveExecution()
val command = requireNotNull(persisted.orderCommand) {
"尚未收到后台下单命令"
}
require(command.acknowledged) { "下单命令尚未安全确认" }
require(!execution.safetyStopped && !execution.isExpired()) {
"执行授权已到期,不能继续订单核验"
}
require(command.taskId == task.id && command.executionId == execution.id) {
"下单命令与当前任务不一致"
}
val reference = requireNotNull(claim.referenceImage) { "参考图缺失" }
val referenceFile = File(appContext.filesDir, reference.relativePath)
require(referenceFile.isFile && referenceFile.length() == reference.sizeBytes) {
"参考图文件缺失"
}
val referenceBytes = referenceFile.readBytes()
require(sha256(referenceBytes) == reference.sha256) { "参考图哈希不一致" }
var dryRun = persisted.orderDryRun
if (dryRun == null) {
dryRun = PendingOrderDryRun(
commandId = command.id,
commandSha256 = command.commandSha256,
status = OrderDryRunStatus.INTENT_SAVED,
startIdempotencyKey = newOpaqueSecret(),
readyIdempotencyKey = newOpaqueSecret()
)
persisted = persisted.copy(orderDryRun = dryRun)
store.save(persisted)
}
require(
dryRun.commandId == command.id &&
dryRun.commandSha256 == command.commandSha256
) { "本地下单预演与后台命令不一致" }
require(dryRun.status != OrderDryRunStatus.READY) { "订单已完成核验" }
require(dryRun.status != OrderDryRunStatus.READY_LOCAL) {
"订单核验证据正在回传"
}
require(dryRun.attemptCount < MAX_ORDER_DRY_RUN_ATTEMPTS) {
"订单核验已达到最大尝试次数"
}
dryRun = dryRun.copy(
attemptCount = dryRun.attemptCount + 1,
lastAttemptAtEpochMillis = System.currentTimeMillis()
)
persisted = persisted.copy(orderDryRun = dryRun)
store.save(persisted)
val remote = api.startOrderDryRun(
session = session,
task = task,
execution = execution,
claimToken = claim.token,
command = command,
idempotencyKey = dryRun.startIdempotencyKey
)
require(
remote.commandId == command.id &&
remote.commandSha256 == command.commandSha256 &&
remote.status in setOf("PREPARING", "READY")
) { "后台下单预演状态无效" }
dryRun = dryRun.copy(
status = if (remote.status == "READY") {
OrderDryRunStatus.READY
} else {
OrderDryRunStatus.PREPARING
},
remoteId = remote.id
)
persisted = persisted.copy(
execution = execution.copy(currentStep = ORDER_DRY_RUN_STEP),
orderDryRun = dryRun
)
saveAndPublish(
backendOnline = true,
message = if (remote.status == "READY") {
"订单已完成核验"
} else {
"正在重新定位已授权商品"
}
)
if (remote.status == "READY") {
return@operation null
}
AuthorizedOrderDryRunContext(
task = task,
command = command,
referenceImage = reference,
referenceImageBytes = referenceBytes,
maxBudgetCents = parseBudgetCents(task.maxBudget)
)
}
suspend fun queueOrderDryRunReady(
draft: OrderDryRunReadyDraft
): Boolean = operation {
val task = requireCurrentTask()
val execution = requireActiveExecution()
val command = requireNotNull(persisted.orderCommand) {
"尚未收到后台下单命令"
}
val dryRun = requireNotNull(persisted.orderDryRun) {
"下单预演尚未开始"
}
require(dryRun.status == OrderDryRunStatus.PREPARING) {
"下单预演状态不能提交核验证据"
}
require(!execution.safetyStopped && !execution.isExpired()) {
"执行授权已到期,不能提交订单核验"
}
require(
draft.cardSignature == command.candidate.cardSignature ||
draft.detailSignature == command.candidate.detailSignature
) { "当前商品与后台授权商品的语义指纹不一致" }
require(
normalizeTitle(draft.observedTitle) ==
normalizeTitle(command.candidate.title)
) { "当前商品标题与后台授权商品不一致" }
require(draft.selectedSku.trim() == command.originalSku.trim()) {
"当前规格与后台授权规格不一致"
}
require(draft.quantity == command.quantity && draft.quantity in 1..99) {
"当前数量与后台授权数量不一致"
}
require(
draft.unitPriceCents > 0 &&
draft.totalPriceCents ==
Math.multiplyExact(draft.unitPriceCents, draft.quantity.toLong())
) { "当前价格或总额无效" }
parseBudgetCents(task.maxBudget)?.let { budgetCents ->
require(draft.totalPriceCents <= budgetCents) {
"当前总额超过任务预算"
}
}
require(
draft.orderConfirmationPng.isNotEmpty() &&
sha256(draft.orderConfirmationPng) == draft.evidenceSha256
) { "订单确认页截图证据无效" }
require(persisted.outbox.none {
it.type == ExecutionOutboxType.ORDER_DRY_RUN_READY
}) { "订单核验证据已在回传队列" }
val localEvidenceID = UUID.randomUUID().toString()
persistEvidenceLocked(localEvidenceID, draft.orderConfirmationPng)
appendOutboxLocked(
ExecutionOutboxItem(
id = localEvidenceID,
type = ExecutionOutboxType.EVIDENCE,
idempotencyKey = newOpaqueSecret(),
payload = "{}",
evidenceRelativePath = "$OUTBOX_DIRECTORY/$localEvidenceID.png"
)
)
val readyPayload = JSONObject()
.put("device_id", requireNotNull(persisted.session).deviceId)
.put("execution_id", execution.id)
.put("claim_generation", task.claimGeneration)
.put("command_id", command.id)
.put("command_sha256", command.commandSha256)
.put("card_signature", draft.cardSignature)
.put("detail_signature", draft.detailSignature)
.put("observed_title", draft.observedTitle.trim())
.put("selected_sku", draft.selectedSku.trim())
.put("quantity", draft.quantity)
.put("unit_price_cents", draft.unitPriceCents)
.put("total_price_cents", draft.totalPriceCents)
.put("evidence_asset_id", localEvidenceID)
.put("evidence_sha256", "sha256:$localEvidenceID")
appendOutboxLocked(
ExecutionOutboxItem(
id = UUID.randomUUID().toString(),
type = ExecutionOutboxType.ORDER_DRY_RUN_READY,
idempotencyKey = dryRun.readyIdempotencyKey,
payload = readyPayload.toString()
)
)
persisted = persisted.copy(
execution = execution.copy(currentStep = ORDER_DRY_RUN_STEP),
orderDryRun = dryRun.copy(
status = OrderDryRunStatus.READY_LOCAL,
cardSignature = draft.cardSignature,
detailSignature = draft.detailSignature,
observedTitle = draft.observedTitle.trim(),
selectedSku = draft.selectedSku.trim(),
quantity = draft.quantity,
unitPriceCents = draft.unitPriceCents,
totalPriceCents = draft.totalPriceCents,
evidenceSha256 = draft.evidenceSha256
)
)
saveAndPublish(
backendOnline = _uiState.value.backendOnline,
message = "订单核验证据已加入加密回传队列"
)
true
} ?: false
suspend fun queueCandidateBatch(
batch: ExecutionCandidateBatchDraft,
evidence: List<ExecutionEvidenceDraft>
@@ -776,7 +1002,8 @@ class ProcurementRepository(
claim = null,
execution = null,
outbox = emptyList(),
orderCommand = null
orderCommand = null,
orderDryRun = null
)
}
@@ -795,7 +1022,8 @@ class ProcurementRepository(
resolvedPayload = replaceEvidenceReference(
resolvedPayload,
knownEvidence.id,
knownEvidence.remoteResourceID
knownEvidence.remoteResourceID,
knownEvidence.remoteSHA256
)
}
}
@@ -899,28 +1127,55 @@ class ProcurementRepository(
}
file.readBytes()
}
val uploadItem = if (item.type == ExecutionOutboxType.EVIDENCE) {
item
} else {
persisted.outbox
.filter {
it.type == ExecutionOutboxType.EVIDENCE &&
it.remoteResourceID != null &&
it.remoteSHA256 != null
}
.fold(item) { resolved, evidence ->
resolved.copy(
payload = replaceEvidenceReference(
resolved.payload,
evidence.id,
requireNotNull(evidence.remoteResourceID),
evidence.remoteSHA256
)
)
}
}
val uploaded = api.uploadExecutionOutboxItem(
session = session,
task = task,
execution = execution,
claimToken = requireNotNull(persisted.claim).token,
item = item,
item = uploadItem,
evidenceBytes = evidenceBytes
)
if (item.type == ExecutionOutboxType.EVIDENCE) {
val evidenceID = requireNotNull(uploaded.evidenceAssetId) {
"后台未返回证据编号"
}
val evidenceSHA256 = requireNotNull(uploaded.evidenceSha256) {
"后台未返回证据哈希"
}
persisted = persisted.copy(
outbox = persisted.outbox.map { pending ->
if (pending.id == item.id) {
pending.copy(remoteResourceID = evidenceID)
pending.copy(
remoteResourceID = evidenceID,
remoteSHA256 = evidenceSHA256
)
} else {
pending.copy(
payload = replaceEvidenceReference(
pending.payload,
item.id,
evidenceID
evidenceID,
evidenceSHA256
)
)
}
@@ -941,6 +1196,26 @@ class ProcurementRepository(
store.save(persisted)
return true
}
if (item.type == ExecutionOutboxType.ORDER_DRY_RUN_READY) {
val remote = requireNotNull(uploaded.orderDryRun) {
"后台未返回订单核验状态"
}
val command = requireNotNull(persisted.orderCommand)
require(
remote.commandId == command.id &&
remote.commandSha256 == command.commandSha256 &&
remote.status == "READY"
) { "后台订单核验状态无效" }
persisted = persisted.copy(
execution = execution.copy(
currentStep = ORDER_DRY_RUN_READY_STEP
),
orderDryRun = requireNotNull(persisted.orderDryRun).copy(
status = OrderDryRunStatus.READY,
remoteId = remote.id
)
)
}
store.save(persisted)
}
}
@@ -948,14 +1223,37 @@ class ProcurementRepository(
private fun replaceEvidenceReference(
payload: String,
localID: String,
remoteID: String
remoteID: String,
remoteSHA256: String?
): String {
val root = JSONObject(payload)
fun replace(value: Any?) {
when (value) {
is JSONObject -> {
val keys = value.keys().asSequence().toList()
keys.forEach { key -> replace(value.opt(key)) }
keys.forEach { key ->
when (value.optString(key)) {
localID -> value.put(key, remoteID)
"sha256:$localID" -> {
if (remoteSHA256 != null) {
value.put(key, remoteSHA256)
}
}
else -> replace(value.opt(key))
}
}
value.optJSONArray("evidence_asset_ids")?.let { ids ->
when {
ids.optString(0) == remoteID && remoteSHA256 != null ->
value.put("detail_evidence_sha256", remoteSHA256)
ids.optString(1) == remoteID && remoteSHA256 != null ->
value.put(
"specification_evidence_sha256",
remoteSHA256
)
else -> Unit
}
}
}
is JSONArray -> {
for (index in 0 until value.length()) {
@@ -973,7 +1271,11 @@ class ProcurementRepository(
}
private fun candidateJSON(candidate: ExecutionCandidateDraft): JSONObject =
JSONObject()
JSONObject().also {
require(candidate.evidenceLocalIDs.size == 2) {
"候选截图关联无效"
}
}
.put("ordinal", candidate.ordinal)
.put("title", candidate.title)
.put("sku_text", candidate.skuText)
@@ -984,11 +1286,11 @@ class ProcurementRepository(
.put("detail_signature", candidate.detailSignature)
.put(
"detail_evidence_sha256",
candidate.detailEvidenceSha256
"sha256:${candidate.evidenceLocalIDs[0]}"
)
.put(
"specification_evidence_sha256",
candidate.specificationEvidenceSha256
"sha256:${candidate.evidenceLocalIDs[1]}"
)
.put("evidence_asset_ids", JSONArray(candidate.evidenceLocalIDs))
.also { json ->
@@ -1099,6 +1401,10 @@ class ProcurementRepository(
execution != null &&
(execution.safetyStopped || execution.isExpired()) ->
ProcurementPhase.AUTHORIZATION_EXPIRED
persisted.orderDryRun?.status == OrderDryRunStatus.READY ->
ProcurementPhase.ORDER_DRY_RUN_READY
persisted.orderDryRun != null ->
ProcurementPhase.ORDER_DRY_RUN_RUNNING
persisted.orderCommand?.acknowledged == true ->
ProcurementPhase.ORDER_AUTHORIZED
execution?.currentStep == WAITING_ADMIN_CONFIRMATION_STEP ->
@@ -1125,6 +1431,7 @@ class ProcurementRepository(
referenceImagePath = imagePath,
execution = execution,
orderCommand = persisted.orderCommand,
orderDryRun = persisted.orderDryRun,
authenticationRequired = session?.isValid() != true
)
}
@@ -1158,18 +1465,37 @@ class ProcurementRepository(
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
private fun parseBudgetCents(value: String?): Long? {
val normalized = value?.trim()?.takeIf(String::isNotEmpty) ?: return null
require(BUDGET_PATTERN.matches(normalized)) { "任务预算格式无效" }
val parts = normalized.split('.', limit = 2)
val whole = parts[0].toLong()
val fraction = parts.getOrNull(1)?.padEnd(2, '0')?.toLong() ?: 0L
return Math.addExact(Math.multiplyExact(whole, 100L), fraction)
}
private fun normalizeTitle(value: String): String =
value.trim().lowercase().replace(TITLE_SPACE_PATTERN, "")
companion object {
private const val LOG_TAG = "ProcurementRepository"
private const val REFERENCE_DIRECTORY = "procurement"
private const val OUTBOX_DIRECTORY = "procurement-outbox"
private const val CONTROLLED_WORKFLOW_STEP = "CONTROLLED_WORKFLOW"
private const val WAITING_ADMIN_CONFIRMATION_STEP =
"WAITING_ADMIN_CONFIRMATION"
private const val ORDER_AUTHORIZED_STEP = "ORDER_AUTHORIZED"
private const val ORDER_DRY_RUN_STEP = "ORDER_DRY_RUN"
private const val ORDER_DRY_RUN_READY_STEP = "ORDER_DRY_RUN_READY"
private const val SAFE_STOPPED_STEP = "SAFE_STOPPED"
private const val MAX_ORDER_DRY_RUN_ATTEMPTS = 5
private const val MAX_REFERENCE_DIMENSION = 4_096
private const val MAX_REFERENCE_PIXELS = 20_000_000L
private const val MAX_OUTBOX_EVIDENCE_BYTES = 8L * 1024L * 1024L
private val SHA256_PATTERN = Regex("^[0-9a-f]{64}$")
private val BUDGET_PATTERN =
Regex("^(0|[1-9][0-9]{0,9})(\\.[0-9]{1,2})?$")
private val TITLE_SPACE_PATTERN = Regex("[\\s\\p{Punct}]+")
private val COMPLETE_OUTCOMES = setOf(
"CANDIDATE_ACCEPTED",
"CANDIDATE_REJECTED",
@@ -129,6 +129,7 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
"remote_resource_id",
item.remoteResourceID
)
putNullable("remote_sha256", item.remoteSHA256)
}
)
}
@@ -137,6 +138,9 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
state.orderCommand?.let { command ->
put("order_command", encodeOrderCommand(command))
}
state.orderDryRun?.let { dryRun ->
put("order_dry_run", encodeOrderDryRun(dryRun))
}
}
private fun decodeState(json: JSONObject): PersistedProcurementState =
@@ -213,7 +217,9 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
evidenceRelativePath =
item.optionalString("evidence_relative_path"),
remoteResourceID =
item.optionalString("remote_resource_id")
item.optionalString("remote_resource_id"),
remoteSHA256 =
item.optionalString("remote_sha256")
)
)
}
@@ -221,9 +227,67 @@ class ProcurementSecureStore(context: Context) : ProcurementStateStore {
} ?: emptyList(),
orderCommand = json.optionalObject("order_command")?.let {
decodeOrderCommand(it)
},
orderDryRun = json.optionalObject("order_dry_run")?.let {
decodeOrderDryRun(it)
}
)
private fun encodeOrderDryRun(dryRun: PendingOrderDryRun): JSONObject =
JSONObject().apply {
put("command_id", dryRun.commandId)
put("command_sha256", dryRun.commandSha256)
put("status", dryRun.status.name)
put("start_idempotency_key", dryRun.startIdempotencyKey)
put("ready_idempotency_key", dryRun.readyIdempotencyKey)
putNullable("remote_id", dryRun.remoteId)
put("attempt_count", dryRun.attemptCount)
dryRun.lastAttemptAtEpochMillis?.let {
put("last_attempt_at_ms", it)
}
putNullable("card_signature", dryRun.cardSignature)
putNullable("detail_signature", dryRun.detailSignature)
putNullable("observed_title", dryRun.observedTitle)
putNullable("selected_sku", dryRun.selectedSku)
dryRun.quantity?.let { put("quantity", it) }
dryRun.unitPriceCents?.let { put("unit_price_cents", it) }
dryRun.totalPriceCents?.let { put("total_price_cents", it) }
putNullable("evidence_sha256", dryRun.evidenceSha256)
}
private fun decodeOrderDryRun(json: JSONObject): PendingOrderDryRun =
PendingOrderDryRun(
commandId = json.getString("command_id"),
commandSha256 = json.getString("command_sha256"),
status = OrderDryRunStatus.valueOf(json.getString("status")),
startIdempotencyKey = json.getString("start_idempotency_key"),
readyIdempotencyKey = json.getString("ready_idempotency_key"),
remoteId = json.optionalString("remote_id"),
attemptCount = json.optInt("attempt_count", 0),
lastAttemptAtEpochMillis =
if (json.has("last_attempt_at_ms")) {
json.getLong("last_attempt_at_ms")
} else {
null
},
cardSignature = json.optionalString("card_signature"),
detailSignature = json.optionalString("detail_signature"),
observedTitle = json.optionalString("observed_title"),
selectedSku = json.optionalString("selected_sku"),
quantity = if (json.has("quantity")) json.getInt("quantity") else null,
unitPriceCents = if (json.has("unit_price_cents")) {
json.getLong("unit_price_cents")
} else {
null
},
totalPriceCents = if (json.has("total_price_cents")) {
json.getLong("total_price_cents")
} else {
null
},
evidenceSha256 = json.optionalString("evidence_sha256")
)
private fun encodeOrderCommand(command: PendingOrderCommand): JSONObject =
JSONObject().apply {
put("id", command.id)
@@ -198,6 +198,8 @@ fun ProcurementScreen(
ProcurementPhase.RUNNING,
ProcurementPhase.WAITING_ADMIN_CONFIRMATION,
ProcurementPhase.ORDER_AUTHORIZED,
ProcurementPhase.ORDER_DRY_RUN_RUNNING,
ProcurementPhase.ORDER_DRY_RUN_READY,
ProcurementPhase.AUTHORIZATION_EXPIRED -> {
if (state.authenticationRequired) {
item(key = "procurement-login") {
@@ -460,6 +462,12 @@ private fun ExecutionDetails(state: ProcurementUiState) {
DetailRow("目标数量", command.quantity.toString())
DetailRow("候选指纹", command.candidate.candidateKey)
}
state.orderDryRun?.let { dryRun ->
DetailRow("订单核验", dryRun.status.name)
dryRun.totalPriceCents?.let { cents ->
DetailRow("核验总额", "¥%.2f".format(cents / 100.0))
}
}
DetailRow(
"后台连接",
when (state.backendOnline) {
@@ -484,11 +492,18 @@ private fun ExecutionDetails(state: ProcurementUiState) {
ProcurementPhase.WAITING_ADMIN_CONFIRMATION ->
"等待后台采购员确认商品"
ProcurementPhase.ORDER_AUTHORIZED ->
"商品授权已安全保存;本版本尚不执行下单"
"商品授权已安全保存,等待自动核验"
ProcurementPhase.ORDER_DRY_RUN_RUNNING ->
"正在重新定位商品并核验规格、数量和金额"
ProcurementPhase.ORDER_DRY_RUN_READY ->
"订单已核验,等待单次提交"
else -> "订单提交保持禁用"
},
color = if (
state.phase == ProcurementPhase.ORDER_AUTHORIZED
state.phase in setOf(
ProcurementPhase.ORDER_AUTHORIZED,
ProcurementPhase.ORDER_DRY_RUN_READY
)
) {
colors.success
} else {
@@ -528,7 +543,9 @@ private fun phaseColor(state: ProcurementUiState) =
ProcurementPhase.AUTHORIZATION_EXPIRED -> BaoziTheme.colors.error
ProcurementPhase.RUNNING,
ProcurementPhase.WAITING_ADMIN_CONFIRMATION,
ProcurementPhase.ORDER_AUTHORIZED -> BaoziTheme.colors.success
ProcurementPhase.ORDER_AUTHORIZED,
ProcurementPhase.ORDER_DRY_RUN_RUNNING,
ProcurementPhase.ORDER_DRY_RUN_READY -> BaoziTheme.colors.success
else -> BaoziTheme.colors.textSecondary
}
@@ -540,5 +557,7 @@ private fun phaseLabel(state: ProcurementUiState): String =
ProcurementPhase.RUNNING -> "受控流程运行中"
ProcurementPhase.WAITING_ADMIN_CONFIRMATION -> "等待后台确认商品"
ProcurementPhase.ORDER_AUTHORIZED -> "后台商品授权已同步"
ProcurementPhase.ORDER_DRY_RUN_RUNNING -> "正在核验订单"
ProcurementPhase.ORDER_DRY_RUN_READY -> "订单已核验,等待提交"
ProcurementPhase.AUTHORIZATION_EXPIRED -> "授权到期,已停止"
}
@@ -0,0 +1,174 @@
package com.roubao.autopilot.pinduoduo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class PinduoduoCheckoutOcrPolicyTest {
@Test
fun `verifies checkout evidence using semantic relative positions`() {
val result = PinduoduoCheckoutOcrPolicy.verify(
lines = checkoutLines(),
authorizedTitle = TITLE,
expectedColor = "GRAY",
expectedSize = "2XL",
expectedQuantity = 1
)
assertEquals(TITLE, result?.observedTitle)
assertEquals("灰色 2XL 建议125-135斤", result?.selectedSummary)
assertEquals(1, result?.quantity)
assertEquals(1_435L, result?.totalPriceCents)
}
@Test
fun `rejects an unlabeled total`() {
val lines = checkoutLines().map { line ->
if (line.text.startsWith("实付款")) {
line.copy(text = "¥14.35")
} else {
line
}
}
assertNull(
PinduoduoCheckoutOcrPolicy.verify(
lines,
TITLE,
"GRAY",
"2XL",
1
)
)
}
@Test
fun `accepts color and size split from their labels by OCR`() {
val split = checkoutLines().flatMap { line ->
when {
line.text.startsWith("颜色分类") -> listOf(
line.copy(text = "颜色分类"),
line.copy(text = "灰色", left = 500)
)
line.text.startsWith("尺码") -> listOf(
line.copy(text = "尺码"),
line.copy(text = "2XL 建议125-135斤", left = 500)
)
else -> listOf(line)
}
}
val result = PinduoduoCheckoutOcrPolicy.verify(
split,
TITLE,
"GRAY",
"2XL",
1
)
assertEquals("灰色 2XL 建议125-135斤", result?.selectedSummary)
}
@Test
fun `rejects quantity outside the product and discount region`() {
val lines = checkoutLines().map { line ->
if (line.text == "1") line.copy(top = 950, bottom = 980) else line
}
assertNull(
PinduoduoCheckoutOcrPolicy.verify(
lines,
TITLE,
"GRAY",
"2XL",
1
)
)
}
@Test
fun `accepts quantity merged with the increment control by OCR`() {
val lines = checkoutLines().map { line ->
if (line.text == "1") line.copy(text = "1 +") else line
}
val result = PinduoduoCheckoutOcrPolicy.verify(
lines,
TITLE,
"GRAY",
"2XL",
1
)
assertEquals(1, result?.quantity)
}
@Test
fun `long title permits bounded OCR character errors`() {
val observed = "前缀" + TITLE.replace("轻奢", "轻著") + "后缀"
assertEquals(
true,
PinduoduoCheckoutOcrPolicy.approximatelyContains(
observed,
TITLE
)
)
}
@Test
fun `long title permits split OCR anchors`() {
val observed = "设计拼接其他文字恤短袖更多文字26轻" +
"其他小众夏季气质新款其他漂亮上衣"
assertEquals(
true,
PinduoduoCheckoutOcrPolicy.approximatelyContains(
observed,
TITLE
)
)
}
@Test
fun `long title permits a rendered prefix truncated by checkout`() {
assertEquals(
true,
PinduoduoCheckoutOcrPolicy.approximatelyContains(
TITLE.take(18),
TITLE
)
)
}
@Test
fun `different title is not an approximate match`() {
assertEquals(
false,
PinduoduoCheckoutOcrPolicy.approximatelyContains(
"夏季纯棉儿童运动套装宽松休闲两件套",
TITLE
)
)
}
private fun checkoutLines() = listOf(
line("手动添加收货地址", 100),
line(TITLE.substring(0, 18), 200),
line(TITLE.substring(18), 240),
line("颜色分类: 灰色", 300),
line("尺码: 2XL 建议125-135斤", 340),
line("1", 430),
line("店铺优惠", 600),
line("平台和限时优惠", 700),
line("实付款: ¥14.35 免运费", 1000)
)
private fun line(text: String, top: Int) =
PinduoduoOcrLine(text, 100, top, 800, top + 40)
private companion object {
const val TITLE =
"设计拼接T恤短袖2026轻奢小众夏季气质新款百搭漂亮上衣出片"
}
}
@@ -75,6 +75,92 @@ class PinduoduoImageSearchAutomationTest {
assertEquals(PinduoduoPage.IMAGE_SEARCH_RESULTS, driver.page)
}
@Test
fun `dismisses camera retry dialog and reopens the prepared image`() =
runTest {
val driver = FakeImageDriver(
page = PinduoduoPage.SEARCH_RESULTS,
retryDialogOnce = true
)
val runner = WorkflowRunner(
PinduoduoImageSearchAutomation(
driver = driver,
pollIntervalMillis = 1,
unknownPageLimit = 3
)
)
val report = runner.run(PinduoduoImageSearchWorkflow.steps())
assertEquals(WorkflowState.SUCCEEDED, report.state)
assertEquals(1, driver.dismissRetryDialogCalls)
assertTrue(driver.preparedImageSelected)
assertEquals(PinduoduoPage.IMAGE_SEARCH_RESULTS, driver.page)
}
@Test
fun `recovers when app opens directly into the camera retry dialog`() =
runTest {
val driver = FakeImageDriver(
page = PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG
)
val runner = WorkflowRunner(
PinduoduoImageSearchAutomation(
driver = driver,
pollIntervalMillis = 1,
unknownPageLimit = 3
)
)
val report = runner.run(PinduoduoImageSearchWorkflow.steps())
assertEquals(WorkflowState.SUCCEEDED, report.state)
assertEquals(1, driver.dismissRetryDialogCalls)
assertTrue(driver.preparedImageSelected)
}
@Test
fun `recovers from specification panel left by candidate collection`() =
runTest {
val driver = FakeImageDriver(PinduoduoPage.SPECIFICATION_PANEL)
val runner = WorkflowRunner(
PinduoduoImageSearchAutomation(
driver = driver,
pollIntervalMillis = 1,
unknownPageLimit = 3
)
)
val report = runner.run(PinduoduoImageSearchWorkflow.steps())
assertEquals(WorkflowState.SUCCEEDED, report.state)
assertEquals(1, driver.closeSpecificationsCalls)
assertTrue(driver.preparedImageSelected)
assertEquals(PinduoduoPage.IMAGE_SEARCH_RESULTS, driver.page)
}
@Test
fun `backs out of order confirmation before a retry search`() = runTest {
val driver = FakeImageDriver(
PinduoduoPage.ORDER_CONFIRMATION,
safetyStopReason = SafetyStopReason.PAYMENT_BOUNDARY
)
val runner = WorkflowRunner(
PinduoduoImageSearchAutomation(
driver = driver,
pollIntervalMillis = 1,
unknownPageLimit = 3
)
)
val report = runner.run(PinduoduoImageSearchWorkflow.steps())
assertEquals(WorkflowState.SUCCEEDED, report.state)
assertEquals(1, driver.returnFromOrderConfirmationCalls)
assertTrue(driver.preparedImageSelected)
assertEquals(PinduoduoPage.IMAGE_SEARCH_RESULTS, driver.page)
}
@Test
fun `payment marker stops before image entry`() = runTest {
val driver = FakeImageDriver(
@@ -99,12 +185,18 @@ class PinduoduoImageSearchAutomationTest {
var page: PinduoduoPage,
private val selectionAllowed: Boolean = true,
private val safetyStopReason: SafetyStopReason? = null,
private val cameraResultRaceOnce: Boolean = false
private val cameraResultRaceOnce: Boolean = false,
private val retryDialogOnce: Boolean = false
) : PinduoduoImageSearchDriver {
var imageSearchOpened = false
var preparedImageSelected = false
var closeSpecificationsCalls = 0
var returnFromOrderConfirmationCalls = 0
var returnFromImageResultsCalls = 0
var dismissRetryDialogCalls = 0
private var currentSafetyStopReason = safetyStopReason
private var cameraResultRaceConsumed = false
private var retryDialogConsumed = false
override suspend fun openApp(): Boolean = true
@@ -112,7 +204,7 @@ class PinduoduoImageSearchAutomationTest {
PinduoduoUiSnapshot(
foregroundPackage = PINDUODUO_PACKAGE,
page = page,
safetyStopReason = safetyStopReason
safetyStopReason = currentSafetyStopReason
)
override suspend fun openImageSearch(): Boolean {
@@ -120,12 +212,21 @@ class PinduoduoImageSearchAutomationTest {
page = if (cameraResultRaceOnce && !cameraResultRaceConsumed) {
cameraResultRaceConsumed = true
PinduoduoPage.IMAGE_SEARCH_RESULTS
} else if (retryDialogOnce && !retryDialogConsumed) {
retryDialogConsumed = true
PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG
} else {
PinduoduoPage.IMAGE_SEARCH
}
return true
}
override suspend fun dismissImageSearchRetryDialog(): Boolean {
dismissRetryDialogCalls += 1
page = PinduoduoPage.HOME
return true
}
override suspend fun selectPreparedImage(): Boolean {
if (!selectionAllowed) {
return false
@@ -135,6 +236,19 @@ class PinduoduoImageSearchAutomationTest {
return true
}
override suspend fun closeSpecifications(): Boolean {
closeSpecificationsCalls += 1
page = PinduoduoPage.PRODUCT_DETAIL
return true
}
override suspend fun returnFromOrderConfirmation(): Boolean {
returnFromOrderConfirmationCalls += 1
page = PinduoduoPage.SPECIFICATION_PANEL
currentSafetyStopReason = null
return true
}
override suspend fun returnFromCandidate(): Boolean {
page = PinduoduoPage.IMAGE_SEARCH_RESULTS
return true
@@ -32,4 +32,24 @@ class PinduoduoObservedTitlePolicyTest {
assertNull(title)
}
@Test
fun `stable identity ignores volatile price and sales text`() {
val first = PinduoduoStableProductIdentityPolicy.signature(
listOf(
"限时 ¥19.90",
"已拼2.1万件",
"法式小香风V领短袖钉珠女2026夏季新款"
)
)
val refreshed = PinduoduoStableProductIdentityPolicy.signature(
listOf(
"限时 ¥21.00",
"已拼2.2万件",
"法式小香风V领短袖钉珠女2026夏季新款"
)
)
assertEquals(first, refreshed)
}
}
@@ -0,0 +1,44 @@
package com.roubao.autopilot.pinduoduo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
class PinduoduoOrderConfirmationParserTest {
@Test
fun `parses modern split sku quantity and payable text`() {
val evidence = PinduoduoOrderConfirmationParser.parse(
listOf(
element("手动添加收货地址", top = 100),
element(
"设计拼接T恤短袖2026轻奢小众夏季气质新款百搭漂亮上衣出片",
top = 200
),
element("颜色分类: 灰色", top = 300),
element("尺码: 2XL 建议125-135斤", top = 400),
element("1, 当前数量为1件", top = 500),
element(
"实付款¥14.35已享7.2折,共优惠5.64元免运费",
top = 600
)
)
)
assertNotNull(evidence)
assertEquals("灰色 2XL 建议125-135斤", evidence!!.selectedSummary)
assertEquals(1, evidence.quantity)
assertEquals(1_435L, evidence.totalPriceCents)
}
private fun element(text: String, top: Int) = PinduoduoUiElement(
text = text,
contentDescription = null,
className = "android.widget.TextView",
resourceId = null,
clickable = false,
editable = false,
enabled = true,
visibleToUser = true,
boundsTop = top
)
}
@@ -0,0 +1,267 @@
package com.roubao.autopilot.pinduoduo
import com.roubao.autopilot.readiness.PINDUODUO_PACKAGE
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class PinduoduoOrderDryRunAutomationTest {
@Test
fun uniquelyMatchedCandidateStopsOnVerifiedOrderConfirmation() = runBlocking {
val driver = FakeDryRunDriver(listOf(card(CARD_A)))
val result = automation(driver).run()
assertEquals(CARD_A, result.candidate.cardSignature)
assertEquals(2, result.confirmation.quantity)
assertEquals(4_000L, result.confirmation.totalPriceCents)
assertEquals(PinduoduoPage.ORDER_CONFIRMATION, driver.page)
assertEquals(2, driver.quantity)
assertEquals(1, driver.quantityActions)
assertTrue(driver.confirmedSpecifications)
}
@Test
fun multipleMatchingDetailsAreRejectedBeforeQuantityOrConfirmation() = runBlocking {
val driver = FakeDryRunDriver(
listOf(card(CARD_A), card(CARD_B)),
details = mapOf(
CARD_A to detail(DETAIL),
CARD_B to detail(DETAIL)
)
)
val error = runCatching { automation(driver).run() }.exceptionOrNull()
assertTrue(error is PinduoduoOrderDryRunException)
assertTrue(error!!.message!!.contains("多个商品"))
assertEquals(0, driver.quantityActions)
assertTrue(!driver.confirmedSpecifications)
}
@Test
fun disabledSkuIsNotAnAuthorizedMatch() = runBlocking {
val driver = FakeDryRunDriver(
listOf(card(CARD_A)),
colorEnabled = false
)
val error = runCatching { automation(driver).run() }.exceptionOrNull()
assertTrue(error is PinduoduoOrderDryRunException)
assertTrue(error!!.message!!.contains("没有唯一匹配"))
assertTrue(!driver.confirmedSpecifications)
}
@Test
fun quantityClickMustChangeExactlyOneStep() = runBlocking {
val driver = FakeDryRunDriver(
listOf(card(CARD_A)),
mutateQuantity = false
)
val error = runCatching { automation(driver).run() }.exceptionOrNull()
assertTrue(error is PinduoduoOrderDryRunException)
assertTrue(error!!.message!!.contains("状态未按一步变化"))
assertTrue(!driver.confirmedSpecifications)
}
@Test
fun titleMismatchIsNeverAcceptedByMatchingSignatureAlone() = runBlocking {
val driver = FakeDryRunDriver(
listOf(card(CARD_A)),
details = mapOf(CARD_A to detail(DETAIL, "另一件完全不同的商品标题"))
)
val error = runCatching { automation(driver).run() }.exceptionOrNull()
assertTrue(error is PinduoduoOrderDryRunException)
assertTrue(error!!.message!!.contains("没有唯一匹配"))
}
@Test
fun transientUnknownSnapshotBeforeScanningIsRetried() = runBlocking {
val driver = FakeDryRunDriver(
listOf(card(CARD_A)),
transientUnknownAtSnapshot = 2
)
val result = automation(driver).run()
assertEquals(CARD_A, result.candidate.cardSignature)
assertEquals(PinduoduoPage.ORDER_CONFIRMATION, driver.page)
}
private fun automation(driver: FakeDryRunDriver) =
PinduoduoOrderDryRunAutomation(
driver = driver,
authorizedTitle = TITLE,
authorizedCardSignature = CARD_A,
authorizedDetailSignature = DETAIL,
authorizedPriceText = "¥20.00",
specificationTarget = PinduoduoSpecificationTarget(
color = "BLACK",
size = "M"
),
quantity = 2,
maxBudgetCents = null,
pollIntervalMillis = 1,
pagePollLimit = 2
)
private class FakeDryRunDriver(
private val cards: List<PinduoduoCandidateCard>,
private val details: Map<String, PinduoduoCandidateDetailEvidence> =
cards.associate { it.signature to detail(DETAIL) },
private val colorEnabled: Boolean = true,
private val mutateQuantity: Boolean = true,
private val transientUnknownAtSnapshot: Int? = null
) : PinduoduoOrderDryRunDriver {
var page = PinduoduoPage.IMAGE_SEARCH_RESULTS
var quantity = 1
var quantityActions = 0
var confirmedSpecifications = false
private var snapshotCount = 0
private var currentCard: String? = null
private var colorSelected = false
private var sizeSelected = false
override suspend fun snapshot(): PinduoduoUiSnapshot {
snapshotCount += 1
if (snapshotCount == transientUnknownAtSnapshot) {
return PinduoduoUiSnapshot(
foregroundPackage = "",
page = PinduoduoPage.UNKNOWN,
safetyStopReason = null
)
}
return PinduoduoUiSnapshot(
foregroundPackage = PINDUODUO_PACKAGE,
page = page,
safetyStopReason = null
)
}
override suspend fun candidateCards(limit: Int) = cards.take(limit)
override suspend fun openCandidate(signature: String): Boolean {
if (cards.none { it.signature == signature }) return false
currentCard = signature
page = PinduoduoPage.PRODUCT_DETAIL
return true
}
override suspend fun readCandidateDetail() =
details[currentCard]
override suspend fun openSpecifications(): Boolean {
page = PinduoduoPage.SPECIFICATION_PANEL
return true
}
override suspend fun readSpecifications() =
PinduoduoSpecificationEvidence(
signature = SPECIFICATION,
semanticTextCount = 8,
groups = listOf(
PinduoduoSpecificationGroup(
PinduoduoSpecificationGroupKind.COLOR,
"颜色",
listOf(
PinduoduoSpecificationOption(
"黑色",
colorSelected,
colorEnabled
)
),
complete = true
),
PinduoduoSpecificationGroup(
PinduoduoSpecificationGroupKind.SIZE,
"尺码",
listOf(
PinduoduoSpecificationOption(
"M",
sizeSelected,
enabled = true
)
),
complete = true
)
),
selectedSummary = "已选择 黑色 M",
price = PinduoduoSpecificationPrice("¥20.00", 2_000L)
)
override suspend fun selectSpecificationOption(
kind: PinduoduoSpecificationGroupKind,
optionText: String
): Boolean {
when (kind) {
PinduoduoSpecificationGroupKind.COLOR -> {
if (!colorEnabled || optionText != "黑色") return false
colorSelected = true
}
PinduoduoSpecificationGroupKind.SIZE -> {
if (optionText != "M") return false
sizeSelected = true
}
}
return true
}
override suspend fun scrollSpecifications() = false
override suspend fun closeSpecifications(): Boolean {
page = PinduoduoPage.PRODUCT_DETAIL
return true
}
override suspend fun returnToResults(): Boolean {
page = PinduoduoPage.IMAGE_SEARCH_RESULTS
return true
}
override suspend fun scrollResults() = false
override suspend fun scrollResultsBackward() = false
override suspend fun readQuantity() = quantity
override suspend fun changeQuantity(increase: Boolean): Boolean {
quantityActions += 1
if (mutateQuantity) {
quantity += if (increase) 1 else -1
}
return true
}
override suspend fun confirmSpecifications(): Boolean {
confirmedSpecifications = true
page = PinduoduoPage.ORDER_CONFIRMATION
return true
}
override suspend fun readOrderConfirmation() =
PinduoduoOrderConfirmationEvidence(
observedTitle = TITLE,
selectedSummary = "已选 黑色 M",
quantity = quantity,
totalPriceCents = 4_000L
)
override suspend fun captureOrderConfirmation() =
PinduoduoScreenshotCapture(byteArrayOf(1, 2, 3), 1080, 2400)
}
private companion object {
const val TITLE = "测试商品标准标题"
val CARD_A = "a".repeat(64)
val CARD_B = "b".repeat(64)
val DETAIL = "c".repeat(64)
val SPECIFICATION = "d".repeat(64)
fun card(signature: String) =
PinduoduoCandidateCard(signature, 4, true)
fun detail(signature: String, title: String = TITLE) =
PinduoduoCandidateDetailEvidence(signature, 8, title)
}
}
@@ -115,6 +115,20 @@ class PinduoduoPageClassifierTest {
assertEquals(PinduoduoPage.IMAGE_SEARCH, snapshot.page)
}
@Test
fun `camera retry dialog is a recoverable image search page`() {
val snapshot = classify(
element(text = "请对准商品或码,保持手机稳定"),
element(text = "取消", clickable = true),
element(text = "再试一次", clickable = true)
)
assertEquals(
PinduoduoPage.IMAGE_SEARCH_RETRY_DIALOG,
snapshot.page
)
}
@Test
fun `image search results require header and sort controls`() {
val snapshot = classify(
@@ -256,6 +270,63 @@ class PinduoduoPageClassifierTest {
)
}
@Test
fun `modern order confirmation uses address payment and payable markers`() {
val snapshot = classify(
element(text = "手动添加收货地址"),
element(text = "微信支付"),
element(text = "先用后付"),
element(text = "找好友支付"),
element(text = "实付款¥14.35已享7.2折,共优惠5.64元免运费")
)
assertEquals(PinduoduoPage.ORDER_CONFIRMATION, snapshot.page)
assertEquals(
SafetyStopReason.PAYMENT_BOUNDARY,
snapshot.safetyStopReason
)
}
@Test
fun `collapsed checkout webview remains a blocked confirmation page`() {
val snapshot = classify(
element(resourceId = "order_checkout")
)
assertEquals(PinduoduoPage.ORDER_CONFIRMATION, snapshot.page)
assertEquals(
SafetyStopReason.PAYMENT_BOUNDARY,
snapshot.safetyStopReason
)
}
@Test
fun `collapsed checkout structure is available only to contextual driver`() {
val elements = listOf(
element(
contentDescription = "返回",
clickable = true,
boundsTop = 154
),
element(
className = "android.webkit.WebView",
boundsLeft = 0,
boundsTop = 258,
boundsRight = 1080,
boundsBottom = 2328
)
)
val global = PinduoduoPageClassifier.classify(
PINDUODUO_PACKAGE,
elements
)
assertTrue(PinduoduoCollapsedCheckoutPolicy.matches(elements))
assertEquals(PinduoduoPage.UNKNOWN, global.page)
assertNull(global.safetyStopReason)
}
@Test
fun `order list uses stable status tabs`() {
val snapshot = classify(
@@ -309,18 +380,27 @@ class PinduoduoPageClassifierTest {
text: String? = null,
contentDescription: String? = null,
className: String = "android.widget.TextView",
resourceId: String? = null,
editable: Boolean = false,
clickable: Boolean = false,
enabled: Boolean = true,
visibleToUser: Boolean = true
visibleToUser: Boolean = true,
boundsLeft: Int = 0,
boundsTop: Int = 0,
boundsRight: Int = 0,
boundsBottom: Int = 0
) = PinduoduoUiElement(
text = text,
contentDescription = contentDescription,
className = className,
resourceId = null,
resourceId = resourceId,
clickable = clickable,
editable = editable,
enabled = enabled,
visibleToUser = visibleToUser
visibleToUser = visibleToUser,
boundsLeft = boundsLeft,
boundsTop = boundsTop,
boundsRight = boundsRight,
boundsBottom = boundsBottom
)
}
@@ -5,6 +5,7 @@ import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okio.Buffer
import org.json.JSONObject
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
@@ -183,7 +184,7 @@ class ProcurementApiClientTest {
)
server.enqueue(
jsonResponse(
"""{"evidence":{"id":"evidence-id"},"replayed":false}"""
"""{"evidence":{"id":"evidence-id","sha256":"${"a".repeat(64)}"},"replayed":false}"""
)
)
val evidenceResult = api.uploadExecutionOutboxItem(
@@ -201,6 +202,7 @@ class ProcurementApiClientTest {
evidenceBytes = byteArrayOf(1, 2, 3)
)
assertEquals("evidence-id", evidenceResult.evidenceAssetId)
assertEquals("a".repeat(64), evidenceResult.evidenceSha256)
val evidenceRequest = server.takeRequest()
assertEquals("/api/v1/tasks/task-id/evidence", evidenceRequest.path)
assertEquals("claim-token-value", evidenceRequest.getHeader("X-Claim-Token"))
@@ -353,6 +355,100 @@ class ProcurementApiClientTest {
assertEquals(null, command)
}
@Test
fun orderDryRunStartAndReadyUseRecoverableDeviceRoutes() = runBlocking {
val task = task(
"/api/v1/tasks/task-id/reference-image?claim_generation=1"
)
val execution = RunningExecution(
id = "execution-id",
currentStep = "ORDER_AUTHORIZED",
expiresAt = "2099-01-01T00:00:00Z",
serverClockOffsetMillis = 0
)
val command = PendingOrderCommand(
id = "command-id",
schemaVersion = 1,
type = "CREATE_PENDING_ORDER",
authorizationVersion = 1,
taskId = "task-id",
executionId = "execution-id",
taskContentSha256 = "a".repeat(64),
originalSku = "黑色 M",
quantity = 2,
candidate = OrderCommandCandidate(
candidateKey = "candidate-key",
observedOrdinal = 1,
title = "候选商品标题",
skuText = "黑色 M",
priceText = "20.00",
cardSignature = "b".repeat(64),
detailSignature = "c".repeat(64),
detailEvidenceSha256 = "d".repeat(64),
specificationEvidenceSha256 = "e".repeat(64)
),
commandSha256 = "f".repeat(64),
authorizationStatus = "ACKNOWLEDGED",
acknowledged = true
)
server.enqueue(
jsonResponse(
"""{"dry_run":{"id":"dry-run-id","command_id":"command-id","command_sha256":"${"f".repeat(64)}","status":"PREPARING"},"replayed":false}"""
)
)
val started = api.startOrderDryRun(
session(),
task,
execution,
"claim-token",
command,
"dry-run-start-key"
)
assertEquals("PREPARING", started.status)
val startRequest = server.takeRequest()
assertEquals(
"/api/v1/tasks/task-id/order-dry-runs/start",
startRequest.path
)
assertEquals(
"dry-run-start-key",
startRequest.getHeader("Idempotency-Key")
)
server.enqueue(
jsonResponse(
"""{"dry_run":{"id":"dry-run-id","command_id":"command-id","command_sha256":"${"f".repeat(64)}","status":"READY"},"replayed":false}"""
)
)
val readyResult = api.uploadExecutionOutboxItem(
session(),
task,
execution,
"claim-token",
ExecutionOutboxItem(
id = "ready-local-id",
type = ExecutionOutboxType.ORDER_DRY_RUN_READY,
idempotencyKey = "dry-run-ready-key",
payload =
"""{"command_id":"command-id","command_sha256":"${"f".repeat(64)}"}"""
)
)
assertEquals("READY", readyResult.orderDryRun?.status)
val readyRequest = server.takeRequest()
assertEquals(
"/api/v1/tasks/task-id/order-dry-runs/command-id/ready",
readyRequest.path
)
assertEquals(
"dry-run-ready-key",
readyRequest.getHeader("Idempotency-Key")
)
assertEquals(
false,
JSONObject(readyRequest.body.readUtf8()).has("command_id")
)
}
private fun session() = ProcurementSession(
backendUrl = server.url("/").toString().trimEnd('/'),
username = "buyer01",