feat(#157): retry purchase tasks in place
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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("不会支付"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -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 状态页手动检查任务
|
||||
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -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 <device-token>
|
||||
Content-Type: application/json
|
||||
|
||||
{"requestId":"<uuid>"}
|
||||
```
|
||||
|
||||
成功响应:
|
||||
|
||||
```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 <device-token>
|
||||
Content-Type: application/json
|
||||
@@ -592,11 +627,10 @@ Content-Type: application/json
|
||||
{"requestId":"<uuid>"}
|
||||
```
|
||||
|
||||
- 原任务必须属于当前 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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user