fix(goauto): preserve exact spec failure evidence (#164)
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
+49
-18
@@ -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,
|
||||
|
||||
+113
-6
@@ -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 {
|
||||
|
||||
@@ -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` | 无法确认订单是否创建,请人工检查 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user