Compare commits

..
31 changed files with 669 additions and 1070 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "cn.ilapage.goauto.agent"
minSdk = 23
targetSdk = 34
versionCode = 79
versionName = "0.9.66"
versionCode = 72
versionName = "0.9.59"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -434,16 +434,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
} else FreshActionResult.FAILED
}
private var lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.UNKNOWN
override fun purchaseSwipeFailureReason(): PurchaseSwipeFailureReason = lastPurchaseSwipeFailure
override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean {
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.UNKNOWN
val root = rootInActiveWindow ?: run {
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.ROOT_UNAVAILABLE
return false
}
val root = rootInActiveWindow ?: return false
val candidates = mutableListOf<AccessibilityNodeInfo>()
walk(root) { node -> if (node.isVisibleToUser && node.isScrollable) candidates += node }
val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT
@@ -452,12 +444,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
}
val target = (directional.ifEmpty { candidates }).maxByOrNull { candidate ->
Rect().also(candidate::getBoundsInScreen).let { it.width().toLong() * it.height() }
} ?: run {
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.NO_SCROLLABLE
return false
}
return swipeNode(target, direction, durationMs, preferScrollAction = false,
onFailure = { lastPurchaseSwipeFailure = it })
} ?: return false
return swipeNode(target, direction, durationMs, preferScrollAction = false)
}
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
@@ -658,21 +646,16 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
preferScrollAction: Boolean = true,
downStartPercent: Int = 25,
downEndPercent: Int = 75,
onFailure: (PurchaseSwipeFailureReason) -> Unit = {},
): Boolean {
fun failed(reason: PurchaseSwipeFailureReason): Boolean {
onFailure(reason)
return false
}
val bounds = Rect().also(node::getBoundsInScreen)
if (bounds.width() < 2 || bounds.height() < 2) return failed(PurchaseSwipeFailureReason.INVALID_BOUNDS)
if (bounds.width() < 2 || bounds.height() < 2) return false
val scrollAction = if (direction == SwipeDirection.UP || direction == SwipeDirection.LEFT) {
AccessibilityNodeInfo.ACTION_SCROLL_FORWARD
} else {
AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD
}
if (preferScrollAction && node.performAction(scrollAction)) return true
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return failed(PurchaseSwipeFailureReason.GESTURE_UNSUPPORTED)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false
val left = bounds.left + bounds.width() * 25 / 100
val right = bounds.left + bounds.width() * 75 / 100
val top = bounds.top + bounds.height() * 25 / 100
@@ -710,9 +693,8 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
},
null,
)
if (!queued) return failed(PurchaseSwipeFailureReason.GESTURE_REJECTED)
if (!latch.await(1500, TimeUnit.MILLISECONDS)) return failed(PurchaseSwipeFailureReason.GESTURE_TIMEOUT)
return completed.get() || failed(PurchaseSwipeFailureReason.GESTURE_CANCELLED)
if (!queued) return false
return latch.await(1500, TimeUnit.MILLISECONDS) && completed.get()
}
private fun dispatchCenterTap(bounds: Rect): Boolean {
@@ -34,9 +34,6 @@ class PurchaseLiveAutomation(
private val pause: (Long) -> Unit = Thread::sleep,
) {
private var submitAttempted = false
private var savedAddressProof: ShippingAddressProof? = null
private var savedAddressActivity: String? = null
private var structuralSubmitAnchor: SnapshotNode? = null
var lastOrderReadFailure: PurchaseOrderReadFailure? = null
private set
@@ -304,11 +301,10 @@ class PurchaseLiveAutomation(
private fun nodeArea(node: SnapshotNode): Long = node.bounds.width.toLong() * node.bounds.height
fun finalConfirmation(input: PurchaseExecutionInput, address: ShippingAddressProof): FinalConfirmationEvidence {
structuralSubmitAnchor = null
val snapshot = driver.capture()
pageProblem(snapshot)
if (snapshot.packageName != PDD_PACKAGE) fail("PURCHASE_CONFIRMATION_LOST", "最终提交前页面已经变化,禁止创建订单")
if (!hasSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) {
if (!hasFinalSavedAddressEvidence(snapshot, address.expectedAddress, address.suffix)) {
fail("PURCHASE_ADDRESS_UPDATE_FAILED", "收货地址保存后复核失败,未创建订单")
}
val submit = finalSubmitTargets(snapshot)
@@ -322,7 +318,6 @@ class PurchaseLiveAutomation(
if (price !in input.minUnitPriceCent..input.maxUnitPriceCent) fail("PURCHASE_PRICE_OUT_OF_RANGE", "当前商品单价超出允许范围", price)
val quantities = snapshot.nodes.filter { it.visible && it.enabled && it.className?.endsWith("EditText") == true }.mapNotNull { it.label.toLongOrNull() }
if (quantities.singleOrNull() != input.quantity) fail("PURCHASE_QUANTITY_MISMATCH", "创建订单前数量复核失败")
structuralSubmitAnchor = submit.single().takeIf { legacySubmitTargets(snapshot).isEmpty() }
return FinalConfirmationEvidence(input.goodsId, input.mappedColor, input.mappedSize, input.quantity, price, address.suffix, snapshot.activityName.orEmpty())
}
@@ -334,18 +329,6 @@ class PurchaseLiveAutomation(
if (snapshot.packageName != PDD_PACKAGE || targets.size != 1) {
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "最终页面或创建订单按钮已变化,禁止创建订单")
}
if (legacySubmitTargets(snapshot).isEmpty() && structuralSubmitAnchor == null) {
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "下单按钮尚未完成最终复核,禁止创建订单")
}
structuralSubmitAnchor?.let { anchor ->
val fresh = targets.single()
if (fresh.path != anchor.path || fresh.className != anchor.className || fresh.label != anchor.label ||
kotlin.math.abs(fresh.bounds.left - anchor.bounds.left) > 32 ||
kotlin.math.abs(fresh.bounds.top - anchor.bounds.top) > 32 ||
kotlin.math.abs(fresh.bounds.right - anchor.bounds.right) > 32 ||
kotlin.math.abs(fresh.bounds.bottom - anchor.bounds.bottom) > 32
) fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "最终下单按钮已变化,禁止创建订单")
}
submitAttempted = true
when (driver.clickFresh(targets.single())) {
FreshActionResult.SUCCESS -> Unit
@@ -483,9 +466,6 @@ class PurchaseLiveAutomation(
snapshot.packageName == PDD_PACKAGE && snapshot.activityName in PDD_PAYMENT_ACTIVITIES
private fun editAndVerifyAddress(panel: UiSnapshot, suffix: String): ShippingAddressProof {
savedAddressProof = null
savedAddressActivity = null
structuralSubmitAnchor = null
val modify = addressModifyTargets(panel)
if (modify.size != 1) fail("PURCHASE_ADDRESS_EDIT_AMBIGUOUS", "没有找到唯一的地址修改按钮,未创建订单")
click(modify.single(), "修改地址")
@@ -505,27 +485,18 @@ class PurchaseLiveAutomation(
val save = uniqueClickable(stable, stable.nodes.filter { it.visible && it.enabled && it.label == "保存" })
if (save.size != 1) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存按钮不唯一,未创建订单")
click(save.single(), "保存地址")
var savedEvidence = waitForStableAddressEditorExit()
if (!savedAddressPageReady(savedEvidence, expected, suffix)) {
val savedEvidence = waitForStableAddressEditorExit()
if (!hasFinalSavedAddressEvidence(savedEvidence, expected, suffix)) {
if (isPurchaseConfirmationPanel(savedEvidence)) {
savedEvidence = restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
} else {
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
val returned = waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
savedAddressPageReady(it, expected, suffix) || isPurchaseConfirmationPanel(it)
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
hasFinalSavedAddressEvidence(it, expected, suffix)
}
savedEvidence = if (!savedAddressPageReady(returned, expected, suffix)) {
restoreFinalEvidenceInCurrentPanel(returned, expected, suffix)
} else returned
}
}
val proof = ShippingAddressProof(expected, suffix)
savedAddressProof = proof
savedAddressActivity = savedEvidence.activityName
if (finalSubmitTargets(savedEvidence).size != 1) {
fail("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", "地址已保存,但未能识别唯一的下单按钮,未创建订单")
}
return proof
return ShippingAddressProof(expected, suffix)
}
private fun isPurchaseConfirmationPanel(snapshot: UiSnapshot): Boolean {
@@ -542,18 +513,17 @@ class PurchaseLiveAutomation(
initial: UiSnapshot,
expected: String,
suffix: String,
): UiSnapshot {
) {
var snapshot = initial
var previousSignature: String? = null
repeat(ADDRESS_CONFIRMATION_SCROLL_LIMIT) { attempt ->
if (savedAddressPageReady(snapshot, expected, suffix)) return snapshot
if (hasFinalSavedAddressEvidence(snapshot, expected, suffix)) return
if (!isPurchaseConfirmationPanel(snapshot)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后采购面板发生变化,未创建订单")
}
val panels = savedAddressPanelScrollTargets(snapshot)
val panels = purchasePanelScrollTargets(snapshot)
if (panels.size != 1) {
val reason = if (panels.isEmpty()) "未找到可用的规格面板滚动区域" else "找到多个规格面板滚动区域"
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后${reason},未创建订单")
fail("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", "地址保存后没有找到唯一的规格面板滚动区域,未创建订单")
}
val panel = panels.single()
val signature = viewportSignature(snapshot, panel)
@@ -568,10 +538,9 @@ class PurchaseLiveAutomation(
snapshot = driver.capture()
pageProblem(snapshot)
}
if (!savedAddressPageReady(snapshot, expected, suffix)) {
if (!hasFinalSavedAddressEvidence(snapshot, expected, suffix)) {
fail("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后规格面板未找到完整订单确认信息,未创建订单")
}
return snapshot
}
private fun hasSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean {
@@ -588,9 +557,8 @@ class PurchaseLiveAutomation(
return suffixMatches.size == 1
}
private fun savedAddressPageReady(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
hasSavedAddressEvidence(snapshot, expected, suffix) &&
(legacySubmitTargets(snapshot).isNotEmpty() || savedPurchasePanel(snapshot, expected, suffix) != null)
private fun hasFinalSavedAddressEvidence(snapshot: UiSnapshot, expected: String, suffix: String): Boolean =
hasSavedAddressEvidence(snapshot, expected, suffix) && finalSubmitTargets(snapshot).size == 1
private fun waitForStableAddressEditorExit(): UiSnapshot {
var consecutiveExits = 0
@@ -623,20 +591,6 @@ class PurchaseLiveAutomation(
fail(code, message)
}
private fun savedAddressPanelScrollTargets(snapshot: UiSnapshot): List<SnapshotNode> {
val panel = PddScreenParser.parse(snapshot, PurchaseRehearsalExecutor.DEFAULT_COLLECTOR, "", null)
.specPanelContainer ?: return purchasePanelScrollTargets(snapshot)
val screenWidth = snapshot.nodes.maxOfOrNull { it.bounds.right }?.coerceAtLeast(1) ?: 1
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
return snapshot.nodes.filter { node ->
node.visible && node.enabled && node.scrollable &&
node.bounds.width * 100 >= screenWidth * 60 &&
node.bounds.height * 100 >= screenHeight * 20 &&
node.bounds.left >= panel.bounds.left && node.bounds.right <= panel.bounds.right &&
node.bounds.top >= panel.bounds.top && node.bounds.bottom <= panel.bounds.bottom
}.distinctBy { it.path }
}
private fun purchasePanelScrollTargets(snapshot: UiSnapshot): List<SnapshotNode> {
val screenWidth = snapshot.nodes.maxOfOrNull { it.bounds.right }?.coerceAtLeast(1) ?: 1
val screenHeight = snapshot.nodes.maxOfOrNull { it.bounds.bottom }?.coerceAtLeast(1) ?: 1
@@ -724,84 +678,11 @@ class PurchaseLiveAutomation(
pause(500)
}
private fun legacySubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> = uniqueClickable(
snapshot,
snapshot.nodes.filter { node -> node.visible && node.enabled && FINAL_SUBMIT_MARKERS.any { node.label == it || node.label.startsWith(it) } },
)
/** Only the just-saved address flow may use a footer whose display copy is unknown. */
private fun finalSubmitTargets(snapshot: UiSnapshot): List<SnapshotNode> {
val legacy = legacySubmitTargets(snapshot)
if (legacy.isNotEmpty()) return legacy
val proof = savedAddressProof ?: return emptyList()
if (snapshot.activityName != savedAddressActivity) return emptyList()
val panel = savedPurchasePanel(snapshot, proof.expectedAddress, proof.suffix) ?: return emptyList()
val visible = snapshot.nodes.filter { it.visible }
val byPath = visible.associateBy(SnapshotNode::path)
val candidates = visible.filter { node ->
node.path.startsWith("${panel.path}/") && node.enabled && node.clickable && !node.scrollable &&
node.bounds.width >= panel.bounds.width / 5 && node.bounds.height >= 2 &&
node.bounds.height <= panel.bounds.height / 4 &&
node.bounds.bottom <= panel.bounds.bottom &&
node.bounds.bottom >= panel.bounds.bottom - panel.bounds.height / 20 &&
node.bounds.left >= panel.bounds.left && node.bounds.right <= panel.bounds.right
}.filter { node ->
// A spec option at the scroll edge is not a fixed footer action.
var parent = node.parentPath?.let(byPath::get)
var inScroller = false
while (parent != null && parent.path != panel.path) {
if (parent.scrollable) inScroller = true
parent = parent.parentPath?.let(byPath::get)
}
!inScroller && parent?.path == panel.path
}
val independent = candidates.filter { parent ->
candidates.none { it.path.startsWith("${parent.path}/") }
}
// Do not silently choose one of several footer actions by text or price.
if (independent.size != 1) return emptyList()
val button = independent.single()
val content = visible.filter { it.path == button.path || it.path.startsWith("${button.path}/") }
val labels = content.map { it.label.replace(" ", "") }
if (labels.any { label -> STRUCTURAL_ACTION_DENY.any(label::contains) } ||
labels.none { FOOTER_PRICE.containsMatchIn(it) } ||
labels.none { FOOTER_PRICE.replace(it, "").any(Char::isLetter) }
) return emptyList()
// Keep the original text anchor; clickFresh reacquires it and its nearest clickable parent.
return listOfNotNull(content.firstOrNull {
it.enabled && it.label.isNotBlank() && nearestClickableAncestor(it, byPath)?.path == button.path
})
}
private fun savedPurchasePanel(snapshot: UiSnapshot, expected: String, suffix: String): SnapshotNode? {
if (snapshot.packageName != PDD_PACKAGE || snapshot.activityName.isNullOrBlank() ||
isKnownPddPaymentActivity(snapshot) || !hasSavedAddressEvidence(snapshot, expected, suffix)
) return null
val visible = snapshot.nodes.filter { it.visible }
if (visible.any { node -> STRUCTURAL_PAGE_DENY.any(node.label::contains) }) return null
val resolution = resolveAddressEntries(snapshot)
if (resolution.addressCardCount != 1 || resolution.tapTargetCount != 1) return null
val address = resolution.target?.node ?: return null
val quantities = visible.filter { it.enabled && it.className?.endsWith("EditText") == true && (it.label.toLongOrNull() ?: 0) > 0 }
val summaries = visible.filter { it.label.replace(" ", "").startsWith("已选") }
val evidence = quantities.singleOrNull() ?: summaries.singleOrNull() ?: return null
val byPath = visible.associateBy(SnapshotNode::path)
val screenHeight = visible.maxOfOrNull { it.bounds.bottom } ?: return null
val screenWidth = visible.maxOfOrNull { it.bounds.right } ?: return null
var common = address.parentPath?.let(byPath::get)
while (common != null) {
if (evidence.path.startsWith("${common.path}/")) {
// Nearest common ancestor is the whole popup, not its inner spec RecyclerView.
return common.takeIf {
!it.scrollable && it.bounds.width >= screenWidth * 3 / 5 &&
it.bounds.height > 0 && it.bounds.height < screenHeight * 95 / 100
}
}
common = common.parentPath?.let(byPath::get)
}
return null
}
private fun orderConfirmationReady(snapshot: UiSnapshot): Boolean {
if (snapshot.packageName != PDD_PACKAGE) return false
if (snapshot.nodes.any { it.visible && it.enabled && MASKED_PHONE.containsMatchIn(it.label) }) return true
@@ -865,9 +746,6 @@ class PurchaseLiveAutomation(
const val WECHAT_PACKAGE = "com.tencent.mm"
val MASKED_PHONE = Regex("(?<![0-9])[0-9]{3}\\*{4}[0-9]{4}(?![0-9])")
val FINAL_SUBMIT_MARKERS = listOf("提交订单", "现在买,仅", "确认购买")
val FOOTER_PRICE = Regex("(?:[¥¥]\\s*\\d+(?:\\.\\d{1,2})?|\\d+(?:\\.\\d{1,2})?\\s*元)")
val STRUCTURAL_ACTION_DENY = listOf("支付", "付款", "先用后付", "收货", "地址", "修改", "保存", "取消", "返回", "关闭", "客服", "收藏", "订单详情", "查看订单")
val STRUCTURAL_PAGE_DENY = listOf("立即支付", "确认支付", "输入支付密码", "待付款", "待支付", "订单编号", "订单号", "订单详情", "修改收货地址", "选择收货地址")
val PAYMENT_MARKERS = listOf("立即支付", "确认支付", "输入支付密码")
val UNPAID_MARKERS = listOf("待付款", "待支付", "去支付")
val ORDER_DETAIL_ENTRY_MARKERS = setOf("查看订单", "订单详情")
@@ -3,11 +3,6 @@ package cn.ilapage.goauto.agent.automation
import java.net.URI
import java.net.URLDecoder
enum class PurchaseSwipeFailureReason {
UNKNOWN, ROOT_UNAVAILABLE, NO_SCROLLABLE, INVALID_BOUNDS, GESTURE_UNSUPPORTED,
GESTURE_REJECTED, GESTURE_CANCELLED, GESTURE_TIMEOUT,
}
interface PurchaseUiDriver {
fun capture(): UiSnapshot
fun clickFresh(target: SnapshotNode): FreshActionResult
@@ -37,7 +32,6 @@ interface PurchaseUiDriver {
fun specRowSwipeFailureReason(): String = "gestureFailed"
fun inputFresh(target: SnapshotNode, value: String): FreshActionResult
fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean
fun purchaseSwipeFailureReason(): PurchaseSwipeFailureReason = PurchaseSwipeFailureReason.UNKNOWN
fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean
fun pullDownGoodsPage(): Boolean = swipePurchase(SwipeDirection.DOWN, 550)
fun backPurchase(): Boolean
@@ -95,8 +89,6 @@ class PurchaseRehearsalExecutor(
private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") },
) {
private var purchasePanelContext: PurchasePanelContext? = null
private var pageIdentity: Pair<String?, String?> = null to null
private var entryRecoveryReason = "not_checked"
fun execute(input: PurchaseExecutionInput, rule: PurchaseRule, supportedCapabilities: Set<String>): PurchaseExecutionOutcome {
purchasePanelContext = null
@@ -378,9 +370,7 @@ class PurchaseRehearsalExecutor(
screen = currentScreen(input)
}
val beforeSignature = specActionSignature(screen)
val beforeIdentity = pageIdentity
val click = driver.clickFreshDetailed(requireNotNull(target))
panelDiagnostic("entrySource=${screen.specEntrySource ?: "unknown"};entryClick=${click.result};entryReason=${click.reason}")
when (click.result) {
// The parser already narrowed to a single semantic candidate; the
// ambiguity here comes from the live tree matching that target more
@@ -395,22 +385,14 @@ class PurchaseRehearsalExecutor(
var wait = waitForSpecPanel(input, beforeSignature)
wait.failure?.let { return it }
if (wait.opened) return null
val refreshed = currentScreen(input)
refreshed.problem?.let { return failure(it.code, it.message) }
if (refreshed.reviewPageOpen) return leaveUnexpectedReviewPage(input)
if (refreshed.specPanelOpen) return null
val recoveryTarget = recoverableSpecEntry(screen, refreshed, action, beforeIdentity)
if (recoveryTarget == null) {
panelDiagnostic("entryRecovery=rejected;reason=$entryRecoveryReason;pageChanged=${wait.changed};${panelEvidence(refreshed)}")
if (wait.changed) {
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
)
}
val gesture = driver.tapSpecFresh(requireNotNull(recoveryTarget))
panelDiagnostic("entryRecovery=attempted;gesture=$gesture;pageChanged=${wait.changed}")
when (gesture) {
when (driver.tapSpecFresh(requireNotNull(target))) {
FreshActionResult.AMBIGUOUS -> return failure(
SPEC_ENTRY_TARGET_AMBIGUOUS,
"规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
@@ -424,7 +406,7 @@ class PurchaseRehearsalExecutor(
wait = waitForSpecPanel(input, specActionSignature(wait.screen))
wait.failure?.let { return it }
if (wait.opened) return null
if (wait.changed && recoverableSpecEntry(screen, wait.screen, action, beforeIdentity) == null) {
if (wait.changed) {
return failure(
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
@@ -436,48 +418,6 @@ class PurchaseRehearsalExecutor(
)
}
// Whole-page animations are not proof of navigation. Recover only a freshly
// parsed, unchanged semantic product entry; never reuse the old node/coordinates.
private fun recoverableSpecEntry(
before: ParsedPddScreen,
after: ParsedPddScreen,
action: PurchaseAction,
identity: Pair<String?, String?>,
): SnapshotNode? {
entryRecoveryReason = when {
identity.first == null || identity.second == null -> "identity_missing"
identity != pageIdentity -> "identity_changed"
!after.isPddPackage || !after.pageEvidenceMatched -> "product_context_missing"
after.problem != null || after.reviewPageOpen -> "page_problem"
after.specPanelOpen -> "panel_open"
after.hasCloseControl -> "close_control"
after.hasPaymentArea -> "payment_area"
after.hasOrderSubmitAction -> "order_action"
after.hasQuantityControls -> "quantity_controls"
after.hasSelectionSummary -> "selection_summary"
before.summary.title.isNullOrBlank() -> "title_missing"
before.summary.title != after.summary.title -> "title_changed"
after.specEntry == null -> "entry_missing"
before.specEntrySource == null -> "entry_source_missing"
before.specEntrySource != after.specEntrySource -> "entry_source_changed"
before.specEntry?.label != after.specEntry.label -> "entry_label_changed"
after.explicitSpecEntryCount > 1 || after.nestedSpecEntryCount > 1 -> "entry_ambiguous"
else -> "eligible"
}
if (entryRecoveryReason != "eligible") return null
val anchor = after.specEntry ?: return null
if (action.textAliases?.let { specEntryMatchesAliases(after, anchor, it) } == false) {
entryRecoveryReason = "rule_alias_mismatch"
return null
}
val target = after.specEntryClickTarget ?: anchor
if (!target.visible || !target.enabled) {
entryRecoveryReason = "entry_unavailable"
return null
}
return target
}
private data class SpecPanelWait(
val screen: ParsedPddScreen,
val opened: Boolean,
@@ -603,7 +543,6 @@ class PurchaseRehearsalExecutor(
): PurchaseExecutionOutcome? {
val initial = currentScreen(input)
initial.problem?.let { return failure(it.code, it.message) }
unavailableExactSpec(initial, dimension, target)?.let { return it }
if (rememberExactSelection(initial, dimension, target, selectionProofs)) return null
val lookup = locateExactSpec(input, dimension, target)
@@ -678,9 +617,7 @@ class PurchaseRehearsalExecutor(
private fun isExactSpecSelected(screen: ParsedPddScreen, dimension: String, target: String): Boolean {
val candidates = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }
if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) {
return dimension == "color" && SelectedColorCard.confirms(target, candidates, screen.sourceNodes)
}
if (candidates.any { it.text != target && (it.node.selected || it.node.checked) }) return false
if (candidates.any { it.text == target && (it.node.selected || it.node.checked) }) return true
if (screen.specPanelOpen && fullSummaryTargetMatches(screen.selectedSummary, target)) return true
return summarySelectionMatches(screen.selectedSummary, dimension, target, candidates)
@@ -854,18 +791,6 @@ class PurchaseRehearsalExecutor(
private data class SpecLookup(val node: SnapshotNode? = null, val failure: PurchaseExecutionOutcome? = null)
private fun unavailableExactSpec(screen: ParsedPddScreen, dimension: String, target: String): PurchaseExecutionOutcome? {
if (!screen.specPanelOpen) return null
val exact = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }.filter { it.text == target }
return if (exact.size == 1 && !exact.single().available) unavailableSpec(dimension) else null
}
private fun unavailableSpec(dimension: String): PurchaseExecutionOutcome {
val role = if (dimension == "color") "颜色" else "尺码"
panelDiagnostic("specLookup=unavailable;dimension=$dimension;reason=exact_target_unavailable")
return failure(SPEC_SAFE_TARGET_MISSING, "目标${role}已售罄或当前不可选,未创建订单")
}
/**
* Searches only parsed, selectable values in the already-open spec panel.
* A short downward pass first restores the top when a previous action left
@@ -902,7 +827,7 @@ class PurchaseRehearsalExecutor(
}
if (exact.size == 1 && exact.single().available) return Inspection(SpecLookup(node = exact.single().node), "", screen.specPanelContainer)
if (exact.size == 1) {
return Inspection(SpecLookup(failure = unavailableSpec(dimension)), "", screen.specPanelContainer)
return Inspection(SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")), "", screen.specPanelContainer)
}
val signature = screen.dimensions.joinToString("|") { item ->
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
@@ -967,7 +892,7 @@ class PurchaseRehearsalExecutor(
val lookup = when {
exact.size > 1 -> SpecLookup(failure = failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件"))
exact.size == 1 && exact.single().available -> SpecLookup(node = exact.single().node)
exact.size == 1 -> SpecLookup(failure = unavailableSpec(dimension))
exact.size == 1 -> SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击"))
else -> null
}
val rows = specValueRows(values)
@@ -1169,17 +1094,16 @@ class PurchaseRehearsalExecutor(
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
if (action.waitAfterMs > 0) pause(action.waitAfterMs)
action.swipeAfter?.let { swipe ->
// Legacy rules prescribe a blind scroll immediately after opening.
// Opening has already been verified; probe/select owns any necessary
// bounded scrolling. Do not let an unnecessary gesture block either
// phase, or move already-visible exact specs out of the viewport.
if (action.type == PurchaseActionType.OPEN_SPEC_PANEL) {
panelDiagnostic("postSwipe=skipped;action=${action.type.wireName};reason=spec_panel_on_demand;panel=${currentScreen(input).specPanelType.name}")
return null
}
// The stock purchase rule asks to reveal additional selector rows after
// opening the sheet. A fully-evidenced non-scrollable selector has no
// scroll target, and treating that absence as an action failure blocks
// an otherwise safe exact-spec flow. Keep all other configured swipes
// mandatory; this exception is limited to that confirmed panel state.
if (action.type == PurchaseActionType.OPEN_SPEC_PANEL &&
currentScreen(input).specPanelType == SpecPanelType.NON_SCROLLABLE_CONFIRMATION
) return null
repeat(swipe.count) { index ->
if (!driver.swipePurchase(swipe.direction, swipe.durationMs)) {
panelDiagnostic("postSwipe=failed;action=${action.type.wireName};direction=${swipe.direction.name};swipeIndex=${index + 1};reason=${driver.purchaseSwipeFailureReason().name.lowercase()}")
return failure("RULE_ACTION_FAILED", "规则要求的有限滑动失败")
}
if (index < swipe.count - 1 && swipe.intervalMs > 0) pause(swipe.intervalMs)
@@ -1203,7 +1127,6 @@ class PurchaseRehearsalExecutor(
private fun currentScreen(input: PurchaseExecutionInput): ParsedPddScreen {
val snapshot = driver.capture()
pageIdentity = snapshot.packageName to snapshot.activityName
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null, purchasePanelContext)
purchasePanelContext = if (screen.isPddPackage && screen.problem == null && screen.specPanelOpen) {
screen.specPanelContainer?.let {
@@ -1,43 +0,0 @@
package cn.ilapage.goauto.agent.automation
/** A narrow exception for one selected text option duplicated by its card/image. */
internal object SelectedColorCard {
fun confirms(target: String, values: List<VisibleSpecValue>, source: List<SnapshotNode>): Boolean {
val exact = values.filter { it.text == target }.singleOrNull() ?: return false
if (!exact.node.selected && !exact.node.checked) return false
val byPath = source.associateBy { it.path }
fun contains(outer: NodeBounds, inner: NodeBounds) =
inner.width > 0 && inner.height > 0 && inner.left >= outer.left && inner.top >= outer.top &&
inner.right <= outer.right && inner.bottom <= outer.bottom
fun cardFor(node: SnapshotNode): SnapshotNode? {
var current = node
val seen = mutableSetOf<String>()
while (seen.add(current.path)) {
if (current.scrollable || !current.visible || !current.enabled) return null
val parent = current.parentPath?.let(byPath::get) ?: return null
if (parent.scrollable) {
return current.takeIf {
it.clickable && it.className == "android.view.ViewGroup" &&
contains(parent.bounds, it.bounds) && contains(it.bounds, node.bounds)
}
}
current = parent
}
return null
}
val card = cardFor(exact.node) ?: return false
// The parser may have deduplicated identical labels across cards. Check
// the source as well before treating a selected duplicate as harmless.
if (source.any { node ->
node.visible && node.enabled && node.clickable &&
SpecValueNormalizer.normalizeColor(node.label) == target &&
cardFor(node)?.let { it.path != card.path } == true
}) return false
return values.filter { it.text != target && (it.node.selected || it.node.checked) }.all { other ->
val node = other.node
// Never merge another textual option, adjacent card, or scroll container.
(node.path == card.path || node.className == "android.widget.ImageView") &&
cardFor(node)?.path == card.path && contains(card.bounds, node.bounds)
}
}
}
@@ -478,8 +478,6 @@ class AgentForegroundService : Service() {
if (accessibility == null) {
PurchaseExecutionOutcome("failed", "ACCESSIBILITY_NOT_READY", "GoAuto 无障碍服务未开启")
} else {
val diagnosticDeviceId = runCatching { identityStore.credentials()?.deviceId ?: 0L }.getOrDefault(0L)
val diagnosticAttempt = task.taskAttemptId.takeIf { it.matches(Regex("^[a-zA-Z0-9-]{1,80}$")) } ?: "invalid"
PurchaseRehearsalExecutor(
driver = accessibility,
openLink = { PddLinkLauncher(this).open(it, preferDirect = true) },
@@ -488,9 +486,7 @@ class AgentForegroundService : Service() {
lastStep.set(step)
purchaseStore.updateStep(task.taskId, task.taskAttemptId, step)
},
panelDiagnostic = { evidence ->
Log.i("GoAutoPurchasePanel", "task=${task.taskId};attempt=$diagnosticAttempt;device=$diagnosticDeviceId;rule=$snapshotHash;$evidence")
},
panelDiagnostic = { evidence -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") },
beforeOrderSubmit = { evidence ->
val boundaryRequestId = UUID.randomUUID().toString()
val finalEvidence = JSONObject()
@@ -18,161 +18,6 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseLiveAutomationTest {
@Test
fun `saved address recognizes split dynamic footer outside spec scroller without a swipe`() {
for (label in listOf("复购价,", "专享优惠,")) {
val driver = LiveDriver(savedStructuredFooter = label)
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
automation.submitOrderOnce()
assertEquals(1, driver.submitClicks)
assertEquals(0, driver.scopedSwipes)
assertEquals(0, driver.backCount)
assertTrue(driver.clicked.contains(label))
assertEquals("PURCHASE_SUBMIT_ALREADY_ATTEMPTED",
(runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException).code)
}
}
@Test
fun `one back from address transition can recognize dynamic footer`() {
val driver = LiveDriver(savedStructuredFooter = "复购价,", savedTransitionWithoutLegacyContext = true)
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
assertEquals(1, driver.backCount)
assertEquals(0, driver.scopedSwipes)
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address with missing ambiguous price only or payment footer reports button failure without scrolling`() {
val mutations: List<(UiSnapshot) -> UiSnapshot> = listOf(
{ s -> s.copy(nodes = s.nodes.filterNot { it.path.startsWith("root/popup/footer") }) },
{ s -> s.copy(nodes = s.nodes + s.nodes.filter { it.path.startsWith("root/popup/footer") }.map {
it.copy(path = it.path.replace("footer", "second-footer"), parentPath = it.parentPath?.replace("footer", "second-footer"))
}) },
{ s -> s.copy(nodes = s.nodes.filterNot { it.path == "root/popup/footer/label" }) },
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup/footer") it.copy(clickable = false) else it }) },
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup/footer/label") it.copy(text = "去付款") else it }) },
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup/footer") it.copy(enabled = false) else it }) },
)
for (mutation in mutations) {
val driver = LiveDriver(savedStructuredFooter = "复购价,")
driver.structuredTransform = mutation
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS", error.code)
assertEquals(0, driver.scopedSwipes)
assertEquals(0, driver.backCount)
assertEquals(0, driver.submitClicks)
}
}
@Test
fun `legacy submit labels retain priority over structure`() {
for (label in listOf("提交订单", "现在买,仅20元", "确认购买")) {
val driver = LiveDriver(savedStructuredFooter = label)
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
automation.submitOrderOnce()
assertEquals(1, driver.submitClicks)
assertEquals(0, driver.scopedSwipes)
}
}
@Test
fun `structural footer cannot bypass final spec quantity price or address verification`() {
val cases = listOf(
input().copy(mappedColor = "白色") to "PURCHASE_SPEC_NOT_MATCHED",
input().copy(quantity = 3) to "PURCHASE_QUANTITY_MISMATCH",
input().copy(maxUnitPriceCent = 100) to "PURCHASE_PRICE_OUT_OF_RANGE",
)
for ((request, code) in cases) {
val driver = LiveDriver(savedStructuredFooter = "复购价,")
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
val error = runCatching { automation.finalConfirmation(request.copy(addressSuffix = "_cg81"), proof) }
.exceptionOrNull() as PurchaseLiveException
assertEquals(code, error.code)
assertEquals(0, driver.submitClicks)
}
val driver = LiveDriver(savedStructuredFooter = "复购价,")
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
driver.structuredTransform = { s -> s.copy(nodes = s.nodes.filterNot { it.path.endsWith("/address/detail") }) }
assertEquals("PURCHASE_ADDRESS_UPDATE_FAILED",
(runCatching { automation.finalConfirmation(input(), proof) }.exceptionOrNull() as PurchaseLiveException).code)
assertEquals(0, driver.submitClicks)
}
@Test
fun `structural footer needs this execution saved address and unchanged page at submission`() {
val driver = LiveDriver(savedStructuredFooter = "复购价,")
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
(runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException).code)
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
(runCatching { PurchaseLiveAutomation(driver, pause = {}).finalConfirmation(input(), proof) }
.exceptionOrNull() as PurchaseLiveException).code)
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
driver.structuredTransform = { s -> s.copy(nodes = s.nodes.map {
if (it.path.endsWith("footer/label")) it.copy(text = "另一操作") else it
}) }
assertEquals("PURCHASE_SUBMIT_TARGET_AMBIGUOUS",
(runCatching { automation.submitOrderOnce() }.exceptionOrNull() as PurchaseLiveException).code)
assertEquals(0, driver.submitClicks)
}
@Test
fun `footer duplicate text nodes remain one action and external overlays are not candidates`() {
val driver = LiveDriver(savedStructuredFooter = "复购价,")
driver.structuredTransform = { s -> s.copy(nodes = s.nodes + listOf(
s.nodes.first { it.path == "root/popup/footer/label" }.copy(
path = "root/popup/footer/duplicate-label", bounds = NodeBounds(760, 2203, 950, 2262)),
s.nodes.first { it.path == "root/popup/footer" }.copy(path = "root/overlay", parentPath = "root", text = "悬浮按钮"),
)) }
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), proof)
automation.submitOrderOnce()
assertEquals(1, driver.submitClicks)
assertFalse(driver.clicked.contains("悬浮按钮"))
}
@Test
fun `failed revalidation clears structural submit authorization`() {
val driver = LiveDriver(savedStructuredFooter = "复购价,")
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
automation.finalConfirmation(input(), proof)
assertTrue(runCatching { automation.finalConfirmation(input().copy(quantity = 3), proof) }.isFailure)
assertTrue(runCatching { automation.submitOrderOnce() }.isFailure)
assertEquals(0, driver.submitClicks)
}
@Test
fun `address alone full page ancestor and order or payment pages cannot authorize structural footer`() {
val mutations: List<(UiSnapshot) -> UiSnapshot> = listOf(
{ s -> s.copy(nodes = s.nodes.filterNot { it.path.endsWith("/quantity") || it.path.endsWith("/selected") }) },
{ s -> s.copy(nodes = s.nodes.map { if (it.path == "root/popup") it.copy(bounds = NodeBounds(0, 0, 1080, 2354)) else it }) },
{ s -> s.copy(activityName = "com.xunmeng.pinduoduo.app_pay.core.PayActivity") },
{ s -> s.copy(packageName = "example.other") },
{ s -> s.copy(nodes = s.nodes.map { if (it.path.endsWith("/selected")) it.copy(text = "订单详情") else it }) },
)
for (mutation in mutations) {
val driver = LiveDriver(savedStructuredFooter = "复购价,")
val automation = PurchaseLiveAutomation(driver, pause = {})
val proof = automation.updateShippingAddress("_cg81")
driver.structuredTransform = mutation
assertTrue(runCatching { automation.finalConfirmation(input(), proof) }.isFailure)
assertEquals(0, driver.submitClicks)
}
}
@Test
fun `verified spec panel advances through one exact confirm target`() {
val driver = SpecConfirmationDriver()
@@ -296,72 +141,6 @@ class PurchaseLiveAutomationTest {
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address back into short spec panel restores without aspect ratio restriction`() {
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true,
savedPanelBounds = NodeBounds(0, 400, 1080, 1325))
val automation = PurchaseLiveAutomation(driver, pause = {})
val address = automation.updateShippingAddress("_cg81")
assertEquals("_cg81", automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), address).addressSuffix)
assertEquals(1, driver.backCount)
assertEquals(1, driver.scopedSwipes)
assertEquals(0, driver.submitClicks)
}
@Test
fun `short saved spec panel still stops when unchanged`() {
val driver = LiveDriver(saveReturnsToSpecPanel = true, savedSpecPanelRecoveryStuck = true,
savedPanelBounds = NodeBounds(0, 400, 1080, 1325))
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
assertEquals(0, driver.backCount)
assertTrue(driver.scopedSwipes in 1..5)
assertEquals(0, driver.submitClicks)
}
@Test
fun `missing and duplicate saved panel scroll regions fail distinctly without gestures`() {
for (count in listOf(0, 2)) {
val driver = LiveDriver(saveReturnsToSpecPanel = true, savedPanelScrollCount = count)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_ENTRY_AMBIGUOUS", error.code)
assertTrue(error.message.orEmpty().contains(if (count == 0) "未找到可用" else "找到多个"))
assertEquals(0, driver.scopedSwipes)
assertEquals(0, driver.submitClicks)
}
}
@Test
fun `saved address back into spec panel restores final evidence with one back`() {
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true)
val automation = PurchaseLiveAutomation(driver, pause = {})
val address = automation.updateShippingAddress("_cg81")
val final = automation.finalConfirmation(input().copy(addressSuffix = "_cg81"), address)
assertEquals("_cg81", final.addressSuffix)
assertEquals(1, driver.backCount)
assertEquals(1, driver.scopedSwipes)
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address back into stuck spec panel fails without another back or submit`() {
val driver = LiveDriver(savedTransitionWithoutLegacyContext = true, backReturnsToSpecPanel = true,
savedSpecPanelRecoveryStuck = true)
val error = runCatching { PurchaseLiveAutomation(driver, pause = {}).updateShippingAddress("_cg81") }
.exceptionOrNull() as PurchaseLiveException
assertEquals("PURCHASE_ADDRESS_SAVE_TIMEOUT", error.code)
assertEquals(1, driver.backCount)
assertTrue(driver.scopedSwipes in 1..5)
assertEquals(0, driver.submitClicks)
}
@Test
fun `saved address returning to spec panel is recovered without pressing back`() {
val driver = LiveDriver(saveReturnsToSpecPanel = true)
@@ -866,11 +645,7 @@ class PurchaseLiveAutomationTest {
private val savedTransitionWithoutLegacyContext: Boolean = false,
private val savedTransitionHidesSuffix: Boolean = false,
private val saveReturnsToSpecPanel: Boolean = false,
private val backReturnsToSpecPanel: Boolean = false,
private val savedPanelBounds: NodeBounds = NodeBounds(0, 400, 1080, 2100),
private val savedPanelScrollCount: Int = 1,
private val savedSpecPanelRecoveryStuck: Boolean = false,
private val savedStructuredFooter: String? = null,
private val duplicatePhoneNodesSameCard: Boolean = false,
private val duplicateSemanticAddressCards: Boolean = false,
private val duplicateAddressCards: Boolean = false,
@@ -901,33 +676,16 @@ class PurchaseLiveAutomationTest {
var orderDetailEntryClicks = 0
var postSubmitCaptureCount = 0
val currentPage: String get() = page
var structuredTransform: (UiSnapshot) -> UiSnapshot = { it }
override fun capture(): UiSnapshot {
if (submitClicks > 0) {
postSubmitCaptureCount++
if (pendingPostSubmitPages.isNotEmpty()) page = pendingPostSubmitPages.removeAt(0)
}
return currentSnapshot().let { if (page == "structured") structuredTransform(it) else it }
return currentSnapshot()
}
private fun currentSnapshot(): UiSnapshot = when (page) {
"structured" -> snapshot(listOf(
node("root", "", bounds = NodeBounds(0, 0, 1080, 2354)),
node("root/popup", "", clickable = true, parentPath = "root", bounds = NodeBounds(0, 366, 1080, 2306)),
node("root/popup/address", "", clickable = true, parentPath = "root/popup", bounds = NodeBounds(0, 400, 1080, 530)),
node("root/popup/address/phone", "138****5678", parentPath = "root/popup/address", bounds = NodeBounds(30, 400, 400, 450)),
node("root/popup/address/detail", address, parentPath = "root/popup/address", bounds = NodeBounds(30, 456, 1000, 505)),
node("root/popup/price", "¥20.00", parentPath = "root/popup", bounds = NodeBounds(396, 620, 700, 690)),
node("root/popup/selected", "已选 黑色 XL", parentPath = "root/popup", bounds = NodeBounds(396, 731, 1053, 845)),
node("root/popup/quantity", "2", className = "android.widget.EditText", parentPath = "root/popup", bounds = NodeBounds(462, 881, 540, 959)),
node("root/popup/specs", "", scrollable = true, className = "androidx.recyclerview.widget.RecyclerView", parentPath = "root/popup", bounds = NodeBounds(0, 1132, 1080, 2057)),
node("root/popup/specs/color", "颜色分类", parentPath = "root/popup/specs", bounds = NodeBounds(36, 1157, 287, 1218)),
node("root/popup/payment-method", "微信支付", clickable = true, parentPath = "root/popup", bounds = NodeBounds(0, 2057, 1080, 2156)),
node("root/popup/footer", "", clickable = true, className = "android.widget.FrameLayout", parentPath = "root/popup", bounds = NodeBounds(0, 2159, 1080, 2306)),
node("root/popup/footer/label", requireNotNull(savedStructuredFooter), parentPath = "root/popup/footer", bounds = NodeBounds(353, 2203, 557, 2262)),
node("root/popup/footer/amount", "20.00元", parentPath = "root/popup/footer", bounds = NodeBounds(557, 2203, 727, 2262)),
))
"panel" -> snapshot(listOf(
node("title", "收货地址"),
node("modify-parent", "", clickable = true),
@@ -950,7 +708,7 @@ class PurchaseLiveAutomationTest {
))
"post-save-spec" -> snapshot(listOf(
node("root", "", bounds = NodeBounds(0, 0, 1080, 2200)),
node("panel", "", scrollable = true, bounds = savedPanelBounds),
node("panel", "", scrollable = true, bounds = NodeBounds(0, 400, 1080, 2100)),
node("panel-title", "确认款式", bounds = NodeBounds(20, 396, 300, 430)),
node("panel/price", "¥20.00", parentPath = "panel", bounds = NodeBounds(20, 460, 300, 520)),
node("panel/selected", "已选 黑色 XL", parentPath = "panel", bounds = NodeBounds(20, 540, 700, 600)),
@@ -962,8 +720,7 @@ class PurchaseLiveAutomationTest {
node("panel/confirm", "确定", clickable = true, parentPath = "panel", bounds = NodeBounds(20, 1200, 500, 1280)),
node("submit-parent", "", clickable = true, bounds = NodeBounds(20, 1900, 1000, 2100)),
node("submit", "提交订单", parentPath = "submit-parent", bounds = NodeBounds(520, 1940, 980, 2040)),
).map { if (it.path == "panel" && savedPanelScrollCount == 0) it.copy(enabled = false) else it } +
if (savedPanelScrollCount > 1) listOf(node("panel/duplicate", "", scrollable = true, bounds = savedPanelBounds, parentPath = "panel")) else emptyList())
))
"order" -> snapshot(listOf(node("status", "待付款"), node("order", "订单号:PDD-202608210001"), node("time", "下单时间:2026-08-21 10:30:00"), node("pay", "立即支付", clickable = true)))
"order-folded" -> snapshot(listOf(node("status", "待付款"), node("pay", "立即支付", clickable = true)))
"order-folded-payment-activity" -> UiSnapshot(PDD, "com.xunmeng.pinduoduo.app_pay.core.PayActivity", listOf(
@@ -1042,11 +799,6 @@ class PurchaseLiveAutomationTest {
override fun clickFresh(target: SnapshotNode): FreshActionResult {
clicked += target.label
if (target.path == "root/popup/footer/label") {
submitClicks++
page = "order"
return FreshActionResult.SUCCESS
}
when (target.label) {
"138****5678" -> page = "panel"
"修改" -> page = "edit"
@@ -1054,7 +806,6 @@ class PurchaseLiveAutomationTest {
page = when {
saveReturnsToSpecPanel -> "post-save-spec"
savedTransitionWithoutLegacyContext -> "saved-transition"
savedStructuredFooter != null -> "structured"
else -> "panel"
}
}
@@ -1112,7 +863,6 @@ class PurchaseLiveAutomationTest {
page = when (page) {
"chooser" -> "payment"
"payment" -> if (orderEvidenceBelowFold) "order-folded" else "order"
"saved-transition" -> if (savedStructuredFooter != null) "structured" else if (backReturnsToSpecPanel) "post-save-spec" else "confirmation"
else -> "confirmation"
}
return true
@@ -9,7 +9,6 @@ import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput
import cn.ilapage.goauto.agent.automation.PurchaseRehearsalExecutor
import cn.ilapage.goauto.agent.automation.PurchaseRuleParser
import cn.ilapage.goauto.agent.automation.PurchaseSpecGesturePolicy
import cn.ilapage.goauto.agent.automation.PurchaseSwipeFailureReason
import cn.ilapage.goauto.agent.automation.PurchaseUiDriver
import cn.ilapage.goauto.agent.automation.RuleValidationException
import cn.ilapage.goauto.agent.automation.SnapshotNode
@@ -99,7 +98,7 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `rule parameters drive aliases and waits while deprecated panel swipes are ignored`() {
fun `rule parameters drive aliases waits and bounded swipes without dangerous clicks`() {
val driver = FakePurchaseDriver()
val pauses = mutableListOf<Long>()
var openCount = 0
@@ -113,7 +112,7 @@ class PurchaseRehearsalExecutorTest {
assertEquals("rehearsal_completed", outcome.resultType)
assertEquals(2_000L, outcome.actualUnitPriceCent)
assertEquals(0, openCount)
assertEquals(0, driver.swipeCount)
assertEquals(2, driver.swipeCount)
assertEquals(2L, driver.quantity)
assertTrue(driver.clicked.containsAll(listOf("选择规格", "黑色", "XL")))
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") })
@@ -765,7 +764,7 @@ class PurchaseRehearsalExecutorTest {
}
@Test
fun `open spec panel still skips legacy follow-up swipe for confirmed non-scrollable panel`() {
fun `open spec panel skips required follow-up swipe only for confirmed non-scrollable panel`() {
val driver = FakePurchaseDriver(nonScrollablePanel = true, purchaseSwipeSucceeds = false)
val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
@@ -774,86 +773,6 @@ class PurchaseRehearsalExecutorTest {
assertEquals(0, driver.swipeCount)
}
@Test
fun `legacy panel preswipe cannot block visible specs in probe or purchase phase`() {
for (nonScrollable in listOf(false, true)) {
for (phase in listOf("spec_probe", "purchase")) {
val driver = FakePurchaseDriver(nonScrollablePanel = nonScrollable, purchaseSwipeSucceeds = false)
val diagnostics = mutableListOf<String>()
val pauses = mutableListOf<Long>()
var probeCount = 0
val raw = rule().replace("\"type\":\"openSpecPanel\"", "\"type\":\"openSpecPanel\",\"waitAfterMs\":321")
val outcome = PurchaseRehearsalExecutor(
driver, { true }, { probeCount++; "{}" }, pause = pauses::add,
panelDiagnostic = diagnostics::add,
).execute(input().copy(phase = phase), PurchaseRuleParser.parse(raw), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, if (phase == "spec_probe") "spec_probe_completed" else "rehearsal_completed", outcome.resultType)
assertEquals(0, driver.swipeCount)
assertTrue(pauses.contains(321L))
assertFalse(pauses.contains(1000L))
assertTrue(diagnostics.any { it.contains("reason=spec_panel_on_demand") })
assertEquals(if (phase == "spec_probe") 1 else 0, probeCount)
assertEquals(if (phase == "spec_probe") 0 else 1, driver.clicked.count { it == "黑色" })
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") })
}
}
}
@Test
fun `non panel mandatory swipes still execute and fail closed`() {
val raw = rule().replace("\"type\":\"selectSpec\"", "\"type\":\"selectSpec\",\"swipeAfter\":{\"direction\":\"up\",\"count\":2,\"durationMs\":500,\"intervalMs\":1000}")
for (succeeds in listOf(false, true)) {
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = succeeds)
val diagnostics = mutableListOf<String>()
val pauses = mutableListOf<Long>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = pauses::add, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(raw), PurchaseAgentCapabilities.supported)
assertEquals(if (succeeds) "rehearsal_completed" else "failed", outcome.resultType)
assertEquals(if (succeeds) 2 else 1, driver.swipeCount)
assertEquals(succeeds, pauses.contains(1000L))
if (!succeeds) {
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertTrue(diagnostics.any { it.contains("action=selectSpec") && it.contains("reason=unknown") })
}
}
}
@Test
fun `quick and order confirmation selectors also skip legacy panel preswipe`() {
for (kind in listOf("QUICK_CONFIRMATION", "ORDER_CONFIRMATION")) {
val driver = FakePurchaseDriver(confirmationPanelKind = kind, purchaseSwipeSucceeds = false)
val diagnostics = mutableListOf<String>()
var probed = false
val outcome = PurchaseRehearsalExecutor(
driver, { true }, { probed = true; "{}" }, pause = {}, panelDiagnostic = diagnostics::add,
).execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "spec_probe_completed", outcome.resultType)
assertTrue(probed)
assertEquals(0, driver.swipeCount)
assertTrue(diagnostics.any { it.contains("postSwipe=skipped") && it.contains("panel=$kind") })
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") || it == "现在买" })
}
}
@Test
fun `mandatory swipe diagnostics use fixed reasons without spec text`() {
val raw = rule().replace("\"type\":\"selectSpec\"", "\"type\":\"selectSpec\",\"swipeAfter\":{\"direction\":\"up\",\"count\":1,\"durationMs\":500}")
for (reason in PurchaseSwipeFailureReason.values()) {
val driver = FakePurchaseDriver(purchaseSwipeSucceeds = false, purchaseSwipeFailure = reason)
val diagnostics = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(raw), PurchaseAgentCapabilities.supported)
assertEquals("RULE_ACTION_FAILED", outcome.errorCode)
assertTrue(diagnostics.contains("postSwipe=failed;action=selectSpec;direction=UP;swipeIndex=1;reason=${reason.name.lowercase()}"))
assertFalse(diagnostics.any { it.contains("黑色") || it.contains("XL") })
}
}
@Test(expected = RuleValidationException::class)
fun `ignored legacy panel swipe still rejects invalid rule parameters`() {
PurchaseRuleParser.parse(rule().replace("\"count\":2", "\"count\":0"))
}
@Test
fun `unrecognized opened panel returns only scalar panel evidence`() {
val driver = FakePurchaseDriver(unrecognizedPanel = true)
@@ -883,84 +802,6 @@ class PurchaseRehearsalExecutorTest {
assertTrue(driver.panel)
}
@Test
fun `selected color card duplicate does not trigger another selection tap`() {
val driver = FakePurchaseDriver(duplicateColorCardEvidence = true)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
assertEquals(0, driver.specTapCount)
}
@Test
fun `dynamic product page recovers ineffective and failed entry clicks once`() {
for (reason in listOf(null, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.TARGET_NOT_FOUND)) {
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
forcedEntryClickReason = reason, specTapResult = FreshActionResult.SUCCESS)
val logs = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = logs::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(outcome.message, "rehearsal_completed", outcome.resultType)
assertEquals(1, driver.specTapCount)
assertTrue(logs.any { it.contains("entryRecovery=attempted") })
assertFalse(logs.any { it.contains("测试商品") || it.contains("选择规格") })
}
}
@Test
fun `dynamic product page with ineffective gesture reports no effect without second tap`() {
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
specTapResult = FreshActionResult.SUCCESS, specTapHasEffect = false)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT", outcome.errorCode)
assertEquals(1, driver.specTapCount)
}
@Test
fun `dynamic product with close control never receives recovery tap`() {
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true, productOverlay = true,
specTapResult = FreshActionResult.SUCCESS)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {})
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED", outcome.errorCode)
assertEquals(0, driver.specTapCount)
}
@Test
fun `changed entry identity or unsafe page never receives recovery tap`() {
for (mode in listOf("missing", "duplicate", "activity", "payment")) {
val diagnostics = mutableListOf<String>()
val driver = FakePurchaseDriver(entryActionHasEffect = false, dynamicProduct = true,
afterEntryMutation = mode, specTapResult = FreshActionResult.SUCCESS)
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = diagnostics::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals(mode, "failed", outcome.resultType)
assertEquals(mode, 0, driver.specTapCount)
val expected = mapOf("missing" to "entry_missing", "duplicate" to "entry_ambiguous",
"activity" to "identity_changed", "payment" to "payment_area").getValue(mode)
assertTrue(diagnostics.toString(), diagnostics.any { it.contains("entryRecovery=rejected;reason=$expected;") })
}
}
@Test
fun `unavailable exact color stops without selection click or searching even when selected`() {
for (selected in listOf(null, "黑色")) {
val driver = FakePurchaseDriver(colors = listOf("黑色", "白色"),
unavailableColors = setOf("黑色")).apply { color = selected }
val logs = mutableListOf<String>()
val outcome = PurchaseRehearsalExecutor(driver, { true }, { null }, pause = {}, panelDiagnostic = logs::add)
.execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported)
assertEquals("PURCHASE_SPEC_SAFE_TARGET_MISSING", outcome.errorCode)
assertEquals("目标颜色已售罄或当前不可选,未创建订单", outcome.message)
assertFalse(driver.clicked.contains("黑色"))
assertEquals(0, driver.specTapCount)
assertEquals(0, driver.swipeCount)
assertTrue(logs.any { it == "specLookup=unavailable;dimension=color;reason=exact_target_unavailable" })
assertFalse(logs.any { it.contains("黑色") })
}
}
@Test
fun `unchanged spec entry after action and gesture returns no effect`() {
val driver = FakePurchaseDriver(
@@ -1386,10 +1227,6 @@ class PurchaseRehearsalExecutorTest {
private val forcedEntryClickReason: FreshClickReason? = null,
private val initialSize: String? = null,
private val entryActionHasEffect: Boolean = true,
private val duplicateColorCardEvidence: Boolean = false,
private val dynamicProduct: Boolean = false,
private val productOverlay: Boolean = false,
private val afterEntryMutation: String = "",
private val specTapResult: FreshActionResult = FreshActionResult.FAILED,
private val specTapHasEffect: Boolean = true,
private val sizeSelectsOnFailedClick: Boolean = false,
@@ -1407,13 +1244,10 @@ class PurchaseRehearsalExecutorTest {
private val pullDownSucceeds: Boolean = true,
private val loseEvidenceAfterPull: Boolean = false,
private val unavailableSizes: Set<String> = emptySet(),
private val unavailableColors: Set<String> = emptySet(),
allSpecsUnavailable: Boolean = false,
private val nonScrollablePanel: Boolean = false,
private val confirmationPanelKind: String? = null,
private val unrecognizedPanel: Boolean = false,
private val purchaseSwipeSucceeds: Boolean = true,
private val purchaseSwipeFailure: PurchaseSwipeFailureReason = PurchaseSwipeFailureReason.UNKNOWN,
initiallyInAgent: Boolean = false,
private val panelBecomesUnknownAfterSizeProof: Boolean = false,
) : PurchaseUiDriver {
@@ -1444,7 +1278,6 @@ class PurchaseRehearsalExecutorTest {
private var horizontalColorPage = 0
private var horizontalSizePage = 0
private var capturesAfterSizeSelection = 0
private var entryAttempted = false
val clicked = mutableListOf<String>()
val clickedPaths = mutableListOf<String>()
@@ -1507,8 +1340,6 @@ class PurchaseRehearsalExecutorTest {
node("title", "测试商品标题文本", 20, 200, 900, 280, className = "android.widget.ViewPager"),
)
val specEntryReady = pddCaptureCount > specEntryVisibleAfterPddCaptures
if (dynamicProduct) nodes += node("decoration", "", 20, 400 + pddCaptureCount % 3, 100, 460)
if (productOverlay) nodes += node("overlay-close", "关闭", 900, 500, 1000, 570, clickable = true)
if (bottomPurchaseEntry && specEntryReady) {
nodes += node("buy", "", 500, 1800, 1080, 2180, clickable = true)
nodes += node("buy/price", "¥20.00", 560, 1840, 760, 1910, parentPath = "buy")
@@ -1517,12 +1348,6 @@ class PurchaseRehearsalExecutorTest {
nodes += node("spec", "选择规格", 20, 1000, 900, 1100, clickable = true)
}
if (includeReviewEntry) nodes += node("review", "商品评价", 20, 1200, 900, 1300, clickable = true)
if (entryAttempted) {
if (afterEntryMutation == "missing") nodes.removeAll { it.path == "spec" }
if (afterEntryMutation == "duplicate") nodes += node("spec2", "选择规格", 20, 1120, 900, 1190, clickable = true)
if (afterEntryMutation == "payment") nodes += node("payment", "微信支付", 20, 1800, 900, 1900)
if (afterEntryMutation == "activity") return UiSnapshot(PDD, "OtherActivity", nodes)
}
return UiSnapshot(PDD, ACTIVITY, nodes)
}
if (unrecognizedPanel) {
@@ -1565,16 +1390,6 @@ class PurchaseRehearsalExecutorTest {
nodes += node(colorParent, "", 0, 460, 1080, 550, scrollable = true, parentPath = "scroll")
}
visibleColors.forEachIndexed { index, value ->
if (duplicateColorCardEvidence && selectedColor == value) {
val cardPath = "scroll/card-$index"
nodes += node(cardPath, "$value 199", 20 + index * 220, 460, 200 + index * 220, 550,
clickable = true, selected = true, parentPath = "scroll", className = "android.view.ViewGroup")
nodes += node("$cardPath/image", "$value 199", 20 + index * 220, 460, 200 + index * 220, 500,
clickable = true, selected = true, parentPath = cardPath, className = "android.widget.ImageView")
nodes += node("$cardPath/text", value, 20 + index * 220, 500, 200 + index * 220, 540,
clickable = true, selected = true, parentPath = cardPath, className = "android.widget.TextView")
return@forEachIndexed
}
nodes += node(
"scroll/color-$index",
value,
@@ -1584,7 +1399,7 @@ class PurchaseRehearsalExecutorTest {
540,
clickable = true,
selected = selectedColor == value,
enabled = !allSpecsUnavailable && value !in unavailableColors,
enabled = !allSpecsUnavailable,
parentPath = colorParent,
)
}
@@ -1622,16 +1437,7 @@ class PurchaseRehearsalExecutorTest {
nodes += if (singleHeading) node("info/quantity", quantity.toString(), 400, 360, 600, 390, className = "android.widget.EditText", parentPath = "info")
else node("quantity", quantity.toString(), 400, 800, 600, 870, className = "android.widget.EditText")
if (!singleHeading) nodes += node("confirm", "确定", 20, 900, 500, 980, clickable = true)
if (confirmationPanelKind != null) {
nodes += node("close", "关闭", 980, 300, 1060, 350, clickable = true)
nodes += node("minus", "减少数量", 300, 800, 380, 870, clickable = true)
nodes += node("plus", "增加数量", 620, 800, 700, 870, clickable = true)
nodes += node("payment", "微信支付", 600, 1000, 900, 1050)
if (confirmationPanelKind == "QUICK_CONFIRMATION") {
nodes += node("quick", "现在买", 520, 2000, 1020, 2080, clickable = true)
}
}
nodes += node("order", "提交订单", 20, if (singleHeading || confirmationPanelKind != null) 2000 else 1100, 500, if (singleHeading || confirmationPanelKind != null) 2080 else 1180, clickable = true)
nodes += node("order", "提交订单", 20, if (singleHeading) 2000 else 1100, 500, if (singleHeading) 2080 else 1180, clickable = true)
nodes += node("pay", "立即支付", 520, 1100, 1020, 1180, clickable = true)
return UiSnapshot(PDD, ACTIVITY, nodes)
}
@@ -1672,7 +1478,6 @@ class PurchaseRehearsalExecutorTest {
}
override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome {
if (target.path in setOf("spec", "buy")) entryAttempted = true
if (target.path in setOf("spec", "buy") && forcedEntryClickReason != null) {
val result = when (forcedEntryClickReason) {
FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> FreshActionResult.NOT_FOUND
@@ -1726,8 +1531,6 @@ class PurchaseRehearsalExecutorTest {
return purchaseSwipeSucceeds
}
override fun purchaseSwipeFailureReason(): PurchaseSwipeFailureReason = purchaseSwipeFailure
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
swipeInPaths += target.path
if (restoreHiddenColorOnDownSwipe && quantity == 2L && direction == SwipeDirection.DOWN) {
@@ -1,47 +0,0 @@
package cn.ilapage.goauto.agent
import cn.ilapage.goauto.agent.automation.*
import org.junit.Assert.*
import org.junit.Test
class SelectedColorCardTest {
private val target = "黑色两件套 19"
private fun node(path: String, parent: String?, label: String, kind: String, selected: Boolean = true,
scrollable: Boolean = false) = SnapshotNode(path, parent, label, null, null, kind,
NodeBounds(0, 0, 300, 400), !scrollable, scrollable, selected, false, true, true)
private val scroll = node("s", null, "", "androidx.recyclerview.widget.RecyclerView", false, true)
private val card = node("s/c", "s", "黑色两件套 199", "android.view.ViewGroup")
private val image = node("s/c/i", "s/c", "黑色两件套 199", "android.widget.ImageView")
private val text = node("s/c/t", "s/c", target, "android.widget.TextView")
private fun check(nodes: List<SnapshotNode>, candidates: List<SnapshotNode> = nodes.filter { it != scroll }) =
SelectedColorCard.confirms(target, candidates.map { VisibleSpecValue(it.label, true, it) }, nodes)
@Test fun `selected target and own card image duplicates are one selection`() {
assertTrue(check(listOf(scroll, card, image, text)))
}
@Test fun `another selected card remains conflict`() {
val other = card.copy(path = "s/other")
assertFalse(check(listOf(scroll, card, image, text, other)))
}
@Test fun `two exact targets are not merged`() {
val otherCard = card.copy(path = "s/other", selected = false)
val otherText = text.copy(path = "s/other/t", parentPath = otherCard.path)
assertFalse(check(listOf(scroll, card, image, text, otherCard, otherText)))
assertFalse(check(listOf(scroll, card, image, text, otherCard, otherText), listOf(card, image, text)))
}
@Test fun `unselected target cannot borrow selection from its container`() {
assertFalse(check(listOf(scroll, card, image, text.copy(selected = false))))
}
@Test fun `scrollable or missing card boundary cannot merge`() {
assertFalse(check(listOf(scroll, card.copy(scrollable = true), image, text)))
assertFalse(check(listOf(card, image, text)))
assertFalse(check(listOf(scroll, card.copy(clickable = false), image, text)))
}
@Test fun `different text sibling is not treated as image duplicate`() {
assertFalse(check(listOf(scroll, card, text, image.copy(className = "android.widget.TextView"))))
}
@Test fun `out of card bounds and parent cycle are rejected`() {
assertFalse(check(listOf(scroll, card, image.copy(bounds = NodeBounds(0, 0, 301, 400)), text)))
assertFalse(check(listOf(scroll, card.copy(parentPath = text.path), image, text)))
}
}
+37 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: 20a1ca65dccde9ca1ecd93419cc1b164a5717fdf
synchronized_at: 2026-09-07T13:34:45Z
wiki_revision: eb9608c98fa117d4fcc2019ed6ae60b6a3670f42
synchronized_at: 2026-09-09T09:24:10Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -68,6 +68,8 @@ Android Portal/Agent
| `pdd_account` | 可选的账号调度引用,只保存名称和状态,不保存凭据 |
| `purchase_task` | 商品外键和不可变快照、执行模式、状态/租约 guard、价格边界、订单、人工支付复核、物流与回填事实 |
| `purchase_task_attempt` | `task_id + attempt_id` 幂等执行记录、阶段、规则哈希、固化规格决策和结构化错误 |
> #241 新增:`server/app/goauto/purchase/order_backfill.go` 与 `order_backfill_handler.go` 提供 `POST /api/agent/v1/purchase-tasks/order-backfill`(Device Token 鉴权,逐条事务、逐条结果);`server/app/goauto/models/purchase_order_guard.go` 在 `PurchaseTask.BeforeSave` 上全局强制订单号唯一,以 `purchase_rule_setting` 单例行串行化订单号分配,避免新增唯一索引迁移;`purchasecontract.ParseAddressSuffix` 为 `AddressSuffix` 的反解,通过回比而非负向前瞻实现(Go RE2 不支持前瞻)。
| `ai_matching_setting` | 唯一单例的启用状态、OpenAI-compatible Base URL、模型、超时、内部部署明文 API Key 和更新人;仅管理员设置接口可以读取该字段 |
`collection_task` 的状态仅为 `pending`、`running`、`completed`、`completed_partial`、`failed`。设备身份和心跳表属于 Agent 领取任务的必要基础,不承载 PDD 业务数据。
@@ -352,3 +354,36 @@ PddProductDetailCollector
`GoAutoAccessibilityService.swipePurchase` 的失败分类由 `PurchaseSwipeFailureReason` 枚举提供;共享 `swipeNode` 仅增加可选分类回调,不改变手势目标、轨迹、1500ms 回调等待或其他调用者行为。`GoAutoPurchasePanel` 日志经 `AgentForegroundService` 关联 task、attempt、device 与规则快照哈希,新增预滑动跳过/必需滑动失败标量;不记录节点文字、坐标、原始控件树、截图或凭据。
Server/Web、数据库和任务快照不变;旧 APK 仍有预滑动行为,必须更新 Agent 才生效。相关验证在 `PurchaseRehearsalExecutorTest`,Android 全量测试与 APK 构建入口不变。
## SYB 逐页保存与部分成功(#239)
实现绑定 c6a962d;代码已实现不代表当前线上已部署。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。
同步状态增加 `partial_success`(部分成功,15 字符,复用现有 varchar(16),无需迁移)。有错误且 created+updated>0 为部分成功;有错误无已提交明细为 failed;完整且无错误为 succeeded(包括无符合店铺的数据)。中断仍为 interrupted,不把中断追认为成功。所有终态沿用活动槽释放规则。
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
入口为 `SyncWithShopSnapshot → loadDailyList → importSyncPage`;`server/app/goauto/sybimport/sync_page.go` 封装页事务和提交后计数;`import_handler.go` 判定终态,`sync_run.go` 保存及筛选,Web 复用 `web/src/views/goauto/syb-sync-runs/index.vue`。
## Android 连续临时采集收尾(#250)
当前实现绑定 `37e8b56`(Agent 0.9.72 / 85),用户确认 v5 按次数返回后切回 Agent。仅 Android,无 Admin/API/权限/数据库迁移;取代 v1~v4 首页导航。
- `AgentSettingsFragment` 在采集间隔后复用卡片和 SwitchMaterial;仅开启显示“返回次数”按钮,Material 单选框选择 1~5 次(默认 3),立即保存或取消。成功无常驻反馈,错误/忙碌显示原因。任务忙碌禁用,弹窗选定时再检查。
- `AgentSettingsStore` 在私有 `goauto_agent_settings` 保存 `continuous_collection`(false)与 `continuous_collection_return_count`(3);次数越界或读取错误回落 3,写入失败恢复旧值,无网络加载。
- `requestCurrentPageCollection` 保留原范围判断;`executeTask` 在采集开始固定次数。`executeTaskWhileAwake` 提交结果、结束图片上传后返回 resultStatus;`ContinuousCollectionPolicy.finish` 仅对完整确认成功调用新 `PddCollectionReturnNavigator`。失败/部分成功不导航。冷却间隔不变,连续模式不启动旧 idle-return。
- 删除 `PddCollectionHomeNavigator` 和专属首页/搜索层判据。新导航器仅使用 capture/back、当前包名、openAgentPreservingTab;1~5 次 Back,每次后 500ms,前台离开 PDD 终止剩余 Back;保留风险场景停止。最后打开 Agent 并等待 500ms 验证包名,不验证 PDD 首页。仍在原任务互斥与唤醒锁范围内完成,结束后原 finally 释放。
- `GoAutoCollector` 日志关联 taskId、attemptNumber、deviceId、规则快照 SHA-256;记录 continuousReturn、configuredBacks、固定 reason 和 actions。原因含 count_completed、left_pdd、back_failed、unsafe_page、agent_launch_failed、agent_not_confirmed、navigation_exception、invalid_count;actions 为 Back 尝试数,不代表最终页面。无节点文字、树、截图或个人数据落盘;不扩大采购诊断存储。
- `ContinuousCollectionTest` 覆盖范围/终态、1~5 次、无变化仍按次数、前台变化、风险、动作/启动失败、包名确认和异常;`ContinuousCollectionSettingsTest` 覆盖开关与次数持久化、默认/越界/写入失败。`:app:testDebugUnitTest :app:assembleDebug` 376 项通过。旧首页专属测试随旧行为替换,新版未安装,UI 大字体/横屏、多设备和完整采集真机链路尚未验证。
## SYB 存疑规格候选解析(#251)
实现绑定 `64b4205`,仅 Server `app/goauto/sybimport`;无 API 字段、数据库迁移、权限、并发模式或 UI 变化。
- `parse_candidates.go: ParseWithCandidates` 先运行原 `Parse`,仅 uncertain 时解码并校验已存 SpecsJSON;复用 `closedShopeeCandidates` 提取颜色/尺码封闭候选,拒绝未知有值角色/重复角色。完整候选组合与原文归一比较唯一时确认,其他情况返回原 ParseResult。
- `ApplyDetail` 在加载既有蝦皮商品后、写入规格前调用;`Reparse` 在保留人工/AI 确认保护后加载关联候选调用。新增 ParseResult 私有 matchedCandidates 标记,使 `mergeParsedSpec` 对已经存在的候选直接返回,避免按维度名重复合并或改变映射。
- #198 `ProcessSpecAIParseRun → Reparse` 自然接入确定性优化,仍需 AI 的记录沿用原后续步骤,不新增异步任务/Provider 调用/接口。
- 验证 `go test ./app/goauto/sybimport ./app/goauto/aimatching ./app/goauto/shopeeproduct ./app/goauto/purchase` 和 `go build ./...` 通过;新增候选正反/格式/组合色/单维度、歧义/空值、同步重解析一致、人工保护及无需调用 AI 测试。AI 原有置信度/失败重试测试改用仍需语义理解的合成输入,继续验证原分支。
- 本轮只使用本地测试数据与 httptest AI,未触发真实 SYB 同步、外部 AI、采购、数据库迁移或服务重启;线上及历史数据效果尚待授权验证。
+24 -2
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: a445dfe3b76336f5c7387a2da9760c5d6e383ff6
synchronized_at: 2026-09-09T02:47:12Z
wiki_revision: cf6bd9fb5dd727417a3ed612ddcf52eb2d8d868d
synchronized_at: 2026-09-09T09:24:15Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -511,3 +511,25 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
- 旧文案无候选时,只接受弹层底部固定区域内一个独立、可见启用、非滚动的可点击容器;不在规格滚动区内选择按钮,不取控件树末项,不使用固定屏幕点击坐标。文字与人民币金额子节点合并为一个按钮;支持如“复购价,”及非支付展示文字变化,但单独价格、多个操作按钮或支付/付款/地址/返回等非提交动作均不放行。
- 地址返回检查、最终复核和提交共用目标定位。结构路径保留原文字节点为重新定位锚点,由已有无障碍点击重新查找其可点击父级;不新增下单手势兜底。最终复核成功后才允许该结构路径提交;提交前再次检查路径、类别、文字与边界,发生变化即停止;复核失败清除本地结构提交锚点。
- 既有规格、数量、价格和地址复核,任务租约、服务端/本地不可逆边界及单次提交规则不变。结构识别不支持支付,不增加采购员操作、配置或审批;不改变 Admin、接口、数据库、规则 JSON、通用解析器及滑动执行器。原始控件树、整屏截图、地址全文和凭据不得落盘或上传。
## 临时采集连续模式(#250)
当前实现绑定 `37e8b56`,Agent 0.9.72 / versionCode 85。2026-09-09 用户批准 v5:按配置次数返回后切回 Agent,替代此前“识别并停留 PDD 首页”的方案;不保证 PDD 最终停在首页。代码、376 项 Android 单测及 Debug 构建通过,新版设置与完整真机闭环待验收。
- 连续采集默认关闭,本机即时保存,失败恢复原值;开启后显示返回次数选择,1~5 次、默认 3,取消不改,写入失败恢复旧值。任务忙碌时开关和次数均禁用,选择提交时再次检查;无新增权限。只用于本次人工新建普通临时采集(source=agent_current_page、attemptNumber=1、无替换来源),Admin 下发、重试、替代与采购不参与。
- 单任务执行开始固定返回次数。完整 completed、服务端提交确认且既有图片上传流程结束后,执行有限 Back,每次后等待 500ms;不要求识别商品/搜索/相机/首页,也不要求画面发生变化。图片上传仍沿用既有非致命行为,不因收尾更改结果。
- 每次 Back 前检查前台仍为 PDD,读取页面后再次检查;离开 PDD 则停止余下 Back。次数完成、离开 PDD 或 Back 动作失败后,尝试打开 Agent 保持原 Tab,等待 500ms 并验证前台包名。动作失败不重试,不把请求启动成功直接当作切回成功。
- 沿用风险页安全边界:登录/验证码/风控、地址编辑、订单详情或支付等证据时保留现场,不发送 Back 或强行切回;Activity 缺失、读取异常明确停止。以上例外不引入首页门禁。
- 导航在原任务互斥和唤醒锁内完成,不变更租约/调度。返回失败只显示人工处理提示,不把成功采集改为失败或自动重采。
- 失败、部分成功、提交未确认保留现场,不运行返回步骤,也不触发旧空闲返回倒计时。关闭开关保持原流程,采集间隔不变。
- 不自动拍照、搜索或创建下一任务,不涉及采购/订单/付款;不使用深链、浏览器、OCR/VLM 或坐标兜底。
## SYB 存疑规格候选核对(#251)
实现绑定 `64b4205`,基线 main `fdd26af`;2026-09-09 代码与本地测试/构建通过,未部署、未重跑生产历史数据。
- 原 Parse 的成功与失败行为不变,仅 uncertain 明细增加既有蝦皮规格候选核对。比较完整原文,沿用【备注】清理,允许全角逗号、逗号两侧空白及连续空白归一;颜色尺码可正序或倒序,使用逗号或空白分隔,只有唯一完整组合才改为 success。返回既有候选标签,不改写标签,不删除加号、不截断体重说明、不做近似匹配。
- 单规格只有在候选确实仅包含该角色时通过;有两个角色时不能用单值补猜另一个角色。空/损坏候选、校验不通过、同角色多维、未知有值维度、多种组合或归一化碰撞,均保留原结果。候选必须已存在,不能用本次待确认输入生成候选再自我证明。
- 同步入库 ApplyDetail、手动 Reparse 共用;成功核对不再次合并规格,因此不新增重复维度、不覆盖已有映射。人工确认仍优先,默认 Reparse 跳过人工与 AI 已确认值;原文保留。
- #198 批处理原本先 Reparse,所以可直接复用本优化:能确定的不用调用 AI;仍不确定的按原 AI 流程处理。没有新增调度、调用额度或同步等待,不自动开启已有任务。
- 不取消 uncertain/failed 状态,不改变采购可信度/可采购规则,不实现一键关联 PDD。部署后新同步或显式重新解析会使用新逻辑;本次没有执行生产批量更新。
+13 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: 49dec4c35da793a32822a82e00f3b657757b6d44
synchronized_at: 2026-09-07T13:35:25Z
wiki_revision: 217239a85b50c880e004ecc19a559fb5e03555e1
synchronized_at: 2026-09-08T01:38:42Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -77,7 +77,7 @@ PATCH /api/admin/v1/syb-products/{productId}/correction
`import` 允许已认证的管理员(admin)和采购员(purchaser)调用(#236),仍须通过 Casbin 权限校验;其他角色返回 403。采购员的 POST 权限由既有启动权限对账写入,不需要新增数据库迁移。操作人取已认证 claims,不接受客户端冒名。请求体提交 `dateFrom`、`dateTo` 后创建持久化后台任务并立即以 `202` 返回 `runId` 和 `status=running`;关闭弹窗、刷新或离开页面不影响任务。没有启用店铺时必须在读取凭据、登录、验证码 OCR 和任意 SYB 网络请求之前返回 `422`。任意时刻只能有一条 `running` 记录,内存锁与数据库唯一执行槽共同阻止单进程和跨进程重复导入;冲突时返回正在执行任务的日期范围。
`sync-runs` 列表支持 `page`、`pageSize`、`status`、`dateFrom`、`dateTo`,详情返回日期范围、状态(`running` / `succeeded` / `failed` / `interrupted`)、处理天数、货运单/明细/新增/覆盖数量、店铺准入与跳过数量、店铺筛选快照哈希、按店铺的 `accepted` / `skipped` 统计、操作人和起止时间。列表和详情对已登录角色只读开放。服务启动时遗留的 `running` 任务改为 `interrupted`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
`sync-runs` 列表支持 `page`、`pageSize`、`status`、`dateFrom`、`dateTo`,详情返回日期范围、状态(`running` / `succeeded` / `partial_success` / `failed` / `interrupted`)、处理天数、货运单/明细/新增/覆盖数量、店铺准入与跳过数量、店铺筛选快照哈希、按店铺的 `accepted` / `skipped` 统计、操作人和起止时间。列表和详情对已登录角色只读开放。服务启动时遗留的 `running` 任务改为 `interrupted`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
列表返回结构化字段(`orderCode`、`shopeeItemId`、`productTitle`、`targetColor`、`targetSize`、`quantity`、`unitPriceCent`、`imageUrl`、`parseStatus`、`parseNote`、`manuallyConfirmed`),不含原始 JSON;`keyword` 匹配订单号、虾皮商品ID 或商品标题,`parseStatus` 筛选 `success`/`uncertain`/`failed`。详情额外返回 `rawJson`(原始 `details[]` 元素,未做任何改写)。
@@ -975,3 +975,13 @@ Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口
本地 `GoAutoPurchasePanel` 脱敏结构日志关联 task、attempt、device、rule(规则 SHA-256),不上传原始页面。新增事件:`postSwipe=skipped;action=openSpecPanel;reason=spec_panel_on_demand;panel=<枚举>`;其他必需滑动失败为 `postSwipe=failed;action=<动作枚举>;direction=<方向枚举>;swipeIndex=<本动作内第几次滑动>;reason=<固定分类>`。
固定失败分类:unknown、root_unavailable、no_scrollable、invalid_bounds、gesture_unsupported、gesture_rejected、gesture_cancelled、gesture_timeout。日志不含规格值、节点文本、坐标、地址、订单、凭据、原始树或截图;结果错误码仍为 RULE_ACTION_FAILED,现有结果提交字段不变。
## SYB 逐页保存与部分成功(#239)
实现绑定 c6a962d;代码已实现不代表当前线上已部署。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。
同步状态增加 `partial_success`(部分成功,15 字符,复用现有 varchar(16),无需迁移)。有错误且 created+updated>0 为部分成功;有错误无已提交明细为 failed;完整且无错误为 succeeded(包括无符合店铺的数据)。中断仍为 interrupted,不把中断追认为成功。所有终态沿用活动槽释放规则。
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
+21 -20
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: SYB-ERP-Interface-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
wiki_revision: a4f4ab200af052bab7ffbabe267238528bcd7cf9
synchronized_at: 2026-09-07T07:02:57Z
wiki_revision: 214485db630141aeb03d4b49b22a65e572fb592a
synchronized_at: 2026-09-08T02:24:10Z
<!-- gitea-wiki-mirror:end -->
# 12 顺云宝(SYB)ERP 接口契约
@@ -220,22 +220,13 @@ Admin 默认 `max_matches = 10000`,可以在配置中调整;上限针对整
有限重试:单次 HTTP 总超时 60 秒,最多执行 3 次,重试前分别等待 1 秒、2 秒,等待须
响应 context 取消。HTTP 401/403、明确会话失效、业务失败、接口数据完整性错误和本地
校验失败不得重试;登录、验证码、单件码写入和任何回填请求也不得使用该机制。重试
耗尽后返回最后一次错误,外层继续保留日期、页码和已获取数量上下文并将同步标为失败。
耗尽后返回最后一次错误,外层保留日期、页码和已获取数量上下文,按最终已保存成果将同步标为失败或部分成功。
今天的货运单会在同步期间持续新增。只有 UTC+8 下的今天发生上述快照漂移时,
允许只重试今天的列表分页,最多 3 次;已经完成的历史日期不得重复拉取,每次尝试
也必须使用独立 ID 集合。第三次仍不稳定时,可以对最后一次取得的合法唯一 ID
读取完整明细并按既有 upsert 保存,但本次同步仍记为失败、明确提示当天未形成
稳定快照且不推进游标,下一次继续覆盖今天。任何尝试都不得突破 `max_matches`;
网络/业务错误、非法 ID 或不完整明细不属于可放宽的快照漂移。
`[必须,#235]` 当天跨页重复 ID 纳入上述最多 3 次列表快照尝试(含首次),
不增加另一层重试次数。发现跨页重复后丢弃本次列表,从预检总数和第一页重新开始,
使用全新 ID 集合;最后一次仍重复时直接失败,不得去重后按成功或降级数据保存。
已经完成的历史日期保持其已有结果,不重复拉取;历史日期重复不适用此恢复。
每页先检查非法 ID 和页内重复,再检查跨页重叠;同页同时存在页内重复和跨页重叠时
仍作为硬错误停止。原有总数漂移/短页的合法唯一列表降级保存条件保持不变。
重复诊断只记录日期、当天尝试序号、首次/当前页码与行号、start、pageSize、
expectedTotal 和已获取唯一数量,不记录真实重复 ID、原始响应或个人数据。
自 #239(实现提交 c6a962d,已于 2026-09-08 随 d403f3b 部署线上)起,改为按页验证和保存:每页先验证条数、合法 ID、页内/跨页重复,再按冻结店铺快照获取并验证全部明细;外部请求完成后才开启页事务。页内任一入库失败回滚整页,已提交的前页保留,计数在事务提交后累计。
当天和历史日期统一处理:分页/明细失败或最终总数漂移时停止该日期并继续后续日期,不在同次运行中重新扫描当天,以避免重复写入和计数。数据库故障、进度持久化失败、明确会话失效、上下文取消或总任务超时停止整个范围。下一次人工或定时同步重新扫描日期,按 (order_code, detail_id) 幂等覆盖并保留人工确认。跨页重复不去重后冒充完整数据;最终日期完整性校验仍覆盖所有店铺。此流程替代此前 #235 的当天三次整日快照重扫。
重复诊断只记录日期、首次/当前页码和行号、start、pageSize、expectedTotal、已获取唯一数量,不记录真实重复 ID、原始响应或个人数据。
### 4.4 统一日期范围同步与覆盖游标
@@ -258,7 +249,7 @@ GoAuto 同步记录页的立即同步允许管理员和采购员(purchaser)
或超过 `max_matches` 时不推进游标。
`[必须]` 每批明细响应必须与请求的货运单 ID 一一对应。缺失、重复、出现未请求
ID,或某张货运单返回空商品明细,都视为不完整并停止同步;已经写入的幂等数据
ID,或某张货运单返回空商品明细,都视为不完整并停止当前日期,继续后续日期;已经写入的幂等数据
可以保留,但只有所有日期全部成功才推进游标。
`[必须]` 登录和验证码只是同步前置步骤。日期范围经过自动 OCR 降级、手工
@@ -500,9 +491,9 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
> 以下「店铺准入」已由 GoAuto #49 采纳并实现;店铺规范化还包括全角/半角统一和忽略大小写。
`[必须]` **同步先校验原始全量,再做店铺准入。** 顺序固定为:按日期查询原始总数
并执行单次容量熔断 → 拉完当天原始列表并核对分页前后总数、页长和唯一 ID → 按
并执行单次容量熔断 → 逐页核对页长和唯一 ID,整日结束复核总数 → 按
`shopName` 去除首尾空白后与启用店铺精确匹配 → 只为接受的货运单请求明细和入库。
不能先过滤再做完整性校验,否则非目标店铺的分页漂移会被掩盖。
店铺过滤只决定明细获取和入库,不能减少原始列表完整性校验范围;已保存不代表整日完整。
`[必须]` 同步开始时只读取一次启用店铺,整次运行使用同一个快照。列表允许但明细
响应中的 `shopName` 变为空或非允许店铺时再次拦截。没有启用店铺时在会话/OCR/
@@ -657,3 +648,13 @@ settings:
- 客户端实现:`server/app/goauto/sybclient/`
- 落库与解析:`server/app/goauto/sybimport/`
- 相关工单:[#48 客户端移植](https://git.ilapage.cn/OPC/goauto/issues/48)、[#41 SYB 货运单商品导入](https://git.ilapage.cn/OPC/goauto/issues/41)、[#37 物流调度与货运宝回填](https://git.ilapage.cn/OPC/goauto/issues/37)
## SYB 逐页保存与部分成功(#239)
实现绑定 c6a962d;2026-09-08 已随 d403f3b 部署线上,未手动触发真实同步验收;#240 上游尾页超时尚未修复。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。
同步状态增加 `partial_success`(部分成功,15 字符,复用现有 varchar(16),无需迁移)。有错误且 created+updated>0 为部分成功;有错误无已提交明细为 failed;完整且无错误为 succeeded(包括无符合店铺的数据)。中断仍为 interrupted,不把中断追认为成功。所有终态沿用活动槽释放规则。
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
+5 -5
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: f951dbc8b6a555cbf8f44cda073910406ba55191
synchronized_at: 2026-09-07T09:43:56Z
wiki_revision: df033d06fd94a4b02a447a27ced8d711521342f3
synchronized_at: 2026-09-08T02:23:08Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
@@ -73,10 +73,10 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
## 客户端密钥部署与验证(#237)
实现基线 `71f7751`;2026-09-07 已按用户授权完成本机 MySQL 迁移、管理员菜单写入和 Server/Web 构建重启;管理页面 HTTP 列表与模块加载已验证。线上仍未部署。
实现基线 `71f7751`;2026-09-07 已按用户授权完成本机 MySQL 迁移、管理员菜单写入和 Server/Web 构建重启;管理页面 HTTP 列表与模块加载已验证。2026-09-08 经用户授权,线上已发布 Server/Web 基线 `d403f3b`,执行迁移 `1788798000000` 并重启 GoAuto/Nginx;迁移记录、两张密钥表、仅管理员菜单关联已回读。HTTP 页面与健康检查通过;未登录管理请求 HTTP 200、业务码 401。真实密钥创建及客户端业务读写闭环尚未验证。
- 发布前须单独授权追加迁移 `server/cmd/migrate/migration/version-local/1788798000000_client_api_key.go`,按既有迁移流程创建 client_api_key、client_api_key_audit 和“采采管理/客户端密钥”管理员菜单。前置父菜单必须存在;不应以赋予普通用户管理员角色代替迁移或权限核验。
- 用户于 2026-09-07 明确确认默认兼容 HTTP/HTTPS、不设开关并接受明文风险;部署本版本并执行迁移后,现有 `http://185.216.248.75:9527` 可以使用客户端密钥管理及客户端 API。本机已部署验证,不能据此宣称线上已经可用。HTTP 会明文传输密钥和业务数据,仍建议使用 HTTPS。
- 用户于 2026-09-07 明确确认默认兼容 HTTP/HTTPS、不设开关并接受明文风险;部署本版本并执行迁移后,现有 `http://185.216.248.75:9527` 可以使用客户端密钥管理及客户端 API。2026-09-08 线上部署及未登录拒绝检查已通过,真实密钥读写验收仍待进行。HTTP 会明文传输密钥和业务数据,仍建议使用 HTTPS。
- 客户端密钥功能不依赖 GOAUTO_TRUST_FORWARDED_PROTO、X-Forwarded-Proto 或 Agent HTTP 例外。既有其他路由的协议和代理配置保持不变;不伪造协议头,不新增明文放行开关。
- 代理、APM、应用日志均不得记录 Authorization、创建响应 secret 或原始业务载荷。应用对两类客户端密钥路由跳过旧请求/响应正文日志,使用专用元数据审计;实际代理日志脱敏仍须部署验收。
- 请求审计 status=0 可能表示在途、进程中断或结果审计更新失败;先按请求关联号核对业务结果,不自动重试采购、采集、同步等操作。停用阻止后续认证,不保证取消已开始的业务操作。
@@ -92,4 +92,4 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
- 真实迁移必须单独授权并先确认唯一待执行版本;常驻 API 加 `-SkipMigration`,日常重启不自动执行未来待审核迁移。#237 迁移 `1788798000000_client_api_key.go` 已执行,sys_migration 记录 `1788798000000`、两张密钥表及管理员菜单已回读;其他角色未新增菜单关联。任务启停状态未改。
- 范围明确的服务控制:`D:/supervisor/supervisord.exe -c D:/supervisor/supervisord.conf ctl status goauto-admin-api goauto-admin-ui`,启动/停止/重启将 status 分别替换为 start/stop/restart。不得为了 GoAuto 重启整个 Supervisor 或其他项目。配置内容变化后需重新读取配置;本次调用 `supervisor.reloadConfig`,再对上述两个程序定向 start,已通过进程命令行核对新目录。
- 日志:`D:/supervisor/logs/goauto-admin-api.log`、`D:/supervisor/logs/goauto-admin-ui.log`;对外分享只能保留脱敏结构摘要。迁移输出不得暴露凭据或业务原文。
- 管理员刷新页面后可进入 `http://127.0.0.1:9527/#/client-keys/index`;菜单缓存未更新时重新登录。已验证列表空态、创建弹窗加载 12 模块及 HTTP 风险提示;未实际创建密钥,真实客户端读写及停用闭环仍待范围明确的验证。线上 GoAuto/Nginx 未重启或发布。
- 管理员刷新页面后可进入 `http://127.0.0.1:9527/#/client-keys/index`;菜单缓存未更新时重新登录。已验证列表空态、创建弹窗加载 12 模块及 HTTP 风险提示;未实际创建密钥,真实客户端读写及停用闭环仍待范围明确的验证。本节为 2026-09-07 本机验证记录;线上已于 2026-09-08 另行授权发布,见上节。
@@ -44,6 +44,30 @@ func seedAIParseCandidate(t *testing.T, db *gorm.DB, serverURL string, detailID
return applied.SYBProduct
}
func TestScheduledParseUsesUniqueCandidatesBeforeCallingAI(t *testing.T) {
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
calls.Add(1)
response.WriteHeader(http.StatusInternalServerError)
}))
defer provider.Close()
db := openTestDB(t)
record := seedAIParseCandidate(t, db, provider.URL, 25102, "黑色 XL")
run, _, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil {
t.Fatal(err)
}
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
t.Fatal(err)
}
if err := db.First(&record, record.ID).Error; err != nil {
t.Fatal(err)
}
if record.ParseStatus != models.SYBParseStatusSuccess || record.TargetColor != "黑色" || record.TargetSize != "XL" || record.AIConfirmed || calls.Load() != 0 {
t.Fatalf("unique candidate must resolve deterministically, status=%s ai=%v calls=%d", record.ParseStatus, record.AIConfirmed, calls.Load())
}
}
func TestScheduledAIParseConfirmsClosedCandidatesAndDoesNotRepeat(t *testing.T) {
var calls atomic.Int32
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
@@ -53,7 +77,7 @@ func TestScheduledAIParseConfirmsClosedCandidatesAndDoesNotRepeat(t *testing.T)
}))
defer provider.Close()
db := openTestDB(t)
record := seedAIParseCandidate(t, db, provider.URL, 19801, "黑色 XL")
record := seedAIParseCandidate(t, db, provider.URL, 19801, "黑 XL")
rawBefore := record.RawJSON
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
@@ -98,7 +122,7 @@ func TestScheduledAIParseLeavesLowConfidenceUnmatchedForSameFingerprint(t *testi
}))
defer provider.Close()
db := openTestDB(t)
record := seedAIParseCandidate(t, db, provider.URL, 19802, "黑色 XL")
record := seedAIParseCandidate(t, db, provider.URL, 19802, "黑 XL")
for i := 0; i < 2; i++ {
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
@@ -175,7 +199,7 @@ func TestScheduledAIParseRetriesProviderFailureAtMostThreeTimes(t *testing.T) {
}))
defer provider.Close()
db := openTestDB(t)
record := seedAIParseCandidate(t, db, provider.URL, 19805, "黑色 XL")
record := seedAIParseCandidate(t, db, provider.URL, 19805, "黑 XL")
for attempt := 1; attempt <= 4; attempt++ {
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
if err != nil || !created {
+6
View File
@@ -96,6 +96,9 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
if err != nil {
return err
}
if shopeeProduct != nil {
parsed = ParseWithCandidates(detail.ProductSpec, shopeeProduct.SpecsJSON)
}
if parsed.Status == models.SYBParseStatusSuccess && shopeeProduct != nil {
if err := mergeParsedSpec(tx, shopeeProduct.ID, parsed); err != nil {
return err
@@ -271,6 +274,9 @@ func fillEmptyArchiveFields(tx *gorm.DB, product *models.ShopeeProduct, detail D
// review but are never pushed into the shared archive, so a messy raw string
// never pollutes the spec list other screens read from.
func mergeParsedSpec(tx *gorm.DB, shopeeProductID uint64, parsed ParseResult) error {
if parsed.matchedCandidates {
return nil
}
var product models.ShopeeProduct
if err := tx.First(&product, shopeeProductID).Error; err != nil {
return err
@@ -171,6 +171,9 @@ func runImport(db *gorm.DB, runID uint64, request ImportRequest, settings config
status := SyncRunSucceeded
if err != nil {
status = SyncRunFailed
if report.Created+report.Updated > 0 {
status = SyncRunPartial
}
}
finishCtx, finishCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer finishCancel()
@@ -4,44 +4,9 @@ import (
"context"
"strings"
"testing"
"time"
)
func freezePaginationToday(t *testing.T) {
t.Helper()
previous := syncNow
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
t.Cleanup(func() { syncNow = previous })
}
func TestTodayCrossPageOverlapRestartsWithIndependentIDs(t *testing.T) {
freezePaginationToday(t)
f := &fakeSYB{perDay: map[string]int{"2026-08-29": 4}}
f.pageIDs = func(_ string, start, call int) []int64 {
if call == 2 {
return []int64{1001, 1002}
}
if call > 2 {
return []int64{int64(2000 + start), int64(2001 + start)}
}
return nil
}
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-29", 2, 4, 100)
if err != nil || len(rows) != 4 || f.listTotalCalls != 2 {
t.Fatalf("rows=%d totals=%d err=%v", len(rows), f.listTotalCalls, err)
}
if got := strings.Join(f.listCalls, ","); got != "2026-08-29:0,2026-08-29:2,2026-08-29:0,2026-08-29:2" {
t.Fatalf("did not restart at first page: %s", got)
}
for index, row := range rows {
if row.ID != int64(2000+index) {
t.Fatal("rows leaked from abandoned attempt")
}
}
}
func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testing.T) {
freezePaginationToday(t)
func TestTodayOverlapPreservesCommittedPagesWithoutImportingOverlappingPage(t *testing.T) {
f := &fakeSYB{perDay: map[string]int{"2026-08-28": 2, "2026-08-29": 4}}
f.pageIDs = func(date string, start, _ int) []int64 {
if date == "2026-08-29" && start == 2 {
@@ -54,7 +19,7 @@ func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testin
if err == nil {
t.Fatal("overlap was treated as successful sync")
}
for _, token := range []string{"连续 3 次", "跨页重复", "firstPage=1", "firstRow=2", "page=2", "row=1", "start=2", "pageSize=2", "expectedTotal=4", "unique=2"} {
for _, token := range []string{"跨页重复", "firstPage=1", "firstRow=2", "page=2", "row=1", "start=2", "pageSize=2", "expectedTotal=4", "unique=2"} {
if !strings.Contains(err.Error(), token) {
t.Fatalf("missing %s in %v", token, err)
}
@@ -62,7 +27,7 @@ func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testin
if strings.Contains(err.Error(), "1001") || strings.Contains(err.Error(), "已保存") {
t.Fatalf("unsafe diagnosis/degraded save: %v", err)
}
if len(f.listCalls) != 7 || f.detailCalls != 1 || report.OrderCount != 2 {
if len(f.listCalls) != 3 || f.detailCalls != 2 || report.OrderCount != 4 || report.Created != 2 || report.Updated != 2 {
t.Fatalf("unexpected retry/import boundary: pages=%d details=%d orders=%d", len(f.listCalls), f.detailCalls, report.OrderCount)
}
var count int64
@@ -72,7 +37,6 @@ func TestTodayPersistentOverlapPreservesYesterdayButDoesNotImportToday(t *testin
}
func TestPaginationHardErrorsDoNotUseOverlapRecovery(t *testing.T) {
freezePaginationToday(t)
for _, tc := range []struct {
name, date, message string
page int
@@ -92,7 +56,7 @@ func TestPaginationHardErrorsDoNotUseOverlapRecovery(t *testing.T) {
}
return nil
}
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), tc.date, 2, 4, 100)
rows, err := loadDailyList(context.Background(), newSyncClient(t, f), tc.date, 2, 4)
if rows != nil || err == nil || !strings.Contains(err.Error(), tc.message) || len(f.listCalls) != tc.calls || f.listTotalCalls != 0 {
t.Fatalf("unexpected recovery: rows=%d pages=%d totals=%d err=%v", len(rows), len(f.listCalls), f.listTotalCalls, err)
}
+2
View File
@@ -34,6 +34,8 @@ type ParseResult struct {
Size string
Status string
Note string
// Already present in the archive; do not re-merge or modify mappings.
matchedCandidates bool
}
// Parse implements the #41/#216 rule: split on the last comma, strip 【...】
@@ -0,0 +1,69 @@
package sybimport
import (
"strings"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
)
// ParseWithCandidates only rescues uncertain parses against previously stored
// Shopee labels. It never uses the current unconfirmed input as its own evidence.
func ParseWithCandidates(raw, specsJSON string) ParseResult {
baseline := Parse(raw)
if baseline.Status != models.SYBParseStatusUncertain {
return baseline
}
specs, err := shopeeproduct.Unmarshal(specsJSON)
if err != nil || shopeeproduct.Validate(specs) != nil {
return baseline
}
for _, dimension := range specs {
if dimension.Role != shopeeproduct.RoleColor && dimension.Role != shopeeproduct.RoleSize && len(dimension.Values) > 0 {
return baseline
}
}
colors, sizes, ambiguous := closedShopeeCandidates(specs)
if ambiguous || len(colors)+len(sizes) == 0 {
return baseline
}
if len(colors) == 0 {
colors = []string{""}
}
if len(sizes) == 0 {
sizes = []string{""}
}
input := normalizeCandidateSpec(stripBrackets(raw))
type pair struct{ color, size string }
matches := map[pair]bool{}
for _, color := range colors {
for _, size := range sizes {
variants := []string{color + "," + size, size + "," + color, color + " " + size, size + " " + color}
if color == "" || size == "" {
variants = []string{color + size}
}
for _, variant := range variants {
if input == normalizeCandidateSpec(variant) {
matches[pair{color, size}] = true
}
}
}
}
if len(matches) != 1 {
return baseline
}
for match := range matches {
return ParseResult{Color: match.color, Size: match.size, Status: models.SYBParseStatusSuccess,
Note: "已按蝦皮既有规格候选唯一核对", matchedCandidates: true}
}
return baseline
}
func normalizeCandidateSpec(value string) string {
value = strings.ReplaceAll(value, ",", ",")
parts := strings.Split(value, ",")
for i, part := range parts {
parts[i] = strings.Join(strings.Fields(part), " ")
}
return strings.Join(parts, ",")
}
@@ -0,0 +1,121 @@
package sybimport_test
import (
"context"
"encoding/json"
"testing"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
"go-admin/app/goauto/sybimport"
)
func candidateSpecs(colors, sizes []string) string {
dimensions := []shopeeproduct.SpecDimension{}
for _, input := range []struct {
role string
values []string
}{{"color", colors}, {"size", sizes}} {
if len(input.values) == 0 {
continue
}
dimension := shopeeproduct.SpecDimension{Name: input.role, Role: input.role}
for _, value := range input.values {
dimension.Values = append(dimension.Values, shopeeproduct.SpecValue{Name: value, Source: shopeeproduct.ValueSourceImport})
}
dimensions = append(dimensions, dimension)
}
encoded, _ := json.Marshal(dimensions)
return string(encoded)
}
func TestCandidateParseRescuesOnlyUniqueWholeInput(t *testing.T) {
for _, test := range []struct {
name, raw string
colors, sizes []string
color, size string
}{
{"combined color", "黑色+白色,L", []string{"黑色+白色", "黑色"}, []string{"L", "XL"}, "黑色+白色", "L"},
{"spaces in color", "浅 灰色,XL", []string{"浅 灰色"}, []string{"XL"}, "浅 灰色", "XL"},
{"full width comma", "黑色,XL", []string{"黑色"}, []string{"XL"}, "黑色", "XL"},
{"reverse full width", "XL,黑色", []string{"黑色"}, []string{"XL"}, "黑色", "XL"},
{"space separator", "黑色 XL", []string{"黑色"}, []string{"XL"}, "黑色", "XL"},
{"size only", "均碼", nil, []string{"均碼"}, "", "均碼"},
{"color only", "黑色+白色", []string{"黑色+白色"}, nil, "黑色+白色", ""},
{"weight description exact", "黑色,XL 建議55.5-60.0公斤穿", []string{"黑色"}, []string{"XL 建議55.5-60.0公斤穿"}, "黑色", "XL 建議55.5-60.0公斤穿"},
} {
t.Run(test.name, func(t *testing.T) {
got := sybimport.ParseWithCandidates(test.raw, candidateSpecs(test.colors, test.sizes))
if got.Status != models.SYBParseStatusSuccess || got.Color != test.color || got.Size != test.size {
t.Fatalf("unexpected parse: %+v", got)
}
})
}
}
func TestCandidateParsePreservesAmbiguityAndNormalResults(t *testing.T) {
for _, test := range []struct{ raw, specs string }{
{"", candidateSpecs([]string{"黑色"}, []string{"XL"})},
{"黑色 XL", "broken"},
{"黑色 XL", "[]"},
{"XL", candidateSpecs([]string{"黑色"}, []string{"XL"})},
{"黑色+白色 XL", candidateSpecs([]string{"黑色", "白色"}, []string{"XL"})},
{"黑色 XL 多余描述", candidateSpecs([]string{"黑色"}, []string{"XL"})},
{"黑色 XL 建議55.5-60.0公斤穿", candidateSpecs([]string{"黑色"}, []string{"XL"})},
{"黑色,XL", candidateSpecs([]string{"黑色", "XL"}, []string{"XL", "黑色"})},
{"浅 灰色 XL", candidateSpecs([]string{"浅 灰色", "浅 灰色"}, []string{"XL"})},
{"白色,L", candidateSpecs([]string{"黑色"}, []string{"XL"})},
{"黑色 XL", `[{"name":"a","role":"color","values":[{"name":"黑色"}]},{"name":"b","role":"color","values":[{"name":"白色"}]},{"name":"s","role":"size","values":[{"name":"XL"}]}]`},
{"黑色 XL", `[{"name":"a","role":"other","values":[{"name":"黑色 XL"}]}]`},
} {
if got, want := sybimport.ParseWithCandidates(test.raw, test.specs), sybimport.Parse(test.raw); got != want {
t.Fatalf("raw %q changed: %+v vs %+v", test.raw, got, want)
}
}
}
func TestImportAndReparseReuseExistingCandidatesWithoutChangingMappings(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
detail := sybimport.DetailInput{ID: 25101, ProductID: 25101, ProductQty: 1, ProductPrice: 1, ProductSpec: "黑色+白色,XL", Raw: json.RawMessage(`{"productSpec":"黑色+白色,XL"}`)}
order := sybimport.OrderInput{Code: "test-251", ShopName: "test"}
first, err := sybimport.ApplyDetail(ctx, db, order, detail)
if err != nil {
t.Fatal(err)
}
if first.SYBProduct.ParseStatus != models.SYBParseStatusUncertain {
t.Fatal("must not self-confirm without candidates")
}
specs := candidateSpecs([]string{"黑色+白色"}, []string{"XL"})
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", *first.SYBProduct.ShopeeProductID).Update("specs_json", specs).Error; err != nil {
t.Fatal(err)
}
outcome, err := sybimport.Reparse(ctx, db, first.SYBProduct.ID, false)
if err != nil || outcome.NewStatus != models.SYBParseStatusSuccess {
t.Fatalf("reparse: %+v %v", outcome, err)
}
second, err := sybimport.ApplyDetail(ctx, db, order, detail)
if err != nil {
t.Fatal(err)
}
if second.SYBProduct.TargetColor != "黑色+白色" || second.SYBProduct.TargetSize != "XL" || second.SYBProduct.ParseStatus != models.SYBParseStatusSuccess {
t.Fatalf("import differs: %+v", second.SYBProduct)
}
if second.SYBProduct.RawJSON != first.SYBProduct.RawJSON {
t.Fatal("raw source changed")
}
if second.ShopeeProduct.SpecsJSON != specs {
t.Fatal("candidate confirmation must not rewrite archive dimensions or mappings")
}
if _, err := sybimport.ManualCorrect(ctx, db, first.SYBProduct.ID, "人工颜色", "人工尺码"); err != nil {
t.Fatal(err)
}
outcome, err = sybimport.Reparse(ctx, db, first.SYBProduct.ID, false)
if err != nil || outcome.Outcome != sybimport.ReparseOutcomeSkippedManual {
t.Fatalf("manual overwritten: %+v %v", outcome, err)
}
third, err := sybimport.ApplyDetail(ctx, db, order, detail)
if err != nil || third.SYBProduct.TargetColor != "人工颜色" {
t.Fatalf("reimport overwrote manual: %v", err)
}
}
+8
View File
@@ -77,6 +77,14 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
return fmt.Errorf("syb product %d: stored raw json is not decodable: %w", record.ID, err)
}
parsed := Parse(raw.ProductSpec)
if parsed.Status == models.SYBParseStatusUncertain && record.ShopeeProductID != nil {
var product models.ShopeeProduct
if err := tx.First(&product, *record.ShopeeProductID).Error; err == nil {
parsed = ParseWithCandidates(raw.ProductSpec, product.SpecsJSON)
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
}
outcome.NewStatus = parsed.Status
if parsed.Color == record.TargetColor && parsed.Size == record.TargetSize && parsed.Status == record.ParseStatus {
+39 -151
View File
@@ -41,8 +41,7 @@ const (
// maxSyncDays bounds one request's window. It is a guard against a typo in
// the date range turning into tens of thousands of remote reads before
// MaxMatches trips.
maxSyncDays = 31
maxTodaySnapshotAttempts = 3
maxSyncDays = 31
)
// SyncReport summarises one sync run.
@@ -82,8 +81,6 @@ type SyncProgress struct {
DaysProcessed int
}
var syncNow = time.Now
type ProgressFunc func(SyncProgress) error
type snapshotDriftError struct {
@@ -94,14 +91,6 @@ type snapshotDriftError struct {
func (err *snapshotDriftError) Error() string { return err.message }
func shanghaiToday() string {
location, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
location = time.FixedZone("Asia/Shanghai", 8*60*60)
}
return syncNow().In(location).Format("2006-01-02")
}
// Sync pulls every shipment order in [dateFrom, dateTo] and folds each detail
// line into the SYB/Shopee archive through ApplyDetail.
//
@@ -109,16 +98,10 @@ func shanghaiToday() string {
// a separate concern (see Connect); a session that dies mid-run surfaces as
// sybclient.ErrSessionInvalid and is treated like any other mid-run failure.
//
// `[必须]` Failure stops the run immediately. Rows already written are NOT
// rolled back — ApplyDetail is idempotent on (order_code, detail_id), so a
// re-run overwrites them rather than duplicating. What must not happen is
// reporting a partial run as a complete one, which would let the missing
// orders go unnoticed forever.
//
// `[必须]` Every day is verified for completeness before anything is written:
// the per-day total is re-read after paging and must not have drifted. SYB's
// list endpoint returns the *page* size in `total` (§4.3, confirmed against
// live data), so the paging loop is driven by listTotal, never by list.total.
// Each validated page is committed independently. Date-local read failures
// preserve committed pages and allow later dates to run. Database, cancellation
// and progress persistence failures stop the run. Completeness is verified at
// the end of each date; partial results must never be reported as full success.
func Sync(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncConfig, dateFrom, dateTo string) (SyncReport, error) {
return SyncWithProgress(ctx, db, client, cfg, dateFrom, dateTo, nil)
}
@@ -190,106 +173,38 @@ func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Cl
plans = append(plans, dayPlan{date: date, total: total})
}
for dayIndex, plan := range plans {
if plan.total == 0 {
if err := emit(dayIndex + 1); err != nil {
return report, err
}
continue
}
// `[必须]` Completeness first, filtering second. loadDailyList proves the
// day's snapshot is whole; filtering before that would let drift among
// other shops' orders hide a hole in the ones we do want
// (docs/12-syb-erp-interface.md §8).
rows, listErr := loadDailyListWithRecovery(ctx, client, plan.date, pageSize, plan.total, maxMatches)
if listErr != nil && len(rows) == 0 {
return report, listErr
}
report.OrderCount += len(rows)
byID := make(map[int64]sybclient.StockRow, len(rows))
ids := make([]int64, 0, len(rows))
for _, row := range rows {
name := stringField(row.Raw, "shopName")
label := name
if sybshop.IsBlank(label) {
label = "(无店铺名)"
}
display, ok := allowed[sybshop.Normalize(name)]
if ok {
label = display
}
if !ok {
entry := report.ShopBreakdown[label]
entry.Skipped++
report.ShopBreakdown[label] = entry
report.ShopSkipped++
continue
}
entry := report.ShopBreakdown[label]
entry.Accepted++
report.ShopBreakdown[label] = entry
report.AcceptedCount++
byID[row.ID] = row
ids = append(ids, row.ID)
}
if len(ids) == 0 {
if err := emit(dayIndex + 1); err != nil {
return report, err
}
continue
}
for start := 0; start < len(ids); start += detailBatch {
end := start + detailBatch
if end > len(ids) {
end = len(ids)
}
batch := ids[start:end]
details, err := client.DetailListByStock(ctx, batch)
if err != nil {
return report, fmt.Errorf("拉取 %s 货运单明细失败(本次同步停止;"+
"已写入的数据保留,重跑会按 (order_code, detail_id) 覆盖): %w", plan.date, err)
}
if err := validateDetailBatch(batch, details); err != nil {
return report, fmt.Errorf("%s 货运单明细不完整:%w;本次同步停止", plan.date, err)
}
for _, detail := range details {
// `[必须]` Re-check the shop on the detail response. The list said
// this order belongs to an enabled shop; if the detail disagrees,
// the two views are inconsistent and importing it would write a
// row for a shop nobody enabled.
name := stringField(detail.Raw, "shopName")
if sybshop.IsBlank(name) {
moveAcceptedToSkipped(&report, stringField(byID[detail.ID].Raw, "shopName"), "(无店铺名)", allowed)
report.AcceptedCount--
report.ShopSkipped++
continue
}
if _, ok := allowed[sybshop.Normalize(name)]; !ok {
moveAcceptedToSkipped(&report, stringField(byID[detail.ID].Raw, "shopName"), name, allowed)
report.AcceptedCount--
report.ShopSkipped++
continue
}
if err := applyStockDetail(ctx, db, byID[detail.ID], detail, &report); err != nil {
return report, err
}
}
if err := emit(dayIndex); err != nil {
return report, err
}
}
if listErr != nil {
return report, listErr
}
if err := emit(dayIndex + 1); err != nil {
daysCompleted := 0
var failures []error
for _, plan := range plans {
if err := ctx.Err(); err != nil {
return report, err
}
_, dayErr := loadDailyList(ctx, client, plan.date, pageSize, plan.total, func(rows []sybclient.StockRow, page int) error {
report.OrderCount += len(rows)
err := importSyncPage(ctx, db, client, rows, allowed, &report)
if err != nil {
return fmt.Errorf("%s 第 %d 页明细/入库阶段失败: %w", plan.date, page, err)
}
if err := emit(daysCompleted); err != nil {
return &syncFatalError{err}
}
return nil
})
if dayErr != nil {
failures = append(failures, dayErr)
var fatal *syncFatalError
if ctx.Err() != nil || errors.As(dayErr, &fatal) || errors.Is(dayErr, sybclient.ErrSessionInvalid) {
return report, errors.Join(failures...)
}
continue
}
daysCompleted++
if err := emit(daysCompleted); err != nil {
return report, errors.Join(append(failures, err)...)
}
}
report.FinishedAt = time.Now().UTC()
return report, nil
return report, errors.Join(failures...)
}
func shopSnapshotNames(allowed map[string]string) []string {
@@ -336,39 +251,7 @@ func moveAcceptedToSkipped(report *SyncReport, listName, detailName string, allo
// `[必须]` The loop bound comes from expectedTotal (listTotal), because
// list.total is the current page's row count, not the filtered total (§4.3).
// Driving the loop with the response's own total would stop after page one.
func loadDailyListWithRecovery(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal, maxMatches int) ([]sybclient.StockRow, error) {
if date != shanghaiToday() {
return loadDailyList(ctx, client, date, pageSize, expectedTotal)
}
var last *snapshotDriftError
for attempt := 1; attempt <= maxTodaySnapshotAttempts; attempt++ {
if attempt > 1 {
total, err := client.ListTotal(ctx, date, date, pageSize)
if err != nil {
return nil, fmt.Errorf("重新查询今天 %s 货运单总数失败: %w", date, err)
}
if total > maxMatches {
return nil, fmt.Errorf("今天 %s 的货运单总数 %d 超过单次同步上限 %d", date, total, maxMatches)
}
expectedTotal = total
}
rows, err := loadDailyList(ctx, client, date, pageSize, expectedTotal)
if err == nil {
return rows, nil
}
var drift *snapshotDriftError
if !errors.As(err, &drift) {
return nil, fmt.Errorf("今天第 %d/%d 次拉取失败: %w", attempt, maxTodaySnapshotAttempts, err)
}
last = drift
}
if last != nil && last.valid && len(last.rows) > 0 {
return last.rows, fmt.Errorf("今天持续变化,已保存本次取得的完整明细,但未形成稳定快照;下次同步继续覆盖: %w", last)
}
return nil, fmt.Errorf("今天持续变化,连续 %d 次未形成稳定快照,本次同步停止: %w", maxTodaySnapshotAttempts, last)
}
func loadDailyList(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal int) ([]sybclient.StockRow, error) {
func loadDailyList(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal int, consume ...func([]sybclient.StockRow, int) error) ([]sybclient.StockRow, error) {
rows := make([]sybclient.StockRow, 0, expectedTotal)
type position struct{ page, row int }
seen := make(map[int64]position, expectedTotal)
@@ -413,6 +296,11 @@ func loadDailyList(ctx context.Context, client *sybclient.Client, date string, p
if len(page) != expectedPageCount {
return nil, &snapshotDriftError{message: fmt.Sprintf("%s 货运单列表第 %d 页不完整且相对初始总数发生变化:预期 %d 行,实际 %d 行", date, pageIndex, expectedPageCount, len(page)), rows: rows, valid: len(page) > expectedPageCount}
}
if len(consume) > 0 {
if err := consume[0](page, pageIndex); err != nil {
return nil, err
}
}
}
// Re-read the total: if it moved while we paged, some order was inserted or
+83
View File
@@ -0,0 +1,83 @@
package sybimport
import (
"context"
"go-admin/app/goauto/sybclient"
"go-admin/app/goauto/sybshop"
"gorm.io/gorm"
)
// Database/progress errors stop the entire run, not merely the current date.
type syncFatalError struct{ error }
func (e *syncFatalError) Unwrap() error { return e.error }
// Read all remote details before opening a page transaction. Publish counters
// only after commit, so a rollback never reports records as saved.
func importSyncPage(ctx context.Context, db *gorm.DB, client *sybclient.Client, rows []sybclient.StockRow, allowed map[string]string, report *SyncReport) error {
next := *report
next.ShopBreakdown = make(map[string]ShopBreakdown, len(report.ShopBreakdown))
for k, v := range report.ShopBreakdown {
next.ShopBreakdown[k] = v
}
byID := make(map[int64]sybclient.StockRow)
var ids []int64
for _, row := range rows {
name := stringField(row.Raw, "shopName")
label := name
if sybshop.IsBlank(label) {
label = "(无店铺名)"
}
display, ok := allowed[sybshop.Normalize(name)]
if ok {
label = display
}
entry := next.ShopBreakdown[label]
if ok {
entry.Accepted++
next.AcceptedCount++
ids = append(ids, row.ID)
byID[row.ID] = row
} else {
entry.Skipped++
next.ShopSkipped++
}
next.ShopBreakdown[label] = entry
}
var details []sybclient.StockDetail
for start := 0; start < len(ids); start += detailBatch {
end := start + detailBatch
if end > len(ids) {
end = len(ids)
}
batch, err := client.DetailListByStock(ctx, ids[start:end])
if err != nil {
return err
}
if err := validateDetailBatch(ids[start:end], batch); err != nil {
return err
}
details = append(details, batch...)
}
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, detail := range details {
name := stringField(detail.Raw, "shopName")
_, ok := allowed[sybshop.Normalize(name)]
if sybshop.IsBlank(name) || !ok {
moveAcceptedToSkipped(&next, stringField(byID[detail.ID].Raw, "shopName"), name, allowed)
next.AcceptedCount--
next.ShopSkipped++
continue
}
if err := applyStockDetail(ctx, tx, byID[detail.ID], detail, &next); err != nil {
return err
}
}
return nil
})
if err != nil {
return &syncFatalError{err}
}
*report = next
return nil
}
@@ -0,0 +1,117 @@
package sybimport
import (
"context"
"errors"
"go-admin/app/goauto/models"
"strings"
"testing"
)
func TestPageFailurePreservesEarlierPagesContinuesDatesAndRerunsIdempotently(t *testing.T) {
db := newSyncTestDB(t)
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}, shortPageAtIndex: 2}
var last SyncProgress
report, err := SyncWithProgress(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02", func(p SyncProgress) error { last = p; return nil })
if err == nil || !strings.Contains(err.Error(), "第 2 页") || report.Created != 2 || report.Updated != 1 || last.DaysProcessed != 1 {
t.Fatalf("report=%+v progress=%+v err=%v", report, last, err)
}
var count int64
db.Model(&models.SYBProduct{}).Count(&count)
if count != 2 {
t.Fatal(count)
}
f.shortPageAtIndex = 0
report, err = Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02")
if err != nil || report.Created != 1 || report.Updated != 3 {
t.Fatalf("rerun=%+v err=%v", report, err)
}
db.Model(&models.SYBProduct{}).Count(&count)
if count != 3 {
t.Fatal(count)
}
}
func TestPageTransactionRollbackDoesNotPublishCountersAndStopsDates(t *testing.T) {
db := newSyncTestDB(t)
if err := db.Exec("CREATE TRIGGER reject_second BEFORE INSERT ON syb_product WHEN NEW.detail_id = 10011 BEGIN SELECT RAISE(ABORT, 'test database failure'); END").Error; err != nil {
t.Fatal(err)
}
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}}
report, err := Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02")
var fatal *syncFatalError
if !errors.As(err, &fatal) || report.Created != 0 || report.DetailCount != 0 || len(f.listCalls) != 1 {
t.Fatalf("report=%+v calls=%v err=%v", report, f.listCalls, err)
}
var count int64
db.Model(&models.SYBProduct{}).Count(&count)
if count != 0 {
t.Fatal(count)
}
}
func TestPageProgressFailureStopsAfterCommittedPage(t *testing.T) {
db := newSyncTestDB(t)
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}}
calls := 0
report, err := SyncWithProgress(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02", func(SyncProgress) error {
calls++
if calls == 2 {
return errors.New("progress unavailable")
}
return nil
})
if err == nil || report.Created != 2 || len(f.listCalls) != 1 {
t.Fatalf("report=%+v err=%v", report, err)
}
}
func TestPageMissingDetailPreservesPriorPageAndContinuesNextDate(t *testing.T) {
db := newSyncTestDB(t)
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}, detailDropID: 1002}
report, err := Sync(context.Background(), db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02")
if err == nil || report.Created != 2 || report.Updated != 1 || report.OrderCount != 4 || len(f.listCalls) != 3 {
t.Fatalf("report=%+v err=%v", report, err)
}
}
func TestPageCancellationPreservesCommitAndStopsNextPage(t *testing.T) {
db := newSyncTestDB(t)
f := &fakeSYB{perDay: map[string]int{"2026-08-01": 3, "2026-08-02": 1}}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
report, err := SyncWithProgress(ctx, db, newSyncClient(t, f), SyncConfig{PageSize: 2, MaxMatches: 100}, "2026-08-01", "2026-08-02", func(p SyncProgress) error {
if p.Report.Created > 0 {
cancel()
}
return nil
})
if !errors.Is(err, context.Canceled) || report.Created != 2 || len(f.listCalls) != 1 {
t.Fatalf("report=%+v err=%v", report, err)
}
}
func TestPartialFinishPreservesProgressReleasesSlotAndIsFilterable(t *testing.T) {
db := newSyncTestDB(t)
ctx := context.Background()
s := NewSyncRunService(db)
run, err := s.Create(ctx, CreateSyncRunInput{DateFrom: "2026-08-01", DateTo: "2026-08-02"})
if err != nil {
t.Fatal(err)
}
report := SyncReport{Created: 2, DetailCount: 2, ShopBreakdown: map[string]ShopBreakdown{}}
if err := s.UpdateProgress(ctx, run.ID, SyncProgress{Report: report, DaysTotal: 2, DaysProcessed: 1}); err != nil {
t.Fatal(err)
}
if err := s.Finish(ctx, run.ID, SyncRunPartial, report, errors.New("第 2 页失败")); err != nil {
t.Fatal(err)
}
row, err := s.Detail(ctx, run.ID)
if err != nil || row.Status != SyncRunPartial || row.ActiveSlot != nil || row.ProgressPercent != 50 || row.Created != 2 {
t.Fatalf("row=%+v err=%v", row, err)
}
result, err := s.List(ctx, SyncRunListRequest{Page: 1, PageSize: 20, Status: SyncRunPartial})
if err != nil || result.Total != 1 {
t.Fatalf("result=%+v err=%v", result, err)
}
}
+2 -1
View File
@@ -18,6 +18,7 @@ import (
const (
SyncRunRunning = "running"
SyncRunSucceeded = "succeeded"
SyncRunPartial = "partial_success"
SyncRunFailed = "failed"
SyncRunInterrupted = "interrupted"
)
@@ -75,7 +76,7 @@ func (s *SyncRunService) UpdateProgress(ctx context.Context, id uint64, progress
}
func (s *SyncRunService) Finish(ctx context.Context, id uint64, status string, report SyncReport, runErr error) error {
if status != SyncRunSucceeded && status != SyncRunFailed {
if status != SyncRunSucceeded && status != SyncRunPartial && status != SyncRunFailed {
return fmt.Errorf("invalid terminal sync status %q", status)
}
payload, err := marshalSyncRunPayload(report.ShopBreakdown, report.ShopFilterSnapshot)
@@ -20,7 +20,7 @@ func (handler Handler) ListSyncRuns(c *gin.Context) {
return
}
status := strings.TrimSpace(c.Query("status"))
if status != "" && status != SyncRunRunning && status != SyncRunSucceeded && status != SyncRunFailed && status != SyncRunInterrupted {
if status != "" && status != SyncRunRunning && status != SyncRunSucceeded && status != SyncRunPartial && status != SyncRunFailed && status != SyncRunInterrupted {
writeError(c, invalidRequest("status 无效"))
return
}
@@ -36,4 +36,12 @@ func TestSyncRunListAndDetailHandlers(t *testing.T) {
if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), `"operatorName":"管理员"`) {
t.Fatalf("详情响应不正确: %d %s", detail.Code, detail.Body.String())
}
if err := NewSyncRunService(db).Finish(context.Background(), run.ID, SyncRunPartial, SyncReport{Created: 1}, nil); err != nil {
t.Fatal(err)
}
partial := httptest.NewRecorder()
engine.ServeHTTP(partial, httptest.NewRequest(http.MethodGet, "/sync-runs?status=partial_success", nil))
if partial.Code != http.StatusOK || !strings.Contains(partial.Body.String(), `"total":1`) {
t.Fatalf("partial filter: %d %s", partial.Code, partial.Body.String())
}
}
-34
View File
@@ -9,7 +9,6 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
@@ -276,39 +275,6 @@ func TestSyncStopsWhenTotalDriftsDuringPaging(t *testing.T) {
}
}
func TestTodaySnapshotDriftRetriesFromFirstPage(t *testing.T) {
originalNow := syncNow
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
t.Cleanup(func() { syncNow = originalNow })
f := &fakeSYB{perDay: map[string]int{"2026-08-29": 10}, totalOverride: map[int]int{1: 11, 2: 10, 3: 10}}
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-29", 10, 10, 1000)
if err != nil || len(rows) != 10 || f.listTotalCalls != 3 {
t.Fatalf("rows=%d totalCalls=%d err=%v", len(rows), f.listTotalCalls, err)
}
}
func TestTodayThirdDriftReturnsValidLastListForDegradedSave(t *testing.T) {
originalNow := syncNow
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
t.Cleanup(func() { syncNow = originalNow })
f := &fakeSYB{perDay: map[string]int{"2026-08-29": 10}, totalOverride: map[int]int{1: 11, 2: 10, 3: 11, 4: 10, 5: 11}}
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-29", 10, 10, 1000)
if err == nil || len(rows) != 10 || !strings.Contains(err.Error(), "已保存本次取得的完整明细") {
t.Fatalf("rows=%d err=%v", len(rows), err)
}
}
func TestHistoricalSnapshotDriftDoesNotRetry(t *testing.T) {
originalNow := syncNow
syncNow = func() time.Time { return time.Date(2026, 8, 29, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) }
t.Cleanup(func() { syncNow = originalNow })
f := &fakeSYB{perDay: map[string]int{"2026-08-28": 10}, totalOverride: map[int]int{1: 11}}
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), "2026-08-28", 10, 10, 1000)
if err == nil || rows != nil || f.listTotalCalls != 1 {
t.Fatalf("rows=%v totalCalls=%d err=%v", rows, f.listTotalCalls, err)
}
}
// 某页行数少于预期同样是不完整快照。
func TestSyncStopsOnShortPage(t *testing.T) {
db := newSyncTestDB(t)
+4 -3
View File
@@ -36,8 +36,9 @@
<el-drawer v-model="detail.open" title="同步记录详情" size="760px">
<div v-loading="detail.loading" class="drawer-body">
<template v-if="detail.item">
<el-alert v-if="detail.item.status === 'failed' || detail.item.status === 'interrupted'" :title="detail.item.errorMessage || statusMeta(detail.item.status).label" :type="detail.item.status === 'failed' ? 'error' : 'warning'" show-icon :closable="false" class="notice" />
<el-alert v-if="detail.item.status === 'failed' || detail.item.status === 'interrupted' || detail.item.status === 'partial_success'" :title="detail.item.errorMessage || statusMeta(detail.item.status).label" :type="detail.item.status === 'failed' ? 'error' : 'warning'" show-icon :closable="false" class="notice" />
<el-alert v-else-if="detail.item.status === 'running'" title="任务正在后台运行,关闭本页不会中断导入。" type="info" show-icon :closable="false" class="notice" />
<el-alert v-if="detail.item.status === 'partial_success'" title="已保存的数据会保留。请重新同步该日期范围补齐缺失数据;重跑不会重复新增相同明细。" type="warning" show-icon :closable="false" class="notice" />
<el-descriptions :column="2" border>
<el-descriptions-item label="记录编号">{{ detail.item.id }}</el-descriptions-item>
<el-descriptions-item label="状态"><el-tag :type="statusMeta(detail.item.status).type">{{ statusMeta(detail.item.status).label }}</el-tag></el-descriptions-item>
@@ -81,7 +82,7 @@ export default {
detail: { open: false, loading: false, item: null },
statusOptions: [
{ label: '执行中', value: 'running' }, { label: '成功', value: 'succeeded' },
{ label: '失败', value: 'failed' }, { label: '已中断', value: 'interrupted' }
{ label: '部分成功', value: 'partial_success' }, { label: '失败', value: 'failed' }, { label: '已中断', value: 'interrupted' }
]
}
},
@@ -92,7 +93,7 @@ export default {
created() { this.load().then(() => { const id = Number(this.$route.query.runId); if (id > 0) this.openDetail(id) }) },
beforeUnmount() { this.stopPolling() },
methods: {
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, partial_success: { label: '部分成功', type: 'warning' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
formatTime(value) { if (!value) return '—'; return new Date(value).toLocaleString('zh-CN', { hour12: false }) },
async load() {
this.loading = true; this.loadError = ''
+28
View File
@@ -6,6 +6,34 @@ async function authenticate(context: any) {
const syncMenu = [{ path: '/syb-sync-runs', component: 'Layout', visible: '0', menuName: 'SybSync', title: 'SYB 同步', children: [{ path: 'index', component: '/goauto/syb-sync-runs/index', visible: '0', menuName: 'GoAutoSybSyncRuns', title: 'SYB 同步记录' }] }];
test('部分成功可筛选并查看已保存数量和补齐提示', async ({ page, context }) => {
await authenticate(context);
const item = { id: 239, status: 'partial_success', dateFrom: '2026-08-01', dateTo: '2026-08-02', daysProcessed: 1, daysTotal: 2, progressPercent: 50, created: 2, updated: 1, orderCount: 3, detailCount: 3, acceptedCount: 3, shopSkipped: 0, errorMessage: '2026-08-01 第 2 页读取失败', shopBreakdown: [] };
let selected = '';
await page.route('**/api/**', route => {
const url = new URL(route.request().url());
if (url.pathname.startsWith('/src/api/')) return route.continue();
if (url.pathname.endsWith('/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['purchaser'], name: '测试用户', avatar: '', introduction: '', permissions: [] } } });
if (url.pathname.endsWith('/sync-runs/239')) return route.fulfill({ json: { code: 200, data: { item } } });
if (url.pathname.endsWith('/sync-runs')) { selected = url.searchParams.get('status') || ''; return route.fulfill({ json: { code: 200, data: { items: [item], total: 1 } } }); }
return route.fulfill({ json: { code: 200, data: url.pathname.endsWith('/menurole') ? syncMenu : [] } });
});
await page.goto('/#/syb-sync-runs/index');
await expect(page.locator('.el-table').getByText('部分成功')).toBeVisible();
await page.locator('.search-form .el-select').click();
await page.getByRole('option', { name: '部分成功' }).click();
await page.getByRole('button', { name: '查询', exact: true }).click();
await expect.poll(() => selected).toBe('partial_success');
await page.getByRole('button', { name: '详情', exact: true }).click();
await expect(page.getByText(item.errorMessage)).toBeVisible();
await expect(page.getByText('已保存的数据会保留。', { exact: false })).toBeVisible();
await expect(page.locator('.el-drawer').getByText('2 / 1', { exact: true })).toBeVisible();
await expect.poll(async () => {
const box = await page.locator('.el-drawer').boundingBox();
return box ? Math.round(box.x + box.width) : 0;
}).toBe(page.viewportSize()!.width);
});
test('同步记录展示失败原因和按店铺统计', async ({ page, context }) => {
await authenticate(context);
await page.route('**/api/**', async route => {