fix(goauto): preserve unknown order evidence (#167)
This commit is contained in:
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 45
|
||||
versionName = "0.9.32"
|
||||
versionCode = 46
|
||||
versionName = "0.9.33"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+50
-8
@@ -20,6 +20,7 @@ data class FinalConfirmationEvidence(
|
||||
)
|
||||
|
||||
data class PurchaseOrderEvidence(val orderNo: String, val submittedAt: String)
|
||||
data class PurchaseOrderReadFailure(val code: String, val message: String)
|
||||
|
||||
class PurchaseLiveException(val code: String, message: String) : IllegalStateException(message)
|
||||
|
||||
@@ -33,6 +34,8 @@ class PurchaseLiveAutomation(
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
) {
|
||||
private var submitAttempted = false
|
||||
var lastOrderReadFailure: PurchaseOrderReadFailure? = null
|
||||
private set
|
||||
fun updateShippingAddress(addressSuffix: String): ShippingAddressProof {
|
||||
if (!addressSuffix.matches(Regex("^_cg[1-9][0-9]*$"))) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "采购任务的地址标记无效")
|
||||
var snapshot = driver.capture()
|
||||
@@ -108,6 +111,11 @@ class PurchaseLiveAutomation(
|
||||
|
||||
/** Reads the order flow with bounded chooser/payment Back actions and one verified PDD foreground request. */
|
||||
fun readOrderResult(): PurchaseOrderEvidence? {
|
||||
lastOrderReadFailure = null
|
||||
fun unknown(code: String, message: String): PurchaseOrderEvidence? {
|
||||
lastOrderReadFailure = PurchaseOrderReadFailure(code, message)
|
||||
return null
|
||||
}
|
||||
val labels = linkedSetOf<String>()
|
||||
var backedOutOfPayment = false
|
||||
var backedOutOfChooser = false
|
||||
@@ -119,30 +127,40 @@ class PurchaseLiveAutomation(
|
||||
val snapshot = driver.capture()
|
||||
if (snapshot.packageName.isNullOrBlank()) {
|
||||
consecutiveEmptySnapshots++
|
||||
if (consecutiveEmptySnapshots >= ORDER_RESULT_MAX_EMPTY_SAMPLES) return null
|
||||
if (consecutiveEmptySnapshots >= ORDER_RESULT_MAX_EMPTY_SAMPLES) {
|
||||
return unknown("PURCHASE_ORDER_EMPTY_TIMEOUT", "等待订单页面时无障碍窗口持续为空")
|
||||
}
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
return@repeat
|
||||
}
|
||||
consecutiveEmptySnapshots = 0
|
||||
val currentLabels = snapshot.nodes.filter { it.visible }.map { it.label }
|
||||
if (isKnownAndroidWechatChooser(snapshot, currentLabels)) {
|
||||
if (backedOutOfChooser || !driver.backPurchase()) return null
|
||||
if (backedOutOfChooser || !driver.backPurchase()) {
|
||||
return unknown("PURCHASE_ORDER_CHOOSER_BACK_FAILED", "系统应用选择页无法安全返回")
|
||||
}
|
||||
backedOutOfChooser = true
|
||||
pause(500)
|
||||
return@repeat
|
||||
}
|
||||
if (snapshot.packageName == WECHAT_PACKAGE) {
|
||||
if (!restoredFromWechat) {
|
||||
if (!driver.bringPddToForeground()) return null
|
||||
if (!driver.bringPddToForeground()) {
|
||||
return unknown("PURCHASE_ORDER_WECHAT_RESTORE_FAILED", "从微信恢复拼多多的请求失败")
|
||||
}
|
||||
restoredFromWechat = true
|
||||
pause(500)
|
||||
return@repeat
|
||||
}
|
||||
if (pddObservedAfterWechatRestore || ++wechatRestorePendingSamples >= ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES) return null
|
||||
if (pddObservedAfterWechatRestore || ++wechatRestorePendingSamples >= ORDER_RESULT_WECHAT_RESTORE_MAX_SAMPLES) {
|
||||
return unknown("PURCHASE_ORDER_WECHAT_RESTORE_TIMEOUT", "从微信恢复拼多多后未在限定时间到达订单页面")
|
||||
}
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
return@repeat
|
||||
}
|
||||
if (snapshot.packageName != PDD_PACKAGE) return null
|
||||
if (snapshot.packageName != PDD_PACKAGE) {
|
||||
return unknown("PURCHASE_ORDER_UNEXPECTED_APP", "核单期间出现未授权应用")
|
||||
}
|
||||
if (restoredFromWechat) pddObservedAfterWechatRestore = true
|
||||
val paymentVisible = isKnownPddPaymentActivity(snapshot) ||
|
||||
currentLabels.any { label -> PAYMENT_MARKERS.any(label::contains) }
|
||||
@@ -155,16 +173,19 @@ class PurchaseLiveAutomation(
|
||||
parseOrderEvidence(labels)?.let { return it }
|
||||
if (paymentVisible && !backedOutOfPayment) {
|
||||
backedOutOfPayment = true
|
||||
if (!driver.backPurchase()) return null
|
||||
if (!driver.backPurchase()) {
|
||||
return unknown("PURCHASE_ORDER_PAYMENT_BACK_FAILED", "支付页无法安全返回订单详情")
|
||||
}
|
||||
pause(500)
|
||||
} else if (paymentVisible) {
|
||||
return null
|
||||
return unknown("PURCHASE_ORDER_PAYMENT_REPEATED", "支付页重复出现,已停止自动核单")
|
||||
} else if (index > 0 && index % 15 == 0) {
|
||||
driver.swipePurchase(SwipeDirection.UP, 400)
|
||||
}
|
||||
pause(ORDER_RESULT_SAMPLE_INTERVAL_MS)
|
||||
}
|
||||
return null
|
||||
val failure = orderEvidenceFailure(labels)
|
||||
return unknown(failure.code, failure.message)
|
||||
}
|
||||
|
||||
private fun isKnownAndroidWechatChooser(snapshot: UiSnapshot, labels: Collection<String>): Boolean {
|
||||
@@ -369,6 +390,27 @@ class PurchaseLiveAutomation(
|
||||
return PurchaseOrderEvidence(orderNumbers.single(), output)
|
||||
}
|
||||
|
||||
private fun orderEvidenceFailure(labels: Collection<String>): PurchaseOrderReadFailure {
|
||||
val text = labels.joinToString(" ")
|
||||
if (labels.none { label -> ORDER_CONTEXT_MARKERS.any(label::contains) }) {
|
||||
return PurchaseOrderReadFailure("PURCHASE_ORDER_CONTEXT_NOT_FOUND", "限定时间内没有出现订单详情证据")
|
||||
}
|
||||
val orderNumbers = ORDER_NO.findAll(text).map { it.groupValues[1] }.distinct().toList()
|
||||
if (orderNumbers.isEmpty()) return PurchaseOrderReadFailure("PURCHASE_ORDER_NO_MISSING", "订单详情缺少可读取的订单号")
|
||||
if (orderNumbers.size > 1) return PurchaseOrderReadFailure("PURCHASE_ORDER_NO_AMBIGUOUS", "订单详情出现多个订单号,无法唯一确认")
|
||||
val rawTime = ORDER_TIME.find(text)?.groupValues?.get(1)
|
||||
?: return PurchaseOrderReadFailure("PURCHASE_ORDER_TIME_MISSING", "订单详情缺少可读取的下单时间")
|
||||
val normalized = rawTime.replace('年', '-').replace('月', '-').replace("日", "").replace('/', '-').replace('.', '-')
|
||||
val validTime = listOf("yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm").any { pattern ->
|
||||
runCatching { SimpleDateFormat(pattern, Locale.ROOT).apply { isLenient = false }.parse(normalized) }.getOrNull() != null
|
||||
}
|
||||
if (!validTime) return PurchaseOrderReadFailure("PURCHASE_ORDER_TIME_INVALID", "订单详情的下单时间格式无法确认")
|
||||
if (labels.none { label -> UNPAID_MARKERS.any(label::contains) || PAYMENT_MARKERS.any(label::contains) }) {
|
||||
return PurchaseOrderReadFailure("PURCHASE_ORDER_UNPAID_EVIDENCE_MISSING", "订单详情缺少待付款状态证据")
|
||||
}
|
||||
return PurchaseOrderReadFailure("PURCHASE_ORDER_RESULT_UNKNOWN", "订单详情证据不完整,无法唯一确认")
|
||||
}
|
||||
|
||||
private fun fail(code: String, message: String): Nothing = throw PurchaseLiveException(code, message)
|
||||
|
||||
private companion object {
|
||||
|
||||
+14
-2
@@ -91,13 +91,25 @@ class PurchaseRehearsalExecutor(
|
||||
live.submitOrderOnce()
|
||||
null
|
||||
} catch (error: PurchaseLiveException) {
|
||||
if (irreversibleStarted) PurchaseExecutionOutcome("order_result_unknown", "PURCHASE_ORDER_RESULT_UNKNOWN", "无法确认订单是否创建,请人工检查")
|
||||
if (irreversibleStarted) PurchaseExecutionOutcome(
|
||||
"order_result_unknown",
|
||||
"PURCHASE_ORDER_RESULT_UNKNOWN",
|
||||
"无法确认订单是否创建,请人工检查",
|
||||
actualUnitPriceCent = observedPrice,
|
||||
)
|
||||
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, actualUnitPriceCent = observedPrice) }
|
||||
?: PurchaseExecutionOutcome("order_result_unknown", "PURCHASE_ORDER_RESULT_UNKNOWN", "无法确认订单是否创建,请人工检查")
|
||||
?: live.lastOrderReadFailure.let { readFailure ->
|
||||
PurchaseExecutionOutcome(
|
||||
"order_result_unknown",
|
||||
readFailure?.code ?: "PURCHASE_ORDER_RESULT_UNKNOWN",
|
||||
readFailure?.message ?: "无法确认订单是否创建,请人工检查",
|
||||
actualUnitPriceCent = observedPrice,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failure != null) {
|
||||
|
||||
+8
-20
@@ -559,9 +559,15 @@ class AgentForegroundService : Service() {
|
||||
val boundaryRequestId = interrupted.orderSubmitRequestId ?: return@forEach
|
||||
try {
|
||||
api.markPurchaseOrderSubmitStarted(interrupted.taskId, boundaryRequestId, token)
|
||||
val evidence = GoAutoAccessibilityService.instance?.let { PurchaseLiveAutomation(it).readOrderResult() }
|
||||
val automation = GoAutoAccessibilityService.instance?.let(::PurchaseLiveAutomation)
|
||||
val evidence = automation?.readOrderResult()
|
||||
val outcome = if (evidence == null) {
|
||||
PurchaseExecutionOutcome("order_result_unknown", "PURCHASE_ORDER_RESULT_UNKNOWN", "无法确认订单是否创建,请人工检查")
|
||||
val readFailure = automation?.lastOrderReadFailure
|
||||
PurchaseExecutionOutcome(
|
||||
"order_result_unknown",
|
||||
readFailure?.code ?: "PURCHASE_ORDER_RESULT_UNKNOWN",
|
||||
readFailure?.message ?: "无法确认订单是否创建,请人工检查",
|
||||
)
|
||||
} else {
|
||||
PurchaseExecutionOutcome("order_created", message = "订单已创建,等待人工检查和支付", pddOrderNo = evidence.orderNo, orderSubmittedAt = evidence.submittedAt)
|
||||
}
|
||||
@@ -597,24 +603,6 @@ class AgentForegroundService : Service() {
|
||||
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)) }
|
||||
outcome.pddOrderNo?.let { put("pddOrderNo", it) }
|
||||
outcome.orderSubmittedAt?.let { put("orderSubmittedAt", it) }
|
||||
outcome.actualUnitPriceCent?.let { put("actualUnitPriceCent", it) }
|
||||
}
|
||||
.toString()
|
||||
|
||||
|
||||
private fun executeTask(
|
||||
api: AgentApiClient,
|
||||
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome
|
||||
import org.json.JSONObject
|
||||
|
||||
internal 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" || outcome.resultType == "order_result_unknown") {
|
||||
put("errorCode", outcome.errorCode ?: "AGENT_EXECUTION_ERROR")
|
||||
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) }
|
||||
outcome.actualUnitPriceCent?.let { put("actualUnitPriceCent", it) }
|
||||
}
|
||||
.toString()
|
||||
@@ -185,6 +185,7 @@ class PurchaseLiveAutomationTest {
|
||||
automation.submitOrderOnce()
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals("PURCHASE_ORDER_EMPTY_TIMEOUT", automation.lastOrderReadFailure?.code)
|
||||
assertEquals(15, driver.postSubmitCaptureCount)
|
||||
assertEquals(0, driver.pddRestoreCount)
|
||||
assertEquals(0, driver.postSubmitBackCount)
|
||||
@@ -200,6 +201,7 @@ class PurchaseLiveAutomationTest {
|
||||
automation.submitOrderOnce()
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals("PURCHASE_ORDER_UNEXPECTED_APP", automation.lastOrderReadFailure?.code)
|
||||
assertEquals(1, driver.postSubmitCaptureCount)
|
||||
assertEquals(0, driver.pddRestoreCount)
|
||||
assertEquals(0, driver.postSubmitBackCount)
|
||||
@@ -215,6 +217,7 @@ class PurchaseLiveAutomationTest {
|
||||
automation.submitOrderOnce()
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals("PURCHASE_ORDER_CONTEXT_NOT_FOUND", automation.lastOrderReadFailure?.code)
|
||||
assertEquals(1, driver.pddRestoreCount)
|
||||
assertEquals(0, driver.postSubmitBackCount)
|
||||
assertEquals(0, driver.genericSwipes)
|
||||
@@ -229,6 +232,7 @@ class PurchaseLiveAutomationTest {
|
||||
automation.submitOrderOnce()
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals("PURCHASE_ORDER_WECHAT_RESTORE_TIMEOUT", automation.lastOrderReadFailure?.code)
|
||||
assertEquals(1, driver.pddRestoreCount)
|
||||
assertEquals(0, driver.genericSwipes)
|
||||
assertEquals(0, driver.postSubmitBackCount)
|
||||
@@ -246,6 +250,18 @@ class PurchaseLiveAutomationTest {
|
||||
assertEquals(0, driver.postSubmitBackCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `order context without submitted time reports stable missing time stage`() {
|
||||
val driver = LiveDriver(postSubmitCaptureSequence = listOf("order-no-time"))
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg37")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg37"), address)
|
||||
automation.submitOrderOnce()
|
||||
|
||||
assertEquals(null, automation.readOrderResult())
|
||||
assertEquals("PURCHASE_ORDER_TIME_MISSING", automation.lastOrderReadFailure?.code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clipped address is revealed by swiping the unique purchase panel`() {
|
||||
val driver = LiveDriver(addressClipped = true)
|
||||
@@ -343,6 +359,7 @@ class PurchaseLiveAutomationTest {
|
||||
node("address-summary", if (savedTransitionHidesSuffix) "已保存的收货信息" else address.substring(address.lastIndexOf("_cg"))),
|
||||
))
|
||||
"order" -> snapshot(listOf(node("status", "待付款"), node("order", "订单号:PDD-202608210001"), node("time", "下单时间:2026-08-21 10:30:00"), node("pay", "立即支付", clickable = true)))
|
||||
"order-no-time" -> snapshot(listOf(node("status", "待付款"), node("order", "订单号:PDD-202608210001"), node("pay", "去支付", clickable = true)))
|
||||
"chooser" -> UiSnapshot(if (trustedChooser) "android" else "example.untrusted", "com.android.internal.app.ChooserActivity", listOf(
|
||||
node("chooser-title", "选择要使用的应用"), node("wechat-1", "微信"), node("wechat-2", "微信分身"),
|
||||
))
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome
|
||||
import cn.ilapage.goauto.agent.service.purchaseResultPayload
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseResultPayloadTest {
|
||||
@Test
|
||||
fun `unknown order result keeps stable failure and verified price without order fields`() {
|
||||
val payload = JSONObject(purchaseResultPayload(
|
||||
requestId = "request-1",
|
||||
attemptId = "attempt-1",
|
||||
outcome = PurchaseExecutionOutcome(
|
||||
resultType = "order_result_unknown",
|
||||
errorCode = "PURCHASE_ORDER_TIME_MISSING",
|
||||
message = "订单详情缺少可读取的下单时间",
|
||||
actualUnitPriceCent = 1999,
|
||||
),
|
||||
))
|
||||
|
||||
assertEquals("order_result_unknown", payload.getString("resultType"))
|
||||
assertEquals("PURCHASE_ORDER_TIME_MISSING", payload.getString("errorCode"))
|
||||
assertEquals(1999L, payload.getLong("actualUnitPriceCent"))
|
||||
assertFalse(payload.has("pddOrderNo"))
|
||||
assertFalse(payload.has("orderSubmittedAt"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user