From 6a0dfed7576014137b1edc27a2dbe16caf5773bf Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 21 Aug 2026 11:06:26 +0800 Subject: [PATCH] feat(#36): add guarded Android live purchase --- android/app/build.gradle.kts | 4 +- .../automation/GoAutoAccessibilityService.kt | 2 + .../automation/PurchaseLiveAutomation.kt | 220 ++++++++++++++++++ .../automation/PurchaseRehearsalExecutor.kt | 55 ++++- .../agent/automation/PurchaseRuleContract.kt | 32 ++- .../goauto/agent/automation/RuleContract.kt | 3 + .../goauto/agent/network/AgentApiClient.kt | 7 + .../agent/persistence/PurchaseTaskStore.kt | 57 ++++- .../agent/service/AgentForegroundService.kt | 51 +++- .../agent/PurchaseLiveAutomationTest.kt | 106 +++++++++ .../agent/PurchaseRehearsalExecutorTest.kt | 17 ++ docs/00-project-profile.md | 8 +- docs/02-architecture-and-code-map.md | 7 +- docs/03-business-rules-and-glossary.md | 17 +- docs/08-agent-api-contract.md | 14 +- 15 files changed, 549 insertions(+), 51 deletions(-) create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b966d8b..7857470 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "cn.ilapage.goauto.agent" minSdk = 23 targetSdk = 34 - versionCode = 1 - versionName = "0.1.0" + versionCode = 2 + versionName = "0.2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 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 3e99344..7c296b1 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 @@ -184,6 +184,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto return swipeNode(target, direction, durationMs, preferScrollAction = false) } + override fun backPurchase(): Boolean = performGlobalAction(GLOBAL_ACTION_BACK) + override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?): Boolean { if (anchor == null) return swipe(SemanticTarget.SPEC_PANEL, direction) val root = rootInActiveWindow ?: return false diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt new file mode 100644 index 0000000..c73b576 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseLiveAutomation.kt @@ -0,0 +1,220 @@ +package cn.ilapage.goauto.agent.automation + +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +data class ShippingAddressProof internal constructor( + internal val expectedAddress: String, + val suffix: String, +) + +data class FinalConfirmationEvidence( + val goodsId: String, + val mappedColor: String, + val mappedSize: String, + val quantity: Long, + val unitPriceCent: Long, + val addressSuffix: String, + val activityName: String, +) + +data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String) + +class PurchaseLiveException(val code: String, message: String) : IllegalStateException(message) + +/** + * The only Android component allowed to mutate a shipping address or click the + * final order button. It never exposes payment actions and never persists the + * address or accessibility tree. + */ +class PurchaseLiveAutomation( + private val driver: PurchaseUiDriver, + private val pause: (Long) -> Unit = Thread::sleep, +) { + private var submitAttempted = false + fun updateShippingAddress(addressSuffix: String): ShippingAddressProof { + if (!addressSuffix.matches(Regex("^_cg[1-9][0-9]*$"))) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "采购任务的地址标记无效") + var snapshot = driver.capture() + repeat(4) { attempt -> + pageProblem(snapshot) + val entries = uniqueClickable(snapshot, snapshot.nodes.filter { it.visible && MASKED_PHONE.containsMatchIn(it.label) }) + if (entries.size == 1) { + click(entries.single(), "收货地址入口") + snapshot = waitFor { it.nodes.any { node -> node.visible && node.label.replace(" ", "") == "收货地址" } } + return editAndVerifyAddress(snapshot, addressSuffix) + } + if (entries.size > 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "收货地址入口不唯一,未创建订单") + if (attempt < 3 && !driver.swipePurchase(SwipeDirection.DOWN, 350)) { + fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到收货地址入口,未创建订单") + } + pause(500) + snapshot = driver.capture() + } + fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到收货地址入口,未创建订单") + } + + fun finalConfirmation(input: PurchaseExecutionInput, address: ShippingAddressProof): FinalConfirmationEvidence { + val snapshot = driver.capture() + pageProblem(snapshot) + if (snapshot.packageName != PDD_PACKAGE) fail("PURCHASE_CONFIRMATION_LOST", "最终提交前页面已经变化,禁止创建订单") + if (snapshot.nodes.none { it.visible && it.label.contains(address.expectedAddress) }) { + fail("PURCHASE_ADDRESS_UPDATE_FAILED", "收货地址保存后复核失败,未创建订单") + } + val submit = finalSubmitTargets(snapshot) + if (submit.size != 1) fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "创建订单按钮不是唯一目标,禁止创建订单") + val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, input.goodsId, null) + val selected = listOf(input.mappedColor, input.mappedSize).filter(String::isNotBlank) + if (selected.any { value -> screen.selectedSummary?.contains(value) != true && snapshot.nodes.none { it.visible && it.label.contains(value) } }) { + fail("PURCHASE_SPEC_NOT_MATCHED", "创建订单前规格复核失败") + } + val price = screen.priceCent ?: fail("RULE_NOT_MATCHED", "创建订单前没有读取到商品单价") + if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) fail("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围") + 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", "创建订单前数量复核失败") + return FinalConfirmationEvidence(input.goodsId, input.mappedColor, input.mappedSize, input.quantity, price, address.suffix, snapshot.activityName.orEmpty()) + } + + fun submitOrderOnce() { + if (submitAttempted) fail("PURCHASE_SUBMIT_ALREADY_ATTEMPTED", "本次采购已经尝试创建订单,禁止再次点击") + val snapshot = driver.capture() + pageProblem(snapshot) + val targets = finalSubmitTargets(snapshot) + if (snapshot.packageName != PDD_PACKAGE || targets.size != 1) { + fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "最终页面或创建订单按钮已变化,禁止创建订单") + } + submitAttempted = true + when (driver.clickFresh(targets.single())) { + FreshActionResult.SUCCESS -> Unit + FreshActionResult.AMBIGUOUS -> fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "创建订单按钮不唯一") + else -> fail("PURCHASE_ORDER_RESULT_UNKNOWN", "创建订单点击结果不明确,请人工检查") + } + } + + /** Only reads the current PDD order/payment flow; the sole navigation is one safe Back. */ + fun readOrderResult(): PurchaseOrderEvidence? { + val labels = linkedSetOf() + var backedOutOfPayment = false + repeat(60) { index -> + val snapshot = driver.capture() + snapshot.nodes.filter { it.visible }.mapTo(labels) { it.label } + parseOrderEvidence(labels)?.let { return it } + val paymentVisible = labels.any { label -> PAYMENT_MARKERS.any(label::contains) } + if (paymentVisible && !backedOutOfPayment) { + backedOutOfPayment = true + driver.backPurchase() + pause(500) + } else if (index > 0 && index % 15 == 0) { + driver.swipePurchase(SwipeDirection.UP, 400) + } + pause(200) + } + return null + } + + private fun editAndVerifyAddress(panel: UiSnapshot, suffix: String): ShippingAddressProof { + val modify = addressModifyTargets(panel) + if (modify.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到唯一的地址修改按钮,未创建订单") + click(modify.single(), "修改地址") + val edit = waitFor { snapshot -> snapshot.nodes.any { it.visible && it.label.replace(" ", "").contains("详细地址") } } + val editors = edit.nodes.filter { it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label.isNotBlank() } + if (editors.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到唯一的详细地址输入框,未创建订单") + val current = editors.single().label.trim() + val body = current.split(Regex("[-_]"), limit = 2).firstOrNull()?.trim().orEmpty() + if (body.isBlank()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "当前详细地址无效,未创建订单") + val expected = body + suffix + if (driver.inputFresh(editors.single(), expected) != FreshActionResult.SUCCESS) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址修改失败,未创建订单") + val stable = waitFor { snapshot -> snapshot.nodes.count { it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label == expected } == 1 } + 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(), "保存地址") + var saved = waitFor { snapshot -> + snapshot.nodes.any { it.visible && it.label.contains(expected) } || finalSubmitTargets(snapshot).size == 1 + } + if (finalSubmitTargets(saved).size != 1) { + if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单") + saved = waitFor { finalSubmitTargets(it).size == 1 } + } + if (saved.nodes.none { it.visible && it.label.contains(expected) }) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后回读不一致,未创建订单") + return ShippingAddressProof(expected, suffix) + } + + private fun waitFor(predicate: (UiSnapshot) -> Boolean): UiSnapshot { + repeat(50) { + val snapshot = driver.capture() + pageProblem(snapshot) + if (predicate(snapshot)) return snapshot + pause(200) + } + fail("PURCHASE_ADDRESS_UPDATE_FAILED", "页面切换超时,未创建订单") + } + + private fun uniqueClickable(snapshot: UiSnapshot, nodes: List): List { + val byPath = snapshot.nodes.associateBy { it.path } + return nodes.mapNotNull { source -> + var node: SnapshotNode? = source + while (node != null && !node.clickable) node = node.parentPath?.let(byPath::get) + node + }.distinctBy { it.path } + } + + private fun addressModifyTargets(snapshot: UiSnapshot): List { + val all = uniqueClickable(snapshot, snapshot.nodes.filter { it.visible && it.label == "修改" }) + if (all.size <= 1) return all + val defaults = snapshot.nodes.filter { it.visible && it.label == "已设默认" } + if (defaults.size != 1) return emptyList() + var ancestor = defaults.single().parentPath + while (ancestor != null) { + val currentAncestor = ancestor + val inDefaultCard = all.filter { it.path == currentAncestor || it.path.startsWith("$currentAncestor/") } + if (inDefaultCard.size == 1) return inDefaultCard + ancestor = snapshot.nodes.firstOrNull { it.path == currentAncestor }?.parentPath + } + return emptyList() + } + + private fun click(node: SnapshotNode, label: String) { + when (driver.clickFresh(node)) { + FreshActionResult.SUCCESS -> Unit + FreshActionResult.AMBIGUOUS -> fail("RULE_AMBIGUOUS", "$label 不唯一") + else -> fail("RULE_ACTION_FAILED", "$label 点击失败") + } + pause(500) + } + + private fun finalSubmitTargets(snapshot: UiSnapshot): List = uniqueClickable( + snapshot, + snapshot.nodes.filter { node -> node.visible && node.enabled && FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) } }, + ) + + private fun pageProblem(snapshot: UiSnapshot) { + val labels = snapshot.nodes.filter { it.visible }.map { it.label } + PddPageClassifier.classify(snapshot.packageName, snapshot.activityName, labels)?.let { fail(it.code, it.message) } + if (labels.any { label -> PAYMENT_MARKERS.any(label::contains) }) fail("PURCHASE_PAYMENT_FORBIDDEN", "系统禁止自动付款") + } + + private fun parseOrderEvidence(labels: Collection): PurchaseOrderEvidence? { + val text = labels.joinToString(" ") + val orderNumbers = ORDER_NO.findAll(text).map { it.groupValues[1] }.distinct().toList() + if (orderNumbers.size != 1 || labels.none { label -> UNPAID_MARKERS.any(label::contains) || PAYMENT_MARKERS.any(label::contains) }) return null + val rawTime = ORDER_TIME.find(text)?.groupValues?.get(1) ?: return null + val normalized = rawTime.replace('年', '-').replace('月', '-').replace("日", "").replace('/', '-').replace('.', '-') + val parsed = listOf("yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm").firstNotNullOfOrNull { pattern -> + runCatching { SimpleDateFormat(pattern, Locale.ROOT).apply { isLenient = false; timeZone = TimeZone.getTimeZone("Asia/Shanghai") }.parse(normalized) }.getOrNull() + } ?: return null + val output = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ROOT).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(parsed) + return PurchaseOrderEvidence(orderNumbers.single(), output) + } + + private fun fail(code: String, message: String): Nothing = throw PurchaseLiveException(code, message) + + private companion object { + const val PDD_PACKAGE = "com.xunmeng.pinduoduo" + val MASKED_PHONE = Regex("(? String?, private val pause: (Long) -> Unit = Thread::sleep, private val stepChanged: (String) -> Unit = {}, + private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") }, ) { fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set): PurchaseExecutionOutcome { validateBeforeDeviceAction(input, rule, supportedCapabilities)?.let { return it } var observedPrice: Long? = null + var addressProof: ShippingAddressProof? = null + var irreversibleStarted = false + val live = PurchaseLiveAutomation(driver, pause) for (action in rule.actions) { stepChanged(action.type.wireName) val failure = when (action.type) { @@ -56,6 +64,28 @@ class PurchaseRehearsalExecutor( if (input.phase == "spec_probe") return probeOutcome() null } + PurchaseActionType.UPDATE_SHIPPING_ADDRESS -> try { + addressProof = live.updateShippingAddress(input.addressSuffix) + null + } catch (error: PurchaseLiveException) { + failure(error.code, error.message ?: "收货地址修改失败,未创建订单") + } + PurchaseActionType.CREATE_ORDER -> try { + val proof = addressProof ?: throw PurchaseLiveException("PURCHASE_ADDRESS_UPDATE_FAILED", "收货地址尚未完成验证,未创建订单") + val evidence = live.finalConfirmation(input, proof) + beforeOrderSubmit(evidence) + irreversibleStarted = true + live.submitOrderOnce() + null + } catch (error: PurchaseLiveException) { + if (irreversibleStarted) PurchaseExecutionOutcome("order_result_unknown", "PURCHASE_ORDER_RESULT_UNKNOWN", "无法确认订单是否创建,请人工检查") + else failure(error.code, error.message ?: "创建订单前检查失败") + } + PurchaseActionType.READ_ORDER_RESULT -> { + if (!irreversibleStarted) failure("PURCHASE_RULE_INVALID", "尚未进入创建订单边界") + else live.readOrderResult()?.let { PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = it.orderNo, orderSubmittedAt = it.submittedAt) } + ?: PurchaseExecutionOutcome("order_result_unknown", "PURCHASE_ORDER_RESULT_UNKNOWN", "无法确认订单是否创建,请人工检查") + } } if (failure != null) { if (failure.errorCode == "PURCHASE_SPEC_NOT_MATCHED" && PurchaseActionType.PROBE_SPECS in rule.actions.map { it.type }) { @@ -66,7 +96,8 @@ class PurchaseRehearsalExecutor( applyPostAction(action)?.let { return it } } if (input.phase == "spec_probe") return failure("PURCHASE_RULE_INVALID", "规格探测任务缺少 probeSpecs 动作") - return PurchaseExecutionOutcome("rehearsal_completed", message = "商品、规格、数量和价格复核完成,已在下单前安全停止") + return if (input.executionMode == "rehearsal") PurchaseExecutionOutcome("rehearsal_completed", message = "商品、规格、数量和价格复核完成,已在下单前安全停止") + else failure("PURCHASE_RULE_INVALID", "正式采购规则缺少核单动作") } private fun validateBeforeDeviceAction( @@ -74,7 +105,9 @@ class PurchaseRehearsalExecutor( rule: PurchaseRule, supported: Set, ): PurchaseExecutionOutcome? { - if (input.executionMode != "rehearsal") return failure("PURCHASE_MODE_NOT_ALLOWED", "当前任务不是安全演练任务") + if (input.executionMode !in setOf("rehearsal", "live")) return failure("PURCHASE_MODE_NOT_ALLOWED", "当前任务模式不可用") + val baseCapability = if (input.executionMode == "live") PurchaseAgentCapabilities.LIVE_V1 else PurchaseAgentCapabilities.REHEARSAL_V1 + if (baseCapability !in rule.requiredCapabilities) return failure("AGENT_CAPABILITY_MISMATCH", "采购规则与任务模式不一致") 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) { @@ -93,19 +126,24 @@ class PurchaseRehearsalExecutor( PurchaseActionType.SET_QUANTITY, PurchaseActionType.VERIFY_UNIT_PRICE, PurchaseActionType.VERIFY_ORDER_SUMMARY, - PurchaseActionType.PROBE_SPECS, + PurchaseActionType.UPDATE_SHIPPING_ADDRESS, + PurchaseActionType.CREATE_ORDER, + PurchaseActionType.READ_ORDER_RESULT, ) - if (types.zipWithNext().any { (first, second) -> ordered.indexOf(first) >= ordered.indexOf(second) }) { + if (types.filterNot { it == PurchaseActionType.PROBE_SPECS }.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( + buildSet { + addAll(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 (input.executionMode == "live") addAll(setOf(PurchaseActionType.UPDATE_SHIPPING_ADDRESS, PurchaseActionType.CREATE_ORDER, PurchaseActionType.READ_ORDER_RESULT)) + } } if (!types.containsAll(required)) return failure("PURCHASE_RULE_INVALID", "采购规则缺少安全演练必要动作") if (input.phase !in setOf("purchase", "spec_probe")) return failure("PURCHASE_RULE_INVALID", "任务执行阶段无效") @@ -195,8 +233,9 @@ class PurchaseRehearsalExecutor( } if (!selected) return failure("PURCHASE_SPEC_NOT_MATCHED", "规格 $target 未能精确选中") } - if (rule.requiredCapabilities.none { it == PurchaseAgentCapabilities.REHEARSAL_V1 }) { - return failure("AGENT_CAPABILITY_MISMATCH", "规则缺少采购演练能力") + val requiredCapability = if (input.executionMode == "live") PurchaseAgentCapabilities.LIVE_V1 else PurchaseAgentCapabilities.REHEARSAL_V1 + if (rule.requiredCapabilities.none { it == requiredCapability }) { + return failure("AGENT_CAPABILITY_MISMATCH", "规则缺少当前采购模式能力") } return null } 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 index a54feff..0a33c05 100644 --- 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 @@ -11,7 +11,10 @@ enum class PurchaseActionType(val wireName: String) { SET_QUANTITY("setQuantity"), VERIFY_UNIT_PRICE("verifyUnitPrice"), VERIFY_ORDER_SUMMARY("verifyOrderSummary"), - PROBE_SPECS("probeSpecs"); + PROBE_SPECS("probeSpecs"), + UPDATE_SHIPPING_ADDRESS("updateShippingAddress"), + CREATE_ORDER("createOrder"), + READ_ORDER_RESULT("readOrderResult"); companion object { fun fromWire(value: String): PurchaseActionType? = entries.firstOrNull { it.wireName == value } @@ -39,9 +42,12 @@ data class PurchaseRule( object PurchaseAgentCapabilities { const val REHEARSAL_V1 = "purchase.rehearsal.v1" + const val LIVE_V1 = "purchase.live.v1" const val SPEC_PROBE_V1 = "purchase.spec-probe.v1" + const val ADDRESS_UPDATE_V1 = "purchase.address-update.v1" + const val ORDER_CREATE_V1 = "purchase.order-create.v1" - val supported = setOf(REHEARSAL_V1, SPEC_PROBE_V1) + val supported = setOf(REHEARSAL_V1, LIVE_V1, SPEC_PROBE_V1, ADDRESS_UPDATE_V1, ORDER_CREATE_V1) } object PurchaseRuleParser { @@ -66,15 +72,31 @@ object PurchaseRuleParser { invalid("采购规则必须声明 schemaVersion=1、ruleType=pddPurchase") } val capabilities = parseCapabilities(root.optJSONArray("requiredCapabilities")) - if (PurchaseAgentCapabilities.REHEARSAL_V1 !in capabilities) { - invalid("演练规则缺少能力 ${PurchaseAgentCapabilities.REHEARSAL_V1}") - } + val live = PurchaseAgentCapabilities.LIVE_V1 in capabilities + val rehearsal = PurchaseAgentCapabilities.REHEARSAL_V1 in capabilities + if (live == rehearsal) invalid("采购规则必须且只能声明一种执行模式能力") 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}") } + actions.forEach { action -> + when (action.type) { + PurchaseActionType.UPDATE_SHIPPING_ADDRESS -> { + if (!live || PurchaseAgentCapabilities.ADDRESS_UPDATE_V1 !in capabilities) invalid("修改地址动作缺少正式采购能力") + if (action.textAliases != null || action.waitAfterMs != 0L || action.swipeAfter != null) invalid("修改地址动作不支持参数化") + } + PurchaseActionType.CREATE_ORDER -> { + if (!live || PurchaseAgentCapabilities.ORDER_CREATE_V1 !in capabilities) invalid("创建订单动作缺少正式采购能力") + if (action.textAliases != null || action.waitAfterMs != 0L || action.swipeAfter != null) invalid("创建订单动作不支持参数化") + } + PurchaseActionType.READ_ORDER_RESULT -> { + if (!live || action.textAliases != null || action.waitAfterMs != 0L || action.swipeAfter != null) invalid("核单动作只允许用于无参数正式采购规则") + } + else -> Unit + } + } return PurchaseRule(capabilities, actions) } 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 3fc17e1..c1112db 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 @@ -97,7 +97,10 @@ object AgentCapabilities { SWIPE_V1, PDD_PRODUCT_DETAIL_V1, PurchaseAgentCapabilities.REHEARSAL_V1, + PurchaseAgentCapabilities.LIVE_V1, PurchaseAgentCapabilities.SPEC_PROBE_V1, + PurchaseAgentCapabilities.ADDRESS_UPDATE_V1, + PurchaseAgentCapabilities.ORDER_CREATE_V1, ) } 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 ae45489..cc298b9 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 @@ -58,6 +58,7 @@ data class PurchaseAgentTask( val minUnitPriceCent: Long, val maxUnitPriceCent: Long, val currency: String, + val addressSuffix: String, val ruleSnapshot: String, val ruleSnapshotHash: String, val leaseVersion: Long, @@ -154,6 +155,11 @@ class AgentApiClient(private val serverUrl: String) { return purchaseTask(requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/start", payload, token)).getJSONObject("data")) } + fun markPurchaseOrderSubmitStarted(taskId: Long, requestId: String, token: String): PurchaseAgentTask { + val payload = JSONObject().put("requestId", requestId) + return purchaseTask(requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/order-submit-started", 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)) @@ -186,6 +192,7 @@ class AgentApiClient(private val serverUrl: String) { minUnitPriceCent = data.getLong("minUnitPriceCent"), maxUnitPriceCent = data.getLong("maxUnitPriceCent"), currency = data.getString("currency"), + addressSuffix = data.optString("addressSuffix"), ruleSnapshot = data.getJSONObject("ruleSnapshot").toString(), ruleSnapshotHash = data.optString("ruleSnapshotHash"), leaseVersion = data.getLong("leaseVersion"), 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 index b678c48..b77c516 100644 --- 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 @@ -9,6 +9,9 @@ data class InterruptedPurchaseAttempt( val taskId: Long, val attemptId: String, val ruleSnapshotHash: String, + val status: String, + val orderSubmitRequestId: String?, + val finalConfirmationJson: String?, ) data class PendingPurchaseOutbox( @@ -31,7 +34,10 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N result_json TEXT, upload_status TEXT NOT NULL, created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL + updated_at INTEGER NOT NULL, + order_submit_request_id TEXT, + final_confirmation_json TEXT, + irreversible_at INTEGER )""".trimIndent(), ) db.execSQL( @@ -62,7 +68,13 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N 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 + override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + if (oldVersion < 2) { + db.execSQL("ALTER TABLE purchase_task ADD COLUMN order_submit_request_id TEXT") + db.execSQL("ALTER TABLE purchase_task ADD COLUMN final_confirmation_json TEXT") + db.execSQL("ALTER TABLE purchase_task ADD COLUMN irreversible_at INTEGER") + } + } @Synchronized fun recordRunning(taskId: Long, attemptId: String, ruleSnapshotHash: String) { @@ -113,6 +125,32 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N writableDatabase.update("purchase_attempt", values, "attempt_id=? AND status=?", arrayOf(attemptId, STATUS_RUNNING)) } + /** Persists the irreversible boundary before the server marker and final click. */ + @Synchronized + fun markOrderSubmitStarted(taskId: Long, attemptId: String, requestId: String, finalConfirmationJson: String) { + require(requestId.isNotBlank() && finalConfirmationJson.isNotBlank()) + val now = System.currentTimeMillis() + writableDatabase.beginTransaction() + try { + val values = ContentValues().apply { + put("current_step", STATUS_ORDER_SUBMIT_STARTED) + put("status", STATUS_ORDER_SUBMIT_STARTED) + put("order_submit_request_id", requestId) + put("final_confirmation_json", finalConfirmationJson) + put("irreversible_at", now) + put("updated_at", now) + } + check(writableDatabase.update("purchase_task", values, "task_id=? AND attempt_id=? AND status=?", arrayOf(taskId.toString(), attemptId, STATUS_RUNNING)) == 1) { + "本地采购任务不在可提交状态" + } + val attempt = ContentValues().apply { put("current_step", STATUS_ORDER_SUBMIT_STARTED); put("updated_at", now) } + check(writableDatabase.update("purchase_attempt", attempt, "attempt_id=? AND status=?", arrayOf(attemptId, STATUS_RUNNING)) == 1) + writableDatabase.setTransactionSuccessful() + } finally { + writableDatabase.endTransaction() + } + } + /** Saves the final result and its upload request atomically. */ @Synchronized fun completeAndEnqueue(taskId: Long, attemptId: String, requestId: String, payloadJson: String) { @@ -154,16 +192,16 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N @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), + arrayOf("task_id", "attempt_id", "rule_snapshot_hash", "status", "order_submit_request_id", "final_confirmation_json"), + "status IN (?,?) AND upload_status=?", + arrayOf(STATUS_RUNNING, STATUS_ORDER_SUBMIT_STARTED, UPLOAD_NONE), null, null, "updated_at ASC", ).use { cursor -> buildList { while (cursor.moveToNext()) { - add(InterruptedPurchaseAttempt(cursor.getLong(0), cursor.getString(1), cursor.getString(2))) + add(InterruptedPurchaseAttempt(cursor.getLong(0), cursor.getString(1), cursor.getString(2), cursor.getString(3), cursor.getString(4), cursor.getString(5))) } } } @@ -187,8 +225,8 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N @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), + "SELECT task_id FROM purchase_task WHERE status IN (?,?) OR upload_status=? ORDER BY updated_at ASC LIMIT 1", + arrayOf(STATUS_RUNNING, STATUS_ORDER_SUBMIT_STARTED, UPLOAD_PENDING), ).use { cursor -> if (cursor.moveToFirst()) cursor.getLong(0) else null } @Synchronized @@ -207,8 +245,9 @@ class PurchaseTaskStore(context: Context) : SQLiteOpenHelper(context, DATABASE_N companion object { private const val DATABASE_NAME = "goauto_purchase.db" - private const val DATABASE_VERSION = 1 + private const val DATABASE_VERSION = 2 private const val STATUS_RUNNING = "running" + const val STATUS_ORDER_SUBMIT_STARTED = "order_submit_started" private const val STATUS_COMPLETED = "completed" private const val UPLOAD_NONE = "none" private const val UPLOAD_PENDING = "pending" 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 e367dab..e86fbb1 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 @@ -31,6 +31,7 @@ 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.PurchaseLiveAutomation import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor import cn.ilapage.goauto.agent.automation.PurchaseRule import cn.ilapage.goauto.agent.automation.PurchaseRuleParser @@ -138,7 +139,7 @@ class AgentForegroundService : Service() { val activeCredentials = credentials ?: error("设备尚未取得认证凭据") if (taskMutex.currentTaskId() == null) { - recoverInterruptedPurchases() + recoverInterruptedPurchases(api, activeCredentials.token) flushPurchaseOutbox(api, activeCredentials.token) runningTaskId.set(purchaseStore.activeTaskId()) if (runningTaskId.get() == null) { @@ -189,7 +190,7 @@ class AgentForegroundService : Service() { private fun scheduleTask(api: AgentApiClient, token: String) { if (taskMutex.currentTaskId() != null) return - recoverInterruptedPurchases() + recoverInterruptedPurchases(api, token) flushPurchaseOutbox(api, token) val purchaseTask = api.nextPurchaseTask(token) if (purchaseTask != null) { @@ -235,8 +236,9 @@ class AgentForegroundService : Service() { 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 taskLabel = if (task.executionMode == "live") "正式采购" else "采购演练" + stateStore.update("BUSY", "正在执行${taskLabel}任务 #${task.taskId}", tokenStored = true) + updateNotification("$taskLabel #${task.taskId}") val outcome = if (!snapshotHashValid) { PurchaseExecutionOutcome("failed", "PURCHASE_RULE_INVALID", "采购规则快照哈希无效") @@ -260,6 +262,20 @@ class AgentForegroundService : Service() { openLink = { PddLinkLauncher(this).open(it) }, probeSpecs = { collectPurchaseProbe(accessibility, task) }, stepChanged = { step -> purchaseStore.updateStep(task.taskId, task.taskAttemptId, step) }, + beforeOrderSubmit = { evidence -> + val boundaryRequestId = UUID.randomUUID().toString() + val finalEvidence = JSONObject() + .put("goodsId", evidence.goodsId) + .put("mappedColor", evidence.mappedColor) + .put("mappedSize", evidence.mappedSize) + .put("quantity", evidence.quantity) + .put("unitPriceCent", evidence.unitPriceCent) + .put("addressSuffix", evidence.addressSuffix) + .put("activityName", evidence.activityName) + .toString() + purchaseStore.markOrderSubmitStarted(task.taskId, task.taskAttemptId, boundaryRequestId, finalEvidence) + api.markPurchaseOrderSubmitStarted(task.taskId, boundaryRequestId, token) + }, ).execute( PurchaseExecutionInput( taskId = task.taskId, @@ -272,6 +288,7 @@ class AgentForegroundService : Service() { quantity = task.quantity, minUnitPriceCent = task.minUnitPriceCent, maxUnitPriceCent = task.maxUnitPriceCent, + addressSuffix = task.addressSuffix, ), parsedRule, PurchaseAgentCapabilities.supported, @@ -285,7 +302,7 @@ class AgentForegroundService : Service() { 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} 已提交") + updateNotification(if (outcome.resultType == "failed") "$taskLabel #${task.taskId} 失败" else "$taskLabel #${task.taskId} 已提交") } catch (error: AgentApiException) { stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true) } catch (error: Exception) { @@ -322,13 +339,31 @@ class AgentForegroundService : Service() { .toString() } - private fun recoverInterruptedPurchases() { + private fun recoverInterruptedPurchases(api: AgentApiClient, token: String) { purchaseStore.interruptedAttempts().forEach { interrupted -> + if (interrupted.status == PurchaseTaskStore.STATUS_ORDER_SUBMIT_STARTED) { + val boundaryRequestId = interrupted.orderSubmitRequestId ?: return@forEach + try { + api.markPurchaseOrderSubmitStarted(interrupted.taskId, boundaryRequestId, token) + val evidence = GoAutoAccessibilityService.instance?.let { PurchaseLiveAutomation(it).readOrderResult() } + val outcome = if (evidence == null) { + PurchaseExecutionOutcome("order_result_unknown", "PURCHASE_ORDER_RESULT_UNKNOWN", "无法确认订单是否创建,请人工检查") + } else { + PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = evidence.orderNo, orderSubmittedAt = evidence.submittedAt) + } + val requestId = UUID.randomUUID().toString() + purchaseStore.completeAndEnqueue(interrupted.taskId, interrupted.attemptId, requestId, purchaseResultPayload(requestId, interrupted.attemptId, outcome)) + } catch (_: AgentApiException) { + // Keep the local irreversible marker. The same server request ID + // is replayed after connectivity returns; the order click is never repeated. + } + return@forEach + } val requestId = UUID.randomUUID().toString() val outcome = PurchaseExecutionOutcome( "failed", "AGENT_RESTARTED_DURING_EXECUTION", - "手机服务在演练执行中重启,已停止任务且不会重复操作拼多多", + "手机服务在创建订单前重启,任务已停止且不会重复操作拼多多", ) purchaseStore.completeAndEnqueue( interrupted.taskId, @@ -359,6 +394,8 @@ class AgentForegroundService : Service() { put("errorMessage", outcome.message.take(1000)) } outcome.probedSpecs?.let { put("probedSpecs", JSONObject(it)) } + outcome.pddOrderNo?.let { put("pddOrderNo", it) } + outcome.orderSubmittedAt?.let { put("orderSubmittedAt", it) } } .toString() diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt new file mode 100644 index 0000000..3b0c68b --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseLiveAutomationTest.kt @@ -0,0 +1,106 @@ +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.PurchaseExecutionInput +import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation +import cn.ilapage.goauto.agent.automation.PurchaseLiveException +import cn.ilapage.goauto.agent.automation.PurchaseRuleParser +import cn.ilapage.goauto.agent.automation.PurchaseUiDriver +import cn.ilapage.goauto.agent.automation.SnapshotNode +import cn.ilapage.goauto.agent.automation.SwipeDirection +import cn.ilapage.goauto.agent.automation.UiSnapshot +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PurchaseLiveAutomationTest { + @Test + fun `address is retagged verified and final order button can only be clicked once`() { + val driver = LiveDriver() + val automation = PurchaseLiveAutomation(driver, pause = {}) + val address = automation.updateShippingAddress("_cg11") + assertEquals("广东省广州市天园街道骏景花园骏晖轩1202_cg11", address.expectedAddress) + val final = automation.finalConfirmation(input(), address) + assertEquals("_cg11", final.addressSuffix) + + automation.submitOrderOnce() + assertEquals(1, driver.submitClicks) + val error = runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException + assertEquals("PURCHASE_SUBMIT_ALREADY_ATTEMPTED", error.code) + assertEquals(1, driver.submitClicks) + + val order = automation.readOrderResult() + assertEquals("PDD-202608210001", order?.orderNo) + assertEquals("2026-08-21T02:30:00Z", order?.submittedAt) + assertFalse(driver.clicked.any { it.contains("支付") }) + } + + @Test + fun `live rule requires reviewed capabilities and payment stays forbidden`() { + val live = PurchaseRuleParser.parse(LIVE_RULE) + assertTrue(live.actions.any { it.type.wireName == "createOrder" }) + val rehearsalWithOrder = LIVE_RULE.replace("purchase.live.v1", "purchase.rehearsal.v1") + assertTrue(runCatching { PurchaseRuleParser.parse(rehearsalWithOrder) }.exceptionOrNull() is PurchaseLiveException || + runCatching { PurchaseRuleParser.parse(rehearsalWithOrder) }.isFailure) + assertTrue(runCatching { PurchaseRuleParser.parse(LIVE_RULE.replace("readOrderResult", "pay")) }.isFailure) + } + + private fun input() = PurchaseExecutionInput( + taskId = 11, + executionMode = "live", + 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, + addressSuffix = "_cg11", + ) + + private class LiveDriver : PurchaseUiDriver { + private var page = "confirmation" + private var address = "广东省广州市天园街道骏景花园骏晖轩1202-old" + val clicked = mutableListOf() + var submitClicks = 0 + + override fun capture(): UiSnapshot = when (page) { + "panel" -> snapshot(listOf(node("title", "收货地址"), node("modify", "修改", clickable = true), node("address", address))) + "edit" -> snapshot(listOf(node("title", "修改收货地址"), node("detail", "详细地址"), node("editor", address, className = "android.widget.EditText"), node("save", "保存", clickable = true))) + "order" -> snapshot(listOf(node("status", "待付款"), node("order", "订单号:PDD-202608210001"), node("time", "下单时间:2026-08-21 10:30:00"), node("pay", "立即支付", clickable = true))) + else -> snapshot(listOf( + node("phone", "138****5678", clickable = true), node("address", address), + node("price", "¥20.00"), node("selected", "已选 黑色 XL"), + node("quantity", "2", className = "android.widget.EditText"), node("submit", "提交订单", clickable = true), + )) + } + + override fun clickFresh(target: SnapshotNode): FreshActionResult { + clicked += target.label + when (target.label) { + "138****5678" -> page = "panel" + "修改" -> page = "edit" + "保存" -> page = "panel" + "提交订单" -> { submitClicks++; page = "order" } + } + return FreshActionResult.SUCCESS + } + + override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult { address = value; return FreshActionResult.SUCCESS } + override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean = true + override fun backPurchase(): Boolean { page = "confirmation"; return true } + + private fun snapshot(nodes: List) = UiSnapshot(PDD, ACTIVITY, nodes) + private fun node(path: String, text: String, clickable: Boolean = false, className: String = "android.widget.TextView") = + SnapshotNode(path, null, text, null, null, className, NodeBounds(0, 0, 500, 100), clickable, false, false, false, true, true) + } + + private companion object { + const val PDD = "com.xunmeng.pinduoduo" + const val ACTIVITY = "com.xunmeng.pinduoduo.activity.NewPageActivity" + const val LIVE_RULE = """{"schemaVersion":1,"ruleType":"pddPurchase","requiredCapabilities":["purchase.live.v1","purchase.address-update.v1","purchase.order-create.v1","purchase.spec-probe.v1"],"actions":[{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"probeSpecs"},{"type":"selectSpec"},{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"},{"type":"updateShippingAddress"},{"type":"createOrder"},{"type":"readOrderResult"}]}""" + } +} 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 72d3494..93efef5 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 @@ -92,6 +92,15 @@ class PurchaseRehearsalExecutorTest { assertFalse(opened) } + @Test + fun `live rule spec probe returns before address or order actions`() { + val driver = FakePurchaseDriver() + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {}) + .execute(input().copy(executionMode = "live", phase = "spec_probe", addressSuffix = "_cg42"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported) + assertEquals("spec_probe_completed", outcome.resultType) + assertFalse(driver.clicked.any { it.contains("订单") || it.contains("地址") || it.contains("支付") }) + } + @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"}]}""" @@ -166,6 +175,12 @@ class PurchaseRehearsalExecutorTest { ] }""" + private fun liveRule() = """{ + "schemaVersion":1,"ruleType":"pddPurchase", + "requiredCapabilities":["purchase.live.v1","purchase.address-update.v1","purchase.order-create.v1","purchase.spec-probe.v1"], + "actions":[{"type":"openProduct"},{"type":"verifyProduct"},{"type":"openSpecPanel"},{"type":"probeSpecs"},{"type":"selectSpec"},{"type":"setQuantity"},{"type":"verifyUnitPrice"},{"type":"verifyOrderSummary"},{"type":"updateShippingAddress"},{"type":"createOrder"},{"type":"readOrderResult"}] + }""" + private class FakePurchaseDriver( private val colors: List = listOf("黑色"), private val priceCent: Long = 2_000, @@ -235,6 +250,8 @@ class PurchaseRehearsalExecutorTest { return true } + override fun backPurchase(): Boolean = true + private fun node( path: String, text: String, diff --git a/docs/00-project-profile.md b/docs/00-project-profile.md index 15e1c91..7ea64a6 100644 --- a/docs/00-project-profile.md +++ b/docs/00-project-profile.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Project-Profile wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.- -wiki_revision: dd7a5eee5576e626bb9078117880e74e4d3a2d5c -synchronized_at: 2026-08-21T01:43:04Z +wiki_revision: b882c9ed3995bcafd103801a1893191b86cc3382 +synchronized_at: 2026-08-21T03:03:24Z # 项目档案 @@ -17,7 +17,7 @@ synchronized_at: 2026-08-21T01:43:04Z | 主要使用者 | 管理员、采购人员、开发维护者 | | Gitea 仓库 | `OPC/goauto` | | 默认分支 | `main` | -| 当前已实施范围 | PDD 商品与采集闭环、虾皮/SYB 商品档案与导入、采购任务服务、Admin 采购管理与人工处理、Android 采购演练与持久结果上报 | +| 当前已实施范围 | PDD 商品与采集闭环、虾皮/SYB 商品档案与导入、采购任务服务、Admin 采购管理与人工处理、Android 采购演练,以及 #36 已实现但尚未真机授权验证的正式地址/待付款订单能力 | | 后续设计范围 | 从 SYB 创建采购任务、创建待付款订单、物流采集与自动回填 | | 预计规模 | 20 台 Android;每天约 100 个采集任务、200 个采购任务 | @@ -70,4 +70,4 @@ synchronized_at: 2026-08-21T01:43:04Z #40、#41、#49~#52 已于 2026-08-20 通过用户验收,虾皮/SYB 商品档案、店铺过滤、异步同步记录、列表布局和 MySQL 同步缺陷修复均已完成。 -#33、#34、#42 已于 2026-08-20 通过用户验收,采购数据契约、服务端状态机和 Android 安全演练基线已经完成。#42 只演练商品、规格、数量和价格,使用本地任务/attempt/Outbox 保证结果重传不重复操作 PDD,不改地址、不创建订单。#35、#55、#56 已于 2026-08-21 通过用户验收,Admin 采购管理、列表与详情查询以及 go-admin 成功响应兼容已经完成;#36 正式创建待付款订单仍属于后续高风险工单。 +#33、#34、#42 已于 2026-08-20 通过用户验收,采购数据契约、服务端状态机和 Android 安全演练基线已经完成。#35、#55、#56 已于 2026-08-21 通过用户验收,Admin 采购管理、列表与详情查询以及 go-admin 成功响应兼容已经完成。#36 已在 Android Agent 0.2.0 实现正式地址后缀、不可逆门禁、一次性创建待付款订单和只读核单;自动化测试与 APK 构建已通过,但未安装到真机、未修改真实地址、未点击真实创建订单,等待用户独立授权验证。 diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 6ec75d0..c6980ec 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -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: bea774951bc16ac3adba62ff4fdc2c4e750a0884 -synchronized_at: 2026-08-21T02:11:49Z +wiki_revision: a582b9ed5712d0c1fceeb5530f60a3d746cbbd24 +synchronized_at: 2026-08-21T03:03:32Z # 架构与代码地图 @@ -46,6 +46,7 @@ Android Portal/Agent - 指定设备只能由该设备领取;未指定任务由在线空闲设备原子领取。 - 重置在事务中清空原结果,保留 URL、goods_id、规则和设备快照,状态恢复为 `pending`。 - Android 本地互斥与服务端原子领取共同保证单设备串行。 +- 正式采购在 Android 本地 SQLite 事务中先保存不可逆状态、稳定服务端请求 ID 和脱敏最终确认快照,再通知服务端并只允许一次创建订单点击;重启后只重放服务端标记、只读核单或上报,不再次点击。 - 原始控件树和截图不持久化;Android Agent 端第一期不使用 OCR/VLM。服务端 SYB 登录验证码识别是唯一例外,见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48)。 ## 最小业务数据 @@ -127,7 +128,7 @@ Android Portal/Agent | SYB 异步导入、当前页采购选择/确认/逐条结果与同步记录页面 | `web/src/views/goauto/syb-products/`、`web/src/views/goauto/syb-sync-runs/`、`web/src/api/goauto/syb-products.js`、`web/src/api/goauto/purchase-tasks.js`;确认原型见 #44 设计证据,导入原型快照为 `prototypes/50/v2/index.html` | | Android Agent 基线 | `android/app/src/main/java/cn/ilapage/goauto/agent/` | | Android 无障碍规则执行 | `android/app/src/main/java/cn/ilapage/goauto/agent/automation/` | -| Android 采购演练规则解释器 | `android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRuleContract.kt`、`PurchaseRehearsalExecutor.kt` | +| Android 采购规则解释器与正式地址/订单动作 | `android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRuleContract.kt`、`PurchaseRehearsalExecutor.kt`、`PurchaseLiveAutomation.kt` | | Android 采购任务、attempt 与 Outbox 持久化 | `android/app/src/main/java/cn/ilapage/goauto/agent/persistence/`;调度入口 `service/AgentForegroundService.kt` | | 三端统一验证 | `scripts/verify.ps1` | diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 1dbdc07..adb92a0 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,15 +2,15 @@ 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: 6f0a0c4d36c7101b5e81660363865f2e75759584 -synchronized_at: 2026-08-21T02:11:53Z +wiki_revision: f0e414f176cd42e0ec79fcabe46c5497e4294e9a +synchronized_at: 2026-08-21T03:03:36Z # 业务规则与术语 ## 当前范围 -当前已实施范围包含 PDD 商品最新档案与采集闭环、虾皮商品档案、SYB 商品与后台导入、采购任务服务、Admin 采购任务与人工处理页面,以及 Android 采购演练与持久结果上报。真实创建待付款订单、物流采集和自动回填仍由独立工单推进。 +当前已实施范围包含 PDD 商品最新档案与采集闭环、虾皮商品档案、SYB 商品与后台导入、采购任务服务、Admin 采购任务与人工处理页面、Android 采购演练,以及 #36 的正式地址后缀与创建待付款订单能力。#36 尚未获得真机创建订单授权和验收;物流采集与自动回填仍由独立工单推进。 ## PDD 商品 @@ -116,9 +116,10 @@ synchronized_at: 2026-08-21T02:11:53Z - Admin 从 SYB 当前页选择商品,可在“创建采购”和“重新解析”两种批量用途间切换;采购模式只允许选择预检合格行,表头全选不跨页。批量预检和正式创建均最多 100 条并逐条重新校验,每条 SYB 明细只创建一个独立任务;部分失败不回滚其他成功项。 - 批量创建使用服务端内置并经契约校验的正式采购规则,每条任务保存不可变快照;设备默认人工指定,也可以留空由符合能力的空闲设备领取。 - 批量创建的价格保护来自 PDD 商品档案而不是 SYB/Shopee 的 TWD 售价:已确认颜色映射时按该颜色人民币价格计算;待规格探测时用可用颜色最低价的 0.2 倍到最高价的 1.5 倍,参考价取最高价。没有可用颜色价格时不能创建。 -- 地址后缀为 `_cg{purchase_task.id}`。修改失败时禁止创建订单;任务与订单正式关联仍以完整 PDD 订单号为准。 -- 点击创建订单前必须先保存 `order_submit_started` 和不可逆时间;结果不明时进入 `order_result_unknown`,禁止自动再次点击。 -- Agent 本地 Room/Outbox 负责断网和重启恢复,服务端以 `task_id + task_attempt_id` 幂等接收并保存最终事实。 +- 地址后缀为 `_cg{purchase_task.id}`。Agent 只在唯一地址入口、唯一修改按钮和唯一详细地址输入框均成立时修改;按首个 `-` 或 `_` 截取地址主体后追加当前后缀,保存后必须回读完整新地址。修改或回读失败时禁止创建订单,地址全文和控件树不落库。 +- 点击创建订单前,Agent 必须先在本地事务保存 `order_submit_started`、不可逆时间、稳定请求 ID 和不含地址全文的最终确认快照,再用同一请求 ID通知服务端;两侧成功后才允许精确点击唯一创建订单按钮一次。 +- 进入不可逆边界后,进程重启、断网、点击结果不明或无法取得唯一订单号/下单时间时只允许只读核单并进入 `order_result_unknown`,禁止再次点击;任务与订单正式关联仍以完整 PDD 订单号为准。 +- Agent 本地 SQLite/Outbox 负责断网和重启恢复,服务端以 `task_id + task_attempt_id` 幂等接收并保存最终事实。 - 人工支付复核只记录 `paid` / `unpaid`;系统不执行或识别支付。快递单号与回填状态属于采购任务,后续物流工单实现。 - 采购任务领取时同时占用设备租约和可选 PDD 账号租约;租约过期后才可释放并重新领取。设备还存在采集任务时不能领取采购任务。 - 规格映射不完整、PDD 档案为待采集或没有规格时,规则必须具有 `purchase.spec-probe.v1`;第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟。无匹配结果明确失败。 @@ -134,8 +135,8 @@ synchronized_at: 2026-08-21T02:11:53Z - Android Agent 端不使用 OCR/VLM 兜底,不保存原始控件树或截图。采集阶段不猜测缺失数据。 - 服务端顺云宝(SYB)登录例外:允许调用配置的线上 OCR 服务识别登录验证码(见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48))。验证码图片会离开本项目发送到该服务,更换服务地址前必须重新评估。此例外只适用于 SYB 登录,不扩大到 Agent 端或任何 PDD 相关流程。 - 采购阶段的规格匹配由服务端决策(见 [#46](https://git.ilapage.cn/OPC/goauto/issues/46)):Agent 本地不得自行猜测规格或点击相近候选,只执行服务端下发的精确规格;AI 无匹配结果时明确失败。 -- 当前 MVP 不创建订单、不支付、不提供实时屏幕或管理端远程控制。 -- Agent 的长期能力可以扩展到采购规则中的“创建订单”,但采集规则不能调用该动作;采购必须作为独立高风险 MVP 实施。付款、免密支付及任何等价动作始终禁止。 +- 正式采购规则可以调用独立审核的改地址、创建待付款订单和只读核单动作;采集规则和演练规则不能调用。真机首次安装或验证仍需独立人工授权。 +- 系统不提供自动支付、实时屏幕或管理端远程控制。付款、免密支付及任何等价动作始终禁止。 ## 设备身份与认证 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 63b1f32..6aedb90 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -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: 8fa791cf3f249096ddad2ede02bf13251f963407 -synchronized_at: 2026-08-21T02:15:58Z +wiki_revision: 440e5c2e9e6a67e532d09301d617849a3df05297 +synchronized_at: 2026-08-21T03:03:51Z # MVP 共享 API 契约 @@ -369,9 +369,9 @@ POST /api/agent/v1/tasks/{taskId}/fail | `DEVICE_BUSY` | 设备已有活动任务 | 否 | | `DEVICE_CAPABILITY_MISMATCH` | 设备缺少任务规则要求的版本化能力 | 否 | -## 采购任务共享契约(#33、#34、#42、#44、#53) +## 采购任务共享契约(#33、#34、#36、#42、#44、#53) -本节固定采购域的数据和接口边界;`purchase_task`、`purchase_task_attempt`、规则校验、服务端 HTTP 状态机、Admin 已有任务管理、SYB 创建入口及 Android 演练执行已经落地。正式地址/订单动作仍由后续高风险工单实现。任何实现都不得扩展为自动支付。 +本节固定采购域的数据和接口边界;`purchase_task`、`purchase_task_attempt`、规则校验、服务端 HTTP 状态机、Admin 已有任务管理、SYB 创建入口、Android 演练执行及 #36 正式地址/订单动作已经落地。#36 尚未获得真机创建订单授权和验收。任何实现都不得扩展为自动支付。 ### 任务与快照 @@ -488,7 +488,11 @@ Admin 列表与详情由 #35 实现;#44 在 SYB 商品列表提供单条/当 结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。慢路径第一趟提交规格后释放设备与已知账号租约,任务进入 `spec_probe_pending`;服务端固化同一 attempt 的 AI/人工决策后,第二趟使用新的 attempt 重新派发。无匹配结果则明确失败。`order_result_unknown` 只允许管理员或采购员人工解除,永不自动重派。 -Android #42 使用本地 SQLite 保存恢复与重传所需的任务、attempt 和 Outbox;最终结果和 Outbox 在同一事务写入。断网或重启后只重发已保存的同一 requestId 和结果,不重新执行 PDD 操作。演练执行器只解释服务端规则快照中的受限类型化动作和参数,精确选择规格、设置数量、校验实际单价并停在安全点;不支持的能力在打开 PDD 前失败,地址修改、创建订单和支付动作均被拒绝。服务端数据库仍是最终事实来源;双方均不保存原始控件树、截图、PDD 凭据或完整收货地址。 +Android #42/#36 使用本地 SQLite 保存恢复与重传所需的任务、attempt 和 Outbox;最终结果和 Outbox 在同一事务写入。演练执行器仍在安全点停止。Agent 0.2.0 另外声明 `purchase.live.v1`、`purchase.address-update.v1` 和 `purchase.order-create.v1`,仅在 `live` 规则同时包含受限 `updateShippingAddress`、`createOrder`、`readOrderResult` 动作时启用。 + +正式地址动作通过脱敏手机号结构定位唯一地址入口,精确选择唯一修改按钮和详细地址输入框;按首个 `-` 或 `_` 截断旧后缀,追加任务 `addressSuffix`,保存并回读完整新地址。地址全文只在本次执行内存中存在,不进入本地最终确认快照、日志或服务端。 + +创建订单前先在本地事务保存 `order_submit_started`、不可逆时间、稳定的 `orderSubmitRequestId` 和脱敏最终确认快照,再调用服务端同名接口;只有两侧标记完成并重新校验商品、规格、数量、单价、地址后缀、PDD 包/Activity 与唯一创建订单按钮后,才点击一次。重启时重放同一标记请求并只读核单;无法取得唯一未付款订单号和 PDD 下单时间时提交 `order_result_unknown`。支付文字仅用于识别未付款/离开支付页,永不点击。服务端数据库仍是最终事实来源;双方均不保存原始控件树、截图、PDD 凭据或完整收货地址。 | 错误码 | 普通提示 | |---|---|