fix(goauto): preserve exact spec failure evidence (#164)

This commit is contained in:
QiuSW
2026-08-31 11:16:45 +08:00
parent 5270390a2e
commit 866aea102f
10 changed files with 295 additions and 74 deletions
+2 -2
View File
@@ -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"
@@ -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",
@@ -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"),
@@ -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,
@@ -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<FreshActionResult> = mutableListOf(),
private val openPddOnFailedClick: Boolean = false,
private val sizeClickResults: MutableList<FreshActionResult> = 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<String> = 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 {
+14 -4
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: 5caf37fa5589e63e128e2beb9bc6acc24edf1cb1
synchronized_at: 2026-08-31T01:35:05Z
wiki_revision: 52119b8626496af10f232fb663595779427f37f0
synchronized_at: 2026-08-31T03:09:59Z
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
@@ -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` | 无法确认订单是否创建,请人工检查 |
+11 -2
View File
@@ -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
+17
View File
@@ -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)
}
+46 -4
View File
@@ -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)
+40 -38
View File
@@ -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 {