From 16948a9e94044e118f1cb993e4bccd79973bd064 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 29 Aug 2026 16:52:23 +0800 Subject: [PATCH] feat(#157): retry purchase tasks in place --- .../goauto/agent/network/AgentApiClient.kt | 24 +++ .../goauto/agent/ui/TaskHistoryFragment.kt | 52 ++++- .../goauto/agent/PurchaseRetryPolicyTest.kt | 15 +- docs/03-business-rules-and-glossary.md | 22 +- docs/08-agent-api-contract.md | 52 ++++- server/app/goauto/purchase/agent_history.go | 30 ++- server/app/goauto/purchase/handler.go | 21 ++ server/app/goauto/purchase/lifecycle.go | 27 ++- server/app/goauto/purchase/reset.go | 198 ++++++++++++++++++ server/app/goauto/purchase/reset_test.go | 195 +++++++++++++++++ server/app/goauto/purchase/router.go | 1 + 11 files changed, 602 insertions(+), 35 deletions(-) create mode 100644 server/app/goauto/purchase/reset.go create mode 100644 server/app/goauto/purchase/reset_test.go 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 8bb6cc0..92b00ef 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 @@ -132,6 +132,14 @@ data class PurchaseRetryResult( val replayed: Boolean, ) +data class PurchaseResetResult( + val taskId: Long, + val taskNo: String, + val attemptNumber: Int, + val status: String, + val replayed: Boolean, +) + data class PurchaseHistoryItem( val taskId: Long, val status: String, @@ -154,6 +162,9 @@ data class PurchaseHistoryItem( data class PurchaseHistoryDetail( val task: PurchaseHistoryItem, + val attemptCount: Long, + val lastFailureCode: String?, + val lastFailureMessage: String?, val replacementEligible: Boolean, val replacementDisabledReason: String?, val replacementMappingStatus: String?, @@ -391,6 +402,9 @@ class AgentApiClient(private val serverUrl: String) { val data = requireNotNull(request("GET", "/api/agent/v1/purchase-tasks/$taskId", null, token)).getJSONObject("data") return PurchaseHistoryDetail( task = purchaseHistoryItem(data.getJSONObject("task")), + attemptCount = data.optLong("attemptCount"), + lastFailureCode = data.nullableString("lastFailureCode"), + lastFailureMessage = data.nullableString("lastFailureMessage"), replacementEligible = data.optBoolean("replacementEligible"), replacementDisabledReason = data.nullableString("replacementDisabledReason"), replacementMappingStatus = data.nullableString("replacementMappingStatus"), @@ -410,6 +424,16 @@ class AgentApiClient(private val serverUrl: String) { ) } + fun resetPurchaseTask(taskId: Long, requestId: String, token: String): PurchaseResetResult { + val payload = JSONObject().put("requestId", requestId) + val data = requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/reset", payload, token)).getJSONObject("data") + return PurchaseResetResult( + taskId = data.getLong("taskId"), taskNo = data.getString("taskNo"), + attemptNumber = data.getInt("attemptNumber"), status = data.getString("status"), + replayed = data.optBoolean("replayed"), + ) + } + private fun historyPath(base: String, page: Int, status: String?, taskNo: String?, days: Int, pageSize: Int): String { require(days in 1..30) { "记录范围必须在 1 到 30 天之间" } val values = mutableListOf("page=${page.coerceAtLeast(1)}", "pageSize=${pageSize.coerceIn(1, 50)}", "days=$days") 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 e0c2d22..3b5b495 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 @@ -31,6 +31,8 @@ import cn.ilapage.goauto.agent.network.CollectionHistoryItem import cn.ilapage.goauto.agent.network.HistoryPage 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 @@ -78,8 +80,11 @@ internal object CollectionResetPolicy { internal object PurchaseRetryPolicy { fun showsAction(status: String, retryable: Boolean): Boolean = status == "failed" && retryable - fun confirmationMessage(): String = - "系统会保留原任务,并创建一个新的采购任务。重试可能创建新的拼多多待付款订单,但系统不会支付。" + fun confirmationMessage(continuing: Boolean = false): String = if (continuing) { + "替代商品已完成匹配。系统会保留原任务并创建一笔新采购任务;可能创建拼多多待付款订单,但不会支付。" + } else { + "将使用服务端最新采购规则重跑当前任务,任务号和商品、规格、价格快照不变;可能创建拼多多待付款订单,但不会支付。" + } } internal enum class ReplacementPresentation { ACTION, ACTIVATION_FAILED, MATCHING, MATCHED, MANUAL_REQUIRED, HIDDEN } @@ -706,6 +711,15 @@ class TaskHistoryFragment : Fragment() { addView(context.label(purchaseStatus(task.status), 14f, statusColor(task.status)).apply { setPadding(0, context.dp(4), 0, context.dp(12)) }) addView(context.label(info, 14f)) }), collectionCardParams()) + val lastFailure = detail.lastFailureMessage?.takeIf(String::isNotBlank) + val attemptSummary = buildString { + append("已尝试 ${detail.attemptCount} 次") + if (lastFailure != null) { + append("\n上次失败:$lastFailure") + if (!detail.lastFailureCode.isNullOrBlank()) append("(${detail.lastFailureCode})") + } + } + resultColumn.addView(context.centeredMessage("执行记录", attemptSummary)) if (task.errorMessage != null) resultColumn.addView(context.centeredMessage(task.errorMessage, "错误代码:${task.errorCode ?: "—"}")) val replacementInProgress = !detail.replacementMappingStatus.isNullOrBlank() || !detail.replacementActivationStatus.isNullOrBlank() if (!replacementInProgress && PurchaseRetryPolicy.showsAction(task.status, task.retryable)) { @@ -715,7 +729,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)) @@ -805,7 +819,7 @@ class TaskHistoryFragment : Fragment() { private fun confirmPurchaseRetry(task: PurchaseHistoryItem, continuing: Boolean = false) { MaterialAlertDialogBuilder(requireContext()) .setTitle(if (continuing) "继续采购 CG-${task.taskId}?" else "重试采购 CG-${task.taskId}?") - .setMessage(PurchaseRetryPolicy.confirmationMessage()) + .setMessage(PurchaseRetryPolicy.confirmationMessage(continuing)) .setNegativeButton("取消", null) .setPositiveButton(if (continuing) "确认继续" else "确认重试") { _, _ -> retryPurchaseTask(task.taskId, continuing) } .show() @@ -823,12 +837,20 @@ class TaskHistoryFragment : Fragment() { val generation = ++requestGeneration showLoading(if (continuing) "正在提交继续采购请求…" else "正在提交重试请求…") Thread { - runCatching { AgentApiClient(serverUrl).retryPurchaseTask(taskId, UUID.randomUUID().toString(), credentials.token) } + 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) + } .onSuccess { result -> resultColumn.post { if (!isAdded || generation != requestGeneration) return@post AgentForegroundService.start(requireContext()) - showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId) + when (result) { + is PurchaseRetryResult -> showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId) + is PurchaseResetResult -> showPurchaseResetSuccess(result.taskNo, result.taskId, result.attemptNumber) + } } } .onFailure { error -> @@ -859,6 +881,24 @@ 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 64e28af..a1ab274 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,20 @@ class PurchaseRetryPolicyTest { } @Test - fun `confirmation explains new task order risk and no payment`() { + fun `retry confirmation explains same task latest rule 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("不会支付")) } + + @Test + fun `continue confirmation remains a new purchase task`() { + val message = PurchaseRetryPolicy.confirmationMessage(continuing = true) + assertTrue(message.contains("新采购任务")) + assertTrue(message.contains("替代商品")) + assertTrue(message.contains("不会支付")) + } } diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 7d46205..596a0b9 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: d8444503e7d5828d05fc7cdce5c6f503bb08adbc -synchronized_at: 2026-08-29T08:00:23Z +wiki_revision: 1e9bf2da1957b31236921cbf572fa8a61d78e275 +synchronized_at: 2026-08-29T08:47:19Z @@ -255,14 +255,16 @@ synchronized_at: 2026-08-29T01:39:44Z ## Agent 受控重试采购 -- 当前设备只可重试自身最近 30 天内、服务端标记 `retryable=true` 的正式采购失败任务;列表和详情都只能发起单任务重试,不支持多选或批量。 -- 重试保留旧失败任务、错误、attempt 和审计事实,使用当前 SYB/PDD 档案、规格映射、价格保护和最新内置采购规则创建新的 `pending` 任务、新任务编号和新地址后缀;新任务固定分派给当前 Device Token 对应设备,不自动换机。 -- 只有未进入不可逆边界、没有 PDD 订单号和下单时间、不是结果未知、同一 SYB 商品没有更新任务,且当前设备在线、空闲和能力满足时才允许;服务端是最终资格判定方,Agent 不得自行猜测。 -- 请求按 `requestId` 幂等。确认界面必须说明可能创建新的 PDD 待付款订单且系统不会支付;取消订单、修改既有订单和支付仍禁止。真机验证前必须再次获得人工授权。 -- 重新采集属于破坏性状态操作,必须先显示确认框并明确提示会清除当前结构化结果和错误;用户取消时不得请求服务端。 -- 服务端是最终事实来源:校验 Device Token、设备归属、在线/空闲、任务终态和同商品活动任务冲突,Android 不得本地绕过。 -- 重置成功后复用原任务,保留 URL、goods_id、设备与规则快照,清除旧结果后恢复 `pending`;执行仍走正常租约和串行调度。 -- 不支持离线排队、跨设备重置、自动换机、重新采购、创建订单或支付。 +- 当前设备只可就地重跑自身最近 30 天内、服务端标记 `retryable=true` 的正式采购失败任务;列表和详情都只能发起单任务重试,不支持多选、批量或自动重试。 +- 普通“重试采购”复用原 `purchase_task.id`,不新建任务,不改变商品、虾皮/SYB 身份、目标规格、已映射规格、价格保护、地址后缀与其他业务快照。重试只把任务恢复为 `pending`,清理错误、租约和运行守卫。 +- 重试时刷新服务端当前采购规则及其类型、schema 和能力要求。#127 尚未实施前,当前规则仍为服务端内置规则;#127 完成后才切换为数据库单例设置,不能在本工单提前引入第二事实源。 +- 新规则下发前必须重新校验原设备在线、空闲且具备全部能力。规则不可用或能力不匹配时拒绝重试,原任务保持失败状态。 +- 已出现 `order_submit_started` 证据,或存在不可逆时间、订单提交请求、PDD 订单号、下单时间的任务一律拒绝就地重试,并提示走既有“授权重新采购”流程,防止重复下单。 +- 每次成功受理就地重试都会为同一任务预建一个新的 `pending` attempt,保存本次规则哈希;正常 Start 复用并转为 `running`。因此尝试次数、每次规则哈希和失败原因均可追溯,且不限制人工重试次数。 +- `requestId` 按“任务 + 请求”幂等;相同请求重放不重复修改任务或增加 attempt。 +- Agent 详情显示已尝试次数与上次失败原因。确认界面必须说明同一任务使用最新规则重跑、可能创建待付款订单且系统不会支付。 +- “继续采购”与普通“重试采购”语义不同:替代商品匹配完成后的“继续采购”继续调用既有 `AgentRetry → BatchRetry → Create`,保留旧任务并按当前替代商品档案创建新任务;Admin 批量重试同样继续创建新任务。二者均不得改成就地更新旧任务。 +- 取消订单、修改既有订单和支付仍禁止;真机重跑可能进入创建待付款订单流程,执行前必须再次取得人工授权。 ## Agent 状态页手动检查任务 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 81032f0..bd90d51 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: ec45e8baa2f55221bee3f9d6951379178020de17 -synchronized_at: 2026-08-29T08:01:18Z +wiki_revision: cbbcc9accc4b1de3bd2b87cb816a204d7acf6e99 +synchronized_at: 2026-08-29T08:48:19Z @@ -582,9 +582,44 @@ Content-Type: application/json - 响应返回 `taskId`、`attemptNumber`、`status` 和可选的 `replayed`,不返回规则快照、URL、Token、控件树或截图。 - 设备离线、任务非终态、设备忙、规则不可用或同商品存在活动任务时返回明确冲突,不支持离线排队。 -## Agent 受控采购重试(#95) +## Agent 受控采购重试(#95、#157) -```text +普通失败任务的“重试采购”改为就地重跑: + +```http +POST /api/agent/v1/purchase-tasks/{taskId}/reset +Authorization: Bearer +Content-Type: application/json + +{"requestId":""} +``` + +成功响应: + +```json +{ + "data": { + "taskId": 12, + "taskNo": "CG-12", + "attemptNumber": 2, + "status": "pending", + "replayed": false + } +} +``` + +- 原任务必须属于当前 Device Token、在最近 30 天内、状态为 `failed`,且是分配给该设备的正式 SYB 采购任务;跨设备或超期按任务不存在处理。 +- 任务不得存在 `irreversibleAt`、`orderSubmitRequestId`、PDD 订单号或下单时间。已有任何不可逆证据时返回 `PURCHASE_RETRY_UNSAFE`,提示走“授权重新采购”,不得恢复为待执行。 +- 服务端在同一事务锁定任务和设备,确认同一 SYB 商品没有更新任务、设备在线且空闲,然后读取当前服务端采购规则,重新校验 schema、动作安全边界与设备能力。 +- 成功时复用原 `purchase_task.id`,只刷新 `ruleSnapshot`、`ruleType`、`ruleSchemaVersion`、`requiredCapabilities`,清除错误、租约和运行守卫并恢复 `pending`。商品、Target、Mapped、价格保护、数量、地址后缀及其他业务快照逐字段保持不变。 +- 每次受理创建该任务的新 `pending` attempt 并记录规则哈希;Start 复用该 attempt 转为 `running`,不会重复创建执行记录。相同 `requestId` 重放返回相同 attempt 且 `replayed=true`;不同请求可在任务再次失败后继续重试,不限制次数。 +- 采购详情新增 `attemptCount`、可选 `lastFailureCode` 和 `lastFailureMessage`,供 Agent 显示已尝试次数与上次失败原因;不返回规则快照、Token、地址、控件树或截图。 +- Android 仍只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”。确认文案必须说明任务号不变、使用最新规则重跑、可能产生待付款订单且系统不会支付。 +- 规则无效、设备离线/忙、能力不匹配、任务状态变化或同一 SYB 商品已有更新任务时,服务端明确拒绝且不得部分修改任务。 + +既有新建任务接口保留原语义: + +```http POST /api/agent/v1/purchase-tasks/{taskId}/retry Authorization: Bearer Content-Type: application/json @@ -592,11 +627,10 @@ Content-Type: application/json {"requestId":""} ``` -- 原任务必须属于当前 Device Token,且在最近 30 天历史范围内;跨设备或超期按任务不存在处理。 -- 服务端复用正式采购失败重试校验:状态为 `failed`、未进入不可逆边界、无 PDD 订单号和下单时间、非结果未知、同一 SYB 商品无更新任务,当前设备在线/空闲/能力满足,并重新校验档案、规格映射、价格保护和采购规则。 -- 成功响应 `data` 返回 `sourceTaskId`、`sourceTaskNo`、新 `taskId`、新 `taskNo` 和 `replayed`。旧任务不清空;新任务为固定当前设备的 `pending`,并使用新地址后缀。 -- `requestId` 必须是 UUID;相同请求重放返回同一新任务,不重复创建。请求体不得指定设备、规格、规则、地址、订单或支付参数。 -- Android 必须使用服务端 `retryable` 决定是否展示入口,提交前明确提示可能产生新的待付款订单且系统不会支付。真机调用属于可能创建订单的高风险验证,必须先取得人工授权。 +- `/retry` 的 `AgentRetry → BatchRetry → Create` 行为不变:保留来源失败任务并创建新任务,返回 `sourceTaskId`、`sourceTaskNo`、新 `taskId`、新 `taskNo` 和 `replayed`。 +- Android 普通“重试采购”不再调用 `/retry`;只有 #132 替代商品规格匹配完成后的“继续采购”继续调用它。Admin 批量重试行为也不变。 +- “继续采购”会重新解析替代商品、规格映射与价格并生成新地址后缀;这与普通失败任务保持快照的就地重跑不可互换。 +- 两个入口都不执行支付。真机调用可能创建待付款订单,必须先取得人工授权。 ## Agent 任务记录范围与同步(#99) diff --git a/server/app/goauto/purchase/agent_history.go b/server/app/goauto/purchase/agent_history.go index a2de6f1..cd62b20 100644 --- a/server/app/goauto/purchase/agent_history.go +++ b/server/app/goauto/purchase/agent_history.go @@ -54,6 +54,9 @@ type AgentPurchaseList struct { type AgentPurchaseDetail struct { Task AgentPurchaseItem `json:"task"` + AttemptCount int64 `json:"attemptCount"` + LastFailureCode string `json:"lastFailureCode,omitempty"` + LastFailureMessage string `json:"lastFailureMessage,omitempty"` ReplacementEligible bool `json:"replacementEligible"` ReplacementDisabledReason string `json:"replacementDisabledReason,omitempty"` ReplacementMappingStatus string `json:"replacementMappingStatus,omitempty"` @@ -136,8 +139,33 @@ func (s *Service) AgentHistoryDetail(ctx context.Context, taskID uint64, token s if inspection.MappingStatus == models.ReplacementItemMappingMatched { continueDecision = s.continuePurchaseEligibility(ctx, task) } + var attemptCount int64 + if err := s.DB.WithContext(ctx).Model(&models.PurchaseTaskAttempt{}). + Where("task_id = ? AND status <> ?", task.ID, models.PurchaseAttemptStatusPending).Count(&attemptCount).Error; err != nil { + return AgentPurchaseDetail{}, internal(err) + } + var lastFailure models.PurchaseTaskAttempt + lastFailureCode, lastFailureMessage := "", "" + if err := s.DB.WithContext(ctx).Where("task_id = ? AND status = ?", task.ID, models.PurchaseAttemptStatusFailed). + Order("attempt_number DESC, id DESC").First(&lastFailure).Error; err == nil { + if lastFailure.ErrorCode != nil { + lastFailureCode = *lastFailure.ErrorCode + } + if lastFailure.ErrorMessage != nil { + lastFailureMessage = *lastFailure.ErrorMessage + } + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return AgentPurchaseDetail{}, internal(err) + } + if lastFailureMessage == "" && task.ErrorMessage != nil { + lastFailureMessage = *task.ErrorMessage + } + if lastFailureCode == "" && task.ErrorCode != nil { + lastFailureCode = *task.ErrorCode + } return AgentPurchaseDetail{ - Task: agentPurchaseItem(task, s.retryQueryEligibility(ctx, task, true)), + Task: agentPurchaseItem(task, s.retryQueryEligibility(ctx, task, true)), + AttemptCount: attemptCount, LastFailureCode: lastFailureCode, LastFailureMessage: lastFailureMessage, ReplacementEligible: inspection.Eligible, ReplacementDisabledReason: inspection.DisabledReason, ReplacementMappingStatus: inspection.MappingStatus, ReplacementActivationStatus: inspection.ActivationStatus, ReplacementActivationErrorMessage: inspection.ActivationErrorMessage, diff --git a/server/app/goauto/purchase/handler.go b/server/app/goauto/purchase/handler.go index 6622f33..43bb6cb 100644 --- a/server/app/goauto/purchase/handler.go +++ b/server/app/goauto/purchase/handler.go @@ -265,6 +265,27 @@ func (h Handler) AgentRetry(c *gin.Context) { c.Header("Cache-Control", "no-store") c.JSON(http.StatusOK, gin.H{"data": result}) } +func (h Handler) AgentReset(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + var req PurchaseResetRequest + if !decode(c, &req) { + return + } + service, serviceOK := h.service(c) + if !serviceOK { + return + } + result, err := service.ResetForDevice(c.Request.Context(), id, req, bearer(c.GetHeader("Authorization"))) + if err != nil { + writeError(c, err) + return + } + c.Header("Cache-Control", "no-store") + c.JSON(http.StatusOK, gin.H{"data": result}) +} func (h Handler) Claim(c *gin.Context) { h.action(c, (*Service).Claim) } func (h Handler) Start(c *gin.Context) { h.action(c, (*Service).Start) } func (h Handler) OrderSubmitStarted(c *gin.Context) { h.action(c, (*Service).MarkOrderSubmitStarted) } diff --git a/server/app/goauto/purchase/lifecycle.go b/server/app/goauto/purchase/lifecycle.go index 2897d29..f1274c2 100644 --- a/server/app/goauto/purchase/lifecycle.go +++ b/server/app/goauto/purchase/lifecycle.go @@ -188,14 +188,29 @@ func (s *Service) Start(ctx context.Context, taskID uint64, req ActionRequest, t if t.SpecSource == "unresolved" { phase = models.PurchaseAttemptPhaseSpecProbe } - var count int64 - if e = tx.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ?", t.ID).Count(&count).Error; e != nil { - return internal(e) - } h := sha256.Sum256([]byte(t.RuleSnapshot)) now := s.Now() - a := models.PurchaseTaskAttempt{TaskID: t.ID, AttemptID: uuid.NewString(), AttemptNumber: int(count) + 1, Phase: phase, Status: models.PurchaseAttemptStatusRunning, DeviceID: &d.ID, RuleSnapshotHash: hex.EncodeToString(h[:]), SpecDecisionSnapshot: t.SpecDecisionSnapshot, StartRequestID: &req.RequestID, StartedAt: &now} - if e = tx.Omit("Task").Create(&a).Error; e != nil { + 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 != hex.EncodeToString(h[:]) { + return fail(CodeStateConflict, "待执行 attempt 与当前任务不一致") + } + a.Status = models.PurchaseAttemptStatusRunning + a.StartRequestID = &req.RequestID + a.StartedAt = &now + if e = tx.Save(&a).Error; e != nil { + return conflictOrInternal(e) + } + } else if errors.Is(e, gorm.ErrRecordNotFound) { + var count int64 + if e = tx.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ?", t.ID).Count(&count).Error; e != nil { + return internal(e) + } + a = models.PurchaseTaskAttempt{TaskID: t.ID, AttemptID: uuid.NewString(), AttemptNumber: int(count) + 1, Phase: phase, Status: models.PurchaseAttemptStatusRunning, DeviceID: &d.ID, RuleSnapshotHash: hex.EncodeToString(h[:]), SpecDecisionSnapshot: t.SpecDecisionSnapshot, StartRequestID: &req.RequestID, StartedAt: &now} + if e = tx.Omit("Task").Create(&a).Error; e != nil { + return internal(e) + } + } else { return internal(e) } if e = t.SetStatus(models.PurchaseTaskStatusRunning); e != nil { diff --git a/server/app/goauto/purchase/reset.go b/server/app/goauto/purchase/reset.go new file mode 100644 index 0000000..0eb1714 --- /dev/null +++ b/server/app/goauto/purchase/reset.go @@ -0,0 +1,198 @@ +package purchase + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + + "go-admin/app/goauto/device" + "go-admin/app/goauto/models" + "go-admin/app/goauto/purchasecontract" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type PurchaseResetRequest struct { + RequestID string `json:"requestId"` +} + +type PurchaseResetResponse struct { + TaskID uint64 `json:"taskId"` + TaskNo string `json:"taskNo"` + AttemptNumber int `json:"attemptNumber"` + Status string `json:"status"` + Replayed bool `json:"replayed,omitempty"` +} + +// Reset restores one failed purchase task to pending without changing its +// product, target, mapping or price snapshots. It is intentionally separate +// from BatchRetry and AgentRetry, which continue to create replacement tasks. +func (s *Service) Reset(ctx context.Context, taskID uint64, req PurchaseResetRequest) (PurchaseResetResponse, error) { + return s.reset(ctx, taskID, req, nil) +} + +func (s *Service) ResetForDevice(ctx context.Context, taskID uint64, req PurchaseResetRequest, token string) (PurchaseResetResponse, error) { + return s.reset(ctx, taskID, req, &token) +} + +func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetRequest, token *string) (PurchaseResetResponse, error) { + if taskID == 0 { + return PurchaseResetResponse{}, fail(CodeTaskNotFound, "采购任务不存在") + } + requestID := strings.TrimSpace(req.RequestID) + if _, err := uuid.Parse(requestID); err != nil { + return PurchaseResetResponse{}, fail(CodeInvalidRequest, "requestId 无效") + } + attemptID := purchaseResetAttemptID(requestID, taskID) + response := PurchaseResetResponse{TaskID: taskID, TaskNo: taskNumber(taskID)} + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var authenticated *models.AgentDevice + if token != nil { + record, err := device.NewService(tx).Authenticate(ctx, *token) + if err != nil { + return err + } + authenticated = &record + } + + var task models.PurchaseTask + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&task, taskID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fail(CodeTaskNotFound, "采购任务不存在") + } + return internal(err) + } + if authenticated != nil && (task.DeviceID == nil || *task.DeviceID != authenticated.ID || task.CreatedAt.Before(s.Now().AddDate(0, 0, -agentPurchaseHistoryDays))) { + return fail(CodeTaskNotFound, "采购任务不存在") + } + + var replay models.PurchaseTaskAttempt + if err := tx.Where("attempt_id = ?", attemptID).First(&replay).Error; err == nil { + if replay.TaskID != task.ID { + return fail(CodeResultConflict, "requestId 已用于其他采购任务") + } + response.AttemptNumber = replay.AttemptNumber + response.Status = models.PurchaseTaskStatusPending + response.Replayed = true + return nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return internal(err) + } + + if err := validatePurchaseResetState(tx, task); err != nil { + return err + } + deviceRecord, err := lockPurchaseResetDevice(tx, task, authenticated) + if err != nil { + return err + } + + rawRule := purchasecontract.DefaultLiveRule() + rule, err := purchasecontract.Validate(rawRule, models.PurchaseExecutionModeLive) + if err != nil { + return fail(CodeInvalidRequest, "当前采购规则不可用,请联系管理员") + } + required := purchasecontract.RequiredCapabilities(rule) + if err := ensureCapabilities(deviceRecord, required); err != nil { + return err + } + if err := ensureDeviceFree(tx, deviceRecord.ID, task.ID, s.Now()); err != nil { + return err + } + requiredJSON, err := json.Marshal(required) + if err != nil { + return internal(err) + } + + var attemptCount int64 + if err := tx.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ?", task.ID).Count(&attemptCount).Error; err != nil { + return internal(err) + } + now := s.Now() + if err := task.SetStatus(models.PurchaseTaskStatusPending); err != nil { + return internal(err) + } + task.RuleType = rule.RuleType + task.RuleSchemaVersion = rule.SchemaVersion + task.RequiredCapabilitiesJSON = string(requiredJSON) + task.RuleSnapshot = string(rawRule) + task.LeaseExpiresAt = nil + task.ClaimRequestID = nil + task.ErrorCode = nil + task.ErrorMessage = nil + task.StatusVersion++ + task.StatusChangedAt = now + if err := tx.Save(&task).Error; err != nil { + return conflictOrInternal(err) + } + + phase := models.PurchaseAttemptPhasePurchase + if task.SpecSource == "unresolved" { + phase = models.PurchaseAttemptPhaseSpecProbe + } + digest := sha256.Sum256(rawRule) + attempt := models.PurchaseTaskAttempt{ + TaskID: task.ID, AttemptID: attemptID, AttemptNumber: int(attemptCount) + 1, + Phase: phase, Status: models.PurchaseAttemptStatusPending, DeviceID: &deviceRecord.ID, + RuleSnapshotHash: hex.EncodeToString(digest[:]), SpecDecisionSnapshot: task.SpecDecisionSnapshot, + } + if err := tx.Omit("Task").Create(&attempt).Error; err != nil { + return conflictOrInternal(err) + } + response.AttemptNumber = attempt.AttemptNumber + response.Status = task.Status + return nil + }) + return response, err +} + +func validatePurchaseResetState(tx *gorm.DB, task models.PurchaseTask) error { + switch task.Status { + case models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusOrderCreated, models.PurchaseTaskStatusOrderResultUnknown: + return fail(CodeRetryUnsafe, "任务可能已经创建订单,请走“授权重新采购”流程") + } + if task.Status != models.PurchaseTaskStatusFailed { + return fail(CodeRetryNotAllowed, "只有采购失败任务可以重试") + } + if task.TaskType != models.PurchaseTaskTypeSYBOrder || task.ExecutionMode != models.PurchaseExecutionModeLive || task.SYBProductID == nil { + return fail(CodeRetryNotAllowed, "只有正式采购的失败任务可以就地重试") + } + if task.IrreversibleAt != nil || task.OrderSubmitRequestID != nil || task.PDDOrderNo != nil || task.OrderSubmittedAt != nil { + return fail(CodeRetryUnsafe, "任务可能已经创建订单,请走“授权重新采购”流程") + } + var latest models.PurchaseTask + if err := tx.Where("syb_product_id = ?", *task.SYBProductID).Order("id DESC").First(&latest).Error; err != nil { + return internal(err) + } + if latest.ID != task.ID { + return fail(CodeRetryStale, fmt.Sprintf("同一 SYB 商品已有更新任务 %s", taskNumber(latest.ID))) + } + return nil +} + +func lockPurchaseResetDevice(tx *gorm.DB, task models.PurchaseTask, authenticated *models.AgentDevice) (models.AgentDevice, error) { + if task.DeviceID == nil { + return models.AgentDevice{}, fail(CodeInvalidRequest, "原任务未分配设备,不能就地重试") + } + var record models.AgentDevice + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&record, *task.DeviceID).Error; err != nil { + return record, internal(err) + } + if authenticated != nil && record.ID != authenticated.ID { + return record, fail(CodeTaskNotFound, "采购任务不存在") + } + if record.Status != models.DeviceStatusOnline || record.TokenRevokedAt != nil { + return record, fail(CodeInvalidRequest, "原设备当前离线,不能就地重试") + } + return record, nil +} + +func purchaseResetAttemptID(requestID string, taskID uint64) string { + return uuid.NewSHA1(uuid.NameSpaceOID, []byte(fmt.Sprintf("purchase-reset:%s:%d", requestID, taskID))).String() +} diff --git a/server/app/goauto/purchase/reset_test.go b/server/app/goauto/purchase/reset_test.go new file mode 100644 index 0000000..1ffddfc --- /dev/null +++ b/server/app/goauto/purchase/reset_test.go @@ -0,0 +1,195 @@ +package purchase + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "testing" + + "go-admin/app/goauto/device" + "go-admin/app/goauto/models" + "go-admin/app/goauto/purchasecontract" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type purchaseImmutableSnapshot struct { + PDDProductID uint64 + PDDURL string + PDDGoodsID string + TargetColor string + TargetSize string + MappedColor string + MappedSize string + Quantity int64 + ReferencePrice int64 + MinPrice int64 + MaxPrice int64 + Currency string + AddressSuffix string + SpecDecision string + ShopeeOrderSnapshot string +} + +func immutablePurchaseSnapshot(task models.PurchaseTask) purchaseImmutableSnapshot { + return purchaseImmutableSnapshot{ + PDDProductID: task.PDDProductID, PDDURL: task.PDDURLSnapshot, PDDGoodsID: task.PDDGoodsIDSnapshot, + TargetColor: task.TargetColorSnapshot, TargetSize: task.TargetSizeSnapshot, + MappedColor: task.MappedColorSnapshot, MappedSize: task.MappedSizeSnapshot, + Quantity: task.Quantity, ReferencePrice: task.ReferenceUnitPriceCent, + MinPrice: task.MinUnitPriceCent, MaxPrice: task.MaxUnitPriceCent, Currency: task.Currency, + AddressSuffix: task.AddressSuffix, SpecDecision: task.SpecDecisionSnapshot, + ShopeeOrderSnapshot: task.ShopeeOrderNoSnapshot, + } +} + +func TestPurchaseResetReusesTaskRefreshesRuleAndIsIdempotent(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + service := testService(db) + failed := failedLiveTask(t, db, service, f) + before := immutablePurchaseSnapshot(failed) + request := PurchaseResetRequest{RequestID: uuid.NewString()} + + first, err := service.ResetForDevice(context.Background(), failed.ID, request, f.token) + if err != nil { + t.Fatal(err) + } + if first.TaskID != failed.ID || first.TaskNo != taskNumber(failed.ID) || first.AttemptNumber != 1 || first.Status != models.PurchaseTaskStatusPending || first.Replayed { + t.Fatalf("unexpected reset: %+v", first) + } + var reset models.PurchaseTask + if err := db.First(&reset, failed.ID).Error; err != nil { + t.Fatal(err) + } + if immutablePurchaseSnapshot(reset) != before { + t.Fatalf("reset changed immutable snapshot: before=%+v after=%+v", before, immutablePurchaseSnapshot(reset)) + } + if reset.ErrorCode != nil || reset.ErrorMessage != nil || reset.LeaseExpiresAt != nil || reset.ClaimRequestID != nil { + t.Fatalf("reset did not clear runtime state: %+v", reset) + } + if reset.RuleSnapshot != string(purchasecontract.DefaultLiveRule()) || reset.RuleType != purchasecontract.RuleTypePurchase || reset.RuleSchemaVersion != purchasecontract.SchemaVersionV1 { + t.Fatalf("reset did not refresh rule: %+v", reset) + } + var attempts []models.PurchaseTaskAttempt + if err := db.Where("task_id = ?", failed.ID).Find(&attempts).Error; err != nil || len(attempts) != 1 || attempts[0].Status != models.PurchaseAttemptStatusPending { + t.Fatalf("pending reset attempt mismatch: attempts=%+v err=%v", attempts, err) + } + digest := sha256.Sum256(purchasecontract.DefaultLiveRule()) + if attempts[0].RuleSnapshotHash != hex.EncodeToString(digest[:]) { + t.Fatalf("attempt rule hash mismatch: %s", attempts[0].RuleSnapshotHash) + } + var taskCount int64 + if err := db.Model(&models.PurchaseTask{}).Count(&taskCount).Error; err != nil || taskCount != 1 { + t.Fatalf("reset created another task: count=%d err=%v", taskCount, err) + } + + replay, err := service.ResetForDevice(context.Background(), failed.ID, request, f.token) + if err != nil || !replay.Replayed || replay.TaskID != failed.ID || replay.AttemptNumber != first.AttemptNumber { + t.Fatalf("reset replay mismatch: %+v err=%v", replay, err) + } + if err := db.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ?", failed.ID).Count(&taskCount).Error; err != nil || taskCount != 1 { + t.Fatalf("reset replay created duplicate attempt: count=%d err=%v", taskCount, err) + } +} + +func TestPurchaseResetPendingAttemptIsUsedByStartAndCanRetryAgain(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + service := testService(db) + failed := failedLiveTask(t, db, service, f) + first, err := service.ResetForDevice(context.Background(), failed.ID, PurchaseResetRequest{RequestID: uuid.NewString()}, f.token) + if err != nil { + t.Fatal(err) + } + if _, err = service.Claim(context.Background(), failed.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil { + t.Fatal(err) + } + started, err := service.Start(context.Background(), failed.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) + if err != nil || started.TaskAttemptID == "" { + t.Fatalf("start reset attempt: payload=%+v err=%v", started, err) + } + var attempts []models.PurchaseTaskAttempt + if err := db.Where("task_id = ?", failed.ID).Order("attempt_number").Find(&attempts).Error; err != nil || len(attempts) != 1 || attempts[0].AttemptNumber != first.AttemptNumber || attempts[0].Status != models.PurchaseAttemptStatusRunning { + t.Fatalf("start created duplicate attempt: attempts=%+v err=%v", attempts, err) + } + + errorCode, errorMessage := "RETRY_FAILED", "第二次尝试失败" + if err := db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&models.PurchaseTaskAttempt{}).Where("attempt_id = ?", started.TaskAttemptID).Updates(map[string]any{"status": models.PurchaseAttemptStatusFailed, "error_code": errorCode, "error_message": errorMessage}).Error; err != nil { + return err + } + var task models.PurchaseTask + if err := tx.First(&task, failed.ID).Error; err != nil { + return err + } + if err := task.SetStatus(models.PurchaseTaskStatusFailed); err != nil { + return err + } + task.ErrorCode, task.ErrorMessage = &errorCode, &errorMessage + return tx.Save(&task).Error + }); err != nil { + t.Fatal(err) + } + detail, err := service.AgentHistoryDetail(context.Background(), failed.ID, f.token) + if err != nil || detail.AttemptCount != 1 || detail.LastFailureCode != errorCode || detail.LastFailureMessage != errorMessage { + t.Fatalf("attempt summary mismatch: detail=%+v err=%v", detail, err) + } + second, err := service.ResetForDevice(context.Background(), failed.ID, PurchaseResetRequest{RequestID: uuid.NewString()}, f.token) + if err != nil || second.TaskID != failed.ID || second.AttemptNumber != first.AttemptNumber+1 { + t.Fatalf("second in-place retry mismatch: %+v err=%v", second, err) + } + if err := db.Where("task_id = ?", failed.ID).Order("attempt_number").Find(&attempts).Error; err != nil || len(attempts) != 2 { + t.Fatalf("attempt history mismatch: attempts=%+v err=%v", attempts, err) + } +} + +func TestPurchaseResetRejectsCapabilityMismatchUnsafeBoundaryAndAnotherDevice(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + service := testService(db) + failed := failedLiveTask(t, db, service, f) + + if err := db.Model(&models.AgentDevice{}).Where("id = ?", f.device.ID).Update("capabilities_json", `[]`).Error; err != nil { + t.Fatal(err) + } + if _, err := service.ResetForDevice(context.Background(), failed.ID, PurchaseResetRequest{RequestID: uuid.NewString()}, f.token); code(err) != CodeCapabilityMismatch { + t.Fatalf("capability mismatch was accepted: %v", err) + } + var unchanged models.PurchaseTask + if err := db.First(&unchanged, failed.ID).Error; err != nil || unchanged.Status != models.PurchaseTaskStatusFailed || unchanged.ErrorCode == nil { + t.Fatalf("rejected reset modified task: task=%+v err=%v", unchanged, err) + } + if err := db.Model(&models.AgentDevice{}).Where("id = ?", f.device.ID).Update("capabilities_json", `[`+ + `"purchase.live.v1","purchase.address-update.v1","purchase.order-create.v1","purchase.spec-probe.v1"]`).Error; err != nil { + t.Fatal(err) + } + now := service.Now() + if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", failed.ID).Update("irreversible_at", now).Error; err != nil { + t.Fatal(err) + } + if _, err := service.ResetForDevice(context.Background(), failed.ID, PurchaseResetRequest{RequestID: uuid.NewString()}, f.token); code(err) != CodeRetryUnsafe { + t.Fatalf("unsafe task was accepted: %v", err) + } + if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", failed.ID). + Updates(map[string]any{"irreversible_at": nil, "status": models.PurchaseTaskStatusOrderSubmitStarted}).Error; err != nil { + t.Fatal(err) + } + if _, err := service.ResetForDevice(context.Background(), failed.ID, PurchaseResetRequest{RequestID: uuid.NewString()}, f.token); code(err) != CodeRetryUnsafe { + t.Fatalf("order_submit_started task did not hit unsafe boundary: %v", err) + } + + otherToken := uuid.NewString() + registration := device.NewService(db) + registration.GenerateToken = func() (string, error) { return otherToken, nil } + if _, err := registration.Register(context.Background(), device.RegisterRequest{ + RequestID: uuid.NewString(), InstallID: uuid.NewString(), Name: "other", Manufacturer: "Samsung", + Model: "Test", AndroidVersion: "14", AgentVersion: "1", PDDVersion: "7", Capabilities: liveCaps(), + }, ""); err != nil { + t.Fatal(err) + } + if _, err := service.ResetForDevice(context.Background(), failed.ID, PurchaseResetRequest{RequestID: uuid.NewString()}, otherToken); code(err) != CodeTaskNotFound { + t.Fatalf("another device could reset task: %v", err) + } +} diff --git a/server/app/goauto/purchase/router.go b/server/app/goauto/purchase/router.go index 2f15d87..31b2313 100644 --- a/server/app/goauto/purchase/router.go +++ b/server/app/goauto/purchase/router.go @@ -20,6 +20,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { agent.GET("/next", h.Next) agent.GET("/:taskId", h.AgentHistoryDetail) agent.POST("/:taskId/retry", h.AgentRetry) + agent.POST("/:taskId/reset", h.AgentReset) agent.POST("/:taskId/claim", h.Claim) agent.POST("/:taskId/start", h.Start) agent.POST("/:taskId/order-submit-started", h.OrderSubmitStarted)