fix(goauto): preserve unknown order evidence (#167)

This commit is contained in:
QiuSW
2026-08-31 14:28:59 +08:00
parent d90bf66cc2
commit ff0cf99be3
11 changed files with 252 additions and 36 deletions
+2 -2
View File
@@ -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"
@@ -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 {
@@ -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) {
@@ -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"))
}
}