From 866aea102f00896660a7668d949ce87ac760a287 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 31 Aug 2026 11:16:45 +0800 Subject: [PATCH] fix(goauto): preserve exact spec failure evidence (#164) --- android/app/build.gradle.kts | 4 +- .../automation/PurchaseRehearsalExecutor.kt | 67 +++++++--- .../goauto/agent/network/AgentApiClient.kt | 2 + .../agent/service/AgentForegroundService.kt | 1 + .../agent/PurchaseRehearsalExecutorTest.kt | 119 +++++++++++++++++- docs/08-agent-api-contract.md | 18 ++- server/app/goauto/purchase/lifecycle.go | 13 +- server/app/goauto/purchase/reset_test.go | 17 +++ server/app/goauto/purchase/service_test.go | 50 +++++++- server/app/goauto/purchase/types.go | 78 ++++++------ 10 files changed, 295 insertions(+), 74 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2ae7790..ea471c2 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "cn.ilapage.goauto.agent" minSdk = 23 targetSdk = 34 - versionCode = 42 - versionName = "0.9.29" + versionCode = 43 + versionName = "0.9.30" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt index 80b88ba..4a27de0 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/automation/PurchaseRehearsalExecutor.kt @@ -28,6 +28,7 @@ data class PurchaseExecutionInput( val goodsId: String, val mappedColor: String, val mappedSize: String, + val specResolutionAllowed: Boolean = false, val quantity: Long, val minUnitPriceCent: Long, val maxUnitPriceCent: Long, @@ -100,7 +101,11 @@ class PurchaseRehearsalExecutor( } } if (failure != null) { - if (failure.errorCode == "PURCHASE_SPEC_NOT_MATCHED" && PurchaseActionType.PROBE_SPECS in rule.actions.map { it.type }) { + if ( + failure.errorCode == SPEC_TARGET_NOT_VISIBLE && + input.specResolutionAllowed && + PurchaseActionType.PROBE_SPECS in rule.actions.map { it.type } + ) { return probeOutcome() } return failure @@ -275,10 +280,12 @@ class PurchaseRehearsalExecutor( private fun selectSpecs(input: PurchaseExecutionInput, rule: PurchaseRule): PurchaseExecutionOutcome? { if (input.mappedColor.isBlank() && input.mappedSize.isBlank()) { - return failure("PURCHASE_SPEC_NOT_MATCHED", "没有下发可用的商品规格") + return failure(SPEC_TARGET_NOT_VISIBLE, "没有下发可用的精确商品规格") } - listOf("color" to input.mappedColor, "size" to input.mappedSize).forEach { (dimension, target) -> - if (target.isBlank()) return@forEach + listOf("color" to input.mappedColor, "size" to input.mappedSize).forEach { (dimension, rawTarget) -> + if (rawTarget.isBlank()) return@forEach + val target = normalizedTarget(dimension, rawTarget) + ?: return failure(SPEC_SAFE_TARGET_MISSING, "下发规格无法安全规范化") selectExactSpecWithRetry(input, dimension, target)?.let { return it } } val requiredCapability = if (input.executionMode == "live") PurchaseAgentCapabilities.LIVE_V1 else PurchaseAgentCapabilities.REHEARSAL_V1 @@ -301,7 +308,7 @@ class PurchaseRehearsalExecutor( val outcome = driver.clickFreshDetailed(requireNotNull(lookup.node)) lastClickReason = outcome.reason if (outcome.result == FreshActionResult.AMBIGUOUS) { - return failure("RULE_AMBIGUOUS", "规格 $target 匹配到多个控件") + return failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件") } clickSucceeded = clickSucceeded || outcome.result == FreshActionResult.SUCCESS @@ -323,15 +330,16 @@ class PurchaseRehearsalExecutor( } if (clickSucceeded) { - return failure("PURCHASE_SPEC_NOT_MATCHED", "规格 $target 未能精确选中") + return failure(SPEC_SELECTION_UNCONFIRMED, "点击规格后未能确认精确选中状态") } - val message = when (lastClickReason) { - FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> "规格 $target 点击前发生变化" - FreshClickReason.NO_CLICKABLE_ANCESTOR -> "规格 $target 暂时无法点击" - FreshClickReason.ACTION_CLICK_FALSE -> "规格 $target 点击未生效" - else -> "规格 $target 选择失败" + val subreason = when (lastClickReason) { + FreshClickReason.ROOT_UNAVAILABLE -> SPEC_CLICK_ROOT_UNAVAILABLE + FreshClickReason.TARGET_NOT_FOUND -> SPEC_CLICK_TARGET_STALE + FreshClickReason.NO_CLICKABLE_ANCESTOR -> SPEC_CLICK_NO_CLICKABLE_ANCESTOR + FreshClickReason.ACTION_CLICK_FALSE -> SPEC_CLICK_ACTION_FALSE + else -> SPEC_CLICK_UNKNOWN } - return failure("RULE_ACTION_FAILED", message) + return failure(SPEC_CLICK_FAILED, subreason) } private fun isExactSpecSelected(screen: ParsedPddScreen, dimension: String, target: String): Boolean { @@ -371,11 +379,14 @@ class PurchaseRehearsalExecutor( return SpecLookup(failure = failure("RULE_NOT_MATCHED", "商品规格面板已经关闭")) to "" } val dimensionValues = screen.dimensions.filter { it.key == dimension }.flatMap { it.values } - val exact = dimensionValues.filter { it.text == target && it.available } + val exact = dimensionValues.filter { it.text == target } if (exact.size > 1) { - return SpecLookup(failure = failure("RULE_AMBIGUOUS", "规格 $target 匹配到多个控件")) to "" + return SpecLookup(failure = failure(SPEC_TARGET_AMBIGUOUS, "精确规格匹配到多个控件")) to "" + } + if (exact.size == 1 && exact.single().available) return SpecLookup(node = exact.single().node) to "" + if (exact.size == 1) { + return SpecLookup(failure = failure(SPEC_SAFE_TARGET_MISSING, "精确规格当前不可安全点击")) to "" } - if (exact.size == 1) return SpecLookup(node = exact.single().node) to "" val signature = screen.dimensions.joinToString("|") { item -> "${item.key}:${item.values.joinToString(",") { value -> "${value.text}:${value.available}" }}" } @@ -395,7 +406,7 @@ class PurchaseRehearsalExecutor( currentSignature = inspected.second } } - return SpecLookup(failure = failure("PURCHASE_SPEC_NOT_MATCHED", "没有找到规格:$target")) + return SpecLookup(failure = failure(SPEC_TARGET_NOT_VISIBLE, "有界搜索后未找到精确规格")) } private fun setQuantity(quantity: Long): PurchaseExecutionOutcome? { @@ -434,9 +445,12 @@ class PurchaseRehearsalExecutor( private fun verifySummary(input: PurchaseExecutionInput, observedPrice: Long?): PurchaseExecutionOutcome? { val screen = currentScreen(input) screen.problem?.let { return failure(it.code, it.message) } - val selected = listOf("color" to input.mappedColor, "size" to input.mappedSize).filter { it.second.isNotBlank() } + val selected = listOf("color" to input.mappedColor, "size" to input.mappedSize).filter { it.second.isNotBlank() }.map { (dimension, rawTarget) -> + dimension to (normalizedTarget(dimension, rawTarget) + ?: return failure(SPEC_SAFE_TARGET_MISSING, "下发规格无法安全规范化")) + } if (selected.any { (dimension, target) -> !isExactSpecSelected(screen, dimension, target) }) { - return failure("PURCHASE_SPEC_NOT_MATCHED", "最终规格复核失败") + return failure(SPEC_SELECTION_UNCONFIRMED, "最终规格复核未能确认精确选中状态") } if (readQuantity() != input.quantity) return failure("PURCHASE_QUANTITY_MISMATCH", "最终数量复核失败") val price = screen.priceCent ?: observedPrice ?: return failure("RULE_NOT_MATCHED", "最终价格复核失败") @@ -465,6 +479,13 @@ class PurchaseRehearsalExecutor( return PurchaseExecutionOutcome("spec_probe_completed", message = "商品规格已回传,等待服务端匹配", probedSpecs = result) } + private fun normalizedTarget(dimension: String, raw: String): String? { + val trimmed = raw.trim() + if (dimension != "size") return trimmed.takeIf(String::isNotBlank) + val normalized = SpecValueNormalizer.normalizeSize(trimmed) + return normalized.takeIf(SpecValueNormalizer::isSafeSize) + } + private fun currentScreen(input: PurchaseExecutionInput): ParsedPddScreen = PddScreenParser.parse(driver.capture(), DEFAULT_COLLECTOR, input.goodsId, null) @@ -497,6 +518,16 @@ class PurchaseRehearsalExecutor( private const val SPEC_SELECTION_FAILED_VERIFY_POLLS = 5 private const val SPEC_SELECTION_POLL_MILLIS = 100L private const val SOLD_OUT_PANEL_CLOSE_MILLIS = 300L + private const val SPEC_TARGET_NOT_VISIBLE = "PURCHASE_SPEC_TARGET_NOT_VISIBLE" + private const val SPEC_TARGET_AMBIGUOUS = "PURCHASE_SPEC_TARGET_AMBIGUOUS" + private const val SPEC_SAFE_TARGET_MISSING = "PURCHASE_SPEC_SAFE_TARGET_MISSING" + private const val SPEC_CLICK_FAILED = "PURCHASE_SPEC_CLICK_FAILED" + private const val SPEC_CLICK_ROOT_UNAVAILABLE = "root_unavailable" + private const val SPEC_CLICK_TARGET_STALE = "target_stale" + private const val SPEC_CLICK_NO_CLICKABLE_ANCESTOR = "no_clickable_ancestor" + private const val SPEC_CLICK_ACTION_FALSE = "action_click_false" + private const val SPEC_CLICK_UNKNOWN = "unknown" + private const val SPEC_SELECTION_UNCONFIRMED = "PURCHASE_SPEC_SELECTION_UNCONFIRMED" val DEFAULT_COLLECTOR = PddCollectorConfig( collectorId = "pddProductDetailV1", specEntryStrategy = "safeBottomSpecEntryV1", diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt index d05737e..517c37e 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt @@ -81,6 +81,7 @@ data class PurchaseAgentTask( val pddGoodsId: String, val mappedColor: String, val mappedSize: String, + val specResolutionAllowed: Boolean, val quantity: Long, val minUnitPriceCent: Long, val maxUnitPriceCent: Long, @@ -568,6 +569,7 @@ class AgentApiClient(private val serverUrl: String) { pddGoodsId = data.getString("pddGoodsId"), mappedColor = data.optString("mappedColor"), mappedSize = data.optString("mappedSize"), + specResolutionAllowed = data.optBoolean("specResolutionAllowed", false), quantity = data.getLong("quantity"), minUnitPriceCent = data.getLong("minUnitPriceCent"), maxUnitPriceCent = data.getLong("maxUnitPriceCent"), 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 346e69f..ef357c8 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 @@ -495,6 +495,7 @@ class AgentForegroundService : Service() { goodsId = task.pddGoodsId, mappedColor = task.mappedColor, mappedSize = task.mappedSize, + specResolutionAllowed = task.specResolutionAllowed, quantity = task.quantity, minUnitPriceCent = task.minUnitPriceCent, maxUnitPriceCent = task.maxUnitPriceCent, diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt index 196a7fc..3afe718 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt @@ -1,6 +1,8 @@ package cn.ilapage.goauto.agent import cn.ilapage.goauto.agent.automation.FreshActionResult +import cn.ilapage.goauto.agent.automation.FreshClickOutcome +import cn.ilapage.goauto.agent.automation.FreshClickReason import cn.ilapage.goauto.agent.automation.NodeBounds import cn.ilapage.goauto.agent.automation.PurchaseAgentCapabilities import cn.ilapage.goauto.agent.automation.PurchaseExecutionInput @@ -60,13 +62,26 @@ class PurchaseRehearsalExecutorTest { fun `missing exact spec returns probe and never chooses similar value`() { val driver = FakePurchaseDriver(colors = listOf("黑色加绒")) val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {}) - .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + .execute(input().copy(specResolutionAllowed = true), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) assertEquals("spec_probe_completed", outcome.resultType) assertEquals("{\"dimensions\":[]}", outcome.probedSpecs) assertFalse(driver.clicked.contains("黑色加绒")) assertTrue(driver.swipeCount <= 11) } + @Test + fun `missing exact spec without server permission preserves the real failure`() { + val driver = FakePurchaseDriver(colors = listOf("黑色加绒")) + var probes = 0 + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{\"dimensions\":[]}" }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("failed", outcome.resultType) + assertEquals("PURCHASE_SPEC_TARGET_NOT_VISIBLE", outcome.errorCode) + assertEquals(0, probes) + assertFalse(driver.clicked.contains("黑色加绒")) + } + @Test fun `exact size below the visible panel is found after a bounded vertical search`() { val driver = FakePurchaseDriver(hiddenSizeUntilUpSwipes = 3) @@ -128,9 +143,10 @@ class PurchaseRehearsalExecutorTest { hideSizeSelectedState = true, ) val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {}) - .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + .execute(input().copy(specResolutionAllowed = true), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) - assertEquals("spec_probe_completed", outcome.resultType) + assertEquals("failed", outcome.resultType) + assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", outcome.errorCode) } @Test @@ -144,7 +160,8 @@ class PurchaseRehearsalExecutorTest { val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {}) .execute(input().copy(mappedSize = target), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) - assertEquals("spec_probe_completed", outcome.resultType) + assertEquals("failed", outcome.resultType) + assertEquals("PURCHASE_SPEC_SELECTION_UNCONFIRMED", outcome.errorCode) } @Test @@ -155,11 +172,78 @@ class PurchaseRehearsalExecutorTest { val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) - assertEquals("RULE_ACTION_FAILED", outcome.errorCode) + assertEquals("PURCHASE_SPEC_CLICK_FAILED", outcome.errorCode) + assertEquals("action_click_false", outcome.message) assertEquals(3, driver.sizeClickCount) assertEquals(null, driver.size) } + @Test + fun `click failures use one stage and stable subreasons`() { + val cases = listOf( + FreshClickReason.ROOT_UNAVAILABLE to "root_unavailable", + FreshClickReason.TARGET_NOT_FOUND to "target_stale", + FreshClickReason.NO_CLICKABLE_ANCESTOR to "no_clickable_ancestor", + FreshClickReason.ACTION_CLICK_FALSE to "action_click_false", + ) + + cases.forEach { (reason, expected) -> + val driver = FakePurchaseDriver(forcedSizeClickReason = reason) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("PURCHASE_SPEC_CLICK_FAILED", outcome.errorCode) + assertEquals(expected, outcome.message) + } + } + + @Test + fun `visible unavailable exact target fails as safe target missing without probing`() { + val driver = FakePurchaseDriver(unavailableSizes = setOf("XL")) + var probes = 0 + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {}) + .execute(input().copy(specResolutionAllowed = true), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("PURCHASE_SPEC_SAFE_TARGET_MISSING", outcome.errorCode) + assertEquals(0, probes) + assertEquals(0, driver.sizeClickCount) + } + + @Test + fun `unsafe normalized targets fail without probing`() { + listOf("¥15.78", "XL ¥15.78起").forEach { unsafeTarget -> + val driver = FakePurchaseDriver() + var probes = 0 + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { probes++; "{}" }, pause = {}) + .execute(input().copy(mappedSize = unsafeTarget, specResolutionAllowed = true), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("PURCHASE_SPEC_SAFE_TARGET_MISSING", outcome.errorCode) + assertEquals(0, probes) + } + } + + @Test + fun `legacy task target with a safe trailing price selects the clean page value`() { + val clean = "XL【建议121-140斤】" + val driver = FakePurchaseDriver(sizes = listOf(clean)) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input().copy(mappedSize = "$clean ¥15.78"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("rehearsal_completed", outcome.resultType) + assertTrue(driver.clicked.contains(clean)) + } + + @Test + fun `two raw page values that normalize to one size fail as ambiguous`() { + val driver = FakePurchaseDriver(sizes = listOf("XL ¥15.78", "XL ¥16.80")) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("failed", outcome.resultType) + assertEquals("PURCHASE_SPEC_TARGET_AMBIGUOUS", outcome.errorCode) + assertEquals(0, driver.sizeClickCount) + } + @Test fun `price outside range fails before any order action`() { val driver = FakePurchaseDriver(priceCent = 4_000) @@ -404,6 +488,7 @@ class PurchaseRehearsalExecutorTest { private val openClickResults: MutableList = mutableListOf(), private val openPddOnFailedClick: Boolean = false, private val sizeClickResults: MutableList = mutableListOf(), + private val forcedSizeClickReason: FreshClickReason? = null, private val sizeSelectsOnFailedClick: Boolean = false, private val selectedSizeSummaryOverride: String? = null, private val hideSizeSelectedState: Boolean = false, @@ -411,6 +496,7 @@ class PurchaseRehearsalExecutorTest { private val recoverSoldOutAfterPull: Boolean = false, private val pullDownSucceeds: Boolean = true, private val loseEvidenceAfterPull: Boolean = false, + private val unavailableSizes: Set = emptySet(), allSpecsUnavailable: Boolean = false, ) : PurchaseUiDriver { var browser = false @@ -482,7 +568,7 @@ class PurchaseRehearsalExecutorTest { 780, clickable = true, selected = !hideSizeSelectedState && size == visibleSize, - enabled = !allSpecsUnavailable, + enabled = !allSpecsUnavailable && visibleSize !in unavailableSizes, parentPath = "scroll", ) } @@ -516,6 +602,27 @@ class PurchaseRehearsalExecutorTest { return FreshActionResult.SUCCESS } + override fun clickFreshDetailed(target: SnapshotNode): FreshClickOutcome { + if (target.label in sizes && forcedSizeClickReason != null) { + sizeClickCount++ + val result = when (forcedSizeClickReason) { + FreshClickReason.ROOT_UNAVAILABLE, FreshClickReason.TARGET_NOT_FOUND -> FreshActionResult.NOT_FOUND + FreshClickReason.NO_CLICKABLE_ANCESTOR, FreshClickReason.ACTION_CLICK_FALSE, FreshClickReason.UNKNOWN -> FreshActionResult.FAILED + FreshClickReason.TARGET_AMBIGUOUS -> FreshActionResult.AMBIGUOUS + FreshClickReason.SUCCESS -> FreshActionResult.SUCCESS + } + return FreshClickOutcome(result, forcedSizeClickReason) + } + val result = clickFresh(target) + val reason = when (result) { + FreshActionResult.SUCCESS -> FreshClickReason.SUCCESS + FreshActionResult.NOT_FOUND -> FreshClickReason.TARGET_NOT_FOUND + FreshActionResult.AMBIGUOUS -> FreshClickReason.TARGET_AMBIGUOUS + FreshActionResult.FAILED -> FreshClickReason.ACTION_CLICK_FALSE + } + return FreshClickOutcome(result, reason) + } + override fun tapPurchaseFresh(target: SnapshotNode): FreshActionResult = clickFresh(target) override fun inputFresh(target: SnapshotNode, value: String): FreshActionResult { diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 79cc3af..deacefa 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -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: 5caf37fa5589e63e128e2beb9bc6acc24edf1cb1 -synchronized_at: 2026-08-31T01:35:05Z +wiki_revision: 52119b8626496af10f232fb663595779427f37f0 +synchronized_at: 2026-08-31T03:09:59Z @@ -405,7 +405,7 @@ POST /api/agent/v1/tasks/{taskId}/fail | `DEVICE_BUSY` | 设备已有活动任务 | 否 | | `DEVICE_CAPABILITY_MISMATCH` | 设备缺少任务规则要求的版本化能力 | 否 | -## 采购任务共享契约(#33、#34、#36、#42、#44、#53、#67) +## 采购任务共享契约(#33、#34、#36、#42、#44、#53、#67、#164) 本节固定采购域的数据和接口边界;`purchase_task`、`purchase_task_attempt`、规则校验、服务端 HTTP 状态机、Admin 已有任务管理、SYB 创建入口、Android 演练执行及 #36 正式地址/订单动作已经落地。#36 尚未获得真机创建订单授权和验收。当前项目没有自动支付动作、入口或测试;后续支付能力必须另行建单并通过显式能力位、服务端开关、金额上限和人工授权门禁。 @@ -526,7 +526,11 @@ Admin 列表与详情由 #35 实现;#67 增加 `shopeeOrderNoSnapshot` 的列 创建阶段需要外部 AI 的任务会先以 `pending` 与持久匹配工作项同事务创建并立即返回 Admin。工作项处于 `pending`、`running`、`retry_wait` 或 `manual_required` 时,`next` 不返回该任务,直接调用 `claim` 或 `start` 也会返回状态冲突。匹配完成后 Agent 仍只接收服务端固化的精确 PDD 原始标签;Android 接口和请求体不新增 AI、候选或人工决策字段。 -结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。慢路径第一趟提交规格后释放设备与已知账号租约,任务进入 `spec_probe_pending`;服务端先使用已确认人工映射,否则对实时/档案可选规格做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。第二趟只会收到服务端已固化的精确 PDD 原始标签;Agent 只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整文字精确点击,连续没有新证据或达到上限即停止。若第二趟仍提交 `spec_probe_completed`,服务端将任务和当前 attempt 明确标记失败、释放租约并保留第一次规格决策,不再进入 `spec_probe_pending` 或再次派发。无匹配、候选不完整、歧义或 Provider 异常同样使任务失败。`order_result_unknown` 只允许管理员或采购员人工解除,永不自动重派。 +结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。慢路径第一趟提交规格后释放设备与已知账号租约,任务进入 `spec_probe_pending`;服务端先使用已确认人工映射,否则对实时/档案可选规格做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。第二趟只会收到服务端已固化的精确 PDD 原始标签;Agent 只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。 + +任务 payload 新增必传布尔字段 `specResolutionAllowed`,它是 Android 是否可以把一次 `PURCHASE_SPEC_TARGET_NOT_VISIBLE` 转为 `spec_probe_completed` 的唯一资格事实。服务端仅在 `taskType=syb_order`、规则声明 `purchase.spec-probe.v1`、没有固化 `SpecDecisionRequestID` 且规格来源允许既有慢路径时返回 `true`;`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。普通就地重试保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格。Android 不得根据映射是否非空、执行 phase、错误文字或本地判断扩大资格。 + +Android 规格失败使用五个稳定阶段:`PURCHASE_SPEC_TARGET_NOT_VISIBLE`、`PURCHASE_SPEC_TARGET_AMBIGUOUS`、`PURCHASE_SPEC_SAFE_TARGET_MISSING`、`PURCHASE_SPEC_CLICK_FAILED` 和 `PURCHASE_SPEC_SELECTION_UNCONFIRMED`。`PURCHASE_SPEC_CLICK_FAILED` 的 `errorMessage` 只允许稳定子原因 `root_unavailable`、`target_stale`、`no_clickable_ancestor`、`action_click_false` 或 `unknown`;其他阶段的消息不得包含规格原文、坐标、控件树或截图。只有 `PURCHASE_SPEC_TARGET_NOT_VISIBLE && specResolutionAllowed=true` 可以提交规格探测,其他四态直接提交真实失败,服务端原样保留稳定阶段/子原因。旧 Agent 在资格已用尽后再次提交 `spec_probe_completed` 时,服务端以 `PURCHASE_SPEC_REPROBE_REJECTED` fail-closed,释放租约并保留第一次规格决策,不再冒充新的选择根因或再次派发。无匹配、候选不完整、歧义或 Provider 异常同样使任务失败。`order_result_unknown` 只允许管理员或采购员人工解除,永不自动重派。 `openSpecPanel.textAliases` 是可选的候选过滤条件,不是原始页面文本选择器。省略该字段时,Agent 使用语义安全的规格入口或底部购买入口;提供时也只能与这些安全候选取交集,匹配不到即返回 `RULE_NOT_MATCHED`。 @@ -546,6 +550,12 @@ Android #42/#36 使用本地 SQLite 保存恢复与重传所需的任务、attem | `PURCHASE_RULE_INVALID` | 采购规则不可用,请联系管理员 | | `AGENT_CAPABILITY_MISMATCH` | 当前手机版本不支持这个任务 | | `PURCHASE_SPEC_NOT_MATCHED` | 没有找到可用的商品规格 | +| `PURCHASE_SPEC_TARGET_NOT_VISIBLE` | 有界搜索后没有看到完整精确规格 | +| `PURCHASE_SPEC_TARGET_AMBIGUOUS` | 同一维度存在多个严格等价候选 | +| `PURCHASE_SPEC_SAFE_TARGET_MISSING` | 目标可见但无法确定安全且唯一的点击目标 | +| `PURCHASE_SPEC_CLICK_FAILED` | 精确目标点击失败;`errorMessage` 为稳定子原因 | +| `PURCHASE_SPEC_SELECTION_UNCONFIRMED` | 点击成功但无法确认精确选中状态 | +| `PURCHASE_SPEC_REPROBE_REJECTED` | 规格探测资格已使用,重复探测被拒绝 | | `PURCHASE_PRICE_OUT_OF_RANGE` | 当前商品单价超出允许范围 | | `PURCHASE_ADDRESS_UPDATE_FAILED` | 收货地址修改失败,未创建订单 | | `PURCHASE_ORDER_RESULT_UNKNOWN` | 无法确认订单是否创建,请人工检查 | diff --git a/server/app/goauto/purchase/lifecycle.go b/server/app/goauto/purchase/lifecycle.go index 8a9e54a..6c6a9d9 100644 --- a/server/app/goauto/purchase/lifecycle.go +++ b/server/app/goauto/purchase/lifecycle.go @@ -12,6 +12,7 @@ import ( "go-admin/app/goauto/aimatching" "go-admin/app/goauto/device" "go-admin/app/goauto/models" + "go-admin/app/goauto/purchasecontract" "github.com/google/uuid" "gorm.io/gorm" @@ -339,7 +340,7 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq // and leaving an active task that can be dispatched forever. next = models.PurchaseTaskStatusFailed a.Status = models.PurchaseAttemptStatusFailed - code, message := "PURCHASE_SPEC_NOT_MATCHED", "再次执行仍未能精确选择商品规格,请检查商品规格" + code, message := CodeSpecReprobeRejected, "该任务的规格探测资格已经使用,重复探测已被拒绝" t.ErrorCode, t.ErrorMessage = &code, &message } else { next = models.PurchaseTaskStatusSpecProbePending @@ -636,7 +637,7 @@ func ensureAccountFree(tx *gorm.DB, accountID *uint64, taskID uint64, now time.T return nil } func (s *Service) payload(t models.PurchaseTask, a *models.PurchaseTaskAttempt, replayed bool) (*TaskPayload, error) { - p := &TaskPayload{TaskID: t.ID, ExecutionMode: t.ExecutionMode, Status: t.Status, DeviceID: t.DeviceID, PDDProductID: t.PDDProductID, PDDURL: t.PDDURLSnapshot, PDDGoodsID: t.PDDGoodsIDSnapshot, TargetColor: t.TargetColorSnapshot, TargetSize: t.TargetSizeSnapshot, MappedColor: t.MappedColorSnapshot, MappedSize: t.MappedSizeSnapshot, Quantity: t.Quantity, MinUnitPriceCent: t.MinUnitPriceCent, MaxUnitPriceCent: t.MaxUnitPriceCent, Currency: t.Currency, AddressSuffix: t.AddressSuffix, RuleSnapshot: json.RawMessage(t.RuleSnapshot), LeaseExpiresAt: t.LeaseExpiresAt, LeaseVersion: t.LeaseVersion, Replayed: replayed} + p := &TaskPayload{TaskID: t.ID, ExecutionMode: t.ExecutionMode, Status: t.Status, DeviceID: t.DeviceID, PDDProductID: t.PDDProductID, PDDURL: t.PDDURLSnapshot, PDDGoodsID: t.PDDGoodsIDSnapshot, TargetColor: t.TargetColorSnapshot, TargetSize: t.TargetSizeSnapshot, MappedColor: t.MappedColorSnapshot, MappedSize: t.MappedSizeSnapshot, SpecResolutionAllowed: specResolutionAllowed(t), Quantity: t.Quantity, MinUnitPriceCent: t.MinUnitPriceCent, MaxUnitPriceCent: t.MaxUnitPriceCent, Currency: t.Currency, AddressSuffix: t.AddressSuffix, RuleSnapshot: json.RawMessage(t.RuleSnapshot), LeaseExpiresAt: t.LeaseExpiresAt, LeaseVersion: t.LeaseVersion, Replayed: replayed} if a != nil { p.TaskAttemptID = a.AttemptID p.AttemptNumber = a.AttemptNumber @@ -645,6 +646,14 @@ func (s *Service) payload(t models.PurchaseTask, a *models.PurchaseTaskAttempt, } return p, nil } + +func specResolutionAllowed(t models.PurchaseTask) bool { + if t.SpecDecisionRequestID != nil || t.TaskType == models.PurchaseTaskTypeStock || t.SpecSource == "direct_select" { + return false + } + required, err := decodeStrings(t.RequiredCapabilitiesJSON) + return err == nil && containsString(required, purchasecontract.CapabilitySpecProbeV1) +} func valuePayload(s *Service, t *models.PurchaseTask, a *models.PurchaseTaskAttempt, replayed bool) (TaskPayload, error) { p, e := s.payload(*t, a, replayed) return *p, e diff --git a/server/app/goauto/purchase/reset_test.go b/server/app/goauto/purchase/reset_test.go index 02f4cdd..3aee438 100644 --- a/server/app/goauto/purchase/reset_test.go +++ b/server/app/goauto/purchase/reset_test.go @@ -27,6 +27,7 @@ type purchaseImmutableSnapshot struct { Currency string AddressSuffix string SpecDecision string + SpecDecisionRequest string ShopeeOrderSnapshot string } @@ -38,6 +39,12 @@ func immutablePurchaseSnapshot(task models.PurchaseTask) purchaseImmutableSnapsh Quantity: task.Quantity, ReferencePrice: task.ReferenceUnitPriceCent, MinPrice: task.MinUnitPriceCent, MaxPrice: task.MaxUnitPriceCent, Currency: task.Currency, AddressSuffix: task.AddressSuffix, SpecDecision: task.SpecDecisionSnapshot, + SpecDecisionRequest: func() string { + if task.SpecDecisionRequestID == nil { + return "" + } + return *task.SpecDecisionRequestID + }(), ShopeeOrderSnapshot: task.ShopeeOrderNoSnapshot, } } @@ -47,6 +54,13 @@ func TestPurchaseResetReusesTaskRefreshesRuleAndIsIdempotent(t *testing.T) { f := seed(t, db, liveCaps(), true) service := testService(db) failed := failedLiveTask(t, db, service, f) + frozenDecisionID := uuid.NewString() + if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", failed.ID).Update("spec_decision_request_id", frozenDecisionID).Error; err != nil { + t.Fatal(err) + } + if err := db.First(&failed, failed.ID).Error; err != nil { + t.Fatal(err) + } before := immutablePurchaseSnapshot(failed) request := PurchaseResetRequest{RequestID: uuid.NewString()} @@ -64,6 +78,9 @@ func TestPurchaseResetReusesTaskRefreshesRuleAndIsIdempotent(t *testing.T) { if immutablePurchaseSnapshot(reset) != before { t.Fatalf("reset changed immutable snapshot: before=%+v after=%+v", before, immutablePurchaseSnapshot(reset)) } + if specResolutionAllowed(reset) { + t.Fatal("in-place retry must not restore a consumed spec resolution allowance") + } if reset.ErrorCode != nil || reset.ErrorMessage != nil || reset.LeaseExpiresAt != nil || reset.ClaimRequestID != nil { t.Fatalf("reset did not clear runtime state: %+v", reset) } diff --git a/server/app/goauto/purchase/service_test.go b/server/app/goauto/purchase/service_test.go index c619646..2eace9c 100644 --- a/server/app/goauto/purchase/service_test.go +++ b/server/app/goauto/purchase/service_test.go @@ -288,12 +288,12 @@ func TestSlowPathUsesTwoAttemptsAndFreezesDecision(t *testing.T) { t.Fatal(err) } first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) - if err != nil || first.Phase != models.PurchaseAttemptPhaseSpecProbe { + if err != nil || first.Phase != models.PurchaseAttemptPhaseSpecProbe || !first.SpecResolutionAllowed { t.Fatalf("probe start: %+v %v", first, err) } probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`)} resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token) - if err != nil || resolved.Status != models.PurchaseTaskStatusPending || resolved.MappedColor != "黑色" || resolved.MappedSize != "XL" { + if err != nil || resolved.Status != models.PurchaseTaskStatusPending || resolved.MappedColor != "黑色" || resolved.MappedSize != "XL" || resolved.SpecResolutionAllowed { t.Fatalf("probe was not resolved by server: %+v %v", resolved, err) } decision := SpecDecisionRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, MappedColor: "黑色", MappedSize: "XL", Source: "ai_match", Decision: []byte(`{"reason":"same label"}`), OperatorID: 1} @@ -305,7 +305,7 @@ func TestSlowPathUsesTwoAttemptsAndFreezesDecision(t *testing.T) { t.Fatal(err) } second, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) - if err != nil || second.Phase != models.PurchaseAttemptPhasePurchase || second.AttemptNumber != 2 { + if err != nil || second.Phase != models.PurchaseAttemptPhasePurchase || second.AttemptNumber != 2 || second.SpecResolutionAllowed { t.Fatalf("second attempt: %+v %v", second, err) } } @@ -344,7 +344,7 @@ func TestSecondSpecProbeFailsClosedWithoutClearingDecision(t *testing.T) { if err = db.First(&saved, task.ID).Error; err != nil { t.Fatal(err) } - if saved.ErrorCode == nil || *saved.ErrorCode != "PURCHASE_SPEC_NOT_MATCHED" { + if saved.ErrorCode == nil || *saved.ErrorCode != CodeSpecReprobeRejected { t.Fatalf("unexpected failure: %v %v", saved.ErrorCode, saved.ErrorMessage) } if saved.MappedColorSnapshot != "黑色" || saved.MappedSizeSnapshot != "XL" || saved.SpecDecisionRequestID == nil { @@ -352,6 +352,48 @@ func TestSecondSpecProbeFailsClosedWithoutClearingDecision(t *testing.T) { } } +func TestSpecResolutionAllowedMatrix(t *testing.T) { + probeCapabilities := `["purchase.live.v1","purchase.spec-probe.v1"]` + tests := []struct { + name string + task models.PurchaseTask + want bool + }{ + { + name: "syb order before a frozen decision", + task: models.PurchaseTask{TaskType: models.PurchaseTaskTypeSYBOrder, SpecSource: "exact_match", RequiredCapabilitiesJSON: probeCapabilities}, + want: true, + }, + { + name: "capability missing", + task: models.PurchaseTask{TaskType: models.PurchaseTaskTypeSYBOrder, SpecSource: "exact_match", RequiredCapabilitiesJSON: `["purchase.live.v1"]`}, + want: false, + }, + { + name: "stock direct select", + task: models.PurchaseTask{TaskType: models.PurchaseTaskTypeStock, SpecSource: "direct_select", RequiredCapabilitiesJSON: probeCapabilities}, + want: false, + }, + } + requestID := "frozen-decision" + tests = append(tests, struct { + name string + task models.PurchaseTask + want bool + }{ + name: "frozen decision survives retry", + task: models.PurchaseTask{TaskType: models.PurchaseTaskTypeSYBOrder, SpecSource: "ai_match", SpecDecisionRequestID: &requestID, RequiredCapabilitiesJSON: probeCapabilities}, + want: false, + }) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := specResolutionAllowed(tt.task); got != tt.want { + t.Fatalf("specResolutionAllowed()=%v want %v", got, tt.want) + } + }) + } +} + func TestCreateUsesDeterministicTraditionalAndWeightMatching(t *testing.T) { db := testDB(t) f := seed(t, db, liveCaps(), false) diff --git a/server/app/goauto/purchase/types.go b/server/app/goauto/purchase/types.go index bb23fcf..41094fe 100644 --- a/server/app/goauto/purchase/types.go +++ b/server/app/goauto/purchase/types.go @@ -7,20 +7,21 @@ import ( ) const ( - CodeInvalidRequest = "PURCHASE_INVALID_REQUEST" - CodeTaskNotFound = "PURCHASE_TASK_NOT_FOUND" - CodeStateConflict = "PURCHASE_STATE_CONFLICT" - CodeCapabilityMismatch = "PURCHASE_CAPABILITY_MISMATCH" - CodeDeviceBusy = "DEVICE_BUSY" - CodeTaskClaimed = "PURCHASE_TASK_ALREADY_CLAIMED" - CodeLeaseExpired = "PURCHASE_LEASE_EXPIRED" - CodeMappingRequired = "PURCHASE_SPEC_MAPPING_REQUIRED" - CodeResultConflict = "PURCHASE_RESULT_CONFLICT" - CodeRePurchaseRequired = "REPURCHASE_AUTHORIZATION_REQUIRED" - CodeRetryNotAllowed = "PURCHASE_RETRY_NOT_ALLOWED" - CodeRetryUnsafe = "PURCHASE_RETRY_UNSAFE" - CodeRetryStale = "PURCHASE_RETRY_STALE" - CodeInternal = "INTERNAL_ERROR" + CodeInvalidRequest = "PURCHASE_INVALID_REQUEST" + CodeTaskNotFound = "PURCHASE_TASK_NOT_FOUND" + CodeStateConflict = "PURCHASE_STATE_CONFLICT" + CodeCapabilityMismatch = "PURCHASE_CAPABILITY_MISMATCH" + CodeDeviceBusy = "DEVICE_BUSY" + CodeTaskClaimed = "PURCHASE_TASK_ALREADY_CLAIMED" + CodeLeaseExpired = "PURCHASE_LEASE_EXPIRED" + CodeMappingRequired = "PURCHASE_SPEC_MAPPING_REQUIRED" + CodeResultConflict = "PURCHASE_RESULT_CONFLICT" + CodeRePurchaseRequired = "REPURCHASE_AUTHORIZATION_REQUIRED" + CodeRetryNotAllowed = "PURCHASE_RETRY_NOT_ALLOWED" + CodeRetryUnsafe = "PURCHASE_RETRY_UNSAFE" + CodeRetryStale = "PURCHASE_RETRY_STALE" + CodeSpecReprobeRejected = "PURCHASE_SPEC_REPROBE_REJECTED" + CodeInternal = "INTERNAL_ERROR" ) type ServiceError struct { @@ -107,30 +108,31 @@ type ActionRequest struct { } type TaskPayload struct { - TaskID uint64 `json:"taskId"` - TaskAttemptID string `json:"taskAttemptId,omitempty"` - AttemptNumber int `json:"attemptNumber,omitempty"` - Phase string `json:"phase,omitempty"` - ExecutionMode string `json:"executionMode"` - Status string `json:"status"` - DeviceID *uint64 `json:"deviceId,omitempty"` - PDDProductID uint64 `json:"pddProductId"` - PDDURL string `json:"pddUrl"` - PDDGoodsID string `json:"pddGoodsId"` - TargetColor string `json:"targetColor"` - TargetSize string `json:"targetSize"` - MappedColor string `json:"mappedColor"` - MappedSize string `json:"mappedSize"` - Quantity int64 `json:"quantity"` - MinUnitPriceCent int64 `json:"minUnitPriceCent"` - MaxUnitPriceCent int64 `json:"maxUnitPriceCent"` - Currency string `json:"currency"` - AddressSuffix string `json:"addressSuffix"` - RuleSnapshot json.RawMessage `json:"ruleSnapshot"` - RuleSnapshotHash string `json:"ruleSnapshotHash,omitempty"` - LeaseExpiresAt *time.Time `json:"leaseExpiresAt,omitempty"` - LeaseVersion uint64 `json:"leaseVersion"` - Replayed bool `json:"replayed,omitempty"` + TaskID uint64 `json:"taskId"` + TaskAttemptID string `json:"taskAttemptId,omitempty"` + AttemptNumber int `json:"attemptNumber,omitempty"` + Phase string `json:"phase,omitempty"` + ExecutionMode string `json:"executionMode"` + Status string `json:"status"` + DeviceID *uint64 `json:"deviceId,omitempty"` + PDDProductID uint64 `json:"pddProductId"` + PDDURL string `json:"pddUrl"` + PDDGoodsID string `json:"pddGoodsId"` + TargetColor string `json:"targetColor"` + TargetSize string `json:"targetSize"` + MappedColor string `json:"mappedColor"` + MappedSize string `json:"mappedSize"` + SpecResolutionAllowed bool `json:"specResolutionAllowed"` + Quantity int64 `json:"quantity"` + MinUnitPriceCent int64 `json:"minUnitPriceCent"` + MaxUnitPriceCent int64 `json:"maxUnitPriceCent"` + Currency string `json:"currency"` + AddressSuffix string `json:"addressSuffix"` + RuleSnapshot json.RawMessage `json:"ruleSnapshot"` + RuleSnapshotHash string `json:"ruleSnapshotHash,omitempty"` + LeaseExpiresAt *time.Time `json:"leaseExpiresAt,omitempty"` + LeaseVersion uint64 `json:"leaseVersion"` + Replayed bool `json:"replayed,omitempty"` } type ResultRequest struct {