Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04c42dca43 | ||
|
|
37e8b567e8 | ||
|
|
ade48a7a38 | ||
|
|
fe321b668c | ||
|
|
c292e58f75 | ||
|
|
a11ad65577 | ||
|
|
7ff2596646 | ||
|
|
074e5aab29 | ||
|
|
659bbc6e1a | ||
|
|
99faf5ad95 | ||
|
|
8a3367a4ea | ||
|
|
c80c746943 | ||
|
|
bec8c84321 |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 77
|
||||
versionName = "0.9.64"
|
||||
versionCode = 85
|
||||
versionName = "0.9.72"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package cn.ilapage.goauto.agent.automation
|
||||
|
||||
/** Complete temporary collections only; caller retains the existing task lock. */
|
||||
internal class PddCollectionReturnNavigator(
|
||||
private val driver: PddCollectorDriver,
|
||||
private val openAgent: () -> Boolean,
|
||||
private val currentPackage: () -> String?,
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
) {
|
||||
var reason = "not_started"
|
||||
private set
|
||||
var actions = 0
|
||||
private set
|
||||
|
||||
fun returnToAgent(count: Int): Boolean {
|
||||
actions = 0
|
||||
if (count !in 1..5) { reason = "invalid_count"; return false }
|
||||
try {
|
||||
reason = "count_completed"
|
||||
for (step in 1..count) {
|
||||
if (currentPackage() != PDD) { reason = "left_pdd"; break }
|
||||
val screen = driver.capture()
|
||||
if (screen.packageName != PDD) { reason = "left_pdd"; break }
|
||||
if (screen.activityName.isNullOrBlank() || unsafe(screen)) {
|
||||
reason = "unsafe_page"
|
||||
return false
|
||||
}
|
||||
// Recheck after capturing: never send Back to another foreground app.
|
||||
if (currentPackage() != PDD) { reason = "left_pdd"; break }
|
||||
actions++
|
||||
if (!driver.back()) { reason = "back_failed"; break }
|
||||
pause(500)
|
||||
}
|
||||
// Retain risk scenes even if the last Back lands on one.
|
||||
if (currentPackage() == PDD && unsafe(driver.capture())) {
|
||||
reason = "unsafe_page"
|
||||
return false
|
||||
}
|
||||
if (!openAgent()) { reason = "agent_launch_failed"; return false }
|
||||
pause(500)
|
||||
if (currentPackage() != AGENT) { reason = "agent_not_confirmed"; return false }
|
||||
return true
|
||||
} catch (_: Exception) {
|
||||
reason = "navigation_exception"
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun unsafe(screen: UiSnapshot): Boolean =
|
||||
PddPageClassifier.classify(screen.packageName, screen.activityName,
|
||||
screen.nodes.filter { it.visible }.map { it.label }) != null ||
|
||||
screen.nodes.any { it.visible && listOf("订单详情", "订单待支付", "确认付款", "立即支付", "编辑收货地址", "新增收货地址", "地址管理").any(it.label::contains) }
|
||||
|
||||
companion object {
|
||||
private const val PDD = "com.xunmeng.pinduoduo"
|
||||
private const val AGENT = "cn.ilapage.goauto.agent"
|
||||
}
|
||||
}
|
||||
+135
-16
@@ -34,6 +34,9 @@ class PurchaseLiveAutomation(
|
||||
private val pause: (Long) -> Unit = Thread::sleep,
|
||||
) {
|
||||
private var submitAttempted = false
|
||||
private var savedAddressProof: ShippingAddressProof? = null
|
||||
private var savedAddressActivity: String? = null
|
||||
private var structuralSubmitAnchor: SnapshotNode? = null
|
||||
var lastOrderReadFailure: PurchaseOrderReadFailure? = null
|
||||
private set
|
||||
|
||||
@@ -301,10 +304,11 @@ class PurchaseLiveAutomation(
|
||||
private fun nodeArea(node: SnapshotNode): Long = node.bounds.width.toLong() * node.bounds.height
|
||||
|
||||
fun finalConfirmation(input: PurchaseExecutionInput, address: ShippingAddressProof): FinalConfirmationEvidence {
|
||||
structuralSubmitAnchor = null
|
||||
val snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
if (snapshot.packageName != PDD_PACKAGE) fail("PURCHASE_CONFIRMATION_LOST", "最终提交前页面已经变化,禁止创建订单")
|
||||
if (!hasFinalSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) {
|
||||
if (!hasSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) {
|
||||
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "收货地址保存后复核失败,未创建订单")
|
||||
}
|
||||
val submit = finalSubmitTargets(snapshot)
|
||||
@@ -318,6 +322,7 @@ class PurchaseLiveAutomation(
|
||||
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) fail("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
|
||||
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", "创建订单前数量复核失败")
|
||||
structuralSubmitAnchor = submit.single().takeIf { legacySubmitTargets(snapshot).isEmpty() }
|
||||
return FinalConfirmationEvidence(input.goodsId, input.mappedColor, input.mappedSize, input.quantity, price, address.suffix, snapshot.activityName.orEmpty())
|
||||
}
|
||||
|
||||
@@ -329,6 +334,18 @@ class PurchaseLiveAutomation(
|
||||
if (snapshot.packageName != PDD_PACKAGE || targets.size != 1) {
|
||||
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "最终页面或创建订单按钮已变化,禁止创建订单")
|
||||
}
|
||||
if (legacySubmitTargets(snapshot).isEmpty() && structuralSubmitAnchor == null) {
|
||||
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "下单按钮尚未完成最终复核,禁止创建订单")
|
||||
}
|
||||
structuralSubmitAnchor?.let { anchor ->
|
||||
val fresh = targets.single()
|
||||
if (fresh.path != anchor.path || fresh.className != anchor.className || fresh.label != anchor.label ||
|
||||
kotlin.math.abs(fresh.bounds.left - anchor.bounds.left) > 32 ||
|
||||
kotlin.math.abs(fresh.bounds.top - anchor.bounds.top) > 32 ||
|
||||
kotlin.math.abs(fresh.bounds.right - anchor.bounds.right) > 32 ||
|
||||
kotlin.math.abs(fresh.bounds.bottom - anchor.bounds.bottom) > 32
|
||||
) fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "最终下单按钮已变化,禁止创建订单")
|
||||
}
|
||||
submitAttempted = true
|
||||
when (driver.clickFresh(targets.single())) {
|
||||
FreshActionResult.SUCCESS -> Unit
|
||||
@@ -466,6 +483,9 @@ class PurchaseLiveAutomation(
|
||||
snapshot.packageName == PDD_PACKAGE && snapshot.activityName in PDD_PAYMENT_ACTIVITIES
|
||||
|
||||
private fun editAndVerifyAddress(panel: UiSnapshot, suffix: String): ShippingAddressProof {
|
||||
savedAddressProof = null
|
||||
savedAddressActivity = null
|
||||
structuralSubmitAnchor = null
|
||||
val modify = addressModifyTargets(panel)
|
||||
if (modify.size != 1) fail("PURCHASE_ADDRESS_EDIT_AMBIGUOUS", "没有找到唯一的地址修改按钮,未创建订单")
|
||||
click(modify.single(), "修改地址")
|
||||
@@ -485,21 +505,27 @@ class PurchaseLiveAutomation(
|
||||
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(), "保存地址")
|
||||
val savedEvidence = waitForStableAddressEditorExit()
|
||||
if (!hasFinalSavedAddressEvidence(savedEvidence, expected, suffix)) {
|
||||
var savedEvidence = waitForStableAddressEditorExit()
|
||||
if (!savedAddressPageReady(savedEvidence, expected, suffix)) {
|
||||
if (isPurchaseConfirmationPanel(savedEvidence)) {
|
||||
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
|
||||
savedEvidence = restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
|
||||
} else {
|
||||
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
|
||||
val returned = waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
|
||||
hasFinalSavedAddressEvidence(it, expected, suffix) || isPurchaseConfirmationPanel(it)
|
||||
savedAddressPageReady(it, expected, suffix) || isPurchaseConfirmationPanel(it)
|
||||
}
|
||||
if (!hasFinalSavedAddressEvidence(returned, expected, suffix)) {
|
||||
savedEvidence = if (!savedAddressPageReady(returned, expected, suffix)) {
|
||||
restoreFinalEvidenceInCurrentPanel(returned, expected, suffix)
|
||||
}
|
||||
} else returned
|
||||
}
|
||||
}
|
||||
return ShippingAddressProof(expected, suffix)
|
||||
val proof = ShippingAddressProof(expected, suffix)
|
||||
savedAddressProof = proof
|
||||
savedAddressActivity = savedEvidence.activityName
|
||||
if (finalSubmitTargets(savedEvidence).size != 1) {
|
||||
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "地址已保存,但未能识别唯一的下单按钮,未创建订单")
|
||||
}
|
||||
return proof
|
||||
}
|
||||
|
||||
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
|
||||
@@ -516,17 +542,18 @@ class PurchaseLiveAutomation(
|
||||
initial: UiSnapshot,
|
||||
expected: String,
|
||||
suffix: String,
|
||||
) {
|
||||
): UiSnapshot {
|
||||
var snapshot = initial
|
||||
var previousSignature: String? = null
|
||||
repeat(ADDRESS_CONFIRMATION_SCROLL_LIMIT) { attempt ->
|
||||
if (hasFinalSavedAddressEvidence(snapshot, expected, suffix)) return
|
||||
if (savedAddressPageReady(snapshot, expected, suffix)) return snapshot
|
||||
if (!isPurchaseConfirmationPanel(snapshot)) {
|
||||
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后采购面板发生变化,未创建订单")
|
||||
}
|
||||
val panels = purchasePanelScrollTargets(snapshot)
|
||||
val panels = savedAddressPanelScrollTargets(snapshot)
|
||||
if (panels.size != 1) {
|
||||
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后没有找到唯一的规格面板滚动区域,未创建订单")
|
||||
val reason = if (panels.isEmpty()) "未找到可用的规格面板滚动区域" else "找到多个规格面板滚动区域"
|
||||
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后${reason},未创建订单")
|
||||
}
|
||||
val panel = panels.single()
|
||||
val signature = viewportSignature(snapshot, panel)
|
||||
@@ -541,9 +568,10 @@ class PurchaseLiveAutomation(
|
||||
snapshot = driver.capture()
|
||||
pageProblem(snapshot)
|
||||
}
|
||||
if (!hasFinalSavedAddressEvidence(snapshot, expected, suffix)) {
|
||||
if (!savedAddressPageReady(snapshot, expected, suffix)) {
|
||||
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板未找到完整订单确认信息,未创建订单")
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private fun hasSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean {
|
||||
@@ -560,8 +588,9 @@ class PurchaseLiveAutomation(
|
||||
return suffixMatches.size == 1
|
||||
}
|
||||
|
||||
private fun hasFinalSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
|
||||
hasSavedAddressEvidence(snapshot, expected, suffix) && finalSubmitTargets(snapshot).size == 1
|
||||
private fun savedAddressPageReady(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
|
||||
hasSavedAddressEvidence(snapshot, expected, suffix) &&
|
||||
(legacySubmitTargets(snapshot).isNotEmpty() || savedPurchasePanel(snapshot, expected, suffix) != null)
|
||||
|
||||
private fun waitForStableAddressEditorExit(): UiSnapshot {
|
||||
var consecutiveExits = 0
|
||||
@@ -594,6 +623,20 @@ class PurchaseLiveAutomation(
|
||||
fail(code, message)
|
||||
}
|
||||
|
||||
private fun savedAddressPanelScrollTargets(snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
val panel = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
|
||||
.specPanelContainer ?: return purchasePanelScrollTargets(snapshot)
|
||||
val screenWidth = snapshot.nodes.maxOfOrNull { it.bounds.right }?.coerceAtLeast(1) ?: 1
|
||||
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
|
||||
return snapshot.nodes.filter { node ->
|
||||
node.visible && node.enabled && node.scrollable &&
|
||||
node.bounds.width * 100 >= screenWidth * 60 &&
|
||||
node.bounds.height * 100 >= screenHeight * 20 &&
|
||||
node.bounds.left >= panel.bounds.left && node.bounds.right <= panel.bounds.right &&
|
||||
node.bounds.top >= panel.bounds.top && node.bounds.bottom <= panel.bounds.bottom
|
||||
}.distinctBy { it.path }
|
||||
}
|
||||
|
||||
private fun purchasePanelScrollTargets(snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
val screenWidth = snapshot.nodes.maxOfOrNull { it.bounds.right }?.coerceAtLeast(1) ?: 1
|
||||
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
|
||||
@@ -681,11 +724,84 @@ class PurchaseLiveAutomation(
|
||||
pause(500)
|
||||
}
|
||||
|
||||
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
|
||||
private fun legacySubmitTargets(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) } },
|
||||
)
|
||||
|
||||
/** Only the just-saved address flow may use a footer whose display copy is unknown. */
|
||||
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> {
|
||||
val legacy = legacySubmitTargets(snapshot)
|
||||
if (legacy.isNotEmpty()) return legacy
|
||||
val proof = savedAddressProof ?: return emptyList()
|
||||
if (snapshot.activityName != savedAddressActivity) return emptyList()
|
||||
val panel = savedPurchasePanel(snapshot, proof.expectedAddress, proof.suffix) ?: return emptyList()
|
||||
val visible = snapshot.nodes.filter { it.visible }
|
||||
val byPath = visible.associateBy(SnapshotNode::path)
|
||||
val candidates = visible.filter { node ->
|
||||
node.path.startsWith("${panel.path}/") && node.enabled && node.clickable && !node.scrollable &&
|
||||
node.bounds.width >= panel.bounds.width / 5 && node.bounds.height >= 2 &&
|
||||
node.bounds.height <= panel.bounds.height / 4 &&
|
||||
node.bounds.bottom <= panel.bounds.bottom &&
|
||||
node.bounds.bottom >= panel.bounds.bottom - panel.bounds.height / 20 &&
|
||||
node.bounds.left >= panel.bounds.left && node.bounds.right <= panel.bounds.right
|
||||
}.filter { node ->
|
||||
// A spec option at the scroll edge is not a fixed footer action.
|
||||
var parent = node.parentPath?.let(byPath::get)
|
||||
var inScroller = false
|
||||
while (parent != null && parent.path != panel.path) {
|
||||
if (parent.scrollable) inScroller = true
|
||||
parent = parent.parentPath?.let(byPath::get)
|
||||
}
|
||||
!inScroller && parent?.path == panel.path
|
||||
}
|
||||
val independent = candidates.filter { parent ->
|
||||
candidates.none { it.path.startsWith("${parent.path}/") }
|
||||
}
|
||||
// Do not silently choose one of several footer actions by text or price.
|
||||
if (independent.size != 1) return emptyList()
|
||||
val button = independent.single()
|
||||
val content = visible.filter { it.path == button.path || it.path.startsWith("${button.path}/") }
|
||||
val labels = content.map { it.label.replace(" ", "") }
|
||||
if (labels.any { label -> STRUCTURAL_ACTION_DENY.any(label::contains) } ||
|
||||
labels.none { FOOTER_PRICE.containsMatchIn(it) } ||
|
||||
labels.none { FOOTER_PRICE.replace(it, "").any(Char::isLetter) }
|
||||
) return emptyList()
|
||||
// Keep the original text anchor; clickFresh reacquires it and its nearest clickable parent.
|
||||
return listOfNotNull(content.firstOrNull {
|
||||
it.enabled && it.label.isNotBlank() && nearestClickableAncestor(it, byPath)?.path == button.path
|
||||
})
|
||||
}
|
||||
|
||||
private fun savedPurchasePanel(snapshot: UiSnapshot, expected: String, suffix: String): SnapshotNode? {
|
||||
if (snapshot.packageName != PDD_PACKAGE || snapshot.activityName.isNullOrBlank() ||
|
||||
isKnownPddPaymentActivity(snapshot) || !hasSavedAddressEvidence(snapshot, expected, suffix)
|
||||
) return null
|
||||
val visible = snapshot.nodes.filter { it.visible }
|
||||
if (visible.any { node -> STRUCTURAL_PAGE_DENY.any(node.label::contains) }) return null
|
||||
val resolution = resolveAddressEntries(snapshot)
|
||||
if (resolution.addressCardCount != 1 || resolution.tapTargetCount != 1) return null
|
||||
val address = resolution.target?.node ?: return null
|
||||
val quantities = visible.filter { it.enabled && it.className?.endsWith("EditText") == true && (it.label.toLongOrNull() ?: 0) > 0 }
|
||||
val summaries = visible.filter { it.label.replace(" ", "").startsWith("已选") }
|
||||
val evidence = quantities.singleOrNull() ?: summaries.singleOrNull() ?: return null
|
||||
val byPath = visible.associateBy(SnapshotNode::path)
|
||||
val screenHeight = visible.maxOfOrNull { it.bounds.bottom } ?: return null
|
||||
val screenWidth = visible.maxOfOrNull { it.bounds.right } ?: return null
|
||||
var common = address.parentPath?.let(byPath::get)
|
||||
while (common != null) {
|
||||
if (evidence.path.startsWith("${common.path}/")) {
|
||||
// Nearest common ancestor is the whole popup, not its inner spec RecyclerView.
|
||||
return common.takeIf {
|
||||
!it.scrollable && it.bounds.width >= screenWidth * 3 / 5 &&
|
||||
it.bounds.height > 0 && it.bounds.height < screenHeight * 95 / 100
|
||||
}
|
||||
}
|
||||
common = common.parentPath?.let(byPath::get)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun orderConfirmationReady(snapshot: UiSnapshot): Boolean {
|
||||
if (snapshot.packageName != PDD_PACKAGE) return false
|
||||
if (snapshot.nodes.any { it.visible && it.enabled && MASKED_PHONE.containsMatchIn(it.label) }) return true
|
||||
@@ -749,6 +865,9 @@ class PurchaseLiveAutomation(
|
||||
const val WECHAT_PACKAGE = "com.tencent.mm"
|
||||
val MASKED_PHONE = Regex("(?<![0-9])[0-9]{3}\\*{4}[0-9]{4}(?![0-9])")
|
||||
val FINAL_SUBMIT_MARKERS = listOf("提交订单", "现在买,仅", "确认购买")
|
||||
val FOOTER_PRICE = Regex("(?:[¥¥]\\s*\\d+(?:\\.\\d{1,2})?|\\d+(?:\\.\\d{1,2})?\\s*元)")
|
||||
val STRUCTURAL_ACTION_DENY = listOf("支付", "付款", "先用后付", "收货", "地址", "修改", "保存", "取消", "返回", "关闭", "客服", "收藏", "订单详情", "查看订单")
|
||||
val STRUCTURAL_PAGE_DENY = listOf("立即支付", "确认支付", "输入支付密码", "待付款", "待支付", "订单编号", "订单号", "订单详情", "修改收货地址", "选择收货地址")
|
||||
val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码")
|
||||
val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付")
|
||||
val ORDER_DETAIL_ENTRY_MARKERS = setOf("查看订单", "订单详情")
|
||||
|
||||
+3
-2
@@ -425,6 +425,7 @@ class PurchaseRehearsalExecutor(
|
||||
wait.failure?.let { return it }
|
||||
if (wait.opened) return null
|
||||
if (wait.changed && recoverableSpecEntry(screen, wait.screen, action, beforeIdentity) == null) {
|
||||
panelDiagnostic("entryRecovery=rejected;reason=$entryRecoveryReason;pageChanged=${wait.changed};${panelEvidence(wait.screen)}")
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
@@ -500,9 +501,9 @@ class PurchaseRehearsalExecutor(
|
||||
private fun waitForSpecPanel(input: PurchaseExecutionInput, beforeSignature: List<SpecActionNodeSignature>): SpecPanelWait {
|
||||
var last = currentScreen(input)
|
||||
var changed = false
|
||||
repeat(SPEC_POST_CLICK_VERIFY_POLLS) {
|
||||
repeat(SPEC_POST_CLICK_VERIFY_POLLS) { poll ->
|
||||
last = currentScreen(input)
|
||||
panelDiagnostic(panelEvidence(last))
|
||||
panelDiagnostic("${panelEvidence(last)};entryWaitPolls=${poll + 1};entryWaitMillis=${poll * SPEC_SELECTION_POLL_MILLIS};closeControl=${last.hasCloseControl};paymentArea=${last.hasPaymentArea}")
|
||||
if (last.reviewPageOpen) {
|
||||
return SpecPanelWait(last, false, true, leaveUnexpectedReviewPage(input))
|
||||
}
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
package cn.ilapage.goauto.agent.persistence
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.FreshActionResult
|
||||
import cn.ilapage.goauto.agent.automation.FreshClickReason
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
|
||||
data class PurchaseDiagnosticContext(
|
||||
val taskId: Long,
|
||||
val attemptId: String,
|
||||
val deviceId: Long,
|
||||
val ruleHash: String,
|
||||
val agentVersion: String,
|
||||
val phase: String,
|
||||
)
|
||||
|
||||
/** Private, bounded diagnostics only. Neither raw evidence nor write errors escape this store. */
|
||||
class PurchaseDiagnosticStore(private val directory: File, private val now: () -> Long = System::currentTimeMillis) {
|
||||
@Synchronized
|
||||
fun record(context: PurchaseDiagnosticContext, elapsedMs: Long, evidence: String): Boolean = runCatching {
|
||||
require(context.taskId > 0 && context.deviceId > 0 && ID.matches(context.attemptId))
|
||||
require(HASH.matches(context.ruleHash) && VERSION.matches(context.agentVersion))
|
||||
require(context.phase in setOf("purchase", "spec_probe"))
|
||||
val fields = sanitize(evidence)
|
||||
if (fields.length() == 0) return false
|
||||
val timestamp = now()
|
||||
val line = JSONObject().put("taskId", context.taskId).put("attemptId", context.attemptId)
|
||||
.put("deviceId", context.deviceId).put("ruleHash", context.ruleHash)
|
||||
.put("agentVersion", context.agentVersion).put("phase", context.phase)
|
||||
.put("timestamp", timestamp).put("elapsedMs", elapsedMs.coerceAtLeast(0))
|
||||
.put("evidence", fields).toString()
|
||||
check(directory.isDirectory || directory.mkdirs())
|
||||
val file = File(directory, "${context.taskId}_${context.attemptId}.jsonl")
|
||||
file.appendText(line + "\n", Charsets.UTF_8)
|
||||
val lines = file.readLines(Charsets.UTF_8)
|
||||
if (lines.size > MAX_EVENTS) file.writeText(lines.takeLast(MAX_EVENTS).joinToString("\n", postfix = "\n"), Charsets.UTF_8)
|
||||
file.setLastModified(timestamp)
|
||||
prune(timestamp)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
|
||||
@Synchronized
|
||||
fun prune(timestamp: Long = now()) {
|
||||
runCatching {
|
||||
val files = directory.listFiles()?.filter { it.isFile && FILE_NAME.matches(it.name) }.orEmpty()
|
||||
files.filter { it.lastModified() < timestamp - RETENTION_MS }.forEach { it.delete() }
|
||||
files.filter(File::exists).sortedWith(compareByDescending<File> { it.lastModified() }.thenBy { it.name })
|
||||
.drop(MAX_ATTEMPTS).forEach { it.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val MAX_ATTEMPTS = 5
|
||||
const val MAX_EVENTS = 128
|
||||
const val RETENTION_MS = 7L * 24 * 60 * 60 * 1000
|
||||
private val ID = Regex("[A-Za-z0-9-]{1,80}")
|
||||
private val HASH = Regex("[0-9a-f]{64}")
|
||||
private val VERSION = Regex("[0-9]+\\.[0-9]+\\.[0-9]+")
|
||||
private val FILE_NAME = Regex("[1-9][0-9]*_[A-Za-z0-9-]{1,80}\\.jsonl")
|
||||
private val numbers = setOf("specEntryCandidates", "explicit", "nested", "bottomPurchase", "entryReadyWaitPolls",
|
||||
"entryReadyWaitMillis", "scrollables", "headings", "options", "entryWaitPolls", "entryWaitMillis")
|
||||
private val booleans = setOf("panelAlreadyOpen", "reviewPage", "pageEvidence", "pageChanged", "summary", "quantity",
|
||||
"orderAction", "closeControl", "paymentArea")
|
||||
private val enums = mapOf(
|
||||
"event" to setOf("started"),
|
||||
"entrySource" to setOf("unknown", "explicit_selection", "nested_selection", "bottom_purchase", "bottom_purchase_rightmost"),
|
||||
"entryClick" to FreshActionResult.values().map { it.name }.toSet(),
|
||||
"entryReason" to FreshClickReason.values().map { it.name }.toSet(),
|
||||
"gesture" to FreshActionResult.values().map { it.name }.toSet(),
|
||||
"entryRecovery" to setOf("rejected", "attempted"),
|
||||
"type" to setOf("UNKNOWN", "NORMAL_SCROLLABLE", "NON_SCROLLABLE_CONFIRMATION", "ORDER_CONFIRMATION", "QUICK_CONFIRMATION"),
|
||||
"reason" to setOf("identity_missing", "identity_changed", "product_context_missing", "page_problem", "panel_open",
|
||||
"close_control", "payment_area", "order_action", "quantity_controls", "selection_summary", "title_missing",
|
||||
"title_changed", "entry_missing", "entry_source_missing", "entry_source_changed", "entry_label_changed",
|
||||
"entry_ambiguous", "rule_alias_mismatch", "entry_unavailable", "eligible"),
|
||||
)
|
||||
|
||||
internal fun sanitize(evidence: String): JSONObject {
|
||||
val result = JSONObject()
|
||||
evidence.take(4096).split(';').take(40).forEach { part ->
|
||||
val key = part.substringBefore('=')
|
||||
val value = part.substringAfter('=', "")
|
||||
when {
|
||||
key in numbers && value.matches(Regex("[0-9]{1,8}")) -> result.put(key, value.toLong())
|
||||
key in booleans && value in setOf("true", "false") -> result.put(key, value == "true")
|
||||
value in enums[key].orEmpty() -> result.put(key, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
-16
@@ -18,6 +18,10 @@ import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import cn.ilapage.goauto.agent.BuildConfig
|
||||
import cn.ilapage.goauto.agent.automation.PddCollectionReturnNavigator
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticContext
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticStore
|
||||
import java.io.File
|
||||
import cn.ilapage.goauto.agent.ClipboardRelayActivity
|
||||
import cn.ilapage.goauto.agent.MainActivity
|
||||
import cn.ilapage.goauto.agent.R
|
||||
@@ -91,6 +95,7 @@ class AgentForegroundService : Service() {
|
||||
private lateinit var settingsStore: AgentSettingsStore
|
||||
private lateinit var stateStore: AgentStateStore
|
||||
private lateinit var purchaseStore: PurchaseTaskStore
|
||||
private lateinit var purchaseDiagnostics: PurchaseDiagnosticStore
|
||||
private lateinit var diagnosticStore: AgentDiagnosticStore
|
||||
private lateinit var diagnosticRecorder: SafeAgentDiagnosticRecorder
|
||||
private lateinit var connectivityManager: ConnectivityManager
|
||||
@@ -109,6 +114,8 @@ class AgentForegroundService : Service() {
|
||||
// Wake locks and cooldown tickets are process-local. Never restore a stale UI flag.
|
||||
stateStore.setKeepScreenOn(false)
|
||||
purchaseStore = PurchaseTaskStore(this)
|
||||
purchaseDiagnostics = PurchaseDiagnosticStore(File(filesDir, "purchase_diagnostics"))
|
||||
runCatching { diagnosticExecutor.execute { purchaseDiagnostics.prune() } }
|
||||
diagnosticStore = AgentDiagnosticStore(this)
|
||||
diagnosticRecorder = SafeAgentDiagnosticRecorder(
|
||||
persist = { event ->
|
||||
@@ -350,11 +357,16 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
runningTaskId.set(task.taskId)
|
||||
stateStore.setActiveTask(task.taskId, "collection")
|
||||
val outcome = executeTask(api, task, credentials.token)
|
||||
val continuous = ContinuousCollectionPolicy.applies(
|
||||
settingsStore.continuousCollection(), task.source, task.attemptNumber, task.replacementOriginType,
|
||||
)
|
||||
val outcome = executeTask(api, task, credentials.token, continuous)
|
||||
publishCurrentPageResult(
|
||||
task.taskId,
|
||||
if (outcome.successful) {
|
||||
if (task.replacementOriginType.isNotBlank()) {
|
||||
if (continuous) {
|
||||
outcome.message ?: "采集已结束,请查看采集记录。"
|
||||
} else if (task.replacementOriginType.isNotBlank()) {
|
||||
"已替换,正在匹配规格"
|
||||
} else {
|
||||
"临时采集任务 #${task.taskId} 已结束,请查看采集记录。"
|
||||
@@ -480,6 +492,15 @@ class AgentForegroundService : Service() {
|
||||
} else {
|
||||
val diagnosticDeviceId = runCatching { identityStore.credentials()?.deviceId ?: 0L }.getOrDefault(0L)
|
||||
val diagnosticAttempt = task.taskAttemptId.takeIf { it.matches(Regex("^[a-zA-Z0-9-]{1,80}$")) } ?: "invalid"
|
||||
val diagnosticContext = PurchaseDiagnosticContext(task.taskId, task.taskAttemptId, diagnosticDeviceId,
|
||||
snapshotHash, BuildConfig.VERSION_NAME, task.phase)
|
||||
val diagnosticStarted = SystemClock.elapsedRealtime()
|
||||
val recordDiagnostic: (String) -> Unit = { evidence ->
|
||||
val elapsed = SystemClock.elapsedRealtime() - diagnosticStarted
|
||||
runCatching { diagnosticExecutor.execute { purchaseDiagnostics.record(diagnosticContext, elapsed, evidence) } }
|
||||
Unit
|
||||
}
|
||||
recordDiagnostic("event=started")
|
||||
PurchaseRehearsalExecutor(
|
||||
driver = accessibility,
|
||||
openLink = { PddLinkLauncher(this).open(it, preferDirect = true) },
|
||||
@@ -489,6 +510,7 @@ class AgentForegroundService : Service() {
|
||||
purchaseStore.updateStep(task.taskId, task.taskAttemptId, step)
|
||||
},
|
||||
panelDiagnostic = { evidence ->
|
||||
recordDiagnostic(evidence)
|
||||
Log.i("GoAutoPurchasePanel", "task=${task.taskId};attempt=$diagnosticAttempt;device=$diagnosticDeviceId;rule=$snapshotHash;$evidence")
|
||||
},
|
||||
beforeOrderSubmit = { evidence ->
|
||||
@@ -635,11 +657,29 @@ class AgentForegroundService : Service() {
|
||||
api: AgentApiClient,
|
||||
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
|
||||
token: String,
|
||||
continuous: Boolean = false,
|
||||
): TaskExecutionSummary {
|
||||
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
|
||||
acquireTaskWakeLock()
|
||||
return try {
|
||||
executeTaskWhileAwake(api, initialTask, token)
|
||||
val returnCount = settingsStore.continuousReturnCount()
|
||||
val outcome = executeTaskWhileAwake(api, initialTask, token, continuous)
|
||||
if (!continuous) outcome else {
|
||||
cancelIdleReturn("连续采集保留 PDD 页面")
|
||||
ContinuousCollectionPolicy.finish(continuous, outcome) {
|
||||
val driver = GoAutoAccessibilityService.instance ?: return@finish false
|
||||
val navigator = PddCollectionReturnNavigator(driver, driver::openAgentPreservingTab, driver::currentPackage)
|
||||
val returned = navigator.returnToAgent(returnCount)
|
||||
runCatching {
|
||||
val hash = java.security.MessageDigest.getInstance("SHA-256")
|
||||
.digest(initialTask.ruleSnapshot.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
|
||||
Log.i("GoAutoCollector", "task=${initialTask.taskId};attempt=${initialTask.attemptNumber};device=${stateStore.read().deviceId};rule=$hash;continuousReturn=$returned;configuredBacks=$returnCount;reason=${navigator.reason};actions=${navigator.actions}")
|
||||
}
|
||||
returned
|
||||
}.also { result ->
|
||||
if (result.successful) runCatching { stateStore.update("ONLINE", result.message.orEmpty(), tokenStored = true) }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
releaseTaskWakeLock()
|
||||
}
|
||||
@@ -649,6 +689,7 @@ class AgentForegroundService : Service() {
|
||||
api: AgentApiClient,
|
||||
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
|
||||
token: String,
|
||||
continuous: Boolean = false,
|
||||
): TaskExecutionSummary {
|
||||
var started = initialTask.status == "running"
|
||||
var resultSafelySubmitted = false
|
||||
@@ -755,25 +796,25 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
}
|
||||
stateStore.update("ONLINE", "任务 #${task.taskId} 已提交:${result.status}", tokenStored = true)
|
||||
beginPostCollectionCooldowns()
|
||||
TaskExecutionSummary(successful = true)
|
||||
beginPostCollectionCooldowns(returnToAgent = !continuous)
|
||||
TaskExecutionSummary(successful = true, resultStatus = result.status)
|
||||
} catch (error: TaskFailure) {
|
||||
if (started) {
|
||||
resultSafelySubmitted = failSafely(api, initialTask.taskId, token, error.code, error.message ?: "采集失败")
|
||||
if (resultSafelySubmitted) beginPostCollectionCooldowns()
|
||||
if (resultSafelySubmitted) beginPostCollectionCooldowns(returnToAgent = !continuous)
|
||||
}
|
||||
TaskExecutionSummary(false, error.code, error.message)
|
||||
} catch (error: AgentApiException) {
|
||||
if (error.code == "REPLACEMENT_ACTIVATION_FAILED") {
|
||||
resultSafelySubmitted = true
|
||||
beginPostCollectionCooldowns()
|
||||
beginPostCollectionCooldowns(returnToAgent = !continuous)
|
||||
}
|
||||
stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true)
|
||||
TaskExecutionSummary(false, error.code, error.message)
|
||||
} catch (error: Exception) {
|
||||
if (started) {
|
||||
resultSafelySubmitted = failSafely(api, initialTask.taskId, token, "AGENT_EXECUTION_ERROR", error.message ?: "Android 执行异常")
|
||||
if (resultSafelySubmitted) beginPostCollectionCooldowns()
|
||||
if (resultSafelySubmitted) beginPostCollectionCooldowns(returnToAgent = !continuous)
|
||||
}
|
||||
TaskExecutionSummary(false, "AGENT_EXECUTION_ERROR", error.message)
|
||||
} finally {
|
||||
@@ -832,8 +873,8 @@ class AgentForegroundService : Service() {
|
||||
executor.schedule(::triggerSync, IdleReturnCoordinator.DEFAULT_COOLDOWN_MILLIS, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
|
||||
private fun beginPostCollectionCooldowns() {
|
||||
beginIdleReturnCooldown()
|
||||
private fun beginPostCollectionCooldowns(returnToAgent: Boolean = true) {
|
||||
if (returnToAgent) beginIdleReturnCooldown() else cancelIdleReturn("连续采集不自动返回 Agent")
|
||||
val ticket = CollectionCooldownPolicy.arm(
|
||||
System.currentTimeMillis(),
|
||||
settingsStore.collectionIntervalRange(),
|
||||
@@ -1152,9 +1193,3 @@ class AgentForegroundService : Service() {
|
||||
}
|
||||
|
||||
private class TaskFailure(val code: String, message: String) : Exception(message)
|
||||
|
||||
private data class TaskExecutionSummary(
|
||||
val successful: Boolean,
|
||||
val code: String? = null,
|
||||
val message: String? = null,
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import cn.ilapage.goauto.agent.BuildConfig
|
||||
import cn.ilapage.goauto.agent.network.ServerUrlPolicy
|
||||
import kotlin.random.Random
|
||||
@@ -113,14 +114,38 @@ internal object TaskDispatchPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
class AgentSettingsStore(context: Context) {
|
||||
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
class AgentSettingsStore internal constructor(private val preferences: SharedPreferences) {
|
||||
constructor(context: Context) : this(context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE))
|
||||
|
||||
fun serverUrl(): String = preferences.getString(SERVER_URL, null)
|
||||
?: BuildConfig.DEFAULT_SERVER_URL.takeIf { it.isNotBlank() }.orEmpty()
|
||||
|
||||
fun deviceName(): String = preferences.getString(DEVICE_NAME, "").orEmpty()
|
||||
|
||||
fun continuousCollection(): Boolean = runCatching { preferences.getBoolean(CONTINUOUS_COLLECTION, false) }.getOrDefault(false)
|
||||
|
||||
fun continuousReturnCount(): Int = runCatching {
|
||||
preferences.getInt(CONTINUOUS_RETURN_COUNT, 3).takeIf { it in 1..5 } ?: 3
|
||||
}.getOrDefault(3)
|
||||
|
||||
fun saveContinuousReturnCount(count: Int) {
|
||||
require(count in 1..5) { "返回次数须为 1~5 次" }
|
||||
val previous = continuousReturnCount()
|
||||
if (!preferences.edit().putInt(CONTINUOUS_RETURN_COUNT, count).commit()) {
|
||||
preferences.edit().putInt(CONTINUOUS_RETURN_COUNT, previous).commit()
|
||||
error("返回次数未保存,请重试")
|
||||
}
|
||||
}
|
||||
|
||||
fun saveContinuousCollection(enabled: Boolean) {
|
||||
val previous = continuousCollection()
|
||||
if (!preferences.edit().putBoolean(CONTINUOUS_COLLECTION, enabled).commit()) {
|
||||
// SharedPreferences also updates memory on failed disk commits; restore the displayed value.
|
||||
preferences.edit().putBoolean(CONTINUOUS_COLLECTION, previous).commit()
|
||||
error("连续采集设置未保存,请重试")
|
||||
}
|
||||
}
|
||||
|
||||
fun historyDays(): Int = HistoryRangePolicy.stored(preferences.getInt(HISTORY_DAYS, 7))
|
||||
|
||||
fun collectionIntervalRange(): CollectionIntervalRange {
|
||||
@@ -177,6 +202,8 @@ class AgentSettingsStore(context: Context) {
|
||||
const val PREFERENCES = "goauto_agent_settings"
|
||||
const val SERVER_URL = "server_url"
|
||||
const val DEVICE_NAME = "device_name"
|
||||
const val CONTINUOUS_COLLECTION = "continuous_collection"
|
||||
const val CONTINUOUS_RETURN_COUNT = "continuous_collection_return_count"
|
||||
const val HISTORY_DAYS = "history_days"
|
||||
const val COLLECTION_INTERVAL_SECONDS = "collection_interval_seconds"
|
||||
const val COLLECTION_INTERVAL_START_SECONDS = "collection_interval_start_seconds"
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package cn.ilapage.goauto.agent.service
|
||||
|
||||
/** Only the foreground, newly requested temporary collection opts in. */
|
||||
internal object ContinuousCollectionPolicy {
|
||||
fun applies(enabled: Boolean, source: String, attemptNumber: Int, replacementOrigin: String): Boolean =
|
||||
enabled && source == "agent_current_page" && attemptNumber == 1 && replacementOrigin.isBlank()
|
||||
|
||||
fun shouldReturn(enabledForTask: Boolean, submitted: Boolean, status: String?): Boolean =
|
||||
enabledForTask && submitted && status == "completed"
|
||||
|
||||
fun finish(enabledForTask: Boolean, outcome: TaskExecutionSummary, navigate: () -> Boolean): TaskExecutionSummary {
|
||||
if (!enabledForTask || !outcome.successful) return outcome
|
||||
if (!shouldReturn(enabledForTask, outcome.successful, outcome.resultStatus)) {
|
||||
return outcome.copy(message = "采集部分成功,已保留现场,请查看采集记录。")
|
||||
}
|
||||
val returned = runCatching(navigate).getOrDefault(false)
|
||||
return outcome.copy(message = if (returned) "采集已完成,返回操作已结束,已切回 Agent。"
|
||||
else "采集已完成,自动返回未完成,请手动处理当前页面并打开 Agent。")
|
||||
}
|
||||
}
|
||||
|
||||
internal data class TaskExecutionSummary(
|
||||
val successful: Boolean,
|
||||
val code: String? = null,
|
||||
val message: String? = null,
|
||||
val resultStatus: String? = null,
|
||||
)
|
||||
@@ -34,6 +34,7 @@ import cn.ilapage.goauto.agent.update.AgentAppUpdateManager
|
||||
import cn.ilapage.goauto.agent.update.UpdateCheckResult
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import java.util.concurrent.Executors
|
||||
@@ -64,6 +65,10 @@ class AgentSettingsFragment : Fragment() {
|
||||
private lateinit var collectionIntervalEndLayout: TextInputLayout
|
||||
private lateinit var collectionIntervalEndInput: TextInputEditText
|
||||
private lateinit var collectionIntervalFeedback: TextView
|
||||
private lateinit var continuousCollectionSwitch: SwitchMaterial
|
||||
private lateinit var continuousReturnCountButton: MaterialButton
|
||||
private lateinit var continuousCollectionFeedback: TextView
|
||||
private var refreshingContinuousCollection = false
|
||||
private lateinit var historyDaysLayout: TextInputLayout
|
||||
private lateinit var historyDaysInput: TextInputEditText
|
||||
private lateinit var historySyncButton: MaterialButton
|
||||
@@ -257,6 +262,69 @@ class AgentSettingsFragment : Fragment() {
|
||||
collectionIntervalFeedback.setPadding(0, context.dp(8), 0, 0)
|
||||
addView(collectionIntervalFeedback)
|
||||
}))
|
||||
addView(context.card(context.cardColumn().apply {
|
||||
continuousCollectionSwitch = SwitchMaterial(context).apply {
|
||||
// Use the switch style supplied by our MaterialComponents (M2) theme.
|
||||
showText = false
|
||||
textOn = "开启"
|
||||
textOff = "关闭"
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
text = "连续采集"
|
||||
textSize = 18f
|
||||
setTextColor(context.getColor(R.color.agent_text))
|
||||
minHeight = context.dp(48)
|
||||
isChecked = settingsStore.continuousCollection()
|
||||
setOnCheckedChangeListener { _, enabled ->
|
||||
if (!refreshingContinuousCollection) {
|
||||
val current = stateStore.read()
|
||||
val result = runCatching {
|
||||
check(current.code != "BUSY" && current.currentTaskId == null) { "任务结束后可修改" }
|
||||
settingsStore.saveContinuousCollection(enabled)
|
||||
}
|
||||
refreshingContinuousCollection = true
|
||||
isChecked = settingsStore.continuousCollection()
|
||||
refreshingContinuousCollection = false
|
||||
continuousCollectionFeedback.text = result.fold(
|
||||
onSuccess = { "" },
|
||||
onFailure = { "未保存:${it.message ?: "请重试"}" },
|
||||
)
|
||||
continuousCollectionFeedback.visibility = if (continuousCollectionFeedback.text.isEmpty()) View.GONE else View.VISIBLE
|
||||
refreshContinuousReturnCount()
|
||||
}
|
||||
}
|
||||
}
|
||||
addView(continuousCollectionSwitch, fullWidth())
|
||||
continuousReturnCountButton = MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
|
||||
minHeight = context.dp(48)
|
||||
setTextColor(context.getColor(R.color.agent_text))
|
||||
setOnClickListener {
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle("返回次数")
|
||||
.setSingleChoiceItems(arrayOf("1 次", "2 次", "3 次", "4 次", "5 次"), settingsStore.continuousReturnCount() - 1) { dialog, which ->
|
||||
val result = runCatching {
|
||||
val current = stateStore.read()
|
||||
check(current.code != "BUSY" && current.currentTaskId == null) { "任务结束后可修改" }
|
||||
check(settingsStore.continuousCollection()) { "请先开启连续采集" }
|
||||
settingsStore.saveContinuousReturnCount(which + 1)
|
||||
}
|
||||
continuousCollectionFeedback.text = result.fold(onSuccess = { "" }, onFailure = { "未保存:${it.message ?: "请重试"}" })
|
||||
continuousCollectionFeedback.visibility = if (result.isSuccess) View.GONE else View.VISIBLE
|
||||
refreshContinuousReturnCount()
|
||||
dialog.dismiss()
|
||||
}
|
||||
.setNegativeButton("取消", null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
addView(continuousReturnCountButton, fullWidth(8))
|
||||
refreshContinuousReturnCount()
|
||||
continuousCollectionFeedback = context.label("", 14f, context.getColor(R.color.agent_text_muted)).apply {
|
||||
visibility = View.GONE
|
||||
accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE
|
||||
}
|
||||
addView(continuousCollectionFeedback, fullWidth(8))
|
||||
}))
|
||||
addView(context.card(context.cardColumn().apply {
|
||||
addView(context.label("任务记录", 18f, context.getColor(R.color.agent_text), true))
|
||||
val syncRow = LinearLayout(context).apply {
|
||||
@@ -654,11 +722,23 @@ class AgentSettingsFragment : Fragment() {
|
||||
return normalizedUrl
|
||||
}
|
||||
|
||||
private fun refreshContinuousReturnCount() {
|
||||
val current = stateStore.read()
|
||||
continuousReturnCountButton.text = "返回次数:${settingsStore.continuousReturnCount()} 次"
|
||||
continuousReturnCountButton.visibility = if (settingsStore.continuousCollection()) View.VISIBLE else View.GONE
|
||||
continuousReturnCountButton.isEnabled = current.code != "BUSY" && current.currentTaskId == null
|
||||
}
|
||||
|
||||
private fun refreshDiagnostics() {
|
||||
if (!isAdded || view == null) return
|
||||
val context = requireContext()
|
||||
val state = stateStore.read()
|
||||
val busy = state.code == "BUSY" || state.currentTaskId != null
|
||||
continuousCollectionSwitch.isEnabled = !busy
|
||||
refreshContinuousReturnCount()
|
||||
if (busy) continuousCollectionFeedback.text = "任务结束后可修改"
|
||||
else if (continuousCollectionFeedback.text == "任务结束后可修改") continuousCollectionFeedback.text = ""
|
||||
continuousCollectionFeedback.visibility = if (continuousCollectionFeedback.text.isEmpty()) View.GONE else View.VISIBLE
|
||||
if (busy && updateDownloading) updateCancelled.set(true)
|
||||
val editable = SettingsAvailabilityResolver.editable(state.code, state.currentTaskId, testing)
|
||||
serverInput.isEnabled = editable
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import cn.ilapage.goauto.agent.service.AgentSettingsStore
|
||||
import java.lang.reflect.Proxy
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class ContinuousCollectionSettingsTest {
|
||||
@Test fun `default off persists and survives store recreation`() {
|
||||
val disk = MemoryPreferences()
|
||||
val store = AgentSettingsStore(disk.preferences)
|
||||
assertFalse(store.continuousCollection())
|
||||
store.saveContinuousCollection(true)
|
||||
assertTrue(AgentSettingsStore(disk.preferences).continuousCollection())
|
||||
store.saveContinuousCollection(false)
|
||||
assertFalse(AgentSettingsStore(disk.preferences).continuousCollection())
|
||||
}
|
||||
|
||||
@Test fun `failed disk commit restores previous in memory preference`() {
|
||||
val disk = MemoryPreferences()
|
||||
val store = AgentSettingsStore(disk.preferences)
|
||||
store.saveContinuousCollection(true)
|
||||
disk.failNextCommit = true
|
||||
assertTrue(runCatching { store.saveContinuousCollection(false) }.isFailure)
|
||||
assertTrue(store.continuousCollection())
|
||||
assertTrue(AgentSettingsStore(disk.preferences).continuousCollection())
|
||||
}
|
||||
|
||||
@Test fun `return count defaults three and persists valid bounds`() {
|
||||
val disk = MemoryPreferences()
|
||||
val store = AgentSettingsStore(disk.preferences)
|
||||
assertEquals(3, store.continuousReturnCount())
|
||||
for (count in 1..5) {
|
||||
store.saveContinuousReturnCount(count)
|
||||
assertEquals(count, AgentSettingsStore(disk.preferences).continuousReturnCount())
|
||||
}
|
||||
for (count in listOf(0, 6)) {
|
||||
assertTrue(runCatching { store.saveContinuousReturnCount(count) }.isFailure)
|
||||
assertEquals(5, store.continuousReturnCount())
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `return count failed write restores previous and invalid stored value defaults`() {
|
||||
val disk = MemoryPreferences()
|
||||
val store = AgentSettingsStore(disk.preferences)
|
||||
store.saveContinuousReturnCount(2)
|
||||
disk.failNextCommit = true
|
||||
assertTrue(runCatching { store.saveContinuousReturnCount(4) }.isFailure)
|
||||
assertEquals(2, AgentSettingsStore(disk.preferences).continuousReturnCount())
|
||||
disk.preferences.edit().putInt("continuous_collection_return_count", 99).commit()
|
||||
assertEquals(3, store.continuousReturnCount())
|
||||
}
|
||||
|
||||
private class MemoryPreferences {
|
||||
private val values = mutableMapOf<String, Any>()
|
||||
var failNextCommit = false
|
||||
val preferences = Proxy.newProxyInstance(SharedPreferences::class.java.classLoader, arrayOf(SharedPreferences::class.java)) { _, method, args ->
|
||||
when (method.name) {
|
||||
"getBoolean", "getInt" -> values[args!![0] as String] ?: args[1]
|
||||
"edit" -> editor()
|
||||
else -> error("Unexpected preference access ${method.name}")
|
||||
}
|
||||
} as SharedPreferences
|
||||
|
||||
private fun editor(): SharedPreferences.Editor {
|
||||
val pending = mutableMapOf<String, Any>()
|
||||
return Proxy.newProxyInstance(SharedPreferences.Editor::class.java.classLoader, arrayOf(SharedPreferences.Editor::class.java)) { proxy, method, args ->
|
||||
when (method.name) {
|
||||
"putBoolean", "putInt" -> { pending[args!![0] as String] = args[1]; proxy }
|
||||
"commit" -> {
|
||||
values.putAll(pending)
|
||||
val success = !failNextCommit
|
||||
failNextCommit = false
|
||||
success
|
||||
}
|
||||
else -> error("Unexpected preference edit ${method.name}")
|
||||
}
|
||||
} as SharedPreferences.Editor
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package cn.ilapage.goauto.agent
|
||||
|
||||
import cn.ilapage.goauto.agent.automation.*
|
||||
import cn.ilapage.goauto.agent.service.ContinuousCollectionPolicy
|
||||
import cn.ilapage.goauto.agent.service.TaskExecutionSummary
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class ContinuousCollectionTest {
|
||||
private val complete = TaskExecutionSummary(true, resultStatus = "completed")
|
||||
|
||||
@Test fun `only new ordinary temporary collection participates`() {
|
||||
assertTrue(ContinuousCollectionPolicy.applies(true, "agent_current_page", 1, ""))
|
||||
assertFalse(ContinuousCollectionPolicy.applies(false, "agent_current_page", 1, ""))
|
||||
for (source in listOf("admin", "purchase")) assertFalse(ContinuousCollectionPolicy.applies(true, source, 1, ""))
|
||||
assertFalse(ContinuousCollectionPolicy.applies(true, "agent_current_page", 2, ""))
|
||||
for (origin in listOf("collection", "purchase")) assertFalse(ContinuousCollectionPolicy.applies(true, "agent_current_page", 1, origin))
|
||||
}
|
||||
|
||||
@Test fun `only acknowledged complete result can navigate`() {
|
||||
for (status in listOf("completed_partial", "failed", "running", null)) assertFalse(ContinuousCollectionPolicy.shouldReturn(true, true, status))
|
||||
assertFalse(ContinuousCollectionPolicy.shouldReturn(true, false, "completed"))
|
||||
assertFalse(ContinuousCollectionPolicy.shouldReturn(false, true, "completed"))
|
||||
assertTrue(ContinuousCollectionPolicy.shouldReturn(true, true, "completed"))
|
||||
}
|
||||
|
||||
@Test fun `failure partial and disabled never invoke navigation`() {
|
||||
val failed = TaskExecutionSummary(false, "failed", "failure")
|
||||
assertEquals(failed, ContinuousCollectionPolicy.finish(true, failed) { error("unexpected") })
|
||||
assertEquals(complete, ContinuousCollectionPolicy.finish(false, complete) { error("unexpected") })
|
||||
val partial = ContinuousCollectionPolicy.finish(true, complete.copy(resultStatus = "completed_partial")) { error("unexpected") }
|
||||
assertTrue(partial.message!!.contains("保留现场"))
|
||||
}
|
||||
|
||||
@Test fun `navigation failure never rewrites collection success`() {
|
||||
for (navigate in listOf<() -> Boolean>({ false }, { error("failure") })) {
|
||||
val result = ContinuousCollectionPolicy.finish(true, complete, navigate)
|
||||
assertTrue(result.successful)
|
||||
assertEquals(complete.code, result.code)
|
||||
assertEquals("completed", result.resultStatus)
|
||||
assertTrue(result.message!!.contains("手动"))
|
||||
}
|
||||
val success = ContinuousCollectionPolicy.finish(true, complete) { true }
|
||||
assertTrue(success.message!!.contains("已切回 Agent"))
|
||||
assertFalse(success.message!!.contains("首页"))
|
||||
}
|
||||
|
||||
@Test fun `configured counts send exact backs even when page is unchanged`() {
|
||||
for (count in 1..5) {
|
||||
val d = Driver()
|
||||
val waits = mutableListOf<Long>()
|
||||
val navigator = d.navigator { waits += it }
|
||||
assertTrue(navigator.returnToAgent(count))
|
||||
assertEquals(count, d.backs)
|
||||
assertEquals(count, navigator.actions)
|
||||
assertEquals(1, d.opens)
|
||||
assertEquals(List(count + 1) { 500L }, waits)
|
||||
assertEquals("count_completed", navigator.reason)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `invalid counts do not navigate`() {
|
||||
for (count in listOf(-1, 0, 6, Int.MAX_VALUE)) {
|
||||
val d = Driver()
|
||||
assertFalse(d.navigator().returnToAgent(count))
|
||||
assertEquals(0, d.backs + d.opens)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `leaving pdd stops remaining backs and returns to agent`() {
|
||||
val d = Driver().apply { leaveAfter = 1 }
|
||||
val navigator = d.navigator()
|
||||
assertTrue(navigator.returnToAgent(3))
|
||||
assertEquals(1, d.backs)
|
||||
assertEquals(1, d.opens)
|
||||
assertEquals("left_pdd", navigator.reason)
|
||||
}
|
||||
|
||||
@Test fun `already outside pdd sends no back`() {
|
||||
val d = Driver().apply { pkg = "launcher" }
|
||||
assertTrue(d.navigator().returnToAgent(3))
|
||||
assertEquals(0, d.backs)
|
||||
assertEquals(1, d.opens)
|
||||
}
|
||||
|
||||
@Test fun `back failure stops without retry but can return to agent`() {
|
||||
val d = Driver().apply { backSuccess = false }
|
||||
val navigator = d.navigator()
|
||||
assertTrue(navigator.returnToAgent(3))
|
||||
assertEquals(1, d.backs)
|
||||
assertEquals("back_failed", navigator.reason)
|
||||
}
|
||||
|
||||
@Test fun `risk pages preserve scene including after last back`() {
|
||||
for (after in listOf(0, 1)) {
|
||||
val d = Driver().apply { riskAfter = after }
|
||||
val navigator = d.navigator()
|
||||
assertFalse(navigator.returnToAgent(1))
|
||||
assertEquals(after, d.backs)
|
||||
assertEquals(0, d.opens)
|
||||
assertEquals("unsafe_page", navigator.reason)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `switching foreground during capture never sends back to other app`() {
|
||||
val d = Driver().apply { changeDuringCapture = true }
|
||||
assertTrue(d.navigator().returnToAgent(3))
|
||||
assertEquals(0, d.backs)
|
||||
}
|
||||
|
||||
@Test fun `agent launch and foreground confirmation failures are explicit`() {
|
||||
for (launch in listOf(false, true)) {
|
||||
val d = Driver().apply { launchSuccess = launch; confirmAgent = false }
|
||||
val navigator = d.navigator()
|
||||
assertFalse(navigator.returnToAgent(1))
|
||||
assertEquals(if (launch) "agent_not_confirmed" else "agent_launch_failed", navigator.reason)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `missing activity or capture exception does not send back`() {
|
||||
for (throws in listOf(false, true)) {
|
||||
val d = Driver().apply { missingActivity = true; throwCapture = throws }
|
||||
assertFalse(d.navigator().returnToAgent(3))
|
||||
assertEquals(0, d.backs + d.opens)
|
||||
}
|
||||
}
|
||||
|
||||
private class Driver : PddCollectorDriver {
|
||||
var pkg = "com.xunmeng.pinduoduo"
|
||||
var backs = 0
|
||||
var opens = 0
|
||||
var leaveAfter = Int.MAX_VALUE
|
||||
var riskAfter = Int.MAX_VALUE
|
||||
var backSuccess = true
|
||||
var launchSuccess = true
|
||||
var confirmAgent = true
|
||||
var missingActivity = false
|
||||
var throwCapture = false
|
||||
var changeDuringCapture = false
|
||||
fun navigator(pause: (Long) -> Unit = {}) = PddCollectionReturnNavigator(this, {
|
||||
opens++
|
||||
if (launchSuccess && confirmAgent) pkg = "cn.ilapage.goauto.agent"
|
||||
launchSuccess
|
||||
}, { pkg }, pause)
|
||||
override fun capture(): UiSnapshot {
|
||||
if (throwCapture) error("capture failed")
|
||||
val snapshot = UiSnapshot(pkg, if (missingActivity) null else "PddActivity", if (backs >= riskAfter) listOf(
|
||||
SnapshotNode("risk", null, "订单详情", null, null, "android.widget.TextView", NodeBounds(0, 0, 100, 100),
|
||||
false, false, false, false, true, true),
|
||||
) else emptyList())
|
||||
if (changeDuringCapture) pkg = "launcher"
|
||||
return snapshot
|
||||
}
|
||||
override fun back(): Boolean {
|
||||
backs++
|
||||
if (backs >= leaveAfter) pkg = "launcher"
|
||||
return backSuccess
|
||||
}
|
||||
override fun clickFresh(target: SnapshotNode) = error("must not click page controls")
|
||||
override fun swipeSpec(direction: SwipeDirection, anchor: SnapshotNode?) = error("must not swipe")
|
||||
override fun pullDownGoodsPage() = error("must not pull down")
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,161 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseLiveAutomationTest {
|
||||
@Test
|
||||
fun `saved address recognizes split dynamic footer outside spec scroller without a swipe`() {
|
||||
for (label in listOf("复购价,", "专享优惠,")) {
|
||||
val driver = LiveDriver(savedStructuredFooter = label)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
|
||||
automation.submitOrderOnce()
|
||||
assertEquals(1, driver.submitClicks)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertEquals(0, driver.backCount)
|
||||
assertTrue(driver.clicked.contains(label))
|
||||
assertEquals("PURCHASE_SUBMIT_ALREADY_ATTEMPTED",
|
||||
(runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException).code)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one back from address transition can recognize dynamic footer`() {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,", savedTransitionWithoutLegacyContext = true)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
|
||||
assertEquals(1, driver.backCount)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved address with missing ambiguous price only or payment footer reports button failure without scrolling`() {
|
||||
val mutations: List<(UiSnapshot) -> UiSnapshot> = listOf(
|
||||
{ s -> s.copy(nodes = s.nodes.filterNot { it.path.startsWith("root/popup/footer") }) },
|
||||
{ s -> s.copy(nodes = s.nodes + s.nodes.filter { it.path.startsWith("root/popup/footer") }.map {
|
||||
it.copy(path = it.path.replace("footer", "second-footer"), parentPath = it.parentPath?.replace("footer", "second-footer"))
|
||||
}) },
|
||||
{ s -> s.copy(nodes = s.nodes.filterNot { it.path == "root/popup/footer/label" }) },
|
||||
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup/footer") it.copy(clickable = false) else it }) },
|
||||
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup/footer/label") it.copy(text = "去付款") else it }) },
|
||||
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup/footer") it.copy(enabled = false) else it }) },
|
||||
)
|
||||
for (mutation in mutations) {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
driver.structuredTransform = mutation
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error.code)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertEquals(0, driver.backCount)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy submit labels retain priority over structure`() {
|
||||
for (label in listOf("提交订单", "现在买,仅20元", "确认购买")) {
|
||||
val driver = LiveDriver(savedStructuredFooter = label)
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
|
||||
automation.submitOrderOnce()
|
||||
assertEquals(1, driver.submitClicks)
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `structural footer cannot bypass final spec quantity price or address verification`() {
|
||||
val cases = listOf(
|
||||
input().copy(mappedColor = "白色") to "PURCHASE_SPEC_NOT_MATCHED",
|
||||
input().copy(quantity = 3) to "PURCHASE_QUANTITY_MISMATCH",
|
||||
input().copy(maxUnitPriceCent = 100) to "PURCHASE_PRICE_OUT_OF_RANGE",
|
||||
)
|
||||
for ((request, code) in cases) {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
val error = runCatching { automation.finalConfirmation(request.copy(addressSuffix = "_cg81"), proof) }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals(code, error.code)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
driver.structuredTransform = { s -> s.copy(nodes = s.nodes.filterNot { it.path.endsWith("/address/detail") }) }
|
||||
assertEquals("PURCHASE_ADDRESS_UPDATE_FAILED",
|
||||
(runCatching { automation.finalConfirmation(input(), proof) }.exceptionOrNull() as PurchaseLiveException).code)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `structural footer needs this execution saved address and unchanged page at submission`() {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
|
||||
(runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException).code)
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
|
||||
(runCatching { PurchaseLiveAutomation(driver, pause = {}).finalConfirmation(input(), proof) }
|
||||
.exceptionOrNull() as PurchaseLiveException).code)
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
|
||||
driver.structuredTransform = { s -> s.copy(nodes = s.nodes.map {
|
||||
if (it.path.endsWith("footer/label")) it.copy(text = "另一操作") else it
|
||||
}) }
|
||||
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
|
||||
(runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException).code)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `footer duplicate text nodes remain one action and external overlays are not candidates`() {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
driver.structuredTransform = { s -> s.copy(nodes = s.nodes + listOf(
|
||||
s.nodes.first { it.path == "root/popup/footer/label" }.copy(
|
||||
path = "root/popup/footer/duplicate-label", bounds = NodeBounds(760, 2203, 950, 2262)),
|
||||
s.nodes.first { it.path == "root/popup/footer" }.copy(path = "root/overlay", parentPath = "root", text = "悬浮按钮"),
|
||||
)) }
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
|
||||
automation.submitOrderOnce()
|
||||
assertEquals(1, driver.submitClicks)
|
||||
assertFalse(driver.clicked.contains("悬浮按钮"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed revalidation clears structural submit authorization`() {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
automation.finalConfirmation(input(), proof)
|
||||
assertTrue(runCatching { automation.finalConfirmation(input().copy(quantity = 3), proof) }.isFailure)
|
||||
assertTrue(runCatching { automation.submitOrderOnce() }.isFailure)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `address alone full page ancestor and order or payment pages cannot authorize structural footer`() {
|
||||
val mutations: List<(UiSnapshot) -> UiSnapshot> = listOf(
|
||||
{ s -> s.copy(nodes = s.nodes.filterNot { it.path.endsWith("/quantity") || it.path.endsWith("/selected") }) },
|
||||
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup") it.copy(bounds = NodeBounds(0, 0, 1080, 2354)) else it }) },
|
||||
{ s -> s.copy(activityName = "com.xunmeng.pinduoduo.app_pay.core.PayActivity") },
|
||||
{ s -> s.copy(packageName = "example.other") },
|
||||
{ s -> s.copy(nodes = s.nodes.map { if (it.path.endsWith("/selected")) it.copy(text = "订单详情") else it }) },
|
||||
)
|
||||
for (mutation in mutations) {
|
||||
val driver = LiveDriver(savedStructuredFooter = "复购价,")
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val proof = automation.updateShippingAddress("_cg81")
|
||||
driver.structuredTransform = mutation
|
||||
assertTrue(runCatching { automation.finalConfirmation(input(), proof) }.isFailure)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verified spec panel advances through one exact confirm target`() {
|
||||
val driver = SpecConfirmationDriver()
|
||||
@@ -141,6 +296,44 @@ class PurchaseLiveAutomationTest {
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved address back into short spec panel restores without aspect ratio restriction`() {
|
||||
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true,
|
||||
savedPanelBounds = NodeBounds(0, 400, 1080, 1325))
|
||||
val automation = PurchaseLiveAutomation(driver, pause = {})
|
||||
val address = automation.updateShippingAddress("_cg81")
|
||||
|
||||
assertEquals("_cg81", automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), address).addressSuffix)
|
||||
assertEquals(1, driver.backCount)
|
||||
assertEquals(1, driver.scopedSwipes)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `short saved spec panel still stops when unchanged`() {
|
||||
val driver = LiveDriver(saveReturnsToSpecPanel = true, savedSpecPanelRecoveryStuck = true,
|
||||
savedPanelBounds = NodeBounds(0, 400, 1080, 1325))
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
|
||||
assertEquals(0, driver.backCount)
|
||||
assertTrue(driver.scopedSwipes in 1..5)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing and duplicate saved panel scroll regions fail distinctly without gestures`() {
|
||||
for (count in listOf(0, 2)) {
|
||||
val driver = LiveDriver(saveReturnsToSpecPanel = true, savedPanelScrollCount = count)
|
||||
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
|
||||
.exceptionOrNull() as PurchaseLiveException
|
||||
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
|
||||
assertTrue(error.message.orEmpty().contains(if (count == 0) "未找到可用" else "找到多个"))
|
||||
assertEquals(0, driver.scopedSwipes)
|
||||
assertEquals(0, driver.submitClicks)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved address back into spec panel restores final evidence with one back`() {
|
||||
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true)
|
||||
@@ -674,7 +867,10 @@ class PurchaseLiveAutomationTest {
|
||||
private val savedTransitionHidesSuffix: Boolean = false,
|
||||
private val saveReturnsToSpecPanel: Boolean = false,
|
||||
private val backReturnsToSpecPanel: Boolean = false,
|
||||
private val savedPanelBounds: NodeBounds = NodeBounds(0, 400, 1080, 2100),
|
||||
private val savedPanelScrollCount: Int = 1,
|
||||
private val savedSpecPanelRecoveryStuck: Boolean = false,
|
||||
private val savedStructuredFooter: String? = null,
|
||||
private val duplicatePhoneNodesSameCard: Boolean = false,
|
||||
private val duplicateSemanticAddressCards: Boolean = false,
|
||||
private val duplicateAddressCards: Boolean = false,
|
||||
@@ -705,16 +901,33 @@ class PurchaseLiveAutomationTest {
|
||||
var orderDetailEntryClicks = 0
|
||||
var postSubmitCaptureCount = 0
|
||||
val currentPage: String get() = page
|
||||
var structuredTransform: (UiSnapshot) -> UiSnapshot = { it }
|
||||
|
||||
override fun capture(): UiSnapshot {
|
||||
if (submitClicks > 0) {
|
||||
postSubmitCaptureCount++
|
||||
if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0)
|
||||
}
|
||||
return currentSnapshot()
|
||||
return currentSnapshot().let { if (page == "structured") structuredTransform(it) else it }
|
||||
}
|
||||
|
||||
private fun currentSnapshot(): UiSnapshot = when (page) {
|
||||
"structured" -> snapshot(listOf(
|
||||
node("root", "", bounds = NodeBounds(0, 0, 1080, 2354)),
|
||||
node("root/popup", "", clickable = true, parentPath = "root", bounds = NodeBounds(0, 366, 1080, 2306)),
|
||||
node("root/popup/address", "", clickable = true, parentPath = "root/popup", bounds = NodeBounds(0, 400, 1080, 530)),
|
||||
node("root/popup/address/phone", "138****5678", parentPath = "root/popup/address", bounds = NodeBounds(30, 400, 400, 450)),
|
||||
node("root/popup/address/detail", address, parentPath = "root/popup/address", bounds = NodeBounds(30, 456, 1000, 505)),
|
||||
node("root/popup/price", "¥20.00", parentPath = "root/popup", bounds = NodeBounds(396, 620, 700, 690)),
|
||||
node("root/popup/selected", "已选 黑色 XL", parentPath = "root/popup", bounds = NodeBounds(396, 731, 1053, 845)),
|
||||
node("root/popup/quantity", "2", className = "android.widget.EditText", parentPath = "root/popup", bounds = NodeBounds(462, 881, 540, 959)),
|
||||
node("root/popup/specs", "", scrollable = true, className = "androidx.recyclerview.widget.RecyclerView", parentPath = "root/popup", bounds = NodeBounds(0, 1132, 1080, 2057)),
|
||||
node("root/popup/specs/color", "颜色分类", parentPath = "root/popup/specs", bounds = NodeBounds(36, 1157, 287, 1218)),
|
||||
node("root/popup/payment-method", "微信支付", clickable = true, parentPath = "root/popup", bounds = NodeBounds(0, 2057, 1080, 2156)),
|
||||
node("root/popup/footer", "", clickable = true, className = "android.widget.FrameLayout", parentPath = "root/popup", bounds = NodeBounds(0, 2159, 1080, 2306)),
|
||||
node("root/popup/footer/label", requireNotNull(savedStructuredFooter), parentPath = "root/popup/footer", bounds = NodeBounds(353, 2203, 557, 2262)),
|
||||
node("root/popup/footer/amount", "20.00元", parentPath = "root/popup/footer", bounds = NodeBounds(557, 2203, 727, 2262)),
|
||||
))
|
||||
"panel" -> snapshot(listOf(
|
||||
node("title", "收货地址"),
|
||||
node("modify-parent", "", clickable = true),
|
||||
@@ -737,7 +950,7 @@ class PurchaseLiveAutomationTest {
|
||||
))
|
||||
"post-save-spec" -> snapshot(listOf(
|
||||
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
|
||||
node("panel", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
|
||||
node("panel", "", scrollable = true, bounds = savedPanelBounds),
|
||||
node("panel-title", "确认款式", bounds = NodeBounds(20, 396, 300, 430)),
|
||||
node("panel/price", "¥20.00", parentPath = "panel", bounds = NodeBounds(20, 460, 300, 520)),
|
||||
node("panel/selected", "已选 黑色 XL", parentPath = "panel", bounds = NodeBounds(20, 540, 700, 600)),
|
||||
@@ -749,7 +962,8 @@ class PurchaseLiveAutomationTest {
|
||||
node("panel/confirm", "确定", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 1200, 500, 1280)),
|
||||
node("submit-parent", "", clickable = true, bounds = NodeBounds(20, 1900, 1000, 2100)),
|
||||
node("submit", "提交订单", parentPath = "submit-parent", bounds = NodeBounds(520, 1940, 980, 2040)),
|
||||
))
|
||||
).map { if (it.path == "panel" && savedPanelScrollCount == 0) it.copy(enabled = false) else it } +
|
||||
if (savedPanelScrollCount > 1) listOf(node("panel/duplicate", "", scrollable = true, bounds = savedPanelBounds, parentPath = "panel")) else emptyList())
|
||||
"order" -> snapshot(listOf(node("status", "待付款"), node("order", "订单号:PDD-202608210001"), node("time", "下单时间:2026-08-21 10:30:00"), node("pay", "立即支付", clickable = true)))
|
||||
"order-folded" -> snapshot(listOf(node("status", "待付款"), node("pay", "立即支付", clickable = true)))
|
||||
"order-folded-payment-activity" -> UiSnapshot(PDD, "com.xunmeng.pinduoduo.app_pay.core.PayActivity", listOf(
|
||||
@@ -828,6 +1042,11 @@ class PurchaseLiveAutomationTest {
|
||||
|
||||
override fun clickFresh(target: SnapshotNode): FreshActionResult {
|
||||
clicked += target.label
|
||||
if (target.path == "root/popup/footer/label") {
|
||||
submitClicks++
|
||||
page = "order"
|
||||
return FreshActionResult.SUCCESS
|
||||
}
|
||||
when (target.label) {
|
||||
"138****5678" -> page = "panel"
|
||||
"修改" -> page = "edit"
|
||||
@@ -835,6 +1054,7 @@ class PurchaseLiveAutomationTest {
|
||||
page = when {
|
||||
saveReturnsToSpecPanel -> "post-save-spec"
|
||||
savedTransitionWithoutLegacyContext -> "saved-transition"
|
||||
savedStructuredFooter != null -> "structured"
|
||||
else -> "panel"
|
||||
}
|
||||
}
|
||||
@@ -892,7 +1112,7 @@ class PurchaseLiveAutomationTest {
|
||||
page = when (page) {
|
||||
"chooser" -> "payment"
|
||||
"payment" -> if (orderEvidenceBelowFold) "order-folded" else "order"
|
||||
"saved-transition" -> if (backReturnsToSpecPanel) "post-save-spec" else "confirmation"
|
||||
"saved-transition" -> if (savedStructuredFooter != null) "structured" else if (backReturnsToSpecPanel) "post-save-spec" else "confirmation"
|
||||
else -> "confirmation"
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -16,13 +16,42 @@ 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.PurchaseDiagnosticContext
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseDiagnosticStore
|
||||
import cn.ilapage.goauto.agent.persistence.PurchaseOutboxUploader
|
||||
import org.json.JSONObject
|
||||
import org.junit.Rule
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PurchaseRehearsalExecutorTest {
|
||||
@get:Rule val diagnosticTemporary = TemporaryFolder()
|
||||
|
||||
@Test
|
||||
fun `entry rejection persists without changing outcome when diagnostic storage fails`() {
|
||||
for (writable in listOf(true, false)) {
|
||||
val location = if (writable) diagnosticTemporary.newFolder() else diagnosticTemporary.newFile()
|
||||
val store = PurchaseDiagnosticStore(location)
|
||||
val context = PurchaseDiagnosticContext(81, "attempt-14", 7, "a".repeat(64), "0.9.67", "purchase")
|
||||
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true, productOverlay = true,
|
||||
specTapResult = FreshActionResult.SUCCESS)
|
||||
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {},
|
||||
panelDiagnostic = { store.record(context, 0, it); Unit })
|
||||
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
|
||||
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
|
||||
assertEquals(0, driver.specTapCount)
|
||||
if (writable) {
|
||||
val events = location.listFiles()!!.single().readLines().map { JSONObject(it).getJSONObject("evidence") }
|
||||
assertEquals("close_control", events.last().getString("reason"))
|
||||
assertTrue(events.any { it.optInt("entryWaitPolls") == 30 })
|
||||
assertTrue(events.any { it.optString("entryClick") == "SUCCESS" })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `color selection then single size heading completes without selecting color again`() {
|
||||
val driver = FakePurchaseDriver(prefixlessSingleHeadingAfterColor = true)
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package cn.ilapage.goauto.agent.persistence
|
||||
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
|
||||
class PurchaseDiagnosticStoreTest {
|
||||
@get:Rule val temporary = TemporaryFolder()
|
||||
private val context = PurchaseDiagnosticContext(81, "attempt-14", 7, "a".repeat(64), "0.9.67", "purchase")
|
||||
|
||||
@Test fun `only known structural fields and fixed values survive`() {
|
||||
val output = PurchaseDiagnosticStore.sanitize(
|
||||
"entryClick=SUCCESS;entryRecovery=rejected;reason=title_changed;summary=false;options=20;" +
|
||||
"title=敏感标题;address=测试地址;token=secret;entrySource=private-value;gesture=private-value;" +
|
||||
"quantity=private-value;scrollables=-1;unknown=private-value",
|
||||
)
|
||||
assertEquals(5, output.length())
|
||||
assertEquals("title_changed", output.getString("reason"))
|
||||
assertFalse(output.getBoolean("summary"))
|
||||
assertFalse(output.toString().contains("private"))
|
||||
assertFalse(output.toString().contains("secret"))
|
||||
assertEquals(0, PurchaseDiagnosticStore.sanitize("reason=测试地址;entryReason=secret").length())
|
||||
}
|
||||
|
||||
@Test fun `events persist across store recreation with identity and elapsed time`() {
|
||||
val dir = temporary.newFolder()
|
||||
assertTrue(PurchaseDiagnosticStore(dir) { 1000 }.record(context, 2300,
|
||||
"entryRecovery=rejected;reason=close_control;entryWaitPolls=30;entryWaitMillis=2900;closeControl=true"))
|
||||
assertTrue(PurchaseDiagnosticStore(dir) { 2000 }.record(context, 2500, "entryClick=SUCCESS"))
|
||||
val lines = dir.listFiles()!!.single().readLines()
|
||||
assertEquals(2, lines.size)
|
||||
val entry = JSONObject(lines.first())
|
||||
assertEquals(81, entry.getLong("taskId"))
|
||||
assertEquals("attempt-14", entry.getString("attemptId"))
|
||||
assertEquals(7, entry.getLong("deviceId"))
|
||||
assertEquals(context.ruleHash, entry.getString("ruleHash"))
|
||||
assertEquals(2300, entry.getLong("elapsedMs"))
|
||||
assertEquals("0.9.67", entry.getString("agentVersion"))
|
||||
assertEquals("close_control", entry.getJSONObject("evidence").getString("reason"))
|
||||
}
|
||||
|
||||
@Test fun `retains last five attempts and last 128 events including failure`() {
|
||||
val dir = temporary.newFolder()
|
||||
var time = 1000L
|
||||
val store = PurchaseDiagnosticStore(dir) { time++ }
|
||||
repeat(7) { store.record(context.copy(attemptId = "attempt-$it"), 0, "event=started") }
|
||||
assertEquals(5, dir.listFiles()!!.size)
|
||||
assertFalse(File(dir, "81_attempt-0.jsonl").exists())
|
||||
repeat(150) { store.record(context, it.toLong(), "entryWaitPolls=$it") }
|
||||
store.record(context, 999, "entryRecovery=rejected;reason=entry_label_changed")
|
||||
val lines = File(dir, "81_attempt-14.jsonl").readLines()
|
||||
assertEquals(128, lines.size)
|
||||
assertEquals("entry_label_changed", JSONObject(lines.last()).getJSONObject("evidence").getString("reason"))
|
||||
assertEquals(5, dir.listFiles()!!.size)
|
||||
}
|
||||
|
||||
@Test fun `expiry deletes only owned files and can run at startup`() {
|
||||
val dir = temporary.newFolder()
|
||||
PurchaseDiagnosticStore(dir) { 1000 }.record(context, 0, "event=started")
|
||||
File(dir, "unrelated.txt").writeText("preserve")
|
||||
File(dir, "unrelated.txt").setLastModified(1000)
|
||||
PurchaseDiagnosticStore(dir) { PurchaseDiagnosticStore.RETENTION_MS + 1001 }.prune()
|
||||
assertEquals(listOf("unrelated.txt"), dir.listFiles()!!.map { it.name })
|
||||
}
|
||||
|
||||
@Test fun `invalid context and filesystem failures never throw`() {
|
||||
val store = PurchaseDiagnosticStore(temporary.newFile())
|
||||
assertFalse(store.record(context, 1, "event=started"))
|
||||
store.prune()
|
||||
val dir = temporary.newFolder()
|
||||
val validStore = PurchaseDiagnosticStore(dir)
|
||||
assertFalse(validStore.record(context.copy(attemptId = "../outside"), 1, "event=started"))
|
||||
assertFalse(validStore.record(context.copy(ruleHash = "secret"), 1, "event=started"))
|
||||
assertFalse(validStore.record(context.copy(phase = "secret"), 1, "event=started"))
|
||||
assertFalse(validStore.record(context, 1, "unknown=secret"))
|
||||
assertTrue(dir.listFiles()!!.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -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: 20a1ca65dccde9ca1ecd93419cc1b164a5717fdf
|
||||
synchronized_at: 2026-09-07T13:34:45Z
|
||||
wiki_revision: 223cb9035a6b6e63072aa6b075889fcd0d6c2f23
|
||||
synchronized_at: 2026-09-09T08:18:24Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -68,6 +68,8 @@ Android Portal/Agent
|
||||
| `pdd_account` | 可选的账号调度引用,只保存名称和状态,不保存凭据 |
|
||||
| `purchase_task` | 商品外键和不可变快照、执行模式、状态/租约 guard、价格边界、订单、人工支付复核、物流与回填事实 |
|
||||
| `purchase_task_attempt` | `task_id + attempt_id` 幂等执行记录、阶段、规则哈希、固化规格决策和结构化错误 |
|
||||
|
||||
> #241 新增:`server/app/goauto/purchase/order_backfill.go` 与 `order_backfill_handler.go` 提供 `POST /api/agent/v1/purchase-tasks/order-backfill`(Device Token 鉴权,逐条事务、逐条结果);`server/app/goauto/models/purchase_order_guard.go` 在 `PurchaseTask.BeforeSave` 上全局强制订单号唯一,以 `purchase_rule_setting` 单例行串行化订单号分配,避免新增唯一索引迁移;`purchasecontract.ParseAddressSuffix` 为 `AddressSuffix` 的反解,通过回比而非负向前瞻实现(Go RE2 不支持前瞻)。
|
||||
| `ai_matching_setting` | 唯一单例的启用状态、OpenAI-compatible Base URL、模型、超时、内部部署明文 API Key 和更新人;仅管理员设置接口可以读取该字段 |
|
||||
|
||||
`collection_task` 的状态仅为 `pending`、`running`、`completed`、`completed_partial`、`failed`。设备身份和心跳表属于 Agent 领取任务的必要基础,不承载 PDD 业务数据。
|
||||
@@ -352,3 +354,26 @@ PddProductDetailCollector
|
||||
`GoAutoAccessibilityService.swipePurchase` 的失败分类由 `PurchaseSwipeFailureReason` 枚举提供;共享 `swipeNode` 仅增加可选分类回调,不改变手势目标、轨迹、1500ms 回调等待或其他调用者行为。`GoAutoPurchasePanel` 日志经 `AgentForegroundService` 关联 task、attempt、device 与规则快照哈希,新增预滑动跳过/必需滑动失败标量;不记录节点文字、坐标、原始控件树、截图或凭据。
|
||||
|
||||
Server/Web、数据库和任务快照不变;旧 APK 仍有预滑动行为,必须更新 Agent 才生效。相关验证在 `PurchaseRehearsalExecutorTest`,Android 全量测试与 APK 构建入口不变。
|
||||
|
||||
## SYB 逐页保存与部分成功(#239)
|
||||
|
||||
实现绑定 c6a962d;代码已实现不代表当前线上已部署。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。
|
||||
|
||||
同步状态增加 `partial_success`(部分成功,15 字符,复用现有 varchar(16),无需迁移)。有错误且 created+updated>0 为部分成功;有错误无已提交明细为 failed;完整且无错误为 succeeded(包括无符合店铺的数据)。中断仍为 interrupted,不把中断追认为成功。所有终态沿用活动槽释放规则。
|
||||
|
||||
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
|
||||
|
||||
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
|
||||
|
||||
入口为 `SyncWithShopSnapshot → loadDailyList → importSyncPage`;`server/app/goauto/sybimport/sync_page.go` 封装页事务和提交后计数;`import_handler.go` 判定终态,`sync_run.go` 保存及筛选,Web 复用 `web/src/views/goauto/syb-sync-runs/index.vue`。
|
||||
|
||||
## Android 连续临时采集收尾(#250)
|
||||
|
||||
当前实现绑定 `37e8b56`(Agent 0.9.72 / 85),用户确认 v5 按次数返回后切回 Agent。仅 Android,无 Admin/API/权限/数据库迁移;取代 v1~v4 首页导航。
|
||||
|
||||
- `AgentSettingsFragment` 在采集间隔后复用卡片和 SwitchMaterial;仅开启显示“返回次数”按钮,Material 单选框选择 1~5 次(默认 3),立即保存或取消。成功无常驻反馈,错误/忙碌显示原因。任务忙碌禁用,弹窗选定时再检查。
|
||||
- `AgentSettingsStore` 在私有 `goauto_agent_settings` 保存 `continuous_collection`(false)与 `continuous_collection_return_count`(3);次数越界或读取错误回落 3,写入失败恢复旧值,无网络加载。
|
||||
- `requestCurrentPageCollection` 保留原范围判断;`executeTask` 在采集开始固定次数。`executeTaskWhileAwake` 提交结果、结束图片上传后返回 resultStatus;`ContinuousCollectionPolicy.finish` 仅对完整确认成功调用新 `PddCollectionReturnNavigator`。失败/部分成功不导航。冷却间隔不变,连续模式不启动旧 idle-return。
|
||||
- 删除 `PddCollectionHomeNavigator` 和专属首页/搜索层判据。新导航器仅使用 capture/back、当前包名、openAgentPreservingTab;1~5 次 Back,每次后 500ms,前台离开 PDD 终止剩余 Back;保留风险场景停止。最后打开 Agent 并等待 500ms 验证包名,不验证 PDD 首页。仍在原任务互斥与唤醒锁范围内完成,结束后原 finally 释放。
|
||||
- `GoAutoCollector` 日志关联 taskId、attemptNumber、deviceId、规则快照 SHA-256;记录 continuousReturn、configuredBacks、固定 reason 和 actions。原因含 count_completed、left_pdd、back_failed、unsafe_page、agent_launch_failed、agent_not_confirmed、navigation_exception、invalid_count;actions 为 Back 尝试数,不代表最终页面。无节点文字、树、截图或个人数据落盘;不扩大采购诊断存储。
|
||||
- `ContinuousCollectionTest` 覆盖范围/终态、1~5 次、无变化仍按次数、前台变化、风险、动作/启动失败、包名确认和异常;`ContinuousCollectionSettingsTest` 覆盖开关与次数持久化、默认/越界/写入失败。`:app:testDebugUnitTest :app:assembleDebug` 376 项通过。旧首页专属测试随旧行为替换,新版未安装,UI 大字体/横屏、多设备和完整采集真机链路尚未验证。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
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: fdcce403423aa799d1517ac75fb38da636ef5217
|
||||
synchronized_at: 2026-09-09T01:22:58Z
|
||||
wiki_revision: 5a49f94d2c8bebe4e05bf3343834567e294e3894
|
||||
synchronized_at: 2026-09-09T08:18:31Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -501,3 +501,25 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
实现 ca815c3,Android 0.9.63/versionCode 76;已通过单元测试并构建,未安装、未进行真实采购验收。入口恢复沿用 #243 条件,拒绝时通过 entryRecovery=rejected;reason=<固定原因码> 区分页面身份、商品上下文、面板/支付区域、数量/摘要、标题、入口来源/文案、歧义、规则别名及可用性;不放宽条件,不新增点击。
|
||||
|
||||
在规格面板识别出唯一完整精确候选且 available=false 时,立即返回既有 PURCHASE_SPEC_SAFE_TARGET_MISSING,提示目标颜色/尺码已售罄或当前不可选,未创建订单;已选中目标同样不能绕过不可用检查。只记录 dimension 和 exact_target_unavailable 固定结构证据,不记录规格原文或原始树。原有纵向与横向定位在此条件下本就停止,不能将本改动描述为修复“已识别售罄仍继续搜索”。未识别精确候选时继续既有有界搜索,不推断售罄,不替换规格;解析器、匹配规则、滚动预算、地址与下单流程不变。
|
||||
|
||||
## 改地址返回后的动态提交按钮(#248)
|
||||
|
||||
实现绑定 `c80c746`,Android 0.9.66 / versionCode 79;已通过 353 项 Android 单元测试并构建,尚未安装或进行真实采购验收。
|
||||
|
||||
- 地址回读与最终提交按钮定位分开判断。旧“提交订单”“现在买,仅”“确认购买”识别继续优先;存在旧文案候选但歧义时不使用结构补充选择其中一个。地址确实缺失时保留既有有限恢复;地址及采购面板上下文已确认后,不因底部按钮文字变化而滚动规格列表。按钮缺失或不唯一使用 `PURCHASE_SUBMIT_TARGET_AMBIGUOUS` 提示按钮问题,不误报地址保存超时。
|
||||
- 结构补充只属于当前执行器中已完成地址修改后的返回流程,不能由其他页面或旧执行的地址证明启用。当前须为 PDD、地址后缀按既有规则回读唯一、地址入口唯一,并存在唯一数量输入或已选摘要;通过地址入口与该证据的最近共同祖先确定整个采购弹层,不把颜色/尺码滚动区域当成弹层。排除全屏大祖先、地址编辑、已知支付 Activity 及订单结果/支付页面,后续定位要求 Activity 与地址返回时一致。
|
||||
- 旧文案无候选时,只接受弹层底部固定区域内一个独立、可见启用、非滚动的可点击容器;不在规格滚动区内选择按钮,不取控件树末项,不使用固定屏幕点击坐标。文字与人民币金额子节点合并为一个按钮;支持如“复购价,”及非支付展示文字变化,但单独价格、多个操作按钮或支付/付款/地址/返回等非提交动作均不放行。
|
||||
- 地址返回检查、最终复核和提交共用目标定位。结构路径保留原文字节点为重新定位锚点,由已有无障碍点击重新查找其可点击父级;不新增下单手势兜底。最终复核成功后才允许该结构路径提交;提交前再次检查路径、类别、文字与边界,发生变化即停止;复核失败清除本地结构提交锚点。
|
||||
- 既有规格、数量、价格和地址复核,任务租约、服务端/本地不可逆边界及单次提交规则不变。结构识别不支持支付,不增加采购员操作、配置或审批;不改变 Admin、接口、数据库、规则 JSON、通用解析器及滑动执行器。原始控件树、整屏截图、地址全文和凭据不得落盘或上传。
|
||||
|
||||
## 临时采集连续模式(#250)
|
||||
|
||||
当前实现绑定 `37e8b56`,Agent 0.9.72 / versionCode 85。2026-09-09 用户批准 v5:按配置次数返回后切回 Agent,替代此前“识别并停留 PDD 首页”的方案;不保证 PDD 最终停在首页。代码、376 项 Android 单测及 Debug 构建通过,新版设置与完整真机闭环待验收。
|
||||
|
||||
- 连续采集默认关闭,本机即时保存,失败恢复原值;开启后显示返回次数选择,1~5 次、默认 3,取消不改,写入失败恢复旧值。任务忙碌时开关和次数均禁用,选择提交时再次检查;无新增权限。只用于本次人工新建普通临时采集(source=agent_current_page、attemptNumber=1、无替换来源),Admin 下发、重试、替代与采购不参与。
|
||||
- 单任务执行开始固定返回次数。完整 completed、服务端提交确认且既有图片上传流程结束后,执行有限 Back,每次后等待 500ms;不要求识别商品/搜索/相机/首页,也不要求画面发生变化。图片上传仍沿用既有非致命行为,不因收尾更改结果。
|
||||
- 每次 Back 前检查前台仍为 PDD,读取页面后再次检查;离开 PDD 则停止余下 Back。次数完成、离开 PDD 或 Back 动作失败后,尝试打开 Agent 保持原 Tab,等待 500ms 并验证前台包名。动作失败不重试,不把请求启动成功直接当作切回成功。
|
||||
- 沿用风险页安全边界:登录/验证码/风控、地址编辑、订单详情或支付等证据时保留现场,不发送 Back 或强行切回;Activity 缺失、读取异常明确停止。以上例外不引入首页门禁。
|
||||
- 导航在原任务互斥和唤醒锁内完成,不变更租约/调度。返回失败只显示人工处理提示,不把成功采集改为失败或自动重采。
|
||||
- 失败、部分成功、提交未确认保留现场,不运行返回步骤,也不触发旧空闲返回倒计时。关闭开关保持原流程,采集间隔不变。
|
||||
- 不自动拍照、搜索或创建下一任务,不涉及采购/订单/付款;不使用深链、浏览器、OCR/VLM 或坐标兜底。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:16Z
|
||||
wiki_revision: 18744477bfd17f396e8c76ec7a2fcc6720acdf6f
|
||||
synchronized_at: 2026-09-09T03:16:51Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -85,3 +85,22 @@ sqlite3 -readonly agent-diagnostics.db "SELECT task_id,reason,color_row_count,co
|
||||
```
|
||||
|
||||
读取时记录设备、Agent 版本、任务号和规则快照;工单只回写查询得到的脱敏聚合数值。读取完成后删除本地导出副本。正式 APK 若不允许 `run-as`,停止排查并确认安全的只读诊断出口,不通过放宽应用安全配置或上传完整数据库绕过。
|
||||
|
||||
## Android 采购规格入口本地诊断(#249)
|
||||
|
||||
实现绑定 `99faf5a`,Agent `0.9.67`(versionCode 80)。单元测试与 Debug 构建已通过,新增版本真机留存/读取尚待验证;本节不属于上文既有实测结论。
|
||||
|
||||
采购执行器启动后,在应用私有 `files/purchase_diagnostics/<taskId>_<attemptId>.jsonl` 保存白名单结构化入口诊断。元数据包含 taskId、attemptId、deviceId、agentVersion、phase、ruleHash(当前任务规则快照 SHA-256)、timestamp 和 elapsedMs。现有任务接口只有 attemptId,没有 attemptNumber;用 attemptId 对照服务端历史,不推算重试序号。
|
||||
|
||||
证据仅包含入口来源枚举、候选数、点击结果/原因枚举、等待轮数、面板结构布尔值/计数,以及受控恢复是否执行及固定拒绝原因。没有商品标题、规格原文、地址、手机号、Cookie、Token、链接、原始控件树或截图。entryWaitMillis 是该等待循环累计的计划等待量;elapsedMs 才是自本次采购执行器诊断起点计量的实际耗时,timestamp 是后台写入时间。
|
||||
|
||||
复用现有单线程诊断队列异步写入,写入/排队失败不改变采购结果;不新增点击、滑动、等待或采购门禁。最多保留最近 5 个 attempt 文件、每文件最后 128 条事件,超过 7 天的文件在服务启动或下次写入时清理。异常断电或存储故障仍可能丢失诊断,不能将日志缺失当成动作未执行;不上传 Admin,不增加服务端接口。
|
||||
|
||||
仅对允许 run-as 的 Debug APK,在设备已连接且选定准确序列号后只读提取:
|
||||
|
||||
```powershell
|
||||
adb -s <device-serial> shell run-as cn.ilapage.goauto.agent ls files/purchase_diagnostics
|
||||
adb -s <device-serial> exec-out run-as cn.ilapage.goauto.agent cat files/purchase_diagnostics/<taskId>_<attemptId>.jsonl
|
||||
```
|
||||
|
||||
先列出文件,再读取本次任务/attempt 的准确文件名;不得扩大为导出所有应用数据或完整数据库。新版安装前的失败不会补生成日志;需要用户授权后重试才能产生新证据。Release 若禁止 run-as 则停止,不更改权限绕过;首版没有导出界面。该功能用于确定拒绝分支,并不代表已经修复对应采购故障。
|
||||
|
||||
Reference in New Issue
Block a user