Compare commits

...
Author SHA1 Message Date
QiuSW 659bbc6e1a docs: describe local purchase diagnostic extraction #249 2026-09-09 11:18:12 +08:00
QiuSW 99faf5ad95 feat(android): persist bounded sanitized purchase entry diagnostics #249 2026-09-09 11:15:08 +08:00
QiuSW 8a3367a4ea docs: record scoped dynamic purchase footer contract #248 2026-09-09 10:48:33 +08:00
QiuSW c80c746943 fix(android): locate dynamic submit footer after address save #248 2026-09-09 10:45:44 +08:00
QiuSW bec8c84321 fix(android): recover saved address in short spec panels #247 2026-09-09 10:03:00 +08:00
QiuSW 7ea34e32c1 fix(android): restore address evidence after returning to spec panel #246 2026-09-09 09:48:10 +08:00
QiuSW d1ddb04959 docs: record exact spec availability diagnostics #245 2026-09-09 09:24:48 +08:00
QiuSW ca815c30de fix(android): clarify entry recovery and unavailable exact specs #245 2026-09-09 09:21:27 +08:00
QiuSW 66e0bdfc07 docs: document same-card selection exception (#244) 2026-09-08 18:12:12 +08:00
QiuSW 93aab6a59b fix(android): ignore selected duplicates within exact color card (#244) 2026-09-08 18:07:06 +08:00
QiuSW ec4802dfe7 docs: define scoped purchase entry recovery (#243) 2026-09-08 17:46:31 +08:00
QiuSW ec8b14ae21 fix(android): recover spec entry despite product page animation (#243) 2026-09-08 17:42:05 +08:00
QiuSW 7ac1355a71 docs(agent): document on-demand spec panel scrolling and diagnostics (#238) 2026-09-07 21:36:17 +08:00
QiuSW 58a6c1c86d fix(agent): replace mandatory spec panel preswipe with on-demand search (#238) 2026-09-07 21:33:31 +08:00
15 changed files with 1128 additions and 62 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 72
versionName = "0.9.59"
versionCode = 80
versionName = "0.9.67"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -434,8 +434,16 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
} else FreshActionResult.FAILED
}
private var lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.UNKNOWN
override fun purchaseSwipeFailureReason(): PurchaseSwipeFailureReason = lastPurchaseSwipeFailure
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
val root = rootInActiveWindow ?: return false
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.UNKNOWN
val root = rootInActiveWindow ?: run {
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.ROOT_UNAVAILABLE
return false
}
val candidates = mutableListOf<AccessibilityNodeInfo>()
walk(root) { node -> if (node.isVisibleToUser && node.isScrollable) candidates += node }
val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT
@@ -444,8 +452,12 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
}
val target = (directional.ifEmpty { candidates }).maxByOrNull { candidate ->
Rect().also(candidate::getBoundsInScreen).let { it.width().toLong() * it.height() }
} ?: return false
return swipeNode(target, direction, durationMs, preferScrollAction = false)
} ?: run {
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.NO_SCROLLABLE
return false
}
return swipeNode(target, direction, durationMs, preferScrollAction = false,
onFailure = { lastPurchaseSwipeFailure = it })
}
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
@@ -646,16 +658,21 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
preferScrollAction: Boolean = true,
downStartPercent: Int = 25,
downEndPercent: Int = 75,
onFailure: (PurchaseSwipeFailureReason) -> Unit = {},
): Boolean {
fun failed(reason: PurchaseSwipeFailureReason): Boolean {
onFailure(reason)
return false
}
val bounds = Rect().also(node::getBoundsInScreen)
if (bounds.width() < 2 || bounds.height() < 2) return false
if (bounds.width() < 2 || bounds.height() < 2) return failed(PurchaseSwipeFailureReason.INVALID_BOUNDS)
val scrollAction = if (direction == SwipeDirection.UP || direction == SwipeDirection.LEFT) {
AccessibilityNodeInfo.ACTION_SCROLL_FORWARD
} else {
AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD
}
if (preferScrollAction && node.performAction(scrollAction)) return true
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return failed(PurchaseSwipeFailureReason.GESTURE_UNSUPPORTED)
val left = bounds.left + bounds.width() * 25 / 100
val right = bounds.left + bounds.width() * 75 / 100
val top = bounds.top + bounds.height() * 25 / 100
@@ -693,8 +710,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
},
null,
)
if (!queued) return false
return latch.await(1500, TimeUnit.MILLISECONDS) && completed.get()
if (!queued) return failed(PurchaseSwipeFailureReason.GESTURE_REJECTED)
if (!latch.await(1500, TimeUnit.MILLISECONDS)) return failed(PurchaseSwipeFailureReason.GESTURE_TIMEOUT)
return completed.get() || failed(PurchaseSwipeFailureReason.GESTURE_CANCELLED)
}
private fun dispatchCenterTap(bounds: Rect): Boolean {
@@ -34,6 +34,9 @@ class PurchaseLiveAutomation(
private val pause: (Long) -> Unit = Thread::sleep,
) {
private var submitAttempted = false
private var savedAddressProof: ShippingAddressProof? = null
private var savedAddressActivity: String? = null
private var structuralSubmitAnchor: SnapshotNode? = null
var lastOrderReadFailure: PurchaseOrderReadFailure? = null
private set
@@ -301,10 +304,11 @@ class PurchaseLiveAutomation(
private fun nodeArea(node: SnapshotNode): Long = node.bounds.width.toLong() * node.bounds.height
fun finalConfirmation(input: PurchaseExecutionInput, address: ShippingAddressProof): FinalConfirmationEvidence {
structuralSubmitAnchor = null
val snapshot = driver.capture()
pageProblem(snapshot)
if (snapshot.packageName != PDD_PACKAGE) fail("PURCHASE_CONFIRMATION_LOST", "最终提交前页面已经变化,禁止创建订单")
if (!hasFinalSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) {
if (!hasSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) {
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "收货地址保存后复核失败,未创建订单")
}
val submit = finalSubmitTargets(snapshot)
@@ -318,6 +322,7 @@ class PurchaseLiveAutomation(
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) fail("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
val quantities = snapshot.nodes.filter { it.visible && it.enabled && it.className?.endsWith("EditText") == true }.mapNotNull { it.label.toLongOrNull() }
if (quantities.singleOrNull() != input.quantity) fail("PURCHASE_QUANTITY_MISMATCH", "创建订单前数量复核失败")
structuralSubmitAnchor = submit.single().takeIf { legacySubmitTargets(snapshot).isEmpty() }
return FinalConfirmationEvidence(input.goodsId, input.mappedColor, input.mappedSize, input.quantity, price, address.suffix, snapshot.activityName.orEmpty())
}
@@ -329,6 +334,18 @@ class PurchaseLiveAutomation(
if (snapshot.packageName != PDD_PACKAGE || targets.size != 1) {
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "最终页面或创建订单按钮已变化,禁止创建订单")
}
if (legacySubmitTargets(snapshot).isEmpty() && structuralSubmitAnchor == null) {
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "下单按钮尚未完成最终复核,禁止创建订单")
}
structuralSubmitAnchor?.let { anchor ->
val fresh = targets.single()
if (fresh.path != anchor.path || fresh.className != anchor.className || fresh.label != anchor.label ||
kotlin.math.abs(fresh.bounds.left - anchor.bounds.left) > 32 ||
kotlin.math.abs(fresh.bounds.top - anchor.bounds.top) > 32 ||
kotlin.math.abs(fresh.bounds.right - anchor.bounds.right) > 32 ||
kotlin.math.abs(fresh.bounds.bottom - anchor.bounds.bottom) > 32
) fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "最终下单按钮已变化,禁止创建订单")
}
submitAttempted = true
when (driver.clickFresh(targets.single())) {
FreshActionResult.SUCCESS -> Unit
@@ -466,6 +483,9 @@ class PurchaseLiveAutomation(
snapshot.packageName == PDD_PACKAGE && snapshot.activityName in PDD_PAYMENT_ACTIVITIES
private fun editAndVerifyAddress(panel: UiSnapshot, suffix: String): ShippingAddressProof {
savedAddressProof = null
savedAddressActivity = null
structuralSubmitAnchor = null
val modify = addressModifyTargets(panel)
if (modify.size != 1) fail("PURCHASE_ADDRESS_EDIT_AMBIGUOUS", "没有找到唯一的地址修改按钮,未创建订单")
click(modify.single(), "修改地址")
@@ -485,18 +505,27 @@ class PurchaseLiveAutomation(
val save = uniqueClickable(stable, stable.nodes.filter { it.visible && it.enabled && it.label == "保存" })
if (save.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存按钮不唯一,未创建订单")
click(save.single(), "保存地址")
val savedEvidence = waitForStableAddressEditorExit()
if (!hasFinalSavedAddressEvidence(savedEvidence, expected, suffix)) {
var savedEvidence = waitForStableAddressEditorExit()
if (!savedAddressPageReady(savedEvidence, expected, suffix)) {
if (isPurchaseConfirmationPanel(savedEvidence)) {
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
savedEvidence = restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
} else {
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
hasFinalSavedAddressEvidence(it, expected, suffix)
val returned = waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
savedAddressPageReady(it, expected, suffix) || isPurchaseConfirmationPanel(it)
}
savedEvidence = if (!savedAddressPageReady(returned, expected, suffix)) {
restoreFinalEvidenceInCurrentPanel(returned, expected, suffix)
} else returned
}
}
return ShippingAddressProof(expected, suffix)
val proof = ShippingAddressProof(expected, suffix)
savedAddressProof = proof
savedAddressActivity = savedEvidence.activityName
if (finalSubmitTargets(savedEvidence).size != 1) {
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "地址已保存,但未能识别唯一的下单按钮,未创建订单")
}
return proof
}
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
@@ -513,17 +542,18 @@ class PurchaseLiveAutomation(
initial: UiSnapshot,
expected: String,
suffix: String,
) {
): UiSnapshot {
var snapshot = initial
var previousSignature: String? = null
repeat(ADDRESS_CONFIRMATION_SCROLL_LIMIT) { attempt ->
if (hasFinalSavedAddressEvidence(snapshot, expected, suffix)) return
if (savedAddressPageReady(snapshot, expected, suffix)) return snapshot
if (!isPurchaseConfirmationPanel(snapshot)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后采购面板发生变化,未创建订单")
}
val panels = purchasePanelScrollTargets(snapshot)
val panels = savedAddressPanelScrollTargets(snapshot)
if (panels.size != 1) {
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后没有找到唯一的规格面板滚动区域,未创建订单")
val reason = if (panels.isEmpty()) "未找到可用的规格面板滚动区域" else "找到多个规格面板滚动区域"
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后${reason},未创建订单")
}
val panel = panels.single()
val signature = viewportSignature(snapshot, panel)
@@ -538,9 +568,10 @@ class PurchaseLiveAutomation(
snapshot = driver.capture()
pageProblem(snapshot)
}
if (!hasFinalSavedAddressEvidence(snapshot, expected, suffix)) {
if (!savedAddressPageReady(snapshot, expected, suffix)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板未找到完整订单确认信息,未创建订单")
}
return snapshot
}
private fun hasSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean {
@@ -557,8 +588,9 @@ class PurchaseLiveAutomation(
return suffixMatches.size == 1
}
private fun hasFinalSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
hasSavedAddressEvidence(snapshot, expected, suffix) && finalSubmitTargets(snapshot).size == 1
private fun savedAddressPageReady(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
hasSavedAddressEvidence(snapshot, expected, suffix) &&
(legacySubmitTargets(snapshot).isNotEmpty() || savedPurchasePanel(snapshot, expected, suffix) != null)
private fun waitForStableAddressEditorExit(): UiSnapshot {
var consecutiveExits = 0
@@ -591,6 +623,20 @@ class PurchaseLiveAutomation(
fail(code, message)
}
private fun savedAddressPanelScrollTargets(snapshot: UiSnapshot): List<SnapshotNode> {
val panel = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
.specPanelContainer ?: return purchasePanelScrollTargets(snapshot)
val screenWidth = snapshot.nodes.maxOfOrNull { it.bounds.right }?.coerceAtLeast(1) ?: 1
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
return snapshot.nodes.filter { node ->
node.visible && node.enabled && node.scrollable &&
node.bounds.width * 100 >= screenWidth * 60 &&
node.bounds.height * 100 >= screenHeight * 20 &&
node.bounds.left >= panel.bounds.left && node.bounds.right <= panel.bounds.right &&
node.bounds.top >= panel.bounds.top && node.bounds.bottom <= panel.bounds.bottom
}.distinctBy { it.path }
}
private fun purchasePanelScrollTargets(snapshot: UiSnapshot): List<SnapshotNode> {
val screenWidth = snapshot.nodes.maxOfOrNull { it.bounds.right }?.coerceAtLeast(1) ?: 1
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
@@ -678,11 +724,84 @@ class PurchaseLiveAutomation(
pause(500)
}
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
private fun legacySubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
snapshot,
snapshot.nodes.filter { node -> node.visible && node.enabled && FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) } },
)
/** Only the just-saved address flow may use a footer whose display copy is unknown. */
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> {
val legacy = legacySubmitTargets(snapshot)
if (legacy.isNotEmpty()) return legacy
val proof = savedAddressProof ?: return emptyList()
if (snapshot.activityName != savedAddressActivity) return emptyList()
val panel = savedPurchasePanel(snapshot, proof.expectedAddress, proof.suffix) ?: return emptyList()
val visible = snapshot.nodes.filter { it.visible }
val byPath = visible.associateBy(SnapshotNode::path)
val candidates = visible.filter { node ->
node.path.startsWith("${panel.path}/") && node.enabled && node.clickable && !node.scrollable &&
node.bounds.width >= panel.bounds.width / 5 && node.bounds.height >= 2 &&
node.bounds.height <= panel.bounds.height / 4 &&
node.bounds.bottom <= panel.bounds.bottom &&
node.bounds.bottom >= panel.bounds.bottom - panel.bounds.height / 20 &&
node.bounds.left >= panel.bounds.left && node.bounds.right <= panel.bounds.right
}.filter { node ->
// A spec option at the scroll edge is not a fixed footer action.
var parent = node.parentPath?.let(byPath::get)
var inScroller = false
while (parent != null && parent.path != panel.path) {
if (parent.scrollable) inScroller = true
parent = parent.parentPath?.let(byPath::get)
}
!inScroller && parent?.path == panel.path
}
val independent = candidates.filter { parent ->
candidates.none { it.path.startsWith("${parent.path}/") }
}
// Do not silently choose one of several footer actions by text or price.
if (independent.size != 1) return emptyList()
val button = independent.single()
val content = visible.filter { it.path == button.path || it.path.startsWith("${button.path}/") }
val labels = content.map { it.label.replace(" ", "") }
if (labels.any { label -> STRUCTURAL_ACTION_DENY.any(label::contains) } ||
labels.none { FOOTER_PRICE.containsMatchIn(it) } ||
labels.none { FOOTER_PRICE.replace(it, "").any(Char::isLetter) }
) return emptyList()
// Keep the original text anchor; clickFresh reacquires it and its nearest clickable parent.
return listOfNotNull(content.firstOrNull {
it.enabled && it.label.isNotBlank() && nearestClickableAncestor(it, byPath)?.path == button.path
})
}
private fun savedPurchasePanel(snapshot: UiSnapshot, expected: String, suffix: String): SnapshotNode? {
if (snapshot.packageName != PDD_PACKAGE || snapshot.activityName.isNullOrBlank() ||
isKnownPddPaymentActivity(snapshot) || !hasSavedAddressEvidence(snapshot, expected, suffix)
) return null
val visible = snapshot.nodes.filter { it.visible }
if (visible.any { node -> STRUCTURAL_PAGE_DENY.any(node.label::contains) }) return null
val resolution = resolveAddressEntries(snapshot)
if (resolution.addressCardCount != 1 || resolution.tapTargetCount != 1) return null
val address = resolution.target?.node ?: return null
val quantities = visible.filter { it.enabled && it.className?.endsWith("EditText") == true && (it.label.toLongOrNull() ?: 0) > 0 }
val summaries = visible.filter { it.label.replace(" ", "").startsWith("已选") }
val evidence = quantities.singleOrNull() ?: summaries.singleOrNull() ?: return null
val byPath = visible.associateBy(SnapshotNode::path)
val screenHeight = visible.maxOfOrNull { it.bounds.bottom } ?: return null
val screenWidth = visible.maxOfOrNull { it.bounds.right } ?: return null
var common = address.parentPath?.let(byPath::get)
while (common != null) {
if (evidence.path.startsWith("${common.path}/")) {
// Nearest common ancestor is the whole popup, not its inner spec RecyclerView.
return common.takeIf {
!it.scrollable && it.bounds.width >= screenWidth * 3 / 5 &&
it.bounds.height > 0 && it.bounds.height < screenHeight * 95 / 100
}
}
common = common.parentPath?.let(byPath::get)
}
return null
}
private fun orderConfirmationReady(snapshot: UiSnapshot): Boolean {
if (snapshot.packageName != PDD_PACKAGE) return false
if (snapshot.nodes.any { it.visible && it.enabled && MASKED_PHONE.containsMatchIn(it.label) }) return true
@@ -746,6 +865,9 @@ class PurchaseLiveAutomation(
const val WECHAT_PACKAGE = "com.tencent.mm"
val MASKED_PHONE = Regex("(?<![0-9])[0-9]{3}\\*{4}[0-9]{4}(?![0-9])")
val FINAL_SUBMIT_MARKERS = listOf("提交订单", "现在买,仅", "确认购买")
val FOOTER_PRICE = Regex("(?:[¥¥]\\s*\\d+(?:\\.\\d{1,2})?|\\d+(?:\\.\\d{1,2})?\\s*元)")
val STRUCTURAL_ACTION_DENY = listOf("支付", "付款", "先用后付", "收货", "地址", "修改", "保存", "取消", "返回", "关闭", "客服", "收藏", "订单详情", "查看订单")
val STRUCTURAL_PAGE_DENY = listOf("立即支付", "确认支付", "输入支付密码", "待付款", "待支付", "订单编号", "订单号", "订单详情", "修改收货地址", "选择收货地址")
val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码")
val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付")
val ORDER_DETAIL_ENTRY_MARKERS = setOf("查看订单", "订单详情")
@@ -3,6 +3,11 @@ package cn.ilapage.goauto.agent.automation
import java.net.URI
import java.net.URLDecoder
enum class PurchaseSwipeFailureReason {
UNKNOWN, ROOT_UNAVAILABLE, NO_SCROLLABLE, INVALID_BOUNDS, GESTURE_UNSUPPORTED,
GESTURE_REJECTED, GESTURE_CANCELLED, GESTURE_TIMEOUT,
}
interface PurchaseUiDriver {
fun capture(): UiSnapshot
fun clickFresh(target: SnapshotNode): FreshActionResult
@@ -32,6 +37,7 @@ interface PurchaseUiDriver {
fun specRowSwipeFailureReason(): String = "gestureFailed"
fun inputFresh(target: SnapshotNode, value: String): FreshActionResult
fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean
fun purchaseSwipeFailureReason(): PurchaseSwipeFailureReason = PurchaseSwipeFailureReason.UNKNOWN
fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean
fun pullDownGoodsPage(): Boolean = swipePurchase(SwipeDirection.DOWN, 550)
fun backPurchase(): Boolean
@@ -89,6 +95,8 @@ class PurchaseRehearsalExecutor(
private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") },
) {
private var purchasePanelContext: PurchasePanelContext? = null
private var pageIdentity: Pair<String?, String?> = null to null
private var entryRecoveryReason = "not_checked"
fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set<String>): PurchaseExecutionOutcome {
purchasePanelContext = null
@@ -370,7 +378,9 @@ class PurchaseRehearsalExecutor(
screen = currentScreen(input)
}
val beforeSignature = specActionSignature(screen)
val beforeIdentity = pageIdentity
val click = driver.clickFreshDetailed(requireNotNull(target))
panelDiagnostic("entrySource=${screen.specEntrySource ?: "unknown"};entryClick=${click.result};entryReason=${click.reason}")
when (click.result) {
// The parser already narrowed to a single semantic candidate; the
// ambiguity here comes from the live tree matching that target more
@@ -385,14 +395,22 @@ class PurchaseRehearsalExecutor(
var wait = waitForSpecPanel(input, beforeSignature)
wait.failure?.let { return it }
if (wait.opened) return null
if (wait.changed) {
val refreshed = currentScreen(input)
refreshed.problem?.let { return failure(it.code, it.message) }
if (refreshed.reviewPageOpen) return leaveUnexpectedReviewPage(input)
if (refreshed.specPanelOpen) return null
val recoveryTarget = recoverableSpecEntry(screen, refreshed, action, beforeIdentity)
if (recoveryTarget == null) {
panelDiagnostic("entryRecovery=rejected;reason=$entryRecoveryReason;pageChanged=${wait.changed};${panelEvidence(refreshed)}")
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
)
}
when (driver.tapSpecFresh(requireNotNull(target))) {
val gesture = driver.tapSpecFresh(requireNotNull(recoveryTarget))
panelDiagnostic("entryRecovery=attempted;gesture=$gesture;pageChanged=${wait.changed}")
when (gesture) {
FreshActionResult.AMBIGUOUS -> return failure(
SPEC_ENTRY_TARGET_AMBIGUOUS,
"规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
@@ -406,7 +424,8 @@ class PurchaseRehearsalExecutor(
wait = waitForSpecPanel(input, specActionSignature(wait.screen))
wait.failure?.let { return it }
if (wait.opened) return null
if (wait.changed) {
if (wait.changed && recoverableSpecEntry(screen, wait.screen, action, beforeIdentity) == null) {
panelDiagnostic("entryRecovery=rejected;reason=$entryRecoveryReason;pageChanged=${wait.changed};${panelEvidence(wait.screen)}")
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
@@ -418,6 +437,48 @@ class PurchaseRehearsalExecutor(
)
}
// Whole-page animations are not proof of navigation. Recover only a freshly
// parsed, unchanged semantic product entry; never reuse the old node/coordinates.
private fun recoverableSpecEntry(
before: ParsedPddScreen,
after: ParsedPddScreen,
action: PurchaseAction,
identity: Pair<String?, String?>,
): SnapshotNode? {
entryRecoveryReason = when {
identity.first == null || identity.second == null -> "identity_missing"
identity != pageIdentity -> "identity_changed"
!after.isPddPackage || !after.pageEvidenceMatched -> "product_context_missing"
after.problem != null || after.reviewPageOpen -> "page_problem"
after.specPanelOpen -> "panel_open"
after.hasCloseControl -> "close_control"
after.hasPaymentArea -> "payment_area"
after.hasOrderSubmitAction -> "order_action"
after.hasQuantityControls -> "quantity_controls"
after.hasSelectionSummary -> "selection_summary"
before.summary.title.isNullOrBlank() -> "title_missing"
before.summary.title != after.summary.title -> "title_changed"
after.specEntry == null -> "entry_missing"
before.specEntrySource == null -> "entry_source_missing"
before.specEntrySource != after.specEntrySource -> "entry_source_changed"
before.specEntry?.label != after.specEntry.label -> "entry_label_changed"
after.explicitSpecEntryCount > 1 || after.nestedSpecEntryCount > 1 -> "entry_ambiguous"
else -> "eligible"
}
if (entryRecoveryReason != "eligible") return null
val anchor = after.specEntry ?: return null
if (action.textAliases?.let { specEntryMatchesAliases(after, anchor, it) } == false) {
entryRecoveryReason = "rule_alias_mismatch"
return null
}
val target = after.specEntryClickTarget ?: anchor
if (!target.visible || !target.enabled) {
entryRecoveryReason = "entry_unavailable"
return null
}
return target
}
private data class SpecPanelWait(
val screen: ParsedPddScreen,
val opened: Boolean,
@@ -440,9 +501,9 @@ class PurchaseRehearsalExecutor(
private fun waitForSpecPanel(input: PurchaseExecutionInput, beforeSignature: List<SpecActionNodeSignature>): SpecPanelWait {
var last = currentScreen(input)
var changed = false
repeat(SPEC_POST_CLICK_VERIFY_POLLS) {
repeat(SPEC_POST_CLICK_VERIFY_POLLS) { poll ->
last = currentScreen(input)
panelDiagnostic(panelEvidence(last))
panelDiagnostic("${panelEvidence(last)};entryWaitPolls=${poll + 1};entryWaitMillis=${poll * SPEC_SELECTION_POLL_MILLIS};closeControl=${last.hasCloseControl};paymentArea=${last.hasPaymentArea}")
if (last.reviewPageOpen) {
return SpecPanelWait(last, false, true, leaveUnexpectedReviewPage(input))
}
@@ -543,6 +604,7 @@ class PurchaseRehearsalExecutor(
): PurchaseExecutionOutcome? {
val initial = currentScreen(input)
initial.problem?.let { return failure(it.code, it.message) }
unavailableExactSpec(initial, dimension, target)?.let { return it }
if (rememberExactSelection(initial, dimension, target, selectionProofs)) return null
val lookup = locateExactSpec(input, dimension, target)
@@ -617,7 +679,9 @@ class PurchaseRehearsalExecutor(
private fun isExactSpecSelected(screen: ParsedPddScreen, dimension: String, target: String): Boolean {
val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) return false
if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) {
return dimension == "color" && SelectedColorCard.confirms(target, candidates, screen.sourceNodes)
}
if (candidates.any { it.text == target && (it.node.selected || it.node.checked) }) return true
if (screen.specPanelOpen && fullSummaryTargetMatches(screen.selectedSummary, target)) return true
return summarySelectionMatches(screen.selectedSummary, dimension, target, candidates)
@@ -791,6 +855,18 @@ class PurchaseRehearsalExecutor(
private data class SpecLookup(val node: SnapshotNode? = null, val failure: PurchaseExecutionOutcome? = null)
private fun unavailableExactSpec(screen: ParsedPddScreen, dimension: String, target: String): PurchaseExecutionOutcome? {
if (!screen.specPanelOpen) return null
val exact = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }.filter { it.text == target }
return if (exact.size == 1 && !exact.single().available) unavailableSpec(dimension) else null
}
private fun unavailableSpec(dimension: String): PurchaseExecutionOutcome {
val role = if (dimension == "color") "颜色" else "尺码"
panelDiagnostic("specLookup=unavailable;dimension=$dimension;reason=exact_target_unavailable")
return failure(SPEC_SAFE_TARGET_MISSING, "目标${role}已售罄或当前不可选,未创建订单")
}
/**
* Searches only parsed, selectable values in the already-open spec panel.
* A short downward pass first restores the top when a previous action left
@@ -827,7 +903,7 @@ class PurchaseRehearsalExecutor(
}
if (exact.size == 1 && exact.single().available) return Inspection(SpecLookup(node = exact.single().node), "", screen.specPanelContainer)
if (exact.size == 1) {
return Inspection(SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")), "", screen.specPanelContainer)
return Inspection(SpecLookup(failure = unavailableSpec(dimension)), "", screen.specPanelContainer)
}
val signature = screen.dimensions.joinToString("|") { item ->
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
@@ -892,7 +968,7 @@ class PurchaseRehearsalExecutor(
val lookup = when {
exact.size > 1 -> SpecLookup(failure = failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件"))
exact.size == 1 && exact.single().available -> SpecLookup(node = exact.single().node)
exact.size == 1 -> SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击"))
exact.size == 1 -> SpecLookup(failure = unavailableSpec(dimension))
else -> null
}
val rows = specValueRows(values)
@@ -1094,16 +1170,17 @@ class PurchaseRehearsalExecutor(
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
if (action.waitAfterMs > 0) pause(action.waitAfterMs)
action.swipeAfter?.let { swipe ->
// The stock purchase rule asks to reveal additional selector rows after
// opening the sheet. A fully-evidenced non-scrollable selector has no
// scroll target, and treating that absence as an action failure blocks
// an otherwise safe exact-spec flow. Keep all other configured swipes
// mandatory; this exception is limited to that confirmed panel state.
if (action.type == PurchaseActionType.OPEN_SPEC_PANEL &&
currentScreen(input).specPanelType == SpecPanelType.NON_SCROLLABLE_CONFIRMATION
) return null
// Legacy rules prescribe a blind scroll immediately after opening.
// Opening has already been verified; probe/select owns any necessary
// bounded scrolling. Do not let an unnecessary gesture block either
// phase, or move already-visible exact specs out of the viewport.
if (action.type == PurchaseActionType.OPEN_SPEC_PANEL) {
panelDiagnostic("postSwipe=skipped;action=${action.type.wireName};reason=spec_panel_on_demand;panel=${currentScreen(input).specPanelType.name}")
return null
}
repeat(swipe.count) { index ->
if (!driver.swipePurchase(swipe.direction, swipe.durationMs)) {
panelDiagnostic("postSwipe=failed;action=${action.type.wireName};direction=${swipe.direction.name};swipeIndex=${index + 1};reason=${driver.purchaseSwipeFailureReason().name.lowercase()}")
return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败")
}
if (index < swipe.count - 1 && swipe.intervalMs > 0) pause(swipe.intervalMs)
@@ -1127,6 +1204,7 @@ class PurchaseRehearsalExecutor(
private fun currentScreen(input: PurchaseExecutionInput): ParsedPddScreen {
val snapshot = driver.capture()
pageIdentity = snapshot.packageName to snapshot.activityName
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null, purchasePanelContext)
purchasePanelContext = if (screen.isPddPackage && screen.problem == null && screen.specPanelOpen) {
screen.specPanelContainer?.let {
@@ -0,0 +1,43 @@
package cn.ilapage.goauto.agent.automation
/** A narrow exception for one selected text option duplicated by its card/image. */
internal object SelectedColorCard {
fun confirms(target: String, values: List<VisibleSpecValue>, source: List<SnapshotNode>): Boolean {
val exact = values.filter { it.text == target }.singleOrNull() ?: return false
if (!exact.node.selected && !exact.node.checked) return false
val byPath = source.associateBy { it.path }
fun contains(outer: NodeBounds, inner: NodeBounds) =
inner.width > 0 && inner.height > 0 && inner.left >= outer.left && inner.top >= outer.top &&
inner.right <= outer.right && inner.bottom <= outer.bottom
fun cardFor(node: SnapshotNode): SnapshotNode? {
var current = node
val seen = mutableSetOf<String>()
while (seen.add(current.path)) {
if (current.scrollable || !current.visible || !current.enabled) return null
val parent = current.parentPath?.let(byPath::get) ?: return null
if (parent.scrollable) {
return current.takeIf {
it.clickable && it.className == "android.view.ViewGroup" &&
contains(parent.bounds, it.bounds) && contains(it.bounds, node.bounds)
}
}
current = parent
}
return null
}
val card = cardFor(exact.node) ?: return false
// The parser may have deduplicated identical labels across cards. Check
// the source as well before treating a selected duplicate as harmless.
if (source.any { node ->
node.visible && node.enabled && node.clickable &&
SpecValueNormalizer.normalizeColor(node.label) == target &&
cardFor(node)?.let { it.path != card.path } == true
}) return false
return values.filter { it.text != target && (it.node.selected || it.node.checked) }.all { other ->
val node = other.node
// Never merge another textual option, adjacent card, or scroll container.
(node.path == card.path || node.className == "android.widget.ImageView") &&
cardFor(node)?.path == card.path && contains(card.bounds, node.bounds)
}
}
}
@@ -0,0 +1,92 @@
package cn.ilapage.goauto.agent.persistence
import cn.ilapage.goauto.agent.automation.FreshActionResult
import cn.ilapage.goauto.agent.automation.FreshClickReason
import java.io.File
import org.json.JSONObject
data class PurchaseDiagnosticContext(
val taskId: Long,
val attemptId: String,
val deviceId: Long,
val ruleHash: String,
val agentVersion: String,
val phase: String,
)
/** Private, bounded diagnostics only. Neither raw evidence nor write errors escape this store. */
class PurchaseDiagnosticStore(private val directory: File, private val now: () -> Long = System::currentTimeMillis) {
@Synchronized
fun record(context: PurchaseDiagnosticContext, elapsedMs: Long, evidence: String): Boolean = runCatching {
require(context.taskId > 0 && context.deviceId > 0 && ID.matches(context.attemptId))
require(HASH.matches(context.ruleHash) && VERSION.matches(context.agentVersion))
require(context.phase in setOf("purchase", "spec_probe"))
val fields = sanitize(evidence)
if (fields.length() == 0) return false
val timestamp = now()
val line = JSONObject().put("taskId", context.taskId).put("attemptId", context.attemptId)
.put("deviceId", context.deviceId).put("ruleHash", context.ruleHash)
.put("agentVersion", context.agentVersion).put("phase", context.phase)
.put("timestamp", timestamp).put("elapsedMs", elapsedMs.coerceAtLeast(0))
.put("evidence", fields).toString()
check(directory.isDirectory || directory.mkdirs())
val file = File(directory, "${context.taskId}_${context.attemptId}.jsonl")
file.appendText(line + "\n", Charsets.UTF_8)
val lines = file.readLines(Charsets.UTF_8)
if (lines.size > MAX_EVENTS) file.writeText(lines.takeLast(MAX_EVENTS).joinToString("\n", postfix = "\n"), Charsets.UTF_8)
file.setLastModified(timestamp)
prune(timestamp)
true
}.getOrDefault(false)
@Synchronized
fun prune(timestamp: Long = now()) {
runCatching {
val files = directory.listFiles()?.filter { it.isFile && FILE_NAME.matches(it.name) }.orEmpty()
files.filter { it.lastModified() < timestamp - RETENTION_MS }.forEach { it.delete() }
files.filter(File::exists).sortedWith(compareByDescending<File> { it.lastModified() }.thenBy { it.name })
.drop(MAX_ATTEMPTS).forEach { it.delete() }
}
}
companion object {
const val MAX_ATTEMPTS = 5
const val MAX_EVENTS = 128
const val RETENTION_MS = 7L * 24 * 60 * 60 * 1000
private val ID = Regex("[A-Za-z0-9-]{1,80}")
private val HASH = Regex("[0-9a-f]{64}")
private val VERSION = Regex("[0-9]+\\.[0-9]+\\.[0-9]+")
private val FILE_NAME = Regex("[1-9][0-9]*_[A-Za-z0-9-]{1,80}\\.jsonl")
private val numbers = setOf("specEntryCandidates", "explicit", "nested", "bottomPurchase", "entryReadyWaitPolls",
"entryReadyWaitMillis", "scrollables", "headings", "options", "entryWaitPolls", "entryWaitMillis")
private val booleans = setOf("panelAlreadyOpen", "reviewPage", "pageEvidence", "pageChanged", "summary", "quantity",
"orderAction", "closeControl", "paymentArea")
private val enums = mapOf(
"event" to setOf("started"),
"entrySource" to setOf("unknown", "explicit_selection", "nested_selection", "bottom_purchase", "bottom_purchase_rightmost"),
"entryClick" to FreshActionResult.values().map { it.name }.toSet(),
"entryReason" to FreshClickReason.values().map { it.name }.toSet(),
"gesture" to FreshActionResult.values().map { it.name }.toSet(),
"entryRecovery" to setOf("rejected", "attempted"),
"type" to setOf("UNKNOWN", "NORMAL_SCROLLABLE", "NON_SCROLLABLE_CONFIRMATION", "ORDER_CONFIRMATION", "QUICK_CONFIRMATION"),
"reason" to setOf("identity_missing", "identity_changed", "product_context_missing", "page_problem", "panel_open",
"close_control", "payment_area", "order_action", "quantity_controls", "selection_summary", "title_missing",
"title_changed", "entry_missing", "entry_source_missing", "entry_source_changed", "entry_label_changed",
"entry_ambiguous", "rule_alias_mismatch", "entry_unavailable", "eligible"),
)
internal fun sanitize(evidence: String): JSONObject {
val result = JSONObject()
evidence.take(4096).split(';').take(40).forEach { part ->
val key = part.substringBefore('=')
val value = part.substringAfter('=', "")
when {
key in numbers && value.matches(Regex("[0-9]{1,8}")) -> result.put(key, value.toLong())
key in booleans && value in setOf("true", "false") -> result.put(key, value == "true")
value in enums[key].orEmpty() -> result.put(key, value)
}
}
return result
}
}
}
@@ -18,6 +18,9 @@ import android.os.PowerManager
import android.os.SystemClock
import android.util.Log
import cn.ilapage.goauto.agent.BuildConfig
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticContext
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticStore
import java.io.File
import cn.ilapage.goauto.agent.ClipboardRelayActivity
import cn.ilapage.goauto.agent.MainActivity
import cn.ilapage.goauto.agent.R
@@ -91,6 +94,7 @@ class AgentForegroundService : Service() {
private lateinit var settingsStore: AgentSettingsStore
private lateinit var stateStore: AgentStateStore
private lateinit var purchaseStore: PurchaseTaskStore
private lateinit var purchaseDiagnostics: PurchaseDiagnosticStore
private lateinit var diagnosticStore: AgentDiagnosticStore
private lateinit var diagnosticRecorder: SafeAgentDiagnosticRecorder
private lateinit var connectivityManager: ConnectivityManager
@@ -109,6 +113,8 @@ class AgentForegroundService : Service() {
// Wake locks and cooldown tickets are process-local. Never restore a stale UI flag.
stateStore.setKeepScreenOn(false)
purchaseStore = PurchaseTaskStore(this)
purchaseDiagnostics = PurchaseDiagnosticStore(File(filesDir, "purchase_diagnostics"))
runCatching { diagnosticExecutor.execute { purchaseDiagnostics.prune() } }
diagnosticStore = AgentDiagnosticStore(this)
diagnosticRecorder = SafeAgentDiagnosticRecorder(
persist = { event ->
@@ -478,6 +484,17 @@ class AgentForegroundService : Service() {
if (accessibility == null) {
PurchaseExecutionOutcome("failed", "ACCESSIBILITY_NOT_READY", "GoAuto 无障碍服务未开启")
} else {
val diagnosticDeviceId = runCatching { identityStore.credentials()?.deviceId ?: 0L }.getOrDefault(0L)
val diagnosticAttempt = task.taskAttemptId.takeIf { it.matches(Regex("^[a-zA-Z0-9-]{1,80}$")) } ?: "invalid"
val diagnosticContext = PurchaseDiagnosticContext(task.taskId, task.taskAttemptId, diagnosticDeviceId,
snapshotHash, BuildConfig.VERSION_NAME, task.phase)
val diagnosticStarted = SystemClock.elapsedRealtime()
val recordDiagnostic: (String) -> Unit = { evidence ->
val elapsed = SystemClock.elapsedRealtime() - diagnosticStarted
runCatching { diagnosticExecutor.execute { purchaseDiagnostics.record(diagnosticContext, elapsed, evidence) } }
Unit
}
recordDiagnostic("event=started")
PurchaseRehearsalExecutor(
driver = accessibility,
openLink = { PddLinkLauncher(this).open(it, preferDirect = true) },
@@ -486,7 +503,10 @@ class AgentForegroundService : Service() {
lastStep.set(step)
purchaseStore.updateStep(task.taskId, task.taskAttemptId, step)
},
panelDiagnostic = { evidence -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") },
panelDiagnostic = { evidence ->
recordDiagnostic(evidence)
Log.i("GoAutoPurchasePanel", "task=${task.taskId};attempt=$diagnosticAttempt;device=$diagnosticDeviceId;rule=$snapshotHash;$evidence")
},
beforeOrderSubmit = { evidence ->
val boundaryRequestId = UUID.randomUUID().toString()
val finalEvidence = JSONObject()
@@ -18,6 +18,161 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseLiveAutomationTest {
@Test
fun `saved address recognizes split dynamic footer outside spec scroller without a swipe`() {
for (label in listOf("复购价,", "专享优惠,")) {
val driver = LiveDriver(savedStructuredFooter = label)
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
automation.submitOrderOnce()
assertEquals(1, driver.submitClicks)
assertEquals(0, driver.scopedSwipes)
assertEquals(0, driver.backCount)
assertTrue(driver.clicked.contains(label))
assertEquals("PURCHASE_SUBMIT_ALREADY_ATTEMPTED",
(runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException).code)
}
}
@Test
fun `one back from address transition can recognize dynamic footer`() {
val driver = LiveDriver(savedStructuredFooter = "复购价,", savedTransitionWithoutLegacyContext = true)
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
assertEquals(1, driver.backCount)
assertEquals(0, driver.scopedSwipes)
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address with missing ambiguous price only or payment footer reports button failure without scrolling`() {
val mutations: List<(UiSnapshot) -> UiSnapshot> = listOf(
{ s -> s.copy(nodes = s.nodes.filterNot { it.path.startsWith("root/popup/footer") }) },
{ s -> s.copy(nodes = s.nodes + s.nodes.filter { it.path.startsWith("root/popup/footer") }.map {
it.copy(path = it.path.replace("footer", "second-footer"), parentPath = it.parentPath?.replace("footer", "second-footer"))
}) },
{ s -> s.copy(nodes = s.nodes.filterNot { it.path == "root/popup/footer/label" }) },
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup/footer") it.copy(clickable = false) else it }) },
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup/footer/label") it.copy(text = "去付款") else it }) },
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup/footer") it.copy(enabled = false) else it }) },
)
for (mutation in mutations) {
val driver = LiveDriver(savedStructuredFooter = "复购价,")
driver.structuredTransform = mutation
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error.code)
assertEquals(0, driver.scopedSwipes)
assertEquals(0, driver.backCount)
assertEquals(0, driver.submitClicks)
}
}
@Test
fun `legacy submit labels retain priority over structure`() {
for (label in listOf("提交订单", "现在买,仅20元", "确认购买")) {
val driver = LiveDriver(savedStructuredFooter = label)
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
automation.submitOrderOnce()
assertEquals(1, driver.submitClicks)
assertEquals(0, driver.scopedSwipes)
}
}
@Test
fun `structural footer cannot bypass final spec quantity price or address verification`() {
val cases = listOf(
input().copy(mappedColor = "白色") to "PURCHASE_SPEC_NOT_MATCHED",
input().copy(quantity = 3) to "PURCHASE_QUANTITY_MISMATCH",
input().copy(maxUnitPriceCent = 100) to "PURCHASE_PRICE_OUT_OF_RANGE",
)
for ((request, code) in cases) {
val driver = LiveDriver(savedStructuredFooter = "复购价,")
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
val error = runCatching { automation.finalConfirmation(request.copy(addressSuffix = "_cg81"), proof) }
.exceptionOrNull() as PurchaseLiveException
assertEquals(code, error.code)
assertEquals(0, driver.submitClicks)
}
val driver = LiveDriver(savedStructuredFooter = "复购价,")
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
driver.structuredTransform = { s -> s.copy(nodes = s.nodes.filterNot { it.path.endsWith("/address/detail") }) }
assertEquals("PURCHASE_ADDRESS_UPDATE_FAILED",
(runCatching { automation.finalConfirmation(input(), proof) }.exceptionOrNull() as PurchaseLiveException).code)
assertEquals(0, driver.submitClicks)
}
@Test
fun `structural footer needs this execution saved address and unchanged page at submission`() {
val driver = LiveDriver(savedStructuredFooter = "复购价,")
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
(runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException).code)
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
(runCatching { PurchaseLiveAutomation(driver, pause = {}).finalConfirmation(input(), proof) }
.exceptionOrNull() as PurchaseLiveException).code)
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
driver.structuredTransform = { s -> s.copy(nodes = s.nodes.map {
if (it.path.endsWith("footer/label")) it.copy(text = "另一操作") else it
}) }
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
(runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException).code)
assertEquals(0, driver.submitClicks)
}
@Test
fun `footer duplicate text nodes remain one action and external overlays are not candidates`() {
val driver = LiveDriver(savedStructuredFooter = "复购价,")
driver.structuredTransform = { s -> s.copy(nodes = s.nodes + listOf(
s.nodes.first { it.path == "root/popup/footer/label" }.copy(
path = "root/popup/footer/duplicate-label", bounds = NodeBounds(760, 2203, 950, 2262)),
s.nodes.first { it.path == "root/popup/footer" }.copy(path = "root/overlay", parentPath = "root", text = "悬浮按钮"),
)) }
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
automation.submitOrderOnce()
assertEquals(1, driver.submitClicks)
assertFalse(driver.clicked.contains("悬浮按钮"))
}
@Test
fun `failed revalidation clears structural submit authorization`() {
val driver = LiveDriver(savedStructuredFooter = "复购价,")
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
automation.finalConfirmation(input(), proof)
assertTrue(runCatching { automation.finalConfirmation(input().copy(quantity = 3), proof) }.isFailure)
assertTrue(runCatching { automation.submitOrderOnce() }.isFailure)
assertEquals(0, driver.submitClicks)
}
@Test
fun `address alone full page ancestor and order or payment pages cannot authorize structural footer`() {
val mutations: List<(UiSnapshot) -> UiSnapshot> = listOf(
{ s -> s.copy(nodes = s.nodes.filterNot { it.path.endsWith("/quantity") || it.path.endsWith("/selected") }) },
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup") it.copy(bounds = NodeBounds(0, 0, 1080, 2354)) else it }) },
{ s -> s.copy(activityName = "com.xunmeng.pinduoduo.app_pay.core.PayActivity") },
{ s -> s.copy(packageName = "example.other") },
{ s -> s.copy(nodes = s.nodes.map { if (it.path.endsWith("/selected")) it.copy(text = "订单详情") else it }) },
)
for (mutation in mutations) {
val driver = LiveDriver(savedStructuredFooter = "复购价,")
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
driver.structuredTransform = mutation
assertTrue(runCatching { automation.finalConfirmation(input(), proof) }.isFailure)
assertEquals(0, driver.submitClicks)
}
}
@Test
fun `verified spec panel advances through one exact confirm target`() {
val driver = SpecConfirmationDriver()
@@ -141,6 +296,72 @@ class PurchaseLiveAutomationTest {
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address back into short spec panel restores without aspect ratio restriction`() {
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true,
savedPanelBounds = NodeBounds(0, 400, 1080, 1325))
val automation = PurchaseLiveAutomation(driver, pause = {})
val address = automation.updateShippingAddress("_cg81")
assertEquals("_cg81", automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), address).addressSuffix)
assertEquals(1, driver.backCount)
assertEquals(1, driver.scopedSwipes)
assertEquals(0, driver.submitClicks)
}
@Test
fun `short saved spec panel still stops when unchanged`() {
val driver = LiveDriver(saveReturnsToSpecPanel = true, savedSpecPanelRecoveryStuck = true,
savedPanelBounds = NodeBounds(0, 400, 1080, 1325))
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
assertEquals(0, driver.backCount)
assertTrue(driver.scopedSwipes in 1..5)
assertEquals(0, driver.submitClicks)
}
@Test
fun `missing and duplicate saved panel scroll regions fail distinctly without gestures`() {
for (count in listOf(0, 2)) {
val driver = LiveDriver(saveReturnsToSpecPanel = true, savedPanelScrollCount = count)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
assertTrue(error.message.orEmpty().contains(if (count == 0) "未找到可用" else "找到多个"))
assertEquals(0, driver.scopedSwipes)
assertEquals(0, driver.submitClicks)
}
}
@Test
fun `saved address back into spec panel restores final evidence with one back`() {
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true)
val automation = PurchaseLiveAutomation(driver, pause = {})
val address = automation.updateShippingAddress("_cg81")
val final = automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), address)
assertEquals("_cg81", final.addressSuffix)
assertEquals(1, driver.backCount)
assertEquals(1, driver.scopedSwipes)
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address back into stuck spec panel fails without another back or submit`() {
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true,
savedSpecPanelRecoveryStuck = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
assertEquals(1, driver.backCount)
assertTrue(driver.scopedSwipes in 1..5)
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address returning to spec panel is recovered without pressing back`() {
val driver = LiveDriver(saveReturnsToSpecPanel = true)
@@ -645,7 +866,11 @@ class PurchaseLiveAutomationTest {
private val savedTransitionWithoutLegacyContext: Boolean = false,
private val savedTransitionHidesSuffix: Boolean = false,
private val saveReturnsToSpecPanel: Boolean = false,
private val backReturnsToSpecPanel: Boolean = false,
private val savedPanelBounds: NodeBounds = NodeBounds(0, 400, 1080, 2100),
private val savedPanelScrollCount: Int = 1,
private val savedSpecPanelRecoveryStuck: Boolean = false,
private val savedStructuredFooter: String? = null,
private val duplicatePhoneNodesSameCard: Boolean = false,
private val duplicateSemanticAddressCards: Boolean = false,
private val duplicateAddressCards: Boolean = false,
@@ -676,16 +901,33 @@ class PurchaseLiveAutomationTest {
var orderDetailEntryClicks = 0
var postSubmitCaptureCount = 0
val currentPage: String get() = page
var structuredTransform: (UiSnapshot) -> UiSnapshot = { it }
override fun capture(): UiSnapshot {
if (submitClicks > 0) {
postSubmitCaptureCount++
if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0)
}
return currentSnapshot()
return currentSnapshot().let { if (page == "structured") structuredTransform(it) else it }
}
private fun currentSnapshot(): UiSnapshot = when (page) {
"structured" -> snapshot(listOf(
node("root", "", bounds = NodeBounds(0, 0, 1080, 2354)),
node("root/popup", "", clickable = true, parentPath = "root", bounds = NodeBounds(0, 366, 1080, 2306)),
node("root/popup/address", "", clickable = true, parentPath = "root/popup", bounds = NodeBounds(0, 400, 1080, 530)),
node("root/popup/address/phone", "138****5678", parentPath = "root/popup/address", bounds = NodeBounds(30, 400, 400, 450)),
node("root/popup/address/detail", address, parentPath = "root/popup/address", bounds = NodeBounds(30, 456, 1000, 505)),
node("root/popup/price", "¥20.00", parentPath = "root/popup", bounds = NodeBounds(396, 620, 700, 690)),
node("root/popup/selected", "已选 黑色 XL", parentPath = "root/popup", bounds = NodeBounds(396, 731, 1053, 845)),
node("root/popup/quantity", "2", className = "android.widget.EditText", parentPath = "root/popup", bounds = NodeBounds(462, 881, 540, 959)),
node("root/popup/specs", "", scrollable = true, className = "androidx.recyclerview.widget.RecyclerView", parentPath = "root/popup", bounds = NodeBounds(0, 1132, 1080, 2057)),
node("root/popup/specs/color", "颜色分类", parentPath = "root/popup/specs", bounds = NodeBounds(36, 1157, 287, 1218)),
node("root/popup/payment-method", "微信支付", clickable = true, parentPath = "root/popup", bounds = NodeBounds(0, 2057, 1080, 2156)),
node("root/popup/footer", "", clickable = true, className = "android.widget.FrameLayout", parentPath = "root/popup", bounds = NodeBounds(0, 2159, 1080, 2306)),
node("root/popup/footer/label", requireNotNull(savedStructuredFooter), parentPath = "root/popup/footer", bounds = NodeBounds(353, 2203, 557, 2262)),
node("root/popup/footer/amount", "20.00元", parentPath = "root/popup/footer", bounds = NodeBounds(557, 2203, 727, 2262)),
))
"panel" -> snapshot(listOf(
node("title", "收货地址"),
node("modify-parent", "", clickable = true),
@@ -708,7 +950,7 @@ class PurchaseLiveAutomationTest {
))
"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", "", scrollable = true, bounds = savedPanelBounds),
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)),
@@ -720,7 +962,8 @@ class PurchaseLiveAutomationTest {
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)),
))
).map { if (it.path == "panel" && savedPanelScrollCount == 0) it.copy(enabled = false) else it } +
if (savedPanelScrollCount > 1) listOf(node("panel/duplicate", "", scrollable = true, bounds = savedPanelBounds, parentPath = "panel")) else emptyList())
"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(
@@ -799,6 +1042,11 @@ class PurchaseLiveAutomationTest {
override fun clickFresh(target: SnapshotNode): FreshActionResult {
clicked += target.label
if (target.path == "root/popup/footer/label") {
submitClicks++
page = "order"
return FreshActionResult.SUCCESS
}
when (target.label) {
"138****5678" -> page = "panel"
"修改" -> page = "edit"
@@ -806,6 +1054,7 @@ class PurchaseLiveAutomationTest {
page = when {
saveReturnsToSpecPanel -> "post-save-spec"
savedTransitionWithoutLegacyContext -> "saved-transition"
savedStructuredFooter != null -> "structured"
else -> "panel"
}
}
@@ -863,6 +1112,7 @@ class PurchaseLiveAutomationTest {
page = when (page) {
"chooser" -> "payment"
"payment" -> if (orderEvidenceBelowFold) "order-folded" else "order"
"saved-transition" -> if (savedStructuredFooter != null) "structured" else if (backReturnsToSpecPanel) "post-save-spec" else "confirmation"
else -> "confirmation"
}
return true
@@ -9,19 +9,49 @@ import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
import cn.ilapage.goauto.agent.automation.PurchaseSpecGesturePolicy
import cn.ilapage.goauto.agent.automation.PurchaseSwipeFailureReason
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
import cn.ilapage.goauto.agent.automation.RuleValidationException
import cn.ilapage.goauto.agent.automation.SnapshotNode
import cn.ilapage.goauto.agent.automation.SwipeDirection
import cn.ilapage.goauto.agent.automation.UiSnapshot
import cn.ilapage.goauto.agent.persistence.PendingPurchaseOutbox
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticContext
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticStore
import cn.ilapage.goauto.agent.persistence.PurchaseOutboxUploader
import org.json.JSONObject
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseRehearsalExecutorTest {
@get:Rule val diagnosticTemporary = TemporaryFolder()
@Test
fun `entry rejection persists without changing outcome when diagnostic storage fails`() {
for (writable in listOf(true, false)) {
val location = if (writable) diagnosticTemporary.newFolder() else diagnosticTemporary.newFile()
val store = PurchaseDiagnosticStore(location)
val context = PurchaseDiagnosticContext(81, "attempt-14", 7, "a".repeat(64), "0.9.67", "purchase")
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true, productOverlay = true,
specTapResult = FreshActionResult.SUCCESS)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {},
panelDiagnostic = { store.record(context, 0, it); Unit })
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
assertEquals(0, driver.specTapCount)
if (writable) {
val events = location.listFiles()!!.single().readLines().map { JSONObject(it).getJSONObject("evidence") }
assertEquals("close_control", events.last().getString("reason"))
assertTrue(events.any { it.optInt("entryWaitPolls") == 30 })
assertTrue(events.any { it.optString("entryClick") == "SUCCESS" })
}
}
}
@Test
fun `color selection then single size heading completes without selecting color again`() {
val driver = FakePurchaseDriver(prefixlessSingleHeadingAfterColor = true)
@@ -98,7 +128,7 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `rule parameters drive aliases waits and bounded swipes without dangerous clicks`() {
fun `rule parameters drive aliases and waits while deprecated panel swipes are ignored`() {
val driver = FakePurchaseDriver()
val pauses = mutableListOf<Long>()
var openCount = 0
@@ -112,7 +142,7 @@ class PurchaseRehearsalExecutorTest {
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(2_000L, outcome.actualUnitPriceCent)
assertEquals(0, openCount)
assertEquals(2, driver.swipeCount)
assertEquals(0, driver.swipeCount)
assertEquals(2L, driver.quantity)
assertTrue(driver.clicked.containsAll(listOf("选择规格", "黑色", "XL")))
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") })
@@ -764,7 +794,7 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `open spec panel skips required follow-up swipe only for confirmed non-scrollable panel`() {
fun `open spec panel still skips legacy follow-up swipe for confirmed non-scrollable panel`() {
val driver = FakePurchaseDriver(nonScrollablePanel = true, purchaseSwipeSucceeds = false)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
@@ -773,6 +803,86 @@ class PurchaseRehearsalExecutorTest {
assertEquals(0, driver.swipeCount)
}
@Test
fun `legacy panel preswipe cannot block visible specs in probe or purchase phase`() {
for (nonScrollable in listOf(false, true)) {
for (phase in listOf("spec_probe", "purchase")) {
val driver = FakePurchaseDriver(nonScrollablePanel = nonScrollable, purchaseSwipeSucceeds = false)
val diagnostics = mutableListOf<String>()
val pauses = mutableListOf<Long>()
var probeCount = 0
val raw = rule().replace("\"type\":\"openSpecPanel\"", "\"type\":\"openSpecPanel\",\"waitAfterMs\":321")
val outcome = PurchaseRehearsalExecutor(
driver, { true }, { probeCount++; "{}" }, pause = pauses::add,
panelDiagnostic = diagnostics::add,
).execute(input().copy(phase = phase), PurchaseRuleParser.parse(raw), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, if (phase == "spec_probe") "spec_probe_completed" else "rehearsal_completed", outcome.resultType)
assertEquals(0, driver.swipeCount)
assertTrue(pauses.contains(321L))
assertFalse(pauses.contains(1000L))
assertTrue(diagnostics.any { it.contains("reason=spec_panel_on_demand") })
assertEquals(if (phase == "spec_probe") 1 else 0, probeCount)
assertEquals(if (phase == "spec_probe") 0 else 1, driver.clicked.count { it == "黑色" })
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") })
}
}
}
@Test
fun `non panel mandatory swipes still execute and fail closed`() {
val raw = rule().replace("\"type\":\"selectSpec\"", "\"type\":\"selectSpec\",\"swipeAfter\":{\"direction\":\"up\",\"count\":2,\"durationMs\":500,\"intervalMs\":1000}")
for (succeeds in listOf(false, true)) {
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = succeeds)
val diagnostics = mutableListOf<String>()
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = pauses::add, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(raw), PurchaseAgentCapabilities.supported)
assertEquals(if (succeeds) "rehearsal_completed" else "failed", outcome.resultType)
assertEquals(if (succeeds) 2 else 1, driver.swipeCount)
assertEquals(succeeds, pauses.contains(1000L))
if (!succeeds) {
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertTrue(diagnostics.any { it.contains("action=selectSpec") && it.contains("reason=unknown") })
}
}
}
@Test
fun `quick and order confirmation selectors also skip legacy panel preswipe`() {
for (kind in listOf("QUICK_CONFIRMATION", "ORDER_CONFIRMATION")) {
val driver = FakePurchaseDriver(confirmationPanelKind = kind, purchaseSwipeSucceeds = false)
val diagnostics = mutableListOf<String>()
var probed = false
val outcome = PurchaseRehearsalExecutor(
driver, { true }, { probed = true; "{}" }, pause = {}, panelDiagnostic = diagnostics::add,
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "spec_probe_completed", outcome.resultType)
assertTrue(probed)
assertEquals(0, driver.swipeCount)
assertTrue(diagnostics.any { it.contains("postSwipe=skipped") && it.contains("panel=$kind") })
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") || it == "现在买" })
}
}
@Test
fun `mandatory swipe diagnostics use fixed reasons without spec text`() {
val raw = rule().replace("\"type\":\"selectSpec\"", "\"type\":\"selectSpec\",\"swipeAfter\":{\"direction\":\"up\",\"count\":1,\"durationMs\":500}")
for (reason in PurchaseSwipeFailureReason.values()) {
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false, purchaseSwipeFailure = reason)
val diagnostics = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(raw), PurchaseAgentCapabilities.supported)
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertTrue(diagnostics.contains("postSwipe=failed;action=selectSpec;direction=UP;swipeIndex=1;reason=${reason.name.lowercase()}"))
assertFalse(diagnostics.any { it.contains("黑色") || it.contains("XL") })
}
}
@Test(expected = RuleValidationException::class)
fun `ignored legacy panel swipe still rejects invalid rule parameters`() {
PurchaseRuleParser.parse(rule().replace("\"count\":2", "\"count\":0"))
}
@Test
fun `unrecognized opened panel returns only scalar panel evidence`() {
val driver = FakePurchaseDriver(unrecognizedPanel = true)
@@ -802,6 +912,84 @@ class PurchaseRehearsalExecutorTest {
assertTrue(driver.panel)
}
@Test
fun `selected color card duplicate does not trigger another selection tap`() {
val driver = FakePurchaseDriver(duplicateColorCardEvidence = true)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
assertEquals(0, driver.specTapCount)
}
@Test
fun `dynamic product page recovers ineffective and failed entry clicks once`() {
for (reason in listOf(null, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.TARGET_NOT_FOUND)) {
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
forcedEntryClickReason = reason, specTapResult = FreshActionResult.SUCCESS)
val logs = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = logs::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
assertEquals(1, driver.specTapCount)
assertTrue(logs.any { it.contains("entryRecovery=attempted") })
assertFalse(logs.any { it.contains("测试商品") || it.contains("选择规格") })
}
}
@Test
fun `dynamic product page with ineffective gesture reports no effect without second tap`() {
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
specTapResult = FreshActionResult.SUCCESS, specTapHasEffect = false)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT", outcome.errorCode)
assertEquals(1, driver.specTapCount)
}
@Test
fun `dynamic product with close control never receives recovery tap`() {
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true, productOverlay = true,
specTapResult = FreshActionResult.SUCCESS)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
assertEquals(0, driver.specTapCount)
}
@Test
fun `changed entry identity or unsafe page never receives recovery tap`() {
for (mode in listOf("missing", "duplicate", "activity", "payment")) {
val diagnostics = mutableListOf<String>()
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
afterEntryMutation = mode, specTapResult = FreshActionResult.SUCCESS)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(mode, "failed", outcome.resultType)
assertEquals(mode, 0, driver.specTapCount)
val expected = mapOf("missing" to "entry_missing", "duplicate" to "entry_ambiguous",
"activity" to "identity_changed", "payment" to "payment_area").getValue(mode)
assertTrue(diagnostics.toString(), diagnostics.any { it.contains("entryRecovery=rejected;reason=$expected;") })
}
}
@Test
fun `unavailable exact color stops without selection click or searching even when selected`() {
for (selected in listOf(null, "黑色")) {
val driver = FakePurchaseDriver(colors = listOf("黑色", "白色"),
unavailableColors = setOf("黑色")).apply { color = selected }
val logs = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = logs::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_SAFE_TARGET_MISSING", outcome.errorCode)
assertEquals("目标颜色已售罄或当前不可选,未创建订单", outcome.message)
assertFalse(driver.clicked.contains("黑色"))
assertEquals(0, driver.specTapCount)
assertEquals(0, driver.swipeCount)
assertTrue(logs.any { it == "specLookup=unavailable;dimension=color;reason=exact_target_unavailable" })
assertFalse(logs.any { it.contains("黑色") })
}
}
@Test
fun `unchanged spec entry after action and gesture returns no effect`() {
val driver = FakePurchaseDriver(
@@ -1227,6 +1415,10 @@ class PurchaseRehearsalExecutorTest {
private val forcedEntryClickReason: FreshClickReason? = null,
private val initialSize: String? = null,
private val entryActionHasEffect: Boolean = true,
private val duplicateColorCardEvidence: Boolean = false,
private val dynamicProduct: Boolean = false,
private val productOverlay: Boolean = false,
private val afterEntryMutation: String = "",
private val specTapResult: FreshActionResult = FreshActionResult.FAILED,
private val specTapHasEffect: Boolean = true,
private val sizeSelectsOnFailedClick: Boolean = false,
@@ -1244,10 +1436,13 @@ class PurchaseRehearsalExecutorTest {
private val pullDownSucceeds: Boolean = true,
private val loseEvidenceAfterPull: Boolean = false,
private val unavailableSizes: Set<String> = emptySet(),
private val unavailableColors: Set<String> = emptySet(),
allSpecsUnavailable: Boolean = false,
private val nonScrollablePanel: Boolean = false,
private val confirmationPanelKind: String? = null,
private val unrecognizedPanel: Boolean = false,
private val purchaseSwipeSucceeds: Boolean = true,
private val purchaseSwipeFailure: PurchaseSwipeFailureReason = PurchaseSwipeFailureReason.UNKNOWN,
initiallyInAgent: Boolean = false,
private val panelBecomesUnknownAfterSizeProof: Boolean = false,
) : PurchaseUiDriver {
@@ -1278,6 +1473,7 @@ class PurchaseRehearsalExecutorTest {
private var horizontalColorPage = 0
private var horizontalSizePage = 0
private var capturesAfterSizeSelection = 0
private var entryAttempted = false
val clicked = mutableListOf<String>()
val clickedPaths = mutableListOf<String>()
@@ -1340,6 +1536,8 @@ class PurchaseRehearsalExecutorTest {
node("title", "测试商品标题文本", 20, 200, 900, 280, className = "android.widget.ViewPager"),
)
val specEntryReady = pddCaptureCount > specEntryVisibleAfterPddCaptures
if (dynamicProduct) nodes += node("decoration", "", 20, 400 + pddCaptureCount % 3, 100, 460)
if (productOverlay) nodes += node("overlay-close", "关闭", 900, 500, 1000, 570, clickable = true)
if (bottomPurchaseEntry && specEntryReady) {
nodes += node("buy", "", 500, 1800, 1080, 2180, clickable = true)
nodes += node("buy/price", "¥20.00", 560, 1840, 760, 1910, parentPath = "buy")
@@ -1348,6 +1546,12 @@ class PurchaseRehearsalExecutorTest {
nodes += node("spec", "选择规格", 20, 1000, 900, 1100, clickable = true)
}
if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true)
if (entryAttempted) {
if (afterEntryMutation == "missing") nodes.removeAll { it.path == "spec" }
if (afterEntryMutation == "duplicate") nodes += node("spec2", "选择规格", 20, 1120, 900, 1190, clickable = true)
if (afterEntryMutation == "payment") nodes += node("payment", "微信支付", 20, 1800, 900, 1900)
if (afterEntryMutation == "activity") return UiSnapshot(PDD, "OtherActivity", nodes)
}
return UiSnapshot(PDD, ACTIVITY, nodes)
}
if (unrecognizedPanel) {
@@ -1390,6 +1594,16 @@ class PurchaseRehearsalExecutorTest {
nodes += node(colorParent, "", 0, 460, 1080, 550, scrollable = true, parentPath = "scroll")
}
visibleColors.forEachIndexed { index, value ->
if (duplicateColorCardEvidence && selectedColor == value) {
val cardPath = "scroll/card-$index"
nodes += node(cardPath, "$value 199", 20 + index * 220, 460, 200 + index * 220, 550,
clickable = true, selected = true, parentPath = "scroll", className = "android.view.ViewGroup")
nodes += node("$cardPath/image", "$value 199", 20 + index * 220, 460, 200 + index * 220, 500,
clickable = true, selected = true, parentPath = cardPath, className = "android.widget.ImageView")
nodes += node("$cardPath/text", value, 20 + index * 220, 500, 200 + index * 220, 540,
clickable = true, selected = true, parentPath = cardPath, className = "android.widget.TextView")
return@forEachIndexed
}
nodes += node(
"scroll/color-$index",
value,
@@ -1399,7 +1613,7 @@ class PurchaseRehearsalExecutorTest {
540,
clickable = true,
selected = selectedColor == value,
enabled = !allSpecsUnavailable,
enabled = !allSpecsUnavailable && value !in unavailableColors,
parentPath = colorParent,
)
}
@@ -1437,7 +1651,16 @@ class PurchaseRehearsalExecutorTest {
nodes += if (singleHeading) node("info/quantity", quantity.toString(), 400, 360, 600, 390, className = "android.widget.EditText", parentPath = "info")
else node("quantity", quantity.toString(), 400, 800, 600, 870, className = "android.widget.EditText")
if (!singleHeading) nodes += node("confirm", "确定", 20, 900, 500, 980, clickable = true)
nodes += node("order", "提交订单", 20, if (singleHeading) 2000 else 1100, 500, if (singleHeading) 2080 else 1180, clickable = true)
if (confirmationPanelKind != null) {
nodes += node("close", "关闭", 980, 300, 1060, 350, clickable = true)
nodes += node("minus", "减少数量", 300, 800, 380, 870, clickable = true)
nodes += node("plus", "增加数量", 620, 800, 700, 870, clickable = true)
nodes += node("payment", "微信支付", 600, 1000, 900, 1050)
if (confirmationPanelKind == "QUICK_CONFIRMATION") {
nodes += node("quick", "现在买", 520, 2000, 1020, 2080, clickable = true)
}
}
nodes += node("order", "提交订单", 20, if (singleHeading || confirmationPanelKind != null) 2000 else 1100, 500, if (singleHeading || confirmationPanelKind != null) 2080 else 1180, clickable = true)
nodes += node("pay", "立即支付", 520, 1100, 1020, 1180, clickable = true)
return UiSnapshot(PDD, ACTIVITY, nodes)
}
@@ -1478,6 +1701,7 @@ class PurchaseRehearsalExecutorTest {
}
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome {
if (target.path in setOf("spec", "buy")) entryAttempted = true
if (target.path in setOf("spec", "buy") && forcedEntryClickReason != null) {
val result = when (forcedEntryClickReason) {
FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> FreshActionResult.NOT_FOUND
@@ -1531,6 +1755,8 @@ class PurchaseRehearsalExecutorTest {
return purchaseSwipeSucceeds
}
override fun purchaseSwipeFailureReason(): PurchaseSwipeFailureReason = purchaseSwipeFailure
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
swipeInPaths += target.path
if (restoreHiddenColorOnDownSwipe && quantity == 2L && direction == SwipeDirection.DOWN) {
@@ -0,0 +1,47 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import org.junit.Assert.*
import org.junit.Test
class SelectedColorCardTest {
private val target = "黑色两件套 19"
private fun node(path: String, parent: String?, label: String, kind: String, selected: Boolean = true,
scrollable: Boolean = false) = SnapshotNode(path, parent, label, null, null, kind,
NodeBounds(0, 0, 300, 400), !scrollable, scrollable, selected, false, true, true)
private val scroll = node("s", null, "", "androidx.recyclerview.widget.RecyclerView", false, true)
private val card = node("s/c", "s", "黑色两件套 199", "android.view.ViewGroup")
private val image = node("s/c/i", "s/c", "黑色两件套 199", "android.widget.ImageView")
private val text = node("s/c/t", "s/c", target, "android.widget.TextView")
private fun check(nodes: List<SnapshotNode>, candidates: List<SnapshotNode> = nodes.filter { it != scroll }) =
SelectedColorCard.confirms(target, candidates.map { VisibleSpecValue(it.label, true, it) }, nodes)
@Test fun `selected target and own card image duplicates are one selection`() {
assertTrue(check(listOf(scroll, card, image, text)))
}
@Test fun `another selected card remains conflict`() {
val other = card.copy(path = "s/other")
assertFalse(check(listOf(scroll, card, image, text, other)))
}
@Test fun `two exact targets are not merged`() {
val otherCard = card.copy(path = "s/other", selected = false)
val otherText = text.copy(path = "s/other/t", parentPath = otherCard.path)
assertFalse(check(listOf(scroll, card, image, text, otherCard, otherText)))
assertFalse(check(listOf(scroll, card, image, text, otherCard, otherText), listOf(card, image, text)))
}
@Test fun `unselected target cannot borrow selection from its container`() {
assertFalse(check(listOf(scroll, card, image, text.copy(selected = false))))
}
@Test fun `scrollable or missing card boundary cannot merge`() {
assertFalse(check(listOf(scroll, card.copy(scrollable = true), image, text)))
assertFalse(check(listOf(card, image, text)))
assertFalse(check(listOf(scroll, card.copy(clickable = false), image, text)))
}
@Test fun `different text sibling is not treated as image duplicate`() {
assertFalse(check(listOf(scroll, card, text, image.copy(className = "android.widget.TextView"))))
}
@Test fun `out of card bounds and parent cycle are rejected`() {
assertFalse(check(listOf(scroll, card, image.copy(bounds = NodeBounds(0, 0, 301, 400)), text)))
assertFalse(check(listOf(scroll, card.copy(parentPath = text.path), image, text)))
}
}
@@ -0,0 +1,81 @@
package cn.ilapage.goauto.agent.persistence
import java.io.File
import org.json.JSONObject
import org.junit.Assert.*
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
class PurchaseDiagnosticStoreTest {
@get:Rule val temporary = TemporaryFolder()
private val context = PurchaseDiagnosticContext(81, "attempt-14", 7, "a".repeat(64), "0.9.67", "purchase")
@Test fun `only known structural fields and fixed values survive`() {
val output = PurchaseDiagnosticStore.sanitize(
"entryClick=SUCCESS;entryRecovery=rejected;reason=title_changed;summary=false;options=20;" +
"title=敏感标题;address=测试地址;token=secret;entrySource=private-value;gesture=private-value;" +
"quantity=private-value;scrollables=-1;unknown=private-value",
)
assertEquals(5, output.length())
assertEquals("title_changed", output.getString("reason"))
assertFalse(output.getBoolean("summary"))
assertFalse(output.toString().contains("private"))
assertFalse(output.toString().contains("secret"))
assertEquals(0, PurchaseDiagnosticStore.sanitize("reason=测试地址;entryReason=secret").length())
}
@Test fun `events persist across store recreation with identity and elapsed time`() {
val dir = temporary.newFolder()
assertTrue(PurchaseDiagnosticStore(dir) { 1000 }.record(context, 2300,
"entryRecovery=rejected;reason=close_control;entryWaitPolls=30;entryWaitMillis=2900;closeControl=true"))
assertTrue(PurchaseDiagnosticStore(dir) { 2000 }.record(context, 2500, "entryClick=SUCCESS"))
val lines = dir.listFiles()!!.single().readLines()
assertEquals(2, lines.size)
val entry = JSONObject(lines.first())
assertEquals(81, entry.getLong("taskId"))
assertEquals("attempt-14", entry.getString("attemptId"))
assertEquals(7, entry.getLong("deviceId"))
assertEquals(context.ruleHash, entry.getString("ruleHash"))
assertEquals(2300, entry.getLong("elapsedMs"))
assertEquals("0.9.67", entry.getString("agentVersion"))
assertEquals("close_control", entry.getJSONObject("evidence").getString("reason"))
}
@Test fun `retains last five attempts and last 128 events including failure`() {
val dir = temporary.newFolder()
var time = 1000L
val store = PurchaseDiagnosticStore(dir) { time++ }
repeat(7) { store.record(context.copy(attemptId = "attempt-$it"), 0, "event=started") }
assertEquals(5, dir.listFiles()!!.size)
assertFalse(File(dir, "81_attempt-0.jsonl").exists())
repeat(150) { store.record(context, it.toLong(), "entryWaitPolls=$it") }
store.record(context, 999, "entryRecovery=rejected;reason=entry_label_changed")
val lines = File(dir, "81_attempt-14.jsonl").readLines()
assertEquals(128, lines.size)
assertEquals("entry_label_changed", JSONObject(lines.last()).getJSONObject("evidence").getString("reason"))
assertEquals(5, dir.listFiles()!!.size)
}
@Test fun `expiry deletes only owned files and can run at startup`() {
val dir = temporary.newFolder()
PurchaseDiagnosticStore(dir) { 1000 }.record(context, 0, "event=started")
File(dir, "unrelated.txt").writeText("preserve")
File(dir, "unrelated.txt").setLastModified(1000)
PurchaseDiagnosticStore(dir) { PurchaseDiagnosticStore.RETENTION_MS + 1001 }.prune()
assertEquals(listOf("unrelated.txt"), dir.listFiles()!!.map { it.name })
}
@Test fun `invalid context and filesystem failures never throw`() {
val store = PurchaseDiagnosticStore(temporary.newFile())
assertFalse(store.record(context, 1, "event=started"))
store.prune()
val dir = temporary.newFolder()
val validStore = PurchaseDiagnosticStore(dir)
assertFalse(validStore.record(context.copy(attemptId = "../outside"), 1, "event=started"))
assertFalse(validStore.record(context.copy(ruleHash = "secret"), 1, "event=started"))
assertFalse(validStore.record(context.copy(phase = "secret"), 1, "event=started"))
assertFalse(validStore.record(context, 1, "unknown=secret"))
assertTrue(dir.listFiles()!!.isEmpty())
}
}
+10 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: 9d3b3f2683b5c77f3fd6601057e64c2cda773a51
synchronized_at: 2026-09-07T09:43:41Z
wiki_revision: 20a1ca65dccde9ca1ecd93419cc1b164a5717fdf
synchronized_at: 2026-09-07T13:34:45Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -344,3 +344,11 @@ PddProductDetailCollector
- 编辑与停用采用启用状态和 version 条件更新,并与变更审计同事务提交;冲突返回 409。业务执行前先持久化请求审计意图,失败则不执行业务。完成后更新状态;更新失败或进程中断可能留下 status=0,表示结果待核对,不能据此自动重放。
- 部分既有业务操作人字段使用该密钥最近授权管理员的 ID 兼容现有外键;实际调用方以独立审计的 key_id 为准,不能把业务字段当成人工操作证据。
- Admin 页面 `web/src/views/goauto/client-keys/index.vue` 复用创建/编辑授权弹窗;菜单位于“采采管理”,仅管理员可见。新追加迁移 `1788798000000_client_api_key.go` 创建两表及管理员菜单,不改 Android。
## 采购规格面板预滑动兼容(#238)
代码基线 `58a6c1c`,Android 0.9.60 / versionCode 73(构建完成不等同于已安装/发布)。`PurchaseRehearsalExecutor.applyPostAction` 对 `openSpecPanel.swipeAfter` 只兼容解析、不执行机械预滑动,`waitAfterMs` 保留;首趟继续原 `probeSpecs` 遍历,第二趟继续原 `selectSpec` 精确查找与容器内有界滚动。其他动作的后置滑动仍沿用既有执行语义,失败不会被统一忽略。
`GoAutoAccessibilityService.swipePurchase` 的失败分类由 `PurchaseSwipeFailureReason` 枚举提供;共享 `swipeNode` 仅增加可选分类回调,不改变手势目标、轨迹、1500ms 回调等待或其他调用者行为。`GoAutoPurchasePanel` 日志经 `AgentForegroundService` 关联 task、attempt、device 与规则快照哈希,新增预滑动跳过/必需滑动失败标量;不记录节点文字、坐标、原始控件树、截图或凭据。
Server/Web、数据库和任务快照不变;旧 APK 仍有预滑动行为,必须更新 Agent 才生效。相关验证在 `PurchaseRehearsalExecutorTest`,Android 全量测试与 APK 构建入口不变。
+58 -5
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: 2a9334053de83a9a0525af074755f67964b48a29
synchronized_at: 2026-09-07T09:43:46Z
wiki_revision: a445dfe3b76336f5c7387a2da9760c5d6e383ff6
synchronized_at: 2026-09-09T02:47:12Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -58,6 +58,13 @@ synchronized_at: 2026-09-07T09:43:46Z
- 对采购人员展示的阶段固定为:待人工处理、未关联 PDD、PDD 待采集、PDD 采集中、PDD 采集失败、规格待匹配、可创建采购、已创建任务、采购成功、待人工核对。
- 主阶段优先级为:待人工核对 → 采购成功 → 已创建任务 → 待人工处理 → 未关联 PDD → PDD 待采集/采集中/采集失败 → 规格待匹配 → 可创建采购。每行只显示一个阶段和对应下一步。
- “待人工核对”表示订单结果不明确,必须先人工核查并禁止自动重试;“采购成功”表示已取得 PDD 订单号和下单时间,不代表已经支付。
- 一个 PDD 订单号只能属于一个采购任务,该唯一性在采购任务保存路径上全局强制(#241)。人工处理结果未知、取消及 lifecycle 保存路径撞号时返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`,提示订单号已属于哪个任务,由采购员人工核对,不静默覆盖原值。
- 不可逆边界之后的 `order_created` 结果回传是上述规则的例外:此时 PDD 真单已创建,发现订单号已属于其他任务时不回滚、不判失败,而是把任务降级为 `order_result_unknown`,冲突订单号以「读到订单号 X,但该号已属于任务 CG-yy」保存在任务与 attempt 的 `error_message`,`pdd_order_no` 留空以维持唯一性,保留下单时间与不可逆时间,进入既有人工处理结果未知通道。首要目标是保住「真单已存在」这一事实,不制造无记录的真实订单。
- Agent 可按收货地址后缀 `_cg<任务号>` 批量回填订单号与下单时间(#241)。
- Agent 侧回填为**人工触发的只读扫描**(#242):入口在采购记录页搜索按钮右侧,输入天数(默认 2)后确认启动。天数口径为滚动 N×24 小时,基准是页面读到的下单时间。采集、采购、回填三者互斥,复用既有任务互斥锁并加原子防重入,忙碌时拒绝启动而不排队、不抢占。
- 回填扫描不假设订单列表有序:只有连续观察到至少 5 单且下单时间严格递减、期间无缺失时间时,才允许「超过指定时间即停止」;一旦出现时间回升或缺失,改为扫至内部上限并明确标记「未完整扫描」,不得把不完整结果显示为已扫完。内部上限用于限制设备占用,不作为用户可配置的门禁。
- 回填扫描全程只读:确认收货、申请退款、催发货、去支付、立即支付、提交订单、付款、退款、取消订单、再次购买、删除订单永久排除为点击目标;点击目标必须自身可点、子树不含上述词,且不与任何含上述词的可见控件几何重叠;不得按固定坐标盲点。遇登录、验证码或风控立即停止并报告。
- 解析不出规范后缀的订单一律跳过,其订单数据不缓存、不上传、不入日志;上传载荷只含后缀、订单号与可选下单时间。本地缓存只采纳服务端确认的事实,并区分页面读到的真实时间与 `irreversible_at` 回落估算值。后缀只承载任务号,请求不含地址全文或收件人信息;只允许回填该设备自己的正式采购任务;页面下单时间优先,缺失时回落该任务的 `irreversible_at` 并标记时间来源,两者皆空则该条失败。
- 已失败、已取消或演练完成的旧采购任务不单独占用主阶段;当前数据仍满足条件时恢复显示“可创建采购”,旧任务继续在采购管理留痕和按既有规则处理。
- 未选择处理阶段时,商品列表仍先返回,当前页阶段和采购准备继续异步批量读取且不调用 AI Provider;选择阶段筛选时,服务端必须先对完整查询结果派生并筛选阶段,再计算总数和分页,不能只过滤当前页。
@@ -67,7 +74,7 @@ synchronized_at: 2026-09-07T09:43:46Z
- 版本迁移会对历史存活店铺回填 `normalized_name = Normalize(display_name)`;回填只改匹配键且可重复执行。若两个存活店铺回填后会得到同一键,迁移必须整体失败并保留原数据,管理员先人工消除歧义后再执行,不能静默合并、删除或改变店铺启用状态。
- 店铺可以从 SYB 真实货运单列表发现,也允许管理员手工补充。只有管理员可以新增、改名、启停和软删除,采购员等其他角色只读。
- 没有任何启用店铺时,导入必须在读取凭据、建立会话、验证码 OCR 和任意 SYB 网络请求之前失败,并给出“请先启用店铺”的可读提示。
- 同步必须先拉取并校验当天原始全量列表的总数、分页和唯一 ID,再按本次同步开始时固定的启用店铺快照过滤;过滤不能降低完整性校验的请求范围或容量上限。
- 同步先预检全范围总数及上限,每页校验原始列表条数、合法 ID 和重复,再按冻结店铺快照获取明细并页事务保存;整日结束核对总数。过滤不能降低完整性校验范围,已保存不能冒充整日完整(#239,c6a962d,已于 2026-09-08 随 d403f3b 部署线上)。
- 只有列表与明细响应的店铺名都非空且命中启用快照时才允许写入。明细店铺名为空、变化为未启用店铺或无法匹配时跳过,并计入跳过数量。
- 停用或软删除店铺只影响后续导入,不删除历史 SYB 商品、虾皮商品或任务数据。已有错误导入数据的清理必须先给出精确 SQL 和影响行数,再由用户单独确认。
@@ -397,7 +404,7 @@ synchronized_at: 2026-09-07T09:43:46Z
- 规格入口与精确规格选择统一按“重新定位唯一目标 → 执行一次无障碍点击 → 读取新页面验证”执行;不得复用旧无障碍节点,不选择相近规格,也不在多候选时默认点击第一个。
- 无障碍点击后页面完全无变化时,只允许对解析器已确认的安全规格入口或服务端下发且唯一命中的精确规格执行一次中心手势兜底。目标必须可见、启用、边界有效,手势后仍须以规格面板强证据或精确选中证据确认结果。
- 页面发生变化但规格面板强证据不足时,不再继续手势或猜测页面,明确失败并只记录面板类型、候选数量和证据布尔值等无敏感标量。原始控件树、节点文字集合和整屏截图仍不得保存或上传。
- 页面发生变化但规格面板强证据不足时,默认明确失败;#243 的同商品页安全入口恢复是限定例外:确认包/Activity 未变、商品标题非空且未变、入口来源与语义未变,无面板、关闭、数量、已选、支付、订单或页面异常信号时,重新定位入口并最多执行一次受控手势。整页普通动态节点变化不单独阻断该恢复;入口缺失或显式/嵌套入口歧义仍拒绝。商品标题一致是当前上下文约束,不宣称已从页面核验 goods_id。诊断只记录入口来源、点击结果/原因、恢复结果和面板数量/布尔等无敏感标量。原始控件树、节点文字集合和整屏截图仍不得保存或上传。
- 规格已处于精确选中状态时不得重复点击。规格查找只在解析器唯一识别的规格面板容器内有限滚动,每次滚动后重新定位容器与目标;容器缺失、歧义、到边或验证失败均 fail-closed。
- 中心手势兜底不得用于修改/保存地址、创建或提交订单、订单详情入口以及任何支付/付款目标;正式创建订单的一次性不可逆门禁与永久禁止支付规则不变。
## PDD 商品反向关联与继续订单采购(#161)
@@ -447,7 +454,7 @@ synchronized_at: 2026-09-07T09:43:46Z
## 客户端密钥与可编辑模块授权(#237)
以下为提交 `71f7751` 已实现的规则;2026-09-07 本机已迁移并验证管理页面可用,线上尚未部署,真实客户端密钥执行闭环尚未验证。
以下为提交 `71f7751` 已实现的规则;2026-09-07 本机已迁移并验证管理页面可用,2026-09-08 已随 d403f3b 完成线上迁移与部署,真实客户端密钥执行闭环尚未验证。
- 仅管理员创建、查看、编辑授权或停用密钥。首版不提供密钥改名、到期、轮换、恢复启用、删除或任意接口授权。
- 模块选择复用“采集采购/采采管理”现有 12 个业务模块;分组勾选只影响当前子模块,新菜单不会自动获得授权。客户端密钥管理、系统账号权限及支付不在可授权模块中。
@@ -458,3 +465,49 @@ synchronized_at: 2026-09-07T09:43:46Z
- 客户端与 Admin 登录 JWT、Agent Device Token 独立。响应排除凭据及原始载荷字段;AI 设置只返回 enabled,不开放 Provider 配置读写或连接测试。设备仅开放列表,不开放身份重置、令牌或解锁接口。
- 执行动作复用既有业务门禁、幂等参数及状态机;客户端采购 batch-retry 沿用 Admin 原有语义,不等同于 Agent 就地 reset。授权重采购、支付复核、取消订单等未列入接口不开放;永久禁止付款。
- 创建响应丢失时不可找回完整密钥,应核对列表并停用可能已创建的记录,再明确创建新密钥,不能盲目自动重试。完整密钥只在创建结果弹窗内存中显示,关闭或离开页面清空,不写浏览器持久存储。
## 打开采购规格面板后按需滚动(#238)
- Android 0.9.60 / versionCode 73,代码 `58a6c1c` 起,规则 `openSpecPanel.swipeAfter` 保留格式校验与旧快照兼容,但不执行打开面板后的固定次数预滑动;不以“必须滑两次成功”作为进入规格探测/选择的条件。动作后的 `waitAfterMs` 仍生效。
- 首趟规格探测和第二趟精确选择仍使用各自既有的按需横向/纵向、有界与稳定终止策略。取消预滑动不等于不探测隐藏规格,也不等于只看首屏。目标不存在、歧义、页面证据不足或必要的有界查找失败时仍明确失败。
- 此调整覆盖所有已经安全识别打开的面板,不再仅特判 NON_SCROLLABLE_CONFIRMATION;不弱化面板验证、精确选中、地址、价格、任务租约、创建订单边界或禁止支付规则。
- 其他动作的后置滑动沿用原行为,必需滑动失败仍返回 RULE_ACTION_FAILED。旧规则 JSON 不回写、不迁移;原任务 ID、历史 attempt、商品与规格快照不变。旧 APK 行为不变,需升级 Agent;本单未改线上规则或执行 CG68 真机采购。
## SYB 逐页保存与部分成功(#239)
实现绑定 c6a962d;2026-09-08 已随 d403f3b 部署线上,未手动触发真实同步验收;#240 上游尾页超时尚未修复。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。
同步状态增加 `partial_success`(部分成功,15 字符,复用现有 varchar(16),无需迁移)。有错误且 created+updated>0 为部分成功;有错误无已提交明细为 failed;完整且无错误为 succeeded(包括无符合店铺的数据)。中断仍为 interrupted,不把中断追认为成功。所有终态沿用活动槽释放规则。
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
## 商品页动态变化下的采购入口恢复(#243)
实现 ec8b14a,Android 0.9.61/versionCode 74;已构建、未安装或真机验收。规格探测和正式采购共用入口函数,面板已打开时不新增点击;仍在同一商品上下文且安全入口存在时,一次重新定位手势恢复不再依赖整页完全静止。手势后仍未打开则报告入口无效果,不循环点击。恢复不能用于地址、创建订单、支付或未知面板,不修改面板分类器及正式下单边界。CG82/CG85 原首次点击未生效的底层原因尚待新日志和授权真机验证。
## 同一颜色卡片重复选中节点(#244)
实现 93aab6a,Android 0.9.62/versionCode 75;已构建并授权覆盖安装,真实采购效果待验收。仅在颜色维度完整精确目标已选中、但存在非目标已选候选时,识别同一卡片重复表达:卡片必须是滚动容器直接子级的可点击非滚动 android.view.ViewGroup,节点父链明确、可见启用且边界包含;其他冲突节点仅可为该卡片自身或其中的 ImageView。另一文字选项、另一张卡片、多个精确目标、目标未选中、父链/边界不明均不适用例外。额外核对内存源节点以拒绝被解析器按同名去重隐藏的跨卡片目标;不保存原始树。
不删除规格数字、不模糊匹配、不改变通用解析器、尺码确认、摘要兜底、入口/滚动/地址/价格/创建订单。没有非目标选中冲突时继续原判断;仅把同卡片容器/图片的重复选中表达从冲突中排除,不新增点击。
## 精确规格不可用与入口恢复诊断(#245)
实现 ca815c3,Android 0.9.63/versionCode 76;已通过单元测试并构建,未安装、未进行真实采购验收。入口恢复沿用 #243 条件,拒绝时通过 entryRecovery=rejected;reason=<固定原因码> 区分页面身份、商品上下文、面板/支付区域、数量/摘要、标题、入口来源/文案、歧义、规则别名及可用性;不放宽条件,不新增点击。
在规格面板识别出唯一完整精确候选且 available=false 时,立即返回既有 PURCHASE_SPEC_SAFE_TARGET_MISSING,提示目标颜色/尺码已售罄或当前不可选,未创建订单;已选中目标同样不能绕过不可用检查。只记录 dimension 和 exact_target_unavailable 固定结构证据,不记录规格原文或原始树。原有纵向与横向定位在此条件下本就停止,不能将本改动描述为修复“已识别售罄仍继续搜索”。未识别精确候选时继续既有有界搜索,不推断售罄,不替换规格;解析器、匹配规则、滚动预算、地址与下单流程不变。
## 改地址返回后的动态提交按钮(#248)
实现绑定 `c80c746`,Android 0.9.66 / versionCode 79;已通过 353 项 Android 单元测试并构建,尚未安装或进行真实采购验收。
- 地址回读与最终提交按钮定位分开判断。旧“提交订单”“现在买,仅”“确认购买”识别继续优先;存在旧文案候选但歧义时不使用结构补充选择其中一个。地址确实缺失时保留既有有限恢复;地址及采购面板上下文已确认后,不因底部按钮文字变化而滚动规格列表。按钮缺失或不唯一使用 `PURCHASE_SUBMIT_TARGET_AMBIGUOUS` 提示按钮问题,不误报地址保存超时。
- 结构补充只属于当前执行器中已完成地址修改后的返回流程,不能由其他页面或旧执行的地址证明启用。当前须为 PDD、地址后缀按既有规则回读唯一、地址入口唯一,并存在唯一数量输入或已选摘要;通过地址入口与该证据的最近共同祖先确定整个采购弹层,不把颜色/尺码滚动区域当成弹层。排除全屏大祖先、地址编辑、已知支付 Activity 及订单结果/支付页面,后续定位要求 Activity 与地址返回时一致。
- 旧文案无候选时,只接受弹层底部固定区域内一个独立、可见启用、非滚动的可点击容器;不在规格滚动区内选择按钮,不取控件树末项,不使用固定屏幕点击坐标。文字与人民币金额子节点合并为一个按钮;支持如“复购价,”及非支付展示文字变化,但单独价格、多个操作按钮或支付/付款/地址/返回等非提交动作均不放行。
- 地址返回检查、最终复核和提交共用目标定位。结构路径保留原文字节点为重新定位锚点,由已有无障碍点击重新查找其可点击父级;不新增下单手势兜底。最终复核成功后才允许该结构路径提交;提交前再次检查路径、类别、文字与边界,发生变化即停止;复核失败清除本地结构提交锚点。
- 既有规格、数量、价格和地址复核,任务租约、服务端/本地不可逆边界及单次提交规则不变。结构识别不支持支付,不增加采购员操作、配置或审批;不改变 Admin、接口、数据库、规则 JSON、通用解析器及滑动执行器。原始控件树、整屏截图、地址全文和凭据不得落盘或上传。
+21 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:16Z
wiki_revision: 18744477bfd17f396e8c76ec7a2fcc6720acdf6f
synchronized_at: 2026-09-09T03:16:51Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -85,3 +85,22 @@ sqlite3 -readonly agent-diagnostics.db "SELECT task_id,reason,color_row_count,co
```
读取时记录设备、Agent 版本、任务号和规则快照;工单只回写查询得到的脱敏聚合数值。读取完成后删除本地导出副本。正式 APK 若不允许 `run-as`,停止排查并确认安全的只读诊断出口,不通过放宽应用安全配置或上传完整数据库绕过。
## Android 采购规格入口本地诊断(#249)
实现绑定 `99faf5a`,Agent `0.9.67`(versionCode 80)。单元测试与 Debug 构建已通过,新增版本真机留存/读取尚待验证;本节不属于上文既有实测结论。
采购执行器启动后,在应用私有 `files/purchase_diagnostics/<taskId>_<attemptId>.jsonl` 保存白名单结构化入口诊断。元数据包含 taskId、attemptId、deviceId、agentVersion、phase、ruleHash(当前任务规则快照 SHA-256)、timestamp 和 elapsedMs。现有任务接口只有 attemptId,没有 attemptNumber;用 attemptId 对照服务端历史,不推算重试序号。
证据仅包含入口来源枚举、候选数、点击结果/原因枚举、等待轮数、面板结构布尔值/计数,以及受控恢复是否执行及固定拒绝原因。没有商品标题、规格原文、地址、手机号、Cookie、Token、链接、原始控件树或截图。entryWaitMillis 是该等待循环累计的计划等待量;elapsedMs 才是自本次采购执行器诊断起点计量的实际耗时,timestamp 是后台写入时间。
复用现有单线程诊断队列异步写入,写入/排队失败不改变采购结果;不新增点击、滑动、等待或采购门禁。最多保留最近 5 个 attempt 文件、每文件最后 128 条事件,超过 7 天的文件在服务启动或下次写入时清理。异常断电或存储故障仍可能丢失诊断,不能将日志缺失当成动作未执行;不上传 Admin,不增加服务端接口。
仅对允许 run-as 的 Debug APK,在设备已连接且选定准确序列号后只读提取:
```powershell
adb -s <device-serial> shell run-as cn.ilapage.goauto.agent ls files/purchase_diagnostics
adb -s <device-serial> exec-out run-as cn.ilapage.goauto.agent cat files/purchase_diagnostics/<taskId>_<attemptId>.jsonl
```
先列出文件,再读取本次任务/attempt 的准确文件名;不得扩大为导出所有应用数据或完整数据库。新版安装前的失败不会补生成日志;需要用户授权后重试才能产生新证据。Release 若禁止 run-as 则停止,不更改权限绕过;首版没有导出界面。该功能用于确定拒绝分支,并不代表已经修复对应采购故障。
+13 -4
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: 59853b796dc787d6af4b6ca3a9c1ae3156cc7152
synchronized_at: 2026-09-07T09:44:13Z
wiki_revision: 49dec4c35da793a32822a82e00f3b657757b6d44
synchronized_at: 2026-09-07T13:35:25Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -472,7 +472,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
|---|---:|---:|---:|---:|
| `openProduct` | 是 | 是 | 是 | 否 |
| `verifyProduct` | 是 | 是 | 否 | 否 |
| `openSpecPanel` | 是 | 是 | 是 | 否 |
| `openSpecPanel` | 是 | 是 | 兼容读取、不执行(0.9.60+,见 #238) | 否 |
| `selectSpec` | 是 | 是 | 是 | 否 |
| `setQuantity` | 是 | 是 | 否 | 否 |
| `verifyUnitPrice` | 是 | 是 | 否 | 否 |
@@ -486,7 +486,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
- 文字候选按控件文字或内容描述**精确匹配**;候选合并后必须唯一命中。点击动作只允许点击唯一文字节点或其最近的可点击父容器,不允许模糊匹配、猜测相近候选、改点兄弟节点。
- 动作 `textAliases` 不能包含地址修改、创建/提交订单、订单号或支付相关文字,防止用安全 action 绕过危险动作类型和能力门禁。只读识别字段使用独立校验:允许订单和支付证据,仍拒绝修改地址、收货地址,并沿用各字段声明的数量、长度、去重和空白限制。
- `waitAfterMs` 表示动作成功后的等待时间,范围为 0~30000 毫秒;省略时为 0。
- `swipeAfter` 表示动作成功后执行一个有限滑动计划。`direction` 只能为 `up` / `down` / `left` / `right`,`count` 为 1~10,`durationMs` 为 100~2000,`intervalMs` 为 0~5000 且省略时为 0。
- `swipeAfter` 通常表示动作成功后执行一个有限滑动计划;Android 0.9.60+ 的 `openSpecPanel` 例外,只兼容读取而不执行预滑动(见 #238)。`direction` 只能为 `up` / `down` / `left` / `right`,`count` 为 1~10,`durationMs` 为 100~2000,`intervalMs` 为 0~5000 且省略时为 0。
- 未在矩阵中授权的 action/参数组合、未知字段、空候选和越界值一律拒绝。`updateShippingAddress`、`createOrder`、`readOrderResult` 等正式动作在其独立高风险契约完成前不接受上述参数。
- 旧的仅含 `actions[].type` 的规则继续有效:候选使用 Agent 内置语义,等待为 0,不执行动作后滑动。
- 服务端保存创建任务时收到的完整原始规则快照;规则后来更新为规则 B,不会改变已有任务中的规则 A 快照。
@@ -966,3 +966,12 @@ Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口
| POST | `/purchase-tasks/batch-retry` | purchase_tasks | purchase |
| POST | `/purchase-tasks/stock` | purchase_tasks | purchase |
| GET | `/ai-matching-settings` | ai_matching | read |
### openSpecPanel 后置滑动兼容与诊断(#238)
版本边界:Android 0.9.60 / versionCode 73,代码 `58a6c1c`。不修改 JSON schema、能力标识、任务接口或已有快照哈希。`openSpecPanel.swipeAfter` 仍按 direction/count/durationMs/intervalMs 原约束校验;解析成功后不执行该准备性滑动,`waitAfterMs` 保留。其他动作后置滑动沿用旧执行语义。旧 Server 可继续下发原快照;旧 APK 仍按原策略执行,不能将本契约描述当作旧设备已获得兼容。
规格探测与精确选择自行负责按需有界滚动,原始候选、精确点击和选中复核不变。跳过预滑动不作为规格探测成功或订单创建证据。
本地 `GoAutoPurchasePanel` 脱敏结构日志关联 task、attempt、device、rule(规则 SHA-256),不上传原始页面。新增事件:`postSwipe=skipped;action=openSpecPanel;reason=spec_panel_on_demand;panel=<枚举>`;其他必需滑动失败为 `postSwipe=failed;action=<动作枚举>;direction=<方向枚举>;swipeIndex=<本动作内第几次滑动>;reason=<固定分类>`。
固定失败分类:unknown、root_unavailable、no_scrollable、invalid_bounds、gesture_unsupported、gesture_rejected、gesture_cancelled、gesture_timeout。日志不含规格值、节点文本、坐标、地址、订单、凭据、原始树或截图;结果错误码仍为 RULE_ACTION_FAILED,现有结果提交字段不变。