diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 10c77a4..2ea820e 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "cn.ilapage.goauto.agent" minSdk = 23 targetSdk = 34 - versionCode = 9 - versionName = "0.5.2" + versionCode = 10 + versionName = "0.6.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 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 1f1c87f..d79b8c4 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 @@ -94,6 +94,14 @@ data class CollectionHistoryDetail( data class CollectionResetResult(val taskId: Long, val status: String, val replayed: Boolean) +data class PurchaseRetryResult( + val sourceTaskId: Long, + val sourceTaskNo: String, + val taskId: Long, + val taskNo: String, + val replayed: Boolean, +) + data class PurchaseHistoryItem( val taskId: Long, val status: String, @@ -109,6 +117,8 @@ data class PurchaseHistoryItem( val orderSubmittedAt: String?, val errorCode: String?, val errorMessage: String?, + val retryable: Boolean, + val retryDisabledReason: String?, val createdAt: String, ) @@ -263,6 +273,15 @@ class AgentApiClient(private val serverUrl: String) { return PurchaseHistoryDetail(purchaseHistoryItem(data.getJSONObject("task"))) } + fun retryPurchaseTask(taskId: Long, requestId: String, token: String): PurchaseRetryResult { + val payload = JSONObject().put("requestId", requestId) + val data = requireNotNull(request("POST", "/api/agent/v1/purchase-tasks/$taskId/retry", payload, token)).getJSONObject("data") + return PurchaseRetryResult( + sourceTaskId = data.getLong("sourceTaskId"), sourceTaskNo = data.getString("sourceTaskNo"), + taskId = data.getLong("taskId"), taskNo = data.getString("taskNo"), replayed = data.optBoolean("replayed"), + ) + } + private fun historyPath(base: String, page: Int, status: String?, taskNo: String?): String { val values = mutableListOf("page=${page.coerceAtLeast(1)}", "pageSize=20") status?.takeIf { it.isNotBlank() }?.let { values += "status=${encode(it)}" } @@ -283,7 +302,9 @@ class AgentApiClient(private val serverUrl: String) { mappedColor = data.optString("mappedColor"), mappedSize = data.optString("mappedSize"), quantity = data.getLong("quantity"), actualUnitPriceCent = data.nullableLong("actualUnitPriceCent"), currency = data.getString("currency"), pddOrderNo = data.nullableString("pddOrderNo"), orderSubmittedAt = data.nullableString("orderSubmittedAt"), - errorCode = data.nullableString("errorCode"), errorMessage = data.nullableString("errorMessage"), createdAt = data.getString("createdAt"), + errorCode = data.nullableString("errorCode"), errorMessage = data.nullableString("errorMessage"), + retryable = data.optBoolean("retryable"), retryDisabledReason = data.nullableString("retryDisabledReason"), + createdAt = data.getString("createdAt"), ) private fun encode(value: String): String = URLEncoder.encode(value, Charsets.UTF_8.name()) 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 3730d9f..e1d3df0 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 @@ -65,6 +65,13 @@ internal object CollectionResetPolicy { } } +internal object PurchaseRetryPolicy { + fun showsAction(status: String, retryable: Boolean): Boolean = status == "failed" && retryable + + fun confirmationMessage(): String = + "系统会保留原任务,并创建一个新的采购任务。重试可能创建新的拼多多待付款订单,但系统不会支付。" +} + class TaskHistoryFragment : Fragment() { private val collection: Boolean get() = requireArguments().getBoolean(ARG_COLLECTION) private lateinit var pageColumn: LinearLayout @@ -254,7 +261,7 @@ class TaskHistoryFragment : Fragment() { } } addPagination(result.page, result.pageSize) - if (!collection) resultColumn.addView(requireContext().centeredMessage("只读记录", "重新采购、取消订单和支付请在管理端处理。")) + if (!collection) resultColumn.addView(requireContext().centeredMessage("安全边界", "失败任务可由本机受控重试;取消订单和支付仍需在管理端处理。")) } } } @@ -300,6 +307,21 @@ class TaskHistoryFragment : Fragment() { addView(context.label(purchaseStatus(item.status), 13f, statusColor(item.status)).apply { setPadding(0, context.dp(4), 0, 0) }) addView(context.label(summary, 14f, context.getColor(R.color.agent_text_muted)).apply { setPadding(0, context.dp(8), 0, 0) }) item.pddOrderNo?.let { addView(context.label("PDD 订单号:$it", 13f, context.getColor(R.color.agent_text_muted))) } + if (PurchaseRetryPolicy.showsAction(item.status, item.retryable)) { + addView(LinearLayout(context).apply { + gravity = Gravity.END + addView(MaterialButton(context, null, com.google.android.material.R.attr.materialButtonOutlinedStyle).apply { + text = "重试采购" + minimumHeight = context.dp(48) + contentDescription = "重试采购任务 CG-${item.taskId}" + setOnClickListener { confirmPurchaseRetry(item) } + }, LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, context.dp(48))) + }, fullWidth(context.dp(8))) + } else if (item.status == "failed" && !item.retryDisabledReason.isNullOrBlank()) { + addView(context.label("暂不可重试:${item.retryDisabledReason}", 13f, context.getColor(R.color.agent_warning)).apply { + setPadding(0, context.dp(8), 0, 0) + }) + } }).apply { isClickable = true; isFocusable = true contentDescription = "查看采购任务 ${item.taskId} 详情" @@ -426,7 +448,76 @@ class TaskHistoryFragment : Fragment() { addView(context.label(info, 14f)) }), collectionCardParams()) if (task.errorMessage != null) resultColumn.addView(context.centeredMessage(task.errorMessage, "错误代码:${task.errorCode ?: "—"}")) - resultColumn.addView(context.centeredMessage("只读详情", "本页没有取消、修改、创建订单或支付入口。")) + if (PurchaseRetryPolicy.showsAction(task.status, task.retryable)) { + resultColumn.addView(MaterialButton(context).apply { + text = "重试采购" + minimumHeight = context.dp(48) + contentDescription = "重试采购任务 CG-${task.taskId}" + setOnClickListener { confirmPurchaseRetry(task) } + }, collectionCardParams()) + resultColumn.addView(context.centeredMessage("重试边界", "系统保留旧任务并创建新任务;不会执行支付。")) + } else if (task.status == "failed") { + val reason = task.retryDisabledReason?.takeIf(String::isNotBlank) ?: "请在管理端核对任务状态。" + resultColumn.addView(context.centeredMessage("不可重试", reason)) + } else { + resultColumn.addView(context.centeredMessage("只读详情", "本页没有取消、修改订单或支付入口。")) + } + } + + private fun confirmPurchaseRetry(task: PurchaseHistoryItem) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle("重试采购 CG-${task.taskId}?") + .setMessage(PurchaseRetryPolicy.confirmationMessage()) + .setNegativeButton("取消", null) + .setPositiveButton("确认重试") { _, _ -> retryPurchaseTask(task.taskId) } + .show() + } + + private fun retryPurchaseTask(taskId: Long) { + val context = requireContext() + val credentials = runCatching { SecureDeviceStore(context).credentials() }.getOrNull() + val serverUrl = AgentSettingsStore(context).serverUrl() + if (credentials == null || serverUrl.isBlank()) { + showMessage("无法重试采购", "设备尚未连接服务端,请先检查设置。", "返回任务详情") { loadPurchaseDetail(taskId) } + return + } + val generation = ++requestGeneration + showLoading("正在提交重试请求…") + Thread { + runCatching { AgentApiClient(serverUrl).retryPurchaseTask(taskId, UUID.randomUUID().toString(), credentials.token) } + .onSuccess { result -> + resultColumn.post { + if (!isAdded || generation != requestGeneration) return@post + AgentForegroundService.start(requireContext()) + showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId) + } + } + .onFailure { error -> + resultColumn.post { + if (isAdded && generation == requestGeneration) { + showMessage("无法重试采购", error.message ?: "请检查设备状态后重试。", "返回任务详情") { loadPurchaseDetail(taskId) } + } + } + } + }.start() + } + + private fun showPurchaseRetrySuccess(sourceTaskNo: String, taskNo: String, taskId: Long) { + resultColumn.removeAllViews() + resultColumn.addView(requireContext().centeredMessage( + "已创建新任务 $taskNo", + "旧任务 $sourceTaskNo 已保留。新任务将由当前设备按正常队列执行;系统不会支付。", + )) + 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) { 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 new file mode 100644 index 0000000..64e28af --- /dev/null +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRetryPolicyTest.kt @@ -0,0 +1,26 @@ +package cn.ilapage.goauto.agent + +import cn.ilapage.goauto.agent.ui.PurchaseRetryPolicy +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PurchaseRetryPolicyTest { + @Test + fun `only server-approved failed tasks show retry action`() { + assertTrue(PurchaseRetryPolicy.showsAction("failed", true)) + assertFalse(PurchaseRetryPolicy.showsAction("failed", false)) + assertFalse(PurchaseRetryPolicy.showsAction("pending", true)) + assertFalse(PurchaseRetryPolicy.showsAction("order_created", true)) + assertFalse(PurchaseRetryPolicy.showsAction("order_result_unknown", true)) + } + + @Test + fun `confirmation explains new task order risk and no payment`() { + val message = PurchaseRetryPolicy.confirmationMessage() + assertTrue(message.contains("保留原任务")) + assertTrue(message.contains("新的采购任务")) + assertTrue(message.contains("待付款订单")) + assertTrue(message.contains("不会支付")) + } +} diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 1ce8e46..5855c99 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.- -wiki_revision: 9a52b45dc154e741ee2abcd9e74b947fa3745a28 -synchronized_at: 2026-08-26T02:11:15Z +wiki_revision: 293ee8c705efbc41d4ce7d4a6f6631652cef9f83 +synchronized_at: 2026-08-26T06:26:15Z # 架构与代码地图 @@ -179,3 +179,9 @@ Android Portal/Agent - 服务端 `task/lifecycle_service.go` 的同一 Reset 事务同时服务管理端和设备端;设备端入口使用显式 Device Token 模式,不能用空 Token 退化为管理端路径。 - `POST /api/agent/v1/collection-tasks/{taskId}/reset` 只返回最小状态,设备归属、忙碌和商品冲突在事务内检查。 - Android `TaskHistoryFragment` 只在采集终态详情显示“重新采集”,确认成功后调用 `AgentForegroundService.start` 唤醒现有轮询,任务执行仍由 `next/claim/start` 完成。 + +## Agent 受控采购重试(#95) + +- 服务端 `server/app/goauto/purchase/retry.go` 的 `AgentRetry` 使用 Device Token 校验任务归属,并复用 Admin `BatchRetry` 的当前档案、不可逆边界、设备能力/忙碌和幂等创建逻辑;旧失败任务不修改,新任务固定原设备。 +- `GET /api/agent/v1/purchase-tasks` 与详情通过 `retryable` / `retryDisabledReason` 返回服务端资格结论;`POST /api/agent/v1/purchase-tasks/{taskId}/retry` 只接受 `requestId`,不接受客户端指定设备、规格或规则。 +- Android `TaskHistoryFragment` 在采购失败列表和详情按服务端资格显示单任务“重试采购”,二次确认后调用新接口;成功后唤醒既有 `AgentForegroundService` 队列,实际执行仍只走 `next → claim → start`。Agent 不实现批量重试、取消订单、修改既有订单或支付。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 5b8f85d..80724a2 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: e83a27ca331f23a9219fbf4bde9020104192762a -synchronized_at: 2026-08-26T02:11:24Z +wiki_revision: 143cb9d565a297cf19e31b89e6d4a1fc809913dd +synchronized_at: 2026-08-26T06:26:37Z # 业务规则与术语 @@ -228,13 +228,20 @@ synchronized_at: 2026-08-26T02:11:24Z ## Agent 当前设备历史 - Agent 只可查看当前 Device Token 对应设备最近 30 天的采集和采购任务,不能跨设备查看。 -- 采集历史可以查看结构化采集结果和错误;采购历史可以查看结构化采购数据和错误,但全部为只读。 +- 采集历史可以查看结构化采集结果和错误;采购历史可以查看结构化采购数据和错误。采购记录不提供取消订单、修改订单或支付入口;只有服务端明确标记可重试的当前设备失败任务,才提供受控“重试采购”。 - 历史接口遵循数据最小化:不下发 Device Token、规则快照、PDD URL、收货地址、原始控件树或截图。 - 实际采购单价只在 Agent 确实观察到时保存;旧任务或未观察到价格时显示“未记录”,不推算、不回填。 ## Agent 受控重新采集 -- 当前设备可以从自身采集任务详情重新采集已完成、部分完成或失败任务;采购任务始终只读,不能在 Agent 重新采购。 +- 当前设备可以从自身采集任务详情重新采集已完成、部分完成或失败任务。 + +## Agent 受控重试采购 + +- 当前设备只可重试自身最近 30 天内、服务端标记 `retryable=true` 的正式采购失败任务;列表和详情都只能发起单任务重试,不支持多选或批量。 +- 重试保留旧失败任务、错误、attempt 和审计事实,使用当前 SYB/PDD 档案、规格映射、价格保护和最新内置采购规则创建新的 `pending` 任务、新任务编号和新地址后缀;新任务固定分派给当前 Device Token 对应设备,不自动换机。 +- 只有未进入不可逆边界、没有 PDD 订单号和下单时间、不是结果未知、同一 SYB 商品没有更新任务,且当前设备在线、空闲和能力满足时才允许;服务端是最终资格判定方,Agent 不得自行猜测。 +- 请求按 `requestId` 幂等。确认界面必须说明可能创建新的 PDD 待付款订单且系统不会支付;取消订单、修改既有订单和支付仍禁止。真机验证前必须再次获得人工授权。 - 重新采集属于破坏性状态操作,必须先显示确认框并明确提示会清除当前结构化结果和错误;用户取消时不得请求服务端。 - 服务端是最终事实来源:校验 Device Token、设备归属、在线/空闲、任务终态和同商品活动任务冲突,Android 不得本地绕过。 - 重置成功后复用原任务,保留 URL、goods_id、设备与规则快照,清除旧结果后恢复 `pending`;执行仍走正常租约和串行调度。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index 38ddacc..2e490f7 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Local-Development-and-Verification wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.- -wiki_revision: dc484e1aa48172d502939e8ebe0ad0e6bd83c7ea -synchronized_at: 2026-08-25T09:44:34Z +wiki_revision: 07ed2f2d05ad058406b7402304fd44b001bad198 +synchronized_at: 2026-08-26T06:26:58Z # 本地开发与验证 @@ -200,6 +200,12 @@ adb shell am start -n cn.ilapage.goauto.agent/.MainActivity - 采集/采购 Tab 在 #90 前只显示明确占位,不应出现采购写操作、PDD 凭据或支付入口。 - Release 仍只接受 HTTPS;Debug 可使用 HTTP 进行局域网联调。安装前必须确认设备空闲,避免重启 Agent 中断任务。 +### Android Agent 0.6.0 采购失败重试检查 + +- 无真机授权时只运行 `cd android && .\\gradlew.bat testDebugUnitTest assembleDebug`,以及服务端采购包测试;不要调用采购重试接口,因为成功后新任务会进入正式采购队列。 +- 获得独立授权后,使用一条确认未进入不可逆边界、无 PDD 订单号的当前设备失败任务检查列表和详情入口、二次确认、旧任务保留、新任务编号/地址后缀、固定原设备与幂等反馈。 +- 取消确认不得发请求;成功后只能由既有队列执行,验证停止在创建待付款订单并读取订单号,永久禁止支付。 + ### Android Agent 0.3.1 空闲返回与亮屏检查 #89 起 Debug APK 版本为 0.3.1(versionCode 4)。先确认设备空闲,再覆盖安装;安装后系统可能关闭无障碍服务,需要人工重新开启。 diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 95042c4..a863d46 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: 7f48742d36b23e7d2c01d83f714812b810bfc34f -synchronized_at: 2026-08-26T02:12:04Z +wiki_revision: 4d953447be50aca5abbef5f208d024a516c8b60b +synchronized_at: 2026-08-26T06:30:08Z # MVP 共享 API 契约 @@ -546,7 +546,7 @@ GET /api/agent/v1/purchase-tasks/{taskId} - `pageSize` 最大为 50;`status` 与 `taskNo` 可以组合过滤。 - 采集任务编号允许 `35` 或 `#35`,采购任务编号允许 `12` 或不区分大小写的 `CG-12`;服务端按精确编号匹配。 -- 列表和详情不返回 Device Token、URL、收货地址、规则快照或原始控件树;采购详情只读,不提供重试、取消、创建订单或支付入口。 +- 列表和详情不返回 Device Token、URL、收货地址、规则快照或原始控件树。采购项额外返回服务端计算的 `retryable` 和可选 `retryDisabledReason`;除受控失败重试外,不提供取消、修改既有订单或支付入口。 - 采集详情返回标题、店铺、销量、评价数、规格维度、颜色价格、SKU、缺失项和结构化错误。 - 采购详情返回蝦皮订单号、PDD 商品、已匹配颜色/尺码、数量、实际单价、PDD 订单号、下单时间和结构化错误。 - Agent 提交采购结果时可携带 `actualUnitPriceCent`(人民币分,非负)。服务端只保存 Agent 实际观察到的值;历史任务或未观察到价格的结果保持 `null`,客户端显示“未记录”。 @@ -565,5 +565,22 @@ Content-Type: application/json - `requestId` 必须为 UUID。同一请求重复提交不会再次清空结果,响应中的 `replayed` 为 `true`。 - 服务端在事务内锁定设备和任务,重新校验设备在线、同商品无其他 `pending/running` 采集任务,并确认该设备没有正在执行或持有有效租约的其他采集/采购任务。 - 成功后保留 URL、goods_id、设备和规则快照,事务删除旧结构化结果及错误,把原任务恢复为 `pending`;Agent 随后只能通过既有 `next → claim → start` 调度执行。 + - 响应只返回 `taskId`、`status` 和可选的 `replayed`,不返回规则快照、URL、Token、控件树或截图。 - 设备离线、任务非终态、设备忙或同商品存在活动任务时返回明确冲突,不支持离线排队。 + +## Agent 受控采购重试(#95) + +```text +POST /api/agent/v1/purchase-tasks/{taskId}/retry +Authorization: Bearer +Content-Type: application/json + +{"requestId":""} +``` + +- 原任务必须属于当前 Device Token,且在最近 30 天历史范围内;跨设备或超期按任务不存在处理。 +- 服务端复用正式采购失败重试校验:状态为 `failed`、未进入不可逆边界、无 PDD 订单号和下单时间、非结果未知、同一 SYB 商品无更新任务,当前设备在线/空闲/能力满足,并重新校验档案、规格映射、价格保护和采购规则。 +- 成功响应 `data` 返回 `sourceTaskId`、`sourceTaskNo`、新 `taskId`、新 `taskNo` 和 `replayed`。旧任务不清空;新任务为固定当前设备的 `pending`,并使用新地址后缀。 +- `requestId` 必须是 UUID;相同请求重放返回同一新任务,不重复创建。请求体不得指定设备、规格、规则、地址、订单或支付参数。 +- Android 必须使用服务端 `retryable` 决定是否展示入口,提交前明确提示可能产生新的待付款订单且系统不会支付。真机调用属于可能创建订单的高风险验证,必须先取得人工授权。 diff --git a/server/app/goauto/purchase/agent_history.go b/server/app/goauto/purchase/agent_history.go index 70be7e6..4d30f2a 100644 --- a/server/app/goauto/purchase/agent_history.go +++ b/server/app/goauto/purchase/agent_history.go @@ -37,6 +37,8 @@ type AgentPurchaseItem struct { OrderSubmittedAt *time.Time `json:"orderSubmittedAt,omitempty"` ErrorCode *string `json:"errorCode,omitempty"` ErrorMessage *string `json:"errorMessage,omitempty"` + Retryable bool `json:"retryable"` + RetryDisabledReason string `json:"retryDisabledReason,omitempty"` CreatedAt time.Time `json:"createdAt"` } @@ -82,7 +84,7 @@ func (s *Service) AgentHistory(ctx context.Context, req AgentHistoryRequest, tok } items := make([]AgentPurchaseItem, 0, len(tasks)) for _, task := range tasks { - items = append(items, agentPurchaseItem(task)) + items = append(items, agentPurchaseItem(task, s.retryQueryEligibility(ctx, task, true))) } return AgentPurchaseList{Items: items, Total: total, Page: req.Page, PageSize: req.PageSize}, nil } @@ -102,10 +104,14 @@ func (s *Service) AgentHistoryDetail(ctx context.Context, taskID uint64, token s if err != nil { return AgentPurchaseDetail{}, internal(err) } - return AgentPurchaseDetail{Task: agentPurchaseItem(task)}, nil + return AgentPurchaseDetail{Task: agentPurchaseItem(task, s.retryQueryEligibility(ctx, task, true))}, nil } -func agentPurchaseItem(task models.PurchaseTask) AgentPurchaseItem { +func agentPurchaseItem(task models.PurchaseTask, retry retryDecision) AgentPurchaseItem { + retryDisabledReason := "" + if task.Status == models.PurchaseTaskStatusFailed { + retryDisabledReason = retry.Reason + } return AgentPurchaseItem{ TaskID: task.ID, Status: task.Status, ShopeeOrderNo: task.ShopeeOrderNoSnapshot, PDDGoodsID: task.PDDGoodsIDSnapshot, PDDTitle: task.PDDTitleSnapshot, @@ -113,6 +119,7 @@ func agentPurchaseItem(task models.PurchaseTask) AgentPurchaseItem { Quantity: task.Quantity, ActualUnitPriceCent: task.ActualUnitPriceCent, Currency: task.Currency, PDDOrderNo: task.PDDOrderNo, OrderSubmittedAt: task.OrderSubmittedAt, ErrorCode: task.ErrorCode, ErrorMessage: task.ErrorMessage, CreatedAt: task.CreatedAt, + Retryable: retry.Allowed, RetryDisabledReason: retryDisabledReason, } } diff --git a/server/app/goauto/purchase/agent_retry_test.go b/server/app/goauto/purchase/agent_retry_test.go new file mode 100644 index 0000000..89387dd --- /dev/null +++ b/server/app/goauto/purchase/agent_retry_test.go @@ -0,0 +1,79 @@ +package purchase + +import ( + "context" + "testing" + + "go-admin/app/goauto/device" + "go-admin/app/goauto/models" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +func TestAgentRetryCreatesOneFixedDeviceTaskAndReplays(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, f.pdd.ID) + service := testService(db) + failed := failedLiveTask(t, db, service, f) + + history, err := service.AgentHistoryDetail(context.Background(), failed.ID, f.token) + if err != nil || !history.Task.Retryable || history.Task.RetryDisabledReason != "" { + t.Fatalf("safe failure was not exposed as retryable: %+v err=%v", history, err) + } + + request := AgentRetryRequest{RequestID: uuid.NewString()} + first, err := service.AgentRetry(context.Background(), failed.ID, request, f.token) + if err != nil || first.TaskID == 0 || first.TaskID == failed.ID || first.SourceTaskID != failed.ID || first.Replayed { + t.Fatalf("unexpected first retry: %+v err=%v", first, err) + } + var oldTask, newTask models.PurchaseTask + if err = db.First(&oldTask, failed.ID).Error; err != nil { + t.Fatal(err) + } + if err = db.First(&newTask, first.TaskID).Error; err != nil { + t.Fatal(err) + } + if oldTask.Status != models.PurchaseTaskStatusFailed || newTask.Status != models.PurchaseTaskStatusPending || newTask.DeviceID == nil || *newTask.DeviceID != f.device.ID || newTask.AddressSuffix == oldTask.AddressSuffix { + t.Fatalf("retry did not preserve old task or fix new task to device: old=%+v new=%+v", oldTask, newTask) + } + + replay, err := service.AgentRetry(context.Background(), failed.ID, request, f.token) + if err != nil || !replay.Replayed || replay.TaskID != first.TaskID { + t.Fatalf("request id was not idempotent: first=%+v replay=%+v err=%v", first, replay, err) + } +} + +func TestAgentRetryRejectsAnotherDeviceAndUnsafeBoundary(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + setCollectedPDDPrice(t, db, f.pdd.ID) + service := testService(db) + failed := failedLiveTask(t, db, service, f) + + 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", + }, ""); err != nil { + t.Fatal(err) + } + if _, err := service.AgentRetry(context.Background(), failed.ID, AgentRetryRequest{RequestID: uuid.NewString()}, otherToken); code(err) != CodeTaskNotFound { + t.Fatalf("other device could retry task: %v", err) + } + + irreversible := service.Now() + if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", failed.ID).Update("irreversible_at", irreversible).Error; err != nil { + t.Fatal(err) + } + detail, err := service.AgentHistoryDetail(context.Background(), failed.ID, f.token) + if err != nil || detail.Task.Retryable || detail.Task.RetryDisabledReason == "" { + t.Fatalf("unsafe task eligibility mismatch: %+v err=%v", detail, err) + } + if _, err := service.AgentRetry(context.Background(), failed.ID, AgentRetryRequest{RequestID: uuid.NewString()}, f.token); code(err) != CodeRetryUnsafe { + t.Fatalf("unsafe boundary was retryable: %v", err) + } +} diff --git a/server/app/goauto/purchase/handler.go b/server/app/goauto/purchase/handler.go index 1ccf664..98b0e47 100644 --- a/server/app/goauto/purchase/handler.go +++ b/server/app/goauto/purchase/handler.go @@ -218,6 +218,27 @@ func (h Handler) AgentHistoryDetail(c *gin.Context) { c.Header("Cache-Control", "no-store") c.JSON(http.StatusOK, gin.H{"data": result}) } +func (h Handler) AgentRetry(c *gin.Context) { + id, ok := pathID(c) + if !ok { + return + } + var req AgentRetryRequest + if !decode(c, &req) { + return + } + service, serviceOK := h.service(c) + if !serviceOK { + return + } + result, err := service.AgentRetry(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/retry.go b/server/app/goauto/purchase/retry.go index 1ca6fa1..61245f2 100644 --- a/server/app/goauto/purchase/retry.go +++ b/server/app/goauto/purchase/retry.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" + "go-admin/app/goauto/device" "go-admin/app/goauto/models" "go-admin/app/goauto/purchasecontract" @@ -37,6 +38,18 @@ type BatchRetryResponse struct { FailedCount int `json:"failedCount"` } +type AgentRetryRequest struct { + RequestID string `json:"requestId"` +} + +type AgentRetryResponse struct { + SourceTaskID uint64 `json:"sourceTaskId"` + SourceTaskNo string `json:"sourceTaskNo"` + TaskID uint64 `json:"taskId"` + TaskNo string `json:"taskNo"` + Replayed bool `json:"replayed"` +} + type retryDecision struct { Allowed bool ReasonCode string @@ -127,6 +140,44 @@ func (s *Service) BatchRetry(ctx context.Context, req BatchRetryRequest) (BatchR return response, nil } +// AgentRetry creates one safe replacement task for the authenticated device. +// It delegates creation and idempotency to BatchRetry so Admin and Agent use +// exactly the same archive, device and irreversible-boundary checks. +func (s *Service) AgentRetry(ctx context.Context, taskID uint64, req AgentRetryRequest, token string) (AgentRetryResponse, error) { + deviceRecord, err := device.NewService(s.DB).Authenticate(ctx, token) + if err != nil { + return AgentRetryResponse{}, err + } + if taskID == 0 { + return AgentRetryResponse{}, fail(CodeTaskNotFound, "采购任务不存在") + } + var source models.PurchaseTask + queryErr := s.DB.WithContext(ctx). + Where("id = ? AND device_id = ? AND created_at >= ?", taskID, deviceRecord.ID, s.Now().AddDate(0, 0, -agentPurchaseHistoryDays)). + First(&source).Error + if errors.Is(queryErr, gorm.ErrRecordNotFound) { + return AgentRetryResponse{}, fail(CodeTaskNotFound, "采购任务不存在") + } + if queryErr != nil { + return AgentRetryResponse{}, internal(queryErr) + } + result, err := s.BatchRetry(ctx, BatchRetryRequest{RequestID: req.RequestID, TaskIDs: []uint64{taskID}}) + if err != nil { + return AgentRetryResponse{}, err + } + if len(result.Items) != 1 || !result.Items[0].Created || result.Items[0].TaskID == nil { + if len(result.Items) == 1 && result.Items[0].ReasonCode != "" { + return AgentRetryResponse{}, fail(result.Items[0].ReasonCode, result.Items[0].Reason) + } + return AgentRetryResponse{}, fail(CodeInternal, "服务端处理失败") + } + item := result.Items[0] + return AgentRetryResponse{ + SourceTaskID: item.SourceTaskID, SourceTaskNo: item.SourceTaskNo, + TaskID: *item.TaskID, TaskNo: item.TaskNo, Replayed: item.Replayed, + }, nil +} + func validateRetryTaskIDs(raw []uint64) ([]uint64, error) { if len(raw) == 0 || len(raw) > maxBatchRetryItems { return nil, fail(CodeInvalidRequest, "taskIds 必须包含 1 至 100 条当前页任务") diff --git a/server/app/goauto/purchase/router.go b/server/app/goauto/purchase/router.go index 0a5b24e..8163eb9 100644 --- a/server/app/goauto/purchase/router.go +++ b/server/app/goauto/purchase/router.go @@ -19,6 +19,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { agent.GET("", h.AgentHistory) agent.GET("/next", h.Next) agent.GET("/:taskId", h.AgentHistoryDetail) + agent.POST("/:taskId/retry", h.AgentRetry) agent.POST("/:taskId/claim", h.Claim) agent.POST("/:taskId/start", h.Start) agent.POST("/:taskId/order-submit-started", h.OrderSubmitStarted)