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 f4f93f3..c9958f7 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 @@ -145,6 +145,8 @@ data class PurchaseHistoryDetail( val replacementMappingStatus: String?, val replacementActivationStatus: String?, val replacementActivationErrorMessage: String?, + val continuePurchaseEligible: Boolean, + val continuePurchaseDisabledReason: String?, ) class AgentApiException( @@ -330,6 +332,8 @@ class AgentApiClient(private val serverUrl: String) { replacementMappingStatus = data.nullableString("replacementMappingStatus"), replacementActivationStatus = data.nullableString("replacementActivationStatus"), replacementActivationErrorMessage = data.nullableString("replacementActivationErrorMessage"), + continuePurchaseEligible = data.optBoolean("continuePurchaseEligible"), + continuePurchaseDisabledReason = data.nullableString("continuePurchaseDisabledReason"), ) } 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 a0ecbe5..5bbad6e 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 @@ -97,6 +97,10 @@ internal object ReplacementActionPolicy { } } +internal object ContinuePurchasePolicy { + fun showsAction(serverEligible: Boolean): Boolean = serverEligible +} + internal class TaskDetailState(restoredTaskId: Long? = null) { var taskId: Long? = restoredTaskId?.takeIf { it > 0L } private set @@ -695,7 +699,8 @@ class TaskHistoryFragment : Fragment() { addView(context.label(info, 14f)) }), collectionCardParams()) if (task.errorMessage != null) resultColumn.addView(context.centeredMessage(task.errorMessage, "错误代码:${task.errorCode ?: "—"}")) - if (PurchaseRetryPolicy.showsAction(task.status, task.retryable)) { + val replacementInProgress = !detail.replacementMappingStatus.isNullOrBlank() || !detail.replacementActivationStatus.isNullOrBlank() + if (!replacementInProgress && PurchaseRetryPolicy.showsAction(task.status, task.retryable)) { resultColumn.addView(MaterialButton(context).apply { text = "重试采购" minimumHeight = context.dp(48) @@ -703,7 +708,7 @@ class TaskHistoryFragment : Fragment() { setOnClickListener { confirmPurchaseRetry(task) } }, collectionCardParams()) resultColumn.addView(context.centeredMessage("重试边界", "系统保留旧任务并创建新任务;不会执行支付。")) - } else if (task.status == "failed") { + } else if (!replacementInProgress && task.status == "failed") { val reason = task.retryDisabledReason?.takeIf(String::isNotBlank) ?: "请在管理端核对任务状态。" resultColumn.addView(context.centeredMessage("不可重试", reason)) } else { @@ -717,6 +722,9 @@ class TaskHistoryFragment : Fragment() { originType = "purchase", taskId = task.taskId, taskNo = "CG-${task.taskId}", + continuePurchaseEligible = detail.continuePurchaseEligible, + continuePurchaseDisabledReason = detail.continuePurchaseDisabledReason, + purchaseTask = task, ) } @@ -728,8 +736,20 @@ class TaskHistoryFragment : Fragment() { originType: String, taskId: Long, taskNo: String, + continuePurchaseEligible: Boolean = false, + continuePurchaseDisabledReason: String? = null, + purchaseTask: PurchaseHistoryItem? = null, ) { val context = requireContext() + if (ContinuePurchasePolicy.showsAction(continuePurchaseEligible) && purchaseTask != null) { + resultColumn.addView(MaterialButton(context).apply { + text = "继续采购" + minimumHeight = context.dp(48) + contentDescription = "继续采购任务 $taskNo" + setOnClickListener { confirmPurchaseRetry(purchaseTask, continuing = true) } + }, collectionCardParams()) + return + } when (ReplacementActionPolicy.presentation(eligible, mappingStatus, activationStatus)) { ReplacementPresentation.ACTIVATION_FAILED -> resultColumn.addView( context.centeredMessage( @@ -738,7 +758,18 @@ class TaskHistoryFragment : Fragment() { ), ) ReplacementPresentation.MATCHING -> resultColumn.addView(context.centeredMessage("规格匹配中", "替代商品已提交,正在匹配规格。")) - ReplacementPresentation.MATCHED -> resultColumn.addView(context.centeredMessage("规格已匹配", "可继续处理原任务。")) + ReplacementPresentation.MATCHED -> { + if (purchaseTask != null) { + resultColumn.addView( + context.centeredMessage( + "暂不可继续采购", + continuePurchaseDisabledReason?.takeIf(String::isNotBlank) ?: "请刷新后重试。", + ), + ) + } else { + resultColumn.addView(context.centeredMessage("规格已匹配", "替代商品规格已经确认。")) + } + } ReplacementPresentation.MANUAL_REQUIRED -> resultColumn.addView(context.centeredMessage("需要人工处理", "需在 Admin 人工匹配规格。")) ReplacementPresentation.ACTION -> resultColumn.addView(MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply { text = "采集替代商品" @@ -763,25 +794,26 @@ class TaskHistoryFragment : Fragment() { .show() } - private fun confirmPurchaseRetry(task: PurchaseHistoryItem) { + private fun confirmPurchaseRetry(task: PurchaseHistoryItem, continuing: Boolean = false) { MaterialAlertDialogBuilder(requireContext()) - .setTitle("重试采购 CG-${task.taskId}?") + .setTitle(if (continuing) "继续采购 CG-${task.taskId}?" else "重试采购 CG-${task.taskId}?") .setMessage(PurchaseRetryPolicy.confirmationMessage()) .setNegativeButton("取消", null) - .setPositiveButton("确认重试") { _, _ -> retryPurchaseTask(task.taskId) } + .setPositiveButton(if (continuing) "确认继续" else "确认重试") { _, _ -> retryPurchaseTask(task.taskId, continuing) } .show() } - private fun retryPurchaseTask(taskId: Long) { + private fun retryPurchaseTask(taskId: Long, continuing: Boolean = false) { val context = requireContext() val credentials = runCatching { SecureDeviceStore(context).credentials() }.getOrNull() val serverUrl = AgentSettingsStore(context).serverUrl() + val failureTitle = if (continuing) "无法继续采购" else "无法重试采购" if (credentials == null || serverUrl.isBlank()) { - showMessage("无法重试采购", "设备尚未连接服务端,请先检查设置。", "返回任务详情") { loadPurchaseDetail(taskId) } + showMessage(failureTitle, "设备尚未连接服务端,请先检查设置。", "返回任务详情") { loadPurchaseDetail(taskId) } return } val generation = ++requestGeneration - showLoading("正在提交重试请求…") + showLoading(if (continuing) "正在提交继续采购请求…" else "正在提交重试请求…") Thread { runCatching { AgentApiClient(serverUrl).retryPurchaseTask(taskId, UUID.randomUUID().toString(), credentials.token) } .onSuccess { result -> @@ -794,7 +826,7 @@ class TaskHistoryFragment : Fragment() { .onFailure { error -> resultColumn.post { if (isAdded && generation == requestGeneration) { - showMessage("无法重试采购", error.message ?: "请检查设备状态后重试。", "返回任务详情") { loadPurchaseDetail(taskId) } + showMessage(failureTitle, error.message ?: "请检查设备状态后重试。", "返回任务详情") { loadPurchaseDetail(taskId) } } } } diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/ReplacementActionPolicyTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/ReplacementActionPolicyTest.kt index 0ee4c06..9fe3363 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/ReplacementActionPolicyTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/ReplacementActionPolicyTest.kt @@ -2,10 +2,19 @@ package cn.ilapage.goauto.agent import cn.ilapage.goauto.agent.ui.ReplacementActionPolicy import cn.ilapage.goauto.agent.ui.ReplacementPresentation +import cn.ilapage.goauto.agent.ui.ContinuePurchasePolicy +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Assert.assertEquals import org.junit.Test class ReplacementActionPolicyTest { + @Test + fun continuePurchaseActionTrustsOnlyServerEligibility() { + assertTrue(ContinuePurchasePolicy.showsAction(true)) + assertFalse(ContinuePurchasePolicy.showsAction(false)) + } + @Test fun serverEligibilityIsTheOnlyWayToShowTheAction() { assertEquals(ReplacementPresentation.ACTION, ReplacementActionPolicy.presentation(true, null, null)) diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 5cafb67..96b4b45 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: 5f829d4d42583b0654d772043047e93a9e1deb79 -synchronized_at: 2026-08-28T10:14:45Z +wiki_revision: 6e33660c976c4412c3c456d04d5b4784a3043732 +synchronized_at: 2026-08-28T10:27:03Z # 业务规则与术语 @@ -326,3 +326,11 @@ synchronized_at: 2026-08-28T10:14:45Z - 替代商品采集结果先按普通采集事务完整保存。随后调用 #131 的原子生效流程;生效失败不得回滚或覆盖已采集商品、规格和 SKU,而是记录稳定的 `failed` 激活状态与限长错误,服务启动后可按同一幂等键仅重试生效,不重新采集。 - 任务详情的 `replacementMappingStatus` 必须由来源任务对应的替换分项推导,不能只读取主表总体状态。状态为 `matching` 时等待自动匹配,`manual_required` 时由 Admin 人工处理,`matched` 后才进入 #132 的继续采购流程。 - 替换采集沿用设备级单任务互斥、采集间隔、无障碍安全边界和禁止支付规则;不会增加轮询,也不会创建采购任务或订单。 + +## 替换匹配完成后继续采购(#132) + +- 只有采购来源对应的替换分项为 `matched`,且既有采购重试的任务终态、最新任务、不可逆边界、当前档案、持久化规格映射、PDD 候选、价格、设备在线/空闲与能力门禁全部通过时,Agent 详情才返回 `continuePurchaseEligible=true`。 +- 继续资格的详情查询不调用 AI,也不把当前可重新推导的确定性匹配当作已确认映射;映射被人工清空、PDD 重新采集导致候选变化或价格缺失时返回服务端原因。点击时再次执行同一前置门禁,防止详情读取后的状态漂移。 +- Agent 的“继续采购”复用既有 `AgentRetry → BatchRetry → Create`,请求仍只有来源任务号和 `requestId`;不新增采购创建接口。旧失败任务保持不可变,创建的新任务重新固化当前替代商品、规格、价格、规则和设备快照。 +- `matching` / `manual_required` 只展示匹配状态;`matched` 但资格不通过时展示服务端下发原因。Admin 批量重试行为不变。 +- 真机点击“继续采购”可能创建新的正式采购任务,必须先取得独立人工授权;永久禁止支付。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 0575e12..6a36810 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: 345d3b29ff7532ba4fd48d64aa3309b9d33225af -synchronized_at: 2026-08-28T10:15:31Z +wiki_revision: 81d3fec214250f9b5be4b049e65ca6a1144e52ad +synchronized_at: 2026-08-28T10:27:46Z # MVP 共享 API 契约 @@ -682,3 +682,21 @@ Content-Type: application/json `replacementOrigin.type` 只能是 `collection` 或 `purchase`。服务端在创建事务内重新检查资格,并把来源、可选纠错记录和初始 `pending` 激活状态固化到任务;同一 `requestId` 重放时来源必须完全一致。资格变化返回 HTTP 409 和 `REPLACEMENT_ORIGIN_NOT_ELIGIBLE`。 识别与结果提交继续复用当前页面采集接口。结果事务提交后,服务端调用 #131 的登记/纠错并生效流程:成功返回正常完成详情;生效失败返回 HTTP 409、`REPLACEMENT_ACTIVATION_FAILED`,但采集任务、商品、规格和 SKU 已安全保存。服务启动恢复只重试 `pending` / `failed` 的激活步骤,不重新打开 PDD 或重新采集。 + +### 替换匹配完成后继续采购(#132) + +采购任务详情在 #130 字段之外增加: + +```json +{ + "replacementMappingStatus": "matched", + "continuePurchaseEligible": true, + "continuePurchaseDisabledReason": null +} +``` + +- `continuePurchaseEligible` 是 Agent 是否显示“继续采购”的唯一资格事实;Android 不组合任务状态、替换状态或既有 `retryable` 自行推断。 +- 服务端仅在该采购任务对应虾皮商品的 replacement item 为 `matched` 时计算继续资格,不读取替换主表总体状态。资格包含既有失败重试的终态、最新任务、不可逆边界、档案、持久化映射、PDD 候选、价格、设备与能力门禁。 +- 详情资格检查不调用 AI,也不把重新推导出的候选当成已确认映射。资格不通过时返回普通人可理解的 `continuePurchaseDisabledReason`;`matching` / `manual_required` 继续使用对应状态文字且不显示按钮。 +- 点击仍调用既有 `POST /api/agent/v1/purchase-tasks/{taskId}/retry`,请求体仍为 `{"requestId":""}`。AgentRetry 在委托 BatchRetry 前重新校验 replacement item 与继续资格,随后由 BatchRetry/Create 执行最终档案和并发校验;没有新增采购任务创建路径。 +- Admin 的 BatchRetry 接口和行为不变。相同 `requestId` 重放返回同一新任务;不同 requestId 再点由最新任务门禁拒绝。 diff --git a/server/app/goauto/purchase/agent_history.go b/server/app/goauto/purchase/agent_history.go index fa351de..77e8a38 100644 --- a/server/app/goauto/purchase/agent_history.go +++ b/server/app/goauto/purchase/agent_history.go @@ -58,6 +58,8 @@ type AgentPurchaseDetail struct { ReplacementMappingStatus string `json:"replacementMappingStatus,omitempty"` ReplacementActivationStatus string `json:"replacementActivationStatus,omitempty"` ReplacementActivationErrorMessage string `json:"replacementActivationErrorMessage,omitempty"` + ContinuePurchaseEligible bool `json:"continuePurchaseEligible"` + ContinuePurchaseDisabledReason string `json:"continuePurchaseDisabledReason,omitempty"` } func (s *Service) AgentHistory(ctx context.Context, req AgentHistoryRequest, token string) (AgentPurchaseList, error) { @@ -129,11 +131,17 @@ func (s *Service) AgentHistoryDetail(ctx context.Context, taskID uint64, token s if err != nil { return AgentPurchaseDetail{}, internal(err) } + continueDecision := retryDecision{} + if inspection.MappingStatus == models.ReplacementItemMappingMatched { + continueDecision = s.continuePurchaseEligibility(ctx, task) + } return AgentPurchaseDetail{ Task: agentPurchaseItem(task, s.retryQueryEligibility(ctx, task, true)), ReplacementEligible: inspection.Eligible, ReplacementDisabledReason: inspection.DisabledReason, ReplacementMappingStatus: inspection.MappingStatus, ReplacementActivationStatus: inspection.ActivationStatus, ReplacementActivationErrorMessage: inspection.ActivationErrorMessage, + ContinuePurchaseEligible: continueDecision.Allowed, + ContinuePurchaseDisabledReason: continueDecision.Reason, }, nil } diff --git a/server/app/goauto/purchase/agent_retry_test.go b/server/app/goauto/purchase/agent_retry_test.go index 89387dd..9559ed8 100644 --- a/server/app/goauto/purchase/agent_retry_test.go +++ b/server/app/goauto/purchase/agent_retry_test.go @@ -2,7 +2,9 @@ package purchase import ( "context" + "strings" "testing" + "time" "go-admin/app/goauto/device" "go-admin/app/goauto/models" @@ -11,6 +13,59 @@ import ( "gorm.io/gorm" ) +func matchedReplacementForAgentRetry(t *testing.T, db *gorm.DB, f fixture, failed *models.PurchaseTask) models.PDDProduct { + t.Helper() + if err := db.First(&f.pdd, f.pdd.ID).Error; err != nil { + t.Fatal(err) + } + target := models.PDDProduct{ + GoodsID: "719834019132", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=719834019132", + Title: "替代商品", Status: "active", SpecsJSON: f.pdd.SpecsJSON, + } + if err := db.Create(&target).Error; err != nil { + t.Fatal(err) + } + rule := models.CollectionRule{Name: "replacement-proof", ContentJSON: `{}`} + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + proof := models.CollectionTask{ + PDDProductID: &target.ID, RuleID: rule.ID, DeviceID: &f.device.ID, + Source: models.CollectionTaskSourceAgentCurrentPage, Status: models.TaskStatusCompleted, + URLSnapshot: target.URL, GoodsIDSnapshot: target.GoodsID, RuleSnapshot: `{}`, + } + if err := db.Create(&proof).Error; err != nil { + t.Fatal(err) + } + now := time.Date(2026, 8, 20, 11, 0, 0, 0, time.UTC) + record := models.PDDProductReplacement{ + SourceProductID: f.pdd.ID, TargetProductID: target.ID, + OriginType: models.ReplacementOriginPurchase, OriginTaskID: failed.ID, + TargetCollectionTaskID: proof.ID, CreatedByDeviceID: f.device.ID, + Status: models.ReplacementStatusActive, MappingStatus: models.ReplacementMappingCompleted, + CreateRequestID: uuid.NewString(), ActivatedAt: &now, + } + if err := db.Create(&record).Error; err != nil { + t.Fatal(err) + } + source := models.ReplacementItemSourceManualMapping + item := models.PDDProductReplacementItem{ + ReplacementID: record.ID, ShopeeProductID: f.shopee.ID, + MappingStatus: models.ReplacementItemMappingMatched, Source: &source, + } + if err := db.Create(&item).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("pdd_product_id", target.ID).Error; err != nil { + t.Fatal(err) + } + errorCode := "PDD_GOODS_SOLD_OUT" + if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", failed.ID).Update("error_code", errorCode).Error; err != nil { + t.Fatal(err) + } + return target +} + func TestAgentRetryCreatesOneFixedDeviceTaskAndReplays(t *testing.T) { db := testDB(t) f := seed(t, db, liveCaps(), true) @@ -77,3 +132,51 @@ func TestAgentRetryRejectsAnotherDeviceAndUnsafeBoundary(t *testing.T) { t.Fatalf("unsafe boundary was retryable: %v", err) } } + +func TestMatchedReplacementExposesContinueAndReusesAgentRetry(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, f.pdd.ID) + service := testService(db) + failed := failedLiveTask(t, db, service, f) + target := matchedReplacementForAgentRetry(t, db, f, &failed) + + detail, err := service.AgentHistoryDetail(context.Background(), failed.ID, f.token) + if err != nil || detail.ReplacementMappingStatus != models.ReplacementItemMappingMatched || !detail.ContinuePurchaseEligible || detail.ContinuePurchaseDisabledReason != "" { + t.Fatalf("continue qualification mismatch: detail=%+v error=%v", detail, err) + } + created, err := service.AgentRetry(context.Background(), failed.ID, AgentRetryRequest{RequestID: uuid.NewString()}, f.token) + if err != nil { + t.Fatalf("continue through AgentRetry: %v", err) + } + var newTask models.PurchaseTask + if err := db.First(&newTask, created.TaskID).Error; err != nil { + t.Fatal(err) + } + if newTask.PDDProductID != target.ID || newTask.PDDGoodsIDSnapshot != target.GoodsID { + t.Fatalf("continue did not resolve current replacement archive: %+v", newTask) + } +} + +func TestMatchedReplacementRejectsClearedMappingWithoutCallingAI(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, f.pdd.ID) + service := testService(db) + failed := failedLiveTask(t, db, service, f) + matchedReplacementForAgentRetry(t, db, f, &failed) + if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", `[]`).Error; err != nil { + t.Fatal(err) + } + matcher := &countingRetryMatcher{} + service.Matcher = matcher + + detail, err := service.AgentHistoryDetail(context.Background(), failed.ID, f.token) + if err != nil || detail.ContinuePurchaseEligible || !strings.Contains(detail.ContinuePurchaseDisabledReason, "规格匹配已失效") || matcher.calls != 0 { + t.Fatalf("cleared mapping qualification mismatch: detail=%+v calls=%d error=%v", detail, matcher.calls, err) + } + _, err = service.AgentRetry(context.Background(), failed.ID, AgentRetryRequest{RequestID: uuid.NewString()}, f.token) + if code(err) != CodeMappingRequired || matcher.calls != 0 { + t.Fatalf("cleared mapping reached AI or retry: code=%s calls=%d error=%v", code(err), matcher.calls, err) + } +} diff --git a/server/app/goauto/purchase/retry.go b/server/app/goauto/purchase/retry.go index 61245f2..89dd801 100644 --- a/server/app/goauto/purchase/retry.go +++ b/server/app/goauto/purchase/retry.go @@ -9,6 +9,7 @@ import ( "go-admin/app/goauto/device" "go-admin/app/goauto/models" "go-admin/app/goauto/purchasecontract" + "go-admin/app/goauto/replacement" "github.com/google/uuid" "gorm.io/gorm" @@ -161,6 +162,18 @@ func (s *Service) AgentRetry(ctx context.Context, taskID uint64, req AgentRetryR if queryErr != nil { return AgentRetryResponse{}, internal(queryErr) } + inspection, err := replacement.NewService(s.DB).InspectOrigin(ctx, models.ReplacementOriginPurchase, taskID, deviceRecord.ID) + if err != nil { + return AgentRetryResponse{}, internal(err) + } + if inspection.MappingStatus != "" { + if inspection.MappingStatus != models.ReplacementItemMappingMatched { + return AgentRetryResponse{}, fail(CodeRetryNotAllowed, "替代商品规格尚未匹配完成") + } + if decision := s.continuePurchaseEligibility(ctx, source); !decision.Allowed { + return AgentRetryResponse{}, fail(decision.ReasonCode, decision.Reason) + } + } result, err := s.BatchRetry(ctx, BatchRetryRequest{RequestID: req.RequestID, TaskIDs: []uint64{taskID}}) if err != nil { return AgentRetryResponse{}, err @@ -224,6 +237,50 @@ func (s *Service) retryQueryEligibility(ctx context.Context, task models.Purchas return s.retryDeviceEligibility(ctx, task, checkDeviceBusy) } +// continuePurchaseEligibility is the read-side qualification used after a +// replacement item reached matched. It applies the same state, archive, price +// and device gates as retry, but never invokes AI and never treats a newly +// derivable mapping as a persisted confirmation. +func (s *Service) continuePurchaseEligibility(ctx context.Context, task models.PurchaseTask) retryDecision { + decision := s.retryStateEligibility(ctx, task) + if !decision.Allowed { + return decision + } + dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{*task.SYBProductID}) + if err != nil { + return retryDecision{ReasonCode: CodeInternal, Reason: "服务端处理失败"} + } + syb, found := dataset.sybByID[*task.SYBProductID] + if !found || syb.ShopeeProductID == nil { + preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false) + return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason} + } + shopee, found := dataset.shopeeByID[*syb.ShopeeProductID] + if !found || shopee.PDDProductID == nil { + preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false) + return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason} + } + pdd, found := dataset.pddByID[*shopee.PDDProductID] + if !found { + preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false) + return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason} + } + mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize) + candidates, _ := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize) + if source == "unresolved" || !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) { + return retryDecision{ReasonCode: CodeMappingRequired, Reason: "规格匹配已失效,请在 Admin 重新确认"} + } + preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false) + if !preview.Eligible { + return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason} + } + decision = s.retryDeviceEligibility(ctx, task, true) + if !decision.Allowed { + return decision + } + return retryDecision{Allowed: true, Preview: preview} +} + func (s *Service) retryStateEligibility(ctx context.Context, task models.PurchaseTask) retryDecision { deny := func(code, message string) retryDecision { return retryDecision{ReasonCode: code, Reason: message}