Compare commits

..
44 changed files with 657 additions and 2339 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 89
versionName = "0.9.76"
versionCode = 72
versionName = "0.9.59"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -434,16 +434,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
} else FreshActionResult.FAILED
}
private var lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.UNKNOWN
override fun purchaseSwipeFailureReason(): PurchaseSwipeFailureReason = lastPurchaseSwipeFailure
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.UNKNOWN
val root = rootInActiveWindow ?: run {
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.ROOT_UNAVAILABLE
return false
}
val root = rootInActiveWindow ?: return false
val candidates = mutableListOf<AccessibilityNodeInfo>()
walk(root) { node -> if (node.isVisibleToUser && node.isScrollable) candidates += node }
val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT
@@ -452,12 +444,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
}
val target = (directional.ifEmpty { candidates }).maxByOrNull { candidate ->
Rect().also(candidate::getBoundsInScreen).let { it.width().toLong() * it.height() }
} ?: run {
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.NO_SCROLLABLE
return false
}
return swipeNode(target, direction, durationMs, preferScrollAction = false,
onFailure = { lastPurchaseSwipeFailure = it })
} ?: return false
return swipeNode(target, direction, durationMs, preferScrollAction = false)
}
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
@@ -658,21 +646,16 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
preferScrollAction: Boolean = true,
downStartPercent: Int = 25,
downEndPercent: Int = 75,
onFailure: (PurchaseSwipeFailureReason) -> Unit = {},
): Boolean {
fun failed(reason: PurchaseSwipeFailureReason): Boolean {
onFailure(reason)
return false
}
val bounds = Rect().also(node::getBoundsInScreen)
if (bounds.width() < 2 || bounds.height() < 2) return failed(PurchaseSwipeFailureReason.INVALID_BOUNDS)
if (bounds.width() < 2 || bounds.height() < 2) return false
val scrollAction = if (direction == SwipeDirection.UP || direction == SwipeDirection.LEFT) {
AccessibilityNodeInfo.ACTION_SCROLL_FORWARD
} else {
AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD
}
if (preferScrollAction && node.performAction(scrollAction)) return true
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return failed(PurchaseSwipeFailureReason.GESTURE_UNSUPPORTED)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false
val left = bounds.left + bounds.width() * 25 / 100
val right = bounds.left + bounds.width() * 75 / 100
val top = bounds.top + bounds.height() * 25 / 100
@@ -710,9 +693,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
},
null,
)
if (!queued) return failed(PurchaseSwipeFailureReason.GESTURE_REJECTED)
if (!latch.await(1500, TimeUnit.MILLISECONDS)) return failed(PurchaseSwipeFailureReason.GESTURE_TIMEOUT)
return completed.get() || failed(PurchaseSwipeFailureReason.GESTURE_CANCELLED)
if (!queued) return false
return latch.await(1500, TimeUnit.MILLISECONDS) && completed.get()
}
private fun dispatchCenterTap(bounds: Rect): Boolean {
@@ -1,58 +0,0 @@
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"
}
}
@@ -186,7 +186,7 @@ object PddScreenParser {
// purchase/order/payment controls must never become collection click targets.
private val nonConfigurableClickDenylist = listOf("提交订单", "确认订单", "支付", "付款")
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null, collectionContext: Boolean = false, purchaseEntryContext: Boolean = false): ParsedPddScreen {
fun parse(snapshot: UiSnapshot, config: PddCollectorConfig, goodsId: String, evidence: PageEvidence?, purchaseContext: PurchasePanelContext? = null): ParsedPddScreen {
val visibleNodes = snapshot.nodes.filter { it.visible }
val visible = visibleNodes.mapNotNull { node ->
val descendants = descendants(node, visibleNodes)
@@ -323,32 +323,12 @@ object PddScreenParser {
(headings.size >= 2 || (continuedPurchasePanel && headings.isNotEmpty())) &&
dimensions.any { it.values.isNotEmpty() } && hasClose &&
hasQuantityControls && hasPaymentArea && hasOrderSubmitAction
// Collection can start before any selection summary exists and with only
// one dimension visible. Keep this exception out of purchase parsing.
val singleDimensionCollectionPanel = collectionContext && purchaseContext == null &&
snapshot.packageName == PDD_PACKAGE && problem == null && !hasSelectionSummary &&
evidence != null && snapshot.packageName == evidence.packageName &&
snapshot.activityName == evidence.activityName &&
snapshot.nodes.any { it.visible && it.matches(evidence.selector) } &&
boundedScrollables.size == 1 && headedPanelScrollable != null && headings.size == 1 &&
dimensions.size == 1 && dimensions.single().values.isNotEmpty() &&
hasClose && hasQuantityControls && hasPaymentArea && hasOrderSubmitAction
// Only the executor that just activated a safe entry on this same
// product Activity can enable this first-view purchase exception.
val singleDimensionPurchasePanel = purchaseEntryContext && !collectionContext &&
snapshot.packageName == PDD_PACKAGE && !snapshot.activityName.isNullOrBlank() &&
goodsId.isNotBlank() && problem == null && !hasSelectionSummary &&
boundedScrollables.size == 1 && headedPanelScrollable != null && headings.size == 1 &&
dimensions.size == 1 && dimensions.single().values.isNotEmpty() &&
hasClose && hasQuantityControls && hasPaymentArea && hasOrderSubmitAction
// Some selected-spec panels omit both the "已选" prefix and a confirm
// button. Keep the same structural evidence required for checkout.
val specPanelType = when {
quickConfirmationEvidence -> SpecPanelType.QUICK_CONFIRMATION
orderConfirmationEvidence -> SpecPanelType.ORDER_CONFIRMATION
structuredSelectionPanel -> SpecPanelType.NORMAL_SCROLLABLE
singleDimensionCollectionPanel -> SpecPanelType.NORMAL_SCROLLABLE
singleDimensionPurchasePanel -> SpecPanelType.NORMAL_SCROLLABLE
panelScrollable != null && (hasSelectionSummary || hasSubmitHint || (hasPanelTitle && hasPanelAction)) -> SpecPanelType.NORMAL_SCROLLABLE
hasSelectionSummary && hasPanelTitle && hasPanelAction -> SpecPanelType.NON_SCROLLABLE_CONFIRMATION
// Some PDD builds expose the complete selector as non-scrollable
@@ -1785,7 +1765,7 @@ class PddProductDetailCollector(
}
private fun parse(goodsId: String, config: PddCollectorConfig, evidence: PageEvidence) =
PddScreenParser.parse(driver.capture(), config, goodsId, evidence, collectionContext = true)
PddScreenParser.parse(driver.capture(), config, goodsId, evidence)
private fun screenSignature(screen: ParsedPddScreen): List<String> = screen.sourceNodes.asSequence()
.filter(SnapshotNode::visible)
@@ -32,12 +32,8 @@ class PurchaseLiveException(val code: String, message: String, val actualUnitPri
class PurchaseLiveAutomation(
private val driver: PurchaseUiDriver,
private val pause: (Long) -> Unit = Thread::sleep,
private val diagnostic: (String) -> Unit = {},
) {
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
@@ -127,9 +123,7 @@ class PurchaseLiveAutomation(
}
pause(1_000)
snapshot = waitFor("PURCHASE_ADDRESS_PANEL_TIMEOUT", "收货地址页面打开超时,未创建订单") {
it.packageName == PDD_PACKAGE &&
it.nodes.any { node -> node.visible && node.label.replace(" ", "") == "收货地址" } &&
addressModifyTargets(it).size == 1
it.nodes.any { node -> node.visible && node.label.replace(" ", "") == "收货地址" }
}
return editAndVerifyAddress(snapshot, addressSuffix)
}
@@ -307,11 +301,10 @@ 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 (!hasSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) {
if (!hasFinalSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) {
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "收货地址保存后复核失败,未创建订单")
}
val submit = finalSubmitTargets(snapshot)
@@ -325,7 +318,6 @@ 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())
}
@@ -337,18 +329,6 @@ 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
@@ -486,13 +466,12 @@ 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(), "修改地址", 1_000)
val edit = waitForAddressEditorReady()
click(modify.single(), "修改地址")
val edit = waitFor("PURCHASE_ADDRESS_EDIT_TIMEOUT", "地址编辑页面打开超时,未创建订单") { snapshot ->
snapshot.nodes.any { it.visible && it.label.replace(" ", "").contains("详细地址") }
}
val editors = shippingAddressEditors(edit)
if (editors.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "没有找到唯一的详细地址输入框,未创建订单")
val current = editors.single().label.trim()
@@ -505,30 +484,19 @@ 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(), "保存地址", 1_000)
var savedEvidence = waitForSavedConfirmation(waitForStableAddressEditorExit(), expected, suffix)
if (!savedAddressPageReady(savedEvidence, expected, suffix)) {
click(save.single(), "保存地址")
val savedEvidence = waitForStableAddressEditorExit()
if (!hasFinalSavedAddressEvidence(savedEvidence, expected, suffix)) {
if (isPurchaseConfirmationPanel(savedEvidence)) {
savedEvidence = restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
} else {
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
pause(1_000)
val returned = waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
savedAddressPageReady(it, expected, suffix) || isPurchaseConfirmationPanel(it)
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
hasFinalSavedAddressEvidence(it, expected, suffix)
}
val settled = waitForSavedConfirmation(returned, expected, suffix)
savedEvidence = if (!savedAddressPageReady(settled, expected, suffix)) {
restoreFinalEvidenceInCurrentPanel(settled, expected, suffix)
} else settled
}
}
val proof = ShippingAddressProof(expected, suffix)
savedAddressProof = proof
savedAddressActivity = savedEvidence.activityName
if (finalSubmitTargets(savedEvidence).size != 1) {
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "地址已保存,但未能识别唯一的下单按钮,未创建订单")
}
return proof
return ShippingAddressProof(expected, suffix)
}
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
@@ -541,56 +509,21 @@ class PurchaseLiveAutomation(
)
}
/** Wait for late address/footer nodes before deciding to scroll. No device actions here. */
private fun waitForSavedConfirmation(initial: UiSnapshot, expected: String, suffix: String): UiSnapshot {
var snapshot = initial
var readyActivity: String? = null
repeat(50) { poll ->
pageProblem(snapshot)
// A stable address list needs the existing single Back, not a footer wait.
if (snapshot.nodes.any { it.visible && it.label == "收货地址" } &&
addressModifyTargets(snapshot).size == 1) return snapshot
val ready = savedAddressPageReady(snapshot, expected, suffix)
if (ready && readyActivity != null && readyActivity == snapshot.activityName) {
savedConfirmationDiagnostic(snapshot, expected, suffix, poll + 1)
return snapshot
}
readyActivity = if (ready) snapshot.activityName else null
if (poll < 49) {
pause(200)
snapshot = driver.capture()
}
}
savedConfirmationDiagnostic(snapshot, expected, suffix, 50)
return snapshot
}
private fun savedConfirmationDiagnostic(snapshot: UiSnapshot, expected: String, suffix: String, polls: Int) {
val address = resolveAddressEntries(snapshot)
diagnostic("savedAddressMatched=${hasSavedAddressEvidence(snapshot, expected, suffix)};" +
"savedPanelMatched=${savedPurchasePanel(snapshot, expected, suffix) != null};" +
"savedLegacySubmitCandidates=${legacySubmitTargets(snapshot).size};" +
"savedAddressCards=${address.addressCardCount};savedAddressTargets=${address.tapTargetCount};" +
"savedScrollCandidates=${savedAddressPanelScrollTargets(snapshot).size};savedConfirmationPolls=$polls")
}
private fun restoreFinalEvidenceInCurrentPanel(
initial: UiSnapshot,
expected: String,
suffix: String,
): UiSnapshot {
) {
var snapshot = initial
var previousSignature: String? = null
repeat(ADDRESS_CONFIRMATION_SCROLL_LIMIT) { attempt ->
if (savedAddressPageReady(snapshot, expected, suffix)) return snapshot
if (hasFinalSavedAddressEvidence(snapshot, expected, suffix)) return
if (!isPurchaseConfirmationPanel(snapshot)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后采购面板发生变化,未创建订单")
}
val panels = savedAddressPanelScrollTargets(snapshot)
val panels = purchasePanelScrollTargets(snapshot)
if (panels.size != 1) {
savedConfirmationDiagnostic(snapshot, expected, suffix, 0)
val reason = if (panels.isEmpty()) "未找到可用的规格面板滚动区域" else "找到多个规格面板滚动区域"
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后${reason},未创建订单")
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后没有找到唯一的规格面板滚动区域,未创建订单")
}
val panel = panels.single()
val signature = viewportSignature(snapshot, panel)
@@ -605,10 +538,9 @@ class PurchaseLiveAutomation(
snapshot = driver.capture()
pageProblem(snapshot)
}
if (!savedAddressPageReady(snapshot, expected, suffix)) {
if (!hasFinalSavedAddressEvidence(snapshot, expected, suffix)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板未找到完整订单确认信息,未创建订单")
}
return snapshot
}
private fun hasSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean {
@@ -625,9 +557,8 @@ class PurchaseLiveAutomation(
return suffixMatches.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 hasFinalSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
hasSavedAddressEvidence(snapshot, expected, suffix) && finalSubmitTargets(snapshot).size == 1
private fun waitForStableAddressEditorExit(): UiSnapshot {
var consecutiveExits = 0
@@ -660,20 +591,6 @@ 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
@@ -752,113 +669,20 @@ class PurchaseLiveAutomation(
}.distinctBy { it.path }
}
private fun waitForAddressEditorReady(): UiSnapshot {
var previous: SnapshotNode? = null
var previousActivity: String? = null
var count = 0
repeat(50) { poll ->
val snapshot = driver.capture()
pageProblem(snapshot)
val editors = if (snapshot.packageName == PDD_PACKAGE) shippingAddressEditors(snapshot) else emptyList()
count = editors.size
diagnostic("addressEditorCandidates=$count;addressEditorPolls=${poll + 1}")
val editor = editors.singleOrNull()
if (editor != null && previous != null && snapshot.activityName == previousActivity &&
editor.path == previous!!.path && editor.bounds == previous!!.bounds && editor.label == previous!!.label) return snapshot
previous = editor
previousActivity = snapshot.activityName
pause(200)
}
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "详细地址输入框未稳定就绪,未创建订单 [candidates=$count]")
}
private fun click(node: SnapshotNode, label: String, settleMillis: Long = 500) {
private fun click(node: SnapshotNode, label: String) {
when (driver.clickFresh(node)) {
FreshActionResult.SUCCESS -> Unit
FreshActionResult.AMBIGUOUS -> fail("RULE_AMBIGUOUS", "$label 不唯一")
else -> fail("RULE_ACTION_FAILED", "$label 点击失败")
}
pause(settleMillis)
pause(500)
}
private fun legacySubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
snapshot,
snapshot.nodes.filter { node -> node.visible && node.enabled && FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) } },
)
/** 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
@@ -922,9 +746,6 @@ 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,11 +3,6 @@ package cn.ilapage.goauto.agent.automation
import java.net.URI
import java.net.URLDecoder
enum class PurchaseSwipeFailureReason {
UNKNOWN, ROOT_UNAVAILABLE, NO_SCROLLABLE, INVALID_BOUNDS, GESTURE_UNSUPPORTED,
GESTURE_REJECTED, GESTURE_CANCELLED, GESTURE_TIMEOUT,
}
interface PurchaseUiDriver {
fun capture(): UiSnapshot
fun clickFresh(target: SnapshotNode): FreshActionResult
@@ -37,7 +32,6 @@ interface PurchaseUiDriver {
fun specRowSwipeFailureReason(): String = "gestureFailed"
fun inputFresh(target: SnapshotNode, value: String): FreshActionResult
fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean
fun purchaseSwipeFailureReason(): PurchaseSwipeFailureReason = PurchaseSwipeFailureReason.UNKNOWN
fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean
fun pullDownGoodsPage(): Boolean = swipePurchase(SwipeDirection.DOWN, 550)
fun backPurchase(): Boolean
@@ -93,23 +87,17 @@ class PurchaseRehearsalExecutor(
private val stepChanged: (String) -> Unit = {},
private val panelDiagnostic: (String) -> Unit = {},
private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") },
private val probeHandoffActivity: String? = null,
private val onProbeHandoff: (String) -> Unit = {},
) {
private var purchasePanelContext: PurchasePanelContext? = null
private var pageIdentity: Pair<String?, String?> = null to null
private var purchaseEntryIdentity: Pair<String?, String?>? = null
private var entryRecoveryReason = "not_checked"
fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set<String>): PurchaseExecutionOutcome {
purchasePanelContext = null
purchaseEntryIdentity = null
validateBeforeDeviceAction(input, rule, supportedCapabilities)?.let { return it }
var observedPrice: Long? = null
var addressProof: ShippingAddressProof? = null
var irreversibleStarted = false
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
val live = PurchaseLiveAutomation(driver, pause, panelDiagnostic)
val live = PurchaseLiveAutomation(driver, pause)
for (action in rule.actions) {
// The immediate phase-two handoff can reuse the PDD page retained by
// spec_probe. A later manual retry may start from Agent (or another
@@ -134,7 +122,7 @@ class PurchaseRehearsalExecutor(
}
PurchaseActionType.VERIFY_ORDER_SUMMARY -> if (input.phase == "spec_probe") null else verifySummary(input, observedPrice, specSelectionProofs)
PurchaseActionType.PROBE_SPECS -> {
if (input.phase == "spec_probe") return probeOutcome(input)
if (input.phase == "spec_probe") return probeOutcome()
null
}
PurchaseActionType.UPDATE_SHIPPING_ADDRESS -> try {
@@ -179,7 +167,7 @@ class PurchaseRehearsalExecutor(
input.specResolutionAllowed &&
PurchaseActionType.PROBE_SPECS in rule.actions.map { it.type }
) {
return probeOutcome(input)
return probeOutcome()
}
return failure
}
@@ -190,33 +178,10 @@ class PurchaseRehearsalExecutor(
else failure("PURCHASE_RULE_INVALID", "正式采购规则缺少核单动作")
}
private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean {
if (probeHandoffActivity == null) {
val screen = currentScreen(input)
val reuse = screen.problem == null && screen.hasPurchaseProductEvidence()
panelDiagnostic("handoffPresent=false;productReused=$reuse;reusePolls=1;reuseReason=legacy_evidence")
return reuse
private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean =
currentScreen(input).let { screen ->
screen.problem == null && screen.hasPurchaseProductEvidence()
}
var consecutive = 0
repeat(10) { poll ->
purchaseEntryIdentity = PDD_PACKAGE to probeHandoffActivity
val screen = currentScreen(input)
if ((pageIdentity.first != null && pageIdentity != (PDD_PACKAGE to probeHandoffActivity)) || screen.problem != null) {
purchaseEntryIdentity = null
panelDiagnostic("handoffPresent=true;productReused=false;reusePolls=${poll + 1};reuseReason=${if (screen.problem != null) "page_problem" else "page_changed"}")
return false
}
consecutive = if (screen.hasPurchaseProductEvidence()) consecutive + 1 else 0
if (consecutive >= 2) {
panelDiagnostic("handoffPresent=true;productReused=true;reusePolls=${poll + 1};reuseReason=handoff_ready")
return true
}
if (poll < 9) pause(200)
}
purchaseEntryIdentity = null
panelDiagnostic("handoffPresent=true;productReused=false;reusePolls=10;reuseReason=evidence_timeout")
return false
}
private fun validateBeforeDeviceAction(
input: PurchaseExecutionInput,
@@ -405,10 +370,7 @@ class PurchaseRehearsalExecutor(
screen = currentScreen(input)
}
val beforeSignature = specActionSignature(screen)
val beforeIdentity = pageIdentity
val click = driver.clickFreshDetailed(requireNotNull(target))
purchaseEntryIdentity = if (click.result == FreshActionResult.SUCCESS) beforeIdentity else null
panelDiagnostic("entrySource=${screen.specEntrySource ?: "unknown"};entryClick=${click.result};entryReason=${click.reason}")
when (click.result) {
// The parser already narrowed to a single semantic candidate; the
// ambiguity here comes from the live tree matching that target more
@@ -423,22 +385,14 @@ class PurchaseRehearsalExecutor(
var wait = waitForSpecPanel(input, beforeSignature)
wait.failure?.let { return it }
if (wait.opened) return null
val refreshed = currentScreen(input)
refreshed.problem?.let { return failure(it.code, it.message) }
if (refreshed.reviewPageOpen) return leaveUnexpectedReviewPage(input)
if (refreshed.specPanelOpen) return null
val recoveryTarget = recoverableSpecEntry(screen, refreshed, action, beforeIdentity)
if (recoveryTarget == null) {
panelDiagnostic("entryRecovery=rejected;reason=$entryRecoveryReason;pageChanged=${wait.changed};${panelEvidence(refreshed)}")
if (wait.changed) {
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
)
}
val gesture = driver.tapSpecFresh(requireNotNull(recoveryTarget))
panelDiagnostic("entryRecovery=attempted;gesture=$gesture;pageChanged=${wait.changed}")
when (gesture) {
when (driver.tapSpecFresh(requireNotNull(target))) {
FreshActionResult.AMBIGUOUS -> return failure(
SPEC_ENTRY_TARGET_AMBIGUOUS,
"规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
@@ -452,8 +406,7 @@ class PurchaseRehearsalExecutor(
wait = waitForSpecPanel(input, specActionSignature(wait.screen))
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)}")
if (wait.changed) {
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
@@ -465,55 +418,6 @@ class PurchaseRehearsalExecutor(
)
}
// Whole-page animations are not proof of navigation. Recover only a freshly
// parsed, unchanged semantic product entry; never reuse the old node/coordinates.
private fun recoverableSpecEntry(
before: ParsedPddScreen,
after: ParsedPddScreen,
action: PurchaseAction,
identity: Pair<String?, String?>,
): SnapshotNode? {
entryRecoveryReason = when {
identity.first == null || identity.second == null -> "identity_missing"
identity != pageIdentity -> "identity_changed"
!after.isPddPackage || !after.pageEvidenceMatched -> "product_context_missing"
after.problem != null || after.reviewPageOpen -> "page_problem"
after.specPanelOpen -> "panel_open"
after.hasCloseControl -> "close_control"
// A product-page payment-method promotion is not a checkout page.
// Keep real order/payment/address evidence blocking the one entry retry.
pageIdentity.second?.contains("pay", ignoreCase = true) == true ||
after.sourceNodes.any { node -> node.visible && listOf(
"立即支付", "确认支付", "去支付", "去付款", "支付密码", "收银台",
"订单详情", "订单编号", "订单号", "待付款", "待支付", "确认订单",
"提交订单", "收货地址", "收货信息",
).any(node.label::contains) } -> "payment_area"
after.hasOrderSubmitAction -> "order_action"
after.hasQuantityControls -> "quantity_controls"
after.hasSelectionSummary -> "selection_summary"
before.summary.title.isNullOrBlank() -> "title_missing"
before.summary.title != after.summary.title -> "title_changed"
after.specEntry == null -> "entry_missing"
before.specEntrySource == null -> "entry_source_missing"
before.specEntrySource != after.specEntrySource -> "entry_source_changed"
before.specEntry?.label != after.specEntry.label -> "entry_label_changed"
after.explicitSpecEntryCount > 1 || after.nestedSpecEntryCount > 1 -> "entry_ambiguous"
else -> "eligible"
}
if (entryRecoveryReason != "eligible") return null
val anchor = after.specEntry ?: return null
if (action.textAliases?.let { specEntryMatchesAliases(after, anchor, it) } == false) {
entryRecoveryReason = "rule_alias_mismatch"
return null
}
val target = after.specEntryClickTarget ?: anchor
if (!target.visible || !target.enabled) {
entryRecoveryReason = "entry_unavailable"
return null
}
return target
}
private data class SpecPanelWait(
val screen: ParsedPddScreen,
val opened: Boolean,
@@ -536,9 +440,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) { poll ->
repeat(SPEC_POST_CLICK_VERIFY_POLLS) {
last = currentScreen(input)
panelDiagnostic("${panelEvidence(last)};entryWaitPolls=${poll + 1};entryWaitMillis=${poll * SPEC_SELECTION_POLL_MILLIS};closeControl=${last.hasCloseControl};paymentArea=${last.hasPaymentArea}")
panelDiagnostic(panelEvidence(last))
if (last.reviewPageOpen) {
return SpecPanelWait(last, false, true, leaveUnexpectedReviewPage(input))
}
@@ -639,7 +543,6 @@ class PurchaseRehearsalExecutor(
): PurchaseExecutionOutcome? {
val initial = currentScreen(input)
initial.problem?.let { return failure(it.code, it.message) }
unavailableExactSpec(initial, dimension, target)?.let { return it }
if (rememberExactSelection(initial, dimension, target, selectionProofs)) return null
val lookup = locateExactSpec(input, dimension, target)
@@ -714,9 +617,7 @@ class PurchaseRehearsalExecutor(
private fun isExactSpecSelected(screen: ParsedPddScreen, dimension: String, target: String): Boolean {
val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) {
return dimension == "color" && SelectedColorCard.confirms(target, candidates, screen.sourceNodes)
}
if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) return false
if (candidates.any { it.text == target && (it.node.selected || it.node.checked) }) return true
if (screen.specPanelOpen && fullSummaryTargetMatches(screen.selectedSummary, target)) return true
return summarySelectionMatches(screen.selectedSummary, dimension, target, candidates)
@@ -890,18 +791,6 @@ class PurchaseRehearsalExecutor(
private data class SpecLookup(val node: SnapshotNode? = null, val failure: PurchaseExecutionOutcome? = null)
private fun unavailableExactSpec(screen: ParsedPddScreen, dimension: String, target: String): PurchaseExecutionOutcome? {
if (!screen.specPanelOpen) return null
val exact = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }.filter { it.text == target }
return if (exact.size == 1 && !exact.single().available) unavailableSpec(dimension) else null
}
private fun unavailableSpec(dimension: String): PurchaseExecutionOutcome {
val role = if (dimension == "color") "颜色" else "尺码"
panelDiagnostic("specLookup=unavailable;dimension=$dimension;reason=exact_target_unavailable")
return failure(SPEC_SAFE_TARGET_MISSING, "目标${role}已售罄或当前不可选,未创建订单")
}
/**
* Searches only parsed, selectable values in the already-open spec panel.
* A short downward pass first restores the top when a previous action left
@@ -938,7 +827,7 @@ class PurchaseRehearsalExecutor(
}
if (exact.size == 1 && exact.single().available) return Inspection(SpecLookup(node = exact.single().node), "", screen.specPanelContainer)
if (exact.size == 1) {
return Inspection(SpecLookup(failure = unavailableSpec(dimension)), "", screen.specPanelContainer)
return Inspection(SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")), "", screen.specPanelContainer)
}
val signature = screen.dimensions.joinToString("|") { item ->
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
@@ -1003,7 +892,7 @@ class PurchaseRehearsalExecutor(
val lookup = when {
exact.size > 1 -> SpecLookup(failure = failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件"))
exact.size == 1 && exact.single().available -> SpecLookup(node = exact.single().node)
exact.size == 1 -> SpecLookup(failure = unavailableSpec(dimension))
exact.size == 1 -> SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击"))
else -> null
}
val rows = specValueRows(values)
@@ -1205,17 +1094,16 @@ class PurchaseRehearsalExecutor(
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
if (action.waitAfterMs > 0) pause(action.waitAfterMs)
action.swipeAfter?.let { swipe ->
// Legacy rules prescribe a blind scroll immediately after opening.
// Opening has already been verified; probe/select owns any necessary
// bounded scrolling. Do not let an unnecessary gesture block either
// phase, or move already-visible exact specs out of the viewport.
if (action.type == PurchaseActionType.OPEN_SPEC_PANEL) {
panelDiagnostic("postSwipe=skipped;action=${action.type.wireName};reason=spec_panel_on_demand;panel=${currentScreen(input).specPanelType.name}")
return null
}
// The stock purchase rule asks to reveal additional selector rows after
// opening the sheet. A fully-evidenced non-scrollable selector has no
// scroll target, and treating that absence as an action failure blocks
// an otherwise safe exact-spec flow. Keep all other configured swipes
// mandatory; this exception is limited to that confirmed panel state.
if (action.type == PurchaseActionType.OPEN_SPEC_PANEL &&
currentScreen(input).specPanelType == SpecPanelType.NON_SCROLLABLE_CONFIRMATION
) return null
repeat(swipe.count) { index ->
if (!driver.swipePurchase(swipe.direction, swipe.durationMs)) {
panelDiagnostic("postSwipe=failed;action=${action.type.wireName};direction=${swipe.direction.name};swipeIndex=${index + 1};reason=${driver.purchaseSwipeFailureReason().name.lowercase()}")
return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败")
}
if (index < swipe.count - 1 && swipe.intervalMs > 0) pause(swipe.intervalMs)
@@ -1224,13 +1112,9 @@ class PurchaseRehearsalExecutor(
return null
}
private fun probeOutcome(input: PurchaseExecutionInput): PurchaseExecutionOutcome {
private fun probeOutcome(): PurchaseExecutionOutcome {
stepChanged("probeSpecs")
val result = probeSpecs() ?: return failure("PURCHASE_SPEC_NOT_MATCHED", "商品规格探测失败")
val screen = currentScreen(input)
if (input.phase == "spec_probe" && screen.problem == null && screen.isPddPackage && screen.specPanelOpen) {
pageIdentity.second?.takeIf(String::isNotBlank)?.let(onProbeHandoff)
}
return PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = result)
}
@@ -1243,10 +1127,7 @@ class PurchaseRehearsalExecutor(
private fun currentScreen(input: PurchaseExecutionInput): ParsedPddScreen {
val snapshot = driver.capture()
pageIdentity = snapshot.packageName to snapshot.activityName
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null, purchasePanelContext,
purchaseEntryContext = purchaseEntryIdentity != null && purchaseEntryIdentity == pageIdentity)
if (purchaseEntryIdentity != pageIdentity) purchaseEntryIdentity = null
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null, purchasePanelContext)
purchasePanelContext = if (screen.isPddPackage && screen.problem == null && screen.specPanelOpen) {
screen.specPanelContainer?.let {
PurchasePanelContext(it, normalizedTarget("color", input.mappedColor).orEmpty())
@@ -1,43 +0,0 @@
package cn.ilapage.goauto.agent.automation
/** A narrow exception for one selected text option duplicated by its card/image. */
internal object SelectedColorCard {
fun confirms(target: String, values: List<VisibleSpecValue>, source: List<SnapshotNode>): Boolean {
val exact = values.filter { it.text == target }.singleOrNull() ?: return false
if (!exact.node.selected && !exact.node.checked) return false
val byPath = source.associateBy { it.path }
fun contains(outer: NodeBounds, inner: NodeBounds) =
inner.width > 0 && inner.height > 0 && inner.left >= outer.left && inner.top >= outer.top &&
inner.right <= outer.right && inner.bottom <= outer.bottom
fun cardFor(node: SnapshotNode): SnapshotNode? {
var current = node
val seen = mutableSetOf<String>()
while (seen.add(current.path)) {
if (current.scrollable || !current.visible || !current.enabled) return null
val parent = current.parentPath?.let(byPath::get) ?: return null
if (parent.scrollable) {
return current.takeIf {
it.clickable && it.className == "android.view.ViewGroup" &&
contains(parent.bounds, it.bounds) && contains(it.bounds, node.bounds)
}
}
current = parent
}
return null
}
val card = cardFor(exact.node) ?: return false
// The parser may have deduplicated identical labels across cards. Check
// the source as well before treating a selected duplicate as harmless.
if (source.any { node ->
node.visible && node.enabled && node.clickable &&
SpecValueNormalizer.normalizeColor(node.label) == target &&
cardFor(node)?.let { it.path != card.path } == true
}) return false
return values.filter { it.text != target && (it.node.selected || it.node.checked) }.all { other ->
val node = other.node
// Never merge another textual option, adjacent card, or scroll container.
(node.path == card.path || node.className == "android.widget.ImageView") &&
cardFor(node)?.path == card.path && contains(card.bounds, node.bounds)
}
}
}
@@ -1,95 +0,0 @@
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",
"addressEditorCandidates", "addressEditorPolls", "savedLegacySubmitCandidates", "savedAddressCards",
"savedAddressTargets", "savedScrollCandidates", "savedConfirmationPolls", "reusePolls")
private val booleans = setOf("panelAlreadyOpen", "reviewPage", "pageEvidence", "pageChanged", "summary", "quantity",
"orderAction", "closeControl", "paymentArea", "savedAddressMatched", "savedPanelMatched", "handoffPresent", "productReused")
private val enums = mapOf(
"reuseReason" to setOf("legacy_evidence", "page_problem", "page_changed", "handoff_ready", "evidence_timeout"),
"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
}
}
}
@@ -18,10 +18,6 @@ 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
@@ -87,7 +83,6 @@ class AgentForegroundService : Service() {
private val collectionCooldownWakeActive = AtomicBoolean(false)
private val currentPageResultNotificationUntil = AtomicLong(0L)
private val idleReturn = IdleReturnCoordinator()
private val purchaseProbeHandoff = PurchaseProbeHandoff()
private var taskWakeLock: PowerManager.WakeLock? = null
private var cooldownWakeLock: PowerManager.WakeLock? = null
private var collectionCooldownWakeLock: PowerManager.WakeLock? = null
@@ -96,7 +91,6 @@ 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
@@ -115,8 +109,6 @@ 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 ->
@@ -284,7 +276,7 @@ class AgentForegroundService : Service() {
evaluateIdleReturn()
return "$MANUAL_COLLECTION_COOLDOWN_PREFIX${CollectionCooldownPolicy.remainingSeconds(System.currentTimeMillis(), ticket)}"
}
TaskDispatchDecision.CHECK_COLLECTION -> purchaseProbeHandoff.clear()
TaskDispatchDecision.CHECK_COLLECTION -> Unit
}
val task = api.nextTask(token)
if (task == null) {
@@ -292,7 +284,6 @@ class AgentForegroundService : Service() {
return MANUAL_EMPTY
}
cancelIdleReturn("收到新的采集任务")
purchaseProbeHandoff.clear()
if (!taskMutex.tryAcquire(task.taskId)) return MANUAL_BUSY
if (task.status == "running") runningTaskId.set(task.taskId)
stateStore.setActiveTask(task.taskId, "collection")
@@ -359,16 +350,11 @@ class AgentForegroundService : Service() {
}
runningTaskId.set(task.taskId)
stateStore.setActiveTask(task.taskId, "collection")
val continuous = ContinuousCollectionPolicy.applies(
settingsStore.continuousCollection(), task.source, task.attemptNumber, task.replacementOriginType,
)
val outcome = executeTask(api, task, credentials.token, continuous)
val outcome = executeTask(api, task, credentials.token)
publishCurrentPageResult(
task.taskId,
if (outcome.successful) {
if (continuous) {
outcome.message ?: "采集已结束,请查看采集记录。"
} else if (task.replacementOriginType.isNotBlank()) {
if (task.replacementOriginType.isNotBlank()) {
"已替换,正在匹配规格"
} else {
"临时采集任务 #${task.taskId} 已结束,请查看采集记录。"
@@ -492,24 +478,7 @@ class AgentForegroundService : Service() {
if (accessibility == null) {
PurchaseExecutionOutcome("failed", "ACCESSIBILITY_NOT_READY", "GoAuto 无障碍服务未开启")
} 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 handoffKey = PurchaseProbeHandoff.Key(task.taskId, task.pddGoodsId, snapshotHash, diagnosticDeviceId)
val handoffActivity = purchaseProbeHandoff.consume(handoffKey, task.phase,
accessibility.currentForegroundRevision(), diagnosticStarted)
val recordDiagnostic: (String) -> Unit = { evidence ->
val elapsed = SystemClock.elapsedRealtime() - diagnosticStarted
runCatching { diagnosticExecutor.execute { purchaseDiagnostics.record(diagnosticContext, elapsed, evidence) } }
Unit
}
recordDiagnostic("event=started")
PurchaseRehearsalExecutor(
probeHandoffActivity = handoffActivity,
onProbeHandoff = { activity -> purchaseProbeHandoff.remember(handoffKey, activity,
accessibility.currentForegroundRevision(), SystemClock.elapsedRealtime()) },
driver = accessibility,
openLink = { PddLinkLauncher(this).open(it, preferDirect = true) },
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) },
@@ -517,10 +486,7 @@ class AgentForegroundService : Service() {
lastStep.set(step)
purchaseStore.updateStep(task.taskId, task.taskAttemptId, step)
},
panelDiagnostic = { evidence ->
recordDiagnostic(evidence)
Log.i("GoAutoPurchasePanel", "task=${task.taskId};attempt=$diagnosticAttempt;device=$diagnosticDeviceId;rule=$snapshotHash;$evidence")
},
panelDiagnostic = { evidence -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") },
beforeOrderSubmit = { evidence ->
val boundaryRequestId = UUID.randomUUID().toString()
val finalEvidence = JSONObject()
@@ -558,7 +524,6 @@ class AgentForegroundService : Service() {
}
val requestId = UUID.randomUUID().toString()
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
if (outcome.resultType != "spec_probe_completed") purchaseProbeHandoff.clear()
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
resultSafelyStored = true
PurchaseResultBubblePolicy.create(
@@ -579,10 +544,7 @@ class AgentForegroundService : Service() {
} catch (error: Exception) {
stateStore.update("TASK_ERROR", error.message ?: "采购演练执行异常", tokenStored = true)
} finally {
if (!resultSafelyStored) {
purchaseProbeHandoff.clear()
cancelIdleReturn("采购结果未安全保存")
}
if (!resultSafelyStored) cancelIdleReturn("采购结果未安全保存")
releaseTaskWakeLock()
}
}
@@ -669,29 +631,11 @@ class AgentForegroundService : Service() {
api: AgentApiClient,
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
token: String,
continuous: Boolean = false,
): TaskExecutionSummary {
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
acquireTaskWakeLock()
return try {
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) }
}
}
executeTaskWhileAwake(api, initialTask, token)
} finally {
releaseTaskWakeLock()
}
@@ -701,7 +645,6 @@ 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
@@ -808,25 +751,25 @@ class AgentForegroundService : Service() {
}
}
stateStore.update("ONLINE", "任务 #${task.taskId} 已提交:${result.status}", tokenStored = true)
beginPostCollectionCooldowns(returnToAgent = !continuous)
TaskExecutionSummary(successful = true, resultStatus = result.status)
beginPostCollectionCooldowns()
TaskExecutionSummary(successful = true)
} catch (error: TaskFailure) {
if (started) {
resultSafelySubmitted = failSafely(api, initialTask.taskId, token, error.code, error.message ?: "采集失败")
if (resultSafelySubmitted) beginPostCollectionCooldowns(returnToAgent = !continuous)
if (resultSafelySubmitted) beginPostCollectionCooldowns()
}
TaskExecutionSummary(false, error.code, error.message)
} catch (error: AgentApiException) {
if (error.code == "REPLACEMENT_ACTIVATION_FAILED") {
resultSafelySubmitted = true
beginPostCollectionCooldowns(returnToAgent = !continuous)
beginPostCollectionCooldowns()
}
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(returnToAgent = !continuous)
if (resultSafelySubmitted) beginPostCollectionCooldowns()
}
TaskExecutionSummary(false, "AGENT_EXECUTION_ERROR", error.message)
} finally {
@@ -885,8 +828,8 @@ class AgentForegroundService : Service() {
executor.schedule(::triggerSync, IdleReturnCoordinator.DEFAULT_COOLDOWN_MILLIS, TimeUnit.MILLISECONDS)
}
private fun beginPostCollectionCooldowns(returnToAgent: Boolean = true) {
if (returnToAgent) beginIdleReturnCooldown() else cancelIdleReturn("连续采集不自动返回 Agent")
private fun beginPostCollectionCooldowns() {
beginIdleReturnCooldown()
val ticket = CollectionCooldownPolicy.arm(
System.currentTimeMillis(),
settingsStore.collectionIntervalRange(),
@@ -1205,3 +1148,9 @@ 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,7 +1,6 @@
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
@@ -114,38 +113,14 @@ internal object TaskDispatchPolicy {
}
}
class AgentSettingsStore internal constructor(private val preferences: SharedPreferences) {
constructor(context: Context) : this(context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE))
class AgentSettingsStore(context: Context) {
private val preferences = 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 {
@@ -202,8 +177,6 @@ class AgentSettingsStore internal constructor(private val preferences: SharedPre
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"
@@ -1,27 +0,0 @@
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,
)
@@ -1,23 +0,0 @@
package cn.ilapage.goauto.agent.service
/** One-use, process-local recognition context; never contains nodes or selection proofs. */
internal class PurchaseProbeHandoff {
data class Key(val taskId: Long, val goodsId: String, val ruleHash: String, val deviceId: Long)
private data class Ticket(val key: Key, val activity: String, val revision: Long, val time: Long)
private var ticket: Ticket? = null
@Synchronized fun remember(key: Key, activity: String, revision: Long, now: Long) {
ticket = Ticket(key, activity, revision, now)
}
@Synchronized fun consume(key: Key, phase: String, revision: Long, now: Long): String? {
val previous = ticket
ticket = null
return previous?.takeIf {
phase == "purchase" && it.key == key && it.revision == revision &&
now - it.time in 0..180_000 && it.activity.isNotBlank()
}?.activity
}
@Synchronized fun clear() { ticket = null }
}
@@ -34,7 +34,6 @@ 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
@@ -65,10 +64,6 @@ 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
@@ -262,69 +257,6 @@ 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 {
@@ -722,23 +654,11 @@ 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
@@ -1,82 +0,0 @@
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
}
}
}
@@ -1,163 +0,0 @@
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")
}
}
@@ -27,66 +27,6 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PddProductDetailCollectorTest {
@Test fun `purchase entry can recognize one heading only with combined structure`() {
val snapshot = singleDimensionPanel()
assertFalse(PddScreenParser.parse(snapshot, config(), GOODS_ID, null).specPanelOpen)
assertEquals(SpecPanelType.NORMAL_SCROLLABLE,
PddScreenParser.parse(snapshot, config(), GOODS_ID, null, purchaseEntryContext = true).specPanelType)
for (missing in listOf("close", "plus", "minus", "info/quantity", "payment", "submit", "scroll/color-title", "scroll/color")) {
val changed = snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == missing })
assertFalse(missing, PddScreenParser.parse(changed, config(), GOODS_ID, null, purchaseEntryContext = true).specPanelOpen)
}
assertFalse(PddScreenParser.parse(snapshot.copy(packageName = "other"), config(), GOODS_ID, null, purchaseEntryContext = true).specPanelOpen)
assertFalse(PddScreenParser.parse(snapshot.copy(activityName = null), config(), GOODS_ID, null, purchaseEntryContext = true).specPanelOpen)
assertFalse(PddScreenParser.parse(snapshot, config(), "", null, purchaseEntryContext = true).specPanelOpen)
}
private fun singleDimensionPanel(): UiSnapshot {
val snapshot = prefixlessPanel()
return snapshot.copy(nodes = snapshot.nodes.filterNot {
it.path.startsWith("scroll/size") || it.path == "info/summary"
}.map {
if (it.path == "root") it.copy(resourceId = "android:id/content", className = "android.widget.FrameLayout") else it
})
}
@Test fun `single dimension without summary is recognized only during collection`() {
for (heading in listOf("颜色分类", "尺码")) {
val snapshot = singleDimensionPanel().let { original ->
original.copy(nodes = original.nodes.map {
if (it.path == "scroll/color-title") it.copy(text = heading) else it
})
}
assertFalse(PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence()).specPanelOpen)
val parsed = PddScreenParser.parse(snapshot, config(), GOODS_ID, evidence(), collectionContext = true)
assertEquals(SpecPanelType.NORMAL_SCROLLABLE, parsed.specPanelType)
assertEquals("scroll", parsed.specPanelContainer?.path)
assertEquals(1, parsed.dimensions.size)
assertEquals(null, parsed.selectedSummary)
assertFalse(parsed.hasSelectionSummary)
}
}
@Test fun `single dimension collection requires all structural and page evidence`() {
val snapshot = singleDimensionPanel()
for (missing in listOf("close", "plus", "minus", "info/quantity", "payment", "submit", "scroll/color-title", "scroll/color", "root")) {
val changed = snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == missing })
assertFalse(missing, PddScreenParser.parse(changed, config(), GOODS_ID, evidence(), collectionContext = true).specPanelOpen)
}
val secondScroll = node("other-scroll", "", 0, 500, 1080, 700, scrollable = true)
val secondSubmit = node("other-submit", "提交订单", 10, 2200, 300, 2300, clickable = true)
for (changed in listOf(
snapshot.copy(nodes = snapshot.nodes + secondScroll),
snapshot.copy(nodes = snapshot.nodes + secondSubmit),
snapshot.copy(packageName = "example.other"),
snapshot.copy(activityName = "example.OtherActivity"),
snapshot.copy(nodes = snapshot.nodes + node("captcha", "请完成安全验证", 0, 0, 600, 100)),
)) {
assertFalse(PddScreenParser.parse(changed, config(), GOODS_ID, evidence(), collectionContext = true).specPanelOpen)
}
assertFalse(PddScreenParser.parse(snapshot, config(), GOODS_ID, null, collectionContext = true).specPanelOpen)
}
private fun prefixlessPanel(): UiSnapshot = UiSnapshot(PDD_PACKAGE, ACTIVITY, listOf(
node("root", "", 0, 0, 1080, 2376),
node("close", "关闭", 970, 270, 1050, 350, clickable = true),
@@ -18,213 +18,6 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseLiveAutomationTest {
@Test fun `address editor waits for loaded unique stable input after one second transitions`() {
val driver = LiveDriver()
var polls = 0
driver.editTransform = { snapshot ->
polls++
when (polls) {
1 -> snapshot.copy(nodes = snapshot.nodes.filterNot { it.path == "editor-address" })
2 -> snapshot.copy(nodes = snapshot.nodes.map { if (it.path == "editor-address") it.copy(text = "") else it })
else -> snapshot
}
}
val pauses = mutableListOf<Long>()
PurchaseLiveAutomation(driver, pause = { pauses += it }).updateShippingAddress("_cg81")
assertTrue(polls >= 4)
assertEquals("editor-address", driver.lastInputTargetPath)
assertEquals(4, pauses.count { it == 1_000L })
assertEquals(0, driver.submitClicks)
}
@Test fun `persistently ambiguous address editors never receive input`() {
val driver = LiveDriver()
driver.editTransform = { snapshot -> snapshot.copy(nodes = snapshot.nodes + snapshot.nodes.single { it.path == "editor-address" }.copy(path = "duplicate-editor")) }
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_UPDATE_FAILED", error.code)
assertTrue(error.message!!.contains("candidates=2"))
assertEquals(null, driver.lastInputTargetPath)
assertFalse(driver.clicked.contains("保存地址"))
}
@Test
fun `non scrollable saved panel waits for late address nodes without back or swipe`() {
val driver = LiveDriver(savedStructuredFooter = "复购价,")
var polls = 0
driver.structuredTransform = { s ->
polls++
s.copy(nodes = s.nodes.filterNot {
it.path.startsWith("root/popup/specs") ||
(polls < 8 && it.path.startsWith("root/popup/address"))
})
}
val logs = mutableListOf<String>()
val automation = PurchaseLiveAutomation(driver, pause = {}, diagnostic = logs::add)
val proof = automation.updateShippingAddress("_cg81")
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
assertTrue(polls >= 9)
assertEquals(0, driver.scopedSwipes)
assertEquals(0, driver.backCount)
assertEquals(0, driver.submitClicks)
assertTrue(logs.any { it.contains("savedAddressMatched=true") && it.contains("savedScrollCandidates=0") })
assertFalse(logs.any { it.contains("_cg81") || it.contains("138") })
}
@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()
@@ -348,72 +141,6 @@ 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)
val automation = PurchaseLiveAutomation(driver, pause = {})
val address = automation.updateShippingAddress("_cg81")
val final = automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), address)
assertEquals("_cg81", final.addressSuffix)
assertEquals(1, driver.backCount)
assertEquals(1, driver.scopedSwipes)
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address back into stuck spec panel fails without another back or submit`() {
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true,
savedSpecPanelRecoveryStuck = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
assertEquals(1, driver.backCount)
assertTrue(driver.scopedSwipes in 1..5)
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address returning to spec panel is recovered without pressing back`() {
val driver = LiveDriver(saveReturnsToSpecPanel = true)
@@ -918,11 +645,7 @@ class PurchaseLiveAutomationTest {
private val savedTransitionWithoutLegacyContext: Boolean = false,
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,
@@ -953,34 +676,16 @@ class PurchaseLiveAutomationTest {
var orderDetailEntryClicks = 0
var postSubmitCaptureCount = 0
val currentPage: String get() = page
var structuredTransform: (UiSnapshot) -> UiSnapshot = { it }
var editTransform: (UiSnapshot) -> UiSnapshot = { it }
override fun capture(): UiSnapshot {
if (submitClicks > 0) {
postSubmitCaptureCount++
if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0)
}
return currentSnapshot().let { if (page == "structured") structuredTransform(it) else if (page == "edit") editTransform(it) else it }
return currentSnapshot()
}
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),
@@ -1003,7 +708,7 @@ class PurchaseLiveAutomationTest {
))
"post-save-spec" -> snapshot(listOf(
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
node("panel", "", scrollable = true, bounds = savedPanelBounds),
node("panel", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
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)),
@@ -1015,8 +720,7 @@ 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(
@@ -1095,11 +799,6 @@ 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"
@@ -1107,7 +806,6 @@ class PurchaseLiveAutomationTest {
page = when {
saveReturnsToSpecPanel -> "post-save-spec"
savedTransitionWithoutLegacyContext -> "saved-transition"
savedStructuredFooter != null -> "structured"
else -> "panel"
}
}
@@ -1165,7 +863,6 @@ class PurchaseLiveAutomationTest {
page = when (page) {
"chooser" -> "payment"
"payment" -> if (orderEvidenceBelowFold) "order-folded" else "order"
"saved-transition" -> if (savedStructuredFooter != null) "structured" else if (backReturnsToSpecPanel) "post-save-spec" else "confirmation"
else -> "confirmation"
}
return true
@@ -9,94 +9,19 @@ import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
import cn.ilapage.goauto.agent.automation.PurchaseSpecGesturePolicy
import cn.ilapage.goauto.agent.automation.PurchaseSwipeFailureReason
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
import cn.ilapage.goauto.agent.automation.RuleValidationException
import cn.ilapage.goauto.agent.automation.SnapshotNode
import cn.ilapage.goauto.agent.automation.SwipeDirection
import cn.ilapage.goauto.agent.automation.UiSnapshot
import cn.ilapage.goauto.agent.persistence.PendingPurchaseOutbox
import cn.ilapage.goauto.agent.persistence.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 {
@Test fun `single heading probe hands recognition to new purchase executor without opening link`() {
val driver = FakePurchaseDriver(singleHeadingOnEntry = true)
var handoff: String? = null
val probe = PurchaseRehearsalExecutor(driver, { true }, { "{}" }, pause = {}, onProbeHandoff = { handoff = it })
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", probe.resultType)
assertTrue(handoff != null)
var opens = 0
val logs = mutableListOf<String>()
PurchaseRehearsalExecutor(driver, { opens++; true }, { null }, pause = {},
probeHandoffActivity = handoff, panelDiagnostic = logs::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(0, opens)
assertTrue(logs.any { it.contains("handoffPresent=true;productReused=true;reusePolls=2;") })
}
@Test fun `handoff tolerates transient empty tree but never reuses another app`() {
for (mode in listOf("transient", "otherApp", "timeout")) {
val original = FakePurchaseDriver(singleHeadingOnEntry = true)
var handoff: String? = null
PurchaseRehearsalExecutor(original, { true }, { "{}" }, pause = {}, onProbeHandoff = { handoff = it })
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
var captures = 0
var opens = 0
val driver = object : PurchaseUiDriver by original {
override fun capture(): UiSnapshot {
captures++
return if (captures <= if (mode == "timeout") 10 else 2) UiSnapshot(if (mode == "otherApp") "cn.ilapage.goauto.agent" else null, null, emptyList()) else original.capture()
}
}
val logs = mutableListOf<String>()
PurchaseRehearsalExecutor(driver, { opens++; true }, { null }, pause = {},
probeHandoffActivity = handoff, panelDiagnostic = logs::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(if (mode == "transient") 0 else 1, opens)
assertTrue(logs.any { it.contains("productReused=${mode == "transient"}") })
if (mode == "timeout") assertTrue(logs.any { it.contains("reusePolls=10;reuseReason=evidence_timeout") })
}
}
@Test fun `safe entry with one visible heading completes probe without reopening`() {
val driver = FakePurchaseDriver(singleHeadingOnEntry = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
}
@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)
@@ -173,7 +98,7 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `rule parameters drive aliases and waits while deprecated panel swipes are ignored`() {
fun `rule parameters drive aliases waits and bounded swipes without dangerous clicks`() {
val driver = FakePurchaseDriver()
val pauses = mutableListOf<Long>()
var openCount = 0
@@ -187,7 +112,7 @@ class PurchaseRehearsalExecutorTest {
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(2_000L, outcome.actualUnitPriceCent)
assertEquals(0, openCount)
assertEquals(0, driver.swipeCount)
assertEquals(2, driver.swipeCount)
assertEquals(2L, driver.quantity)
assertTrue(driver.clicked.containsAll(listOf("选择规格", "黑色", "XL")))
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") })
@@ -839,7 +764,7 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `open spec panel still skips legacy follow-up swipe for confirmed non-scrollable panel`() {
fun `open spec panel skips required follow-up swipe only for confirmed non-scrollable panel`() {
val driver = FakePurchaseDriver(nonScrollablePanel = true, purchaseSwipeSucceeds = false)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
@@ -848,86 +773,6 @@ class PurchaseRehearsalExecutorTest {
assertEquals(0, driver.swipeCount)
}
@Test
fun `legacy panel preswipe cannot block visible specs in probe or purchase phase`() {
for (nonScrollable in listOf(false, true)) {
for (phase in listOf("spec_probe", "purchase")) {
val driver = FakePurchaseDriver(nonScrollablePanel = nonScrollable, purchaseSwipeSucceeds = false)
val diagnostics = mutableListOf<String>()
val pauses = mutableListOf<Long>()
var probeCount = 0
val raw = rule().replace("\"type\":\"openSpecPanel\"", "\"type\":\"openSpecPanel\",\"waitAfterMs\":321")
val outcome = PurchaseRehearsalExecutor(
driver, { true }, { probeCount++; "{}" }, pause = pauses::add,
panelDiagnostic = diagnostics::add,
).execute(input().copy(phase = phase), PurchaseRuleParser.parse(raw), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, if (phase == "spec_probe") "spec_probe_completed" else "rehearsal_completed", outcome.resultType)
assertEquals(0, driver.swipeCount)
assertTrue(pauses.contains(321L))
assertFalse(pauses.contains(1000L))
assertTrue(diagnostics.any { it.contains("reason=spec_panel_on_demand") })
assertEquals(if (phase == "spec_probe") 1 else 0, probeCount)
assertEquals(if (phase == "spec_probe") 0 else 1, driver.clicked.count { it == "黑色" })
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") })
}
}
}
@Test
fun `non panel mandatory swipes still execute and fail closed`() {
val raw = rule().replace("\"type\":\"selectSpec\"", "\"type\":\"selectSpec\",\"swipeAfter\":{\"direction\":\"up\",\"count\":2,\"durationMs\":500,\"intervalMs\":1000}")
for (succeeds in listOf(false, true)) {
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = succeeds)
val diagnostics = mutableListOf<String>()
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = pauses::add, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(raw), PurchaseAgentCapabilities.supported)
assertEquals(if (succeeds) "rehearsal_completed" else "failed", outcome.resultType)
assertEquals(if (succeeds) 2 else 1, driver.swipeCount)
assertEquals(succeeds, pauses.contains(1000L))
if (!succeeds) {
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertTrue(diagnostics.any { it.contains("action=selectSpec") && it.contains("reason=unknown") })
}
}
}
@Test
fun `quick and order confirmation selectors also skip legacy panel preswipe`() {
for (kind in listOf("QUICK_CONFIRMATION", "ORDER_CONFIRMATION")) {
val driver = FakePurchaseDriver(confirmationPanelKind = kind, purchaseSwipeSucceeds = false)
val diagnostics = mutableListOf<String>()
var probed = false
val outcome = PurchaseRehearsalExecutor(
driver, { true }, { probed = true; "{}" }, pause = {}, panelDiagnostic = diagnostics::add,
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "spec_probe_completed", outcome.resultType)
assertTrue(probed)
assertEquals(0, driver.swipeCount)
assertTrue(diagnostics.any { it.contains("postSwipe=skipped") && it.contains("panel=$kind") })
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") || it == "现在买" })
}
}
@Test
fun `mandatory swipe diagnostics use fixed reasons without spec text`() {
val raw = rule().replace("\"type\":\"selectSpec\"", "\"type\":\"selectSpec\",\"swipeAfter\":{\"direction\":\"up\",\"count\":1,\"durationMs\":500}")
for (reason in PurchaseSwipeFailureReason.values()) {
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false, purchaseSwipeFailure = reason)
val diagnostics = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(raw), PurchaseAgentCapabilities.supported)
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertTrue(diagnostics.contains("postSwipe=failed;action=selectSpec;direction=UP;swipeIndex=1;reason=${reason.name.lowercase()}"))
assertFalse(diagnostics.any { it.contains("黑色") || it.contains("XL") })
}
}
@Test(expected = RuleValidationException::class)
fun `ignored legacy panel swipe still rejects invalid rule parameters`() {
PurchaseRuleParser.parse(rule().replace("\"count\":2", "\"count\":0"))
}
@Test
fun `unrecognized opened panel returns only scalar panel evidence`() {
val driver = FakePurchaseDriver(unrecognizedPanel = true)
@@ -957,97 +802,6 @@ class PurchaseRehearsalExecutorTest {
assertTrue(driver.panel)
}
@Test
fun `selected color card duplicate does not trigger another selection tap`() {
val driver = FakePurchaseDriver(duplicateColorCardEvidence = true)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
assertEquals(0, driver.specTapCount)
}
@Test
fun `dynamic product page recovers ineffective and failed entry clicks once`() {
for (reason in listOf(null, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.TARGET_NOT_FOUND)) {
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
forcedEntryClickReason = reason, specTapResult = FreshActionResult.SUCCESS)
val logs = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = logs::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
assertEquals(1, driver.specTapCount)
assertTrue(logs.any { it.contains("entryRecovery=attempted") })
assertFalse(logs.any { it.contains("测试商品") || it.contains("选择规格") })
}
}
@Test
fun `dynamic product page with ineffective gesture reports no effect without second tap`() {
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
specTapResult = FreshActionResult.SUCCESS, specTapHasEffect = false)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT", outcome.errorCode)
assertEquals(1, driver.specTapCount)
}
@Test
fun `dynamic product with close control never receives recovery tap`() {
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true, productOverlay = true,
specTapResult = FreshActionResult.SUCCESS)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
assertEquals(0, driver.specTapCount)
}
@Test
fun `payment method promotion alone permits one safe product entry retry`() {
for (effect in listOf(true, false)) {
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
afterEntryMutation = "payment-promotion", specTapResult = FreshActionResult.SUCCESS, specTapHasEffect = effect)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
if (effect) assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
else assertEquals("PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT", outcome.errorCode)
assertEquals(1, driver.specTapCount)
}
}
@Test
fun `changed entry identity or unsafe page never receives recovery tap`() {
for (mode in listOf("missing", "duplicate", "activity", "payment", "order")) {
val diagnostics = mutableListOf<String>()
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
afterEntryMutation = mode, specTapResult = FreshActionResult.SUCCESS)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(mode, "failed", outcome.resultType)
assertEquals(mode, 0, driver.specTapCount)
val expected = mapOf("missing" to "entry_missing", "duplicate" to "entry_ambiguous",
"activity" to "identity_changed", "payment" to "payment_area", "order" to "payment_area").getValue(mode)
assertTrue(diagnostics.toString(), diagnostics.any { it.contains("entryRecovery=rejected;reason=$expected;") })
}
}
@Test
fun `unavailable exact color stops without selection click or searching even when selected`() {
for (selected in listOf(null, "黑色")) {
val driver = FakePurchaseDriver(colors = listOf("黑色", "白色"),
unavailableColors = setOf("黑色")).apply { color = selected }
val logs = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = logs::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_SAFE_TARGET_MISSING", outcome.errorCode)
assertEquals("目标颜色已售罄或当前不可选,未创建订单", outcome.message)
assertFalse(driver.clicked.contains("黑色"))
assertEquals(0, driver.specTapCount)
assertEquals(0, driver.swipeCount)
assertTrue(logs.any { it == "specLookup=unavailable;dimension=color;reason=exact_target_unavailable" })
assertFalse(logs.any { it.contains("黑色") })
}
}
@Test
fun `unchanged spec entry after action and gesture returns no effect`() {
val driver = FakePurchaseDriver(
@@ -1093,10 +847,10 @@ class PurchaseRehearsalExecutorTest {
assertEquals("PURCHASE_SPEC_ENTRY_NOT_FOUND", outcome.errorCode)
assertEquals(
"specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000",
diagnostics.single { it.startsWith("specEntryCandidates=") },
diagnostics.single(),
)
assertEquals(21, pauses.count { it == 100L })
assertFalse(diagnostics.any { it.contains("选择规格") })
assertFalse(diagnostics.single().contains("选择规格"))
}
@Test
@@ -1464,7 +1218,6 @@ class PurchaseRehearsalExecutorTest {
private val reviewBackSucceeds: Boolean = true,
private val hiddenSizeUntilUpSwipes: Int = 0,
private val prefixlessSingleHeadingAfterColor: Boolean = false,
private val singleHeadingOnEntry: Boolean = false,
private val hideSizeAfterSelection: Boolean = false,
private val revealGridSizeAfterUpSwipes: Int? = null,
private val openClickResults: MutableList<FreshActionResult> = mutableListOf(),
@@ -1474,10 +1227,6 @@ class PurchaseRehearsalExecutorTest {
private val forcedEntryClickReason: FreshClickReason? = null,
private val initialSize: String? = null,
private val entryActionHasEffect: Boolean = true,
private val duplicateColorCardEvidence: Boolean = false,
private val dynamicProduct: Boolean = false,
private val productOverlay: Boolean = false,
private val afterEntryMutation: String = "",
private val specTapResult: FreshActionResult = FreshActionResult.FAILED,
private val specTapHasEffect: Boolean = true,
private val sizeSelectsOnFailedClick: Boolean = false,
@@ -1495,13 +1244,10 @@ class PurchaseRehearsalExecutorTest {
private val pullDownSucceeds: Boolean = true,
private val loseEvidenceAfterPull: Boolean = false,
private val unavailableSizes: Set<String> = emptySet(),
private val unavailableColors: Set<String> = emptySet(),
allSpecsUnavailable: Boolean = false,
private val nonScrollablePanel: Boolean = false,
private val confirmationPanelKind: String? = null,
private val unrecognizedPanel: Boolean = false,
private val purchaseSwipeSucceeds: Boolean = true,
private val purchaseSwipeFailure: PurchaseSwipeFailureReason = PurchaseSwipeFailureReason.UNKNOWN,
initiallyInAgent: Boolean = false,
private val panelBecomesUnknownAfterSizeProof: Boolean = false,
) : PurchaseUiDriver {
@@ -1532,7 +1278,6 @@ class PurchaseRehearsalExecutorTest {
private var horizontalColorPage = 0
private var horizontalSizePage = 0
private var capturesAfterSizeSelection = 0
private var entryAttempted = false
val clicked = mutableListOf<String>()
val clickedPaths = mutableListOf<String>()
@@ -1595,8 +1340,6 @@ class PurchaseRehearsalExecutorTest {
node("title", "测试商品标题文本", 20, 200, 900, 280, className = "android.widget.ViewPager"),
)
val specEntryReady = pddCaptureCount > specEntryVisibleAfterPddCaptures
if (dynamicProduct) nodes += node("decoration", "", 20, 400 + pddCaptureCount % 3, 100, 460)
if (productOverlay) nodes += node("overlay-close", "关闭", 900, 500, 1000, 570, clickable = true)
if (bottomPurchaseEntry && specEntryReady) {
nodes += node("buy", "", 500, 1800, 1080, 2180, clickable = true)
nodes += node("buy/price", "¥20.00", 560, 1840, 760, 1910, parentPath = "buy")
@@ -1605,14 +1348,6 @@ class PurchaseRehearsalExecutorTest {
nodes += node("spec", "选择规格", 20, 1000, 900, 1100, clickable = true)
}
if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true)
if (entryAttempted) {
if (afterEntryMutation == "missing") nodes.removeAll { it.path == "spec" }
if (afterEntryMutation == "duplicate") nodes += node("spec2", "选择规格", 20, 1120, 900, 1190, clickable = true)
if (afterEntryMutation == "payment-promotion") nodes += node("payment", "微信支付", 20, 1800, 900, 1900)
if (afterEntryMutation == "payment") nodes += node("payment", "确认支付", 20, 1800, 900, 1900)
if (afterEntryMutation == "order") nodes += node("order", "订单详情", 20, 1800, 900, 1900)
if (afterEntryMutation == "activity") return UiSnapshot(PDD, "OtherActivity", nodes)
}
return UiSnapshot(PDD, ACTIVITY, nodes)
}
if (unrecognizedPanel) {
@@ -1621,7 +1356,7 @@ class PurchaseRehearsalExecutorTest {
node("panel-title", "确认款式", 20, 396, 300, 430),
))
}
val singleHeading = singleHeadingOnEntry || (prefixlessSingleHeadingAfterColor && color != null)
val singleHeading = prefixlessSingleHeadingAfterColor && color != null
val hideColor = singleHeading || (hideColorAfterQuantitySet && quantity == 2L && !hiddenColorRestored)
val hideSize = (hideSizeAfterQuantitySet && quantity == 2L) || (hideSizeAfterSelection && size != null)
val hideSummary = hideSelectedSummaryAfterQuantitySet && quantity == 2L
@@ -1655,16 +1390,6 @@ class PurchaseRehearsalExecutorTest {
nodes += node(colorParent, "", 0, 460, 1080, 550, scrollable = true, parentPath = "scroll")
}
visibleColors.forEachIndexed { index, value ->
if (duplicateColorCardEvidence && selectedColor == value) {
val cardPath = "scroll/card-$index"
nodes += node(cardPath, "$value 199", 20 + index * 220, 460, 200 + index * 220, 550,
clickable = true, selected = true, parentPath = "scroll", className = "android.view.ViewGroup")
nodes += node("$cardPath/image", "$value 199", 20 + index * 220, 460, 200 + index * 220, 500,
clickable = true, selected = true, parentPath = cardPath, className = "android.widget.ImageView")
nodes += node("$cardPath/text", value, 20 + index * 220, 500, 200 + index * 220, 540,
clickable = true, selected = true, parentPath = cardPath, className = "android.widget.TextView")
return@forEachIndexed
}
nodes += node(
"scroll/color-$index",
value,
@@ -1674,7 +1399,7 @@ class PurchaseRehearsalExecutorTest {
540,
clickable = true,
selected = selectedColor == value,
enabled = !allSpecsUnavailable && value !in unavailableColors,
enabled = !allSpecsUnavailable,
parentPath = colorParent,
)
}
@@ -1712,16 +1437,7 @@ class PurchaseRehearsalExecutorTest {
nodes += if (singleHeading) node("info/quantity", quantity.toString(), 400, 360, 600, 390, className = "android.widget.EditText", parentPath = "info")
else node("quantity", quantity.toString(), 400, 800, 600, 870, className = "android.widget.EditText")
if (!singleHeading) nodes += node("confirm", "确定", 20, 900, 500, 980, clickable = true)
if (confirmationPanelKind != null) {
nodes += node("close", "关闭", 980, 300, 1060, 350, clickable = true)
nodes += node("minus", "减少数量", 300, 800, 380, 870, clickable = true)
nodes += node("plus", "增加数量", 620, 800, 700, 870, clickable = true)
nodes += node("payment", "微信支付", 600, 1000, 900, 1050)
if (confirmationPanelKind == "QUICK_CONFIRMATION") {
nodes += node("quick", "现在买", 520, 2000, 1020, 2080, clickable = true)
}
}
nodes += node("order", "提交订单", 20, if (singleHeading || confirmationPanelKind != null) 2000 else 1100, 500, if (singleHeading || confirmationPanelKind != null) 2080 else 1180, clickable = true)
nodes += node("order", "提交订单", 20, if (singleHeading) 2000 else 1100, 500, if (singleHeading) 2080 else 1180, clickable = true)
nodes += node("pay", "立即支付", 520, 1100, 1020, 1180, clickable = true)
return UiSnapshot(PDD, ACTIVITY, nodes)
}
@@ -1762,7 +1478,6 @@ class PurchaseRehearsalExecutorTest {
}
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome {
if (target.path in setOf("spec", "buy")) entryAttempted = true
if (target.path in setOf("spec", "buy") && forcedEntryClickReason != null) {
val result = when (forcedEntryClickReason) {
FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> FreshActionResult.NOT_FOUND
@@ -1816,8 +1531,6 @@ class PurchaseRehearsalExecutorTest {
return purchaseSwipeSucceeds
}
override fun purchaseSwipeFailureReason(): PurchaseSwipeFailureReason = purchaseSwipeFailure
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
swipeInPaths += target.path
if (restoreHiddenColorOnDownSwipe && quantity == 2L && direction == SwipeDirection.DOWN) {
@@ -1,47 +0,0 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import org.junit.Assert.*
import org.junit.Test
class SelectedColorCardTest {
private val target = "黑色两件套 19"
private fun node(path: String, parent: String?, label: String, kind: String, selected: Boolean = true,
scrollable: Boolean = false) = SnapshotNode(path, parent, label, null, null, kind,
NodeBounds(0, 0, 300, 400), !scrollable, scrollable, selected, false, true, true)
private val scroll = node("s", null, "", "androidx.recyclerview.widget.RecyclerView", false, true)
private val card = node("s/c", "s", "黑色两件套 199", "android.view.ViewGroup")
private val image = node("s/c/i", "s/c", "黑色两件套 199", "android.widget.ImageView")
private val text = node("s/c/t", "s/c", target, "android.widget.TextView")
private fun check(nodes: List<SnapshotNode>, candidates: List<SnapshotNode> = nodes.filter { it != scroll }) =
SelectedColorCard.confirms(target, candidates.map { VisibleSpecValue(it.label, true, it) }, nodes)
@Test fun `selected target and own card image duplicates are one selection`() {
assertTrue(check(listOf(scroll, card, image, text)))
}
@Test fun `another selected card remains conflict`() {
val other = card.copy(path = "s/other")
assertFalse(check(listOf(scroll, card, image, text, other)))
}
@Test fun `two exact targets are not merged`() {
val otherCard = card.copy(path = "s/other", selected = false)
val otherText = text.copy(path = "s/other/t", parentPath = otherCard.path)
assertFalse(check(listOf(scroll, card, image, text, otherCard, otherText)))
assertFalse(check(listOf(scroll, card, image, text, otherCard, otherText), listOf(card, image, text)))
}
@Test fun `unselected target cannot borrow selection from its container`() {
assertFalse(check(listOf(scroll, card, image, text.copy(selected = false))))
}
@Test fun `scrollable or missing card boundary cannot merge`() {
assertFalse(check(listOf(scroll, card.copy(scrollable = true), image, text)))
assertFalse(check(listOf(card, image, text)))
assertFalse(check(listOf(scroll, card.copy(clickable = false), image, text)))
}
@Test fun `different text sibling is not treated as image duplicate`() {
assertFalse(check(listOf(scroll, card, text, image.copy(className = "android.widget.TextView"))))
}
@Test fun `out of card bounds and parent cycle are rejected`() {
assertFalse(check(listOf(scroll, card, image.copy(bounds = NodeBounds(0, 0, 301, 400)), text)))
assertFalse(check(listOf(scroll, card.copy(parentPath = text.path), image, text)))
}
}
@@ -1,92 +0,0 @@
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 `saved confirmation diagnostics retain only bounded scalars`() {
val fields = PurchaseDiagnosticStore.sanitize("savedAddressMatched=false;savedPanelMatched=true;" +
"savedLegacySubmitCandidates=0;savedAddressCards=1;savedAddressTargets=1;" +
"savedScrollCandidates=0;savedConfirmationPolls=50;savedAddress=secret;" +
"savedAddressMatched=secret;savedScrollCandidates=-1")
assertEquals(7, fields.length())
assertFalse(fields.getBoolean("savedAddressMatched"))
assertEquals(50, fields.getInt("savedConfirmationPolls"))
assertFalse(fields.toString().contains("secret"))
}
@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())
}
}
@@ -1,39 +0,0 @@
package cn.ilapage.goauto.agent.service
import org.junit.Assert.*
import org.junit.Test
class PurchaseProbeHandoffTest {
private val key = PurchaseProbeHandoff.Key(107, "123456", "a".repeat(64), 7)
@Test fun `same task consumes once and manual retry cannot inherit`() {
val store = PurchaseProbeHandoff()
store.remember(key, "PddActivity", 3, 100)
assertEquals("PddActivity", store.consume(key, "purchase", 3, 60_100))
assertNull(store.consume(key, "purchase", 3, 60_101))
}
@Test fun `mismatched task goods rule device phase revision and expiry reject and consume`() {
for (other in listOf(key.copy(taskId = 108), key.copy(goodsId = "654321"),
key.copy(ruleHash = "b".repeat(64)), key.copy(deviceId = 8))) {
val store = PurchaseProbeHandoff()
store.remember(key, "PddActivity", 3, 100)
assertNull(store.consume(other, "purchase", 3, 101))
assertNull(store.consume(key, "purchase", 3, 102))
}
for ((phase, revision, now) in listOf(Triple("spec_probe", 3L, 101L),
Triple("purchase", 4L, 101L), Triple("purchase", 3L, 180_101L), Triple("purchase", 3L, 99L))) {
val store = PurchaseProbeHandoff()
store.remember(key, "PddActivity", 3, 100)
assertNull(store.consume(key, phase, revision, now))
}
}
@Test fun `clear and process recreation discard context`() {
val store = PurchaseProbeHandoff()
store.remember(key, "PddActivity", 3, 100)
store.clear()
assertNull(store.consume(key, "purchase", 3, 101))
assertNull(PurchaseProbeHandoff().consume(key, "purchase", 3, 101))
}
}
+2 -75
View File
@@ -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: 029dc3e155e1828484c62efd0ac804616fcbc714
synchronized_at: 2026-09-10T09:13:27Z
wiki_revision: eb9608c98fa117d4fcc2019ed6ae60b6a3670f42
synchronized_at: 2026-09-09T09:24:10Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -387,76 +387,3 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- #198 `ProcessSpecAIParseRun → Reparse` 自然接入确定性优化,仍需 AI 的记录沿用原后续步骤,不新增异步任务/Provider 调用/接口。
- 验证 `go test ./app/goauto/sybimport ./app/goauto/aimatching ./app/goauto/shopeeproduct ./app/goauto/purchase` 和 `go build ./...` 通过;新增候选正反/格式/组合色/单维度、歧义/空值、同步重解析一致、人工保护及无需调用 AI 测试。AI 原有置信度/失败重试测试改用仍需语义理解的合成输入,继续验证原分支。
- 本轮只使用本地测试数据与 httptest AI,未触发真实 SYB 同步、外部 AI、采购、数据库迁移或服务重启;线上及历史数据效果尚待授权验证。
## 蝦皮详情匹配等待与回读(#254)
实现绑定 `9088e6b`(2026-09-10,分支 fix/254-match-loading);已通过合成数据测试,尚未合并 main 或发布线上。
- Server `shopeeproduct.Service.DetailWithAutoMatchBudget` 为详情 GET 附加派生的整次匹配预算,不返回 Provider、Key 等配置。`AutoMatchMappings` 读取 AI 设置 T,使用 2T+10 秒的单个父 context;颜色、尺码、保存及结果回读共用剩余时间。初始配置读取也受有界 context 约束,读取耗时从后续预算扣除。
- 批量定时匹配调用内部 `autoMatchMappings` 并继续使用原 MaxProviderTimeout=600 秒,不随本次详情入口调整;候选建议与其他 AI 功能不改。
- Web `ShopeeProductDetailDrawer` 在提交前以普通 GET 刷新商品与预算,仅 auto-match POST 使用 (2T+20) 秒超时。准备失败/缺少预算时不发 POST;旧 Server 必须先更新,不能只发布新版 Web。
- 加载状态同时绑定商品加载代次和操作序号。关闭/切换立即结束该页面等待状态;同商品重开读取详情,旧响应/旧 finally 不得污染新操作。匹配期间禁止在当前抽屉编辑映射,原人工草稿放弃确认保留。
- POST 未正常返回时只回读详情及关联 PDD,不自动重发 POST;回读失败结束转圈并保留刷新入口。GET 沿用单请求10秒超时,完整结果回读最多两次串行 GET,不是无限等待。
- 验证:Go shopeeproduct/aimatching/clientapi 测试与 go build;Web `node --test tests/unit/shopee-match-lifecycle.test.cjs`,Playwright shopee-match-lifecycle 与 shopee-pdd-picker,受影响 ESLint 与 build:prod。
## SYB 行内一键关联最近采集(#253)
实现绑定 `b1594dc`,2026-09-10;分支 feat/253-one-click-link,包含 #254 依赖。已完成合成数据测试及构建,未合并 main、未部署,不代表当前线上已具备此能力。
- Web `syb-products/index.vue` 在“去关联”旁增加“一键关联+下拉箭头”,共享 `quick-link.js` 页面级状态。`quick-link-device-preference.js` 按当前浏览器来源、API环境、用户ID保存设备ID,独立于批量采购偏好;不存密钥或商品数据。首次主动作选机继续原行,箭头选机只保存偏好。
- Server `shopeeproduct/latest_collection.go` 复用 LinkPDD 的可选 Admin 模式。短事务核对 SYB→Shopee、未关联/规格版本、设备状态,选择并锁定最新 completed/completed_partial 的 agent_current_page 任务及其 PDD;仅完整采集、active且有可用颜色或尺码时更新,WHERE pdd_product_id IS NULL 不覆盖其他操作者的关联。响应保留该事务产生的规格版本及来源task/device/PDD,不能用后续GET替换冻结上下文再自动匹配。
- 不新增表、迁移、路由或权限对账。原手动关联/替换请求不变;共享 Handler 明确拒绝 Client API 使用此跨模块模式,不能借 Shopee-only write grant 读取 SYB/设备上下文。来源任务原规则快照不改,taskId 可回溯既有采集证据。
- 关联后读取现有采购准备;需要人工确认或不具备AI资格时停止自动链路并保留关联,其他情况调用原 AutoMatchMappings,沿用 #254 动态预算及原事务保存/上下文冲突保护。最后刷新实际处理阶段,不调用创建采购/订单接口。
- 运行期间冻结行、商品、手机;前端重复主动作禁用,切换偏好仍只影响未来操作。卸载或 keep-alive deactivated 令旧操作失效,禁止旧回调继续下一步;不声称已经取消服务端在途写入。异常仅有限GET回读和刷新,不自动重发POST。
- 验证命令:Server `go test -p 1 ./...`、`go build ./...`;Web `node --test tests/unit/syb-quick-link.test.cjs tests/unit/shopee-match-lifecycle.test.cjs`、`pnpm exec playwright test tests/e2e/syb-quick-link.spec.ts tests/e2e/shopee-match-lifecycle.spec.ts --workers=1`、受影响文件 ESLint、`pnpm build:prod`。SQLite 单连接并发测试不代替生产 MySQL 行锁验收;真实AI、生产数据、跨设备人工闭环待验证。
## SYB 行内一键替换(#258)
实现绑定 `2b01974`;部署与验收见 #258 发布证据。本节补充 #253,不改变原未关联入口。
- `syb-products/quick-link.js` 复用分体按钮、设备偏好和匹配/回读流程;已关联且允许处理阶段时同位置显示一键替换。先只读预览,固定任务、目标商品及规格版本;不同商品一次确认,同商品不重绑。
- `shopeeproduct/latest_collection.go` 在原 latestCollection 增加可选 replacement 模式。预览和提交均短事务锁定 SYB、Shopee、原PDD、设备、来源采集与目标PDD,核对旧关联及上下文。提交按预览 taskId 读取,不重新挑选后来采集的商品。
- Admin 路由注入 `purchase.ValidateQuickReplacement`,在事务内复用采购准备数据集及处理阶段计算,不调用 AI。缺少校验函数拒绝替换;客户端密钥不注入且仍拒绝 latestCollection。无新路由、权限、表或迁移。
- 关联完成后才执行现有 AI 匹配,不在数据库事务中等待 Provider;同一 SYB 正式采购创建与替换共用 SYB 行锁。历史采购快照不变。SQLite 合成测试覆盖冲突与串行并发请求,不能替代生产 MySQL 多连接锁验收。
## 辅助信息缺失的采集关联(#259)
实现绑定 `e467706`,仅 Server;部署/验收见 #259。替代 #253/#258 中“部分完成一律拒绝”的限制,不改变 completed 原有路径。
- completed_partial 的 missing 必须是非空合法列表且仅包含 salesText、shopName、reviewCount,才进入辅助信息兼容检查;title、规格/价格缺失、遍历截断、SKU数量上限及未知原因仍拒绝。
- `shopeeproduct/partial_collection.go` 核对本次 task 的维度和值、可售SKU的完整标记/规格引用/非负价格、本次颜色价格,以及当前档案可选值由本次有效SKU覆盖,不能借旧档案合并残留的规格或价格通过。真实单维度允许,不虚构另一维度;本次明确报告缺少规格仍拒绝。
- 一键关联、替换预览及确认提交共用验证。失败沿用409 LATEST_COLLECTION_UNAVAILABLE及现有提示,不回退更早采集、不换手机。状态 completed_partial 和 missing 原样保留;辅助字段缺失仍可在任务详情查看。
- 无接口字段、数据库迁移、Android/Web或权限变更;普通人工关联与采购/AI资格不变。不将“允许关联”视为“已匹配或已采购”。
## 采购单标题面板与地址就绪等待(#260、#261)
实现绑定 #260 `3c1ffa4`(执行器回归测试 `7de4318`)、#261 `5ea07f1`,Android 0.9.74 / versionCode87,基于0.9.73。安装及真实采购验收以工单证据为准;不代表 main 的 Android 已整合这些分支。
- 采购仅在安全规格入口点击返回SUCCESS后,记录本次页面包名/Activity作为内存上下文;开始新执行或页面身份改变时清除。`PddScreenParser` 的新增 purchaseEntryContext 默认关闭,仅该采购执行器传入;普通解析和采集原判定保留。
- 当前仍为同一PDD Activity、商品ID非空、没有页面风险、没有选中摘要,且唯一有标题规格滚动区、一个维度有值,同时具有关闭、数量、支付展示区域与下单操作证据时,单标题可识别为NORMAL_SCROLLABLE。只是允许进入既有精确规格探测/选择,不执行下单或付款,不全局认可UNKNOWN,不增加入口重复点击或滑动。
- 地址入口点击原有1000ms保留;地址列表等待增加唯一修改目标要求。点击修改和保存由500ms改为1000ms,其他通用动作间隔不变,无新配置项。
- 修改后不再只见“详细地址”文字就立即写入。最多50次、每次间隔200ms读取(不含读取耗时),检查PDD页面风险;唯一可见启用有内容的详细地址EditText,连续两次路径、边界、内容及Activity相同才继续原输入流程。始终零个/多个或不稳定时失败,不取第一个候选。
- 输入内容生成、输入后回读、保存后退出及返回规格面板、最终复核和单次下单边界不变。新增本机脱敏诊断 addressEditorCandidates / addressEditorPolls 两个整数,与task/attempt/device/ruleHash关联;不记录地址内容、原始树或整屏截图。
- 仅Android;无Server/Web、共享API字段、数据库迁移或权限变化。真实采购、地址写入及订单创建不作为自动安装验证,不执行付款。
### v2 商品入口补点与地址返回等待(#260、#261)
2026-09-10 实现绑定 #260 `713111d`、#261 `d0a82f3`,Android 0.9.75 / versionCode88;真实采购效果以工单验收为准,非 main 合并声明。
- 入口恢复不再把全页面支付方式展示文案单独视为支付页。当前包名/Activity、商品标题和安全入口必须与点击前一致,入口唯一且可用;已打开面板、关闭控件、数量、选中摘要、订单操作、风险/评价页面仍拒绝恢复。支付 Activity 或明确支付、订单、地址文本继续阻止补点。仅允许既有一次重新定位后的规格入口中心手势,随后仍验证面板,不循环、不扩展到地址保存或下单。
- 保存地址后的确认读取、必要时一次返回后的确认读取,增加最多50次、间隔200ms的有界就绪检查,页面捕获耗时另计。连续两次同一Activity满足原地址回读与确认面板条件后继续;完整信息不要求滚动区域。已明确到地址列表时保留原返回路径,不等待列表出现下单按钮;返回后增加1000ms缓冲。
- 就绪检查不修改原地址回读和结构下单入口规则;超时后只对既有识别的面板、唯一可用滚动区域执行原有有限滚动。零/多个区域仍失败,不凭地址入口或未知底部节点授权下单。最终精确规格、数量、价格、地址与单次下单边界不变。
- 私有采购诊断新增布尔 savedAddressMatched、savedPanelMatched;整数 savedLegacySubmitCandidates、savedAddressCards、savedAddressTargets、savedScrollCandidates、savedConfirmationPolls。只记录标量并绑定task/attempt/device/ruleHash,不保存地址、原始树或整屏图。
- CG109 v1现场读取未达到空闲状态,不能确认原失败具体缺哪项证据;v2覆盖迟到确认节点及可观测性,不将合成测试通过冒充真机修复验收。无Server/Web/API/迁移/权限变化,不需要服务器重启。
## 探测到正式采购的单次面板交接(#262)
2026-09-10 实现绑定 `2ea32fd`,Android0.9.76/code89;基于#260/#261,未声明合并main或真实采购验收通过。
- AgentForegroundService通过PurchaseProbeHandoff只在成功spec_probe且结束后新快照仍识别PDD规格面板时保留一次性内存交接。仅taskId/goodsId/ruleHash/deviceId、Activity、前台revision和单调时钟时间,不保留控件节点、地址或选中状态,不落盘;有效期180秒。
- 正式阶段消费时检查同任务/商品/规则/设备、前台revision及时间,消费后即清除。失配/超时/无任务或采集任务调度/结果异常/进程重启均不能沿用;普通手动再试不会继承已消费交接。现有服务端租约、互斥和匹配等待策略不变。
- 有交接时,执行器恢复限定的purchaseEntryContext,最多10次、间隔200ms新读取(捕获耗时另计);连续两次相同PDD Activity且具备既有商品/面板证据才跳过openProduct。空树可有限等待,其他应用/Activity改变/页面风险立即拒绝复用。没有交接时保留原一次判断路径;不把UNKNOWN当成功,不跳过正式精确规格/数量/价格/地址或下单验证。
- 私有诊断新增handoffPresent、productReused布尔值,reusePolls整数及reuseReason固定枚举legacy_evidence/page_problem/page_changed/handoff_ready/evidence_timeout。仍绑定task/attempt/device/ruleHash,无敏感文本。
- 无Server/Web、共享API或数据库变化,不需重启服务器。只有合成测试验证;原CG107/108日志证实两阶段,但未记录旧版重开判断,不把上下文丢失假设宣称为唯一现场根因。
+2 -79
View File
@@ -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: d8aeff02fba57cba0b53fb22cdb524fd5cc0cb3e
synchronized_at: 2026-09-10T09:13:33Z
wiki_revision: cf6bd9fb5dd727417a3ed612ddcf52eb2d8d868d
synchronized_at: 2026-09-09T09:24:15Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -37,8 +37,6 @@ synchronized_at: 2026-09-10T09:13:33Z
- PDD 重新采集或更换关联后,目标规格仍存在则映射继续有效;目标规格消失时详情标记“已失效”,服务端拒绝保存不存在的目标,采购预检和创建也拒绝使用失效映射并提示重新选择。
- 批量软删除逐条校验引用(虾皮商品被 SYB 明细或采购任务引用时不可删除)并逐条返回结果;已删除商品默认不出现在列表,可筛选查看并恢复。
- #257:蝦皮颜色与尺码建议/一键匹配先保留仍有效的已确认映射,再复用相同标准化比较:简体、空白、全半角与大小写统一。颜色标准化后仅命中一个当前可选 PDD 候选时,不调用 AI,建议标为 matched,一键匹配按既有流程保存 exact_match + confirmed;未命中或多个不同候选标准化后相同,仍走原 AI 与置信度/候选校验。只转换比较键,蝦皮/PDD 原规格名不改,映射目标保存 PDD 原始候选名称;不引入颜色同义词猜测、不自动重算历史映射。
## SYB 商品明细
- 一行对应 SYB 一条明确的商品/颜色/尺码/数量明细;唯一键为「蝦皮订单号 `code` + 来源明细 `id`」组合,不是全局唯一 ID(未在多货运单样本中验证过全局唯一性)。
@@ -106,8 +104,6 @@ synchronized_at: 2026-09-10T09:13:33Z
- PDD 完整规格选择器若无主滚动容器且尚未选择规格而没有已选摘要,仍可识别为非滚动规格面板,但必须同时出现至少两个已解析维度、至少两个可选项、唯一数量控件和唯一底部订单动作;缺少其中任一强证据时保持未识别。该订单动作仍仅作页面证据,永久禁止点击。
- Agent 可扩展,但规则必须按任务类型授权:采集规则不能创建订单,采购规则只能使用独立审核的创建订单能力。当前项目不实现支付动作、入口或测试;支付、下单和订单相关文字可以作为只读页面证据配置,但不得成为点击目标。后续支付能力必须单独评估并至少具备显式能力位、服务端开关、单笔金额上限和人工授权。
- #256(Android 0.9.73,实现 43703e0):采集执行器可识别无“已选/请选择”摘要且当前仅一个规格标题的滚动规格面板。必须匹配 PDD 包名、任务详情 Activity 与选择器,无登录/验证等异常,并同时具备唯一有界规格滚动区域、唯一标题及非空候选、关闭、唯一数量输入/增减、支付区和唯一下方提交订单只读动作;缺失任一证据仍拒绝。此分支只由采集上下文启用,不生成虚假选中摘要,采购与其他解析调用维持原条件。订单动作仍仅作只读证据,采集绝不点击。
## 采集任务
- 任务与结果共用 `collection_task` 主表,不建立独立 `collection_result` 主表。
@@ -537,76 +533,3 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- 同步入库 ApplyDetail、手动 Reparse 共用;成功核对不再次合并规格,因此不新增重复维度、不覆盖已有映射。人工确认仍优先,默认 Reparse 跳过人工与 AI 已确认值;原文保留。
- #198 批处理原本先 Reparse,所以可直接复用本优化:能确定的不用调用 AI;仍不确定的按原 AI 流程处理。没有新增调度、调用额度或同步等待,不自动开启已有任务。
- 不取消 uncertain/failed 状态,不改变采购可信度/可采购规则,不实现一键关联 PDD。部署后新同步或显式重新解析会使用新逻辑;本次没有执行生产批量更新。
## 蝦皮详情匹配等待与回读(#254)
实现绑定 `9088e6b`(2026-09-10,分支 fix/254-match-loading);已通过合成数据测试,尚未合并 main 或发布线上。
- AI 设置中的 timeoutSeconds=T 仍是单次 Provider 请求的上限。详情一键匹配的整次预算为 2T+10 秒,浏览器 POST 等待上限为 2T+20 秒;例如 T=60 时130/140秒,T=180时370/380秒。只保留现有一个 AI 设置入口,不新增时间配置框,也不自动改变管理员已保存的 T。
- 超时上限不是固定等待。颜色/尺码串行执行且共用整次预算;正常部分匹配仍按原事务保存合格结果,AI异常在保存前返回时不提交本轮新映射。既有置信度、候选、人工确认映射保护和采购资格不变。
- 前端超时、关闭或切换详情不代表后端已取消、未保存或已回滚;必须以回读结果为准,无法回读显示结果未确认,不自动重复匹配、不自动创建采购。
- 单商品详情的成功/失败/超时/关闭重开都必须结束失效的等待状态;旧请求不得覆盖新商品或新操作。匹配进行期间本抽屉不能人工改映射,已有未保存草稿仍按原确认流程处理。
- UI匹配提示的等待上限仅指POST,不含提交前GET及结果核对GET;结果核对也有限时,不能让失败提示再次陷入无限转圈。
## SYB 行内一键关联最近采集(#253)
实现绑定 `b1594dc`,2026-09-10;分支 feat/253-one-click-link,包含 #254 依赖。已完成合成数据测试及构建,未合并 main、未部署,不代表当前线上已具备此能力。
- 未关联 PDD 且既有处理阶段提供“去关联”的 SYB 行可用一键关联;原人工入口不删除。首次点击选择采集手机后继续当前行,后续使用记忆手机;小箭头查看/切换手机,保存只修改全行共用偏好。取消不改变偏好、不执行关联。与批量采购设备选择互不影响。
- 以所选手机的临时采集 source=agent_current_page 为范围,从 completed、completed_partial 中按 finished_at DESC、id DESC 取最新一条;失败/待执行/执行中不参与。最新为部分成功或商品停用、无可用规格时明确停止,不回退更旧商品,不切换其他手机。离线手机允许使用已有成功结果,停用或已删除手机需要重新选择。
- 主动作开始冻结行、蝦皮商品和设备;服务端关联事务固定来源采集任务、PDD商品与规格版本。仅未关联时自动写入,已有或被其他操作改变的关联不覆盖。关联作用于蝦皮商品,因此共用该蝦皮档案的其他 SYB 明细也受影响;切换手机不会重绑已有商品,也不会改变在途操作。
- 先保存关联,再复用现有颜色/尺码自动匹配及保存。有效已确认映射保留;普通部分匹配保存合格结果,AI故障不提交本轮新映射,但已保存关联保留。后续可从“去匹配”或详情人工继续。failed 且无人工/AI确认时不能自动跳过人工解析;uncertain 不统一拦截,仍以原采购准备规则为准。
- 操作完成不保证立即可采购,必须刷新现有处理阶段;缺规格、缺映射、解析需确认或其他原资格不满足时继续显示真实待处理阶段。不自动创建采购任务、不下单、不支付。
- 请求超时/网络中断只做有限回读,不重复提交;读回失败明确结果未确认,避免无限转圈。离开页面不代表后端停止或回滚。普通用户沿用现有采购员权限,无新管理员专属开关。
## SYB 已关联商品的一键替换(#258)
实现绑定 `2b01974`;部署与验收见 #258 发布证据。
- 复用一键关联及小箭头的位置,保留原查看 PDD 商品。PDD 待采集、采集失败、停用或缺价格的对应处理阶段可替换;采集中、已创建/运行采购、成功或结果未知不开放。服务端提交时再次检查,不能只相信按钮。
- 复用每用户/浏览器/API环境的手机偏好;首次选择继续当前行,小箭头仅切换偏好。预览仅选所选手机最新 completed/completed_partial 临时采集,部分成功或不可用不回退旧商品。确认后固定来源任务和目标,不改用新采集;来源状态、设备、目标规格或旧关联改变时拒绝。
- 不同商品显示一次确认,包含旧/新商品、手机和共享蝦皮档案影响;取消不写入。同商品不弹替换确认且不重写关联,只继续匹配。共享该蝦皮档案的 SYB 明细使用新关联,历史采购任务快照不改。
- 沿用既有更换关联和有效映射检查,再复用自动匹配保存。部分匹配、AI失败或解析需人工处理时保留已保存关联,显示真实处理阶段,不保证立即可采购、不自动创建采购。
- 网络异常只有限回读,区分目标商品、仍为原商品和其他关联,不把“已有某个关联”误报为本次替换成功;不自动重发写请求。没有新增后台作业、订单或付款操作。
## 辅助信息缺失的采集关联(#259)
实现绑定 `e467706`,仅 Server;部署/验收见 #259。替代 #253/#258 中“部分完成一律拒绝”的限制,不改变 completed 原有路径。
- completed_partial 的 missing 必须是非空合法列表且仅包含 salesText、shopName、reviewCount,才进入辅助信息兼容检查;title、规格/价格缺失、遍历截断、SKU数量上限及未知原因仍拒绝。
- `shopeeproduct/partial_collection.go` 核对本次 task 的维度和值、可售SKU的完整标记/规格引用/非负价格、本次颜色价格,以及当前档案可选值由本次有效SKU覆盖,不能借旧档案合并残留的规格或价格通过。真实单维度允许,不虚构另一维度;本次明确报告缺少规格仍拒绝。
- 一键关联、替换预览及确认提交共用验证。失败沿用409 LATEST_COLLECTION_UNAVAILABLE及现有提示,不回退更早采集、不换手机。状态 completed_partial 和 missing 原样保留;辅助字段缺失仍可在任务详情查看。
- 无接口字段、数据库迁移、Android/Web或权限变更;普通人工关联与采购/AI资格不变。不将“允许关联”视为“已匹配或已采购”。
## 采购单标题面板与地址就绪等待(#260、#261)
实现绑定 #260 `3c1ffa4`(执行器回归测试 `7de4318`)、#261 `5ea07f1`,Android 0.9.74 / versionCode87,基于0.9.73。安装及真实采购验收以工单证据为准;不代表 main 的 Android 已整合这些分支。
- 采购仅在安全规格入口点击返回SUCCESS后,记录本次页面包名/Activity作为内存上下文;开始新执行或页面身份改变时清除。`PddScreenParser` 的新增 purchaseEntryContext 默认关闭,仅该采购执行器传入;普通解析和采集原判定保留。
- 当前仍为同一PDD Activity、商品ID非空、没有页面风险、没有选中摘要,且唯一有标题规格滚动区、一个维度有值,同时具有关闭、数量、支付展示区域与下单操作证据时,单标题可识别为NORMAL_SCROLLABLE。只是允许进入既有精确规格探测/选择,不执行下单或付款,不全局认可UNKNOWN,不增加入口重复点击或滑动。
- 地址入口点击原有1000ms保留;地址列表等待增加唯一修改目标要求。点击修改和保存由500ms改为1000ms,其他通用动作间隔不变,无新配置项。
- 修改后不再只见“详细地址”文字就立即写入。最多50次、每次间隔200ms读取(不含读取耗时),检查PDD页面风险;唯一可见启用有内容的详细地址EditText,连续两次路径、边界、内容及Activity相同才继续原输入流程。始终零个/多个或不稳定时失败,不取第一个候选。
- 输入内容生成、输入后回读、保存后退出及返回规格面板、最终复核和单次下单边界不变。新增本机脱敏诊断 addressEditorCandidates / addressEditorPolls 两个整数,与task/attempt/device/ruleHash关联;不记录地址内容、原始树或整屏截图。
- 仅Android;无Server/Web、共享API字段、数据库迁移或权限变化。真实采购、地址写入及订单创建不作为自动安装验证,不执行付款。
### v2 商品入口补点与地址返回等待(#260、#261)
2026-09-10 实现绑定 #260 `713111d`、#261 `d0a82f3`,Android 0.9.75 / versionCode88;真实采购效果以工单验收为准,非 main 合并声明。
- 入口恢复不再把全页面支付方式展示文案单独视为支付页。当前包名/Activity、商品标题和安全入口必须与点击前一致,入口唯一且可用;已打开面板、关闭控件、数量、选中摘要、订单操作、风险/评价页面仍拒绝恢复。支付 Activity 或明确支付、订单、地址文本继续阻止补点。仅允许既有一次重新定位后的规格入口中心手势,随后仍验证面板,不循环、不扩展到地址保存或下单。
- 保存地址后的确认读取、必要时一次返回后的确认读取,增加最多50次、间隔200ms的有界就绪检查,页面捕获耗时另计。连续两次同一Activity满足原地址回读与确认面板条件后继续;完整信息不要求滚动区域。已明确到地址列表时保留原返回路径,不等待列表出现下单按钮;返回后增加1000ms缓冲。
- 就绪检查不修改原地址回读和结构下单入口规则;超时后只对既有识别的面板、唯一可用滚动区域执行原有有限滚动。零/多个区域仍失败,不凭地址入口或未知底部节点授权下单。最终精确规格、数量、价格、地址与单次下单边界不变。
- 私有采购诊断新增布尔 savedAddressMatched、savedPanelMatched;整数 savedLegacySubmitCandidates、savedAddressCards、savedAddressTargets、savedScrollCandidates、savedConfirmationPolls。只记录标量并绑定task/attempt/device/ruleHash,不保存地址、原始树或整屏图。
- CG109 v1现场读取未达到空闲状态,不能确认原失败具体缺哪项证据;v2覆盖迟到确认节点及可观测性,不将合成测试通过冒充真机修复验收。无Server/Web/API/迁移/权限变化,不需要服务器重启。
## 探测到正式采购的单次面板交接(#262)
2026-09-10 实现绑定 `2ea32fd`,Android0.9.76/code89;基于#260/#261,未声明合并main或真实采购验收通过。
- AgentForegroundService通过PurchaseProbeHandoff只在成功spec_probe且结束后新快照仍识别PDD规格面板时保留一次性内存交接。仅taskId/goodsId/ruleHash/deviceId、Activity、前台revision和单调时钟时间,不保留控件节点、地址或选中状态,不落盘;有效期180秒。
- 正式阶段消费时检查同任务/商品/规则/设备、前台revision及时间,消费后即清除。失配/超时/无任务或采集任务调度/结果异常/进程重启均不能沿用;普通手动再试不会继承已消费交接。现有服务端租约、互斥和匹配等待策略不变。
- 有交接时,执行器恢复限定的purchaseEntryContext,最多10次、间隔200ms新读取(捕获耗时另计);连续两次相同PDD Activity且具备既有商品/面板证据才跳过openProduct。空树可有限等待,其他应用/Activity改变/页面风险立即拒绝复用。没有交接时保留原一次判断路径;不把UNKNOWN当成功,不跳过正式精确规格/数量/价格/地址或下单验证。
- 私有诊断新增handoffPresent、productReused布尔值,reusePolls整数及reuseReason固定枚举legacy_evidence/page_problem/page_changed/handoff_ready/evidence_timeout。仍绑定task/attempt/device/ruleHash,无敏感文本。
- 无Server/Web、共享API或数据库变化,不需重启服务器。只有合成测试验证;原CG107/108日志证实两阶段,但未记录旧版重开判断,不把上下文丢失假设宣称为唯一现场根因。
+2 -21
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
wiki_revision: 18744477bfd17f396e8c76ec7a2fcc6720acdf6f
synchronized_at: 2026-09-09T03:16:51Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:16Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -85,22 +85,3 @@ 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 则停止,不更改权限绕过;首版没有导出界面。该功能用于确定拒绝分支,并不代表已经修复对应采购故障。
+13 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: 49dec4c35da793a32822a82e00f3b657757b6d44
synchronized_at: 2026-09-07T13:35:25Z
wiki_revision: 217239a85b50c880e004ecc19a559fb5e03555e1
synchronized_at: 2026-09-08T01:38:42Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -77,7 +77,7 @@ PATCH /api/admin/v1/syb-products/{productId}/correction
`import` 允许已认证的管理员(admin)和采购员(purchaser)调用(#236),仍须通过 Casbin 权限校验;其他角色返回 403。采购员的 POST 权限由既有启动权限对账写入,不需要新增数据库迁移。操作人取已认证 claims,不接受客户端冒名。请求体提交 `dateFrom`、`dateTo` 后创建持久化后台任务并立即以 `202` 返回 `runId` 和 `status=running`;关闭弹窗、刷新或离开页面不影响任务。没有启用店铺时必须在读取凭据、登录、验证码 OCR 和任意 SYB 网络请求之前返回 `422`。任意时刻只能有一条 `running` 记录,内存锁与数据库唯一执行槽共同阻止单进程和跨进程重复导入;冲突时返回正在执行任务的日期范围。
`sync-runs` 列表支持 `page`、`pageSize`、`status`、`dateFrom`、`dateTo`,详情返回日期范围、状态(`running` / `succeeded` / `failed` / `interrupted`)、处理天数、货运单/明细/新增/覆盖数量、店铺准入与跳过数量、店铺筛选快照哈希、按店铺的 `accepted` / `skipped` 统计、操作人和起止时间。列表和详情对已登录角色只读开放。服务启动时遗留的 `running` 任务改为 `interrupted`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
`sync-runs` 列表支持 `page`、`pageSize`、`status`、`dateFrom`、`dateTo`,详情返回日期范围、状态(`running` / `succeeded` / `partial_success` / `failed` / `interrupted`)、处理天数、货运单/明细/新增/覆盖数量、店铺准入与跳过数量、店铺筛选快照哈希、按店铺的 `accepted` / `skipped` 统计、操作人和起止时间。列表和详情对已登录角色只读开放。服务启动时遗留的 `running` 任务改为 `interrupted`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
列表返回结构化字段(`orderCode`、`shopeeItemId`、`productTitle`、`targetColor`、`targetSize`、`quantity`、`unitPriceCent`、`imageUrl`、`parseStatus`、`parseNote`、`manuallyConfirmed`),不含原始 JSON;`keyword` 匹配订单号、虾皮商品ID 或商品标题,`parseStatus` 筛选 `success`/`uncertain`/`failed`。详情额外返回 `rawJson`(原始 `details[]` 元素,未做任何改写)。
@@ -975,3 +975,13 @@ Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口
本地 `GoAutoPurchasePanel` 脱敏结构日志关联 task、attempt、device、rule(规则 SHA-256),不上传原始页面。新增事件:`postSwipe=skipped;action=openSpecPanel;reason=spec_panel_on_demand;panel=<枚举>`;其他必需滑动失败为 `postSwipe=failed;action=<动作枚举>;direction=<方向枚举>;swipeIndex=<本动作内第几次滑动>;reason=<固定分类>`。
固定失败分类:unknown、root_unavailable、no_scrollable、invalid_bounds、gesture_unsupported、gesture_rejected、gesture_cancelled、gesture_timeout。日志不含规格值、节点文本、坐标、地址、订单、凭据、原始树或截图;结果错误码仍为 RULE_ACTION_FAILED,现有结果提交字段不变。
## SYB 逐页保存与部分成功(#239)
实现绑定 c6a962d;代码已实现不代表当前线上已部署。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。
同步状态增加 `partial_success`(部分成功,15 字符,复用现有 varchar(16),无需迁移)。有错误且 created+updated>0 为部分成功;有错误无已提交明细为 failed;完整且无错误为 succeeded(包括无符合店铺的数据)。中断仍为 interrupted,不把中断追认为成功。所有终态沿用活动槽释放规则。
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
+21 -20
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: SYB-ERP-Interface-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
wiki_revision: a4f4ab200af052bab7ffbabe267238528bcd7cf9
synchronized_at: 2026-09-07T07:02:57Z
wiki_revision: 214485db630141aeb03d4b49b22a65e572fb592a
synchronized_at: 2026-09-08T02:24:10Z
<!-- gitea-wiki-mirror:end -->
# 12 顺云宝(SYB)ERP 接口契约
@@ -220,22 +220,13 @@ Admin 默认 `max_matches = 10000`,可以在配置中调整;上限针对整
有限重试:单次 HTTP 总超时 60 秒,最多执行 3 次,重试前分别等待 1 秒、2 秒,等待须
响应 context 取消。HTTP 401/403、明确会话失效、业务失败、接口数据完整性错误和本地
校验失败不得重试;登录、验证码、单件码写入和任何回填请求也不得使用该机制。重试
耗尽后返回最后一次错误,外层继续保留日期、页码和已获取数量上下文并将同步标为失败。
耗尽后返回最后一次错误,外层保留日期、页码和已获取数量上下文,按最终已保存成果将同步标为失败或部分成功。
今天的货运单会在同步期间持续新增。只有 UTC+8 下的今天发生上述快照漂移时,
允许只重试今天的列表分页,最多 3 次;已经完成的历史日期不得重复拉取,每次尝试
也必须使用独立 ID 集合。第三次仍不稳定时,可以对最后一次取得的合法唯一 ID
读取完整明细并按既有 upsert 保存,但本次同步仍记为失败、明确提示当天未形成
稳定快照且不推进游标,下一次继续覆盖今天。任何尝试都不得突破 `max_matches`;
网络/业务错误、非法 ID 或不完整明细不属于可放宽的快照漂移。
`[必须,#235]` 当天跨页重复 ID 纳入上述最多 3 次列表快照尝试(含首次),
不增加另一层重试次数。发现跨页重复后丢弃本次列表,从预检总数和第一页重新开始,
使用全新 ID 集合;最后一次仍重复时直接失败,不得去重后按成功或降级数据保存。
已经完成的历史日期保持其已有结果,不重复拉取;历史日期重复不适用此恢复。
每页先检查非法 ID 和页内重复,再检查跨页重叠;同页同时存在页内重复和跨页重叠时
仍作为硬错误停止。原有总数漂移/短页的合法唯一列表降级保存条件保持不变。
重复诊断只记录日期、当天尝试序号、首次/当前页码与行号、start、pageSize、
expectedTotal 和已获取唯一数量,不记录真实重复 ID、原始响应或个人数据。
自 #239(实现提交 c6a962d,已于 2026-09-08 随 d403f3b 部署线上)起,改为按页验证和保存:每页先验证条数、合法 ID、页内/跨页重复,再按冻结店铺快照获取并验证全部明细;外部请求完成后才开启页事务。页内任一入库失败回滚整页,已提交的前页保留,计数在事务提交后累计。
当天和历史日期统一处理:分页/明细失败或最终总数漂移时停止该日期并继续后续日期,不在同次运行中重新扫描当天,以避免重复写入和计数。数据库故障、进度持久化失败、明确会话失效、上下文取消或总任务超时停止整个范围。下一次人工或定时同步重新扫描日期,按 (order_code, detail_id) 幂等覆盖并保留人工确认。跨页重复不去重后冒充完整数据;最终日期完整性校验仍覆盖所有店铺。此流程替代此前 #235 的当天三次整日快照重扫。
重复诊断只记录日期、首次/当前页码和行号、start、pageSize、expectedTotal、已获取唯一数量,不记录真实重复 ID、原始响应或个人数据。
### 4.4 统一日期范围同步与覆盖游标
@@ -258,7 +249,7 @@ GoAuto 同步记录页的立即同步允许管理员和采购员(purchaser)
或超过 `max_matches` 时不推进游标。
`[必须]` 每批明细响应必须与请求的货运单 ID 一一对应。缺失、重复、出现未请求
ID,或某张货运单返回空商品明细,都视为不完整并停止同步;已经写入的幂等数据
ID,或某张货运单返回空商品明细,都视为不完整并停止当前日期,继续后续日期;已经写入的幂等数据
可以保留,但只有所有日期全部成功才推进游标。
`[必须]` 登录和验证码只是同步前置步骤。日期范围经过自动 OCR 降级、手工
@@ -500,9 +491,9 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
> 以下「店铺准入」已由 GoAuto #49 采纳并实现;店铺规范化还包括全角/半角统一和忽略大小写。
`[必须]` **同步先校验原始全量,再做店铺准入。** 顺序固定为:按日期查询原始总数
并执行单次容量熔断 → 拉完当天原始列表并核对分页前后总数、页长和唯一 ID → 按
并执行单次容量熔断 → 逐页核对页长和唯一 ID,整日结束复核总数 → 按
`shopName` 去除首尾空白后与启用店铺精确匹配 → 只为接受的货运单请求明细和入库。
不能先过滤再做完整性校验,否则非目标店铺的分页漂移会被掩盖。
店铺过滤只决定明细获取和入库,不能减少原始列表完整性校验范围;已保存不代表整日完整。
`[必须]` 同步开始时只读取一次启用店铺,整次运行使用同一个快照。列表允许但明细
响应中的 `shopName` 变为空或非允许店铺时再次拦截。没有启用店铺时在会话/OCR/
@@ -657,3 +648,13 @@ settings:
- 客户端实现:`server/app/goauto/sybclient/`
- 落库与解析:`server/app/goauto/sybimport/`
- 相关工单:[#48 客户端移植](https://git.ilapage.cn/OPC/goauto/issues/48)、[#41 SYB 货运单商品导入](https://git.ilapage.cn/OPC/goauto/issues/41)、[#37 物流调度与货运宝回填](https://git.ilapage.cn/OPC/goauto/issues/37)
## SYB 逐页保存与部分成功(#239)
实现绑定 c6a962d;2026-09-08 已随 d403f3b 部署线上,未手动触发真实同步验收;#240 上游尾页超时尚未修复。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。
同步状态增加 `partial_success`(部分成功,15 字符,复用现有 varchar(16),无需迁移)。有错误且 created+updated>0 为部分成功;有错误无已提交明细为 failed;完整且无错误为 succeeded(包括无符合店铺的数据)。中断仍为 interrupted,不把中断追认为成功。所有终态沿用活动槽释放规则。
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
+5 -5
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: f951dbc8b6a555cbf8f44cda073910406ba55191
synchronized_at: 2026-09-07T09:43:56Z
wiki_revision: df033d06fd94a4b02a447a27ced8d711521342f3
synchronized_at: 2026-09-08T02:23:08Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
@@ -73,10 +73,10 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
## 客户端密钥部署与验证(#237)
实现基线 `71f7751`;2026-09-07 已按用户授权完成本机 MySQL 迁移、管理员菜单写入和 Server/Web 构建重启;管理页面 HTTP 列表与模块加载已验证。线上仍未部署。
实现基线 `71f7751`;2026-09-07 已按用户授权完成本机 MySQL 迁移、管理员菜单写入和 Server/Web 构建重启;管理页面 HTTP 列表与模块加载已验证。2026-09-08 经用户授权,线上已发布 Server/Web 基线 `d403f3b`,执行迁移 `1788798000000` 并重启 GoAuto/Nginx;迁移记录、两张密钥表、仅管理员菜单关联已回读。HTTP 页面与健康检查通过;未登录管理请求 HTTP 200、业务码 401。真实密钥创建及客户端业务读写闭环尚未验证。
- 发布前须单独授权追加迁移 `server/cmd/migrate/migration/version-local/1788798000000_client_api_key.go`,按既有迁移流程创建 client_api_key、client_api_key_audit 和“采采管理/客户端密钥”管理员菜单。前置父菜单必须存在;不应以赋予普通用户管理员角色代替迁移或权限核验。
- 用户于 2026-09-07 明确确认默认兼容 HTTP/HTTPS、不设开关并接受明文风险;部署本版本并执行迁移后,现有 `http://185.216.248.75:9527` 可以使用客户端密钥管理及客户端 API。本机已部署验证,不能据此宣称线上已经可用。HTTP 会明文传输密钥和业务数据,仍建议使用 HTTPS。
- 用户于 2026-09-07 明确确认默认兼容 HTTP/HTTPS、不设开关并接受明文风险;部署本版本并执行迁移后,现有 `http://185.216.248.75:9527` 可以使用客户端密钥管理及客户端 API。2026-09-08 线上部署及未登录拒绝检查已通过,真实密钥读写验收仍待进行。HTTP 会明文传输密钥和业务数据,仍建议使用 HTTPS。
- 客户端密钥功能不依赖 GOAUTO_TRUST_FORWARDED_PROTO、X-Forwarded-Proto 或 Agent HTTP 例外。既有其他路由的协议和代理配置保持不变;不伪造协议头,不新增明文放行开关。
- 代理、APM、应用日志均不得记录 Authorization、创建响应 secret 或原始业务载荷。应用对两类客户端密钥路由跳过旧请求/响应正文日志,使用专用元数据审计;实际代理日志脱敏仍须部署验收。
- 请求审计 status=0 可能表示在途、进程中断或结果审计更新失败;先按请求关联号核对业务结果,不自动重试采购、采集、同步等操作。停用阻止后续认证,不保证取消已开始的业务操作。
@@ -92,4 +92,4 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
- 真实迁移必须单独授权并先确认唯一待执行版本;常驻 API 加 `-SkipMigration`,日常重启不自动执行未来待审核迁移。#237 迁移 `1788798000000_client_api_key.go` 已执行,sys_migration 记录 `1788798000000`、两张密钥表及管理员菜单已回读;其他角色未新增菜单关联。任务启停状态未改。
- 范围明确的服务控制:`D:/supervisor/supervisord.exe -c D:/supervisor/supervisord.conf ctl status goauto-admin-api goauto-admin-ui`,启动/停止/重启将 status 分别替换为 start/stop/restart。不得为了 GoAuto 重启整个 Supervisor 或其他项目。配置内容变化后需重新读取配置;本次调用 `supervisor.reloadConfig`,再对上述两个程序定向 start,已通过进程命令行核对新目录。
- 日志:`D:/supervisor/logs/goauto-admin-api.log`、`D:/supervisor/logs/goauto-admin-ui.log`;对外分享只能保留脱敏结构摘要。迁移输出不得暴露凭据或业务原文。
- 管理员刷新页面后可进入 `http://127.0.0.1:9527/#/client-keys/index`;菜单缓存未更新时重新登录。已验证列表空态、创建弹窗加载 12 模块及 HTTP 风险提示;未实际创建密钥,真实客户端读写及停用闭环仍待范围明确的验证。线上 GoAuto/Nginx 未重启或发布。
- 管理员刷新页面后可进入 `http://127.0.0.1:9527/#/client-keys/index`;菜单缓存未更新时重新登录。已验证列表空态、创建弹窗加载 12 模块及 HTTP 风险提示;未实际创建密钥,真实客户端读写及停用闭环仍待范围明确的验证。本节为 2026-09-07 本机验证记录;线上已于 2026-09-08 另行授权发布,见上节。
@@ -44,6 +44,30 @@ func seedAIParseCandidate(t *testing.T, db *gorm.DB, serverURL string, detailID
return applied.SYBProduct
}
func TestScheduledParseUsesUniqueCandidatesBeforeCallingAI(t *testing.T) {
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
calls.Add(1)
response.WriteHeader(http.StatusInternalServerError)
}))
defer provider.Close()
db := openTestDB(t)
record := seedAIParseCandidate(t, db, provider.URL, 25102, "黑色 XL")
run, _, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil {
t.Fatal(err)
}
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
t.Fatal(err)
}
if err := db.First(&record, record.ID).Error; err != nil {
t.Fatal(err)
}
if record.ParseStatus != models.SYBParseStatusSuccess || record.TargetColor != "黑色" || record.TargetSize != "XL" || record.AIConfirmed || calls.Load() != 0 {
t.Fatalf("unique candidate must resolve deterministically, status=%s ai=%v calls=%d", record.ParseStatus, record.AIConfirmed, calls.Load())
}
}
func TestScheduledAIParseConfirmsClosedCandidatesAndDoesNotRepeat(t *testing.T) {
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
@@ -53,7 +77,7 @@ func TestScheduledAIParseConfirmsClosedCandidatesAndDoesNotRepeat(t *testing.T)
}))
defer provider.Close()
db := openTestDB(t)
record := seedAIParseCandidate(t, db, provider.URL, 19801, "黑色 XL")
record := seedAIParseCandidate(t, db, provider.URL, 19801, "黑 XL")
rawBefore := record.RawJSON
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
@@ -98,7 +122,7 @@ func TestScheduledAIParseLeavesLowConfidenceUnmatchedForSameFingerprint(t *testi
}))
defer provider.Close()
db := openTestDB(t)
record := seedAIParseCandidate(t, db, provider.URL, 19802, "黑色 XL")
record := seedAIParseCandidate(t, db, provider.URL, 19802, "黑 XL")
for i := 0; i < 2; i++ {
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
@@ -175,7 +199,7 @@ func TestScheduledAIParseRetriesProviderFailureAtMostThreeTimes(t *testing.T) {
}))
defer provider.Close()
db := openTestDB(t)
record := seedAIParseCandidate(t, db, provider.URL, 19805, "黑色 XL")
record := seedAIParseCandidate(t, db, provider.URL, 19805, "黑 XL")
for attempt := 1; attempt <= 4; attempt++ {
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
+6
View File
@@ -96,6 +96,9 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
if err != nil {
return err
}
if shopeeProduct != nil {
parsed = ParseWithCandidates(detail.ProductSpec, shopeeProduct.SpecsJSON)
}
if parsed.Status == models.SYBParseStatusSuccess && shopeeProduct != nil {
if err := mergeParsedSpec(tx, shopeeProduct.ID, parsed); err != nil {
return err
@@ -271,6 +274,9 @@ func fillEmptyArchiveFields(tx *gorm.DB, product *models.ShopeeProduct, detail D
// review but are never pushed into the shared archive, so a messy raw string
// never pollutes the spec list other screens read from.
func mergeParsedSpec(tx *gorm.DB, shopeeProductID uint64, parsed ParseResult) error {
if parsed.matchedCandidates {
return nil
}
var product models.ShopeeProduct
if err := tx.First(&product, shopeeProductID).Error; err != nil {
return err
@@ -171,6 +171,9 @@ func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config
status := SyncRunSucceeded
if err != nil {
status = SyncRunFailed
if report.Created+report.Updated > 0 {
status = SyncRunPartial
}
}
finishCtx, finishCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer finishCancel()
@@ -4,44 +4,9 @@ import (
"context"
"strings"
"testing"
"time"
)
func freezePaginationToday(t *testing.T) {
t.Helper()
previous := syncNow
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
t.Cleanup(func() { syncNow = previous })
}
func TestTodayCrossPageOverlapRestartsWithIndependentIDs(t *testing.T) {
freezePaginationToday(t)
f := &fakeSYB{perDay: map[string]int{"2026-08-29": 4}}
f.pageIDs = func(_ string, start, call int) []int64 {
if call == 2 {
return []int64{1001, 1002}
}
if call > 2 {
return []int64{int64(2000 + start), int64(2001 + start)}
}
return nil
}
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-29", 2, 4, 100)
if err != nil || len(rows) != 4 || f.listTotalCalls != 2 {
t.Fatalf("rows=%d totals=%d err=%v", len(rows), f.listTotalCalls, err)
}
if got := strings.Join(f.listCalls, ","); got != "2026-08-29:0,2026-08-29:2,2026-08-29:0,2026-08-29:2" {
t.Fatalf("did not restart at first page: %s", got)
}
for index, row := range rows {
if row.ID != int64(2000+index) {
t.Fatal("rows leaked from abandoned attempt")
}
}
}
func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testing.T) {
freezePaginationToday(t)
func TestTodayOverlapPreservesCommittedPagesWithoutImportingOverlappingPage(t *testing.T) {
f := &fakeSYB{perDay: map[string]int{"2026-08-28": 2, "2026-08-29": 4}}
f.pageIDs = func(date string, start, _ int) []int64 {
if date == "2026-08-29" && start == 2 {
@@ -54,7 +19,7 @@ func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testin
if err == nil {
t.Fatal("overlap was treated as successful sync")
}
for _, token := range []string{"连续 3 次", "跨页重复", "firstPage=1", "firstRow=2", "page=2", "row=1", "start=2", "pageSize=2", "expectedTotal=4", "unique=2"} {
for _, token := range []string{"跨页重复", "firstPage=1", "firstRow=2", "page=2", "row=1", "start=2", "pageSize=2", "expectedTotal=4", "unique=2"} {
if !strings.Contains(err.Error(), token) {
t.Fatalf("missing %s in %v", token, err)
}
@@ -62,7 +27,7 @@ func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testin
if strings.Contains(err.Error(), "1001") || strings.Contains(err.Error(), "已保存") {
t.Fatalf("unsafe diagnosis/degraded save: %v", err)
}
if len(f.listCalls) != 7 || f.detailCalls != 1 || report.OrderCount != 2 {
if len(f.listCalls) != 3 || f.detailCalls != 2 || report.OrderCount != 4 || report.Created != 2 || report.Updated != 2 {
t.Fatalf("unexpected retry/import boundary: pages=%d details=%d orders=%d", len(f.listCalls), f.detailCalls, report.OrderCount)
}
var count int64
@@ -72,7 +37,6 @@ func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testin
}
func TestPaginationHardErrorsDoNotUseOverlapRecovery(t *testing.T) {
freezePaginationToday(t)
for _, tc := range []struct {
name, date, message string
page int
@@ -92,7 +56,7 @@ func TestPaginationHardErrorsDoNotUseOverlapRecovery(t *testing.T) {
}
return nil
}
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), tc.date, 2, 4, 100)
rows, err := loadDailyList(context.Background(), newSyncClient(t, f), tc.date, 2, 4)
if rows != nil || err == nil || !strings.Contains(err.Error(), tc.message) || len(f.listCalls) != tc.calls || f.listTotalCalls != 0 {
t.Fatalf("unexpected recovery: rows=%d pages=%d totals=%d err=%v", len(rows), len(f.listCalls), f.listTotalCalls, err)
}
+2
View File
@@ -34,6 +34,8 @@ type ParseResult struct {
Size string
Status string
Note string
// Already present in the archive; do not re-merge or modify mappings.
matchedCandidates bool
}
// Parse implements the #41/#216 rule: split on the last comma, strip 【...】
@@ -0,0 +1,69 @@
package sybimport
import (
"strings"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
)
// ParseWithCandidates only rescues uncertain parses against previously stored
// Shopee labels. It never uses the current unconfirmed input as its own evidence.
func ParseWithCandidates(raw, specsJSON string) ParseResult {
baseline := Parse(raw)
if baseline.Status != models.SYBParseStatusUncertain {
return baseline
}
specs, err := shopeeproduct.Unmarshal(specsJSON)
if err != nil || shopeeproduct.Validate(specs) != nil {
return baseline
}
for _, dimension := range specs {
if dimension.Role != shopeeproduct.RoleColor && dimension.Role != shopeeproduct.RoleSize && len(dimension.Values) > 0 {
return baseline
}
}
colors, sizes, ambiguous := closedShopeeCandidates(specs)
if ambiguous || len(colors)+len(sizes) == 0 {
return baseline
}
if len(colors) == 0 {
colors = []string{""}
}
if len(sizes) == 0 {
sizes = []string{""}
}
input := normalizeCandidateSpec(stripBrackets(raw))
type pair struct{ color, size string }
matches := map[pair]bool{}
for _, color := range colors {
for _, size := range sizes {
variants := []string{color + "," + size, size + "," + color, color + " " + size, size + " " + color}
if color == "" || size == "" {
variants = []string{color + size}
}
for _, variant := range variants {
if input == normalizeCandidateSpec(variant) {
matches[pair{color, size}] = true
}
}
}
}
if len(matches) != 1 {
return baseline
}
for match := range matches {
return ParseResult{Color: match.color, Size: match.size, Status: models.SYBParseStatusSuccess,
Note: "已按蝦皮既有规格候选唯一核对", matchedCandidates: true}
}
return baseline
}
func normalizeCandidateSpec(value string) string {
value = strings.ReplaceAll(value, ",", ",")
parts := strings.Split(value, ",")
for i, part := range parts {
parts[i] = strings.Join(strings.Fields(part), " ")
}
return strings.Join(parts, ",")
}
@@ -0,0 +1,121 @@
package sybimport_test
import (
"context"
"encoding/json"
"testing"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
"go-admin/app/goauto/sybimport"
)
func candidateSpecs(colors, sizes []string) string {
dimensions := []shopeeproduct.SpecDimension{}
for _, input := range []struct {
role string
values []string
}{{"color", colors}, {"size", sizes}} {
if len(input.values) == 0 {
continue
}
dimension := shopeeproduct.SpecDimension{Name: input.role, Role: input.role}
for _, value := range input.values {
dimension.Values = append(dimension.Values, shopeeproduct.SpecValue{Name: value, Source: shopeeproduct.ValueSourceImport})
}
dimensions = append(dimensions, dimension)
}
encoded, _ := json.Marshal(dimensions)
return string(encoded)
}
func TestCandidateParseRescuesOnlyUniqueWholeInput(t *testing.T) {
for _, test := range []struct {
name, raw string
colors, sizes []string
color, size string
}{
{"combined color", "黑色+白色,L", []string{"黑色+白色", "黑色"}, []string{"L", "XL"}, "黑色+白色", "L"},
{"spaces in color", "浅 灰色,XL", []string{"浅 灰色"}, []string{"XL"}, "浅 灰色", "XL"},
{"full width comma", "黑色,XL", []string{"黑色"}, []string{"XL"}, "黑色", "XL"},
{"reverse full width", "XL,黑色", []string{"黑色"}, []string{"XL"}, "黑色", "XL"},
{"space separator", "黑色 XL", []string{"黑色"}, []string{"XL"}, "黑色", "XL"},
{"size only", "均碼", nil, []string{"均碼"}, "", "均碼"},
{"color only", "黑色+白色", []string{"黑色+白色"}, nil, "黑色+白色", ""},
{"weight description exact", "黑色,XL 建議55.5-60.0公斤穿", []string{"黑色"}, []string{"XL 建議55.5-60.0公斤穿"}, "黑色", "XL 建議55.5-60.0公斤穿"},
} {
t.Run(test.name, func(t *testing.T) {
got := sybimport.ParseWithCandidates(test.raw, candidateSpecs(test.colors, test.sizes))
if got.Status != models.SYBParseStatusSuccess || got.Color != test.color || got.Size != test.size {
t.Fatalf("unexpected parse: %+v", got)
}
})
}
}
func TestCandidateParsePreservesAmbiguityAndNormalResults(t *testing.T) {
for _, test := range []struct{ raw, specs string }{
{"", candidateSpecs([]string{"黑色"}, []string{"XL"})},
{"黑色 XL", "broken"},
{"黑色 XL", "[]"},
{"XL", candidateSpecs([]string{"黑色"}, []string{"XL"})},
{"黑色+白色 XL", candidateSpecs([]string{"黑色", "白色"}, []string{"XL"})},
{"黑色 XL 多余描述", candidateSpecs([]string{"黑色"}, []string{"XL"})},
{"黑色 XL 建議55.5-60.0公斤穿", candidateSpecs([]string{"黑色"}, []string{"XL"})},
{"黑色,XL", candidateSpecs([]string{"黑色", "XL"}, []string{"XL", "黑色"})},
{"浅 灰色 XL", candidateSpecs([]string{"浅 灰色", "浅 灰色"}, []string{"XL"})},
{"白色,L", candidateSpecs([]string{"黑色"}, []string{"XL"})},
{"黑色 XL", `[{"name":"a","role":"color","values":[{"name":"黑色"}]},{"name":"b","role":"color","values":[{"name":"白色"}]},{"name":"s","role":"size","values":[{"name":"XL"}]}]`},
{"黑色 XL", `[{"name":"a","role":"other","values":[{"name":"黑色 XL"}]}]`},
} {
if got, want := sybimport.ParseWithCandidates(test.raw, test.specs), sybimport.Parse(test.raw); got != want {
t.Fatalf("raw %q changed: %+v vs %+v", test.raw, got, want)
}
}
}
func TestImportAndReparseReuseExistingCandidatesWithoutChangingMappings(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
detail := sybimport.DetailInput{ID: 25101, ProductID: 25101, ProductQty: 1, ProductPrice: 1, ProductSpec: "黑色+白色,XL", Raw: json.RawMessage(`{"productSpec":"黑色+白色,XL"}`)}
order := sybimport.OrderInput{Code: "test-251", ShopName: "test"}
first, err := sybimport.ApplyDetail(ctx, db, order, detail)
if err != nil {
t.Fatal(err)
}
if first.SYBProduct.ParseStatus != models.SYBParseStatusUncertain {
t.Fatal("must not self-confirm without candidates")
}
specs := candidateSpecs([]string{"黑色+白色"}, []string{"XL"})
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", *first.SYBProduct.ShopeeProductID).Update("specs_json", specs).Error; err != nil {
t.Fatal(err)
}
outcome, err := sybimport.Reparse(ctx, db, first.SYBProduct.ID, false)
if err != nil || outcome.NewStatus != models.SYBParseStatusSuccess {
t.Fatalf("reparse: %+v %v", outcome, err)
}
second, err := sybimport.ApplyDetail(ctx, db, order, detail)
if err != nil {
t.Fatal(err)
}
if second.SYBProduct.TargetColor != "黑色+白色" || second.SYBProduct.TargetSize != "XL" || second.SYBProduct.ParseStatus != models.SYBParseStatusSuccess {
t.Fatalf("import differs: %+v", second.SYBProduct)
}
if second.SYBProduct.RawJSON != first.SYBProduct.RawJSON {
t.Fatal("raw source changed")
}
if second.ShopeeProduct.SpecsJSON != specs {
t.Fatal("candidate confirmation must not rewrite archive dimensions or mappings")
}
if _, err := sybimport.ManualCorrect(ctx, db, first.SYBProduct.ID, "人工颜色", "人工尺码"); err != nil {
t.Fatal(err)
}
outcome, err = sybimport.Reparse(ctx, db, first.SYBProduct.ID, false)
if err != nil || outcome.Outcome != sybimport.ReparseOutcomeSkippedManual {
t.Fatalf("manual overwritten: %+v %v", outcome, err)
}
third, err := sybimport.ApplyDetail(ctx, db, order, detail)
if err != nil || third.SYBProduct.TargetColor != "人工颜色" {
t.Fatalf("reimport overwrote manual: %v", err)
}
}
+8
View File
@@ -77,6 +77,14 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
return fmt.Errorf("syb product %d: stored raw json is not decodable: %w", record.ID, err)
}
parsed := Parse(raw.ProductSpec)
if parsed.Status == models.SYBParseStatusUncertain && record.ShopeeProductID != nil {
var product models.ShopeeProduct
if err := tx.First(&product, *record.ShopeeProductID).Error; err == nil {
parsed = ParseWithCandidates(raw.ProductSpec, product.SpecsJSON)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
outcome.NewStatus = parsed.Status
if parsed.Color == record.TargetColor && parsed.Size == record.TargetSize && parsed.Status == record.ParseStatus {
+39 -151
View File
@@ -41,8 +41,7 @@ const (
// maxSyncDays bounds one request's window. It is a guard against a typo in
// the date range turning into tens of thousands of remote reads before
// MaxMatches trips.
maxSyncDays = 31
maxTodaySnapshotAttempts = 3
maxSyncDays = 31
)
// SyncReport summarises one sync run.
@@ -82,8 +81,6 @@ type SyncProgress struct {
DaysProcessed int
}
var syncNow = time.Now
type ProgressFunc func(SyncProgress) error
type snapshotDriftError struct {
@@ -94,14 +91,6 @@ type snapshotDriftError struct {
func (err *snapshotDriftError) Error() string { return err.message }
func shanghaiToday() string {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
location = time.FixedZone("Asia/Shanghai", 8*60*60)
}
return syncNow().In(location).Format("2006-01-02")
}
// Sync pulls every shipment order in [dateFrom, dateTo] and folds each detail
// line into the SYB/Shopee archive through ApplyDetail.
//
@@ -109,16 +98,10 @@ func shanghaiToday() string {
// a separate concern (see Connect); a session that dies mid-run surfaces as
// sybclient.ErrSessionInvalid and is treated like any other mid-run failure.
//
// `[必须]` Failure stops the run immediately. Rows already written are NOT
// rolled back — ApplyDetail is idempotent on (order_code, detail_id), so a
// re-run overwrites them rather than duplicating. What must not happen is
// reporting a partial run as a complete one, which would let the missing
// orders go unnoticed forever.
//
// `[必须]` Every day is verified for completeness before anything is written:
// the per-day total is re-read after paging and must not have drifted. SYB's
// list endpoint returns the *page* size in `total` (§4.3, confirmed against
// live data), so the paging loop is driven by listTotal, never by list.total.
// Each validated page is committed independently. Date-local read failures
// preserve committed pages and allow later dates to run. Database, cancellation
// and progress persistence failures stop the run. Completeness is verified at
// the end of each date; partial results must never be reported as full success.
func Sync(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncConfig, dateFrom, dateTo string) (SyncReport, error) {
return SyncWithProgress(ctx, db, client, cfg, dateFrom, dateTo, nil)
}
@@ -190,106 +173,38 @@ func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Cl
plans = append(plans, dayPlan{date: date, total: total})
}
for dayIndex, plan := range plans {
if plan.total == 0 {
if err := emit(dayIndex + 1); err != nil {
return report, err
}
continue
}
// `[必须]` Completeness first, filtering second. loadDailyList proves the
// day's snapshot is whole; filtering before that would let drift among
// other shops' orders hide a hole in the ones we do want
// (docs/12-syb-erp-interface.md §8).
rows, listErr := loadDailyListWithRecovery(ctx, client, plan.date, pageSize, plan.total, maxMatches)
if listErr != nil && len(rows) == 0 {
return report, listErr
}
report.OrderCount += len(rows)
byID := make(map[int64]sybclient.StockRow, len(rows))
ids := make([]int64, 0, len(rows))
for _, row := range rows {
name := stringField(row.Raw, "shopName")
label := name
if sybshop.IsBlank(label) {
label = "(无店铺名)"
}
display, ok := allowed[sybshop.Normalize(name)]
if ok {
label = display
}
if !ok {
entry := report.ShopBreakdown[label]
entry.Skipped++
report.ShopBreakdown[label] = entry
report.ShopSkipped++
continue
}
entry := report.ShopBreakdown[label]
entry.Accepted++
report.ShopBreakdown[label] = entry
report.AcceptedCount++
byID[row.ID] = row
ids = append(ids, row.ID)
}
if len(ids) == 0 {
if err := emit(dayIndex + 1); err != nil {
return report, err
}
continue
}
for start := 0; start < len(ids); start += detailBatch {
end := start + detailBatch
if end > len(ids) {
end = len(ids)
}
batch := ids[start:end]
details, err := client.DetailListByStock(ctx, batch)
if err != nil {
return report, fmt.Errorf("拉取 %s 货运单明细失败(本次同步停止;"+
"已写入的数据保留,重跑会按 (order_code, detail_id) 覆盖): %w", plan.date, err)
}
if err := validateDetailBatch(batch, details); err != nil {
return report, fmt.Errorf("%s 货运单明细不完整:%w;本次同步停止", plan.date, err)
}
for _, detail := range details {
// `[必须]` Re-check the shop on the detail response. The list said
// this order belongs to an enabled shop; if the detail disagrees,
// the two views are inconsistent and importing it would write a
// row for a shop nobody enabled.
name := stringField(detail.Raw, "shopName")
if sybshop.IsBlank(name) {
moveAcceptedToSkipped(&report, stringField(byID[detail.ID].Raw, "shopName"), "(无店铺名)", allowed)
report.AcceptedCount--
report.ShopSkipped++
continue
}
if _, ok := allowed[sybshop.Normalize(name)]; !ok {
moveAcceptedToSkipped(&report, stringField(byID[detail.ID].Raw, "shopName"), name, allowed)
report.AcceptedCount--
report.ShopSkipped++
continue
}
if err := applyStockDetail(ctx, db, byID[detail.ID], detail, &report); err != nil {
return report, err
}
}
if err := emit(dayIndex); err != nil {
return report, err
}
}
if listErr != nil {
return report, listErr
}
if err := emit(dayIndex + 1); err != nil {
daysCompleted := 0
var failures []error
for _, plan := range plans {
if err := ctx.Err(); err != nil {
return report, err
}
_, dayErr := loadDailyList(ctx, client, plan.date, pageSize, plan.total, func(rows []sybclient.StockRow, page int) error {
report.OrderCount += len(rows)
err := importSyncPage(ctx, db, client, rows, allowed, &report)
if err != nil {
return fmt.Errorf("%s 第 %d 页明细/入库阶段失败: %w", plan.date, page, err)
}
if err := emit(daysCompleted); err != nil {
return &syncFatalError{err}
}
return nil
})
if dayErr != nil {
failures = append(failures, dayErr)
var fatal *syncFatalError
if ctx.Err() != nil || errors.As(dayErr, &fatal) || errors.Is(dayErr, sybclient.ErrSessionInvalid) {
return report, errors.Join(failures...)
}
continue
}
daysCompleted++
if err := emit(daysCompleted); err != nil {
return report, errors.Join(append(failures, err)...)
}
}
report.FinishedAt = time.Now().UTC()
return report, nil
return report, errors.Join(failures...)
}
func shopSnapshotNames(allowed map[string]string) []string {
@@ -336,39 +251,7 @@ func moveAcceptedToSkipped(report *SyncReport, listName, detailName string, allo
// `[必须]` The loop bound comes from expectedTotal (listTotal), because
// list.total is the current page's row count, not the filtered total (§4.3).
// Driving the loop with the response's own total would stop after page one.
func loadDailyListWithRecovery(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal, maxMatches int) ([]sybclient.StockRow, error) {
if date != shanghaiToday() {
return loadDailyList(ctx, client, date, pageSize, expectedTotal)
}
var last *snapshotDriftError
for attempt := 1; attempt <= maxTodaySnapshotAttempts; attempt++ {
if attempt > 1 {
total, err := client.ListTotal(ctx, date, date, pageSize)
if err != nil {
return nil, fmt.Errorf("重新查询今天 %s 货运单总数失败: %w", date, err)
}
if total > maxMatches {
return nil, fmt.Errorf("今天 %s 的货运单总数 %d 超过单次同步上限 %d", date, total, maxMatches)
}
expectedTotal = total
}
rows, err := loadDailyList(ctx, client, date, pageSize, expectedTotal)
if err == nil {
return rows, nil
}
var drift *snapshotDriftError
if !errors.As(err, &drift) {
return nil, fmt.Errorf("今天第 %d/%d 次拉取失败: %w", attempt, maxTodaySnapshotAttempts, err)
}
last = drift
}
if last != nil && last.valid && len(last.rows) > 0 {
return last.rows, fmt.Errorf("今天持续变化,已保存本次取得的完整明细,但未形成稳定快照;下次同步继续覆盖: %w", last)
}
return nil, fmt.Errorf("今天持续变化,连续 %d 次未形成稳定快照,本次同步停止: %w", maxTodaySnapshotAttempts, last)
}
func loadDailyList(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal int) ([]sybclient.StockRow, error) {
func loadDailyList(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal int, consume ...func([]sybclient.StockRow, int) error) ([]sybclient.StockRow, error) {
rows := make([]sybclient.StockRow, 0, expectedTotal)
type position struct{ page, row int }
seen := make(map[int64]position, expectedTotal)
@@ -413,6 +296,11 @@ func loadDailyList(ctx context.Context, client *sybclient.Client, date string, p
if len(page) != expectedPageCount {
return nil, &snapshotDriftError{message: fmt.Sprintf("%s 货运单列表第 %d 页不完整且相对初始总数发生变化:预期 %d 行,实际 %d 行", date, pageIndex, expectedPageCount, len(page)), rows: rows, valid: len(page) > expectedPageCount}
}
if len(consume) > 0 {
if err := consume[0](page, pageIndex); err != nil {
return nil, err
}
}
}
// Re-read the total: if it moved while we paged, some order was inserted or
+83
View File
@@ -0,0 +1,83 @@
package sybimport
import (
"context"
"go-admin/app/goauto/sybclient"
"go-admin/app/goauto/sybshop"
"gorm.io/gorm"
)
// Database/progress errors stop the entire run, not merely the current date.
type syncFatalError struct{ error }
func (e *syncFatalError) Unwrap() error { return e.error }
// Read all remote details before opening a page transaction. Publish counters
// only after commit, so a rollback never reports records as saved.
func importSyncPage(ctx context.Context, db *gorm.DB, client *sybclient.Client, rows []sybclient.StockRow, allowed map[string]string, report *SyncReport) error {
next := *report
next.ShopBreakdown = make(map[string]ShopBreakdown, len(report.ShopBreakdown))
for k, v := range report.ShopBreakdown {
next.ShopBreakdown[k] = v
}
byID := make(map[int64]sybclient.StockRow)
var ids []int64
for _, row := range rows {
name := stringField(row.Raw, "shopName")
label := name
if sybshop.IsBlank(label) {
label = "(无店铺名)"
}
display, ok := allowed[sybshop.Normalize(name)]
if ok {
label = display
}
entry := next.ShopBreakdown[label]
if ok {
entry.Accepted++
next.AcceptedCount++
ids = append(ids, row.ID)
byID[row.ID] = row
} else {
entry.Skipped++
next.ShopSkipped++
}
next.ShopBreakdown[label] = entry
}
var details []sybclient.StockDetail
for start := 0; start < len(ids); start += detailBatch {
end := start + detailBatch
if end > len(ids) {
end = len(ids)
}
batch, err := client.DetailListByStock(ctx, ids[start:end])
if err != nil {
return err
}
if err := validateDetailBatch(ids[start:end], batch); err != nil {
return err
}
details = append(details, batch...)
}
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, detail := range details {
name := stringField(detail.Raw, "shopName")
_, ok := allowed[sybshop.Normalize(name)]
if sybshop.IsBlank(name) || !ok {
moveAcceptedToSkipped(&next, stringField(byID[detail.ID].Raw, "shopName"), name, allowed)
next.AcceptedCount--
next.ShopSkipped++
continue
}
if err := applyStockDetail(ctx, tx, byID[detail.ID], detail, &next); err != nil {
return err
}
}
return nil
})
if err != nil {
return &syncFatalError{err}
}
*report = next
return nil
}
@@ -0,0 +1,117 @@
package sybimport
import (
"context"
"errors"
"go-admin/app/goauto/models"
"strings"
"testing"
)
func TestPageFailurePreservesEarlierPagesContinuesDatesAndRerunsIdempotently(t *testing.T) {
db := newSyncTestDB(t)
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}, shortPageAtIndex: 2}
var last SyncProgress
report, err := SyncWithProgress(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02", func(p SyncProgress) error { last = p; return nil })
if err == nil || !strings.Contains(err.Error(), "第 2 页") || report.Created != 2 || report.Updated != 1 || last.DaysProcessed != 1 {
t.Fatalf("report=%+v progress=%+v err=%v", report, last, err)
}
var count int64
db.Model(&models.SYBProduct{}).Count(&count)
if count != 2 {
t.Fatal(count)
}
f.shortPageAtIndex = 0
report, err = Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02")
if err != nil || report.Created != 1 || report.Updated != 3 {
t.Fatalf("rerun=%+v err=%v", report, err)
}
db.Model(&models.SYBProduct{}).Count(&count)
if count != 3 {
t.Fatal(count)
}
}
func TestPageTransactionRollbackDoesNotPublishCountersAndStopsDates(t *testing.T) {
db := newSyncTestDB(t)
if err := db.Exec("CREATE TRIGGER reject_second BEFORE INSERT ON syb_product WHEN NEW.detail_id = 10011 BEGIN SELECT RAISE(ABORT, 'test database failure'); END").Error; err != nil {
t.Fatal(err)
}
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}}
report, err := Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02")
var fatal *syncFatalError
if !errors.As(err, &fatal) || report.Created != 0 || report.DetailCount != 0 || len(f.listCalls) != 1 {
t.Fatalf("report=%+v calls=%v err=%v", report, f.listCalls, err)
}
var count int64
db.Model(&models.SYBProduct{}).Count(&count)
if count != 0 {
t.Fatal(count)
}
}
func TestPageProgressFailureStopsAfterCommittedPage(t *testing.T) {
db := newSyncTestDB(t)
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}}
calls := 0
report, err := SyncWithProgress(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02", func(SyncProgress) error {
calls++
if calls == 2 {
return errors.New("progress unavailable")
}
return nil
})
if err == nil || report.Created != 2 || len(f.listCalls) != 1 {
t.Fatalf("report=%+v err=%v", report, err)
}
}
func TestPageMissingDetailPreservesPriorPageAndContinuesNextDate(t *testing.T) {
db := newSyncTestDB(t)
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}, detailDropID: 1002}
report, err := Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02")
if err == nil || report.Created != 2 || report.Updated != 1 || report.OrderCount != 4 || len(f.listCalls) != 3 {
t.Fatalf("report=%+v err=%v", report, err)
}
}
func TestPageCancellationPreservesCommitAndStopsNextPage(t *testing.T) {
db := newSyncTestDB(t)
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
report, err := SyncWithProgress(ctx, db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02", func(p SyncProgress) error {
if p.Report.Created > 0 {
cancel()
}
return nil
})
if !errors.Is(err, context.Canceled) || report.Created != 2 || len(f.listCalls) != 1 {
t.Fatalf("report=%+v err=%v", report, err)
}
}
func TestPartialFinishPreservesProgressReleasesSlotAndIsFilterable(t *testing.T) {
db := newSyncTestDB(t)
ctx := context.Background()
s := NewSyncRunService(db)
run, err := s.Create(ctx, CreateSyncRunInput{DateFrom: "2026-08-01", DateTo: "2026-08-02"})
if err != nil {
t.Fatal(err)
}
report := SyncReport{Created: 2, DetailCount: 2, ShopBreakdown: map[string]ShopBreakdown{}}
if err := s.UpdateProgress(ctx, run.ID, SyncProgress{Report: report, DaysTotal: 2, DaysProcessed: 1}); err != nil {
t.Fatal(err)
}
if err := s.Finish(ctx, run.ID, SyncRunPartial, report, errors.New("第 2 页失败")); err != nil {
t.Fatal(err)
}
row, err := s.Detail(ctx, run.ID)
if err != nil || row.Status != SyncRunPartial || row.ActiveSlot != nil || row.ProgressPercent != 50 || row.Created != 2 {
t.Fatalf("row=%+v err=%v", row, err)
}
result, err := s.List(ctx, SyncRunListRequest{Page: 1, PageSize: 20, Status: SyncRunPartial})
if err != nil || result.Total != 1 {
t.Fatalf("result=%+v err=%v", result, err)
}
}
+2 -1
View File
@@ -18,6 +18,7 @@ import (
const (
SyncRunRunning = "running"
SyncRunSucceeded = "succeeded"
SyncRunPartial = "partial_success"
SyncRunFailed = "failed"
SyncRunInterrupted = "interrupted"
)
@@ -75,7 +76,7 @@ func (s *SyncRunService) UpdateProgress(ctx context.Context, id uint64, progress
}
func (s *SyncRunService) Finish(ctx context.Context, id uint64, status string, report SyncReport, runErr error) error {
if status != SyncRunSucceeded && status != SyncRunFailed {
if status != SyncRunSucceeded && status != SyncRunPartial && status != SyncRunFailed {
return fmt.Errorf("invalid terminal sync status %q", status)
}
payload, err := marshalSyncRunPayload(report.ShopBreakdown, report.ShopFilterSnapshot)
@@ -20,7 +20,7 @@ func (handler Handler) ListSyncRuns(c *gin.Context) {
return
}
status := strings.TrimSpace(c.Query("status"))
if status != "" && status != SyncRunRunning && status != SyncRunSucceeded && status != SyncRunFailed && status != SyncRunInterrupted {
if status != "" && status != SyncRunRunning && status != SyncRunSucceeded && status != SyncRunPartial && status != SyncRunFailed && status != SyncRunInterrupted {
writeError(c, invalidRequest("status 无效"))
return
}
@@ -36,4 +36,12 @@ func TestSyncRunListAndDetailHandlers(t *testing.T) {
if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), `"operatorName":"管理员"`) {
t.Fatalf("详情响应不正确: %d %s", detail.Code, detail.Body.String())
}
if err := NewSyncRunService(db).Finish(context.Background(), run.ID, SyncRunPartial, SyncReport{Created: 1}, nil); err != nil {
t.Fatal(err)
}
partial := httptest.NewRecorder()
engine.ServeHTTP(partial, httptest.NewRequest(http.MethodGet, "/sync-runs?status=partial_success", nil))
if partial.Code != http.StatusOK || !strings.Contains(partial.Body.String(), `"total":1`) {
t.Fatalf("partial filter: %d %s", partial.Code, partial.Body.String())
}
}
-34
View File
@@ -9,7 +9,6 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
@@ -276,39 +275,6 @@ func TestSyncStopsWhenTotalDriftsDuringPaging(t *testing.T) {
}
}
func TestTodaySnapshotDriftRetriesFromFirstPage(t *testing.T) {
originalNow := syncNow
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
t.Cleanup(func() { syncNow = originalNow })
f := &fakeSYB{perDay: map[string]int{"2026-08-29": 10}, totalOverride: map[int]int{1: 11, 2: 10, 3: 10}}
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-29", 10, 10, 1000)
if err != nil || len(rows) != 10 || f.listTotalCalls != 3 {
t.Fatalf("rows=%d totalCalls=%d err=%v", len(rows), f.listTotalCalls, err)
}
}
func TestTodayThirdDriftReturnsValidLastListForDegradedSave(t *testing.T) {
originalNow := syncNow
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
t.Cleanup(func() { syncNow = originalNow })
f := &fakeSYB{perDay: map[string]int{"2026-08-29": 10}, totalOverride: map[int]int{1: 11, 2: 10, 3: 11, 4: 10, 5: 11}}
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-29", 10, 10, 1000)
if err == nil || len(rows) != 10 || !strings.Contains(err.Error(), "已保存本次取得的完整明细") {
t.Fatalf("rows=%d err=%v", len(rows), err)
}
}
func TestHistoricalSnapshotDriftDoesNotRetry(t *testing.T) {
originalNow := syncNow
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
t.Cleanup(func() { syncNow = originalNow })
f := &fakeSYB{perDay: map[string]int{"2026-08-28": 10}, totalOverride: map[int]int{1: 11}}
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-28", 10, 10, 1000)
if err == nil || rows != nil || f.listTotalCalls != 1 {
t.Fatalf("rows=%v totalCalls=%d err=%v", rows, f.listTotalCalls, err)
}
}
// 某页行数少于预期同样是不完整快照。
func TestSyncStopsOnShortPage(t *testing.T) {
db := newSyncTestDB(t)
+4 -3
View File
@@ -36,8 +36,9 @@
<el-drawer v-model="detail.open" title="同步记录详情" size="760px">
<div v-loading="detail.loading" class="drawer-body">
<template v-if="detail.item">
<el-alert v-if="detail.item.status === 'failed' || detail.item.status === 'interrupted'" :title="detail.item.errorMessage || statusMeta(detail.item.status).label" :type="detail.item.status === 'failed' ? 'error' : 'warning'" show-icon :closable="false" class="notice" />
<el-alert v-if="detail.item.status === 'failed' || detail.item.status === 'interrupted' || detail.item.status === 'partial_success'" :title="detail.item.errorMessage || statusMeta(detail.item.status).label" :type="detail.item.status === 'failed' ? 'error' : 'warning'" show-icon :closable="false" class="notice" />
<el-alert v-else-if="detail.item.status === 'running'" title="任务正在后台运行,关闭本页不会中断导入。" type="info" show-icon :closable="false" class="notice" />
<el-alert v-if="detail.item.status === 'partial_success'" title="已保存的数据会保留。请重新同步该日期范围补齐缺失数据;重跑不会重复新增相同明细。" type="warning" show-icon :closable="false" class="notice" />
<el-descriptions :column="2" border>
<el-descriptions-item label="记录编号">{{ detail.item.id }}</el-descriptions-item>
<el-descriptions-item label="状态"><el-tag :type="statusMeta(detail.item.status).type">{{ statusMeta(detail.item.status).label }}</el-tag></el-descriptions-item>
@@ -81,7 +82,7 @@ export default {
detail: { open: false, loading: false, item: null },
statusOptions: [
{ label: '执行中', value: 'running' }, { label: '成功', value: 'succeeded' },
{ label: '失败', value: 'failed' }, { label: '已中断', value: 'interrupted' }
{ label: '部分成功', value: 'partial_success' }, { label: '失败', value: 'failed' }, { label: '已中断', value: 'interrupted' }
]
}
},
@@ -92,7 +93,7 @@ export default {
created() { this.load().then(() => { const id = Number(this.$route.query.runId); if (id > 0) this.openDetail(id) }) },
beforeUnmount() { this.stopPolling() },
methods: {
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, partial_success: { label: '部分成功', type: 'warning' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) },
async load() {
this.loading = true; this.loadError = ''
+28
View File
@@ -6,6 +6,34 @@ async function authenticate(context: any) {
const syncMenu = [{ path: '/syb-sync-runs', component: 'Layout', visible: '0', menuName: 'SybSync', title: 'SYB 同步', children: [{ path: 'index', component: '/goauto/syb-sync-runs/index', visible: '0', menuName: 'GoAutoSybSyncRuns', title: 'SYB 同步记录' }] }];
test('部分成功可筛选并查看已保存数量和补齐提示', async ({ page, context }) => {
await authenticate(context);
const item = { id: 239, status: 'partial_success', dateFrom: '2026-08-01', dateTo: '2026-08-02', daysProcessed: 1, daysTotal: 2, progressPercent: 50, created: 2, updated: 1, orderCount: 3, detailCount: 3, acceptedCount: 3, shopSkipped: 0, errorMessage: '2026-08-01 第 2 页读取失败', shopBreakdown: [] };
let selected = '';
await page.route('**/api/**', route => {
const url = new URL(route.request().url());
if (url.pathname.startsWith('/src/api/')) return route.continue();
if (url.pathname.endsWith('/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['purchaser'], name: '测试用户', avatar: '', introduction: '', permissions: [] } } });
if (url.pathname.endsWith('/sync-runs/239')) return route.fulfill({ json: { code: 200, data: { item } } });
if (url.pathname.endsWith('/sync-runs')) { selected = url.searchParams.get('status') || ''; return route.fulfill({ json: { code: 200, data: { items: [item], total: 1 } } }); }
return route.fulfill({ json: { code: 200, data: url.pathname.endsWith('/menurole') ? syncMenu : [] } });
});
await page.goto('/#/syb-sync-runs/index');
await expect(page.locator('.el-table').getByText('部分成功')).toBeVisible();
await page.locator('.search-form .el-select').click();
await page.getByRole('option', { name: '部分成功' }).click();
await page.getByRole('button', { name: '查询', exact: true }).click();
await expect.poll(() => selected).toBe('partial_success');
await page.getByRole('button', { name: '详情', exact: true }).click();
await expect(page.getByText(item.errorMessage)).toBeVisible();
await expect(page.getByText('已保存的数据会保留。', { exact: false })).toBeVisible();
await expect(page.locator('.el-drawer').getByText('2 / 1', { exact: true })).toBeVisible();
await expect.poll(async () => {
const box = await page.locator('.el-drawer').boundingBox();
return box ? Math.round(box.x + box.width) : 0;
}).toBe(page.viewportSize()!.width);
});
test('同步记录展示失败原因和按店铺统计', async ({ page, context }) => {
await authenticate(context);
await page.route('**/api/**', async route => {