Compare commits

...
11 changed files with 676 additions and 68 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 59
versionName = "0.9.46"
versionCode = 65
versionName = "0.9.52"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -330,6 +330,42 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
}
}
override fun clickAddressEntryFresh(target: SnapshotNode): FreshClickOutcome {
val root = rootInActiveWindow ?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.ROOT_UNAVAILABLE)
val indexes = target.path.split('/').mapNotNull(String::toIntOrNull)
if (indexes.isEmpty() || indexes.first() != 0 || indexes.size != target.path.split('/').size) {
return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
}
var node = root
for (index in indexes.drop(1)) {
node = node.getChild(index)
?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
}
val bounds = Rect().also(node::getBoundsInScreen)
if (!node.isVisibleToUser || !node.isEnabled ||
node.preferredOrDescendantLabel() != target.label ||
node.className?.toString() != target.className ||
kotlin.math.abs(bounds.centerX() - target.bounds.centerX) > 32 ||
kotlin.math.abs(bounds.centerY() - target.bounds.centerY) > 32
) {
return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
}
var ancestorDepth = 0
while (!node.isClickable) {
node = node.parent ?: return FreshClickOutcome(
FreshActionResult.FAILED,
FreshClickReason.NO_CLICKABLE_ANCESTOR,
clickableAncestorDepth = ancestorDepth,
)
ancestorDepth++
}
return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) {
FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, 1, ancestorDepth)
} else {
FreshClickOutcome(FreshActionResult.FAILED, FreshClickReason.ACTION_CLICK_FALSE, 1, ancestorDepth)
}
}
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult {
val root = rootInActiveWindow ?: return FreshActionResult.NOT_FOUND
val candidates = mutableListOf<AccessibilityNodeInfo>()
@@ -128,6 +128,7 @@ data class ParsedPddScreen(
val bottomPurchaseEntryCount: Int,
val problem: PageProblem?,
val sourceNodes: List<SnapshotNode>,
val isPddPackage: Boolean,
) {
fun isTransientSoldOut(
exactText: String,
@@ -162,6 +163,7 @@ data class ParsedPddScreen(
}
object PddScreenParser {
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
private data class SafeSpecEntry(val anchor: SnapshotNode, val clickTarget: SnapshotNode)
private val pricePattern = Regex("[¥¥]\\s*([0-9]+(?:\\.[0-9]{1,2})?)")
private val salesPattern = Regex("已拼\\s*[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?\\s*\\+?\\s*(?:件|人)?")
@@ -397,6 +399,7 @@ object PddScreenParser {
bottomPurchaseEntryCount = bottomSpecEntries.size,
problem = problem,
sourceNodes = visibleNodes,
isPddPackage = snapshot.packageName == PDD_PACKAGE,
)
}
@@ -42,7 +42,7 @@ class PurchaseLiveAutomation(
* page. Only the selector's unique exact confirm button is clickable; an
* order-submit or payment control can never satisfy this transition.
*/
fun advanceToOrderConfirmation() {
fun advanceToOrderConfirmation(allowUnclassifiedPanelWithSelectionProof: Boolean = false) {
var snapshot = driver.capture()
pageProblem(snapshot)
if (orderConfirmationReady(snapshot)) return
@@ -50,7 +50,16 @@ class PurchaseLiveAutomation(
fail("PURCHASE_SPEC_CONFIRMATION_NOT_READY", "当前不是拼多多规格页面,未创建订单")
}
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
if (screen.specPanelType !in setOf(SpecPanelType.NORMAL_SCROLLABLE, SpecPanelType.NON_SCROLLABLE_CONFIRMATION)) {
val unclassifiedSelectionPanelWithProof = allowUnclassifiedPanelWithSelectionProof &&
screen.specPanelType == SpecPanelType.UNKNOWN &&
screen.priceCent != null &&
snapshot.nodes.count {
it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label.toLongOrNull() != null
} == 1
if (
screen.specPanelType !in setOf(SpecPanelType.NORMAL_SCROLLABLE, SpecPanelType.NON_SCROLLABLE_CONFIRMATION) &&
!unclassifiedSelectionPanelWithProof
) {
fail("PURCHASE_SPEC_CONFIRMATION_NOT_READY", "当前规格面板不能安全确认,未创建订单")
}
val aliases = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR.textAliases.specPanel.confirmAliases
@@ -86,12 +95,31 @@ class PurchaseLiveAutomation(
var unchangedCount = 0
for (attempt in 0 until 5) {
pageProblem(snapshot)
val entries = mergedDirect(snapshot.nodes.filter { it.visible && it.enabled && MASKED_PHONE.containsMatchIn(it.label) })
if (entries.size == 1) {
when (driver.tapPurchaseFresh(entries.single())) {
val resolution = resolveAddressEntries(snapshot)
if (resolution.addressCardCount > 1 || resolution.tapTargetCount > 1) {
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单 [${resolution.diagnostic()}]")
}
if (resolution.target != null) {
pause(ADDRESS_ENTRY_STABLE_INTERVAL_MS)
val stableSnapshot = driver.capture()
pageProblem(stableSnapshot)
val stableResolution = resolveAddressEntries(stableSnapshot)
if (stableResolution.addressCardCount > 1 || stableResolution.tapTargetCount > 1) {
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单 [${stableResolution.diagnostic()}]")
}
if (stableResolution.target == null || stableResolution.fingerprint() != resolution.fingerprint()) {
snapshot = stableSnapshot
continue
}
val target = stableResolution.target
val result = when (target.activation) {
AddressEntryActivation.CLICK -> driver.clickAddressEntryFresh(target.node).result
AddressEntryActivation.TAP -> driver.tapPurchaseFresh(target.node)
}
when (result) {
FreshActionResult.SUCCESS -> Unit
FreshActionResult.AMBIGUOUS -> fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单")
else -> fail("PURCHASE_ADDRESS_ENTRY_NOT_READY", "收货地址入口点击失败,未创建订单")
FreshActionResult.AMBIGUOUS -> fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单 [${stableResolution.diagnostic()}]")
else -> fail("PURCHASE_ADDRESS_ENTRY_NOT_READY", "收货地址入口点击失败,未创建订单 [${stableResolution.diagnostic()}]")
}
pause(1_000)
snapshot = waitFor("PURCHASE_ADDRESS_PANEL_TIMEOUT", "收货地址页面打开超时,未创建订单") {
@@ -99,7 +127,9 @@ class PurchaseLiveAutomation(
}
return editAndVerifyAddress(snapshot, addressSuffix)
}
if (entries.size > 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单")
if (resolution.phoneNodeCount > 0) {
fail("PURCHASE_ADDRESS_ENTRY_NOT_READY", "收货地址入口无法安全定位,未创建订单 [${resolution.diagnostic()}]")
}
val panel = purchasePanelScrollTargets(snapshot)
if (panel.size != 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "没有找到唯一的规格面板滚动区域,未创建订单")
val signature = viewportSignature(snapshot, panel.single())
@@ -115,6 +145,161 @@ class PurchaseLiveAutomation(
fail("PURCHASE_ADDRESS_PANEL_TIMEOUT", "规格面板下拉后仍未找到收货地址,未创建订单")
}
private enum class AddressEntryActivation { CLICK, TAP }
private data class AddressEntryTarget(
val node: SnapshotNode,
val activation: AddressEntryActivation,
val cardKey: String,
)
private data class AddressCardCandidate(
val phone: SnapshotNode,
val clickableCard: SnapshotNode?,
val semanticKey: String?,
)
private data class AddressEntryResolution(
val phoneNodeCount: Int,
val addressCardCount: Int,
val tapTargetCount: Int,
val target: AddressEntryTarget?,
) {
fun diagnostic(): String =
"phoneNodes=$phoneNodeCount;addressCards=$addressCardCount;tapTargets=$tapTargetCount"
fun fingerprint(): String? = target?.let {
listOf(
it.activation.name,
it.cardKey,
it.node.label,
it.node.className.orEmpty(),
it.node.bounds.left,
it.node.bounds.top,
it.node.bounds.right,
it.node.bounds.bottom,
).joinToString(":")
}
}
private fun resolveAddressEntries(snapshot: UiSnapshot): AddressEntryResolution {
val phones = snapshot.nodes.filter {
it.visible && it.enabled && maskedPhone(it.label) != null
}.distinctBy { it.path }
if (phones.isEmpty()) return AddressEntryResolution(0, 0, 0, null)
val byPath = snapshot.nodes.associateBy(SnapshotNode::path)
val candidates = phones.map { phone ->
val card = nearestClickableAncestor(phone, byPath)
AddressCardCandidate(phone, card, card?.let { addressCardSemanticKey(it, snapshot) })
}
val groups = mutableListOf<MutableList<AddressCardCandidate>>()
candidates.forEach { candidate ->
val matching = groups.filter { group -> group.any { sameLogicalAddress(it, candidate) } }
if (matching.isEmpty()) {
groups += mutableListOf(candidate)
} else {
val primary = matching.first()
primary += candidate
matching.drop(1).forEach { duplicate ->
primary += duplicate
groups.remove(duplicate)
}
}
}
val targets = groups.mapIndexed { index, group ->
val clickable = group.filter { it.clickableCard != null }
.minWithOrNull(compareBy<AddressCardCandidate>({ nodeArea(requireNotNull(it.clickableCard)) }, { it.phone.path }))
if (clickable != null) {
AddressEntryTarget(clickable.phone, AddressEntryActivation.CLICK, logicalAddressKey(group, index))
} else {
AddressEntryTarget(group.minBy { nodeArea(it.phone) }.phone, AddressEntryActivation.TAP, logicalAddressKey(group, index))
}
}
val actionable = targets.filter { it.node.bounds.width >= 2 && it.node.bounds.height >= 2 }
return AddressEntryResolution(
phoneNodeCount = phones.size,
addressCardCount = targets.size,
tapTargetCount = actionable.size,
target = actionable.singleOrNull(),
)
}
private fun nearestClickableAncestor(
source: SnapshotNode,
byPath: Map<String, SnapshotNode>,
): SnapshotNode? {
var current: SnapshotNode? = source
while (current != null) {
if (current.clickable && current.visible && current.enabled) return current
current = current.parentPath?.let(byPath::get)
}
return null
}
private fun addressCardSemanticKey(card: SnapshotNode, snapshot: UiSnapshot): String? {
val labels = snapshot.nodes.asSequence()
.filter { it.visible && (it.path == card.path || it.path.startsWith("${card.path}/")) }
.map { normalizeAddressSemantic(it.label) }
.filter { it.isNotBlank() && maskedPhone(it) == null && it !in ADDRESS_CARD_GENERIC_LABELS }
.distinct()
.sorted()
.toList()
if (labels.none { it.length >= ADDRESS_SEMANTIC_MIN_LENGTH }) return null
return labels.joinToString("|")
}
private fun normalizeAddressSemantic(value: String): String = value
.replace(Regex("\\s+"), "")
.replace(",", ",")
.replace(":", ":")
.lowercase()
private fun sameLogicalAddress(first: AddressCardCandidate, second: AddressCardCandidate): Boolean {
if (maskedPhone(first.phone.label) != maskedPhone(second.phone.label)) return false
if (first.clickableCard?.path != null && first.clickableCard.path == second.clickableCard?.path) return true
if (sameAddressRegion(first.phone, second.phone)) return true
val firstCard = first.clickableCard
val secondCard = second.clickableCard
if (firstCard != null && secondCard != null && boundsRepresentSameRegion(firstCard.bounds, secondCard.bounds)) return true
return first.semanticKey != null && first.semanticKey == second.semanticKey
}
private fun boundsRepresentSameRegion(first: NodeBounds, second: NodeBounds): Boolean {
val overlapWidth = (minOf(first.right, second.right) - maxOf(first.left, second.left)).coerceAtLeast(0)
val overlapHeight = (minOf(first.bottom, second.bottom) - maxOf(first.top, second.top)).coerceAtLeast(0)
val overlap = overlapWidth.toLong() * overlapHeight
val smaller = minOf(first.width.toLong() * first.height, second.width.toLong() * second.height)
return smaller > 0 && overlap * 100 >= smaller * ADDRESS_CARD_OVERLAP_PERCENT
}
private fun logicalAddressKey(group: List<AddressCardCandidate>, index: Int): String {
val semantic = group.mapNotNull(AddressCardCandidate::semanticKey).distinct().singleOrNull()
return semantic?.let { "semantic:$it" } ?: "geometry:$index:${group.minOf { it.phone.bounds.top }}"
}
private fun sameAddressRegion(first: SnapshotNode, second: SnapshotNode): Boolean {
if (maskedPhone(first.label) != maskedPhone(second.label)) return false
val overlapWidth = (minOf(first.bounds.right, second.bounds.right) - maxOf(first.bounds.left, second.bounds.left)).coerceAtLeast(0)
val overlapHeight = (minOf(first.bounds.bottom, second.bounds.bottom) - maxOf(first.bounds.top, second.bounds.top)).coerceAtLeast(0)
val overlap = overlapWidth.toLong() * overlapHeight
val smaller = minOf(nodeArea(first), nodeArea(second))
val containsCenter = (
first.bounds.centerX in second.bounds.left..second.bounds.right &&
first.bounds.centerY in second.bounds.top..second.bounds.bottom
) || (
second.bounds.centerX in first.bounds.left..first.bounds.right &&
second.bounds.centerY in first.bounds.top..first.bounds.bottom
)
return (smaller > 0 && overlap * 100 >= smaller * 50) || containsCenter
}
private fun maskedPhone(value: String): String? = MASKED_PHONE.find(value)?.value
private fun nodeArea(node: SnapshotNode): Long = node.bounds.width.toLong() * node.bounds.height
fun finalConfirmation(input: PurchaseExecutionInput, address: ShippingAddressProof): FinalConfirmationEvidence {
val snapshot = driver.capture()
pageProblem(snapshot)
@@ -302,14 +487,62 @@ class PurchaseLiveAutomation(
click(save.single(), "保存地址")
val savedEvidence = waitForStableAddressEditorExit()
if (!hasFinalSavedAddressEvidence(savedEvidence, expected, suffix)) {
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
hasFinalSavedAddressEvidence(it, expected, suffix)
if (isPurchaseConfirmationPanel(savedEvidence)) {
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
} else {
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
hasFinalSavedAddressEvidence(it, expected, suffix)
}
}
}
return ShippingAddressProof(expected, suffix)
}
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
return screen.specPanelType in setOf(
SpecPanelType.NORMAL_SCROLLABLE,
SpecPanelType.NON_SCROLLABLE_CONFIRMATION,
SpecPanelType.ORDER_CONFIRMATION,
)
}
private fun restoreFinalEvidenceInCurrentPanel(
initial: UiSnapshot,
expected: String,
suffix: String,
) {
var snapshot = initial
var previousSignature: String? = null
repeat(ADDRESS_CONFIRMATION_SCROLL_LIMIT) { attempt ->
if (hasFinalSavedAddressEvidence(snapshot, expected, suffix)) return
if (!isPurchaseConfirmationPanel(snapshot)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后采购面板发生变化,未创建订单")
}
val panels = purchasePanelScrollTargets(snapshot)
if (panels.size != 1) {
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后没有找到唯一的规格面板滚动区域,未创建订单")
}
val panel = panels.single()
val signature = viewportSignature(snapshot, panel)
if (signature == previousSignature) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板未找到完整订单确认信息,未创建订单")
}
previousSignature = signature
if (!driver.swipePurchaseIn(panel, SwipeDirection.DOWN, 350)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板无法定位订单确认信息,未创建订单")
}
if (attempt < ADDRESS_CONFIRMATION_SCROLL_LIMIT - 1) pause(ADDRESS_CONFIRMATION_SCROLL_INTERVAL_MS)
snapshot = driver.capture()
pageProblem(snapshot)
}
if (!hasFinalSavedAddressEvidence(snapshot, expected, suffix)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板未找到完整订单确认信息,未创建订单")
}
}
private fun hasSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean {
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
val visible = snapshot.nodes.filter { it.visible }
@@ -372,7 +605,7 @@ class PurchaseLiveAutomation(
private fun viewportSignature(snapshot: UiSnapshot, panel: SnapshotNode): String = snapshot.nodes
.filter { node -> node.visible && inside(node.bounds, panel.bounds) }
.joinToString("|") { node ->
listOf(node.className.orEmpty(), node.bounds.left, node.bounds.top, node.bounds.right, node.bounds.bottom, node.clickable, node.scrollable).joinToString(":")
listOf(node.path, node.className.orEmpty(), node.bounds.left, node.bounds.top, node.bounds.right, node.bounds.bottom, node.clickable, node.scrollable).joinToString(":")
}
private fun inside(child: NodeBounds, parent: NodeBounds): Boolean =
@@ -535,5 +768,11 @@ class PurchaseLiveAutomation(
const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L
const val SPEC_CONFIRMATION_MAX_SAMPLES = 20
const val SPEC_CONFIRMATION_SAMPLE_INTERVAL_MS = 100L
const val ADDRESS_ENTRY_STABLE_INTERVAL_MS = 200L
const val ADDRESS_CARD_OVERLAP_PERCENT = 70
const val ADDRESS_SEMANTIC_MIN_LENGTH = 4
val ADDRESS_CARD_GENERIC_LABELS = setOf("收货地址", "修改", "默认地址", "默认")
const val ADDRESS_CONFIRMATION_SCROLL_LIMIT = 5
const val ADDRESS_CONFIRMATION_SCROLL_INTERVAL_MS = 500L
}
}
@@ -10,6 +10,13 @@ interface PurchaseUiDriver {
result = clickFresh(target),
reason = FreshClickReason.UNKNOWN,
)
/**
* Reacquires one address-entry node by its accessibility path and validates
* its immutable snapshot traits before clicking its nearest clickable
* ancestor. This is intentionally limited to the reversible transition
* from order confirmation into address management.
*/
fun clickAddressEntryFresh(target: SnapshotNode): FreshClickOutcome = clickFreshDetailed(target)
fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult
/**
* Reacquires and center-taps a target that the purchase parser has already
@@ -83,10 +90,15 @@ class PurchaseRehearsalExecutor(
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
val live = PurchaseLiveAutomation(driver, pause)
for (action in rule.actions) {
// spec_probe already opened this task's URL and the server reserves
// the device while matching. Skip the whole navigation action,
// including its configured wait/swipe hooks, in phase two.
if (input.phase == "purchase" && action.type == PurchaseActionType.OPEN_PRODUCT) continue
// The immediate phase-two handoff can reuse the PDD page retained by
// spec_probe. A later manual retry may start from Agent (or another
// unrelated screen), so only skip navigation when a fresh snapshot
// still carries safe PDD product/spec evidence.
if (
input.phase == "purchase" &&
action.type == PurchaseActionType.OPEN_PRODUCT &&
canReuseCurrentProduct(input)
) continue
stepChanged(action.type.wireName)
val failure = when (action.type) {
PurchaseActionType.OPEN_PRODUCT -> openProduct(input, action)
@@ -105,7 +117,7 @@ class PurchaseRehearsalExecutor(
null
}
PurchaseActionType.UPDATE_SHIPPING_ADDRESS -> try {
live.advanceToOrderConfirmation()
live.advanceToOrderConfirmation(hasAllRecordedSelectionProofs(input, specSelectionProofs))
addressProof = live.updateShippingAddress(input.addressSuffix)
null
} catch (error: PurchaseLiveException) {
@@ -157,6 +169,11 @@ class PurchaseRehearsalExecutor(
else failure("PURCHASE_RULE_INVALID", "正式采购规则缺少核单动作")
}
private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean =
currentScreen(input).let { screen ->
screen.problem == null && screen.hasPurchaseProductEvidence()
}
private fun validateBeforeDeviceAction(
input: PurchaseExecutionInput,
rule: PurchaseRule,
@@ -634,7 +651,8 @@ class PurchaseRehearsalExecutor(
val reason: String,
val summaryTokenMatched: Boolean,
val candidateCount: Int,
val proofPresent: Boolean,
val proofRecorded: Boolean,
val proofUsable: Boolean,
val targetMatchCount: Int = 0,
val relocationAttempted: Boolean = false,
val relocationSwipes: Int = 0,
@@ -648,21 +666,25 @@ class PurchaseRehearsalExecutor(
): FinalSpecVerification {
val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
val matchingNodes = candidates.filter { it.text == target }
val proofPresent = proof?.dimension == dimension && proof.target == target && proof.panelType == screen.specPanelType
val proofRecorded = proof?.dimension == dimension && proof.target == target
val proofUsable = proofRecorded && (
proof?.panelType == screen.specPanelType ||
(screen.specPanelType == SpecPanelType.UNKNOWN && screen.isPddPackage && screen.rootAvailable)
)
if (matchingNodes.size == 1 && (matchingNodes.single().node.selected || matchingNodes.single().node.checked)) {
return FinalSpecVerification(true, "selected_node", false, candidates.size, proofPresent, matchingNodes.size)
return FinalSpecVerification(true, "selected_node", false, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
}
if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) {
return FinalSpecVerification(false, "visible_selected_conflict", false, candidates.size, proofPresent, matchingNodes.size)
return FinalSpecVerification(false, "visible_selected_conflict", false, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
}
val token = if (dimension == "size") SpecValueNormalizer.primarySizeToken(target) else target.trim()
val summaryTokenMatched = !token.isNullOrBlank() && SpecValueNormalizer.summaryHasExactToken(screen.selectedSummary, token)
if (!summaryTokenMatched) {
if (screen.selectedSummary == null && screen.specPanelOpen && proofPresent) {
return FinalSpecVerification(true, "attempt_selection_state", false, candidates.size, true, matchingNodes.size)
if (screen.selectedSummary == null && proofUsable) {
return FinalSpecVerification(true, "attempt_selection_state", false, candidates.size, true, true, matchingNodes.size)
}
return FinalSpecVerification(false, "summary_token_missing", false, candidates.size, proofPresent, matchingNodes.size)
return FinalSpecVerification(false, "summary_token_missing", false, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
}
// PDD can rerender or scroll the previously selected dimension out of
// the current viewport while the selected summary remains visible. A
@@ -673,15 +695,15 @@ class PurchaseRehearsalExecutor(
if (dimension == "size") SpecValueNormalizer.primarySizeToken(candidate.text) == token else candidate.text == token
}
if (visibleTokenMatches.size == 1 && visibleTokenMatches.single().text == target) {
return FinalSpecVerification(true, "visible_candidate", true, candidates.size, proofPresent, matchingNodes.size)
return FinalSpecVerification(true, "visible_candidate", true, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
}
if (visibleTokenMatches.isNotEmpty()) {
return FinalSpecVerification(false, "visible_candidate_conflict", true, candidates.size, proofPresent, matchingNodes.size)
return FinalSpecVerification(false, "visible_candidate_conflict", true, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
}
return if (proofPresent) {
FinalSpecVerification(true, "attempt_selection_proof", true, candidates.size, true, matchingNodes.size)
return if (proofUsable) {
FinalSpecVerification(true, "attempt_selection_proof", true, candidates.size, true, true, matchingNodes.size)
} else {
FinalSpecVerification(false, "selection_proof_missing", true, candidates.size, false, matchingNodes.size)
FinalSpecVerification(false, "selection_proof_missing", true, candidates.size, proofRecorded, false, matchingNodes.size)
}
}
@@ -693,7 +715,7 @@ class PurchaseRehearsalExecutor(
): FinalSpecVerification {
var screen = currentScreen(input)
screen.problem?.let {
return FinalSpecVerification(false, it.code, false, 0, proof != null)
return FinalSpecVerification(false, it.code, false, 0, proof != null, false)
}
var verification = verifyExactSpecSelection(screen, dimension, target, proof)
if (verification.confirmed || verification.targetMatchCount > 0 || verification.reason == "visible_selected_conflict") return verification
@@ -849,7 +871,7 @@ class PurchaseRehearsalExecutor(
val diagnostic = "dimension=$dimension,reason=${verification.reason},panel=$panel," +
"summary=${screen.selectedSummary != null},tokenMatched=${verification.summaryTokenMatched}," +
"candidates=${verification.candidateCount},targetMatches=${verification.targetMatchCount}," +
"proof=${verification.proofPresent}," +
"proofRecorded=${verification.proofRecorded},proofUsable=${verification.proofUsable}," +
"relocated=${verification.relocationAttempted},relocationSwipes=${verification.relocationSwipes}"
return failure(SPEC_SELECTION_UNCONFIRMED, "最终规格复核未能确认精确选中状态 [$diagnostic]")
}
@@ -864,6 +886,18 @@ class PurchaseRehearsalExecutor(
return null
}
private fun hasAllRecordedSelectionProofs(
input: PurchaseExecutionInput,
proofs: Map<String, ExactSpecSelectionProof>,
): Boolean {
val required = listOf("color" to input.mappedColor, "size" to input.mappedSize)
.filter { it.second.isNotBlank() }
.map { (dimension, rawTarget) -> dimension to normalizedTarget(dimension, rawTarget) }
return required.isNotEmpty() && required.all { (dimension, target) ->
target != null && proofs[dimension]?.let { it.dimension == dimension && it.target == target } == true
}
}
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
if (action.waitAfterMs > 0) pause(action.waitAfterMs)
action.swipeAfter?.let { swipe ->
@@ -83,10 +83,12 @@ internal object CollectionResetPolicy {
internal object PurchaseRetryPolicy {
fun showsAction(status: String, retryable: Boolean): Boolean = status == "failed" && retryable
fun usesInPlaceReset(continuing: Boolean): Boolean = !continuing
fun confirmationMessage(continuing: Boolean = false): String = if (continuing) {
"替代商品已完成匹配。系统会保留原任务并创建一笔新采购任务;可能创建拼多多待付款订单,但不会支付。"
} else {
"系统会保留原任务,并根据当前商品档案和最新采购规则创建一笔新采购任务;可能创建拼多多待付款订单,但不会支付。"
"系统会复用原采购任务并开始新一次执行,使用当前有效采购规则,商品和规格等任务快照保持不变;可能创建拼多多待付款订单,但不会支付。"
}
}
@@ -746,7 +748,7 @@ class TaskHistoryFragment : Fragment() {
contentDescription = "重试采购任务 CG-${task.taskId}"
setOnClickListener { confirmPurchaseRetry(task) }
}, collectionCardParams())
resultColumn.addView(context.centeredMessage("重试边界", "保留当前失败任务并创建新任务;新任务读取当前商品档案和采购规则,不会执行支付。"))
resultColumn.addView(context.centeredMessage("重试边界", "复用当前任务并新增一次执行;使用当前有效采购规则,商品和规格快照不变,不会执行支付。"))
} else if (!replacementInProgress && task.status == "failed") {
val reason = task.retryDisabledReason?.takeIf(String::isNotBlank) ?: "请在管理端核对任务状态。"
resultColumn.addView(context.centeredMessage("不可重试", reason))
@@ -864,18 +866,25 @@ class TaskHistoryFragment : Fragment() {
val generation = ++requestGeneration
showLoading(if (continuing) "正在提交继续采购请求…" else "正在提交重试请求…")
Thread {
runCatching {
runCatching<Unit> {
val client = AgentApiClient(serverUrl)
val requestId = UUID.randomUUID().toString()
client.retryPurchaseTask(taskId, requestId, credentials.token)
}
.onSuccess { result ->
if (PurchaseRetryPolicy.usesInPlaceReset(continuing)) {
val result = client.resetPurchaseTask(taskId, requestId, credentials.token)
resultColumn.post {
if (!isAdded || generation != requestGeneration) return@post
AgentForegroundService.start(requireContext())
showPurchaseResetSuccess(result.taskNo, result.taskId, result.attemptNumber)
}
} else {
val result = client.retryPurchaseTask(taskId, requestId, credentials.token)
resultColumn.post {
if (!isAdded || generation != requestGeneration) return@post
AgentForegroundService.start(requireContext())
showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId)
}
}
}
.onFailure { error ->
resultColumn.post {
if (isAdded && generation == requestGeneration) {
@@ -886,6 +895,24 @@ class TaskHistoryFragment : Fragment() {
}.start()
}
private fun showPurchaseResetSuccess(taskNo: String, taskId: Long, attemptNumber: Int) {
resultColumn.removeAllViews()
resultColumn.addView(requireContext().centeredMessage(
"$taskNo 已进入第 $attemptNumber 次执行",
"原采购任务已复用,并将由当前设备按正常队列执行;系统不会支付。",
))
resultColumn.addView(MaterialButton(requireContext()).apply {
text = "查看当前任务"
minimumHeight = requireContext().dp(48)
setOnClickListener { loadPurchaseDetail(taskId) }
}, collectionCardParams())
resultColumn.addView(MaterialButton(requireContext(), null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
text = "返回采购记录"
minimumHeight = requireContext().dp(48)
setOnClickListener { page = 1; load() }
}, collectionCardParams())
}
private fun showPurchaseRetrySuccess(sourceTaskNo: String, taskNo: String, taskId: Long) {
resultColumn.removeAllViews()
resultColumn.addView(requireContext().centeredMessage(
@@ -1,6 +1,8 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.FreshActionResult
import cn.ilapage.goauto.agent.automation.FreshClickOutcome
import cn.ilapage.goauto.agent.automation.FreshClickReason
import cn.ilapage.goauto.agent.automation.NodeBounds
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
@@ -56,6 +58,41 @@ class PurchaseLiveAutomationTest {
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `recorded exact selections allow one unique confirm on an unclassified pdd panel`() {
val driver = SpecConfirmationDriver(unclassifiedSpecPanel = true)
PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation(
allowUnclassifiedPanelWithSelectionProof = true,
)
assertEquals(listOf("确定"), driver.clicked)
assertEquals("order", driver.page)
}
@Test
fun `unclassified pdd panel without recorded selections remains blocked`() {
val driver = SpecConfirmationDriver(unclassifiedSpecPanel = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation() }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_CONFIRMATION_NOT_READY", error.code)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `recorded selections never authorize a generic unclassified pdd dialog`() {
val driver = SpecConfirmationDriver(unclassifiedSpecPanel = true, unclassifiedHasPurchaseEvidence = false)
val error = runCatching {
PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation(
allowUnclassifiedPanelWithSelectionProof = true,
)
}.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_CONFIRMATION_NOT_READY", error.code)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `address is retagged verified and final order button can only be clicked once`() {
val driver = LiveDriver()
@@ -104,6 +141,31 @@ class PurchaseLiveAutomationTest {
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address returning to spec panel is recovered without pressing back`() {
val driver = LiveDriver(saveReturnsToSpecPanel = true)
val automation = PurchaseLiveAutomation(driver, pause = {})
val address = automation.updateShippingAddress("_cg91")
val final = automation.finalConfirmation(input().copy(addressSuffix = "_cg91"), address)
assertEquals("_cg91", final.addressSuffix)
assertEquals(0, driver.backCount)
assertEquals(1, driver.scopedSwipes)
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address stuck on spec panel fails without backing out or submitting`() {
val driver = LiveDriver(saveReturnsToSpecPanel = true, savedSpecPanelRecoveryStuck = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg92") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
assertEquals(0, driver.backCount)
assertEquals(0, driver.submitClicks)
}
@Test
fun `edit field containing suffix cannot impersonate post save evidence`() {
val driver = LiveDriver(saveStaysInEdit = true)
@@ -439,6 +501,52 @@ class PurchaseLiveAutomationTest {
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `duplicate masked phone nodes in one clickable address card are activated once`() {
val driver = LiveDriver(duplicatePhoneNodesSameCard = true)
PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg94")
assertEquals(1, driver.clicked.count { it == "138****5678" })
assertEquals(0, driver.addressTaps)
}
@Test
fun `duplicate semantic address cards in separate accessibility trees are activated once`() {
val driver = LiveDriver(duplicateSemanticAddressCards = true)
PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg94")
assertEquals(1, driver.addressPathClicks)
assertEquals(1, driver.clicked.count { it == "138****5678" })
assertEquals(0, driver.addressTaps)
}
@Test
fun `two independent address cards remain ambiguous without any click`() {
val driver = LiveDriver(duplicateAddressCards = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg95") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
assertTrue(error.message.orEmpty().contains("phoneNodes=2;addressCards=2;tapTargets=2"))
assertFalse(error.message.orEmpty().contains("138"))
assertTrue(driver.clicked.isEmpty())
assertEquals(0, driver.addressTaps)
}
@Test
fun `address entry path drift fails without fallback tap`() {
val driver = LiveDriver(duplicatePhoneNodesSameCard = true, addressEntryPathUnavailable = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg94") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_ENTRY_NOT_READY", error.code)
assertTrue(driver.clicked.isEmpty())
assertEquals(1, driver.addressPathClicks)
assertEquals(0, driver.addressTaps)
}
private fun input() = PurchaseExecutionInput(
taskId = 11,
executionMode = "live",
@@ -457,6 +565,8 @@ class PurchaseLiveAutomationTest {
private val confirmLabels: List<String> = listOf("确定"),
private val advanceAfterClick: Boolean = true,
private val specPanelScrollable: Boolean = false,
private val unclassifiedSpecPanel: Boolean = false,
private val unclassifiedHasPurchaseEvidence: Boolean = true,
startOnOrderPage: Boolean = false,
) : PurchaseUiDriver {
var page = if (startOnOrderPage) "order" else "spec"
@@ -475,6 +585,14 @@ class PurchaseLiveAutomationTest {
} else {
snapshot(buildList {
add(node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)))
if (unclassifiedSpecPanel) {
if (unclassifiedHasPurchaseEvidence) {
add(node("price", "¥20.00"))
add(node("quantity", "1", className = "android.widget.EditText"))
}
confirmLabels.forEachIndexed { index, label -> add(node("confirm-$index", label, clickable = true)) }
return@buildList
}
if (specPanelScrollable) {
add(node("scroll", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 1500)))
}
@@ -526,6 +644,12 @@ class PurchaseLiveAutomationTest {
private val saveStaysInEdit: Boolean = false,
private val savedTransitionWithoutLegacyContext: Boolean = false,
private val savedTransitionHidesSuffix: Boolean = false,
private val saveReturnsToSpecPanel: Boolean = false,
private val savedSpecPanelRecoveryStuck: Boolean = false,
private val duplicatePhoneNodesSameCard: Boolean = false,
private val duplicateSemanticAddressCards: Boolean = false,
private val duplicateAddressCards: Boolean = false,
private val addressEntryPathUnavailable: Boolean = false,
private val wechatLoginAfterSubmit: Boolean = false,
private val wechatRestoreStuck: Boolean = false,
private val orderEvidenceBelowFold: Boolean = false,
@@ -544,6 +668,7 @@ class PurchaseLiveAutomationTest {
var scopedSwipes = 0
var genericSwipes = 0
var addressTaps = 0
var addressPathClicks = 0
var lastInputTargetPath: String? = null
var backCount = 0
var postSubmitBackCount = 0
@@ -581,6 +706,21 @@ class PurchaseLiveAutomationTest {
node("transition-title", "选择收货信息"),
node("address-summary", if (savedTransitionHidesSuffix) "已保存的收货信息" else address.substring(address.lastIndexOf("_cg"))),
))
"post-save-spec" -> snapshot(listOf(
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
node("panel", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
node("panel-title", "确认款式", bounds = NodeBounds(20, 396, 300, 430)),
node("panel/price", "¥20.00", parentPath = "panel", bounds = NodeBounds(20, 460, 300, 520)),
node("panel/selected", "已选 黑色 XL", parentPath = "panel", bounds = NodeBounds(20, 540, 700, 600)),
node("panel/color-heading", "颜色分类", parentPath = "panel", bounds = NodeBounds(20, 650, 300, 700)),
node("panel/color", "黑色", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 720, 220, 790)),
node("panel/size-heading", "尺码", parentPath = "panel", bounds = NodeBounds(20, 850, 300, 900)),
node("panel/size", "XL", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 920, 220, 990)),
node("panel/quantity", "2", className = "android.widget.EditText", parentPath = "panel", bounds = NodeBounds(400, 1050, 600, 1120)),
node("panel/confirm", "确定", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 1200, 500, 1280)),
node("submit-parent", "", clickable = true, bounds = NodeBounds(20, 1900, 1000, 2100)),
node("submit", "提交订单", parentPath = "submit-parent", bounds = NodeBounds(520, 1940, 980, 2040)),
))
"order" -> snapshot(listOf(node("status", "待付款"), node("order", "订单号:PDD-202608210001"), node("time", "下单时间:2026-08-21 10:30:00"), node("pay", "立即支付", clickable = true)))
"order-folded" -> snapshot(listOf(node("status", "待付款"), node("pay", "立即支付", clickable = true)))
"order-folded-payment-activity" -> UiSnapshot(PDD, "com.xunmeng.pinduoduo.app_pay.core.PayActivity", listOf(
@@ -614,7 +754,30 @@ class PurchaseLiveAutomationTest {
)
if (duplicatePanels) nodes += node("panel2", "", scrollable = true, bounds = NodeBounds(0, 500, 1080, 2000))
if (addressVisible) {
nodes += node("phone", "138****5678")
when {
duplicateSemanticAddressCards -> {
nodes += node("address-layer-a", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 840))
nodes += node("address-layer-a/phone", "138****5678", parentPath = "address-layer-a", bounds = NodeBounds(20, 650, 400, 710))
nodes += node("address-layer-a/detail", "广东省广州市天园街道骏景花园", parentPath = "address-layer-a", bounds = NodeBounds(20, 720, 900, 790))
nodes += node("address-layer-b", "", clickable = true, bounds = NodeBounds(0, 900, 1080, 1120))
nodes += node("address-layer-b/phone", "138****5678", parentPath = "address-layer-b", bounds = NodeBounds(20, 930, 400, 990))
nodes += node("address-layer-b/detail", "广东省广州市天园街道骏景花园", parentPath = "address-layer-b", bounds = NodeBounds(20, 1000, 900, 1070))
}
duplicateAddressCards -> {
nodes += node("address-card-a", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 820))
nodes += node("address-card-a/phone", "138****5678", parentPath = "address-card-a", bounds = NodeBounds(20, 650, 400, 710))
nodes += node("address-card-a/detail", "广东省广州市天园街道一号", parentPath = "address-card-a", bounds = NodeBounds(20, 720, 900, 780))
nodes += node("address-card-b", "", clickable = true, bounds = NodeBounds(0, 840, 1080, 1040))
nodes += node("address-card-b/phone", "138****5678", parentPath = "address-card-b", bounds = NodeBounds(20, 870, 400, 930))
nodes += node("address-card-b/detail", "广东省广州市天园街道二号", parentPath = "address-card-b", bounds = NodeBounds(20, 940, 900, 1000))
}
duplicatePhoneNodesSameCard -> {
nodes += node("address-card", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 900))
nodes += node("address-card/phone-a", "138****5678", parentPath = "address-card", bounds = NodeBounds(20, 650, 400, 710))
nodes += node("address-card/phone-b", "138****5678", parentPath = "address-card", bounds = NodeBounds(20, 720, 440, 790))
}
else -> nodes += node("phone", "138****5678")
}
val suffixStart = address.lastIndexOf("_cg")
val addressBody = if (suffixStart >= 0) address.substring(0, suffixStart) else address
val addressSuffix = if (suffixStart >= 0) address.substring(suffixStart) else ""
@@ -639,7 +802,13 @@ class PurchaseLiveAutomationTest {
when (target.label) {
"138****5678" -> page = "panel"
"修改" -> page = "edit"
"保存" -> if (!saveStaysInEdit) page = if (savedTransitionWithoutLegacyContext) "saved-transition" else "panel"
"保存" -> if (!saveStaysInEdit) {
page = when {
saveReturnsToSpecPanel -> "post-save-spec"
savedTransitionWithoutLegacyContext -> "saved-transition"
else -> "panel"
}
}
"提交订单" -> {
submitClicks++
page = when {
@@ -657,6 +826,14 @@ class PurchaseLiveAutomationTest {
return FreshActionResult.SUCCESS
}
override fun clickAddressEntryFresh(target: SnapshotNode): FreshClickOutcome {
addressPathClicks++
if (addressEntryPathUnavailable) {
return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
}
return FreshClickOutcome(clickFresh(target), FreshClickReason.SUCCESS, 1, 1)
}
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult {
addressTaps++
clicked += target.label
@@ -677,6 +854,7 @@ class PurchaseLiveAutomationTest {
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
scopedSwipes++
if (target.path == "panel" && direction == SwipeDirection.DOWN) addressVisible = true
if (page == "post-save-spec" && direction == SwipeDirection.DOWN && !savedSpecPanelRecoveryStuck) page = "confirmation"
return true
}
override fun backPurchase(): Boolean {
@@ -89,6 +89,57 @@ class PurchaseRehearsalExecutorTest {
assertFalse(pauses.contains(700))
}
@Test
fun `manual purchase retry from agent reopens the task product url`() {
val driver = FakePurchaseDriver(initiallyInAgent = true)
var openCount = 0
val outcome = PurchaseRehearsalExecutor(
driver,
openLink = {
openCount++
driver.leaveAgentAndOpenBrowser()
true
},
probeSpecs = { null },
pause = {},
).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(1, openCount)
assertEquals(1, driver.openClickCount)
}
@Test
fun `purchase phase reopens when pdd foreground has no product evidence`() {
val driver = FakePurchaseDriver(loadingPddCaptures = 1)
var openCount = 0
val outcome = PurchaseRehearsalExecutor(
driver,
openLink = { openCount++; driver.browser = true; true },
probeSpecs = { null },
pause = {},
).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(1, openCount)
assertEquals(1, driver.openClickCount)
}
@Test
fun `purchase phase does not reuse a pdd login page`() {
val driver = FakePurchaseDriver(pddProblemLabels = listOf("手机号登录", "登录后继续"))
var openCount = 0
val outcome = PurchaseRehearsalExecutor(
driver,
openLink = { openCount++; driver.browser = true; true },
probeSpecs = { null },
pause = {},
).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_LOGIN_REQUIRED", outcome.errorCode)
assertEquals(1, openCount)
}
@Test
fun `price range failure reports the observed unit price`() {
val driver = FakePurchaseDriver()
@@ -245,6 +296,17 @@ class PurchaseRehearsalExecutorTest {
assertEquals(1, driver.clicked.count { it == "黑色" })
}
@Test
fun `final verification keeps current attempt proof when selected panel becomes unclassified`() {
val driver = FakePurchaseDriver(panelBecomesUnknownAfterSizeProof = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(quantity = 1), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(1, driver.clicked.count { it == "黑色" })
assertEquals(1, driver.clicked.count { it == "XL" })
}
@Test
fun `final verification reuses exact attempt state without scrolling back`() {
val driver = FakePurchaseDriver(
@@ -310,7 +372,8 @@ class PurchaseRehearsalExecutorTest {
assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", outcome.errorCode)
assertTrue(outcome.message.contains("dimension=size"))
assertTrue(outcome.message.contains("reason=visible_candidate_conflict"))
assertTrue(outcome.message.contains("proof=true"))
assertTrue(outcome.message.contains("proofRecorded=true"))
assertTrue(outcome.message.contains("proofUsable=true"))
}
@Test
@@ -622,14 +685,16 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `verify product waits for product evidence instead of a visible loading frame`() {
fun `purchase reopens and waits for product evidence instead of a visible loading frame`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(loadingPddCaptures = 1)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(2, pauses.count { it == 100L })
// Two stable reads belong to reopening the product; verifyProduct then
// independently requires its second stable read before continuing.
assertEquals(3, pauses.count { it == 100L })
}
@Test
@@ -994,8 +1059,11 @@ class PurchaseRehearsalExecutorTest {
private val nonScrollablePanel: Boolean = false,
private val unrecognizedPanel: Boolean = false,
private val purchaseSwipeSucceeds: Boolean = true,
initiallyInAgent: Boolean = false,
private val panelBecomesUnknownAfterSizeProof: Boolean = false,
) : PurchaseUiDriver {
var browser = false
private var inAgent = initiallyInAgent
var panel = false
var color: String? = null
var size: String? = initialSize
@@ -1015,10 +1083,14 @@ class PurchaseRehearsalExecutorTest {
private var pddCaptureCount = 0
private var browserCaptureCount = 0
private var hiddenColorRestored = false
private var capturesAfterSizeSelection = 0
val clicked = mutableListOf<String>()
val clickedPaths = mutableListOf<String>()
override fun capture(): UiSnapshot {
if (inAgent) {
return UiSnapshot("cn.ilapage.goauto.agent", "MainActivity", listOf(node("content", "", 0, 0, 1080, 2200)))
}
if (browser && !panel && color == null && size == null) {
browserCaptureCount++
if (browserCaptureCount <= browserOpenVisibleAfterCaptures) {
@@ -1029,6 +1101,17 @@ class PurchaseRehearsalExecutorTest {
openNodes += node("content", "", 0, 0, 1080, 2200)
return UiSnapshot("com.heytap.browser", "BrowserActivity", openNodes)
}
if (panelBecomesUnknownAfterSizeProof && panel && size != null) {
capturesAfterSizeSelection++
if (capturesAfterSizeSelection > 1) {
return UiSnapshot(PDD, ACTIVITY, listOf(
node("content", "", 0, 0, 1080, 2200),
node("price", "¥20.00", 20, 300, 300, 360),
node("quantity", "1", 400, 800, 600, 870, className = "android.widget.EditText"),
node("confirm", "确定", 20, 900, 500, 980, clickable = true),
))
}
}
pddCaptureCount++
if (pddCaptureCount <= loadingPddCaptures) {
return UiSnapshot(PDD, ACTIVITY, listOf(node("content", "", 0, 0, 1080, 2200)))
@@ -1145,6 +1228,11 @@ class PurchaseRehearsalExecutorTest {
return UiSnapshot(PDD, ACTIVITY, nodes)
}
fun leaveAgentAndOpenBrowser() {
inAgent = false
browser = true
}
override fun clickFresh(target: SnapshotNode): FreshActionResult {
clicked += target.label
clickedPaths += target.path
@@ -16,19 +16,22 @@ class PurchaseRetryPolicyTest {
}
@Test
fun `retry confirmation explains new task current archive and no payment`() {
fun `ordinary retry uses same task new attempt and no payment`() {
val message = PurchaseRetryPolicy.confirmationMessage()
assertTrue(message.contains("保留原任务"))
assertTrue(message.contains("当前商品档案"))
assertTrue(message.contains("最新采购规则"))
assertTrue(message.contains("新采购任务"))
assertTrue(PurchaseRetryPolicy.usesInPlaceReset(continuing = false))
assertTrue(message.contains("复用原采购任务"))
assertTrue(message.contains("新一次执行"))
assertTrue(message.contains("当前有效采购规则"))
assertTrue(message.contains("任务快照保持不变"))
assertTrue(message.contains("待付款订单"))
assertTrue(message.contains("不会支付"))
assertFalse(message.contains("新采购任务"))
}
@Test
fun `continue confirmation remains a new purchase task`() {
val message = PurchaseRetryPolicy.confirmationMessage(continuing = true)
assertFalse(PurchaseRetryPolicy.usesInPlaceReset(continuing = true))
assertTrue(message.contains("新采购任务"))
assertTrue(message.contains("替代商品"))
assertTrue(message.contains("不会支付"))
+9 -9
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: 21ad1681d12a40e7042567f03716d899d49b737e
synchronized_at: 2026-09-05T07:16:58Z
wiki_revision: 240e8fb9e365d534bbf0c7b2cd8ff1438bc8857b
synchronized_at: 2026-09-05T09:03:48Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -257,13 +257,13 @@ synchronized_at: 2026-09-05T07:16:58Z
## Agent 受控重试采购
- 当前设备只可重试自身最近 30 天内、服务端标记 `retryable=true` 的正式采购失败任务;列表和详情都只能发起单任务重试,不支持多选、批量或自动重试。
- 普通“重试采购”调用既有 `AgentRetry → BatchRetry → Create`:原失败任务及其商品、目标规格、执行规格、价格和执行记录保持不可变;服务端根据当前 SYB、虾皮/PDD 档案、当前采购规则和当前设备创建不同 `purchase_task.id` 的新任务。
- 新 SYB 采购任务继续遵循 #215 的强制当次规格探测,首趟不得直接使用历史任务的规格决策;当前档案、规则、价格、设备或能力门禁不通过时拒绝创建,旧任务保持失败状态。
- 普通“重试采购”调用就地 `/reset`:复用原 `purchase_task.id`,只新增 attempt;新 attempt 使用当前有效采购规则和最新版 Agent 代码,商品、目标规格、执行规格、数量、价格、地址及既有规格决策等业务快照保持不变。
- 就地重试不恢复已经消耗的真机规格探测资格;目标规格存在但对应执行规格为空时必须拒绝,不能进入正式采购阶段。需要按替代商品或已变化业务规格重新决策时,必须走明确的新任务流程。
- 已出现 `order_submit_started` 证据,或存在不可逆时间、订单提交请求、PDD 订单号、下单时间的任务一律拒绝重试,并提示走既有“授权重新采购”流程,防止重复下单。
- `requestId` 按“来源任务 + 请求”幂等;相同请求重放返回同一新任务,不重复创建。
- Android 只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”。确认和成功反馈必须说明旧任务保留、新任务读取当前档案和规则、可能创建待付款订单且系统不会支付。
- 历史兼容的就地 `/reset` 服务端入口不得把“目标规格存在但对应执行规格为空”的任务恢复到正式采购阶段;此类异常快照必须拒绝,并提示创建新任务。Android 普通重试不再调用该入口。
- 替代商品匹配完成后的“继续采购”和 Admin 批量重试继续使用同一新任务语义;取消订单、修改既有订单和支付仍禁止。真机重试可能进入创建待付款订单流程,执行前必须再次取得人工授权。
- `requestId` 按“任务 + 重置请求”幂等;相同请求重放返回同一 task ID 和 attempt,不重复递增。
- Android 只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”。确认和成功反馈必须说明复用原任务、新增一次执行、使用当前规则但业务快照不变、可能创建待付款订单且系统不会支付。
- `/reset` 不得把“目标规格存在但对应执行规格为空”的任务恢复到正式采购阶段;此类异常快照必须拒绝,并提示使用明确的新任务流程。
- 替代商品匹配完成后的“继续采购”继续调用 `AgentRetry → BatchRetry → Create`,按当前档案创建不同 task ID;Admin 批量重试也保持新任务语义。取消订单、修改既有订单和支付仍禁止。真机重试可能进入创建待付款订单流程,执行前必须再次取得人工授权。
## Agent 状态页手动检查任务
@@ -341,7 +341,7 @@ synchronized_at: 2026-09-05T07:16:58Z
## SYB 采购强制当次规格探测(#215)
- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。首趟只打开一次浏览器商品链接;匹配期间当前设备保留给同一任务,不领取其他采购或采集任务;第二趟复用 PDD 当前页,不再次打开链接,也不严格核验标题、goodsId 或页面指纹,但仍要求 PDD 包名与商品/规格/订单页面结构安全证据。已有长期映射只作商品档案事实,不直接进入任务执行规格。
- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。首趟只打开一次浏览器商品链接;匹配期间当前设备保留给同一任务,不领取其他采购或采集任务。连续进入第二趟且当前仍有 PDD 商品页或规格面板强证据时复用当前页、不再次打开链接;手动同任务重试,或当前处于 Agent、其他应用及缺少上述强证据时,重新打开任务固化的商品 URL。复用或重开均不严格核验标题、goodsId 或页面指纹,但仍要求 PDD 包名与商品/规格/订单页面结构安全证据。已有长期映射只作商品档案事实,不直接进入任务执行规格。
- 首趟候选与 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和幂等结果哈希关联;第二趟失败不得回到首趟循环探测。备货 `stock/direct_select` 没有 SYB 目标规格,继续使用用户逐字选择的档案规格,不进入本规则。
- 候选和 Provider 结果仅保存颜色、尺码原始标签及结构化决策,不保存控件树、整屏截图、账号、地址、订单或支付数据;付款仍永久禁止。
+10 -10
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: e5a443e16e288143c2c890c674038c531abe3f16
synchronized_at: 2026-09-05T07:17:37Z
wiki_revision: f3242938de4ac55c47f5c6eebd69184024e6a0ea
synchronized_at: 2026-09-05T09:04:42Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -535,11 +535,11 @@ Admin 列表与详情由 #35 实现;#67 增加 `shopeeOrderNoSnapshot` 的列
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/order-submit-started` | 创建订单前先落不可逆标记;演练任务和 `spec_probe` attempt 永远拒绝 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/result` | 请求体携带 `taskAttemptId` 和 `requestId`;幂等提交演练、规格探测、订单或失败结果 |
自 #215 起,新建 SYB 采购任务不再从 PDD 档案创建持久匹配工作项,也不在首次派发前调用外部 AI;部署前已存在的 `purchase_spec_match_work_item` 继续按原状态兼容处理。新任务首次 `start` 固定得到 `phase=spec_probe`,Android 通过浏览器打开任务链接一次并经既有结果字段回传当次候选;匹配成功后的第二次 `start` 才得到 `phase=purchase` 和服务端固化的精确 PDD 原始标签。第二阶段直接复用首趟保留的 PDD 页面,不再次打开浏览器链接,也不以标题、goodsId 或页面指纹做严格同页校验;仍必须通过 PDD 包名和商品/规格/订单页面结构安全证据。Android 不接收 AI 配置或自由决策权限。
自 #215 起,新建 SYB 采购任务不再从 PDD 档案创建持久匹配工作项,也不在首次派发前调用外部 AI;部署前已存在的 `purchase_spec_match_work_item` 继续按原状态兼容处理。新任务首次 `start` 固定得到 `phase=spec_probe`,Android 通过浏览器打开任务链接一次并经既有结果字段回传当次候选;匹配成功后的第二次 `start` 才得到 `phase=purchase` 和服务端固化的精确 PDD 原始标签。连续第二阶段仅在当前 PDD 页面仍有商品页或规格面板强证据时复用首趟页面、不再次打开浏览器链接;手动同任务重试,或当前为 Agent、其他应用及缺少上述强证据时,Android 重新打开任务 `urlSnapshot`。两种路径都不以标题、goodsId 或页面指纹做严格同页校验,仍必须通过 PDD 包名和商品/规格/订单页面结构安全证据。Android 不接收 AI 配置或自由决策权限。
结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。每个新 SYB 采购任务的第一趟只读遍历当次 PDD 规格面板并提交颜色、尺码原始候选,随后释放数据库租约和已知账号运行守卫并进入 `spec_probe_pending`,但服务端调度与 Agent 必须把当前设备保留给同一采购流程:`next` 返回该等待任务,Agent 只轮询等待,不领取其他采购或采集任务。服务端只以任务冻结的 SYB 目标和当次候选先做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。AI 的颜色和尺码必须逐字属于当次对应候选,否则按无匹配失败。第二趟只会收到服务端固化的精确 PDD 原始标签;Agent 复用首趟仍打开的页面,只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。
任务 payload 的必传布尔字段 `specResolutionAllowed` 是 Android 是否可以提交规格探测的唯一资格事实。新建 `taskType=syb_order` 任务必须由声明 `purchase.spec-probe.v1` 的规则创建,初始 `SpecDecisionRequestID` 为空且 `specSource=unresolved`,首趟返回 `true`;当次决策固化后返回 `false`。`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。历史兼容的就地 `/reset` 保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格;映射不完整时必须拒绝,不能进入正式采购阶段。普通 Agent 重试创建新任务并重新取得一次探测资格。Android 不得根据映射是否非空、错误文字或本地判断扩大资格。
任务 payload 的必传布尔字段 `specResolutionAllowed` 是 Android 是否可以提交规格探测的唯一资格事实。新建 `taskType=syb_order` 任务必须由声明 `purchase.spec-probe.v1` 的规则创建,初始 `SpecDecisionRequestID` 为空且 `specSource=unresolved`,首趟返回 `true`;当次决策固化后返回 `false`。`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。普通 Agent 重试使用就地 `/reset`,保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格;映射不完整时必须拒绝,不能进入正式采购阶段。只有替代商品“继续采购”或 Admin 批量重试创建的新任务才按 #215 重新取得一次探测资格。Android 不得根据映射是否非空、错误文字或本地判断扩大资格。
Android 规格失败使用五个稳定阶段:`PURCHASE_SPEC_TARGET_NOT_VISIBLE`、`PURCHASE_SPEC_TARGET_AMBIGUOUS`、`PURCHASE_SPEC_SAFE_TARGET_MISSING`、`PURCHASE_SPEC_CLICK_FAILED` 和 `PURCHASE_SPEC_SELECTION_UNCONFIRMED`。`PURCHASE_SPEC_CLICK_FAILED` 的 `errorMessage` 只允许稳定子原因 `root_unavailable`、`target_stale`、`no_clickable_ancestor`、`action_click_false` 或 `unknown`;其他阶段的消息不得包含规格原文、坐标、控件树或截图。只有 `PURCHASE_SPEC_TARGET_NOT_VISIBLE && specResolutionAllowed=true` 可以提交规格探测,其他四态直接提交真实失败,服务端原样保留稳定阶段/子原因。旧 Agent 在资格已用尽后再次提交 `spec_probe_completed` 时,服务端以 `PURCHASE_SPEC_REPROBE_REJECTED` fail-closed,释放租约并保留第一次规格决策,不再冒充新的选择根因或再次派发。无匹配、候选不完整、歧义或 Provider 异常同样使任务失败。`order_result_unknown` 只允许管理员或采购员人工解除,永不自动重派。
@@ -617,9 +617,9 @@ Content-Type: application/json
- 响应返回 `taskId`、`attemptNumber`、`status` 和可选的 `replayed`,不返回规则快照、URL、Token、控件树或截图。
- 设备离线、任务非终态、设备忙、规则不可用或同商品存在活动任务时返回明确冲突,不支持离线排队。
## Agent 受控采购重试(#95、#157、#217)
## Agent 受控采购重试(#95、#157、#217、#225)
普通失败任务的“重试采购”和替代商品匹配完成后的“继续采购”统一调用新任务接口:
替代商品匹配完成后的“继续采购”调用新任务接口;普通失败任务的“重试采购”使用下方同任务重置接口:
```http
POST /api/agent/v1/purchase-tasks/{taskId}/retry
@@ -647,11 +647,11 @@ Content-Type: application/json
- 来源任务不得存在 `irreversibleAt`、`orderSubmitRequestId`、PDD 订单号或下单时间。已有任何不可逆证据时返回 `PURCHASE_RETRY_UNSAFE`,提示走“授权重新采购”,不得创建新任务。
- `AgentRetry → BatchRetry → Create` 保留来源失败任务并创建不同 `purchase_task.id` 的新任务;新任务重新读取当前 SYB、虾皮/PDD 档案、当前采购规则、价格保护和设备能力,重新生成地址后缀,不继承来源任务的旧规格决策。
- 新 SYB 任务按 #215 固定从 `spec_probe` 开始。相同 `requestId` 重放返回同一新任务且 `replayed=true`;不同 requestId 再次请求受同一 SYB 商品最新任务和设备并发门禁约束。
- Android 只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”;确认文案和成功反馈必须说明原任务保留、新任务使用当前档案与规则、可能产生待付款订单且系统不会支付。
- 本新任务接口只供替代商品“继续采购”;确认和成功反馈必须说明保留来源任务、按当前替代商品档案创建新任务、可能产生待付款订单且系统不会支付。
- 规则无效、当前档案或价格不合格、设备离线/忙、能力不匹配、任务状态变化或同一 SYB 商品已有更新任务时,服务端明确拒绝且不得部分创建。
- Admin 批量重试继续使用相同的新任务语义;替代商品“继续采购”仍在 AgentRetry 前额外验证替换分项与继续采购资格。
历史兼容的就地重置接口仍保留,但 Android 普通重试不再调用:
普通失败任务的“重试采购”调用同任务重置接口:
```http
POST /api/agent/v1/purchase-tasks/{taskId}/reset
@@ -661,8 +661,8 @@ Content-Type: application/json
{"requestId":"<uuid>"}
```
- `/reset` 只允许安全失败、无不可逆证据且不存在更新任务的原任务;它保留原业务快照并刷新当前规则。
- 目标颜色存在但映射颜色为空,或目标尺码存在但映射尺码为空时,必须返回 `PURCHASE_SPEC_MAPPING_REQUIRED`,不得创建 `purchase` attempt 或下发正式采购 payload。
- `/reset` 只允许当前设备的安全失败任务且不得存在不可逆证据或更新任务;它复用原 `purchase_task.id`、新增 attempt、刷新当前有效规则,并保持商品、目标/执行规格、数量、价格、地址和规格决策等业务快照不变。
- 目标颜色存在但映射颜色为空,或目标尺码存在但映射尺码为空时,必须返回 `PURCHASE_SPEC_MAPPING_REQUIRED`,不得创建 `purchase` attempt 或下发正式采购 payload。相同 `requestId` 重放返回同一 task ID 和 attempt,不重复递增;Android 必须明确提示复用原任务和新的 attempt 序号。
- 两个入口都不执行支付。真机调用可能创建待付款订单,必须先取得人工授权。
## Agent 任务记录范围与同步(#99)