diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8c083e7..6e8b4ac 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 = 54 - versionName = "0.9.41" + versionCode = 55 + versionName = "0.9.42" 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 9cd9f6d..0ee551d 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 @@ -83,6 +83,10 @@ class PurchaseRehearsalExecutor( val specSelectionProofs = mutableMapOf() val live = PurchaseLiveAutomation(driver, pause) for (action in rule.actions) { + // spec_probe already opened this task's URL and the server reserves + // the device while matching. Skip the whole navigation action, + // including its configured wait/swipe hooks, in phase two. + if (input.phase == "purchase" && action.type == PurchaseActionType.OPEN_PRODUCT) continue stepChanged(action.type.wireName) val failure = when (action.type) { PurchaseActionType.OPEN_PRODUCT -> openProduct(input, action) 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 7ee100e..a245b17 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 @@ -256,13 +256,20 @@ class AgentForegroundService : Service() { flushPurchaseOutbox(api, token) val collectionCooldown = activeCollectionCooldown() val purchaseTask = api.nextPurchaseTask(token) - when (TaskDispatchPolicy.decide(purchaseTask != null, collectionCooldown != null)) { + when (TaskDispatchPolicy.decide(purchaseTask?.status, collectionCooldown != null)) { TaskDispatchDecision.RUN_PURCHASE -> { cancelIdleReturn("收到新的采购任务") releaseCollectionCooldownWakeLock() schedulePurchaseTask(api, requireNotNull(purchaseTask), token) return MANUAL_PURCHASE_TASK } + TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH -> { + val waitingTask = requireNotNull(purchaseTask) + cancelIdleReturn("等待采购规格匹配") + stateStore.update("ONLINE", "采购任务 #${waitingTask.taskId} 正在匹配规格", tokenStored = true) + updateNotification("采购任务 #${waitingTask.taskId} 等待规格匹配") + return MANUAL_PURCHASE_MATCH_PENDING + } TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN -> { val ticket = requireNotNull(collectionCooldown) showCollectionCooldown(ticket) @@ -1074,6 +1081,7 @@ class AgentForegroundService : Service() { const val MANUAL_EMPTY = "empty" const val MANUAL_COLLECTION_TASK = "collection_task" const val MANUAL_PURCHASE_TASK = "purchase_task" + const val MANUAL_PURCHASE_MATCH_PENDING = "purchase_match_pending" const val MANUAL_BUSY = "busy" const val MANUAL_CONFIG_REQUIRED = "config_required" const val MANUAL_AUTH_ERROR = "auth_error" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt index bf7791b..047361c 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentStateStore.kt @@ -99,13 +99,15 @@ internal object CollectionCooldownPolicy { internal enum class TaskDispatchDecision { RUN_PURCHASE, + WAIT_FOR_PURCHASE_MATCH, WAIT_FOR_COLLECTION_COOLDOWN, CHECK_COLLECTION, } internal object TaskDispatchPolicy { - fun decide(purchaseAvailable: Boolean, collectionCooldownActive: Boolean): TaskDispatchDecision = when { - purchaseAvailable -> TaskDispatchDecision.RUN_PURCHASE + fun decide(purchaseStatus: String?, collectionCooldownActive: Boolean): TaskDispatchDecision = when { + purchaseStatus == "spec_probe_pending" -> TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH + purchaseStatus != null -> TaskDispatchDecision.RUN_PURCHASE collectionCooldownActive -> TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN else -> TaskDispatchDecision.CHECK_COLLECTION } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/CollectionCooldownPolicyTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/CollectionCooldownPolicyTest.kt index 35bdf11..4e1eb07 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/CollectionCooldownPolicyTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/CollectionCooldownPolicyTest.kt @@ -86,15 +86,19 @@ class CollectionCooldownPolicyTest { fun `purchase keeps priority while collection waits for cooldown`() { assertEquals( TaskDispatchDecision.RUN_PURCHASE, - TaskDispatchPolicy.decide(purchaseAvailable = true, collectionCooldownActive = true), + TaskDispatchPolicy.decide(purchaseStatus = "pending", collectionCooldownActive = true), + ) + assertEquals( + TaskDispatchDecision.WAIT_FOR_PURCHASE_MATCH, + TaskDispatchPolicy.decide(purchaseStatus = "spec_probe_pending", collectionCooldownActive = true), ) assertEquals( TaskDispatchDecision.WAIT_FOR_COLLECTION_COOLDOWN, - TaskDispatchPolicy.decide(purchaseAvailable = false, collectionCooldownActive = true), + TaskDispatchPolicy.decide(purchaseStatus = null, collectionCooldownActive = true), ) assertEquals( TaskDispatchDecision.CHECK_COLLECTION, - TaskDispatchPolicy.decide(purchaseAvailable = false, collectionCooldownActive = false), + TaskDispatchPolicy.decide(purchaseStatus = null, collectionCooldownActive = false), ) } } 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 777e506..71dbd49 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 @@ -81,12 +81,12 @@ class PurchaseRehearsalExecutorTest { assertEquals("rehearsal_completed", outcome.resultType) assertEquals(2_000L, outcome.actualUnitPriceCent) - assertEquals(1, openCount) + assertEquals(0, openCount) assertEquals(2, driver.swipeCount) assertEquals(2L, driver.quantity) - assertTrue(driver.clicked.containsAll(listOf("打开", "选择规格", "黑色", "XL"))) + assertTrue(driver.clicked.containsAll(listOf("选择规格", "黑色", "XL"))) assertFalse(driver.clicked.any { it.contains("订单") || it.contains("支付") }) - assertTrue(pauses.contains(700)) + assertFalse(pauses.contains(700)) } @Test @@ -404,8 +404,8 @@ class PurchaseRehearsalExecutorTest { @Test fun `ambiguous browser target stops safely`() { val driver = FakePurchaseDriver(duplicateOpen = true) - val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) - .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {}) + .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) assertEquals("RULE_AMBIGUOUS", outcome.errorCode) assertTrue(driver.clicked.isEmpty()) } @@ -416,10 +416,10 @@ class PurchaseRehearsalExecutorTest { openClickResults = mutableListOf(FreshActionResult.FAILED), openPddOnFailedClick = true, ) - val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = {}) - .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = {}) + .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) - assertEquals("rehearsal_completed", outcome.resultType) + assertEquals("spec_probe_completed", outcome.resultType) assertEquals(1, driver.openClickCount) } @@ -429,10 +429,10 @@ class PurchaseRehearsalExecutorTest { openClickResults = mutableListOf(FreshActionResult.NOT_FOUND, FreshActionResult.SUCCESS), ) val pauses = mutableListOf() - val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add) - .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add) + .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) - assertEquals("rehearsal_completed", outcome.resultType) + assertEquals("spec_probe_completed", outcome.resultType) assertEquals(2, driver.openClickCount) assertTrue(pauses.size <= 50) } @@ -443,8 +443,8 @@ class PurchaseRehearsalExecutorTest { openClickResults = MutableList(10) { FreshActionResult.FAILED }, ) val pauses = mutableListOf() - val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { null }, pause = pauses::add) - .execute(input(), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) + val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{}" }, pause = pauses::add) + .execute(input().copy(phase = "spec_probe"), PurchaseRuleParser.parse(rule()), PurchaseAgentCapabilities.supported) assertEquals("RULE_ACTION_FAILED", outcome.errorCode) assertTrue(driver.openClickCount in 1..5) @@ -523,9 +523,7 @@ class PurchaseRehearsalExecutorTest { "specEntryCandidates=0;explicit=0;nested=0;bottomPurchase=0;panelAlreadyOpen=false;reviewPage=false;pageEvidence=true;entryReadyWaitPolls=20;entryReadyWaitMillis=2000", diagnostics.single(), ) - // One 100ms pause belongs to the existing open-product foreground poll; - // the diagnostic proves the entry-ready loop itself used exactly 20. - assertEquals(21, pauses.count { it == 100L }) + assertEquals(20, pauses.count { it == 100L }) assertFalse(diagnostics.single().contains("选择规格")) } diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 76362b9..6370d55 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: bd2748f2b58daee92eaa1e5ed16ce61fa18f728f -synchronized_at: 2026-09-05T01:00:01Z +wiki_revision: 0761aa56d2eb0e5b544b1947336371e0716ae907 +synchronized_at: 2026-09-05T02:27:41Z # 业务规则与术语 @@ -341,7 +341,7 @@ synchronized_at: 2026-09-05T01:00:01Z ## SYB 采购强制当次规格探测(#215) -- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。已有长期映射只作商品档案事实,不直接进入任务执行规格。 +- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。首趟只打开一次浏览器商品链接;匹配期间当前设备保留给同一任务,不领取其他采购或采集任务;第二趟复用 PDD 当前页,不再次打开链接,也不严格核验标题、goodsId 或页面指纹,但仍要求 PDD 包名与商品/规格/订单页面结构安全证据。已有长期映射只作商品档案事实,不直接进入任务执行规格。 - 首趟候选与 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和幂等结果哈希关联;第二趟失败不得回到首趟循环探测。备货 `stock/direct_select` 没有 SYB 目标规格,继续使用用户逐字选择的档案规格,不进入本规则。 - 候选和 Provider 结果仅保存颜色、尺码原始标签及结构化决策,不保存控件树、整屏截图、账号、地址、订单或支付数据;付款仍永久禁止。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 77c8600..fcbb8e2 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: 644b864833730130cf4a2098a01611da0c0ac45d -synchronized_at: 2026-09-05T01:01:14Z +wiki_revision: 08a65eb49d3d1091f0be30e6f6d1a9372f6e5b37 +synchronized_at: 2026-09-05T02:28:13Z # MVP 共享 API 契约 @@ -415,7 +415,7 @@ POST /api/agent/v1/tasks/{taskId}/fail | 状态 | 含义 | 是否占用 SYB 活动槽 | |---|---|---| | `pending` | 待执行 | 是 | -| `spec_probe_pending` | 第一趟探测结束,待服务端固化规格并重新派发 | 是 | +| `spec_probe_pending` | 第一趟探测结束,服务端规格匹配中;当前设备保留同任务连续流程且不得领取其他任务 | 是 | | `running` | Agent 执行中 | 是 | | `rehearsal_completed` | 演练安全结束,未改地址、未创建订单 | 否 | | `order_submit_started` | 不可逆标记已落库,只能核单,禁止再次点击 | 是 | @@ -529,15 +529,15 @@ Admin 列表与详情由 #35 实现;#67 增加 `shopeeOrderNoSnapshot` 的列 | 方法 | 路径 | 说明 | |---|---|---| -| `GET` | `/api/agent/v1/purchase-tasks/next` | 返回与设备能力兼容的指定任务或空闲任务 | +| `GET` | `/api/agent/v1/purchase-tasks/next` | 优先返回当前设备的运行任务;存在 `spec_probe_pending` 时返回同一等待任务以阻止其他任务插队,否则返回能力兼容的指定任务或空闲任务 | | `POST` | `/api/agent/v1/purchase-tasks/{taskId}/claim` | `requestId` 原子领取,并建立设备/可选账号租约 | | `POST` | `/api/agent/v1/purchase-tasks/{taskId}/start` | 创建不可变 `taskAttemptId` | | `POST` | `/api/agent/v1/purchase-tasks/{taskId}/order-submit-started` | 创建订单前先落不可逆标记;演练任务和 `spec_probe` attempt 永远拒绝 | | `POST` | `/api/agent/v1/purchase-tasks/{taskId}/result` | 请求体携带 `taskAttemptId` 和 `requestId`;幂等提交演练、规格探测、订单或失败结果 | -自 #215 起,新建 SYB 采购任务不再从 PDD 档案创建持久匹配工作项,也不在首次派发前调用外部 AI;部署前已存在的 `purchase_spec_match_work_item` 继续按原状态兼容处理。新任务首次 `start` 固定得到 `phase=spec_probe`,Android 通过既有结果字段回传当次候选;匹配成功后的第二次 `start` 才得到 `phase=purchase` 和服务端固化的精确 PDD 原始标签。Android 不接收 AI 配置或自由决策权限。 +自 #215 起,新建 SYB 采购任务不再从 PDD 档案创建持久匹配工作项,也不在首次派发前调用外部 AI;部署前已存在的 `purchase_spec_match_work_item` 继续按原状态兼容处理。新任务首次 `start` 固定得到 `phase=spec_probe`,Android 通过浏览器打开任务链接一次并经既有结果字段回传当次候选;匹配成功后的第二次 `start` 才得到 `phase=purchase` 和服务端固化的精确 PDD 原始标签。第二阶段直接复用首趟保留的 PDD 页面,不再次打开浏览器链接,也不以标题、goodsId 或页面指纹做严格同页校验;仍必须通过 PDD 包名和商品/规格/订单页面结构安全证据。Android 不接收 AI 配置或自由决策权限。 -结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。每个新 SYB 采购任务的第一趟只读遍历当次 PDD 规格面板并提交颜色、尺码原始候选,随后释放设备与已知账号租约并进入 `spec_probe_pending`;服务端只以任务冻结的 SYB 目标和当次候选先做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。AI 的颜色和尺码必须逐字属于当次对应候选,否则按无匹配失败。第二趟只会收到服务端固化的精确 PDD 原始标签;Agent 只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。 +结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。每个新 SYB 采购任务的第一趟只读遍历当次 PDD 规格面板并提交颜色、尺码原始候选,随后释放数据库租约和已知账号运行守卫并进入 `spec_probe_pending`,但服务端调度与 Agent 必须把当前设备保留给同一采购流程:`next` 返回该等待任务,Agent 只轮询等待,不领取其他采购或采集任务。服务端只以任务冻结的 SYB 目标和当次候选先做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。AI 的颜色和尺码必须逐字属于当次对应候选,否则按无匹配失败。第二趟只会收到服务端固化的精确 PDD 原始标签;Agent 复用首趟仍打开的页面,只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。 任务 payload 的必传布尔字段 `specResolutionAllowed` 是 Android 是否可以提交规格探测的唯一资格事实。新建 `taskType=syb_order` 任务必须由声明 `purchase.spec-probe.v1` 的规则创建,初始 `SpecDecisionRequestID` 为空且 `specSource=unresolved`,首趟返回 `true`;当次决策固化后返回 `false`。`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。历史兼容的就地 `/reset` 保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格;映射不完整时必须拒绝,不能进入正式采购阶段。普通 Agent 重试创建新任务并重新取得一次探测资格。Android 不得根据映射是否非空、错误文字或本地判断扩大资格。 diff --git a/server/app/goauto/purchase/lifecycle.go b/server/app/goauto/purchase/lifecycle.go index 9fffa06..0fe6d07 100644 --- a/server/app/goauto/purchase/lifecycle.go +++ b/server/app/goauto/purchase/lifecycle.go @@ -33,6 +33,19 @@ func (s *Service) Next(ctx context.Context, token string) (*TaskPayload, error) } else if !errors.Is(err, gorm.ErrRecordNotFound) { return nil, internal(err) } + // A completed probe reserves the device's purchase flow while the server + // resolves the exact specs. Returning that task as a waiting payload keeps + // the Agent from claiming another purchase or collection task and preserves + // the PDD page that the probe just inspected. + var waiting models.PurchaseTask + if err = s.DB.WithContext(ctx). + Where("device_id = ? AND status = ?", d.ID, models.PurchaseTaskStatusSpecProbePending). + Order("created_at, id"). + First(&waiting).Error; err == nil { + return s.payload(waiting, nil, false) + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, internal(err) + } now := s.Now() var candidates []models.PurchaseTask if err = s.DB.WithContext(ctx).Where("status IN ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?) AND (device_id IS NULL OR device_id = ?)", []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now, d.ID).Order("CASE WHEN device_id IS NULL THEN 1 ELSE 0 END, created_at, id").Limit(100).Find(&candidates).Error; err != nil { @@ -659,7 +672,7 @@ func (s *Service) withRunning(ctx context.Context, taskID uint64, token string, func ensureDeviceFree(tx *gorm.DB, deviceID, taskID uint64, now time.Time) error { var count int64 - if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND device_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", taskID, deviceID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted}, []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).Count(&count).Error; e != nil { + if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND device_id = ? AND (status IN ? OR (status = ? AND lease_expires_at > ?))", taskID, deviceID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending}, models.PurchaseTaskStatusPending, now).Count(&count).Error; e != nil { return internal(e) } if count > 0 { @@ -678,7 +691,7 @@ func ensureAccountFree(tx *gorm.DB, accountID *uint64, taskID uint64, now time.T return nil } var count int64 - if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND pdd_account_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", taskID, *accountID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted}, []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now).Count(&count).Error; e != nil { + if e := tx.Model(&models.PurchaseTask{}).Where("id <> ? AND pdd_account_id = ? AND (status IN ? OR (status = ? AND lease_expires_at > ?))", taskID, *accountID, []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending}, models.PurchaseTaskStatusPending, now).Count(&count).Error; e != nil { return internal(e) } if count > 0 { diff --git a/server/app/goauto/purchase/service_test.go b/server/app/goauto/purchase/service_test.go index 265e966..26bc5e7 100644 --- a/server/app/goauto/purchase/service_test.go +++ b/server/app/goauto/purchase/service_test.go @@ -625,6 +625,42 @@ func TestCreateDispatchesProbeInsteadOfExternalArchiveMatching(t *testing.T) { } } +func TestNextReturnsAssignedProbeWaitingTaskBeforeOtherWork(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":1200}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil { + t.Fatal(err) + } + service := testService(db) + request := StockCreateRequest{ + RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive, + PDDProductID: f.pdd.ID, DeviceID: &f.device.ID, Color: "黑色", Size: "XL", + Quantity: 1, MinUnitPriceCent: 900, MaxUnitPriceCent: 1500, + } + waiting, _, err := service.CreateStock(context.Background(), request) + if err != nil { + t.Fatal(err) + } + request.RequestID = uuid.NewString() + other, _, err := service.CreateStock(context.Background(), request) + if err != nil { + t.Fatal(err) + } + if err = db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", waiting.ID).Updates(map[string]any{ + "status": models.PurchaseTaskStatusSpecProbePending, "mapped_color_snapshot": "", "mapped_size_snapshot": "", + }).Error; err != nil { + t.Fatal(err) + } + + next, err := service.Next(context.Background(), f.token) + if err != nil || next == nil || next.TaskID != waiting.ID || next.Status != models.PurchaseTaskStatusSpecProbePending { + t.Fatalf("waiting probe task not reserved: next=%+v other=%d err=%v", next, other.ID, err) + } + if _, err = service.Claim(context.Background(), other.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); code(err) != CodeDeviceBusy { + t.Fatalf("other purchase claimed while probe waits: %v", err) + } +} + func TestOrderUnknownIsNotAutomaticallyRedispatched(t *testing.T) { db := testDB(t) f := seed(t, db, liveCaps(), true) diff --git a/server/app/goauto/task/current_page.go b/server/app/goauto/task/current_page.go index baa7239..3cefb97 100644 --- a/server/app/goauto/task/current_page.go +++ b/server/app/goauto/task/current_page.go @@ -182,9 +182,9 @@ func ensureDeviceIdleForCurrentPage(tx *gorm.DB, deviceID uint64, now time.Time) return serviceError(CodeDeviceBusy, "设备正在执行任务,请稍后再试") } if err := tx.Model(&models.PurchaseTask{}). - Where("device_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", deviceID, - []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted}, - []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now). + Where("device_id = ? AND (status IN ? OR (status = ? AND lease_expires_at > ?))", deviceID, + []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending}, + models.PurchaseTaskStatusPending, now). Count(&busy).Error; err != nil { return internalError(err) } diff --git a/server/app/goauto/task/lifecycle_service.go b/server/app/goauto/task/lifecycle_service.go index 5a9f4da..6c4d5b9 100644 --- a/server/app/goauto/task/lifecycle_service.go +++ b/server/app/goauto/task/lifecycle_service.go @@ -246,9 +246,9 @@ func ensureDeviceIdleForReset(tx *gorm.DB, deviceID, taskID uint64, now time.Tim return serviceError(CodeDeviceBusy, "设备正在执行其他任务") } if err := tx.Model(&models.PurchaseTask{}). - Where("device_id = ? AND (status IN ? OR (status IN ? AND lease_expires_at > ?))", deviceID, - []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted}, - []string{models.PurchaseTaskStatusPending, models.PurchaseTaskStatusSpecProbePending}, now). + Where("device_id = ? AND (status IN ? OR (status = ? AND lease_expires_at > ?))", deviceID, + []string{models.PurchaseTaskStatusRunning, models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusSpecProbePending}, + models.PurchaseTaskStatusPending, now). Count(&busy).Error; err != nil { return internalError(err) }