diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt index cfae3c8..8da2982 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt @@ -91,6 +91,13 @@ data class VisibleSpecValue( data class VisibleDimension(val key: String, val name: String, val values: List) enum class SpecPanelType { UNKNOWN, NORMAL_SCROLLABLE, NON_SCROLLABLE_CONFIRMATION, QUICK_CONFIRMATION, ORDER_CONFIRMATION } +object PddSoldOutRecoveryDefaults { + const val EXACT_TEXT = "商品已售罄" + const val PULL_DOWN_COUNT = 2 + const val INTERVAL_MILLIS = 1_000L + const val SETTLE_MILLIS = 2_000L +} + data class ParsedPddScreen( val summary: ProductSummary, val dimensions: List, @@ -137,6 +144,16 @@ data class ParsedPddScreen( } return pageEvidenceMatched && (hasExactText || hasFallbackAtTop) && !hasPrimaryProductEvidence } + + fun isSoldOutForPurchase(): Boolean { + val values = dimensions.flatMap(VisibleDimension::values) + return isTransientSoldOut(exactText = PddSoldOutRecoveryDefaults.EXACT_TEXT) || + (specPanelOpen && values.isNotEmpty() && values.none(VisibleSpecValue::available)) + } + + fun hasPurchaseProductEvidence(): Boolean = + packageMatched && rootAvailable && + (specPanelOpen || specEntry != null || quickConfirmationEntry != null || summary.title != null) } object PddScreenParser { diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt index dc0535e..ecebb41 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt @@ -14,6 +14,7 @@ interface PurchaseUiDriver { fun inputFresh(target: SnapshotNode, value: String): FreshActionResult fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean + fun pullDownGoodsPage(): Boolean = swipePurchase(SwipeDirection.DOWN, 550) fun backPurchase(): Boolean fun bringPddToForeground(): Boolean = false } @@ -60,8 +61,10 @@ class PurchaseRehearsalExecutor( stepChanged(action.type.wireName) val failure = when (action.type) { PurchaseActionType.OPEN_PRODUCT -> openProduct(input, action) - PurchaseActionType.VERIFY_PRODUCT -> verifyProduct() - PurchaseActionType.OPEN_SPEC_PANEL -> openSpecPanel(input, action) + PurchaseActionType.VERIFY_PRODUCT -> verifyProduct(input) + PurchaseActionType.OPEN_SPEC_PANEL -> { + openSpecPanel(input, action) ?: recoverSoldOut(input, closeSpecPanel = true) ?: openSpecPanel(input, action) + } PurchaseActionType.SELECT_SPEC -> if (input.phase == "spec_probe") null else selectSpecs(input, rule) PurchaseActionType.SET_QUANTITY -> if (input.phase == "spec_probe") null else setQuantity(input.quantity) PurchaseActionType.VERIFY_UNIT_PRICE -> if (input.phase == "spec_probe") null else verifyPrice(input).also { @@ -197,16 +200,50 @@ class PurchaseRehearsalExecutor( return failure("PDD_DETAIL_ENTRY_FAILED", "没有进入拼多多商品页面") } - private fun verifyProduct(): PurchaseExecutionOutcome? { + private fun verifyProduct(input: PurchaseExecutionInput): PurchaseExecutionOutcome? { repeat(50) { val snapshot = driver.capture() pageProblem(snapshot)?.let { return it } - if (snapshot.packageName == PDD_PACKAGE && snapshot.nodes.any { it.visible }) return null + if (snapshot.packageName == PDD_PACKAGE && snapshot.nodes.any { it.visible }) { + return recoverSoldOut(input, PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)) + } pause(100) } return failure("PDD_DETAIL_ENTRY_FAILED", "没有进入拼多多商品页面") } + private fun recoverSoldOut( + input: PurchaseExecutionInput, + initial: ParsedPddScreen = currentScreen(input), + closeSpecPanel: Boolean = false, + ): PurchaseExecutionOutcome? { + if (!initial.isSoldOutForPurchase()) return null + if (closeSpecPanel && initial.specPanelOpen) { + if (!driver.backPurchase()) { + return failure("PDD_GOODS_SOLD_OUT", "商品规格均已售罄,规格面板关闭失败") + } + pause(SOLD_OUT_PANEL_CLOSE_MILLIS) + } + repeat(PddSoldOutRecoveryDefaults.PULL_DOWN_COUNT) { index -> + if (!driver.pullDownGoodsPage()) { + return failure("PDD_GOODS_SOLD_OUT", "商品页显示已售罄,页面下拉恢复失败") + } + if (index < PddSoldOutRecoveryDefaults.PULL_DOWN_COUNT - 1) { + pause(PddSoldOutRecoveryDefaults.INTERVAL_MILLIS) + } + } + pause(PddSoldOutRecoveryDefaults.SETTLE_MILLIS) + val recovered = currentScreen(input) + recovered.problem?.let { return failure(it.code, it.message) } + if (recovered.isSoldOutForPurchase()) { + return failure("PDD_GOODS_SOLD_OUT", "商品页下拉恢复后仍显示已售罄") + } + if (!recovered.hasPurchaseProductEvidence()) { + return failure("RULE_NOT_MATCHED", "商品页下拉恢复后页面证据失效") + } + return null + } + private fun openSpecPanel(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? { var screen = currentScreen(input) screen.problem?.let { return failure(it.code, it.message) } @@ -443,6 +480,7 @@ class PurchaseRehearsalExecutor( private const val SPEC_SELECTION_SUCCESS_VERIFY_POLLS = 20 private const val SPEC_SELECTION_FAILED_VERIFY_POLLS = 5 private const val SPEC_SELECTION_POLL_MILLIS = 100L + private const val SOLD_OUT_PANEL_CLOSE_MILLIS = 300L val DEFAULT_COLLECTOR = PddCollectorConfig( collectorId = "pddProductDetailV1", specEntryStrategy = "safeBottomSpecEntryV1", diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt index 3359aa6..12f50ce 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt @@ -177,6 +177,60 @@ class PurchaseRehearsalExecutorTest { assertEquals(50, pauses.size) } + @Test + fun `transient sold out page recovers before opening specs`() { + val driver = FakePurchaseDriver(soldOut = true, recoverSoldOutAfterPull = true) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("rehearsal_completed", outcome.resultType) + assertEquals(2, driver.pullDownCount) + assertTrue(driver.clicked.contains("选择规格")) + } + + @Test + fun `persistent sold out page returns replacement eligible error`() { + val driver = FakePurchaseDriver(soldOut = true) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("PDD_GOODS_SOLD_OUT", outcome.errorCode) + assertEquals(2, driver.pullDownCount) + assertFalse(driver.clicked.contains("选择规格")) + } + + @Test + fun `sold out recovery gesture failure stops immediately`() { + val driver = FakePurchaseDriver(soldOut = true, pullDownSucceeds = false) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("PDD_GOODS_SOLD_OUT", outcome.errorCode) + assertEquals(1, driver.pullDownCount) + } + + @Test + fun `sold out recovery rejects a page that lost product evidence`() { + val driver = FakePurchaseDriver(soldOut = true, loseEvidenceAfterPull = true) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("RULE_NOT_MATCHED", outcome.errorCode) + assertEquals(2, driver.pullDownCount) + assertFalse(driver.clicked.contains("选择规格")) + } + + @Test + fun `all unavailable specs recover on goods page and reopen panel`() { + val driver = FakePurchaseDriver(allSpecsUnavailable = true, recoverSoldOutAfterPull = true) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("rehearsal_completed", outcome.resultType) + assertEquals(2, driver.pullDownCount) + assertEquals(2, driver.clicked.count { it == "选择规格" }) + } + @Test fun `goods id mismatch fails before opening pdd`() { var opened = false @@ -308,6 +362,11 @@ class PurchaseRehearsalExecutorTest { private val openPddOnFailedClick: Boolean = false, private val sizeClickResults: MutableList = mutableListOf(), private val sizeSelectsOnFailedClick: Boolean = false, + soldOut: Boolean = false, + private val recoverSoldOutAfterPull: Boolean = false, + private val pullDownSucceeds: Boolean = true, + private val loseEvidenceAfterPull: Boolean = false, + allSpecsUnavailable: Boolean = false, ) : PurchaseUiDriver { var browser = false var panel = false @@ -318,6 +377,10 @@ class PurchaseRehearsalExecutorTest { var upSwipeCount = 0 var openClickCount = 0 var sizeClickCount = 0 + var pullDownCount = 0 + private var soldOut = soldOut + private var allSpecsUnavailable = allSpecsUnavailable + private var productEvidenceLost = false val clicked = mutableListOf() override fun capture(): UiSnapshot { @@ -328,6 +391,16 @@ class PurchaseRehearsalExecutorTest { return UiSnapshot("com.heytap.browser", "BrowserActivity", openNodes) } if (!panel) { + if (productEvidenceLost) { + return UiSnapshot(PDD, ACTIVITY, listOf(node("content", "", 0, 0, 1080, 2200))) + } + if (soldOut) { + return UiSnapshot(PDD, ACTIVITY, listOf( + node("content", "", 0, 0, 1080, 2200), + node("sold-out", "商品已售罄", 100, 300, 900, 380), + node("similar", "相似商品", 100, 500, 900, 580), + )) + } val nodes = mutableListOf( node("content", "", 0, 0, 1080, 2200), ) @@ -350,11 +423,11 @@ class PurchaseRehearsalExecutorTest { node("scroll/color-heading", "颜色分类", 20, 410, 300, 450, parentPath = "scroll"), ) colors.forEachIndexed { index, value -> - nodes += node("scroll/color-$index", value, 20 + index * 220, 470, 200 + index * 220, 540, clickable = true, selected = color == value, parentPath = "scroll") + nodes += node("scroll/color-$index", value, 20 + index * 220, 470, 200 + index * 220, 540, clickable = true, selected = color == value, enabled = !allSpecsUnavailable, parentPath = "scroll") } nodes += node("scroll/size-heading", "尺码", 20, 650, 300, 690, parentPath = "scroll") val visibleSize = if (upSwipeCount >= hiddenSizeUntilUpSwipes) "XL" else "S" - nodes += node("scroll/size", visibleSize, 20, 710, 200, 780, clickable = true, selected = size == visibleSize, parentPath = "scroll") + nodes += node("scroll/size", visibleSize, 20, 710, 200, 780, clickable = true, selected = size == visibleSize, enabled = !allSpecsUnavailable, parentPath = "scroll") nodes += node("quantity", quantity.toString(), 400, 800, 600, 870, className = "android.widget.EditText") nodes += node("confirm", "确定", 20, 900, 500, 980, clickable = true) nodes += node("order", "提交订单", 20, 1100, 500, 1180, clickable = true) @@ -401,7 +474,24 @@ class PurchaseRehearsalExecutorTest { override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean = swipePurchase(direction, durationMs) - override fun backPurchase(): Boolean = true + override fun pullDownGoodsPage(): Boolean { + pullDownCount++ + if (!pullDownSucceeds) return false + if (recoverSoldOutAfterPull && pullDownCount >= 2) { + soldOut = false + allSpecsUnavailable = false + } + if (loseEvidenceAfterPull && pullDownCount >= 2) { + soldOut = false + productEvidenceLost = true + } + return true + } + + override fun backPurchase(): Boolean { + panel = false + return true + } private fun node( path: String, @@ -413,9 +503,10 @@ class PurchaseRehearsalExecutorTest { clickable: Boolean = false, scrollable: Boolean = false, selected: Boolean = false, + enabled: Boolean = true, className: String = "android.widget.TextView", parentPath: String? = null, - ) = SnapshotNode(path, parentPath, text, null, null, className, NodeBounds(left, top, right, bottom), clickable, scrollable, selected, false, true, true) + ) = SnapshotNode(path, parentPath, text, null, null, className, NodeBounds(left, top, right, bottom), clickable, scrollable, selected, false, enabled, true) } private companion object { diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 596a0b9..48da7b8 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -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: 1e9bf2da1957b31236921cbf572fa8a61d78e275 -synchronized_at: 2026-08-29T08:47:19Z +wiki_revision: 2e474dd5f804c3e754ecc8e8722c9469e424acf0 +synchronized_at: 2026-08-29T09:02:23Z @@ -156,6 +156,7 @@ synchronized_at: 2026-08-29T01:39:44Z - 采购任务领取时同时占用设备租约和可选 PDD 账号租约;租约过期后才可释放并重新领取。设备还存在采集任务时不能领取采购任务。 - 采购规格由服务端按顺序决策:先使用已确认的人工映射;否则仅在同一规格角色的可选 PDD 原始标签中做唯一确定性匹配(繁体转简体、空格/全半角/大小写统一,以及公斤/斤换算);仍无唯一结果才调用已启用的服务端 AI。AI 必须返回候选集中的原始标签,候选不完整、歧义、AI 无结果或服务不可用均明确失败,不派发第二趟、更不创建订单。 - Agent 选择服务端下发的精确颜色或尺码时,只能在已确认打开的规格面板内有限纵向滑动、每次重新读取可选节点并按完整原始文字点击;连续没有新证据或达到上限即停止,不得点击相近规格。第一趟探测并固化规格后,第二趟仍无法精确选择而再次提交探测时,服务端必须明确失败并释放活动槽,保留第一次决策证据,禁止清空决策、循环派发或进入地址与创建订单动作。 +- 采购 Agent 在确认进入 PDD 商品页后、打开规格前,复用采集侧的假售罄识别;规格面板已打开且当前解析到的规格值全部不可选时也按售罄处理。两种情况都只允许关闭规格面板后对商品页执行一次有界恢复(默认下拉 2 次、间隔 1000 毫秒、等待 2000 毫秒),恢复动作失败或恢复后仍售罄时返回 `PDD_GOODS_SOLD_OUT`,恢复后商品页证据丢失时按页面规则不匹配失败;不得继续选择规格、修改地址或创建订单。该失败码继续进入既有替代商品资格判定。 - 规格映射不完整、PDD 档案为待采集或没有规格时,规则必须具有 `purchase.spec-probe.v1`;第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟。Android 不自行匹配或猜测;其只接收服务端已经固化的精确原始规格标签。 - Agent 提交的相同 attempt 最终结果只能写入一次;相同请求重放返回原事实,不同内容拒绝覆盖。`order_result_unknown` 不参与自动派发,只能人工解除。 - 已创建订单默认禁止再次采购;管理员或采购员可以做一次性重新采购授权,新任务创建成功时在同一事务消耗授权,旧任务和旧订单保留。已标记为已支付的订单不能授权或创建重新采购任务。