fix(syb): 修正规格颜色尺码反序 (#216)

This commit is contained in:
QiuSW
2026-09-04 19:33:35 +08:00
parent c09ddc8474
commit bd386cfae4
22 changed files with 563 additions and 51 deletions
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Project-Profile
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.-
wiki_revision: 6207da4ffac6b5821eda7869b9ea335b7585826d
synchronized_at: 2026-08-31T16:06:21Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:29:41Z
<!-- gitea-wiki-mirror:end -->
# 项目档案
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Development-Workflow
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Development-Workflow.-
wiki_revision: 3167457d79824afe532dd5560990af8a485a57d1
synchronized_at: 2026-08-27T09:37:51Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:29:46Z
<!-- gitea-wiki-mirror:end -->
# 开发工作流
+26 -11
View File
@@ -2,16 +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
<!-- gitea-wiki-mirror:end -->
<!-- gitea-wiki-mirror:start -->
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: dc5ccd2104e8ded715c0c0bb23d3827dc9a7fd7a
synchronized_at: 2026-08-29T01:39:35Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:29:50Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -56,7 +48,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 +310,26 @@ 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,也不执行付款。
## SYB 异常规格 AI 定时解析(#198)
- `server/app/goauto/sybimport/ai_parse_batch.go` 以 SYB 明细为单位执行“确定性重解析 → 封闭候选 AI 解析”,与 #195 的“蝦皮规格 → PDD 规格”任务保持独立。
- `syb_spec_ai_parse_run` 保存全局批次、活动槽、租约与结构化计数;`syb_spec_ai_parse_work_item` 按 `syb_product_id` 唯一保存输入指纹、尝试次数、冷却和逐行租约。
- `syb_product` 的 `ai_confirmed`、置信度、限长理由、确认时间和隐藏输入指纹记录 AI 确认事实;`parse_status` 仍只记录确定性解析器结果,`manually_confirmed` 仍只代表人工决定。
- Provider 只接收单条 `productSpec` 和关联蝦皮商品的颜色/尺码候选;返回值必须逐字属于对应候选。采购可信门禁接受仍有效的 AI 确认,但候选消失后立即 fail-closed。
## 定时任务持久化执行历史(#199)
- `server/app/jobs/execution_log.go` 为 Exec 与 HTTP 两类调度入口统一记录一次执行生命周期;`server/app/jobs/models/sys_job_execution_log.go` 对应表 `sys_job_execution_log`,保存执行标识、任务/调用目标快照、定时触发类型、开始/结束时间、耗时、`running` / `succeeded` / `failed` / `interrupted` 状态及脱敏错误码和摘要。
- 服务启动时按当前“每个数据库一个调度器实例”的拓扑,把上次进程遗留的 `running` 记录安全收敛为 `interrupted`;任务删除后历史仍保留并可只读查询。
- `GET /api/v1/sysjob/:id/execution-logs` 由 `server/app/jobs/service/execution_log.go`、`apis/execution_log.go` 和 `router/sys_job.go` 提供任务级分页、状态与开始时间过滤;沿用隐藏菜单 `JobLog` 的角色菜单绑定和精确 GET 权限。Web 入口为 `web/src/views/schedule/index.vue` 的单选“日志”按钮,详情页为 `web/src/views/schedule/log.vue`。
- 执行历史明确不保存任务参数、AI Provider 地址或密钥、第三方原始响应和业务原始载荷;当前不提供删除、保留期限自动化、WebSocket 实时流或立即执行动作。
- 追加迁移为 `server/cmd/migrate/migration/version-local/1788357000000_sys_job_execution_log.go`:创建执行历史表、登记只读 API、关联 `JobLog` 菜单,并只给迁移前已绑定该菜单的角色补充精确 Casbin 权限。
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Local-Development-and-Verification
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.-
wiki_revision: 0bb8d63fe2a3c6b1021bfa18800f5062de726868
synchronized_at: 2026-08-31T16:06:55Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:02Z
<!-- gitea-wiki-mirror:end -->
# 本地开发与验证
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Common-Changes
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Common-Changes.-
wiki_revision: b7d5520da954c544248e979c4ababb86d2784a43
synchronized_at: 2026-08-27T09:38:36Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:12Z
<!-- gitea-wiki-mirror:end -->
# 常见修改指南
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Troubleshooting
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
wiki_revision: a8d4790811aa825c78830cb9c71c6b21723e4584
synchronized_at: 2026-08-28T03:35:05Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:16Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Product-Requirements-Overview
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Product-Requirements-Overview.-
wiki_revision: 0d9924b4566e567edf167ba678fedde28a41cfd3
synchronized_at: 2026-08-28T02:27:12Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:20Z
<!-- gitea-wiki-mirror:end -->
# 产品需求总览与当前 MVP
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Delivery-Issues
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Delivery-Issues.-
wiki_revision: 0eed28dd5dc629ce66071833d5fefbdf24d567d3
synchronized_at: 2026-08-24T02:35:58Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:28Z
<!-- gitea-wiki-mirror:end -->
# 当前 MVP 交付工单索引
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: OnePlus-Real-Device-Acceptance
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/OnePlus-Real-Device-Acceptance.-
wiki_revision: 5b74a3b915cda6307cd59087bf9ce295f4c5e806
synchronized_at: 2026-08-21T08:08:57Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:32Z
<!-- gitea-wiki-mirror:end -->
# 一加真机验收记录
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: PDD-Detail-Rule-Migration-Analysis
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/PDD-Detail-Rule-Migration-Analysis.-
wiki_revision: 1fe9ed083c4fc565a50b4186571233d1dfab58a7
synchronized_at: 2026-08-17T03:28:38Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:37Z
<!-- gitea-wiki-mirror:end -->
# PDD 商品详情采集规则迁移分析
+20 -7
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: SYB-ERP-Interface-Contract
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/SYB-ERP-Interface-Contract.-
wiki_revision: f596931468caf524cbd9d90b3c299b32a8cf8228
synchronized_at: 2026-08-28T02:29:07Z
wiki_revision: ad9adc22e69e48c9a8fd87d7121066eecca55fd6
synchronized_at: 2026-09-04T11:30:50Z
<!-- gitea-wiki-mirror:end -->
# 12 顺云宝(SYB)ERP 接口契约
@@ -393,19 +393,32 @@ POST /am/stock/detail/listByStock?hist=0
顺运宝给的是**商品级 ID + 规格原文**,没有規格 ID。
### 6.3 `productSpec` 的格式和蝦皮报表完全一致
### 6.3 `productSpec` 的角色顺序不是固定的
```text
蝦皮目录 `spec_raw` 黑色,M【建議40-50公斤】
顺运宝 productSpec 白色,L【建議50-60公斤】
黑色+白色【純棉兩件裝】 簡約親膚,L【建議52.5-60公斤】
均碼,黑色
```
`[必须]` 规格身份统一复用 `spec.SpecKey()`:只折叠空白,不猜颜色、尺码或建议。
第三方目录脚本提交 `spec_raw` 和明确的解析结果;顺运宝匹配不到时交给人工确认。
2026-09-04 的真实数据确认:`productSpec` 至少存在 `颜色,尺码` 与
`尺码,颜色` 两种顺序,不能再固定把逗号前当颜色、逗号后当尺码。
`[必须]` 先按最后一个 ASCII 逗号拆成两段并剥离 `【...】` 备注。只有两侧
恰好一侧带有明确尺码信号(如均码、字母尺码或明确尺码单位)时,才把该侧判为
尺码、另一侧判为颜色。两侧都像尺码时标记存疑;不得使用模糊颜色词库、AI 或
跨维度候选猜测角色。两侧都没有明确尺码信号时,暂按既有 `颜色,尺码` 契约兼容,
并继续执行原有复杂分隔符和空值校验。
`[必须]` 修正规格角色时保留原始 `raw_json`,不得覆盖人工确认值。已错误写入
虾皮档案的导入规格只能在确认无其他 SYB 明细引用且没有规格映射时移除;人工规格、
映射及历史采购任务保持不变。
`[必须]` 规格身份继续复用 `spec.SpecKey()`:只折叠空白,不改写用于 PDD
精确点击的原始候选标签。第三方目录脚本提交 `spec_raw` 和明确解析结果;角色或
规格比对不明确时保持存疑,不得跨颜色/尺码猜测。
### 6.4 由此推导出的匹配路径
```text
productId ──→ shopee_products.goods_id 商品级,直接相等
productSpec ──→ SpecKey() ──→ 稳定规格身份键
+30 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-and-Operations
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-and-Operations.-
wiki_revision: 80ae5c8cea87c624fff63ce3e302aa7bf204811e
synchronized_at: 2026-08-31T16:07:03Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:08Z
<!-- gitea-wiki-mirror:end -->
# 部署与运维
@@ -39,6 +39,34 @@ 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。
## SYB 异常规格 AI 定时任务(#198)
- 追加迁移 `1788354164329_syb_spec_ai_parse.go` 创建 AI 确认字段、`syb_spec_ai_parse_run`、`syb_spec_ai_parse_work_item`,并幂等写入 `sys_job.invoke_target=GoAutoSYBSpecAIParse`。
- 系统任务默认关闭,默认 Cron 为每小时第 5 分钟、参数 `{"batchLimit":20}`;迁移重跑不得覆盖管理员后续修改的 Cron、参数或启停状态。建议先运行该任务,再由默认第 15 分钟的 #195 完成蝦皮到 PDD 的规格匹配。
- 多实例通过可空唯一活动槽和租约保证全局单批运行;逐行工作项按输入指纹去重。Provider 临时失败至少 60 分钟后重试且最多 3 次,低置信度/无结果在输入不变时不重复调用。
- 上线顺序为:备份数据库、执行追加迁移、发布服务并保持任务关闭、核验表/字段/任务种子,再由管理员决定是否启用。排错只查看运行计数、工作状态和脱敏错误,不输出 API Key、Provider 原始响应、完整 RawJSON 或订单数据。
## 定时任务执行历史运维(#199)
- 发布 #199 时先备份数据库,再执行追加迁移 `1788357000000_sys_job_execution_log.go`,随后发布服务与 Web。迁移创建 `sys_job_execution_log`、登记 `GET /api/v1/sysjob/:id/execution-logs`,并只为原本绑定隐藏菜单 `JobLog` 的角色写入精确读取权限;菜单缺失时迁移明确失败,不扩大角色授权。
- 服务启动会将上次进程遗留的 `running` 执行记录标记为 `interrupted`。该恢复依赖当前线上每个数据库只运行一个调度器实例;扩展为多调度器前必须另建工单引入实例租约,不能直接复用此判断。
- 排错从 Admin 定时任务页单选任务后进入“日志”,按状态和开始时间查询。记录只含稳定错误码和脱敏摘要;需要定位细节时查看受控服务日志,不得把任务参数、Provider 配置/响应、密钥或业务原始数据复制进执行历史。
- 当前没有执行历史删除接口和自动保留策略;删除定时任务不删除历史。数据库容量治理需要另建工单评估。#198 的 `GoAutoSYBSpecAIParse` 在 #199 发布和迁移后仍保持关闭,启用必须由管理员另行确认。
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Home
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Home
wiki_revision: d5a46210da9dffa27247d80b20ab31b40790e48a
synchronized_at: 2026-08-31T16:06:14Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:29:34Z
<!-- gitea-wiki-mirror:end -->
# GoAuto 文档中心
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Deployment-Template
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Deployment-Template.-
wiki_revision: 389f09d292b0aa408f26f1967e33eec82d37cedf
synchronized_at: 2026-08-27T09:39:50Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:41Z
<!-- gitea-wiki-mirror:end -->
# 部署文档模板
+2 -2
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-Archive-Template
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Task-Archive-Template.-
wiki_revision: 53927b3b69fa26943f323f9be53153f80ed54530
synchronized_at: 2026-08-24T08:23:07Z
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
synchronized_at: 2026-09-04T11:30:46Z
<!-- gitea-wiki-mirror:end -->
> 本模板只用于用户明确要求的专项历史快照或读取既有归档,不属于标准任务闭环。单次任务的唯一事实来源是 Gitea 工单;不要为了完成普通任务创建本页面,也不要自动导出到 `docs/task/`。
+26 -7
View File
@@ -13,6 +13,13 @@ import (
var bracketPattern = regexp.MustCompile(`【[^】]*】`)
// explicitSizePattern recognizes only values whose spelling carries a strong
// size signal. SYB has now been observed returning both "color,size" and
// "size,color". A color dictionary would inevitably guess at product-specific
// labels, so role reversal is allowed only when exactly one side matches this
// deliberately narrow pattern.
var explicitSizePattern = regexp.MustCompile(`(?i)^(?:均(?:码|碼|号|號)|one\s*size|free\s*size|x{0,4}[sml]|[2-9]xl|(?:加大|大|中|小)(?:码|碼|号|號)|\d+(?:\.\d+)?(?:cm|mm|m|码|碼|号|號|公分)|\d+(?:\.\d+)?(?:[-~~至到]\d+(?:\.\d+)?)?(?:斤|公斤|千克|kg))$`)
// ambiguousPattern flags leftover separators or multi-token noise after
// bracket stripping — the signal that a "clean" split still isn't reliable.
// Observed in the real SYB sample: "黑色+白色【純棉兩件裝】 簡約親膚" strips
@@ -29,8 +36,8 @@ type ParseResult struct {
Note string
}
// Parse implements the #41 rule: split on the last comma into color/size,
// strip 【...】 annotations, and classify the result. It never guesses a
// Parse implements the #41/#216 rule: split on the last comma, strip 【...】
// annotations, and classify the result. It never guesses a
// missing value and never invents a color or size that is not literally
// present in the input.
//
@@ -41,9 +48,13 @@ type ParseResult struct {
// brackets stripped, becomes the size candidate; color stays empty. A
// single token with no separator cannot be split into two dimensions
// without guessing which one it is.
// - comma present, last segment (size) and first segment(s) (color) both
// non-empty after stripping brackets, and the color candidate has no
// leftover separator/whitespace -> success.
// - comma present and exactly one side has an explicit size signal -> that
// side is size and the other side is color. This supports both observed
// SYB orders without allowing AI or a fuzzy color dictionary to swap roles.
// - comma present and neither side has an explicit size signal -> retain the
// established SYB color,size contract for backward compatibility.
// - comma present and both sides have explicit size signals -> uncertain;
// there is no safe color decision.
// - comma present but either side is empty after stripping, or the color
// candidate still carries a leftover '+' or internal whitespace -> the
// split happened but is not trustworthy -> uncertain.
@@ -65,12 +76,20 @@ func Parse(raw string) ParseResult {
return ParseResult{Size: size, Status: models.SYBParseStatusUncertain, Note: "无分隔符,仅识别出单一规格值"}
}
colorPart := stripBrackets(trimmed[:lastComma])
sizePart := stripBrackets(trimmed[lastComma+1:])
firstPart := stripBrackets(trimmed[:lastComma])
secondPart := stripBrackets(trimmed[lastComma+1:])
colorPart, sizePart := firstPart, secondPart
if colorPart == "" || sizePart == "" {
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "按逗号拆分后有一侧为空"}
}
firstIsSize, secondIsSize := explicitSizePattern.MatchString(firstPart), explicitSizePattern.MatchString(secondPart)
if firstIsSize && secondIsSize {
return ParseResult{Color: firstPart, Size: secondPart, Status: models.SYBParseStatusUncertain, Note: "逗号两侧均具有尺码特征,无法安全识别颜色"}
}
if firstIsSize {
colorPart, sizePart = secondPart, firstPart
}
if ambiguousPattern.MatchString(colorPart) {
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "颜色部分含备注文本或多个分隔符,拆分结果可能不准确"}
}
+21
View File
@@ -19,6 +19,27 @@ func TestParseRealSample_CleanColorAndSize(t *testing.T) {
}
}
func TestParseReversedSizeAndColorWhenOnlyFirstHasExplicitSizeEvidence(t *testing.T) {
result := sybimport.Parse("均碼,黑色")
if result.Color != "黑色" || result.Size != "均碼" || result.Status != models.SYBParseStatusSuccess {
t.Fatalf("expected reversed role parse 黑色/均碼 success, got %+v", result)
}
}
func TestParseKeepsTraditionalColorAndSizeOrderWhenSecondHasSizeEvidence(t *testing.T) {
result := sybimport.Parse("黑色,均码")
if result.Color != "黑色" || result.Size != "均码" || result.Status != models.SYBParseStatusSuccess {
t.Fatalf("expected normal role parse 黑色/均码 success, got %+v", result)
}
}
func TestParseTwoExplicitSizesIsUncertainInsteadOfSwapping(t *testing.T) {
result := sybimport.Parse("M,L")
if result.Status != models.SYBParseStatusUncertain || result.Color != "M" || result.Size != "L" {
t.Fatalf("expected conflicting size evidence to stay uncertain, got %+v", result)
}
}
func TestParseRealSample_AmbiguousColorWithAnnotationAndSeparator(t *testing.T) {
result := sybimport.Parse("黑色+白色【純棉兩件裝】 簡約親膚,L【建議52.5-60公斤】")
if result.Status != models.SYBParseStatusUncertain {
+164
View File
@@ -0,0 +1,164 @@
package sybimport
import (
"context"
"encoding/json"
"sort"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
"gorm.io/gorm"
)
// SpecRoleRepairReport contains only aggregate migration evidence. It does not
// expose order numbers, raw source payloads, or specification text.
type SpecRoleRepairReport struct {
SYBRows int
ShopeeProducts int
}
type specRoleChange struct {
OldColor string
OldSize string
}
// RepairReversedSpecRoles corrects only the historical shape that #216 can
// prove was role-reversed: the newly parsed color/size are exactly the old
// size/color. Human-confirmed rows are never overwritten. RawJSON is read for
// reparsing and deliberately excluded from every update.
func RepairReversedSpecRoles(ctx context.Context, db *gorm.DB) (SpecRoleRepairReport, error) {
report := SpecRoleRepairReport{}
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var records []models.SYBProduct
if err := tx.Where("manually_confirmed = ?", false).Find(&records).Error; err != nil {
return err
}
changesByProduct := map[uint64][]specRoleChange{}
for _, record := range records {
var raw rawDetailSpec
if json.Unmarshal([]byte(record.RawJSON), &raw) != nil {
continue
}
parsed := Parse(raw.ProductSpec)
if parsed.Status == models.SYBParseStatusFailed || parsed.Color == record.TargetColor ||
parsed.Color != record.TargetSize || parsed.Size != record.TargetColor {
continue
}
if err := tx.Model(&models.SYBProduct{}).Where("id = ?", record.ID).Updates(map[string]any{
"target_color": parsed.Color,
"target_size": parsed.Size,
"parse_status": parsed.Status,
"parse_note": parsed.Note,
}).Error; err != nil {
return err
}
report.SYBRows++
if record.ShopeeProductID != nil {
productID := *record.ShopeeProductID
changesByProduct[productID] = append(changesByProduct[productID], specRoleChange{
OldColor: record.TargetColor,
OldSize: record.TargetSize,
})
}
}
for productID, changes := range changesByProduct {
changed, err := reconcileRepairedShopeeSpecs(tx, productID, changes)
if err != nil {
return err
}
if changed {
report.ShopeeProducts++
}
}
return nil
})
return report, err
}
func reconcileRepairedShopeeSpecs(tx *gorm.DB, productID uint64, changes []specRoleChange) (bool, error) {
var product models.ShopeeProduct
if err := tx.First(&product, productID).Error; err != nil {
return false, err
}
existing, err := shopeeproduct.Unmarshal(product.SpecsJSON)
if err != nil {
return false, err
}
var rows []models.SYBProduct
if err := tx.Where("shopee_product_id = ? AND (parse_status = ? OR manually_confirmed = ?)",
productID, models.SYBParseStatusSuccess, true).Find(&rows).Error; err != nil {
return false, err
}
usedColors, usedSizes := map[string]bool{}, map[string]bool{}
for _, row := range rows {
if row.TargetColor != "" {
usedColors[row.TargetColor] = true
}
if row.TargetSize != "" {
usedSizes[row.TargetSize] = true
}
}
oldColors, oldSizes := map[string]bool{}, map[string]bool{}
for _, change := range changes {
oldColors[change.OldColor] = true
oldSizes[change.OldSize] = true
}
cleaned := make([]shopeeproduct.SpecDimension, 0, len(existing))
for _, dimension := range existing {
values := make([]shopeeproduct.SpecValue, 0, len(dimension.Values))
for _, value := range dimension.Values {
remove := value.Source == shopeeproduct.ValueSourceImport && value.Mapping == nil
switch dimension.Role {
case shopeeproduct.RoleColor:
remove = remove && oldColors[value.Name] && !usedColors[value.Name]
case shopeeproduct.RoleSize:
remove = remove && oldSizes[value.Name] && !usedSizes[value.Name]
default:
remove = false
}
if !remove {
values = append(values, value)
}
}
dimension.Values = values
cleaned = append(cleaned, dimension)
}
incoming := make([]shopeeproduct.SpecDimension, 0, 2)
if values := sortedImportValues(usedColors); len(values) > 0 {
incoming = append(incoming, shopeeproduct.SpecDimension{Name: "颜色", Role: shopeeproduct.RoleColor, Values: values})
}
if values := sortedImportValues(usedSizes); len(values) > 0 {
incoming = append(incoming, shopeeproduct.SpecDimension{Name: "尺码", Role: shopeeproduct.RoleSize, Values: values})
}
reconciled := shopeeproduct.Merge(cleaned, incoming)
if err := shopeeproduct.Validate(reconciled); err != nil {
return false, err
}
encoded, err := shopeeproduct.Marshal(reconciled)
if err != nil {
return false, err
}
if encoded == product.SpecsJSON {
return false, nil
}
return true, tx.Model(&models.ShopeeProduct{}).Where("id = ?", productID).Update("specs_json", encoded).Error
}
func sortedImportValues(set map[string]bool) []shopeeproduct.SpecValue {
names := make([]string, 0, len(set))
for name := range set {
names = append(names, name)
}
sort.Strings(names)
values := make([]shopeeproduct.SpecValue, 0, len(names))
for _, name := range names {
values = append(values, shopeeproduct.SpecValue{Name: name, Source: shopeeproduct.ValueSourceImport})
}
return values
}
@@ -0,0 +1,126 @@
package sybimport
import (
"context"
"testing"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
)
func TestRepairReversedSpecRolesIsSafeAndIdempotent(t *testing.T) {
db := newSyncTestDB(t)
manualMapping := &shopeeproduct.Mapping{PDDValue: "藏青", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}
specs, err := shopeeproduct.Marshal([]shopeeproduct.SpecDimension{
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{
{Name: "均碼", Source: shopeeproduct.ValueSourceImport},
{Name: "手工蓝", Source: shopeeproduct.ValueSourceManual, Mapping: manualMapping},
}},
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "黑色", Source: shopeeproduct.ValueSourceImport}}},
})
if err != nil {
t.Fatal(err)
}
product := models.ShopeeProduct{ShopeeItemID: "ROLE-REPAIR", Title: "测试", ShopName: "测试店铺", Currency: "TWD", SpecsJSON: specs}
if err := db.Create(&product).Error; err != nil {
t.Fatal(err)
}
raw := `{"productSpec":"均碼,黑色"}`
row := models.SYBProduct{OrderCode: "ROLE-ORDER", DetailID: 1, StockID: 1, ShopeeItemID: product.ShopeeItemID,
ShopeeProductID: &product.ID, ProductTitle: "测试", TargetColor: "均碼", TargetSize: "黑色", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: raw}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
manual := models.SYBProduct{OrderCode: "MANUAL-ORDER", DetailID: 2, StockID: 2, ShopeeItemID: product.ShopeeItemID,
ShopeeProductID: &product.ID, ProductTitle: "测试", TargetColor: "人工颜色", TargetSize: "人工尺码", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, ManuallyConfirmed: true, RawJSON: raw}
if err := db.Create(&manual).Error; err != nil {
t.Fatal(err)
}
report, err := RepairReversedSpecRoles(context.Background(), db)
if err != nil || report.SYBRows != 1 || report.ShopeeProducts != 1 {
t.Fatalf("unexpected repair report=%+v err=%v", report, err)
}
var repaired, untouched models.SYBProduct
db.First(&repaired, row.ID)
db.First(&untouched, manual.ID)
if repaired.TargetColor != "黑色" || repaired.TargetSize != "均碼" || repaired.RawJSON != raw {
t.Fatalf("repaired row is invalid: %+v", repaired)
}
if untouched.TargetColor != "人工颜色" || untouched.TargetSize != "人工尺码" {
t.Fatalf("manual row was overwritten: %+v", untouched)
}
db.First(&product, product.ID)
reconciled, err := shopeeproduct.Unmarshal(product.SpecsJSON)
if err != nil {
t.Fatal(err)
}
assertSpecValue(t, reconciled, shopeeproduct.RoleColor, "均碼", false)
assertSpecValue(t, reconciled, shopeeproduct.RoleSize, "黑色", false)
assertSpecValue(t, reconciled, shopeeproduct.RoleColor, "黑色", true)
assertSpecValue(t, reconciled, shopeeproduct.RoleSize, "均碼", true)
assertSpecValue(t, reconciled, shopeeproduct.RoleColor, "手工蓝", true)
replay, err := RepairReversedSpecRoles(context.Background(), db)
if err != nil || replay.SYBRows != 0 || replay.ShopeeProducts != 0 {
t.Fatalf("repair was not idempotent: report=%+v err=%v", replay, err)
}
}
func TestRepairReversedSpecRolesKeepsAmbiguousColorUncertain(t *testing.T) {
db := newSyncTestDB(t)
specs, err := shopeeproduct.Marshal([]shopeeproduct.SpecDimension{
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "均碼", Source: shopeeproduct.ValueSourceImport}}},
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "黑色+白色", Source: shopeeproduct.ValueSourceImport}}},
})
if err != nil {
t.Fatal(err)
}
product := models.ShopeeProduct{ShopeeItemID: "ROLE-UNCERTAIN", Title: "测试", ShopName: "测试店铺", Currency: "TWD", SpecsJSON: specs}
if err := db.Create(&product).Error; err != nil {
t.Fatal(err)
}
row := models.SYBProduct{OrderCode: "UNCERTAIN-ORDER", DetailID: 1, StockID: 1, ShopeeItemID: product.ShopeeItemID,
ShopeeProductID: &product.ID, ProductTitle: "测试", TargetColor: "均碼", TargetSize: "黑色+白色", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"productSpec":"均碼,黑色+白色"}`}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
report, err := RepairReversedSpecRoles(context.Background(), db)
if err != nil || report.SYBRows != 1 || report.ShopeeProducts != 1 {
t.Fatalf("unexpected repair report=%+v err=%v", report, err)
}
db.First(&row, row.ID)
if row.TargetColor != "黑色+白色" || row.TargetSize != "均碼" || row.ParseStatus != models.SYBParseStatusUncertain {
t.Fatalf("ambiguous color role/status was not preserved safely: %+v", row)
}
db.First(&product, product.ID)
reconciled, err := shopeeproduct.Unmarshal(product.SpecsJSON)
if err != nil {
t.Fatal(err)
}
assertSpecValue(t, reconciled, shopeeproduct.RoleColor, "均碼", false)
assertSpecValue(t, reconciled, shopeeproduct.RoleSize, "黑色+白色", false)
}
func assertSpecValue(t *testing.T, specs []shopeeproduct.SpecDimension, role, name string, want bool) {
t.Helper()
found := false
for _, dimension := range specs {
if dimension.Role != role {
continue
}
for _, value := range dimension.Values {
if value.Name == name {
found = true
}
}
}
if found != want {
t.Fatalf("role=%s value=%q found=%v want=%v specs=%+v", role, name, found, want, specs)
}
}
@@ -0,0 +1,34 @@
package version_local
import (
"context"
"runtime"
goautomigrations "go-admin/app/goauto/migrations"
"go-admin/app/goauto/sybimport"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
// This migration repairs SYB rows whose two specification roles were stored
// in reverse before #216 recognized both observed source orders. The domain
// repair also removes only now-unreferenced, unmapped import values from the
// corresponding Shopee archive; human facts and raw source JSON are retained.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSYBSpecRoleOrderRepair)
}
func migrateSYBSpecRoleOrderRepair(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 := sybimport.RepairReversedSpecRoles(context.Background(), tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,62 @@
package version_local
import (
"testing"
goautomigrations "go-admin/app/goauto/migrations"
"go-admin/app/goauto/models"
"go-admin/app/goauto/shopeeproduct"
common "go-admin/common/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func TestMigrateSYBSpecRoleOrderRepair(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&common.Migration{}); err != nil {
t.Fatal(err)
}
if err := goautomigrations.Migrate(db); err != nil {
t.Fatal(err)
}
specs, err := shopeeproduct.Marshal([]shopeeproduct.SpecDimension{
{Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "均碼", Source: shopeeproduct.ValueSourceImport}}},
{Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "黑色", Source: shopeeproduct.ValueSourceImport}}},
})
if err != nil {
t.Fatal(err)
}
product := models.ShopeeProduct{ShopeeItemID: "MIGRATION-ROLE", Title: "测试", ShopName: "测试店铺", Currency: "TWD", SpecsJSON: specs}
if err := db.Create(&product).Error; err != nil {
t.Fatal(err)
}
row := models.SYBProduct{OrderCode: "MIGRATION-ORDER", DetailID: 1, StockID: 1, ShopeeItemID: product.ShopeeItemID,
ShopeeProductID: &product.ID, ProductTitle: "测试", TargetColor: "均碼", TargetSize: "黑色", Quantity: 1,
ParseStatus: models.SYBParseStatusSuccess, RawJSON: `{"productSpec":"均碼,黑色"}`}
if err := db.Create(&row).Error; err != nil {
t.Fatal(err)
}
const version = "1787984000000"
if err := migrateSYBSpecRoleOrderRepair(db, version); err != nil {
t.Fatalf("执行规格角色修复迁移失败: %v", err)
}
if err := db.First(&row, row.ID).Error; err != nil {
t.Fatal(err)
}
if row.TargetColor != "黑色" || row.TargetSize != "均碼" {
t.Fatalf("迁移未修正规格角色: %q/%q", row.TargetColor, row.TargetSize)
}
var records int64
if err := db.Model(&common.Migration{}).Where("version = ?", version).Count(&records).Error; err != nil {
t.Fatal(err)
}
if records != 1 {
t.Fatalf("迁移应记录一次版本,实际 %d", records)
}
}
@@ -0,0 +1,30 @@
package version_local
import (
"context"
"runtime"
"go-admin/app/goauto/sybimport"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
// The first #216 local migration was exercised against live data before this
// follow-up case was known. Rerunning the idempotent repair lets already-
// migrated installations correct proven role reversals whose color remains
// uncertain, while fresh installations simply execute an empty second pass.
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSYBSpecRoleOrderUncertainRepair)
}
func migrateSYBSpecRoleOrderUncertainRepair(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if _, err := sybimport.RepairReversedSpecRoles(context.Background(), tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}