From 888cc8b617bd3362626ff2634b153f554777ccfd Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 20 Aug 2026 23:09:42 +0800 Subject: [PATCH] feat(#42): add persistent purchase rehearsal execution --- .../automation/GoAutoAccessibilityService.kt | 48 ++- .../automation/PurchaseRehearsalExecutor.kt | 306 ++++++++++++++++++ .../agent/automation/PurchaseRuleContract.kt | 142 ++++++++ .../goauto/agent/automation/RuleContract.kt | 8 +- .../goauto/agent/network/AgentApiClient.kt | 60 ++++ .../persistence/PurchaseOutboxUploader.kt | 15 + .../agent/persistence/PurchaseTaskStore.kt | 217 +++++++++++++ .../agent/service/AgentForegroundService.kt | 189 +++++++++++ .../agent/PurchaseRehearsalExecutorTest.kt | 257 +++++++++++++++ 9 files changed, 1237 insertions(+), 5 deletions(-) create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRuleContract.kt create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/persistence/PurchaseOutboxUploader.kt create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/persistence/PurchaseTaskStore.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt index fd58128..3e99344 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/GoAutoAccessibilityService.kt @@ -18,7 +18,7 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean -class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollectorDriver { +class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollectorDriver, PurchaseUiDriver { private val activityTracker by lazy { ActivityEvidenceTracker { packageName, className -> isDeclaredActivity(packageName, className) } } @@ -149,6 +149,41 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) FreshActionResult.SUCCESS else FreshActionResult.FAILED } + override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult { + val root = rootInActiveWindow ?: return FreshActionResult.NOT_FOUND + val candidates = mutableListOf() + walk(root) { node -> + val bounds = Rect().also(node::getBoundsInScreen) + if (node.preferredOrDescendantLabel() == target.label && + node.className?.toString() == target.className && + kotlin.math.abs(bounds.centerX() - target.bounds.centerX) <= 32 && + kotlin.math.abs(bounds.centerY() - target.bounds.centerY) <= 32 + ) candidates += node + } + if (candidates.isEmpty()) return FreshActionResult.NOT_FOUND + if (candidates.size != 1) return FreshActionResult.AMBIGUOUS + val args = Bundle().apply { + putCharSequence(AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, value) + } + return if (candidates.single().performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args)) { + FreshActionResult.SUCCESS + } else FreshActionResult.FAILED + } + + override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean { + val root = rootInActiveWindow ?: return false + val candidates = mutableListOf() + walk(root) { node -> if (node.isVisibleToUser && node.isScrollable) candidates += node } + val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT + val directional = candidates.filter { candidate -> + Rect().also(candidate::getBoundsInScreen).let { if (horizontal) it.width() > it.height() else it.height() >= it.width() } + } + 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) + } + override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean { if (anchor == null) return swipe(SemanticTarget.SPEC_PANEL, direction) val root = rootInActiveWindow ?: return false @@ -242,7 +277,12 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto return callbackReceived && completed.get() } - private fun swipeNode(node: AccessibilityNodeInfo, direction: SwipeDirection): Boolean { + private fun swipeNode( + node: AccessibilityNodeInfo, + direction: SwipeDirection, + durationMs: Long = 450, + preferScrollAction: Boolean = true, + ): Boolean { val bounds = Rect().also(node::getBoundsInScreen) if (bounds.width() < 2 || bounds.height() < 2) return false val scrollAction = if (direction == SwipeDirection.UP || direction == SwipeDirection.LEFT) { @@ -250,7 +290,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto } else { AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD } - if (node.performAction(scrollAction)) return true + if (preferScrollAction && node.performAction(scrollAction)) return true if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false val left = bounds.left + bounds.width() * 25 / 100 val right = bounds.left + bounds.width() * 75 / 100 @@ -271,7 +311,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto val completed = AtomicBoolean(false) val latch = CountDownLatch(1) val queued = dispatchGesture( - GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0, 450)).build(), + GestureDescription.Builder().addStroke(GestureDescription.StrokeDescription(path, 0, durationMs)).build(), object : GestureResultCallback() { override fun onCompleted(gestureDescription: GestureDescription?) { completed.set(true) 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 new file mode 100644 index 0000000..e21a38d --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt @@ -0,0 +1,306 @@ +package cn.ilapage.goauto.agent.automation + +import java.net.URI +import java.net.URLDecoder + +interface PurchaseUiDriver { + fun capture(): UiSnapshot + fun clickFresh(target: SnapshotNode): FreshActionResult + fun inputFresh(target: SnapshotNode, value: String): FreshActionResult + fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean +} + +data class PurchaseExecutionInput( + val taskId: Long, + val executionMode: String, + val phase: String, + val url: String, + val goodsId: String, + val mappedColor: String, + val mappedSize: String, + val quantity: Long, + val minUnitPriceCent: Long, + val maxUnitPriceCent: Long, +) + +data class PurchaseExecutionOutcome( + val resultType: String, + val errorCode: String? = null, + val message: String, + val probedSpecs: String? = null, +) + +class PurchaseRehearsalExecutor( + private val driver: PurchaseUiDriver, + private val openLink: (String) -> Boolean, + private val probeSpecs: () -> String?, + private val pause: (Long) -> Unit = Thread::sleep, + private val stepChanged: (String) -> Unit = {}, +) { + fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set): PurchaseExecutionOutcome { + validateBeforeDeviceAction(input, rule, supportedCapabilities)?.let { return it } + var observedPrice: Long? = null + for (action in rule.actions) { + 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.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 { + if (it == null) observedPrice = currentScreen(input).priceCent + } + PurchaseActionType.VERIFY_ORDER_SUMMARY -> if (input.phase == "spec_probe") null else verifySummary(input, observedPrice) + PurchaseActionType.PROBE_SPECS -> { + if (input.phase == "spec_probe") return probeOutcome() + null + } + } + if (failure != null) { + if (failure.errorCode == "PURCHASE_SPEC_NOT_MATCHED" && PurchaseActionType.PROBE_SPECS in rule.actions.map { it.type }) { + return probeOutcome() + } + return failure + } + applyPostAction(action)?.let { return it } + } + if (input.phase == "spec_probe") return failure("PURCHASE_RULE_INVALID", "规格探测任务缺少 probeSpecs 动作") + return PurchaseExecutionOutcome("rehearsal_completed", message = "商品、规格、数量和价格复核完成,已在下单前安全停止") + } + + private fun validateBeforeDeviceAction( + input: PurchaseExecutionInput, + rule: PurchaseRule, + supported: Set, + ): PurchaseExecutionOutcome? { + if (input.executionMode != "rehearsal") return failure("PURCHASE_MODE_NOT_ALLOWED", "当前任务不是安全演练任务") + val missing = rule.requiredCapabilities.filterNot(supported::contains) + if (missing.isNotEmpty()) return failure("AGENT_CAPABILITY_MISMATCH", "当前手机版本不支持这个任务:${missing.joinToString()}") + if (input.quantity !in 1..999 || input.minUnitPriceCent < 0 || input.maxUnitPriceCent < input.minUnitPriceCent) { + return failure("PURCHASE_RULE_INVALID", "任务数量或价格区间无效") + } + if (goodsIdFromUrl(input.url) != input.goodsId || input.goodsId.any { !it.isDigit() }) { + return failure("PDD_GOODS_MISMATCH", "任务商品编号与商品链接不一致") + } + val types = rule.actions.map { it.type } + if (types.distinct().size != types.size) return failure("PURCHASE_RULE_INVALID", "采购规则动作不能重复") + val ordered = listOf( + PurchaseActionType.OPEN_PRODUCT, + PurchaseActionType.VERIFY_PRODUCT, + PurchaseActionType.OPEN_SPEC_PANEL, + PurchaseActionType.SELECT_SPEC, + PurchaseActionType.SET_QUANTITY, + PurchaseActionType.VERIFY_UNIT_PRICE, + PurchaseActionType.VERIFY_ORDER_SUMMARY, + PurchaseActionType.PROBE_SPECS, + ) + if (types.zipWithNext().any { (first, second) -> ordered.indexOf(first) >= ordered.indexOf(second) }) { + return failure("PURCHASE_RULE_INVALID", "采购规则动作顺序无效") + } + val required = if (input.phase == "spec_probe") { + setOf(PurchaseActionType.OPEN_PRODUCT, PurchaseActionType.VERIFY_PRODUCT, PurchaseActionType.OPEN_SPEC_PANEL, PurchaseActionType.PROBE_SPECS) + } else { + setOf( + PurchaseActionType.OPEN_PRODUCT, PurchaseActionType.VERIFY_PRODUCT, PurchaseActionType.OPEN_SPEC_PANEL, + PurchaseActionType.SELECT_SPEC, PurchaseActionType.SET_QUANTITY, PurchaseActionType.VERIFY_UNIT_PRICE, + PurchaseActionType.VERIFY_ORDER_SUMMARY, + ) + } + if (!types.containsAll(required)) return failure("PURCHASE_RULE_INVALID", "采购规则缺少安全演练必要动作") + if (input.phase !in setOf("purchase", "spec_probe")) return failure("PURCHASE_RULE_INVALID", "任务执行阶段无效") + return null + } + + private fun openProduct(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? { + if (!openLink(input.url)) return failure("PDD_LINK_INVALID", "任务中的 PDD 链接无法打开") + val aliases = action.textAliases ?: listOf("打开拼多多APP", "打开拼多多 App", "打开") + repeat(50) { + val snapshot = driver.capture() + pageProblem(snapshot)?.let { return it } + if (snapshot.packageName == PDD_PACKAGE) return null + val candidates = snapshot.nodes.filter { it.visible && it.enabled && it.label in aliases } + if (candidates.size > 1) return failure("RULE_AMBIGUOUS", "打开拼多多按钮不唯一") + if (candidates.size == 1) { + return when (driver.clickFresh(candidates.single())) { + FreshActionResult.SUCCESS -> null + FreshActionResult.AMBIGUOUS -> failure("RULE_AMBIGUOUS", "打开拼多多按钮不唯一") + else -> failure("RULE_ACTION_FAILED", "打开拼多多失败") + } + } + pause(100) + } + return failure("PDD_DETAIL_ENTRY_FAILED", "没有进入拼多多商品页面") + } + + private fun verifyProduct(): PurchaseExecutionOutcome? { + repeat(50) { + val snapshot = driver.capture() + pageProblem(snapshot)?.let { return it } + if (snapshot.packageName == PDD_PACKAGE && snapshot.nodes.any { it.visible }) return null + pause(100) + } + return failure("PDD_DETAIL_ENTRY_FAILED", "没有进入拼多多商品页面") + } + + private fun openSpecPanel(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? { + var screen = currentScreen(input) + screen.problem?.let { return failure(it.code, it.message) } + if (screen.specPanelOpen) return null + val aliases = action.textAliases + val candidates = if (aliases == null) listOfNotNull(screen.specEntry, screen.quickConfirmationEntry) else { + screen.sourceNodes.filter { it.visible && it.enabled && it.label in aliases } + } + if (candidates.size > 1) return failure("RULE_AMBIGUOUS", "规格入口匹配到多个控件") + val target = candidates.singleOrNull() ?: return failure("RULE_NOT_MATCHED", "没有找到商品规格入口") + when (driver.clickFresh(target)) { + FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "规格入口匹配到多个控件") + FreshActionResult.SUCCESS -> Unit + else -> return failure("RULE_ACTION_FAILED", "商品规格入口点击失败") + } + repeat(30) { + screen = currentScreen(input) + screen.problem?.let { return failure(it.code, it.message) } + if (screen.specPanelOpen) return null + pause(100) + } + return failure("RULE_NOT_MATCHED", "商品规格面板没有打开") + } + + private fun selectSpecs(input: PurchaseExecutionInput, rule: PurchaseRule): PurchaseExecutionOutcome? { + if (input.mappedColor.isBlank() && input.mappedSize.isBlank()) { + return failure("PURCHASE_SPEC_NOT_MATCHED", "没有下发可用的商品规格") + } + listOf("color" to input.mappedColor, "size" to input.mappedSize).forEach { (dimension, target) -> + if (target.isBlank()) return@forEach + val screen = currentScreen(input) + screen.problem?.let { return failure(it.code, it.message) } + val values = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }.filter { it.text == target && it.available } + if (values.isEmpty()) return failure("PURCHASE_SPEC_NOT_MATCHED", "没有找到规格:$target") + if (values.size != 1) return failure("RULE_AMBIGUOUS", "规格 $target 匹配到多个控件") + when (driver.clickFresh(values.single().node)) { + FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "规格 $target 匹配到多个控件") + FreshActionResult.SUCCESS -> Unit + else -> return failure("RULE_ACTION_FAILED", "规格 $target 选择失败") + } + var selected = false + repeat(20) { + val refreshed = currentScreen(input) + refreshed.problem?.let { return failure(it.code, it.message) } + selected = refreshed.selectedSummary?.contains(target) == true || + refreshed.dimensions.filter { it.key == dimension }.flatMap { it.values } + .any { it.text == target && (it.node.selected || it.node.checked) } + if (selected) return@repeat + pause(100) + } + if (!selected) return failure("PURCHASE_SPEC_NOT_MATCHED", "规格 $target 未能精确选中") + } + if (rule.requiredCapabilities.none { it == PurchaseAgentCapabilities.REHEARSAL_V1 }) { + return failure("AGENT_CAPABILITY_MISMATCH", "规则缺少采购演练能力") + } + return null + } + + private fun setQuantity(quantity: Long): PurchaseExecutionOutcome? { + val snapshot = driver.capture() + pageProblem(snapshot)?.let { return it } + val inputs = snapshot.nodes.filter { it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label.toLongOrNull() != null } + if (inputs.size > 1) return failure("RULE_AMBIGUOUS", "商品数量输入框不唯一") + val current = inputs.singleOrNull()?.label?.toLongOrNull() ?: 1L + if (current == quantity) return null + val input = inputs.singleOrNull() + if (input != null && driver.inputFresh(input, quantity.toString()) == FreshActionResult.SUCCESS) { + if (readQuantity() == quantity) return null + } + val increase = quantity > current + val aliases = if (increase) setOf("增加数量", "+") else setOf("减少数量", "-") + val buttons = snapshot.nodes.filter { it.visible && it.enabled && it.label in aliases } + if (buttons.size != 1) return failure(if (buttons.size > 1) "RULE_AMBIGUOUS" else "RULE_NOT_MATCHED", "数量调整按钮不唯一或不存在") + repeat(kotlin.math.abs(quantity - current).toInt()) { + when (driver.clickFresh(buttons.single())) { + FreshActionResult.SUCCESS -> pause(100) + FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "数量调整按钮不唯一") + else -> return failure("RULE_ACTION_FAILED", "商品数量调整失败") + } + } + return if (readQuantity() == quantity) null else failure("PURCHASE_QUANTITY_MISMATCH", "商品数量没有调整到 $quantity") + } + + private fun verifyPrice(input: PurchaseExecutionInput): PurchaseExecutionOutcome? { + val screen = currentScreen(input) + screen.problem?.let { return failure(it.code, it.message) } + val price = screen.priceCent ?: return failure("RULE_NOT_MATCHED", "没有读取到商品单价") + return if (price in input.minUnitPriceCent..input.maxUnitPriceCent) null + else failure("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围") + } + + private fun verifySummary(input: PurchaseExecutionInput, observedPrice: Long?): PurchaseExecutionOutcome? { + val screen = currentScreen(input) + screen.problem?.let { return failure(it.code, it.message) } + val selected = listOf(input.mappedColor, input.mappedSize).filter(String::isNotBlank) + if (selected.any { screen.selectedSummary?.contains(it) != true }) { + return failure("PURCHASE_SPEC_NOT_MATCHED", "最终规格复核失败") + } + if (readQuantity() != input.quantity) return failure("PURCHASE_QUANTITY_MISMATCH", "最终数量复核失败") + val price = screen.priceCent ?: observedPrice ?: return failure("RULE_NOT_MATCHED", "最终价格复核失败") + if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) { + return failure("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围") + } + return null + } + + private fun applyPostAction(action: PurchaseAction): PurchaseExecutionOutcome? { + if (action.waitAfterMs > 0) pause(action.waitAfterMs) + action.swipeAfter?.let { swipe -> + repeat(swipe.count) { index -> + if (!driver.swipePurchase(swipe.direction, swipe.durationMs)) { + return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败") + } + if (index < swipe.count - 1 && swipe.intervalMs > 0) pause(swipe.intervalMs) + } + } + return null + } + + private fun probeOutcome(): PurchaseExecutionOutcome { + stepChanged("probeSpecs") + val result = probeSpecs() ?: return failure("PURCHASE_SPEC_NOT_MATCHED", "商品规格探测失败") + return PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = result) + } + + private fun currentScreen(input: PurchaseExecutionInput): ParsedPddScreen = + PddScreenParser.parse(driver.capture(), DEFAULT_COLLECTOR, input.goodsId, null) + + private fun pageProblem(snapshot: UiSnapshot): PurchaseExecutionOutcome? = + PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, snapshot.nodes.filter { it.visible }.map { it.label }) + ?.let { failure(it.code, it.message) } + + private fun readQuantity(): Long? = driver.capture().nodes.singleOrNull { + it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label.toLongOrNull() != null + }?.label?.toLongOrNull() + + private fun goodsIdFromUrl(raw: String): String? = runCatching { + val uri = URI(raw) + if (uri.scheme !in setOf("http", "https") || !(uri.host == "yangkeduo.com" || uri.host?.endsWith(".yangkeduo.com") == true)) return@runCatching null + uri.rawQuery?.split("&")?.mapNotNull { part -> + val pair = part.split("=", limit = 2) + if (URLDecoder.decode(pair[0], "UTF-8") == "goods_id") URLDecoder.decode(pair.getOrElse(1) { "" }, "UTF-8") else null + }?.singleOrNull() + }.getOrNull() + + private fun failure(code: String, message: String) = PurchaseExecutionOutcome("failed", code, message) + + companion object { + private const val PDD_PACKAGE = "com.xunmeng.pinduoduo" + val DEFAULT_COLLECTOR = PddCollectorConfig( + collectorId = "pddProductDetailV1", + specEntryStrategy = "safeBottomSpecEntryV1", + priceParser = "pddRmbPriceV1", + priceGranularity = "color", + colorAliases = listOf("颜色分类", "颜色", "花色", "款式"), + sizeAliases = listOf("尺码", "尺寸", "规格", "型号"), + timeoutsMs = mapOf("page" to 30_000, "specPanel" to 10_000, "selection" to 2_000, "price" to 2_000, "overall" to 180_000), + limits = mapOf("goodsPageVerticalSwipes" to 3, "specHorizontalSwipes" to 12, "specVerticalSwipes" to 12, "stableEdgeReads" to 2, "stablePriceReads" to 2, "maxSkuCount" to 500), + ) + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRuleContract.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRuleContract.kt new file mode 100644 index 0000000..a54feff --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRuleContract.kt @@ -0,0 +1,142 @@ +package cn.ilapage.goauto.agent.automation + +import org.json.JSONArray +import org.json.JSONObject + +enum class PurchaseActionType(val wireName: String) { + OPEN_PRODUCT("openProduct"), + VERIFY_PRODUCT("verifyProduct"), + OPEN_SPEC_PANEL("openSpecPanel"), + SELECT_SPEC("selectSpec"), + SET_QUANTITY("setQuantity"), + VERIFY_UNIT_PRICE("verifyUnitPrice"), + VERIFY_ORDER_SUMMARY("verifyOrderSummary"), + PROBE_SPECS("probeSpecs"); + + companion object { + fun fromWire(value: String): PurchaseActionType? = entries.firstOrNull { it.wireName == value } + } +} + +data class PurchaseSwipePlan( + val direction: SwipeDirection, + val count: Int, + val durationMs: Long, + val intervalMs: Long, +) + +data class PurchaseAction( + val type: PurchaseActionType, + val textAliases: List?, + val waitAfterMs: Long, + val swipeAfter: PurchaseSwipePlan?, +) + +data class PurchaseRule( + val requiredCapabilities: List, + val actions: List, +) + +object PurchaseAgentCapabilities { + const val REHEARSAL_V1 = "purchase.rehearsal.v1" + const val SPEC_PROBE_V1 = "purchase.spec-probe.v1" + + val supported = setOf(REHEARSAL_V1, SPEC_PROBE_V1) +} + +object PurchaseRuleParser { + private val swipeActions = setOf( + PurchaseActionType.OPEN_PRODUCT, + PurchaseActionType.OPEN_SPEC_PANEL, + PurchaseActionType.SELECT_SPEC, + PurchaseActionType.PROBE_SPECS, + ) + private val forbiddenText = listOf( + "pay", "payment", "支付", "付款", "免密", "修改地址", "收货地址", "创建订单", "提交订单", "订单号", + ) + + fun parse(raw: String): PurchaseRule { + val root = try { + JSONObject(raw) + } catch (_: Exception) { + invalid("采购规则不是有效的 JSON 对象") + } + rejectUnknown(root, setOf("schemaVersion", "ruleType", "requiredCapabilities", "actions"), "采购规则") + if (root.optInt("schemaVersion", -1) != 1 || root.optString("ruleType") != "pddPurchase") { + invalid("采购规则必须声明 schemaVersion=1、ruleType=pddPurchase") + } + val capabilities = parseCapabilities(root.optJSONArray("requiredCapabilities")) + if (PurchaseAgentCapabilities.REHEARSAL_V1 !in capabilities) { + invalid("演练规则缺少能力 ${PurchaseAgentCapabilities.REHEARSAL_V1}") + } + val items = root.optJSONArray("actions") ?: invalid("actions 必须是非空数组") + if (items.length() !in 1..64) invalid("actions 必须包含 1..64 个动作") + val actions = (0 until items.length()).map { index -> parseAction(items, index) } + if (actions.any { it.type == PurchaseActionType.PROBE_SPECS } && PurchaseAgentCapabilities.SPEC_PROBE_V1 !in capabilities) { + invalid("probeSpecs 缺少能力 ${PurchaseAgentCapabilities.SPEC_PROBE_V1}") + } + return PurchaseRule(capabilities, actions) + } + + private fun parseCapabilities(items: JSONArray?): List { + items ?: invalid("requiredCapabilities 必填") + if (items.length() !in 1..32) invalid("requiredCapabilities 必须包含 1..32 项") + return (0 until items.length()).map { index -> + items.optString(index).trim().also { + if (!Regex("^[a-z][a-z0-9.-]{0,79}$").matches(it)) invalid("能力标识无效: $it") + } + }.also { if (it.distinct().size != it.size) invalid("requiredCapabilities 不能重复") } + } + + private fun parseAction(items: JSONArray, index: Int): PurchaseAction { + val item = items.optJSONObject(index) ?: invalid("actions[$index] 必须是对象") + rejectUnknown(item, setOf("type", "textAliases", "waitAfterMs", "swipeAfter"), "actions[$index]") + val wireType = item.optString("type") + if (wireType.equals("pay", true) || wireType.lowercase().contains("payment")) { + throw RuleValidationException("PURCHASE_PAYMENT_FORBIDDEN", "系统禁止自动付款") + } + val type = PurchaseActionType.fromWire(wireType) + ?: throw RuleValidationException("PURCHASE_RULE_INVALID", "采购规则包含不支持或非演练动作: $wireType") + val aliases = if (item.has("textAliases") && !item.isNull("textAliases")) { + val values = item.optJSONArray("textAliases") ?: invalid("actions[$index].textAliases 必须是数组") + if (values.length() !in 1..16) invalid("actions[$index].textAliases 必须包含 1..16 项") + (0 until values.length()).map { aliasIndex -> + val alias = values.optString(aliasIndex) + if (alias.isEmpty() || alias.trim() != alias || alias.codePointCount(0, alias.length) > 64) { + invalid("actions[$index].textAliases[$aliasIndex] 无效") + } + val lower = alias.lowercase() + if (forbiddenText.any(lower::contains)) { + throw RuleValidationException("PURCHASE_PAYMENT_FORBIDDEN", "文字候选包含地址、下单或支付操作") + } + alias + }.also { if (it.distinct().size != it.size) invalid("actions[$index].textAliases 不能重复") } + } else null + val waitAfterMs = if (item.has("waitAfterMs") && !item.isNull("waitAfterMs")) item.optLong("waitAfterMs", -1) else 0 + if (waitAfterMs !in 0..30_000) invalid("actions[$index].waitAfterMs 必须为 0..30000") + if (item.has("swipeAfter") && !item.isNull("swipeAfter") && item.optJSONObject("swipeAfter") == null) { + invalid("actions[$index].swipeAfter 必须是对象") + } + val swipe = item.optJSONObject("swipeAfter")?.let { value -> + if (type !in swipeActions) invalid("动作 ${type.wireName} 不支持 swipeAfter") + rejectUnknown(value, setOf("direction", "count", "durationMs", "intervalMs"), "actions[$index].swipeAfter") + val direction = runCatching { SwipeDirection.valueOf(value.optString("direction").uppercase()) } + .getOrElse { invalid("swipeAfter.direction 只支持 up、down、left、right") } + val count = value.optInt("count", -1) + val duration = value.optLong("durationMs", -1) + val interval = value.optLong("intervalMs", 0) + if (count !in 1..10) invalid("swipeAfter.count 必须为 1..10") + if (duration !in 100..2_000) invalid("swipeAfter.durationMs 必须为 100..2000") + if (interval !in 0..5_000) invalid("swipeAfter.intervalMs 必须为 0..5000") + PurchaseSwipePlan(direction, count, duration, interval) + } + return PurchaseAction(type, aliases, waitAfterMs, swipe) + } + + private fun rejectUnknown(value: JSONObject, allowed: Set, label: String) { + val unknown = value.keys().asSequence().filterNot(allowed::contains).toList() + if (unknown.isNotEmpty()) invalid("$label 包含未知字段: ${unknown.joinToString()}") + } + + private fun invalid(message: String): Nothing = throw RuleValidationException("PURCHASE_RULE_INVALID", message) +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleContract.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleContract.kt index c69b017..3fc17e1 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleContract.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/RuleContract.kt @@ -92,7 +92,13 @@ object AgentCapabilities { const val SWIPE_V1 = "action.swipe.v1" const val PDD_PRODUCT_DETAIL_V1 = "collector.pdd.product-detail.v1" - val supported: List = listOf(SCHEMA_V2, SWIPE_V1, PDD_PRODUCT_DETAIL_V1) + val supported: List = listOf( + SCHEMA_V2, + SWIPE_V1, + PDD_PRODUCT_DETAIL_V1, + PurchaseAgentCapabilities.REHEARSAL_V1, + PurchaseAgentCapabilities.SPEC_PROBE_V1, + ) } object RuleParser { diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt index 5d9a59e..ae45489 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt @@ -43,6 +43,26 @@ data class AgentTask( val status: String, ) +data class PurchaseAgentTask( + val taskId: Long, + val taskAttemptId: String, + val phase: String, + val executionMode: String, + val status: String, + val pddProductId: Long, + val pddUrl: String, + val pddGoodsId: String, + val mappedColor: String, + val mappedSize: String, + val quantity: Long, + val minUnitPriceCent: Long, + val maxUnitPriceCent: Long, + val currency: String, + val ruleSnapshot: String, + val ruleSnapshotHash: String, + val leaseVersion: Long, +) + class AgentApiException( val status: Int, val code: String, @@ -119,6 +139,26 @@ class AgentApiClient(private val serverUrl: String) { requireNotNull(request("POST", "/api/agent/v1/tasks/$taskId/fail", payload, token)) } + fun nextPurchaseTask(token: String): PurchaseAgentTask? { + val response = request("GET", "/api/agent/v1/purchase-tasks/next", null, token) ?: return null + return purchaseTask(response.getJSONObject("data")) + } + + fun claimPurchaseTask(taskId: Long, requestId: String, token: String): PurchaseAgentTask { + val payload = JSONObject().put("requestId", requestId) + return purchaseTask(requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/claim", payload, token)).getJSONObject("data")) + } + + fun startPurchaseTask(taskId: Long, requestId: String, token: String): PurchaseAgentTask { + val payload = JSONObject().put("requestId", requestId) + return purchaseTask(requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/start", payload, token)).getJSONObject("data")) + } + + fun submitPurchaseResult(taskId: Long, payloadJson: String, token: String) { + val payload = JSONObject(payloadJson) + requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/result", payload, token)) + } + private fun task(data: JSONObject) = AgentTask( taskId = data.getLong("taskId"), pddProductId = data.getLong("pddProductId"), @@ -131,6 +171,26 @@ class AgentApiClient(private val serverUrl: String) { status = data.getString("status"), ) + private fun purchaseTask(data: JSONObject) = PurchaseAgentTask( + taskId = data.getLong("taskId"), + taskAttemptId = data.optString("taskAttemptId"), + phase = data.optString("phase"), + executionMode = data.getString("executionMode"), + status = data.getString("status"), + pddProductId = data.getLong("pddProductId"), + pddUrl = data.getString("pddUrl"), + pddGoodsId = data.getString("pddGoodsId"), + mappedColor = data.optString("mappedColor"), + mappedSize = data.optString("mappedSize"), + quantity = data.getLong("quantity"), + minUnitPriceCent = data.getLong("minUnitPriceCent"), + maxUnitPriceCent = data.getLong("maxUnitPriceCent"), + currency = data.getString("currency"), + ruleSnapshot = data.getJSONObject("ruleSnapshot").toString(), + ruleSnapshotHash = data.optString("ruleSnapshotHash"), + leaseVersion = data.getLong("leaseVersion"), + ) + private fun post(path: String, payload: JSONObject, token: String?): JSONObject { return requireNotNull(request("POST", path, payload, token)) } diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/PurchaseOutboxUploader.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/PurchaseOutboxUploader.kt new file mode 100644 index 0000000..d735f30 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/PurchaseOutboxUploader.kt @@ -0,0 +1,15 @@ +package cn.ilapage.goauto.agent.persistence + +/** Uploads already-persisted results only; it never invokes device automation. */ +class PurchaseOutboxUploader( + private val pending: () -> List, + private val submit: (PendingPurchaseOutbox) -> Unit, + private val markUploaded: (PendingPurchaseOutbox) -> Unit, +) { + fun flush() { + pending().forEach { item -> + submit(item) + markUploaded(item) + } + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/PurchaseTaskStore.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/PurchaseTaskStore.kt new file mode 100644 index 0000000..b678c48 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/PurchaseTaskStore.kt @@ -0,0 +1,217 @@ +package cn.ilapage.goauto.agent.persistence + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteOpenHelper + +data class InterruptedPurchaseAttempt( + val taskId: Long, + val attemptId: String, + val ruleSnapshotHash: String, +) + +data class PendingPurchaseOutbox( + val id: Long, + val taskId: Long, + val attemptId: String, + val requestId: String, + val payloadJson: String, +) + +class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { + override fun onCreate(db: SQLiteDatabase) { + db.execSQL( + """CREATE TABLE purchase_task ( + task_id INTEGER PRIMARY KEY, + attempt_id TEXT NOT NULL, + rule_snapshot_hash TEXT NOT NULL, + current_step TEXT NOT NULL, + status TEXT NOT NULL, + result_json TEXT, + upload_status TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )""".trimIndent(), + ) + db.execSQL( + """CREATE TABLE purchase_attempt ( + attempt_id TEXT PRIMARY KEY, + task_id INTEGER NOT NULL, + rule_snapshot_hash TEXT NOT NULL, + current_step TEXT NOT NULL, + status TEXT NOT NULL, + result_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )""".trimIndent(), + ) + db.execSQL( + """CREATE TABLE purchase_outbox ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + attempt_id TEXT NOT NULL, + request_id TEXT NOT NULL UNIQUE, + payload_json TEXT NOT NULL, + upload_status TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )""".trimIndent(), + ) + db.execSQL("CREATE INDEX idx_purchase_outbox_pending ON purchase_outbox(upload_status, id)") + db.execSQL("CREATE INDEX idx_purchase_attempt_task ON purchase_attempt(task_id, created_at)") + } + + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit + + @Synchronized + fun recordRunning(taskId: Long, attemptId: String, ruleSnapshotHash: String) { + require(taskId > 0 && attemptId.isNotBlank() && ruleSnapshotHash.matches(Regex("^[0-9a-f]{64}$"))) + val now = System.currentTimeMillis() + writableDatabase.beginTransaction() + try { + writableDatabase.insertWithOnConflict( + "purchase_task", + null, + ContentValues().apply { + put("task_id", taskId) + put("attempt_id", attemptId) + put("rule_snapshot_hash", ruleSnapshotHash) + put("current_step", "started") + put("status", STATUS_RUNNING) + put("upload_status", UPLOAD_NONE) + put("created_at", now) + put("updated_at", now) + }, + SQLiteDatabase.CONFLICT_REPLACE, + ) + writableDatabase.insertWithOnConflict( + "purchase_attempt", + null, + ContentValues().apply { + put("attempt_id", attemptId) + put("task_id", taskId) + put("rule_snapshot_hash", ruleSnapshotHash) + put("current_step", "started") + put("status", STATUS_RUNNING) + put("created_at", now) + put("updated_at", now) + }, + SQLiteDatabase.CONFLICT_IGNORE, + ) + writableDatabase.setTransactionSuccessful() + } finally { + writableDatabase.endTransaction() + } + } + + @Synchronized + fun updateStep(taskId: Long, attemptId: String, step: String) { + val now = System.currentTimeMillis() + val values = ContentValues().apply { put("current_step", step.take(80)); put("updated_at", now) } + writableDatabase.update("purchase_task", values, "task_id=? AND attempt_id=? AND status=?", arrayOf(taskId.toString(), attemptId, STATUS_RUNNING)) + writableDatabase.update("purchase_attempt", values, "attempt_id=? AND status=?", arrayOf(attemptId, STATUS_RUNNING)) + } + + /** Saves the final result and its upload request atomically. */ + @Synchronized + fun completeAndEnqueue(taskId: Long, attemptId: String, requestId: String, payloadJson: String) { + require(requestId.isNotBlank() && payloadJson.isNotBlank()) + val now = System.currentTimeMillis() + writableDatabase.beginTransaction() + try { + val result = ContentValues().apply { + put("current_step", "submit_result") + put("status", STATUS_COMPLETED) + put("result_json", payloadJson) + put("updated_at", now) + } + check(writableDatabase.update("purchase_attempt", result, "attempt_id=?", arrayOf(attemptId)) == 1) { + "本地采购 attempt 不存在" + } + val taskResult = ContentValues(result).apply { put("upload_status", UPLOAD_PENDING) } + check(writableDatabase.update("purchase_task", taskResult, "task_id=? AND attempt_id=?", arrayOf(taskId.toString(), attemptId)) == 1) { + "本地采购任务不存在" + } + val outbox = ContentValues().apply { + put("task_id", taskId) + put("attempt_id", attemptId) + put("request_id", requestId) + put("payload_json", payloadJson) + put("upload_status", UPLOAD_PENDING) + put("created_at", now) + put("updated_at", now) + } + check(writableDatabase.insertWithOnConflict("purchase_outbox", null, outbox, SQLiteDatabase.CONFLICT_IGNORE) != -1L) { + "采购结果 Outbox 保存失败" + } + writableDatabase.setTransactionSuccessful() + } finally { + writableDatabase.endTransaction() + } + } + + @Synchronized + fun interruptedAttempts(): List = readableDatabase.query( + "purchase_task", + arrayOf("task_id", "attempt_id", "rule_snapshot_hash"), + "status=? AND upload_status=?", + arrayOf(STATUS_RUNNING, UPLOAD_NONE), + null, + null, + "updated_at ASC", + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add(InterruptedPurchaseAttempt(cursor.getLong(0), cursor.getString(1), cursor.getString(2))) + } + } + } + + @Synchronized + fun pendingOutbox(): List = readableDatabase.query( + "purchase_outbox", + arrayOf("id", "task_id", "attempt_id", "request_id", "payload_json"), + "upload_status=?", + arrayOf(UPLOAD_PENDING), + null, + null, + "id ASC", + ).use { cursor -> + buildList { + while (cursor.moveToNext()) { + add(PendingPurchaseOutbox(cursor.getLong(0), cursor.getLong(1), cursor.getString(2), cursor.getString(3), cursor.getString(4))) + } + } + } + + @Synchronized + fun activeTaskId(): Long? = readableDatabase.rawQuery( + "SELECT task_id FROM purchase_task WHERE status=? OR upload_status=? ORDER BY updated_at ASC LIMIT 1", + arrayOf(STATUS_RUNNING, UPLOAD_PENDING), + ).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null } + + @Synchronized + fun markUploaded(outboxId: Long, taskId: Long, attemptId: String) { + val now = System.currentTimeMillis() + writableDatabase.beginTransaction() + try { + val uploaded = ContentValues().apply { put("upload_status", UPLOAD_SENT); put("updated_at", now) } + check(writableDatabase.update("purchase_outbox", uploaded, "id=? AND upload_status=?", arrayOf(outboxId.toString(), UPLOAD_PENDING)) == 1) + check(writableDatabase.update("purchase_task", uploaded, "task_id=? AND attempt_id=?", arrayOf(taskId.toString(), attemptId)) == 1) + writableDatabase.setTransactionSuccessful() + } finally { + writableDatabase.endTransaction() + } + } + + companion object { + private const val DATABASE_NAME = "goauto_purchase.db" + private const val DATABASE_VERSION = 1 + private const val STATUS_RUNNING = "running" + private const val STATUS_COMPLETED = "completed" + private const val UPLOAD_NONE = "none" + private const val UPLOAD_PENDING = "pending" + private const val UPLOAD_SENT = "sent" + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index eb0619d..e367dab 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -25,6 +25,15 @@ import cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService import cn.ilapage.goauto.agent.automation.PddLinkLauncher import cn.ilapage.goauto.agent.automation.PddDetailEntryRunner import cn.ilapage.goauto.agent.automation.PddProductDetailCollector +import cn.ilapage.goauto.agent.automation.PageEvidence +import cn.ilapage.goauto.agent.automation.NodeSelector +import cn.ilapage.goauto.agent.automation.CollectionRule +import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities +import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput +import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome +import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor +import cn.ilapage.goauto.agent.automation.PurchaseRule +import cn.ilapage.goauto.agent.automation.PurchaseRuleParser import cn.ilapage.goauto.agent.automation.RuleExecutor import cn.ilapage.goauto.agent.automation.RuleParser import cn.ilapage.goauto.agent.automation.RuleValidationException @@ -32,7 +41,12 @@ import cn.ilapage.goauto.agent.identity.SecureDeviceStore import cn.ilapage.goauto.agent.network.AgentApiClient import cn.ilapage.goauto.agent.network.AgentApiException import cn.ilapage.goauto.agent.network.DeviceInfo +import cn.ilapage.goauto.agent.network.PurchaseAgentTask import cn.ilapage.goauto.agent.network.ServerUrlPolicy +import cn.ilapage.goauto.agent.persistence.PurchaseTaskStore +import cn.ilapage.goauto.agent.persistence.PurchaseOutboxUploader +import org.json.JSONArray +import org.json.JSONObject import java.util.concurrent.Executors import java.util.concurrent.ExecutorService import java.util.concurrent.ScheduledExecutorService @@ -51,6 +65,7 @@ class AgentForegroundService : Service() { private lateinit var identityStore: SecureDeviceStore private lateinit var settingsStore: AgentSettingsStore private lateinit var stateStore: AgentStateStore + private lateinit var purchaseStore: PurchaseTaskStore private lateinit var connectivityManager: ConnectivityManager private val networkCallback = object : ConnectivityManager.NetworkCallback() { @@ -64,6 +79,8 @@ class AgentForegroundService : Service() { identityStore = SecureDeviceStore(this) settingsStore = AgentSettingsStore(this) stateStore = AgentStateStore(this) + purchaseStore = PurchaseTaskStore(this) + runningTaskId.set(purchaseStore.activeTaskId()) connectivityManager = getSystemService(ConnectivityManager::class.java) createNotificationChannel() startForeground(NOTIFICATION_ID, notification("正在启动")) @@ -81,6 +98,7 @@ class AgentForegroundService : Service() { runCatching { connectivityManager.unregisterNetworkCallback(networkCallback) } executor.shutdownNow() taskExecutor.shutdownNow() + purchaseStore.close() super.onDestroy() } @@ -119,6 +137,16 @@ class AgentForegroundService : Service() { } val activeCredentials = credentials ?: error("设备尚未取得认证凭据") + if (taskMutex.currentTaskId() == null) { + recoverInterruptedPurchases() + flushPurchaseOutbox(api, activeCredentials.token) + runningTaskId.set(purchaseStore.activeTaskId()) + if (runningTaskId.get() == null) { + api.nextPurchaseTask(activeCredentials.token)?.takeIf { it.status == "running" }?.let { + runningTaskId.set(it.taskId) + } + } + } val heartbeat = api.heartbeat( activeCredentials.token, currentTaskId = runningTaskId.get(), @@ -161,6 +189,13 @@ class AgentForegroundService : Service() { private fun scheduleTask(api: AgentApiClient, token: String) { if (taskMutex.currentTaskId() != null) return + recoverInterruptedPurchases() + flushPurchaseOutbox(api, token) + val purchaseTask = api.nextPurchaseTask(token) + if (purchaseTask != null) { + schedulePurchaseTask(api, purchaseTask, token) + return + } val task = api.nextTask(token) ?: return if (!taskMutex.tryAcquire(task.taskId)) return if (task.status == "running") runningTaskId.set(task.taskId) @@ -174,6 +209,160 @@ class AgentForegroundService : Service() { } } + private fun schedulePurchaseTask(api: AgentApiClient, task: PurchaseAgentTask, token: String) { + if (!taskMutex.tryAcquire(task.taskId)) return + runningTaskId.set(task.taskId) + taskExecutor.execute { + try { + executePurchaseTask(api, task, token) + } finally { + taskMutex.release(task.taskId) + runningTaskId.set(purchaseStore.activeTaskId()) + } + } + } + + private fun executePurchaseTask(api: AgentApiClient, initial: PurchaseAgentTask, token: String) { + val wakeLock = acquireTaskWakeLock() + try { + val claimed = if (initial.status == "pending") { + api.claimPurchaseTask(initial.taskId, UUID.randomUUID().toString(), token) + } else initial + val task = if (claimed.status == "pending") { + api.startPurchaseTask(claimed.taskId, UUID.randomUUID().toString(), token) + } else claimed + check(task.status == "running" && task.taskAttemptId.isNotBlank()) { "采购任务没有有效 attempt" } + val snapshotHashValid = task.ruleSnapshotHash.matches(Regex("^[0-9a-f]{64}$")) + val snapshotHash = task.ruleSnapshotHash.takeIf { snapshotHashValid } ?: "0".repeat(64) + purchaseStore.recordRunning(task.taskId, task.taskAttemptId, snapshotHash) + stateStore.update("BUSY", "正在安全演练采购任务 #${task.taskId}", tokenStored = true) + updateNotification("采购演练 #${task.taskId}") + + val outcome = if (!snapshotHashValid) { + PurchaseExecutionOutcome("failed", "PURCHASE_RULE_INVALID", "采购规则快照哈希无效") + } else { + var parseFailure: PurchaseExecutionOutcome? = null + val parsedRule: PurchaseRule? = try { + PurchaseRuleParser.parse(task.ruleSnapshot) + } catch (error: RuleValidationException) { + parseFailure = PurchaseExecutionOutcome("failed", error.code, error.message ?: "采购规则不可用") + null + } + if (parsedRule == null) { + requireNotNull(parseFailure) + } else { + val accessibility = GoAutoAccessibilityService.instance + if (accessibility == null) { + PurchaseExecutionOutcome("failed", "ACCESSIBILITY_NOT_READY", "GoAuto 无障碍服务未开启") + } else { + PurchaseRehearsalExecutor( + driver = accessibility, + openLink = { PddLinkLauncher(this).open(it) }, + probeSpecs = { collectPurchaseProbe(accessibility, task) }, + stepChanged = { step -> purchaseStore.updateStep(task.taskId, task.taskAttemptId, step) }, + ).execute( + PurchaseExecutionInput( + taskId = task.taskId, + executionMode = task.executionMode, + phase = task.phase, + url = task.pddUrl, + goodsId = task.pddGoodsId, + mappedColor = task.mappedColor, + mappedSize = task.mappedSize, + quantity = task.quantity, + minUnitPriceCent = task.minUnitPriceCent, + maxUnitPriceCent = task.maxUnitPriceCent, + ), + parsedRule, + PurchaseAgentCapabilities.supported, + ) + } + } + } + val requestId = UUID.randomUUID().toString() + val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome) + purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload) + flushPurchaseOutbox(api, token) + val message = if (outcome.resultType == "failed") "${outcome.errorCode}:${outcome.message}" else outcome.message + stateStore.update(if (outcome.resultType == "failed") "TASK_ERROR" else "ONLINE", message, tokenStored = true) + updateNotification(if (outcome.resultType == "failed") "采购演练 #${task.taskId} 失败" else "采购演练 #${task.taskId} 已提交") + } catch (error: AgentApiException) { + stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true) + } catch (error: Exception) { + stateStore.update("TASK_ERROR", error.message ?: "采购演练执行异常", tokenStored = true) + } finally { + if (wakeLock.isHeld) wakeLock.release() + } + } + + private fun collectPurchaseProbe(accessibility: GoAutoAccessibilityService, task: PurchaseAgentTask): String? { + val snapshot = accessibility.capture() + val activity = snapshot.activityName ?: return null + val rule = CollectionRule( + schemaVersion = 2, + steps = emptyList(), + ruleType = "pddProductDetail", + pageEvidence = PageEvidence("com.xunmeng.pinduoduo", activity, NodeSelector()), + collector = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, + ) + val result = PddProductDetailCollector(accessibility).collect(task.pddGoodsId, rule) + val payload = result.payload ?: return null + return JSONObject() + .put("goodsId", task.pddGoodsId) + .put("status", payload.status) + .put("dimensions", JSONArray().apply { + payload.dimensions.forEach { dimension -> + put(JSONObject().put("key", dimension.key).put("name", dimension.name).put("values", JSONArray(dimension.values))) + } + }) + .put("colorPrices", JSONArray().apply { + payload.colorPrices.forEach { price -> put(JSONObject().put("color", price.color).put("priceCent", price.priceCent)) } + }) + .put("missing", JSONArray(payload.missing)) + .toString() + } + + private fun recoverInterruptedPurchases() { + purchaseStore.interruptedAttempts().forEach { interrupted -> + val requestId = UUID.randomUUID().toString() + val outcome = PurchaseExecutionOutcome( + "failed", + "AGENT_RESTARTED_DURING_EXECUTION", + "手机服务在演练执行中重启,已停止任务且不会重复操作拼多多", + ) + purchaseStore.completeAndEnqueue( + interrupted.taskId, + interrupted.attemptId, + requestId, + purchaseResultPayload(requestId, interrupted.attemptId, outcome), + ) + } + } + + private fun flushPurchaseOutbox(api: AgentApiClient, token: String) { + PurchaseOutboxUploader( + pending = purchaseStore::pendingOutbox, + submit = { item -> api.submitPurchaseResult(item.taskId, item.payloadJson, token) }, + markUploaded = { item -> purchaseStore.markUploaded(item.id, item.taskId, item.attemptId) }, + ).flush() + runningTaskId.set(purchaseStore.activeTaskId()) + } + + private fun purchaseResultPayload(requestId: String, attemptId: String, outcome: PurchaseExecutionOutcome): String = + JSONObject() + .put("requestId", requestId) + .put("taskAttemptId", attemptId) + .put("resultType", outcome.resultType) + .apply { + if (outcome.resultType == "failed") { + put("errorCode", outcome.errorCode ?: "AGENT_EXECUTION_ERROR") + put("errorMessage", outcome.message.take(1000)) + } + outcome.probedSpecs?.let { put("probedSpecs", JSONObject(it)) } + } + .toString() + + private fun executeTask(api: AgentApiClient, initialTask: cn.ilapage.goauto.agent.network.AgentTask, token: String) { val wakeLock = acquireTaskWakeLock() try { 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 new file mode 100644 index 0000000..72d3494 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt @@ -0,0 +1,257 @@ +package cn.ilapage.goauto.agent + +import cn.ilapage.goauto.agent.automation.FreshActionResult +import cn.ilapage.goauto.agent.automation.NodeBounds +import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities +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.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.PurchaseOutboxUploader +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PurchaseRehearsalExecutorTest { + @Test + fun `rule parameters drive aliases waits and bounded swipes without dangerous clicks`() { + val driver = FakePurchaseDriver() + val pauses = mutableListOf() + var openCount = 0 + val outcome = PurchaseRehearsalExecutor( + driver, + openLink = { openCount++; driver.browser = true; true }, + probeSpecs = { null }, + pause = pauses::add, + ).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("rehearsal_completed", outcome.resultType) + assertEquals(1, openCount) + assertEquals(2, driver.swipeCount) + assertEquals(2L, driver.quantity) + assertTrue(driver.clicked.containsAll(listOf("打开", "选择规格", "黑色", "XL"))) + assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") }) + assertTrue(pauses.contains(700)) + } + + @Test + fun `unsupported capability fails before opening pdd`() { + val driver = FakePurchaseDriver() + var openCount = 0 + val unsupported = rule().replace( + "\"purchase.rehearsal.v1\",\"purchase.spec-probe.v1\"", + "\"purchase.rehearsal.v1\",\"purchase.spec-probe.v1\",\"purchase.future.v1\"", + ) + val outcome = PurchaseRehearsalExecutor(driver, { openCount++; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(unsupported), PurchaseAgentCapabilities.supported) + assertEquals("AGENT_CAPABILITY_MISMATCH", outcome.errorCode) + assertEquals(0, openCount) + assertTrue(driver.clicked.isEmpty()) + } + + @Test + fun `missing exact spec returns probe and never chooses similar value`() { + val driver = FakePurchaseDriver(colors = listOf("黑色加绒")) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals("spec_probe_completed", outcome.resultType) + assertEquals("{\"dimensions\":[]}", outcome.probedSpecs) + assertFalse(driver.clicked.contains("黑色加绒")) + } + + @Test + fun `price outside range fails before any order action`() { + val driver = FakePurchaseDriver(priceCent = 4_000) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals("PURCHASE_PRICE_OUT_OF_RANGE", outcome.errorCode) + assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") }) + } + + @Test + fun `ambiguous browser target stops safely`() { + val driver = FakePurchaseDriver(duplicateOpen = true) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals("RULE_AMBIGUOUS", outcome.errorCode) + assertTrue(driver.clicked.isEmpty()) + } + + @Test + fun `goods id mismatch fails before opening pdd`() { + var opened = false + val outcome = PurchaseRehearsalExecutor(FakePurchaseDriver(), { opened = true; true }, { null }, pause = {}) + .execute(input().copy(goodsId = "999"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + assertEquals("PDD_GOODS_MISMATCH", outcome.errorCode) + assertFalse(opened) + } + + @Test + fun `parser keeps type only compatibility and rejects dangerous or unknown fields`() { + val legacy = """{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"openProduct"}]}""" + assertEquals(0, PurchaseRuleParser.parse(legacy).actions.single().waitAfterMs) + listOf( + """{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"createOrder"}]}""", + """{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"openProduct","selector":{}}]}""", + """{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.rehearsal.v1"],"actions":[{"type":"openProduct","textAliases":["立即支付"]}]}""", + ).forEach { raw -> + var rejected = false + try { + PurchaseRuleParser.parse(raw) + } catch (_: RuleValidationException) { + rejected = true + } + assertTrue("expected rejection for $raw", rejected) + } + } + + @Test + fun `outbox retry resends the same saved result and never creates another result`() { + val item = PendingPurchaseOutbox(1, 42, "attempt-1", "request-1", "{\"resultType\":\"rehearsal_completed\"}") + var uploaded = false + val submitted = mutableListOf() + var failFirst = true + val uploader = PurchaseOutboxUploader( + pending = { if (uploaded) emptyList() else listOf(item) }, + submit = { + submitted += it + if (failFirst) { + failFirst = false + error("offline") + } + }, + markUploaded = { uploaded = true }, + ) + runCatching(uploader::flush) + assertFalse(uploaded) + uploader.flush() + uploader.flush() + assertTrue(uploaded) + assertEquals(listOf("request-1", "request-1"), submitted.map { it.requestId }) + assertEquals(1, submitted.map { it.payloadJson }.distinct().size) + } + + private fun input() = PurchaseExecutionInput( + taskId = 42, + executionMode = "rehearsal", + phase = "purchase", + url = "https://mobile.yangkeduo.com/goods.html?goods_id=719834019024", + goodsId = "719834019024", + mappedColor = "黑色", + mappedSize = "XL", + quantity = 2, + minUnitPriceCent = 1_000, + maxUnitPriceCent = 3_000, + ) + + private fun rule() = """{ + "schemaVersion":1, + "ruleType":"pddPurchase", + "requiredCapabilities":["purchase.rehearsal.v1","purchase.spec-probe.v1"], + "actions":[ + {"type":"openProduct","textAliases":["打开拼多多APP","打开"],"waitAfterMs":700}, + {"type":"verifyProduct"}, + {"type":"openSpecPanel","textAliases":["选择规格"],"swipeAfter":{"direction":"up","count":2,"durationMs":500,"intervalMs":1000}}, + {"type":"selectSpec"}, + {"type":"setQuantity"}, + {"type":"verifyUnitPrice"}, + {"type":"verifyOrderSummary"}, + {"type":"probeSpecs"} + ] + }""" + + private class FakePurchaseDriver( + private val colors: List = listOf("黑色"), + private val priceCent: Long = 2_000, + private val duplicateOpen: Boolean = false, + ) : PurchaseUiDriver { + var browser = false + var panel = false + var color: String? = null + var size: String? = null + var quantity = 1L + var swipeCount = 0 + val clicked = mutableListOf() + + override fun capture(): UiSnapshot { + if (browser && !panel && color == null && size == null) { + val openNodes = mutableListOf(node("open", "打开", 0, 100, 300, 180, clickable = true)) + if (duplicateOpen) openNodes += node("open2", "打开", 400, 100, 700, 180, clickable = true) + openNodes += node("content", "", 0, 0, 1080, 2200) + return UiSnapshot("com.heytap.browser", "BrowserActivity", openNodes) + } + if (!panel) { + return UiSnapshot(PDD, ACTIVITY, listOf( + node("content", "", 0, 0, 1080, 2200), + node("spec", "选择规格", 20, 1000, 900, 1100, clickable = true), + )) + } + val nodes = mutableListOf( + node("content", "", 0, 0, 1080, 2200), + node("price", "¥${priceCent / 100}.${(priceCent % 100).toString().padStart(2, '0')}", 20, 300, 300, 360), + node("selected", "已选 ${color.orEmpty()} ${size.orEmpty()}", 20, 365, 700, 395), + node("title", "确认款式", 20, 396, 300, 430), + node("scroll", "", 0, 400, 1080, 950, scrollable = true), + 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/size-heading", "尺码", 20, 650, 300, 690, parentPath = "scroll") + nodes += node("scroll/size", "XL", 20, 710, 200, 780, clickable = true, selected = size == "XL", 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) + nodes += node("pay", "立即支付", 520, 1100, 1020, 1180, clickable = true) + return UiSnapshot(PDD, ACTIVITY, nodes) + } + + override fun clickFresh(target: SnapshotNode): FreshActionResult { + clicked += target.label + when (target.label) { + "打开" -> browser = false + "选择规格" -> panel = true + "XL" -> size = "XL" + in colors -> color = target.label + "增加数量" -> quantity++ + "减少数量" -> quantity-- + } + return FreshActionResult.SUCCESS + } + + override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult { + quantity = value.toLong() + return FreshActionResult.SUCCESS + } + + override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean { + swipeCount++ + return true + } + + private fun node( + path: String, + text: String, + left: Int, + top: Int, + right: Int, + bottom: Int, + clickable: Boolean = false, + scrollable: Boolean = false, + selected: Boolean = false, + 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) + } + + private companion object { + const val PDD = "com.xunmeng.pinduoduo" + const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity" + } +}