From c09ddc84748b1c9d19fd3d7830058bc7d7647716 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 4 Sep 2026 19:03:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(purchase):=20=E5=BC=BA=E5=88=B6=E7=9C=9F?= =?UTF-8?q?=E6=9C=BA=E6=8E=A2=E6=B5=8B=E8=A7=84=E6=A0=BC=E5=90=8E=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=20(#215)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/PurchaseRehearsalExecutorTest.kt | 15 +- docs/03-business-rules-and-glossary.md | 26 +-- docs/08-agent-api-contract.md | 16 +- .../app/goauto/purchase/agent_retry_test.go | 11 +- server/app/goauto/purchase/batch.go | 75 +------- .../goauto/purchase/batch_spec_match_test.go | 17 +- server/app/goauto/purchase/batch_test.go | 20 +- server/app/goauto/purchase/lifecycle.go | 35 ++-- .../app/goauto/purchase/match_worker_test.go | 13 ++ server/app/goauto/purchase/process_stage.go | 3 +- server/app/goauto/purchase/retry.go | 29 ++- server/app/goauto/purchase/retry_test.go | 6 +- server/app/goauto/purchase/service.go | 83 +-------- server/app/goauto/purchase/service_test.go | 173 +++++++++++++++--- 14 files changed, 281 insertions(+), 241 deletions(-) diff --git a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt index 0e9768c..777e506 100644 --- a/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt +++ b/android/app/src/test/java/cn/ilapage/goauto/agent/PurchaseRehearsalExecutorTest.kt @@ -618,11 +618,22 @@ class PurchaseRehearsalExecutorTest { } @Test - fun `live rule spec probe returns before address or order actions`() { + fun `live rule spec probe ignores historical mapping and returns before address or order actions`() { val driver = FakePurchaseDriver() val outcome = PurchaseRehearsalExecutor(driver, { driver.browser = true; true }, { "{\"dimensions\":[]}" }, pause = {}) - .execute(input().copy(executionMode = "live", phase = "spec_probe", addressSuffix = "_cg42"), PurchaseRuleParser.parse(liveRule()), PurchaseAgentCapabilities.supported) + .execute( + input().copy( + executionMode = "live", + phase = "spec_probe", + mappedColor = "历史颜色", + mappedSize = "历史尺码", + addressSuffix = "_cg42", + ), + PurchaseRuleParser.parse(liveRule()), + PurchaseAgentCapabilities.supported, + ) assertEquals("spec_probe_completed", outcome.resultType) + assertEquals("{\"dimensions\":[]}", outcome.probedSpecs) assertFalse(driver.clicked.any { it.contains("订单") || it.contains("地址") || it.contains("支付") }) } diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 7071698..c887f24 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: c7f56f233263b3c83df7e31cf75c89c48ff24111 -synchronized_at: 2026-09-04T10:22:02Z +wiki_revision: 069248c1466d54273900a4c87d1285436f7d50f9 +synchronized_at: 2026-09-04T10:57:41Z # 业务规则与术语 @@ -140,7 +140,7 @@ synchronized_at: 2026-09-04T10:22:02Z - 演练规则在服务端契约层拒绝改地址、创建订单和核单动作;正式动作还必须通过版本化能力协商。规则只包含类型化动作,禁止任意脚本。 - 价格保护保存参考单价、最低单价、最高单价和币种,执行时以 PDD App 实际单价判断;价格越界明确失败,不考虑优惠券。 - Admin 从 SYB 当前页选择商品,可在“创建采购”和“重新解析”两种批量用途间切换;采购模式只允许选择预检合格行,表头全选不跨页。批量预检和正式创建均最多 100 条并逐条重新校验,每条 SYB 明细只创建一个独立任务;部分失败不回滚其他成功项。 -- SYB 商品列表在列表数据返回后立即展示,采购准备状态独立异步加载;只读批量预检只执行批量数据库读取、已确认映射和本地确定性识别,禁止调用 AI Provider。未保存的确定性结果仍显示“规格待匹配”,不能作为“可创建采购”的依据。SYB 批量创建采购按最新数据完整复核,只有目标颜色和尺码都已保存为确认映射,且共同命中最近一次成功或部分成功采集中的完整可售 SKU 组合时才允许创建;否则必须先执行批量 AI 匹配或人工确认,不能把未匹配明细带入采购任务内的人工匹配步骤。 +- SYB 商品列表在列表数据返回后立即展示,采购准备状态独立异步加载;只读批量预检只执行批量数据库读取和本地确定性识别,禁止调用 AI Provider。自 #215 起,未保存或已失效的长期规格映射不再阻断创建采购;满足商品关联、PDD 状态、价格、规则、设备和订单安全门禁后即可创建任务,每个新 SYB 采购任务都在首个 attempt 以当次 PDD 页面候选重新决策。 - SYB 商品页的 PDD 采集资格独立于采购处理阶段:只要已关联的 PDD 商品未停用、没有 `pending` / `running` 采集任务且存在可用采集规则,即可创建采集任务;因此已完成采集并进入“可创建采购”的商品也可以重新采集。相同 PDD 商品在批量创建前按商品去重,服务端创建时仍按最新状态复核。 - 批量创建读取管理员选定的当前采购规则并重新执行契约校验,每条任务保存不可变快照;当前规则缺失或无效时明确阻断,不回退到代码常量。设备默认人工指定,也可以留空由符合能力的空闲设备领取。 - 批量创建的价格保护来自 PDD 商品档案而不是 SYB/Shopee 的 TWD 售价:当前采购规则可用 `priceGuard.minRatio`(0.1~1.0)和 `maxRatio`(1.0~3.0)配置比例,缺省仍为 0.2 / 1.5;最低价向下取整、最高价向上取整到人民币分。已确认颜色映射时按该颜色计算,待规格探测时以全部可用颜色的最低/最高价计算,参考价取最高价;没有可用颜色价格时不能创建。 @@ -152,15 +152,15 @@ synchronized_at: 2026-09-04T10:22:02Z - Agent 本地 SQLite/Outbox 负责断网和重启恢复,服务端以 `task_id + task_attempt_id` 幂等接收并保存最终事实。 - 人工支付复核只记录 `paid` / `unpaid`;系统不执行或识别支付。快递单号与回填状态属于采购任务,后续物流工单实现。 - 采购任务领取时同时占用设备租约和可选 PDD 账号租约;租约过期后才可释放并重新领取。设备还存在采集任务时不能领取采购任务。 -- 采购规格由服务端按顺序决策:先使用已确认的人工映射;否则仅在同一规格角色的可选 PDD 原始标签中做唯一确定性匹配(繁体转简体、空格/全半角/大小写统一,以及公斤/斤换算);仍无唯一结果才调用已启用的服务端 AI。AI 必须返回候选集中的原始标签,候选不完整、歧义、AI 无结果或服务不可用均明确失败,不派发第二趟、更不创建订单。 +- 自 #215 起,每个新 SYB 采购任务无论是否存在已确认长期映射,都先由 Agent 在当次 PDD 规格面板内有界遍历颜色和尺码并回传原始候选。服务端只以任务冻结的 SYB 目标规格与当次候选先做唯一确定性匹配(繁体转简体、空格/全半角/大小写统一,以及公斤/斤换算),仍无唯一结果才调用已启用的服务端 AI。AI 必须逐字返回当次候选集中的原始标签;候选不完整、集合外返回、歧义、AI 无结果或服务不可用均明确失败,不派发第二趟、更不创建订单。 - Agent 选择服务端下发的精确颜色或尺码时,只能在已确认打开的规格面板内有限纵向滑动、每次重新读取可选节点并按完整原始文字点击;连续没有新证据或达到上限即停止,不得点击相近规格。第一趟探测并固化规格后,第二趟仍无法精确选择而再次提交探测时,服务端必须明确失败并释放活动槽,保留第一次决策证据,禁止清空决策、循环派发或进入地址与创建订单动作。 - 采购 Agent 在确认进入 PDD 商品页后、打开规格前,复用采集侧的假售罄识别;规格面板已打开且当前解析到的规格值全部不可选时也按售罄处理。两种情况都只允许关闭规格面板后对商品页执行一次有界恢复(默认下拉 2 次、间隔 1000 毫秒、等待 2000 毫秒),恢复动作失败或恢复后仍售罄时返回 `PDD_GOODS_SOLD_OUT`,恢复后商品页证据丢失时按页面规则不匹配失败;不得继续选择规格、修改地址或创建订单。该失败码继续进入既有替代商品资格判定。 -- 规格映射不完整、已保存的确认映射对当前 PDD 档案失效、确定性匹配失败、PDD 档案为待采集或没有规格时,规则必须具有 `purchase.spec-probe.v1`;自 #190 起前两种情况不再拒绝创建,而是把 `spec_source` 降级为 `unresolved` 并交由规格探测解析,规则不具备该能力时仍然拒绝创建;第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟。Android 不自行匹配或猜测;其只接收服务端已经固化的精确原始规格标签。 +- 所有新 SYB 采购任务的规则都必须具有 `purchase.spec-probe.v1`;创建时任务级 `mappedColor` / `mappedSize` 为空且 `spec_source=unresolved`,长期映射和 PDD 档案候选都不能跳过首趟探测。第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟;探测阶段从服务端拒绝进入 `order_submit_started`。Android 不自行匹配或猜测,只在第二趟接收并精确选择服务端固化的当次 PDD 原始标签。 - Agent 提交的相同 attempt 最终结果只能写入一次;相同请求重放返回原事实,不同内容拒绝覆盖。`order_result_unknown` 不参与自动派发,只能人工解除。 - 已创建订单默认禁止再次采购;管理员或采购员可以做一次性重新采购授权,新任务创建成功时在同一事务消耗授权,旧任务和旧订单保留。已标记为已支付的订单不能授权或创建重新采购任务。 - 人工回填候选只允许从已支付订单选择;同一 SYB 明细后来选择的订单覆盖旧候选,但不删除旧订单事实。 - Admin 采购管理只查看和处理已有任务,不提供创建入口或支付按钮;单条和批量采购任务都从 SYB 商品列表发起。订单结果未知时必须先人工核对并解除;处于该状态时页面不提供重新采购授权。 -- 采购失败任务可在采购管理当前页批量勾选重试,最多 100 条。重试不修改旧任务,而是用当前 SYB/PDD 档案、当前规格映射、当前价格保护和当前采购规则创建新的 `pending` 任务,并生成新任务编号与地址后缀;来源蝦皮订单号沿用失败任务的不可变快照。 +- 采购失败任务可在采购管理当前页批量勾选重试,最多 100 条。重试不修改旧任务,而是用当前 SYB/PDD 档案、目标规格、当前价格保护和当前采购规则创建新的 `pending` 任务,并生成新任务编号与地址后缀;来源蝦皮订单号沿用失败任务的不可变快照。新任务不复用旧任务或长期档案的执行规格,仍必须先完成当次真机规格探测。 - 只有正式采购、未进入不可逆边界、没有订单号或下单时间、且仍是同一 SYB 最新记录的 `failed` 任务可重试。原设备离线、停用、忙碌或能力不足时该项失败且不自动换机;未指定设备时仍由空闲设备领取。 - 批量重试逐项处理并允许部分成功;同一请求幂等重放不会重复创建。失败任务不再使用一次性重新采购授权,该授权只保留给已经创建过订单且满足条件的任务。 @@ -336,12 +336,16 @@ synchronized_at: 2026-09-04T10:22:02Z - 自动确认必须保存来源、实际置信度和脱敏限长原因;人工确认完成后同步对应分项及主表总体状态。worker 不得覆盖已由人工改变的映射。 - 纠错不能简单执行 B→C;必须把原 A→B 记录置为 `superseded`,建立 A→C,并只处理原记录冻结的虾皮商品影响集合。 -## 创建采购任务时的异步规格匹配(#148) +## 历史:创建采购任务时的异步规格匹配(#148,#215 后仅兼容存量) -- 创建任务继续同步使用已确认映射和唯一确定性 `exact_match`;只有需要外部 AI 时,任务与 `purchase_spec_match_work_item` 在同一事务创建,接口立即返回。活动工作项或 `manual_required` 未处理前,`Next`、`Claim`、`Start` 均拒绝任务。 -- AI 结果只固化到采购任务快照,不自动写回虾皮商品长期规格映射。`ai_match` 必须达到设置阈值、严格属于当前候选、原因非空且写入前输入指纹未变化;否则转人工,Agent 不猜测。 -- provider 网络、超时或 5xx 按 30 秒、2 分钟、10 分钟退避,最多尝试 3 次;预算内恢复会自动继续,耗尽后转 `manual_required`。Admin 可重新入队或从当前候选人工选择。 -- 异步 provider 单次调用最长 60 秒,即 `min(配置超时, 60 秒)`;设置页测试连接仍使用完整配置超时。取消任务会同时终结活动工作项,输入变化会关闭旧工作项并要求重新入队。 +- #215 起,新建 SYB 采购任务不再根据 PDD 档案创建 `purchase_spec_match_work_item`,也不在首次派发前调用外部 AI;规格决策统一来自首趟真机探测的当次候选。 +- 部署前已存在的匹配工作项继续保留审计、恢复、重试和人工处理能力;其 AI 封闭候选、置信度、输入指纹、退避和最长 60 秒调用边界不变,但不能成为新任务跳过当次探测的依据。 + +## SYB 采购强制当次规格探测(#215) + +- 每个新 SYB 采购任务固定执行“首趟只读探测 → 服务端确定性优先/必要时 AI → 固化任务级精确规格 → 第二趟正式采购”。已有长期映射只作商品档案事实,不直接进入任务执行规格。 +- 首趟候选与 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和幂等结果哈希关联;第二趟失败不得回到首趟循环探测。备货 `stock/direct_select` 没有 SYB 目标规格,继续使用用户逐字选择的档案规格,不进入本规则。 +- 候选和 Provider 结果仅保存颜色、尺码原始标签及结构化决策,不保存控件树、整屏截图、账号、地址、订单或支付数据;付款仍永久禁止。 ## Agent 手动采集替代商品(#130) diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index ff44bc8..43402ae 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: a48b6a104c9b96834bbe122a226ba50baf8e4f93 -synchronized_at: 2026-09-04T10:22:06Z +wiki_revision: a022259ba39db7588eaa3b6579c799e2eb6a6f9e +synchronized_at: 2026-09-04T10:58:12Z # MVP 共享 API 契约 @@ -405,7 +405,7 @@ POST /api/agent/v1/tasks/{taskId}/fail - `executionMode` 只能是 `rehearsal` 或 `live`,创建后不可修改。 - 正式 `live` 任务必须引用一条 `syb_product`;无副作用的 `rehearsal` 可以不引用 SYB。 -- 创建时固化 SYB、蝦皮订单号、虾皮商品、PDD 商品、目标规格、映射规格、数量、价格区间、币种、URL、`goods_id`、规则和可选 PDD 账号引用。正式任务的 `shopeeOrderNoSnapshot` 来自创建时的 `syb_product.order_code`;同一订单号可对应多条任务,之后来源商品修改不会改写任务快照。 +- 创建时固化 SYB、蝦皮订单号、虾皮商品、PDD 商品、目标规格、数量、价格区间、币种、URL、`goods_id`、规则和可选 PDD 账号引用。新建 SYB 任务的执行映射初始为空,首趟当次 PDD 规格探测决策成功后才固化 `mappedColor` / `mappedSize`;正式任务的 `shopeeOrderNoSnapshot` 来自创建时的 `syb_product.order_code`,之后来源商品修改不会改写任务快照。 - Android 无法可靠读取当前 PDD 账号,因此 `pddAccountId` 和 `pddAccountRefSnapshot` 均可空。已知账号时参与账号级串行,未知时不阻止任务。 - 地址后缀由任务 ID 唯一确定为 `_cg{taskId}`;#34 创建任务时必须在同一事务内回写快照。 - 一个任务最多对应一个 PDD 订单;重新采购必须新建任务,旧任务和旧订单保留。 @@ -511,7 +511,7 @@ POST /api/agent/v1/tasks/{taskId}/fail | `GET` | `/api/admin/v1/purchase-tasks/{taskId}` | 只读任务详情与 attempt 历史;不返回规则原文、Token、凭据、完整地址、控件树或截图 | | `POST` | `/api/admin/v1/purchase-tasks` | `requestId`;单条创建 | | `POST` | `/api/admin/v1/purchase-tasks/stock` | 创建备货采购;`requestId`、`executionMode`、`pddProductId`、可选 `deviceId` / `pddAccountId`、`color`、可选 `size`、`quantity`、`minUnitPriceCent`、`maxUnitPriceCent`。服务端使用内置规则并从所选颜色归档派生参考价;相同 `requestId` 幂等返回原任务 | -| `POST` | `/api/admin/v1/purchase-tasks/batch-preview` | `sybProductIds`(1~100)和可选 `deviceId`;逐条返回采购是否可创建、价格区间、原因和下一步,并独立返回 PDD 采集资格 `collectionEligible` / `collectionDisabledReason` 与规格匹配资格 `aiMatchEligible` / `aiMatchDisabledReason`,不创建任务。只有目标颜色和尺码已保存为确认映射,且共同命中最近一次成功或部分成功采集中的完整可售 SKU 组合时,`eligible` 才为 `true`;只在内存中得到的确定性结果不算采购就绪 | +| `POST` | `/api/admin/v1/purchase-tasks/batch-preview` | `sybProductIds`(1~100)和可选 `deviceId`;逐条返回采购是否可创建、价格区间、原因和下一步,并独立返回 PDD 采集资格 `collectionEligible` / `collectionDisabledReason` 与长期规格匹配资格 `aiMatchEligible` / `aiMatchDisabledReason`,不创建任务且不调用 AI Provider。自 #215 起,长期映射缺失或失效不再单独使 `eligible=false`;新任务会在首趟当次 PDD 页面探测中决定执行规格 | | `POST` | `/api/admin/v1/purchase-tasks/batch-spec-match` | `sybProductIds`(1~100);仅处理服务端预检 `aiMatchEligible=true` 的明细,其他明细按具体禁用原因返回 `skipped`,并逐条返回 `auto_confirmed` / `pending` / `failed` / `skipped`。资格要求 SYB 采购规格解析成功、蝦皮与 PDD 关联完整、PDD 当前规格可用,且最近一次成功或部分成功采集存在完整可售 SKU 组合证据;未人工确认的 `parse_status=uncertain` 不进入自动确认,`manuallyConfirmed=true` 与 `parse_status=success` 同等视为可信规格。唯一确定性 `exact_match` 不调用 Provider,直接通过独立路径保存为 `confirmed`;其余结果只有 `ai_match` 置信度达到 `autoConfirmMinConfidence`、理由非空、返回值属于当前候选且颜色+尺码命中同一个可售 SKU 组合时才保存为 `confirmed`。低置信度、无效组合和 Provider 异常不改写现有映射 | | `POST` | `/api/admin/v1/purchase-tasks/batch` | 批次 `requestId`、`sybProductIds`(1~100)和可选 `deviceId`;每条派生稳定幂等键并独立创建,部分失败不回滚成功项 | | `POST` | `/api/admin/v1/purchase-tasks/{taskId}/authorize-repurchase` | 一次性授权;创建新任务后自动消耗 | @@ -532,14 +532,14 @@ Admin 列表与详情由 #35 实现;#67 增加 `shopeeOrderNoSnapshot` 的列 | `GET` | `/api/agent/v1/purchase-tasks/next` | 返回与设备能力兼容的指定任务或空闲任务 | | `POST` | `/api/agent/v1/purchase-tasks/{taskId}/claim` | `requestId` 原子领取,并建立设备/可选账号租约 | | `POST` | `/api/agent/v1/purchase-tasks/{taskId}/start` | 创建不可变 `taskAttemptId` | -| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/order-submit-started` | 创建订单前先落不可逆标记;演练任务永远拒绝 | +| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/order-submit-started` | 创建订单前先落不可逆标记;演练任务和 `spec_probe` attempt 永远拒绝 | | `POST` | `/api/agent/v1/purchase-tasks/{taskId}/result` | 请求体携带 `taskAttemptId` 和 `requestId`;幂等提交演练、规格探测、订单或失败结果 | -创建阶段需要外部 AI 的任务会先以 `pending` 与持久匹配工作项同事务创建并立即返回 Admin。工作项处于 `pending`、`running`、`retry_wait` 或 `manual_required` 时,`next` 不返回该任务,直接调用 `claim` 或 `start` 也会返回状态冲突。匹配完成后 Agent 仍只接收服务端固化的精确 PDD 原始标签;Android 接口和请求体不新增 AI、候选或人工决策字段。 +自 #215 起,新建 SYB 采购任务不再从 PDD 档案创建持久匹配工作项,也不在首次派发前调用外部 AI;部署前已存在的 `purchase_spec_match_work_item` 继续按原状态兼容处理。新任务首次 `start` 固定得到 `phase=spec_probe`,Android 通过既有结果字段回传当次候选;匹配成功后的第二次 `start` 才得到 `phase=purchase` 和服务端固化的精确 PDD 原始标签。Android 不接收 AI 配置或自由决策权限。 -结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。慢路径第一趟提交规格后释放设备与已知账号租约,任务进入 `spec_probe_pending`;服务端先使用已确认人工映射,否则对实时/档案可选规格做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。第二趟只会收到服务端已固化的精确 PDD 原始标签;Agent 只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。 +结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。每个新 SYB 采购任务的第一趟只读遍历当次 PDD 规格面板并提交颜色、尺码原始候选,随后释放设备与已知账号租约并进入 `spec_probe_pending`;服务端只以任务冻结的 SYB 目标和当次候选先做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。AI 的颜色和尺码必须逐字属于当次对应候选,否则按无匹配失败。第二趟只会收到服务端固化的精确 PDD 原始标签;Agent 只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。 -任务 payload 新增必传布尔字段 `specResolutionAllowed`,它是 Android 是否可以把一次 `PURCHASE_SPEC_TARGET_NOT_VISIBLE` 转为 `spec_probe_completed` 的唯一资格事实。服务端仅在 `taskType=syb_order`、规则声明 `purchase.spec-probe.v1`、没有固化 `SpecDecisionRequestID` 且规格来源允许既有慢路径时返回 `true`;`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。普通就地重试保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格。Android 不得根据映射是否非空、执行 phase、错误文字或本地判断扩大资格。 +任务 payload 的必传布尔字段 `specResolutionAllowed` 是 Android 是否可以提交规格探测的唯一资格事实。新建 `taskType=syb_order` 任务必须由声明 `purchase.spec-probe.v1` 的规则创建,初始 `SpecDecisionRequestID` 为空且 `specSource=unresolved`,首趟返回 `true`;当次决策固化后返回 `false`。`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。普通就地重试保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格;第二趟选择失败必须提交真实失败,不能重新探测。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` 只允许管理员或采购员人工解除,永不自动重派。 diff --git a/server/app/goauto/purchase/agent_retry_test.go b/server/app/goauto/purchase/agent_retry_test.go index 9559ed8..d0e192e 100644 --- a/server/app/goauto/purchase/agent_retry_test.go +++ b/server/app/goauto/purchase/agent_retry_test.go @@ -2,7 +2,6 @@ package purchase import ( "context" - "strings" "testing" "time" @@ -158,7 +157,7 @@ func TestMatchedReplacementExposesContinueAndReusesAgentRetry(t *testing.T) { } } -func TestMatchedReplacementRejectsClearedMappingWithoutCallingAI(t *testing.T) { +func TestMatchedReplacementAllowsRetryWithoutPersistedMappingAndDoesNotCallAI(t *testing.T) { db := testDB(t) f := seed(t, db, liveCaps(), true) setCollectedPDDPrice(t, db, f.pdd.ID) @@ -172,11 +171,11 @@ func TestMatchedReplacementRejectsClearedMappingWithoutCallingAI(t *testing.T) { service.Matcher = matcher detail, err := service.AgentHistoryDetail(context.Background(), failed.ID, f.token) - if err != nil || detail.ContinuePurchaseEligible || !strings.Contains(detail.ContinuePurchaseDisabledReason, "规格匹配已失效") || matcher.calls != 0 { + if err != nil || !detail.ContinuePurchaseEligible || detail.ContinuePurchaseDisabledReason != "" || matcher.calls != 0 { t.Fatalf("cleared mapping qualification mismatch: detail=%+v calls=%d error=%v", detail, matcher.calls, err) } - _, err = service.AgentRetry(context.Background(), failed.ID, AgentRetryRequest{RequestID: uuid.NewString()}, f.token) - if code(err) != CodeMappingRequired || matcher.calls != 0 { - t.Fatalf("cleared mapping reached AI or retry: code=%s calls=%d error=%v", code(err), matcher.calls, err) + created, err := service.AgentRetry(context.Background(), failed.ID, AgentRetryRequest{RequestID: uuid.NewString()}, f.token) + if err != nil || created.TaskID == 0 || matcher.calls != 0 { + t.Fatalf("cleared mapping retry did not create probe task: result=%+v calls=%d error=%v", created, matcher.calls, err) } } diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go index 1c70f98..d371b94 100644 --- a/server/app/goauto/purchase/batch.go +++ b/server/app/goauto/purchase/batch.go @@ -105,8 +105,7 @@ func (s *Service) BatchPreview(ctx context.Context, req BatchPreviewRequest) (Ba items := make([]BatchPreviewItem, 0, len(ids)) eligible := 0 for _, id := range ids { - item := s.previewFromDataset(ctx, id, dataset, false, guard) - item.enforcePersistedMatch(id, dataset) + item := s.previewFromDataset(id, dataset, guard) item.applyCollectionEligibility(id, dataset) qualification := aiMatchQualificationForDataset(id, dataset) item.AIMatchEligible = qualification.Eligible @@ -331,22 +330,6 @@ func (s *Service) validateBatchDevice(ctx context.Context, deviceID *uint64, rul return ensureCapabilities(record, purchasecontract.RequiredCapabilities(rule)) } -func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem { - _, _, currentRule, ruleErr := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive) - if ruleErr != nil { - item := BatchPreviewItem{SYBProductID: id} - item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(ruleErr) - return item - } - dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id}) - if err != nil { - item := BatchPreviewItem{SYBProductID: id} - item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry" - return item - } - return s.previewFromDataset(ctx, id, dataset, true, purchasecontract.EffectivePriceGuard(currentRule)) -} - func (s *Service) previewOneDeterministic(ctx context.Context, id uint64, guard purchasecontract.PriceLimits) BatchPreviewItem { dataset, err := s.loadBatchPreviewDataset(ctx, []uint64{id}) if err != nil { @@ -354,8 +337,7 @@ func (s *Service) previewOneDeterministic(ctx context.Context, id uint64, guard item.ReasonCode, item.Reason, item.NextAction = CodeInternal, "服务端处理失败", "retry" return item } - item := s.previewFromDataset(ctx, id, dataset, false, guard) - item.enforcePersistedMatch(id, dataset) + item := s.previewFromDataset(id, dataset, guard) return item } @@ -379,7 +361,7 @@ func taskPointerValue(value *uint64) uint64 { return *value } -func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset batchPreviewDataset, allowAI bool, guard purchasecontract.PriceLimits) BatchPreviewItem { +func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, guard purchasecontract.PriceLimits) BatchPreviewItem { item := BatchPreviewItem{SYBProductID: id} syb, found := dataset.sybByID[id] if !found { @@ -423,26 +405,15 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat } mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize) item.MappedColor, item.MappedSize = mappedColor, mappedSize - candidates, archiveUsable := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize) + candidates, _ := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize) // #190:失效的已保存映射不再拦截,降级为 unresolved,与创建路径保持一致。 if source != "unresolved" && !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) { mappedColor, mappedSize, source = "", "", "unresolved" item.MappedColor, item.MappedSize = "", "" } if source == "unresolved" { - if !archiveUsable { - item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "关联的 PDD 商品没有完整可选规格", "open_pdd" - return item - } request := aimatching.MatchRequest{TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, Colors: candidates.Colors, Sizes: candidates.Sizes} - if allowAI { - matched, matchErr := s.matcher().Resolve(ctx, request) - if matchErr != nil { - item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(purchaseMatchError(matchErr)) - return item - } - item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize - } else if matched, ok := aimatching.DeterministicMatch(request); ok { + if matched, ok := aimatching.DeterministicMatch(request); ok { item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize } // #190:映射不完整不再拦截,任务以 unresolved 建立并交由规格探测解析。 @@ -476,42 +447,6 @@ func (s *Service) previewFromDataset(ctx context.Context, id uint64, dataset bat return item } -// enforcePersistedMatch is the #188 gate for the SYB list and batch-create -// workflow. A transient deterministic result is not purchase readiness: the -// color/size mapping must already be confirmed on the Shopee product and must -// still identify one complete, available SKU combination when collection -// evidence exists. Manually entered or externally imported PDD products may -// legitimately have no such collection record. -func (item *BatchPreviewItem) enforcePersistedMatch(id uint64, dataset batchPreviewDataset) { - if item.ReasonCode != "" && item.ReasonCode != CodeMappingRequired { - return - } - syb, ok := dataset.sybByID[id] - if !ok || syb.ShopeeProductID == nil { - return - } - shopee, ok := dataset.shopeeByID[*syb.ShopeeProductID] - if !ok || shopee.PDDProductID == nil { - return - } - pdd, ok := dataset.pddByID[*shopee.PDDProductID] - if !ok || pdd.Status != "active" { - return - } - mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize) - item.MappedColor, item.MappedSize = mappedColor, mappedSize - if source == "unresolved" { - item.Eligible = false - item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "规格尚未匹配并保存,请先完成 AI 匹配或人工确认", "open_mapping" - return - } - combinations := dataset.skuCombinationsByPDD[pdd.ID] - if len(combinations) > 0 && !validSKUCombination(combinations, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) { - item.Eligible = false - item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "已保存规格映射不属于当前可售的 PDD 规格组合,请重新匹配", "open_mapping" - } -} - func purchasePriceRange(raw, mappedColor string, guard purchasecontract.PriceLimits) (reference, minPrice, maxPrice int64, err error) { var dimensions []productspec.Dimension if json.Unmarshal([]byte(raw), &dimensions) != nil { diff --git a/server/app/goauto/purchase/batch_spec_match_test.go b/server/app/goauto/purchase/batch_spec_match_test.go index ab936d2..fa7b41c 100644 --- a/server/app/goauto/purchase/batch_spec_match_test.go +++ b/server/app/goauto/purchase/batch_spec_match_test.go @@ -9,7 +9,6 @@ import ( "go-admin/app/goauto/models" "go-admin/app/goauto/shopeeproduct" - "github.com/google/uuid" "gorm.io/gorm" ) @@ -137,18 +136,14 @@ func exactBatchSpecFixture(t *testing.T) (*Service, fixture) { return testService(db), f } -func TestBatchSpecMatchPersistsExactMatchBeforePurchaseCreation(t *testing.T) { +func TestBatchSpecMatchPersistsExactMatchButPurchaseDoesNotWaitForIt(t *testing.T) { service, f := exactBatchSpecFixture(t) matcher := &batchSpecMatcher{} service.Matcher = matcher before, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}}) - if err != nil || len(before.Items) != 1 || before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStageColorMapping { - t.Fatalf("unpersisted exact match was not gated: %+v err=%v", before, err) - } - blocked, err := service.BatchCreate(context.Background(), BatchCreateRequest{RequestID: uuid.NewString(), SYBProductIDs: []uint64{f.syb.ID}, DeviceID: &f.device.ID}) - if err != nil || blocked.CreatedCount != 0 || blocked.FailedCount != 1 || blocked.Items[0].ReasonCode != CodeMappingRequired { - t.Fatalf("batch create bypassed persisted mapping gate: %+v err=%v", blocked, err) + if err != nil || len(before.Items) != 1 || !before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStagePurchaseReady { + t.Fatalf("live-probe purchase was incorrectly gated by persisted mapping: %+v err=%v", before, err) } matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}}) @@ -186,8 +181,8 @@ func TestExtractedUncertainSpecsCanBeMatchedBeforePurchase(t *testing.T) { service.Matcher = matcher before, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}}) - if err != nil || len(before.Items) != 1 || before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStageColorMapping { - t.Fatalf("extracted uncertain specs did not enter matching: %+v err=%v", before, err) + if err != nil || len(before.Items) != 1 || !before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStagePurchaseReady { + t.Fatalf("extracted uncertain specs were not purchase-ready for live probing: %+v err=%v", before, err) } matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}}) if err != nil || matched.AutoConfirmedCount != 1 || matcher.calls != 0 { @@ -236,7 +231,7 @@ func TestBatchPreviewExposesExplicitAIMatchEligibility(t *testing.T) { t.Fatalf("preview failed: %+v err=%v", preview, err) } item := preview.Items[0] - if !item.AIMatchEligible || item.AIMatchDisabledReason != "" || item.Eligible || item.ProcessStage != ProcessStageColorMapping { + if !item.AIMatchEligible || item.AIMatchDisabledReason != "" || !item.Eligible || item.ProcessStage != ProcessStagePurchaseReady { t.Fatalf("explicit AI eligibility mismatch: %+v", item) } } diff --git a/server/app/goauto/purchase/batch_test.go b/server/app/goauto/purchase/batch_test.go index 2b0b79e..cbe46f0 100644 --- a/server/app/goauto/purchase/batch_test.go +++ b/server/app/goauto/purchase/batch_test.go @@ -288,8 +288,24 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) { if queries != 7 { t.Fatalf("batch preview used %d queries, want 7 bounded queries including collection eligibility and current purchase rule", queries) } - if len(response.Items) != 2 || response.Items[0].Eligible || response.Items[1].Eligible || response.Items[0].ReasonCode != CodeMappingRequired { - t.Fatalf("unresolved rows must not be reported as purchase-ready: %+v", response) + if len(response.Items) != 2 || !response.Items[0].Eligible || !response.Items[1].Eligible || response.EligibleCount != 2 { + t.Fatalf("unresolved rows must be ready for mandatory live probing: %+v", response) + } +} + +func TestBatchPreviewAllowsIncompleteArchivedCandidatesForMandatoryLiveProbe(t *testing.T) { + db := testDB(t) + fixture := seed(t, db, liveCaps(), false) + if err := db.Model(&models.PDDProduct{}).Where("id = ?", fixture.pdd.ID).Update( + "specs_json", + `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":2000}]}]`, + ).Error; err != nil { + t.Fatal(err) + } + + response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{fixture.syb.ID}}) + if err != nil || len(response.Items) != 1 || !response.Items[0].Eligible || response.Items[0].ProcessStage != ProcessStagePurchaseReady { + t.Fatalf("incomplete archive blocked live probe task creation: %+v err=%v", response, err) } } diff --git a/server/app/goauto/purchase/lifecycle.go b/server/app/goauto/purchase/lifecycle.go index c358eb3..f9a47b7 100644 --- a/server/app/goauto/purchase/lifecycle.go +++ b/server/app/goauto/purchase/lifecycle.go @@ -266,6 +266,9 @@ func (s *Service) MarkOrderSubmitStarted(ctx context.Context, taskID uint64, req if t.ExecutionMode != models.PurchaseExecutionModeLive { return TaskPayload{}, fail(CodeStateConflict, "演练任务不能创建订单") } + if a.Phase != models.PurchaseAttemptPhasePurchase { + return TaskPayload{}, fail(CodeStateConflict, "规格探测阶段不能进入创建订单边界") + } if _, e := uuid.Parse(req.RequestID); e != nil { return TaskPayload{}, fail(CodeInvalidRequest, "requestId 无效") } @@ -536,19 +539,29 @@ func (s *Service) resolveProbedSpecs(ctx context.Context, taskID uint64, attempt } decision.NoMatch, decision.Decision = true, snapshot decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", "没有找到可采购的 PDD 颜色或尺码" - } else if matched, matchErr := s.matcher().Resolve(ctx, request); matchErr == nil { - snapshot, marshalErr := json.Marshal(matched.Decision) - if marshalErr != nil { - return TaskPayload{}, internal(marshalErr) - } - decision.MappedColor, decision.MappedSize, decision.Source, decision.Decision = matched.MappedColor, matched.MappedSize, matched.Source, snapshot } else { - snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, purchaseMatchReason(matchErr))) - if marshalErr != nil { - return TaskPayload{}, internal(marshalErr) + matched, matchErr := s.matcher().Resolve(ctx, request) + valid := matchErr == nil && (matched.Source == aimatching.SourceExact || matched.Source == aimatching.SourceAI) && + matchCandidateValid(request.TargetColor, matched.MappedColor, request.Colors) && + matchCandidateValid(request.TargetSize, matched.MappedSize, request.Sizes) + if valid { + snapshot, marshalErr := json.Marshal(matched.Decision) + if marshalErr != nil { + return TaskPayload{}, internal(marshalErr) + } + decision.MappedColor, decision.MappedSize, decision.Source, decision.Decision = matched.MappedColor, matched.MappedSize, matched.Source, snapshot + } else { + reason := purchaseMatchReason(matchErr) + if matchErr == nil { + reason = "AI 规格匹配结果不属于当次 PDD 候选" + } + snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, reason)) + if marshalErr != nil { + return TaskPayload{}, internal(marshalErr) + } + decision.NoMatch, decision.Decision = true, snapshot + decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", reason } - decision.NoMatch, decision.Decision = true, snapshot - decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", purchaseMatchReason(matchErr) } updated, _, err := s.applySpecDecision(ctx, taskID, decision, nil) if err != nil { diff --git a/server/app/goauto/purchase/match_worker_test.go b/server/app/goauto/purchase/match_worker_test.go index 43523e5..e9b4909 100644 --- a/server/app/goauto/purchase/match_worker_test.go +++ b/server/app/goauto/purchase/match_worker_test.go @@ -40,6 +40,19 @@ func queuedMatchTask(t *testing.T) (*gorm.DB, fixture, models.PurchaseTask) { if err != nil { t.Fatal(err) } + fingerprint, snapshot, err := currentPurchaseMatchInput(db, task) + if err != nil { + t.Fatal(err) + } + work := models.PurchaseSpecMatchWorkItem{ + PurchaseTaskID: task.ID, + Status: models.PurchaseMatchPending, + InputFingerprint: fingerprint, + InputSnapshotJSON: snapshot, + } + if err := db.Create(&work).Error; err != nil { + t.Fatal(err) + } return db, f, task } diff --git a/server/app/goauto/purchase/process_stage.go b/server/app/goauto/purchase/process_stage.go index 0415496..f2b7f64 100644 --- a/server/app/goauto/purchase/process_stage.go +++ b/server/app/goauto/purchase/process_stage.go @@ -69,8 +69,7 @@ func (s *Service) ProcessStages(ctx context.Context, ids []uint64) (map[uint64]P return nil, err } for _, id := range ids { - preview := s.previewFromDataset(ctx, id, dataset, false, guard) - preview.enforcePersistedMatch(id, dataset) + preview := s.previewFromDataset(id, dataset, guard) qualification := aiMatchQualificationForDataset(id, dataset) preview.AIMatchEligible = qualification.Eligible preview.AIMatchDisabledReason = qualification.DisabledReason diff --git a/server/app/goauto/purchase/retry.go b/server/app/goauto/purchase/retry.go index 06c5898..6ee4f81 100644 --- a/server/app/goauto/purchase/retry.go +++ b/server/app/goauto/purchase/retry.go @@ -218,7 +218,12 @@ func (s *Service) retryEligibility(ctx context.Context, task models.PurchaseTask return decision } - preview := s.previewOne(ctx, *task.SYBProductID) + _, _, currentRule, ruleErr := purchaserule.CurrentRule(ctx, s.DB, models.PurchaseExecutionModeLive) + if ruleErr != nil { + code, message, _ := serviceErrorFields(ruleErr) + return retryDecision{ReasonCode: code, Reason: message} + } + preview := s.previewOneDeterministic(ctx, *task.SYBProductID, purchasecontract.EffectivePriceGuard(currentRule)) if !preview.Eligible { return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason} } @@ -230,10 +235,9 @@ func (s *Service) retryEligibility(ctx context.Context, task models.PurchaseTask } // retryQueryEligibility is deliberately limited to local task and device -// safety checks. Admin list/detail queries must never call previewOne because -// previewOne can fall back to the external AI provider for unresolved specs. -// The batch-retry command runs retryEligibility and performs that complete, -// current-archive validation only after the operator submits the retry. +// safety checks. The batch-retry command performs deterministic archive and +// price validation, but specification resolution belongs to the new task's +// mandatory live probe and never calls AI before task creation. func (s *Service) retryQueryEligibility(ctx context.Context, task models.PurchaseTask, checkDeviceBusy bool) retryDecision { decision := s.retryStateEligibility(ctx, task) if !decision.Allowed { @@ -263,25 +267,20 @@ func (s *Service) continuePurchaseEligibility(ctx context.Context, task models.P guard := purchasecontract.EffectivePriceGuard(currentRule) syb, found := dataset.sybByID[*task.SYBProductID] if !found || syb.ShopeeProductID == nil { - preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard) + preview := s.previewFromDataset(*task.SYBProductID, dataset, guard) return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason} } shopee, found := dataset.shopeeByID[*syb.ShopeeProductID] if !found || shopee.PDDProductID == nil { - preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard) + preview := s.previewFromDataset(*task.SYBProductID, dataset, guard) return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason} } - pdd, found := dataset.pddByID[*shopee.PDDProductID] + _, found = dataset.pddByID[*shopee.PDDProductID] if !found { - preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard) + preview := s.previewFromDataset(*task.SYBProductID, dataset, guard) return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason} } - mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize) - candidates, _ := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize) - if source == "unresolved" || !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, mappedColor, mappedSize) { - return retryDecision{ReasonCode: CodeMappingRequired, Reason: "规格匹配已失效,请在 Admin 重新确认"} - } - preview := s.previewFromDataset(ctx, *task.SYBProductID, dataset, false, guard) + preview := s.previewFromDataset(*task.SYBProductID, dataset, guard) if !preview.Eligible { return retryDecision{ReasonCode: preview.ReasonCode, Reason: preview.Reason} } diff --git a/server/app/goauto/purchase/retry_test.go b/server/app/goauto/purchase/retry_test.go index 4ed1bea..986454e 100644 --- a/server/app/goauto/purchase/retry_test.go +++ b/server/app/goauto/purchase/retry_test.go @@ -223,7 +223,7 @@ func TestAdminListRetryEligibilityRejectsBusyOriginalDevice(t *testing.T) { } } -func TestAdminQueriesDoNotResolveSpecsButBatchRetryStillDoes(t *testing.T) { +func TestAdminQueriesAndBatchRetryDoNotResolveArchivedSpecs(t *testing.T) { db := testDB(t) f := seed(t, db, liveCaps(), false) service := testService(db) @@ -253,8 +253,8 @@ func TestAdminQueriesDoNotResolveSpecsButBatchRetryStillDoes(t *testing.T) { if err != nil { t.Fatal(err) } - if matcher.calls != 1 || retry.CreatedCount != 0 || retry.FailedCount != 1 || retry.Items[0].ReasonCode != CodeMappingRequired { - t.Fatalf("batch retry skipped full matching: result=%+v matcherCalls=%d", retry, matcher.calls) + if matcher.calls != 0 || retry.CreatedCount != 1 || retry.FailedCount != 0 { + t.Fatalf("batch retry did not create a mandatory probe task: result=%+v matcherCalls=%d", retry, matcher.calls) } } diff --git a/server/app/goauto/purchase/service.go b/server/app/goauto/purchase/service.go index 9ddd90e..57c6945 100644 --- a/server/app/goauto/purchase/service.go +++ b/server/app/goauto/purchase/service.go @@ -54,11 +54,6 @@ type SpecMatcher interface { Resolve(context.Context, aimatching.MatchRequest) (aimatching.MatchResult, error) } -type externalMatchPlan struct { - fingerprint string - snapshot string -} - type matchFingerprint struct { SYBProductID uint64 `json:"sybProductId"` TargetColor string `json:"targetColor"` @@ -96,51 +91,6 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (models.Purchas return call.task, call.replayed, call.err } -func (s *Service) prepareExternalMatch(ctx context.Context, req CreateRequest) (*externalMatchPlan, error) { - if req.ExecutionMode != models.PurchaseExecutionModeLive || req.SYBProductID == nil { - return nil, nil - } - var syb models.SYBProduct - if err := s.DB.WithContext(ctx).First(&syb, *req.SYBProductID).Error; err != nil { - return nil, notFound(err, "顺云宝商品不存在") - } - if syb.ShopeeProductID == nil { - return nil, fail(CodeInvalidRequest, "该商品尚未关联蝦皮商品") - } - var shopee models.ShopeeProduct - if err := s.DB.WithContext(ctx).First(&shopee, *syb.ShopeeProductID).Error; err != nil { - return nil, notFound(err, "蝦皮商品不存在") - } - if shopee.PDDProductID == nil { - return nil, fail(CodeInvalidRequest, "该蝦皮商品尚未关联拼多多商品") - } - var pdd models.PDDProduct - if err := s.DB.WithContext(ctx).First(&pdd, *shopee.PDDProductID).Error; err != nil { - return nil, notFound(err, "拼多多商品不存在") - } - targetColor, targetSize := strings.TrimSpace(req.TargetColor), strings.TrimSpace(req.TargetSize) - if targetColor == "" { - targetColor = syb.TargetColor - } - if targetSize == "" { - targetSize = syb.TargetSize - } - _, _, source := confirmedMappings(shopee.SpecsJSON, targetColor, targetSize) - candidates, usable := archiveCandidates(pdd.SpecsJSON, targetColor, targetSize) - if pdd.Status != "active" || source != "unresolved" || !usable { - return nil, nil - } - request := aimatching.MatchRequest{TargetColor: targetColor, TargetSize: targetSize, Colors: candidates.Colors, Sizes: candidates.Sizes} - if _, deterministic := aimatching.DeterministicMatch(request); deterministic { - return nil, nil - } - fingerprint, snapshot, err := creationMatchInput(syb, shopee, pdd, request) - if err != nil { - return nil, internal(err) - } - return &externalMatchPlan{fingerprint: fingerprint, snapshot: snapshot}, nil -} - func creationMatchInput(syb models.SYBProduct, shopee models.ShopeeProduct, pdd models.PDDProduct, request aimatching.MatchRequest) (string, string, error) { canonical := func(raw string) (json.RawMessage, error) { var value any @@ -194,10 +144,6 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas } else if !errors.Is(err, gorm.ErrRecordNotFound) { return models.PurchaseTask{}, false, internal(err) } - externalPlan, err := s.prepareExternalMatch(ctx, req) - if err != nil { - return models.PurchaseTask{}, false, err - } replayed := false err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.Where("create_request_id = ?", req.RequestID).First(&out).Error; err == nil { @@ -334,22 +280,20 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas if currency == "" { currency = shopee.Currency } - mappedColor, mappedSize, specSource = confirmedMappings(shopee.SpecsJSON, targetColor, targetSize) + // Every new SYB purchase must use the candidates observed on the + // current PDD page. Persisted mappings remain product-level history, + // but they cannot skip this task's read-only probe phase. + mappedColor, mappedSize, specSource = "", "", "unresolved" } candidates, archiveUsable := archiveCandidates(pdd.SpecsJSON, targetColor, targetSize) matchRequest := aimatching.MatchRequest{TargetColor: targetColor, TargetSize: targetSize, Colors: candidates.Colors, Sizes: candidates.Sizes} - if externalPlan != nil { - fingerprint, fingerprintErr := creationMatchFingerprint(syb, shopee, pdd, matchRequest) - if fingerprintErr != nil { - return internal(fingerprintErr) - } - if fingerprint != externalPlan.fingerprint { - return fail(CodeMappingRequired, "规格匹配输入已变化,请重新创建采购任务") - } - } if taskType == models.PurchaseTaskTypeStock { // Direct stock selection was already validated above. It never enters // mapping, deterministic matching, or the external AI fallback. + } else if req.ExecutionMode == models.PurchaseExecutionModeLive { + // The first attempt is always spec_probe for an SYB purchase. The + // exact task-level decision is frozen only from that probe result. + mappedColor, mappedSize, specSource, decisionSnapshot = "", "", "unresolved", "{}" } else if pdd.Status != "active" { mappedColor, mappedSize, specSource = "", "", "unresolved" } else if specSource == "manual_mapping" || specSource == "exact_match" || specSource == "ai_match" { @@ -365,8 +309,6 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas } } else if !archiveUsable { mappedColor, mappedSize, specSource = "", "", "unresolved" - } else if externalPlan != nil { - mappedColor, mappedSize, specSource = "", "", "unresolved" } else if match, matched := aimatching.DeterministicMatch(matchRequest); !matched { // #190:确定性匹配失败不再拒绝,降级为 unresolved 交给规格探测。 mappedColor, mappedSize, specSource = "", "", "unresolved" @@ -404,17 +346,8 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas if err := tx.Model(&out).Update("address_suffix", out.AddressSuffix).Error; err != nil { return internal(err) } - if externalPlan != nil { - work := models.PurchaseSpecMatchWorkItem{PurchaseTaskID: out.ID, Status: models.PurchaseMatchPending, InputFingerprint: externalPlan.fingerprint, InputSnapshotJSON: externalPlan.snapshot} - if err := tx.Create(&work).Error; err != nil { - return internal(err) - } - } return nil }) - if err == nil && !replayed && externalPlan != nil && s.StartMatching != nil { - s.StartMatching(s.DB) - } return out, replayed, err } diff --git a/server/app/goauto/purchase/service_test.go b/server/app/goauto/purchase/service_test.go index 2642488..265e966 100644 --- a/server/app/goauto/purchase/service_test.go +++ b/server/app/goauto/purchase/service_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "go-admin/app/goauto/aimatching" "go-admin/app/goauto/device" "go-admin/app/goauto/migrations" "go-admin/app/goauto/models" @@ -29,6 +30,17 @@ type fixture struct { token string } +type liveProbeMatcher struct { + result aimatching.MatchResult + err error + calls int +} + +func (matcher *liveProbeMatcher) Resolve(context.Context, aimatching.MatchRequest) (aimatching.MatchResult, error) { + matcher.calls++ + return matcher.result, matcher.err +} + func testDB(t *testing.T) *gorm.DB { t.Helper() dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared&_foreign_keys=on", strings.ReplaceAll(t.Name(), "/", "_")) @@ -107,6 +119,30 @@ func createLive(t *testing.T, s *Service, f fixture) (models.PurchaseTask, error r, _, err := s.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive, SYBProductID: &f.syb.ID, DeviceID: &f.device.ID, MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true)}) return r, err } + +func startLivePurchaseAfterProbe(t *testing.T, s *Service, f fixture, task models.PurchaseTask) TaskPayload { + t.Helper() + if _, err := s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil { + t.Fatal(err) + } + probeAttempt, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) + if err != nil || probeAttempt.Phase != models.PurchaseAttemptPhaseSpecProbe { + t.Fatalf("probe start: %+v %v", probeAttempt, err) + } + probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: probeAttempt.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`)} + if _, err = s.SubmitResult(context.Background(), task.ID, probe, f.token); err != nil { + t.Fatal(err) + } + if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil { + t.Fatal(err) + } + purchaseAttempt, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) + if err != nil || purchaseAttempt.Phase != models.PurchaseAttemptPhasePurchase { + t.Fatalf("purchase start: %+v %v", purchaseAttempt, err) + } + return purchaseAttempt +} + func code(err error) string { var e *ServiceError if errors.As(err, &e) { @@ -224,9 +260,23 @@ func TestCreateAndLifecycleValidateCapabilitiesAndIdempotentResult(t *testing.T) if err != nil { t.Fatal(err) } - if start.Phase != models.PurchaseAttemptPhasePurchase { + if start.Phase != models.PurchaseAttemptPhaseSpecProbe { t.Fatalf("unexpected phase %s", start.Phase) } + if _, err = s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); code(err) != CodeStateConflict { + t.Fatalf("probe entered order boundary: %v", err) + } + probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: start.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`)} + if _, err = s.SubmitResult(context.Background(), task.ID, probe, f.token); err != nil { + t.Fatal(err) + } + if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil { + t.Fatal(err) + } + start, err = s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) + if err != nil || start.Phase != models.PurchaseAttemptPhasePurchase { + t.Fatalf("purchase start: %+v %v", start, err) + } if _, err = s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil { t.Fatal(err) } @@ -243,7 +293,7 @@ func TestCreateAndLifecycleValidateCapabilitiesAndIdempotentResult(t *testing.T) } var count int64 db.Model(&models.PurchaseTaskAttempt{}).Where("task_id = ?", task.ID).Count(&count) - if count != 1 || first.Status != models.PurchaseTaskStatusOrderCreated { + if count != 2 || first.Status != models.PurchaseTaskStatusOrderCreated { t.Fatalf("duplicate attempt or wrong status: %d %+v", count, first) } var stored models.PurchaseTask @@ -344,6 +394,63 @@ func TestSlowPathUsesTwoAttemptsAndFreezesDecision(t *testing.T) { } } +func TestLiveProbeCallsAIOnlyAfterCandidatesAreReturned(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), false) + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙白").Error; err != nil { + t.Fatal(err) + } + request := aimatching.MatchRequest{TargetColor: "象牙白", TargetSize: "XL", Colors: []string{"米白色", "白色"}, Sizes: []string{"XL"}} + matcher := &liveProbeMatcher{result: aimatching.RecordedMatch(request, aimatching.SourceAI, "米白色", "XL", "在当次候选中唯一匹配")} + s := testService(db) + s.Matcher = matcher + task, err := createLive(t, s, f) + if err != nil || matcher.calls != 0 || task.SpecSource != "unresolved" { + t.Fatalf("task creation called matcher or skipped probe: task=%+v calls=%d err=%v", task, matcher.calls, err) + } + if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil { + t.Fatal(err) + } + first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) + if err != nil || first.Phase != models.PurchaseAttemptPhaseSpecProbe { + t.Fatalf("probe start: %+v %v", first, err) + } + probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["米白色","白色"]},{"key":"size","values":["XL"]}]}`)} + resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token) + if err != nil || matcher.calls != 1 || resolved.Status != models.PurchaseTaskStatusPending || resolved.MappedColor != "米白色" || resolved.MappedSize != "XL" { + t.Fatalf("probe AI result: %+v calls=%d err=%v", resolved, matcher.calls, err) + } +} + +func TestLiveProbeRejectsMatcherValueOutsideCurrentCandidates(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), false) + request := aimatching.MatchRequest{TargetColor: "黑色", TargetSize: "XL", Colors: []string{"黑色"}, Sizes: []string{"XL"}} + matcher := &liveProbeMatcher{result: aimatching.RecordedMatch(request, aimatching.SourceAI, "候选外颜色", "XL", "无效返回")} + s := testService(db) + s.Matcher = matcher + task, err := createLive(t, s, f) + if err != nil { + t.Fatal(err) + } + if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil { + t.Fatal(err) + } + first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) + if err != nil { + t.Fatal(err) + } + probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黑色"]},{"key":"size","values":["XL"]}]}`)} + resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token) + if err != nil || resolved.Status != models.PurchaseTaskStatusFailed || resolved.MappedColor != "" || resolved.MappedSize != "" { + t.Fatalf("out-of-candidate result was accepted: %+v err=%v", resolved, err) + } + var saved models.PurchaseTask + if err = db.First(&saved, task.ID).Error; err != nil || saved.ErrorCode == nil || *saved.ErrorCode != "PURCHASE_SPEC_NOT_MATCHED" { + t.Fatalf("outside-candidate failure was not persisted: %+v err=%v", saved, err) + } +} + func TestSecondSpecProbeFailsClosedWithoutClearingDecision(t *testing.T) { db := testDB(t) f := seed(t, db, liveCaps(), false) @@ -428,7 +535,7 @@ func TestSpecResolutionAllowedMatrix(t *testing.T) { } } -func TestCreateUsesDeterministicTraditionalAndWeightMatching(t *testing.T) { +func TestLiveProbeUsesDeterministicTraditionalAndWeightMatching(t *testing.T) { db := testDB(t) f := seed(t, db, liveCaps(), false) if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"浅蓝色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"4XL 160-170斤","selectable":true}]}]`).Error; err != nil { @@ -437,12 +544,26 @@ func TestCreateUsesDeterministicTraditionalAndWeightMatching(t *testing.T) { if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{"target_color": "淺藍色", "target_size": "4XL 80-85公斤"}).Error; err != nil { t.Fatal(err) } - task, err := createLive(t, testService(db), f) - if err != nil || task.SpecSource != "exact_match" || task.MappedColorSnapshot != "浅蓝色" || task.MappedSizeSnapshot != "4XL 160-170斤" { - t.Fatalf("deterministic match failed: %+v %v", task, err) + s := testService(db) + task, err := createLive(t, s, f) + if err != nil || task.SpecSource != "unresolved" || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" { + t.Fatalf("live task skipped probe: %+v %v", task, err) } - if strings.Contains(task.SpecDecisionSnapshot, "apiKey") || !strings.Contains(task.SpecDecisionSnapshot, "exact_match") { - t.Fatalf("task decision snapshot is unsafe or incomplete: %s", task.SpecDecisionSnapshot) + if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil { + t.Fatal(err) + } + first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) + if err != nil || first.Phase != models.PurchaseAttemptPhaseSpecProbe { + t.Fatalf("probe start: %+v %v", first, err) + } + probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["浅蓝色"]},{"key":"size","values":["4XL 160-170斤"]}]}`)} + resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token) + if err != nil || resolved.MappedColor != "浅蓝色" || resolved.MappedSize != "4XL 160-170斤" { + t.Fatalf("deterministic probe match failed: %+v %v", resolved, err) + } + var saved models.PurchaseTask + if err = db.First(&saved, task.ID).Error; err != nil || strings.Contains(saved.SpecDecisionSnapshot, "apiKey") || !strings.Contains(saved.SpecDecisionSnapshot, "exact_match") { + t.Fatalf("task decision snapshot is unsafe or incomplete: %s err=%v", saved.SpecDecisionSnapshot, err) } } @@ -457,12 +578,12 @@ func TestCreateDoesNotTrustMappedLabelsFromRequest(t *testing.T) { SYBProductID: &f.syb.ID, DeviceID: &f.device.ID, MappedColor: "白色", MappedSize: "XL", MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true), }) - if err != nil || task.SpecSource != "exact_match" || task.MappedColorSnapshot != "黑色" || task.MappedSizeSnapshot != "XL" { - t.Fatalf("request-provided mapping must not override server match: %+v %v", task, err) + if err != nil || task.SpecSource != "unresolved" || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" { + t.Fatalf("request-provided mapping must not skip live probe: %+v %v", task, err) } } -func TestCreateQueuesUsableArchiveWhenDeterministicMatchFails(t *testing.T) { +func TestCreateDoesNotQueueArchiveMatchingBeforeLiveProbe(t *testing.T) { db := testDB(t) f := seed(t, db, liveCaps(), false) if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"L","selectable":true}]}]`).Error; err != nil { @@ -475,12 +596,12 @@ func TestCreateQueuesUsableArchiveWhenDeterministicMatchFails(t *testing.T) { t.Fatalf("task=%+v err=%v", task, err) } var count int64 - if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ?", task.ID).Count(&count).Error; err != nil || count != 1 { + if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ?", task.ID).Count(&count).Error; err != nil || count != 0 { t.Fatalf("work count=%d err=%v", count, err) } } -func TestCreateQueuesExternalMatchingWithoutCallingProvider(t *testing.T) { +func TestCreateDispatchesProbeInsteadOfExternalArchiveMatching(t *testing.T) { db := testDB(t) f := seed(t, db, liveCaps(), false) if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000},{"name":"米白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil { @@ -495,12 +616,12 @@ func TestCreateQueuesExternalMatchingWithoutCallingProvider(t *testing.T) { if err != nil || task.ID == 0 || task.SpecSource != "unresolved" { t.Fatalf("queued task=%+v err=%v", task, err) } - var work models.PurchaseSpecMatchWorkItem - if err := db.Where("purchase_task_id = ?", task.ID).First(&work).Error; err != nil || work.Status != models.PurchaseMatchPending || work.InputFingerprint == "" { - t.Fatalf("work=%+v err=%v", work, err) + var count int64 + if err := db.Model(&models.PurchaseSpecMatchWorkItem{}).Where("purchase_task_id = ?", task.ID).Count(&count).Error; err != nil || count != 0 { + t.Fatalf("unexpected archive match work count=%d err=%v", count, err) } - if next, err := service.Next(context.Background(), f.token); err != nil || next != nil { - t.Fatalf("matching task dispatched: %+v %v", next, err) + if next, err := service.Next(context.Background(), f.token); err != nil || next == nil || next.MappedColor != "" || next.MappedSize != "" || !next.SpecResolutionAllowed { + t.Fatalf("probe task was not dispatchable: %+v %v", next, err) } } @@ -509,9 +630,10 @@ func TestOrderUnknownIsNotAutomaticallyRedispatched(t *testing.T) { f := seed(t, db, liveCaps(), true) s := testService(db) task, _ := createLive(t, s, f) - s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) - started, _ := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) - s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) + started := startLivePurchaseAfterProbe(t, s, f, task) + if _, err := s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil { + t.Fatal(err) + } actual := int64(1999) if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{ RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown", @@ -527,7 +649,7 @@ func TestOrderUnknownIsNotAutomaticallyRedispatched(t *testing.T) { t.Fatalf("unknown task evidence not preserved: %+v", saved) } var attempt models.PurchaseTaskAttempt - if err := db.Where("task_id = ?", task.ID).First(&attempt).Error; err != nil { + if err := db.Where("task_id = ?", task.ID).Order("attempt_number DESC").First(&attempt).Error; err != nil { t.Fatal(err) } if attempt.ErrorCode == nil || *attempt.ErrorCode != CodeOrderTimeMissing || attempt.ErrorMessage == nil { @@ -544,9 +666,10 @@ func TestOrderUnknownRejectsOrderFieldsAndUnapprovedFailureCode(t *testing.T) { f := seed(t, db, liveCaps(), true) s := testService(db) task, _ := createLive(t, s, f) - s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) - started, _ := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) - s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) + started := startLivePurchaseAfterProbe(t, s, f, task) + if _, err := s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil { + t.Fatal(err) + } if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{ RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown",