Files
goauto/docs/08-agent-api-contract.md
T

1096 lines
99 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- gitea-wiki-mirror:start -->
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: 23160c055e8f6185a4108e284afc8216b41373e5
synchronized_at: 2026-09-10T03:37:18Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
本文件是服务端、Web 和 Android 的共享契约事实来源。字段可以在实施工单中补充,但语义变化必须同步更新本文档。
## 通用约定
- 正式环境默认只允许 HTTPS;管理员显式设置 `GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true` 时,全部 `/api/agent/v1/**` 也接受 HTTP。Android Debug/Release 均可配置 HTTP 或 HTTPS Origin。
- 每台设备使用独立 Bearer Token。
- 时间使用 RFC 3339 UTC,金额使用整数分。
- 所有写接口携带 `requestId` 并保证幂等。
- 错误响应包含稳定的 `code`、可读的 `message` 和 `retryable`。
## 管理端:PDD 商品
```http
POST /api/admin/v1/pdd-products
PATCH /api/admin/v1/pdd-products/{productId}
GET /api/admin/v1/pdd-products
GET /api/admin/v1/pdd-products/{productId}
```
新增请求只提交 `requestId` 和 `url`。服务端规范化 URL、提取 `goods_id`;无法提取时返回 `PDD_GOODS_ID_INVALID`,已存在时返回 `PDD_PRODUCT_EXISTS` 和现有商品 ID。编辑请求完整提交 URL、标题、店铺、数字销量/评价、状态和 `specs`;人工保存覆盖当前值,但不改变已有任务快照。
`specs` 是维度数组。维度包含非空 `name`、`role`(`color` / `size` / `other`)和非空 `values`;规格值包含 `name`、`selectable`,只有颜色值允许包含非负整数分 `priceCent`。列表支持 `keyword` 搜索 goods_id、标题或店铺,并支持 `status` 筛选。商品状态为 `pending`、`active`、`disabled`。列表项另返回 `collectionSelectable`、可选的 `collectionDisabledReason` 和 `activeCollectionTaskId`,供当前页批量选择;服务端提交时仍必须重新校验。
Android 提交结果的契约不增加字段:服务端在保存任务结果的同一事务更新 PDD 商品最新档案。`completed` 全量覆盖,`completed_partial` 合并明确获得的数据,`failed` 不更新;人工 `disabled` 状态不会被采集自动改回 `active`。
## 管理端:虾皮商品
```http
GET /api/admin/v1/shopee-products
POST /api/admin/v1/shopee-products
GET /api/admin/v1/shopee-products/{productId}
PATCH /api/admin/v1/shopee-products/{productId}
POST /api/admin/v1/shopee-products/{productId}/link-pdd
POST /api/admin/v1/shopee-products/{productId}/restore
POST /api/admin/v1/shopee-products/{productId}/specs/values
DELETE /api/admin/v1/shopee-products/{productId}/specs/values
PUT /api/admin/v1/shopee-products/{productId}/specs/mapping
DELETE /api/admin/v1/shopee-products/{productId}/specs/mapping
POST /api/admin/v1/shopee-products/{productId}/specs/mapping/confirm
POST /api/admin/v1/shopee-products/{productId}/specs/mapping/confirm-exact-matches
POST /api/admin/v1/shopee-products/{productId}/specs/mapping/preview-auto-size
POST /api/admin/v1/shopee-products/batch-delete
```
创建与关联/映射相关写操作均提交 `requestId` 做幂等重放;重放请求返回相同结果并标记 `replayed`。创建请求提交 `shopeeItemId`、`title`、`shopName`,可选 `pddProductId` 和 `specs`;`shopeeItemId` 重复时返回 `SHOPEE_ITEM_ID_EXISTS` 和已存在商品 ID。`link-pdd` 校验 PDD 商品存在且非 `disabled`,否则分别返回 `PDD_PRODUCT_NOT_FOUND` 或 `PDD_PRODUCT_DISABLED`;关联成功后返回值包含 `sharedByPddCount`,表示当前共用同一 PDD 商品的虾皮商品数。
`specs` 结构同 PDD 商品的维度/规格值形状,但规格值额外携带 `source`(`import` / `manual`)与可选的 `mapping`(`pddValue`、`source`、`status`、`confidence`、`reason`)。新增规格值固定为 `manual` 来源;删除规格值仅允许 `manual` 来源,`import` 来源返回 `SPEC_VALUE_NOT_MANUAL`。设置映射时,`exact_match` 与 `ai_match` 来源一律写入 `pending` 状态,与请求体中的 `status` 无关;只有 `manual` 来源可以直接写入 `confirmed`。`confirm-exact-matches` 仅确认 `source=exact_match` 且状态为 `pending` 的映射,不影响 `ai_match`。该通用入口不因 #188 改变;#188 仅通过下述采购批量规格匹配接口的独立写入路径,将唯一确定性 `exact_match` 或通过高置信度门槛的 `ai_match` 写入 `confirmed`。
`preview-auto-size` 是只读计算接口(使用 `POST` 触发计算,不写数据库),无需 `requestId`。它读取当前蝦皮尺码与关联 PDD 的可选尺码,只返回格式统一后唯一确定的匹配;响应含 `items[]`(`valueName`、可选 `pddValue`、`status=preserved|matched|pending`、`reason`)、`pddValues`、`matchedCount` 和 `pendingCount`。已确认且目标仍存在的映射标为 `preserved`;无唯一结果标为 `pending`。Admin 的显式“保存修改”仍通过既有设置/确认接口落库。
设置映射时,`pddValue` 必须是关联 PDD 商品同角色下当前可选的原始规格标签,否则返回 `INVALID_REQUEST`。PDD 重新采集后旧目标消失时,Admin 标记失效;采购预检与任务创建返回 `PURCHASE_SPEC_MAPPING_REQUIRED` 和“规格匹配已失效,请重新选择 PDD 规格”,不得把旧标签下发给 Agent。
`batch-delete` 提交 `requestId` 和 `ids`(1~500 个),逐条校验引用后返回每条的 `status`(`deleted` / `skipped`)与 `reason`;引用检查覆盖 SYB 明细(#41)与采购任务(#33/#34),两张表落地前恒不阻塞删除。`restore` 恢复一条已软删除商品,恢复后原有 PDD 关联与规格映射保持不变。列表接口 `status=deleted` 筛选已删除商品,默认只返回存活商品。
## 管理端:SYB 商品明细
```http
GET /api/admin/v1/syb-products
GET /api/admin/v1/syb-products/{productId}
POST /api/admin/v1/syb-products/import
GET /api/admin/v1/syb-products/sync-runs
GET /api/admin/v1/syb-products/sync-runs/{runId}
POST /api/admin/v1/syb-products/{productId}/reparse
POST /api/admin/v1/syb-products/reparse-batch
PATCH /api/admin/v1/syb-products/{productId}/correction
```
`import` 允许已认证的管理员(admin)和采购员(purchaser)调用(#236),仍须通过 Casbin 权限校验;其他角色返回 403。采购员的 POST 权限由既有启动权限对账写入,不需要新增数据库迁移。操作人取已认证 claims,不接受客户端冒名。请求体提交 `dateFrom`、`dateTo` 后创建持久化后台任务并立即以 `202` 返回 `runId` 和 `status=running`;关闭弹窗、刷新或离开页面不影响任务。没有启用店铺时必须在读取凭据、登录、验证码 OCR 和任意 SYB 网络请求之前返回 `422`。任意时刻只能有一条 `running` 记录,内存锁与数据库唯一执行槽共同阻止单进程和跨进程重复导入;冲突时返回正在执行任务的日期范围。
`sync-runs` 列表支持 `page`、`pageSize`、`status`、`dateFrom`、`dateTo`,详情返回日期范围、状态(`running` / `succeeded` / `partial_success` / `failed` / `interrupted`)、处理天数、货运单/明细/新增/覆盖数量、店铺准入与跳过数量、店铺筛选快照哈希、按店铺的 `accepted` / `skipped` 统计、操作人和起止时间。列表和详情对已登录角色只读开放。服务启动时遗留的 `running` 任务改为 `interrupted`;中途失败或中断已经写入的数据保留,重新导入仍按唯一键覆盖。
列表返回结构化字段(`orderCode`、`shopeeItemId`、`productTitle`、`targetColor`、`targetSize`、`quantity`、`unitPriceCent`、`imageUrl`、`parseStatus`、`parseNote`、`manuallyConfirmed`),不含原始 JSON;`keyword` 匹配订单号、虾皮商品ID 或商品标题,`parseStatus` 筛选 `success`/`uncertain`/`failed`。详情额外返回 `rawJson`(原始 `details[]` 元素,未做任何改写)。
`reparse` 与 `reparse-batch` 只读取已保存的 `rawJson` 重新执行解析规则,不请求 SYB 接口;请求体 `force` 为 `true` 时才覆盖 `manuallyConfirmed` 的行,默认跳过并在批量结果中标记 `skipped_manual`。批量结果逐条返回 `outcome`(`reparsed` / `skipped_manual` / `unchanged`)与解析状态变化,`ids` 中任何一个不存在都会使整个请求返回错误(与虾皮商品批量删除的"部分成功"语义不同:ID 不存在通常是操作员选错了页面)。
`correction` 提交 `targetColor`、`targetSize`,覆盖解析结果并标记 `manuallyConfirmed=true`;非空的人工修正与解析成功同等可信,会合并进已关联的虾皮商品档案。
## 管理端:SYB 店铺
```http
GET /api/admin/v1/syb-shops
POST /api/admin/v1/syb-shops
PATCH /api/admin/v1/syb-shops/{shopId}/name
PATCH /api/admin/v1/syb-shops/{shopId}/enabled
DELETE /api/admin/v1/syb-shops/{shopId}
POST /api/admin/v1/syb-shops/discover
```
列表支持 `keyword`、`enabledOnly`、`page` 和 `pageSize`,返回店铺原名、规范化名称、启用状态及分页汇总。写操作由 go-admin/Casbin 仅授权管理员;其他角色只读。删除为软删除,不清理已经导入的数据。
`discover` 提交日期范围,从 SYB 真实货运单列表发现店铺,返回 `shopName` 和 `status`(`new` / `exists`);发现只读,不自动新增。店铺比较固定执行首尾去空白、全角/半角统一和忽略大小写。同步先完成原始列表完整性校验,再按一次性启用店铺快照过滤;明细响应中的店铺名为空或不在快照内时不得写入。
## 管理端:采集规则
```http
POST /api/admin/v1/collection-rules
PATCH /api/admin/v1/collection-rules/{ruleId}
DELETE /api/admin/v1/collection-rules/{ruleId}
GET /api/admin/v1/collection-rules
GET /api/admin/v1/collection-rules/templates/pdd-product-detail-v1
```
规则只有 `name` 和 `content` 等当前值,创建成功后立即可用。删除为软删除;已删除规则不能创建新任务,已有任务继续使用任务内快照。
模板接口返回服务端内置的完整 v2 `content`,以及浏览器包名、别名数量/长度、超时、遍历上限和规格面板滑动动作的可编辑范围。管理端以安全表单修改这些参数,并显示只读 JSON 预览;模板中的采集器 ID、价格解析器、页面根证据、动作类型和语义目标不可在界面中改写。最终创建或更新请求仍由服务端按完整 v2 契约重新校验,不能依赖前端校验。
`content` 支持原有 `schemaVersion: 1` 线性步骤和 `schemaVersion: 2` 类型化采集器契约。v1 契约如下:
```json
{
"schemaVersion": 1,
"packageName": "com.xunmeng.pinduoduo",
"steps": [
{
"id": "extract-title",
"page": "product-detail",
"activityName": "com.xunmeng.pinduoduo.activity.NewPageActivity",
"action": "extract",
"selector": {"resourceId": "com.xunmeng.pinduoduo:id/title"},
"field": "title",
"timeoutMs": 5000
}
]
}
```
- `action` 只允许 `wait`、`click`、`input`、`back`、`extract`;除 `back` 外都必须有非空 `selector`。
- `selector` 可组合 `resourceId`、`text`、`contentDescription`、`className`、`clickable`,执行操作前必须唯一命中;不唯一时以 `RULE_AMBIGUOUS` 失败,不猜测目标。
- 点击步骤仍以唯一命中的节点作为语义证据;若文字节点本身不可点击,只允许沿其父链点击最近的 `clickable` 容器,不搜索兄弟节点或相近候选。父链不存在可点击容器时以 `RULE_ACTION_FAILED` 失败。
- 每一步可以用 `packageName` 覆盖根级默认值;只允许拼多多、受支持浏览器和 Android 系统选择器包。
- `activityName` 可声明该步骤允许执行的精确 Activity;商品详情页规则应同时校验 PDD 包名、目标 Activity 和唯一控件,避免登录页等同包页面误判为商品页。
- `input` 必须有 `value`,`extract` 必须有 `field`;单步 `timeoutMs` 范围为 100~30000 毫秒。
- `optional: true` 仅表示单步超时后继续,适合不同 ROM 可能不存在的浏览器/系统“打开”确认层;多匹配和动作执行失败仍立即失败。
- 规则解析阶段按用途校验文字:支付、付款、提交订单等可以出现在只读识别字段中,但不能成为点击目标;修改地址、收货地址在可配置点击目标和只读识别字段中都拒绝。完整控件树只在 Android 内存中用于匹配,不保存也不上传。
- 浏览器和 Android 系统包中的 `click` 只允许精确目标 `打开拼多多APP`、`打开拼多多 App` 或 `打开`;其它点击在规则解析阶段拒绝。Android 先以浏览器打开服务端已规范化的 PDD URL,再由这些受控步骤进入拼多多。
### v2 PDD 商品详情规则
v2 完整示例见 [PDD 商品详情规则](rules/pdd-product-detail-v2.proposed.json)。规则声明:
- `ruleType: pddProductDetail`。
- `navigation.steps`:只负责浏览器和系统确认层;不能点击 PDD 页面。
- `pageEvidence`:精确的 PDD 包名、Activity 和非空节点证据。
- `collector.collectorId: pddProductDetailV1`:引用 Agent 中经过测试的类型化能力,不下发可执行代码。
- `hooks.afterSpecPanelOpen`:固定阶段的安全动作;当前只允许对语义目标 `specPanel` 执行 `swipe`,方向为上下左右、单动作次数 1~5、等待 0~2000 毫秒,单阶段最多 8 个动作。
- `pageRecovery.transientSoldOut`:商品详情页出现规则配置的 `exactText`,或顶部区域出现 `fallbackTopText` 且缺少主商品规格/购买强证据时,允许对商品页执行一次有界恢复;内置默认值仍为“商品已售罄”与“相似商品”,默认下拉 2 次、间隔 1000 毫秒。推荐卡片自身的标题、价格和销量不属于主商品证据。`fallbackTopText` 缺省时 Agent 使用“相似商品”兼容旧快照。Agent 必须声明无障碍手势能力,且只把系统回调完成的下拉计为成功。规格面板内的 SKU 售罄不触发。恢复后仍异常时失败码为 `PDD_GOODS_SOLD_OUT`。
- `navigationRecovery.reopenBrowser`:首次跳转未通过精确商品详情页证据时,Agent 重新显式打开任务 `urlSnapshot` 并重放安全导航步骤;最多恢复 1 次,重开后等待 500~5000 毫秒。登录、验证码、风控、无效链接和进入详情页后的采集失败不触发。
- `collector.dimensionAliases`、`collector.textAliases`、`timeoutsMs` 和 `limits`:分别管理颜色/尺码分类别名、只读页面识别文案、超时和遍历/SKU 上限。`textAliases` 可选;旧规则快照缺省时 Agent 使用当时内置默认值。新规则可在 `specPanel`、`dimension`、`selection`、`review`、`soldOut`、`purchase` 六组中配置面板、维度、选择摘要、评价入口、主商品证据和购买入口文字;每个数组为 1~20 项、单项不超过 30 个 Unicode 字符、不得有首尾空白或重复项。维度分类只依据规则的 `dimensionAliases`,代码不再以“颜色/款式/尺码/尺寸”字面量兜底,因此规则可以真实收窄。订单和支付文字允许作为只读识别证据;`修改地址`、`收货地址` 始终拒绝。用于阻止创建订单、确认订单、支付和付款控件成为采集点击目标的拒绝清单固定保留在 Agent,不能由规则覆盖。
例如规格面板打开后向上滑动两次,只修改规则:
```json
{"hooks":{"afterSpecPanelOpen":[{"action":"swipe","target":"specPanel","direction":"up","count":2,"settleMs":350}]}}
```
Agent 使用可扩展的类型化动作注册表,而不是任意脚本。采集规则不能创建订单;采购规则可以引用单独审核的创建订单能力。当前契约不实现支付动作,`pay` / `payment` 动作仍被拒绝;订单和支付文字只允许作为只读识别信号,不能成为点击目标。
Android 的 `pddProductDetailV1` 采集器执行以下固定流程:
1. 以规则中的包名、精确 Activity 和节点选择器验证商品详情页,并持续识别登录、验证码、风控和失效商品页面。
2. 在商品页有限次纵向查找安全规格入口;规格面板必须同时具备规格维度和确认摘要或可滚动区域等强证据。
- 入口候选排除评价、评论、晒单和问答上下文,不使用全页面“规格词 + 任意数字”猜测;底部兜底必须命中真实购买/拼单文字及其可点击链路,且排除提交订单与支付语义。
- 商品评价页与详情页共用 `NewPageActivity` 时,首次误触允许全局返回、重新验证详情页并重新定位一次;第二次误触停止。点击无变化、误入评价页和面板证据不匹配分别返回 `SPEC_ENTRY_CLICK_NO_EFFECT`、`SPEC_ENTRY_OPENED_REVIEW`、`SPEC_PANEL_EVIDENCE_NOT_MATCHED`。
3. 规格面板以有界稳定读取恢复到顶部;横向颜色容器和纵向面板容器必须由已识别规格节点的祖先关系锁定,不能只按屏幕中最大滚动区域猜测。
4. 颜色先归左,再按视觉行执行左到右、右到左交替的蛇形遍历;每次点击都在最新无障碍树中重新定位唯一文字控件。点击后必须取得目标选中、已选摘要或规格面板选中状态/价格变化证据,等待短暂刷新后连续读取相同价格;其他颜色残留选中标记不能阻塞当前颜色,同价合法,但动作无效果时不能沿用点击前旧价格。结构化轨迹只记录短颜色标签、点击结果、证据类型、价格采样和 `selection_not_confirmed`、`price_not_found`、`price_not_stable` 原因。
5. 滚动优先调用目标容器的无障碍前进/后退动作;回退坐标手势时必须等待系统完成回调。视口签名包含规格文字和 bounds,连续稳定后才确认到边。
6. 尺码仅通过有限次纵向滑动读取,不点击尺码;首次定位后保存容器和选项结构,标题滚出屏幕后仍可续页。颜色价格展开到该颜色下的可用尺码 SKU。
7. 缺失颜色价格、尺码、第三规格维度或超过 SKU 上限时提交有界的 `completed_partial`;不猜测缺失值。采集期间离开商品页或出现验证码、登录、风控时明确失败。
无障碍节点只投影为 Agent 进程内的瞬时不可变模型,不序列化、不上传、不写入文件。完整状态机接入后 Agent 才上报 `collector.pdd.product-detail.v1`。
## 管理端:AI 规格匹配设置
```http
GET /api/admin/v1/ai-matching-settings
PUT /api/admin/v1/ai-matching-settings
POST /api/admin/v1/ai-matching-settings/test
```
这是采购规格匹配的单例配置,Provider 固定为 `openai_compatible`。管理员可读取、保存和测试连接;保存请求包含 `enabled`、`baseUrl`、`model`、`timeoutSeconds` 与 `apiKey`。`timeoutSeconds` 默认 15,允许范围为 3~600 秒。根据 #62 已确认的内部部署例外,管理员 `GET` 响应会返回已保存的明文 `apiKey`,供下次查看和替换;采购员 `GET` 只返回 `enabled`,不能读取 Provider、地址、模型或 API Key,也不能保存或测试。
Base URL 支持内网或公网的 `http://`、`https://`,不限制为局域网地址。服务端只在管理员点击测试或采购规格没有唯一确定性结果时,才向 Provider 发送目标颜色/尺码和 PDD 可选颜色/尺码;请求中不包含控件树、截图、地址、账号或订单。HTTP 不加密 API Key 的传输,生产环境建议使用 HTTPS。服务端禁止跟随 Provider 重定向,Provider 返回的颜色/尺码必须逐字等于当前候选原始标签,否则按无匹配处理。
## 管理端:采集任务
```http
POST /api/admin/v1/collection-tasks
POST /api/admin/v1/collection-tasks/batch
GET /api/admin/v1/collection-tasks
GET /api/admin/v1/collection-tasks/{taskId}
POST /api/admin/v1/collection-tasks/{taskId}/reset
DELETE /api/admin/v1/collection-tasks/{taskId}
```
创建请求:
```json
{
"requestId": "uuid",
"pddProductId": "product-id",
"ruleId": "rule-id",
"deviceId": "device-id-or-null"
}
```
服务端在事务中复制当前 `url`、`goods_id` 和完整规则内容到任务。同一商品已有 `pending` 或 `running` 任务时返回 `PDD_PRODUCT_TASK_ACTIVE`。
批量创建请求:
```json
{
"requestId": "batch-uuid",
"pddProductIds": [1, 2, 3],
"ruleId": 2,
"deviceId": null
}
```
`pddProductIds` 必须包含 1~100 个不重复的有效 ID。一个批次统一使用当前规则和可选设备;每个商品独立调用单任务创建事务并固化自己的 URL、goods_id、规则和设备快照。批次 `requestId` 与商品 ID 派生稳定的逐项创建标识,同一请求重放返回原任务,不重复创建。
HTTP 200 响应返回 `successCount`、`failureCount` 和按请求顺序排列的 `items`。成功项包含 `pddProductId`、`success: true`、`taskId` 和可选 `replayed`;失败项包含 `success: false`、稳定 `code` 和普通人可理解的 `message`。商品已停用返回 `PDD_PRODUCT_DISABLED`,活动任务冲突返回 `PDD_PRODUCT_TASK_ACTIVE`。逐项失败不回滚其它成功项;请求 JSON、批次 UUID、重复商品或数量上限无效时整体返回 `INVALID_REQUEST`。
重置只允许 `completed`、`completed_partial` 或 `failed`:
- 保留 `pddProductId`、`deviceId`、`urlSnapshot`、`goodsIdSnapshot`、`ruleId` 和 `ruleSnapshot`。
- 删除全部规格维度、规格值、SKU 和 SKU 值关联。
- 清空标题、店铺、销量、评价、错误与执行时间。
- 状态恢复为 `pending`。
`running` 禁止重置和删除。失败任务允许删除后重新创建。
`reset` 请求体和失败任务 `DELETE` 请求体均为 `{"requestId":"uuid"}`。只允许删除 `failed`;重置允许 `completed`、`completed_partial`、`failed`。二者均按 `requestId` 幂等。
## Android:注册与心跳
```http
POST /api/agent/v1/register
POST /api/agent/v1/heartbeat
```
客户端首次启动生成并持久化 `installId`,直接提交设备信息;服务端收到合法请求即创建或更新设备,不要求注册码、后台审核或人工确认。`installId` 在服务端唯一,硬件标识不能作为替代唯一键。响应返回 `deviceId`、Device Token 和心跳间隔,客户端安全保存 Token,服务端只保存其不可逆摘要。后续用 `installId` 保持同一安装实例的设备身份;心跳上报设备状态和 `currentTaskId`,服务端据此判断在线/空闲,但任务领取仍以数据库原子约束为准。
注册请求:
```json
{
"requestId": "uuid",
"installId": "uuid",
"name": "OPPO-PKG110",
"manufacturer": "OPPO",
"model": "PKG110",
"androidVersion": "16",
"agentVersion": "0.1.0",
"pddVersion": "7.72.0",
"capabilities": ["rule.schema.v2", "action.swipe.v1", "collector.pdd.product-detail.v1"]
}
```
- 新 `installId` 立即创建设备并返回一次 `deviceToken`;响应带 `Cache-Control: no-store`。
- 相同 `requestId` 重放只幂等返回原 `deviceId`,不再次返回 Token,也不覆盖设备信息。
- 已存在 `installId` 的新请求必须携带该设备的 Bearer Token,认证成功后幂等更新设备信息,不轮换 Token。
- 缺少或使用错误/已吊销 Token 返回 HTTP 409 和 `DEVICE_INSTALL_ID_CONFLICT`。
- 生产模式默认只接受 TLS。只有服务部署在可信反向代理之后并显式设置 `GOAUTO_TRUST_FORWARDED_PROTO=true` 时,服务端才接受代理的 `X-Forwarded-Proto: https`;不得在服务直接暴露公网时开启。管理员显式设置 `GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true` 后,注册、心跳、采集、采购、Agent 版本检查与下载等全部 `/api/agent/v1/**` 可直接使用 HTTP;Device Token、任务内容、设备状态和执行结果会以明文经过链路。注册接口仍有单实例、按直连来源 IP 的基础限流,网关仍须设置共享限流。该开关不影响管理端和第三方服务的 HTTPS 约束。
- `capabilities` 最多 32 项,使用小写的版本化能力名。旧 Agent 可以不提交该字段并继续执行 v1;v2 任务只能由包含规则所需全部能力的设备领取。
管理员设备动作:
```http
POST /api/admin/v1/devices/{deviceId}/disable
POST /api/admin/v1/devices/{deviceId}/token/revoke
```
两者都要求 go-admin 管理端认证与角色授权。吊销同时停用设备;重新启用和重新签发 Token 不在本工单范围。
心跳请求使用设备 Bearer Token:
```json
{
"requestId": "uuid",
"currentTaskId": "task-id-or-null",
"capabilities": ["rule.schema.v2", "action.swipe.v1"]
}
```
服务端校验 `currentTaskId` 必须与该设备唯一的执行中任务一致;执行中任务统一覆盖采集任务的 `running`,以及采购任务的 `running` / `order_submit_started`。不一致返回 HTTP 409 和 `DEVICE_TASK_MISMATCH`,不更新心跳。响应返回服务端确认的 `currentTaskId`、`online`、由两类任务统一派生的 `busy`、服务端时间和下一次心跳间隔。相同 `requestId` 重放不推进心跳时间;若数据库异常存在跨任务域同时运行,服务端返回内部一致性错误,不静默选择其中一条。
默认心跳间隔 15 秒、离线阈值 45 秒、扫描间隔 15 秒。超过阈值后设备标为 `offline`:运行中的采集任务和未进入订单提交边界的采购任务清除租约与运行 guard,转为 `failed`,对应采购 attempt 同步失败并结束,错误码为 `DEVICE_OFFLINE`,且不自动重试或换机。采购任务已处于 `order_submit_started` 时必须转为 `order_result_unknown`,释放设备/账号运行槽并等待人工核对,禁止自动重派或再次点击创建订单。设备重新发送合法心跳后可以恢复 `online`,但失败或结果未知的任务不会自动恢复。
## Android:获取、领取和开始任务
```http
GET /api/agent/v1/tasks/next
POST /api/agent/v1/tasks/{taskId}/claim
POST /api/agent/v1/tasks/{taskId}/start
```
`claim` 和 `start` 请求体都为 `{"requestId":"uuid"}`。相同 `requestId` 重放返回首次成功后的当前任务载荷,不延长租约、不增加租约版本。`next` 没有可用任务时返回 HTTP 204。
领取规则:
1. 指定设备的任务只能由指定设备领取。
2. `deviceId` 为空的任务可由在线且没有活动任务的设备领取。
3. `claim` 在一个数据库事务中设置设备和租约,竞争失败返回 `TASK_ALREADY_CLAIMED`。
4. 设备已有活动任务时返回 `DEVICE_BUSY`。
5. 指定设备和领取设备必须具备规则快照要求的全部能力;不兼容时返回 `DEVICE_CAPABILITY_MISMATCH`。未指定设备任务会跳过不兼容设备。
`claim` 保持任务为 `pending`,写入设备、两分钟领取租约和递增的 `leaseVersion`;`start` 只接受当前设备持有的有效租约,将状态原子改为 `running`、记录开始时间并续租。Android 进程还必须以本地互斥锁确保同一时刻只有一个任务进入执行器。
任务载荷包含:
```json
{
"taskId": "task-id",
"pddProductId": "product-id",
"urlSnapshot": "https://mobile.yangkeduo.com/goods.html?goods_id=123",
"goodsIdSnapshot": "123",
"ruleId": "rule-id",
"ruleSnapshot": {},
"timeoutSeconds": 120
}
```
规则已经软删除不影响载荷和执行。
## Android:提交结果
```http
POST /api/agent/v1/tasks/{taskId}/result
```
```json
{
"requestId": "uuid",
"status": "completed_partial",
"product": {
"pddGoodsId": "123",
"title": "商品标题",
"shopName": "店铺名",
"salesText": "已拼5872件",
"reviewCount": 64
},
"dimensions": [
{"key": "color", "name": "颜色", "values": ["黑色", "白色"]},
{"key": "size", "name": "尺码", "values": ["M", "L"]}
],
"colorPrices": [
{"color": "黑色", "priceCent": 1000},
{"color": "白色", "priceCent": 1200}
],
"skus": [
{"specs": {"color": "黑色", "size": "M"}, "priceCent": 1000, "available": true}
],
"missing": ["白色/L"]
}
```
服务端验证任务当前设备和状态后,在一个事务中更新 `collection_task` 结果字段并重建结果子表。终态任务拒绝不同内容的再次提交;相同 `requestId` 幂等返回原响应。
规格值规范化属于服务端最终契约防线:
- size 规格值只允许剥离位于文本尾部的 ¥ / ¥ 数字价格片段,括号说明、体重区间和其他规格身份文本原样保留;Android 与服务端使用相同语义。
- 服务端在结果完整性校验前执行规范化。规范化后为空、同维度重复、仍残留货币符号或无法由最小安全规则确定时,整维不接受,并原子丢弃引用该维度的 SKU;color 被拒时同时丢弃 colorPrices。
- 服务端强制将任务收敛为 completed_partial,并在 missing 增加稳定值 spec_dimension_invalid:<dimensionKey>,例如 spec_dimension_invalid:size。客户端应把它展示为对应规格维度无法安全采集,不得视为完整档案,也不得自行猜测或补齐候选。
- 被拒维度不得以污染值覆盖商品已有档案;历史任务和既有商品数据不自动清洗,须在修复后的 Agent 上重新采集。
## Android:提交失败
```http
POST /api/agent/v1/tasks/{taskId}/fail
```
| 错误码 | 含义 | 默认重试 |
|---|---|---|
| `DEVICE_OFFLINE` | 执行中连接中断 | 否 |
| `ACCESSIBILITY_NOT_READY` | 无障碍未绑定 | 否 |
| `PDD_LOGIN_REQUIRED` | PDD 登录失效 | 否 |
| `PDD_CAPTCHA_REQUIRED` | 验证码或人机验证 | 否 |
| `PDD_RISK_CONTROL` | 风控页面 | 否 |
| `PDD_LINK_INVALID` | 商品链接无效、商品不存在或已下架 | 否 |
| `PDD_GOODS_SOLD_OUT` | 商品页一次性下拉恢复后仍显示商品已售罄,或恢复动作失败 | 否 |
| `PDD_DETAIL_ENTRY_FAILED` | 一次性重开浏览器后仍未进入精确 PDD 商品详情页 | 否 |
| `RULE_NOT_MATCHED` | 找不到唯一控件或页面 | 否 |
| `SPEC_ENTRY_CLICK_NO_EFFECT` | 规格入口点击后页面没有变化 | 否 |
| `SPEC_ENTRY_OPENED_REVIEW` | 规格入口误进入商品评价页且受控恢复失败 | 否 |
| `SPEC_PANEL_EVIDENCE_NOT_MATCHED` | 页面发生变化,但规格面板强证据不足 | 否 |
| `RULE_AMBIGUOUS` | 规则同时匹配多个控件 | 否 |
| `TASK_ALREADY_CLAIMED` | 未指定任务已被其他设备领取 | 否 |
| `DEVICE_BUSY` | 设备已有活动任务 | 否 |
| `DEVICE_CAPABILITY_MISMATCH` | 设备缺少任务规则要求的版本化能力 | 否 |
## 采购任务共享契约(#33、#34、#36、#42、#44、#53、#67、#164、#166)
本节固定采购域的数据和接口边界;`purchase_task`、`purchase_task_attempt`、规则校验、服务端 HTTP 状态机、Admin 已有任务管理、SYB 创建入口、Android 演练执行及 #36 正式地址/订单动作已经落地。#36 尚未获得真机创建订单授权和验收。当前项目没有自动支付动作、入口或测试;后续支付能力必须另行建单并通过显式能力位、服务端开关、金额上限和人工授权门禁。
### 任务与快照
- `executionMode` 只能是 `rehearsal` 或 `live`,创建后不可修改。
- 正式 `live` 任务必须引用一条 `syb_product`;无副作用的 `rehearsal` 可以不引用 SYB。
- 创建时固化 SYB、蝦皮订单号、虾皮商品、PDD 商品、目标规格、数量、价格区间、币种、URL、`goods_id`、规则和可选 PDD 账号引用。新建 SYB 任务的执行映射初始为空,首趟当次 PDD 规格探测决策成功后才固化 `mappedColor` / `mappedSize`;正式任务的 `shopeeOrderNoSnapshot` 来自创建时的 `syb_product.order_code`,之后来源商品修改不会改写任务快照。
- Android 无法可靠读取当前 PDD 账号,因此 `pddAccountId` 和 `pddAccountRefSnapshot` 均可空。已知账号时参与账号级串行,未知时不阻止任务。
- 地址后缀由任务 ID 唯一确定为 `_cg{taskId}`;#34 创建任务时必须在同一事务内回写快照。
- 一个任务最多对应一个 PDD 订单;重新采购必须新建任务,旧任务和旧订单保留。
状态集合:
| 状态 | 含义 | 是否占用 SYB 活动槽 |
|---|---|---|
| `pending` | 待执行 | 是 |
| `spec_probe_pending` | 第一趟探测结束,服务端规格匹配中;当前设备保留同任务连续流程且不得领取其他任务 | 是 |
| `running` | Agent 执行中 | 是 |
| `rehearsal_completed` | 演练安全结束,未改地址、未创建订单 | 否 |
| `order_submit_started` | 不可逆标记已落库,只能核单,禁止再次点击 | 是 |
| `order_created` | 已取得唯一 PDD 订单号和下单时间 | 是 |
| `order_result_unknown` | 无法确认是否下单,必须人工处理 | 是 |
| `failed` / `cancelled` | 终态 | 否 |
同一个 `sybProductId` 最多一个占用活动槽的任务;一个设备最多一个执行中的采购任务;已知的同一个 PDD 账号最多一个执行中的采购任务。终态历史不删除。
`purchase_task.task_type` 固定为 `syb_order` 或 `stock`,旧记录迁移为 `syb_order`。`stock` 的 `sybProductId`、`shopeeProductId` 及对应身份快照为空,颜色和尺码必须逐字命中当前 `active` PDD 档案中的可选值,`specSource=direct_select`;同商品的多个活动备货任务可以共存,但执行期仍受设备和可选账号互斥约束。`stock` 不进入 SYB 批量预检/批量重试、Agent 重试、商品替换、重新采购授权或 SYB 回填;失败后返回“重新创建备货采购”的可读提示。
### 规则快照与能力
采购规则是 JSON 对象。#53 在既有 schema v1 中增加受限参数,不引入任意脚本:
```json
{
"schemaVersion": 1,
"ruleType": "pddPurchase",
"requiredCapabilities": ["purchase.rehearsal.v1"],
"actions": [
{
"type": "openProduct",
"textAliases": ["打开拼多多APP", "打开"],
"waitAfterMs": 1000
},
{
"type": "openSpecPanel",
"textAliases": ["选择规格"],
"swipeAfter": {
"direction": "up",
"count": 2,
"durationMs": 500,
"intervalMs": 1000
}
},
{
"type": "probeSpecs",
"dimensionAliases": {
"color": ["颜色分类", "颜色", "款式", "颜色款式", "花色", "组合"],
"size": ["尺码", "尺寸", "套餐", "参考分类"]
}
},
{"type": "selectSpec"},
{"type": "setQuantity"},
{"type": "verifyUnitPrice"},
{"type": "verifyOrderSummary"}
]
}
```
安全动作参数矩阵:
| action | `textAliases` | `waitAfterMs` | `swipeAfter` | `dimensionAliases` |
|---|---:|---:|---:|---:|
| `openProduct` | 是 | 是 | 是 | 否 |
| `verifyProduct` | 是 | 是 | 否 | 否 |
| `openSpecPanel` | 是 | 是 | 兼容读取、不执行(0.9.60+,见 #238) | 否 |
| `selectSpec` | 是 | 是 | 是 | 否 |
| `setQuantity` | 是 | 是 | 否 | 否 |
| `verifyUnitPrice` | 是 | 是 | 否 | 否 |
| `verifyOrderSummary` | 是 | 是 | 否 | 否 |
| `probeSpecs` | 是 | 是 | 是 | 是 |
参数规则:
- `dimensionAliases` 仅允许配置在 `probeSpecs`,并同时提供 `color` 与 `size`;每组包含 1~20 个互不重复、无首尾空白的非空文字,每项最多 30 个 Unicode 码点。它只用于只读规格维度探测,不参与 `selectSpec` 点击;旧规则快照缺省时使用 Agent 内置兼容别名。
- `textAliases` 省略时使用 Agent 对该类型化动作的内置语义目标;显式提供时必须包含 1~16 个互不重复、无首尾空白的非空文字,每项最多 64 个字符。
- 文字候选按控件文字或内容描述**精确匹配**;候选合并后必须唯一命中。点击动作只允许点击唯一文字节点或其最近的可点击父容器,不允许模糊匹配、猜测相近候选、改点兄弟节点。
- 动作 `textAliases` 不能包含地址修改、创建/提交订单、订单号或支付相关文字,防止用安全 action 绕过危险动作类型和能力门禁。只读识别字段使用独立校验:允许订单和支付证据,仍拒绝修改地址、收货地址,并沿用各字段声明的数量、长度、去重和空白限制。
- `waitAfterMs` 表示动作成功后的等待时间,范围为 0~30000 毫秒;省略时为 0。
- `swipeAfter` 通常表示动作成功后执行一个有限滑动计划;Android 0.9.60+ 的 `openSpecPanel` 例外,只兼容读取而不执行预滑动(见 #238)。`direction` 只能为 `up` / `down` / `left` / `right`,`count` 为 1~10,`durationMs` 为 100~2000,`intervalMs` 为 0~5000 且省略时为 0。
- 未在矩阵中授权的 action/参数组合、未知字段、空候选和越界值一律拒绝。`updateShippingAddress`、`createOrder`、`readOrderResult` 等正式动作在其独立高风险契约完成前不接受上述参数。
- 旧的仅含 `actions[].type` 的规则继续有效:候选使用 Agent 内置语义,等待为 0,不执行动作后滑动。
- 服务端保存创建任务时收到的完整原始规则快照;规则后来更新为规则 B,不会改变已有任务中的规则 A 快照。
演练规则必须包含 `purchase.rehearsal.v1`,并且不能包含 `updateShippingAddress`、`createOrder`、`readOrderResult`。正式规则必须包含 `purchase.live.v1`;改地址和创建订单还分别要求 `purchase.address-update.v1`、`purchase.order-create.v1`。`probeSpecs` 要求 `purchase.spec-probe.v1`。任意模式下,尚未实现的 `pay`、名称包含 `payment` 的动作以及未知动作一律拒绝;服务端不下发任意脚本。
### Android 规格动作执行边界(#214)
本工单不改变采购规则 schema、Agent HTTP 字段或能力标识,只收紧 Android 对 `openSpecPanel` 与 `selectSpec` 的执行语义:
- 每次操作从最新 `rootInActiveWindow` 重新定位;规格入口必须是解析器已接受的唯一安全候选,规格值必须逐字等于任务固化的服务端映射值且在当前维度唯一、可用。
- 动作前先检查后置条件;规格已精确选中时直接成功,不重复点击。否则最多分发一次 `ACTION_CLICK`,再从新快照验证规格面板强证据或精确选中证据。
- `ACTION_CLICK` 后页面结构完全无变化时,Android 可以重新定位同一唯一目标,并在目标可见、启用、中心点位于屏幕内且边界有效时执行一次中心手势。手势后仍未取得后置证据时明确失败,不继续点击。
- 点击后页面已变化但面板分类仍为未知时返回 `PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED`;两种点击后页面均无变化时返回 `PURCHASE_SPEC_ENTRY_CLICK_NO_EFFECT`。诊断只携带面板类型、滚动容器数、标题数、选项数及证据布尔值,不携带节点文字或完整控件树。
- 有界查找规格时只使用当前解析结果中的唯一 `specPanelContainer`;每次滚动前重新定位该容器,优先使用节点滚动动作,失败后才在容器边界内使用手势。缺少唯一容器时停止,不回退到全页面最大滚动区域。
- 受控中心手势接口固定拒绝地址、保存地址、创建/提交/确认订单、确认购买和支付/付款语义;`updateShippingAddress`、`createOrder`、`readOrderResult` 不使用该兜底。创建订单的一次性不可逆状态机与禁止支付契约不变。
### 管理端接口
| 方法 | 路径 | 幂等键 / 说明 |
|---|---|---|
| `GET` | `/api/admin/v1/purchase-tasks` | 分页列表;可按任务 ID、蝦皮订单号 `shopeeOrderNo`、状态、模式、SYB 商品 ID 和 PDD 订单号筛选 |
| `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`,不创建任务且不调用 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` | 一次性授权;创建新任务后自动消耗 |
| `POST` | `/api/admin/v1/purchase-tasks/{taskId}/payment-review` | `paid` 或 `unpaid`,管理员和采购员可操作 |
| `POST` | `/api/admin/v1/purchase-tasks/{taskId}/writeback-candidate` | 人工选择回填候选;后选的已支付订单覆盖同一 SYB 明细的旧选择 |
| `POST` | `/api/admin/v1/purchase-tasks/{taskId}/cancel` | 人工取消;执行中和已开始提交订单时禁止直接取消 |
| `POST` | `/api/admin/v1/purchase-tasks/{taskId}/resolve-unknown` | 人工把结果未知解除为已创建订单或已取消 |
| `POST` | `/api/admin/v1/purchase-tasks/{taskId}/spec-decision` | 固化第一趟规格探测决策;同一 attempt 不可改写 |
Admin 列表与详情由 #35 实现;#67 增加 `shopeeOrderNoSnapshot` 的列表/详情返回和 `shopeeOrderNo` 筛选,空快照返回空字符串并由页面显示“—”,不与 `pddOrderNo` 混用;#44 在 SYB 商品列表提供单条/当前页批量创建入口。页面预检只负责提前解释,`batch` 提交时仍逐条执行现有单任务事务和活动任务、订单、支付、设备能力门禁;返回项包含任务号,或失败原因与下一步建议。只有管理员和采购员可以调用。
创建请求的价格保护使用整数分:`referenceUnitPriceCent`、`minUnitPriceCent`、`maxUnitPriceCent` 和 `currency`。执行时以 PDD App 实际单价校验;低于最小值或高于最大值均返回普通人可理解的价格越界错误,不考虑优惠券,不以订单总价替代单价判断。批量创建不接受浏览器提交价格:服务端读取当前采购规则的可选 `priceGuard`,`minRatio` 允许 0.1~1.0,`maxRatio` 允许 1.0~3.0 且前者不得大于后者;字段缺省时使用 0.2 / 1.5。最低边界按“最低颜色价 × minRatio”向下取整,最高边界按“最高颜色价 × maxRatio”向上取整,均使用定点整数计算以保持旧默认逐分一致;没有可用颜色价格时逐条拒绝。SYB/Shopee 的 TWD 售价不参与 PDD 采购价格保护。
### Android 接口(由 #34 实现)
| 方法 | 路径 | 说明 |
|---|---|---|
| `GET` | `/api/agent/v1/purchase-tasks/next` | 优先返回当前设备的运行任务;存在 `spec_probe_pending` 时返回同一等待任务以阻止其他任务插队,否则返回能力兼容的指定任务或空闲任务 |
| `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` | 创建订单前先落不可逆标记;演练任务和 `spec_probe` attempt 永远拒绝 |
| `POST` | `/api/agent/v1/purchase-tasks/{taskId}/result` | 请求体携带 `taskAttemptId` 和 `requestId`;幂等提交演练、规格探测、订单或失败结果 |
自 #215 起,新建 SYB 采购任务不再从 PDD 档案创建持久匹配工作项,也不在首次派发前调用外部 AI;部署前已存在的 `purchase_spec_match_work_item` 继续按原状态兼容处理。新任务首次 `start` 固定得到 `phase=spec_probe`,Android 通过浏览器打开任务链接一次并经既有结果字段回传当次候选;匹配成功后的第二次 `start` 才得到 `phase=purchase` 和服务端固化的精确 PDD 原始标签。连续第二阶段仅在当前 PDD 页面仍有商品页或规格面板强证据时复用首趟页面、不再次打开浏览器链接;手动同任务重试,或当前为 Agent、其他应用及缺少上述强证据时,Android 重新打开任务 `urlSnapshot`。两种路径都不以标题、goodsId 或页面指纹做严格同页校验,仍必须通过 PDD 包名和商品/规格/订单页面结构安全证据。Android 不接收 AI 配置或自由决策权限。
结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。每个新 SYB 采购任务的第一趟只读遍历当次 PDD 规格面板并提交颜色、尺码原始候选,随后释放数据库租约和已知账号运行守卫并进入 `spec_probe_pending`,但服务端调度与 Agent 必须把当前设备保留给同一采购流程:`next` 返回该等待任务,Agent 只轮询等待,不领取其他采购或采集任务。服务端只以任务冻结的 SYB 目标和当次候选先做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。AI 的颜色和尺码必须逐字属于当次对应候选,否则按无匹配失败。第二趟只会收到服务端固化的精确 PDD 原始标签;Agent 复用首趟仍打开的页面,只在已打开的规格面板内做有限纵向滑动,每次重新读取节点并按完整规范化文字精确点击,连续没有新证据或达到上限即停止。尺码的任务目标与页面值在选择边界使用同一安全尾价规范化;不改写任务快照,规范化为空、仍含货币符号或多个原始候选折叠为同一值时安全失败。
任务 payload 的必传布尔字段 `specResolutionAllowed` 是 Android 是否可以提交规格探测的唯一资格事实。新建 `taskType=syb_order` 任务必须由声明 `purchase.spec-probe.v1` 的规则创建,初始 `SpecDecisionRequestID` 为空且 `specSource=unresolved`,首趟返回 `true`;当次决策固化后返回 `false`。`stock`、`direct_select`、已固化规格决策、能力缺失及其他组合均返回 `false`。普通 Agent 重试使用就地 `/reset`,保留 `SpecDecisionRequestID`、目标规格、映射规格和规格决策快照,不能恢复探测资格;映射不完整时必须拒绝,不能进入正式采购阶段。只有替代商品“继续采购”或 Admin 批量重试创建的新任务才按 #215 重新取得一次探测资格。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` 只允许管理员或采购员人工解除,永不自动重派。
`openSpecPanel.textAliases` 是可选的候选过滤条件,不是原始页面文本选择器。省略该字段时,Agent 使用语义安全的规格入口或底部购买入口;提供时也只能与这些安全候选取交集,匹配不到即返回 `PURCHASE_SPEC_ENTRY_NOT_FOUND`。
底部购买入口同时保留语义文字锚点和已经验证的最近可点击卡片。评价文字出现在锚点、卡片、祖先或后代上下文时拒绝候选;fresh 点击必须绑定该卡片,卡片由可点击变为不可点击时按节点失效处理,不得继续向更宽父容器漂移。点击后只有识别到规格面板才算成功;若进入具有顶部评价标题及至少两项评价页信号的评价列表,Agent 最多执行一次系统返回,确认回到 PDD 商品页后以 `PURCHASE_SPEC_ENTRY_OPENED_REVIEW / returned_to_product` 失败并停止,不得自动再次点击同一入口。返回失败或无法确认分别使用稳定子原因 `back_failed` / `return_unconfirmed`。入口诊断不保存文字、坐标、控件树或截图。
Android #42/#36 使用本地 SQLite 保存恢复与重传所需的任务、attempt 和 Outbox;最终结果和 Outbox 在同一事务写入。演练执行器仍在安全点停止。Agent 0.2.0 另外声明 `purchase.live.v1`、`purchase.address-update.v1` 和 `purchase.order-create.v1`,仅在 `live` 规则同时包含受限 `updateShippingAddress`、`createOrder`、`readOrderResult` 动作时启用。
正式地址动作在已经选好规格、数量的当前规格/下单面板内执行,不额外点击进入确认页。若脱敏手机号地址入口被裁切,Agent 只在唯一、可见且占据主要屏幕宽度的纵向面板容器内执行有限向下拉动,每次重新读取页面;不得退化为选择页面最大滚动区域。地址入口通过唯一脱敏手机号文字节点确认并在每次动作前重新定位;即使文字节点不可点击,也只允许在该文字节点中心执行一次精确手势,禁止向上查找并点击可能覆盖整个下单面板的宽泛可点击祖先。近乎完全重叠无障碍重复节点合并为一个目标,多个独立目标、多个候选面板、滚动无进展或页面切换超时均以分阶段错误结束并禁止创建订单。入口出现后,Agent 精确选择唯一修改按钮和详细地址输入框;“修改”“保存”和“提交订单”等文字节点即使要沿父链执行点击,也始终保留原始文字、类名和位置作为新鲜定位锚点,可点击父节点只用于验证和最终动作,禁止用无文字父节点替换锚点。按首个 `-` 或 `_` 截断旧后缀,追加任务 `addressSuffix`,保存并回读完整新地址。地址全文只在本次执行内存中存在,不进入本地最终确认快照、日志或服务端。
地址编辑页存在多个输入框时,以“详细地址”标签为结构锚点,只接受与标签纵向相交且位于标签右侧的唯一、可见、启用、非空输入框;不能要求页面只有一个输入框,也不能按输入框顺序猜测。
创建订单前先在本地事务保存 `order_submit_started`、不可逆时间、稳定的 `orderSubmitRequestId` 和脱敏最终确认快照,再调用服务端同名接口;只有两侧标记完成并重新校验商品、规格、数量、单价、地址后缀、PDD 包/Activity 与唯一创建订单按钮后,才点击一次。重启时重放同一标记请求并只读核单;无法取得唯一未付款订单号和 PDD 下单时间时提交 `order_result_unknown`。支付文字仅用于识别未付款/离开支付页,永不点击。服务端数据库仍是最终事实来源;双方均不保存原始控件树、截图、PDD 凭据或完整收货地址。
创建订单后若出现 Android 多微信应用选择器,只读核单器必须同时确认前台包为 `android` / `com.android.intentresolver`、Activity 为白名单 `ChooserActivity` / `ResolverActivity`、页面出现已知系统选择器标题且至少一个候选以“微信”开头,才允许执行一次系统返回;不得点击任何微信候选。若前台已经是精确微信包 `com.tencent.mm`,只读核单器不得点击、输入、登录、支付或强制停止微信,只允许执行一次无参数 PDD 启动 Intent 并等待既有 PDD 任务栈回到前台;`startActivity()` 成功只表示恢复请求已发起。请求后在固定最多 15 次、每次 200ms 的宽限期内允许微信或空窗口短暂残留,不执行点击、返回、输入或滑动;观察到 PDD 后结束宽限,宽限超时、已经观察到 PDD 后再次进入微信或出现稳定未知应用时返回未知结果。随后若前台为 PDD `com.xunmeng.pinduoduo.app_pay.core.PayActivity` 或当前页面出现支付动作文字,最多再返回一次。返回后只读解析唯一订单号和下单时间;选择器或支付页重复出现、白名单不成立、恢复动作重复、无法到达订单详情、结果不唯一或超时均返回未知结果,禁止再次点击创建订单、取消订单或支付。
Agent 主动提交 `order_result_unknown` 时保持订单号和下单时间为空,但允许携带创建订单前已经严格验证的 `actualUnitPriceCent`。Agent 同时提交脱敏稳定失败阶段,服务端只接受白名单并按错误码写入固定提示,不信任或保存页面原文;阶段覆盖空窗口超时、选择器返回失败、微信恢复失败/超时、未知应用、支付页返回失败/重复、订单上下文缺失、订单号缺失/歧义、下单时间缺失/无效和待付款证据缺失。任务与 attempt 保存同一错误阶段,Admin 和 Agent 历史读取数据库最终事实;旧 Agent 未提交阶段时归一为 `PURCHASE_ORDER_RESULT_UNKNOWN`。
#241 追加:全局订单号唯一性校验在人工处理结果未知、取消及 lifecycle 保存路径返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`(HTTP 409、`retryable=false`),提示“订单号已属于任务 CG-任务ID”;批量回填继续使用原有 `PURCHASE_BACKFILL_ORDER_ALREADY_USED`。不可逆边界后的 `order_created` 结果回传为例外:发现该号已属于其他任务时成功受理结果,将 `order_submit_started` 降级为 `order_result_unknown`,不回滚或自动重派。冲突订单号不写入 `pdd_order_no`,而以“读到订单号 X,但该号已属于任务 CG-yy”保存到任务和 attempt 的 `error_message`,两者 `error_code` 均为 `PURCHASE_ORDER_NUMBER_ALREADY_USED`;保留下单时间、不可逆时间及实际单价。attempt 以 failed 结束并保留原始 `order_created` 结果类型、请求 ID 和摘要,重复提交按原幂等协议返回;任务释放租约和运行槽,进入既有人工处理结果未知通道,权限不变。
| 错误码 | 普通提示 |
|---|---|
| `PURCHASE_MODE_NOT_ALLOWED` | 当前任务模式不允许执行此操作 |
| `PURCHASE_RULE_INVALID` | 采购规则不可用,请联系管理员 |
| `AGENT_CAPABILITY_MISMATCH` | 当前手机版本不支持这个任务 |
| `PURCHASE_SPEC_NOT_MATCHED` | 没有找到可用的商品规格 |
| `PURCHASE_SPEC_TARGET_NOT_VISIBLE` | 有界搜索后没有看到完整精确规格 |
| `PURCHASE_SPEC_TARGET_AMBIGUOUS` | 同一维度存在多个严格等价候选 |
| `PURCHASE_SPEC_SAFE_TARGET_MISSING` | 目标可见但无法确定安全且唯一的点击目标 |
| `PURCHASE_SPEC_CLICK_FAILED` | 精确目标点击失败;`errorMessage` 为稳定子原因 |
| `PURCHASE_SPEC_SELECTION_UNCONFIRMED` | 点击成功但无法确认精确选中状态 |
| `PURCHASE_SPEC_REPROBE_REJECTED` | 规格探测资格已使用,重复探测被拒绝 |
| `PURCHASE_SPEC_ENTRY_NOT_FOUND` | 没有找到安全且唯一的商品规格入口 |
| `PURCHASE_SPEC_ENTRY_TARGET_AMBIGUOUS` | 规格入口候选不唯一 |
| `PURCHASE_SPEC_ENTRY_CLICK_FAILED` | 规格入口 fresh 点击失败;`errorMessage` 为稳定子原因 |
| `PURCHASE_SPEC_ENTRY_OPENED_REVIEW` | 规格入口误入评价页;`errorMessage` 记录一次安全返回结果 |
| `PURCHASE_SPEC_PANEL_NOT_OPENED` | 点击后未识别到商品规格面板 |
| `PURCHASE_PRICE_OUT_OF_RANGE` | 当前商品单价超出允许范围 |
| `PURCHASE_ADDRESS_UPDATE_FAILED` | 收货地址修改失败,未创建订单 |
| `PURCHASE_ORDER_RESULT_UNKNOWN` | 无法确认订单是否创建,请人工检查 |
| `PURCHASE_PAYMENT_FORBIDDEN` | 系统禁止自动付款 |
## Agent 当前设备任务历史(#90)
四个只读接口统一使用设备注册所得的 Device Token,只返回该 Token 对应设备最近 30 天内的任务;设备 A 查询设备 B 的任务时按不存在处理,不泄露任务是否存在。
```http
GET /api/agent/v1/collection-tasks?page=1&pageSize=20&status=failed&taskNo=%2335
GET /api/agent/v1/collection-tasks/{taskId}
GET /api/agent/v1/purchase-tasks?page=1&pageSize=20&status=failed&taskNo=CG-12
GET /api/agent/v1/purchase-tasks/{taskId}
```
- `pageSize` 最大为 50;`status` 与 `taskNo` 可以组合过滤。
- 采集任务编号允许 `35` 或 `#35`,采购任务编号允许 `12` 或不区分大小写的 `CG-12`;服务端按精确编号匹配。
- 列表和详情不返回 Device Token、URL、收货地址、规则快照或原始控件树。采购项额外返回服务端计算的 `retryable` 和可选 `retryDisabledReason`;除受控失败重试外,不提供取消、修改既有订单或支付入口。
- 采集摘要返回当前 `attemptNumber`;采集详情返回标题、店铺、销量、评价数、规格维度、颜色价格、SKU、缺失项、结构化错误,以及不含规则快照的历史 attempt 序号、状态、规则 ID、错误和时间摘要。详情另返回 `colorImages` 数组,元素为 `{color,imagePath,width,height}`,只包含 `source_task_id` 等于当前任务的颜色图片并按颜色排序;无图时必须为 `[]`。`imagePath` 是既有 `/static/uploadfile/goauto-color/` 相对路径,Agent 使用已配置服务端 Origin 加载,加载失败不得影响其他详情字段。
- 采购列表和详情分别返回任务目标规格 `targetColor` / `targetSize` 与最终执行规格 `mappedColor` / `mappedSize`。Agent 界面必须分开展示;自动化只执行服务端下发的 `mapped*` 精确规格,映射为空时仍可查看原始目标,不得以目标值替代执行值。详情另返回蝦皮订单号、PDD 商品、数量、实际单价、PDD 订单号、下单时间和结构化错误。
- Agent 提交采购结果时可携带 `actualUnitPriceCent`(人民币分,非负)。服务端只保存 Agent 实际观察到的值;历史任务或未观察到价格的结果保持 `null`,客户端显示“未记录”。
## Agent 受控重新采集(#91)
```http
POST /api/agent/v1/collection-tasks/{taskId}/reset
Authorization: Bearer <device-token>
Content-Type: application/json
{"requestId":"<uuid>"}
```
- 只允许任务原关联设备使用自身 Device Token 重采:所有来源的 `failed` 任务均可重采;Admin 来源继续允许 `completed`、`completed_partial` 重置,Agent 当前页来源只允许失败后重采。跨设备统一返回 `TASK_NOT_FOUND`。
- `requestId` 必须为 UUID。同一请求重复提交不会重复归档、递增 attempt 或清空结果,响应中的 `replayed` 为 `true`。
- 服务端在事务内锁定设备和任务,重新校验设备在线、同商品无其他 `pending/running` 采集任务,并确认该设备没有正在执行或持有有效租约的其他采集/采购任务。
- 成功时复用原 `collection_task.id`,把旧终态执行归档到 `collection_task_attempt`,将 `attemptNumber` 加一,再清理主表当前结果/错误/租约并恢复为 `pending`;不会新建采集任务。
- 新 attempt 使用最新有效规则:Admin 来源读取原 `ruleId` 的当前存活内容,Agent 当前页来源读取当前手动采集默认规则。规则不存在、不可用或与设备能力不兼容时拒绝且不修改任务。
- 当前页任务尚未识别商品时允许下一 attempt 首次绑定;已有商品身份时重新识别必须逐字命中原 goods_id,不同商品返回身份冲突。
- 响应返回 `taskId`、`attemptNumber`、`status` 和可选的 `replayed`,不返回规则快照、URL、Token、控件树或截图。
- 设备离线、任务非终态、设备忙、规则不可用或同商品存在活动任务时返回明确冲突,不支持离线排队。
## Agent 受控采购重试(#95、#157、#217、#225)
替代商品匹配完成后的“继续采购”调用新任务接口;普通失败任务的“重试采购”使用下方同任务重置接口:
```http
POST /api/agent/v1/purchase-tasks/{taskId}/retry
Authorization: Bearer <device-token>
Content-Type: application/json
{"requestId":"<uuid>"}
```
成功响应:
```json
{
"data": {
"sourceTaskId": 12,
"sourceTaskNo": "CG-12",
"taskId": 13,
"taskNo": "CG-13",
"replayed": false
}
}
```
- 来源任务必须属于当前 Device Token、在最近 30 天内、状态为 `failed`,且是分配给该设备的正式 SYB 采购任务;跨设备或超期按任务不存在处理。
- 来源任务不得存在 `irreversibleAt`、`orderSubmitRequestId`、PDD 订单号或下单时间。已有任何不可逆证据时返回 `PURCHASE_RETRY_UNSAFE`,提示走“授权重新采购”,不得创建新任务。
- `AgentRetry → BatchRetry → Create` 保留来源失败任务并创建不同 `purchase_task.id` 的新任务;新任务重新读取当前 SYB、虾皮/PDD 档案、当前采购规则、价格保护和设备能力,重新生成地址后缀,不继承来源任务的旧规格决策。
- 新 SYB 任务按 #215 固定从 `spec_probe` 开始。相同 `requestId` 重放返回同一新任务且 `replayed=true`;不同 requestId 再次请求受同一 SYB 商品最新任务和设备并发门禁约束。
- 本新任务接口只供替代商品“继续采购”;确认和成功反馈必须说明保留来源任务、按当前替代商品档案创建新任务、可能产生待付款订单且系统不会支付。
- 规则无效、当前档案或价格不合格、设备离线/忙、能力不匹配、任务状态变化或同一 SYB 商品已有更新任务时,服务端明确拒绝且不得部分创建。
- Admin 批量重试继续使用相同的新任务语义;替代商品“继续采购”仍在 AgentRetry 前额外验证替换分项与继续采购资格。
普通失败任务的“重试采购”调用同任务重置接口:
```http
POST /api/agent/v1/purchase-tasks/{taskId}/reset
Authorization: Bearer <device-token>
Content-Type: application/json
{"requestId":"<uuid>"}
```
- `/reset` 只允许当前设备的安全失败任务且不得存在不可逆证据或更新任务;它复用原 `purchase_task.id`、新增 attempt、刷新当前有效规则,并保持商品、目标/执行规格、数量、价格、地址和规格决策等业务快照不变。
- 目标颜色存在但映射颜色为空,或目标尺码存在但映射尺码为空时,必须返回 `PURCHASE_SPEC_MAPPING_REQUIRED`,不得创建 `purchase` attempt 或下发正式采购 payload。相同 `requestId` 重放返回同一 task ID 和 attempt,不重复递增;Android 必须明确提示复用原任务和新的 attempt 序号。
- 两个入口都不执行支付。真机调用可能创建待付款订单,必须先取得人工授权。
## Agent 任务记录范围与同步(#99)
采集、采购任务列表接口新增可选查询参数 `days`:
```http
GET /api/agent/v1/collection-tasks?page=1&pageSize=20&days=7&status=failed&taskNo=%2335
GET /api/agent/v1/purchase-tasks?page=1&pageSize=20&days=7&status=failed&taskNo=CG-12
```
- `days` 只允许 1–30 的正整数;省略时为 30,保持旧客户端兼容。详情接口仍限制最近 30 天。
- 记录范围、状态、任务编号和分页只作用于当前 Device Token 对应设备,不能查询其他设备。
- Agent 采集/采购 Tab 的顶部下拉只重新读取当前 Tab、当前筛选、当前编号和当前页;不领取、执行、重置或重试任务。
- 设置 Tab 可输入最近 1~30 天的任意整数,默认 7 天;空值、非整数或越界时不发起请求并提示“请输入 1~30 天”。一次同步当前设备的采集与采购列表摘要;同步为服务端到 Agent 的单向读取,允许分别成功并显示部分同步结果。
- 本地只缓存列表摘要和同步时间;详情仍按需请求。缓存不得包含 Device Token、完整规则快照、PDD URL、地址、控件树或截图。同步本身不打开 PDD、不修改地址、不创建订单、不支付。
## Agent 当前页面临时采集(#101)
### 管理端默认规则
```http
GET /api/admin/v1/collection-rules/agent-manual-setting
PUT /api/admin/v1/collection-rules/agent-manual-setting
Content-Type: application/json
{"requestId":"<uuid>","ruleId":3}
```
- 只允许管理员读取和修改“Agent 手动采集默认规则”;PUT 的 `requestId` 必须为 UUID,重复请求幂等。
- 规则必须是仍存在的 PDD 商品详情 v2 规则。数据库迁移首次执行时,优先选择最近成功/部分成功任务使用的存活规则;没有成功历史时选择最近更新的存活 v2 规则;仍没有时保持未配置。
- 创建任务时重新验证规则与设备能力,并把完整规则快照固化到第 1 次 attempt;以后修改默认规则不影响正在执行的 attempt,但失败任务人工重采时会读取当前默认规则并固化到新的 attempt。
### 创建并占用当前设备
```http
POST /api/agent/v1/current-page-collection-tasks
Authorization: Bearer <device-token>
Content-Type: application/json
{"requestId":"<uuid>"}
```
- Device Token 确定设备。服务端在事务中锁定设备,校验在线、空闲、无活动采集/采购任务、默认规则和 `collector.pdd.current-page-share.v1` 能力。
- 成功即创建来源为 `agent_current_page` 的标准采集任务,固定当前设备、状态为 `running`、建立租约并返回规则快照;不再调用普通 `next → claim → start`。
- 同一设备与 `requestId` 重放返回原任务且 `replayed=true`,不创建第二条任务、不续租。
- 身份识别前响应中的 `pddProductId` 为 `null`,`urlSnapshot` 与 `goodsIdSnapshot` 为空字符串;普通 `admin` 来源任务仍必须在创建时具备完整商品身份。
### 识别当前商品
```http
POST /api/agent/v1/current-page-collection-tasks/{taskId}/identify
Authorization: Bearer <device-token>
Content-Type: application/json
{"requestId":"<uuid>","shareUrl":"https://p.pinduoduo.com/..."}
```
- Android 只上传从本次新鲜剪贴板内容中唯一提取出的分享 URL,不上传完整分享文案。
- Agent 从剪贴板文本提取唯一白名单链接:含 goods_id 的链接直接提交;白名单内但 URL 本身无 goods_id 的链接(包括 `p.pinduoduo.com` 短链和 `mobile.yangkeduo.com/goods2.html?ps=...`)优先在手机侧用无 Cookie、无项目凭据的移动端 GET 展开,最多跟随 4 次跳转、每跳校验 HTTPS/主机/端口/userinfo,连接与读取各超时 5 秒,正文最多读取 64KB。URL 或正文只接受边界明确的 `goods_id`,不得把 `refer_goods_id` 等相近字段当作商品身份。展开失败可提交原链接,由服务端以相同白名单、最多 4 次重定向和 10 秒总超时兜底;请求字段仍只传 `shareUrl`。
- 服务端提取 5~32 位纯数字 `goods_id`,形成标准 URL,并在事务中创建或复用唯一 PDD 商品;同商品已有活动采集任务或身份冲突时拒绝。
- 相同识别 `requestId` 直接返回已确认身份且不再次访问短链;任务首次确认身份后,不允许不同请求覆盖为其他商品。
- 识别后继续复用 `POST /api/agent/v1/tasks/{taskId}/result` 与 `/fail`。结果接口要求任务已绑定身份且结果 goods_id 一致;完成、部分完成和失败继续按既有状态机释放设备槽。
- Agent 历史的任务摘要返回 `source` 和当前 `attemptNumber`;详情返回不含规则快照的历史 attempt 摘要。所有 `failed` 当前页任务均可通过既有 reset 接口复用原任务重采;发起前 Android 必须确认无障碍就绪、没有本地执行中任务、最近见过 PDD 前台并通过采集冷却。
### 失败任务采集替代商品(#130)
失败采集详情和失败采购详情增加以下服务端计算字段:
```json
{
"replacementEligible": true,
"replacementDisabledReason": null,
"replacementMappingStatus": "matching",
"replacementActivationStatus": "activated",
"replacementActivationErrorMessage": null
}
```
- 采集来源的 `replacementEligible=true` 不再要求任务为 `failed`,也不检查错误码;当前 Device Token 对应设备、源商品存在、没有生效或待处理替换,且该商品名下不存在 `running`、`order_submit_started`、`order_result_unknown` 采购任务时即可返回。命中采购硬拦截时 `replacementDisabledReason` 明确说明正在执行或结果待核对。采购来源仍要求失败任务且错误码逐字等于 `PDD_LINK_INVALID` 或 `PDD_GOODS_SOLD_OUT`;其他设备、无源商品或进行中的替换均不得由 Android 自行放宽。
- `replacementMappingStatus` 取当前来源对应的分项状态:`matching`、`matched` 或 `manual_required`。采购来源必须限定到该任务的 `shopeeProductId`;不能以替换主表总体状态代替。
- `replacementActivationStatus` 为 `pending`、`activated` 或 `failed`;激活失败时已采集数据仍为完成态,并返回限长的 `replacementActivationErrorMessage`。
创建替代商品采集任务仍调用:
```http
POST /api/agent/v1/current-page-collection-tasks
Authorization: Bearer <device-token>
Content-Type: application/json
{
"requestId": "<uuid>",
"replacementOrigin": {"type": "collection", "taskId": 123}
}
```
`replacementOrigin.type` 只能是 `collection` 或 `purchase`。服务端在创建事务内重新检查资格,并把来源、可选纠错记录和初始 `pending` 激活状态固化到任务;同一 `requestId` 重放时来源必须完全一致。资格变化返回 HTTP 409 和 `REPLACEMENT_ORIGIN_NOT_ELIGIBLE`。
识别与结果提交继续复用当前页面采集接口。结果事务提交后,服务端调用 #131 的登记/纠错并生效流程:成功返回正常完成详情;生效失败返回 HTTP 409、`REPLACEMENT_ACTIVATION_FAILED`,但采集任务、商品、规格和 SKU 已安全保存。服务启动恢复只重试 `pending` / `failed` 的激活步骤,不重新打开 PDD 或重新采集。
### 替换匹配完成后继续采购(#132)
采购任务详情在 #130 字段之外增加:
```json
{
"replacementMappingStatus": "matched",
"continuePurchaseEligible": true,
"continuePurchaseDisabledReason": null
}
```
- `continuePurchaseEligible` 是 Agent 是否显示“继续采购”的唯一资格事实;Android 不组合任务状态、替换状态或既有 `retryable` 自行推断。
- 服务端仅在该采购任务对应虾皮商品的 replacement item 为 `matched` 时计算继续资格,不读取替换主表总体状态。资格包含既有失败重试的终态、最新任务、不可逆边界、档案、持久化映射、PDD 候选、价格、设备与能力门禁。
- 详情资格检查不调用 AI,也不把重新推导出的候选当成已确认映射。资格不通过时返回普通人可理解的 `continuePurchaseDisabledReason`;`matching` / `manual_required` 继续使用对应状态文字且不显示按钮。
- 点击仍调用既有 `POST /api/agent/v1/purchase-tasks/{taskId}/retry`,请求体仍为 `{"requestId":"<uuid>"}`。AgentRetry 在委托 BatchRetry 前重新校验 replacement item 与继续资格,随后由 BatchRetry/Create 执行最终档案和并发校验;没有新增采购任务创建路径。
- Admin 的 BatchRetry 接口和行为不变。相同 `requestId` 重放返回同一新任务;不同 requestId 再点由最新任务门禁拒绝。
## 采集颜色图片上传(#133)
结构化采集结果成功提交后,Agent 可为任务已采集的颜色逐张调用:
```http
POST /api/agent/v1/tasks/{taskId}/color-images
Authorization: Bearer <device-token>
Content-Type: multipart/form-data
color=<任务结果中的精确颜色值>
file=<JPEG 二进制>
```
成功返回 HTTP 201:
```json
{
"data": {
"color": "紫色",
"imagePath": "/static/uploadfile/goauto-color/<uuid>.jpg",
"width": 316,
"height": 316
}
}
```
契约约束:
- 必须先成功调用 `POST /api/agent/v1/tasks/{taskId}/result`;任务须为当前 Device Token 所属设备的 `completed` 或 `completed_partial` 任务。
- `color` 必须逐字匹配该任务持久化的 `color` 维度值;服务端不做相近颜色推断。
- 只接受实际内容为 JPEG 的文件;单张最大 512 KiB,宽高必须为正且均不超过 1024;单任务最多 64 张。
- 同一 PDD 商品和颜色重复上传以最新图片为准。响应路径供 Admin 商品详情展示,不返回整屏截图、控件树、XML 或个人数据。
- 接口独立于结果提交。任一上传失败都不得修改任务状态、`missing`、规格、颜色价格或 SKU;Agent 不进行会拖长任务的重试。
- 典型错误:`COLOR_IMAGE_TOO_LARGE`(HTTP 413)、`COLOR_IMAGE_UNSUPPORTED`(HTTP 415)、`COLOR_IMAGE_INVALID`(HTTP 422)、任务/设备/状态冲突(HTTP 409)。
## 采购规则管理接口
| 方法 | 路径 | 说明 |
|---|---|---|
| `GET` / `POST` | `/api/admin/v1/purchase-rules` | 管理员分页查看或创建严格校验的采购规则 |
| `PATCH` / `DELETE` | `/api/admin/v1/purchase-rules/{ruleId}` | 管理员修改或删除非当前规则;当前规则不能删除 |
| `GET` / `PUT` | `/api/admin/v1/purchase-rules/current` | 查看或按 `requestId + ruleId` 幂等切换当前规则 |
新采购任务、批量预检、批量创建、批量重试和安全的同任务重试读取当前规则;当前设置缺失、规则已删除或内容不通过正式采购契约时返回明确错误,不回退到内置常量。既有任务的 `ruleSnapshot` 不变。所有接口仅管理员可用。
`priceGuard` 支持以下两种互斥形态:
```json
{"enabled":true,"minRatio":0.2,"maxRatio":1.5}
```
```json
{"enabled":false,"absoluteMaxUnitPriceCent":5000}
```
- `enabled` 缺省时视为 `true`,兼容既有倍率规则;整个 `priceGuard` 缺省时继续使用默认倍率 0.2~1.5。
- 启用时必须提供 `minRatio`(0.1~1.0)与 `maxRatio`(1.0~3.0),最多 4 位小数且最低不得大于最高;不得提供绝对上限。
- 关闭时不得提供倍率字段,必须提供整数分 `absoluteMaxUnitPriceCent`,范围 1~100000。服务端仍从精确映射颜色读取参考价用于预检和审计,但新任务固化 `minUnitPriceCent=0`、`maxUnitPriceCent=absoluteMaxUnitPriceCent`。
- Android 严格解析两种形态,但不根据规则自行重算价格范围,只执行任务下发的最小/最大单价;未知字段、混用字段、缺少绝对上限或越界值均以 `PURCHASE_RULE_INVALID` 拒绝。
- `PURCHASE_PRICE_OUT_OF_RANGE` 失败结果应携带本次观察到的 `actualUnitPriceCent`;服务端保存该值用于任务历史诊断。该字段不授权 Agent 放宽任务边界。
## Agent 应用版本接口
| 方法 | 路径 | 认证与说明 |
|---|---|---|
| `GET` / `POST` | `/api/admin/v1/agent-app-releases` | 仅管理员;列表或上传不超过 200 MB 的 APK,服务端解析版本并计算 SHA-256 |
| `PUT` | `/api/admin/v1/agent-app-releases/current` | 仅管理员;按 `requestId + releaseId` 幂等设置当前版本 |
| `GET` | `/api/admin/v1/agent-app-releases/{releaseId}/download` | Admin JWT 鉴权下载,不提供公开静态地址 |
| `GET` | `/api/agent/v1/app/latest` | Device Token;返回当前版本元数据或 `data=null` |
| `GET` | `/api/agent/v1/app/releases/{releaseId}/download` | Device Token;私有 APK 下载 |
Agent 只比较整数 `versionCode`。设备有活动任务时禁止检查、下载和安装;下载到应用私有缓存并校验响应大小与 SHA-256,失败立即删除。安装使用 FileProvider 和 Android 系统安装确认页;未知来源权限必须由用户在系统设置授权,不静默安装。
## 设备身份恢复(#201)
```http
POST /api/admin/v1/devices/{deviceId}/identity-reset
POST /api/agent/v1/register
X-GoAuto-Device-Recovery-Code: <one-time-code>
```
仅管理员可以对未停用的既有设备发起身份重置。服务端立即使旧 Device Token 无效,并生成 10 分钟内仅能使用一次的恢复码;恢复码只在该管理员操作的响应中返回一次,服务端仅保存不可逆摘要,管理端设备列表、日志、任务接口和 Android 本地持久化均不得保存或返回原文。管理员将恢复码经受控人工渠道输入同一安装实例的 Agent 设置页。
Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口。服务端必须同时校验同一 `installId`、未停用状态、恢复码摘要、未过期和未使用;成功后使用原 `deviceId` 写入新 Token 摘要并返回一次新 Token,清除恢复码摘要和有效期。旧 Token 与恢复码都立即失效,已分配的 pending 采集或采购任务保持原 `deviceId`,不创建替代设备记录。缺少或错误恢复码仍为 `DEVICE_INSTALL_ID_CONFLICT`;过期码为 `DEVICE_RECOVERY_EXPIRED`;停用设备为 `DEVICE_DISABLED`。
自 #223 起,新建 SYB 任务在保持 `mappedColor` / `mappedSize` 为空和首趟 `spec_probe` 不变的同时,把创建时与目标规格对应的 confirmed 商品映射冻结为仅供服务端决策的指导快照。服务端收到当次候选后按角色验证该快照:只有规范化后唯一对应当次候选时才复用,并固化当次候选原文;否则该角色继续执行确定性匹配,仍未解决才把该角色及其封闭候选交给 AI。已解决角色不得重复发送给 AI,最终颜色和尺码仍须逐字属于各自当次候选。任务决策快照通过 `roleSources` 记录每个角色的 `manual_mapping` / `exact_match` / `ai_match` 来源;任务级 `specSource` 使用现有枚举汇总,不新增 Agent 决策权限。
## 客户端 API 与管理员密钥管理(#237)
实现基线 `71f7751`;以下接口已通过隔离测试;2026-09-07 本机 MySQL 迁移及管理员通过 HTTP 读取列表、模块目录已验证,真实密钥创建/编辑/停用及业务访问仍未联调,线上尚未部署。Android 接口不变。
### 管理接口
前缀 `/api/admin/v1/client-keys`,要求 Admin JWT 和 admin 角色,默认接受 HTTP/HTTPS,响应 `Cache-Control: no-store`。
| 方法与相对路径 | 输入 | 成功 data |
|---|---|---|
| GET 空路径 | page,固定每页 20 | items、total、page、pageSize |
| GET /modules | 无 | 模块数组:key、title、group、writable、actions |
| POST 空路径 | name(1~80 字符)、grants | key 元数据与仅本次返回的 secret |
| PATCH /:keyId/grants | version、grants | 更新后的元数据 |
| POST /:keyId/disable | version | 停用后的元数据 |
授权示例:`{"module":"pdd_products","write":false,"actions":[]}`;grants 为非空数组。元数据包括 id、name、prefix、enabled、version、grants、createdBy、updatedBy、createdAt、updatedAt、lastUsedAt,不返回摘要或完整密钥。管理请求只接受单个 JSON 对象、拒绝未知字段、最大 64 KiB。成功 code=200;无效输入 422、不存在 404、授权版本冲突或已停用 409。
### 客户端访问与错误语义
- 前缀 `/api/client/v1`,只接受 `Authorization: Bearer <客户端密钥>`,不接受 Cookie 或 URL 凭据,不与 JWT、Device Token 通用。
- 根据用户 2026-09-07 的明确确认,管理与客户端接口在所有环境默认接受 HTTP/HTTPS,不设置协议开关,也不依赖 X-Forwarded-Proto 或 GOAUTO_TRUST_FORWARDED_PROTO。HTTP 明文传输密钥及业务数据,优先使用 HTTPS;不影响其他接口各自的协议要求。
- 不再因 HTTP 返回 426;401 为缺失、无效或停用密钥;403 为模块/动作未授权;400 为查询参数携带凭据;503 为认证或审计暂不可用。业务错误沿用各既有接口。
- 一般请求体最大 16 MiB;响应只支持有限 JSON(32 MiB),递归过滤凭据与原始载荷字段。不支持直接流式/二进制文件接口。响应不可序列化或超限时返回 502;业务可能已执行,必须先核对结果,不要自动重试。
- 通过密钥认证的请求由服务端生成 `X-Client-Request-Id` 关联审计;它不是业务幂等键,原业务接口要求的 requestId 等字段仍须提供。允许请求必须先落审计意图;完成状态更新失败可留下 status=0。无效密钥没有 key_id 关联审计;拒绝授权的 403 审计为尽力记录。
- GET `/ai-matching-settings` 只返回 `data.enabled`。POST `/ai-matching-settings/resolve` 接受 targetColor、targetSize、colors、sizes;每组最多 200 项,每个值最多 255 字符,总请求最大 64 KiB;确定性优先,必要时使用当前 Provider,返回 mappedColor、mappedSize、source;不保存映射、不创建任务,无法可靠匹配返回 422 安全提示。
### 明确开放的接口清单
下表路径均相对 `/api/client/v1`;普通业务请求字段沿用本文对应 Admin 业务契约。read=选中模块,write=模块读写,其他值均需 actions 逐项授权。没有列出的 Admin 接口不能用客户端密钥调用;新增 Admin 路由不会自动开放。
| 方法 | 路径 | 模块键 | 能力 |
|---|---|---|---|
| POST | `/ai-matching-settings/resolve` | ai_matching | match |
| GET | `/devices` | devices | read |
| GET | `/pdd-products` | pdd_products | read |
| GET | `/pdd-products/:productId` | pdd_products | read |
| POST | `/pdd-products` | pdd_products | write |
| PATCH | `/pdd-products/:productId` | pdd_products | write |
| GET | `/shopee-products` | shopee_products | read |
| GET | `/shopee-products/:productId` | shopee_products | read |
| POST | `/shopee-products` | shopee_products | write |
| PATCH | `/shopee-products/:productId` | shopee_products | write |
| POST | `/shopee-products/:productId/link-pdd` | shopee_products | write |
| POST | `/shopee-products/:productId/specs/values` | shopee_products | write |
| PUT | `/shopee-products/:productId/specs/mapping` | shopee_products | write |
| DELETE | `/shopee-products/:productId/specs/values` | shopee_products | delete |
| DELETE | `/shopee-products/:productId/specs/mapping` | shopee_products | delete |
| POST | `/shopee-products/batch-delete` | shopee_products | delete |
| POST | `/shopee-products/:productId/specs/mapping/auto-match` | shopee_products | match |
| POST | `/shopee-products/:productId/specs/mapping/confirm` | shopee_products | match |
| GET | `/syb-products` | syb_products | read |
| GET | `/syb-products/:productId` | syb_products | read |
| PATCH | `/syb-products/:productId/correction` | syb_products | write |
| POST | `/syb-products/:productId/reparse` | syb_products | reparse |
| POST | `/syb-products/reparse-batch` | syb_products | reparse |
| GET | `/syb-products/sync-runs` | syb_sync_runs | read |
| GET | `/syb-products/sync-runs/:runId` | syb_sync_runs | read |
| POST | `/syb-products/import` | syb_sync_runs | sync |
| GET | `/syb-inner-codes` | syb_inner_codes | read |
| GET | `/syb-inner-codes/:recordId` | syb_inner_codes | read |
| GET | `/syb-inner-codes/match-jobs/:jobId` | syb_inner_codes | read |
| GET | `/syb-inner-codes/apply-batches/:batchId` | syb_inner_codes | read |
| POST | `/syb-inner-codes/rematch` | syb_inner_codes | match |
| POST | `/syb-inner-codes/import` | syb_inner_codes | import |
| POST | `/syb-inner-codes/batch-delete` | syb_inner_codes | delete |
| POST | `/syb-inner-codes/apply-preview` | syb_inner_codes | writeback |
| POST | `/syb-inner-codes/apply` | syb_inner_codes | writeback |
| POST | `/syb-inner-codes/:recordId/recheck` | syb_inner_codes | match |
| GET | `/syb-shops` | syb_shops | read |
| POST | `/syb-shops` | syb_shops | write |
| PATCH | `/syb-shops/:shopId/name` | syb_shops | write |
| PATCH | `/syb-shops/:shopId/enabled` | syb_shops | write |
| DELETE | `/syb-shops/:shopId` | syb_shops | delete |
| GET | `/collection-rules` | collection_rules | read |
| POST | `/collection-rules` | collection_rules | write |
| PATCH | `/collection-rules/:ruleId` | collection_rules | write |
| DELETE | `/collection-rules/:ruleId` | collection_rules | delete |
| GET | `/purchase-rules` | purchase_rules | read |
| GET | `/purchase-rules/current` | purchase_rules | read |
| POST | `/purchase-rules` | purchase_rules | write |
| PATCH | `/purchase-rules/:ruleId` | purchase_rules | write |
| DELETE | `/purchase-rules/:ruleId` | purchase_rules | delete |
| PUT | `/purchase-rules/current` | purchase_rules | activate |
| GET | `/collection-tasks` | collection_tasks | read |
| GET | `/collection-tasks/:taskId` | collection_tasks | read |
| POST | `/collection-tasks` | collection_tasks | collect |
| POST | `/collection-tasks/batch` | collection_tasks | collect |
| POST | `/collection-tasks/:taskId/reset` | collection_tasks | collect |
| DELETE | `/collection-tasks/:taskId` | collection_tasks | delete |
| GET | `/purchase-tasks` | purchase_tasks | read |
| GET | `/purchase-tasks/:taskId` | purchase_tasks | read |
| POST | `/purchase-tasks/batch-preview` | purchase_tasks | purchase |
| POST | `/purchase-tasks` | purchase_tasks | purchase |
| POST | `/purchase-tasks/batch` | purchase_tasks | purchase |
| POST | `/purchase-tasks/batch-retry` | purchase_tasks | purchase |
| POST | `/purchase-tasks/stock` | purchase_tasks | purchase |
| GET | `/ai-matching-settings` | ai_matching | read |
### openSpecPanel 后置滑动兼容与诊断(#238)
版本边界:Android 0.9.60 / versionCode 73,代码 `58a6c1c`。不修改 JSON schema、能力标识、任务接口或已有快照哈希。`openSpecPanel.swipeAfter` 仍按 direction/count/durationMs/intervalMs 原约束校验;解析成功后不执行该准备性滑动,`waitAfterMs` 保留。其他动作后置滑动沿用旧执行语义。旧 Server 可继续下发原快照;旧 APK 仍按原策略执行,不能将本契约描述当作旧设备已获得兼容。
规格探测与精确选择自行负责按需有界滚动,原始候选、精确点击和选中复核不变。跳过预滑动不作为规格探测成功或订单创建证据。
本地 `GoAutoPurchasePanel` 脱敏结构日志关联 task、attempt、device、rule(规则 SHA-256),不上传原始页面。新增事件:`postSwipe=skipped;action=openSpecPanel;reason=spec_panel_on_demand;panel=<枚举>`;其他必需滑动失败为 `postSwipe=failed;action=<动作枚举>;direction=<方向枚举>;swipeIndex=<本动作内第几次滑动>;reason=<固定分类>`。
固定失败分类:unknown、root_unavailable、no_scrollable、invalid_bounds、gesture_unsupported、gesture_rejected、gesture_cancelled、gesture_timeout。日志不含规格值、节点文本、坐标、地址、订单、凭据、原始树或截图;结果错误码仍为 RULE_ACTION_FAILED,现有结果提交字段不变。
## SYB 逐页保存与部分成功(#239)
实现绑定 c6a962d;代码已实现不代表当前线上已部署。每页完整明细在外部请求结束后按页事务保存;页回滚不累计明细/新增/覆盖数,已提交页保留。日期局部读取失败继续下一日期,全局数据库/进度/会话/取消故障停止。当天漂移不在一次运行内重扫;后续运行重新扫描并幂等补齐。
同步状态增加 `partial_success`(部分成功,15 字符,复用现有 varchar(16),无需迁移)。有错误且 created+updated>0 为部分成功;有错误无已提交明细为 failed;完整且无错误为 succeeded(包括无符合店铺的数据)。中断仍为 interrupted,不把中断追认为成功。所有终态沿用活动槽释放规则。
`orderCount` 是已验证页的原始列表读取数量;`detailCount`、`created`、`updated` 为已提交明细及其新增/覆盖数量;`daysProcessed` 是完整通过的日期数,不是已尝试日期数。失败日期/页码/阶段写入现有脱敏限长 errorMessage。部分成功不刷新店铺的完整同步统计。
Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态、状态筛选及详情支持部分成功,详情保留已保存数量、错误原因与重新同步补齐提示。定时任务日志只表示异步任务受理,不等于最终业务同步成功。
## Agent 采购订单批量回填(#241)
本节为 #241 服务端实现契约,2026-09-08 按用户授权直接更新本地镜像;线上 Wiki 与其他长期文档由审核阶段同步。本节不表示已经部署或完成真机验收。
`POST /api/agent/v1/purchase-tasks/order-backfill`
使用 `Authorization: Bearer <Device Token>`,沿用 `RequireAgentHTTPS`、`GOAUTO_ALLOW_INSECURE_AGENT_HTTP` 与既有可信转发协议策略。无需 Admin JWT、claim、start 或 attempt。设备号只从认证读取,请求不得指定 deviceId、地址全文、收件人、手机号或原始控件树;未知 JSON 字段拒绝。此接口只记录已观察到的订单事实,不执行设备动作、创建订单或付款。
请求示例(页面时间先按 Asia/Shanghai 理解,再以带时区 RFC3339/RFC3339Nano 发送):
```json
{
"requestId": "5826cdda-dcd6-442e-90c3-9b75ba6fb8d8",
"items": [
{"addressSuffix": "_cg7", "pddOrderNo": "EXAMPLE-ORDER-7", "orderSubmittedAt": "2026-09-08T20:30:00+08:00"},
{"addressSuffix": "_cg72", "pddOrderNo": "EXAMPLE-ORDER-72"}
]
}
```
- requestId 必须为 UUID;items 为 1~50 条,保持输入顺序;请求体上限沿用 1 MiB。
- addressSuffix 只接受 `AddressSuffix(id)` 生成的完整字符串。任务号为非零 uint64,拒绝前导零、正负号、空格、尾随文本、多个后缀与溢出;`_cg7` 与 `_cg72` 分别定位任务 7 和 72。
- pddOrderNo 必填,最多 100 个 Unicode 字符,不接受首尾空白、换行或制表符,不自动裁剪后覆盖旧值。
- orderSubmittedAt 缺失或 null 时回落任务 irreversible_at;空字符串、无时区文本及非法时间是条目错误,不触发回落。存储统一 UTC。页面值与 irreversible_at 都缺失时该条失败。
有效批次返回 HTTP 200,包括全部条目失败的批次;每条独立事务,失败不撤销其他条目已提交的数据。响应包裹为 `data`,并设 `Cache-Control: no-store`:
```json
{
"data": {
"requestId": "5826cdda-dcd6-442e-90c3-9b75ba6fb8d8",
"items": [
{"index": 0, "taskId": 7, "result": "backfilled", "code": "BACKFILLED", "status": "order_created", "statusVersion": 5, "pddOrderNo": "EXAMPLE-ORDER-7", "orderSubmittedAt": "2026-09-08T12:30:00Z", "timeSource": "page", "retryable": false},
{"index": 1, "taskId": 72, "result": "backfilled", "code": "BACKFILLED", "status": "order_created", "statusVersion": 4, "pddOrderNo": "EXAMPLE-ORDER-72", "orderSubmittedAt": "2026-09-08T12:31:00Z", "timeSource": "irreversible_at", "retryable": false}
]
}
}
```
index 从 0 开始;后缀无法解析时不返回 taskId。result 为 `backfilled`、`already_backfilled`、`conflict` 或 `failed`。已认证设备所属任务可返回提交后的状态、版本、已保存订单号及时间;拒绝条目尽可能返回当前已提交事实。跨设备任务和不存在任务不返回这些业务字段,事务回滚后的内存值绝不作为最终事实返回。
timeSource 说明已保存时间的来源:`page` 为页面值,`irreversible_at` 为估算回落,`existing_unknown` 为原先已创建的历史订单且没有可证明的来源。没有已保存时间时省略 timeSource。客户端必须保留估算标记,不得把回落值或 unknown 宣称为页面真实时间。重复回填不会用新页面时间自动校正旧时间。
| 条目 code | result | 含义 |
|---|---|---|
| `BACKFILLED` | backfilled | 本条完成回填 |
| `ALREADY_BACKFILLED` | already_backfilled | 正式任务已为 order_created 且订单号相同,无写入 |
| `PURCHASE_BACKFILL_SUFFIX_INVALID` | failed | 非法、非规范、零、溢出或歧义后缀 |
| `PURCHASE_TASK_NOT_FOUND` | failed | 任务不存在 |
| `PURCHASE_BACKFILL_DEVICE_MISMATCH` | failed | 未绑定设备或不属于认证设备 |
| `PURCHASE_STATE_CONFLICT` | failed | 非正式采购,或状态不允许回填 |
| `PURCHASE_INVALID_REQUEST` | failed | 订单号非法 |
| `PURCHASE_ORDER_TIME_INVALID` | failed | 提供的页面时间无效 |
| `PURCHASE_ORDER_TIME_MISSING` | failed | 页面时间与 irreversible_at 均无有效值 |
| `PURCHASE_BACKFILL_ORDER_CONFLICT` | conflict | 任务已有不同订单号 |
| `PURCHASE_BACKFILL_BATCH_CONFLICT` | conflict | 同批同任务出现多个不同订单号,该任务所有条目均拒绝 |
| `PURCHASE_BACKFILL_ORDER_ALREADY_USED` | conflict | 同一订单号已对应其他任务 |
| `INTERNAL_ERROR` | failed | 数据库失败、死锁等,retryable=true,可安全重放 |
批级 JSON/UUID/数量错误为 HTTP 422 `PURCHASE_INVALID_REQUEST`;鉴权、停用设备和 HTTPS 限制复用既有错误(401 `DEVICE_TOKEN_INVALID`、403 `DEVICE_DISABLED`、426 `HTTPS_REQUIRED`)。批级失败使用既有 `{code,message,retryable}` 包裹,未开始条目写入。
### 状态、幂等与并发
新服务在事务中锁定任务并检查来源状态,仅允许当前设备的 `live + order_result_unknown` 首次写入;`live + order_created` 只在订单号相同时返回已回填。其他状态(包括 running、failed、cancelled 与演练)均拒绝。复用 SetStatus 同步占用字段,同一事务递增 statusVersion、设置 statusChangedAt、清空主任务当前错误及租约;原始 attempt、规则快照、支付与物流、SYB 回写字段不变。
requestId 沿用 UUID 约定,不增加批次表或全局幂等缓存。既有 unknown_resolve_request_id 槽存储 `backfill:<page|irreversible_at>:<由 requestId 和后缀派生的 UUID>`(最多 61 字符),用于任务级关联和保留本功能时间来源。相同任务和订单号即使更换 requestId 也无写入;同 requestId 改内容仍重新执行设备、状态和订单冲突检查,不凭 requestId 直接放行。批内不同任务提交同一订单号时,先成功提交者占用,其余条目返回订单已被使用;已存在的历史重复订单号不自动修复。
订单号尚无唯一索引,本实现不迁移数据库。共享模型保存钩子复用 `purchase_rule_setting.id=1` 行作短事务互斥锁,再以锁定读检查订单号归属,覆盖回填、原人工解除和旧结果提交路径;单例缺失时拒绝写入,数据库死锁时回滚失败事务。原 ResolveUnknown 与 Admin 鉴权代码保持不变。禁止通过跳过模型钩子的直接 SQL 写入宣称具备此保证。
新回填路径禁用包含绑定参数的 SQL 日志,不记录请求正文、订单号、地址或原始树;任务号和认证设备号沿既有任务关联不可变 ruleSnapshot。此接口未新增日志载荷或任务/attempt。
本地测试覆盖事务回滚、并发服务调用和旧写入路径,使用 SQLite;MySQL 8.4 多连接/多进程的实际行锁、生产数据和真机端到端回填尚待环境验收,不以单元测试替代。
## 蝦皮详情匹配等待与回读(#254)
实现绑定 `9088e6b`(2026-09-10,分支 fix/254-match-loading);已通过合成数据测试,尚未合并 main 或发布线上。
本节是 Admin 蝦皮详情接口补充,不修改 Android 请求或采购协议。
- `GET /api/admin/v1/shopee-products/:productId` 的 `data` 增加 `autoMatchTimeoutSeconds` 整数,为后端详情一键匹配整次预算 `2 × AI timeoutSeconds + 10`,配置范围3~600对应16~1210秒。它是非敏感派生预算,沿用商品详情读取权限;不是开放 AI 管理配置读取,响应不包含 Key、Provider、BaseURL 或 Model。现有客户端路由若复用本 Handler 同样仅获得这个数字。
- `POST /api/admin/v1/shopee-products/:productId/specs/mapping/auto-match` 请求字段、业务权限、版本核对与响应映射语义不变。服务端按收到请求时的 AI 配置确定整次父预算,读取配置耗时扣除;Provider 请求仍受自身超时及父 context 剩余时间中更短者约束。
- Admin 点击前先GET新详情取得预算,POST timeout=(autoMatchTimeoutSeconds+10)×1000毫秒;缺字段、非整数或超范围时明确拒绝开始匹配,不猜配置。前后端需配套升级。
- 准备后配置可能变化,浏览器仍可能先超时;不要据超时响应断言事务是否提交。POST失败/断连/超时只执行有限GET回读,不自动重发POST;失败回读显示结果未知。回读结果表示当前已保存状态,不保证这次请求已经永久不再写入,也不保证全部项目都匹配成功。
- 普通GET默认10秒;本轮未修改建议接口610秒、其他AI功能或后台批量匹配600秒预算。T=60时130/140秒、T=180时370/380秒。
## SYB 行内一键关联最近采集(#253)
实现绑定 `b1594dc`,2026-09-10;分支 feat/253-one-click-link,包含 #254 依赖。已完成合成数据测试及构建,未合并 main、未部署,不代表当前线上已具备此能力。
本节仅补充 Admin API;Android 和客户端密钥旧请求不变。
`POST /api/admin/v1/shopee-products/{productId}/link-pdd` 新增与非零 pddProductId 互斥的可选模式:
```json
{"requestId":"UUID","latestCollection":{"sybProductId":1,"deviceId":2,"specContextVersion":"详情返回的未关联版本"}}
```
- 校验当前 SYB→Shopee、未关联、规格版本和设备存在/未停用;离线可用。短事务选择最新已结束成功或部分成功的临时采集,排序 finished_at DESC、id DESC;部分成功不跳过回退旧记录。只接受 completed、存在 finishedAt/PDD、PDD active 且存在可选颜色或尺码。
- 成功返回原 `product`,及 `collectionSource: {taskId, deviceId, pddProductId, requiresSpecConfirmation}`。product.specContextVersion 是此次事务写入后的冻结版本;requiresSpecConfirmation 对应 failed 且没有人工或AI确认,完整资格仍由现有 batch-preview 派生。不得把后续其他人的商品版本当作本次自动匹配上下文。
- 409 `PDD_LINK_CONFLICT` 表示已有关联;409 `LATEST_COLLECTION_UNAVAILABLE` 表示设备/最近采集/候选不可用;409 `SPEC_CONTEXT_VERSION_STALE` 沿用现有语义。非法参数422。重复请求不会覆盖已有关联,成功响应丢失后重发也会冲突,客户端应回读而非自动重试。last_update_request_id 保留本次请求标识;不承诺额外持久化完整重放响应。
- `/api/client/v1/.../link-pdd` 带 latestCollection 时返回403 FORBIDDEN,不扩大API Key授权模块;原手动 pddProductId 模式行为不变。
- Web 先GET详情及 #254 autoMatchTimeoutSeconds,再提交关联;关联响应中的冻结版本交给现有 auto-match。其匹配仍自动保存,无额外“保存修改”写请求。普通请求/回读每次10秒,auto-match 单独使用派生前端预算2T+20秒;业务拒绝直接提示,网络/匹配失败至多一次详情回读,再刷新准备状态,禁止自动POST重试。
- Server/Web 需配套更新并包含 #254;无需迁移/Android升级。现有人工关联、匹配阈值、规格/采购资格、任务与支付边界不变。