diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 55e42bd..ff3d6d4 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 = 52 - versionName = "0.9.39" + versionCode = 53 + versionName = "0.9.40" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt index 0407254..4bb614a 100644 --- a/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt +++ b/android/app/src/main/java/cn/ilapage/goauto/agent/ui/TaskHistoryFragment.kt @@ -36,8 +36,6 @@ import cn.ilapage.goauto.agent.network.HistoryPage import cn.ilapage.goauto.agent.network.HistoryColorImage import cn.ilapage.goauto.agent.network.PurchaseHistoryDetail import cn.ilapage.goauto.agent.network.PurchaseHistoryItem -import cn.ilapage.goauto.agent.network.PurchaseResetResult -import cn.ilapage.goauto.agent.network.PurchaseRetryResult import cn.ilapage.goauto.agent.persistence.TaskHistoryCache import cn.ilapage.goauto.agent.service.AgentForegroundService import cn.ilapage.goauto.agent.service.AgentSettingsStore @@ -88,7 +86,7 @@ internal object PurchaseRetryPolicy { fun confirmationMessage(continuing: Boolean = false): String = if (continuing) { "替代商品已完成匹配。系统会保留原任务并创建一笔新采购任务;可能创建拼多多待付款订单,但不会支付。" } else { - "将使用服务端最新采购规则重跑当前任务,任务号和商品、规格、价格快照不变;可能创建拼多多待付款订单,但不会支付。" + "系统会保留原任务,并根据当前商品档案和最新采购规则创建一笔新采购任务;可能创建拼多多待付款订单,但不会支付。" } } @@ -748,7 +746,7 @@ class TaskHistoryFragment : Fragment() { contentDescription = "重试采购任务 CG-${task.taskId}" setOnClickListener { confirmPurchaseRetry(task) } }, collectionCardParams()) - resultColumn.addView(context.centeredMessage("重试边界", "复用当前任务并刷新采购规则;不会执行支付。")) + resultColumn.addView(context.centeredMessage("重试边界", "保留当前失败任务并创建新任务;新任务读取当前商品档案和采购规则,不会执行支付。")) } else if (!replacementInProgress && task.status == "failed") { val reason = task.retryDisabledReason?.takeIf(String::isNotBlank) ?: "请在管理端核对任务状态。" resultColumn.addView(context.centeredMessage("不可重试", reason)) @@ -869,17 +867,13 @@ class TaskHistoryFragment : Fragment() { runCatching { val client = AgentApiClient(serverUrl) val requestId = UUID.randomUUID().toString() - if (continuing) client.retryPurchaseTask(taskId, requestId, credentials.token) - else client.resetPurchaseTask(taskId, requestId, credentials.token) + client.retryPurchaseTask(taskId, requestId, credentials.token) } .onSuccess { result -> resultColumn.post { if (!isAdded || generation != requestGeneration) return@post AgentForegroundService.start(requireContext()) - when (result) { - is PurchaseRetryResult -> showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId) - is PurchaseResetResult -> showPurchaseResetSuccess(result.taskNo, result.taskId, result.attemptNumber) - } + showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId) } } .onFailure { error -> @@ -910,24 +904,6 @@ class TaskHistoryFragment : Fragment() { }, collectionCardParams()) } - private fun showPurchaseResetSuccess(taskNo: String, taskId: Long, attemptNumber: Int) { - resultColumn.removeAllViews() - resultColumn.addView(requireContext().centeredMessage( - "已加入重试队列 $taskNo", - "任务号保持不变,将使用最新采购规则进行第 $attemptNumber 次尝试;系统不会支付。", - )) - resultColumn.addView(MaterialButton(requireContext()).apply { - text = "查看当前任务" - minimumHeight = requireContext().dp(48) - setOnClickListener { loadPurchaseDetail(taskId) } - }, collectionCardParams()) - resultColumn.addView(MaterialButton(requireContext(), null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply { - text = "返回采购记录" - minimumHeight = requireContext().dp(48) - setOnClickListener { page = 1; load() } - }, collectionCardParams()) - } - private fun addPagination(currentPage: Int, pageSize: Int) { if (total <= pageSize && currentPage == 1) return val context = requireContext() diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRetryPolicyTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRetryPolicyTest.kt index a1ab274..1421024 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRetryPolicyTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRetryPolicyTest.kt @@ -16,11 +16,12 @@ class PurchaseRetryPolicyTest { } @Test - fun `retry confirmation explains same task latest rule and no payment`() { + fun `retry confirmation explains new task current archive and no payment`() { val message = PurchaseRetryPolicy.confirmationMessage() - assertTrue(message.contains("当前任务")) + assertTrue(message.contains("保留原任务")) + assertTrue(message.contains("当前商品档案")) assertTrue(message.contains("最新采购规则")) - assertFalse(message.contains("新采购任务")) + assertTrue(message.contains("新采购任务")) assertTrue(message.contains("待付款订单")) assertTrue(message.contains("不会支付")) } diff --git a/server/app/goauto/apprelease/apk_test.go b/server/app/goauto/apprelease/apk_test.go index 366880e..d6ddbf7 100644 --- a/server/app/goauto/apprelease/apk_test.go +++ b/server/app/goauto/apprelease/apk_test.go @@ -18,7 +18,7 @@ func TestParseBuiltAgentAPKWhenAvailable(t *testing.T) { if err != nil { t.Fatal(err) } - if metadata.VersionCode != 49 || metadata.VersionName != "0.9.36" { + if metadata.VersionCode != 53 || metadata.VersionName != "0.9.40" { t.Fatalf("metadata=%+v", metadata) } } diff --git a/server/app/goauto/purchase/agent_retry_test.go b/server/app/goauto/purchase/agent_retry_test.go index d0e192e..5497c95 100644 --- a/server/app/goauto/purchase/agent_retry_test.go +++ b/server/app/goauto/purchase/agent_retry_test.go @@ -71,6 +71,11 @@ func TestAgentRetryCreatesOneFixedDeviceTaskAndReplays(t *testing.T) { setCollectedPDDPrice(t, db, f.pdd.ID) service := testService(db) failed := failedLiveTask(t, db, service, f) + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{ + "target_color": "当前黑色", "target_size": "均碼", + }).Error; err != nil { + t.Fatal(err) + } history, err := service.AgentHistoryDetail(context.Background(), failed.ID, f.token) if err != nil || !history.Task.Retryable || history.Task.RetryDisabledReason != "" { @@ -92,6 +97,9 @@ func TestAgentRetryCreatesOneFixedDeviceTaskAndReplays(t *testing.T) { if oldTask.Status != models.PurchaseTaskStatusFailed || newTask.Status != models.PurchaseTaskStatusPending || newTask.DeviceID == nil || *newTask.DeviceID != f.device.ID || newTask.AddressSuffix == oldTask.AddressSuffix { t.Fatalf("retry did not preserve old task or fix new task to device: old=%+v new=%+v", oldTask, newTask) } + if newTask.TargetColorSnapshot != "当前黑色" || newTask.TargetSizeSnapshot != "均碼" || newTask.SpecSource != "unresolved" || purchaseAttemptPhase(newTask) != models.PurchaseAttemptPhaseSpecProbe { + t.Fatalf("retry did not use current SYB targets and a fresh probe: %+v", newTask) + } replay, err := service.AgentRetry(context.Background(), failed.ID, request, f.token) if err != nil || !replay.Replayed || replay.TaskID != first.TaskID { diff --git a/server/app/goauto/purchase/lifecycle.go b/server/app/goauto/purchase/lifecycle.go index f9a47b7..9fffa06 100644 --- a/server/app/goauto/purchase/lifecycle.go +++ b/server/app/goauto/purchase/lifecycle.go @@ -186,17 +186,14 @@ func (s *Service) Start(ctx context.Context, taskID uint64, req ActionRequest, t if e = ensureAccountFree(tx, t.PDDAccountID, t.ID, s.Now()); e != nil { return e } - phase := models.PurchaseAttemptPhasePurchase - if t.SpecSource == "unresolved" { - phase = models.PurchaseAttemptPhaseSpecProbe - } + phase := purchaseAttemptPhase(t) ruleSnapshotHash := purchaseRuleSnapshotHash(t.RuleSnapshot) now := s.Now() var a models.PurchaseTaskAttempt if e = tx.Where("task_id = ? AND status = ?", t.ID, models.PurchaseAttemptStatusPending).Order("attempt_number DESC, id DESC").First(&a).Error; e == nil { - if a.DeviceID == nil || *a.DeviceID != d.ID || a.RuleSnapshotHash != ruleSnapshotHash { + if a.DeviceID == nil || *a.DeviceID != d.ID || a.RuleSnapshotHash != ruleSnapshotHash || a.Phase != phase { failureCode := CodeStateConflict - message := "采购规则快照校验失败,请重新重试任务" + message := "采购任务执行快照校验失败,请重新创建任务" a.Status = models.PurchaseAttemptStatusFailed a.ErrorCode = &failureCode a.ErrorMessage = &message diff --git a/server/app/goauto/purchase/reset.go b/server/app/goauto/purchase/reset.go index 6c292dc..5e08130 100644 --- a/server/app/goauto/purchase/reset.go +++ b/server/app/goauto/purchase/reset.go @@ -87,6 +87,9 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq if err := validatePurchaseResetState(tx, task); err != nil { return err } + if task.SpecSource != "unresolved" && !hasCompletePurchaseSpec(task) { + return fail(CodeMappingRequired, "任务没有完整的精确商品规格,请创建新采购任务") + } deviceRecord, err := lockPurchaseResetDevice(tx, task, authenticated) if err != nil { return err @@ -130,10 +133,7 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq return conflictOrInternal(err) } - phase := models.PurchaseAttemptPhasePurchase - if task.SpecSource == "unresolved" { - phase = models.PurchaseAttemptPhaseSpecProbe - } + phase := purchaseAttemptPhase(task) // MySQL normalizes values written to a JSON column. Reload the task before // hashing so the pending attempt uses the exact representation Start will // read later, rather than the pre-persistence DefaultLiveRule bytes. @@ -155,6 +155,18 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq return response, err } +func purchaseAttemptPhase(task models.PurchaseTask) string { + if task.SpecSource == "unresolved" || !hasCompletePurchaseSpec(task) { + return models.PurchaseAttemptPhaseSpecProbe + } + return models.PurchaseAttemptPhasePurchase +} + +func hasCompletePurchaseSpec(task models.PurchaseTask) bool { + return (strings.TrimSpace(task.TargetColorSnapshot) == "" || strings.TrimSpace(task.MappedColorSnapshot) != "") && + (strings.TrimSpace(task.TargetSizeSnapshot) == "" || strings.TrimSpace(task.MappedSizeSnapshot) != "") +} + func validatePurchaseResetState(tx *gorm.DB, task models.PurchaseTask) error { switch task.Status { case models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusOrderCreated, models.PurchaseTaskStatusOrderResultUnknown: diff --git a/server/app/goauto/purchase/reset_test.go b/server/app/goauto/purchase/reset_test.go index 3aee438..a4647de 100644 --- a/server/app/goauto/purchase/reset_test.go +++ b/server/app/goauto/purchase/reset_test.go @@ -108,6 +108,36 @@ func TestPurchaseResetReusesTaskRefreshesRuleAndIsIdempotent(t *testing.T) { } } +func TestPurchaseResetRejectsResolvedDecisionWithoutExactSpec(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + service := testService(db) + failed := failedLiveTask(t, db, service, f) + decisionID := uuid.NewString() + if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", failed.ID).Updates(map[string]any{ + "spec_source": "ai_match", "spec_decision_request_id": decisionID, + "mapped_color_snapshot": "", "mapped_size_snapshot": "", + }).Error; err != nil { + t.Fatal(err) + } + + _, err := service.ResetForDevice(context.Background(), failed.ID, PurchaseResetRequest{RequestID: uuid.NewString()}, f.token) + if code(err) != CodeMappingRequired { + t.Fatalf("resolved decision without exact spec was reset: %v", err) + } + var task models.PurchaseTask + if queryErr := db.First(&task, failed.ID).Error; queryErr != nil { + t.Fatal(queryErr) + } + if task.Status != models.PurchaseTaskStatusFailed { + t.Fatalf("rejected reset changed task status: %+v", task) + } + var attemptCount int64 + if queryErr := db.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ?", failed.ID).Count(&attemptCount).Error; queryErr != nil || attemptCount != 0 { + t.Fatalf("rejected reset created attempt: count=%d err=%v", attemptCount, queryErr) + } +} + func TestPurchaseStartPersistsPendingAttemptSnapshotMismatchAsFailure(t *testing.T) { db := testDB(t) f := seed(t, db, liveCaps(), true)