Compare commits

..
20 changed files with 512 additions and 268 deletions
+14 -2
View File
@@ -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: eb9608c98fa117d4fcc2019ed6ae60b6a3670f42
synchronized_at: 2026-09-09T09:24:10Z
wiki_revision: e7a91d1d9c0f628297fb6ebe41b0d27d551c79ea
synchronized_at: 2026-09-10T01:41:52Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -387,3 +387,15 @@ 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。
+13 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: cf6bd9fb5dd727417a3ed612ddcf52eb2d8d868d
synchronized_at: 2026-09-09T09:24:15Z
wiki_revision: c1ff5d88432826af67d3e40f58837bd98d93cfce
synchronized_at: 2026-09-10T01:41:58Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -533,3 +533,14 @@ 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;结果核对也有限时,不能让失败提示再次陷入无限转圈。
+91 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Android-Agent-API-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Android-Agent-API-Contract.-
wiki_revision: 217239a85b50c880e004ecc19a559fb5e03555e1
synchronized_at: 2026-09-08T01:38:42Z
wiki_revision: 9898c6141b878407c2622c986eb9e15f0b39774d
synchronized_at: 2026-09-10T01:42:20Z
<!-- gitea-wiki-mirror:end -->
# MVP 共享 API 契约
@@ -556,7 +556,9 @@ 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` 或当前页面出现支付动作文字,最多再返回一次。返回后只读解析唯一订单号和下单时间;选择器或支付页重复出现、白名单不成立、恢复动作重复、无法到达订单详情、结果不唯一或超时均返回未知结果,禁止再次点击创建订单、取消订单或支付。
`order_result_unknown` 保持订单号和下单时间为空,但允许携带创建订单前已经严格验证的 `actualUnitPriceCent`。Agent 同时提交脱敏稳定失败阶段,服务端只接受白名单并按错误码写入固定提示,不信任或保存页面原文;阶段覆盖空窗口超时、选择器返回失败、微信恢复失败/超时、未知应用、支付页返回失败/重复、订单上下文缺失、订单号缺失/歧义、下单时间缺失/无效和待付款证据缺失。任务与 attempt 保存同一错误阶段,Admin 和 Agent 历史读取数据库最终事实;旧 Agent 未提交阶段时归一为 `PURCHASE_ORDER_RESULT_UNKNOWN`。
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 和摘要,重复提交按原幂等协议返回;任务释放租约和运行槽,进入既有人工处理结果未知通道,权限不变。
| 错误码 | 普通提示 |
|---|---|
@@ -985,3 +987,89 @@ 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秒。
+14 -3
View File
@@ -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: df033d06fd94a4b02a447a27ced8d711521342f3
synchronized_at: 2026-09-08T02:23:08Z
wiki_revision: 933eaf6cc02fed843a6898269fdb5f6848ac2a8a
synchronized_at: 2026-09-10T01:42:05Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
@@ -41,7 +41,7 @@ GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true
### API 长请求超时约束
Admin 蝦皮规格 AI 匹配会同步等待外部 Provider:Provider 配置允许 3~600 秒,复合的一键匹配共享 600 秒总预算,Admin 客户端等待 610 秒。因此服务端 `settings.application.writetimeout` 必须至少为 620 秒,受版本管理的标准配置固定为 `620`;启动时低于该值必须明确失败,禁止以 2 秒等短写超时运行,否则连接会在领域错误返回前被截断并表现为代理 502。反向代理的读/发送超时也必须大于服务端窗口。
Admin 蝦皮规格 AI 匹配会同步等待外部 Provider:Provider 配置允许 3~600 秒,旧版详情复合一键匹配共享600秒总预算,旧Admin客户端等待610秒;#254未部署前仍适用,部署后详情入口改用下节动态预算。因此服务端 `settings.application.writetimeout` 必须至少为 620 秒,受版本管理的标准配置固定为 `620`;启动时低于该值必须明确失败,禁止以 2 秒等短写超时运行,否则连接会在领域错误返回前被截断并表现为代理 502。反向代理的读/发送超时也必须大于服务端窗口。
Provider 故障日志只允许记录调用关联 ID、操作类型、耗时、上游 HTTP 状态码或网络错误分类;不得记录 API Key、Authorization、Provider URL、模型输入、候选规格、商品原始内容或响应正文。Provider 失败时接口返回 HTTP 503 与 `AI_MATCHING_UNAVAILABLE`,供 Admin 显示安全中文提示。
@@ -93,3 +93,14 @@ 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不需升级。
+30 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"strings"
"time"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
@@ -48,7 +49,20 @@ 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) {
ctx, cancel := context.WithTimeout(ctx, aimatching.MaxProviderTimeout)
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)
defer cancel()
requestID := strings.TrimSpace(request.RequestID)
@@ -248,6 +262,21 @@ 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,6 +10,7 @@ import (
"strings"
"time"
"go-admin/app/goauto/aimatching"
"go-admin/app/goauto/models"
"github.com/google/uuid"
@@ -166,7 +167,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]})
response, matchErr := service.autoMatchMappings(ctx, product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: fingerprint[:64]}, aimatching.MaxProviderTimeout)
// fingerprint begins with the 64-character context version.
postFingerprint := fingerprint
if next, _, nextErr := service.autoMatchEligibility(ctx, product); nextErr == nil && next != "" {
@@ -0,0 +1,91 @@
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")
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ func (handler Handler) Detail(c *gin.Context) {
if !ok {
return
}
response, err := service.Detail(c.Request.Context(), id)
response, err := service.DetailWithAutoMatchBudget(c.Request.Context(), id)
respond(c, response, err)
}
+13 -2
View File
@@ -76,8 +76,9 @@ type ProductView struct {
}
type SaveResponse struct {
Product ProductView `json:"product"`
Replayed bool `json:"replayed,omitempty"`
Product ProductView `json:"product"`
Replayed bool `json:"replayed,omitempty"`
AutoMatchTimeoutSeconds int `json:"autoMatchTimeoutSeconds,omitempty"`
}
type ListItemView struct {
@@ -156,6 +157,16 @@ 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 {
@@ -44,30 +44,6 @@ 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) {
@@ -77,7 +53,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 {
@@ -122,7 +98,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 {
@@ -199,7 +175,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 {
-6
View File
@@ -96,9 +96,6 @@ 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
@@ -274,9 +271,6 @@ 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
-2
View File
@@ -34,8 +34,6 @@ 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 【...】
@@ -1,69 +0,0 @@
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, ",")
}
@@ -1,121 +0,0 @@
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)
}
}
-8
View File
@@ -77,14 +77,6 @@ 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 {
+7 -4
View File
@@ -8,8 +8,8 @@ export function createShopeeProduct(data) {
return request({ url: '/api/admin/v1/shopee-products', method: 'post', data })
}
export function getShopeeProduct(productId) {
return request({ url: `/api/admin/v1/shopee-products/${productId}`, method: 'get' })
export function getShopeeProduct(productId, options = {}) {
return request({ url: `/api/admin/v1/shopee-products/${productId}`, method: 'get', ...options })
}
export function updateShopeeProduct(productId, data) {
@@ -66,8 +66,11 @@ 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) {
return request({ url: `/api/admin/v1/shopee-products/${productId}/specs/mapping/auto-match`, 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 startShopeeSpecAutoMatchRun(data) {
@@ -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" @click="openEdit">编辑档案</el-button></div>
<el-descriptions :column="2" border>
<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">
<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,7 +27,10 @@
<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-tabs v-model="detail.activeSpecTab" class="mapping-tabs">
<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-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">
@@ -48,10 +51,10 @@
</el-tab-pane>
</el-tabs>
<div class="section-heading"><h3>添加规格值</h3></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>
<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>
</template>
</div>
<template #footer><el-button @click="closeDetail">关闭</el-button><el-button type="primary" :loading="detail.saving" :disabled="!hasMappingChanges" @click="saveAllMappings">保存修改</el-button></template>
<template #footer><el-button @click="closeDetail">关闭</el-button><el-button type="primary" :loading="detail.saving" :disabled="detail.aiMatching || !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">
@@ -95,7 +98,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,
detailDrawerOpen: false, detail: this.emptyDetail(), loadError: '', requestGeneration: 0, loadedProductId: null, matchOperation: 0,
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: '' }
@@ -105,7 +108,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.pddLoaded && !this.hasMappingChanges && ['color', 'size'].some(role => this.specRows(role).length > 0 && this.pddSpecValues(role).length > 0) }
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) }
},
watch: {
modelValue: { immediate: true, handler(open) { if (open) this.requestSwitch(this.productId); else this.closeInternals() } },
@@ -114,17 +117,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, product: null, pddProduct: null, pddLoaded: false, activeSpecTab: 'color', highlightColor: '' } },
emptyDetail() { return { loading: false, saving: false, aiMatching: false, matchMessage: '', matchMessageType: 'info', 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 && generation === this.requestGeneration && Number(this.productId) === Number(id) },
isCurrent(generation, id) { return this.modelValue && this.detailDrawerOpen && 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 {
@@ -138,13 +141,14 @@ export default {
this.loadError = error?.response?.data?.message || error?.response?.data?.msg || '虾皮商品详情加载失败,请稍后重试'
} finally { if (this.isCurrent(generation, id)) this.detail.loading = 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) },
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) },
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); 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 }) },
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 }) },
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() },
@@ -161,7 +165,60 @@ 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() { 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 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 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() },
@@ -0,0 +1,67 @@
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 })
})
}
+1 -1
View File
@@ -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: { 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: { 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 }] }] }}}})
}
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: [] }}})
@@ -0,0 +1,93 @@
// 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)
})