feat(t217): verify authorized order dry runs
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "com.roubao.autopilot"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 18
|
||||
versionName = "1.4.13"
|
||||
versionCode = 19
|
||||
versionName = "1.4.14"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
@@ -107,6 +107,9 @@ dependencies {
|
||||
// JSON
|
||||
implementation("org.json:json:20231013")
|
||||
|
||||
// Bundled on-device OCR for Pinduoduo WebView checkout evidence
|
||||
implementation("com.google.mlkit:text-recognition-chinese:16.0.1")
|
||||
|
||||
// Unit tests
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
|
||||
|
||||
+47
@@ -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
|
||||
}
|
||||
|
||||
+211
-12
@@ -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*$")
|
||||
}
|
||||
|
||||
+9
@@ -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()
|
||||
|
||||
|
||||
+103
@@ -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()
|
||||
}
|
||||
+14
@@ -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))
|
||||
|
||||
+328
@@ -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
|
||||
}
|
||||
+46
-6
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+392
@@ -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}$")
|
||||
}
|
||||
}
|
||||
+171
@@ -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
|
||||
}
|
||||
+58
-3
@@ -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
|
||||
|
||||
+2
-1
@@ -43,7 +43,8 @@ object OrderCommandIntegrity {
|
||||
) { "后台订单命令指纹无效" }
|
||||
require(
|
||||
command.authorizationStatus == "DELIVERED" ||
|
||||
command.authorizationStatus == "ACKNOWLEDGED"
|
||||
command.authorizationStatus == "ACKNOWLEDGED" ||
|
||||
command.authorizationStatus == "EXECUTING"
|
||||
) { "后台订单命令状态无效" }
|
||||
require(sha256(command) == command.commandSha256) {
|
||||
"后台订单命令完整性校验失败"
|
||||
|
||||
+3
-1
@@ -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 {
|
||||
|
||||
+97
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
-3
@@ -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)
|
||||
|
||||
|
||||
+21
@@ -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
|
||||
|
||||
+61
-2
@@ -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,
|
||||
|
||||
+336
-10
@@ -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",
|
||||
|
||||
+65
-1
@@ -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)
|
||||
|
||||
+22
-3
@@ -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 -> "授权到期,已停止"
|
||||
}
|
||||
|
||||
+174
@@ -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轻奢小众夏季气质新款百搭漂亮上衣出片"
|
||||
}
|
||||
}
|
||||
+116
-2
@@ -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
|
||||
|
||||
+20
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+44
@@ -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
|
||||
)
|
||||
}
|
||||
+267
@@ -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)
|
||||
}
|
||||
}
|
||||
+83
-3
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+97
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user