Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f27ec16307 | ||
|
|
1d029d34c7 | ||
|
|
b12460825c | ||
|
|
d7924619b6 | ||
|
|
041cd8d03f |
@@ -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: de7e400503d6e7b9c335657114893de345bfead5
|
||||
synchronized_at: 2026-08-31T03:33:36Z
|
||||
wiki_revision: 2864a22f9e0f54a0663a56ab27d4b84052be3aac
|
||||
synchronized_at: 2026-09-01T15:31:13Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
@@ -56,7 +56,7 @@ Android Portal/Agent
|
||||
- Android 本地互斥与服务端原子领取共同保证单设备串行。
|
||||
- 正式采购在 Android 本地 SQLite 事务中先保存不可逆状态、稳定服务端请求 ID 和脱敏最终确认快照,再通知服务端并只允许一次创建订单点击;重启后只重放服务端标记、只读核单或上报,不再次点击。
|
||||
- 原始控件树和截图不持久化;Android Agent 端第一期不使用 OCR/VLM。服务端 SYB 登录验证码识别是唯一例外,见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48)。
|
||||
- 蝦皮规格映射独立保存在 `shopee_product.specs_json`:颜色只能选择关联 PDD 当前可选颜色,允许多个蝦皮颜色共用一个 PDD 颜色;尺码自动匹配只预览格式统一后的唯一确定结果。PDD 目标规格消失后页面标记失效,映射保存和采购创建均拒绝继续使用;无需新增数据库表或 Android 能力。
|
||||
- 蝦皮规格映射独立保存在 `shopee_product.specs_json`:颜色只能选择关联 PDD 当前可选颜色,允许多个蝦皮颜色共用一个 PDD 颜色。Admin 商品详情的“一键匹配颜色和尺码”在服务端统一计算两个维度:保留当前仍有效的已确认映射,将唯一确定匹配及达到阈值、具备理由且候选仍有效的 AI 匹配,在重新校验规格上下文后于同一事务直接写为 `confirmed`,无需人工确认;低置信度或无结果保持未匹配,Provider 异常或上下文变化时不写入任何本次结果。PDD 目标规格消失后页面标记失效,映射保存和采购创建均拒绝继续使用;无需新增数据库表或 Android 能力。
|
||||
|
||||
## 最小业务数据
|
||||
|
||||
@@ -318,3 +318,11 @@ PddProductDetailCollector
|
||||
- `GET /api/admin/v1/pdd-products/{productId}/related-syb-products` 返回统一扁平分页,参数为 `page`、`pageSize`(默认 20、最大 100)、`scope=actionable|all`(默认 actionable)和可选 `shopeeProductId`。管理员和采购员可读,其他角色由 Casbin 拒绝。
|
||||
- `purchase.Service.ProcessStages` 是 SYB 页和 PDD 关联订单共用的只读阶段入口;批量加载、不调用 AI。当前采购规则缺失或无效时仍返回基础关联、采集和已有任务事实,依赖规则才能创建的行明确标记不可采购。
|
||||
- Web 的 PDD 商品详情把“创建备货采购”保留在顶部,把“关联订单继续采购”放在关联商品区块;订单行使用统一分页,并复用既有 `batch-preview` / `batch` 创建路径。
|
||||
|
||||
## 蝦皮规格自动匹配批处理(#195)
|
||||
|
||||
- 服务端在 `server/app/goauto/shopeeproduct/auto_match_batch.go` 复用单商品“一键匹配颜色和尺码”原子服务,定时任务与 Admin 手动执行共用同一批处理入口。
|
||||
- `shopee_spec_auto_match_run` 保存触发来源、稳定 `requestId`、运行摘要和可空唯一 `active_slot`;活动槽与租约保证多实例、定时和手动同时触发时全局最多一个运行批次。
|
||||
- `shopee_spec_auto_match_work_item` 按蝦皮商品唯一保存输入指纹、尝试次数、下次尝试时间和逐商品租约。已完成或低置信度/无结果的相同输入不重复调用 AI;规格、关联或 AI 设置更新时间变化后才允许重新处理。
|
||||
- 迁移 `1788290000000_shopee_spec_auto_match.go` 幂等创建两张表并写入调用目标 `GoAutoShopeeSpecAutoMatch`。系统任务默认关闭,默认 Cron 为每小时第 15 分钟、每批最多 20 个商品。
|
||||
- Admin 蝦皮商品列表通过异步手动接口启动同一批次并轮询最近运行摘要;该入口仅管理员可用,不创建采购任务、订单,不调用 Android Agent,也不执行付款。
|
||||
|
||||
@@ -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: a6f63cc745cf0e0df7ca94521a70fb6af3adea26
|
||||
synchronized_at: 2026-09-01T09:55:26Z
|
||||
wiki_revision: 0e1df62c48aeba32a7d5e4956783f0af68e8e658
|
||||
synchronized_at: 2026-09-01T15:31:24Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -31,9 +31,9 @@ synchronized_at: 2026-09-01T09:55:26Z
|
||||
- 关联 PDD 商品时校验目标存在且非 `disabled`;不提供手工输入商品 ID 的入口,只能通过搜索选择。
|
||||
- 参考售价 `sale_price_cent` 为整数分,`currency` 为 ISO 4217 代码;币种取系统配置默认值,不逐商品选择,缺省回退为 `TWD`。
|
||||
- 规格值来源分 `import`(SYB 导入,不可人工删除,只能清除映射)与 `manual`(人工添加,可删除);导入与人工值按「维度 + 名称」合并,不重复创建。
|
||||
- 映射来源分 `manual`(人工,允许创建时即为已确认)、`exact_match`(名称标准化后唯一一致)、`ai_match`(AI 建议)。通用 `SetMapping` 入口仍将 `exact_match` 与 `ai_match` 一律写为 `pending`,必须人工确认后才生效;#188 的 SYB 商品页批量 AI 匹配是独立例外,其进入条件已由 #190 放开:只要蝦皮与 PDD 关联完整、PDD 当前为 `active` 且能给出可选颜色/尺码候选、并且已解析出至少一个目标颜色或尺码,即可进入批量匹配。解析状态(含 `parse_status=uncertain` 与 `failed`)、PDD 含颜色尺码之外的可选规格、蝦皮档案中未找到目标颜色或尺码、缺少完整可售 SKU 组合证据,自 #190 起都不再阻断匹配;本项目为内部系统,由此产生的“以错误目标规格进行匹配并保存映射”的风险由人工承担。唯一确定性 `exact_match` 不调用外部 AI,可直接保存为 `confirmed`;其余结果只有在 `ai_match` 置信度存在且达到服务端阈值、理由非空、返回值属于当前可选候选并命中同一个可售颜色+尺码组合时,才可直接持久化为 `confirmed`。低置信度、无组合证据、无效组合或 Provider 异常不得改写现有映射。
|
||||
- 映射来源分 `manual`(人工,允许创建时即为已确认)、`exact_match`(名称标准化后唯一一致)、`ai_match`(AI 建议)。通用 `SetMapping` 入口仍将 `exact_match` 与 `ai_match` 一律写为 `pending`,必须人工确认后才生效;#188 的 SYB 商品页批量 AI 匹配与 #194 的 Admin 蝦皮商品详情一键匹配是两个独立例外。#188 的进入条件已由 #190 放开:只要蝦皮与 PDD 关联完整、PDD 当前为 `active` 且能给出可选颜色/尺码候选、并且已解析出至少一个目标颜色或尺码,即可进入批量匹配。解析状态(含 `parse_status=uncertain` 与 `failed`)、PDD 含颜色尺码之外的可选规格、蝦皮档案中未找到目标颜色或尺码、缺少完整可售 SKU 组合证据,自 #190 起都不再阻断匹配;本项目为内部系统,由此产生的“以错误目标规格进行匹配并保存映射”的风险由人工承担。唯一确定性 `exact_match` 不调用外部 AI,可直接保存为 `confirmed`;其余结果只有在 `ai_match` 置信度存在且达到服务端阈值、理由非空、返回值属于当前可选候选并命中同一个可售颜色+尺码组合时,才可直接持久化为 `confirmed`。低置信度、无组合证据、无效组合或 Provider 异常不得改写现有映射。
|
||||
- 颜色映射只能从关联 PDD 商品当前可选颜色中选择,不允许自由输入;未使用颜色优先显示,已被其他蝦皮颜色使用的颜色仍可选择并显示占用者,因此支持多对一。
|
||||
- 尺码“一键自动匹配”只生成可审阅草稿:繁简体、首尾/重复空格、大小写和全半角统一后只有唯一结果才预填;没有唯一结果时保持待人工选择。保存只改映射,不改蝦皮或 PDD 原始规格。
|
||||
- Admin 蝦皮商品详情只保留一个“一键匹配颜色和尺码”入口。已确认且目标仍存在的映射保留;名称标准化后的唯一确定结果直接写为 `exact_match + confirmed`;其余只有在 AI 置信度达到服务端当前阈值、理由非空、返回值仍属于当前可选候选时才写为 `ai_match + confirmed`。低置信度或无结果保持未匹配;Provider 失败、AI 未启用、关联或规格上下文变化时本次不写入。操作前有未保存的人工修改时禁用一键匹配;保存只改映射,不改蝦皮或 PDD 原始规格。
|
||||
- PDD 重新采集或更换关联后,目标规格仍存在则映射继续有效;目标规格消失时详情标记“已失效”,服务端拒绝保存不存在的目标,采购预检和创建也拒绝使用失效映射并提示重新选择。
|
||||
- 批量软删除逐条校验引用(虾皮商品被 SYB 明细或采购任务引用时不可删除)并逐条返回结果;已删除商品默认不出现在列表,可筛选查看并恢复。
|
||||
|
||||
@@ -393,3 +393,11 @@ synchronized_at: 2026-09-01T09:55:26Z
|
||||
- 规格映射摘要使用 `specMappingStatus=complete|incomplete|not_required`、确认数和总数。`not_required` 只表示虾皮商品没有颜色或尺码维度;商品摘要不替代订单行采购资格。
|
||||
- “关联订单继续采购”只处理已有 SYB 订单;“创建备货采购”不关联 SYB 订单。两者入口和文案必须明确区分。继续采购复用既有批量预检和批量创建,价格、映射、并发、订单和不可逆门禁不变。
|
||||
- SYB 订单号、目标颜色/尺码、数量和当前采购任务状态只允许管理员与采购员读取;其他角色由服务端拒绝。查询路径不调用 AI Provider。
|
||||
|
||||
## 定时与手动蝦皮规格自动匹配(#195)
|
||||
|
||||
- 系统只扫描存活且已关联 `active` PDD 商品、两边至少共享颜色或尺码角色、并且至少存在一个未确认或已失效映射的蝦皮商品。
|
||||
- 每个商品复用详情页“一键匹配颜色和尺码”的规则:保留当前仍有效的 `confirmed` 映射;唯一确定匹配和达到服务端阈值、理由非空、候选仍有效的 AI 结果直接保存为 `confirmed`;低置信度、无结果、Provider 异常或上下文漂移不猜测、不写入错误映射。
|
||||
- 定时与管理员手动执行共用全局活动槽和逐商品工作状态。单批默认最多 20 个商品;同一规格上下文与 AI 设置更新时间未变化时,已完成、低置信度或无结果商品不重复调用 AI。
|
||||
- Provider 临时失败最多尝试 3 次,间隔至少 60 分钟;输入变化后重新计算指纹并允许重新处理。运行记录只保存结构化计数和脱敏限长错误,不保存 API Key、Provider 原始响应、商品原始 JSON 或个人数据。
|
||||
- 系统定时任务迁移后默认关闭,须由管理员明确启用。该能力仅维护蝦皮与 PDD 颜色/尺码映射,不创建采购任务、PDD 订单,不触发 Agent,也不执行付款。
|
||||
|
||||
@@ -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: c214791a0b055e5b56c0ad05e73829a1f141dd46
|
||||
synchronized_at: 2026-09-01T08:03:51Z
|
||||
wiki_revision: 87219820eec3964ee5379f1edba6a62e94bddd2e
|
||||
synchronized_at: 2026-09-01T15:32:52Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# MVP 共享 API 契约
|
||||
@@ -49,15 +49,18 @@ DELETE /api/admin/v1/shopee-products/{productId}/specs/mapping
|
||||
POST /api/admin/v1/shopee-products/{productId}/specs/mapping/confirm
|
||||
POST /api/admin/v1/shopee-products/{productId}/specs/mapping/confirm-exact-matches
|
||||
POST /api/admin/v1/shopee-products/{productId}/specs/mapping/preview-auto-size
|
||||
POST /api/admin/v1/shopee-products/{productId}/specs/mapping/auto-match
|
||||
POST /api/admin/v1/shopee-products/batch-delete
|
||||
```
|
||||
|
||||
创建与关联/映射相关写操作均提交 `requestId` 做幂等重放;重放请求返回相同结果并标记 `replayed`。创建请求提交 `shopeeItemId`、`title`、`shopName`,可选 `pddProductId` 和 `specs`;`shopeeItemId` 重复时返回 `SHOPEE_ITEM_ID_EXISTS` 和已存在商品 ID。`link-pdd` 校验 PDD 商品存在且非 `disabled`,否则分别返回 `PDD_PRODUCT_NOT_FOUND` 或 `PDD_PRODUCT_DISABLED`;关联成功后返回值包含 `sharedByPddCount`,表示当前共用同一 PDD 商品的虾皮商品数。
|
||||
|
||||
`specs` 结构同 PDD 商品的维度/规格值形状,但规格值额外携带 `source`(`import` / `manual`)与可选的 `mapping`(`pddValue`、`source`、`status`、`confidence`、`reason`)。新增规格值固定为 `manual` 来源;删除规格值仅允许 `manual` 来源,`import` 来源返回 `SPEC_VALUE_NOT_MANUAL`。设置映射时,`exact_match` 与 `ai_match` 来源一律写入 `pending` 状态,与请求体中的 `status` 无关;只有 `manual` 来源可以直接写入 `confirmed`。`confirm-exact-matches` 仅确认 `source=exact_match` 且状态为 `pending` 的映射,不影响 `ai_match`。该通用入口不因 #188 改变;#188 仅通过下述采购批量规格匹配接口的独立写入路径,将唯一确定性 `exact_match` 或通过高置信度门槛的 `ai_match` 写入 `confirmed`。
|
||||
`specs` 结构同 PDD 商品的维度/规格值形状,但规格值额外携带 `source`(`import` / `manual`)与可选的 `mapping`(`pddValue`、`source`、`status`、`confidence`、`reason`)。新增规格值固定为 `manual` 来源;删除规格值仅允许 `manual` 来源,`import` 来源返回 `SPEC_VALUE_NOT_MANUAL`。设置映射时,`exact_match` 与 `ai_match` 来源一律写入 `pending` 状态,与请求体中的 `status` 无关;只有 `manual` 来源可以直接写入 `confirmed`。`confirm-exact-matches` 仅确认 `source=exact_match` 且状态为 `pending` 的映射,不影响 `ai_match`。该通用入口不因 #188 或 #194 改变;两者只能通过各自独立的服务端写入路径,将唯一确定性 `exact_match` 或通过高置信度门槛的 `ai_match` 写入 `confirmed`。
|
||||
|
||||
`preview-auto-size` 是只读计算接口(使用 `POST` 触发计算,不写数据库),无需 `requestId`。它读取当前蝦皮尺码与关联 PDD 的可选尺码,只返回格式统一后唯一确定的匹配;响应含 `items[]`(`valueName`、可选 `pddValue`、`status=preserved|matched|pending`、`reason`)、`pddValues`、`matchedCount` 和 `pendingCount`。已确认且目标仍存在的映射标为 `preserved`;无唯一结果标为 `pending`。Admin 的显式“保存修改”仍通过既有设置/确认接口落库。
|
||||
|
||||
`auto-match` 是 Admin 蝦皮商品详情一键匹配颜色和尺码的独立写入接口。请求体必须提交 UUID `requestId` 和详情返回的 `specContextVersion`。服务端在事务外完成必要的 Provider 调用,入事务后重新锁定蝦皮商品与 PDD 商品,复核关联、完整规格上下文、当前可选候选、AI 开关和最新置信度阈值。保留有效的已确认映射;唯一确定结果直接保存为 `exact_match + confirmed`;置信度达标、理由非空且候选仍有效的 AI 结果保存为 `ai_match + confirmed`;其余项保持未匹配。响应含 `product`、`items[]`(`dimension`、`role`、`valueName`、可选 `pddValue`、`status=confirmed|preserved|unmatched`、可选 `source`、`confidence`、`reason`)、`confirmedCount`、`preservedCount`、`unmatchedCount` 和 `replayed`。同一 `requestId` 幂等重放;Provider 异常或 AI 未启用返回 `AI_MATCHING_UNAVAILABLE`,关联或规格变化返回 `SPEC_CONTEXT_VERSION_STALE`,两者都不写入本次结果。
|
||||
|
||||
设置映射时,`pddValue` 必须是关联 PDD 商品同角色下当前可选的原始规格标签,否则返回 `INVALID_REQUEST`。PDD 重新采集后旧目标消失时,Admin 标记失效;采购预检与任务创建返回 `PURCHASE_SPEC_MAPPING_REQUIRED` 和“规格匹配已失效,请重新选择 PDD 规格”,不得把旧标签下发给 Agent。
|
||||
|
||||
`batch-delete` 提交 `requestId` 和 `ids`(1~500 个),逐条校验引用后返回每条的 `status`(`deleted` / `skipped`)与 `reason`;引用检查覆盖 SYB 明细(#41)与采购任务(#33/#34),两张表落地前恒不阻塞删除。`restore` 恢复一条已软删除商品,恢复后原有 PDD 关联与规格映射保持不变。列表接口 `status=deleted` 筛选已删除商品,默认只返回存活商品。
|
||||
@@ -845,3 +848,17 @@ file=<JPEG 二进制>
|
||||
| `GET` | `/api/agent/v1/app/releases/{releaseId}/download` | Device Token;私有 APK 下载 |
|
||||
|
||||
Agent 只比较整数 `versionCode`。设备有活动任务时禁止检查、下载和安装;下载到应用私有缓存并校验响应大小与 SHA-256,失败立即删除。安装使用 FileProvider 和 Android 系统安装确认页;未知来源权限必须由用户在系统设置授权,不静默安装。
|
||||
|
||||
## Admin 蝦皮规格自动匹配批次(#195)
|
||||
|
||||
以下接口仅管理员可用,采购员与其他角色必须拒绝;两者不属于 Android Agent 接口,不改变任何 Agent 契约。
|
||||
|
||||
### `POST /api/admin/v1/shopee-spec-auto-match/runs`
|
||||
|
||||
请求:`{ "requestId": "UUID" }`。服务端以 `requestId` 幂等,异步受理默认最多 20 个商品的手动批次,并返回 HTTP 202、`data.run` 运行摘要。若已有活动批次,不再创建第二个批次,返回当前运行并标记 `alreadyRunning=true`;相同请求重放标记 `replayed=true`。
|
||||
|
||||
### `GET /api/admin/v1/shopee-spec-auto-match/runs/latest`
|
||||
|
||||
返回 `data.run`;从未执行时为 `null`。运行摘要包含 `id/requestId/trigger/status/batchLimit/scannedCount/eligibleCount/processedCount/confirmedCount/unmatchedCount/failedCount/startedAt/finishedAt` 和可选脱敏 `errorSummary`,不得返回 API Key、Provider 原始响应或商品原始 JSON。
|
||||
|
||||
`status` 当前为 `running`、`completed`、`completed_partial` 或 `failed`。页面只轮询最近摘要;查询本身不调用 AI。批次只更新蝦皮商品规格映射,不创建采购任务、订单或付款动作。
|
||||
|
||||
@@ -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: 80ae5c8cea87c624fff63ce3e302aa7bf204811e
|
||||
synchronized_at: 2026-08-31T16:07:03Z
|
||||
wiki_revision: 929a9496d2e4aa25769351e08075dce6bd993b2d
|
||||
synchronized_at: 2026-09-02T09:40:46Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 部署与运维
|
||||
@@ -39,6 +39,20 @@ GOAUTO_ALLOW_INSECURE_AGENT_HTTP=true
|
||||
|
||||
发布、重启、设置当前 Agent 版本和真机安装都属于高风险操作,必须有对应工单和人工确认。不得在部署过程中执行付款、创建订单或清理生产数据。
|
||||
|
||||
### API 长请求超时约束
|
||||
|
||||
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 显示安全中文提示。
|
||||
|
||||
## 回滚
|
||||
|
||||
服务异常时把 `/home/goauto/current` 切回上一已验证发布目录并重启 `goauto.service`,随后复核服务状态和 Agent 接口。不要删除当前或历史 APK/发布目录来代替回滚;数据库变化如需回退必须单独评估。
|
||||
|
||||
## 蝦皮规格自动匹配任务运维(#195)
|
||||
|
||||
- 迁移 `1788290000000_shopee_spec_auto_match.go` 创建运行与工作状态表,并幂等写入 `sys_job.invoke_target=GoAutoShopeeSpecAutoMatch`。首次部署状态为关闭,迁移重跑不会覆盖管理员后来修改的 Cron、参数或启停状态。
|
||||
- 默认 Cron 为 `0 15 * * * *`(每小时第 15 分钟),默认 `batchLimit` 为 20,合法范围 1~100。上线后先保持关闭,在 Admin 蝦皮商品列表手动执行小批次并核对运行摘要和映射,再由管理员在定时任务页明确启用。
|
||||
- 定时和手动触发共享数据库唯一活动槽。看到“已有自动匹配正在运行”属于正常互斥结果;运行租约超过 30 分钟会在下次触发时安全释放并把旧运行标记失败。处理每个商品前会续租。
|
||||
- Provider 临时失败的商品至少 60 分钟后重试,最多 3 次;低置信度或无结果在输入指纹不变时不会重复调用。排错优先查看 `shopee_spec_auto_match_run` 的结构化计数与脱敏错误,以及 `shopee_spec_auto_match_work_item` 的状态、尝试次数、下次尝试时间和租约;不得输出或复制 API Key、Provider 原始响应、商品原始 JSON 或个人数据。
|
||||
- 紧急回退只需关闭系统任务并停止使用 Admin 手动入口;保留运行记录和既有已确认映射用于审计,不删除数据,也不回滚 #194。
|
||||
|
||||
@@ -136,6 +136,8 @@ func moduleKeyForAPI(path string) string {
|
||||
return ModulePDDProducts
|
||||
case strings.HasPrefix(path, "/api/admin/v1/shopee-products"):
|
||||
return ModuleShopeeProducts
|
||||
case strings.HasPrefix(path, "/api/admin/v1/shopee-spec-auto-match"):
|
||||
return ModuleShopeeProducts
|
||||
case strings.HasPrefix(path, "/api/admin/v1/syb-products/sync-runs"):
|
||||
return ModuleSYBSyncRuns
|
||||
case strings.HasPrefix(path, "/api/admin/v1/syb-products"):
|
||||
|
||||
@@ -47,6 +47,9 @@ var AdminAPIs = []APIPermission{
|
||||
{"预览尺码匹配", "/api/admin/v1/shopee-products/:productId/specs/mapping/preview-auto-size", "POST", true},
|
||||
{"AI 建议颜色映射", "/api/admin/v1/shopee-products/:productId/specs/mapping/suggest-colors", "POST", true},
|
||||
{"AI 建议尺码映射", "/api/admin/v1/shopee-products/:productId/specs/mapping/suggest-sizes", "POST", true},
|
||||
{"一键匹配并确认颜色尺码", "/api/admin/v1/shopee-products/:productId/specs/mapping/auto-match", "POST", true},
|
||||
{"手动执行虾皮规格自动匹配", "/api/admin/v1/shopee-spec-auto-match/runs", "POST", false},
|
||||
{"查看最近虾皮规格自动匹配", "/api/admin/v1/shopee-spec-auto-match/runs/latest", "GET", false},
|
||||
|
||||
{"查看 SYB 商品", "/api/admin/v1/syb-products", "GET", true},
|
||||
{"查看 SYB 商品详情", "/api/admin/v1/syb-products/:productId", "GET", true},
|
||||
|
||||
@@ -15,10 +15,12 @@ func TestPurchaserPermissionMatrixHasNoDuplicates(t *testing.T) {
|
||||
|
||||
func TestPurchaserExcludesAdministratorOperations(t *testing.T) {
|
||||
denied := map[string]bool{
|
||||
"POST /api/admin/v1/devices/:deviceId/disable": true,
|
||||
"POST /api/admin/v1/syb-products/import": true,
|
||||
"POST /api/admin/v1/collection-rules": true,
|
||||
"PUT /api/admin/v1/ai-matching-settings": true,
|
||||
"POST /api/admin/v1/devices/:deviceId/disable": true,
|
||||
"POST /api/admin/v1/syb-products/import": true,
|
||||
"POST /api/admin/v1/collection-rules": true,
|
||||
"PUT /api/admin/v1/ai-matching-settings": true,
|
||||
"POST /api/admin/v1/shopee-spec-auto-match/runs": true,
|
||||
"GET /api/admin/v1/shopee-spec-auto-match/runs/latest": true,
|
||||
}
|
||||
for _, permission := range PurchaserAPIs() {
|
||||
if denied[permission.Method+" "+permission.Path] {
|
||||
@@ -62,3 +64,12 @@ func TestPurchaserMayRunBatchSpecMatch(t *testing.T) {
|
||||
}
|
||||
t.Fatal("missing purchaser batch spec match permission")
|
||||
}
|
||||
|
||||
func TestPurchaserMayAutoMatchShopeeProductSpecs(t *testing.T) {
|
||||
for _, permission := range PurchaserAPIs() {
|
||||
if permission.Method == "POST" && permission.Path == "/api/admin/v1/shopee-products/:productId/specs/mapping/auto-match" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("missing purchaser shopee auto-match permission")
|
||||
}
|
||||
|
||||
@@ -25,12 +25,28 @@ const (
|
||||
defaultAutoConfirmMinConfidence = 0.9
|
||||
)
|
||||
|
||||
// MaxProviderTimeout is also the total budget used by composite synchronous
|
||||
// AI operations. This keeps their HTTP response inside the Admin and API
|
||||
// transport windows even when an operation needs more than one provider call.
|
||||
const MaxProviderTimeout = 600 * time.Second
|
||||
|
||||
// Service owns the internal AI Provider configuration. The API key exception
|
||||
// is deliberately narrow: it is plain text only in the dedicated settings
|
||||
// table and is returned only by the administrator settings handler.
|
||||
type Service struct {
|
||||
DB *gorm.DB
|
||||
HTTPClient *http.Client
|
||||
DB *gorm.DB
|
||||
HTTPClient *http.Client
|
||||
ProviderFailureLogger func(ProviderFailureDiagnostic)
|
||||
}
|
||||
|
||||
// ProviderFailureDiagnostic deliberately contains no URL, model, prompt,
|
||||
// candidates, response body or credential. It is safe for operational logs.
|
||||
type ProviderFailureDiagnostic struct {
|
||||
CallID string
|
||||
Operation string
|
||||
Kind string
|
||||
StatusCode int
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) *Service {
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/go-admin-team/go-admin-core/logger"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Suggestion limits are enforced defensively here too, even though callers
|
||||
@@ -95,18 +98,26 @@ func (s *Service) SuggestBatch(ctx context.Context, request SuggestRequest) (Sug
|
||||
}
|
||||
httpRequest.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
callID, startedAt := uuid.NewString(), time.Now()
|
||||
response, err := s.httpClient().Do(httpRequest)
|
||||
if err != nil {
|
||||
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: providerNetworkErrorKind(err), Duration: time.Since(startedAt)})
|
||||
return SuggestResult{}, &Error{Code: CodeProviderUnavailable, Message: "AI 建议服务暂时不可用", Cause: err}
|
||||
}
|
||||
defer response.Body.Close()
|
||||
limited := io.LimitReader(response.Body, 1<<20)
|
||||
responseBody, readErr := io.ReadAll(limited)
|
||||
if readErr != nil || response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
if readErr != nil {
|
||||
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: "read_error", StatusCode: response.StatusCode, Duration: time.Since(startedAt)})
|
||||
return SuggestResult{}, fail(CodeProviderUnavailable, "AI 建议服务暂时不可用")
|
||||
}
|
||||
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: "http_status", StatusCode: response.StatusCode, Duration: time.Since(startedAt)})
|
||||
return SuggestResult{}, fail(CodeProviderUnavailable, "AI 建议服务暂时不可用")
|
||||
}
|
||||
raw, err := parseSuggestChoices(responseBody)
|
||||
if err != nil {
|
||||
s.logProviderFailure(ProviderFailureDiagnostic{CallID: callID, Operation: "suggest_batch", Kind: "invalid_response", StatusCode: response.StatusCode, Duration: time.Since(startedAt)})
|
||||
return SuggestResult{}, fail(CodeProviderUnavailable, "AI 建议响应无效")
|
||||
}
|
||||
|
||||
@@ -145,6 +156,22 @@ func (s *Service) SuggestBatch(ctx context.Context, request SuggestRequest) (Sug
|
||||
return SuggestResult{Decisions: decisions, Provider: ProviderOpenAICompatible, Model: setting.Model}, nil
|
||||
}
|
||||
|
||||
func (s *Service) logProviderFailure(diagnostic ProviderFailureDiagnostic) {
|
||||
if s.ProviderFailureLogger != nil {
|
||||
s.ProviderFailureLogger(diagnostic)
|
||||
return
|
||||
}
|
||||
log.Warnf("AI provider call failed: call_id=%s operation=%s kind=%s status=%d duration_ms=%d",
|
||||
diagnostic.CallID, diagnostic.Operation, diagnostic.Kind, diagnostic.StatusCode, diagnostic.Duration.Milliseconds())
|
||||
}
|
||||
|
||||
func providerNetworkErrorKind(err error) string {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "timeout"
|
||||
}
|
||||
return "network_error"
|
||||
}
|
||||
|
||||
func suggestSystemPrompt(dimension string) string {
|
||||
noun := "颜色或尺码"
|
||||
switch dimension {
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
@@ -134,3 +136,93 @@ func TestSuggestBatchRequiresConfiguredProvider(t *testing.T) {
|
||||
t.Fatalf("expected CodeNotConfigured, got %v", target.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestBatchLogsSafeDiagnosticForProvider502(t *testing.T) {
|
||||
const sensitiveBody = "api-key-and-provider-body-must-not-be-logged"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, sensitiveBody, http.StatusBadGateway)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := openSuggestTestDB(t)
|
||||
seedEnabledSetting(t, db, server.URL)
|
||||
var diagnostic ProviderFailureDiagnostic
|
||||
service := NewService(db)
|
||||
service.ProviderFailureLogger = func(value ProviderFailureDiagnostic) { diagnostic = value }
|
||||
|
||||
_, err := service.SuggestBatch(context.Background(), SuggestRequest{
|
||||
Sources: []SuggestSource{{ID: "s1", Label: "sensitive-source"}},
|
||||
Candidates: []SuggestCandidate{{ID: "c1", Label: "sensitive-candidate"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected provider failure")
|
||||
}
|
||||
if diagnostic.Operation != "suggest_batch" || diagnostic.Kind != "http_status" || diagnostic.StatusCode != http.StatusBadGateway || diagnostic.CallID == "" {
|
||||
t.Fatalf("unexpected diagnostic: %+v", diagnostic)
|
||||
}
|
||||
printed := fmt.Sprintf("%+v", diagnostic)
|
||||
for _, secret := range []string{sensitiveBody, "sensitive-source", "sensitive-candidate", "test-key", server.URL} {
|
||||
if strings.Contains(printed, secret) {
|
||||
t.Fatalf("diagnostic leaked %q: %s", secret, printed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestBatchClassifiesProviderTimeout(t *testing.T) {
|
||||
db := openSuggestTestDB(t)
|
||||
seedEnabledSetting(t, db, "http://provider.invalid")
|
||||
var diagnostic ProviderFailureDiagnostic
|
||||
service := NewService(db)
|
||||
service.HTTPClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, context.DeadlineExceeded
|
||||
})}
|
||||
service.ProviderFailureLogger = func(value ProviderFailureDiagnostic) { diagnostic = value }
|
||||
|
||||
_, err := service.SuggestBatch(context.Background(), SuggestRequest{
|
||||
Sources: []SuggestSource{{ID: "s1", Label: "黑色"}}, Candidates: []SuggestCandidate{{ID: "c1", Label: "黑色"}},
|
||||
})
|
||||
if err == nil || diagnostic.Kind != "timeout" || diagnostic.StatusCode != 0 {
|
||||
t.Fatalf("timeout was not safely classified: diagnostic=%+v err=%v", diagnostic, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestBatchClassifiesInvalidResponseWithoutLoggingBody(t *testing.T) {
|
||||
const sensitiveBody = "not-json-with-sensitive-provider-details"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(sensitiveBody))
|
||||
}))
|
||||
defer server.Close()
|
||||
db := openSuggestTestDB(t)
|
||||
seedEnabledSetting(t, db, server.URL)
|
||||
var diagnostic ProviderFailureDiagnostic
|
||||
service := NewService(db)
|
||||
service.ProviderFailureLogger = func(value ProviderFailureDiagnostic) { diagnostic = value }
|
||||
|
||||
_, err := service.SuggestBatch(context.Background(), SuggestRequest{
|
||||
Sources: []SuggestSource{{ID: "s1", Label: "黑色"}}, Candidates: []SuggestCandidate{{ID: "c1", Label: "黑色"}},
|
||||
})
|
||||
if err == nil || diagnostic.Kind != "invalid_response" || strings.Contains(fmt.Sprintf("%+v", diagnostic), sensitiveBody) {
|
||||
t.Fatalf("invalid response diagnostic is unsafe or missing: diagnostic=%+v err=%v", diagnostic, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestBatchAllowsProviderResponseAfterTwoSeconds(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(2100 * time.Millisecond)
|
||||
chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.95,"reason":"match"}]}`)(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := openSuggestTestDB(t)
|
||||
seedEnabledSetting(t, db, server.URL)
|
||||
result, err := NewService(db).SuggestBatch(context.Background(), SuggestRequest{
|
||||
Sources: []SuggestSource{{ID: "s1", Label: "黑色"}}, Candidates: []SuggestCandidate{{ID: "c1", Label: "黑色"}},
|
||||
})
|
||||
if err != nil || result.Decisions["s1"].CandidateID != "c1" {
|
||||
t.Fatalf("delayed provider response failed: result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return fn(request) }
|
||||
|
||||
@@ -34,6 +34,8 @@ func MigratedModels() []any {
|
||||
&models.PDDProduct{},
|
||||
&models.AIMatchingSetting{},
|
||||
&models.ShopeeProduct{},
|
||||
&models.ShopeeSpecAutoMatchRun{},
|
||||
&models.ShopeeSpecAutoMatchWorkItem{},
|
||||
&models.SYBProduct{},
|
||||
&models.SYBSession{},
|
||||
&models.SYBShop{},
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// ShopeeSpecAutoMatchRun is one scheduled or administrator-triggered batch.
|
||||
// ActiveSlot is 1 only while running; its nullable unique index is the
|
||||
// database-level cross-process mutex shared by both trigger paths.
|
||||
type ShopeeSpecAutoMatchRun struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
RequestID string `json:"requestId" gorm:"size:36;not null;uniqueIndex:ux_shopee_spec_auto_match_run_request"`
|
||||
Trigger string `json:"trigger" gorm:"size:16;not null;index"`
|
||||
Status string `json:"status" gorm:"size:24;not null;index"`
|
||||
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_shopee_spec_auto_match_run_active"`
|
||||
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
|
||||
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
|
||||
RequestedBy *uint64 `json:"requestedBy,omitempty"`
|
||||
BatchLimit int `json:"batchLimit" gorm:"not null;default:20"`
|
||||
ScannedCount int `json:"scannedCount" gorm:"not null;default:0"`
|
||||
EligibleCount int `json:"eligibleCount" gorm:"not null;default:0"`
|
||||
ProcessedCount int `json:"processedCount" gorm:"not null;default:0"`
|
||||
ConfirmedCount int `json:"confirmedCount" gorm:"not null;default:0"`
|
||||
UnmatchedCount int `json:"unmatchedCount" gorm:"not null;default:0"`
|
||||
FailedCount int `json:"failedCount" gorm:"not null;default:0"`
|
||||
ErrorSummary string `json:"errorSummary,omitempty" gorm:"size:500;not null;default:''"`
|
||||
StartedAt time.Time `json:"startedAt" gorm:"not null"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (ShopeeSpecAutoMatchRun) TableName() string { return "shopee_spec_auto_match_run" }
|
||||
|
||||
// ShopeeSpecAutoMatchWorkItem remembers the last input fingerprint and retry
|
||||
// state for each product, preventing unchanged low-confidence inputs from
|
||||
// repeatedly spending AI calls.
|
||||
type ShopeeSpecAutoMatchWorkItem struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
ShopeeProductID uint64 `json:"shopeeProductId" gorm:"not null;uniqueIndex:ux_shopee_spec_auto_match_work_product"`
|
||||
RunID *uint64 `json:"runId,omitempty" gorm:"index"`
|
||||
InputFingerprint string `json:"inputFingerprint" gorm:"size:128;not null;default:'';index"`
|
||||
Status string `json:"status" gorm:"size:24;not null;index"`
|
||||
AttemptCount int `json:"attemptCount" gorm:"not null;default:0"`
|
||||
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty" gorm:"index"`
|
||||
LeaseOwner string `json:"-" gorm:"size:64;not null;default:''"`
|
||||
LeaseExpiresAt *time.Time `json:"-" gorm:"index"`
|
||||
ConfirmedCount int `json:"confirmedCount" gorm:"not null;default:0"`
|
||||
UnmatchedCount int `json:"unmatchedCount" gorm:"not null;default:0"`
|
||||
LastErrorCode string `json:"lastErrorCode,omitempty" gorm:"size:64;not null;default:''"`
|
||||
LastError string `json:"lastError,omitempty" gorm:"size:500;not null;default:''"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (ShopeeSpecAutoMatchWorkItem) TableName() string {
|
||||
return "shopee_spec_auto_match_work_item"
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type AutoMatchRequest struct {
|
||||
RequestID string `json:"requestId"`
|
||||
SpecContextVersion string `json:"specContextVersion"`
|
||||
}
|
||||
|
||||
type AutoMatchItem struct {
|
||||
Dimension string `json:"dimension"`
|
||||
Role string `json:"role"`
|
||||
ValueName string `json:"valueName"`
|
||||
PDDValue string `json:"pddValue,omitempty"`
|
||||
Status string `json:"status"` // confirmed | preserved | unmatched
|
||||
Source string `json:"source,omitempty"`
|
||||
Confidence *float64 `json:"confidence,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type AutoMatchResponse struct {
|
||||
Product ProductView `json:"product"`
|
||||
Items []AutoMatchItem `json:"items"`
|
||||
ConfirmedCount int `json:"confirmedCount"`
|
||||
PreservedCount int `json:"preservedCount"`
|
||||
UnmatchedCount int `json:"unmatchedCount"`
|
||||
Replayed bool `json:"replayed"`
|
||||
}
|
||||
|
||||
type autoMatchRef struct {
|
||||
dimension string
|
||||
valueName string
|
||||
}
|
||||
|
||||
// AutoMatchMappings resolves both color and size values, then persists every
|
||||
// accepted result as confirmed in one transaction. Provider calls happen
|
||||
// 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)
|
||||
defer cancel()
|
||||
|
||||
requestID := strings.TrimSpace(request.RequestID)
|
||||
if _, err := uuid.Parse(requestID); err != nil {
|
||||
return AutoMatchResponse{}, invalidRequest("requestId 必须是 UUID")
|
||||
}
|
||||
requestVersion := strings.TrimSpace(request.SpecContextVersion)
|
||||
if requestVersion == "" {
|
||||
return AutoMatchResponse{}, contextVersionStale()
|
||||
}
|
||||
|
||||
db := service.DB.WithContext(ctx)
|
||||
var replay models.ShopeeProduct
|
||||
if err := db.Where("id = ? AND last_update_request_id = ?", id, requestID).First(&replay).Error; err == nil {
|
||||
return service.autoMatchReplay(ctx, id)
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return AutoMatchResponse{}, internalError(err)
|
||||
}
|
||||
|
||||
var shopee models.ShopeeProduct
|
||||
if err := db.First(&shopee, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return AutoMatchResponse{}, productNotFound()
|
||||
}
|
||||
return AutoMatchResponse{}, internalError(err)
|
||||
}
|
||||
if shopee.PDDProductID == nil {
|
||||
return AutoMatchResponse{}, &ServiceError{Code: CodePDDProductNotFound, Message: "请先关联 PDD 商品"}
|
||||
}
|
||||
var pdd models.PDDProduct
|
||||
if err := db.First(&pdd, *shopee.PDDProductID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return AutoMatchResponse{}, &ServiceError{Code: CodePDDProductNotFound, Message: "关联的 PDD 商品不存在"}
|
||||
}
|
||||
return AutoMatchResponse{}, internalError(err)
|
||||
}
|
||||
if computeSpecContextVersion(shopee.PDDProductID, shopee.SpecsJSON, pdd.SpecsJSON) != requestVersion {
|
||||
return AutoMatchResponse{}, contextVersionStale()
|
||||
}
|
||||
initialSpecs, err := Unmarshal(shopee.SpecsJSON)
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, internalError(err)
|
||||
}
|
||||
|
||||
items := make([]AutoMatchItem, 0)
|
||||
for _, role := range []string{RoleColor, RoleSize} {
|
||||
refs := autoMatchRefs(initialSpecs, role)
|
||||
if len(refs) == 0 {
|
||||
continue
|
||||
}
|
||||
preview, err := service.suggestMappings(ctx, id, requestVersion, role)
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, err
|
||||
}
|
||||
if len(preview.Items) != len(refs) {
|
||||
return AutoMatchResponse{}, internalError(errors.New("AI 匹配结果与规格输入数量不一致"))
|
||||
}
|
||||
for index, suggestion := range preview.Items {
|
||||
item := AutoMatchItem{
|
||||
Dimension: refs[index].dimension,
|
||||
Role: role,
|
||||
ValueName: refs[index].valueName,
|
||||
PDDValue: suggestion.PDDValue,
|
||||
Confidence: suggestion.Confidence,
|
||||
Reason: strings.TrimSpace(suggestion.Reason),
|
||||
Status: "unmatched",
|
||||
}
|
||||
switch suggestion.Status {
|
||||
case "preserved":
|
||||
item.Status = "preserved"
|
||||
case "matched":
|
||||
item.Status = "confirmed"
|
||||
item.Source = MappingSourceExactMatch
|
||||
case "ai_matched":
|
||||
if suggestion.Confidence != nil && item.Reason != "" {
|
||||
item.Status = "confirmed"
|
||||
item.Source = MappingSourceAIMatch
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
|
||||
replayed := false
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
var current models.ShopeeProduct
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return productNotFound()
|
||||
}
|
||||
return internalError(err)
|
||||
}
|
||||
if current.LastUpdateRequestID != nil && *current.LastUpdateRequestID == requestID {
|
||||
replayed = true
|
||||
return nil
|
||||
}
|
||||
if current.PDDProductID == nil || *current.PDDProductID != pdd.ID {
|
||||
return contextVersionStale()
|
||||
}
|
||||
var currentPDD models.PDDProduct
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤tPDD, pdd.ID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return contextVersionStale()
|
||||
}
|
||||
return internalError(err)
|
||||
}
|
||||
if computeSpecContextVersion(current.PDDProductID, current.SpecsJSON, currentPDD.SpecsJSON) != requestVersion {
|
||||
return contextVersionStale()
|
||||
}
|
||||
|
||||
currentSpecs, err := Unmarshal(current.SpecsJSON)
|
||||
if err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
candidates := map[string]map[string]bool{}
|
||||
for _, role := range []string{RoleColor, RoleSize} {
|
||||
values, err := selectablePDDValues(currentPDD.SpecsJSON, role)
|
||||
if err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
candidates[role] = values
|
||||
}
|
||||
|
||||
needsAISettings := false
|
||||
for _, item := range items {
|
||||
if item.Status == "confirmed" && item.Source == MappingSourceAIMatch {
|
||||
needsAISettings = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if needsAISettings {
|
||||
settings, err := aimatching.NewService(tx).Settings(ctx)
|
||||
if err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if !settings.Enabled {
|
||||
return aiUnavailable("AI 匹配未启用,请先在设置中配置并启用")
|
||||
}
|
||||
for index := range items {
|
||||
item := &items[index]
|
||||
if item.Status == "confirmed" && item.Source == MappingSourceAIMatch &&
|
||||
(item.Confidence == nil || *item.Confidence < settings.AutoConfirmMinConfidence || strings.TrimSpace(item.Reason) == "") {
|
||||
item.Status, item.Source = "unmatched", ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plans := make(map[string]AutoMatchItem, len(items))
|
||||
for _, item := range items {
|
||||
plans[item.Dimension+"\x00"+item.ValueName] = item
|
||||
}
|
||||
for dimensionIndex, dimension := range currentSpecs {
|
||||
if dimension.Role != RoleColor && dimension.Role != RoleSize {
|
||||
continue
|
||||
}
|
||||
for valueIndex, value := range dimension.Values {
|
||||
item, ok := plans[dimension.Name+"\x00"+value.Name]
|
||||
if !ok || item.Status != "confirmed" {
|
||||
continue
|
||||
}
|
||||
if !candidates[dimension.Role][item.PDDValue] {
|
||||
return contextVersionStale()
|
||||
}
|
||||
currentSpecs[dimensionIndex].Values[valueIndex].Mapping = &Mapping{
|
||||
PDDValue: item.PDDValue, Source: item.Source, Status: MappingStatusConfirmed,
|
||||
Confidence: item.Confidence, Reason: item.Reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := Validate(currentSpecs); err != nil {
|
||||
return invalidRequest(err.Error())
|
||||
}
|
||||
specsJSON, err := Marshal(currentSpecs)
|
||||
if err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if err := tx.Model(&models.ShopeeProduct{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"specs_json": specsJSON, "last_update_request_id": requestID,
|
||||
}).Error; err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
if err := syncReplacementItemAfterManualEdit(tx, id, currentSpecs); err != nil {
|
||||
return internalError(err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, err
|
||||
}
|
||||
if replayed {
|
||||
return service.autoMatchReplay(ctx, id)
|
||||
}
|
||||
response, err := service.autoMatchResponse(ctx, id, items)
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func autoMatchRefs(specs []SpecDimension, role string) []autoMatchRef {
|
||||
refs := make([]autoMatchRef, 0)
|
||||
for _, dimension := range specs {
|
||||
if dimension.Role != role {
|
||||
continue
|
||||
}
|
||||
for _, value := range dimension.Values {
|
||||
refs = append(refs, autoMatchRef{dimension: dimension.Name, valueName: value.Name})
|
||||
}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func (service *Service) autoMatchResponse(ctx context.Context, id uint64, items []AutoMatchItem) (AutoMatchResponse, error) {
|
||||
detail, err := service.Detail(ctx, id)
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, err
|
||||
}
|
||||
response := AutoMatchResponse{Product: detail.Product, Items: items}
|
||||
for _, item := range items {
|
||||
switch item.Status {
|
||||
case "confirmed":
|
||||
response.ConfirmedCount++
|
||||
case "preserved":
|
||||
response.PreservedCount++
|
||||
default:
|
||||
response.UnmatchedCount++
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (service *Service) autoMatchReplay(ctx context.Context, id uint64) (AutoMatchResponse, error) {
|
||||
detail, err := service.Detail(ctx, id)
|
||||
if err != nil {
|
||||
return AutoMatchResponse{}, err
|
||||
}
|
||||
response := AutoMatchResponse{Product: detail.Product, Items: []AutoMatchItem{}, Replayed: true}
|
||||
for _, dimension := range detail.Product.Specs {
|
||||
if dimension.Role != RoleColor && dimension.Role != RoleSize {
|
||||
continue
|
||||
}
|
||||
for _, value := range dimension.Values {
|
||||
item := AutoMatchItem{Dimension: dimension.Name, Role: dimension.Role, ValueName: value.Name, Status: "unmatched"}
|
||||
if value.Mapping != nil && value.Mapping.Status == MappingStatusConfirmed {
|
||||
item.PDDValue, item.Status, item.Source = value.Mapping.PDDValue, "preserved", value.Mapping.Source
|
||||
item.Confidence, item.Reason = value.Mapping.Confidence, value.Mapping.Reason
|
||||
response.PreservedCount++
|
||||
} else {
|
||||
response.UnmatchedCount++
|
||||
}
|
||||
response.Items = append(response.Items, item)
|
||||
}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
SpecAutoMatchInvokeTarget = "GoAutoShopeeSpecAutoMatch"
|
||||
defaultAutoMatchBatchLimit = 20
|
||||
autoMatchLeaseDuration = 30 * time.Minute
|
||||
autoMatchRetryDelay = time.Hour
|
||||
maxAutoMatchAttempts = 3
|
||||
)
|
||||
|
||||
type AutoMatchRunView struct {
|
||||
models.ShopeeSpecAutoMatchRun
|
||||
AlreadyRunning bool `json:"alreadyRunning,omitempty"`
|
||||
Replayed bool `json:"replayed,omitempty"`
|
||||
}
|
||||
|
||||
// StartAutoMatchRun acquires the single database-backed activity slot. A
|
||||
// repeated requestId is idempotent; a concurrent trigger receives the current
|
||||
// run instead of starting a second batch.
|
||||
func (service *Service) StartAutoMatchRun(ctx context.Context, trigger, requestID string, requestedBy *uint64, batchLimit int) (AutoMatchRunView, bool, error) {
|
||||
if _, err := uuid.Parse(strings.TrimSpace(requestID)); err != nil {
|
||||
return AutoMatchRunView{}, false, invalidRequest("requestId 必须是 UUID")
|
||||
}
|
||||
if trigger != "manual" && trigger != "scheduled" {
|
||||
return AutoMatchRunView{}, false, invalidRequest("trigger 无效")
|
||||
}
|
||||
if batchLimit <= 0 {
|
||||
batchLimit = defaultAutoMatchBatchLimit
|
||||
}
|
||||
if batchLimit > 100 {
|
||||
return AutoMatchRunView{}, false, invalidRequest("batchLimit 不能超过 100")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
lease := now.Add(autoMatchLeaseDuration)
|
||||
owner := uuid.NewString()
|
||||
one := uint8(1)
|
||||
var result models.ShopeeSpecAutoMatchRun
|
||||
created := false
|
||||
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&models.ShopeeSpecAutoMatchRun{}).
|
||||
Where("status = ? AND active_slot = ? AND lease_expires_at < ?", "running", 1, now).
|
||||
Updates(map[string]any{"status": "failed", "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "error_summary": "上次运行租约过期,已安全释放", "finished_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("request_id = ?", requestID).First(&result).Error; err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("status = ? AND active_slot = ?", "running", 1).First(&result).Error; err == nil {
|
||||
result.ActiveSlot = &one
|
||||
return nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
result = models.ShopeeSpecAutoMatchRun{RequestID: requestID, Trigger: trigger, Status: "running", ActiveSlot: &one, LeaseOwner: owner, LeaseExpiresAt: &lease, RequestedBy: requestedBy, BatchLimit: batchLimit, StartedAt: now}
|
||||
if err := tx.Create(&result).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
created = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// A unique-slot race means another instance won after our read. Return
|
||||
// its run as the stable, non-error result.
|
||||
if findErr := service.DB.WithContext(ctx).Where("status = ? AND active_slot = ?", "running", 1).First(&result).Error; findErr == nil {
|
||||
return AutoMatchRunView{ShopeeSpecAutoMatchRun: result, AlreadyRunning: true}, false, nil
|
||||
}
|
||||
return AutoMatchRunView{}, false, internalError(err)
|
||||
}
|
||||
view := AutoMatchRunView{ShopeeSpecAutoMatchRun: result}
|
||||
if !created {
|
||||
view.AlreadyRunning = result.RequestID != requestID
|
||||
view.Replayed = result.RequestID == requestID
|
||||
}
|
||||
return view, created, nil
|
||||
}
|
||||
|
||||
func (service *Service) LatestAutoMatchRun(ctx context.Context) (*AutoMatchRunView, error) {
|
||||
var run models.ShopeeSpecAutoMatchRun
|
||||
err := service.DB.WithContext(ctx).Order("id DESC").First(&run).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, internalError(err)
|
||||
}
|
||||
return &AutoMatchRunView{ShopeeSpecAutoMatchRun: run}, nil
|
||||
}
|
||||
|
||||
// ProcessAutoMatchRun performs a bounded batch. It is safe to call from an
|
||||
// HTTP-launched goroutine or the scheduler because only the run owning the
|
||||
// active slot may update and finish itself.
|
||||
func (service *Service) ProcessAutoMatchRun(ctx context.Context, runID uint64) error {
|
||||
var run models.ShopeeSpecAutoMatchRun
|
||||
if err := service.DB.WithContext(ctx).First(&run, runID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if run.Status != "running" || run.ActiveSlot == nil || *run.ActiveSlot != 1 {
|
||||
return nil
|
||||
}
|
||||
limit := run.BatchLimit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = defaultAutoMatchBatchLimit
|
||||
}
|
||||
var candidates []models.ShopeeProduct
|
||||
queryLimit := limit * 25
|
||||
if queryLimit < 100 {
|
||||
queryLimit = 100
|
||||
}
|
||||
if queryLimit > 1000 {
|
||||
queryLimit = 1000
|
||||
}
|
||||
if err := service.DB.WithContext(ctx).
|
||||
Joins("JOIN pdd_product ON pdd_product.id = shopee_product.pdd_product_id AND pdd_product.status = ?", "active").
|
||||
Where("shopee_product.pdd_product_id IS NOT NULL").
|
||||
Order("shopee_product.updated_at ASC, shopee_product.id ASC").Limit(queryLimit).Find(&candidates).Error; err != nil {
|
||||
service.finishAutoMatchRun(run, "failed", 0, 0, 0, 0, 0, 1, "扫描符合条件的商品失败")
|
||||
return err
|
||||
}
|
||||
|
||||
eligible, processed, confirmed, unmatched, failed := 0, 0, 0, 0, 0
|
||||
firstError := ""
|
||||
for _, product := range candidates {
|
||||
if processed >= limit {
|
||||
break
|
||||
}
|
||||
fingerprint, ok, err := service.autoMatchEligibility(ctx, product)
|
||||
if err != nil {
|
||||
failed++
|
||||
if firstError == "" {
|
||||
firstError = safeBatchError(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
eligible++
|
||||
work, claimed, err := service.claimAutoMatchWork(ctx, run, product.ID, fingerprint)
|
||||
if err != nil {
|
||||
failed++
|
||||
if firstError == "" {
|
||||
firstError = safeBatchError(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !claimed {
|
||||
continue
|
||||
}
|
||||
processed++
|
||||
service.renewAutoMatchRun(run)
|
||||
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 != "" {
|
||||
postFingerprint = next
|
||||
}
|
||||
if matchErr != nil {
|
||||
failed++
|
||||
if firstError == "" {
|
||||
firstError = safeBatchError(matchErr)
|
||||
}
|
||||
service.completeAutoMatchWork(work, postFingerprint, 0, 0, matchErr)
|
||||
continue
|
||||
}
|
||||
confirmed += response.ConfirmedCount
|
||||
unmatched += response.UnmatchedCount
|
||||
service.completeAutoMatchWork(work, postFingerprint, response.ConfirmedCount, response.UnmatchedCount, nil)
|
||||
}
|
||||
status := "completed"
|
||||
if failed > 0 {
|
||||
status = "completed_partial"
|
||||
}
|
||||
return service.finishAutoMatchRun(run, status, len(candidates), eligible, processed, confirmed, unmatched, failed, firstError)
|
||||
}
|
||||
|
||||
func (service *Service) autoMatchEligibility(ctx context.Context, product models.ShopeeProduct) (string, bool, error) {
|
||||
if product.PDDProductID == nil {
|
||||
return "", false, nil
|
||||
}
|
||||
var pdd models.PDDProduct
|
||||
if err := service.DB.WithContext(ctx).First(&pdd, *product.PDDProductID).Error; err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if pdd.Status != "active" {
|
||||
return "", false, nil
|
||||
}
|
||||
shopeeSpecs, err := Unmarshal(product.SpecsJSON)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
shared, needsMatch := false, false
|
||||
for _, role := range []string{RoleColor, RoleSize} {
|
||||
pddValues, err := selectablePDDValues(pdd.SpecsJSON, role)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if len(pddValues) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, dimension := range shopeeSpecs {
|
||||
if dimension.Role != role || len(dimension.Values) == 0 {
|
||||
continue
|
||||
}
|
||||
shared = true
|
||||
for _, value := range dimension.Values {
|
||||
if value.Mapping == nil || value.Mapping.Status != MappingStatusConfirmed || !pddValues[value.Mapping.PDDValue] {
|
||||
needsMatch = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !shared || !needsMatch {
|
||||
return "", false, nil
|
||||
}
|
||||
contextVersion := computeSpecContextVersion(product.PDDProductID, product.SpecsJSON, pdd.SpecsJSON)
|
||||
var setting struct{ UpdatedAt time.Time }
|
||||
_ = service.DB.WithContext(ctx).Table((models.AIMatchingSetting{}).TableName()).Select("updated_at").Where("id = ?", 1).Scan(&setting).Error
|
||||
h := sha256.Sum256([]byte(contextVersion + "\x00" + setting.UpdatedAt.UTC().Format(time.RFC3339Nano)))
|
||||
// Keeping the context version as a prefix lets ProcessAutoMatchRun pass the
|
||||
// exact version to #194 without re-reading a potentially drifting input.
|
||||
return contextVersion + hex.EncodeToString(h[:]), true, nil
|
||||
}
|
||||
|
||||
func (service *Service) claimAutoMatchWork(ctx context.Context, run models.ShopeeSpecAutoMatchRun, productID uint64, fingerprint string) (models.ShopeeSpecAutoMatchWorkItem, bool, error) {
|
||||
now := time.Now().UTC()
|
||||
lease := now.Add(autoMatchLeaseDuration)
|
||||
var work models.ShopeeSpecAutoMatchWorkItem
|
||||
err := service.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
err := tx.Where("shopee_product_id = ?", productID).First(&work).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
work = models.ShopeeSpecAutoMatchWorkItem{ShopeeProductID: productID, RunID: &run.ID, InputFingerprint: fingerprint, Status: "running", AttemptCount: 1, LeaseOwner: run.LeaseOwner, LeaseExpiresAt: &lease}
|
||||
return tx.Create(&work).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if work.InputFingerprint == fingerprint {
|
||||
if work.Status == "completed" || work.Status == "unmatched" || work.AttemptCount >= maxAutoMatchAttempts || (work.NextAttemptAt != nil && work.NextAttemptAt.After(now)) || (work.Status == "running" && work.LeaseExpiresAt != nil && work.LeaseExpiresAt.After(now)) {
|
||||
return errWorkNotClaimed
|
||||
}
|
||||
} else {
|
||||
work.AttemptCount = 0
|
||||
}
|
||||
updates := map[string]any{"run_id": run.ID, "input_fingerprint": fingerprint, "status": "running", "attempt_count": work.AttemptCount + 1, "next_attempt_at": nil, "lease_owner": run.LeaseOwner, "lease_expires_at": lease, "last_error_code": "", "last_error": ""}
|
||||
if err := tx.Model(&models.ShopeeSpecAutoMatchWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.First(&work, work.ID).Error
|
||||
})
|
||||
if errors.Is(err, errWorkNotClaimed) {
|
||||
return work, false, nil
|
||||
}
|
||||
return work, err == nil, err
|
||||
}
|
||||
|
||||
var errWorkNotClaimed = errors.New("auto match work not claimed")
|
||||
|
||||
func (service *Service) completeAutoMatchWork(work models.ShopeeSpecAutoMatchWorkItem, fingerprint string, confirmed, unmatched int, matchErr error) {
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{"input_fingerprint": fingerprint, "lease_owner": "", "lease_expires_at": nil, "confirmed_count": confirmed, "unmatched_count": unmatched}
|
||||
if matchErr == nil {
|
||||
if unmatched > 0 {
|
||||
updates["status"] = "unmatched"
|
||||
} else {
|
||||
updates["status"] = "completed"
|
||||
}
|
||||
updates["next_attempt_at"], updates["last_error_code"], updates["last_error"] = nil, "", ""
|
||||
} else {
|
||||
code := batchErrorCode(matchErr)
|
||||
updates["status"], updates["last_error_code"], updates["last_error"] = "failed", code, safeBatchError(matchErr)
|
||||
if code == CodeAIUnavailable && work.AttemptCount < maxAutoMatchAttempts {
|
||||
next := now.Add(autoMatchRetryDelay)
|
||||
updates["next_attempt_at"] = next
|
||||
} else {
|
||||
updates["next_attempt_at"] = nil
|
||||
}
|
||||
}
|
||||
_ = service.DB.Model(&models.ShopeeSpecAutoMatchWorkItem{}).Where("id = ?", work.ID).Updates(updates).Error
|
||||
}
|
||||
|
||||
func (service *Service) renewAutoMatchRun(run models.ShopeeSpecAutoMatchRun) {
|
||||
lease := time.Now().UTC().Add(autoMatchLeaseDuration)
|
||||
_ = service.DB.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Update("lease_expires_at", lease).Error
|
||||
}
|
||||
|
||||
func (service *Service) finishAutoMatchRun(run models.ShopeeSpecAutoMatchRun, status string, scanned, eligible, processed, confirmed, unmatched, failed int, summary string) error {
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{"status": status, "active_slot": nil, "lease_owner": "", "lease_expires_at": nil, "scanned_count": scanned, "eligible_count": eligible, "processed_count": processed, "confirmed_count": confirmed, "unmatched_count": unmatched, "failed_count": failed, "error_summary": truncateBatchText(summary), "finished_at": now}
|
||||
return service.DB.Model(&models.ShopeeSpecAutoMatchRun{}).Where("id = ? AND status = ? AND lease_owner = ?", run.ID, "running", run.LeaseOwner).Updates(updates).Error
|
||||
}
|
||||
|
||||
func batchErrorCode(err error) string {
|
||||
var serviceErr *ServiceError
|
||||
if errors.As(err, &serviceErr) {
|
||||
return serviceErr.Code
|
||||
}
|
||||
return CodeInternal
|
||||
}
|
||||
|
||||
func safeBatchError(err error) string {
|
||||
var serviceErr *ServiceError
|
||||
if errors.As(err, &serviceErr) {
|
||||
return truncateBatchText(serviceErr.Message)
|
||||
}
|
||||
return "服务端处理失败"
|
||||
}
|
||||
|
||||
func truncateBatchText(value string) string {
|
||||
runes := []rune(strings.TrimSpace(value))
|
||||
if len(runes) > 500 {
|
||||
runes = runes[:500]
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
type scheduledAutoMatchArgs struct {
|
||||
BatchLimit int `json:"batchLimit"`
|
||||
}
|
||||
|
||||
type ScheduledAutoMatchJob struct{}
|
||||
|
||||
func (ScheduledAutoMatchJob) Exec(_ interface{}) error {
|
||||
return errors.New("规格自动匹配定时任务缺少数据库连接")
|
||||
}
|
||||
|
||||
func (ScheduledAutoMatchJob) ExecWithDB(db *gorm.DB, arg interface{}) error {
|
||||
args := scheduledAutoMatchArgs{BatchLimit: defaultAutoMatchBatchLimit}
|
||||
if raw, ok := arg.(string); ok && strings.TrimSpace(raw) != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &args); err != nil {
|
||||
return fmt.Errorf("规格自动匹配参数不是合法 JSON: %w", err)
|
||||
}
|
||||
}
|
||||
if args.BatchLimit < 1 || args.BatchLimit > 100 {
|
||||
return errors.New("规格自动匹配 batchLimit 必须在 1 到 100 之间")
|
||||
}
|
||||
service := NewService(db)
|
||||
run, created, err := service.StartAutoMatchRun(context.Background(), "scheduled", uuid.NewString(), nil, args.BatchLimit)
|
||||
if err != nil || !created {
|
||||
return err
|
||||
}
|
||||
return service.ProcessAutoMatchRun(context.Background(), run.ID)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestAutoMatchRunIsIdempotentAndGloballySerialized(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
service := NewService(db)
|
||||
requestID := uuid.NewString()
|
||||
first, created, err := service.StartAutoMatchRun(context.Background(), "manual", requestID, nil, 20)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("first=%+v created=%v err=%v", first, created, err)
|
||||
}
|
||||
replay, created, err := service.StartAutoMatchRun(context.Background(), "manual", requestID, nil, 20)
|
||||
if err != nil || created || !replay.Replayed || replay.ID != first.ID {
|
||||
t.Fatalf("replay=%+v created=%v err=%v", replay, created, err)
|
||||
}
|
||||
concurrent, created, err := service.StartAutoMatchRun(context.Background(), "scheduled", uuid.NewString(), nil, 20)
|
||||
if err != nil || created || !concurrent.AlreadyRunning || concurrent.ID != first.ID {
|
||||
t.Fatalf("concurrent=%+v created=%v err=%v", concurrent, created, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessAutoMatchRunConfirmsExactSizeAndFinishes(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
service := NewService(db)
|
||||
createdProduct, err := service.Create(context.Background(), CreateRequest{
|
||||
RequestID: uuid.NewString(), ShopeeItemID: "SP-BATCH-EXACT", PDDProductID: &pdd.ID,
|
||||
Specs: []SpecDimension{{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: " xl ", Source: ValueSourceImport}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run, started, err := service.StartAutoMatchRun(context.Background(), "manual", uuid.NewString(), nil, 20)
|
||||
if err != nil || !started {
|
||||
t.Fatalf("run=%+v started=%v err=%v", run, started, err)
|
||||
}
|
||||
if err := service.ProcessAutoMatchRun(context.Background(), run.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
latest, err := service.LatestAutoMatchRun(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if latest == nil || latest.Status != "completed" || latest.ProcessedCount != 1 || latest.ConfirmedCount != 1 || latest.ActiveSlot != nil {
|
||||
t.Fatalf("latest=%+v", latest)
|
||||
}
|
||||
detail, err := service.Detail(context.Background(), createdProduct.Product.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mapping := detail.Product.Specs[0].Values[0].Mapping
|
||||
if mapping == nil || mapping.Status != MappingStatusConfirmed || mapping.PDDValue != "XL" {
|
||||
t.Fatalf("mapping=%+v", mapping)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnchangedUnmatchedWorkIsNotClaimedAgain(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
service := NewService(db)
|
||||
one := uint8(1)
|
||||
run := models.ShopeeSpecAutoMatchRun{RequestID: uuid.NewString(), Trigger: "manual", Status: "running", ActiveSlot: &one, LeaseOwner: uuid.NewString(), BatchLimit: 20}
|
||||
if err := db.Create(&run).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
work, claimed, err := service.claimAutoMatchWork(context.Background(), run, 99, "fingerprint")
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("work=%+v claimed=%v err=%v", work, claimed, err)
|
||||
}
|
||||
service.completeAutoMatchWork(work, "fingerprint", 0, 1, nil)
|
||||
_, claimed, err = service.claimAutoMatchWork(context.Background(), run, 99, "fingerprint")
|
||||
if err != nil || claimed {
|
||||
t.Fatalf("unchanged unmatched claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
_, claimed, err = service.claimAutoMatchWork(context.Background(), run, 99, "changed")
|
||||
if err != nil || !claimed {
|
||||
t.Fatalf("changed input claimed=%v err=%v", claimed, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestAutoMatchMappingsConfirmsColorAndSizeInOneRequest(t *testing.T) {
|
||||
provider := httptest.NewServer(chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.96,"reason":"颜色含义唯一一致"}]}`))
|
||||
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)
|
||||
}
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), CreateRequest{
|
||||
RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-BOTH", PDDProductID: &pdd.ID,
|
||||
Specs: []SpecDimension{
|
||||
{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "深黑", Source: ValueSourceImport}}},
|
||||
{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: " xl ", Source: ValueSourceImport}}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requestID := uuid.NewString()
|
||||
response, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: requestID, SpecContextVersion: created.Product.SpecContextVersion})
|
||||
if err != nil {
|
||||
t.Fatalf("auto match: %v", err)
|
||||
}
|
||||
if response.ConfirmedCount != 2 || response.PreservedCount != 0 || response.UnmatchedCount != 0 {
|
||||
t.Fatalf("unexpected counts: %+v", response)
|
||||
}
|
||||
color := response.Product.Specs[0].Values[0].Mapping
|
||||
size := response.Product.Specs[1].Values[0].Mapping
|
||||
if color == nil || color.Status != MappingStatusConfirmed || color.Source != MappingSourceAIMatch || color.PDDValue != "黑色" {
|
||||
t.Fatalf("color not auto-confirmed: %+v", color)
|
||||
}
|
||||
if size == nil || size.Status != MappingStatusConfirmed || size.Source != MappingSourceExactMatch || size.PDDValue != "XL" {
|
||||
t.Fatalf("size not auto-confirmed: %+v", size)
|
||||
}
|
||||
|
||||
replayed, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: requestID, SpecContextVersion: created.Product.SpecContextVersion})
|
||||
if err != nil || !replayed.Replayed || replayed.PreservedCount != 2 {
|
||||
t.Fatalf("idempotent replay failed: response=%+v err=%v", replayed, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchMappingsLeavesLowConfidenceUnmatched(t *testing.T) {
|
||||
provider := httptest.NewServer(chatCompletionResponder(`{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.4,"reason":"只能判断为相近颜色"}]}`))
|
||||
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)
|
||||
}
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), CreateRequest{
|
||||
RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-LOW", PDDProductID: &pdd.ID,
|
||||
Specs: []SpecDimension{
|
||||
{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "浅黑", Source: ValueSourceImport}}},
|
||||
{Name: "尺码", Role: RoleSize, Values: []SpecValue{{Name: "XL", Source: ValueSourceImport}}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.ConfirmedCount != 1 || response.UnmatchedCount != 1 || response.Product.Specs[0].Values[0].Mapping != nil {
|
||||
t.Fatalf("low confidence color was persisted: %+v", response)
|
||||
}
|
||||
if response.Product.Specs[1].Values[0].Mapping == nil || response.Product.Specs[1].Values[0].Mapping.Status != MappingStatusConfirmed {
|
||||
t.Fatalf("deterministic size was not confirmed: %+v", response.Product.Specs[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchMappingsProviderFailureDoesNotWrite(t *testing.T) {
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "unavailable", http.StatusBadGateway)
|
||||
}))
|
||||
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)
|
||||
}
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), CreateRequest{RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-FAIL", PDDProductID: &pdd.ID, Specs: []SpecDimension{{Name: "颜色", Role: RoleColor, Values: []SpecValue{{Name: "深黑", Source: ValueSourceImport}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion}); err == nil || errCode(t, err) != CodeAIUnavailable {
|
||||
t.Fatalf("expected AI unavailable, got %v", err)
|
||||
}
|
||||
detail, err := service.Detail(context.Background(), created.Product.ID)
|
||||
if err != nil || detail.Product.Specs[0].Values[0].Mapping != nil {
|
||||
t.Fatalf("provider failure changed mappings: detail=%+v err=%v", detail, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteErrorMapsAIUnavailableToStructured503(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
writeError(context, aiUnavailable("AI 匹配服务暂时不可用,请稍后重试"))
|
||||
|
||||
if recorder.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), `"code":"AI_MATCHING_UNAVAILABLE"`) {
|
||||
t.Fatalf("response is not structured: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoMatchMappingsRejectsContextDriftBeforeAtomicWrite(t *testing.T) {
|
||||
db := openTestDB(t)
|
||||
pdd := seedPDDProduct(t, db, "active")
|
||||
var calls atomic.Int32
|
||||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
call := calls.Add(1)
|
||||
if call == 2 {
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true},{"name":"灰色","selectable":true}]},{"name":"尺码","role":"size","values":[{"name":"M","selectable":true}]}]`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"choices": []map[string]any{{"message": map[string]any{"content": `{"suggestions":[{"sourceId":"s1","candidateId":"c1","confidence":0.98,"reason":"唯一可靠候选"}]}`}}}})
|
||||
}))
|
||||
defer provider.Close()
|
||||
setting := seedEnabledAISetting(t, provider.URL, 0.9)
|
||||
if err := db.Create(&setting).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db)
|
||||
created, err := service.Create(context.Background(), CreateRequest{
|
||||
RequestID: uuid.NewString(), ShopeeItemID: "SP-AUTO-STALE", 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)
|
||||
}
|
||||
if _, err := service.AutoMatchMappings(context.Background(), created.Product.ID, AutoMatchRequest{RequestID: uuid.NewString(), SpecContextVersion: created.Product.SpecContextVersion}); err == nil || errCode(t, err) != CodeSpecContextStale {
|
||||
t.Fatalf("expected stale context, got %v", err)
|
||||
}
|
||||
detail, err := service.Detail(context.Background(), created.Product.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, dimension := range detail.Product.Specs {
|
||||
for _, value := range dimension.Values {
|
||||
if value.Mapping != nil {
|
||||
t.Fatalf("context drift caused partial write: %+v", detail.Product.Specs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
@@ -278,6 +279,67 @@ func (handler Handler) SuggestSizeMappings(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
||||
}
|
||||
|
||||
func (handler Handler) AutoMatchMappings(c *gin.Context) {
|
||||
id, ok := handler.pathID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request AutoMatchRequest
|
||||
if err := decodeJSON(c, &request); err != nil {
|
||||
writeError(c, invalidRequest("请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
service, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response, err := service.AutoMatchMappings(c.Request.Context(), id, request)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": response})
|
||||
}
|
||||
|
||||
func (handler Handler) StartAutoMatchRun(c *gin.Context) {
|
||||
var request struct {
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
if err := decodeJSON(c, &request); err != nil {
|
||||
writeError(c, invalidRequest("请求 JSON 无效"))
|
||||
return
|
||||
}
|
||||
service, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
operator := currentUserID(c)
|
||||
run, created, err := service.StartAutoMatchRun(c.Request.Context(), "manual", request.RequestID, &operator, defaultAutoMatchBatchLimit)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
if created {
|
||||
go func(runID uint64, db *gorm.DB) {
|
||||
_ = NewService(db).ProcessAutoMatchRun(context.Background(), runID)
|
||||
}(run.ID, service.DB)
|
||||
}
|
||||
c.JSON(http.StatusAccepted, gin.H{"code": 200, "data": gin.H{"run": run}})
|
||||
}
|
||||
|
||||
func (handler Handler) LatestAutoMatchRun(c *gin.Context) {
|
||||
service, ok := handler.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
run, err := service.LatestAutoMatchRun(c.Request.Context())
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"run": run}})
|
||||
}
|
||||
|
||||
func (handler Handler) BatchDelete(c *gin.Context) {
|
||||
var request BatchDeleteRequest
|
||||
if err := decodeJSON(c, &request); err != nil {
|
||||
|
||||
@@ -10,6 +10,9 @@ import (
|
||||
func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
handler := Handler{}
|
||||
admin := engine.Group("/api/admin/v1/shopee-products").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
adminOnlyRuns := engine.Group("/api/admin/v1/shopee-spec-auto-match/runs").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(middleware.RequireRoleKey("admin"))
|
||||
adminOnlyRuns.POST("", handler.StartAutoMatchRun)
|
||||
adminOnlyRuns.GET("/latest", handler.LatestAutoMatchRun)
|
||||
admin.GET("", handler.List)
|
||||
admin.POST("", handler.Create)
|
||||
admin.POST("/batch-delete", handler.BatchDelete)
|
||||
@@ -26,4 +29,5 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) {
|
||||
admin.POST("/:productId/specs/mapping/preview-auto-size", handler.PreviewAutoSizeMatches)
|
||||
admin.POST("/:productId/specs/mapping/suggest-colors", handler.SuggestColorMappings)
|
||||
admin.POST("/:productId/specs/mapping/suggest-sizes", handler.SuggestSizeMappings)
|
||||
admin.POST("/:productId/specs/mapping/auto-match", handler.AutoMatchMappings)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
"go-admin/app/goauto/sybimport"
|
||||
)
|
||||
|
||||
@@ -12,8 +13,9 @@ import (
|
||||
// 字典 key 可以配置到 自动任务 调用目标 中;
|
||||
func InitJob() {
|
||||
jobList = map[string]JobExec{
|
||||
"ExamplesOne": ExamplesOne{},
|
||||
sybimport.HourlySyncInvokeTarget: sybimport.HourlySyncJob{},
|
||||
"ExamplesOne": ExamplesOne{},
|
||||
sybimport.HourlySyncInvokeTarget: sybimport.HourlySyncJob{},
|
||||
shopeeproduct.SpecAutoMatchInvokeTarget: shopeeproduct.ScheduledAutoMatchJob{},
|
||||
// ...
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,12 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
// The synchronous Admin AI endpoints allow a provider timeout of up to 600
|
||||
// seconds and the browser waits 610 seconds. Keep the HTTP server alive a
|
||||
// little longer so it can return the domain response instead of truncating
|
||||
// the connection and surfacing a proxy-level 502.
|
||||
const minimumAPIWriteTimeout = 620 * time.Second
|
||||
|
||||
var AppRouters = make([]func(), 0)
|
||||
|
||||
func init() {
|
||||
@@ -125,11 +131,15 @@ func run() error {
|
||||
)
|
||||
}
|
||||
|
||||
writeTimeout, err := validatedAPIWriteTimeout(config.ApplicationConfig.WriterTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", config.ApplicationConfig.Host, config.ApplicationConfig.Port),
|
||||
Handler: sdk.Runtime.GetEngine(),
|
||||
ReadTimeout: time.Duration(config.ApplicationConfig.ReadTimeout) * time.Second,
|
||||
WriteTimeout: time.Duration(config.ApplicationConfig.WriterTimeout) * time.Second,
|
||||
WriteTimeout: writeTimeout,
|
||||
}
|
||||
|
||||
go func() {
|
||||
@@ -193,6 +203,14 @@ func run() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatedAPIWriteTimeout(seconds int) (time.Duration, error) {
|
||||
timeout := time.Duration(seconds) * time.Second
|
||||
if timeout < minimumAPIWriteTimeout {
|
||||
return 0, fmt.Errorf("application writetimeout must be at least %s for synchronous AI requests", minimumAPIWriteTimeout)
|
||||
}
|
||||
return timeout, nil
|
||||
}
|
||||
|
||||
type policyLoader interface {
|
||||
LoadPolicy() error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidatedAPIWriteTimeoutProtectsSynchronousAIRequests(t *testing.T) {
|
||||
if _, err := validatedAPIWriteTimeout(2); err == nil {
|
||||
t.Fatal("two-second write timeout must be rejected")
|
||||
}
|
||||
got, err := validatedAPIWriteTimeout(620)
|
||||
if err != nil {
|
||||
t.Fatalf("620-second write timeout should be accepted: %v", err)
|
||||
}
|
||||
if got != 620*time.Second {
|
||||
t.Fatalf("write timeout = %s, want 620s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"runtime"
|
||||
|
||||
goautomigrations "go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
jobsmodels "go-admin/app/jobs/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateShopeeSpecAutoMatch)
|
||||
}
|
||||
|
||||
func migrateShopeeSpecAutoMatch(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := goautomigrations.Migrate(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureShopeeSpecAutoMatchJob(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func ensureShopeeSpecAutoMatchJob(db *gorm.DB) error {
|
||||
var existing jobsmodels.SysJob
|
||||
err := db.Where("invoke_target = ?", shopeeproduct.SpecAutoMatchInvokeTarget).First(&existing).Error
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
return db.Create(&jobsmodels.SysJob{
|
||||
JobName: "蝦皮规格自动匹配", JobGroup: "GoAuto", JobType: 2,
|
||||
CronExpression: "0 15 * * * *", InvokeTarget: shopeeproduct.SpecAutoMatchInvokeTarget,
|
||||
Args: `{"batchLimit":20}`, MisfirePolicy: 1, Concurrent: 1, Status: 1,
|
||||
}).Error
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/shopeeproduct"
|
||||
jobsmodels "go-admin/app/jobs/models"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestEnsureShopeeSpecAutoMatchJobIsDisabledIdempotentAndPreservesChanges(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:shopee-spec-auto-match-job?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&jobsmodels.SysJob{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensureShopeeSpecAutoMatchJob(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var job jobsmodels.SysJob
|
||||
if err := db.Where("invoke_target = ?", shopeeproduct.SpecAutoMatchInvokeTarget).First(&job).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status != 1 || job.CronExpression != "0 15 * * * *" || job.Args != `{"batchLimit":20}` {
|
||||
t.Fatalf("unexpected seed: %+v", job)
|
||||
}
|
||||
if err := db.Model(&job).Updates(map[string]any{"status": 2, "cron_expression": "0 30 * * * *", "args": `{"batchLimit":5}`}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensureShopeeSpecAutoMatchJob(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&jobsmodels.SysJob{}).Where("invoke_target = ?", shopeeproduct.SpecAutoMatchInvokeTarget).Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("count=%d", count)
|
||||
}
|
||||
if err := db.Where("invoke_target = ?", shopeeproduct.SpecAutoMatchInvokeTarget).First(&job).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status != 2 || job.CronExpression != "0 30 * * * *" || job.Args != `{"batchLimit":5}` {
|
||||
t.Fatalf("admin changes overwritten: %+v", job)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ settings:
|
||||
# 端口号
|
||||
port: 8000 # 服务端口号
|
||||
readtimeout: 1
|
||||
writertimeout: 2
|
||||
writertimeout: 620
|
||||
# 数据权限功能开关
|
||||
enabledp: false
|
||||
ssl:
|
||||
|
||||
@@ -9,7 +9,7 @@ settings:
|
||||
# 端口号
|
||||
port: 8000 # 服务端口号
|
||||
readtimeout: 1
|
||||
writertimeout: 2
|
||||
writertimeout: 620
|
||||
# 数据权限功能开关
|
||||
enabledp: false
|
||||
logger:
|
||||
@@ -84,4 +84,4 @@ settings:
|
||||
# blockingTimeout: 5
|
||||
# reclaimInterval: 1
|
||||
locker:
|
||||
redis:
|
||||
redis:
|
||||
|
||||
@@ -66,6 +66,18 @@ 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 startShopeeSpecAutoMatchRun(data) {
|
||||
return request({ url: '/api/admin/v1/shopee-spec-auto-match/runs', method: 'post', data })
|
||||
}
|
||||
|
||||
export function getLatestShopeeSpecAutoMatchRun() {
|
||||
return request({ url: '/api/admin/v1/shopee-spec-auto-match/runs/latest', method: 'get' })
|
||||
}
|
||||
|
||||
export function batchDeleteShopeeProducts(data) {
|
||||
return request({ url: '/api/admin/v1/shopee-products/batch-delete', method: 'post', data })
|
||||
}
|
||||
|
||||
@@ -23,13 +23,12 @@
|
||||
</el-descriptions>
|
||||
<p v-if="detail.product.pddProductId && detail.product.sharedByPddCount > 1" class="muted shared-note">该 PDD 商品当前被 {{ detail.product.sharedByPddCount }} 个虾皮商品共用,允许共用。</p>
|
||||
|
||||
<div class="section-heading"><h3>规格匹配</h3><span class="mapping-progress">{{ mappingProgressText }}</span></div>
|
||||
<el-alert title="只保存虾皮规格与 PDD 规格的对应关系,不会修改两端的原始颜色和尺码。" type="info" :closable="false" show-icon class="notice" />
|
||||
<div class="section-heading"><h3>规格匹配</h3><div class="mapping-heading-actions"><span class="mapping-progress">{{ mappingProgressText }}</span><el-button type="primary" :loading="detail.aiMatching" :disabled="!canAutoMatch" :title="hasMappingChanges ? '请先保存或撤销当前人工修改' : ''" @click="autoMatchMappings">一键匹配颜色和尺码</el-button></div></div>
|
||||
<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-tab-pane label="颜色匹配" name="color">
|
||||
<div class="tab-actions"><span class="muted">AI 只生成未保存的建议,置信度不足不会预填,均需人工确认后保存。</span><el-button type="primary" plain :loading="detail.aiSuggesting.color" :disabled="!canAIMatchColor" @click="previewAISuggestions('color')">AI 匹配</el-button></div>
|
||||
<el-empty v-if="!specRows('color').length" description="虾皮商品暂无颜色规格" />
|
||||
<el-table v-else :data="specRows('color')" :row-class-name="mappingRowClass" border size="small">
|
||||
<el-table-column label="虾皮颜色" min-width="180"><template #default="{ row }"><div class="primary">{{ row.name }}</div><div class="muted">{{ row.source === 'manual' ? '人工添加' : 'SYB 导入' }}</div></template></el-table-column>
|
||||
@@ -39,7 +38,6 @@
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="尺码匹配" name="size">
|
||||
<div class="tab-actions"><span class="muted">先按格式统一自动匹配;未匹配的项再用 AI 生成建议,置信度不足不会预填。</span><el-button type="primary" plain :loading="detail.aiSuggesting.size" :disabled="!canAIMatchSize" @click="previewAISuggestions('size')">AI 匹配</el-button></div>
|
||||
<el-empty v-if="!specRows('size').length" description="虾皮商品暂无尺码规格" />
|
||||
<el-table v-else :data="specRows('size')" border size="small">
|
||||
<el-table-column label="虾皮尺码" min-width="180"><template #default="{ row }"><div class="primary">{{ row.name }}</div><div class="muted">{{ row.source === 'manual' ? '人工添加' : 'SYB 导入' }}</div></template></el-table-column>
|
||||
@@ -79,7 +77,7 @@ import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
getShopeeProduct, updateShopeeProduct, linkShopeeProductPdd, addShopeeSpecValue,
|
||||
setShopeeSpecMapping, clearShopeeSpecMapping, confirmShopeeSpecMapping,
|
||||
suggestShopeeColorMappings, suggestShopeeSizeMappings
|
||||
autoMatchShopeeSpecMappings
|
||||
} from '@/api/goauto/shopee-products'
|
||||
import { getPddProduct, listPddProducts } from '@/api/goauto/pdd-products'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
@@ -107,8 +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} 已匹配` : '暂无可匹配规格' },
|
||||
canAIMatchColor() { return this.detail.pddLoaded && this.specRows('color').length > 0 && this.pddSpecValues('color').length > 0 },
|
||||
canAIMatchSize() { return this.detail.pddLoaded && this.specRows('size').length > 0 && this.pddSpecValues('size').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,7 +114,7 @@ export default {
|
||||
action(value) { if (value === 'link_pdd' && this.detail.product && !this.pddPicker.open) this.openPddPicker() }
|
||||
},
|
||||
methods: {
|
||||
emptyDetail() { return { loading: false, saving: false, aiSuggesting: { color: false, size: false }, 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 && 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) },
|
||||
@@ -164,7 +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 previewAISuggestions(role) { const id = this.loadedProductId; const generation = this.requestGeneration; this.detail.aiSuggesting[role] = true; try { const suggest = role === 'color' ? suggestShopeeColorMappings : suggestShopeeSizeMappings; const r = await suggest(id, { specContextVersion: this.detail.product.specContextVersion }); if (!this.isCurrent(generation, id)) return; const byName = new Map(r.data.items.map(item => [item.valueName, item])); this.specRows(role).forEach(row => { const item = byName.get(row.name); if (!item) return; row.previewReason = item.reason || ''; row.draftConfidence = item.confidence ?? null; if (item.apply && item.status !== 'preserved') { row.pddValueDraft = item.pddValue || ''; row.draftSource = item.status === 'matched' ? 'exact_match' : 'ai_match' } }); const suggestedCount = r.data.items.filter(item => item.status === 'ai_suggested').length; ElMessage.success(`匹配 ${r.data.matchedCount} 项,AI 建议但置信度不足 ${suggestedCount} 项,待人工选择 ${r.data.pendingCount} 项`) } finally { if (this.isCurrent(generation, id)) this.detail.aiSuggesting[role] = 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() },
|
||||
@@ -175,5 +172,5 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drawer-body{padding:0 4px 24px}.drawer-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.link{color:#1677ff;cursor:pointer}.notice{margin-bottom:16px}.shared-note{margin:8px 0 0}.section-heading{display:flex;align-items:center;justify-content:space-between;margin:24px 0 12px}.section-heading h3{margin:0}.mapping-progress{font-size:13px;color:#606266}.mapping-tabs{margin-top:4px}.mapping-select{width:100%}.tab-actions{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:4px 0 12px}.preview-reason{margin-top:4px}:deep(.mapping-row-highlight td){background:#fff7d6!important}.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}.quick-label{font-size:12px;color:#606266;width:32px}.picker-help{margin:8px 0 0}.picker-thumb,.picker-image-placeholder{display:flex;width:56px;height:56px;margin:auto;border-radius:4px}.picker-image-placeholder{align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.edit-help{margin-left:8px}@media(max-width:768px){.tab-actions{align-items:flex-start;flex-direction:column}}
|
||||
.drawer-body{padding:0 4px 24px}.drawer-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:16px}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.link{color:#1677ff;cursor:pointer}.notice{margin-bottom:16px}.shared-note{margin:8px 0 0}.section-heading{display:flex;align-items:center;justify-content:space-between;margin:24px 0 12px}.section-heading h3{margin:0}.mapping-heading-actions{display:flex;align-items:center;gap:12px}.mapping-progress{font-size:13px;color:#606266}.mapping-tabs{margin-top:4px}.mapping-select{width:100%}.preview-reason{margin-top:4px}:deep(.mapping-row-highlight td){background:#fff7d6!important}.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}.quick-label{font-size:12px;color:#606266;width:32px}.picker-help{margin:8px 0 0}.picker-thumb,.picker-image-placeholder{display:flex;width:56px;height:56px;margin:auto;border-radius:4px}.picker-image-placeholder{align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.edit-help{margin-left:8px}@media(max-width:768px){.section-heading,.mapping-heading-actions{align-items:flex-start;flex-direction:column}}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<BasicLayout><template #wrapper>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search"><el-form-item><el-button type="primary" :icon="Plus" @click="openCreate">添加</el-button></el-form-item><el-form-item label="搜索"><el-input v-model="query.keyword" placeholder="虾皮商品ID、标题或店铺" clearable @keyup.enter="search" /></el-form-item><el-form-item label="范围"><el-select v-model="query.status" style="width:150px"><el-option label="全部(不含已删除)" value="" /><el-option label="已删除" value="deleted" /></el-select></el-form-item><el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item><el-form-item><el-button :icon="Delete" type="danger" plain :disabled="selectedProducts.length === 0" @click="openBatchDelete">删除{{ selectedProducts.length ? ` (${selectedProducts.length})` : '' }}</el-button></el-form-item></el-form>
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="search"><el-form-item><el-button type="primary" :icon="Plus" @click="openCreate">添加</el-button></el-form-item><el-form-item label="搜索"><el-input v-model="query.keyword" placeholder="虾皮商品ID、标题或店铺" clearable @keyup.enter="search" /></el-form-item><el-form-item label="范围"><el-select v-model="query.status" style="width:150px"><el-option label="全部(不含已删除)" value="" /><el-option label="已删除" value="deleted" /></el-select></el-form-item><el-form-item><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button></el-form-item><el-form-item><el-button :icon="Delete" type="danger" plain :disabled="selectedProducts.length === 0" @click="openBatchDelete">删除{{ selectedProducts.length ? ` (${selectedProducts.length})` : '' }}</el-button></el-form-item><el-form-item v-if="isAdmin"><el-button type="success" plain :loading="autoMatchRun.submitting" :disabled="autoMatchRun.latest?.status === 'running'" @click="startAutoMatchRun">{{ autoMatchRun.latest?.status === 'running' ? '自动匹配进行中' : '执行规格自动匹配' }}</el-button><span class="run-summary">{{ autoMatchRunSummary }}</span></el-form-item></el-form>
|
||||
<el-alert v-if="query.status === 'deleted'" title="当前显示已删除商品,可逐条恢复。恢复后原有 PDD 关联与规格映射保持不变。" type="warning" :closable="false" show-icon class="notice" />
|
||||
<el-table ref="productTable" v-loading="loading" :data="products" row-key="id" border stripe empty-text="暂无虾皮商品" @selection-change="handleSelectionChange"><el-table-column v-if="query.status !== 'deleted'" type="selection" width="48" /><el-table-column label="参考图" width="76"><template #default="{ row }"><el-image v-if="row.imageUrl" :src="row.imageUrl" fit="cover" class="thumb" :preview-src-list="[row.imageUrl]" preview-teleported /><div v-else class="thumb placeholder">无图</div></template></el-table-column><el-table-column label="虾皮商品ID" prop="shopeeItemId" min-width="140" /><el-table-column label="标题 / 店铺" min-width="220"><template #default="{ row }"><div class="primary">{{ row.title || '资料待完善' }}</div><div class="muted">{{ row.shopName || '尚未填写店铺' }}</div></template></el-table-column><el-table-column label="售价" width="120"><template #default="{ row }">{{ priceText(row) }}</template></el-table-column><el-table-column label="PDD 商品" min-width="160"><template #default="{ row }"><a v-if="row.pddProductId" class="link" href="javascript:void(0)" @click="openPddDetail(row.pddProductId)">PDD-{{ row.pddProductId }} ↗</a><span v-else class="muted">未关联</span></template></el-table-column><el-table-column label="映射状态" min-width="180"><template #default="{ row }"><el-tag :type="mappingMeta(row).type">{{ mappingMeta(row).label }}</el-tag></template></el-table-column><el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button v-if="row.deleted" type="primary" link @click="restore(row)">恢复</el-button><el-button v-else type="primary" link @click="openDetail(row.id)">详情</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" /><p class="scope-note">本页不支持按虾皮订单号搜索——商品档案不含订单数据;订单相关字段在 SYB 商品模块与采购任务模块查看。</p>
|
||||
@@ -19,9 +19,9 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, RefreshLeft, Search, Delete } from '@element-plus/icons-vue'
|
||||
import { listShopeeProducts, createShopeeProduct, restoreShopeeProduct, batchDeleteShopeeProducts } from '@/api/goauto/shopee-products'
|
||||
import { listShopeeProducts, createShopeeProduct, restoreShopeeProduct, batchDeleteShopeeProducts, startShopeeSpecAutoMatchRun, getLatestShopeeSpecAutoMatchRun } from '@/api/goauto/shopee-products'
|
||||
import { listPddProducts } from '@/api/goauto/pdd-products'
|
||||
import { createRequestId } from '@/utils/request-id'
|
||||
import ShopeeProductDetailDrawer from './ShopeeProductDetailDrawer.vue'
|
||||
@@ -29,10 +29,16 @@ import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
|
||||
|
||||
export default {
|
||||
name: 'GoAutoShopeeProducts', components: { ShopeeProductDetailDrawer, PddProductDetailDrawer }, setup() { return { Plus, RefreshLeft, Search, Delete } },
|
||||
data() { return { loading: false, products: [], selectedProducts: [], total: 0, query: { page: 1, pageSize: 20, keyword: '', status: '' }, createDialog: { open: false, saving: false }, createData: this.emptyCreate(), createRules: { shopeeItemId: [{ required: true, message: '请输入虾皮商品ID', trigger: 'blur' }, { max: 64, message: '不能超过 64 个字符', trigger: 'blur' }] }, quickColor: '', quickSize: '', colorValues: [], sizeValues: [], pddPicker: { open: false, loading: false, keyword: '', items: [] }, batchDelete: this.emptyBatchDelete(), detail: { open: false, productId: null, targetColor: '', action: '' }, pddDetail: { open: false, productId: null }} },
|
||||
watch: { '$route.query': { deep: true, handler() { this.applyRouteDetail() } }}, created() { this.load(); this.applyRouteDetail() }, activated() { this.applyRouteDetail() },
|
||||
data() { return { loading: false, products: [], selectedProducts: [], total: 0, query: { page: 1, pageSize: 20, keyword: '', status: '' }, createDialog: { open: false, saving: false }, createData: this.emptyCreate(), createRules: { shopeeItemId: [{ required: true, message: '请输入虾皮商品ID', trigger: 'blur' }, { max: 64, message: '不能超过 64 个字符', trigger: 'blur' }] }, quickColor: '', quickSize: '', colorValues: [], sizeValues: [], pddPicker: { open: false, loading: false, keyword: '', items: [] }, batchDelete: this.emptyBatchDelete(), detail: { open: false, productId: null, targetColor: '', action: '' }, pddDetail: { open: false, productId: null }, autoMatchRun: { submitting: false, latest: null }, autoMatchPollTimer: null } },
|
||||
computed: {
|
||||
isAdmin() { return (this.$store.getters.roles || []).includes('admin') },
|
||||
autoMatchRunSummary() { const run = this.autoMatchRun.latest; if (!run) return '最近一次:暂无'; if (run.status === 'running') return `最近一次:运行中,已处理 ${run.processedCount || 0}`; const status = run.status === 'completed' ? '完成' : run.status === 'completed_partial' ? '部分完成' : '失败'; return `最近一次:${status},处理 ${run.processedCount || 0},确认 ${run.confirmedCount || 0},未匹配 ${run.unmatchedCount || 0}` }
|
||||
},
|
||||
watch: { '$route.query': { deep: true, handler() { this.applyRouteDetail() } }}, created() { this.load(); this.applyRouteDetail(); if (this.isAdmin) this.loadLatestAutoMatchRun() }, activated() { this.applyRouteDetail(); if (this.isAdmin) this.loadLatestAutoMatchRun() }, beforeUnmount() { if (this.autoMatchPollTimer) clearTimeout(this.autoMatchPollTimer) },
|
||||
methods: {
|
||||
emptyCreate() { return { shopeeItemId: '', title: '', shopName: '', pddProductId: null } }, emptyBatchDelete() { return { open: false, saving: false, step: 'confirm', products: [], results: [], deletedCount: 0, skippedCount: 0 } },
|
||||
async loadLatestAutoMatchRun() { if (!this.isAdmin) return; const response = await getLatestShopeeSpecAutoMatchRun(); this.autoMatchRun.latest = response.data.run || null; if (this.autoMatchPollTimer) clearTimeout(this.autoMatchPollTimer); if (this.autoMatchRun.latest?.status === 'running') this.autoMatchPollTimer = setTimeout(() => this.loadLatestAutoMatchRun(), 2000) },
|
||||
async startAutoMatchRun() { await ElMessageBox.confirm('将在后台处理最多 20 个符合条件的商品,并可能调用 AI。不会创建采购任务或订单。是否继续?', '执行规格自动匹配', { type: 'warning', confirmButtonText: '开始执行', cancelButtonText: '取消' }); this.autoMatchRun.submitting = true; try { const response = await startShopeeSpecAutoMatchRun({ requestId: createRequestId() }); this.autoMatchRun.latest = response.data.run; ElMessage.success(response.data.run.alreadyRunning ? '已有自动匹配正在运行' : '已开始后台匹配'); await this.loadLatestAutoMatchRun() } finally { this.autoMatchRun.submitting = false } },
|
||||
applyRouteDetail() { const id = Number(this.$route.query.productId); if (!Number.isInteger(id) || id <= 0) return; this.detail = { open: true, productId: id, targetColor: String(this.$route.query.targetColor || '').trim(), action: String(this.$route.query.action || '') } },
|
||||
async load() { this.loading = true; this.selectedProducts = []; this.$refs.productTable?.clearSelection(); try { const r = await listShopeeProducts(this.query); this.products = r.data.items; this.total = r.data.total } finally { this.loading = false } }, search() { this.query.page = 1; this.load() }, reset() { this.query = { page: 1, pageSize: 20, keyword: '', status: '' }; this.load() }, handleSelectionChange(rows) { this.selectedProducts = rows },
|
||||
priceText(row) { if (row.salePriceCent === null || row.salePriceCent === undefined) return '—'; return `${row.currency || ''} ${(row.salePriceCent / 100).toFixed(2)}` }, mappingMeta(product) { 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?.status === 'confirmed').length; return confirmed === values.length ? { label: '可采购', type: 'success' } : { label: `待完善 · ${confirmed}/${values.length} 已确认`, type: 'warning' } },
|
||||
@@ -45,5 +51,5 @@ export default {
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.page-card{min-height:calc(100vh - 124px)}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.thumb{width:48px;height:48px;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}.scope-note{margin-top:12px;font-size:12px;color:#b91c1c}.notice{margin-bottom:16px}.picker-help{margin:8px 0 0}.picker-thumb,.picker-image-placeholder{display:flex;width:56px;height:56px;margin:auto;border-radius:4px}.picker-image-placeholder{align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.spec-quick-add{display:flex;flex-direction:column;gap:8px;width:100%}.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}.quick-label{font-size:12px;color:#606266;width:32px}.chips{display:flex;flex-wrap:wrap;gap:6px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}.create-help{margin-left:8px}@media(max-width:768px){.form-grid{grid-template-columns:1fr}}
|
||||
.page-card{min-height:calc(100vh - 124px)}.search-form{padding:16px 16px 0;margin-bottom:16px;border:1px solid #e5e7eb;border-radius:8px;background:#f8fafc}.run-summary{margin-left:10px;font-size:12px;color:#606266}.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}.thumb{width:48px;height:48px;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}.scope-note{margin-top:12px;font-size:12px;color:#b91c1c}.notice{margin-bottom:16px}.picker-help{margin:8px 0 0}.picker-thumb,.picker-image-placeholder{display:flex;width:56px;height:56px;margin:auto;border-radius:4px}.picker-image-placeholder{align-items:center;justify-content:center;background:#f1f5f9;color:#909399;font-size:11px}.spec-quick-add{display:flex;flex-direction:column;gap:8px;width:100%}.quick-row{display:flex;align-items:center;gap:8px;margin:6px 0}.quick-label{font-size:12px;color:#606266;width:32px}.chips{display:flex;flex-wrap:wrap;gap:6px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 20px}.create-help{margin-left:8px}@media(max-width:768px){.form-grid{grid-template-columns:1fr}}
|
||||
</style>
|
||||
|
||||
@@ -51,22 +51,26 @@ test('PDD 选择器默认最近五条并支持搜索与清空回退', async({ pa
|
||||
expect(listURLs.at(-1)?.searchParams.get('status')).toBe('active')
|
||||
})
|
||||
|
||||
test('虾皮详情原地叠加 PDD 抽屉且低置信度 AI 建议不预填', async({ page, context }) => {
|
||||
test('虾皮详情一键匹配颜色尺码后直接使用并可原地叠加 PDD 抽屉', async({ page, context }) => {
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
|
||||
let autoMatched = false
|
||||
let autoMatchBody: Record<string, unknown> | null = null
|
||||
await page.route('**/api/**', async route => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname.startsWith('/src/api/')) return route.continue()
|
||||
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['purchaser'], name: '采购员', avatar: '', introduction: '', permissions: [] }}})
|
||||
if (url.pathname.endsWith('/api/v1/menurole')) return route.fulfill({ json: { code: 200, data: routes }})
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products')) return route.fulfill({ json: { code: 200, data: { items: [{ id: 9, shopeeItemId: 'S9', title: '叠加抽屉商品', shopName: '测试店', pddProductId: 17, specs: [{ name: '颜色', role: 'color', values: [{ name: '藏青色', source: 'syb', mapping: null }] }] }], total: 1, page: 1, pageSize: 20 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products/9/specs/mapping/suggest-colors')) {
|
||||
return route.fulfill({ json: { code: 200, data: { items: [{ valueName: '藏青色', pddValue: '黑色', status: 'ai_suggested', apply: false, confidence: 0.45, reason: '置信度不足,请人工选择' }], matchedCount: 0, pendingCount: 1 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products/9/specs/mapping/auto-match')) {
|
||||
autoMatchBody = route.request().postDataJSON()
|
||||
autoMatched = true
|
||||
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: 3, specs: [{ name: '颜色', role: 'color', values: [{ name: '藏青色', source: 'syb', mapping: 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 }] }], colorImages: [] }, relatedShopeeProducts: [] }}})
|
||||
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: [] }}})
|
||||
return route.fulfill({ json: { code: 200, data: [] }})
|
||||
})
|
||||
|
||||
@@ -75,15 +79,19 @@ test('虾皮详情原地叠加 PDD 抽屉且低置信度 AI 建议不预填', as
|
||||
page.on('popup', () => { popupCount += 1 })
|
||||
await page.getByRole('button', { name: '详情', exact: true }).click()
|
||||
await expect(page.getByText('虾皮商品详情', { exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'AI 匹配', exact: true }).click()
|
||||
await expect(page.getByText('置信度不足,请人工选择', { exact: true })).toBeVisible()
|
||||
await expect(page.locator('.el-drawer:visible .mapping-select input').first()).toHaveValue('')
|
||||
await expect(page.getByRole('button', { name: '一键匹配颜色和尺码', exact: true })).toHaveCount(1)
|
||||
await expect(page.getByRole('button', { name: 'AI 匹配', exact: true })).toHaveCount(0)
|
||||
await page.getByRole('button', { name: '一键匹配颜色和尺码', exact: true }).click()
|
||||
await expect(page.locator('.el-drawer:visible').getByText('黑色 · ¥12.99', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('可采购', { exact: true })).toBeVisible()
|
||||
expect(autoMatchBody?.specContextVersion).toBe('context-3')
|
||||
expect(String(autoMatchBody?.requestId || '')).toMatch(/^[0-9a-f-]{36}$/)
|
||||
await page.getByLabel('虾皮商品详情').getByText('PDD-17 ↗', { exact: true }).click()
|
||||
await expect(page.locator('.el-drawer:visible').getByText('PDD 商品详情', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('PDD 叠加详情', { exact: true })).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByText('PDD 叠加详情', { exact: true })).toBeHidden()
|
||||
await expect(page.getByText('虾皮商品详情', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('置信度不足,请人工选择', { exact: true })).toBeVisible()
|
||||
await expect(page.locator('.el-drawer:visible').getByText('黑色 · ¥12.99', { exact: true })).toBeVisible()
|
||||
expect(popupCount).toBe(0)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { expect, test } 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' }] }]
|
||||
|
||||
test('管理员可确认并手动启动规格自动匹配且查看摘要', async({ page, context }) => {
|
||||
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
|
||||
let started = false
|
||||
let requestBody: Record<string, unknown> | null = null
|
||||
await page.route('**/api/**', async route => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname.startsWith('/src/api/')) return route.continue()
|
||||
if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] }}})
|
||||
if (url.pathname.endsWith('/api/v1/menurole')) return route.fulfill({ json: { code: 200, data: routes }})
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-products')) return route.fulfill({ json: { code: 200, data: { items: [], total: 0, page: 1, pageSize: 20 }}})
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-spec-auto-match/runs/latest')) {
|
||||
const run = started ? { id: 8, status: 'completed', processedCount: 12, confirmedCount: 9, unmatchedCount: 3 } : null
|
||||
return route.fulfill({ json: { code: 200, data: { run }}})
|
||||
}
|
||||
if (url.pathname.endsWith('/api/admin/v1/shopee-spec-auto-match/runs')) {
|
||||
requestBody = route.request().postDataJSON()
|
||||
started = true
|
||||
return route.fulfill({ status: 202, json: { code: 200, data: { run: { id: 8, status: 'running', processedCount: 0 }}}})
|
||||
}
|
||||
return route.fulfill({ json: { code: 200, data: [] }})
|
||||
})
|
||||
|
||||
await page.goto('/#/shopee-products')
|
||||
await page.getByRole('button', { name: '执行规格自动匹配', exact: true }).click()
|
||||
await expect(page.getByText('将在后台处理最多 20 个符合条件的商品,并可能调用 AI。不会创建采购任务或订单。是否继续?', { exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: '开始执行', exact: true }).click()
|
||||
await expect(page.getByText('最近一次:完成,处理 12,确认 9,未匹配 3', { exact: true })).toBeVisible()
|
||||
expect(String(requestBody?.requestId || '')).toMatch(/^[0-9a-f-]{36}$/)
|
||||
})
|
||||
Reference in New Issue
Block a user