feat(purchase): 强制真机探测规格后匹配 (#215)

This commit is contained in:
QiuSW
2026-09-04 19:03:16 +08:00
parent 683aa9c671
commit c09ddc8474
14 changed files with 281 additions and 241 deletions
@@ -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("支付") })
}
+15 -11
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -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)
+8 -8
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 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` 只允许管理员或采购员人工解除,永不自动重派。
@@ -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)
}
}
+5 -70
View File
@@ -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 {
@@ -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)
}
}
+18 -2
View File
@@ -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)
}
}
+24 -11
View File
@@ -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 {
@@ -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
}
+1 -2
View File
@@ -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
+14 -15
View File
@@ -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}
}
+3 -3
View File
@@ -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)
}
}
+8 -75
View File
@@ -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
}
+148 -25
View File
@@ -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",