From 855d052e4eae278eccdd301417b421ff80ba2c9d Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 11 Sep 2026 08:45:45 +0800 Subject: [PATCH] feat(purchase): allow one in-panel spec rematch #263 --- .../automation/PurchaseRehearsalExecutor.kt | 13 ++++++++++++- .../goauto/agent/network/AgentApiClient.kt | 12 ++++++++++++ .../agent/service/AgentForegroundService.kt | 12 +++++++++++- server/app/goauto/purchase/lifecycle.go | 18 +++++++++++++++--- server/app/goauto/purchase/types.go | 2 ++ 5 files changed, 52 insertions(+), 5 deletions(-) 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 e5e3c9d..8fe052d 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 @@ -69,6 +69,7 @@ data class PurchaseExecutionInput( val mappedColor: String, val mappedSize: String, val specResolutionAllowed: Boolean = false, + val specRematchAllowed: Boolean = false, val quantity: Long, val minUnitPriceCent: Long, val maxUnitPriceCent: Long, @@ -95,6 +96,7 @@ class PurchaseRehearsalExecutor( private val beforeOrderSubmit: (FinalConfirmationEvidence) -> Unit = { throw PurchaseLiveException("PURCHASE_MODE_NOT_ALLOWED", "当前执行器没有正式采购授权") }, private val probeHandoffActivity: String? = null, private val onProbeHandoff: (String) -> Unit = {}, + private val rematchSpecs: ((PurchaseExecutionInput) -> PurchaseExecutionInput?)? = null, ) { private var purchasePanelContext: PurchasePanelContext? = null private var pageIdentity: Pair = null to null @@ -127,7 +129,7 @@ class PurchaseRehearsalExecutor( PurchaseActionType.OPEN_SPEC_PANEL -> { openSpecPanel(input, action) ?: recoverSoldOut(input, closeSpecPanel = true) ?: openSpecPanel(input, action) } - PurchaseActionType.SELECT_SPEC -> if (input.phase == "spec_probe") null else selectSpecs(input, rule, specSelectionProofs) + PurchaseActionType.SELECT_SPEC -> if (input.phase == "spec_probe") null else selectSpecsWithRematch(input, rule, specSelectionProofs) PurchaseActionType.SET_QUANTITY -> if (input.phase == "spec_probe") null else setQuantity(input.quantity) PurchaseActionType.VERIFY_UNIT_PRICE -> if (input.phase == "spec_probe") null else verifyPrice(input).also { if (it == null) observedPrice = currentScreen(input).priceCent @@ -190,6 +192,15 @@ class PurchaseRehearsalExecutor( else failure("PURCHASE_RULE_INVALID", "正式采购规则缺少核单动作") } + private fun selectSpecsWithRematch(input: PurchaseExecutionInput, rule: PurchaseRule, proofs: MutableMap): PurchaseExecutionOutcome? { + val first = selectSpecs(input, rule, proofs) + if (first?.errorCode != SPEC_TARGET_NOT_VISIBLE || !input.specRematchAllowed || rematchSpecs == null) return first + val refreshed = rematchSpecs.invoke(input) ?: return first + panelDiagnostic("specRematch=accepted;pageReuse=true") + proofs.clear() + return selectSpecs(refreshed.copy(specRematchAllowed = false), rule, proofs) + } + private fun canReuseCurrentProduct(input: PurchaseExecutionInput): Boolean { if (probeHandoffActivity == null) { val screen = currentScreen(input) diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt index 9f017c0..b4f6b11 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/network/AgentApiClient.kt @@ -84,6 +84,7 @@ data class PurchaseAgentTask( val mappedColor: String, val mappedSize: String, val specResolutionAllowed: Boolean, + val specRematchAllowed: Boolean, val quantity: Long, val minUnitPriceCent: Long, val maxUnitPriceCent: Long, @@ -453,6 +454,16 @@ class AgentApiClient(private val serverUrl: String) { requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/result", payload, token)) } + fun rematchPurchaseTask(taskId: Long, attemptId: String, probedSpecs: String, token: String): PurchaseAgentTask { + val payload = JSONObject().put("requestId", UUID.randomUUID().toString()) + .put("taskAttemptId", attemptId).put("resultType", "spec_rematch_completed") + .put("probedSpecs", JSONObject(probedSpecs)) + val data = requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/result", payload, token)).getJSONObject("data") + val pending = purchaseTask(data) + val claimed = claimPurchaseTask(taskId, UUID.randomUUID().toString(), token) + return startPurchaseTask(claimed.taskId, UUID.randomUUID().toString(), token) + } + fun collectionHistory(token: String, page: Int, status: String?, taskNo: String?, days: Int = 30, pageSize: Int = 20): HistoryPage { val data = requireNotNull(request("GET", historyPath("/api/agent/v1/collection-tasks", page, status, taskNo, days, pageSize), null, token)).getJSONObject("data") return HistoryPage( @@ -600,6 +611,7 @@ class AgentApiClient(private val serverUrl: String) { mappedColor = data.optString("mappedColor"), mappedSize = data.optString("mappedSize"), specResolutionAllowed = data.optBoolean("specResolutionAllowed", false), + specRematchAllowed = data.optBoolean("specRematchAllowed", false), quantity = data.getLong("quantity"), minUnitPriceCent = data.getLong("minUnitPriceCent"), maxUnitPriceCent = data.getLong("maxUnitPriceCent"), diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/service/AgentForegroundService.kt index 006c3d6..8054ef5 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 @@ -464,7 +464,7 @@ class AgentForegroundService : Service() { val claimed = if (initial.status == "pending") { api.claimPurchaseTask(initial.taskId, UUID.randomUUID().toString(), token) } else initial - val task = if (claimed.status == "pending") { + var task = if (claimed.status == "pending") { api.startPurchaseTask(claimed.taskId, UUID.randomUUID().toString(), token) } else claimed check(task.status == "running" && task.taskAttemptId.isNotBlank()) { "采购任务没有有效 attempt" } @@ -511,6 +511,15 @@ class AgentForegroundService : Service() { onProbeHandoff = { activity -> purchaseProbeHandoff.remember(handoffKey, activity, accessibility.currentForegroundRevision(), SystemClock.elapsedRealtime()) }, driver = accessibility, + rematchSpecs = { execution -> + val raw = collectPurchaseProbe(accessibility, task, parsedRule) + if (raw == null) null else { + val refreshed = api.rematchPurchaseTask(task.taskId, task.taskAttemptId, raw, token) + task = refreshed + execution.copy(mappedColor = refreshed.mappedColor, mappedSize = refreshed.mappedSize, + specRematchAllowed = refreshed.specRematchAllowed) + } + }, openLink = { PddLinkLauncher(this).open(it, preferDirect = true) }, probeSpecs = { collectPurchaseProbe(accessibility, task, parsedRule) }, stepChanged = { step -> @@ -549,6 +558,7 @@ class AgentForegroundService : Service() { minUnitPriceCent = task.minUnitPriceCent, maxUnitPriceCent = task.maxUnitPriceCent, addressSuffix = task.addressSuffix, + specRematchAllowed = task.specRematchAllowed, ), parsedRule, PurchaseAgentCapabilities.supported, diff --git a/server/app/goauto/purchase/lifecycle.go b/server/app/goauto/purchase/lifecycle.go index 543eb42..f8cb75f 100644 --- a/server/app/goauto/purchase/lifecycle.go +++ b/server/app/goauto/purchase/lifecycle.go @@ -342,6 +342,18 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq now := s.Now() next := "" switch req.ResultType { + case "spec_rematch_completed": + if t.ExecutionMode != models.PurchaseExecutionModeLive || t.Status != models.PurchaseTaskStatusRunning || t.SpecDecisionRequestID == nil || t.IrreversibleAt != nil || len(req.ProbedSpecs) == 0 { + return TaskPayload{}, fail(CodeSpecRematchRejected, "当前任务不满足一次现场重新匹配条件") + } + var rematchCount int64 + if e := tx.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ? AND result_type = ?", t.ID, "spec_rematch_completed").Count(&rematchCount).Error; e != nil { return TaskPayload{}, internal(e) } + if rematchCount > 0 { return TaskPayload{}, fail(CodeSpecRematchRejected, "该任务的现场重新匹配次数已用尽") } + next = models.PurchaseTaskStatusSpecProbePending + a.Status = models.PurchaseAttemptStatusCompleted + t.SpecDecisionRequestID = nil + t.SpecDecisionBy = nil + t.MappedColorSnapshot, t.MappedSizeSnapshot, t.SpecSource = "", "", "unresolved" case "spec_probe_completed": if len(req.ProbedSpecs) == 0 { return TaskPayload{}, fail(CodeInvalidRequest, "规格探测结果无效") @@ -413,7 +425,7 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq a.ResultHash = &digest a.ResultType = &req.ResultType a.FinishedAt = &now - if req.ResultType == "spec_probe_completed" { + if req.ResultType == "spec_probe_completed" || req.ResultType == "spec_rematch_completed" { a.SpecDecisionSnapshot = string(req.ProbedSpecs) } if e := tx.Omit("Task").Save(a).Error; e != nil { @@ -424,7 +436,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 || payload.Status != models.PurchaseTaskStatusSpecProbePending { + if err != nil || (req.ResultType != "spec_probe_completed" && req.ResultType != "spec_rematch_completed") || payload.Replayed || payload.Status != models.PurchaseTaskStatusSpecProbePending { return payload, err } return s.resolveProbedSpecs(ctx, taskID, req.TaskAttemptID, req.ProbedSpecs) @@ -700,7 +712,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, 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} + 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), SpecRematchAllowed: t.SpecDecisionRequestID != nil && t.TaskType == models.PurchaseTaskTypeSYBOrder && t.Status == models.PurchaseTaskStatusRunning, 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 diff --git a/server/app/goauto/purchase/types.go b/server/app/goauto/purchase/types.go index b3431b6..23d5e1e 100644 --- a/server/app/goauto/purchase/types.go +++ b/server/app/goauto/purchase/types.go @@ -21,6 +21,7 @@ const ( CodeRetryUnsafe = "PURCHASE_RETRY_UNSAFE" CodeRetryStale = "PURCHASE_RETRY_STALE" CodeSpecReprobeRejected = "PURCHASE_SPEC_REPROBE_REJECTED" + CodeSpecRematchRejected = "PURCHASE_SPEC_REMATCH_REJECTED" CodeOrderResultUnknown = "PURCHASE_ORDER_RESULT_UNKNOWN" CodeOrderEmptyTimeout = "PURCHASE_ORDER_EMPTY_TIMEOUT" CodeOrderChooserBack = "PURCHASE_ORDER_CHOOSER_BACK_FAILED" @@ -137,6 +138,7 @@ type TaskPayload struct { MappedColor string `json:"mappedColor"` MappedSize string `json:"mappedSize"` SpecResolutionAllowed bool `json:"specResolutionAllowed"` + SpecRematchAllowed bool `json:"specRematchAllowed"` Quantity int64 `json:"quantity"` MinUnitPriceCent int64 `json:"minUnitPriceCent"` MaxUnitPriceCent int64 `json:"maxUnitPriceCent"`