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