feat(#42): add persistent purchase rehearsal execution
This commit is contained in:
+44
-4
@@ -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)
|
||||
|
||||
+306
@@ -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))
|
||||
}
|
||||
|
||||
+15
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user