fix(android): recognize sold-out purchases (#158)

This commit is contained in:
QiuSW
2026-08-29 17:07:24 +08:00
parent 16948a9e94
commit 77a2b2506e
4 changed files with 157 additions and 10 deletions
@@ -91,6 +91,13 @@ data class VisibleSpecValue(
data class VisibleDimension(val key: String, val name: String, val values: List<VisibleSpecValue>)
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<VisibleDimension>,
@@ -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 {
@@ -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",
@@ -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<FreshActionResult> = 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<String>()
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 {
+3 -2
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: 1e9bf2da1957b31236921cbf572fa8a61d78e275
synchronized_at: 2026-08-29T08:47:19Z
wiki_revision: 2e474dd5f804c3e754ecc8e8722c9469e424acf0
synchronized_at: 2026-08-29T09:02:23Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -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` 不参与自动派发,只能人工解除。
- 已创建订单默认禁止再次采购;管理员或采购员可以做一次性重新采购授权,新任务创建成功时在同一事务消耗授权,旧任务和旧订单保留。已标记为已支付的订单不能授权或创建重新采购任务。