Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d2e61bb72 | ||
|
|
96cc2ab700 |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "cn.ilapage.goauto.agent"
|
||||
minSdk = 23
|
||||
targetSdk = 34
|
||||
versionCode = 52
|
||||
versionName = "0.9.39"
|
||||
versionCode = 53
|
||||
versionName = "0.9.40"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -36,8 +36,6 @@ import cn.ilapage.goauto.agent.network.HistoryPage
|
||||
import cn.ilapage.goauto.agent.network.HistoryColorImage
|
||||
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
|
||||
@@ -88,7 +86,7 @@ internal object PurchaseRetryPolicy {
|
||||
fun confirmationMessage(continuing: Boolean = false): String = if (continuing) {
|
||||
"替代商品已完成匹配。系统会保留原任务并创建一笔新采购任务;可能创建拼多多待付款订单,但不会支付。"
|
||||
} else {
|
||||
"将使用服务端最新采购规则重跑当前任务,任务号和商品、规格、价格快照不变;可能创建拼多多待付款订单,但不会支付。"
|
||||
"系统会保留原任务,并根据当前商品档案和最新采购规则创建一笔新采购任务;可能创建拼多多待付款订单,但不会支付。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,7 +746,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))
|
||||
@@ -869,17 +867,13 @@ class TaskHistoryFragment : Fragment() {
|
||||
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)
|
||||
client.retryPurchaseTask(taskId, requestId, credentials.token)
|
||||
}
|
||||
.onSuccess { result ->
|
||||
resultColumn.post {
|
||||
if (!isAdded || generation != requestGeneration) return@post
|
||||
AgentForegroundService.start(requireContext())
|
||||
when (result) {
|
||||
is PurchaseRetryResult -> showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId)
|
||||
is PurchaseResetResult -> showPurchaseResetSuccess(result.taskNo, result.taskId, result.attemptNumber)
|
||||
}
|
||||
showPurchaseRetrySuccess(result.sourceTaskNo, result.taskNo, result.taskId)
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
@@ -910,24 +904,6 @@ 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,12 @@ class PurchaseRetryPolicyTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retry confirmation explains same task latest rule and no payment`() {
|
||||
fun `retry confirmation explains new task current archive 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("待付款订单"))
|
||||
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: 069248c1466d54273900a4c87d1285436f7d50f9
|
||||
synchronized_at: 2026-09-04T10:57:41Z
|
||||
wiki_revision: bd2748f2b58daee92eaa1e5ed16ce61fa18f728f
|
||||
synchronized_at: 2026-09-05T01:00:01Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -256,16 +256,14 @@ synchronized_at: 2026-09-04T10:57:41Z
|
||||
|
||||
## Agent 受控重试采购
|
||||
|
||||
- 当前设备只可就地重跑自身最近 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 批量重试同样继续创建新任务。二者均不得改成就地更新旧任务。
|
||||
- 取消订单、修改既有订单和支付仍禁止;真机重跑可能进入创建待付款订单流程,执行前必须再次取得人工授权。
|
||||
- 当前设备只可重试自身最近 30 天内、服务端标记 `retryable=true` 的正式采购失败任务;列表和详情都只能发起单任务重试,不支持多选、批量或自动重试。
|
||||
- 普通“重试采购”调用既有 `AgentRetry → BatchRetry → Create`:原失败任务及其商品、目标规格、执行规格、价格和执行记录保持不可变;服务端根据当前 SYB、虾皮/PDD 档案、当前采购规则和当前设备创建不同 `purchase_task.id` 的新任务。
|
||||
- 新 SYB 采购任务继续遵循 #215 的强制当次规格探测,首趟不得直接使用历史任务的规格决策;当前档案、规则、价格、设备或能力门禁不通过时拒绝创建,旧任务保持失败状态。
|
||||
- 已出现 `order_submit_started` 证据,或存在不可逆时间、订单提交请求、PDD 订单号、下单时间的任务一律拒绝重试,并提示走既有“授权重新采购”流程,防止重复下单。
|
||||
- `requestId` 按“来源任务 + 请求”幂等;相同请求重放返回同一新任务,不重复创建。
|
||||
- Android 只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”。确认和成功反馈必须说明旧任务保留、新任务读取当前档案和规则、可能创建待付款订单且系统不会支付。
|
||||
- 历史兼容的就地 `/reset` 服务端入口不得把“目标规格存在但对应执行规格为空”的任务恢复到正式采购阶段;此类异常快照必须拒绝,并提示创建新任务。Android 普通重试不再调用该入口。
|
||||
- 替代商品匹配完成后的“继续采购”和 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: a022259ba39db7588eaa3b6579c799e2eb6a6f9e
|
||||
synchronized_at: 2026-09-04T10:58:12Z
|
||||
wiki_revision: 644b864833730130cf4a2098a01611da0c0ac45d
|
||||
synchronized_at: 2026-09-05T01:01:14Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# MVP 共享 API 契约
|
||||
@@ -539,7 +539,7 @@ Admin 列表与详情由 #35 实现;#67 增加 `shopeeOrderNoSnapshot` 的列
|
||||
|
||||
结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。每个新 SYB 采购任务的第一趟只读遍历当次 PDD 规格面板并提交颜色、尺码原始候选,随后释放设备与已知账号租约并进入 `spec_probe_pending`;服务端只以任务冻结的 SYB 目标和当次候选先做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。AI 的颜色和尺码必须逐字属于当次对应候选,否则按无匹配失败。第二趟只会收到服务端固化的精确 PDD 原始标签;Agent 只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。
|
||||
|
||||
任务 payload 的必传布尔字段 `specResolutionAllowed` 是 Android 是否可以提交规格探测的唯一资格事实。新建 `taskType=syb_order` 任务必须由声明 `purchase.spec-probe.v1` 的规则创建,初始 `SpecDecisionRequestID` 为空且 `specSource=unresolved`,首趟返回 `true`;当次决策固化后返回 `false`。`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。普通就地重试保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格;第二趟选择失败必须提交真实失败,不能重新探测。Android 不得根据映射是否非空、错误文字或本地判断扩大资格。
|
||||
任务 payload 的必传布尔字段 `specResolutionAllowed` 是 Android 是否可以提交规格探测的唯一资格事实。新建 `taskType=syb_order` 任务必须由声明 `purchase.spec-probe.v1` 的规则创建,初始 `SpecDecisionRequestID` 为空且 `specSource=unresolved`,首趟返回 `true`;当次决策固化后返回 `false`。`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。历史兼容的就地 `/reset` 保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格;映射不完整时必须拒绝,不能进入正式采购阶段。普通 Agent 重试创建新任务并重新取得一次探测资格。Android 不得根据映射是否非空、错误文字或本地判断扩大资格。
|
||||
|
||||
Android 规格失败使用五个稳定阶段:`PURCHASE_SPEC_TARGET_NOT_VISIBLE`、`PURCHASE_SPEC_TARGET_AMBIGUOUS`、`PURCHASE_SPEC_SAFE_TARGET_MISSING`、`PURCHASE_SPEC_CLICK_FAILED` 和 `PURCHASE_SPEC_SELECTION_UNCONFIRMED`。`PURCHASE_SPEC_CLICK_FAILED` 的 `errorMessage` 只允许稳定子原因 `root_unavailable`、`target_stale`、`no_clickable_ancestor`、`action_click_false` 或 `unknown`;其他阶段的消息不得包含规格原文、坐标、控件树或截图。只有 `PURCHASE_SPEC_TARGET_NOT_VISIBLE && specResolutionAllowed=true` 可以提交规格探测,其他四态直接提交真实失败,服务端原样保留稳定阶段/子原因。旧 Agent 在资格已用尽后再次提交 `spec_probe_completed` 时,服务端以 `PURCHASE_SPEC_REPROBE_REJECTED` fail-closed,释放租约并保留第一次规格决策,不再冒充新的选择根因或再次派发。无匹配、候选不完整、歧义或 Provider 异常同样使任务失败。`order_result_unknown` 只允许管理员或采购员人工解除,永不自动重派。
|
||||
|
||||
@@ -617,12 +617,12 @@ Content-Type: application/json
|
||||
- 响应返回 `taskId`、`attemptNumber`、`status` 和可选的 `replayed`,不返回规则快照、URL、Token、控件树或截图。
|
||||
- 设备离线、任务非终态、设备忙、规则不可用或同商品存在活动任务时返回明确冲突,不支持离线排队。
|
||||
|
||||
## Agent 受控采购重试(#95、#157)
|
||||
## Agent 受控采购重试(#95、#157、#217)
|
||||
|
||||
普通失败任务的“重试采购”改为就地重跑:
|
||||
普通失败任务的“重试采购”和替代商品匹配完成后的“继续采购”统一调用新任务接口:
|
||||
|
||||
```http
|
||||
POST /api/agent/v1/purchase-tasks/{taskId}/reset
|
||||
POST /api/agent/v1/purchase-tasks/{taskId}/retry
|
||||
Authorization: Bearer <device-token>
|
||||
Content-Type: application/json
|
||||
|
||||
@@ -634,37 +634,35 @@ Content-Type: application/json
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"taskId": 12,
|
||||
"taskNo": "CG-12",
|
||||
"attemptNumber": 2,
|
||||
"status": "pending",
|
||||
"sourceTaskId": 12,
|
||||
"sourceTaskNo": "CG-12",
|
||||
"taskId": 13,
|
||||
"taskNo": "CG-13",
|
||||
"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 商品已有更新任务时,服务端明确拒绝且不得部分修改任务。
|
||||
- 来源任务必须属于当前 Device Token、在最近 30 天内、状态为 `failed`,且是分配给该设备的正式 SYB 采购任务;跨设备或超期按任务不存在处理。
|
||||
- 来源任务不得存在 `irreversibleAt`、`orderSubmitRequestId`、PDD 订单号或下单时间。已有任何不可逆证据时返回 `PURCHASE_RETRY_UNSAFE`,提示走“授权重新采购”,不得创建新任务。
|
||||
- `AgentRetry → BatchRetry → Create` 保留来源失败任务并创建不同 `purchase_task.id` 的新任务;新任务重新读取当前 SYB、虾皮/PDD 档案、当前采购规则、价格保护和设备能力,重新生成地址后缀,不继承来源任务的旧规格决策。
|
||||
- 新 SYB 任务按 #215 固定从 `spec_probe` 开始。相同 `requestId` 重放返回同一新任务且 `replayed=true`;不同 requestId 再次请求受同一 SYB 商品最新任务和设备并发门禁约束。
|
||||
- Android 只在服务端 `retryable=true` 且状态为 `failed` 时显示普通“重试采购”;确认文案和成功反馈必须说明原任务保留、新任务使用当前档案与规则、可能产生待付款订单且系统不会支付。
|
||||
- 规则无效、当前档案或价格不合格、设备离线/忙、能力不匹配、任务状态变化或同一 SYB 商品已有更新任务时,服务端明确拒绝且不得部分创建。
|
||||
- Admin 批量重试继续使用相同的新任务语义;替代商品“继续采购”仍在 AgentRetry 前额外验证替换分项与继续采购资格。
|
||||
|
||||
既有新建任务接口保留原语义:
|
||||
历史兼容的就地重置接口仍保留,但 Android 普通重试不再调用:
|
||||
|
||||
```http
|
||||
POST /api/agent/v1/purchase-tasks/{taskId}/retry
|
||||
POST /api/agent/v1/purchase-tasks/{taskId}/reset
|
||||
Authorization: Bearer <device-token>
|
||||
Content-Type: application/json
|
||||
|
||||
{"requestId":"<uuid>"}
|
||||
```
|
||||
|
||||
- `/retry` 的 `AgentRetry → BatchRetry → Create` 行为不变:保留来源失败任务并创建新任务,返回 `sourceTaskId`、`sourceTaskNo`、新 `taskId`、新 `taskNo` 和 `replayed`。
|
||||
- Android 普通“重试采购”不再调用 `/retry`;只有 #132 替代商品规格匹配完成后的“继续采购”继续调用它。Admin 批量重试行为也不变。
|
||||
- “继续采购”会重新解析替代商品、规格映射与价格并生成新地址后缀;这与普通失败任务保持快照的就地重跑不可互换。
|
||||
- `/reset` 只允许安全失败、无不可逆证据且不存在更新任务的原任务;它保留原业务快照并刷新当前规则。
|
||||
- 目标颜色存在但映射颜色为空,或目标尺码存在但映射尺码为空时,必须返回 `PURCHASE_SPEC_MAPPING_REQUIRED`,不得创建 `purchase` attempt 或下发正式采购 payload。
|
||||
- 两个入口都不执行支付。真机调用可能创建待付款订单,必须先取得人工授权。
|
||||
|
||||
## Agent 任务记录范围与同步(#99)
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestParseBuiltAgentAPKWhenAvailable(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if metadata.VersionCode != 49 || metadata.VersionName != "0.9.36" {
|
||||
if metadata.VersionCode != 53 || metadata.VersionName != "0.9.40" {
|
||||
t.Fatalf("metadata=%+v", metadata)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,11 @@ func TestAgentRetryCreatesOneFixedDeviceTaskAndReplays(t *testing.T) {
|
||||
setCollectedPDDPrice(t, db, f.pdd.ID)
|
||||
service := testService(db)
|
||||
failed := failedLiveTask(t, db, service, f)
|
||||
if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{
|
||||
"target_color": "当前黑色", "target_size": "均碼",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
history, err := service.AgentHistoryDetail(context.Background(), failed.ID, f.token)
|
||||
if err != nil || !history.Task.Retryable || history.Task.RetryDisabledReason != "" {
|
||||
@@ -92,6 +97,9 @@ func TestAgentRetryCreatesOneFixedDeviceTaskAndReplays(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
if newTask.TargetColorSnapshot != "当前黑色" || newTask.TargetSizeSnapshot != "均碼" || newTask.SpecSource != "unresolved" || purchaseAttemptPhase(newTask) != models.PurchaseAttemptPhaseSpecProbe {
|
||||
t.Fatalf("retry did not use current SYB targets and a fresh probe: %+v", newTask)
|
||||
}
|
||||
|
||||
replay, err := service.AgentRetry(context.Background(), failed.ID, request, f.token)
|
||||
if err != nil || !replay.Replayed || replay.TaskID != first.TaskID {
|
||||
|
||||
@@ -186,17 +186,14 @@ func (s *Service) Start(ctx context.Context, taskID uint64, req ActionRequest, t
|
||||
if e = ensureAccountFree(tx, t.PDDAccountID, t.ID, s.Now()); e != nil {
|
||||
return e
|
||||
}
|
||||
phase := models.PurchaseAttemptPhasePurchase
|
||||
if t.SpecSource == "unresolved" {
|
||||
phase = models.PurchaseAttemptPhaseSpecProbe
|
||||
}
|
||||
phase := purchaseAttemptPhase(t)
|
||||
ruleSnapshotHash := purchaseRuleSnapshotHash(t.RuleSnapshot)
|
||||
now := s.Now()
|
||||
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 != ruleSnapshotHash {
|
||||
if a.DeviceID == nil || *a.DeviceID != d.ID || a.RuleSnapshotHash != ruleSnapshotHash || a.Phase != phase {
|
||||
failureCode := CodeStateConflict
|
||||
message := "采购规则快照校验失败,请重新重试任务"
|
||||
message := "采购任务执行快照校验失败,请重新创建任务"
|
||||
a.Status = models.PurchaseAttemptStatusFailed
|
||||
a.ErrorCode = &failureCode
|
||||
a.ErrorMessage = &message
|
||||
|
||||
@@ -87,6 +87,9 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq
|
||||
if err := validatePurchaseResetState(tx, task); err != nil {
|
||||
return err
|
||||
}
|
||||
if task.SpecSource != "unresolved" && !hasCompletePurchaseSpec(task) {
|
||||
return fail(CodeMappingRequired, "任务没有完整的精确商品规格,请创建新采购任务")
|
||||
}
|
||||
deviceRecord, err := lockPurchaseResetDevice(tx, task, authenticated)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -130,10 +133,7 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq
|
||||
return conflictOrInternal(err)
|
||||
}
|
||||
|
||||
phase := models.PurchaseAttemptPhasePurchase
|
||||
if task.SpecSource == "unresolved" {
|
||||
phase = models.PurchaseAttemptPhaseSpecProbe
|
||||
}
|
||||
phase := purchaseAttemptPhase(task)
|
||||
// MySQL normalizes values written to a JSON column. Reload the task before
|
||||
// hashing so the pending attempt uses the exact representation Start will
|
||||
// read later, rather than the pre-persistence DefaultLiveRule bytes.
|
||||
@@ -155,6 +155,18 @@ func (s *Service) reset(ctx context.Context, taskID uint64, req PurchaseResetReq
|
||||
return response, err
|
||||
}
|
||||
|
||||
func purchaseAttemptPhase(task models.PurchaseTask) string {
|
||||
if task.SpecSource == "unresolved" || !hasCompletePurchaseSpec(task) {
|
||||
return models.PurchaseAttemptPhaseSpecProbe
|
||||
}
|
||||
return models.PurchaseAttemptPhasePurchase
|
||||
}
|
||||
|
||||
func hasCompletePurchaseSpec(task models.PurchaseTask) bool {
|
||||
return (strings.TrimSpace(task.TargetColorSnapshot) == "" || strings.TrimSpace(task.MappedColorSnapshot) != "") &&
|
||||
(strings.TrimSpace(task.TargetSizeSnapshot) == "" || strings.TrimSpace(task.MappedSizeSnapshot) != "")
|
||||
}
|
||||
|
||||
func validatePurchaseResetState(tx *gorm.DB, task models.PurchaseTask) error {
|
||||
switch task.Status {
|
||||
case models.PurchaseTaskStatusOrderSubmitStarted, models.PurchaseTaskStatusOrderCreated, models.PurchaseTaskStatusOrderResultUnknown:
|
||||
|
||||
@@ -108,6 +108,36 @@ func TestPurchaseResetReusesTaskRefreshesRuleAndIsIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurchaseResetRejectsResolvedDecisionWithoutExactSpec(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
service := testService(db)
|
||||
failed := failedLiveTask(t, db, service, f)
|
||||
decisionID := uuid.NewString()
|
||||
if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.PurchaseTask{}).Where("id = ?", failed.ID).Updates(map[string]any{
|
||||
"spec_source": "ai_match", "spec_decision_request_id": decisionID,
|
||||
"mapped_color_snapshot": "", "mapped_size_snapshot": "",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := service.ResetForDevice(context.Background(), failed.ID, PurchaseResetRequest{RequestID: uuid.NewString()}, f.token)
|
||||
if code(err) != CodeMappingRequired {
|
||||
t.Fatalf("resolved decision without exact spec was reset: %v", err)
|
||||
}
|
||||
var task models.PurchaseTask
|
||||
if queryErr := db.First(&task, failed.ID).Error; queryErr != nil {
|
||||
t.Fatal(queryErr)
|
||||
}
|
||||
if task.Status != models.PurchaseTaskStatusFailed {
|
||||
t.Fatalf("rejected reset changed task status: %+v", task)
|
||||
}
|
||||
var attemptCount int64
|
||||
if queryErr := db.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ?", failed.ID).Count(&attemptCount).Error; queryErr != nil || attemptCount != 0 {
|
||||
t.Fatalf("rejected reset created attempt: count=%d err=%v", attemptCount, queryErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurchaseStartPersistsPendingAttemptSnapshotMismatchAsFailure(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
|
||||
Reference in New Issue
Block a user