Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26c19165bb | ||
|
|
64b4205d32 |
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: 46a5f8234be2d55298779532058a0490a94d3167
|
||||
synchronized_at: 2026-09-10T03:36:58Z
|
||||
wiki_revision: eb9608c98fa117d4fcc2019ed6ae60b6a3670f42
|
||||
synchronized_at: 2026-09-09T09:24:10Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -387,27 +387,3 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- #198 `ProcessSpecAIParseRun → Reparse` 自然接入确定性优化,仍需 AI 的记录沿用原后续步骤,不新增异步任务/Provider 调用/接口。
|
||||
- 验证 `go test ./app/goauto/sybimport ./app/goauto/aimatching ./app/goauto/shopeeproduct ./app/goauto/purchase` 和 `go build ./...` 通过;新增候选正反/格式/组合色/单维度、歧义/空值、同步重解析一致、人工保护及无需调用 AI 测试。AI 原有置信度/失败重试测试改用仍需语义理解的合成输入,继续验证原分支。
|
||||
- 本轮只使用本地测试数据与 httptest AI,未触发真实 SYB 同步、外部 AI、采购、数据库迁移或服务重启;线上及历史数据效果尚待授权验证。
|
||||
|
||||
|
||||
## 蝦皮详情匹配等待与回读(#254)
|
||||
|
||||
实现绑定 `9088e6b`(2026-09-10,分支 fix/254-match-loading);已通过合成数据测试,尚未合并 main 或发布线上。
|
||||
|
||||
- Server `shopeeproduct.Service.DetailWithAutoMatchBudget` 为详情 GET 附加派生的整次匹配预算,不返回 Provider、Key 等配置。`AutoMatchMappings` 读取 AI 设置 T,使用 2T+10 秒的单个父 context;颜色、尺码、保存及结果回读共用剩余时间。初始配置读取也受有界 context 约束,读取耗时从后续预算扣除。
|
||||
- 批量定时匹配调用内部 `autoMatchMappings` 并继续使用原 MaxProviderTimeout=600 秒,不随本次详情入口调整;候选建议与其他 AI 功能不改。
|
||||
- Web `ShopeeProductDetailDrawer` 在提交前以普通 GET 刷新商品与预算,仅 auto-match POST 使用 (2T+20) 秒超时。准备失败/缺少预算时不发 POST;旧 Server 必须先更新,不能只发布新版 Web。
|
||||
- 加载状态同时绑定商品加载代次和操作序号。关闭/切换立即结束该页面等待状态;同商品重开读取详情,旧响应/旧 finally 不得污染新操作。匹配期间禁止在当前抽屉编辑映射,原人工草稿放弃确认保留。
|
||||
- POST 未正常返回时只回读详情及关联 PDD,不自动重发 POST;回读失败结束转圈并保留刷新入口。GET 沿用单请求10秒超时,完整结果回读最多两次串行 GET,不是无限等待。
|
||||
- 验证:Go shopeeproduct/aimatching/clientapi 测试与 go build;Web `node --test tests/unit/shopee-match-lifecycle.test.cjs`,Playwright shopee-match-lifecycle 与 shopee-pdd-picker,受影响 ESLint 与 build:prod。
|
||||
|
||||
|
||||
## SYB 行内一键关联最近采集(#253)
|
||||
|
||||
实现绑定 `b1594dc`,2026-09-10;分支 feat/253-one-click-link,包含 #254 依赖。已完成合成数据测试及构建,未合并 main、未部署,不代表当前线上已具备此能力。
|
||||
|
||||
- Web `syb-products/index.vue` 在“去关联”旁增加“一键关联+下拉箭头”,共享 `quick-link.js` 页面级状态。`quick-link-device-preference.js` 按当前浏览器来源、API环境、用户ID保存设备ID,独立于批量采购偏好;不存密钥或商品数据。首次主动作选机继续原行,箭头选机只保存偏好。
|
||||
- Server `shopeeproduct/latest_collection.go` 复用 LinkPDD 的可选 Admin 模式。短事务核对 SYB→Shopee、未关联/规格版本、设备状态,选择并锁定最新 completed/completed_partial 的 agent_current_page 任务及其 PDD;仅完整采集、active且有可用颜色或尺码时更新,WHERE pdd_product_id IS NULL 不覆盖其他操作者的关联。响应保留该事务产生的规格版本及来源task/device/PDD,不能用后续GET替换冻结上下文再自动匹配。
|
||||
- 不新增表、迁移、路由或权限对账。原手动关联/替换请求不变;共享 Handler 明确拒绝 Client API 使用此跨模块模式,不能借 Shopee-only write grant 读取 SYB/设备上下文。来源任务原规则快照不改,taskId 可回溯既有采集证据。
|
||||
- 关联后读取现有采购准备;需要人工确认或不具备AI资格时停止自动链路并保留关联,其他情况调用原 AutoMatchMappings,沿用 #254 动态预算及原事务保存/上下文冲突保护。最后刷新实际处理阶段,不调用创建采购/订单接口。
|
||||
- 运行期间冻结行、商品、手机;前端重复主动作禁用,切换偏好仍只影响未来操作。卸载或 keep-alive deactivated 令旧操作失效,禁止旧回调继续下一步;不声称已经取消服务端在途写入。异常仅有限GET回读和刷新,不自动重发POST。
|
||||
- 验证命令:Server `go test -p 1 ./...`、`go build ./...`;Web `node --test tests/unit/syb-quick-link.test.cjs tests/unit/shopee-match-lifecycle.test.cjs`、`pnpm exec playwright test tests/e2e/syb-quick-link.spec.ts tests/e2e/shopee-match-lifecycle.spec.ts --workers=1`、受影响文件 ESLint、`pnpm build:prod`。SQLite 单连接并发测试不代替生产 MySQL 行锁验收;真实AI、生产数据、跨设备人工闭环待验证。
|
||||
|
||||
@@ -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: 6513b68930ee8a29327e91bcb51efd4f47e26990
|
||||
synchronized_at: 2026-09-10T03:37:02Z
|
||||
wiki_revision: cf6bd9fb5dd727417a3ed612ddcf52eb2d8d868d
|
||||
synchronized_at: 2026-09-09T09:24:15Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -533,26 +533,3 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- 同步入库 ApplyDetail、手动 Reparse 共用;成功核对不再次合并规格,因此不新增重复维度、不覆盖已有映射。人工确认仍优先,默认 Reparse 跳过人工与 AI 已确认值;原文保留。
|
||||
- #198 批处理原本先 Reparse,所以可直接复用本优化:能确定的不用调用 AI;仍不确定的按原 AI 流程处理。没有新增调度、调用额度或同步等待,不自动开启已有任务。
|
||||
- 不取消 uncertain/failed 状态,不改变采购可信度/可采购规则,不实现一键关联 PDD。部署后新同步或显式重新解析会使用新逻辑;本次没有执行生产批量更新。
|
||||
|
||||
|
||||
## 蝦皮详情匹配等待与回读(#254)
|
||||
|
||||
实现绑定 `9088e6b`(2026-09-10,分支 fix/254-match-loading);已通过合成数据测试,尚未合并 main 或发布线上。
|
||||
|
||||
- AI 设置中的 timeoutSeconds=T 仍是单次 Provider 请求的上限。详情一键匹配的整次预算为 2T+10 秒,浏览器 POST 等待上限为 2T+20 秒;例如 T=60 时130/140秒,T=180时370/380秒。只保留现有一个 AI 设置入口,不新增时间配置框,也不自动改变管理员已保存的 T。
|
||||
- 超时上限不是固定等待。颜色/尺码串行执行且共用整次预算;正常部分匹配仍按原事务保存合格结果,AI异常在保存前返回时不提交本轮新映射。既有置信度、候选、人工确认映射保护和采购资格不变。
|
||||
- 前端超时、关闭或切换详情不代表后端已取消、未保存或已回滚;必须以回读结果为准,无法回读显示结果未确认,不自动重复匹配、不自动创建采购。
|
||||
- 单商品详情的成功/失败/超时/关闭重开都必须结束失效的等待状态;旧请求不得覆盖新商品或新操作。匹配进行期间本抽屉不能人工改映射,已有未保存草稿仍按原确认流程处理。
|
||||
- UI匹配提示的等待上限仅指POST,不含提交前GET及结果核对GET;结果核对也有限时,不能让失败提示再次陷入无限转圈。
|
||||
|
||||
|
||||
## SYB 行内一键关联最近采集(#253)
|
||||
|
||||
实现绑定 `b1594dc`,2026-09-10;分支 feat/253-one-click-link,包含 #254 依赖。已完成合成数据测试及构建,未合并 main、未部署,不代表当前线上已具备此能力。
|
||||
|
||||
- 未关联 PDD 且既有处理阶段提供“去关联”的 SYB 行可用一键关联;原人工入口不删除。首次点击选择采集手机后继续当前行,后续使用记忆手机;小箭头查看/切换手机,保存只修改全行共用偏好。取消不改变偏好、不执行关联。与批量采购设备选择互不影响。
|
||||
- 以所选手机的临时采集 source=agent_current_page 为范围,从 completed、completed_partial 中按 finished_at DESC、id DESC 取最新一条;失败/待执行/执行中不参与。最新为部分成功或商品停用、无可用规格时明确停止,不回退更旧商品,不切换其他手机。离线手机允许使用已有成功结果,停用或已删除手机需要重新选择。
|
||||
- 主动作开始冻结行、蝦皮商品和设备;服务端关联事务固定来源采集任务、PDD商品与规格版本。仅未关联时自动写入,已有或被其他操作改变的关联不覆盖。关联作用于蝦皮商品,因此共用该蝦皮档案的其他 SYB 明细也受影响;切换手机不会重绑已有商品,也不会改变在途操作。
|
||||
- 先保存关联,再复用现有颜色/尺码自动匹配及保存。有效已确认映射保留;普通部分匹配保存合格结果,AI故障不提交本轮新映射,但已保存关联保留。后续可从“去匹配”或详情人工继续。failed 且无人工/AI确认时不能自动跳过人工解析;uncertain 不统一拦截,仍以原采购准备规则为准。
|
||||
- 操作完成不保证立即可采购,必须刷新现有处理阶段;缺规格、缺映射、解析需确认或其他原资格不满足时继续显示真实待处理阶段。不自动创建采购任务、不下单、不支付。
|
||||
- 请求超时/网络中断只做有限回读,不重复提交;读回失败明确结果未确认,避免无限转圈。离开页面不代表后端停止或回滚。普通用户沿用现有采购员权限,无新管理员专属开关。
|
||||
|
||||
@@ -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: 23160c055e8f6185a4108e284afc8216b41373e5
|
||||
synchronized_at: 2026-09-10T03:37:18Z
|
||||
wiki_revision: 217239a85b50c880e004ecc19a559fb5e03555e1
|
||||
synchronized_at: 2026-09-08T01:38:42Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# MVP 共享 API 契约
|
||||
@@ -556,9 +556,7 @@ Android #42/#36 使用本地 SQLite 保存恢复与重传所需的任务、attem
|
||||
创建订单前先在本地事务保存 `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 和摘要,重复提交按原幂等协议返回;任务释放租约和运行槽,进入既有人工处理结果未知通道,权限不变。
|
||||
`order_result_unknown` 保持订单号和下单时间为空,但允许携带创建订单前已经严格验证的 `actualUnitPriceCent`。Agent 同时提交脱敏稳定失败阶段,服务端只接受白名单并按错误码写入固定提示,不信任或保存页面原文;阶段覆盖空窗口超时、选择器返回失败、微信恢复失败/超时、未知应用、支付页返回失败/重复、订单上下文缺失、订单号缺失/歧义、下单时间缺失/无效和待付款证据缺失。任务与 attempt 保存同一错误阶段,Admin 和 Agent 历史读取数据库最终事实;旧 Agent 未提交阶段时归一为 `PURCHASE_ORDER_RESULT_UNKNOWN`。
|
||||
|
||||
| 错误码 | 普通提示 |
|
||||
|---|---|
|
||||
@@ -987,109 +985,3 @@ Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口
|
||||
`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升级。现有人工关联、匹配阈值、规格/采购资格、任务与支付边界不变。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: 933eaf6cc02fed843a6898269fdb5f6848ac2a8a
|
||||
synchronized_at: 2026-09-10T01:42:05Z
|
||||
wiki_revision: df033d06fd94a4b02a447a27ced8d711521342f3
|
||||
synchronized_at: 2026-09-08T02:23:08Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 部署与运维
|
||||
@@ -41,7 +41,7 @@ GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true
|
||||
|
||||
### API 长请求超时约束
|
||||
|
||||
Admin 蝦皮规格 AI 匹配会同步等待外部 Provider:Provider 配置允许 3~600 秒,旧版详情复合一键匹配共享600秒总预算,旧Admin客户端等待610秒;#254未部署前仍适用,部署后详情入口改用下节动态预算。因此服务端 `settings.application.writetimeout` 必须至少为 620 秒,受版本管理的标准配置固定为 `620`;启动时低于该值必须明确失败,禁止以 2 秒等短写超时运行,否则连接会在领域错误返回前被截断并表现为代理 502。反向代理的读/发送超时也必须大于服务端窗口。
|
||||
Admin 蝦皮规格 AI 匹配会同步等待外部 Provider:Provider 配置允许 3~600 秒,复合的一键匹配共享 600 秒总预算,Admin 客户端等待 610 秒。因此服务端 `settings.application.writetimeout` 必须至少为 620 秒,受版本管理的标准配置固定为 `620`;启动时低于该值必须明确失败,禁止以 2 秒等短写超时运行,否则连接会在领域错误返回前被截断并表现为代理 502。反向代理的读/发送超时也必须大于服务端窗口。
|
||||
|
||||
Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、上游 HTTP 状态码或网络错误分类;不得记录 API Key、Authorization、Provider URL、模型输入、候选规格、商品原始内容或响应正文。Provider 失败时接口返回 HTTP 503 与 `AI_MATCHING_UNAVAILABLE`,供 Admin 显示安全中文提示。
|
||||
|
||||
@@ -93,14 +93,3 @@ Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、
|
||||
- 范围明确的服务控制:`D:/supervisor/supervisord.exe -c D:/supervisor/supervisord.conf ctl status goauto-admin-api goauto-admin-ui`,启动/停止/重启将 status 分别替换为 start/stop/restart。不得为了 GoAuto 重启整个 Supervisor 或其他项目。配置内容变化后需重新读取配置;本次调用 `supervisor.reloadConfig`,再对上述两个程序定向 start,已通过进程命令行核对新目录。
|
||||
- 日志:`D:/supervisor/logs/goauto-admin-api.log`、`D:/supervisor/logs/goauto-admin-ui.log`;对外分享只能保留脱敏结构摘要。迁移输出不得暴露凭据或业务原文。
|
||||
- 管理员刷新页面后可进入 `http://127.0.0.1:9527/#/client-keys/index`;菜单缓存未更新时重新登录。已验证列表空态、创建弹窗加载 12 模块及 HTTP 风险提示;未实际创建密钥,真实客户端读写及停用闭环仍待范围明确的验证。本节为 2026-09-07 本机验证记录;线上已于 2026-09-08 另行授权发布,见上节。
|
||||
|
||||
|
||||
## 蝦皮详情匹配等待与回读(#254)
|
||||
|
||||
实现绑定 `9088e6b`(2026-09-10,分支 fix/254-match-loading);已通过合成数据测试,尚未合并 main 或发布线上。
|
||||
|
||||
- 本次只完成代码构建与合成测试,未改线上配置、AI设置、常驻服务或Nginx;部署与重启需另行授权。
|
||||
- 详情匹配新窗口是后端 2T+10 秒、Web POST 2T+20 秒。当前默认API最低写超时校验仍为620秒,这是历史最低值,不保证容纳所有动态预算;发布前读取实际 AI timeoutSeconds、application.writetimeout 和生效代理配置核对。
|
||||
- 建议API写超时至少为 2T+30 秒,且仍满足原最低620秒;代理读/发送超时需留足对应窗口。T=60/180时原620秒足够容纳应用预算;T=600时至少需要1230秒API写超时及适当更长的代理窗口。不得仅设置较大的AI超时却忽略传输层更早截断。
|
||||
- 上述是发布核对要求,不是本单已修改/验证线上代理的结论。超大T带来长连接占用,不能用关闭超时来代替正确预算。新增失败回读不代表必须延长任何线上请求。
|
||||
- 必须配套发布 Server/Web;仅上新版Web连接旧Server时,因缺失派生预算会提示等待时间不可用并停止匹配。原Android不需升级。
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/models"
|
||||
@@ -49,20 +48,7 @@ type autoMatchRef struct {
|
||||
// before the transaction; the transaction rechecks the complete spec context
|
||||
// and PDD candidate set so a stale decision can never be written.
|
||||
func (service *Service) AutoMatchMappings(ctx context.Context, id uint64, request AutoMatchRequest) (AutoMatchResponse, error) {
|
||||
started := time.Now()
|
||||
// Bound the initial settings lookup too. This budget is specific to this
|
||||
// composite endpoint; other AI operations retain their existing limits.
|
||||
ctx, cancel := context.WithTimeout(ctx, 1210*time.Second)
|
||||
defer cancel()
|
||||
seconds, err := service.autoMatchTimeout(ctx)
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, err
|
||||
}
|
||||
return service.autoMatchMappings(ctx, id, request, time.Duration(seconds)*time.Second-time.Since(started))
|
||||
}
|
||||
|
||||
func (service *Service) autoMatchMappings(ctx context.Context, id uint64, request AutoMatchRequest, budget time.Duration) (AutoMatchResponse, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, budget)
|
||||
ctx, cancel := context.WithTimeout(ctx, aimatching.MaxProviderTimeout)
|
||||
defer cancel()
|
||||
|
||||
requestID := strings.TrimSpace(request.RequestID)
|
||||
@@ -262,21 +248,6 @@ func (service *Service) autoMatchMappings(ctx context.Context, id uint64, reques
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func autoMatchBudgetSeconds(providerSeconds int) int {
|
||||
if providerSeconds < 3 || providerSeconds > 600 {
|
||||
providerSeconds = 15 // same default as AI settings; never allow unbounded waits
|
||||
}
|
||||
return providerSeconds*2 + 10
|
||||
}
|
||||
|
||||
func (service *Service) autoMatchTimeout(ctx context.Context) (int, error) {
|
||||
settings, err := aimatching.NewService(service.DB).Settings(ctx)
|
||||
if err != nil {
|
||||
return 0, internalError(err)
|
||||
}
|
||||
return autoMatchBudgetSeconds(settings.TimeoutSeconds), nil
|
||||
}
|
||||
|
||||
func autoMatchRefs(specs []SpecDimension, role string) []autoMatchRef {
|
||||
refs := make([]autoMatchRef, 0)
|
||||
for _, dimension := range specs {
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -167,7 +166,7 @@ func (service *Service) ProcessAutoMatchRun(ctx context.Context, runID uint64) e
|
||||
}
|
||||
processed++
|
||||
service.renewAutoMatchRun(run)
|
||||
response, matchErr := service.autoMatchMappings(ctx, product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: fingerprint[:64]}, aimatching.MaxProviderTimeout)
|
||||
response, matchErr := service.AutoMatchMappings(ctx, product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: fingerprint[:64]})
|
||||
// fingerprint begins with the 64-character context version.
|
||||
postFingerprint := fingerprint
|
||||
if next, _, nextErr := service.autoMatchEligibility(ctx, product); nextErr == nil && next != "" {
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestAutoMatchBudgetFollowsProviderSetting(t *testing.T) {
|
||||
for _, tc := range []struct{ ai, total int }{{3, 16}, {60, 130}, {180, 370}, {600, 1210}, {0, 40}, {601, 40}} {
|
||||
if got := autoMatchBudgetSeconds(tc.ai); got != tc.total {
|
||||
t.Fatalf("AI %d: got %d want %d", tc.ai, got, tc.total)
|
||||
}
|
||||
}
|
||||
db := openTestDB(t)
|
||||
setting := seedEnabledAISetting(t, "https://example.invalid", 0.9)
|
||||
setting.TimeoutSeconds = 60
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := NewService(db)
|
||||
created, err := s.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "budget-test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail, err := s.DetailWithAutoMatchBudget(context.Background(), created.Product.ID)
|
||||
if err != nil || detail.AutoMatchTimeoutSeconds != 130 {
|
||||
t.Fatalf("budget detail: %d %v", detail.AutoMatchTimeoutSeconds, err)
|
||||
}
|
||||
body, _ := json.Marshal(detail)
|
||||
if strings.Contains(string(body), "apiKey") || strings.Contains(string(body), "example.invalid") {
|
||||
t.Fatal("AI configuration leaked")
|
||||
}
|
||||
if err := db.Model(&setting).Update("timeout_seconds", 180).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
detail, err = s.DetailWithAutoMatchBudget(context.Background(), created.Product.ID)
|
||||
if err != nil || detail.AutoMatchTimeoutSeconds != 370 {
|
||||
t.Fatalf("fresh budget: %d %v", detail.AutoMatchTimeoutSeconds, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchCompositeBudgetCancelsWithoutSaving(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
responder := chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.96,"reason":"unique match"}]}`)
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if calls.Add(1) == 1 {
|
||||
responder(w, r)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}))
|
||||
defer provider.Close()
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
setting := seedEnabledAISetting(t, provider.URL, 0.9)
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := NewService(db)
|
||||
created, err := s.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "budget-cancel", PDDProductID: &pdd.ID,
|
||||
Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "深黑", Source: ValueSourceImport}}}, {Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: "加大码", Source: ValueSourceImport}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
started := time.Now()
|
||||
_, err = s.autoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion}, 100*time.Millisecond)
|
||||
if err == nil || time.Since(started) > time.Second {
|
||||
t.Fatalf("budget not enforced: %v", err)
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Fatalf("expected sequential color and size calls, got %d", calls.Load())
|
||||
}
|
||||
detail, err := s.Detail(context.Background(), created.Product.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.Product.Specs[0].Values[0].Mapping != nil {
|
||||
t.Fatal("timed-out operation saved mappings")
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func (handler Handler) Detail(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := service.DetailWithAutoMatchBudget(c.Request.Context(), id)
|
||||
response, err := service.Detail(c.Request.Context(), id)
|
||||
respond(c, response, err)
|
||||
}
|
||||
|
||||
@@ -96,12 +96,6 @@ func (handler Handler) LinkPDD(c *gin.Context) {
|
||||
writeError(c, invalidRequest("请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
// This composite workflow reads SYB and device context. It must not inherit
|
||||
// a client key's Shopee-only write grant through the shared handler.
|
||||
if request.LatestCollection != nil && !strings.HasPrefix(c.FullPath(), "/api/admin/v1/") {
|
||||
c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "一键关联仅限 Admin 入口"})
|
||||
return
|
||||
}
|
||||
service, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
@@ -473,7 +467,7 @@ func writeError(c *gin.Context, err error) {
|
||||
status = http.StatusConflict
|
||||
case CodeProductNotFound, CodePDDProductNotFound, CodeMappingNotFound, CodeValueNotFound:
|
||||
status = http.StatusNotFound
|
||||
case CodePDDProductDisabled, CodeSpecContextStale, CodeLatestCollectionUnavailable, CodeLinkConflict:
|
||||
case CodePDDProductDisabled, CodeSpecContextStale:
|
||||
status = http.StatusConflict
|
||||
case CodeAIUnavailable:
|
||||
status = http.StatusServiceUnavailable
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
const CodeLatestCollectionUnavailable = "LATEST_COLLECTION_UNAVAILABLE"
|
||||
const CodeLinkConflict = "PDD_LINK_CONFLICT"
|
||||
|
||||
type LatestCollectionRequest struct {
|
||||
SYBProductID uint64 `json:"sybProductId"`
|
||||
DeviceID uint64 `json:"deviceId"`
|
||||
SpecContextVersion string `json:"specContextVersion"`
|
||||
}
|
||||
|
||||
type LatestCollectionSource struct {
|
||||
TaskID uint64 `json:"taskId"`
|
||||
DeviceID uint64 `json:"deviceId"`
|
||||
PDDProductID uint64 `json:"pddProductId"`
|
||||
RequiresSpecConfirmation bool `json:"requiresSpecConfirmation"`
|
||||
}
|
||||
|
||||
func latestUnavailable(message string) error {
|
||||
return &ServiceError{Code: CodeLatestCollectionUnavailable, Message: message}
|
||||
}
|
||||
|
||||
// Resolve the candidate exactly once under short row locks; never hold a
|
||||
// transaction while calling AI. The returned context belongs to this write,
|
||||
// not a later GET that could silently adopt another operator's association.
|
||||
func (service *Service) linkLatestCollection(ctx context.Context, id uint64, request LinkPDDRequest) (SaveResponse, error) {
|
||||
r := request.LatestCollection
|
||||
if request.PDDProductID != 0 || r.DeviceID == 0 || r.SYBProductID == 0 || r.SpecContextVersion == "" {
|
||||
return SaveResponse{}, invalidRequest("一键关联需要 SYB、设备及规格版本,不能同时指定 PDD 商品")
|
||||
}
|
||||
var response SaveResponse
|
||||
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var syb models.SYBProduct
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&syb, r.SYBProductID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return latestUnavailable("SYB 明细不存在,请刷新列表")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if syb.ShopeeProductID == nil || *syb.ShopeeProductID != id {
|
||||
return contextVersionStale()
|
||||
}
|
||||
var shopee models.ShopeeProduct
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&shopee, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return productNotFound()
|
||||
}
|
||||
return err
|
||||
}
|
||||
if shopee.PDDProductID != nil {
|
||||
return &ServiceError{Code: CodeLinkConflict, Message: "商品已有关联,请刷新后查看;一键关联不会覆盖"}
|
||||
}
|
||||
if computeSpecContextVersion(nil, shopee.SpecsJSON, "") != r.SpecContextVersion {
|
||||
return contextVersionStale()
|
||||
}
|
||||
var device models.AgentDevice
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&device, r.DeviceID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return latestUnavailable("手机已不存在,请重新选择")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if device.Status != models.DeviceStatusOnline && device.Status != models.DeviceStatusOffline {
|
||||
return latestUnavailable("手机已停用,请重新选择")
|
||||
}
|
||||
var task models.CollectionTask
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("device_id = ? AND source = ? AND status IN ?", r.DeviceID, models.CollectionTaskSourceAgentCurrentPage, []string{models.TaskStatusCompleted, models.TaskStatusCompletedPartial}).Order("finished_at DESC, id DESC").First(&task).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return latestUnavailable("这台手机暂无成功的临时采集,请先采集商品")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if task.Status != models.TaskStatusCompleted || task.FinishedAt == nil || task.PDDProductID == nil {
|
||||
return latestUnavailable("最近一次临时采集不完整,请先补采;不会回退关联更早的商品")
|
||||
}
|
||||
var pdd models.PDDProduct
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pdd, *task.PDDProductID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return latestUnavailable("最近采集的 PDD 商品已不存在")
|
||||
}
|
||||
return err
|
||||
}
|
||||
if pdd.Status != "active" {
|
||||
return latestUnavailable("最近采集的 PDD 商品尚不可用,请先检查采集结果")
|
||||
}
|
||||
color, err := selectablePDDValues(pdd.SpecsJSON, RoleColor)
|
||||
if err != nil {
|
||||
return latestUnavailable("最近采集的 PDD 规格无效,请先补采")
|
||||
}
|
||||
size, err := selectablePDDValues(pdd.SpecsJSON, RoleSize)
|
||||
if err != nil || len(color)+len(size) == 0 {
|
||||
return latestUnavailable("最近采集的 PDD 缺少可用规格,请先补采")
|
||||
}
|
||||
result := tx.Model(&models.ShopeeProduct{}).Where("id = ? AND pdd_product_id IS NULL", id).Updates(map[string]any{"pdd_product_id": pdd.ID, "last_update_request_id": request.RequestID})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return contextVersionStale()
|
||||
}
|
||||
response, err = NewService(tx).Detail(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response.CollectionSource = &LatestCollectionSource{TaskID: task.ID, DeviceID: device.ID, PDDProductID: pdd.ID, RequiresSpecConfirmation: syb.ParseStatus == models.SYBParseStatusFailed && !syb.ManuallyConfirmed && !syb.AIConfirmed}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
var domain *ServiceError
|
||||
if errors.As(err, &domain) {
|
||||
return SaveResponse{}, err
|
||||
}
|
||||
return SaveResponse{}, internalError(err)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func latestFixture(t *testing.T) (*Service, uint64, LinkPDDRequest, models.CollectionTask) {
|
||||
t.Helper()
|
||||
db := openTestDB(t)
|
||||
device := models.AgentDevice{InstallID: uuid.NewString(), TokenDigest: uuid.NewString(), Status: models.DeviceStatusOffline}
|
||||
if err := db.Create(&device).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule := models.CollectionRule{Name: "synthetic", ContentJSON: "{}"}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
s := NewService(db)
|
||||
created, err := s.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: uuid.NewString(), Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "黑色", Source: ValueSourceManual}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
syb := models.SYBProduct{OrderCode: "synthetic", DetailID: 1, Quantity: 1, ParseStatus: "success", ShopeeProductID: &created.Product.ID}
|
||||
if err := db.Create(&syb).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
task := models.CollectionTask{DeviceID: &device.ID, RuleID: rule.ID, PDDProductID: &pdd.ID, Source: models.CollectionTaskSourceAgentCurrentPage, Status: models.TaskStatusCompleted, RuleSnapshot: "{}", FinishedAt: &now}
|
||||
if err := db.Create(&task).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return s, created.Product.ID, LinkPDDRequest{RequestID: uuid.NewString(), LatestCollection: &LatestCollectionRequest{DeviceID: device.ID, SYBProductID: syb.ID, SpecContextVersion: created.Product.SpecContextVersion}}, task
|
||||
}
|
||||
|
||||
func TestLatestCollectionOfflineAndOnlyIfUnlinked(t *testing.T) {
|
||||
s, id, req, task := latestFixture(t)
|
||||
result, err := s.LinkPDD(context.Background(), id, req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.CollectionSource.TaskID != task.ID || *result.Product.PDDProductID != *task.PDDProductID || result.Product.SpecContextVersion == req.LatestCollection.SpecContextVersion {
|
||||
t.Fatalf("invalid frozen response: %+v", result)
|
||||
}
|
||||
// Another request (or an ambiguous network retry) cannot overwrite the link.
|
||||
req.RequestID = uuid.NewString()
|
||||
if _, err := s.LinkPDD(context.Background(), id, req); err == nil || errCode(t, err) != CodeLinkConflict {
|
||||
t.Fatalf("expected conflict: %v", err)
|
||||
}
|
||||
other := seedPDDProduct(t, s.DB, "active")
|
||||
if _, err := s.LinkPDD(context.Background(), id, LinkPDDRequest{RequestID: uuid.NewString(), PDDProductID: other.ID}); err != nil {
|
||||
t.Fatalf("manual replacement regressed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestCollectionRejectsInvalidContextWithoutWrite(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
change func(*gorm.DB, *LinkPDDRequest, models.CollectionTask)
|
||||
}{
|
||||
{"partial", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
|
||||
db.Model(&task).Update("status", "completed_partial")
|
||||
}},
|
||||
{"disabled-device", func(db *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) {
|
||||
db.Model(&models.AgentDevice{}).Where("id = ?", r.LatestCollection.DeviceID).Update("status", "disabled")
|
||||
}},
|
||||
{"missing-device", func(_ *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) { r.LatestCollection.DeviceID += 100 }},
|
||||
{"admin-source", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
|
||||
db.Model(&task).Update("source", "admin")
|
||||
}},
|
||||
{"disabled-pdd", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
|
||||
db.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("status", "disabled")
|
||||
}},
|
||||
{"empty-specs", func(db *gorm.DB, _ *LinkPDDRequest, task models.CollectionTask) {
|
||||
db.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", "[]")
|
||||
}},
|
||||
{"stale-version", func(_ *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) {
|
||||
r.LatestCollection.SpecContextVersion = "stale"
|
||||
}},
|
||||
{"syb-link-changed", func(db *gorm.DB, r *LinkPDDRequest, _ models.CollectionTask) {
|
||||
db.Model(&models.SYBProduct{}).Where("id = ?", r.LatestCollection.SYBProductID).Update("shopee_product_id", nil)
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s, id, r, task := latestFixture(t)
|
||||
tc.change(s.DB, &r, task)
|
||||
if _, err := s.LinkPDD(context.Background(), id, r); err == nil {
|
||||
t.Fatal("unexpected successful write")
|
||||
}
|
||||
var product models.ShopeeProduct
|
||||
s.DB.First(&product, id)
|
||||
if product.PDDProductID != nil {
|
||||
t.Fatal("rejected request wrote association")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestCollectionOrdersByFinishThenIDAndDoesNotSkipPartial(t *testing.T) {
|
||||
s, id, r, first := latestFixture(t)
|
||||
newer := first
|
||||
newer.ID = 0
|
||||
newer.Status = models.TaskStatusCompletedPartial
|
||||
if err := s.DB.Create(&newer).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.LinkPDD(context.Background(), id, r); err == nil {
|
||||
t.Fatal("skipped latest partial")
|
||||
}
|
||||
// A newer ID with an older completion time must not win.
|
||||
older := newer
|
||||
older.ID = 0
|
||||
older.Status = models.TaskStatusCompleted
|
||||
ts := first.FinishedAt.Add(-time.Hour)
|
||||
older.FinishedAt = &ts
|
||||
if err := s.DB.Create(&older).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.LinkPDD(context.Background(), id, r); err == nil {
|
||||
t.Fatal("sorted by id instead of completion")
|
||||
}
|
||||
if err := s.DB.Model(&newer).Update("status", models.TaskStatusCompleted).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := s.LinkPDD(context.Background(), id, r)
|
||||
if err != nil || result.CollectionSource.TaskID != newer.ID {
|
||||
t.Fatalf("wrong latest result: %+v %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestCollectionKeepsParseConfirmationBoundary(t *testing.T) {
|
||||
for _, status := range []string{"success", "uncertain", "failed"} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
s, id, r, _ := latestFixture(t)
|
||||
s.DB.Model(&models.SYBProduct{}).Where("id = ?", r.LatestCollection.SYBProductID).Update("parse_status", status)
|
||||
result, err := s.LinkPDD(context.Background(), id, r)
|
||||
if err != nil || result.CollectionSource.RequiresSpecConfirmation != (status == "failed") {
|
||||
t.Fatalf("parse boundary: %+v %v", result, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientKeyCannotUseAdminCompositeLink(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.POST("/api/client/v1/shopee-products/:productId/link-pdd", Handler{}.LinkPDD)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/client/v1/shopee-products/1/link-pdd", strings.NewReader(`{"latestCollection":{"deviceId":1,"sybProductId":1,"specContextVersion":"x"}}`)))
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("unexpected status: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// SQLite has no row-level FOR UPDATE. A single test connection serializes
|
||||
// transactions to exercise concurrent callers; production MySQL locking must
|
||||
// additionally be verified in its deployment environment.
|
||||
func TestLatestCollectionConcurrentCallersOnlyOneWins(t *testing.T) {
|
||||
s, id, r, _ := latestFixture(t)
|
||||
sqlDB, err := s.DB.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
errors := make(chan error, 2)
|
||||
start := make(chan struct{})
|
||||
for range 2 {
|
||||
go func() { <-start; _, err := s.LinkPDD(context.Background(), id, r); errors <- err }()
|
||||
}
|
||||
close(start)
|
||||
wins := 0
|
||||
for range 2 {
|
||||
err := <-errors
|
||||
if err == nil {
|
||||
wins++
|
||||
} else if errCode(t, err) != CodeLinkConflict {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if wins != 1 {
|
||||
t.Fatalf("expected one write, got %d", wins)
|
||||
}
|
||||
}
|
||||
@@ -76,10 +76,8 @@ type ProductView struct {
|
||||
}
|
||||
|
||||
type SaveResponse struct {
|
||||
Product ProductView `json:"product"`
|
||||
Replayed bool `json:"replayed,omitempty"`
|
||||
AutoMatchTimeoutSeconds int `json:"autoMatchTimeoutSeconds,omitempty"`
|
||||
CollectionSource *LatestCollectionSource `json:"collectionSource,omitempty"`
|
||||
Product ProductView `json:"product"`
|
||||
Replayed bool `json:"replayed,omitempty"`
|
||||
}
|
||||
|
||||
type ListItemView struct {
|
||||
@@ -158,16 +156,6 @@ func (service *Service) Create(ctx context.Context, request CreateRequest) (Save
|
||||
return SaveResponse{Product: view}, err
|
||||
}
|
||||
|
||||
// DetailWithAutoMatchBudget exposes only a derived time budget, never AI settings.
|
||||
func (service *Service) DetailWithAutoMatchBudget(ctx context.Context, id uint64) (SaveResponse, error) {
|
||||
response, err := service.Detail(ctx, id)
|
||||
if err != nil {
|
||||
return SaveResponse{}, err
|
||||
}
|
||||
response.AutoMatchTimeoutSeconds, err = service.autoMatchTimeout(ctx)
|
||||
return response, err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- update (archive fields only)
|
||||
|
||||
type UpdateRequest struct {
|
||||
@@ -274,9 +262,8 @@ func (service *Service) List(ctx context.Context, request ListRequest) (ListResp
|
||||
// ---------------------------------------------------------------- link PDD
|
||||
|
||||
type LinkPDDRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
PDDProductID uint64 `json:"pddProductId"`
|
||||
LatestCollection *LatestCollectionRequest `json:"latestCollection,omitempty"`
|
||||
RequestID string `json:"requestId"`
|
||||
PDDProductID uint64 `json:"pddProductId"`
|
||||
}
|
||||
|
||||
// LinkPDD sets or changes the linked PDD product via the search-and-select
|
||||
@@ -287,9 +274,6 @@ func (service *Service) LinkPDD(ctx context.Context, id uint64, request LinkPDDR
|
||||
if _, err := uuid.Parse(strings.TrimSpace(request.RequestID)); err != nil {
|
||||
return SaveResponse{}, invalidRequest("requestId 必须是 UUID")
|
||||
}
|
||||
if request.LatestCollection != nil {
|
||||
return service.linkLatestCollection(ctx, id, request)
|
||||
}
|
||||
if request.PDDProductID == 0 {
|
||||
return SaveResponse{}, invalidRequest("pddProductId 必填")
|
||||
}
|
||||
|
||||
@@ -44,6 +44,30 @@ func seedAIParseCandidate(t *testing.T, db *gorm.DB, serverURL string, detailID
|
||||
return applied.SYBProduct
|
||||
}
|
||||
|
||||
func TestScheduledParseUsesUniqueCandidatesBeforeCallingAI(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
calls.Add(1)
|
||||
response.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer provider.Close()
|
||||
db := openTestDB(t)
|
||||
record := seedAIParseCandidate(t, db, provider.URL, 25102, "黑色 XL")
|
||||
run, _, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := sybimport.ProcessSpecAIParseRun(context.Background(), db, run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.First(&record, record.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.ParseStatus != models.SYBParseStatusSuccess || record.TargetColor != "黑色" || record.TargetSize != "XL" || record.AIConfirmed || calls.Load() != 0 {
|
||||
t.Fatalf("unique candidate must resolve deterministically, status=%s ai=%v calls=%d", record.ParseStatus, record.AIConfirmed, calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduledAIParseConfirmsClosedCandidatesAndDoesNotRepeat(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
||||
@@ -53,7 +77,7 @@ func TestScheduledAIParseConfirmsClosedCandidatesAndDoesNotRepeat(t *testing.T)
|
||||
}))
|
||||
defer provider.Close()
|
||||
db := openTestDB(t)
|
||||
record := seedAIParseCandidate(t, db, provider.URL, 19801, "黑色 XL")
|
||||
record := seedAIParseCandidate(t, db, provider.URL, 19801, "黑 XL")
|
||||
rawBefore := record.RawJSON
|
||||
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
||||
if err != nil || !created {
|
||||
@@ -98,7 +122,7 @@ func TestScheduledAIParseLeavesLowConfidenceUnmatchedForSameFingerprint(t *testi
|
||||
}))
|
||||
defer provider.Close()
|
||||
db := openTestDB(t)
|
||||
record := seedAIParseCandidate(t, db, provider.URL, 19802, "黑色 XL")
|
||||
record := seedAIParseCandidate(t, db, provider.URL, 19802, "黑 XL")
|
||||
for i := 0; i < 2; i++ {
|
||||
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
||||
if err != nil || !created {
|
||||
@@ -175,7 +199,7 @@ func TestScheduledAIParseRetriesProviderFailureAtMostThreeTimes(t *testing.T) {
|
||||
}))
|
||||
defer provider.Close()
|
||||
db := openTestDB(t)
|
||||
record := seedAIParseCandidate(t, db, provider.URL, 19805, "黑色 XL")
|
||||
record := seedAIParseCandidate(t, db, provider.URL, 19805, "黑 XL")
|
||||
for attempt := 1; attempt <= 4; attempt++ {
|
||||
run, created, err := sybimport.StartSpecAIParseRun(context.Background(), db, uuid.NewString(), 20)
|
||||
if err != nil || !created {
|
||||
|
||||
@@ -96,6 +96,9 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if shopeeProduct != nil {
|
||||
parsed = ParseWithCandidates(detail.ProductSpec, shopeeProduct.SpecsJSON)
|
||||
}
|
||||
if parsed.Status == models.SYBParseStatusSuccess && shopeeProduct != nil {
|
||||
if err := mergeParsedSpec(tx, shopeeProduct.ID, parsed); err != nil {
|
||||
return err
|
||||
@@ -271,6 +274,9 @@ func fillEmptyArchiveFields(tx *gorm.DB, product *models.ShopeeProduct, detail D
|
||||
// review but are never pushed into the shared archive, so a messy raw string
|
||||
// never pollutes the spec list other screens read from.
|
||||
func mergeParsedSpec(tx *gorm.DB, shopeeProductID uint64, parsed ParseResult) error {
|
||||
if parsed.matchedCandidates {
|
||||
return nil
|
||||
}
|
||||
var product models.ShopeeProduct
|
||||
if err := tx.First(&product, shopeeProductID).Error; err != nil {
|
||||
return err
|
||||
|
||||
@@ -34,6 +34,8 @@ type ParseResult struct {
|
||||
Size string
|
||||
Status string
|
||||
Note string
|
||||
// Already present in the archive; do not re-merge or modify mappings.
|
||||
matchedCandidates bool
|
||||
}
|
||||
|
||||
// Parse implements the #41/#216 rule: split on the last comma, strip 【...】
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package sybimport
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
)
|
||||
|
||||
// ParseWithCandidates only rescues uncertain parses against previously stored
|
||||
// Shopee labels. It never uses the current unconfirmed input as its own evidence.
|
||||
func ParseWithCandidates(raw, specsJSON string) ParseResult {
|
||||
baseline := Parse(raw)
|
||||
if baseline.Status != models.SYBParseStatusUncertain {
|
||||
return baseline
|
||||
}
|
||||
specs, err := shopeeproduct.Unmarshal(specsJSON)
|
||||
if err != nil || shopeeproduct.Validate(specs) != nil {
|
||||
return baseline
|
||||
}
|
||||
for _, dimension := range specs {
|
||||
if dimension.Role != shopeeproduct.RoleColor && dimension.Role != shopeeproduct.RoleSize && len(dimension.Values) > 0 {
|
||||
return baseline
|
||||
}
|
||||
}
|
||||
colors, sizes, ambiguous := closedShopeeCandidates(specs)
|
||||
if ambiguous || len(colors)+len(sizes) == 0 {
|
||||
return baseline
|
||||
}
|
||||
if len(colors) == 0 {
|
||||
colors = []string{""}
|
||||
}
|
||||
if len(sizes) == 0 {
|
||||
sizes = []string{""}
|
||||
}
|
||||
input := normalizeCandidateSpec(stripBrackets(raw))
|
||||
type pair struct{ color, size string }
|
||||
matches := map[pair]bool{}
|
||||
for _, color := range colors {
|
||||
for _, size := range sizes {
|
||||
variants := []string{color + "," + size, size + "," + color, color + " " + size, size + " " + color}
|
||||
if color == "" || size == "" {
|
||||
variants = []string{color + size}
|
||||
}
|
||||
for _, variant := range variants {
|
||||
if input == normalizeCandidateSpec(variant) {
|
||||
matches[pair{color, size}] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(matches) != 1 {
|
||||
return baseline
|
||||
}
|
||||
for match := range matches {
|
||||
return ParseResult{Color: match.color, Size: match.size, Status: models.SYBParseStatusSuccess,
|
||||
Note: "已按蝦皮既有规格候选唯一核对", matchedCandidates: true}
|
||||
}
|
||||
return baseline
|
||||
}
|
||||
|
||||
func normalizeCandidateSpec(value string) string {
|
||||
value = strings.ReplaceAll(value, ",", ",")
|
||||
parts := strings.Split(value, ",")
|
||||
for i, part := range parts {
|
||||
parts[i] = strings.Join(strings.Fields(part), " ")
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package sybimport_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
"go-admin/app/goauto/sybimport"
|
||||
)
|
||||
|
||||
func candidateSpecs(colors, sizes []string) string {
|
||||
dimensions := []shopeeproduct.SpecDimension{}
|
||||
for _, input := range []struct {
|
||||
role string
|
||||
values []string
|
||||
}{{"color", colors}, {"size", sizes}} {
|
||||
if len(input.values) == 0 {
|
||||
continue
|
||||
}
|
||||
dimension := shopeeproduct.SpecDimension{Name: input.role, Role: input.role}
|
||||
for _, value := range input.values {
|
||||
dimension.Values = append(dimension.Values, shopeeproduct.SpecValue{Name: value, Source: shopeeproduct.ValueSourceImport})
|
||||
}
|
||||
dimensions = append(dimensions, dimension)
|
||||
}
|
||||
encoded, _ := json.Marshal(dimensions)
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func TestCandidateParseRescuesOnlyUniqueWholeInput(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, raw string
|
||||
colors, sizes []string
|
||||
color, size string
|
||||
}{
|
||||
{"combined color", "黑色+白色,L", []string{"黑色+白色", "黑色"}, []string{"L", "XL"}, "黑色+白色", "L"},
|
||||
{"spaces in color", "浅 灰色,XL", []string{"浅 灰色"}, []string{"XL"}, "浅 灰色", "XL"},
|
||||
{"full width comma", "黑色,XL", []string{"黑色"}, []string{"XL"}, "黑色", "XL"},
|
||||
{"reverse full width", "XL,黑色", []string{"黑色"}, []string{"XL"}, "黑色", "XL"},
|
||||
{"space separator", "黑色 XL", []string{"黑色"}, []string{"XL"}, "黑色", "XL"},
|
||||
{"size only", "均碼", nil, []string{"均碼"}, "", "均碼"},
|
||||
{"color only", "黑色+白色", []string{"黑色+白色"}, nil, "黑色+白色", ""},
|
||||
{"weight description exact", "黑色,XL 建議55.5-60.0公斤穿", []string{"黑色"}, []string{"XL 建議55.5-60.0公斤穿"}, "黑色", "XL 建議55.5-60.0公斤穿"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := sybimport.ParseWithCandidates(test.raw, candidateSpecs(test.colors, test.sizes))
|
||||
if got.Status != models.SYBParseStatusSuccess || got.Color != test.color || got.Size != test.size {
|
||||
t.Fatalf("unexpected parse: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCandidateParsePreservesAmbiguityAndNormalResults(t *testing.T) {
|
||||
for _, test := range []struct{ raw, specs string }{
|
||||
{"", candidateSpecs([]string{"黑色"}, []string{"XL"})},
|
||||
{"黑色 XL", "broken"},
|
||||
{"黑色 XL", "[]"},
|
||||
{"XL", candidateSpecs([]string{"黑色"}, []string{"XL"})},
|
||||
{"黑色+白色 XL", candidateSpecs([]string{"黑色", "白色"}, []string{"XL"})},
|
||||
{"黑色 XL 多余描述", candidateSpecs([]string{"黑色"}, []string{"XL"})},
|
||||
{"黑色 XL 建議55.5-60.0公斤穿", candidateSpecs([]string{"黑色"}, []string{"XL"})},
|
||||
{"黑色,XL", candidateSpecs([]string{"黑色", "XL"}, []string{"XL", "黑色"})},
|
||||
{"浅 灰色 XL", candidateSpecs([]string{"浅 灰色", "浅 灰色"}, []string{"XL"})},
|
||||
{"白色,L", candidateSpecs([]string{"黑色"}, []string{"XL"})},
|
||||
{"黑色 XL", `[{"name":"a","role":"color","values":[{"name":"黑色"}]},{"name":"b","role":"color","values":[{"name":"白色"}]},{"name":"s","role":"size","values":[{"name":"XL"}]}]`},
|
||||
{"黑色 XL", `[{"name":"a","role":"other","values":[{"name":"黑色 XL"}]}]`},
|
||||
} {
|
||||
if got, want := sybimport.ParseWithCandidates(test.raw, test.specs), sybimport.Parse(test.raw); got != want {
|
||||
t.Fatalf("raw %q changed: %+v vs %+v", test.raw, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportAndReparseReuseExistingCandidatesWithoutChangingMappings(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
ctx := context.Background()
|
||||
detail := sybimport.DetailInput{ID: 25101, ProductID: 25101, ProductQty: 1, ProductPrice: 1, ProductSpec: "黑色+白色,XL", Raw: json.RawMessage(`{"productSpec":"黑色+白色,XL"}`)}
|
||||
order := sybimport.OrderInput{Code: "test-251", ShopName: "test"}
|
||||
first, err := sybimport.ApplyDetail(ctx, db, order, detail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.SYBProduct.ParseStatus != models.SYBParseStatusUncertain {
|
||||
t.Fatal("must not self-confirm without candidates")
|
||||
}
|
||||
specs := candidateSpecs([]string{"黑色+白色"}, []string{"XL"})
|
||||
if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", *first.SYBProduct.ShopeeProductID).Update("specs_json", specs).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outcome, err := sybimport.Reparse(ctx, db, first.SYBProduct.ID, false)
|
||||
if err != nil || outcome.NewStatus != models.SYBParseStatusSuccess {
|
||||
t.Fatalf("reparse: %+v %v", outcome, err)
|
||||
}
|
||||
second, err := sybimport.ApplyDetail(ctx, db, order, detail)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.SYBProduct.TargetColor != "黑色+白色" || second.SYBProduct.TargetSize != "XL" || second.SYBProduct.ParseStatus != models.SYBParseStatusSuccess {
|
||||
t.Fatalf("import differs: %+v", second.SYBProduct)
|
||||
}
|
||||
if second.SYBProduct.RawJSON != first.SYBProduct.RawJSON {
|
||||
t.Fatal("raw source changed")
|
||||
}
|
||||
if second.ShopeeProduct.SpecsJSON != specs {
|
||||
t.Fatal("candidate confirmation must not rewrite archive dimensions or mappings")
|
||||
}
|
||||
if _, err := sybimport.ManualCorrect(ctx, db, first.SYBProduct.ID, "人工颜色", "人工尺码"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outcome, err = sybimport.Reparse(ctx, db, first.SYBProduct.ID, false)
|
||||
if err != nil || outcome.Outcome != sybimport.ReparseOutcomeSkippedManual {
|
||||
t.Fatalf("manual overwritten: %+v %v", outcome, err)
|
||||
}
|
||||
third, err := sybimport.ApplyDetail(ctx, db, order, detail)
|
||||
if err != nil || third.SYBProduct.TargetColor != "人工颜色" {
|
||||
t.Fatalf("reimport overwrote manual: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,14 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
|
||||
return fmt.Errorf("syb product %d: stored raw json is not decodable: %w", record.ID, err)
|
||||
}
|
||||
parsed := Parse(raw.ProductSpec)
|
||||
if parsed.Status == models.SYBParseStatusUncertain && record.ShopeeProductID != nil {
|
||||
var product models.ShopeeProduct
|
||||
if err := tx.First(&product, *record.ShopeeProductID).Error; err == nil {
|
||||
parsed = ParseWithCandidates(raw.ProductSpec, product.SpecsJSON)
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
outcome.NewStatus = parsed.Status
|
||||
|
||||
if parsed.Color == record.TargetColor && parsed.Size == record.TargetSize && parsed.Status == record.ParseStatus {
|
||||
|
||||
@@ -255,9 +255,7 @@ func (service *Service) IdentifyCurrentPage(ctx context.Context, taskID uint64,
|
||||
return serviceError(CodeCurrentPageIdentityConflict, "当前商品与任务已识别商品不一致")
|
||||
}
|
||||
now := service.Now()
|
||||
// Validate the locked task's real state; an empty model fails BeforeSave
|
||||
// when a retried current-page task already has a product binding.
|
||||
if err := tx.Model(&record).Where("id = ?", record.ID).Updates(map[string]any{
|
||||
if err := tx.Model(&models.CollectionTask{}).Where("id = ?", record.ID).Updates(map[string]any{
|
||||
"identify_request_id": request.RequestID, "identity_resolved_at": now,
|
||||
}).Error; err != nil {
|
||||
return internalError(err)
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/rulecontract"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestCurrentPageReidentifyAfterSameTaskRetry(t *testing.T) {
|
||||
db := openTaskDatabase(t)
|
||||
_, token := registerTaskDeviceWithCapabilities(t, db, "retry-device", []string{
|
||||
rulecontract.CapabilitySchemaV2,
|
||||
rulecontract.CapabilityPDDProductDetailV1,
|
||||
rulecontract.CapabilityPDDCurrentPageShareV1,
|
||||
})
|
||||
_, otherToken := registerTaskDevice(t, db, "other-device")
|
||||
rule := models.CollectionRule{Name: "retry-rule", ContentJSON: v2TaskRuleSnapshot()}
|
||||
if err := db.Create(&rule).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&models.AgentManualCollectionSetting{ID: 1, RuleID: rule.ID}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := newTaskService(db)
|
||||
ctx := context.Background()
|
||||
created, err := service.CreateCurrentPage(ctx, CurrentPageCreateRequest{RequestID: uuid.NewString()}, token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
load := func() models.CollectionTask {
|
||||
t.Helper()
|
||||
var record models.CollectionTask
|
||||
if err := db.First(&record, created.TaskID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return record
|
||||
}
|
||||
request := CurrentPageIdentifyRequest{
|
||||
RequestID: uuid.NewString(), ShareURL: "https://mobile.yangkeduo.com/goods.html?goods_id=700000000255",
|
||||
}
|
||||
// Direct links resolve locally: the regression never calls PDD or a live DB.
|
||||
identity, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const failureCode = "SPEC_PANEL_EVIDENCE_NOT_MATCHED"
|
||||
if _, err := service.SubmitFailure(ctx, created.TaskID, FailRequest{
|
||||
RequestID: uuid.NewString(), Code: failureCode, Message: "Synthetic panel failure",
|
||||
}, token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reset, err := service.ResetForDevice(ctx, created.TaskID, ActionRequest{RequestID: uuid.NewString()}, token)
|
||||
if err != nil || reset.TaskID != created.TaskID || reset.AttemptNumber != 2 || reset.Status != models.TaskStatusPending {
|
||||
t.Fatalf("same-task reset: %+v, %v", reset, err)
|
||||
}
|
||||
resetRecord := load()
|
||||
if resetRecord.PDDProductID == nil || *resetRecord.PDDProductID != identity.PDDProductID ||
|
||||
resetRecord.IdentifyRequestID != nil || resetRecord.IdentityResolvedAt != nil {
|
||||
t.Fatal("reset must keep the product and clear per-attempt identification")
|
||||
}
|
||||
if _, err := service.Claim(ctx, created.TaskID, ActionRequest{RequestID: uuid.NewString()}, token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.Start(ctx, created.TaskID, ActionRequest{RequestID: uuid.NewString()}, token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := load()
|
||||
request.RequestID = uuid.NewString()
|
||||
for _, tc := range []struct {
|
||||
name, token, url, code string
|
||||
}{
|
||||
{"other device", otherToken, request.ShareURL, CodeTaskNotFound},
|
||||
{"other product", token, "https://mobile.yangkeduo.com/goods.html?goods_id=700000000256", CodeCurrentPageIdentityConflict},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := service.IdentifyCurrentPage(ctx, created.TaskID, CurrentPageIdentifyRequest{RequestID: uuid.NewString(), ShareURL: tc.url}, tc.token)
|
||||
if taskErrorCode(t, err) != tc.code {
|
||||
t.Fatalf("expected %s, got %v", tc.code, err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, load()) {
|
||||
t.Fatal("rejected identification changed the task")
|
||||
}
|
||||
})
|
||||
}
|
||||
reidentified, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token)
|
||||
if err != nil {
|
||||
t.Fatalf("reidentify same product after retry: %v", err)
|
||||
}
|
||||
if reidentified.TaskID != created.TaskID || reidentified.PDDProductID != identity.PDDProductID ||
|
||||
reidentified.GoodsID != identity.GoodsID || reidentified.URL != identity.URL || !reidentified.Replayed {
|
||||
t.Fatalf("identity changed: %+v", reidentified)
|
||||
}
|
||||
after := load()
|
||||
if after.IdentifyRequestID == nil || *after.IdentifyRequestID != request.RequestID || after.IdentityResolvedAt == nil {
|
||||
t.Fatal("new attempt identification was not persisted")
|
||||
}
|
||||
// Apart from identity metadata and the normal update timestamp, every field
|
||||
// (including status, device, rule, lease and uniqueness slots) must survive.
|
||||
expected := before
|
||||
expected.IdentifyRequestID = after.IdentifyRequestID
|
||||
expected.IdentityResolvedAt = after.IdentityResolvedAt
|
||||
expected.UpdatedAt = after.UpdatedAt
|
||||
if !reflect.DeepEqual(expected, after) {
|
||||
t.Fatal("reidentification changed unrelated task fields")
|
||||
}
|
||||
service.ResolveCurrentPageShare = func(context.Context, string) (ResolvedPDDShare, error) {
|
||||
t.Fatal("replay must not resolve the link again")
|
||||
return ResolvedPDDShare{}, nil
|
||||
}
|
||||
replay, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token)
|
||||
if err != nil || replay != reidentified || !reflect.DeepEqual(after, load()) {
|
||||
t.Fatalf("replay changed identity or task: %+v, %v", replay, err)
|
||||
}
|
||||
service.ResolveCurrentPageShare = nil
|
||||
title := "Synthetic collected product"
|
||||
result, err := service.SubmitResult(ctx, created.TaskID, ResultRequest{
|
||||
RequestID: uuid.NewString(), Status: models.TaskStatusCompleted,
|
||||
Product: ResultProduct{PDDGoodsID: identity.GoodsID, Title: &title},
|
||||
Dimensions: []ResultDimension{{Key: "color", Name: "颜色", Values: []string{"黑色"}}},
|
||||
ColorPrices: []ResultColorPrice{{Color: "黑色", PriceCent: 1200}},
|
||||
SKUs: []ResultSKU{{Specs: map[string]string{"color": "黑色"}, PriceCent: 1200, Available: true}},
|
||||
}, token)
|
||||
if err != nil || result.Task.ID != created.TaskID || result.Task.Status != models.TaskStatusCompleted {
|
||||
t.Fatalf("complete retried collection: %+v, %v", result, err)
|
||||
}
|
||||
terminal := load()
|
||||
request.RequestID = uuid.NewString()
|
||||
if _, err := service.IdentifyCurrentPage(ctx, created.TaskID, request, token); taskErrorCode(t, err) != CodeTaskStateConflict {
|
||||
t.Fatalf("expected terminal-state rejection: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(terminal, load()) {
|
||||
t.Fatal("terminal identification changed the task")
|
||||
}
|
||||
var archived []models.CollectionTaskAttempt
|
||||
if err := db.Where("task_id = ?", created.TaskID).Find(&archived).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(archived) != 1 || archived[0].AttemptNumber != 1 || archived[0].Status != models.TaskStatusFailed ||
|
||||
archived[0].ErrorCode == nil || *archived[0].ErrorCode != failureCode {
|
||||
t.Fatal("original failed attempt was not preserved")
|
||||
}
|
||||
for _, model := range []any{&models.CollectionTask{}, &models.PDDProduct{}} {
|
||||
var count int64
|
||||
if err := db.Model(model).Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatalf("retry duplicated task/product: count=%d, %v", count, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,16 +8,16 @@ export function createShopeeProduct(data) {
|
||||
return request({ url: '/api/admin/v1/shopee-products', method: 'post', data })
|
||||
}
|
||||
|
||||
export function getShopeeProduct(productId, options = {}) {
|
||||
return request({ url: `/api/admin/v1/shopee-products/${productId}`, method: 'get', ...options })
|
||||
export function getShopeeProduct(productId) {
|
||||
return request({ url: `/api/admin/v1/shopee-products/${productId}`, method: 'get' })
|
||||
}
|
||||
|
||||
export function updateShopeeProduct(productId, data) {
|
||||
return request({ url: `/api/admin/v1/shopee-products/${productId}`, method: 'patch', data })
|
||||
}
|
||||
|
||||
export function linkShopeeProductPdd(productId, data, options = {}) {
|
||||
return request({ url: `/api/admin/v1/shopee-products/${productId}/link-pdd`, method: 'post', data, ...options })
|
||||
export function linkShopeeProductPdd(productId, data) {
|
||||
return request({ url: `/api/admin/v1/shopee-products/${productId}/link-pdd`, method: 'post', data })
|
||||
}
|
||||
|
||||
export function restoreShopeeProduct(productId, data) {
|
||||
@@ -66,11 +66,8 @@ export function suggestShopeeSizeMappings(productId, data) {
|
||||
return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/suggest-sizes`, method: 'post', data, timeout: aiSuggestTimeoutMs })
|
||||
}
|
||||
|
||||
export function autoMatchShopeeSpecMappings(productId, data, timeoutSeconds) {
|
||||
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 16 || timeoutSeconds > 1210) {
|
||||
return Promise.reject(new Error('匹配等待时间不可用,请刷新页面后重试'))
|
||||
}
|
||||
return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/auto-match`, method: 'post', data, timeout: (timeoutSeconds + 10) * 1000, suppressErrorMessage: true })
|
||||
export function autoMatchShopeeSpecMappings(productId, data) {
|
||||
return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/auto-match`, method: 'post', data, timeout: aiSuggestTimeoutMs })
|
||||
}
|
||||
|
||||
export function startShopeeSpecAutoMatchRun(data) {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
// Separate from the purchase-device preference; browser origin provides the
|
||||
// outer isolation and API environment + user identify the inner namespace.
|
||||
function key(userId) {
|
||||
const id = Number(userId)
|
||||
return Number.isSafeInteger(id) && id > 0 ? `goauto:quick-link-device:${encodeURIComponent(process.env.VUE_APP_BASE_API || '/')}:user:${id}` : null
|
||||
}
|
||||
|
||||
export function readQuickLinkDevice(userId) {
|
||||
try {
|
||||
const name = key(userId)
|
||||
if (!name) return null
|
||||
const id = JSON.parse(localStorage.getItem(name) || 'null')
|
||||
return Number.isSafeInteger(id) && id > 0 ? id : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
export function rememberQuickLinkDevice(userId, deviceId) {
|
||||
try {
|
||||
const name = key(userId)
|
||||
if (!name || !Number.isSafeInteger(deviceId) || deviceId <= 0) return false
|
||||
localStorage.setItem(name, JSON.stringify(deviceId))
|
||||
return true
|
||||
} catch { return false }
|
||||
}
|
||||
@@ -13,8 +13,8 @@
|
||||
<template #default><el-button link type="primary" @click="reload">重新加载</el-button></template>
|
||||
</el-alert>
|
||||
<template v-if="detail.product">
|
||||
<div class="drawer-actions"><el-tag :type="mappingMeta(detail.product).type">{{ mappingMeta(detail.product).label }}</el-tag><el-button type="primary" :disabled="detail.aiMatching" @click="openEdit">编辑档案</el-button></div>
|
||||
<el-descriptions :column="2" border :inert="detail.aiMatching">
|
||||
<div class="drawer-actions"><el-tag :type="mappingMeta(detail.product).type">{{ mappingMeta(detail.product).label }}</el-tag><el-button type="primary" @click="openEdit">编辑档案</el-button></div>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="虾皮商品ID">{{ detail.product.shopeeItemId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="店铺">{{ detail.product.shopName || '未填写' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="标题" :span="2">{{ detail.product.title || '未填写' }}</el-descriptions-item>
|
||||
@@ -27,10 +27,7 @@
|
||||
<el-alert title="成功匹配会直接保存并可用于采购;未通过置信度或候选校验的项目保持未匹配。不会修改蝦皮或 PDD 的原始颜色和尺码。" type="info" :closable="false" show-icon class="notice" />
|
||||
<el-alert v-if="!detail.product.pddProductId" title="请先关联 PDD 商品,再进行规格匹配。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-alert v-else-if="detail.pddLoaded && !pddSpecValues('color').length && !pddSpecValues('size').length" title="关联的 PDD 商品暂无可用规格,请先完成商品采集。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-alert v-if="detail.matchMessage" :title="detail.matchMessage" :type="detail.matchMessageType" :closable="false" show-icon class="notice" role="status" aria-live="polite">
|
||||
<template #default><el-button v-if="!detail.aiMatching" link type="primary" @click="reload">刷新查看</el-button></template>
|
||||
</el-alert>
|
||||
<el-tabs v-model="detail.activeSpecTab" class="mapping-tabs" :inert="detail.aiMatching">
|
||||
<el-tabs v-model="detail.activeSpecTab" class="mapping-tabs">
|
||||
<el-tab-pane label="颜色匹配" name="color">
|
||||
<el-empty v-if="!specRows('color').length" description="虾皮商品暂无颜色规格" />
|
||||
<el-table v-else :data="specRows('color')" :row-class-name="mappingRowClass" border size="small">
|
||||
@@ -51,10 +48,10 @@
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<div class="section-heading"><h3>添加规格值</h3></div>
|
||||
<div class="quick-row" :inert="detail.aiMatching"><span class="quick-label">维度</span><el-select v-model="addValueForm.role" size="small" style="width:110px"><el-option label="颜色" value="color" /><el-option label="尺码" value="size" /><el-option label="其他" value="other" /></el-select><el-input v-model="addValueForm.dimension" size="small" placeholder="维度名称,如 颜色 / 尺码" style="width:160px" /><el-input v-model="addValueForm.name" size="small" placeholder="规格值名称" style="width:160px" /><el-button size="small" type="primary" @click="submitAddValue">+ 添加规格值</el-button></div>
|
||||
<div class="quick-row"><span class="quick-label">维度</span><el-select v-model="addValueForm.role" size="small" style="width:110px"><el-option label="颜色" value="color" /><el-option label="尺码" value="size" /><el-option label="其他" value="other" /></el-select><el-input v-model="addValueForm.dimension" size="small" placeholder="维度名称,如 颜色 / 尺码" style="width:160px" /><el-input v-model="addValueForm.name" size="small" placeholder="规格值名称" style="width:160px" /><el-button size="small" type="primary" @click="submitAddValue">+ 添加规格值</el-button></div>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer><el-button @click="closeDetail">关闭</el-button><el-button type="primary" :loading="detail.saving" :disabled="detail.aiMatching || !hasMappingChanges" @click="saveAllMappings">保存修改</el-button></template>
|
||||
<template #footer><el-button @click="closeDetail">关闭</el-button><el-button type="primary" :loading="detail.saving" :disabled="!hasMappingChanges" @click="saveAllMappings">保存修改</el-button></template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="pddPicker.open" title="搜索并选择 PDD 商品" width="860px" append-to-body :close-on-click-modal="false">
|
||||
@@ -98,7 +95,7 @@ export default {
|
||||
emits: ['update:modelValue', 'product-updated', 'mapping-updated', 'pdd-linked', 'purchase-created', 'switch-cancelled'],
|
||||
data() {
|
||||
return {
|
||||
detailDrawerOpen: false, detail: this.emptyDetail(), loadError: '', requestGeneration: 0, loadedProductId: null, matchOperation: 0,
|
||||
detailDrawerOpen: false, detail: this.emptyDetail(), loadError: '', requestGeneration: 0, loadedProductId: null,
|
||||
pddPicker: { open: false, loading: false, keyword: '', items: [] }, pddDetail: { open: false, productId: null },
|
||||
edit: { open: false, saving: false, productId: null }, editData: { title: '', shopName: '', imageUrl: '', salePriceCent: null },
|
||||
addValueForm: { dimension: '', role: 'other', name: '' }
|
||||
@@ -108,7 +105,7 @@ export default {
|
||||
allSpecRows() { return this.detail.product ? this.detail.product.specs.flatMap(d => d.values) : [] },
|
||||
hasMappingChanges() { return this.allSpecRows.some(row => (row.pddValueDraft || '') !== (row.originalPddValue || '')) },
|
||||
mappingProgressText() { const rows = this.allSpecRows.filter(row => row.role === 'color' || row.role === 'size'); const valid = rows.filter(row => row.pddValueDraft && this.pddValueExists(row.role, row.pddValueDraft)).length; return rows.length ? `${valid}/${rows.length} 已匹配` : '暂无可匹配规格' },
|
||||
canAutoMatch() { return !this.detail.aiMatching && !this.detail.loading && !this.detail.saving && !this.edit.open && !this.pddPicker.open && !this.pddDetail.open && this.detail.pddLoaded && !this.hasMappingChanges && ['color', 'size'].some(role => this.specRows(role).length > 0 && this.pddSpecValues(role).length > 0) }
|
||||
canAutoMatch() { return this.detail.pddLoaded && !this.hasMappingChanges && ['color', 'size'].some(role => this.specRows(role).length > 0 && this.pddSpecValues(role).length > 0) }
|
||||
},
|
||||
watch: {
|
||||
modelValue: { immediate: true, handler(open) { if (open) this.requestSwitch(this.productId); else this.closeInternals() } },
|
||||
@@ -117,17 +114,17 @@ export default {
|
||||
action(value) { if (value === 'link_pdd' && this.detail.product && !this.pddPicker.open) this.openPddPicker() }
|
||||
},
|
||||
methods: {
|
||||
emptyDetail() { return { loading: false, saving: false, aiMatching: false, matchMessage: '', matchMessageType: 'info', product: null, pddProduct: null, pddLoaded: false, activeSpecTab: 'color', highlightColor: '' } },
|
||||
emptyDetail() { return { loading: false, saving: false, aiMatching: false, product: null, pddProduct: null, pddLoaded: false, activeSpecTab: 'color', highlightColor: '' } },
|
||||
validProductId(id) { return Number.isInteger(Number(id)) && Number(id) > 0 },
|
||||
isCurrent(generation, id) { return this.modelValue && this.detailDrawerOpen && generation === this.requestGeneration && Number(this.productId) === Number(id) },
|
||||
isCurrent(generation, id) { return this.modelValue && generation === this.requestGeneration && Number(this.productId) === Number(id) },
|
||||
async confirmDiscard() { if (!this.hasMappingChanges) return true; return this.$confirm('当前有未保存的规格匹配,确定放弃吗?', '放弃修改?', { type: 'warning', confirmButtonText: '放弃修改', cancelButtonText: '继续编辑' }).then(() => true).catch(() => false) },
|
||||
async requestSwitch(id) {
|
||||
if (!this.validProductId(id)) { this.loadError = '虾皮商品编号无效'; this.detailDrawerOpen = true; return }
|
||||
if (this.loadedProductId === Number(id) && this.detail.product) { this.detailDrawerOpen = true; return }
|
||||
if (this.detail.product && !await this.confirmDiscard()) { this.$emit('switch-cancelled', this.loadedProductId); return }
|
||||
await this.loadProduct(Number(id))
|
||||
},
|
||||
async loadProduct(id) {
|
||||
this.finishMatchView()
|
||||
const generation = ++this.requestGeneration
|
||||
this.loadedProductId = null; this.detailDrawerOpen = true; this.detail = { ...this.emptyDetail(), loading: true, highlightColor: String(this.targetColor || '').trim() }; this.loadError = ''; this.pddPicker.open = false; this.pddDetail.open = false
|
||||
try {
|
||||
@@ -141,14 +138,13 @@ export default {
|
||||
this.loadError = error?.response?.data?.message || error?.response?.data?.msg || '虾皮商品详情加载失败,请稍后重试'
|
||||
} finally { if (this.isCurrent(generation, id)) this.detail.loading = false }
|
||||
},
|
||||
async reload() { if (this.detail.aiMatching || !await this.confirmDiscard()) return; return this.loadProduct(Number(this.productId)) },
|
||||
finishMatchView() { this.matchOperation++; this.detail.aiMatching = false; this.detail.matchMessage = '' },
|
||||
closeInternals() { this.finishMatchView(); this.requestGeneration++; this.detailDrawerOpen = false; this.pddPicker.open = false; this.pddDetail.open = false; this.edit.open = false },
|
||||
async beforeCloseDetail(done) { if (this.pddDetail.open) return; if (!await this.confirmDiscard()) return; this.finishMatchView(); done(); this.requestGeneration++; this.$emit('update:modelValue', false) },
|
||||
reload() { return this.loadProduct(Number(this.productId)) },
|
||||
closeInternals() { this.requestGeneration++; this.detailDrawerOpen = false; this.pddPicker.open = false; this.pddDetail.open = false; this.edit.open = false },
|
||||
async beforeCloseDetail(done) { if (this.pddDetail.open) return; if (!await this.confirmDiscard()) return; done(); this.requestGeneration++; this.$emit('update:modelValue', false) },
|
||||
closeDetail() { this.beforeCloseDetail(() => { this.detailDrawerOpen = false }) },
|
||||
withDraftMappings(product) { return { ...product, specs: (product.specs || []).map(d => ({ ...d, values: d.values.map(v => ({ ...v, dimensionName: d.name, role: d.role, pddValueDraft: v.mapping ? v.mapping.pddValue : '', originalPddValue: v.mapping ? v.mapping.pddValue : '', draftSource: v.mapping ? v.mapping.source : 'manual', previewReason: '', draftConfidence: null })) })) } },
|
||||
async loadLinkedPdd(pddId, generation = this.requestGeneration, productId = this.loadedProductId) { this.detail.pddLoaded = false; this.detail.pddProduct = null; try { const r = await getPddProduct(pddId); if (!this.isCurrent(generation, productId)) return; this.detail.pddProduct = r.data.product } finally { if (this.isCurrent(generation, productId)) this.detail.pddLoaded = true } },
|
||||
async refreshDetail(eventName = 'mapping-updated') { const id = this.loadedProductId; const generation = this.requestGeneration; const r = await getShopeeProduct(id, { suppressErrorMessage: this.detail.aiMatching }); if (!this.isCurrent(generation, id)) return; this.detail.product = this.withDraftMappings(r.data.product); if (this.detail.product.pddProductId) await this.loadLinkedPdd(this.detail.product.pddProductId, generation, id); if (this.isCurrent(generation, id)) this.$emit(eventName, { id, product: this.detail.product }) },
|
||||
async refreshDetail(eventName = 'mapping-updated') { const id = this.loadedProductId; const generation = this.requestGeneration; const r = await getShopeeProduct(id); if (!this.isCurrent(generation, id)) return; this.detail.product = this.withDraftMappings(r.data.product); if (this.detail.product.pddProductId) await this.loadLinkedPdd(this.detail.product.pddProductId, generation, id); this.$emit(eventName, { id, product: this.detail.product }) },
|
||||
priceText(row) { if (row.salePriceCent === null || row.salePriceCent === undefined) return '—'; return `${row.currency || ''} ${(row.salePriceCent / 100).toFixed(2)}` },
|
||||
mappingMeta(product) { if (!product) return { label: '-', type: 'info' }; if (!product.pddProductId) return { label: '未关联 PDD 商品', type: 'info' }; const values = (product.specs || []).flatMap(d => d.values); if (!values.length) return { label: '待完善', type: 'warning' }; const confirmed = values.filter(v => v.mapping && v.mapping.status === 'confirmed' && (!this.detail.pddLoaded || this.pddValueExists(v.role || '', v.mapping.pddValue))).length; return confirmed === values.length ? { label: '可采购', type: 'success' } : { label: `待完善 · ${confirmed}/${values.length} 已确认`, type: 'warning' } },
|
||||
openPddPicker() { this.pddPicker = { open: true, loading: false, keyword: '', items: [] }; this.searchPddPicker() },
|
||||
@@ -165,60 +161,7 @@ export default {
|
||||
markManual(row) { row.draftSource = 'manual'; row.previewReason = ''; row.draftConfidence = null },
|
||||
clearDraft(row) { row.pddValueDraft = ''; row.draftSource = 'manual'; row.previewReason = ''; row.draftConfidence = null },
|
||||
mappingRowClass({ row }) { return this.detail.highlightColor && row.role === 'color' && row.name === this.detail.highlightColor ? 'mapping-row-highlight' : '' },
|
||||
async autoMatchMappings() {
|
||||
if (!this.canAutoMatch) return
|
||||
const id = this.loadedProductId
|
||||
const generation = this.requestGeneration
|
||||
const operation = ++this.matchOperation
|
||||
const current = () => this.isCurrent(generation, id) && operation === this.matchOperation
|
||||
this.detail.aiMatching = true
|
||||
this.detail.matchMessage = '正在读取最新规格与等待时间'
|
||||
this.detail.matchMessageType = 'info'
|
||||
let submitted = false
|
||||
let received = false
|
||||
try {
|
||||
const prepared = await getShopeeProduct(id, { suppressErrorMessage: true })
|
||||
if (!current()) return
|
||||
const seconds = prepared.data.autoMatchTimeoutSeconds
|
||||
if (!Number.isInteger(seconds) || seconds < 16 || seconds > 1210) throw new Error('匹配等待时间不可用,请确认服务端已更新后刷新重试')
|
||||
this.detail.product = this.withDraftMappings(prepared.data.product)
|
||||
this.detail.matchMessage = `正在匹配并保存,最多等待 ${seconds + 10} 秒`
|
||||
submitted = true
|
||||
const r = await autoMatchShopeeSpecMappings(id, { requestId: createRequestId(), specContextVersion: prepared.data.product.specContextVersion }, seconds)
|
||||
if (!current()) return
|
||||
received = true
|
||||
this.detail.matchMessage = '匹配请求已完成,正在读取保存结果'
|
||||
await this.refreshDetail()
|
||||
if (!current()) return
|
||||
const unmatched = Number(r.data.unmatchedCount || 0)
|
||||
const message = `新匹配并确认 ${Number(r.data.confirmedCount || 0)} 项,保留已确认 ${Number(r.data.preservedCount || 0)} 项,未匹配 ${unmatched} 项`
|
||||
this.detail.matchMessage = message
|
||||
this.detail.matchMessageType = unmatched ? 'warning' : 'success'
|
||||
if (unmatched) ElMessage.warning(message); else ElMessage.success(message)
|
||||
} catch (error) {
|
||||
if (!current()) return
|
||||
this.detail.matchMessageType = 'warning'
|
||||
if (!submitted) {
|
||||
this.detail.matchMessage = error?.message || '读取匹配设置失败,请刷新后重试'
|
||||
return
|
||||
}
|
||||
// A lost response is not proof of a rollback. Read once, never retry POST.
|
||||
if (received) {
|
||||
this.detail.matchMessage = '匹配请求已完成,但结果暂未确认,请刷新查看'
|
||||
return
|
||||
}
|
||||
this.detail.matchMessage = '匹配请求超时或未正常返回,正在核对保存结果'
|
||||
try {
|
||||
await this.refreshDetail()
|
||||
if (!current()) return
|
||||
this.detail.matchMessage = '已刷新当前保存结果;原请求未正常返回,请检查未匹配项后再操作'
|
||||
} catch {
|
||||
if (current()) this.detail.matchMessage = '结果暂未确认,请刷新查看;不要重复提交匹配'
|
||||
}
|
||||
} finally {
|
||||
if (current()) this.detail.aiMatching = false
|
||||
}
|
||||
},
|
||||
async autoMatchMappings() { const id = this.loadedProductId; const generation = this.requestGeneration; this.detail.aiMatching = true; try { const r = await autoMatchShopeeSpecMappings(id, { requestId: createRequestId(), specContextVersion: this.detail.product.specContextVersion }); if (!this.isCurrent(generation, id)) return; const confirmed = Number(r.data.confirmedCount || 0); const preserved = Number(r.data.preservedCount || 0); const unmatched = Number(r.data.unmatchedCount || 0); await this.refreshDetail(); const message = `新匹配并确认 ${confirmed} 项,保留已确认 ${preserved} 项,未匹配 ${unmatched} 项`; if (unmatched > 0) ElMessage.warning(message); else ElMessage.success(message) } finally { if (this.isCurrent(generation, id)) this.detail.aiMatching = false } },
|
||||
async confirmPendingMapping(row) { await confirmShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }); await this.refreshDetail() },
|
||||
async saveAllMappings() { const changes = this.allSpecRows.filter(row => (row.pddValueDraft || '') !== (row.originalPddValue || '')); if (!changes.length) return; const invalid = changes.find(row => row.pddValueDraft && !this.pddValueExists(row.role, row.pddValueDraft)); if (invalid) { ElMessage.warning(`PDD 规格“${invalid.pddValueDraft}”已不存在,请重新选择`); return } this.detail.saving = true; try { for (const row of changes) { if (!row.pddValueDraft) { if (row.originalPddValue) await clearShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }); continue } const source = ['exact_match', 'ai_match'].includes(row.draftSource) ? row.draftSource : 'manual'; const payload = { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name, pddValue: row.pddValueDraft, source }; if (source === 'ai_match') { if (row.draftConfidence !== null && row.draftConfidence !== undefined) payload.confidence = row.draftConfidence; if (row.previewReason) payload.reason = row.previewReason } await setShopeeSpecMapping(this.loadedProductId, payload); if (source === 'exact_match') await confirmShopeeSpecMapping(this.loadedProductId, { requestId: createRequestId(), dimension: row.dimensionName, valueName: row.name }) } ElMessage.success('规格匹配已保存'); await this.refreshDetail() } finally { this.detail.saving = false } },
|
||||
async submitAddValue() { const dimension = this.addValueForm.dimension.trim(); const name = this.addValueForm.name.trim(); if (!dimension || !name) { ElMessage.warning('请填写维度名称和规格值'); return } await addShopeeSpecValue(this.loadedProductId, { requestId: createRequestId(), dimension, role: this.addValueForm.role, name }); this.addValueForm = { dimension: '', role: 'other', name: '' }; await this.refreshDetail() },
|
||||
|
||||
@@ -25,54 +25,12 @@
|
||||
<el-table-column label="数量" prop="quantity" width="56" />
|
||||
<el-table-column label="售价" width="110"><template #default="{ row }">{{ priceText(row) }}</template></el-table-column>
|
||||
<el-table-column label="解析状态" width="80"><template #default="{ row }"><el-tag :type="parseMeta(row.parseStatus).type">{{ parseMeta(row.parseStatus).label }}</el-tag><el-tag v-if="row.manuallyConfirmed" size="small" type="success" effect="plain" style="margin-left:4px">人工已确认</el-tag></template></el-table-column>
|
||||
<el-table-column v-if="canPurchase" label="处理阶段" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<span v-if="purchaseReadinessLoading" class="muted">正在检查</span>
|
||||
<template v-else>
|
||||
<el-tag :type="processMeta(purchaseReady(row).processStage).type">{{ purchaseReady(row).processStageLabel || processMeta(purchaseReady(row).processStage).label }}</el-tag>
|
||||
<div class="purchase-reason">{{ purchaseReady(row).processStageReason || purchaseReady(row).reason || '请刷新后重试' }}</div>
|
||||
<div v-if="purchaseReady(row).processStage === 'purchase_ready'" class="muted">{{ purchasePriceText(purchaseReady(row)) }}</div>
|
||||
<div class="quick-link-actions">
|
||||
<el-button v-if="purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction })" type="primary" link @click="runPurchaseNextAction(row, { ...purchaseReady(row), nextAction: purchaseReady(row).processNextAction })">{{ purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction }) }}</el-button>
|
||||
<el-button-group v-if="quickCanLink(row)">
|
||||
<el-button size="small" type="primary" plain :loading="quickBusyId === row.id" :disabled="!!quickBusyId" @click="quickStart(row)">一键关联</el-button>
|
||||
<el-dropdown trigger="click" @command="quickOpenPicker()">
|
||||
<el-button size="small" type="primary" plain aria-label="一键关联使用的手机" :icon="ArrowDown" />
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item disabled>当前手机:{{ quickDeviceLabel() }}</el-dropdown-item>
|
||||
<el-dropdown-item command="switch">切换手机</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</el-button-group>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="quickBusyId === row.id || quickMessages[row.id]" class="muted" aria-live="polite">{{ quickBusyId === row.id ? quickPhase : quickMessages[row.id] }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="canPurchase" label="处理阶段" min-width="210"><template #default="{ row }"><span v-if="purchaseReadinessLoading" class="muted">正在检查</span><template v-else><el-tag :type="processMeta(purchaseReady(row).processStage).type">{{ purchaseReady(row).processStageLabel || processMeta(purchaseReady(row).processStage).label }}</el-tag><div class="purchase-reason">{{ purchaseReady(row).processStageReason || purchaseReady(row).reason || '请刷新后重试' }}</div><div v-if="purchaseReady(row).processStage === 'purchase_ready'" class="muted">{{ purchasePriceText(purchaseReady(row)) }}</div><el-button v-if="purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction })" type="primary" link @click="runPurchaseNextAction(row, { ...purchaseReady(row), nextAction: purchaseReady(row).processNextAction })">{{ purchaseActionLabel({ nextAction: purchaseReady(row).processNextAction }) }}</el-button></template></template></el-table-column>
|
||||
<el-table-column label="操作" width="132" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openDetail(row.id)">详情</el-button><el-button v-if="canPurchase && isPurchaseCandidate(row)" type="primary" link @click="openSinglePurchase(row)">采购</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="quickPicker.open" :title="quickPicker.row ? '选择采集手机' : '切换采集手机'" width="460px" :close-on-click-modal="false" @close="quickCancelPicker">
|
||||
<p>{{ quickPicker.row ? '选择后继续关联刚才的商品。' : '只修改各行共用的手机,不启动关联,也不影响正在进行的操作。' }}</p>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="采集手机">
|
||||
<el-select v-model="quickPicker.deviceId" :loading="quickDeviceLoading" placeholder="请选择手机" style="width:100%">
|
||||
<el-option v-for="device in quickDevices" :key="device.id" :value="device.id" :label="`${device.name}(#${device.id} · ${device.status === 'online' ? '在线' : '离线'})`" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<p class="muted">使用这台手机最近一次成功的临时采集;离线也可使用已有结果。关联会作用于共用该蝦皮商品的 SYB 明细。</p>
|
||||
<p v-if="quickDeviceError" role="alert">{{ quickDeviceError }} <el-button link type="primary" @click="quickOpenPicker(quickPicker.row)">重试</el-button></p>
|
||||
<template #footer>
|
||||
<el-button @click="quickCancelPicker">取消</el-button>
|
||||
<el-button type="primary" :disabled="quickDeviceLoading || !!quickDeviceError || !quickDevices.some(d => d.id === quickPicker.deviceId)" @click="quickSaveDevice">{{ quickPicker.row ? '选择并继续' : '保存选择' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量创建采购任务确认 -->
|
||||
<el-dialog v-model="purchaseDialog.open" title="批量创建采购任务" width="900px" :close-on-click-modal="false">
|
||||
<div v-loading="purchaseDialog.loading">
|
||||
@@ -213,8 +171,7 @@
|
||||
|
||||
<script>
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, RefreshLeft, ArrowDown } from '@element-plus/icons-vue'
|
||||
import quickLink from './quick-link'
|
||||
import { Search, RefreshLeft } from '@element-plus/icons-vue'
|
||||
import { listSybProducts, getSybProduct, reparseSybProduct, correctSybProduct } from '@/api/goauto/syb-products'
|
||||
import { listSybShops } from '@/api/goauto/syb-shops'
|
||||
import { listDevices } from '@/api/goauto/devices'
|
||||
@@ -229,8 +186,7 @@ import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
|
||||
export default {
|
||||
name: 'GoAutoSybProducts',
|
||||
components: { ShopeeProductDetailDrawer, PddProductDetailDrawer },
|
||||
mixins: [quickLink],
|
||||
setup() { return { Search, RefreshLeft, ArrowDown } },
|
||||
setup() { return { Search, RefreshLeft } },
|
||||
data() {
|
||||
return {
|
||||
loading: false, products: [], selectedProducts: [], total: 0,
|
||||
@@ -557,7 +513,6 @@ export default {
|
||||
.selection-count strong{margin:0 4px;font-variant-numeric:tabular-nums;color:#1e40af}
|
||||
.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}
|
||||
.missing{color:#b91c1c}
|
||||
.quick-link-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:4px 0}
|
||||
.thumb{width:44px;height:44px;border-radius:4px;object-fit:cover}.thumb.placeholder{display:flex;align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}
|
||||
.link{color:#1677ff;cursor:pointer}
|
||||
.shopee-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listDevices } from '@/api/goauto/devices'
|
||||
import { getShopeeProduct, linkShopeeProductPdd, autoMatchShopeeSpecMappings } from '@/api/goauto/shopee-products'
|
||||
import { previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
import { readQuickLinkDevice, rememberQuickLinkDevice } from '@/utils/quick-link-device-preference'
|
||||
|
||||
const readOptions = { timeout: 10000, suppressErrorMessage: true }
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
quickDeviceId: null, quickDevices: [], quickDeviceError: '', quickDeviceLoading: false,
|
||||
quickPicker: { open: false, deviceId: null, row: null },
|
||||
quickBusyId: null, quickPhase: '', quickMessages: {}, quickGeneration: 0, quickPickerGeneration: 0
|
||||
}
|
||||
},
|
||||
created() { this.quickDeviceId = readQuickLinkDevice(this.$store.getters.userId) },
|
||||
beforeUnmount() { this.quickInvalidate() },
|
||||
deactivated() { this.quickInvalidate() },
|
||||
methods: {
|
||||
quickInvalidate() {
|
||||
this.quickGeneration++
|
||||
this.quickPickerGeneration++
|
||||
if (this.quickBusyId) this.quickMessages[this.quickBusyId] = '操作结果待确认,请刷新核对;离开页面不代表服务端已取消'
|
||||
this.quickBusyId = null
|
||||
this.quickPhase = ''
|
||||
this.quickPicker.open = false
|
||||
},
|
||||
quickCanLink(row) {
|
||||
const ready = this.purchaseReady(row)
|
||||
return this.canPurchase && !!row.shopeeProductId && ready.processNextAction === 'open_pdd_link' && !ready.pddProductId
|
||||
},
|
||||
quickDeviceLabel() {
|
||||
const device = this.quickDevices.find(d => d.id === this.quickDeviceId)
|
||||
return device ? `${device.name}(#${device.id})` : this.quickDeviceId ? `手机 #${this.quickDeviceId}` : '尚未选择'
|
||||
},
|
||||
async quickOpenPicker(row = null) {
|
||||
const generation = ++this.quickPickerGeneration
|
||||
this.quickPicker = { open: true, deviceId: this.quickDeviceId, row: row ? { ...row } : null }
|
||||
this.quickDeviceLoading = true
|
||||
this.quickDeviceError = ''
|
||||
try {
|
||||
// Include offline phones: historical collection does not require a live phone.
|
||||
const response = await listDevices({ page: 1, pageSize: 100 })
|
||||
if (generation !== this.quickPickerGeneration) return
|
||||
this.quickDevices = response.data.items.filter(d => d.status === 'online' || d.status === 'offline')
|
||||
if (!this.quickDevices.length) this.quickDeviceError = '暂无可选手机,请先在设备列表中登记或启用手机'
|
||||
} catch { if (generation === this.quickPickerGeneration) this.quickDeviceError = '手机列表加载失败,请重试' } finally { if (generation === this.quickPickerGeneration) this.quickDeviceLoading = false }
|
||||
},
|
||||
quickCancelPicker() { this.quickPickerGeneration++; this.quickPicker.open = false },
|
||||
quickSaveDevice() {
|
||||
const id = this.quickPicker.deviceId
|
||||
if (this.quickDeviceLoading || this.quickDeviceError || !this.quickDevices.some(d => d.id === id)) return
|
||||
const row = this.quickPicker.row
|
||||
this.quickDeviceId = id
|
||||
if (!rememberQuickLinkDevice(this.$store.getters.userId, id)) ElMessage.warning('本次选择已生效,但浏览器未能保存;下次可能需要重新选择')
|
||||
this.quickCancelPicker()
|
||||
if (row) this.quickStart(row)
|
||||
},
|
||||
async quickStart(row) {
|
||||
if (!this.quickCanLink(row) || this.quickBusyId) return
|
||||
if (!this.quickDeviceId) return this.quickOpenPicker(row)
|
||||
const frozen = { sybProductId: row.id, productId: row.shopeeProductId, deviceId: this.quickDeviceId }
|
||||
const generation = ++this.quickGeneration
|
||||
const current = () => generation === this.quickGeneration
|
||||
this.quickBusyId = row.id
|
||||
this.quickMessages[row.id] = ''
|
||||
this.quickPhase = '正在关联'
|
||||
let wrote = false
|
||||
try {
|
||||
const detail = (await getShopeeProduct(frozen.productId, readOptions)).data
|
||||
if (!current()) return
|
||||
if (detail.product.pddProductId) throw new Error('该商品已经关联,请查看现有关联')
|
||||
const linked = (await linkShopeeProductPdd(frozen.productId, {
|
||||
requestId: createRequestId(),
|
||||
latestCollection: { sybProductId: frozen.sybProductId, deviceId: frozen.deviceId, specContextVersion: detail.product.specContextVersion }
|
||||
}, readOptions)).data
|
||||
wrote = true
|
||||
if (!current()) return
|
||||
const ready = (await previewPurchaseTasks({ sybProductIds: [frozen.sybProductId] }, readOptions)).data.items[0]
|
||||
if (!current()) return
|
||||
if (linked.collectionSource.requiresSpecConfirmation || !ready?.aiMatchEligible) {
|
||||
this.quickMessages[row.id] = ready?.eligible ? '已关联,当前明细可创建采购' : '已关联;请按处理阶段提示继续人工处理'
|
||||
} else {
|
||||
this.quickPhase = '正在匹配并保存'
|
||||
const result = (await autoMatchShopeeSpecMappings(frozen.productId, {
|
||||
requestId: createRequestId(), specContextVersion: linked.product.specContextVersion
|
||||
}, detail.autoMatchTimeoutSeconds)).data
|
||||
if (!current()) return
|
||||
this.quickMessages[row.id] = result.unmatchedCount ? '已关联并保存有效匹配;剩余规格请去匹配处理' : '已关联并保存匹配;以最新处理阶段为准'
|
||||
}
|
||||
} catch (error) {
|
||||
if (!current()) return
|
||||
const reason = error?.response?.data?.message || error?.message || '请求失败'
|
||||
// A definite validation/conflict rejection is not an unknown write.
|
||||
if (!wrote && [403, 404, 409, 422].includes(error?.response?.status)) {
|
||||
this.quickMessages[row.id] = reason
|
||||
return
|
||||
}
|
||||
this.quickPhase = '正在核对结果'
|
||||
try {
|
||||
const saved = (await getShopeeProduct(frozen.productId, readOptions)).data.product
|
||||
if (!current()) return
|
||||
this.quickMessages[row.id] = saved.pddProductId
|
||||
? `当前已有关联,匹配结果请去匹配查看。${reason}`
|
||||
: `当前未查到关联,操作结果未确认,请刷新核对后再操作。${reason}`
|
||||
} catch {
|
||||
if (current()) this.quickMessages[row.id] = `${wrote ? '关联已保存;' : ''}结果未确认,请刷新核对,不要连续重复点击。${reason}`
|
||||
}
|
||||
} finally {
|
||||
if (current()) {
|
||||
this.quickBusyId = null
|
||||
this.quickPhase = ''
|
||||
// Read-only refresh, never automatically resubmit a failed write.
|
||||
void this.loadPurchaseReadiness(this.products.map(item => item.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { expect, test, Page } from '@playwright/test'
|
||||
|
||||
const routes = [{ path: '/collection-purchase', component: 'Layout', menuName: 'GoAutoCollectionPurchase', title: '采集采购', visible: '0', children: [{ path: '/shopee-products', component: '/goauto/shopee-products/index', menuName: 'GoAutoShopeeProducts', title: '虾皮商品', visible: '0' }] }]
|
||||
|
||||
async function fixture(page: Page, mode: 'pending' | 'lost' | 'read-failed') {
|
||||
const releases: Array<() => void> = []
|
||||
let posts = 0, saved = false
|
||||
await page.context().addCookies([{ name: 'Admin-Token', value: 'synthetic-test-only', domain: 'localhost', path: '/' }])
|
||||
const product = () => ({ id: 9, shopeeItemId: 'S9', title: '匹配等待测试', pddProductId: 17, specContextVersion: 'context-254', specs: [{ name: '颜色', role: 'color', values: [{ name: '深黑', source: 'import', mapping: saved ? { pddValue: '黑色', source: 'ai_match', status: 'confirmed' } : null }] }] })
|
||||
await page.route('**/api/**', async route => {
|
||||
const path = new URL(route.request().url()).pathname
|
||||
if (path.startsWith('/src/api/')) return route.continue()
|
||||
if (path.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['purchaser'], name: '测试采购员', avatar: '', introduction: '', permissions: [] } } })
|
||||
if (path.endsWith('/api/v1/menurole')) return route.fulfill({ json: { code: 200, data: routes } })
|
||||
if (path.endsWith('/api/admin/v1/shopee-products')) return route.fulfill({ json: { code: 200, data: { items: [product()], total: 1, page: 1, pageSize: 20 } } })
|
||||
if (path.endsWith('/specs/mapping/auto-match')) {
|
||||
posts++
|
||||
if (mode === 'pending') await new Promise<void>(resolve => releases.push(resolve))
|
||||
saved = true
|
||||
if (mode !== 'pending') return route.abort('timedout')
|
||||
return route.fulfill({ json: { code: 200, data: { confirmedCount: 1, preservedCount: 0, unmatchedCount: 0 } } })
|
||||
}
|
||||
if (path.endsWith('/shopee-products/9')) {
|
||||
if (posts && mode === 'read-failed') return route.abort('failed')
|
||||
return route.fulfill({ json: { code: 200, data: { product: product(), autoMatchTimeoutSeconds: 130 } } })
|
||||
}
|
||||
if (path.endsWith('/pdd-products/17')) return route.fulfill({ json: { code: 200, data: { product: { id: 17, goodsId: '17', specs: [{ name: '颜色', role: 'color', values: [{ name: '黑色', selectable: true }] }] } } } })
|
||||
return route.fulfill({ json: { code: 200, data: [] } })
|
||||
})
|
||||
await page.goto('/#/shopee-products')
|
||||
await page.getByRole('button', { name: '详情', exact: true }).click()
|
||||
const match = page.getByRole('button', { name: '一键匹配颜色和尺码', exact: true })
|
||||
await expect(match).toBeEnabled()
|
||||
return { match, releases, posts: () => posts }
|
||||
}
|
||||
|
||||
test('关闭重开与迟到响应不污染新匹配,显示动态140秒预算', async ({ page }) => {
|
||||
const f = await fixture(page, 'pending')
|
||||
await f.match.click()
|
||||
await expect(page.getByText('正在匹配并保存,最多等待 140 秒', { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '编辑档案', exact: true })).toBeDisabled()
|
||||
await expect(f.match).toBeDisabled()
|
||||
await page.screenshot({ path: 'test-results/254-waiting.png', fullPage: true })
|
||||
await page.getByRole('button', { name: '关闭', exact: true }).click()
|
||||
await page.getByRole('button', { name: '详情', exact: true }).click()
|
||||
await expect(f.match).toBeEnabled()
|
||||
await f.match.click()
|
||||
await expect.poll(f.posts).toBe(2)
|
||||
f.releases[0]()
|
||||
await expect(f.match).toBeDisabled()
|
||||
f.releases[1]()
|
||||
await expect(f.match).toBeEnabled()
|
||||
await expect(page.getByText('新匹配并确认 1 项,保留已确认 0 项,未匹配 0 项', { exact: true }).first()).toBeVisible()
|
||||
})
|
||||
|
||||
for (const mode of ['lost', 'read-failed'] as const) {
|
||||
test(`${mode}:结束等待且不重试POST`, async ({ page }) => {
|
||||
const f = await fixture(page, mode)
|
||||
await f.match.click()
|
||||
const text = mode === 'lost' ? '已刷新当前保存结果;原请求未正常返回,请检查未匹配项后再操作' : '结果暂未确认,请刷新查看;不要重复提交匹配'
|
||||
await expect(page.getByText(text, { exact: true })).toBeVisible()
|
||||
await expect(f.match).toBeEnabled()
|
||||
expect(f.posts()).toBe(1)
|
||||
await expect(page.getByRole('button', { name: '刷新查看', exact: true })).toBeVisible()
|
||||
await page.screenshot({ path: `test-results/254-${mode}.png`, fullPage: true })
|
||||
})
|
||||
}
|
||||
@@ -67,7 +67,7 @@ test('虾皮详情一键匹配颜色尺码后直接使用并可原地叠加 PDD
|
||||
return route.fulfill({ json: { code: 200, data: { confirmedCount: 2, preservedCount: 0, unmatchedCount: 0, items: [] }}})
|
||||
}
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products/9')) {
|
||||
return route.fulfill({ json: { code: 200, data: { autoMatchTimeoutSeconds: 130, product: { id: 9, shopeeItemId: 'S9', title: '叠加抽屉商品', shopName: '测试店', pddProductId: 17, specContextVersion: 'context-3', specs: [{ name: '颜色', role: 'color', values: [{ name: '藏青色', source: 'import', mapping: autoMatched ? { pddValue: '黑色', source: 'ai_match', status: 'confirmed', confidence: 0.96, reason: '颜色含义唯一一致' } : null }] }, { name: '尺码', role: 'size', values: [{ name: 'XL', source: 'import', mapping: autoMatched ? { pddValue: 'XL', source: 'exact_match', status: 'confirmed', reason: '格式统一后唯一匹配' } : null }] }] }}}})
|
||||
return route.fulfill({ json: { code: 200, data: { product: { id: 9, shopeeItemId: 'S9', title: '叠加抽屉商品', shopName: '测试店', pddProductId: 17, specContextVersion: 'context-3', specs: [{ name: '颜色', role: 'color', values: [{ name: '藏青色', source: 'import', mapping: autoMatched ? { pddValue: '黑色', source: 'ai_match', status: 'confirmed', confidence: 0.96, reason: '颜色含义唯一一致' } : null }] }, { name: '尺码', role: 'size', values: [{ name: 'XL', source: 'import', mapping: autoMatched ? { pddValue: 'XL', source: 'exact_match', status: 'confirmed', reason: '格式统一后唯一匹配' } : null }] }] }}}})
|
||||
}
|
||||
if (url.pathname.endsWith('/api/admin/v1/pdd-products/17/related-syb-products')) return route.fulfill({ json: { code: 200, data: { items: [], total: 0 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/pdd-products/17')) return route.fulfill({ json: { code: 200, data: { product: { id: 17, goodsId: 'P17', title: 'PDD 叠加详情', shopName: 'PDD 店', status: 'active', url: 'https://mobile.yangkeduo.com/goods.html?goods_id=17', specs: [{ name: '颜色分类', role: 'color', values: [{ name: '黑色', selectable: true, priceCent: 1299 }] }, { name: '尺码', role: 'size', values: [{ name: 'XL', selectable: true }] }], colorImages: [] }, relatedShopeeProducts: [] }}})
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
async function setup(page: any) {
|
||||
const state = { userId: 1, role: 'purchaser', links: [] as any[], matches: [] as any[], linked: new Set<number>(), matched: new Set<number>(), aiFailure: false }
|
||||
await page.context().addCookies([{ name: 'Admin-Token', value: 'mock-token', domain: 'localhost', path: '/' }])
|
||||
await page.route('**/api/**', async (route: any) => {
|
||||
const url = new URL(route.request().url()), path = url.pathname
|
||||
const ok = (data: any) => route.fulfill({ json: { code: 200, data } })
|
||||
if (path.startsWith('/src/api/')) return route.continue()
|
||||
if (path.endsWith('/getinfo')) return ok({ userId: state.userId, roles: [state.role], name: '合成测试', avatar: '', permissions: [] })
|
||||
if (path.endsWith('/menurole')) return ok([{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [{ path: '/syb-products', component: '/goauto/syb-products/index', menuName: 'GoAutoSybProducts', title: 'SYB 商品', visible: '0' }] }])
|
||||
if (path.endsWith('/syb-products')) return ok({ items: [1, 2].map(id => ({ id, orderCode: `TEST-${id}`, shopeeItemId: `100${id}`, shopeeProductId: id + 10, productTitle: '测试商品', targetColor: '黑色', targetSize: 'XL', quantity: 1, parseStatus: 'success' })), total: 2 })
|
||||
if (path.endsWith('/devices')) return ok({ items: [{ id: 10, name: '手机A', status: 'offline' }, { id: 20, name: '手机B', status: 'online' }], total: 2 })
|
||||
if (path.endsWith('/batch-preview')) return ok({ items: route.request().postDataJSON().sybProductIds.map((id: number) => ({ sybProductId: id, pddProductId: state.linked.has(id) ? 55 : null, eligible: state.matched.has(id), aiMatchEligible: state.linked.has(id) && !state.matched.has(id), processStage: state.matched.has(id) ? 'purchase_ready' : state.linked.has(id) ? 'color_mapping' : 'pdd_unlinked', processNextAction: state.linked.has(id) ? 'open_mapping' : 'open_pdd_link' })) })
|
||||
const match = path.match(/shopee-products\/(\d+)(.*)/)
|
||||
if (match) {
|
||||
const productId = Number(match[1]), id = productId - 10
|
||||
const product = { id: productId, pddProductId: state.linked.has(id) ? 55 : null, specContextVersion: state.linked.has(id) ? 'after' : 'before', specs: [] }
|
||||
if (match[2] === '/link-pdd') {
|
||||
const body = route.request().postDataJSON(); state.links.push(body); state.linked.add(id)
|
||||
return ok({ product: { ...product, pddProductId: 55, specContextVersion: 'after' }, collectionSource: { taskId: 7, deviceId: body.latestCollection.deviceId, pddProductId: 55 } })
|
||||
}
|
||||
if (match[2] === '/specs/mapping/auto-match') {
|
||||
state.matches.push(route.request().postDataJSON())
|
||||
if (state.aiFailure) return route.fulfill({ status: 503, json: { code: 'AI_MATCHING_UNAVAILABLE', message: 'AI 匹配服务暂时不可用' } })
|
||||
state.matched.add(id); return ok({ product, unmatchedCount: 0 })
|
||||
}
|
||||
return ok({ product, autoMatchTimeoutSeconds: 370 })
|
||||
}
|
||||
return ok([])
|
||||
})
|
||||
await page.goto('/#/syb-products')
|
||||
await expect(page.getByRole('button', { name: '一键关联', exact: true })).toHaveCount(2)
|
||||
return state
|
||||
}
|
||||
|
||||
async function choose(page: any, name: string, button: string) {
|
||||
const dialog = page.getByRole('dialog')
|
||||
await dialog.locator('.el-select').click()
|
||||
await page.getByRole('option', { name: new RegExp(name) }).click()
|
||||
await dialog.getByRole('button', { name: button, exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
}
|
||||
|
||||
test('first choose continues clicked row; saves match and real stage; preference survives refresh', async ({ page }) => {
|
||||
const state = await setup(page)
|
||||
await page.getByRole('button', { name: '一键关联', exact: true }).nth(1).click()
|
||||
await choose(page, '手机A', '选择并继续')
|
||||
await expect.poll(() => state.matches.length).toBe(1)
|
||||
expect(state.links[0].latestCollection).toMatchObject({ sybProductId: 2, deviceId: 10 })
|
||||
await expect(page.locator('tbody').getByText('可创建采购', { exact: true })).toBeVisible()
|
||||
await page.reload()
|
||||
await page.getByRole('button', { name: '一键关联', exact: true }).click()
|
||||
await expect.poll(() => state.links.length).toBe(2)
|
||||
expect(state.links[1].latestCollection.deviceId).toBe(10)
|
||||
await expect(page.getByRole('dialog')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('arrow first-use saves only; switch from either row affects all rows without linking', async ({ page }) => {
|
||||
const state = await setup(page)
|
||||
await page.getByRole('button', { name: '一键关联使用的手机' }).first().click()
|
||||
await page.getByRole('menuitem', { name: '切换手机' }).click()
|
||||
await choose(page, '手机A', '保存选择')
|
||||
expect(state.links).toHaveLength(0)
|
||||
await page.getByRole('button', { name: '一键关联使用的手机' }).nth(1).click()
|
||||
await expect(page.getByRole('menuitem', { name: /当前手机:手机A/ })).toBeVisible()
|
||||
await page.getByRole('menuitem', { name: '切换手机' }).click()
|
||||
await choose(page, '手机B', '保存选择')
|
||||
expect(state.links).toHaveLength(0)
|
||||
await page.getByRole('button', { name: '一键关联', exact: true }).first().click()
|
||||
await expect.poll(() => state.links.length).toBe(1)
|
||||
expect(state.links[0].latestCollection.deviceId).toBe(20)
|
||||
})
|
||||
|
||||
test('AI failure retains association, ends waiting and offers manual matching', async ({ page }) => {
|
||||
const state = await setup(page); state.aiFailure = true
|
||||
await page.getByRole('button', { name: '一键关联', exact: true }).first().click()
|
||||
await choose(page, '手机A', '选择并继续')
|
||||
await expect(page.getByText(/当前已有关联,匹配结果请去匹配查看/)).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '去匹配', exact: true })).toBeVisible()
|
||||
expect(state.links).toHaveLength(1); expect(state.matches).toHaveLength(1)
|
||||
await expect(page.getByRole('button', { name: '一键关联', exact: true })).toBeEnabled()
|
||||
})
|
||||
|
||||
test('cancel, different user and no purchaser role do not create links', async ({ page }) => {
|
||||
const state = await setup(page)
|
||||
await page.getByRole('button', { name: '一键关联', exact: true }).first().click()
|
||||
await page.getByRole('dialog').getByRole('button', { name: '取消', exact: true }).click()
|
||||
expect(state.links).toHaveLength(0)
|
||||
await page.getByRole('button', { name: '一键关联使用的手机' }).first().click()
|
||||
await page.getByRole('menuitem', { name: '切换手机' }).click()
|
||||
await choose(page, '手机A', '保存选择')
|
||||
state.userId = 2; await page.reload()
|
||||
await page.getByRole('button', { name: '一键关联', exact: true }).first().click()
|
||||
await expect(page.getByRole('dialog', { name: '选择采集手机' })).toBeVisible()
|
||||
state.role = 'viewer'; await page.reload()
|
||||
await expect(page.getByRole('button', { name: '一键关联', exact: true })).toHaveCount(0)
|
||||
expect(state.links).toHaveLength(0)
|
||||
})
|
||||
@@ -1,93 +0,0 @@
|
||||
// Run with node --test. Exercise the real SFC methods without a browser or API.
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { parse } = require('@vue/compiler-sfc')
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/shopee-products/ShopeeProductDetailDrawer.vue'), 'utf8')
|
||||
const script = parse(source).descriptor.script.content.replace(/import[\s\S]*?from ['"][^'"]+['"]/g, '').replace('export default', 'return')
|
||||
const product = id => ({ id, specContextVersion: 'v1', specs: [{ name: '颜色', role: 'color', values: [{ name: '黑', source: 'import' }] }] })
|
||||
const reply = id => ({ data: { product: product(id), autoMatchTimeoutSeconds: 130 } })
|
||||
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
|
||||
function instance(get = async id => reply(id), post = async () => ({ data: {} })) {
|
||||
const options = new Function('getShopeeProduct', 'autoMatchShopeeSpecMappings', 'createRequestId', 'ElMessage', 'PddProductDetailDrawer', script)(get, post, () => 'id', { success() {}, warning() {} }, {})
|
||||
const vm = { modelValue: true, productId: 1, $emit() {}, $confirm: async () => {}, ...options.methods }
|
||||
Object.assign(vm, options.data.call(vm))
|
||||
Object.assign(vm, { loadedProductId: 1, detailDrawerOpen: true })
|
||||
vm.detail.product = product(1); vm.detail.pddLoaded = true
|
||||
// Focus method lifecycle, not the already-covered candidate predicates.
|
||||
Object.defineProperty(vm, 'canAutoMatch', { get: () => !vm.detail.aiMatching && !vm.hasMappingChanges })
|
||||
vm.hasMappingChanges = false
|
||||
return vm
|
||||
}
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve))
|
||||
|
||||
test('close/reopen same product refreshes, old result/finally cannot clear a new operation', async () => {
|
||||
const first = deferred(), second = deferred(); let calls = 0, reads = 0
|
||||
const vm = instance(async id => { reads++; return reply(id) }, () => (++calls === 1 ? first.promise : second.promise))
|
||||
const old = vm.autoMatchMappings(); await tick()
|
||||
vm.modelValue = false; vm.closeInternals()
|
||||
assert.equal(vm.detail.aiMatching, false)
|
||||
vm.modelValue = true; await vm.requestSwitch(1)
|
||||
assert.equal(reads, 2)
|
||||
const fresh = vm.autoMatchMappings(); await tick()
|
||||
first.resolve({ data: { confirmedCount: 99 } }); await old
|
||||
assert.equal(vm.detail.aiMatching, true)
|
||||
assert.equal(vm.detail.matchMessage.includes('99'), false)
|
||||
second.resolve({ data: { confirmedCount: 1 } }); await fresh
|
||||
assert.equal(vm.detail.aiMatching, false)
|
||||
})
|
||||
test('duplicate clicks do not send a second POST and dynamic budget is forwarded', async () => {
|
||||
const result = deferred(); let calls = 0, seconds
|
||||
const vm = instance(undefined, (id, data, budget) => { calls++; seconds = budget; return result.promise })
|
||||
const running = vm.autoMatchMappings(); await tick(); await vm.autoMatchMappings()
|
||||
assert.equal(calls, 1); assert.equal(seconds, 130)
|
||||
result.resolve({ data: {} }); await running; assert.equal(vm.detail.aiMatching, false)
|
||||
})
|
||||
for (const reason of ['timeout', 'Network Error', 'business failure']) {
|
||||
test(`${reason}: one readback, no automatic POST retry`, async () => {
|
||||
let reads = 0, posts = 0
|
||||
const vm = instance(async id => { reads++; return reply(id) }, async () => { posts++; throw new Error(reason) })
|
||||
await vm.autoMatchMappings()
|
||||
assert.equal(reads, 2); assert.equal(posts, 1); assert.equal(vm.detail.aiMatching, false)
|
||||
assert.match(vm.detail.matchMessage, /已刷新当前保存结果/)
|
||||
})
|
||||
}
|
||||
test('readback failure ends busy with explicit unknown result', async () => {
|
||||
let reads = 0
|
||||
const vm = instance(async id => { if (++reads > 1) throw new Error('offline'); return reply(id) }, async () => { throw new Error('timeout') })
|
||||
await vm.autoMatchMappings(); assert.equal(vm.detail.aiMatching, false); assert.match(vm.detail.matchMessage, /结果暂未确认/)
|
||||
})
|
||||
test('prepare failure or missing budget does not POST', async () => {
|
||||
for (const get of [async () => { throw new Error('offline') }, async id => ({ data: { product: product(id) } })]) {
|
||||
let posts = 0; const vm = instance(get, async () => { posts++ })
|
||||
await vm.autoMatchMappings(); assert.equal(posts, 0); assert.equal(vm.detail.aiMatching, false)
|
||||
}
|
||||
})
|
||||
test('switch while readback is pending never writes old data or messages', async () => {
|
||||
const pending = deferred(); let reads = 0
|
||||
const vm = instance(async id => { if (++reads === 2) return pending.promise; return reply(id) })
|
||||
const old = vm.autoMatchMappings(); await tick()
|
||||
vm.productId = 2; await vm.requestSwitch(2)
|
||||
pending.resolve(reply(1)); await old
|
||||
assert.equal(vm.detail.product.id, 2); assert.equal(vm.detail.matchMessage, ''); assert.equal(vm.detail.aiMatching, false)
|
||||
})
|
||||
test('manual drafts prevent matching and refused discard prevents refresh', async () => {
|
||||
let calls = 0; const vm = instance(async id => { calls++; return reply(id) })
|
||||
vm.hasMappingChanges = true; vm.$confirm = async () => { throw new Error('cancel') }
|
||||
await vm.autoMatchMappings(); await vm.reload(); assert.equal(calls, 0)
|
||||
})
|
||||
|
||||
test('API uses derived time plus margin only for auto-match; suggestions stay unchanged', async () => {
|
||||
const apiSource = fs.readFileSync(path.join(__dirname, '../../src/api/goauto/shopee-products.js'), 'utf8').replace(/import[^\n]+\n/g, '').replace(/export function/g, 'function')
|
||||
const calls = []
|
||||
const api = new Function('request', apiSource + '\nreturn { autoMatchShopeeSpecMappings, suggestShopeeColorMappings, suggestShopeeSizeMappings };')(config => { calls.push(config); return Promise.resolve({}) })
|
||||
for (const seconds of [16, 130, 370, 1210]) {
|
||||
await api.autoMatchShopeeSpecMappings(1, {}, seconds)
|
||||
assert.equal(calls.at(-1).timeout, (seconds + 10) * 1000)
|
||||
}
|
||||
await assert.rejects(api.autoMatchShopeeSpecMappings(1, {}, undefined))
|
||||
assert.equal(calls.length, 4)
|
||||
await api.suggestShopeeColorMappings(1, {}); assert.equal(calls.at(-1).timeout, 610000)
|
||||
await api.suggestShopeeSizeMappings(1, {}); assert.equal(calls.at(-1).timeout, 610000)
|
||||
})
|
||||
@@ -1,89 +0,0 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../src/views/goauto/syb-products/quick-link.js'), 'utf8').replace(/import[^\n]+\n/g, '').replace('export default', 'return')
|
||||
const row = { id: 1, shopeeProductId: 11 }
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve))
|
||||
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes }); return { promise, resolve } }
|
||||
function instance(overrides = {}) {
|
||||
const calls = { link: [], match: [], reads: 0, saved: [], refresh: 0 }
|
||||
const dependencies = {
|
||||
ElMessage: { warning() {} },
|
||||
listDevices: async () => ({ data: { items: [{ id: 10, status: 'offline', name: 'A' }, { id: 20, status: 'online', name: 'B' }] } }),
|
||||
getShopeeProduct: async id => { calls.reads++; return { data: { product: { id, specContextVersion: 'before', pddProductId: calls.link.length ? 55 : null }, autoMatchTimeoutSeconds: 370 } } },
|
||||
linkShopeeProductPdd: async (id, body) => { calls.link.push(body); return { data: { product: { id, specContextVersion: 'after' }, collectionSource: { taskId: 9, pddProductId: 55 } } } },
|
||||
autoMatchShopeeSpecMappings: async (id, body, seconds) => { calls.match.push({ id, body, seconds }); return { data: { unmatchedCount: 0 } } },
|
||||
previewPurchaseTasks: async () => ({ data: { items: [{ aiMatchEligible: true }] } }),
|
||||
createRequestId: () => 'request-id', readQuickLinkDevice: () => null,
|
||||
rememberQuickLinkDevice: (user, id) => { calls.saved.push(id); return true }, ...overrides
|
||||
}
|
||||
const options = new Function(...Object.keys(dependencies), source)(...Object.values(dependencies))
|
||||
const vm = { ...options.methods, ...options.data(), canPurchase: true, products: [row], $store: { getters: { userId: 1 } }, purchaseReady: () => ({ processNextAction: 'open_pdd_link' }), loadPurchaseReadiness: async () => { calls.refresh++ } }
|
||||
return { vm, calls, options }
|
||||
}
|
||||
test('first selection continues original row and forwards frozen context / #254 budget', async () => {
|
||||
const { vm, calls } = instance()
|
||||
await vm.quickStart(row); assert.equal(calls.link.length, 0)
|
||||
vm.quickPicker.deviceId = 10; vm.quickSaveDevice(); await tick()
|
||||
assert.equal(calls.link.length, 1); assert.equal(calls.link[0].latestCollection.sybProductId, 1)
|
||||
assert.equal(calls.link[0].latestCollection.deviceId, 10)
|
||||
assert.equal(calls.match[0].body.specContextVersion, 'after'); assert.equal(calls.match[0].seconds, 370)
|
||||
assert.equal(vm.quickBusyId, null)
|
||||
})
|
||||
test('switch saves shared preference only; cancel changes nothing', async () => {
|
||||
const { vm, calls } = instance(); vm.quickDeviceId = 10
|
||||
await vm.quickOpenPicker(); vm.quickPicker.deviceId = 20; vm.quickCancelPicker()
|
||||
assert.equal(vm.quickDeviceId, 10); assert.equal(calls.saved.length, 0)
|
||||
await vm.quickOpenPicker(); vm.quickPicker.deviceId = 20; vm.quickSaveDevice()
|
||||
assert.equal(vm.quickDeviceId, 20); assert.equal(calls.link.length, 0)
|
||||
})
|
||||
test('in-flight device is frozen and double click cannot duplicate POST', async () => {
|
||||
const wait = deferred(); let links = 0, device
|
||||
const { vm } = instance({ getShopeeProduct: () => wait.promise, linkShopeeProductPdd: async (id, body) => { links++; device = body.latestCollection.deviceId; return { data: { product: { specContextVersion: 'after' }, collectionSource: {} } } } })
|
||||
vm.quickDeviceId = 10; const running = vm.quickStart(row)
|
||||
vm.quickDeviceId = 20; await vm.quickStart(row)
|
||||
wait.resolve({ data: { product: { specContextVersion: 'before' }, autoMatchTimeoutSeconds: 130 } }); await running
|
||||
assert.equal(links, 1); assert.equal(device, 10)
|
||||
})
|
||||
test('leaving during prepare prevents later association / matching', async () => {
|
||||
const wait = deferred(); const { vm, calls, options } = instance({ getShopeeProduct: () => wait.promise })
|
||||
vm.quickDeviceId = 10; const running = vm.quickStart(row); options.beforeUnmount.call(vm)
|
||||
wait.resolve({ data: { product: { specContextVersion: 'before' } } }); await running
|
||||
assert.equal(calls.link.length, 0); assert.equal(calls.match.length, 0)
|
||||
})
|
||||
for (const error of ['timeout', 'Network Error', 'AI 匹配服务暂时不可用']) {
|
||||
test(`${error}: finite readback, end loading, no automatic write retry`, async () => {
|
||||
let matches = 0
|
||||
const { vm, calls } = instance({ autoMatchShopeeSpecMappings: async () => { matches++; throw new Error(error) } })
|
||||
vm.quickDeviceId = 10; await vm.quickStart(row)
|
||||
assert.equal(calls.link.length, 1); assert.equal(matches, 1); assert.equal(calls.reads, 2)
|
||||
assert.equal(vm.quickBusyId, null); assert.match(vm.quickMessages[1], /当前已有关联/)
|
||||
})
|
||||
}
|
||||
test('failed recovery reports unknown and clears busy', async () => {
|
||||
const { vm } = instance({ getShopeeProduct: async () => { throw new Error('offline') } })
|
||||
vm.quickDeviceId = 10; await vm.quickStart(row)
|
||||
assert.equal(vm.quickBusyId, null); assert.match(vm.quickMessages[1], /结果未确认/)
|
||||
})
|
||||
test('definite candidate rejection explains cause without AI or a write retry', async () => {
|
||||
let links = 0
|
||||
const { vm, calls } = instance({ linkShopeeProductPdd: async () => { links++; throw { response: { status: 409, data: { message: '最近一次临时采集不完整,请先补采' } } } } })
|
||||
vm.quickDeviceId = 10; await vm.quickStart(row)
|
||||
assert.equal(links, 1); assert.equal(calls.match.length, 0); assert.equal(calls.reads, 1)
|
||||
assert.equal(vm.quickBusyId, null); assert.equal(vm.quickMessages[1], '最近一次临时采集不完整,请先补采')
|
||||
})
|
||||
test('manual-required readiness retains association without AI call', async () => {
|
||||
const { vm, calls } = instance({ previewPurchaseTasks: async () => ({ data: { items: [{ aiMatchEligible: false, eligible: false }] } }) })
|
||||
vm.quickDeviceId = 10; await vm.quickStart(row)
|
||||
assert.equal(calls.link.length, 1); assert.equal(calls.match.length, 0); assert.match(vm.quickMessages[1], /人工处理/)
|
||||
})
|
||||
test('preference isolates user and API environment, handles storage failure', () => {
|
||||
const source = fs.readFileSync(path.join(__dirname, '../../src/utils/quick-link-device-preference.js'), 'utf8').replace(/export function/g, 'function')
|
||||
const map = new Map(); const process = { env: { VUE_APP_BASE_API: '/a' } }
|
||||
const storage = { getItem: k => map.get(k), setItem: (k, v) => map.set(k, v) }
|
||||
const p = new Function('localStorage', 'process', source + '; return {readQuickLinkDevice,rememberQuickLinkDevice}')(storage, process)
|
||||
p.rememberQuickLinkDevice(1, 10); assert.equal(p.readQuickLinkDevice(1), 10); assert.equal(p.readQuickLinkDevice(2), null)
|
||||
process.env.VUE_APP_BASE_API = '/b'; assert.equal(p.readQuickLinkDevice(1), null)
|
||||
storage.setItem = () => { throw new Error('denied') }; assert.equal(p.rememberQuickLinkDevice(1, 20), false)
|
||||
})
|
||||
Reference in New Issue
Block a user