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 df826fc..250d08e 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 @@ -215,12 +215,9 @@ class PurchaseRehearsalExecutor( } listOf("color" to input.mappedColor, "size" to input.mappedSize).forEach { (dimension, target) -> if (target.isBlank()) return@forEach - val screen = currentScreen(input) - screen.problem?.let { return failure(it.code, it.message) } - val values = screen.dimensions.filter { it.key == dimension }.flatMap { it.values }.filter { it.text == target && it.available } - if (values.isEmpty()) return failure("PURCHASE_SPEC_NOT_MATCHED", "没有找到规格:$target") - if (values.size != 1) return failure("RULE_AMBIGUOUS", "规格 $target 匹配到多个控件") - when (driver.clickFresh(values.single().node)) { + val lookup = locateExactSpec(input, dimension, target) + lookup.failure?.let { return it } + when (driver.clickFresh(requireNotNull(lookup.node))) { FreshActionResult.AMBIGUOUS -> return failure("RULE_AMBIGUOUS", "规格 $target 匹配到多个控件") FreshActionResult.SUCCESS -> Unit else -> return failure("RULE_ACTION_FAILED", "规格 $target 选择失败") @@ -244,6 +241,50 @@ class PurchaseRehearsalExecutor( return null } + private data class SpecLookup(val node: SnapshotNode? = null, val failure: PurchaseExecutionOutcome? = null) + + /** + * Searches only parsed, selectable values in the already-open spec panel. + * A short downward pass first restores the top when a previous action left + * the panel lower down; the bounded upward pass then visits lower values. + * Every gesture is followed by a fresh parse, and unchanged evidence ends + * that direction early. + */ + private fun locateExactSpec(input: PurchaseExecutionInput, dimension: String, target: String): SpecLookup { + fun inspect(): Pair { + val screen = currentScreen(input) + screen.problem?.let { return SpecLookup(failure = failure(it.code, it.message)) to "" } + if (!screen.specPanelOpen) { + 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 } + if (exact.size > 1) { + return SpecLookup(failure = failure("RULE_AMBIGUOUS", "规格 $target 匹配到多个控件")) 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}" }}" + } + return null to signature + } + + var inspected = inspect() + inspected.first?.let { return it } + var currentSignature = inspected.second + for ((direction, limit) in listOf(SwipeDirection.DOWN to 3, SwipeDirection.UP to 6)) { + for (attempt in 0 until limit) { + if (!driver.swipePurchase(direction, 350)) break + pause(300) + inspected = inspect() + inspected.first?.let { return it } + if (inspected.second == currentSignature) break + currentSignature = inspected.second + } + } + return SpecLookup(failure = failure("PURCHASE_SPEC_NOT_MATCHED", "没有找到规格:$target")) + } + private fun setQuantity(quantity: Long): PurchaseExecutionOutcome? { val snapshot = driver.capture() pageProblem(snapshot)?.let { return it } 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 90ecb81..96cff7c 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 @@ -63,6 +63,18 @@ class PurchaseRehearsalExecutorTest { assertEquals("spec_probe_completed", outcome.resultType) assertEquals("{\"dimensions\":[]}", outcome.probedSpecs) assertFalse(driver.clicked.contains("黑色加绒")) + assertTrue(driver.swipeCount <= 11) + } + + @Test + fun `exact size below the visible panel is found after a bounded vertical search`() { + val driver = FakePurchaseDriver(hiddenSizeUntilUpSwipes = 3) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) + .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + + assertEquals("rehearsal_completed", outcome.resultType) + assertTrue(driver.clicked.contains("XL")) + assertEquals(3, driver.upSwipeCount) } @Test @@ -209,6 +221,7 @@ class PurchaseRehearsalExecutorTest { private val duplicateOpen: Boolean = false, private val bottomPurchaseEntry: Boolean = false, private val includeReviewEntry: Boolean = false, + private val hiddenSizeUntilUpSwipes: Int = 0, ) : PurchaseUiDriver { var browser = false var panel = false @@ -216,6 +229,7 @@ class PurchaseRehearsalExecutorTest { var size: String? = null var quantity = 1L var swipeCount = 0 + var upSwipeCount = 0 val clicked = mutableListOf() override fun capture(): UiSnapshot { @@ -251,7 +265,8 @@ class PurchaseRehearsalExecutorTest { nodes += node("scroll/color-$index", value, 20 + index * 220, 470, 200 + index * 220, 540, clickable = true, selected = color == value, parentPath = "scroll") } nodes += node("scroll/size-heading", "尺码", 20, 650, 300, 690, parentPath = "scroll") - nodes += node("scroll/size", "XL", 20, 710, 200, 780, clickable = true, selected = size == "XL", parentPath = "scroll") + val visibleSize = if (upSwipeCount >= hiddenSizeUntilUpSwipes) "XL" else "S" + nodes += node("scroll/size", visibleSize, 20, 710, 200, 780, clickable = true, selected = size == visibleSize, parentPath = "scroll") nodes += node("quantity", quantity.toString(), 400, 800, 600, 870, className = "android.widget.EditText") nodes += node("confirm", "确定", 20, 900, 500, 980, clickable = true) nodes += node("order", "提交订单", 20, 1100, 500, 1180, clickable = true) @@ -279,6 +294,7 @@ class PurchaseRehearsalExecutorTest { override fun swipePurchase(direction: SwipeDirection, durationMs: Long): Boolean { swipeCount++ + if (direction == SwipeDirection.UP) upSwipeCount++ return true } diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 884ede4..2a21953 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.- -wiki_revision: 69ae1e11038f1262cee2d86c8be26453b9476624 -synchronized_at: 2026-08-21T08:55:21Z +wiki_revision: e4ecce2abf9a5ac210522747ccadee110aec0aa1 +synchronized_at: 2026-08-22T01:05:28Z # 业务规则与术语 @@ -125,6 +125,7 @@ synchronized_at: 2026-08-21T08:55:21Z - 人工支付复核只记录 `paid` / `unpaid`;系统不执行或识别支付。快递单号与回填状态属于采购任务,后续物流工单实现。 - 采购任务领取时同时占用设备租约和可选 PDD 账号租约;租约过期后才可释放并重新领取。设备还存在采集任务时不能领取采购任务。 - 采购规格由服务端按顺序决策:先使用已确认的人工映射;否则仅在同一规格角色的可选 PDD 原始标签中做唯一确定性匹配(繁体转简体、空格/全半角/大小写统一,以及公斤/斤换算);仍无唯一结果才调用已启用的服务端 AI。AI 必须返回候选集中的原始标签,候选不完整、歧义、AI 无结果或服务不可用均明确失败,不派发第二趟、更不创建订单。 +- Agent 选择服务端下发的精确颜色或尺码时,只能在已确认打开的规格面板内有限纵向滑动、每次重新读取可选节点并按完整原始文字点击;连续没有新证据或达到上限即停止,不得点击相近规格。第一趟探测并固化规格后,第二趟仍无法精确选择而再次提交探测时,服务端必须明确失败并释放活动槽,保留第一次决策证据,禁止清空决策、循环派发或进入地址与创建订单动作。 - 规格映射不完整、PDD 档案为待采集或没有规格时,规则必须具有 `purchase.spec-probe.v1`;第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟。Android 不自行匹配或猜测;其只接收服务端已经固化的精确原始规格标签。 - Agent 提交的相同 attempt 最终结果只能写入一次;相同请求重放返回原事实,不同内容拒绝覆盖。`order_result_unknown` 不参与自动派发,只能人工解除。 - 已创建订单默认禁止再次采购;管理员或采购员可以做一次性重新采购授权,新任务创建成功时在同一事务消耗授权,旧任务和旧订单保留。已标记为已支付的订单不能授权或创建重新采购任务。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 3f6b1e6..300aa3c 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: 26b3a4aa0d95089e689020647938e5c54a4f733f -synchronized_at: 2026-08-21T09:20:05Z +wiki_revision: e4ecce2abf9a5ac210522747ccadee110aec0aa1 +synchronized_at: 2026-08-22T01:05:28Z # MVP 共享 API 契约 @@ -503,7 +503,7 @@ Admin 列表与详情由 #35 实现;#44 在 SYB 商品列表提供单条/当 | `POST` | `/api/agent/v1/purchase-tasks/{taskId}/order-submit-started` | 创建订单前先落不可逆标记;演练任务永远拒绝 | | `POST` | `/api/agent/v1/purchase-tasks/{taskId}/result` | 请求体携带 `taskAttemptId` 和 `requestId`;幂等提交演练、规格探测、订单或失败结果 | -结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。慢路径第一趟提交规格后释放设备与已知账号租约,任务进入 `spec_probe_pending`;服务端先使用已确认人工映射,否则对实时/档案可选规格做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。第二趟只会收到服务端已固化的精确 PDD 原始标签;无匹配、候选不完整、歧义或 Provider 异常均使任务失败,不再派发。`order_result_unknown` 只允许管理员或采购员人工解除,永不自动重派。 +结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。慢路径第一趟提交规格后释放设备与已知账号租约,任务进入 `spec_probe_pending`;服务端先使用已确认人工映射,否则对实时/档案可选规格做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。第二趟只会收到服务端已固化的精确 PDD 原始标签;Agent 只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整文字精确点击,连续没有新证据或达到上限即停止。若第二趟仍提交 `spec_probe_completed`,服务端将任务和当前 attempt 明确标记失败、释放租约并保留第一次规格决策,不再进入 `spec_probe_pending` 或再次派发。无匹配、候选不完整、歧义或 Provider 异常同样使任务失败。`order_result_unknown` 只允许管理员或采购员人工解除,永不自动重派。 `openSpecPanel.textAliases` 是可选的候选过滤条件,不是原始页面文本选择器。省略该字段时,Agent 使用语义安全的规格入口或底部购买入口;提供时也只能与这些安全候选取交集,匹配不到即返回 `RULE_NOT_MATCHED`。 diff --git a/server/app/goauto/purchase/lifecycle.go b/server/app/goauto/purchase/lifecycle.go index 652fa90..9569599 100644 --- a/server/app/goauto/purchase/lifecycle.go +++ b/server/app/goauto/purchase/lifecycle.go @@ -268,11 +268,22 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq if len(req.ProbedSpecs) == 0 { return TaskPayload{}, fail(CodeInvalidRequest, "规格探测结果无效") } - next = models.PurchaseTaskStatusSpecProbePending - a.Status = models.PurchaseAttemptStatusCompleted - t.SpecSource = "unresolved" - t.MappedColorSnapshot = "" - t.MappedSizeSnapshot = "" + if t.SpecDecisionRequestID != nil { + // One slow-path decision has already been frozen for this task. A + // second probe means the Agent still could not select that exact + // decision. Fail closed instead of clearing the auditable decision + // and leaving an active task that can be dispatched forever. + next = models.PurchaseTaskStatusFailed + a.Status = models.PurchaseAttemptStatusFailed + code, message := "PURCHASE_SPEC_NOT_MATCHED", "再次执行仍未能精确选择商品规格,请检查商品规格" + t.ErrorCode, t.ErrorMessage = &code, &message + } else { + next = models.PurchaseTaskStatusSpecProbePending + a.Status = models.PurchaseAttemptStatusCompleted + t.SpecSource = "unresolved" + t.MappedColorSnapshot = "" + t.MappedSizeSnapshot = "" + } case "rehearsal_completed": if t.ExecutionMode != models.PurchaseExecutionModeRehearsal { return TaskPayload{}, fail(CodeStateConflict, "正式任务不能提交演练结果") @@ -322,7 +333,7 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq } return valuePayload(s, t, a, false) }) - if err != nil || req.ResultType != "spec_probe_completed" || payload.Replayed { + if err != nil || req.ResultType != "spec_probe_completed" || payload.Replayed || payload.Status != models.PurchaseTaskStatusSpecProbePending { return payload, err } return s.resolveProbedSpecs(ctx, taskID, req.TaskAttemptID, req.ProbedSpecs) diff --git a/server/app/goauto/purchase/service_test.go b/server/app/goauto/purchase/service_test.go index 94b75a2..bd74738 100644 --- a/server/app/goauto/purchase/service_test.go +++ b/server/app/goauto/purchase/service_test.go @@ -230,6 +230,48 @@ func TestSlowPathUsesTwoAttemptsAndFreezesDecision(t *testing.T) { } } +func TestSecondSpecProbeFailsClosedWithoutClearingDecision(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), false) + s := testService(db) + task, err := createLive(t, s, f) + if err != nil { + t.Fatal(err) + } + if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil { + t.Fatal(err) + } + first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) + if err != nil { + t.Fatal(err) + } + probe := json.RawMessage(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`) + if _, err = s.SubmitResult(context.Background(), task.ID, ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: probe}, f.token); err != nil { + t.Fatal(err) + } + if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil { + t.Fatal(err) + } + second, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) + if err != nil || second.Phase != models.PurchaseAttemptPhasePurchase { + t.Fatalf("second attempt: %+v %v", second, err) + } + result, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: second.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: probe}, f.token) + if err != nil || result.Status != models.PurchaseTaskStatusFailed { + t.Fatalf("second probe must fail closed: %+v %v", result, err) + } + var saved models.PurchaseTask + if err = db.First(&saved, task.ID).Error; err != nil { + t.Fatal(err) + } + if saved.ErrorCode == nil || *saved.ErrorCode != "PURCHASE_SPEC_NOT_MATCHED" { + t.Fatalf("unexpected failure: %v %v", saved.ErrorCode, saved.ErrorMessage) + } + if saved.MappedColorSnapshot != "黑色" || saved.MappedSizeSnapshot != "XL" || saved.SpecDecisionRequestID == nil { + t.Fatalf("the first decision must remain auditable: %+v", saved) + } +} + func TestCreateUsesDeterministicTraditionalAndWeightMatching(t *testing.T) { db := testDB(t) f := seed(t, db, liveCaps(), false)