feat(#42): add persistent purchase rehearsal execution

This commit is contained in:
QiuSW
2026-08-20 23:09:42 +08:00
parent b0f33bf679
commit 888cc8b617
9 changed files with 1237 additions and 5 deletions
@@ -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<AccessibilityNodeInfo>()
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<AccessibilityNodeInfo>()
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)
@@ -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<String>): 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<String>,
): 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),
)
}
}
@@ -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<String>?,
val waitAfterMs: Long,
val swipeAfter: PurchaseSwipePlan?,
)
data class PurchaseRule(
val requiredCapabilities: List<String>,
val actions: List<PurchaseAction>,
)
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<String> {
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<String>, 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)
}
@@ -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<String> = listOf(SCHEMA_V2, SWIPE_V1, PDD_PRODUCT_DETAIL_V1)
val supported: List<String> = listOf(
SCHEMA_V2,
SWIPE_V1,
PDD_PRODUCT_DETAIL_V1,
PurchaseAgentCapabilities.REHEARSAL_V1,
PurchaseAgentCapabilities.SPEC_PROBE_V1,
)
}
object RuleParser {
@@ -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))
}
@@ -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<PendingPurchaseOutbox>,
private val submit: (PendingPurchaseOutbox) -> Unit,
private val markUploaded: (PendingPurchaseOutbox) -> Unit,
) {
fun flush() {
pending().forEach { item ->
submit(item)
markUploaded(item)
}
}
}
@@ -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<InterruptedPurchaseAttempt> = 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<PendingPurchaseOutbox> = 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"
}
}
@@ -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 {
@@ -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<Long>()
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<PendingPurchaseOutbox>()
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<String> = 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<String>()
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"
}
}