Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ea34e32c1 | ||
|
|
d1ddb04959 | ||
|
|
ca815c30de | ||
|
|
66e0bdfc07 | ||
|
|
93aab6a59b | ||
|
|
ec4802dfe7 | ||
|
|
ec8b14ae21 | ||
|
|
7ac1355a71 | ||
|
|
58a6c1c86d |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 72
|
||||
versionName = "0.9.59"
|
||||
versionCode = 77
|
||||
versionName = "0.9.64"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
+25
-7
@@ -434,8 +434,16 @@ 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 {
|
||||
val root = rootInActiveWindow ?: return false
|
||||
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.UNKNOWN
|
||||
val root = rootInActiveWindow ?: run {
|
||||
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.ROOT_UNAVAILABLE
|
||||
return false
|
||||
}
|
||||
val candidates = mutableListOf<AccessibilityNodeInfo>()
|
||||
walk(root) { node -> if (node.isVisibleToUser && node.isScrollable) candidates += node }
|
||||
val horizontal = direction == SwipeDirection.LEFT || direction == SwipeDirection.RIGHT
|
||||
@@ -444,8 +452,12 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
}
|
||||
val target = (directional.ifEmpty { candidates }).maxByOrNull { candidate ->
|
||||
Rect().also(candidate::getBoundsInScreen).let { it.width().toLong() * it.height() }
|
||||
} ?: return false
|
||||
return swipeNode(target, direction, durationMs, preferScrollAction = false)
|
||||
} ?: run {
|
||||
lastPurchaseSwipeFailure = PurchaseSwipeFailureReason.NO_SCROLLABLE
|
||||
return false
|
||||
}
|
||||
return swipeNode(target, direction, durationMs, preferScrollAction = false,
|
||||
onFailure = { lastPurchaseSwipeFailure = it })
|
||||
}
|
||||
|
||||
override fun swipePurchaseIn(target: SnapshotNode, direction: SwipeDirection, durationMs: Long): Boolean {
|
||||
@@ -646,16 +658,21 @@ 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 false
|
||||
if (bounds.width() < 2 || bounds.height() < 2) return failed(PurchaseSwipeFailureReason.INVALID_BOUNDS)
|
||||
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 false
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return failed(PurchaseSwipeFailureReason.GESTURE_UNSUPPORTED)
|
||||
val left = bounds.left + bounds.width() * 25 / 100
|
||||
val right = bounds.left + bounds.width() * 75 / 100
|
||||
val top = bounds.top + bounds.height() * 25 / 100
|
||||
@@ -693,8 +710,9 @@ class GoAutoAccessibilityService : AccessibilityService(), UiDriver, PddCollecto
|
||||
},
|
||||
null,
|
||||
)
|
||||
if (!queued) return false
|
||||
return latch.await(1500, TimeUnit.MILLISECONDS) && completed.get()
|
||||
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)
|
||||
}
|
||||
|
||||
private fun dispatchCenterTap(bounds: Rect): Boolean {
|
||||
|
||||
+5
-2
@@ -491,8 +491,11 @@ class PurchaseLiveAutomation(
|
||||
restoreFinalEvidenceInCurrentPanel(savedEvidence, expected, suffix)
|
||||
} else {
|
||||
if (!driver.backPurchase()) fail("PURCHASE_ADDRESS_UPDATE_FAILED", "地址保存后无法返回订单页面,未创建订单")
|
||||
waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
|
||||
hasFinalSavedAddressEvidence(it, expected, suffix)
|
||||
val returned = waitFor("PURCHASE_ADDRESS_SAVE_TIMEOUT", "地址保存后无法返回订单页面,未创建订单") {
|
||||
hasFinalSavedAddressEvidence(it, expected, suffix) || isPurchaseConfirmationPanel(it)
|
||||
}
|
||||
if (!hasFinalSavedAddressEvidence(returned, expected, suffix)) {
|
||||
restoreFinalEvidenceInCurrentPanel(returned, expected, suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+91
-14
@@ -3,6 +3,11 @@ 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
|
||||
@@ -32,6 +37,7 @@ 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
|
||||
@@ -89,6 +95,8 @@ 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
|
||||
@@ -370,7 +378,9 @@ 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
|
||||
@@ -385,14 +395,22 @@ class PurchaseRehearsalExecutor(
|
||||
var wait = waitForSpecPanel(input, beforeSignature)
|
||||
wait.failure?.let { return it }
|
||||
if (wait.opened) return null
|
||||
if (wait.changed) {
|
||||
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)}")
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口点击后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
)
|
||||
}
|
||||
|
||||
when (driver.tapSpecFresh(requireNotNull(target))) {
|
||||
val gesture = driver.tapSpecFresh(requireNotNull(recoveryTarget))
|
||||
panelDiagnostic("entryRecovery=attempted;gesture=$gesture;pageChanged=${wait.changed}")
|
||||
when (gesture) {
|
||||
FreshActionResult.AMBIGUOUS -> return failure(
|
||||
SPEC_ENTRY_TARGET_AMBIGUOUS,
|
||||
"规格入口手势目标不唯一 [${specEntryEvidence(screen, 1, entryReadyWaitPolls)}]",
|
||||
@@ -406,7 +424,7 @@ class PurchaseRehearsalExecutor(
|
||||
wait = waitForSpecPanel(input, specActionSignature(wait.screen))
|
||||
wait.failure?.let { return it }
|
||||
if (wait.opened) return null
|
||||
if (wait.changed) {
|
||||
if (wait.changed && recoverableSpecEntry(screen, wait.screen, action, beforeIdentity) == null) {
|
||||
return failure(
|
||||
SPEC_PANEL_EVIDENCE_NOT_MATCHED,
|
||||
"规格入口手势后页面已变化,但规格面板强证据不足 [${panelEvidence(wait.screen)}]",
|
||||
@@ -418,6 +436,48 @@ 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,
|
||||
@@ -543,6 +603,7 @@ 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)
|
||||
@@ -617,7 +678,9 @@ 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 false
|
||||
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 true
|
||||
if (screen.specPanelOpen && fullSummaryTargetMatches(screen.selectedSummary, target)) return true
|
||||
return summarySelectionMatches(screen.selectedSummary, dimension, target, candidates)
|
||||
@@ -791,6 +854,18 @@ 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
|
||||
@@ -827,7 +902,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 = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")), "", screen.specPanelContainer)
|
||||
return Inspection(SpecLookup(failure = unavailableSpec(dimension)), "", screen.specPanelContainer)
|
||||
}
|
||||
val signature = screen.dimensions.joinToString("|") { item ->
|
||||
"${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}"
|
||||
@@ -892,7 +967,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 = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击"))
|
||||
exact.size == 1 -> SpecLookup(failure = unavailableSpec(dimension))
|
||||
else -> null
|
||||
}
|
||||
val rows = specValueRows(values)
|
||||
@@ -1094,16 +1169,17 @@ class PurchaseRehearsalExecutor(
|
||||
private fun applyPostAction(input: PurchaseExecutionInput, action: PurchaseAction): PurchaseExecutionOutcome? {
|
||||
if (action.waitAfterMs > 0) pause(action.waitAfterMs)
|
||||
action.swipeAfter?.let { swipe ->
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
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)
|
||||
@@ -1127,6 +1203,7 @@ class PurchaseRehearsalExecutor(
|
||||
|
||||
private fun currentScreen(input: PurchaseExecutionInput): ParsedPddScreen {
|
||||
val snapshot = driver.capture()
|
||||
pageIdentity = snapshot.packageName to snapshot.activityName
|
||||
val screen = PddScreenParser.parse(snapshot, DEFAULT_COLLECTOR, input.goodsId, null, purchasePanelContext)
|
||||
purchasePanelContext = if (screen.isPddPackage && screen.problem == null && screen.specPanelOpen) {
|
||||
screen.specPanelContainer?.let {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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,6 +478,8 @@ 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) },
|
||||
@@ -486,7 +488,9 @@ class AgentForegroundService : Service() {
|
||||
lastStep.set(step)
|
||||
purchaseStore.updateStep(task.taskId, task.taskAttemptId, step)
|
||||
},
|
||||
panelDiagnostic = { evidence -> Log.i("GoAutoPurchasePanel", "task=${task.taskId};$evidence") },
|
||||
panelDiagnostic = { evidence ->
|
||||
Log.i("GoAutoPurchasePanel", "task=${task.taskId};attempt=$diagnosticAttempt;device=$diagnosticDeviceId;rule=$snapshotHash;$evidence")
|
||||
},
|
||||
beforeOrderSubmit = { evidence ->
|
||||
val boundaryRequestId = UUID.randomUUID().toString()
|
||||
val finalEvidence = JSONObject()
|
||||
|
||||
@@ -141,6 +141,34 @@ class PurchaseLiveAutomationTest {
|
||||
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)
|
||||
@@ -645,6 +673,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 savedSpecPanelRecoveryStuck: Boolean = false,
|
||||
private val duplicatePhoneNodesSameCard: Boolean = false,
|
||||
private val duplicateSemanticAddressCards: Boolean = false,
|
||||
@@ -863,6 +892,7 @@ class PurchaseLiveAutomationTest {
|
||||
page = when (page) {
|
||||
"chooser" -> "payment"
|
||||
"payment" -> if (orderEvidenceBelowFold) "order-folded" else "order"
|
||||
"saved-transition" -> if (backReturnsToSpecPanel) "post-save-spec" else "confirmation"
|
||||
else -> "confirmation"
|
||||
}
|
||||
return true
|
||||
|
||||
+202
-5
@@ -9,6 +9,7 @@ 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
|
||||
@@ -98,7 +99,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rule parameters drive aliases waits and bounded swipes without dangerous clicks`() {
|
||||
fun `rule parameters drive aliases and waits while deprecated panel swipes are ignored`() {
|
||||
val driver = FakePurchaseDriver()
|
||||
val pauses = mutableListOf<Long>()
|
||||
var openCount = 0
|
||||
@@ -112,7 +113,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
assertEquals("rehearsal_completed", outcome.resultType)
|
||||
assertEquals(2_000L, outcome.actualUnitPriceCent)
|
||||
assertEquals(0, openCount)
|
||||
assertEquals(2, driver.swipeCount)
|
||||
assertEquals(0, driver.swipeCount)
|
||||
assertEquals(2L, driver.quantity)
|
||||
assertTrue(driver.clicked.containsAll(listOf("选择规格", "黑色", "XL")))
|
||||
assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") })
|
||||
@@ -764,7 +765,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `open spec panel skips required follow-up swipe only for confirmed non-scrollable panel`() {
|
||||
fun `open spec panel still skips legacy follow-up swipe 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)
|
||||
@@ -773,6 +774,86 @@ 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)
|
||||
@@ -802,6 +883,84 @@ 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(
|
||||
@@ -1227,6 +1386,10 @@ 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,
|
||||
@@ -1244,10 +1407,13 @@ 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 {
|
||||
@@ -1278,6 +1444,7 @@ 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>()
|
||||
|
||||
@@ -1340,6 +1507,8 @@ 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")
|
||||
@@ -1348,6 +1517,12 @@ 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) {
|
||||
@@ -1390,6 +1565,16 @@ 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,
|
||||
@@ -1399,7 +1584,7 @@ class PurchaseRehearsalExecutorTest {
|
||||
540,
|
||||
clickable = true,
|
||||
selected = selectedColor == value,
|
||||
enabled = !allSpecsUnavailable,
|
||||
enabled = !allSpecsUnavailable && value !in unavailableColors,
|
||||
parentPath = colorParent,
|
||||
)
|
||||
}
|
||||
@@ -1437,7 +1622,16 @@ 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)
|
||||
nodes += node("order", "提交订单", 20, if (singleHeading) 2000 else 1100, 500, if (singleHeading) 2080 else 1180, 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("pay", "立即支付", 520, 1100, 1020, 1180, clickable = true)
|
||||
return UiSnapshot(PDD, ACTIVITY, nodes)
|
||||
}
|
||||
@@ -1478,6 +1672,7 @@ 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
|
||||
@@ -1531,6 +1726,8 @@ 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) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
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)))
|
||||
}
|
||||
}
|
||||
@@ -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: f465790ee95fef0f1a501626da50cc66d740f0fc
|
||||
synchronized_at: 2026-09-08T01:38:06Z
|
||||
wiki_revision: 20a1ca65dccde9ca1ecd93419cc1b164a5717fdf
|
||||
synchronized_at: 2026-09-07T13:34:45Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -352,15 +352,3 @@ 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`。
|
||||
|
||||
@@ -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: acd4730253a1ee9c806b823fbee97f59d8bc5d07
|
||||
synchronized_at: 2026-09-08T01:38:12Z
|
||||
wiki_revision: fdcce403423aa799d1517ac75fb38da636ef5217
|
||||
synchronized_at: 2026-09-09T01:22:58Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -58,6 +58,13 @@ synchronized_at: 2026-09-08T01:38:12Z
|
||||
- 对采购人员展示的阶段固定为:待人工处理、未关联 PDD、PDD 待采集、PDD 采集中、PDD 采集失败、规格待匹配、可创建采购、已创建任务、采购成功、待人工核对。
|
||||
- 主阶段优先级为:待人工核对 → 采购成功 → 已创建任务 → 待人工处理 → 未关联 PDD → PDD 待采集/采集中/采集失败 → 规格待匹配 → 可创建采购。每行只显示一个阶段和对应下一步。
|
||||
- “待人工核对”表示订单结果不明确,必须先人工核查并禁止自动重试;“采购成功”表示已取得 PDD 订单号和下单时间,不代表已经支付。
|
||||
- 一个 PDD 订单号只能属于一个采购任务,该唯一性在采购任务保存路径上全局强制(#241)。人工处理结果未知、取消及 lifecycle 保存路径撞号时返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`,提示订单号已属于哪个任务,由采购员人工核对,不静默覆盖原值。
|
||||
- 不可逆边界之后的 `order_created` 结果回传是上述规则的例外:此时 PDD 真单已创建,发现订单号已属于其他任务时不回滚、不判失败,而是把任务降级为 `order_result_unknown`,冲突订单号以「读到订单号 X,但该号已属于任务 CG-yy」保存在任务与 attempt 的 `error_message`,`pdd_order_no` 留空以维持唯一性,保留下单时间与不可逆时间,进入既有人工处理结果未知通道。首要目标是保住「真单已存在」这一事实,不制造无记录的真实订单。
|
||||
- Agent 可按收货地址后缀 `_cg<任务号>` 批量回填订单号与下单时间(#241)。
|
||||
- Agent 侧回填为**人工触发的只读扫描**(#242):入口在采购记录页搜索按钮右侧,输入天数(默认 2)后确认启动。天数口径为滚动 N×24 小时,基准是页面读到的下单时间。采集、采购、回填三者互斥,复用既有任务互斥锁并加原子防重入,忙碌时拒绝启动而不排队、不抢占。
|
||||
- 回填扫描不假设订单列表有序:只有连续观察到至少 5 单且下单时间严格递减、期间无缺失时间时,才允许「超过指定时间即停止」;一旦出现时间回升或缺失,改为扫至内部上限并明确标记「未完整扫描」,不得把不完整结果显示为已扫完。内部上限用于限制设备占用,不作为用户可配置的门禁。
|
||||
- 回填扫描全程只读:确认收货、申请退款、催发货、去支付、立即支付、提交订单、付款、退款、取消订单、再次购买、删除订单永久排除为点击目标;点击目标必须自身可点、子树不含上述词,且不与任何含上述词的可见控件几何重叠;不得按固定坐标盲点。遇登录、验证码或风控立即停止并报告。
|
||||
- 解析不出规范后缀的订单一律跳过,其订单数据不缓存、不上传、不入日志;上传载荷只含后缀、订单号与可选下单时间。本地缓存只采纳服务端确认的事实,并区分页面读到的真实时间与 `irreversible_at` 回落估算值。后缀只承载任务号,请求不含地址全文或收件人信息;只允许回填该设备自己的正式采购任务;页面下单时间优先,缺失时回落该任务的 `irreversible_at` 并标记时间来源,两者皆空则该条失败。
|
||||
- 已失败、已取消或演练完成的旧采购任务不单独占用主阶段;当前数据仍满足条件时恢复显示“可创建采购”,旧任务继续在采购管理留痕和按既有规则处理。
|
||||
- 未选择处理阶段时,商品列表仍先返回,当前页阶段和采购准备继续异步批量读取且不调用 AI Provider;选择阶段筛选时,服务端必须先对完整查询结果派生并筛选阶段,再计算总数和分页,不能只过滤当前页。
|
||||
|
||||
@@ -67,7 +74,7 @@ synchronized_at: 2026-09-08T01:38:12Z
|
||||
- 版本迁移会对历史存活店铺回填 `normalized_name = Normalize(display_name)`;回填只改匹配键且可重复执行。若两个存活店铺回填后会得到同一键,迁移必须整体失败并保留原数据,管理员先人工消除歧义后再执行,不能静默合并、删除或改变店铺启用状态。
|
||||
- 店铺可以从 SYB 真实货运单列表发现,也允许管理员手工补充。只有管理员可以新增、改名、启停和软删除,采购员等其他角色只读。
|
||||
- 没有任何启用店铺时,导入必须在读取凭据、建立会话、验证码 OCR 和任意 SYB 网络请求之前失败,并给出“请先启用店铺”的可读提示。
|
||||
- 同步先预检全范围总数及上限,每页校验原始列表条数、合法 ID 和重复,再按冻结店铺快照获取明细并页事务保存;整日结束核对总数。过滤不能降低完整性校验范围,已保存不能冒充整日完整(#239,c6a962d,待部署版本)。
|
||||
- 同步先预检全范围总数及上限,每页校验原始列表条数、合法 ID 和重复,再按冻结店铺快照获取明细并页事务保存;整日结束核对总数。过滤不能降低完整性校验范围,已保存不能冒充整日完整(#239,c6a962d,已于 2026-09-08 随 d403f3b 部署线上)。
|
||||
- 只有列表与明细响应的店铺名都非空且命中启用快照时才允许写入。明细店铺名为空、变化为未启用店铺或无法匹配时跳过,并计入跳过数量。
|
||||
- 停用或软删除店铺只影响后续导入,不删除历史 SYB 商品、虾皮商品或任务数据。已有错误导入数据的清理必须先给出精确 SQL 和影响行数,再由用户单独确认。
|
||||
|
||||
@@ -397,7 +404,7 @@ synchronized_at: 2026-09-08T01:38:12Z
|
||||
|
||||
- 规格入口与精确规格选择统一按“重新定位唯一目标 → 执行一次无障碍点击 → 读取新页面验证”执行;不得复用旧无障碍节点,不选择相近规格,也不在多候选时默认点击第一个。
|
||||
- 无障碍点击后页面完全无变化时,只允许对解析器已确认的安全规格入口或服务端下发且唯一命中的精确规格执行一次中心手势兜底。目标必须可见、启用、边界有效,手势后仍须以规格面板强证据或精确选中证据确认结果。
|
||||
- 页面发生变化但规格面板强证据不足时,不再继续手势或猜测页面,明确失败并只记录面板类型、候选数量和证据布尔值等无敏感标量。原始控件树、节点文字集合和整屏截图仍不得保存或上传。
|
||||
- 页面发生变化但规格面板强证据不足时,默认明确失败;#243 的同商品页安全入口恢复是限定例外:确认包/Activity 未变、商品标题非空且未变、入口来源与语义未变,无面板、关闭、数量、已选、支付、订单或页面异常信号时,重新定位入口并最多执行一次受控手势。整页普通动态节点变化不单独阻断该恢复;入口缺失或显式/嵌套入口歧义仍拒绝。商品标题一致是当前上下文约束,不宣称已从页面核验 goods_id。诊断只记录入口来源、点击结果/原因、恢复结果和面板数量/布尔等无敏感标量。原始控件树、节点文字集合和整屏截图仍不得保存或上传。
|
||||
- 规格已处于精确选中状态时不得重复点击。规格查找只在解析器唯一识别的规格面板容器内有限滚动,每次滚动后重新定位容器与目标;容器缺失、歧义、到边或验证失败均 fail-closed。
|
||||
- 中心手势兜底不得用于修改/保存地址、创建或提交订单、订单详情入口以及任何支付/付款目标;正式创建订单的一次性不可逆门禁与永久禁止支付规则不变。
|
||||
## PDD 商品反向关联与继续订单采购(#161)
|
||||
@@ -447,7 +454,7 @@ synchronized_at: 2026-09-08T01:38:12Z
|
||||
|
||||
## 客户端密钥与可编辑模块授权(#237)
|
||||
|
||||
以下为提交 `71f7751` 已实现的规则;2026-09-07 本机已迁移并验证管理页面可用,线上尚未部署,真实客户端密钥执行闭环尚未验证。
|
||||
以下为提交 `71f7751` 已实现的规则;2026-09-07 本机已迁移并验证管理页面可用,2026-09-08 已随 d403f3b 完成线上迁移与部署,真实客户端密钥执行闭环尚未验证。
|
||||
|
||||
- 仅管理员创建、查看、编辑授权或停用密钥。首版不提供密钥改名、到期、轮换、恢复启用、删除或任意接口授权。
|
||||
- 模块选择复用“采集采购/采采管理”现有 12 个业务模块;分组勾选只影响当前子模块,新菜单不会自动获得授权。客户端密钥管理、系统账号权限及支付不在可授权模块中。
|
||||
@@ -468,10 +475,29 @@ synchronized_at: 2026-09-08T01:38:12Z
|
||||
|
||||
## SYB 逐页保存与部分成功(#239)
|
||||
|
||||
实现绑定 c6a962d;代码已实现不代表当前线上已部署。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。
|
||||
实现绑定 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 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
|
||||
|
||||
|
||||
## 商品页动态变化下的采购入口恢复(#243)
|
||||
|
||||
实现 ec8b14a,Android 0.9.61/versionCode 74;已构建、未安装或真机验收。规格探测和正式采购共用入口函数,面板已打开时不新增点击;仍在同一商品上下文且安全入口存在时,一次重新定位手势恢复不再依赖整页完全静止。手势后仍未打开则报告入口无效果,不循环点击。恢复不能用于地址、创建订单、支付或未知面板,不修改面板分类器及正式下单边界。CG82/CG85 原首次点击未生效的底层原因尚待新日志和授权真机验证。
|
||||
|
||||
|
||||
## 同一颜色卡片重复选中节点(#244)
|
||||
|
||||
实现 93aab6a,Android 0.9.62/versionCode 75;已构建并授权覆盖安装,真实采购效果待验收。仅在颜色维度完整精确目标已选中、但存在非目标已选候选时,识别同一卡片重复表达:卡片必须是滚动容器直接子级的可点击非滚动 android.view.ViewGroup,节点父链明确、可见启用且边界包含;其他冲突节点仅可为该卡片自身或其中的 ImageView。另一文字选项、另一张卡片、多个精确目标、目标未选中、父链/边界不明均不适用例外。额外核对内存源节点以拒绝被解析器按同名去重隐藏的跨卡片目标;不保存原始树。
|
||||
|
||||
不删除规格数字、不模糊匹配、不改变通用解析器、尺码确认、摘要兜底、入口/滚动/地址/价格/创建订单。没有非目标选中冲突时继续原判断;仅把同卡片容器/图片的重复选中表达从冲突中排除,不新增点击。
|
||||
|
||||
|
||||
## 精确规格不可用与入口恢复诊断(#245)
|
||||
|
||||
实现 ca815c3,Android 0.9.63/versionCode 76;已通过单元测试并构建,未安装、未进行真实采购验收。入口恢复沿用 #243 条件,拒绝时通过 entryRecovery=rejected;reason=<固定原因码> 区分页面身份、商品上下文、面板/支付区域、数量/摘要、标题、入口来源/文案、歧义、规则别名及可用性;不放宽条件,不新增点击。
|
||||
|
||||
在规格面板识别出唯一完整精确候选且 available=false 时,立即返回既有 PURCHASE_SPEC_SAFE_TARGET_MISSING,提示目标颜色/尺码已售罄或当前不可选,未创建订单;已选中目标同样不能绕过不可用检查。只记录 dimension 和 exact_target_unavailable 固定结构证据,不记录规格原文或原始树。原有纵向与横向定位在此条件下本就停止,不能将本改动描述为修复“已识别售罄仍继续搜索”。未识别精确候选时继续既有有界搜索,不推断售罄,不替换规格;解析器、匹配规则、滚动预算、地址与下单流程不变。
|
||||
|
||||
@@ -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: 217239a85b50c880e004ecc19a559fb5e03555e1
|
||||
synchronized_at: 2026-09-08T01:38:42Z
|
||||
wiki_revision: 49dec4c35da793a32822a82e00f3b657757b6d44
|
||||
synchronized_at: 2026-09-07T13:35:25Z
|
||||
<!-- 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` / `partial_success` / `failed` / `interrupted`)、处理天数、货运单/明细/新增/覆盖数量、店铺准入与跳过数量、店铺筛选快照哈希、按店铺的 `accepted` / `skipped` 统计、操作人和起止时间。列表和详情对已登录角色只读开放。服务启动时遗留的 `running` 任务改为 `interrupted`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
|
||||
`sync-runs` 列表支持 `page`、`pageSize`、`status`、`dateFrom`、`dateTo`,详情返回日期范围、状态(`running` / `succeeded` / `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,13 +975,3 @@ 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 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
|
||||
|
||||
@@ -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: dd53eaaaf9e81afa0f286bbabba1d0e48aa17b9c
|
||||
synchronized_at: 2026-09-08T01:39:10Z
|
||||
wiki_revision: a4f4ab200af052bab7ffbabe267238528bcd7cf9
|
||||
synchronized_at: 2026-09-07T07:02:57Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 12 顺云宝(SYB)ERP 接口契约
|
||||
@@ -220,13 +220,22 @@ Admin 默认 `max_matches = 10000`,可以在配置中调整;上限针对整
|
||||
有限重试:单次 HTTP 总超时 60 秒,最多执行 3 次,重试前分别等待 1 秒、2 秒,等待须
|
||||
响应 context 取消。HTTP 401/403、明确会话失效、业务失败、接口数据完整性错误和本地
|
||||
校验失败不得重试;登录、验证码、单件码写入和任何回填请求也不得使用该机制。重试
|
||||
耗尽后返回最后一次错误,外层保留日期、页码和已获取数量上下文,按最终已保存成果将同步标为失败或部分成功。
|
||||
耗尽后返回最后一次错误,外层继续保留日期、页码和已获取数量上下文并将同步标为失败。
|
||||
|
||||
自 #239(实现提交 c6a962d,待部署版本)起,改为按页验证和保存:每页先验证条数、合法 ID、页内/跨页重复,再按冻结店铺快照获取并验证全部明细;外部请求完成后才开启页事务。页内任一入库失败回滚整页,已提交的前页保留,计数在事务提交后累计。
|
||||
|
||||
当天和历史日期统一处理:分页/明细失败或最终总数漂移时停止该日期并继续后续日期,不在同次运行中重新扫描当天,以避免重复写入和计数。数据库故障、进度持久化失败、明确会话失效、上下文取消或总任务超时停止整个范围。下一次人工或定时同步重新扫描日期,按 (order_code, detail_id) 幂等覆盖并保留人工确认。跨页重复不去重后冒充完整数据;最终日期完整性校验仍覆盖所有店铺。此流程替代此前 #235 的当天三次整日快照重扫。
|
||||
|
||||
重复诊断只记录日期、首次/当前页码和行号、start、pageSize、expectedTotal、已获取唯一数量,不记录真实重复 ID、原始响应或个人数据。
|
||||
今天的货运单会在同步期间持续新增。只有 UTC+8 下的今天发生上述快照漂移时,
|
||||
允许只重试今天的列表分页,最多 3 次;已经完成的历史日期不得重复拉取,每次尝试
|
||||
也必须使用独立 ID 集合。第三次仍不稳定时,可以对最后一次取得的合法唯一 ID
|
||||
读取完整明细并按既有 upsert 保存,但本次同步仍记为失败、明确提示当天未形成
|
||||
稳定快照且不推进游标,下一次继续覆盖今天。任何尝试都不得突破 `max_matches`;
|
||||
网络/业务错误、非法 ID 或不完整明细不属于可放宽的快照漂移。
|
||||
`[必须,#235]` 当天跨页重复 ID 纳入上述最多 3 次列表快照尝试(含首次),
|
||||
不增加另一层重试次数。发现跨页重复后丢弃本次列表,从预检总数和第一页重新开始,
|
||||
使用全新 ID 集合;最后一次仍重复时直接失败,不得去重后按成功或降级数据保存。
|
||||
已经完成的历史日期保持其已有结果,不重复拉取;历史日期重复不适用此恢复。
|
||||
每页先检查非法 ID 和页内重复,再检查跨页重叠;同页同时存在页内重复和跨页重叠时
|
||||
仍作为硬错误停止。原有总数漂移/短页的合法唯一列表降级保存条件保持不变。
|
||||
重复诊断只记录日期、当天尝试序号、首次/当前页码与行号、start、pageSize、
|
||||
expectedTotal 和已获取唯一数量,不记录真实重复 ID、原始响应或个人数据。
|
||||
|
||||
### 4.4 统一日期范围同步与覆盖游标
|
||||
|
||||
@@ -249,7 +258,7 @@ GoAuto 同步记录页的立即同步允许管理员和采购员(purchaser)
|
||||
或超过 `max_matches` 时不推进游标。
|
||||
|
||||
`[必须]` 每批明细响应必须与请求的货运单 ID 一一对应。缺失、重复、出现未请求
|
||||
ID,或某张货运单返回空商品明细,都视为不完整并停止当前日期,继续后续日期;已经写入的幂等数据
|
||||
ID,或某张货运单返回空商品明细,都视为不完整并停止同步;已经写入的幂等数据
|
||||
可以保留,但只有所有日期全部成功才推进游标。
|
||||
|
||||
`[必须]` 登录和验证码只是同步前置步骤。日期范围经过自动 OCR 降级、手工
|
||||
@@ -491,9 +500,9 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
|
||||
> 以下「店铺准入」已由 GoAuto #49 采纳并实现;店铺规范化还包括全角/半角统一和忽略大小写。
|
||||
|
||||
`[必须]` **同步先校验原始全量,再做店铺准入。** 顺序固定为:按日期查询原始总数
|
||||
并执行单次容量熔断 → 逐页核对页长和唯一 ID,整日结束复核总数 → 按
|
||||
并执行单次容量熔断 → 拉完当天原始列表并核对分页前后总数、页长和唯一 ID → 按
|
||||
`shopName` 去除首尾空白后与启用店铺精确匹配 → 只为接受的货运单请求明细和入库。
|
||||
店铺过滤只决定明细获取和入库,不能减少原始列表完整性校验范围;已保存不代表整日完整。
|
||||
不能先过滤再做完整性校验,否则非目标店铺的分页漂移会被掩盖。
|
||||
|
||||
`[必须]` 同步开始时只读取一次启用店铺,整次运行使用同一个快照。列表允许但明细
|
||||
响应中的 `shopName` 变为空或非允许店铺时再次拦截。没有启用店铺时在会话/OCR/
|
||||
@@ -648,13 +657,3 @@ 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;代码已实现不代表当前线上已部署。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。
|
||||
|
||||
同步状态增加 `partial_success`(部分成功,15 字符,复用现有 varchar(16),无需迁移)。有错误且 created+updated>0 为部分成功;有错误无已提交明细为 failed;完整且无错误为 succeeded(包括无符合店铺的数据)。中断仍为 interrupted,不把中断追认为成功。所有终态沿用活动槽释放规则。
|
||||
|
||||
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
|
||||
|
||||
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
|
||||
|
||||
@@ -171,9 +171,6 @@ 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,9 +4,44 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTodayOverlapPreservesCommittedPagesWithoutImportingOverlappingPage(t *testing.T) {
|
||||
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)
|
||||
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 {
|
||||
@@ -19,7 +54,7 @@ func TestTodayOverlapPreservesCommittedPagesWithoutImportingOverlappingPage(t *t
|
||||
if err == nil {
|
||||
t.Fatal("overlap was treated as successful sync")
|
||||
}
|
||||
for _, token := range []string{"跨页重复", "firstPage=1", "firstRow=2", "page=2", "row=1", "start=2", "pageSize=2", "expectedTotal=4", "unique=2"} {
|
||||
for _, token := range []string{"连续 3 次", "跨页重复", "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)
|
||||
}
|
||||
@@ -27,7 +62,7 @@ func TestTodayOverlapPreservesCommittedPagesWithoutImportingOverlappingPage(t *t
|
||||
if strings.Contains(err.Error(), "1001") || strings.Contains(err.Error(), "已保存") {
|
||||
t.Fatalf("unsafe diagnosis/degraded save: %v", err)
|
||||
}
|
||||
if len(f.listCalls) != 3 || f.detailCalls != 2 || report.OrderCount != 4 || report.Created != 2 || report.Updated != 2 {
|
||||
if len(f.listCalls) != 7 || f.detailCalls != 1 || report.OrderCount != 2 {
|
||||
t.Fatalf("unexpected retry/import boundary: pages=%d details=%d orders=%d", len(f.listCalls), f.detailCalls, report.OrderCount)
|
||||
}
|
||||
var count int64
|
||||
@@ -37,6 +72,7 @@ func TestTodayOverlapPreservesCommittedPagesWithoutImportingOverlappingPage(t *t
|
||||
}
|
||||
|
||||
func TestPaginationHardErrorsDoNotUseOverlapRecovery(t *testing.T) {
|
||||
freezePaginationToday(t)
|
||||
for _, tc := range []struct {
|
||||
name, date, message string
|
||||
page int
|
||||
@@ -56,7 +92,7 @@ func TestPaginationHardErrorsDoNotUseOverlapRecovery(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rows, err := loadDailyList(context.Background(), newSyncClient(t, f), tc.date, 2, 4)
|
||||
rows, err := loadDailyListWithRecovery(context.Background(), newSyncClient(t, f), tc.date, 2, 4, 100)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ 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
|
||||
maxSyncDays = 31
|
||||
maxTodaySnapshotAttempts = 3
|
||||
)
|
||||
|
||||
// SyncReport summarises one sync run.
|
||||
@@ -81,6 +82,8 @@ type SyncProgress struct {
|
||||
DaysProcessed int
|
||||
}
|
||||
|
||||
var syncNow = time.Now
|
||||
|
||||
type ProgressFunc func(SyncProgress) error
|
||||
|
||||
type snapshotDriftError struct {
|
||||
@@ -91,6 +94,14 @@ 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.
|
||||
//
|
||||
@@ -98,10 +109,16 @@ func (err *snapshotDriftError) Error() string { return err.message }
|
||||
// a separate concern (see Connect); a session that dies mid-run surfaces as
|
||||
// sybclient.ErrSessionInvalid and is treated like any other mid-run failure.
|
||||
//
|
||||
// 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.
|
||||
// `[必须]` 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.
|
||||
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)
|
||||
}
|
||||
@@ -173,38 +190,106 @@ func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Cl
|
||||
plans = append(plans, dayPlan{date: date, total: total})
|
||||
}
|
||||
|
||||
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...)
|
||||
for dayIndex, plan := range plans {
|
||||
if plan.total == 0 {
|
||||
if err := emit(dayIndex + 1); err != nil {
|
||||
return report, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
daysCompleted++
|
||||
if err := emit(daysCompleted); err != nil {
|
||||
return report, errors.Join(append(failures, err)...)
|
||||
// `[必须]` 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 {
|
||||
return report, err
|
||||
}
|
||||
}
|
||||
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
return report, errors.Join(failures...)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func shopSnapshotNames(allowed map[string]string) []string {
|
||||
@@ -251,7 +336,39 @@ 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 loadDailyList(ctx context.Context, client *sybclient.Client, date string, pageSize, expectedTotal int, consume ...func([]sybclient.StockRow, int) error) ([]sybclient.StockRow, error) {
|
||||
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) {
|
||||
rows := make([]sybclient.StockRow, 0, expectedTotal)
|
||||
type position struct{ page, row int }
|
||||
seen := make(map[int64]position, expectedTotal)
|
||||
@@ -296,11 +413,6 @@ 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
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
const (
|
||||
SyncRunRunning = "running"
|
||||
SyncRunSucceeded = "succeeded"
|
||||
SyncRunPartial = "partial_success"
|
||||
SyncRunFailed = "failed"
|
||||
SyncRunInterrupted = "interrupted"
|
||||
)
|
||||
@@ -76,7 +75,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 != SyncRunPartial && status != SyncRunFailed {
|
||||
if status != SyncRunSucceeded && 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 != SyncRunPartial && status != SyncRunFailed && status != SyncRunInterrupted {
|
||||
if status != "" && status != SyncRunRunning && status != SyncRunSucceeded && status != SyncRunFailed && status != SyncRunInterrupted {
|
||||
writeError(c, invalidRequest("status 无效"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -36,12 +36,4 @@ 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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/models"
|
||||
@@ -275,6 +276,39 @@ 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)
|
||||
|
||||
@@ -36,9 +36,8 @@
|
||||
<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' || 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-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-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>
|
||||
@@ -82,7 +81,7 @@ export default {
|
||||
detail: { open: false, loading: false, item: null },
|
||||
statusOptions: [
|
||||
{ label: '执行中', value: 'running' }, { label: '成功', value: 'succeeded' },
|
||||
{ label: '部分成功', value: 'partial_success' }, { label: '失败', value: 'failed' }, { label: '已中断', value: 'interrupted' }
|
||||
{ label: '失败', value: 'failed' }, { label: '已中断', value: 'interrupted' }
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -93,7 +92,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' }, partial_success: { label: '部分成功', type: 'warning' }, failed: { label: '失败', type: 'danger' }, interrupted: { label: '已中断', type: 'warning' }}[status] || { label: status || '-', type: 'info' } },
|
||||
statusMeta(status) { return { running: { label: '执行中', type: 'primary' }, succeeded: { label: '成功', type: 'success' }, 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 = ''
|
||||
|
||||
@@ -6,34 +6,6 @@ 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 => {
|
||||
|
||||
Reference in New Issue
Block a user