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 59c712a..f4f93f3 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 @@ -43,6 +43,7 @@ data class AgentTask( val leaseVersion: Long, val status: String, val source: String, + val replacementOriginType: String, ) data class CurrentPageIdentity( @@ -100,6 +101,11 @@ data class CollectionHistoryDetail( val colorPrices: List, val skus: List, val missing: List, + val replacementEligible: Boolean, + val replacementDisabledReason: String?, + val replacementMappingStatus: String?, + val replacementActivationStatus: String?, + val replacementActivationErrorMessage: String?, ) data class CollectionResetResult(val taskId: Long, val status: String, val replayed: Boolean) @@ -132,7 +138,14 @@ data class PurchaseHistoryItem( val createdAt: String, ) -data class PurchaseHistoryDetail(val task: PurchaseHistoryItem) +data class PurchaseHistoryDetail( + val task: PurchaseHistoryItem, + val replacementEligible: Boolean, + val replacementDisabledReason: String?, + val replacementMappingStatus: String?, + val replacementActivationStatus: String?, + val replacementActivationErrorMessage: String?, +) class AgentApiException( val status: Int, @@ -184,8 +197,19 @@ class AgentApiClient(private val serverUrl: String) { return task(response.getJSONObject("data")) } - fun createCurrentPageCollectionTask(requestId: String, token: String): AgentTask { + fun createCurrentPageCollectionTask( + requestId: String, + token: String, + replacementOriginType: String? = null, + replacementOriginTaskId: Long? = null, + ): AgentTask { val payload = JSONObject().put("requestId", requestId) + if (!replacementOriginType.isNullOrBlank() && replacementOriginTaskId != null) { + payload.put( + "replacementOrigin", + JSONObject().put("type", replacementOriginType).put("taskId", replacementOriginTaskId), + ) + } return task(requireNotNull(request("POST", "/api/agent/v1/current-page-collection-tasks", payload, token)).getJSONObject("data")) } @@ -275,6 +299,11 @@ class AgentApiClient(private val serverUrl: String) { HistorySku(specs, item.getLong("priceCent"), item.getBoolean("available"), item.getBoolean("complete")) }, missing = data.getJSONArray("missing").strings(), + replacementEligible = data.optBoolean("replacementEligible"), + replacementDisabledReason = data.nullableString("replacementDisabledReason"), + replacementMappingStatus = data.nullableString("replacementMappingStatus"), + replacementActivationStatus = data.nullableString("replacementActivationStatus"), + replacementActivationErrorMessage = data.nullableString("replacementActivationErrorMessage"), ) } @@ -294,7 +323,14 @@ class AgentApiClient(private val serverUrl: String) { fun purchaseHistoryDetail(taskId: Long, token: String): PurchaseHistoryDetail { val data = requireNotNull(request("GET", "/api/agent/v1/purchase-tasks/$taskId", null, token)).getJSONObject("data") - return PurchaseHistoryDetail(purchaseHistoryItem(data.getJSONObject("task"))) + return PurchaseHistoryDetail( + task = purchaseHistoryItem(data.getJSONObject("task")), + replacementEligible = data.optBoolean("replacementEligible"), + replacementDisabledReason = data.nullableString("replacementDisabledReason"), + replacementMappingStatus = data.nullableString("replacementMappingStatus"), + replacementActivationStatus = data.nullableString("replacementActivationStatus"), + replacementActivationErrorMessage = data.nullableString("replacementActivationErrorMessage"), + ) } fun retryPurchaseTask(taskId: Long, requestId: String, token: String): PurchaseRetryResult { @@ -345,6 +381,7 @@ class AgentApiClient(private val serverUrl: String) { leaseVersion = data.getLong("leaseVersion"), status = data.getString("status"), source = data.optString("source", "admin"), + replacementOriginType = data.optString("replacementOriginType"), ) private fun purchaseTask(data: JSONObject) = PurchaseAgentTask( 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 8a1ddd1..fb64d6b 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 @@ -131,7 +131,11 @@ class AgentForegroundService : Service() { if (intent?.action == ACTION_RECONNECT) registeredThisProcess.set(false) if (intent?.action == ACTION_CHECK_NOW) manualCheckRequested.set(true) if (intent?.action == ACTION_CURRENT_PAGE_COLLECTION) { - requestCurrentPageCollection(intent.getStringExtra(EXTRA_CURRENT_PAGE_REQUEST_ID).orEmpty()) + requestCurrentPageCollection( + intent.getStringExtra(EXTRA_CURRENT_PAGE_REQUEST_ID).orEmpty(), + intent.getStringExtra(EXTRA_REPLACEMENT_ORIGIN_TYPE), + intent.getLongExtra(EXTRA_REPLACEMENT_ORIGIN_TASK_ID, 0L).takeIf { it > 0L }, + ) return START_STICKY } triggerSync() @@ -291,18 +295,22 @@ class AgentForegroundService : Service() { }) } - private fun requestCurrentPageCollection(requestId: String) { + private fun requestCurrentPageCollection( + requestId: String, + replacementOriginType: String?, + replacementOriginTaskId: Long?, + ) { if (requestId.isBlank()) { - publishCurrentPageResult(0L, "采集请求无效,请重试。") + publishCurrentPageResult(0L, "采集请求无效,请重试。", replacementOriginType, replacementOriginTaskId) return } activeCollectionCooldown()?.let { ticket -> val remaining = CollectionCooldownPolicy.remainingSeconds(System.currentTimeMillis(), ticket) - publishCurrentPageResult(0L, "采集间隔中,还需 $remaining 秒。") + publishCurrentPageResult(0L, "采集间隔中,还需 $remaining 秒。", replacementOriginType, replacementOriginTaskId) return } if (!taskMutex.tryAcquire(CURRENT_PAGE_RESERVATION_ID)) { - publishCurrentPageResult(0L, "设备正在执行任务,请稍后再试。") + publishCurrentPageResult(0L, "设备正在执行任务,请稍后再试。", replacementOriginType, replacementOriginTaskId) return } cancelIdleReturn("正在创建当前页面采集任务") @@ -318,7 +326,12 @@ class AgentForegroundService : Service() { throw TaskFailure("CURRENT_PDD_PAGE_NOT_FOUND", "请先在拼多多打开目标商品详情页") } val api = AgentApiClient(serverUrl) - val task = api.createCurrentPageCollectionTask(requestId, credentials.token) + val task = api.createCurrentPageCollectionTask( + requestId, + credentials.token, + replacementOriginType, + replacementOriginTaskId, + ) taskId = task.taskId check(taskMutex.transfer(CURRENT_PAGE_RESERVATION_ID, task.taskId)) { "当前页面采集本地占用状态不一致" @@ -329,15 +342,21 @@ class AgentForegroundService : Service() { publishCurrentPageResult( task.taskId, if (outcome.successful) { - "临时采集任务 #${task.taskId} 已结束,请查看采集记录。" + if (task.replacementOriginType.isNotBlank()) { + "已替换,正在匹配规格" + } else { + "临时采集任务 #${task.taskId} 已结束,请查看采集记录。" + } } else { currentPageUserMessage(outcome.code.orEmpty(), outcome.message.orEmpty()) }, + replacementOriginType, + replacementOriginTaskId, ) } catch (error: TaskFailure) { - publishCurrentPageResult(taskId, currentPageUserMessage(error.code, error.message ?: "采集失败")) + publishCurrentPageResult(taskId, currentPageUserMessage(error.code, error.message ?: "采集失败"), replacementOriginType, replacementOriginTaskId) } catch (error: AgentApiException) { - publishCurrentPageResult(taskId, currentPageUserMessage(error.code, error.message)) + publishCurrentPageResult(taskId, currentPageUserMessage(error.code, error.message), replacementOriginType, replacementOriginTaskId) } catch (error: Exception) { if (taskId > 0L) { val token = runCatching { identityStore.credentials()?.token }.getOrNull() @@ -353,7 +372,7 @@ class AgentForegroundService : Service() { } } } - publishCurrentPageResult(taskId, "采集失败,请查看任务详情。") + publishCurrentPageResult(taskId, "采集失败,请查看任务详情。", replacementOriginType, replacementOriginTaskId) } finally { if (taskId > 0L) { runningTaskId.compareAndSet(taskId, null) @@ -366,13 +385,20 @@ class AgentForegroundService : Service() { } } - private fun publishCurrentPageResult(taskId: Long, message: String) { + private fun publishCurrentPageResult( + taskId: Long, + message: String, + replacementOriginType: String? = null, + replacementOriginTaskId: Long? = null, + ) { currentPageResultNotificationUntil.set(SystemClock.elapsedRealtime() + CURRENT_PAGE_RESULT_NOTIFICATION_MILLIS) updateNotification(message, force = true) sendBroadcast(Intent(ACTION_CURRENT_PAGE_RESULT).apply { setPackage(packageName) putExtra(EXTRA_CURRENT_PAGE_TASK_ID, taskId) putExtra(EXTRA_CURRENT_PAGE_MESSAGE, message) + replacementOriginType?.let { putExtra(EXTRA_REPLACEMENT_ORIGIN_TYPE, it) } + replacementOriginTaskId?.let { putExtra(EXTRA_REPLACEMENT_ORIGIN_TASK_ID, it) } }) } @@ -382,6 +408,7 @@ class AgentForegroundService : Service() { "CURRENT_PDD_PAGE_NOT_FOUND" -> "没有找到商品详情页,请重新打开商品后再试。" "PDD_SHARE_UNAVAILABLE", "PDD_COPY_LINK_UNAVAILABLE", "PDD_CLIPBOARD_UNAVAILABLE", "PDD_SHARE_LINK_INVALID" -> "无法识别商品链接,请确认商品页可以分享后再试。" + "REPLACEMENT_ACTIVATION_FAILED" -> "采集成功,但替换生效失败,请稍后重试。" else -> fallback.ifBlank { "采集失败,请稍后重试。" } } @@ -694,6 +721,10 @@ class AgentForegroundService : Service() { } TaskExecutionSummary(false, error.code, error.message) } catch (error: AgentApiException) { + if (error.code == "REPLACEMENT_ACTIVATION_FAILED") { + resultSafelySubmitted = true + beginPostCollectionCooldowns() + } stateStore.update("TASK_ERROR", "${error.code}:${error.message}", tokenStored = true) TaskExecutionSummary(false, error.code, error.message) } catch (error: Exception) { @@ -1001,6 +1032,8 @@ class AgentForegroundService : Service() { const val EXTRA_CURRENT_PAGE_REQUEST_ID = "current_page_request_id" const val EXTRA_CURRENT_PAGE_TASK_ID = "current_page_task_id" const val EXTRA_CURRENT_PAGE_MESSAGE = "current_page_message" + const val EXTRA_REPLACEMENT_ORIGIN_TYPE = "replacement_origin_type" + const val EXTRA_REPLACEMENT_ORIGIN_TASK_ID = "replacement_origin_task_id" const val MANUAL_EMPTY = "empty" const val MANUAL_COLLECTION_TASK = "collection_task" const val MANUAL_PURCHASE_TASK = "purchase_task" @@ -1044,10 +1077,17 @@ class AgentForegroundService : Service() { } } - fun collectCurrentPage(context: Context, requestId: String): CurrentPageLaunchResult { + fun collectCurrentPage( + context: Context, + requestId: String, + replacementOriginType: String? = null, + replacementOriginTaskId: Long? = null, + ): CurrentPageLaunchResult { val intent = Intent(context, AgentForegroundService::class.java).apply { action = ACTION_CURRENT_PAGE_COLLECTION putExtra(EXTRA_CURRENT_PAGE_REQUEST_ID, requestId) + replacementOriginType?.let { putExtra(EXTRA_REPLACEMENT_ORIGIN_TYPE, it) } + replacementOriginTaskId?.let { putExtra(EXTRA_REPLACEMENT_ORIGIN_TASK_ID, it) } } return cn.ilapage.goauto.agent.automation.CurrentPageNavigationPolicy.launchBeforeOptionalBackground( launch = { 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 2df32f1..a0ecbe5 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 @@ -84,6 +84,19 @@ internal object PurchaseRetryPolicy { "系统会保留原任务,并创建一个新的采购任务。重试可能创建新的拼多多待付款订单,但系统不会支付。" } +internal enum class ReplacementPresentation { ACTION, ACTIVATION_FAILED, MATCHING, MATCHED, MANUAL_REQUIRED, HIDDEN } + +internal object ReplacementActionPolicy { + fun presentation(eligible: Boolean, mappingStatus: String?, activationStatus: String?): ReplacementPresentation = when { + activationStatus == "failed" -> ReplacementPresentation.ACTIVATION_FAILED + mappingStatus == "matching" -> ReplacementPresentation.MATCHING + mappingStatus == "matched" -> ReplacementPresentation.MATCHED + mappingStatus == "manual_required" -> ReplacementPresentation.MANUAL_REQUIRED + eligible -> ReplacementPresentation.ACTION + else -> ReplacementPresentation.HIDDEN + } +} + internal class TaskDetailState(restoredTaskId: Long? = null) { var taskId: Long? = restoredTaskId?.takeIf { it > 0L } private set @@ -113,18 +126,33 @@ class TaskHistoryFragment : Fragment() { private var currentPageReceiverRegistered = false private val currentPageReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action != AgentForegroundService.ACTION_CURRENT_PAGE_RESULT || !collection) return + if (intent?.action != AgentForegroundService.ACTION_CURRENT_PAGE_RESULT) return val message = intent.getStringExtra(AgentForegroundService.EXTRA_CURRENT_PAGE_MESSAGE).orEmpty() val taskId = intent.getLongExtra(AgentForegroundService.EXTRA_CURRENT_PAGE_TASK_ID, 0L) + val originType = intent.getStringExtra(AgentForegroundService.EXTRA_REPLACEMENT_ORIGIN_TYPE) + val originTaskId = intent.getLongExtra(AgentForegroundService.EXTRA_REPLACEMENT_ORIGIN_TASK_ID, 0L) + val handlesResult = when (originType) { + "collection" -> collection + "purchase" -> !collection + null -> collection + else -> false + } + if (!handlesResult) return if (message.isNotBlank()) toast(message) - if (taskId > 0L && isAdded) loadCollectionDetail(taskId) else if (isAdded) load() + if (!isAdded) return + when { + originType == "collection" && collection && originTaskId > 0L -> loadCollectionDetail(originTaskId) + originType == "purchase" && !collection && originTaskId > 0L -> loadPurchaseDetail(originTaskId) + originType == null && collection && taskId > 0L -> loadCollectionDetail(taskId) + originType == null && collection -> load() + } } } override fun onCreate(state: Bundle?) { super.onCreate(state) detailState = TaskDetailState(state?.getLong(STATE_DETAIL_TASK_ID)?.takeIf { it > 0L }) - if (collection) registerCurrentPageReceiver() + registerCurrentPageReceiver() } override fun onSaveInstanceState(outState: Bundle) { @@ -506,24 +534,20 @@ class TaskHistoryFragment : Fragment() { setOnClickListener { confirmReset(task) } }, collectionCardParams()) } + renderReplacementAction( + eligible = detail.replacementEligible, + mappingStatus = detail.replacementMappingStatus, + activationStatus = detail.replacementActivationStatus, + activationErrorMessage = detail.replacementActivationErrorMessage, + originType = "collection", + taskId = task.taskId, + taskNo = "#${task.taskId}", + ) } private fun confirmCurrentPageCollection() { - collectionCooldownMessage()?.let { message -> - showCurrentPageBlocked(message) - return - } val context = requireContext() - val settings = AgentSettingsStore(context) - val credentials = runCatching { SecureDeviceStore(context).credentials() }.getOrNull() - val accessibility = GoAutoAccessibilityService.instance - val problem = when { - settings.serverUrl().isBlank() || credentials == null -> "设备尚未连接服务端,请先检查设置。" - AccessibilityReadinessDetector.current(context) != AccessibilityReadiness.READY || accessibility == null -> "请先到“设置”开启采集采购助手。" - AgentStateStore(context).read().currentTaskId != null -> "设备正在执行任务,请稍后再试。" - !accessibility.hasRecentPddForeground() -> "请先在拼多多打开目标商品详情页,再切回 Agent。" - else -> null - } + val problem = currentPageCollectionProblem() if (problem != null) { showCurrentPageBlocked(problem) return @@ -533,18 +557,38 @@ class TaskHistoryFragment : Fragment() { .setMessage("请确认拼多多已停留在目标商品详情页。Agent 将读取当前商品资料和分享链接;不会采购、创建订单或支付。") .setNegativeButton("取消", null) .setPositiveButton("开始采集") { _, _ -> - toast("正在创建临时采集任务…") - when (AgentForegroundService.collectCurrentPage(context, UUID.randomUUID().toString())) { - CurrentPageLaunchResult.STARTED -> Unit - CurrentPageLaunchResult.START_FAILED -> - showCurrentPageBlocked("系统未允许启动采集服务,请保持 Agent 在前台后重试。") - CurrentPageLaunchResult.BACKGROUND_FAILED -> - showCurrentPageBlocked("无法将 Agent 切到后台,请手动返回拼多多后重试。") - } + launchCurrentPageCollection() } .show() } + private fun currentPageCollectionProblem(): String? { + collectionCooldownMessage()?.let { return it } + val context = requireContext() + val settings = AgentSettingsStore(context) + val credentials = runCatching { SecureDeviceStore(context).credentials() }.getOrNull() + val accessibility = GoAutoAccessibilityService.instance + return when { + settings.serverUrl().isBlank() || credentials == null -> "设备尚未连接服务端,请先检查设置。" + AccessibilityReadinessDetector.current(context) != AccessibilityReadiness.READY || accessibility == null -> "请先到“设置”开启采集采购助手。" + AgentStateStore(context).read().currentTaskId != null -> "设备正在执行任务,请稍后再试。" + !accessibility.hasRecentPddForeground() -> "请先在拼多多打开目标商品详情页,再切回 Agent。" + else -> null + } + } + + private fun launchCurrentPageCollection(originType: String? = null, originTaskId: Long? = null) { + val context = requireContext() + toast(if (originType == null) "正在创建临时采集任务…" else "正在创建替代商品采集任务…") + when (AgentForegroundService.collectCurrentPage(context, UUID.randomUUID().toString(), originType, originTaskId)) { + CurrentPageLaunchResult.STARTED -> Unit + CurrentPageLaunchResult.START_FAILED -> + showCurrentPageBlocked("系统未允许启动采集服务,请保持 Agent 在前台后重试。") + CurrentPageLaunchResult.BACKGROUND_FAILED -> + showCurrentPageBlocked("无法将 Agent 切到后台,请手动返回拼多多后重试。") + } + } + private fun showCurrentPageBlocked(message: String) { MaterialAlertDialogBuilder(requireContext()) .setTitle("暂时不能采集") @@ -665,6 +709,58 @@ class TaskHistoryFragment : Fragment() { } else { resultColumn.addView(context.centeredMessage("只读详情", "本页没有取消、修改订单或支付入口。")) } + renderReplacementAction( + eligible = detail.replacementEligible, + mappingStatus = detail.replacementMappingStatus, + activationStatus = detail.replacementActivationStatus, + activationErrorMessage = detail.replacementActivationErrorMessage, + originType = "purchase", + taskId = task.taskId, + taskNo = "CG-${task.taskId}", + ) + } + + private fun renderReplacementAction( + eligible: Boolean, + mappingStatus: String?, + activationStatus: String?, + activationErrorMessage: String?, + originType: String, + taskId: Long, + taskNo: String, + ) { + val context = requireContext() + when (ReplacementActionPolicy.presentation(eligible, mappingStatus, activationStatus)) { + ReplacementPresentation.ACTIVATION_FAILED -> resultColumn.addView( + context.centeredMessage( + "采集成功,但替换生效失败", + activationErrorMessage?.takeIf(String::isNotBlank) ?: "请稍后重试。", + ), + ) + ReplacementPresentation.MATCHING -> resultColumn.addView(context.centeredMessage("规格匹配中", "替代商品已提交,正在匹配规格。")) + ReplacementPresentation.MATCHED -> 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 = "采集替代商品" + minimumHeight = context.dp(48) + contentDescription = "为任务 $taskNo 采集替代商品" + setOnClickListener { confirmReplacement(originType, taskId, taskNo) } + }, collectionCardParams()) + ReplacementPresentation.HIDDEN -> Unit + } + } + + private fun confirmReplacement(originType: String, taskId: Long, taskNo: String) { + currentPageCollectionProblem()?.let { problem -> + showCurrentPageBlocked(problem) + return + } + MaterialAlertDialogBuilder(requireContext()) + .setTitle("采集替代商品") + .setMessage("用当前商品替换 $taskNo 的失效商品?") + .setNegativeButton("取消", null) + .setPositiveButton("替换") { _, _ -> launchCurrentPageCollection(originType, taskId) } + .show() } private fun confirmPurchaseRetry(task: PurchaseHistoryItem) { 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 new file mode 100644 index 0000000..0ee4c06 --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/ReplacementActionPolicyTest.kt @@ -0,0 +1,22 @@ +package cn.ilapage.goauto.agent + +import cn.ilapage.goauto.agent.ui.ReplacementActionPolicy +import cn.ilapage.goauto.agent.ui.ReplacementPresentation +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReplacementActionPolicyTest { + @Test + fun serverEligibilityIsTheOnlyWayToShowTheAction() { + assertEquals(ReplacementPresentation.ACTION, ReplacementActionPolicy.presentation(true, null, null)) + assertEquals(ReplacementPresentation.HIDDEN, ReplacementActionPolicy.presentation(false, null, null)) + } + + @Test + fun workflowStatesTakePriorityOverEligibility() { + assertEquals(ReplacementPresentation.ACTIVATION_FAILED, ReplacementActionPolicy.presentation(true, "matching", "failed")) + assertEquals(ReplacementPresentation.MATCHING, ReplacementActionPolicy.presentation(true, "matching", "activated")) + assertEquals(ReplacementPresentation.MATCHED, ReplacementActionPolicy.presentation(false, "matched", "activated")) + assertEquals(ReplacementPresentation.MANUAL_REQUIRED, ReplacementActionPolicy.presentation(false, "manual_required", "activated")) + } +} diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 5643564..5cafb67 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: 14d111bf1043f9c31f4f54dbfbca38826e986ab9 -synchronized_at: 2026-08-28T09:50:14Z +wiki_revision: 5f829d4d42583b0654d772043047e93a9e1deb79 +synchronized_at: 2026-08-28T10:14:45Z # 业务规则与术语 @@ -318,3 +318,11 @@ synchronized_at: 2026-08-28T09:50:14Z - 用户于 2026-08-28 确认放宽 #46 的 AI 口径:`ai_match` 仅在置信度存在且不低于服务端阈值(默认 0.9)、结果严格属于当前候选、颜色/尺码角色唯一完整、原因非空且写入前输入版本未变化时自动确认。否则不得猜测或选择相近候选,转 `manual_required`。 - 自动确认必须保存来源、实际置信度和脱敏限长原因;人工确认完成后同步对应分项及主表总体状态。worker 不得覆盖已由人工改变的映射。 - 纠错不能简单执行 B→C;必须把原 A→B 记录置为 `superseded`,建立 A→C,并只处理原记录冻结的虾皮商品影响集合。 + +## Agent 手动采集替代商品(#130) + +- 只有同一设备上的失败采集或失败采购任务,且错误码逐字等于 `PDD_LINK_INVALID` 或 `PDD_GOODS_SOLD_OUT` 时,Agent 详情才显示“采集替代商品”;Android 不自行推断资格。 +- Agent 创建当前页面采集任务时携带 `replacementOrigin.type`(`collection` / `purchase`)和来源 `taskId`。服务端再次校验设备、任务终态、错误码、源商品、既有替换与进行中的替换流程,并把来源和激活状态持久化到采集任务,保证进程重启后仍可恢复。 +- 替代商品采集结果先按普通采集事务完整保存。随后调用 #131 的原子生效流程;生效失败不得回滚或覆盖已采集商品、规格和 SKU,而是记录稳定的 `failed` 激活状态与限长错误,服务启动后可按同一幂等键仅重试生效,不重新采集。 +- 任务详情的 `replacementMappingStatus` 必须由来源任务对应的替换分项推导,不能只读取主表总体状态。状态为 `matching` 时等待自动匹配,`manual_required` 时由 Admin 人工处理,`matched` 后才进入 #132 的继续采购流程。 +- 替换采集沿用设备级单任务互斥、采集间隔、无障碍安全边界和禁止支付规则;不会增加轮询,也不会创建采购任务或订单。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 3ac7f33..0575e12 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: 0bbe3f1041a0795a156dcd1205dbe85474e80f8c -synchronized_at: 2026-08-27T10:14:59Z +wiki_revision: 345d3b29ff7532ba4fd48d64aa3309b9d33225af +synchronized_at: 2026-08-28T10:15:31Z # MVP 共享 API 契约 @@ -647,3 +647,38 @@ Content-Type: application/json - 相同识别 `requestId` 直接返回已确认身份且不再次访问短链;任务首次确认身份后,不允许不同请求覆盖为其他商品。 - 识别后继续复用 `POST /api/agent/v1/tasks/{taskId}/result` 与 `/fail`。结果接口要求任务已绑定身份且结果 goods_id 一致;完成、部分完成和失败继续按既有状态机释放设备槽。 - Agent 历史的任务摘要增加 `source`;`agent_current_page` 展示为“Agent 当前页面”,不新增任务状态。该来源任务不支持重置,失败后由用户从当前商品页重新发起新任务。 + +### 失败任务采集替代商品(#130) + +失败采集详情和失败采购详情增加以下服务端计算字段: + +```json +{ + "replacementEligible": true, + "replacementDisabledReason": null, + "replacementMappingStatus": "matching", + "replacementActivationStatus": "activated", + "replacementActivationErrorMessage": null +} +``` + +- `replacementEligible=true` 仅适用于当前 Device Token 对应设备、任务状态为 `failed`,且错误码逐字等于 `PDD_LINK_INVALID` 或 `PDD_GOODS_SOLD_OUT`;其他错误、其他设备、无源商品、已存在生效替换或已有待处理替换均不得由 Android 自行放宽。 +- `replacementMappingStatus` 取当前来源对应的分项状态:`matching`、`matched` 或 `manual_required`。采购来源必须限定到该任务的 `shopeeProductId`;不能以替换主表总体状态代替。 +- `replacementActivationStatus` 为 `pending`、`activated` 或 `failed`;激活失败时已采集数据仍为完成态,并返回限长的 `replacementActivationErrorMessage`。 + +创建替代商品采集任务仍调用: + +```http +POST /api/agent/v1/current-page-collection-tasks +Authorization: Bearer +Content-Type: application/json + +{ + "requestId": "", + "replacementOrigin": {"type": "collection", "taskId": 123} +} +``` + +`replacementOrigin.type` 只能是 `collection` 或 `purchase`。服务端在创建事务内重新检查资格,并把来源、可选纠错记录和初始 `pending` 激活状态固化到任务;同一 `requestId` 重放时来源必须完全一致。资格变化返回 HTTP 409 和 `REPLACEMENT_ORIGIN_NOT_ELIGIBLE`。 + +识别与结果提交继续复用当前页面采集接口。结果事务提交后,服务端调用 #131 的登记/纠错并生效流程:成功返回正常完成详情;生效失败返回 HTTP 409、`REPLACEMENT_ACTIVATION_FAILED`,但采集任务、商品、规格和 SKU 已安全保存。服务启动恢复只重试 `pending` / `failed` 的激活步骤,不重新打开 PDD 或重新采集。 diff --git a/server/app/goauto/models/schema.go b/server/app/goauto/models/schema.go index dca7f3c..4d32edc 100644 --- a/server/app/goauto/models/schema.go +++ b/server/app/goauto/models/schema.go @@ -117,43 +117,51 @@ func (CollectionRule) TableName() string { return "collection_rule" } // while value 1 makes the composite unique indexes enforce active-task limits // consistently on SQLite, MySQL and PostgreSQL. type CollectionTask struct { - ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` - PDDProductID *uint64 `json:"pddProductId" gorm:"uniqueIndex:ux_collection_task_active_product,priority:1"` - PDDProduct *PDDProduct `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"` - RuleID uint64 `json:"ruleId" gorm:"not null;index"` - Rule CollectionRule `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"` - DeviceID *uint64 `json:"deviceId" gorm:"index;uniqueIndex:ux_collection_task_running_device,priority:1"` - Device *AgentDevice `json:"-"` - Source string `json:"source" gorm:"size:32;not null;default:admin;index;check:ck_collection_task_source,source IN ('admin','agent_current_page')"` - Status string `json:"status" gorm:"size:24;not null;index;check:ck_collection_task_status,status IN ('pending','running','completed','completed_partial','failed')"` - ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_collection_task_active_product,priority:2;check:ck_collection_task_active_slot,(status IN ('pending','running') AND active_slot = 1) OR (status NOT IN ('pending','running') AND active_slot IS NULL)"` - DeviceRunSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_collection_task_running_device,priority:2;check:ck_collection_task_device_run_slot,(status = 'running' AND device_id IS NOT NULL AND device_run_slot = 1) OR (status <> 'running' AND device_run_slot IS NULL)"` - URLSnapshot string `json:"urlSnapshot" gorm:"type:text;not null"` - GoodsIDSnapshot string `json:"goodsIdSnapshot" gorm:"size:32;not null;index"` - RuleSnapshot string `json:"ruleSnapshot" gorm:"type:text;not null"` - LeaseExpiresAt *time.Time `json:"leaseExpiresAt" gorm:"index"` - LeaseVersion uint64 `json:"leaseVersion" gorm:"not null;default:0"` - ClaimRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_claim_request_id"` - StartRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_start_request_id"` - CreateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_create_request_id"` - IdentifyRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_identify_request_id"` - ResetRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_reset_request_id"` - DeleteRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_delete_request_id"` - FailRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_fail_request_id"` - Title *string `json:"title" gorm:"size:500"` - ShopName *string `json:"shopName" gorm:"size:255"` - SalesText *string `json:"salesText" gorm:"size:120"` - ReviewCount *int64 `json:"reviewCount"` - MissingJSON *string `json:"missing" gorm:"type:text"` - ResultRequestID *string `json:"resultRequestId" gorm:"size:64;uniqueIndex:ux_collection_task_result_request_id"` - ErrorCode *string `json:"errorCode" gorm:"size:64;index"` - ErrorMessage *string `json:"errorMessage" gorm:"size:1000"` - StartedAt *time.Time `json:"startedAt"` - FinishedAt *time.Time `json:"finishedAt"` - IdentityResolvedAt *time.Time `json:"identityResolvedAt"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + PDDProductID *uint64 `json:"pddProductId" gorm:"uniqueIndex:ux_collection_task_active_product,priority:1"` + PDDProduct *PDDProduct `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"` + RuleID uint64 `json:"ruleId" gorm:"not null;index"` + Rule CollectionRule `json:"-" gorm:"constraint:OnUpdate:CASCADE,OnDelete:RESTRICT"` + DeviceID *uint64 `json:"deviceId" gorm:"index;uniqueIndex:ux_collection_task_running_device,priority:1"` + Device *AgentDevice `json:"-"` + Source string `json:"source" gorm:"size:32;not null;default:admin;index;check:ck_collection_task_source,source IN ('admin','agent_current_page')"` + Status string `json:"status" gorm:"size:24;not null;index;check:ck_collection_task_status,status IN ('pending','running','completed','completed_partial','failed')"` + ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_collection_task_active_product,priority:2;check:ck_collection_task_active_slot,(status IN ('pending','running') AND active_slot = 1) OR (status NOT IN ('pending','running') AND active_slot IS NULL)"` + DeviceRunSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_collection_task_running_device,priority:2;check:ck_collection_task_device_run_slot,(status = 'running' AND device_id IS NOT NULL AND device_run_slot = 1) OR (status <> 'running' AND device_run_slot IS NULL)"` + URLSnapshot string `json:"urlSnapshot" gorm:"type:text;not null"` + GoodsIDSnapshot string `json:"goodsIdSnapshot" gorm:"size:32;not null;index"` + RuleSnapshot string `json:"ruleSnapshot" gorm:"type:text;not null"` + LeaseExpiresAt *time.Time `json:"leaseExpiresAt" gorm:"index"` + LeaseVersion uint64 `json:"leaseVersion" gorm:"not null;default:0"` + ClaimRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_claim_request_id"` + StartRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_start_request_id"` + CreateRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_create_request_id"` + IdentifyRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_identify_request_id"` + ResetRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_reset_request_id"` + DeleteRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_delete_request_id"` + FailRequestID *string `json:"-" gorm:"size:36;uniqueIndex:ux_collection_task_fail_request_id"` + Title *string `json:"title" gorm:"size:500"` + ShopName *string `json:"shopName" gorm:"size:255"` + SalesText *string `json:"salesText" gorm:"size:120"` + ReviewCount *int64 `json:"reviewCount"` + MissingJSON *string `json:"missing" gorm:"type:text"` + ResultRequestID *string `json:"resultRequestId" gorm:"size:64;uniqueIndex:ux_collection_task_result_request_id"` + ErrorCode *string `json:"errorCode" gorm:"size:64;index"` + ErrorMessage *string `json:"errorMessage" gorm:"size:1000"` + StartedAt *time.Time `json:"startedAt"` + FinishedAt *time.Time `json:"finishedAt"` + IdentityResolvedAt *time.Time `json:"identityResolvedAt"` + ReplacementOriginType *string `json:"-" gorm:"size:16;index:ix_collection_replacement_origin,priority:1;check:ck_collection_replacement_origin_type,replacement_origin_type IS NULL OR replacement_origin_type IN ('collection','purchase')"` + ReplacementOriginTaskID *uint64 `json:"-" gorm:"index:ix_collection_replacement_origin,priority:2"` + ReplacementCorrectionID *uint64 `json:"-" gorm:"index"` + ReplacementActivationStatus *string `json:"replacementActivationStatus,omitempty" gorm:"size:16;index;check:ck_collection_replacement_activation_status,replacement_activation_status IS NULL OR replacement_activation_status IN ('pending','activated','failed')"` + ReplacementID *uint64 `json:"replacementId,omitempty" gorm:"index"` + ReplacementActivationErrorCode *string `json:"replacementActivationErrorCode,omitempty" gorm:"size:64"` + ReplacementActivationErrorMessage *string `json:"replacementActivationErrorMessage,omitempty" gorm:"size:500"` + ReplacementActivatedAt *time.Time `json:"replacementActivatedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` } func (CollectionTask) TableName() string { return "collection_task" } diff --git a/server/app/goauto/purchase/agent_history.go b/server/app/goauto/purchase/agent_history.go index e66b46e..fa351de 100644 --- a/server/app/goauto/purchase/agent_history.go +++ b/server/app/goauto/purchase/agent_history.go @@ -9,6 +9,7 @@ import ( "go-admin/app/goauto/device" "go-admin/app/goauto/models" + "go-admin/app/goauto/replacement" "gorm.io/gorm" ) @@ -51,7 +52,12 @@ type AgentPurchaseList struct { } type AgentPurchaseDetail struct { - Task AgentPurchaseItem `json:"task"` + Task AgentPurchaseItem `json:"task"` + ReplacementEligible bool `json:"replacementEligible"` + ReplacementDisabledReason string `json:"replacementDisabledReason,omitempty"` + ReplacementMappingStatus string `json:"replacementMappingStatus,omitempty"` + ReplacementActivationStatus string `json:"replacementActivationStatus,omitempty"` + ReplacementActivationErrorMessage string `json:"replacementActivationErrorMessage,omitempty"` } func (s *Service) AgentHistory(ctx context.Context, req AgentHistoryRequest, token string) (AgentPurchaseList, error) { @@ -119,7 +125,16 @@ func (s *Service) AgentHistoryDetail(ctx context.Context, taskID uint64, token s if err != nil { return AgentPurchaseDetail{}, internal(err) } - return AgentPurchaseDetail{Task: agentPurchaseItem(task, s.retryQueryEligibility(ctx, task, true))}, nil + inspection, err := replacement.NewService(s.DB).InspectOrigin(ctx, models.ReplacementOriginPurchase, taskID, deviceRecord.ID) + if err != nil { + return AgentPurchaseDetail{}, internal(err) + } + 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, + }, nil } func agentPurchaseItem(task models.PurchaseTask, retry retryDecision) AgentPurchaseItem { diff --git a/server/app/goauto/replacement/origin.go b/server/app/goauto/replacement/origin.go new file mode 100644 index 0000000..cc5af50 --- /dev/null +++ b/server/app/goauto/replacement/origin.go @@ -0,0 +1,165 @@ +package replacement + +import ( + "context" + "errors" + + "go-admin/app/goauto/models" + + "gorm.io/gorm" +) + +const ( + CodeOriginNotEligible = "REPLACEMENT_ORIGIN_NOT_ELIGIBLE" + + ErrorPDDLinkInvalid = "PDD_LINK_INVALID" + ErrorPDDGoodsSoldOut = "PDD_GOODS_SOLD_OUT" +) + +type OriginInspection struct { + Eligible bool + DisabledReason string + SourceProductID uint64 + ShopeeProductID uint64 + MappingStatus string + ActivationStatus string + ActivationErrorMessage string + CorrectionReplacementID uint64 +} + +func (service *Service) InspectOrigin(ctx context.Context, originType string, taskID, deviceID uint64) (OriginInspection, error) { + if service.DB == nil || taskID == 0 || deviceID == 0 || (originType != models.ReplacementOriginCollection && originType != models.ReplacementOriginPurchase) { + return OriginInspection{}, fail(CodeInvalidRequest, "替换来源无效") + } + result := OriginInspection{} + var status string + var errorCode *string + switch originType { + case models.ReplacementOriginCollection: + var task models.CollectionTask + if err := service.DB.WithContext(ctx).Where("id = ? AND device_id = ?", taskID, deviceID).First(&task).Error; errors.Is(err, gorm.ErrRecordNotFound) { + return OriginInspection{}, fail(CodeOriginTaskNotFound, "来源采集任务不存在") + } else if err != nil { + return OriginInspection{}, internal(err) + } + if task.PDDProductID != nil { + result.SourceProductID = *task.PDDProductID + } + status, errorCode = task.Status, task.ErrorCode + case models.ReplacementOriginPurchase: + var task models.PurchaseTask + if err := service.DB.WithContext(ctx).Where("id = ? AND device_id = ?", taskID, deviceID).First(&task).Error; errors.Is(err, gorm.ErrRecordNotFound) { + return OriginInspection{}, fail(CodeOriginTaskNotFound, "来源采购任务不存在") + } else if err != nil { + return OriginInspection{}, internal(err) + } + result.SourceProductID = task.PDDProductID + if task.ShopeeProductID != nil { + result.ShopeeProductID = *task.ShopeeProductID + } + status, errorCode = task.Status, task.ErrorCode + } + if result.SourceProductID == 0 { + result.DisabledReason = "原任务没有可替换的拼多多商品" + return result, nil + } + if active, found, err := service.activeForOrigin(ctx, result); err != nil { + return OriginInspection{}, err + } else if found { + result.MappingStatus = active + result.DisabledReason = "该商品已经发起替换" + return result, nil + } + var activation models.CollectionTask + if err := service.DB.WithContext(ctx). + Where("replacement_origin_type = ? AND replacement_origin_task_id = ? AND replacement_activation_status IS NOT NULL", originType, taskID). + Order("id DESC").First(&activation).Error; err == nil { + if activation.ReplacementActivationStatus != nil { + result.ActivationStatus = *activation.ReplacementActivationStatus + } + if activation.ReplacementActivationErrorMessage != nil { + result.ActivationErrorMessage = *activation.ReplacementActivationErrorMessage + } + if result.ActivationStatus == "pending" || result.ActivationStatus == "failed" { + result.DisabledReason = result.ActivationErrorMessage + if result.DisabledReason == "" { + result.DisabledReason = "替换正在处理" + } + return result, nil + } + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return OriginInspection{}, internal(err) + } + if originType == models.ReplacementOriginPurchase && result.ShopeeProductID != 0 { + correctionID, err := service.correctionForOrigin(ctx, result.SourceProductID, result.ShopeeProductID) + if err != nil { + return OriginInspection{}, err + } + result.CorrectionReplacementID = correctionID + } + failed := status == models.TaskStatusFailed || status == models.PurchaseTaskStatusFailed + if !failed { + result.DisabledReason = "只有失败任务可以替换商品" + return result, nil + } + if errorCode == nil || (*errorCode != ErrorPDDLinkInvalid && *errorCode != ErrorPDDGoodsSoldOut) { + result.DisabledReason = "当前失败原因不属于商品失效或售罄" + return result, nil + } + result.Eligible = true + return result, nil +} + +func (service *Service) activeForOrigin(ctx context.Context, origin OriginInspection) (string, bool, error) { + var record models.PDDProductReplacement + err := service.DB.WithContext(ctx).Where("source_product_id = ? AND status = ?", origin.SourceProductID, models.ReplacementStatusActive).First(&record).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", false, nil + } + if err != nil { + return "", false, internal(err) + } + var items []models.PDDProductReplacementItem + query := service.DB.WithContext(ctx).Where("replacement_id = ?", record.ID) + if origin.ShopeeProductID != 0 { + query = query.Where("shopee_product_id = ?", origin.ShopeeProductID) + } + if err := query.Find(&items).Error; err != nil { + return "", false, internal(err) + } + return derivedItemStatus(items), true, nil +} + +func derivedItemStatus(items []models.PDDProductReplacementItem) string { + if len(items) == 0 { + return models.ReplacementItemMappingMatching + } + allMatched := true + for _, item := range items { + if item.MappingStatus == models.ReplacementItemMappingMatching { + return models.ReplacementItemMappingMatching + } + if item.MappingStatus != models.ReplacementItemMappingMatched { + allMatched = false + } + } + if allMatched { + return models.ReplacementItemMappingMatched + } + return models.ReplacementItemMappingManualRequired +} + +func (service *Service) correctionForOrigin(ctx context.Context, targetProductID, shopeeProductID uint64) (uint64, error) { + var item models.PDDProductReplacementItem + err := service.DB.WithContext(ctx). + Joins("JOIN pdd_product_replacement r ON r.id = pdd_product_replacement_item.replacement_id"). + Where("r.target_product_id = ? AND r.status = ? AND pdd_product_replacement_item.shopee_product_id = ?", targetProductID, models.ReplacementStatusActive, shopeeProductID). + Order("r.id DESC").First(&item).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, nil + } + if err != nil { + return 0, internal(err) + } + return item.ReplacementID, nil +} diff --git a/server/app/goauto/replacement/service_test.go b/server/app/goauto/replacement/service_test.go index 83287c2..0b46e04 100644 --- a/server/app/goauto/replacement/service_test.go +++ b/server/app/goauto/replacement/service_test.go @@ -99,6 +99,40 @@ func replacementCode(err error) string { return "" } +func TestInspectOriginUsesExactFailureCodesAndDeviceBoundary(t *testing.T) { + fixture := seedReplacementFixture(t) + linkInvalid := ErrorPDDLinkInvalid + if err := fixture.db.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).Where("id = ?", fixture.origin.ID).Update("error_code", linkInvalid).Error; err != nil { + t.Fatal(err) + } + + inspection, err := fixture.service.InspectOrigin(context.Background(), models.ReplacementOriginCollection, fixture.origin.ID, fixture.device.ID) + if err != nil || !inspection.Eligible || inspection.SourceProductID != fixture.source.ID { + t.Fatalf("eligible collection origin: inspection=%+v error=%v", inspection, err) + } + + notExact := "PDD_LINK_INVALID_RETRY" + if err := fixture.db.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).Where("id = ?", fixture.origin.ID).Update("error_code", notExact).Error; err != nil { + t.Fatal(err) + } + inspection, err = fixture.service.InspectOrigin(context.Background(), models.ReplacementOriginCollection, fixture.origin.ID, fixture.device.ID) + if err != nil || inspection.Eligible || inspection.DisabledReason == "" { + t.Fatalf("non-exact code must be disabled: inspection=%+v error=%v", inspection, err) + } + + otherDevice := fixture.device + otherDevice.ID = 0 + otherDevice.InstallID = uuid.NewString() + otherDevice.TokenDigest = strings.Repeat("a", 64) + if err := fixture.db.Create(&otherDevice).Error; err != nil { + t.Fatal(err) + } + _, err = fixture.service.InspectOrigin(context.Background(), models.ReplacementOriginCollection, fixture.origin.ID, otherDevice.ID) + if replacementCode(err) != CodeOriginTaskNotFound { + t.Fatalf("cross-device origin must be hidden, got %v", err) + } +} + func TestRegisterAndQueryCollectionOrigin(t *testing.T) { fixture := seedReplacementFixture(t) request := fixture.request() diff --git a/server/app/goauto/task/agent_history.go b/server/app/goauto/task/agent_history.go index 8982b56..29cf285 100644 --- a/server/app/goauto/task/agent_history.go +++ b/server/app/goauto/task/agent_history.go @@ -10,6 +10,7 @@ import ( "go-admin/app/goauto/device" "go-admin/app/goauto/models" + "go-admin/app/goauto/replacement" "gorm.io/gorm" ) @@ -46,14 +47,19 @@ type AgentCollectionList struct { } type AgentCollectionDetail struct { - Task AgentCollectionItem `json:"task"` - ShopName *string `json:"shopName,omitempty"` - SalesText *string `json:"salesText,omitempty"` - ReviewCount *int64 `json:"reviewCount,omitempty"` - Dimensions []DetailDimension `json:"dimensions"` - ColorPrices []AgentColorPrice `json:"colorPrices"` - SKUs []AgentCollectionSKU `json:"skus"` - Missing []string `json:"missing"` + Task AgentCollectionItem `json:"task"` + ShopName *string `json:"shopName,omitempty"` + SalesText *string `json:"salesText,omitempty"` + ReviewCount *int64 `json:"reviewCount,omitempty"` + Dimensions []DetailDimension `json:"dimensions"` + ColorPrices []AgentColorPrice `json:"colorPrices"` + SKUs []AgentCollectionSKU `json:"skus"` + Missing []string `json:"missing"` + ReplacementEligible bool `json:"replacementEligible"` + ReplacementDisabledReason string `json:"replacementDisabledReason,omitempty"` + ReplacementMappingStatus string `json:"replacementMappingStatus,omitempty"` + ReplacementActivationStatus string `json:"replacementActivationStatus,omitempty"` + ReplacementActivationErrorMessage string `json:"replacementActivationErrorMessage,omitempty"` } type AgentColorPrice struct { @@ -137,6 +143,10 @@ func (service *Service) AgentHistoryDetail(ctx context.Context, taskID uint64, t if err != nil { return AgentCollectionDetail{}, err } + inspection, err := replacement.NewService(service.DB).InspectOrigin(ctx, models.ReplacementOriginCollection, taskID, deviceRecord.ID) + if err != nil { + return AgentCollectionDetail{}, internalError(err) + } colorPrices := make([]AgentColorPrice, 0, len(detail.ColorPrices)) for _, value := range detail.ColorPrices { colorPrices = append(colorPrices, AgentColorPrice{Color: value.Color, PriceCent: value.PriceCent}) @@ -149,6 +159,9 @@ func (service *Service) AgentHistoryDetail(ctx context.Context, taskID uint64, t Task: agentCollectionItem(record), ShopName: record.ShopName, SalesText: record.SalesText, ReviewCount: record.ReviewCount, Dimensions: detail.Dimensions, ColorPrices: colorPrices, SKUs: skus, Missing: detail.Missing, + ReplacementEligible: inspection.Eligible, ReplacementDisabledReason: inspection.DisabledReason, + ReplacementMappingStatus: inspection.MappingStatus, ReplacementActivationStatus: inspection.ActivationStatus, + ReplacementActivationErrorMessage: inspection.ActivationErrorMessage, }, nil } diff --git a/server/app/goauto/task/current_page.go b/server/app/goauto/task/current_page.go index a818047..7635e9d 100644 --- a/server/app/goauto/task/current_page.go +++ b/server/app/goauto/task/current_page.go @@ -14,6 +14,7 @@ import ( "go-admin/app/goauto/device" "go-admin/app/goauto/models" + "go-admin/app/goauto/replacement" "go-admin/app/goauto/rulecontract" "github.com/google/uuid" @@ -29,7 +30,13 @@ const ( ) type CurrentPageCreateRequest struct { - RequestID string `json:"requestId"` + RequestID string `json:"requestId"` + ReplacementOrigin *ReplacementOriginRequest `json:"replacementOrigin,omitempty"` +} + +type ReplacementOriginRequest struct { + Type string `json:"type"` + TaskID uint64 `json:"taskId"` } type CurrentPageIdentifyRequest struct { @@ -69,6 +76,9 @@ func (service *Service) CreateCurrentPage(ctx context.Context, request CurrentPa if replay.Source != models.CollectionTaskSourceAgentCurrentPage || replay.DeviceID == nil || *replay.DeviceID != deviceRecord.ID { return serviceError(CodeTaskStateConflict, "requestId 已被其他任务使用") } + if !sameReplacementOrigin(replay, request.ReplacementOrigin) { + return serviceError(CodeTaskStateConflict, "requestId 的替换来源不一致") + } response, err = service.payload(replay, true) return err } else if !errors.Is(err, gorm.ErrRecordNotFound) { @@ -116,6 +126,22 @@ func (service *Service) CreateCurrentPage(ctx context.Context, request CurrentPa LeaseExpiresAt: &lease, LeaseVersion: 1, CreateRequestID: &request.RequestID, StartRequestID: &request.RequestID, StartedAt: &now, } + if request.ReplacementOrigin != nil { + inspection, err := replacement.NewService(tx).InspectOrigin(ctx, request.ReplacementOrigin.Type, request.ReplacementOrigin.TaskID, deviceRecord.ID) + if err != nil { + return err + } + if !inspection.Eligible { + return serviceError(replacement.CodeOriginNotEligible, inspection.DisabledReason) + } + pending := "pending" + task.ReplacementOriginType = &request.ReplacementOrigin.Type + task.ReplacementOriginTaskID = &request.ReplacementOrigin.TaskID + task.ReplacementActivationStatus = &pending + if inspection.CorrectionReplacementID != 0 { + task.ReplacementCorrectionID = &inspection.CorrectionReplacementID + } + } if err := tx.Create(&task).Error; err != nil { return internalError(err) } @@ -125,6 +151,13 @@ func (service *Service) CreateCurrentPage(ctx context.Context, request CurrentPa return response, err } +func sameReplacementOrigin(task models.CollectionTask, origin *ReplacementOriginRequest) bool { + if origin == nil { + return task.ReplacementOriginType == nil && task.ReplacementOriginTaskID == nil + } + return task.ReplacementOriginType != nil && task.ReplacementOriginTaskID != nil && *task.ReplacementOriginType == origin.Type && *task.ReplacementOriginTaskID == origin.TaskID +} + func ensureCurrentPageRule(content string) error { var header struct { SchemaVersion int `json:"schemaVersion"` diff --git a/server/app/goauto/task/current_page_test.go b/server/app/goauto/task/current_page_test.go index 68ebb9d..0993c77 100644 --- a/server/app/goauto/task/current_page_test.go +++ b/server/app/goauto/task/current_page_test.go @@ -134,6 +134,90 @@ func TestCurrentPageTaskRequiresConfiguredRuleAndCapability(t *testing.T) { } } +func TestCreateCurrentPagePersistsEligibleReplacementOrigin(t *testing.T) { + db := openTaskDatabase(t) + deviceRecord, token := registerTaskDeviceWithCapabilities(t, db, "replacement-device", []string{ + rulecontract.CapabilitySchemaV2, + rulecontract.CapabilityPDDProductDetailV1, + rulecontract.CapabilityPDDCurrentPageShareV1, + }) + rule := models.CollectionRule{Name: "current-page-rule", ContentJSON: v2TaskRuleSnapshot()} + if err := db.Create(&rule).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&models.AgentManualCollectionSetting{ID: 1, RuleID: rule.ID}).Error; err != nil { + t.Fatal(err) + } + product := models.PDDProduct{GoodsID: "700000000130", URL: "https://mobile.yangkeduo.com/goods.html?goods_id=700000000130", Status: "disabled"} + if err := db.Create(&product).Error; err != nil { + t.Fatal(err) + } + shopee := models.ShopeeProduct{ + ShopeeItemID: "shopee-130", Title: "待替换商品", PDDProductID: &product.ID, + SpecsJSON: `[{"name":"颜色","shopeeValue":"黑色","pddValue":"黑色"}]`, + } + if err := db.Create(&shopee).Error; err != nil { + t.Fatal(err) + } + errorCode := "PDD_GOODS_SOLD_OUT" + origin := models.CollectionTask{ + PDDProductID: &product.ID, RuleID: rule.ID, DeviceID: &deviceRecord.ID, + Status: models.TaskStatusFailed, ErrorCode: &errorCode, URLSnapshot: product.URL, + GoodsIDSnapshot: product.GoodsID, RuleSnapshot: rule.ContentJSON, + } + if err := db.Create(&origin).Error; err != nil { + t.Fatal(err) + } + requestID := uuid.NewString() + request := CurrentPageCreateRequest{ + RequestID: requestID, + ReplacementOrigin: &ReplacementOriginRequest{Type: models.ReplacementOriginCollection, TaskID: origin.ID}, + } + created, err := newTaskService(db).CreateCurrentPage(context.Background(), request, token) + if err != nil { + t.Fatalf("create replacement collection: %v", err) + } + var persisted models.CollectionTask + if err := db.First(&persisted, created.TaskID).Error; err != nil { + t.Fatal(err) + } + if persisted.ReplacementOriginType == nil || *persisted.ReplacementOriginType != models.ReplacementOriginCollection || + persisted.ReplacementOriginTaskID == nil || *persisted.ReplacementOriginTaskID != origin.ID || + persisted.ReplacementActivationStatus == nil || *persisted.ReplacementActivationStatus != "pending" { + t.Fatalf("replacement context was not persisted: %+v", persisted) + } + replay, err := newTaskService(db).CreateCurrentPage(context.Background(), request, token) + if err != nil || !replay.Replayed || replay.TaskID != created.TaskID { + t.Fatalf("replacement replay mismatch: response=%+v error=%v", replay, err) + } + identity, err := newTaskService(db).IdentifyCurrentPage(context.Background(), created.TaskID, CurrentPageIdentifyRequest{ + RequestID: uuid.NewString(), ShareURL: "https://mobile.yangkeduo.com/goods.html?goods_id=700000000131", + }, token) + if err != nil { + t.Fatalf("identify replacement target: %v", err) + } + title := "替代商品" + detail, err := newTaskService(db).SubmitResult(context.Background(), created.TaskID, ResultRequest{ + RequestID: uuid.NewString(), Status: models.TaskStatusCompleted, + Product: ResultProduct{PDDGoodsID: identity.GoodsID, Title: &title}, + Dimensions: []ResultDimension{{Key: "color", Name: "颜色", Values: []string{"白色"}}}, + ColorPrices: []ResultColorPrice{{Color: "白色", PriceCent: 1300}}, + SKUs: []ResultSKU{{Specs: map[string]string{"color": "白色"}, PriceCent: 1300, Available: true}}, + }, token) + if err != nil || detail.Task.Status != models.TaskStatusCompleted { + t.Fatalf("replacement result: detail=%+v error=%v", detail, err) + } + if err := db.First(&persisted, created.TaskID).Error; err != nil { + t.Fatal(err) + } + if persisted.ReplacementActivationStatus == nil || *persisted.ReplacementActivationStatus != "activated" || persisted.ReplacementID == nil { + t.Fatalf("replacement was not activated: %+v", persisted) + } + if err := db.First(&shopee, shopee.ID).Error; err != nil || shopee.PDDProductID == nil || *shopee.PDDProductID != identity.PDDProductID { + t.Fatalf("shopee product was not relinked: product=%+v error=%v", shopee, err) + } +} + func TestResolvePDDShareURLRejectsUnsafeOrAmbiguousIdentity(t *testing.T) { valid, err := ResolvePDDShareURL(context.Background(), "https://mobile.yangkeduo.com/goods.html?goods_id=972800403573") if err != nil || valid.GoodsID != "972800403573" { diff --git a/server/app/goauto/task/handler.go b/server/app/goauto/task/handler.go index 67e03e2..5f45779 100644 --- a/server/app/goauto/task/handler.go +++ b/server/app/goauto/task/handler.go @@ -10,6 +10,7 @@ import ( "strings" "go-admin/app/goauto/device" + "go-admin/app/goauto/replacement" "github.com/gin-gonic/gin" "github.com/go-admin-team/go-admin-core/sdk/pkg" @@ -286,6 +287,8 @@ func writeError(context *gin.Context, err error) { status = http.StatusNotFound case CodeTaskAlreadyClaimed, CodeTaskAssignedOther, CodeDeviceBusy, CodeDeviceOffline, CodeTaskStateConflict, CodeTaskLeaseExpired, CodeCurrentPageIdentityRequired, CodeCurrentPageIdentityConflict: status = http.StatusConflict + case replacement.CodeOriginNotEligible, CodeReplacementActivationFailed: + status = http.StatusConflict case CodeProductTaskActive, CodeProductDisabled: status = http.StatusConflict case CodeProductNotFound, CodeRuleNotFound, CodeDeviceNotFound, CodeAgentManualRuleNotConfigured: diff --git a/server/app/goauto/task/replacement_activation.go b/server/app/goauto/task/replacement_activation.go new file mode 100644 index 0000000..7813ee7 --- /dev/null +++ b/server/app/goauto/task/replacement_activation.go @@ -0,0 +1,107 @@ +package task + +import ( + "context" + "errors" + "time" + + "go-admin/app/goauto/models" + "go-admin/app/goauto/replacement" + + "gorm.io/gorm" +) + +const CodeReplacementActivationFailed = "REPLACEMENT_ACTIVATION_FAILED" + +func (service *Service) activatePersistedReplacement(ctx context.Context, taskID uint64) error { + var task models.CollectionTask + if err := service.DB.WithContext(ctx).First(&task, taskID).Error; err != nil { + return internalError(err) + } + if task.ReplacementOriginType == nil || task.ReplacementOriginTaskID == nil { + return nil + } + if task.ReplacementActivationStatus != nil && *task.ReplacementActivationStatus == "activated" { + return nil + } + if task.Status != models.TaskStatusCompleted && task.Status != models.TaskStatusCompletedPartial { + return nil + } + if task.PDDProductID == nil || task.DeviceID == nil || task.CreateRequestID == nil { + return service.markReplacementActivationFailed(ctx, task, "REPLACEMENT_CONTEXT_INVALID", "替换采集上下文不完整") + } + inspection, err := replacement.NewService(service.DB).InspectOrigin(ctx, *task.ReplacementOriginType, *task.ReplacementOriginTaskID, *task.DeviceID) + if err != nil { + return service.markReplacementActivationFailed(ctx, task, replacementErrorCode(err), "来源任务重新校验失败") + } + request := replacement.RegisterRequest{ + RequestID: *task.CreateRequestID, SourceProductID: inspection.SourceProductID, TargetProductID: *task.PDDProductID, + OriginType: *task.ReplacementOriginType, OriginTaskID: *task.ReplacementOriginTaskID, + TargetCollectionTaskID: task.ID, CreatedByDeviceID: *task.DeviceID, + } + replacementService := replacement.NewService(service.DB) + var result replacement.ActivationResult + if task.ReplacementCorrectionID != nil { + result, err = replacementService.CorrectAndActivate(ctx, *task.ReplacementCorrectionID, request) + } else { + result, err = replacementService.RegisterAndActivate(ctx, request) + } + if err != nil { + return service.markReplacementActivationFailed(ctx, task, replacementErrorCode(err), "采集成功,但替换生效失败") + } + now := service.Now() + activated := "activated" + if err := service.DB.WithContext(ctx).Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).Where("id = ? AND replacement_activation_status <> ?", task.ID, activated). + Updates(map[string]any{"replacement_activation_status": activated, "replacement_id": result.Record.Replacement.ID, "replacement_activation_error_code": nil, "replacement_activation_error_message": nil, "replacement_activated_at": now}).Error; err != nil { + return internalError(err) + } + return nil +} + +func (service *Service) markReplacementActivationFailed(ctx context.Context, task models.CollectionTask, code, message string) error { + failed := "failed" + code = compactReplacementText(code, 64) + message = compactReplacementText(message, 500) + if err := service.DB.WithContext(ctx).Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).Where("id = ?", task.ID).Updates(map[string]any{ + "replacement_activation_status": failed, "replacement_activation_error_code": code, "replacement_activation_error_message": message, + }).Error; err != nil { + return internalError(err) + } + return &ServiceError{Code: CodeReplacementActivationFailed, Message: message, Retryable: true} +} + +func replacementErrorCode(err error) string { + var target *replacement.ServiceError + if errors.As(err, &target) { + return target.Code + } + return CodeReplacementActivationFailed +} + +func compactReplacementText(value string, limit int) string { + runes := []rune(value) + if len(runes) > limit { + return string(runes[:limit]) + } + return value +} + +// RecoverReplacementActivations resumes already-collected replacement tasks. +// It never recollects product data and all writes remain idempotent. +func RecoverReplacementActivations(db *gorm.DB) { + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + var tasks []models.CollectionTask + if err := db.WithContext(ctx).Where("replacement_activation_status IN ? AND status IN ?", []string{"pending", "failed"}, []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial}).Order("id").Find(&tasks).Error; err != nil { + return + } + service := NewService(db) + for _, task := range tasks { + if ctx.Err() != nil { + return + } + _ = service.activatePersistedReplacement(ctx, task.ID) + } + }() +} diff --git a/server/app/goauto/task/result_service.go b/server/app/goauto/task/result_service.go index f8c96a6..a0e1777 100644 --- a/server/app/goauto/task/result_service.go +++ b/server/app/goauto/task/result_service.go @@ -117,6 +117,9 @@ func (service *Service) SubmitResult(ctx context.Context, taskID uint64, request if err != nil { return DetailResponse{}, err } + if err := service.activatePersistedReplacement(ctx, taskID); err != nil { + return DetailResponse{}, err + } detail, err := service.Detail(ctx, taskID) detail.Replayed = replayed return detail, err diff --git a/server/app/goauto/task/service.go b/server/app/goauto/task/service.go index 5729eb6..0e29510 100644 --- a/server/app/goauto/task/service.go +++ b/server/app/goauto/task/service.go @@ -51,18 +51,19 @@ type ActionRequest struct { } type TaskPayload struct { - TaskID uint64 `json:"taskId"` - PDDProductID *uint64 `json:"pddProductId"` - URLSnapshot string `json:"urlSnapshot"` - GoodsIDSnapshot string `json:"goodsIdSnapshot"` - Source string `json:"source"` - RuleID uint64 `json:"ruleId"` - RuleSnapshot json.RawMessage `json:"ruleSnapshot"` - TimeoutSeconds int `json:"timeoutSeconds"` - LeaseExpiresAt *time.Time `json:"leaseExpiresAt,omitempty"` - LeaseVersion uint64 `json:"leaseVersion"` - Status string `json:"status"` - Replayed bool `json:"replayed,omitempty"` + TaskID uint64 `json:"taskId"` + PDDProductID *uint64 `json:"pddProductId"` + URLSnapshot string `json:"urlSnapshot"` + GoodsIDSnapshot string `json:"goodsIdSnapshot"` + Source string `json:"source"` + ReplacementOriginType string `json:"replacementOriginType,omitempty"` + RuleID uint64 `json:"ruleId"` + RuleSnapshot json.RawMessage `json:"ruleSnapshot"` + TimeoutSeconds int `json:"timeoutSeconds"` + LeaseExpiresAt *time.Time `json:"leaseExpiresAt,omitempty"` + LeaseVersion uint64 `json:"leaseVersion"` + Status string `json:"status"` + Replayed bool `json:"replayed,omitempty"` } type Service struct { @@ -290,13 +291,20 @@ func (service *Service) payload(record models.CollectionTask, replayed bool) (Ta return TaskPayload{ TaskID: record.ID, PDDProductID: record.PDDProductID, URLSnapshot: record.URLSnapshot, GoodsIDSnapshot: record.GoodsIDSnapshot, - Source: record.Source, + Source: record.Source, ReplacementOriginType: pointerValue(record.ReplacementOriginType), RuleID: record.RuleID, RuleSnapshot: rule, TimeoutSeconds: timeout, LeaseExpiresAt: record.LeaseExpiresAt, LeaseVersion: record.LeaseVersion, Status: record.Status, Replayed: replayed, }, nil } +func pointerValue(value *string) string { + if value == nil { + return "" + } + return *value +} + func (service *Service) leaseDuration() time.Duration { if service.LeaseDuration <= 0 { return DefaultLeaseDuration diff --git a/server/cmd/api/server.go b/server/cmd/api/server.go index bb63d48..769d1f3 100644 --- a/server/cmd/api/server.go +++ b/server/cmd/api/server.go @@ -24,6 +24,7 @@ import ( goautoreplacement "go-admin/app/goauto/replacement" goautosybimport "go-admin/app/goauto/sybimport" goautosybinnercode "go-admin/app/goauto/sybinnercode" + goautotask "go-admin/app/goauto/task" "go-admin/app/jobs" "go-admin/common/database" "go-admin/common/global" @@ -100,6 +101,7 @@ func run() error { return fmt.Errorf("recover interrupted SYB inner-code writes: %w", err) } goautoreplacement.RecoverMatching(db) + goautotask.RecoverReplacementActivations(db) } offlineMonitorContext, stopOfflineMonitors := context.WithCancel(context.Background()) defer stopOfflineMonitors() diff --git a/server/cmd/migrate/migration/version-local/1787885200000_agent_product_replacement.go b/server/cmd/migrate/migration/version-local/1787885200000_agent_product_replacement.go new file mode 100644 index 0000000..0a8d8cb --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1787885200000_agent_product_replacement.go @@ -0,0 +1,25 @@ +package version_local + +import ( + "runtime" + + goautomigrations "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + + "gorm.io/gorm" +) + +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateAgentProductReplacement) +} + +func migrateAgentProductReplacement(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := goautomigrations.Migrate(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +}