From 56f24e1dbc42cd47da1b3d24c8d876d704978fc5 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 22 Sep 2026 11:42:02 +0800 Subject: [PATCH] fix(collection): skip needless spec-panel top swipes and fail explicit empty probes (#334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PddProductDetailCollector.moveSpecPanelToTop always swiped DOWN at least once even when the panel already showed its topmost color heading, and required two identical viewport signatures to stop. On a real device that extra swipe could drag the bottom sheet and make the color/size headings disappear, after which the purchase spec probe silently reported spec_probe_completed with zero dimensions and the server reported the generic PURCHASE_SPEC_NOT_MATCHED, hiding the real cause (goods 8580, tasks 551/552). - moveSpecPanelToTop now skips the restore swipe when the panel is already at top (the first parsed dimension is "color" with visible values), and stops and fails explicitly (SPEC_PANEL_TOP_COLLAPSED) if a restore swipe makes headings/dimensions vanish, instead of swiping further or returning an empty success. - New AgentDiagnosticReason.SPEC_PANEL_TOP_ALREADY / SPEC_PANEL_TOP_COLLAPSED record swipe count and heading/dimension counts before/after (booleans/counts only, no page text). - New PurchaseSpecProbePolicy demotes an Agent spec_probe_completed outcome with zero collected dimensions into an explicit PURCHASE_SPEC_PROBE_EMPTY failure ("规格探测未读取到任何颜色或尺码") before it is persisted/reported, instead of reaching the server as a normal empty probe. - Server resolveProbedSpecs uses the same explicit PURCHASE_SPEC_PROBE_EMPTY code/message when a probe result has zero colors and zero sizes, as defense in depth for older Agent builds. Tests: PddProductDetailCollectorTest (already-at-top skips the restore swipe; not-at-top restores and still collects; vanishing headings stop swiping and fail), PurchaseSpecProbePolicyTest, and service_test.go TestLiveProbeWithNoDimensionsFailsWithExplicitEmptyProbeCode. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- .../automation/PddProductDetailCollector.kt | 55 ++++++++- .../automation/PurchaseSpecProbePolicy.kt | 51 ++++++++ .../agent/persistence/AgentDiagnosticStore.kt | 4 + .../agent/service/AgentForegroundService.kt | 6 +- .../agent/PddProductDetailCollectorTest.kt | 113 +++++++++++++++--- .../agent/PurchaseSpecProbePolicyTest.kt | 78 ++++++++++++ server/app/goauto/purchase/lifecycle.go | 17 ++- server/app/goauto/purchase/service_test.go | 41 +++++++ 8 files changed, 345 insertions(+), 20 deletions(-) create mode 100644 android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseSpecProbePolicy.kt create mode 100644 android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseSpecProbePolicyTest.kt diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt index c0b3f2b..aff5ded 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PddProductDetailCollector.kt @@ -1602,6 +1602,41 @@ class PddProductDetailCollector( .count() } + /** + * #334: the panel already shows its topmost color heading (the first + * parsed dimension is "color") with values visible, so no restore swipe + * is required. Swiping an already-top panel drags the bottom sheet + * further and can make the color/size headings vanish. + */ + private fun isSpecPanelAtTop(screen: ParsedPddScreen): Boolean { + val first = screen.dimensions.firstOrNull() ?: return false + return first.key == "color" && first.values.isNotEmpty() + } + + private fun recordSpecPanelTopSwipe( + reason: AgentDiagnosticReason, + screen: ParsedPddScreen, + swipeCount: Int, + headingCountBefore: Int?, + headingCountAfter: Int?, + ) { + if (taskId <= 0) return + diagnostic( + AgentDiagnosticEvent( + taskId = taskId, + // A dedicated PAGE_STABILITY event on purpose: SPEC_PANEL_ENTRY + // already carries the panel-open/restore diagnostics elsewhere, + // and several tests assert a single SPEC_PANEL_ENTRY event per run. + stage = AgentDiagnosticStage.PAGE_STABILITY, + reason = reason, + attempt = swipeCount, + targetClassName = "type=${screen.specPanelType.name};headBefore=${headingCountBefore ?: -1};" + + "headAfter=${headingCountAfter ?: -1};dim=${screen.dimensions.sumOf { it.values.size }}", + clickableAncestorDepth = screen.panelHeadingCount, + ), + ) + } + private fun moveSpecPanelToTop( goodsId: String, config: PddCollectorConfig, @@ -1611,17 +1646,35 @@ class PddProductDetailCollector( ): PddCollectorResult? { var previous: List? = null var stable = 0 - repeat(config.limits.getValue("specVerticalSwipes")) { + var priorHeadingCount = -1 + var priorDimensionCount = -1 + var swipes = 0 + repeat(config.limits.getValue("specVerticalSwipes")) { pass -> if (now() > deadline) return failure("RULE_NOT_MATCHED", "采集超过规则总超时") val screen = parse(goodsId, config, evidence) screen.problem?.let { return failure(it.code, it.message) } if (!screen.pageEvidenceMatched) return failure("RULE_NOT_MATCHED", "采集期间离开 PDD 商品详情页") + if (pass == 0 && isSpecPanelAtTop(screen)) { + recordSpecPanelTopSwipe(AgentDiagnosticReason.SPEC_PANEL_TOP_ALREADY, screen, swipes, null, screen.panelHeadingCount) + return null + } + val headingCount = screen.panelHeadingCount + val dimensionCount = screen.dimensions.sumOf { it.values.size } + if (pass > 0 && priorHeadingCount > 0 && + (headingCount < priorHeadingCount || (priorDimensionCount > 0 && dimensionCount == 0)) + ) { + recordSpecPanelTopSwipe(AgentDiagnosticReason.SPEC_PANEL_TOP_COLLAPSED, screen, swipes, priorHeadingCount, headingCount) + return failure("SPEC_PANEL_TOP_COLLAPSED", "回顶滑动导致规格标题消失,已停止滑动") + } + priorHeadingCount = headingCount + priorDimensionCount = dimensionCount val signature = viewportSignature(screen) stable = if (previous != null && signature == previous) stable + 1 else 0 previous = signature if (stable >= config.limits.getValue("stableEdgeReads")) return null val anchor = screen.dimensions.flatMap { it.values }.firstOrNull()?.node ?: specPanelContainer ?: return null if (!driver.swipeSpec(SwipeDirection.DOWN, anchor)) return null + swipes++ pause(350) } return null diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseSpecProbePolicy.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseSpecProbePolicy.kt new file mode 100644 index 0000000..9389fa6 --- /dev/null +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseSpecProbePolicy.kt @@ -0,0 +1,51 @@ +package cn.ilapage.goauto.agent.automation + +import org.json.JSONObject + +/** + * #334: a spec probe that produced zero color/size dimensions must fail + * explicitly rather than being reported as `spec_probe_completed` with an + * empty result. An empty probe previously reached the server as a normal + * "no matchable spec" outcome (`PURCHASE_SPEC_NOT_MATCHED`), which hid the + * real cause (an over-swiped spec panel losing its headings, see + * `PddProductDetailCollector.moveSpecPanelToTop`). + * + * This lives outside [PurchaseRehearsalExecutor] on purpose: the executor's + * `probeSpecs` callback is opaque by contract (any JSON payload the caller + * wants to hand to the server), and its own unit tests exercise that + * contract with canned empty-dimension payloads unrelated to this device + * bug. The check instead runs once, at the point where + * [cn.ilapage.goauto.agent.service.AgentForegroundService] turns the real + * collector payload into the outcome that gets persisted and reported. + */ +object PurchaseSpecProbePolicy { + const val EMPTY_PROBE_CODE = "PURCHASE_SPEC_PROBE_EMPTY" + const val EMPTY_PROBE_MESSAGE = "规格探测未读取到任何颜色或尺码" + + /** True when the probe JSON's `dimensions` array has no entries, or entries with no values. */ + fun isDimensionsEmpty(probedSpecsJson: String?): Boolean { + if (probedSpecsJson.isNullOrBlank()) return true + return try { + val dimensions = JSONObject(probedSpecsJson).optJSONArray("dimensions") ?: return true + var total = 0 + for (i in 0 until dimensions.length()) { + total += dimensions.optJSONObject(i)?.optJSONArray("values")?.length() ?: 0 + } + total == 0 + } catch (_: Exception) { + // Malformed payload cannot be trusted as a real, non-empty probe result. + true + } + } + + /** + * Demotes a `spec_probe_completed` outcome with zero collected dimensions + * into an explicit failure. Any other outcome (including real failures, + * or a probe that did collect dimensions) is returned unchanged. + */ + fun demoteIfEmpty(outcome: PurchaseExecutionOutcome): PurchaseExecutionOutcome { + if (outcome.resultType != "spec_probe_completed") return outcome + if (!isDimensionsEmpty(outcome.probedSpecs)) return outcome + return PurchaseExecutionOutcome("failed", EMPTY_PROBE_CODE, EMPTY_PROBE_MESSAGE) + } +} diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt index 64f53a6..7570f93 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/persistence/AgentDiagnosticStore.kt @@ -30,6 +30,10 @@ enum class AgentDiagnosticReason { SPEC_PANEL_RESTORE_LIMIT, SPEC_PANEL_RESTORE_CONTAINER_UNAVAILABLE, SPEC_PANEL_RESTORE_GESTURE_FAILED, + /** #334: the spec panel already showed the topmost color heading; the top-restore swipe was skipped. */ + SPEC_PANEL_TOP_ALREADY, + /** #334: a top-restore swipe made spec headings or dimensions vanish (the bottom sheet was dragged); swiping stopped. */ + SPEC_PANEL_TOP_COLLAPSED, SPEC_PANEL_EVIDENCE_NOT_MATCHED, SPEC_ENTRY_CLICK_NO_EFFECT, SIZE_FOUND, diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index c833f6a..acab072 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt @@ -38,6 +38,7 @@ import cn.ilapage.goauto.agent.automation.PageEvidence import cn.ilapage.goauto.agent.automation.NodeSelector import cn.ilapage.goauto.agent.automation.CollectionRule import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities +import cn.ilapage.goauto.agent.automation.PurchaseSpecProbePolicy import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome import cn.ilapage.goauto.agent.automation.PurchaseLiveAutomation @@ -591,7 +592,7 @@ class AgentForegroundService : Service() { stateStore.update("BUSY", "正在执行${taskLabel}任务 #${task.taskId}", tokenStored = true) updateNotification("$taskLabel #${task.taskId}") - val outcome = if (!snapshotHashValid) { + val rawOutcome = if (!snapshotHashValid) { PurchaseExecutionOutcome("failed", "PURCHASE_RULE_INVALID", "采购规则快照哈希无效") } else { var parseFailure: PurchaseExecutionOutcome? = null @@ -652,6 +653,9 @@ class AgentForegroundService : Service() { } } } + // #334: a spec probe that read zero colors/sizes must fail explicitly + // instead of being reported as a normal, empty spec_probe_completed. + val outcome = PurchaseSpecProbePolicy.demoteIfEmpty(rawOutcome) val requestId = UUID.randomUUID().toString() val payload = purchaseResultPayload(requestId, task.taskAttemptId, outcome) purchaseStore.completeAndEnqueue(task.taskId, task.taskAttemptId, requestId, payload) diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt index a087ba0..7b5657c 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PddProductDetailCollectorTest.kt @@ -974,6 +974,69 @@ class PddProductDetailCollectorTest { assertTrue(driver.captureCount > driver.clickCount) } + @Test + fun `spec panel already at top skips the restore swipe and still collects colors and sizes`() { + // Default fixture state already renders the color heading first (at + // top). #334: moveSpecPanelToTop must not issue any DOWN swipe in + // that case, since dragging an already-top panel can lose headings + // on a real device. + val driver = FakeCollectorDriver( + colorPages = listOf(listOf("红色", "蓝色")), + sizePages = listOf(listOf("S", "M")), + prices = mapOf("红色" to 1099L, "蓝色" to 1299L), + ) + var clock = 0L + val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule()) + + assertTrue(result.successful) + val payload = requireNotNull(result.payload) + assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color }) + assertEquals(listOf("S", "M"), payload.dimensions.first { it.key == "size" }.values) + assertEquals(0, driver.swipes.count { it.first == SwipeDirection.DOWN }) + } + + @Test + fun `spec panel not at top restores with the original swipe behavior before collecting`() { + // The color heading is hidden until one DOWN (top-restore) swipe has + // happened, simulating a panel scrolled past its headings. + val driver = FakeCollectorDriver( + colorPages = listOf(listOf("红色", "蓝色")), + sizePages = listOf(listOf("S", "M")), + prices = mapOf("红色" to 1099L, "蓝色" to 1299L), + specPanelHidesColorInitially = true, + ) + var clock = 0L + val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule()) + + assertTrue(result.successful) + val payload = requireNotNull(result.payload) + assertEquals(listOf("红色", "蓝色"), payload.colorPrices.map { it.color }) + assertEquals(listOf("S", "M"), payload.dimensions.first { it.key == "size" }.values) + assertTrue(driver.swipes.count { it.first == SwipeDirection.DOWN } >= 1) + } + + @Test + fun `spec headings vanishing after a restore swipe stops swiping and fails explicitly`() { + // The panel starts not-at-top (color heading hidden) so the restore + // swipe runs; after that swipe every heading and value disappears, + // simulating the bottom sheet being dragged off screen. #334 + // requires the collector to stop and fail instead of reporting an + // empty successful result. + val driver = FakeCollectorDriver( + colorPages = listOf(listOf("红色", "蓝色")), + sizePages = listOf(listOf("S", "M")), + prices = mapOf("红色" to 1099L, "蓝色" to 1299L), + specPanelHidesColorInitially = true, + specPanelCollapsesAfterTopSwipe = true, + ) + var clock = 0L + val result = PddProductDetailCollector(driver, { clock }, { clock += it }).collect(GOODS_ID, rule()) + + assertFalse(result.successful) + assertEquals("SPEC_PANEL_TOP_COLLAPSED", result.code) + assertEquals(1, driver.swipes.count { it.first == SwipeDirection.DOWN }) + } + @Test fun `duplicate sizes after price cleanup reject the whole size dimension`() { val driver = FakeCollectorDriver( @@ -1044,7 +1107,9 @@ class PddProductDetailCollectorTest { assertTrue(result.successful) assertEquals(listOf("A色", "B色", "C色", "F色", "E色", "D色"), driver.clickedLabels) assertEquals(colors, requireNotNull(result.payload).dimensions.first { it.key == "color" }.values) - assertTrue(driver.swipes.any { it.first == SwipeDirection.DOWN && it.second != null }) + // #334: the panel already shows the color heading at top, so + // moveSpecPanelToTop must not issue a needless DOWN restore swipe. + assertTrue(driver.swipes.none { it.first == SwipeDirection.DOWN }) assertTrue(driver.swipes.any { it.first == SwipeDirection.RIGHT && it.second != null }) assertTrue(driver.swipes.any { it.first == SwipeDirection.UP && it.second != null }) } @@ -1757,6 +1822,14 @@ class PddProductDetailCollectorTest { private val selectedSummaryPrefix: String = "已选", private val imageColorCards: Boolean = false, private val navigateAwayAfterColorClick: Boolean = false, + // #334: simulates a spec panel that is not scrolled to top yet (the + // color heading/values are hidden, only the size heading is visible) + // until one DOWN (top-restore) swipe has happened. + private val specPanelHidesColorInitially: Boolean = false, + // #334: simulates a bottom sheet dragged past its headings by a + // restore swipe — once at least one DOWN swipe has happened, every + // spec heading and value disappears from the panel. + private val specPanelCollapsesAfterTopSwipe: Boolean = false, ) : PddCollectorDriver { var captureCount = 0 var clickCount = 0 @@ -1770,6 +1843,7 @@ class PddProductDetailCollectorTest { private var previousSelected: String? = null private var horizontalPage = 0 private var verticalPage = 0 + private var downSwipeCount = 0 private var priceRead = 0 private var panelOpen = !startWithPanelClosed private var reviewOpen = false @@ -1832,11 +1906,13 @@ class PddProductDetailCollectorTest { node("scroll", "", 0, 380, 1080, 1900, scrollable = scrollablePanel), ) val continuationPage = hideDimensionHeadingsAfterFirstVerticalPage && verticalPage > 0 + val panelCollapsedNow = specPanelCollapsesAfterTopSwipe && downSwipeCount >= 1 + val hideColorNow = specPanelHidesColorInitially && downSwipeCount == 0 val visibleColors = colorVerticalPages?.get(verticalPage.coerceAtMost(colorVerticalPages.lastIndex)) ?: colorPages[horizontalPage.coerceAtMost(colorPages.lastIndex)] val colorRowCount = (visibleColors.size + rowSize - 1) / rowSize val sizeHeadingTop = maxOf(700, 470 + colorRowCount * 90 + 20) - if (!continuationPage) { + if (!continuationPage && !hideColorNow && !panelCollapsedNow) { nodes += node("scroll/color-heading", "颜色分类", 20, 400, 300, 450, parentPath = "scroll") visibleColors .filterNot { hideSelectedColorOption && it == selected } @@ -1877,19 +1953,21 @@ class PddProductDetailCollectorTest { ) } } - if (!continuationPage) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, sizeHeadingTop, 300, sizeHeadingTop + 50, parentPath = "scroll") - sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size -> - nodes += node( - "scroll/size-$size-$captureCount", - size, - 30 + index * 230, - sizeHeadingTop + 70, - 220 + index * 230, - sizeHeadingTop + 140, - clickable = true, - selected = size == initialSelectedSize, - parentPath = "scroll", - ) + if (!continuationPage && !panelCollapsedNow) nodes += node("scroll/size-heading", sizeHeadingLabel, 20, sizeHeadingTop, 300, sizeHeadingTop + 50, parentPath = "scroll") + if (!panelCollapsedNow) { + sizePages[verticalPage.coerceAtMost(sizePages.lastIndex)].forEachIndexed { index, size -> + nodes += node( + "scroll/size-$size-$captureCount", + size, + 30 + index * 230, + sizeHeadingTop + 70, + 220 + index * 230, + sizeHeadingTop + 140, + clickable = true, + selected = size == initialSelectedSize, + parentPath = "scroll", + ) + } } if (extraDimension) { nodes += node("scroll/capacity-heading", "容量", 20, 900, 300, 950, parentPath = "scroll") @@ -1966,7 +2044,10 @@ class PddProductDetailCollectorTest { SwipeDirection.LEFT -> horizontalPage = (horizontalPage + 1).coerceAtMost(colorPages.lastIndex) SwipeDirection.UP -> verticalPage = (verticalPage + 1) .coerceAtMost(maxOf(sizePages.lastIndex, colorVerticalPages?.lastIndex ?: 0)) - SwipeDirection.DOWN -> verticalPage = (verticalPage - 1).coerceAtLeast(0) + SwipeDirection.DOWN -> { + verticalPage = (verticalPage - 1).coerceAtLeast(0) + downSwipeCount++ + } else -> Unit } return true diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseSpecProbePolicyTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseSpecProbePolicyTest.kt new file mode 100644 index 0000000..eb52de0 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseSpecProbePolicyTest.kt @@ -0,0 +1,78 @@ +package cn.ilapage.goauto.agent + +import cn.ilapage.goauto.agent.automation.PurchaseExecutionOutcome +import cn.ilapage.goauto.agent.automation.PurchaseSpecProbePolicy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PurchaseSpecProbePolicyTest { + + @Test + fun `zero dimensions is treated as empty`() { + assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty("""{"dimensions":[]}""")) + } + + @Test + fun `dimensions with no values is treated as empty`() { + assertTrue( + PurchaseSpecProbePolicy.isDimensionsEmpty( + """{"dimensions":[{"key":"color","name":"颜色","values":[]},{"key":"size","name":"尺码","values":[]}]}""", + ), + ) + } + + @Test + fun `null blank or malformed payload is treated as empty`() { + assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty(null)) + assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty("")) + assertTrue(PurchaseSpecProbePolicy.isDimensionsEmpty("not json")) + } + + @Test + fun `dimensions with at least one value is not empty`() { + assertFalse( + PurchaseSpecProbePolicy.isDimensionsEmpty( + """{"dimensions":[{"key":"color","name":"颜色","values":["红色"]}]}""", + ), + ) + } + + @Test + fun `demoteIfEmpty replaces an empty spec_probe_completed outcome with an explicit failure`() { + val outcome = PurchaseExecutionOutcome( + "spec_probe_completed", + message = "商品规格已回传,等待服务端匹配", + probedSpecs = """{"dimensions":[]}""", + ) + + val demoted = PurchaseSpecProbePolicy.demoteIfEmpty(outcome) + + assertEquals("failed", demoted.resultType) + assertEquals("PURCHASE_SPEC_PROBE_EMPTY", demoted.errorCode) + assertEquals("规格探测未读取到任何颜色或尺码", demoted.message) + } + + @Test + fun `demoteIfEmpty leaves a non-empty probe result unchanged`() { + val outcome = PurchaseExecutionOutcome( + "spec_probe_completed", + message = "商品规格已回传,等待服务端匹配", + probedSpecs = """{"dimensions":[{"key":"color","name":"颜色","values":["红色"]}]}""", + ) + + val demoted = PurchaseSpecProbePolicy.demoteIfEmpty(outcome) + + assertEquals(outcome, demoted) + } + + @Test + fun `demoteIfEmpty leaves other outcome types unchanged`() { + val outcome = PurchaseExecutionOutcome("failed", "PURCHASE_SPEC_NOT_MATCHED", "商品规格探测失败") + + val demoted = PurchaseSpecProbePolicy.demoteIfEmpty(outcome) + + assertEquals(outcome, demoted) + } +} diff --git a/server/app/goauto/purchase/lifecycle.go b/server/app/goauto/purchase/lifecycle.go index db7b2a0..7516225 100644 --- a/server/app/goauto/purchase/lifecycle.go +++ b/server/app/goauto/purchase/lifecycle.go @@ -647,12 +647,25 @@ func (s *Service) resolveProbedSpecs(ctx context.Context, taskID uint64, attempt request := aimatching.MatchRequest{TargetColor: task.TargetColorSnapshot, TargetSize: task.TargetSizeSnapshot, Colors: candidates.Colors, Sizes: candidates.Sizes} decision := SpecDecisionRequest{RequestID: uuid.NewString(), TaskAttemptID: attemptID, Source: aimatching.SourceAI} if !complete { - snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, "规格探测结果没有包含所需的可选颜色或尺码")) + // #334: a probe that read zero colors and zero sizes (both dimensions + // empty) is a distinct, more specific failure than "candidates present + // but none matched the target" — it usually means the spec panel was + // over-swiped off the device screen. The Agent now fails this case + // explicitly before submitting, but resolveProbedSpecs keeps the same + // explicit code as a defense in depth for any spec_probe_completed + // result that still arrives empty (e.g. older Agent builds). + reason := "规格探测结果没有包含所需的可选颜色或尺码" + code, message := "PURCHASE_SPEC_NOT_MATCHED", "没有找到可采购的 PDD 颜色或尺码" + if len(candidates.Colors) == 0 && len(candidates.Sizes) == 0 { + reason = "规格探测未读取到任何颜色或尺码" + code, message = "PURCHASE_SPEC_PROBE_EMPTY", "规格探测未读取到任何颜色或尺码" + } + snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, reason)) if marshalErr != nil { return TaskPayload{}, internal(marshalErr) } decision.NoMatch, decision.Decision = true, snapshot - decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", "没有找到可采购的 PDD 颜色或尺码" + decision.FailureCode, decision.FailureMessage = code, message } else { matched, matchErr := s.resolveProbedMatch(ctx, task, request) valid := matchErr == nil && (matched.Source == "manual_mapping" || matched.Source == aimatching.SourceExact || matched.Source == aimatching.SourceAI) && diff --git a/server/app/goauto/purchase/service_test.go b/server/app/goauto/purchase/service_test.go index edcdad2..d7be5a1 100644 --- a/server/app/goauto/purchase/service_test.go +++ b/server/app/goauto/purchase/service_test.go @@ -592,6 +592,47 @@ func TestLiveProbeUsesAccurateMessageWhenCompleteCandidatesCannotBeMatched(t *te } } +// #334: a probe with zero colors and zero sizes is a distinct, more specific +// failure ("规格探测未读取到任何颜色或尺码") than the generic "candidates +// present but none matched" message, so operators can tell an over-swiped +// device probe apart from an ordinary spec mismatch. +func TestLiveProbeWithNoDimensionsFailsWithExplicitEmptyProbeCode(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), false) + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙白").Error; err != nil { + t.Fatal(err) + } + matcher := &liveProbeMatcher{err: errors.New("AI should not be called for an empty probe")} + s := testService(db) + s.Matcher = matcher + task, err := createLive(t, s, f) + if err != nil { + t.Fatal(err) + } + if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil { + t.Fatal(err) + } + first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) + if err != nil { + t.Fatal(err) + } + probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[]}`)} + resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token) + if err != nil || matcher.calls != 0 || resolved.Status != models.PurchaseTaskStatusFailed { + t.Fatalf("empty probe did not fail closed without calling AI: %+v calls=%d err=%v", resolved, matcher.calls, err) + } + var saved models.PurchaseTask + if err = db.First(&saved, task.ID).Error; err != nil { + t.Fatal(err) + } + if saved.ErrorCode == nil || *saved.ErrorCode != "PURCHASE_SPEC_PROBE_EMPTY" { + t.Fatalf("empty probe did not use the explicit empty-probe code: %+v", saved) + } + if saved.ErrorMessage == nil || *saved.ErrorMessage != "规格探测未读取到任何颜色或尺码" { + t.Fatalf("empty probe did not use the explicit empty-probe message: %+v", saved) + } +} + func TestSecondSpecProbeFailsClosedWithoutClearingDecision(t *testing.T) { db := testDB(t) f := seed(t, db, liveCaps(), false)