Compare commits

...
Author SHA1 Message Date
QiuSW 1f40a7fcb1 fix(agent): resolve duplicated semantic address cards (#229) 2026-09-05 18:28:47 +08:00
QiuSW c9aaade6e9 fix(agent): deduplicate address entry nodes (#229) 2026-09-05 18:17:54 +08:00
QiuSW 5ee3b62906 fix(agent): retain exact specs across panel transitions (#228) 2026-09-05 17:58:52 +08:00
QiuSW b829a203dc fix(agent): retain purchase panel after address save (#227) 2026-09-05 17:30:40 +08:00
QiuSW 9320e5528c docs(purchase): sync retry page entry contract (#226) 2026-09-05 17:08:20 +08:00
QiuSW 41fe461f94 fix(android): reopen PDD for manual purchase retries (#226) 2026-09-05 17:00:25 +08:00
QiuSW b306f417d5 docs(agent): sync in-place retry contract (#225) 2026-09-05 16:50:01 +08:00
QiuSW 8f5ff4525e docs(purchase): sync retry business rule (#225) 2026-09-05 16:50:00 +08:00
QiuSW 9124e92ed6 fix(agent): retry purchases in place (#225) 2026-09-05 16:45:52 +08:00
QiuSW 095dbfacbf fix(android): retain verified spec selections (#224) 2026-09-05 16:33:24 +08:00
QiuSW cdcb6930d0 fix(android): revalidate offscreen selected specs (#224) 2026-09-05 16:21:01 +08:00
QiuSW d84f6ddf21 fix(purchase): reuse validated probe mappings (#223) 2026-09-05 15:21:17 +08:00
QiuSW f40331189b fix(android): wait for stable PDD product page (#222) 2026-09-05 12:40:19 +08:00
QiuSW 2895f3d72d fix(purchase): advance past spec confirmation (#220) 2026-09-05 10:54:02 +08:00
QiuSW e4051ed8df feat(purchase): reuse probed PDD page (#219) 2026-09-05 10:34:35 +08:00
QiuSW 5859a819c8 feat(agent): 显示采购结果气泡 (#218) 2026-09-05 10:01:21 +08:00
30 changed files with 1901 additions and 141 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 53
versionName = "0.9.40"
versionCode = 65
versionName = "0.9.52"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -49,6 +49,7 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
cn.ilapage.goauto.agent.automation.GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
screenPolicyHandler.removeCallbacks(screenPolicyRefresh)
screenPolicyHandler.post(screenPolicyRefresh)
}
@@ -18,6 +18,8 @@ import android.util.Log
import android.view.Display
import android.view.accessibility.AccessibilityEvent
import android.view.accessibility.AccessibilityNodeInfo
import cn.ilapage.goauto.agent.ui.PurchaseResultBubbleController
import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePresentation
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
@@ -32,6 +34,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
ActivityEvidenceTracker { packageName, className -> isDeclaredActivity(packageName, className) }
}
private var accessibilityButtonCallback: AccessibilityButtonController.AccessibilityButtonCallback? = null
private val purchaseResultBubble by lazy {
PurchaseResultBubbleController(this) { currentPackage() == PDD_PACKAGE }
}
override fun onServiceConnected() {
serviceInfo = serviceInfo.apply {
@@ -48,18 +53,19 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event?.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
val packageName = event.packageName?.toString()
if (packageName != null && packageName != lastForegroundPackage) {
lastForegroundPackage = packageName
val foregroundPackage = event.packageName?.toString()
if (foregroundPackage != null && foregroundPackage != lastForegroundPackage) {
lastForegroundPackage = foregroundPackage
foregroundRevision.incrementAndGet()
}
if (packageName == PDD_PACKAGE) lastPddForegroundAt.set(SystemClock.elapsedRealtime())
activityTracker.observe(packageName, event.className?.toString())
if (foregroundPackage == PDD_PACKAGE) lastPddForegroundAt.set(SystemClock.elapsedRealtime())
activityTracker.observe(foregroundPackage, event.className?.toString())
}
}
override fun onInterrupt() = Unit
override fun onDestroy() {
dismissPurchaseResultBubble()
unregisterAccessibilityButton()
if (instance === this) instance = null
super.onDestroy()
@@ -71,6 +77,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
runCatching {
val callback = object : AccessibilityButtonController.AccessibilityButtonCallback() {
override fun onClicked(controller: AccessibilityButtonController) {
dismissPurchaseResultBubble()
AccessibilityButtonPolicy.handleClick(::openAgentPreservingTab)
}
}
@@ -115,6 +122,7 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
).restore(packageName, timeoutMillis)
fun openAgentPreservingTab(): Boolean = runCatching {
dismissPurchaseResultBubble()
startActivity(
android.content.Intent(this, cn.ilapage.goauto.agent.MainActivity::class.java).apply {
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP or android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP)
@@ -123,6 +131,14 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
true
}.getOrDefault(false)
fun showPurchaseResultBubble(presentation: PurchaseResultBubblePresentation) {
purchaseResultBubble.show(presentation)
}
fun dismissPurchaseResultBubble() {
purchaseResultBubble.dismiss()
}
override fun visibleTexts(): List<String> {
val root = rootInActiveWindow ?: return emptyList()
val texts = mutableListOf<String>()
@@ -314,6 +330,42 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
}
}
override fun clickAddressEntryFresh(target: SnapshotNode): FreshClickOutcome {
val root = rootInActiveWindow ?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.ROOT_UNAVAILABLE)
val indexes = target.path.split('/').mapNotNull(String::toIntOrNull)
if (indexes.isEmpty() || indexes.first() != 0 || indexes.size != target.path.split('/').size) {
return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
}
var node = root
for (index in indexes.drop(1)) {
node = node.getChild(index)
?: return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
}
val bounds = Rect().also(node::getBoundsInScreen)
if (!node.isVisibleToUser || !node.isEnabled ||
node.preferredOrDescendantLabel() != target.label ||
node.className?.toString() != target.className ||
kotlin.math.abs(bounds.centerX() - target.bounds.centerX) > 32 ||
kotlin.math.abs(bounds.centerY() - target.bounds.centerY) > 32
) {
return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
}
var ancestorDepth = 0
while (!node.isClickable) {
node = node.parent ?: return FreshClickOutcome(
FreshActionResult.FAILED,
FreshClickReason.NO_CLICKABLE_ANCESTOR,
clickableAncestorDepth = ancestorDepth,
)
ancestorDepth++
}
return if (node.performAction(AccessibilityNodeInfo.ACTION_CLICK)) {
FreshClickOutcome(FreshActionResult.SUCCESS, FreshClickReason.SUCCESS, 1, ancestorDepth)
} else {
FreshClickOutcome(FreshActionResult.FAILED, FreshClickReason.ACTION_CLICK_FALSE, 1, ancestorDepth)
}
}
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult {
val root = rootInActiveWindow ?: return FreshActionResult.NOT_FOUND
val candidates = mutableListOf<AccessibilityNodeInfo>()
@@ -128,6 +128,7 @@ data class ParsedPddScreen(
val bottomPurchaseEntryCount: Int,
val problem: PageProblem?,
val sourceNodes: List<SnapshotNode>,
val isPddPackage: Boolean,
) {
fun isTransientSoldOut(
exactText: String,
@@ -162,6 +163,7 @@ data class ParsedPddScreen(
}
object PddScreenParser {
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
private data class SafeSpecEntry(val anchor: SnapshotNode, val clickTarget: SnapshotNode)
private val pricePattern = Regex("[¥¥]\\s*([0-9]+(?:\\.[0-9]{1,2})?)")
private val salesPattern = Regex("已拼\\s*[0-9]+(?:\\.[0-9]+)?\\s*(?:万|亿)?\\s*\\+?\\s*(?:件|人)?")
@@ -397,6 +399,7 @@ object PddScreenParser {
bottomPurchaseEntryCount = bottomSpecEntries.size,
problem = problem,
sourceNodes = visibleNodes,
isPddPackage = snapshot.packageName == PDD_PACKAGE,
)
}
@@ -36,6 +36,58 @@ class PurchaseLiveAutomation(
private var submitAttempted = false
var lastOrderReadFailure: PurchaseOrderReadFailure? = null
private set
/**
* Advances an already verified spec selector to the order confirmation
* page. Only the selector's unique exact confirm button is clickable; an
* order-submit or payment control can never satisfy this transition.
*/
fun advanceToOrderConfirmation(allowUnclassifiedPanelWithSelectionProof: Boolean = false) {
var snapshot = driver.capture()
pageProblem(snapshot)
if (orderConfirmationReady(snapshot)) return
if (snapshot.packageName != PDD_PACKAGE) {
fail("PURCHASE_SPEC_CONFIRMATION_NOT_READY", "当前不是拼多多规格页面,未创建订单")
}
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
val unclassifiedSelectionPanelWithProof = allowUnclassifiedPanelWithSelectionProof &&
screen.specPanelType == SpecPanelType.UNKNOWN &&
screen.priceCent != null &&
snapshot.nodes.count {
it.visible && it.enabled && it.className?.endsWith("EditText") == true && it.label.toLongOrNull() != null
} == 1
if (
screen.specPanelType !in setOf(SpecPanelType.NORMAL_SCROLLABLE, SpecPanelType.NON_SCROLLABLE_CONFIRMATION) &&
!unclassifiedSelectionPanelWithProof
) {
fail("PURCHASE_SPEC_CONFIRMATION_NOT_READY", "当前规格面板不能安全确认,未创建订单")
}
val aliases = PurchaseRehearsalExecutor.DEFAULT_COLLECTOR.textAliases.specPanel.confirmAliases
.map { it.replace(" ", "") }
.toSet()
val targets = snapshot.nodes.filter { node ->
node.visible && node.enabled && node.clickable && node.label.replace(" ", "") in aliases
}.distinctBy { it.path }
if (targets.isEmpty()) {
fail("PURCHASE_SPEC_CONFIRM_TARGET_MISSING", "没有找到唯一的规格确认按钮,未创建订单")
}
if (targets.size > 1) {
fail("PURCHASE_SPEC_CONFIRM_TARGET_AMBIGUOUS", "规格确认按钮不唯一,未创建订单")
}
when (driver.clickFresh(targets.single())) {
FreshActionResult.SUCCESS -> Unit
FreshActionResult.AMBIGUOUS -> fail("PURCHASE_SPEC_CONFIRM_TARGET_AMBIGUOUS", "规格确认按钮不唯一,未创建订单")
else -> fail("PURCHASE_SPEC_CONFIRM_CLICK_FAILED", "规格确认按钮点击失败,未创建订单")
}
repeat(SPEC_CONFIRMATION_MAX_SAMPLES) {
pause(SPEC_CONFIRMATION_SAMPLE_INTERVAL_MS)
snapshot = driver.capture()
pageProblem(snapshot)
if (orderConfirmationReady(snapshot)) return
}
fail("PURCHASE_SPEC_CONFIRMATION_UNCONFIRMED", "规格确认后没有进入订单确认页,未创建订单")
}
fun updateShippingAddress(addressSuffix: String): ShippingAddressProof {
if (!addressSuffix.matches(Regex("^_cg[1-9][0-9]*$"))) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "采购任务的地址标记无效")
var snapshot = driver.capture()
@@ -43,12 +95,31 @@ class PurchaseLiveAutomation(
var unchangedCount = 0
for (attempt in 0 until 5) {
pageProblem(snapshot)
val entries = mergedDirect(snapshot.nodes.filter { it.visible && it.enabled && MASKED_PHONE.containsMatchIn(it.label) })
if (entries.size == 1) {
when (driver.tapPurchaseFresh(entries.single())) {
val resolution = resolveAddressEntries(snapshot)
if (resolution.addressCardCount > 1 || resolution.tapTargetCount > 1) {
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单 [${resolution.diagnostic()}]")
}
if (resolution.target != null) {
pause(ADDRESS_ENTRY_STABLE_INTERVAL_MS)
val stableSnapshot = driver.capture()
pageProblem(stableSnapshot)
val stableResolution = resolveAddressEntries(stableSnapshot)
if (stableResolution.addressCardCount > 1 || stableResolution.tapTargetCount > 1) {
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单 [${stableResolution.diagnostic()}]")
}
if (stableResolution.target == null || stableResolution.fingerprint() != resolution.fingerprint()) {
snapshot = stableSnapshot
continue
}
val target = stableResolution.target
val result = when (target.activation) {
AddressEntryActivation.CLICK -> driver.clickAddressEntryFresh(target.node).result
AddressEntryActivation.TAP -> driver.tapPurchaseFresh(target.node)
}
when (result) {
FreshActionResult.SUCCESS -> Unit
FreshActionResult.AMBIGUOUS -> fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单")
else -> fail("PURCHASE_ADDRESS_ENTRY_NOT_READY", "收货地址入口点击失败,未创建订单")
FreshActionResult.AMBIGUOUS -> fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单 [${stableResolution.diagnostic()}]")
else -> fail("PURCHASE_ADDRESS_ENTRY_NOT_READY", "收货地址入口点击失败,未创建订单 [${stableResolution.diagnostic()}]")
}
pause(1_000)
snapshot = waitFor("PURCHASE_ADDRESS_PANEL_TIMEOUT", "收货地址页面打开超时,未创建订单") {
@@ -56,7 +127,9 @@ class PurchaseLiveAutomation(
}
return editAndVerifyAddress(snapshot, addressSuffix)
}
if (entries.size > 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "收货地址入口不唯一,未创建订单")
if (resolution.phoneNodeCount > 0) {
fail("PURCHASE_ADDRESS_ENTRY_NOT_READY", "收货地址入口无法安全定位,未创建订单 [${resolution.diagnostic()}]")
}
val panel = purchasePanelScrollTargets(snapshot)
if (panel.size != 1) fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "没有找到唯一的规格面板滚动区域,未创建订单")
val signature = viewportSignature(snapshot, panel.single())
@@ -72,6 +145,161 @@ class PurchaseLiveAutomation(
fail("PURCHASE_ADDRESS_PANEL_TIMEOUT", "规格面板下拉后仍未找到收货地址,未创建订单")
}
private enum class AddressEntryActivation { CLICK, TAP }
private data class AddressEntryTarget(
val node: SnapshotNode,
val activation: AddressEntryActivation,
val cardKey: String,
)
private data class AddressCardCandidate(
val phone: SnapshotNode,
val clickableCard: SnapshotNode?,
val semanticKey: String?,
)
private data class AddressEntryResolution(
val phoneNodeCount: Int,
val addressCardCount: Int,
val tapTargetCount: Int,
val target: AddressEntryTarget?,
) {
fun diagnostic(): String =
"phoneNodes=$phoneNodeCount;addressCards=$addressCardCount;tapTargets=$tapTargetCount"
fun fingerprint(): String? = target?.let {
listOf(
it.activation.name,
it.cardKey,
it.node.label,
it.node.className.orEmpty(),
it.node.bounds.left,
it.node.bounds.top,
it.node.bounds.right,
it.node.bounds.bottom,
).joinToString(":")
}
}
private fun resolveAddressEntries(snapshot: UiSnapshot): AddressEntryResolution {
val phones = snapshot.nodes.filter {
it.visible && it.enabled && maskedPhone(it.label) != null
}.distinctBy { it.path }
if (phones.isEmpty()) return AddressEntryResolution(0, 0, 0, null)
val byPath = snapshot.nodes.associateBy(SnapshotNode::path)
val candidates = phones.map { phone ->
val card = nearestClickableAncestor(phone, byPath)
AddressCardCandidate(phone, card, card?.let { addressCardSemanticKey(it, snapshot) })
}
val groups = mutableListOf<MutableList<AddressCardCandidate>>()
candidates.forEach { candidate ->
val matching = groups.filter { group -> group.any { sameLogicalAddress(it, candidate) } }
if (matching.isEmpty()) {
groups += mutableListOf(candidate)
} else {
val primary = matching.first()
primary += candidate
matching.drop(1).forEach { duplicate ->
primary += duplicate
groups.remove(duplicate)
}
}
}
val targets = groups.mapIndexed { index, group ->
val clickable = group.filter { it.clickableCard != null }
.minWithOrNull(compareBy<AddressCardCandidate>({ nodeArea(requireNotNull(it.clickableCard)) }, { it.phone.path }))
if (clickable != null) {
AddressEntryTarget(clickable.phone, AddressEntryActivation.CLICK, logicalAddressKey(group, index))
} else {
AddressEntryTarget(group.minBy { nodeArea(it.phone) }.phone, AddressEntryActivation.TAP, logicalAddressKey(group, index))
}
}
val actionable = targets.filter { it.node.bounds.width >= 2 && it.node.bounds.height >= 2 }
return AddressEntryResolution(
phoneNodeCount = phones.size,
addressCardCount = targets.size,
tapTargetCount = actionable.size,
target = actionable.singleOrNull(),
)
}
private fun nearestClickableAncestor(
source: SnapshotNode,
byPath: Map<String, SnapshotNode>,
): SnapshotNode? {
var current: SnapshotNode? = source
while (current != null) {
if (current.clickable && current.visible && current.enabled) return current
current = current.parentPath?.let(byPath::get)
}
return null
}
private fun addressCardSemanticKey(card: SnapshotNode, snapshot: UiSnapshot): String? {
val labels = snapshot.nodes.asSequence()
.filter { it.visible && (it.path == card.path || it.path.startsWith("${card.path}/")) }
.map { normalizeAddressSemantic(it.label) }
.filter { it.isNotBlank() && maskedPhone(it) == null && it !in ADDRESS_CARD_GENERIC_LABELS }
.distinct()
.sorted()
.toList()
if (labels.none { it.length >= ADDRESS_SEMANTIC_MIN_LENGTH }) return null
return labels.joinToString("|")
}
private fun normalizeAddressSemantic(value: String): String = value
.replace(Regex("\\s+"), "")
.replace(",", ",")
.replace(":", ":")
.lowercase()
private fun sameLogicalAddress(first: AddressCardCandidate, second: AddressCardCandidate): Boolean {
if (maskedPhone(first.phone.label) != maskedPhone(second.phone.label)) return false
if (first.clickableCard?.path != null && first.clickableCard.path == second.clickableCard?.path) return true
if (sameAddressRegion(first.phone, second.phone)) return true
val firstCard = first.clickableCard
val secondCard = second.clickableCard
if (firstCard != null && secondCard != null && boundsRepresentSameRegion(firstCard.bounds, secondCard.bounds)) return true
return first.semanticKey != null && first.semanticKey == second.semanticKey
}
private fun boundsRepresentSameRegion(first: NodeBounds, second: NodeBounds): Boolean {
val overlapWidth = (minOf(first.right, second.right) - maxOf(first.left, second.left)).coerceAtLeast(0)
val overlapHeight = (minOf(first.bottom, second.bottom) - maxOf(first.top, second.top)).coerceAtLeast(0)
val overlap = overlapWidth.toLong() * overlapHeight
val smaller = minOf(first.width.toLong() * first.height, second.width.toLong() * second.height)
return smaller > 0 && overlap * 100 >= smaller * ADDRESS_CARD_OVERLAP_PERCENT
}
private fun logicalAddressKey(group: List<AddressCardCandidate>, index: Int): String {
val semantic = group.mapNotNull(AddressCardCandidate::semanticKey).distinct().singleOrNull()
return semantic?.let { "semantic:$it" } ?: "geometry:$index:${group.minOf { it.phone.bounds.top }}"
}
private fun sameAddressRegion(first: SnapshotNode, second: SnapshotNode): Boolean {
if (maskedPhone(first.label) != maskedPhone(second.label)) return false
val overlapWidth = (minOf(first.bounds.right, second.bounds.right) - maxOf(first.bounds.left, second.bounds.left)).coerceAtLeast(0)
val overlapHeight = (minOf(first.bounds.bottom, second.bounds.bottom) - maxOf(first.bounds.top, second.bounds.top)).coerceAtLeast(0)
val overlap = overlapWidth.toLong() * overlapHeight
val smaller = minOf(nodeArea(first), nodeArea(second))
val containsCenter = (
first.bounds.centerX in second.bounds.left..second.bounds.right &&
first.bounds.centerY in second.bounds.top..second.bounds.bottom
) || (
second.bounds.centerX in first.bounds.left..first.bounds.right &&
second.bounds.centerY in first.bounds.top..first.bounds.bottom
)
return (smaller > 0 && overlap * 100 >= smaller * 50) || containsCenter
}
private fun maskedPhone(value: String): String? = MASKED_PHONE.find(value)?.value
private fun nodeArea(node: SnapshotNode): Long = node.bounds.width.toLong() * node.bounds.height
fun finalConfirmation(input: PurchaseExecutionInput, address: ShippingAddressProof): FinalConfirmationEvidence {
val snapshot = driver.capture()
pageProblem(snapshot)
@@ -259,14 +487,62 @@ class PurchaseLiveAutomation(
click(save.single(), "保存地址")
val savedEvidence = waitForStableAddressEditorExit()
if (!hasFinalSavedAddressEvidence(savedEvidence, expected, suffix)) {
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
hasFinalSavedAddressEvidence(it, expected, suffix)
if (isPurchaseConfirmationPanel(savedEvidence)) {
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
} else {
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
hasFinalSavedAddressEvidence(it, expected, suffix)
}
}
}
return ShippingAddressProof(expected, suffix)
}
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
val screen = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
return screen.specPanelType in setOf(
SpecPanelType.NORMAL_SCROLLABLE,
SpecPanelType.NON_SCROLLABLE_CONFIRMATION,
SpecPanelType.ORDER_CONFIRMATION,
)
}
private fun restoreFinalEvidenceInCurrentPanel(
initial: UiSnapshot,
expected: String,
suffix: String,
) {
var snapshot = initial
var previousSignature: String? = null
repeat(ADDRESS_CONFIRMATION_SCROLL_LIMIT) { attempt ->
if (hasFinalSavedAddressEvidence(snapshot, expected, suffix)) return
if (!isPurchaseConfirmationPanel(snapshot)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后采购面板发生变化,未创建订单")
}
val panels = purchasePanelScrollTargets(snapshot)
if (panels.size != 1) {
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后没有找到唯一的规格面板滚动区域,未创建订单")
}
val panel = panels.single()
val signature = viewportSignature(snapshot, panel)
if (signature == previousSignature) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板未找到完整订单确认信息,未创建订单")
}
previousSignature = signature
if (!driver.swipePurchaseIn(panel, SwipeDirection.DOWN, 350)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板无法定位订单确认信息,未创建订单")
}
if (attempt < ADDRESS_CONFIRMATION_SCROLL_LIMIT - 1) pause(ADDRESS_CONFIRMATION_SCROLL_INTERVAL_MS)
snapshot = driver.capture()
pageProblem(snapshot)
}
if (!hasFinalSavedAddressEvidence(snapshot, expected, suffix)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板未找到完整订单确认信息,未创建订单")
}
}
private fun hasSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean {
if (snapshot.packageName != PDD_PACKAGE || shippingAddressEditors(snapshot).isNotEmpty()) return false
val visible = snapshot.nodes.filter { it.visible }
@@ -329,7 +605,7 @@ class PurchaseLiveAutomation(
private fun viewportSignature(snapshot: UiSnapshot, panel: SnapshotNode): String = snapshot.nodes
.filter { node -> node.visible && inside(node.bounds, panel.bounds) }
.joinToString("|") { node ->
listOf(node.className.orEmpty(), node.bounds.left, node.bounds.top, node.bounds.right, node.bounds.bottom, node.clickable, node.scrollable).joinToString(":")
listOf(node.path, node.className.orEmpty(), node.bounds.left, node.bounds.top, node.bounds.right, node.bounds.bottom, node.clickable, node.scrollable).joinToString(":")
}
private fun inside(child: NodeBounds, parent: NodeBounds): Boolean =
@@ -407,6 +683,13 @@ class PurchaseLiveAutomation(
snapshot.nodes.filter { node -> node.visible && node.enabled && FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) } },
)
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
return PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null).specPanelType ==
SpecPanelType.ORDER_CONFIRMATION
}
/** A post-submit navigation target is allowed only when it is one exact, non-payment PDD order-detail entry. */
private fun orderDetailEntryTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
snapshot,
@@ -483,5 +766,13 @@ class PurchaseLiveAutomation(
const val ORDER_RESULT_PAYMENT_POST_BACK_MAX_SAMPLES = 3
const val ORDER_RESULT_SCROLL_SAMPLE_INTERVAL = 15
const val ORDER_RESULT_SAMPLE_INTERVAL_MS = 200L
const val SPEC_CONFIRMATION_MAX_SAMPLES = 20
const val SPEC_CONFIRMATION_SAMPLE_INTERVAL_MS = 100L
const val ADDRESS_ENTRY_STABLE_INTERVAL_MS = 200L
const val ADDRESS_CARD_OVERLAP_PERCENT = 70
const val ADDRESS_SEMANTIC_MIN_LENGTH = 4
val ADDRESS_CARD_GENERIC_LABELS = setOf("收货地址", "修改", "默认地址", "默认")
const val ADDRESS_CONFIRMATION_SCROLL_LIMIT = 5
const val ADDRESS_CONFIRMATION_SCROLL_INTERVAL_MS = 500L
}
}
@@ -10,6 +10,13 @@ interface PurchaseUiDriver {
result = clickFresh(target),
reason = FreshClickReason.UNKNOWN,
)
/**
* Reacquires one address-entry node by its accessibility path and validates
* its immutable snapshot traits before clicking its nearest clickable
* ancestor. This is intentionally limited to the reversible transition
* from order confirmation into address management.
*/
fun clickAddressEntryFresh(target: SnapshotNode): FreshClickOutcome = clickFreshDetailed(target)
fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult
/**
* Reacquires and center-taps a target that the purchase parser has already
@@ -83,6 +90,15 @@ class PurchaseRehearsalExecutor(
val specSelectionProofs = mutableMapOf<String, ExactSpecSelectionProof>()
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
// unrelated screen), so only skip navigation when a fresh snapshot
// still carries safe PDD product/spec evidence.
if (
input.phase == "purchase" &&
action.type == PurchaseActionType.OPEN_PRODUCT &&
canReuseCurrentProduct(input)
) continue
stepChanged(action.type.wireName)
val failure = when (action.type) {
PurchaseActionType.OPEN_PRODUCT -> openProduct(input, action)
@@ -101,6 +117,7 @@ class PurchaseRehearsalExecutor(
null
}
PurchaseActionType.UPDATE_SHIPPING_ADDRESS -> try {
live.advanceToOrderConfirmation(hasAllRecordedSelectionProofs(input, specSelectionProofs))
addressProof = live.updateShippingAddress(input.addressSuffix)
null
} catch (error: PurchaseLiveException) {
@@ -152,6 +169,11 @@ class PurchaseRehearsalExecutor(
else failure("PURCHASE_RULE_INVALID", "正式采购规则缺少核单动作")
}
private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean =
currentScreen(input).let { screen ->
screen.problem == null && screen.hasPurchaseProductEvidence()
}
private fun validateBeforeDeviceAction(
input: PurchaseExecutionInput,
rule: PurchaseRule,
@@ -208,10 +230,20 @@ class PurchaseRehearsalExecutor(
var clickAttempted = false
var nextClickPoll = 0
var lastClickReason = FreshClickReason.UNKNOWN
var stableEvidenceReads = 0
var pddForegroundObserved = false
repeat(OPEN_PRODUCT_POLL_LIMIT) { poll ->
val snapshot = driver.capture()
pageProblem(snapshot)?.let { return it }
if (snapshot.packageName == PDD_PACKAGE) return null
if (snapshot.packageName == PDD_PACKAGE) {
pddForegroundObserved = true
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)
stableEvidenceReads = if (screen.hasPurchaseProductEvidence()) stableEvidenceReads + 1 else 0
if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) return null
pause(OPEN_PRODUCT_POLL_MILLIS)
return@repeat
}
stableEvidenceReads = 0
val candidates = snapshot.nodes.filter { it.visible && it.enabled && it.label in aliases }
if (candidates.size > 1) return failure("RULE_AMBIGUOUS", "打开拼多多按钮不唯一")
if (candidates.size == 1 && poll >= nextClickPoll) {
@@ -229,6 +261,9 @@ class PurchaseRehearsalExecutor(
}
pause(OPEN_PRODUCT_POLL_MILLIS)
}
if (pddForegroundObserved) {
return failure("PDD_DETAIL_ENTRY_FAILED", "打开拼多多后未识别到稳定商品页面")
}
if (clickAttempted) {
val message = when (lastClickReason) {
FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> "打开拼多多入口发生变化"
@@ -242,14 +277,20 @@ class PurchaseRehearsalExecutor(
}
private fun verifyProduct(input: PurchaseExecutionInput): PurchaseExecutionOutcome? {
repeat(50) {
var stableEvidenceReads = 0
repeat(PRODUCT_PAGE_POLL_LIMIT) {
val snapshot = driver.capture()
pageProblem(snapshot)?.let { return it }
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null)
if (screen.hasPurchaseProductEvidence()) {
return recoverSoldOut(input, screen)
stableEvidenceReads++
if (stableEvidenceReads >= PRODUCT_PAGE_STABLE_READS) {
return recoverSoldOut(input, screen)
}
} else {
stableEvidenceReads = 0
}
pause(100)
pause(OPEN_PRODUCT_POLL_MILLIS)
}
return failure("PDD_DETAIL_ENTRY_FAILED", "没有进入拼多多商品页面")
}
@@ -347,7 +388,10 @@ class PurchaseRehearsalExecutor(
"规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
)
FreshActionResult.SUCCESS -> Unit
else -> return failure(SPEC_ENTRY_CLICK_FAILED, click.reason.specEntrySubreason())
else -> return failure(
SPEC_ENTRY_CLICK_FAILED,
click.reason.specEntrySubreasonAfterGestureFailure(),
)
}
wait = waitForSpecPanel(input, specActionSignature(wait.screen))
wait.failure?.let { return it }
@@ -454,6 +498,12 @@ class PurchaseRehearsalExecutor(
else -> "unknown"
}
private fun FreshClickReason.specEntrySubreasonAfterGestureFailure(): String = when (this) {
FreshClickReason.SUCCESS -> "gesture_failed_after_action_click_no_effect"
FreshClickReason.UNKNOWN -> "gesture_failed_after_unclassified_action_result"
else -> specEntrySubreason()
}
private fun selectSpecs(
input: PurchaseExecutionInput,
rule: PurchaseRule,
@@ -575,7 +625,11 @@ class PurchaseRehearsalExecutor(
return matchingCandidates.size == 1 && matchingCandidates.single() == target
}
private data class ExactSpecSelectionProof(val dimension: String, val target: String)
private data class ExactSpecSelectionProof(
val dimension: String,
val target: String,
val panelType: SpecPanelType,
)
private fun exactSpecSelectionProof(
screen: ParsedPddScreen,
@@ -587,7 +641,7 @@ class PurchaseRehearsalExecutor(
val tokenCandidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }.filter { candidate ->
if (dimension == "size") SpecValueNormalizer.primarySizeToken(candidate.text) == token else candidate.text == token
}
return ExactSpecSelectionProof(dimension, target).takeIf {
return ExactSpecSelectionProof(dimension, target, screen.specPanelType).takeIf {
tokenCandidates.size == 1 && tokenCandidates.single().text == target
}
}
@@ -597,7 +651,11 @@ class PurchaseRehearsalExecutor(
val reason: String,
val summaryTokenMatched: Boolean,
val candidateCount: Int,
val proofPresent: Boolean,
val proofRecorded: Boolean,
val proofUsable: Boolean,
val targetMatchCount: Int = 0,
val relocationAttempted: Boolean = false,
val relocationSwipes: Int = 0,
)
private fun verifyExactSpecSelection(
@@ -608,15 +666,25 @@ class PurchaseRehearsalExecutor(
): FinalSpecVerification {
val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
val matchingNodes = candidates.filter { it.text == target }
val proofPresent = proof?.dimension == dimension && proof.target == target
val proofRecorded = proof?.dimension == dimension && proof.target == target
val proofUsable = proofRecorded && (
proof?.panelType == screen.specPanelType ||
(screen.specPanelType == SpecPanelType.UNKNOWN && screen.isPddPackage && screen.rootAvailable)
)
if (matchingNodes.size == 1 && (matchingNodes.single().node.selected || matchingNodes.single().node.checked)) {
return FinalSpecVerification(true, "selected_node", false, candidates.size, proofPresent)
return FinalSpecVerification(true, "selected_node", false, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
}
if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) {
return FinalSpecVerification(false, "visible_selected_conflict", false, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
}
val token = if (dimension == "size") SpecValueNormalizer.primarySizeToken(target) else target.trim()
val summaryTokenMatched = !token.isNullOrBlank() && SpecValueNormalizer.summaryHasExactToken(screen.selectedSummary, token)
if (!summaryTokenMatched) {
return FinalSpecVerification(false, "summary_token_missing", false, candidates.size, proofPresent)
if (screen.selectedSummary == null && proofUsable) {
return FinalSpecVerification(true, "attempt_selection_state", false, candidates.size, true, true, matchingNodes.size)
}
return FinalSpecVerification(false, "summary_token_missing", false, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
}
// PDD can rerender or scroll the previously selected dimension out of
// the current viewport while the selected summary remains visible. A
@@ -627,18 +695,83 @@ class PurchaseRehearsalExecutor(
if (dimension == "size") SpecValueNormalizer.primarySizeToken(candidate.text) == token else candidate.text == token
}
if (visibleTokenMatches.size == 1 && visibleTokenMatches.single().text == target) {
return FinalSpecVerification(true, "visible_candidate", true, candidates.size, proofPresent)
return FinalSpecVerification(true, "visible_candidate", true, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
}
if (visibleTokenMatches.isNotEmpty()) {
return FinalSpecVerification(false, "visible_candidate_conflict", true, candidates.size, proofPresent)
return FinalSpecVerification(false, "visible_candidate_conflict", true, candidates.size, proofRecorded, proofUsable, matchingNodes.size)
}
return if (proofPresent) {
FinalSpecVerification(true, "attempt_selection_proof", true, candidates.size, true)
return if (proofUsable) {
FinalSpecVerification(true, "attempt_selection_proof", true, candidates.size, true, true, matchingNodes.size)
} else {
FinalSpecVerification(false, "selection_proof_missing", true, candidates.size, false)
FinalSpecVerification(false, "selection_proof_missing", true, candidates.size, proofRecorded, false, matchingNodes.size)
}
}
private fun verifyExactSpecSelectionWithRelocation(
input: PurchaseExecutionInput,
dimension: String,
target: String,
proof: ExactSpecSelectionProof?,
): FinalSpecVerification {
var screen = currentScreen(input)
screen.problem?.let {
return FinalSpecVerification(false, it.code, false, 0, proof != null, false)
}
var verification = verifyExactSpecSelection(screen, dimension, target, proof)
if (verification.confirmed || verification.targetMatchCount > 0 || verification.reason == "visible_selected_conflict") return verification
if (!screen.specPanelOpen) return verification.copy(reason = "relocation_panel_closed", relocationAttempted = true)
var signature = screen.dimensions.joinToString("|") { item ->
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
}
var swipes = 0
for ((direction, limit) in listOf(SwipeDirection.DOWN to 3, SwipeDirection.UP to 6)) {
for (attempt in 0 until limit) {
val container = screen.specPanelContainer
?: return verification.copy(
reason = "relocation_container_missing",
relocationAttempted = true,
relocationSwipes = swipes,
)
if (!driver.swipePurchaseIn(container, direction, 350)) {
return verification.copy(
reason = "relocation_swipe_failed",
relocationAttempted = true,
relocationSwipes = swipes,
)
}
swipes++
pause(300)
screen = currentScreen(input)
screen.problem?.let {
return verification.copy(
reason = it.code,
relocationAttempted = true,
relocationSwipes = swipes,
)
}
if (!screen.specPanelOpen) {
return verification.copy(
reason = "relocation_panel_closed",
relocationAttempted = true,
relocationSwipes = swipes,
)
}
verification = verifyExactSpecSelection(screen, dimension, target, proof).copy(
relocationAttempted = true,
relocationSwipes = swipes,
)
if (verification.confirmed || verification.targetMatchCount > 0 || verification.reason == "visible_selected_conflict") return verification
val refreshedSignature = screen.dimensions.joinToString("|") { item ->
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
}
if (refreshedSignature == signature) break
signature = refreshedSignature
}
}
return verification.copy(relocationAttempted = true, relocationSwipes = swipes)
}
private data class SpecLookup(val node: SnapshotNode? = null, val failure: PurchaseExecutionOutcome? = null)
/**
@@ -726,23 +859,26 @@ class PurchaseRehearsalExecutor(
observedPrice: Long?,
selectionProofs: Map<String, ExactSpecSelectionProof>,
): PurchaseExecutionOutcome? {
val screen = currentScreen(input)
screen.problem?.let { return failure(it.code, it.message) }
val selected = listOf("color" to input.mappedColor, "size" to input.mappedSize).filter { it.second.isNotBlank() }.map { (dimension, rawTarget) ->
dimension to (normalizedTarget(dimension, rawTarget)
?: return failure(SPEC_SAFE_TARGET_MISSING, "下发规格无法安全规范化"))
}
selected.forEach { (dimension, target) ->
val verification = verifyExactSpecSelection(screen, dimension, target, selectionProofs[dimension])
val verification = verifyExactSpecSelectionWithRelocation(input, dimension, target, selectionProofs[dimension])
if (!verification.confirmed) {
val screen = currentScreen(input)
val panel = screen.specPanelType.name.lowercase()
val diagnostic = "dimension=$dimension,reason=${verification.reason},panel=$panel," +
"summary=${screen.selectedSummary != null},tokenMatched=${verification.summaryTokenMatched}," +
"candidates=${verification.candidateCount},proof=${verification.proofPresent}"
"candidates=${verification.candidateCount},targetMatches=${verification.targetMatchCount}," +
"proofRecorded=${verification.proofRecorded},proofUsable=${verification.proofUsable}," +
"relocated=${verification.relocationAttempted},relocationSwipes=${verification.relocationSwipes}"
return failure(SPEC_SELECTION_UNCONFIRMED, "最终规格复核未能确认精确选中状态 [$diagnostic]")
}
}
if (readQuantity() != input.quantity) return failure("PURCHASE_QUANTITY_MISMATCH", "最终数量复核失败")
val screen = currentScreen(input)
screen.problem?.let { return failure(it.code, it.message) }
val price = screen.priceCent ?: observedPrice ?: return failure("RULE_NOT_MATCHED", "最终价格复核失败")
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) {
return failure("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
@@ -750,6 +886,18 @@ class PurchaseRehearsalExecutor(
return null
}
private fun hasAllRecordedSelectionProofs(
input: PurchaseExecutionInput,
proofs: Map<String, ExactSpecSelectionProof>,
): Boolean {
val required = listOf("color" to input.mappedColor, "size" to input.mappedSize)
.filter { it.second.isNotBlank() }
.map { (dimension, rawTarget) -> dimension to normalizedTarget(dimension, rawTarget) }
return required.isNotEmpty() && required.all { (dimension, target) ->
target != null && proofs[dimension]?.let { it.dimension == dimension && it.target == target } == true
}
}
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
if (action.waitAfterMs > 0) pause(action.waitAfterMs)
action.swipeAfter?.let { swipe ->
@@ -809,7 +957,9 @@ class PurchaseRehearsalExecutor(
companion object {
private const val PDD_PACKAGE = "com.xunmeng.pinduoduo"
private const val OPEN_PRODUCT_POLL_LIMIT = 50
private const val OPEN_PRODUCT_POLL_LIMIT = 150
private const val PRODUCT_PAGE_POLL_LIMIT = 150
private const val PRODUCT_PAGE_STABLE_READS = 2
private const val OPEN_PRODUCT_RETRY_POLLS = 10
private const val OPEN_PRODUCT_POLL_MILLIS = 100L
private const val SPEC_ENTRY_READY_WAIT_POLLS = 20
@@ -57,6 +57,7 @@ import cn.ilapage.goauto.agent.persistence.AgentDiagnosticEvent
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticReason
import cn.ilapage.goauto.agent.persistence.AgentDiagnosticStage
import cn.ilapage.goauto.agent.persistence.SafeAgentDiagnosticRecorder
import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePolicy
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.Executors
@@ -255,13 +256,20 @@ class AgentForegroundService : Service() {
flushPurchaseOutbox(api, token)
val collectionCooldown = activeCollectionCooldown()
val purchaseTask = api.nextPurchaseTask(token)
when (TaskDispatchPolicy.decide(purchaseTask != null, collectionCooldown != null)) {
when (TaskDispatchPolicy.decide(purchaseTask?.status, collectionCooldown != null)) {
TaskDispatchDecision.RUN_PURCHASE -> {
cancelIdleReturn("收到新的采购任务")
releaseCollectionCooldownWakeLock()
schedulePurchaseTask(api, requireNotNull(purchaseTask), token)
return MANUAL_PURCHASE_TASK
}
TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH -> {
val waitingTask = requireNotNull(purchaseTask)
cancelIdleReturn("等待采购规格匹配")
stateStore.update("ONLINE", "采购任务 #${waitingTask.taskId} 正在匹配规格", tokenStored = true)
updateNotification("采购任务 #${waitingTask.taskId} 等待规格匹配")
return MANUAL_PURCHASE_MATCH_PENDING
}
TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN -> {
val ticket = requireNotNull(collectionCooldown)
showCollectionCooldown(ticket)
@@ -434,8 +442,10 @@ class AgentForegroundService : Service() {
}
private fun executePurchaseTask(api: AgentApiClient, initial: PurchaseAgentTask, token: String) {
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
acquireTaskWakeLock()
var resultSafelyStored = false
val lastStep = AtomicReference("started")
try {
val claimed = if (initial.status == "pending") {
api.claimPurchaseTask(initial.taskId, UUID.randomUUID().toString(), token)
@@ -472,7 +482,10 @@ class AgentForegroundService : Service() {
driver = accessibility,
openLink = { PddLinkLauncher(this).open(it) },
probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) },
stepChanged = { step -> purchaseStore.updateStep(task.taskId, task.taskAttemptId, step) },
stepChanged = { step ->
lastStep.set(step)
purchaseStore.updateStep(task.taskId, task.taskAttemptId, step)
},
panelDiagnostic = { evidence -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") },
beforeOrderSubmit = { evidence ->
val boundaryRequestId = UUID.randomUUID().toString()
@@ -513,6 +526,14 @@ class AgentForegroundService : Service() {
val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome)
purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload)
resultSafelyStored = true
PurchaseResultBubblePolicy.create(
taskId = task.taskId,
resultType = outcome.resultType,
lastStep = lastStep.get(),
resultMessage = outcome.message,
)?.let { presentation ->
GoAutoAccessibilityService.instance?.showPurchaseResultBubble(presentation)
}
beginIdleReturnCooldown()
flushPurchaseOutbox(api, token)
val message = if (outcome.resultType == "failed") "${outcome.errorCode}:${outcome.message}" else outcome.message
@@ -611,6 +632,7 @@ class AgentForegroundService : Service() {
initialTask: cn.ilapage.goauto.agent.network.AgentTask,
token: String,
): TaskExecutionSummary {
GoAutoAccessibilityService.instance?.dismissPurchaseResultBubble()
acquireTaskWakeLock()
return try {
executeTaskWhileAwake(api, initialTask, token)
@@ -1059,6 +1081,7 @@ class AgentForegroundService : Service() {
const val MANUAL_EMPTY = "empty"
const val MANUAL_COLLECTION_TASK = "collection_task"
const val MANUAL_PURCHASE_TASK = "purchase_task"
const val MANUAL_PURCHASE_MATCH_PENDING = "purchase_match_pending"
const val MANUAL_BUSY = "busy"
const val MANUAL_CONFIG_REQUIRED = "config_required"
const val MANUAL_AUTH_ERROR = "auth_error"
@@ -99,13 +99,15 @@ internal object CollectionCooldownPolicy {
internal enum class TaskDispatchDecision {
RUN_PURCHASE,
WAIT_FOR_PURCHASE_MATCH,
WAIT_FOR_COLLECTION_COOLDOWN,
CHECK_COLLECTION,
}
internal object TaskDispatchPolicy {
fun decide(purchaseAvailable: Boolean, collectionCooldownActive: Boolean): TaskDispatchDecision = when {
purchaseAvailable -> TaskDispatchDecision.RUN_PURCHASE
fun decide(purchaseStatus: String?, collectionCooldownActive: Boolean): TaskDispatchDecision = when {
purchaseStatus == "spec_probe_pending" -> TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH
purchaseStatus != null -> TaskDispatchDecision.RUN_PURCHASE
collectionCooldownActive -> TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN
else -> TaskDispatchDecision.CHECK_COLLECTION
}
@@ -0,0 +1,114 @@
package cn.ilapage.goauto.agent.ui
import android.content.Context
import android.graphics.PixelFormat
import android.graphics.drawable.GradientDrawable
import android.os.Handler
import android.os.Looper
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.LinearLayout
import android.widget.TextView
import cn.ilapage.goauto.agent.R
class PurchaseResultBubbleController(
private val context: Context,
private val canShow: () -> Boolean,
) {
private val mainHandler = Handler(Looper.getMainLooper())
private val windowManager = context.getSystemService(WindowManager::class.java)
private val session = PurchaseResultBubbleSession()
private var bubbleView: View? = null
fun show(presentation: PurchaseResultBubblePresentation) {
val revision = session.replace()
mainHandler.post {
if (!session.isCurrent(revision)) return@post
removeCurrentView()
if (!canShow()) return@post
val view = buildView(presentation)
runCatching { windowManager.addView(view, layoutParams()) }
.onSuccess {
bubbleView = view
mainHandler.postDelayed({
if (session.isCurrent(revision)) {
session.dismiss()
removeCurrentView()
}
}, presentation.durationMillis)
}
}
}
fun dismiss() {
session.dismiss()
mainHandler.post(::removeCurrentView)
}
private fun buildView(presentation: PurchaseResultBubblePresentation): View {
val horizontalPadding = context.dp(16)
val verticalPadding = context.dp(12)
return LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(horizontalPadding, verticalPadding, horizontalPadding, verticalPadding)
elevation = context.dp(8).toFloat()
background = GradientDrawable().apply {
cornerRadius = context.dp(16).toFloat()
setColor(context.getColor(if (presentation.isFailure) R.color.purchase_result_failure_background else R.color.purchase_result_success_background))
}
contentDescription = "${presentation.title}。${presentation.message}"
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_YES
descendantFocusability = ViewGroup.FOCUS_BLOCK_DESCENDANTS
addView(TextView(context).apply {
text = presentation.title
setTextColor(context.getColor(R.color.purchase_result_text))
textSize = 14f
setTypeface(typeface, android.graphics.Typeface.BOLD)
maxLines = 2
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT))
addView(TextView(context).apply {
text = presentation.message
setTextColor(context.getColor(R.color.purchase_result_text_secondary))
textSize = 13f
maxLines = 3
importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
}, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
topMargin = context.dp(4)
})
}
}
private fun layoutParams(): WindowManager.LayoutParams {
val availableWidth = context.resources.displayMetrics.widthPixels - context.dp(32)
return WindowManager.LayoutParams(
availableWidth.coerceAtMost(context.dp(360)),
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
PixelFormat.TRANSLUCENT,
).apply {
gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
y = context.statusBarHeight() + context.dp(8)
windowAnimations = android.R.style.Animation_Toast
}
}
private fun removeCurrentView() {
val view = bubbleView ?: return
bubbleView = null
runCatching { windowManager.removeViewImmediate(view) }
}
private fun Context.dp(value: Int): Int = (value * resources.displayMetrics.density).toInt()
@Suppress("DiscouragedApi")
private fun Context.statusBarHeight(): Int {
val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android")
return if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else dp(24)
}
}
@@ -0,0 +1,86 @@
package cn.ilapage.goauto.agent.ui
data class PurchaseResultBubblePresentation(
val title: String,
val message: String,
val isFailure: Boolean,
val durationMillis: Long,
)
object PurchaseResultBubblePolicy {
const val SUCCESS_DURATION_MILLIS = 3_000L
const val FAILURE_DURATION_MILLIS = 8_000L
private const val MAX_MESSAGE_LENGTH = 96
private val stepLabels = mapOf(
"started" to "准备采购",
"openProduct" to "打开商品",
"verifyProduct" to "核对商品",
"openSpecPanel" to "打开规格面板",
"selectSpec" to "选择颜色与尺码",
"setQuantity" to "设置数量",
"verifyUnitPrice" to "核对价格",
"verifyOrderSummary" to "核对订单",
"probeSpecs" to "采集规格",
"updateShippingAddress" to "更新收货地址",
"createOrder" to "创建待付款订单",
"order_submit_started" to "创建待付款订单",
"readOrderResult" to "读取订单编号和下单时间",
)
fun create(
taskId: Long,
resultType: String,
lastStep: String?,
resultMessage: String?,
): PurchaseResultBubblePresentation? {
if (resultType == "spec_probe_completed") return null
val failure = resultType == "failed" || resultType == "order_result_unknown"
if (failure) {
val label = stepLabels[lastStep] ?: "处理采购任务"
return PurchaseResultBubblePresentation(
title = if (resultType == "order_result_unknown") "CG-$taskId 待人工核对|$label" else "CG-$taskId 失败于:$label",
message = sanitize(resultMessage, "采购失败,请返回 Agent 查看详情"),
isFailure = true,
durationMillis = FAILURE_DURATION_MILLIS,
)
}
val successMessage = when (resultType) {
"order_created" -> "已获取订单编号和下单时间"
"rehearsal_completed" -> "商品、规格、数量和价格复核完成"
else -> sanitize(resultMessage, "采购结果已安全保存")
}
return PurchaseResultBubblePresentation(
title = "CG-$taskId 采购完成",
message = successMessage,
isFailure = false,
durationMillis = SUCCESS_DURATION_MILLIS,
)
}
private fun sanitize(value: String?, fallback: String): String {
val normalized = value.orEmpty()
.replace(Regex("https?://\\S+", RegexOption.IGNORE_CASE), "[链接已隐藏]")
.replace(Regex("(?i)(token|authorization|cookie)\\s*[:=]\\s*\\S+")) { match ->
"${match.groupValues[1]}=[已隐藏]"
}
.replace(Regex("[\\r\\n\\t]+"), " ")
.replace(Regex("\\s{2,}"), " ")
.trim()
.ifBlank { fallback }
return normalized.take(MAX_MESSAGE_LENGTH)
}
}
class PurchaseResultBubbleSession {
private var revision = 0L
@Synchronized
fun replace(): Long = ++revision
@Synchronized
fun dismiss(): Long = ++revision
@Synchronized
fun isCurrent(candidate: Long): Boolean = candidate == revision
}
@@ -83,10 +83,12 @@ internal object CollectionResetPolicy {
internal object PurchaseRetryPolicy {
fun showsAction(status: String, retryable: Boolean): Boolean = status == "failed" && retryable
fun usesInPlaceReset(continuing: Boolean): Boolean = !continuing
fun confirmationMessage(continuing: Boolean = false): String = if (continuing) {
"替代商品已完成匹配。系统会保留原任务并创建一笔新采购任务;可能创建拼多多待付款订单,但不会支付。"
} else {
"系统会保留原任务,并根据当前商品档案和最新采购规则创建一笔新采购任务;可能创建拼多多待付款订单,但不会支付。"
"系统会复用原采购任务并开始新一次执行,使用当前有效采购规则,商品和规格等任务快照保持不变;可能创建拼多多待付款订单,但不会支付。"
}
}
@@ -746,7 +748,7 @@ class TaskHistoryFragment : Fragment() {
contentDescription = "重试采购任务 CG-${task.taskId}"
setOnClickListener { confirmPurchaseRetry(task) }
}, collectionCardParams())
resultColumn.addView(context.centeredMessage("重试边界", "保留当前失败任务并创建新任务;新任务读取当前商品档案和采购规则,不会执行支付。"))
resultColumn.addView(context.centeredMessage("重试边界", "复用当前任务并新增一次执行;使用当前有效采购规则,商品和规格快照不变,不会执行支付。"))
} else if (!replacementInProgress && task.status == "failed") {
val reason = task.retryDisabledReason?.takeIf(String::isNotBlank) ?: "请在管理端核对任务状态。"
resultColumn.addView(context.centeredMessage("不可重试", reason))
@@ -864,18 +866,25 @@ class TaskHistoryFragment : Fragment() {
val generation = ++requestGeneration
showLoading(if (continuing) "正在提交继续采购请求…" else "正在提交重试请求…")
Thread {
runCatching {
runCatching<Unit> {
val client = AgentApiClient(serverUrl)
val requestId = UUID.randomUUID().toString()
client.retryPurchaseTask(taskId, requestId, credentials.token)
}
.onSuccess { result ->
if (PurchaseRetryPolicy.usesInPlaceReset(continuing)) {
val result = client.resetPurchaseTask(taskId, requestId, credentials.token)
resultColumn.post {
if (!isAdded || generation != requestGeneration) return@post
AgentForegroundService.start(requireContext())
showPurchaseResetSuccess(result.taskNo, result.taskId, result.attemptNumber)
}
} else {
val result = client.retryPurchaseTask(taskId, requestId, credentials.token)
resultColumn.post {
if (!isAdded || generation != requestGeneration) return@post
AgentForegroundService.start(requireContext())
showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId)
}
}
}
.onFailure { error ->
resultColumn.post {
if (isAdded && generation == requestGeneration) {
@@ -886,6 +895,24 @@ class TaskHistoryFragment : Fragment() {
}.start()
}
private fun showPurchaseResetSuccess(taskNo: String, taskId: Long, attemptNumber: Int) {
resultColumn.removeAllViews()
resultColumn.addView(requireContext().centeredMessage(
"$taskNo 已进入第 $attemptNumber 次执行",
"原采购任务已复用,并将由当前设备按正常队列执行;系统不会支付。",
))
resultColumn.addView(MaterialButton(requireContext()).apply {
text = "查看当前任务"
minimumHeight = requireContext().dp(48)
setOnClickListener { loadPurchaseDetail(taskId) }
}, collectionCardParams())
resultColumn.addView(MaterialButton(requireContext(), null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply {
text = "返回采购记录"
minimumHeight = requireContext().dp(48)
setOnClickListener { page = 1; load() }
}, collectionCardParams())
}
private fun showPurchaseRetrySuccess(sourceTaskNo: String, taskNo: String, taskId: Long) {
resultColumn.removeAllViews()
resultColumn.addView(requireContext().centeredMessage(
@@ -8,4 +8,8 @@
<color name="agent_text_muted">#CBD5E1</color>
<color name="agent_warning">#FBBF24</color>
<color name="agent_error">#F87171</color>
<color name="purchase_result_success_background">#166534</color>
<color name="purchase_result_failure_background">#991B1B</color>
<color name="purchase_result_text">#FFFFFF</color>
<color name="purchase_result_text_secondary">#F1F5F9</color>
</resources>
@@ -86,15 +86,19 @@ class CollectionCooldownPolicyTest {
fun `purchase keeps priority while collection waits for cooldown`() {
assertEquals(
TaskDispatchDecision.RUN_PURCHASE,
TaskDispatchPolicy.decide(purchaseAvailable = true, collectionCooldownActive = true),
TaskDispatchPolicy.decide(purchaseStatus = "pending", collectionCooldownActive = true),
)
assertEquals(
TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH,
TaskDispatchPolicy.decide(purchaseStatus = "spec_probe_pending", collectionCooldownActive = true),
)
assertEquals(
TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN,
TaskDispatchPolicy.decide(purchaseAvailable = false, collectionCooldownActive = true),
TaskDispatchPolicy.decide(purchaseStatus = null, collectionCooldownActive = true),
)
assertEquals(
TaskDispatchDecision.CHECK_COLLECTION,
TaskDispatchPolicy.decide(purchaseAvailable = false, collectionCooldownActive = false),
TaskDispatchPolicy.decide(purchaseStatus = null, collectionCooldownActive = false),
)
}
}
@@ -1,6 +1,8 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.FreshActionResult
import cn.ilapage.goauto.agent.automation.FreshClickOutcome
import cn.ilapage.goauto.agent.automation.FreshClickReason
import cn.ilapage.goauto.agent.automation.NodeBounds
import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation
@@ -16,6 +18,81 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseLiveAutomationTest {
@Test
fun `verified spec panel advances through one exact confirm target`() {
val driver = SpecConfirmationDriver()
PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation()
assertEquals(listOf("确定"), driver.clicked)
assertEquals("order", driver.page)
}
@Test
fun `ambiguous missing and unchanged spec confirmation stop safely`() {
val ambiguous = SpecConfirmationDriver(confirmLabels = listOf("确定", "确认"))
val ambiguousError = runCatching { PurchaseLiveAutomation(ambiguous, pause = {}).advanceToOrderConfirmation() }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_CONFIRM_TARGET_AMBIGUOUS", ambiguousError.code)
assertTrue(ambiguous.clicked.isEmpty())
val missing = SpecConfirmationDriver(confirmLabels = emptyList(), specPanelScrollable = true)
val missingError = runCatching { PurchaseLiveAutomation(missing, pause = {}).advanceToOrderConfirmation() }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_CONFIRM_TARGET_MISSING", missingError.code)
assertTrue(missing.clicked.isEmpty())
val unchanged = SpecConfirmationDriver(advanceAfterClick = false)
val unchangedError = runCatching { PurchaseLiveAutomation(unchanged, pause = {}).advanceToOrderConfirmation() }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_CONFIRMATION_UNCONFIRMED", unchangedError.code)
assertEquals(listOf("确定"), unchanged.clicked)
}
@Test
fun `existing order confirmation does not click submit or payment controls`() {
val driver = SpecConfirmationDriver(startOnOrderPage = true)
PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation()
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `recorded exact selections allow one unique confirm on an unclassified pdd panel`() {
val driver = SpecConfirmationDriver(unclassifiedSpecPanel = true)
PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation(
allowUnclassifiedPanelWithSelectionProof = true,
)
assertEquals(listOf("确定"), driver.clicked)
assertEquals("order", driver.page)
}
@Test
fun `unclassified pdd panel without recorded selections remains blocked`() {
val driver = SpecConfirmationDriver(unclassifiedSpecPanel = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation() }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_CONFIRMATION_NOT_READY", error.code)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `recorded selections never authorize a generic unclassified pdd dialog`() {
val driver = SpecConfirmationDriver(unclassifiedSpecPanel = true, unclassifiedHasPurchaseEvidence = false)
val error = runCatching {
PurchaseLiveAutomation(driver, pause = {}).advanceToOrderConfirmation(
allowUnclassifiedPanelWithSelectionProof = true,
)
}.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SPEC_CONFIRMATION_NOT_READY", error.code)
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `address is retagged verified and final order button can only be clicked once`() {
val driver = LiveDriver()
@@ -64,6 +141,31 @@ class PurchaseLiveAutomationTest {
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address returning to spec panel is recovered without pressing back`() {
val driver = LiveDriver(saveReturnsToSpecPanel = true)
val automation = PurchaseLiveAutomation(driver, pause = {})
val address = automation.updateShippingAddress("_cg91")
val final = automation.finalConfirmation(input().copy(addressSuffix = "_cg91"), address)
assertEquals("_cg91", final.addressSuffix)
assertEquals(0, driver.backCount)
assertEquals(1, driver.scopedSwipes)
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address stuck on spec panel fails without backing out or submitting`() {
val driver = LiveDriver(saveReturnsToSpecPanel = true, savedSpecPanelRecoveryStuck = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg92") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
assertEquals(0, driver.backCount)
assertEquals(0, driver.submitClicks)
}
@Test
fun `edit field containing suffix cannot impersonate post save evidence`() {
val driver = LiveDriver(saveStaysInEdit = true)
@@ -399,6 +501,52 @@ class PurchaseLiveAutomationTest {
assertTrue(driver.clicked.isEmpty())
}
@Test
fun `duplicate masked phone nodes in one clickable address card are activated once`() {
val driver = LiveDriver(duplicatePhoneNodesSameCard = true)
PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg94")
assertEquals(1, driver.clicked.count { it == "138****5678" })
assertEquals(0, driver.addressTaps)
}
@Test
fun `duplicate semantic address cards in separate accessibility trees are activated once`() {
val driver = LiveDriver(duplicateSemanticAddressCards = true)
PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg94")
assertEquals(1, driver.addressPathClicks)
assertEquals(1, driver.clicked.count { it == "138****5678" })
assertEquals(0, driver.addressTaps)
}
@Test
fun `two independent address cards remain ambiguous without any click`() {
val driver = LiveDriver(duplicateAddressCards = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg95") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
assertTrue(error.message.orEmpty().contains("phoneNodes=2;addressCards=2;tapTargets=2"))
assertFalse(error.message.orEmpty().contains("138"))
assertTrue(driver.clicked.isEmpty())
assertEquals(0, driver.addressTaps)
}
@Test
fun `address entry path drift fails without fallback tap`() {
val driver = LiveDriver(duplicatePhoneNodesSameCard = true, addressEntryPathUnavailable = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg94") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_ENTRY_NOT_READY", error.code)
assertTrue(driver.clicked.isEmpty())
assertEquals(1, driver.addressPathClicks)
assertEquals(0, driver.addressTaps)
}
private fun input() = PurchaseExecutionInput(
taskId = 11,
executionMode = "live",
@@ -413,6 +561,78 @@ class PurchaseLiveAutomationTest {
addressSuffix = "_cg11",
)
private class SpecConfirmationDriver(
private val confirmLabels: List<String> = listOf("确定"),
private val advanceAfterClick: Boolean = true,
private val specPanelScrollable: Boolean = false,
private val unclassifiedSpecPanel: Boolean = false,
private val unclassifiedHasPurchaseEvidence: Boolean = true,
startOnOrderPage: Boolean = false,
) : PurchaseUiDriver {
var page = if (startOnOrderPage) "order" else "spec"
val clicked = mutableListOf<String>()
override fun capture(): UiSnapshot = if (page == "order") {
snapshot(listOf(
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
node("scroll", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
node("summary", "已选 黑色 均码"),
node("quantity", "1", className = "android.widget.EditText"),
node("phone", "138****5678"),
node("submit", "提交订单", clickable = true),
node("payment", "微信支付"),
))
} else {
snapshot(buildList {
add(node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)))
if (unclassifiedSpecPanel) {
if (unclassifiedHasPurchaseEvidence) {
add(node("price", "¥20.00"))
add(node("quantity", "1", className = "android.widget.EditText"))
}
confirmLabels.forEachIndexed { index, label -> add(node("confirm-$index", label, clickable = true)) }
return@buildList
}
if (specPanelScrollable) {
add(node("scroll", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 1500)))
}
add(node("title", "确认款式"))
add(node("summary", "已选 黑色 均码"))
val parent = "scroll".takeIf { specPanelScrollable }
val prefix = if (specPanelScrollable) "scroll/" else ""
add(node("${prefix}color-heading", "颜色分类", parentPath = parent))
add(node("${prefix}color", "黑色", clickable = true, parentPath = parent))
add(node("${prefix}size-heading", "尺码", parentPath = parent))
add(node("${prefix}size", "均码", clickable = true, parentPath = parent))
add(node("quantity", "1", className = "android.widget.EditText"))
confirmLabels.forEachIndexed { index, label -> add(node("confirm-$index", label, clickable = true)) }
})
}
override fun clickFresh(target: SnapshotNode): FreshActionResult {
clicked += target.label
if (advanceAfterClick && target.label in confirmLabels) page = "order"
return FreshActionResult.SUCCESS
}
override fun tapPurchaseFresh(target: SnapshotNode) = FreshActionResult.FAILED
override fun inputFresh(target: SnapshotNode, value: String) = FreshActionResult.FAILED
override fun swipePurchase(direction: SwipeDirection, durationMs: Long) = false
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long) = false
override fun backPurchase() = false
private fun snapshot(nodes: List<SnapshotNode>) = UiSnapshot(PDD, ACTIVITY, nodes)
private fun node(
path: String,
text: String,
clickable: Boolean = false,
scrollable: Boolean = false,
className: String = "android.widget.TextView",
bounds: NodeBounds = NodeBounds(20, 100, 900, 180),
parentPath: String? = null,
) = SnapshotNode(path, parentPath, text, null, null, className, bounds, clickable, scrollable, false, false, true, true)
}
private class LiveDriver(
private val addressClipped: Boolean = false,
private val duplicatePanels: Boolean = false,
@@ -424,6 +644,12 @@ class PurchaseLiveAutomationTest {
private val saveStaysInEdit: Boolean = false,
private val savedTransitionWithoutLegacyContext: Boolean = false,
private val savedTransitionHidesSuffix: Boolean = false,
private val saveReturnsToSpecPanel: Boolean = false,
private val savedSpecPanelRecoveryStuck: Boolean = false,
private val duplicatePhoneNodesSameCard: Boolean = false,
private val duplicateSemanticAddressCards: Boolean = false,
private val duplicateAddressCards: Boolean = false,
private val addressEntryPathUnavailable: Boolean = false,
private val wechatLoginAfterSubmit: Boolean = false,
private val wechatRestoreStuck: Boolean = false,
private val orderEvidenceBelowFold: Boolean = false,
@@ -442,6 +668,7 @@ class PurchaseLiveAutomationTest {
var scopedSwipes = 0
var genericSwipes = 0
var addressTaps = 0
var addressPathClicks = 0
var lastInputTargetPath: String? = null
var backCount = 0
var postSubmitBackCount = 0
@@ -479,6 +706,21 @@ class PurchaseLiveAutomationTest {
node("transition-title", "选择收货信息"),
node("address-summary", if (savedTransitionHidesSuffix) "已保存的收货信息" else address.substring(address.lastIndexOf("_cg"))),
))
"post-save-spec" -> snapshot(listOf(
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
node("panel", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
node("panel-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)),
node("panel/color-heading", "颜色分类", parentPath = "panel", bounds = NodeBounds(20, 650, 300, 700)),
node("panel/color", "黑色", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 720, 220, 790)),
node("panel/size-heading", "尺码", parentPath = "panel", bounds = NodeBounds(20, 850, 300, 900)),
node("panel/size", "XL", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 920, 220, 990)),
node("panel/quantity", "2", className = "android.widget.EditText", parentPath = "panel", bounds = NodeBounds(400, 1050, 600, 1120)),
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)),
))
"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(
@@ -512,7 +754,30 @@ class PurchaseLiveAutomationTest {
)
if (duplicatePanels) nodes += node("panel2", "", scrollable = true, bounds = NodeBounds(0, 500, 1080, 2000))
if (addressVisible) {
nodes += node("phone", "138****5678")
when {
duplicateSemanticAddressCards -> {
nodes += node("address-layer-a", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 840))
nodes += node("address-layer-a/phone", "138****5678", parentPath = "address-layer-a", bounds = NodeBounds(20, 650, 400, 710))
nodes += node("address-layer-a/detail", "广东省广州市天园街道骏景花园", parentPath = "address-layer-a", bounds = NodeBounds(20, 720, 900, 790))
nodes += node("address-layer-b", "", clickable = true, bounds = NodeBounds(0, 900, 1080, 1120))
nodes += node("address-layer-b/phone", "138****5678", parentPath = "address-layer-b", bounds = NodeBounds(20, 930, 400, 990))
nodes += node("address-layer-b/detail", "广东省广州市天园街道骏景花园", parentPath = "address-layer-b", bounds = NodeBounds(20, 1000, 900, 1070))
}
duplicateAddressCards -> {
nodes += node("address-card-a", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 820))
nodes += node("address-card-a/phone", "138****5678", parentPath = "address-card-a", bounds = NodeBounds(20, 650, 400, 710))
nodes += node("address-card-a/detail", "广东省广州市天园街道一号", parentPath = "address-card-a", bounds = NodeBounds(20, 720, 900, 780))
nodes += node("address-card-b", "", clickable = true, bounds = NodeBounds(0, 840, 1080, 1040))
nodes += node("address-card-b/phone", "138****5678", parentPath = "address-card-b", bounds = NodeBounds(20, 870, 400, 930))
nodes += node("address-card-b/detail", "广东省广州市天园街道二号", parentPath = "address-card-b", bounds = NodeBounds(20, 940, 900, 1000))
}
duplicatePhoneNodesSameCard -> {
nodes += node("address-card", "", clickable = true, bounds = NodeBounds(0, 620, 1080, 900))
nodes += node("address-card/phone-a", "138****5678", parentPath = "address-card", bounds = NodeBounds(20, 650, 400, 710))
nodes += node("address-card/phone-b", "138****5678", parentPath = "address-card", bounds = NodeBounds(20, 720, 440, 790))
}
else -> nodes += node("phone", "138****5678")
}
val suffixStart = address.lastIndexOf("_cg")
val addressBody = if (suffixStart >= 0) address.substring(0, suffixStart) else address
val addressSuffix = if (suffixStart >= 0) address.substring(suffixStart) else ""
@@ -537,7 +802,13 @@ class PurchaseLiveAutomationTest {
when (target.label) {
"138****5678" -> page = "panel"
"修改" -> page = "edit"
"保存" -> if (!saveStaysInEdit) page = if (savedTransitionWithoutLegacyContext) "saved-transition" else "panel"
"保存" -> if (!saveStaysInEdit) {
page = when {
saveReturnsToSpecPanel -> "post-save-spec"
savedTransitionWithoutLegacyContext -> "saved-transition"
else -> "panel"
}
}
"提交订单" -> {
submitClicks++
page = when {
@@ -555,6 +826,14 @@ class PurchaseLiveAutomationTest {
return FreshActionResult.SUCCESS
}
override fun clickAddressEntryFresh(target: SnapshotNode): FreshClickOutcome {
addressPathClicks++
if (addressEntryPathUnavailable) {
return FreshClickOutcome(FreshActionResult.NOT_FOUND, FreshClickReason.TARGET_NOT_FOUND)
}
return FreshClickOutcome(clickFresh(target), FreshClickReason.SUCCESS, 1, 1)
}
override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult {
addressTaps++
clicked += target.label
@@ -575,6 +854,7 @@ class PurchaseLiveAutomationTest {
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
scopedSwipes++
if (target.path == "panel" && direction == SwipeDirection.DOWN) addressVisible = true
if (page == "post-save-spec" && direction == SwipeDirection.DOWN && !savedSpecPanelRecoveryStuck) page = "confirmation"
return true
}
override fun backPurchase(): Boolean {
@@ -81,12 +81,63 @@ class PurchaseRehearsalExecutorTest {
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(2_000L, outcome.actualUnitPriceCent)
assertEquals(1, openCount)
assertEquals(0, openCount)
assertEquals(2, driver.swipeCount)
assertEquals(2L, driver.quantity)
assertTrue(driver.clicked.containsAll(listOf("打开", "选择规格", "黑色", "XL")))
assertTrue(driver.clicked.containsAll(listOf("选择规格", "黑色", "XL")))
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") })
assertTrue(pauses.contains(700))
assertFalse(pauses.contains(700))
}
@Test
fun `manual purchase retry from agent reopens the task product url`() {
val driver = FakePurchaseDriver(initiallyInAgent = true)
var openCount = 0
val outcome = PurchaseRehearsalExecutor(
driver,
openLink = {
openCount++
driver.leaveAgentAndOpenBrowser()
true
},
probeSpecs = { null },
pause = {},
).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(1, openCount)
assertEquals(1, driver.openClickCount)
}
@Test
fun `purchase phase reopens when pdd foreground has no product evidence`() {
val driver = FakePurchaseDriver(loadingPddCaptures = 1)
var openCount = 0
val outcome = PurchaseRehearsalExecutor(
driver,
openLink = { openCount++; driver.browser = true; true },
probeSpecs = { null },
pause = {},
).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(1, openCount)
assertEquals(1, driver.openClickCount)
}
@Test
fun `purchase phase does not reuse a pdd login page`() {
val driver = FakePurchaseDriver(pddProblemLabels = listOf("手机号登录", "登录后继续"))
var openCount = 0
val outcome = PurchaseRehearsalExecutor(
driver,
openLink = { openCount++; driver.browser = true; true },
probeSpecs = { null },
pause = {},
).execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_LOGIN_REQUIRED", outcome.errorCode)
assertEquals(1, openCount)
}
@Test
@@ -233,7 +284,7 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `final verification rejects prior proof when selected summary is no longer visible`() {
fun `final verification keeps exact attempt selection when summary is no longer visible`() {
val driver = FakePurchaseDriver(
hideColorAfterQuantitySet = true,
hideSelectedSummaryAfterQuantitySet = true,
@@ -241,11 +292,53 @@ class PurchaseRehearsalExecutorTest {
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(1, driver.clicked.count { it == "黑色" })
}
@Test
fun `final verification keeps current attempt proof when selected panel becomes unclassified`() {
val driver = FakePurchaseDriver(panelBecomesUnknownAfterSizeProof = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input().copy(quantity = 1), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(1, driver.clicked.count { it == "黑色" })
assertEquals(1, driver.clicked.count { it == "XL" })
}
@Test
fun `final verification reuses exact attempt state without scrolling back`() {
val driver = FakePurchaseDriver(
hideColorAfterQuantitySet = true,
hideSelectedSummaryAfterQuantitySet = true,
restoreHiddenColorOnDownSwipe = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertFalse(driver.swipeInPaths.contains("scroll"))
assertEquals(1, driver.clicked.count { it == "黑色" })
assertEquals(1, driver.clicked.count { it == "XL" })
}
@Test
fun `final verification rejects explicit conflicting selected color`() {
val driver = FakePurchaseDriver(
colors = listOf("黑色", "白色"),
hideSelectedSummaryAfterQuantitySet = true,
selectedColorOverrideAfterQuantitySet = "白色",
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("failed", outcome.resultType)
assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", outcome.errorCode)
assertTrue(outcome.message.contains("dimension=color"))
assertTrue(outcome.message.contains("reason=summary_token_missing"))
assertTrue(outcome.message.contains("proof=true"))
assertTrue(outcome.message.contains("reason=visible_selected_conflict"))
assertTrue(outcome.message.contains("targetMatches=1"))
assertEquals(1, driver.clicked.count { it == "黑色" })
}
@Test
@@ -279,7 +372,8 @@ class PurchaseRehearsalExecutorTest {
assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", outcome.errorCode)
assertTrue(outcome.message.contains("dimension=size"))
assertTrue(outcome.message.contains("reason=visible_candidate_conflict"))
assertTrue(outcome.message.contains("proof=true"))
assertTrue(outcome.message.contains("proofRecorded=true"))
assertTrue(outcome.message.contains("proofUsable=true"))
}
@Test
@@ -404,8 +498,8 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `ambiguous browser target stops safely`() {
val driver = FakePurchaseDriver(duplicateOpen = true)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("RULE_AMBIGUOUS", outcome.errorCode)
assertTrue(driver.clicked.isEmpty())
}
@@ -416,10 +510,10 @@ class PurchaseRehearsalExecutorTest {
openClickResults = mutableListOf(FreshActionResult.FAILED),
openPddOnFailedClick = true,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {})
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals("spec_probe_completed", outcome.resultType)
assertEquals(1, driver.openClickCount)
}
@@ -429,10 +523,10 @@ class PurchaseRehearsalExecutorTest {
openClickResults = mutableListOf(FreshActionResult.NOT_FOUND, FreshActionResult.SUCCESS),
)
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals("spec_probe_completed", outcome.resultType)
assertEquals(2, driver.openClickCount)
assertTrue(pauses.size <= 50)
}
@@ -440,15 +534,50 @@ class PurchaseRehearsalExecutorTest {
@Test
fun `persistent open click failure is bounded and remains safely failed`() {
val driver = FakePurchaseDriver(
openClickResults = MutableList(10) { FreshActionResult.FAILED },
openClickResults = MutableList(20) { FreshActionResult.FAILED },
)
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertTrue(driver.openClickCount in 1..5)
assertEquals(50, pauses.size)
assertTrue(driver.openClickCount in 1..15)
assertEquals(150, pauses.size)
}
@Test
fun `five second browser interstitial can still reach a stable product page`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(browserOpenVisibleAfterCaptures = 50)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("spec_probe_completed", outcome.resultType)
assertEquals(1, driver.openClickCount)
assertTrue(pauses.count { it == 100L } in 52..60)
}
@Test
fun `pdd foreground without stable product evidence times out at fifteen seconds`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(loadingPddCaptures = 200)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_DETAIL_ENTRY_FAILED", outcome.errorCode)
assertEquals("打开拼多多后未识别到稳定商品页面", outcome.message)
assertEquals(150, pauses.count { it == 100L })
}
@Test
fun `explicit pdd login page fails before the product timeout`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(pddProblemLabels = listOf("手机号登录", "登录后继续"))
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add)
.execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PDD_LOGIN_REQUIRED", outcome.errorCode)
assertTrue(pauses.count { it == 100L } < 5)
}
@Test
@@ -505,6 +634,20 @@ class PurchaseRehearsalExecutorTest {
assertFalse(driver.panel)
}
@Test
fun `failed spec entry gesture preserves the action click no effect reason`() {
val driver = FakePurchaseDriver(
entryActionHasEffect = false,
specTapResult = FreshActionResult.FAILED,
)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_FAILED", outcome.errorCode)
assertEquals("gesture_failed_after_action_click_no_effect", outcome.message)
assertFalse(outcome.message.contains("unknown"))
}
@Test
fun `missing spec entry emits scalar source counts without node text`() {
val diagnostics = mutableListOf<String>()
@@ -523,8 +666,6 @@ class PurchaseRehearsalExecutorTest {
"specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000",
diagnostics.single(),
)
// One 100ms pause belongs to the existing open-product foreground poll;
// the diagnostic proves the entry-ready loop itself used exactly 20.
assertEquals(21, pauses.count { it == 100L })
assertFalse(diagnostics.single().contains("选择规格"))
}
@@ -544,14 +685,16 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `verify product waits for product evidence instead of a visible loading frame`() {
fun `purchase reopens and waits for product evidence instead of a visible loading frame`() {
val pauses = mutableListOf<Long>()
val driver = FakePurchaseDriver(loadingPddCaptures = 1)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(1, pauses.count { it == 100L })
// Two stable reads belong to reopening the product; verifyProduct then
// independently requires its second stable read before continuing.
assertEquals(3, pauses.count { it == 100L })
}
@Test
@@ -882,6 +1025,8 @@ class PurchaseRehearsalExecutorTest {
private val missingSpecEntry: Boolean = false,
private val specEntryVisibleAfterPddCaptures: Int = 0,
private val loadingPddCaptures: Int = 0,
private val browserOpenVisibleAfterCaptures: Int = 0,
private val pddProblemLabels: List<String> = emptyList(),
private val includeReviewEntry: Boolean = false,
private val openReviewOnBottomClick: Boolean = false,
private val reviewBackSucceeds: Boolean = true,
@@ -901,6 +1046,8 @@ class PurchaseRehearsalExecutorTest {
private val hideColorAfterQuantitySet: Boolean = false,
private val hideSizeAfterQuantitySet: Boolean = false,
private val hideSelectedSummaryAfterQuantitySet: Boolean = false,
private val restoreHiddenColorOnDownSwipe: Boolean = false,
private val selectedColorOverrideAfterQuantitySet: String? = null,
private val selectedSummaryOverrideAfterQuantitySet: String? = null,
private val finalSizesAfterQuantitySet: List<String>? = null,
soldOut: Boolean = false,
@@ -912,8 +1059,11 @@ class PurchaseRehearsalExecutorTest {
private val nonScrollablePanel: Boolean = false,
private val unrecognizedPanel: Boolean = false,
private val purchaseSwipeSucceeds: Boolean = true,
initiallyInAgent: Boolean = false,
private val panelBecomesUnknownAfterSizeProof: Boolean = false,
) : PurchaseUiDriver {
var browser = false
private var inAgent = initiallyInAgent
var panel = false
var color: String? = null
var size: String? = initialSize
@@ -931,20 +1081,46 @@ class PurchaseRehearsalExecutorTest {
private var productEvidenceLost = false
private var reviewPage = false
private var pddCaptureCount = 0
private var browserCaptureCount = 0
private var hiddenColorRestored = false
private var capturesAfterSizeSelection = 0
val clicked = mutableListOf<String>()
val clickedPaths = mutableListOf<String>()
override fun capture(): UiSnapshot {
if (inAgent) {
return UiSnapshot("cn.ilapage.goauto.agent", "MainActivity", listOf(node("content", "", 0, 0, 1080, 2200)))
}
if (browser && !panel && color == null && size == null) {
browserCaptureCount++
if (browserCaptureCount <= browserOpenVisibleAfterCaptures) {
return UiSnapshot("com.heytap.browser", "BrowserActivity", listOf(node("content", "", 0, 0, 1080, 2200)))
}
val openNodes = mutableListOf(node("open", "打开", 0, 100, 300, 180, clickable = true))
if (duplicateOpen) openNodes += node("open2", "打开", 400, 100, 700, 180, clickable = true)
openNodes += node("content", "", 0, 0, 1080, 2200)
return UiSnapshot("com.heytap.browser", "BrowserActivity", openNodes)
}
if (panelBecomesUnknownAfterSizeProof && panel && size != null) {
capturesAfterSizeSelection++
if (capturesAfterSizeSelection > 1) {
return UiSnapshot(PDD, ACTIVITY, listOf(
node("content", "", 0, 0, 1080, 2200),
node("price", "¥20.00", 20, 300, 300, 360),
node("quantity", "1", 400, 800, 600, 870, className = "android.widget.EditText"),
node("confirm", "确定", 20, 900, 500, 980, clickable = true),
))
}
}
pddCaptureCount++
if (pddCaptureCount <= loadingPddCaptures) {
return UiSnapshot(PDD, ACTIVITY, listOf(node("content", "", 0, 0, 1080, 2200)))
}
if (pddProblemLabels.isNotEmpty()) {
return UiSnapshot(PDD, ACTIVITY, pddProblemLabels.mapIndexed { index, label ->
node("problem-$index", label, 20, 100 + index * 100, 900, 180 + index * 100)
})
}
if (!panel) {
if (reviewPage) {
return UiSnapshot(PDD, ACTIVITY, listOf(
@@ -986,7 +1162,7 @@ class PurchaseRehearsalExecutorTest {
node("panel-title", "确认款式", 20, 396, 300, 430),
))
}
val hideColor = hideColorAfterQuantitySet && quantity == 2L
val hideColor = hideColorAfterQuantitySet && quantity == 2L && !hiddenColorRestored
val hideSize = hideSizeAfterQuantitySet && quantity == 2L
val hideSummary = hideSelectedSummaryAfterQuantitySet && quantity == 2L
val displayedSummary = if (quantity == 2L && selectedSummaryOverrideAfterQuantitySet != null) {
@@ -1005,8 +1181,20 @@ class PurchaseRehearsalExecutorTest {
}
if (!hideColor) {
nodes += node("scroll/color-heading", "颜色分类", 20, 410, 300, 450, parentPath = "scroll")
val selectedColor = if (quantity == 2L) selectedColorOverrideAfterQuantitySet ?: color else color
colors.forEachIndexed { index, value ->
nodes += node("scroll/color-$index", value, 20 + index * 220, 470, 200 + index * 220, 540, clickable = true, selected = color == value, enabled = !allSpecsUnavailable, parentPath = "scroll")
nodes += node(
"scroll/color-$index",
value,
20 + index * 220,
470,
200 + index * 220,
540,
clickable = true,
selected = selectedColor == value,
enabled = !allSpecsUnavailable,
parentPath = "scroll",
)
}
}
if (!hideSize) {
@@ -1040,6 +1228,11 @@ class PurchaseRehearsalExecutorTest {
return UiSnapshot(PDD, ACTIVITY, nodes)
}
fun leaveAgentAndOpenBrowser() {
inAgent = false
browser = true
}
override fun clickFresh(target: SnapshotNode): FreshActionResult {
clicked += target.label
clickedPaths += target.path
@@ -1126,6 +1319,9 @@ class PurchaseRehearsalExecutorTest {
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
swipeInPaths += target.path
if (restoreHiddenColorOnDownSwipe && quantity == 2L && direction == SwipeDirection.DOWN) {
hiddenColorRestored = true
}
return swipePurchase(direction, durationMs)
}
@@ -0,0 +1,72 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.ui.PurchaseResultBubblePolicy
import cn.ilapage.goauto.agent.ui.PurchaseResultBubbleSession
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseResultBubblePolicyTest {
@Test
fun `spec probe does not display a final result bubble`() {
assertNull(PurchaseResultBubblePolicy.create(69, "spec_probe_completed", "probeSpecs", "规格已回传"))
}
@Test
fun `failure includes the real step and sanitized reason for eight seconds`() {
val result = requireNotNull(PurchaseResultBubblePolicy.create(
69,
"failed",
"selectSpec",
"没有找到精确规格\n请重试 token=secret-value",
))
assertEquals("CG-69 失败于:选择颜色与尺码", result.title)
assertEquals("没有找到精确规格 请重试 token=[已隐藏]", result.message)
assertTrue(result.isFailure)
assertEquals(8_000L, result.durationMillis)
}
@Test
fun `unknown order result is presented as a failure requiring manual check`() {
val result = requireNotNull(PurchaseResultBubblePolicy.create(
70,
"order_result_unknown",
"readOrderResult",
"无法确认订单是否创建,请人工检查",
))
assertEquals("CG-70 待人工核对|读取订单编号和下单时间", result.title)
assertTrue(result.isFailure)
assertEquals(8_000L, result.durationMillis)
}
@Test
fun `successful order uses a short factual summary for three seconds`() {
val result = requireNotNull(PurchaseResultBubblePolicy.create(
71,
"order_created",
"readOrderResult",
"ignored",
))
assertEquals("CG-71 采购完成", result.title)
assertEquals("已获取订单编号和下单时间", result.message)
assertFalse(result.isFailure)
assertEquals(3_000L, result.durationMillis)
}
@Test
fun `new result and dismiss invalidate an older scheduled bubble`() {
val session = PurchaseResultBubbleSession()
val first = session.replace()
val second = session.replace()
assertFalse(session.isCurrent(first))
assertTrue(session.isCurrent(second))
session.dismiss()
assertFalse(session.isCurrent(second))
}
}
@@ -16,19 +16,22 @@ class PurchaseRetryPolicyTest {
}
@Test
fun `retry confirmation explains new task current archive and no payment`() {
fun `ordinary retry uses same task new attempt and no payment`() {
val message = PurchaseRetryPolicy.confirmationMessage()
assertTrue(message.contains("保留原任务"))
assertTrue(message.contains("当前商品档案"))
assertTrue(message.contains("最新采购规则"))
assertTrue(message.contains("新采购任务"))
assertTrue(PurchaseRetryPolicy.usesInPlaceReset(continuing = false))
assertTrue(message.contains("复用原采购任务"))
assertTrue(message.contains("新一次执行"))
assertTrue(message.contains("当前有效采购规则"))
assertTrue(message.contains("任务快照保持不变"))
assertTrue(message.contains("待付款订单"))
assertTrue(message.contains("不会支付"))
assertFalse(message.contains("新采购任务"))
}
@Test
fun `continue confirmation remains a new purchase task`() {
val message = PurchaseRetryPolicy.confirmationMessage(continuing = true)
assertFalse(PurchaseRetryPolicy.usesInPlaceReset(continuing = true))
assertTrue(message.contains("新采购任务"))
assertTrue(message.contains("替代商品"))
assertTrue(message.contains("不会支付"))
+8 -4
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:29:41Z
wiki_revision: 7468b9fbdd4d0bbbb9a73580c22ec868b3085753
synchronized_at: 2026-09-05T07:16:39Z
<!-- gitea-wiki-mirror:end -->
# 项目档案
@@ -21,16 +21,20 @@ synchronized_at: 2026-09-04T11:29:41Z
| 后续设计范围 | 从 SYB 创建采购任务、创建待付款订单、物流采集与自动回填 |
| 预计规模 | 20 台 Android;每天约 100 个采集任务、200 个采购任务 |
## 项目治理模式
GoAuto 默认采用轻量治理:文案、注释、格式、局部样式或布局、预期行为明确的小 Bug,以及不改变接口、数据结构、权限和安全边界的单模块低风险调整可以直接实施,无需为了留痕补建工单。完整独立需求、新页面、跨模块功能,以及涉及 API、数据结构、权限、安全、迁移或范围不明确的变化必须建立单元工单。采购、创建订单、真实个人或生产数据、权限、安全、并发、迁移、删除、发布和不可逆操作始终升级为高风险,必须具备对象和范围明确的人工授权;已有有效授权时不机械重复确认,范围或环境发生实质变化时重新确认。永久禁止付款以及设备、隐私、AI 和真机安全红线不可裁剪。
## 建设基线
| 基线 | 来源与版本 | 许可证 / 使用方式 | GoAuto 适配 |
|---|---|---|---|
| DevHarness | `D:\OPC\dev_harness`,目标提交 `4bbacf4d7fb265984396bb5589c544105043fa0b` | 开发流程与文档模板 | 2026-08-27 升级(#114):在 #76 已采用的单人工单事实源基础上,增量加入部署模板、Gitea MCP 与最小工单读取、线上原型默认审核与按需 HTML 导出、PowerShell UTF-8/ExecutionPolicy 边界;继续保留 GoAuto 专用安全与设计门禁 |
| DevHarness | `D:\OPC\dev_harness`,目标提交 `ecab899` | 开发流程与文档模板 | 2026-09-05 升级(#221):在既有单人工单事实源基础上,选择性加入轻量治理、明确授权边界、中文默认沟通、Windows PowerShell 安全规则,以及按 revision 增量同步与显式深度检查;继续保留 GoAuto 专用高风险与安全门禁 |
| 服务端 | `go-admin` v2.3.0 | 上游开源管理端基线;升级时复核许可证和安全公告 | 保留认证、菜单、配置和管理端基础能力,新增 GoAuto 业务模块 |
| 管理端 | `go-admin-ui` v3.0.0,`web/package.json` 标注 MIT | Vue 管理界面基线 | 保留应用外壳与通用组件,新增 GoAuto 页面 |
| Android | 原生 Kotlin Agent | 自研业务客户端 | 通过管理员明确配置的 HTTP 或 HTTPS Origin 直连服务端,不保留 Windows 桌面 Client/ADB 作为生产拓扑 |
升级必须比较当前记录的目标提交与新的明确提交,不能笼统复制“最新版”。本次上一基线为 `bfdf648962d11a8024f62768380d8571e1f45f68`,目标为 `4bbacf4d7fb265984396bb5589c544105043fa0b`,区间共 13 个提交;更早基线 `b1f500128d6eb100985792d4a715db8b6b5ae203` 的适配见 #47。模板内容一律按 GoAuto 事实改写:不复制 DevHarness 的项目事实、任务记录、占位部署参数或历史归档;`harness.py` 继续校验 GoAuto 实际核心页面与产品 README,GoAuto 更严格的付款、订单、设备、数据和真机门禁继续优先。
升级必须比较当前记录的目标提交与新的明确提交,不能笼统复制“最新版”。本次上一基线为 `4bbacf4d7fb265984396bb5589c544105043fa0b`,目标为 `ecab899`,选择性适配其后的 7 个提交;更早基线适配见 #47 与 #114。模板内容一律按 GoAuto 事实改写:不复制 DevHarness 的项目事实、任务记录、占位部署参数或历史归档;`harness.py` 继续校验 GoAuto 实际核心页面与产品 README,GoAuto 更严格的付款、订单、设备、数据和真机门禁继续优先。
## 交付单元
+22 -3
View File
@@ -2,12 +2,18 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Development-Workflow
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Development-Workflow.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:29:46Z
wiki_revision: 62ddbe4469740c02ce4a6ca2fd1966a89a79322f
synchronized_at: 2026-09-05T07:16:44Z
<!-- gitea-wiki-mirror:end -->
# 开发工作流
## 语言与术语
- 用户可以使用中文、英文或合理的中英混合语言交流;默认使用中文分析、回复、编写工单和维护内部项目文档。
- 代码标识符、命令、参数、路径、文件名、API 名称、协议名、日志和错误原文保持原样;必要时补充简短中文解释。
- 用户明确要求某次回复或交付物使用其他语言时,按该次要求执行,不改写接口契约或影响搜索和执行的原文。
## 事实来源
| 信息 | 唯一事实来源 |
@@ -18,7 +24,7 @@ synchronized_at: 2026-09-04T11:29:46Z
| 源码、迁移、测试、版本绑定分析、本地原型和核心 Wiki 镜像 | Git |
| 可编辑交互设计 | QuantUX;App ID、版本、链接和确认状态记录在工单 |
核心页面通过 `wiki-docs.json` 显式映射,固定执行 Wiki → `docs/` 单向同步。既有 Wiki 任务归档和 `docs/task/` 只作历史兼容;标准任务不创建或导出,只有用户明确要求专项快照时才使用 `archive` / `export`。
核心页面通过 `wiki-docs.json` 显式映射,固定执行 Wiki → `docs/` 单向同步。日常 `sync` 与 `sync --check` 先比较页面 revision,revision 未变化时不重复下载正文;疑似镜像损坏或需要完整核对时显式使用 `sync --deep-check`。既有 Wiki 任务归档和 `docs/task/` 只作历史兼容;标准任务不创建或导出,只有用户明确要求专项快照时才使用 `archive` / `export`。
## 权威源与事实边界
@@ -53,6 +59,19 @@ synchronized_at: 2026-09-04T11:29:46Z
- 每个核心页面写入后必须在线回读并取得 revision。页面缺失、回读失败或没有 revision 时停止初始化。
- 产品编码前运行 `python dev_scripts/harness.py sync --verify`;全部成功才表示初始化完成。
## 项目治理模式与不可裁剪底线
GoAuto 默认采用轻量治理。文案、注释、格式、局部样式或布局、预期行为明确的小 Bug,以及不改变接口、数据结构、权限和安全边界的单模块低风险调整可以直接实施,无需为了留痕补建工单。完整独立需求、新页面、跨模块功能,以及 API、数据结构、权限、安全、迁移或范围不明确的变化必须建单。
采购、创建订单、真实个人或生产数据、权限、安全、并发、数据库迁移、删除数据、发布和其他不可逆操作始终升级为高风险。无论任务采用何种最小门禁,凭据保护、永久禁止付款、个人与生产数据最小化、设备互斥、Agent 禁止 OCR/VLM、控件树与整屏截图禁存、工作区保护、真实测试和人工验收均不可裁剪。
### 明确授权后的执行
- 当前聊天中用户给出的明确指令,或 Gitea 工单中能够归属于有权人工的明确授权,可以作为执行依据,不要求把同一授权重复复制到工单后再次确认。
- 授权必须能识别操作、对象和范围;Agent 自动生成的工单、草稿、摘要或对用户意图的转述不能单独构成人工授权。
- 获得有效授权后,只核对准确目标、授权范围和当前状态等最小必要前提,不得仅因操作不可逆而重复询问或拒绝。
- 授权不自动覆盖相邻对象或后续任务;环境、对象、范围或影响发生实质变化时重新确认。平台自身强制的审批、安全策略或权限限制继续有效。
## 工单与设计证据双门禁
正式实施前先判断是否需要工单,再判断需要什么设计证据。工单不能替代原型确认,原型也不能替代技术方案、安全检查和单元工单。
+12 -10
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: bd2748f2b58daee92eaa1e5ed16ce61fa18f728f
synchronized_at: 2026-09-05T01:00:01Z
wiki_revision: 240e8fb9e365d534bbf0c7b2cd8ff1438bc8857b
synchronized_at: 2026-09-05T09:03:48Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -144,7 +144,7 @@ synchronized_at: 2026-09-05T01:00:01Z
- SYB 商品页的 PDD 采集资格独立于采购处理阶段:只要已关联的 PDD 商品未停用、没有 `pending` / `running` 采集任务且存在可用采集规则,即可创建采集任务;因此已完成采集并进入“可创建采购”的商品也可以重新采集。相同 PDD 商品在批量创建前按商品去重,服务端创建时仍按最新状态复核。
- 批量创建读取管理员选定的当前采购规则并重新执行契约校验,每条任务保存不可变快照;当前规则缺失或无效时明确阻断,不回退到代码常量。设备默认人工指定,也可以留空由符合能力的空闲设备领取。
- 批量创建的价格保护来自 PDD 商品档案而不是 SYB/Shopee 的 TWD 售价:当前采购规则可用 `priceGuard.minRatio`(0.1~1.0)和 `maxRatio`(1.0~3.0)配置比例,缺省仍为 0.2 / 1.5;最低价向下取整、最高价向上取整到人民币分。已确认颜色映射时按该颜色计算,待规格探测时以全部可用颜色的最低/最高价计算,参考价取最高价;没有可用颜色价格时不能创建。
- 地址后缀为 `_cg{purchase_task.id}`。选好规格和数量后,Agent 仍停留在当前规格/下单面板;若收货地址被面板裁切,只允许在唯一、可见且占据主要宽度的纵向滚动容器内有限向下拉动来显示地址,禁止按页面最大滚动区域盲目滑动。Agent 只在唯一地址入口、唯一修改按钮和唯一详细地址输入框均成立时修改;脱敏手机号文字本身不可点击时,仍以该唯一文字节点的中心坐标执行一次精确手势点击,不沿用可能覆盖整个下单面板的可点击祖先。“修改”“保存”和“提交订单”等文字节点即使依赖可点击父节点,也必须保留原始文字节点作为每次重新定位的锚点,父节点只用于验证存在可点击路径。近乎完全重叠的无障碍重复节点按一个目标处理,仍有多个独立目标或页面切换超时则明确失败。按首个 `-` 或 `_` 截取地址主体后追加当前后缀,保存后必须回读完整新地址。修改或回读失败时禁止创建订单,地址全文和控件树不落库。
- 地址后缀为 `_cg{purchase_task.id}`。精确规格、数量、价格和摘要核验通过后,若仍处于已识别的规格面板,Agent 只允许点击唯一、可见、启用、可点击且文案精确命中“确定/确认”别名的规格确认控件;不得点击提交订单、确认购买、付款、支付、地址修改或地址保存控件,也不使用中心手势兜底。点击后必须重新读取并确认已出现订单确认页或地址入口强证据,否则明确失败。进入订单确认页后,若收货地址被面板裁切,只允许在唯一、可见且占据主要宽度的纵向滚动容器内有限向下拉动来显示地址,禁止按页面最大滚动区域盲目滑动。Agent 只在唯一地址入口、唯一修改按钮和唯一详细地址输入框均成立时修改;脱敏手机号文字本身不可点击时,仍以该唯一文字节点的中心坐标执行一次精确手势点击,不沿用可能覆盖整个下单面板的可点击祖先。“修改”“保存”和“提交订单”等文字节点即使依赖可点击父节点,也必须保留原始文字节点作为每次重新定位的锚点,父节点只用于验证存在可点击路径。近乎完全重叠的无障碍重复节点按一个目标处理,仍有多个独立目标或页面切换超时则明确失败。按首个 `-` 或 `_` 截取地址主体后追加当前后缀,保存后必须回读完整新地址。修改或回读失败时禁止创建订单,地址全文和控件树不落库。
- 地址编辑页可以同时存在收货人、手机号和详细地址等多个输入框;Agent 只选择与“详细地址”标签纵向重叠且位于其右侧的唯一输入框,不能用页面输入框总数或顺序猜测。
- 点击创建订单前,Agent 必须先在本地事务保存 `order_submit_started`、不可逆时间、稳定请求 ID 和不含地址全文的最终确认快照,再用同一请求 ID通知服务端;两侧成功后才允许精确点击唯一创建订单按钮一次。
- 进入不可逆边界后,进程重启、断网、点击结果不明或无法取得唯一订单号/下单时间时只允许只读核单并进入 `order_result_unknown`,禁止再次点击;任务与订单正式关联仍以完整 PDD 订单号为准。
@@ -257,13 +257,13 @@ synchronized_at: 2026-09-05T01:00:01Z
## Agent 受控重试采购
- 当前设备只可重试自身最近 30 天内、服务端标记 `retryable=true` 的正式采购失败任务;列表和详情都只能发起单任务重试,不支持多选、批量或自动重试。
- 普通“重试采购”调用既有 `AgentRetry → BatchRetry → Create`:原失败任务及其商品、目标规格、执行规格、价格和执行记录保持不可变;服务端根据当前 SYB、虾皮/PDD 档案、当前采购规则和当前设备创建不同 `purchase_task.id` 的新任务。
- 新 SYB 采购任务继续遵循 #215 的强制当次规格探测,首趟不得直接使用历史任务的规格决策;当前档案、规则、价格、设备或能力门禁不通过时拒绝创建,旧任务保持失败状态。
- 普通“重试采购”调用就地 `/reset`:复用原 `purchase_task.id`,只新增 attempt;新 attempt 使用当前有效采购规则和最新版 Agent 代码,商品、目标规格、执行规格、数量、价格、地址及既有规格决策等业务快照保持不变。
- 就地重试不恢复已经消耗的真机规格探测资格;目标规格存在但对应执行规格为空时必须拒绝,不能进入正式采购阶段。需要按替代商品或已变化业务规格重新决策时,必须走明确的新任务流程。
- 已出现 `order_submit_started` 证据,或存在不可逆时间、订单提交请求、PDD 订单号、下单时间的任务一律拒绝重试,并提示走既有“授权重新采购”流程,防止重复下单。
- `requestId` 按“来源任务 + 请求”幂等;相同请求重放返回同一新任务,不重复创建。
- Android 只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”。确认和成功反馈必须说明旧任务保留、新任务读取当前档案和规则、可能创建待付款订单且系统不会支付。
- 历史兼容的就地 `/reset` 服务端入口不得把“目标规格存在但对应执行规格为空”的任务恢复到正式采购阶段;此类异常快照必须拒绝,并提示创建新任务。Android 普通重试不再调用该入口。
- 替代商品匹配完成后的“继续采购”和 Admin 批量重试继续使用同一新任务语义;取消订单、修改既有订单和支付仍禁止。真机重试可能进入创建待付款订单流程,执行前必须再次取得人工授权。
- `requestId` 按“任务 + 重置请求”幂等;相同请求重放返回同一 task ID 和 attempt,不重复递增。
- Android 只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”。确认和成功反馈必须说明复用原任务、新增一次执行、使用当前规则但业务快照不变、可能创建待付款订单且系统不会支付。
- `/reset` 不得把“目标规格存在但对应执行规格为空”的任务恢复到正式采购阶段;此类异常快照必须拒绝,并提示使用明确的新任务流程。
- 替代商品匹配完成后的“继续采购”继续调用 `AgentRetry → BatchRetry → Create`,按当前档案创建不同 task ID;Admin 批量重试也保持新任务语义。取消订单、修改既有订单和支付仍禁止。真机重试可能进入创建待付款订单流程,执行前必须再次取得人工授权。
## Agent 状态页手动检查任务
@@ -341,7 +341,7 @@ synchronized_at: 2026-09-05T01:00:01Z
## SYB 采购强制当次规格探测(#215)
- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。已有长期映射只作商品档案事实,不直接进入任务执行规格。
- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。首趟只打开一次浏览器商品链接;匹配期间当前设备保留给同一任务,不领取其他采购或采集任务。连续进入第二趟且当前仍有 PDD 商品页或规格面板强证据时复用当前页、不再次打开链接;手动同任务重试,或当前处于 Agent、其他应用及缺少上述强证据时,重新打开任务固化的商品 URL。复用或重开均不严格核验标题、goodsId 或页面指纹,但仍要求 PDD 包名与商品/规格/订单页面结构安全证据。已有长期映射只作商品档案事实,不直接进入任务执行规格。
- 首趟候选与 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和幂等结果哈希关联;第二趟失败不得回到首趟循环探测。备货 `stock/direct_select` 没有 SYB 目标规格,继续使用用户逐字选择的档案规格,不进入本规则。
- 候选和 Provider 结果仅保存颜色、尺码原始标签及结构化决策,不保存控件树、整屏截图、账号、地址、订单或支付数据;付款仍永久禁止。
@@ -429,3 +429,5 @@ synchronized_at: 2026-09-05T01:00:01Z
- 管理员可在设备管理对未停用设备发起“重置设备身份”。此动作不删除设备记录、不改变设备 ID、能力或已绑定的待领取任务;它立即使旧 Token 无效,并只生成一次、有效期 10 分钟的恢复码。
- 恢复码仅显示给发起操作的管理员一次,服务端只保存摘要;不得进入列表、日志、任务记录、Android 持久化或普通接口。手机操作员必须在同一安装实例的 Agent 设置中手动输入。
- 服务端只接受同一 `installId`、未过期且尚未使用的恢复码完成重新注册,成功后签发新 Token 并使恢复码失效。过期、重复使用、installId 不符或停用均明确失败;不能通过清空数据、直接改库或“吊销 Token”恢复原任务归属。
- 自 #223 起,任务创建时把与冻结 SYB 目标对应的已确认商品规格映射保存为不可变的探测指导快照,但仍不得跳过首趟真机探测。探测完成后,每个角色先验证快照映射能否按既有规范化规则唯一对应当次候选,能对应时固化当次候选原文;不能对应时只对该未解决角色执行确定性匹配,仍无结果才调用 AI。已解决角色不重复交给 AI,任一最终值仍必须逐字属于当次候选;历史映射失效、规范化后歧义或角色不符时不得复用。候选完整但无法决策时提示“已采集到当前规格,但未能确定颜色或尺码映射”,不再误报候选不存在。
+28 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.-
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:02Z
wiki_revision: 835494c4a63a1494601658561fde1ab76657be3d
synchronized_at: 2026-09-05T07:17:05Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
@@ -24,6 +24,32 @@ T01 已建立可执行的三端骨架。建议从仓库根目录运行统一脚
- 不得仅为设置编码重复启动一层 PowerShell;嵌套进程会增加启动时间、转义复杂度和错误定位成本。
- 代码发现优先使用项目配置的代码图工具;检索字符串、配置和非代码文件,或图工具不足时使用 `rg`。
### PowerShell 语法与外部命令
- Windows 命令不得默认套用 Bash 语法;复杂正则优先先赋给变量或使用 `rg -e`,避免在多层引号中继续嵌套。
- 多行 Python 或 JSON 正文使用单引号 PowerShell here-string,避免 `$()`、反引号和变量被 PowerShell 提前展开:
```powershell
$script = @'
print("保持原文")
'@
$script | python -
```
- `foreach`、`if` 等语句块应保留在同一个 PowerShell 解析上下文中;需要收集表达式结果时使用数组表达式:
```powershell
$items = @(foreach ($path in $paths) {
if (Test-Path -LiteralPath $path) { Get-Item -LiteralPath $path }
})
```
- `rg` 使用真实目录配合 `-g/--glob`,不要把 Bash 风格通配路径作为目录参数:
```powershell
rg -n -g '*.md' 'sync --check' docs
```
### 文件编码与控制台输出
文件解码和控制台输出是两个边界。读取 UTF-8 文本时,在命令支持的情况下显式指定字面路径与编码:
+15 -13
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: 644b864833730130cf4a2098a01611da0c0ac45d
synchronized_at: 2026-09-05T01:01:14Z
wiki_revision: f3242938de4ac55c47f5c6eebd69184024e6a0ea
synchronized_at: 2026-09-05T09:04:42Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -415,7 +415,7 @@ POST /api/agent/v1/tasks/{taskId}/fail
| 状态 | 含义 | 是否占用 SYB 活动槽 |
|---|---|---|
| `pending` | 待执行 | 是 |
| `spec_probe_pending` | 第一趟探测结束,待服务端固化规格并重新派发 | 是 |
| `spec_probe_pending` | 第一趟探测结束,服务端规格匹配中;当前设备保留同任务连续流程且不得领取其他任务 | 是 |
| `running` | Agent 执行中 | 是 |
| `rehearsal_completed` | 演练安全结束,未改地址、未创建订单 | 否 |
| `order_submit_started` | 不可逆标记已落库,只能核单,禁止再次点击 | 是 |
@@ -529,17 +529,17 @@ Admin 列表与详情由 #35 实现;#67 增加 `shopeeOrderNoSnapshot` 的列
| 方法 | 路径 | 说明 |
|---|---|---|
| `GET` | `/api/agent/v1/purchase-tasks/next` | 返回与设备能力兼容的指定任务或空闲任务 |
| `GET` | `/api/agent/v1/purchase-tasks/next` | 优先返回当前设备的运行任务;存在 `spec_probe_pending` 时返回同一等待任务以阻止其他任务插队,否则返回能力兼容的指定任务或空闲任务 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/claim` | `requestId` 原子领取,并建立设备/可选账号租约 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/start` | 创建不可变 `taskAttemptId` |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/order-submit-started` | 创建订单前先落不可逆标记;演练任务和 `spec_probe` attempt 永远拒绝 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/result` | 请求体携带 `taskAttemptId` 和 `requestId`;幂等提交演练、规格探测、订单或失败结果 |
自 #215 起,新建 SYB 采购任务不再从 PDD 档案创建持久匹配工作项,也不在首次派发前调用外部 AI;部署前已存在的 `purchase_spec_match_work_item` 继续按原状态兼容处理。新任务首次 `start` 固定得到 `phase=spec_probe`,Android 通过既有结果字段回传当次候选;匹配成功后的第二次 `start` 才得到 `phase=purchase` 和服务端固化的精确 PDD 原始标签。Android 不接收 AI 配置或自由决策权限。
自 #215 起,新建 SYB 采购任务不再从 PDD 档案创建持久匹配工作项,也不在首次派发前调用外部 AI;部署前已存在的 `purchase_spec_match_work_item` 继续按原状态兼容处理。新任务首次 `start` 固定得到 `phase=spec_probe`,Android 通过浏览器打开任务链接一次并经既有结果字段回传当次候选;匹配成功后的第二次 `start` 才得到 `phase=purchase` 和服务端固化的精确 PDD 原始标签。连续第二阶段仅在当前 PDD 页面仍有商品页或规格面板强证据时复用首趟页面、不再次打开浏览器链接;手动同任务重试,或当前为 Agent、其他应用及缺少上述强证据时,Android 重新打开任务 `urlSnapshot`。两种路径都不以标题、goodsId 或页面指纹做严格同页校验,仍必须通过 PDD 包名和商品/规格/订单页面结构安全证据。Android 不接收 AI 配置或自由决策权限。
结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。每个新 SYB 采购任务的第一趟只读遍历当次 PDD 规格面板并提交颜色、尺码原始候选,随后释放设备与已知账号租约并进入 `spec_probe_pending`;服务端只以任务冻结的 SYB 目标和当次候选先做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。AI 的颜色和尺码必须逐字属于当次对应候选,否则按无匹配失败。第二趟只会收到服务端固化的精确 PDD 原始标签;Agent 只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。
结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。每个新 SYB 采购任务的第一趟只读遍历当次 PDD 规格面板并提交颜色、尺码原始候选,随后释放数据库租约和已知账号运行守卫并进入 `spec_probe_pending`,但服务端调度与 Agent 必须把当前设备保留给同一采购流程:`next` 返回该等待任务,Agent 只轮询等待,不领取其他采购或采集任务。服务端只以任务冻结的 SYB 目标和当次候选先做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。AI 的颜色和尺码必须逐字属于当次对应候选,否则按无匹配失败。第二趟只会收到服务端固化的精确 PDD 原始标签;Agent 复用首趟仍打开的页面,只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。
任务 payload 的必传布尔字段 `specResolutionAllowed` 是 Android 是否可以提交规格探测的唯一资格事实。新建 `taskType=syb_order` 任务必须由声明 `purchase.spec-probe.v1` 的规则创建,初始 `SpecDecisionRequestID` 为空且 `specSource=unresolved`,首趟返回 `true`;当次决策固化后返回 `false`。`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。历史兼容的就地 `/reset` 保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格;映射不完整时必须拒绝,不能进入正式采购阶段。普通 Agent 重试创建新任务并重新取得一次探测资格。Android 不得根据映射是否非空、错误文字或本地判断扩大资格。
任务 payload 的必传布尔字段 `specResolutionAllowed` 是 Android 是否可以提交规格探测的唯一资格事实。新建 `taskType=syb_order` 任务必须由声明 `purchase.spec-probe.v1` 的规则创建,初始 `SpecDecisionRequestID` 为空且 `specSource=unresolved`,首趟返回 `true`;当次决策固化后返回 `false`。`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。普通 Agent 重试使用就地 `/reset`,保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格;映射不完整时必须拒绝,不能进入正式采购阶段。只有替代商品“继续采购”或 Admin 批量重试创建的新任务才按 #215 重新取得一次探测资格。Android 不得根据映射是否非空、错误文字或本地判断扩大资格。
Android 规格失败使用五个稳定阶段:`PURCHASE_SPEC_TARGET_NOT_VISIBLE`、`PURCHASE_SPEC_TARGET_AMBIGUOUS`、`PURCHASE_SPEC_SAFE_TARGET_MISSING`、`PURCHASE_SPEC_CLICK_FAILED` 和 `PURCHASE_SPEC_SELECTION_UNCONFIRMED`。`PURCHASE_SPEC_CLICK_FAILED` 的 `errorMessage` 只允许稳定子原因 `root_unavailable`、`target_stale`、`no_clickable_ancestor`、`action_click_false` 或 `unknown`;其他阶段的消息不得包含规格原文、坐标、控件树或截图。只有 `PURCHASE_SPEC_TARGET_NOT_VISIBLE && specResolutionAllowed=true` 可以提交规格探测,其他四态直接提交真实失败,服务端原样保留稳定阶段/子原因。旧 Agent 在资格已用尽后再次提交 `spec_probe_completed` 时,服务端以 `PURCHASE_SPEC_REPROBE_REJECTED` fail-closed,释放租约并保留第一次规格决策,不再冒充新的选择根因或再次派发。无匹配、候选不完整、歧义或 Provider 异常同样使任务失败。`order_result_unknown` 只允许管理员或采购员人工解除,永不自动重派。
@@ -617,9 +617,9 @@ Content-Type: application/json
- 响应返回 `taskId`、`attemptNumber`、`status` 和可选的 `replayed`,不返回规则快照、URL、Token、控件树或截图。
- 设备离线、任务非终态、设备忙、规则不可用或同商品存在活动任务时返回明确冲突,不支持离线排队。
## Agent 受控采购重试(#95、#157、#217)
## Agent 受控采购重试(#95、#157、#217、#225)
普通失败任务的“重试采购”和替代商品匹配完成后的“继续采购”统一调用新任务接口:
替代商品匹配完成后的“继续采购”调用新任务接口;普通失败任务的“重试采购”使用下方同任务重置接口:
```http
POST /api/agent/v1/purchase-tasks/{taskId}/retry
@@ -647,11 +647,11 @@ Content-Type: application/json
- 来源任务不得存在 `irreversibleAt`、`orderSubmitRequestId`、PDD 订单号或下单时间。已有任何不可逆证据时返回 `PURCHASE_RETRY_UNSAFE`,提示走“授权重新采购”,不得创建新任务。
- `AgentRetry → BatchRetry → Create` 保留来源失败任务并创建不同 `purchase_task.id` 的新任务;新任务重新读取当前 SYB、虾皮/PDD 档案、当前采购规则、价格保护和设备能力,重新生成地址后缀,不继承来源任务的旧规格决策。
- 新 SYB 任务按 #215 固定从 `spec_probe` 开始。相同 `requestId` 重放返回同一新任务且 `replayed=true`;不同 requestId 再次请求受同一 SYB 商品最新任务和设备并发门禁约束。
- Android 只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”;确认文案和成功反馈必须说明原任务保留、新任务使用当前档案与规则、可能产生待付款订单且系统不会支付。
- 本新任务接口只供替代商品“继续采购”;确认和成功反馈必须说明保留来源任务、按当前替代商品档案创建新任务、可能产生待付款订单且系统不会支付。
- 规则无效、当前档案或价格不合格、设备离线/忙、能力不匹配、任务状态变化或同一 SYB 商品已有更新任务时,服务端明确拒绝且不得部分创建。
- Admin 批量重试继续使用相同的新任务语义;替代商品“继续采购”仍在 AgentRetry 前额外验证替换分项与继续采购资格。
历史兼容的就地重置接口仍保留,但 Android 普通重试不再调用:
普通失败任务的“重试采购”调用同任务重置接口:
```http
POST /api/agent/v1/purchase-tasks/{taskId}/reset
@@ -661,8 +661,8 @@ Content-Type: application/json
{"requestId":"<uuid>"}
```
- `/reset` 只允许安全失败、无不可逆证据且不存在更新任务的原任务;它保留原业务快照并刷新当前规则。
- 目标颜色存在但映射颜色为空,或目标尺码存在但映射尺码为空时,必须返回 `PURCHASE_SPEC_MAPPING_REQUIRED`,不得创建 `purchase` attempt 或下发正式采购 payload。
- `/reset` 只允许当前设备的安全失败任务且不得存在不可逆证据或更新任务;它复用原 `purchase_task.id`、新增 attempt、刷新当前有效规则,并保持商品、目标/执行规格、数量、价格、地址和规格决策等业务快照不变。
- 目标颜色存在但映射颜色为空,或目标尺码存在但映射尺码为空时,必须返回 `PURCHASE_SPEC_MAPPING_REQUIRED`,不得创建 `purchase` attempt 或下发正式采购 payload。相同 `requestId` 重放返回同一 task ID 和 attempt,不重复递增;Android 必须明确提示复用原任务和新的 attempt 序号。
- 两个入口都不执行支付。真机调用可能创建待付款订单,必须先取得人工授权。
## Agent 任务记录范围与同步(#99)
@@ -865,3 +865,5 @@ X-GoAuto-Device-Recovery-Code: <one-time-code>
仅管理员可以对未停用的既有设备发起身份重置。服务端立即使旧 Device Token 无效,并生成 10 分钟内仅能使用一次的恢复码;恢复码只在该管理员操作的响应中返回一次,服务端仅保存不可逆摘要,管理端设备列表、日志、任务接口和 Android 本地持久化均不得保存或返回原文。管理员将恢复码经受控人工渠道输入同一安装实例的 Agent 设置页。
Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口。服务端必须同时校验同一 `installId`、未停用状态、恢复码摘要、未过期和未使用;成功后使用原 `deviceId` 写入新 Token 摘要并返回一次新 Token,清除恢复码摘要和有效期。旧 Token 与恢复码都立即失效,已分配的 pending 采集或采购任务保持原 `deviceId`,不创建替代设备记录。缺少或错误恢复码仍为 `DEVICE_INSTALL_ID_CONFLICT`;过期码为 `DEVICE_RECOVERY_EXPIRED`;停用设备为 `DEVICE_DISABLED`。
自 #223 起,新建 SYB 任务在保持 `mappedColor` / `mappedSize` 为空和首趟 `spec_probe` 不变的同时,把创建时与目标规格对应的 confirmed 商品映射冻结为仅供服务端决策的指导快照。服务端收到当次候选后按角色验证该快照:只有规范化后唯一对应当次候选时才复用,并固化当次候选原文;否则该角色继续执行确定性匹配,仍未解决才把该角色及其封闭候选交给 AI。已解决角色不得重复发送给 AI,最终颜色和尺码仍须逐字属于各自当次候选。任务决策快照通过 `roleSources` 记录每个角色的 `manual_mapping` / `exact_match` / `ai_match` 来源;任务级 `specSource` 使用现有枚举汇总,不新增 Agent 决策权限。
+6 -5
View File
@@ -35,11 +35,12 @@ type CandidateSnapshot struct {
}
type DecisionSnapshot struct {
Source string `json:"source"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
Candidates CandidateSnapshot `json:"candidates"`
Matched struct {
Source string `json:"source"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
Candidates CandidateSnapshot `json:"candidates"`
RoleSources map[string]string `json:"roleSources,omitempty"`
Matched struct {
Color string `json:"color,omitempty"`
Size string `json:"size,omitempty"`
} `json:"matched"`
+1 -1
View File
@@ -18,7 +18,7 @@ func TestParseBuiltAgentAPKWhenAvailable(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if metadata.VersionCode != 53 || metadata.VersionName != "0.9.40" {
if metadata.VersionCode != 54 || metadata.VersionName != "0.9.41" {
t.Fatalf("metadata=%+v", metadata)
}
}
+18 -5
View File
@@ -33,6 +33,19 @@ func (s *Service) Next(ctx context.Context, token string) (*TaskPayload, error)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, internal(err)
}
// A completed probe reserves the device's purchase flow while the server
// resolves the exact specs. Returning that task as a waiting payload keeps
// the Agent from claiming another purchase or collection task and preserves
// the PDD page that the probe just inspected.
var waiting models.PurchaseTask
if err = s.DB.WithContext(ctx).
Where("device_id = ? AND status = ?", d.ID, models.PurchaseTaskStatusSpecProbePending).
Order("created_at, id").
First(&waiting).Error; err == nil {
return s.payload(waiting, nil, false)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, internal(err)
}
now := s.Now()
var candidates []models.PurchaseTask
if err = s.DB.WithContext(ctx).Where("status IN ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?) AND (device_id IS NULL OR device_id = ?)", []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now, d.ID).Order("CASE WHEN device_id IS NULL THEN 1 ELSE 0 END, created_at, id").Limit(100).Find(&candidates).Error; err != nil {
@@ -537,8 +550,8 @@ func (s *Service) resolveProbedSpecs(ctx context.Context, taskID uint64, attempt
decision.NoMatch, decision.Decision = true, snapshot
decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", "没有找到可采购的 PDD 颜色或尺码"
} else {
matched, matchErr := s.matcher().Resolve(ctx, request)
valid := matchErr == nil && (matched.Source == aimatching.SourceExact || matched.Source == aimatching.SourceAI) &&
matched, matchErr := s.resolveProbedMatch(ctx, task, request)
valid := matchErr == nil && (matched.Source == "manual_mapping" || matched.Source == aimatching.SourceExact || matched.Source == aimatching.SourceAI) &&
matchCandidateValid(request.TargetColor, matched.MappedColor, request.Colors) &&
matchCandidateValid(request.TargetSize, matched.MappedSize, request.Sizes)
if valid {
@@ -577,7 +590,7 @@ func purchaseMatchReason(err error) string {
return "AI 规格匹配暂时不可用,请稍后重新创建采购任务"
}
}
return "没有找到可采购的 PDD 颜色或尺码"
return "已采集到当前规格,但未能确定颜色或尺码映射"
}
type probedSpecCandidates struct {
@@ -659,7 +672,7 @@ func (s *Service) withRunning(ctx context.Context, taskID uint64, token string,
func ensureDeviceFree(tx *gorm.DB, deviceID, taskID uint64, now time.Time) error {
var count int64
if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND device_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", taskID, deviceID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted}, []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).Count(&count).Error; e != nil {
if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND device_id = ? AND (status IN ? OR (status = ? AND lease_expires_at > ?))", taskID, deviceID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending}, models.PurchaseTaskStatusPending, now).Count(&count).Error; e != nil {
return internal(e)
}
if count > 0 {
@@ -678,7 +691,7 @@ func ensureAccountFree(tx *gorm.DB, accountID *uint64, taskID uint64, now time.T
return nil
}
var count int64
if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND pdd_account_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", taskID, *accountID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted}, []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).Count(&count).Error; e != nil {
if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND pdd_account_id = ? AND (status IN ? OR (status = ? AND lease_expires_at > ?))", taskID, *accountID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending}, models.PurchaseTaskStatusPending, now).Count(&count).Error; e != nil {
return internal(e)
}
if count > 0 {
@@ -0,0 +1,207 @@
package purchase
import (
"context"
"encoding/json"
"strings"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
)
// resolveProbedMatch keeps the live probe as the source of executable labels.
// Confirmed product mappings may guide the decision only when they still map
// uniquely to a label observed by this attempt.
func (s *Service) resolveProbedMatch(ctx context.Context, task models.PurchaseTask, request aimatching.MatchRequest) (aimatching.MatchResult, error) {
resolvedColor, resolvedSize := "", ""
roleSources := map[string]string{}
mappings := probeGuidanceFromSnapshot(task.SpecDecisionSnapshot)
if candidate, ok := currentMappedCandidate(mappings.color.value, request.Colors); ok {
resolvedColor, roleSources[shopeeproduct.RoleColor] = candidate, mappings.color.source
}
if candidate, ok := currentMappedCandidate(mappings.size.value, request.Sizes); ok {
resolvedSize, roleSources[shopeeproduct.RoleSize] = candidate, mappings.size.source
}
if task.TargetColorSnapshot != "" && resolvedColor == "" {
if exact, ok := aimatching.DeterministicMatch(aimatching.MatchRequest{TargetColor: task.TargetColorSnapshot, Colors: request.Colors}); ok {
resolvedColor, roleSources[shopeeproduct.RoleColor] = exact.MappedColor, aimatching.SourceExact
}
}
if task.TargetSizeSnapshot != "" && resolvedSize == "" {
if exact, ok := aimatching.DeterministicMatch(aimatching.MatchRequest{TargetSize: task.TargetSizeSnapshot, Sizes: request.Sizes}); ok {
resolvedSize, roleSources[shopeeproduct.RoleSize] = exact.MappedSize, aimatching.SourceExact
}
}
missingColor := task.TargetColorSnapshot != "" && resolvedColor == ""
missingSize := task.TargetSizeSnapshot != "" && resolvedSize == ""
var providerDecision aimatching.DecisionSnapshot
if missingColor || missingSize {
remaining := aimatching.MatchRequest{}
if missingColor {
remaining.TargetColor, remaining.Colors = task.TargetColorSnapshot, request.Colors
}
if missingSize {
remaining.TargetSize, remaining.Sizes = task.TargetSizeSnapshot, request.Sizes
}
matched, err := s.matcher().Resolve(ctx, remaining)
if err != nil {
return aimatching.MatchResult{}, err
}
if matched.Source != aimatching.SourceExact && matched.Source != aimatching.SourceAI {
return aimatching.MatchResult{}, &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "规格匹配来源无效"}
}
providerDecision = matched.Decision
if missingColor {
if !matchCandidateValid(remaining.TargetColor, matched.MappedColor, remaining.Colors) {
return aimatching.MatchResult{}, &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "AI 返回的颜色不属于当次候选"}
}
resolvedColor, roleSources[shopeeproduct.RoleColor] = matched.MappedColor, matched.Source
}
if missingSize {
if !matchCandidateValid(remaining.TargetSize, matched.MappedSize, remaining.Sizes) {
return aimatching.MatchResult{}, &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "AI 返回的尺码不属于当次候选"}
}
resolvedSize, roleSources[shopeeproduct.RoleSize] = matched.MappedSize, matched.Source
}
}
source := probeSourceSummary(roleSources)
reason := "当次候选完成确定性匹配"
if containsRoleSource(roleSources, "manual_mapping") || containsRoleSource(roleSources, aimatching.SourceAI) {
reason = "已确认映射经当次候选验证,未解决规格按确定性或 AI 匹配"
}
result := aimatching.RecordedMatch(request, source, resolvedColor, resolvedSize, reason)
result.Decision.RoleSources = roleSources
result.Decision.Provider = providerDecision.Provider
result.Decision.Model = providerDecision.Model
result.Decision.Confidence = providerDecision.Confidence
return result, nil
}
type confirmedProbeMapping struct {
value string
source string
invalid bool
}
type confirmedProbeMappingSet struct {
color confirmedProbeMapping
size confirmedProbeMapping
}
type probeGuidanceSnapshot struct {
ConfirmedMappings map[string]probeGuidanceMapping `json:"confirmedMappings,omitempty"`
}
type probeGuidanceMapping struct {
Value string `json:"value"`
Source string `json:"source"`
}
func newProbeGuidanceSnapshot(raw, targetColor, targetSize string) (string, error) {
mappings := confirmedProbeMappings(raw, targetColor, targetSize)
snapshot := probeGuidanceSnapshot{ConfirmedMappings: map[string]probeGuidanceMapping{}}
if mappings.color.value != "" {
snapshot.ConfirmedMappings[shopeeproduct.RoleColor] = probeGuidanceMapping{Value: mappings.color.value, Source: mappings.color.source}
}
if mappings.size.value != "" {
snapshot.ConfirmedMappings[shopeeproduct.RoleSize] = probeGuidanceMapping{Value: mappings.size.value, Source: mappings.size.source}
}
encoded, err := json.Marshal(snapshot)
return string(encoded), err
}
func probeGuidanceFromSnapshot(raw string) confirmedProbeMappingSet {
var snapshot probeGuidanceSnapshot
if json.Unmarshal([]byte(raw), &snapshot) != nil {
return confirmedProbeMappingSet{}
}
result := confirmedProbeMappingSet{}
if mapping, ok := snapshot.ConfirmedMappings[shopeeproduct.RoleColor]; ok {
result.color = confirmedProbeMapping{value: strings.TrimSpace(mapping.Value), source: mapping.Source}
}
if mapping, ok := snapshot.ConfirmedMappings[shopeeproduct.RoleSize]; ok {
result.size = confirmedProbeMapping{value: strings.TrimSpace(mapping.Value), source: mapping.Source}
}
return result
}
func confirmedProbeMappings(raw, targetColor, targetSize string) confirmedProbeMappingSet {
var specs []shopeeproduct.SpecDimension
if json.Unmarshal([]byte(raw), &specs) != nil {
return confirmedProbeMappingSet{}
}
result := confirmedProbeMappingSet{}
for _, dimension := range specs {
for _, value := range dimension.Values {
if value.Mapping == nil || value.Mapping.Status != shopeeproduct.MappingStatusConfirmed {
continue
}
mapping := confirmedProbeMapping{value: strings.TrimSpace(value.Mapping.PDDValue), source: mapSource(value.Mapping.Source)}
switch {
case dimension.Role == shopeeproduct.RoleColor && value.Name == targetColor:
result.color = mergeConfirmedProbeMapping(result.color, mapping)
case dimension.Role == shopeeproduct.RoleSize && value.Name == targetSize:
result.size = mergeConfirmedProbeMapping(result.size, mapping)
}
}
}
return result
}
func mergeConfirmedProbeMapping(current, incoming confirmedProbeMapping) confirmedProbeMapping {
if current.invalid {
return current
}
if current.value == "" {
return incoming
}
if current.value != incoming.value {
return confirmedProbeMapping{invalid: true}
}
return current
}
func currentMappedCandidate(mapped string, candidates []string) (string, bool) {
mapped = strings.TrimSpace(mapped)
if mapped == "" {
return "", false
}
matches := make([]string, 0, 1)
seen := map[string]bool{}
for _, candidate := range candidates {
candidate = strings.TrimSpace(candidate)
if candidate == "" || seen[candidate] || aimatching.Normalize(candidate) != aimatching.Normalize(mapped) {
continue
}
seen[candidate] = true
matches = append(matches, candidate)
}
if len(matches) != 1 {
return "", false
}
return matches[0], true
}
func probeSourceSummary(roleSources map[string]string) string {
if containsRoleSource(roleSources, "manual_mapping") {
return "manual_mapping"
}
if containsRoleSource(roleSources, aimatching.SourceAI) {
return aimatching.SourceAI
}
return aimatching.SourceExact
}
func containsRoleSource(roleSources map[string]string, target string) bool {
for _, source := range roleSources {
if source == target {
return true
}
}
return false
}
+8 -3
View File
@@ -281,9 +281,14 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
currency = shopee.Currency
}
// Every new SYB purchase must use the candidates observed on the
// current PDD page. Persisted mappings remain product-level history,
// but they cannot skip this task's read-only probe phase.
// current PDD page. Freeze confirmed mappings only as guidance for
// validation against that future probe; they never skip the probe.
mappedColor, mappedSize, specSource = "", "", "unresolved"
guidance, marshalErr := newProbeGuidanceSnapshot(shopee.SpecsJSON, targetColor, targetSize)
if marshalErr != nil {
return internal(marshalErr)
}
decisionSnapshot = guidance
}
candidates, archiveUsable := archiveCandidates(pdd.SpecsJSON, targetColor, targetSize)
matchRequest := aimatching.MatchRequest{TargetColor: targetColor, TargetSize: targetSize, Colors: candidates.Colors, Sizes: candidates.Sizes}
@@ -293,7 +298,7 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas
} else if req.ExecutionMode == models.PurchaseExecutionModeLive {
// The first attempt is always spec_probe for an SYB purchase. The
// exact task-level decision is frozen only from that probe result.
mappedColor, mappedSize, specSource, decisionSnapshot = "", "", "unresolved", "{}"
mappedColor, mappedSize, specSource = "", "", "unresolved"
} else if pdd.Status != "active" {
mappedColor, mappedSize, specSource = "", "", "unresolved"
} else if specSource == "manual_mapping" || specSource == "exact_match" || specSource == "ai_match" {
+178 -5
View File
@@ -31,13 +31,15 @@ type fixture struct {
}
type liveProbeMatcher struct {
result aimatching.MatchResult
err error
calls int
result aimatching.MatchResult
err error
calls int
request aimatching.MatchRequest
}
func (matcher *liveProbeMatcher) Resolve(context.Context, aimatching.MatchRequest) (aimatching.MatchResult, error) {
func (matcher *liveProbeMatcher) Resolve(_ context.Context, request aimatching.MatchRequest) (aimatching.MatchResult, error) {
matcher.calls++
matcher.request = request
return matcher.result, matcher.err
}
@@ -422,10 +424,109 @@ func TestLiveProbeCallsAIOnlyAfterCandidatesAreReturned(t *testing.T) {
}
}
func TestLiveProbeReusesConfirmedColorAndResolvesCurrentSizeWithoutAI(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{
"target_color": "薑黃色",
"target_size": "5XL",
}).Error; err != nil {
t.Fatal(err)
}
specs := []shopeeproduct.SpecDimension{
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "薑黃色", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "黄色", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}}}},
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "5XL", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "XXXXXL", Source: shopeeproduct.MappingSourceAIMatch, Status: shopeeproduct.MappingStatusConfirmed, Reason: "历史档案原文"}}}},
}
raw, _ := json.Marshal(specs)
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", string(raw)).Error; err != nil {
t.Fatal(err)
}
matcher := &liveProbeMatcher{err: errors.New("AI should not be called")}
s := testService(db)
s.Matcher = matcher
task, err := createLive(t, s, f)
if err != nil || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" {
t.Fatalf("live task skipped mandatory probe: %+v err=%v", task, err)
}
if err = db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", `[]`).Error; err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil {
t.Fatal(err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["白色","黄色","黑色"]},{"key":"size","values":["4XL","5XL"]}]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || matcher.calls != 0 || resolved.Status != models.PurchaseTaskStatusPending {
t.Fatalf("confirmed mapping resolution failed: %+v calls=%d err=%v", resolved, matcher.calls, err)
}
if resolved.MappedColor != "黄色" || resolved.MappedSize != "5XL" {
t.Fatalf("task did not freeze current probe labels: %+v", resolved)
}
var saved models.PurchaseTask
if err = db.First(&saved, task.ID).Error; err != nil {
t.Fatal(err)
}
var decision struct {
RoleSources map[string]string `json:"roleSources"`
}
if err = json.Unmarshal([]byte(saved.SpecDecisionSnapshot), &decision); err != nil {
t.Fatal(err)
}
if saved.SpecSource != "manual_mapping" || decision.RoleSources[shopeeproduct.RoleColor] != "manual_mapping" || decision.RoleSources[shopeeproduct.RoleSize] != aimatching.SourceExact {
t.Fatalf("mixed role sources were not audited: task=%+v decision=%+v", saved, decision)
}
}
func TestLiveProbeCallsAIOnlyForRoleWithoutCurrentConfirmedMapping(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "薑黃色").Error; err != nil {
t.Fatal(err)
}
specs := []shopeeproduct.SpecDimension{
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "薑黃色", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "旧黄色", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}}}},
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "XL", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "XL", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}}}},
}
raw, _ := json.Marshal(specs)
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", string(raw)).Error; err != nil {
t.Fatal(err)
}
aiRequest := aimatching.MatchRequest{TargetColor: "薑黃色", Colors: []string{"黄色"}}
matcher := &liveProbeMatcher{result: aimatching.RecordedMatch(aiRequest, aimatching.SourceAI, "黄色", "", "当前颜色候选匹配")}
s := testService(db)
s.Matcher = matcher
task, err := createLive(t, s, f)
if err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil {
t.Fatal(err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黄色"]},{"key":"size","values":["XL"]}]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || matcher.calls != 1 || resolved.MappedColor != "黄色" || resolved.MappedSize != "XL" {
t.Fatalf("partial AI resolution failed: %+v request=%+v err=%v", resolved, matcher.request, err)
}
if matcher.request.TargetColor != "薑黃色" || matcher.request.TargetSize != "" || len(matcher.request.Colors) != 1 || len(matcher.request.Sizes) != 0 {
t.Fatalf("resolved size was unnecessarily sent to AI: %+v", matcher.request)
}
}
func TestLiveProbeRejectsMatcherValueOutsideCurrentCandidates(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
request := aimatching.MatchRequest{TargetColor: "黑色", TargetSize: "XL", Colors: []string{"黑色"}, Sizes: []string{"XL"}}
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙黑").Error; err != nil {
t.Fatal(err)
}
request := aimatching.MatchRequest{TargetColor: "象牙黑", Colors: []string{"黑色"}}
matcher := &liveProbeMatcher{result: aimatching.RecordedMatch(request, aimatching.SourceAI, "候选外颜色", "XL", "无效返回")}
s := testService(db)
s.Matcher = matcher
@@ -449,6 +550,42 @@ func TestLiveProbeRejectsMatcherValueOutsideCurrentCandidates(t *testing.T) {
if err = db.First(&saved, task.ID).Error; err != nil || saved.ErrorCode == nil || *saved.ErrorCode != "PURCHASE_SPEC_NOT_MATCHED" {
t.Fatalf("outside-candidate failure was not persisted: %+v err=%v", saved, err)
}
if saved.ErrorMessage == nil || *saved.ErrorMessage != "已采集到当前规格,但未能确定颜色或尺码映射" {
t.Fatalf("outside-candidate failure reason was not preserved: %+v", saved)
}
}
func TestLiveProbeUsesAccurateMessageWhenCompleteCandidatesCannotBeMatched(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), false)
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "薑黃色").Error; err != nil {
t.Fatal(err)
}
s := testService(db)
s.Matcher = &liveProbeMatcher{err: &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "no match"}}
task, err := createLive(t, s, f)
if err != nil {
t.Fatal(err)
}
if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
t.Fatal(err)
}
first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token)
if err != nil {
t.Fatal(err)
}
probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黄色"]},{"key":"size","values":["XL"]}]}`)}
resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token)
if err != nil || resolved.Status != models.PurchaseTaskStatusFailed {
t.Fatalf("no-match probe did not converge: %+v err=%v", resolved, err)
}
var saved models.PurchaseTask
if err = db.First(&saved, task.ID).Error; err != nil {
t.Fatal(err)
}
if saved.ErrorMessage == nil || *saved.ErrorMessage != "已采集到当前规格,但未能确定颜色或尺码映射" {
t.Fatalf("complete candidates used misleading failure reason: %+v", saved)
}
}
func TestSecondSpecProbeFailsClosedWithoutClearingDecision(t *testing.T) {
@@ -625,6 +762,42 @@ func TestCreateDispatchesProbeInsteadOfExternalArchiveMatching(t *testing.T) {
}
}
func TestNextReturnsAssignedProbeWaitingTaskBeforeOtherWork(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":1200}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil {
t.Fatal(err)
}
service := testService(db)
request := StockCreateRequest{
RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive,
PDDProductID: f.pdd.ID, DeviceID: &f.device.ID, Color: "黑色", Size: "XL",
Quantity: 1, MinUnitPriceCent: 900, MaxUnitPriceCent: 1500,
}
waiting, _, err := service.CreateStock(context.Background(), request)
if err != nil {
t.Fatal(err)
}
request.RequestID = uuid.NewString()
other, _, err := service.CreateStock(context.Background(), request)
if err != nil {
t.Fatal(err)
}
if err = db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", waiting.ID).Updates(map[string]any{
"status": models.PurchaseTaskStatusSpecProbePending, "mapped_color_snapshot": "", "mapped_size_snapshot": "",
}).Error; err != nil {
t.Fatal(err)
}
next, err := service.Next(context.Background(), f.token)
if err != nil || next == nil || next.TaskID != waiting.ID || next.Status != models.PurchaseTaskStatusSpecProbePending {
t.Fatalf("waiting probe task not reserved: next=%+v other=%d err=%v", next, other.ID, err)
}
if _, err = service.Claim(context.Background(), other.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); code(err) != CodeDeviceBusy {
t.Fatalf("other purchase claimed while probe waits: %v", err)
}
}
func TestOrderUnknownIsNotAutomaticallyRedispatched(t *testing.T) {
db := testDB(t)
f := seed(t, db, liveCaps(), true)
+3 -3
View File
@@ -182,9 +182,9 @@ func ensureDeviceIdleForCurrentPage(tx *gorm.DB, deviceID uint64, now time.Time)
return serviceError(CodeDeviceBusy, "设备正在执行任务,请稍后再试")
}
if err := tx.Model(&models.PurchaseTask{}).
Where("device_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", deviceID,
[]string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted},
[]string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).
Where("device_id = ? AND (status IN ? OR (status = ? AND lease_expires_at > ?))", deviceID,
[]string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending},
models.PurchaseTaskStatusPending, now).
Count(&busy).Error; err != nil {
return internalError(err)
}
+3 -3
View File
@@ -246,9 +246,9 @@ func ensureDeviceIdleForReset(tx *gorm.DB, deviceID, taskID uint64, now time.Tim
return serviceError(CodeDeviceBusy, "设备正在执行其他任务")
}
if err := tx.Model(&models.PurchaseTask{}).
Where("device_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", deviceID,
[]string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted},
[]string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).
Where("device_id = ? AND (status IN ? OR (status = ? AND lease_expires_at > ?))", deviceID,
[]string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending},
models.PurchaseTaskStatusPending, now).
Count(&busy).Error; err != nil {
return internalError(err)
}