Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c137f5072f | ||
|
|
82b4ebd406 | ||
|
|
b83f20c6b7 | ||
|
|
e0ed05d15a | ||
|
|
8163dd10eb | ||
|
|
aed295c8c1 | ||
|
|
c14f5f528b | ||
|
|
8293b27c19 | ||
|
|
c24e8a4439 | ||
|
|
40a254dc41 | ||
|
|
503b3cbbc5 | ||
|
|
cad9f6d185 | ||
|
|
78df997fc3 | ||
|
|
20f59b68bd | ||
|
|
652bf30f5a | ||
|
|
79929036bb | ||
|
|
e4677069d7 |
@@ -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: 6c8866d6186d8d6cc022256f2f369cc3072e99b5
|
||||
synchronized_at: 2026-09-10T07:50:14Z
|
||||
wiki_revision: 029dc3e155e1828484c62efd0ac804616fcbc714
|
||||
synchronized_at: 2026-09-11T09:09:07Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -420,3 +420,43 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- `shopeeproduct/latest_collection.go` 在原 latestCollection 增加可选 replacement 模式。预览和提交均短事务锁定 SYB、Shopee、原PDD、设备、来源采集与目标PDD,核对旧关联及上下文。提交按预览 taskId 读取,不重新挑选后来采集的商品。
|
||||
- Admin 路由注入 `purchase.ValidateQuickReplacement`,在事务内复用采购准备数据集及处理阶段计算,不调用 AI。缺少校验函数拒绝替换;客户端密钥不注入且仍拒绝 latestCollection。无新路由、权限、表或迁移。
|
||||
- 关联完成后才执行现有 AI 匹配,不在数据库事务中等待 Provider;同一 SYB 正式采购创建与替换共用 SYB 行锁。历史采购快照不变。SQLite 合成测试覆盖冲突与串行并发请求,不能替代生产 MySQL 多连接锁验收。
|
||||
|
||||
## 辅助信息缺失的采集关联(#259)
|
||||
|
||||
实现绑定 `e467706`,仅 Server;部署/验收见 #259。替代 #253/#258 中“部分完成一律拒绝”的限制,不改变 completed 原有路径。
|
||||
|
||||
- completed_partial 的 missing 必须是非空合法列表且仅包含 salesText、shopName、reviewCount,才进入辅助信息兼容检查;title、规格/价格缺失、遍历截断、SKU数量上限及未知原因仍拒绝。
|
||||
- `shopeeproduct/partial_collection.go` 核对本次 task 的维度和值、可售SKU的完整标记/规格引用/非负价格、本次颜色价格,以及当前档案可选值由本次有效SKU覆盖,不能借旧档案合并残留的规格或价格通过。真实单维度允许,不虚构另一维度;本次明确报告缺少规格仍拒绝。
|
||||
- 一键关联、替换预览及确认提交共用验证。失败沿用409 LATEST_COLLECTION_UNAVAILABLE及现有提示,不回退更早采集、不换手机。状态 completed_partial 和 missing 原样保留;辅助字段缺失仍可在任务详情查看。
|
||||
- 无接口字段、数据库迁移、Android/Web或权限变更;普通人工关联与采购/AI资格不变。不将“允许关联”视为“已匹配或已采购”。
|
||||
|
||||
## 采购单标题面板与地址就绪等待(#260、#261)
|
||||
|
||||
实现绑定 #260 `3c1ffa4`(执行器回归测试 `7de4318`)、#261 `5ea07f1`,Android 0.9.74 / versionCode87,基于0.9.73。安装及真实采购验收以工单证据为准;不代表 main 的 Android 已整合这些分支。
|
||||
|
||||
- 采购仅在安全规格入口点击返回SUCCESS后,记录本次页面包名/Activity作为内存上下文;开始新执行或页面身份改变时清除。`PddScreenParser` 的新增 purchaseEntryContext 默认关闭,仅该采购执行器传入;普通解析和采集原判定保留。
|
||||
- 当前仍为同一PDD Activity、商品ID非空、没有页面风险、没有选中摘要,且唯一有标题规格滚动区、一个维度有值,同时具有关闭、数量、支付展示区域与下单操作证据时,单标题可识别为NORMAL_SCROLLABLE。只是允许进入既有精确规格探测/选择,不执行下单或付款,不全局认可UNKNOWN,不增加入口重复点击或滑动。
|
||||
- 地址入口点击原有1000ms保留;地址列表等待增加唯一修改目标要求。点击修改和保存由500ms改为1000ms,其他通用动作间隔不变,无新配置项。
|
||||
- 修改后不再只见“详细地址”文字就立即写入。最多50次、每次间隔200ms读取(不含读取耗时),检查PDD页面风险;唯一可见启用有内容的详细地址EditText,连续两次路径、边界、内容及Activity相同才继续原输入流程。始终零个/多个或不稳定时失败,不取第一个候选。
|
||||
- 输入内容生成、输入后回读、保存后退出及返回规格面板、最终复核和单次下单边界不变。新增本机脱敏诊断 addressEditorCandidates / addressEditorPolls 两个整数,与task/attempt/device/ruleHash关联;不记录地址内容、原始树或整屏截图。
|
||||
- 仅Android;无Server/Web、共享API字段、数据库迁移或权限变化。真实采购、地址写入及订单创建不作为自动安装验证,不执行付款。
|
||||
|
||||
### v2 商品入口补点与地址返回等待(#260、#261)
|
||||
|
||||
2026-09-10 实现绑定 #260 `713111d`、#261 `d0a82f3`,Android 0.9.75 / versionCode88;真实采购效果以工单验收为准,非 main 合并声明。
|
||||
|
||||
- 入口恢复不再把全页面支付方式展示文案单独视为支付页。当前包名/Activity、商品标题和安全入口必须与点击前一致,入口唯一且可用;已打开面板、关闭控件、数量、选中摘要、订单操作、风险/评价页面仍拒绝恢复。支付 Activity 或明确支付、订单、地址文本继续阻止补点。仅允许既有一次重新定位后的规格入口中心手势,随后仍验证面板,不循环、不扩展到地址保存或下单。
|
||||
- 保存地址后的确认读取、必要时一次返回后的确认读取,增加最多50次、间隔200ms的有界就绪检查,页面捕获耗时另计。连续两次同一Activity满足原地址回读与确认面板条件后继续;完整信息不要求滚动区域。已明确到地址列表时保留原返回路径,不等待列表出现下单按钮;返回后增加1000ms缓冲。
|
||||
- 就绪检查不修改原地址回读和结构下单入口规则;超时后只对既有识别的面板、唯一可用滚动区域执行原有有限滚动。零/多个区域仍失败,不凭地址入口或未知底部节点授权下单。最终精确规格、数量、价格、地址与单次下单边界不变。
|
||||
- 私有采购诊断新增布尔 savedAddressMatched、savedPanelMatched;整数 savedLegacySubmitCandidates、savedAddressCards、savedAddressTargets、savedScrollCandidates、savedConfirmationPolls。只记录标量并绑定task/attempt/device/ruleHash,不保存地址、原始树或整屏图。
|
||||
- CG109 v1现场读取未达到空闲状态,不能确认原失败具体缺哪项证据;v2覆盖迟到确认节点及可观测性,不将合成测试通过冒充真机修复验收。无Server/Web/API/迁移/权限变化,不需要服务器重启。
|
||||
|
||||
## 探测到正式采购的单次面板交接(#262)
|
||||
|
||||
2026-09-10 实现绑定 `2ea32fd`,Android0.9.76/code89;基于#260/#261,未声明合并main或真实采购验收通过。
|
||||
|
||||
- AgentForegroundService通过PurchaseProbeHandoff只在成功spec_probe且结束后新快照仍识别PDD规格面板时保留一次性内存交接。仅taskId/goodsId/ruleHash/deviceId、Activity、前台revision和单调时钟时间,不保留控件节点、地址或选中状态,不落盘;有效期180秒。
|
||||
- 正式阶段消费时检查同任务/商品/规则/设备、前台revision及时间,消费后即清除。失配/超时/无任务或采集任务调度/结果异常/进程重启均不能沿用;普通手动再试不会继承已消费交接。现有服务端租约、互斥和匹配等待策略不变。
|
||||
- 有交接时,执行器恢复限定的purchaseEntryContext,最多10次、间隔200ms新读取(捕获耗时另计);连续两次相同PDD Activity且具备既有商品/面板证据才跳过openProduct。空树可有限等待,其他应用/Activity改变/页面风险立即拒绝复用。没有交接时保留原一次判断路径;不把UNKNOWN当成功,不跳过正式精确规格/数量/价格/地址或下单验证。
|
||||
- 私有诊断新增handoffPresent、productReused布尔值,reusePolls整数及reuseReason固定枚举legacy_evidence/page_problem/page_changed/handoff_ready/evidence_timeout。仍绑定task/attempt/device/ruleHash,无敏感文本。
|
||||
- 无Server/Web、共享API或数据库变化,不需重启服务器。只有合成测试验证;原CG107/108日志证实两阶段,但未记录旧版重开判断,不把上下文丢失假设宣称为唯一现场根因。
|
||||
|
||||
@@ -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: d016609062586a278560226600b5a1443c6fb442
|
||||
synchronized_at: 2026-09-10T07:50:17Z
|
||||
wiki_revision: d8aeff02fba57cba0b53fb22cdb524fd5cc0cb3e
|
||||
synchronized_at: 2026-09-11T09:09:16Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -570,3 +570,43 @@ Web 唯一展示位置为“采集采购 → SYB 同步记录”:列表状态
|
||||
- 不同商品显示一次确认,包含旧/新商品、手机和共享蝦皮档案影响;取消不写入。同商品不弹替换确认且不重写关联,只继续匹配。共享该蝦皮档案的 SYB 明细使用新关联,历史采购任务快照不改。
|
||||
- 沿用既有更换关联和有效映射检查,再复用自动匹配保存。部分匹配、AI失败或解析需人工处理时保留已保存关联,显示真实处理阶段,不保证立即可采购、不自动创建采购。
|
||||
- 网络异常只有限回读,区分目标商品、仍为原商品和其他关联,不把“已有某个关联”误报为本次替换成功;不自动重发写请求。没有新增后台作业、订单或付款操作。
|
||||
|
||||
## 辅助信息缺失的采集关联(#259)
|
||||
|
||||
实现绑定 `e467706`,仅 Server;部署/验收见 #259。替代 #253/#258 中“部分完成一律拒绝”的限制,不改变 completed 原有路径。
|
||||
|
||||
- completed_partial 的 missing 必须是非空合法列表且仅包含 salesText、shopName、reviewCount,才进入辅助信息兼容检查;title、规格/价格缺失、遍历截断、SKU数量上限及未知原因仍拒绝。
|
||||
- `shopeeproduct/partial_collection.go` 核对本次 task 的维度和值、可售SKU的完整标记/规格引用/非负价格、本次颜色价格,以及当前档案可选值由本次有效SKU覆盖,不能借旧档案合并残留的规格或价格通过。真实单维度允许,不虚构另一维度;本次明确报告缺少规格仍拒绝。
|
||||
- 一键关联、替换预览及确认提交共用验证。失败沿用409 LATEST_COLLECTION_UNAVAILABLE及现有提示,不回退更早采集、不换手机。状态 completed_partial 和 missing 原样保留;辅助字段缺失仍可在任务详情查看。
|
||||
- 无接口字段、数据库迁移、Android/Web或权限变更;普通人工关联与采购/AI资格不变。不将“允许关联”视为“已匹配或已采购”。
|
||||
|
||||
## 采购单标题面板与地址就绪等待(#260、#261)
|
||||
|
||||
实现绑定 #260 `3c1ffa4`(执行器回归测试 `7de4318`)、#261 `5ea07f1`,Android 0.9.74 / versionCode87,基于0.9.73。安装及真实采购验收以工单证据为准;不代表 main 的 Android 已整合这些分支。
|
||||
|
||||
- 采购仅在安全规格入口点击返回SUCCESS后,记录本次页面包名/Activity作为内存上下文;开始新执行或页面身份改变时清除。`PddScreenParser` 的新增 purchaseEntryContext 默认关闭,仅该采购执行器传入;普通解析和采集原判定保留。
|
||||
- 当前仍为同一PDD Activity、商品ID非空、没有页面风险、没有选中摘要,且唯一有标题规格滚动区、一个维度有值,同时具有关闭、数量、支付展示区域与下单操作证据时,单标题可识别为NORMAL_SCROLLABLE。只是允许进入既有精确规格探测/选择,不执行下单或付款,不全局认可UNKNOWN,不增加入口重复点击或滑动。
|
||||
- 地址入口点击原有1000ms保留;地址列表等待增加唯一修改目标要求。点击修改和保存由500ms改为1000ms,其他通用动作间隔不变,无新配置项。
|
||||
- 修改后不再只见“详细地址”文字就立即写入。最多50次、每次间隔200ms读取(不含读取耗时),检查PDD页面风险;唯一可见启用有内容的详细地址EditText,连续两次路径、边界、内容及Activity相同才继续原输入流程。始终零个/多个或不稳定时失败,不取第一个候选。
|
||||
- 输入内容生成、输入后回读、保存后退出及返回规格面板、最终复核和单次下单边界不变。新增本机脱敏诊断 addressEditorCandidates / addressEditorPolls 两个整数,与task/attempt/device/ruleHash关联;不记录地址内容、原始树或整屏截图。
|
||||
- 仅Android;无Server/Web、共享API字段、数据库迁移或权限变化。真实采购、地址写入及订单创建不作为自动安装验证,不执行付款。
|
||||
|
||||
### v2 商品入口补点与地址返回等待(#260、#261)
|
||||
|
||||
2026-09-10 实现绑定 #260 `713111d`、#261 `d0a82f3`,Android 0.9.75 / versionCode88;真实采购效果以工单验收为准,非 main 合并声明。
|
||||
|
||||
- 入口恢复不再把全页面支付方式展示文案单独视为支付页。当前包名/Activity、商品标题和安全入口必须与点击前一致,入口唯一且可用;已打开面板、关闭控件、数量、选中摘要、订单操作、风险/评价页面仍拒绝恢复。支付 Activity 或明确支付、订单、地址文本继续阻止补点。仅允许既有一次重新定位后的规格入口中心手势,随后仍验证面板,不循环、不扩展到地址保存或下单。
|
||||
- 保存地址后的确认读取、必要时一次返回后的确认读取,增加最多50次、间隔200ms的有界就绪检查,页面捕获耗时另计。连续两次同一Activity满足原地址回读与确认面板条件后继续;完整信息不要求滚动区域。已明确到地址列表时保留原返回路径,不等待列表出现下单按钮;返回后增加1000ms缓冲。
|
||||
- 就绪检查不修改原地址回读和结构下单入口规则;超时后只对既有识别的面板、唯一可用滚动区域执行原有有限滚动。零/多个区域仍失败,不凭地址入口或未知底部节点授权下单。最终精确规格、数量、价格、地址与单次下单边界不变。
|
||||
- 私有采购诊断新增布尔 savedAddressMatched、savedPanelMatched;整数 savedLegacySubmitCandidates、savedAddressCards、savedAddressTargets、savedScrollCandidates、savedConfirmationPolls。只记录标量并绑定task/attempt/device/ruleHash,不保存地址、原始树或整屏图。
|
||||
- CG109 v1现场读取未达到空闲状态,不能确认原失败具体缺哪项证据;v2覆盖迟到确认节点及可观测性,不将合成测试通过冒充真机修复验收。无Server/Web/API/迁移/权限变化,不需要服务器重启。
|
||||
|
||||
## 探测到正式采购的单次面板交接(#262)
|
||||
|
||||
2026-09-10 实现绑定 `2ea32fd`,Android0.9.76/code89;基于#260/#261,未声明合并main或真实采购验收通过。
|
||||
|
||||
- AgentForegroundService通过PurchaseProbeHandoff只在成功spec_probe且结束后新快照仍识别PDD规格面板时保留一次性内存交接。仅taskId/goodsId/ruleHash/deviceId、Activity、前台revision和单调时钟时间,不保留控件节点、地址或选中状态,不落盘;有效期180秒。
|
||||
- 正式阶段消费时检查同任务/商品/规则/设备、前台revision及时间,消费后即清除。失配/超时/无任务或采集任务调度/结果异常/进程重启均不能沿用;普通手动再试不会继承已消费交接。现有服务端租约、互斥和匹配等待策略不变。
|
||||
- 有交接时,执行器恢复限定的purchaseEntryContext,最多10次、间隔200ms新读取(捕获耗时另计);连续两次相同PDD Activity且具备既有商品/面板证据才跳过openProduct。空树可有限等待,其他应用/Activity改变/页面风险立即拒绝复用。没有交接时保留原一次判断路径;不把UNKNOWN当成功,不跳过正式精确规格/数量/价格/地址或下单验证。
|
||||
- 私有诊断新增handoffPresent、productReused布尔值,reusePolls整数及reuseReason固定枚举legacy_evidence/page_problem/page_changed/handoff_ready/evidence_timeout。仍绑定task/attempt/device/ruleHash,无敏感文本。
|
||||
- 无Server/Web、共享API或数据库变化,不需重启服务器。只有合成测试验证;原CG107/108日志证实两阶段,但未记录旧版重开判断,不把上下文丢失假设宣称为唯一现场根因。
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Troubleshooting
|
||||
wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0
|
||||
synchronized_at: 2026-09-04T11:30:16Z
|
||||
wiki_revision: 18744477bfd17f396e8c76ec7a2fcc6720acdf6f
|
||||
synchronized_at: 2026-09-11T09:10:00Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -85,3 +85,22 @@ sqlite3 -readonly agent-diagnostics.db "SELECT task_id,reason,color_row_count,co
|
||||
```
|
||||
|
||||
读取时记录设备、Agent 版本、任务号和规则快照;工单只回写查询得到的脱敏聚合数值。读取完成后删除本地导出副本。正式 APK 若不允许 `run-as`,停止排查并确认安全的只读诊断出口,不通过放宽应用安全配置或上传完整数据库绕过。
|
||||
|
||||
## Android 采购规格入口本地诊断(#249)
|
||||
|
||||
实现绑定 `99faf5a`,Agent `0.9.67`(versionCode 80)。单元测试与 Debug 构建已通过,新增版本真机留存/读取尚待验证;本节不属于上文既有实测结论。
|
||||
|
||||
采购执行器启动后,在应用私有 `files/purchase_diagnostics/<taskId>_<attemptId>.jsonl` 保存白名单结构化入口诊断。元数据包含 taskId、attemptId、deviceId、agentVersion、phase、ruleHash(当前任务规则快照 SHA-256)、timestamp 和 elapsedMs。现有任务接口只有 attemptId,没有 attemptNumber;用 attemptId 对照服务端历史,不推算重试序号。
|
||||
|
||||
证据仅包含入口来源枚举、候选数、点击结果/原因枚举、等待轮数、面板结构布尔值/计数,以及受控恢复是否执行及固定拒绝原因。没有商品标题、规格原文、地址、手机号、Cookie、Token、链接、原始控件树或截图。entryWaitMillis 是该等待循环累计的计划等待量;elapsedMs 才是自本次采购执行器诊断起点计量的实际耗时,timestamp 是后台写入时间。
|
||||
|
||||
复用现有单线程诊断队列异步写入,写入/排队失败不改变采购结果;不新增点击、滑动、等待或采购门禁。最多保留最近 5 个 attempt 文件、每文件最后 128 条事件,超过 7 天的文件在服务启动或下次写入时清理。异常断电或存储故障仍可能丢失诊断,不能将日志缺失当成动作未执行;不上传 Admin,不增加服务端接口。
|
||||
|
||||
仅对允许 run-as 的 Debug APK,在设备已连接且选定准确序列号后只读提取:
|
||||
|
||||
```powershell
|
||||
adb -s <device-serial> shell run-as cn.ilapage.goauto.agent ls files/purchase_diagnostics
|
||||
adb -s <device-serial> exec-out run-as cn.ilapage.goauto.agent cat files/purchase_diagnostics/<taskId>_<attemptId>.jsonl
|
||||
```
|
||||
|
||||
先列出文件,再读取本次任务/attempt 的准确文件名;不得扩大为导出所有应用数据或完整数据库。新版安装前的失败不会补生成日志;需要用户授权后重试才能产生新证据。Release 若禁止 run-as 则停止,不更改权限绕过;首版没有导出界面。该功能用于确定拒绝分支,并不代表已经修复对应采购故障。
|
||||
|
||||
@@ -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: a96638c0b97eac622d53b04420da7ca35a1c010d
|
||||
synchronized_at: 2026-09-10T07:50:35Z
|
||||
wiki_revision: ba259178cf6514d5b48ea425005dfd366ba803a3
|
||||
synchronized_at: 2026-09-12T03:09:46Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# MVP 共享 API 契约
|
||||
@@ -100,6 +100,33 @@ POST /api/admin/v1/syb-shops/discover
|
||||
|
||||
`discover` 提交日期范围,从 SYB 真实货运单列表发现店铺,返回 `shopName` 和 `status`(`new` / `exists`);发现只读,不自动新增。店铺比较固定执行首尾去空白、全角/半角统一和忽略大小写。同步先完成原始列表完整性校验,再按一次性启用店铺快照过滤;明细响应中的店铺名为空或不在快照内时不得写入。
|
||||
|
||||
## 管理端:SYB 商品过滤(#269)
|
||||
|
||||
```http
|
||||
GET /api/admin/v1/syb-product-filters
|
||||
POST /api/admin/v1/syb-product-filters
|
||||
PATCH /api/admin/v1/syb-product-filters/{id}/enabled
|
||||
DELETE /api/admin/v1/syb-product-filters/{id}
|
||||
```
|
||||
|
||||
同步时按 `variationSku` 决定明细是否入库。规则分两类,由 `kind` 区分:`char` 是结构判据
|
||||
(`-`、`#`),`keyword` 是关键词清单。列表支持 `kind`、`keyword` 和分页,返回 `keyword`、
|
||||
`normalizedKeyword`、`enabled`、`lastHitCount`、`lastHitAt`、`note`,`char` 另有
|
||||
`lastChangedBy`、`lastChangedAt`。
|
||||
|
||||
`[必须]` 服务端必须拒绝对 `kind=char` 行的 `POST` 新增与 `DELETE` 删除,分别返回
|
||||
`FORBIDDEN`(`结构过滤不可新增` / `结构过滤不可删除`),不得只依赖前端隐藏按钮。
|
||||
`kind=char` 的 `PATCH enabled` 允许,并记录操作人与时间。
|
||||
|
||||
`[必须]` `lastHitCount` 是每条规则各自的命中数,由同步结束时回写;未命中的规则写 0,
|
||||
从未同步过为 `null`。停用 `kind=char` 的二次确认提示条数必须取该行实时的 `lastHitCount`,
|
||||
不得写死——写死的数字在数据变化后会给出错误警告。
|
||||
|
||||
关键词归一化与店铺一致:去首尾空白、全角/半角统一、忽略大小写;**不做简繁转换**,SYB 数据
|
||||
为繁体。唯一性按 (`kind`, `normalizedKeyword`) 判定,重复添加返回
|
||||
`SYB_PRODUCT_FILTER_DUPLICATE`。写操作由 go-admin/Casbin 仅授权管理员,其他角色只读。
|
||||
删除为软删除,不清理已经入库的商品明细。
|
||||
|
||||
## 管理端:采集规则
|
||||
|
||||
```http
|
||||
@@ -556,7 +583,15 @@ Android #42/#36 使用本地 SQLite 保存恢复与重传所需的任务、attem
|
||||
创建订单前先在本地事务保存 `order_submit_started`、不可逆时间、稳定的 `orderSubmitRequestId` 和脱敏最终确认快照,再调用服务端同名接口;只有两侧标记完成并重新校验商品、规格、数量、单价、地址后缀、PDD 包/Activity 与唯一创建订单按钮后,才点击一次。重启时重放同一标记请求并只读核单;无法取得唯一未付款订单号和 PDD 下单时间时提交 `order_result_unknown`。支付文字仅用于识别未付款/离开支付页,永不点击。服务端数据库仍是最终事实来源;双方均不保存原始控件树、截图、PDD 凭据或完整收货地址。
|
||||
|
||||
创建订单后若出现 Android 多微信应用选择器,只读核单器必须同时确认前台包为 `android` / `com.android.intentresolver`、Activity 为白名单 `ChooserActivity` / `ResolverActivity`、页面出现已知系统选择器标题且至少一个候选以“微信”开头,才允许执行一次系统返回;不得点击任何微信候选。若前台已经是精确微信包 `com.tencent.mm`,只读核单器不得点击、输入、登录、支付或强制停止微信,只允许执行一次无参数 PDD 启动 Intent 并等待既有 PDD 任务栈回到前台;`startActivity()` 成功只表示恢复请求已发起。请求后在固定最多 15 次、每次 200ms 的宽限期内允许微信或空窗口短暂残留,不执行点击、返回、输入或滑动;观察到 PDD 后结束宽限,宽限超时、已经观察到 PDD 后再次进入微信或出现稳定未知应用时返回未知结果。随后若前台为 PDD `com.xunmeng.pinduoduo.app_pay.core.PayActivity` 或当前页面出现支付动作文字,最多再返回一次。返回后只读解析唯一订单号和下单时间;选择器或支付页重复出现、白名单不成立、恢复动作重复、无法到达订单详情、结果不唯一或超时均返回未知结果,禁止再次点击创建订单、取消订单或支付。
|
||||
Agent 主动提交 `order_result_unknown` 时保持订单号和下单时间为空,但允许携带创建订单前已经严格验证的 `actualUnitPriceCent`。Agent 同时提交脱敏稳定失败阶段,服务端只接受白名单并按错误码写入固定提示,不信任或保存页面原文;阶段覆盖空窗口超时、选择器返回失败、微信恢复失败/超时、未知应用、支付页返回失败/重复、订单上下文缺失、订单号缺失/歧义、下单时间缺失/无效和待付款证据缺失。任务与 attempt 保存同一错误阶段,Admin 和 Agent 历史读取数据库最终事实;旧 Agent 未提交阶段时归一为 `PURCHASE_ORDER_RESULT_UNKNOWN`。
|
||||
Agent 主动提交 `order_result_unknown` 时保持订单号和下单时间为空,但允许携带创建订单前已经严格验证的 `actualUnitPriceCent`。Agent 同时提交脱敏稳定失败阶段,服务端只接受白名单错误码,并按错误码写入固定提示作为面向人的主文案;不采信 Agent 文本作为权威描述,也不保存原始控件树或截图。阶段覆盖空窗口超时、选择器返回失败、微信恢复失败/超时、未知应用、支付页返回失败/重复、订单上下文缺失、订单号缺失/歧义、下单时间缺失/无效和待付款证据缺失。任务与 attempt 保存同一错误阶段,Admin 和 Agent 历史读取数据库最终事实;旧 Agent 未提交阶段时归一为 `PURCHASE_ORDER_RESULT_UNKNOWN`。
|
||||
|
||||
#271 修订:`errorMessage` 的语义由「仅固定提示」改为「**固定提示 + Agent 诊断证据**」。固定提示仍由错误码唯一决定并排在最前,Agent 提交的脱敏诊断追加在其后。
|
||||
|
||||
`[必须]` 追加前压平为单行:去除控制字符与零宽不换行空格、连续空白合并为一个空格;按 `error_message` 列宽 1000 截断,且**固定提示优先完整保留**,被截断的证据以 `…` 结尾。证据与固定提示相同时不重复拼接。Agent 文本为空或错误码不在白名单时行为不变,仍返回 `INVALID_REQUEST`。
|
||||
|
||||
改动原因:原做法丢弃了 Agent 已经算出并上报的诊断。`#210` 为 `PURCHASE_ORDER_PAYMENT_REPEATED` 计算 `paymentBackAttempts` 与 `consecutivePaymentSamplesAfterBack`,但 2026-09-07 至 09-11 线上 61 次该错误的 `error_message` 无一携带这两个值,导致「返回动作未生效」「页面渲染慢于容忍窗口」「页面已是订单页但关键词未命中」三种情形无法区分。相邻的 `failed` 结果分支本就原样保存 Agent 文本,因此本修订不引入新的信任假设。
|
||||
|
||||
`[必须]` `failed` 结果分支同样把错误码与消息写入 attempt,不再只写任务。此前该路径产生的 attempt 行 `error_code` 为 NULL,按 attempt 统计失败时这些记录是隐形的。
|
||||
|
||||
#241 追加:全局订单号唯一性校验在人工处理结果未知、取消及 lifecycle 保存路径返回 `PURCHASE_ORDER_NUMBER_ALREADY_USED`(HTTP 409、`retryable=false`),提示“订单号已属于任务 CG-任务ID”;批量回填继续使用原有 `PURCHASE_BACKFILL_ORDER_ALREADY_USED`。不可逆边界后的 `order_created` 结果回传为例外:发现该号已属于其他任务时成功受理结果,将 `order_submit_started` 降级为 `order_result_unknown`,不回滚或自动重派。冲突订单号不写入 `pdd_order_no`,而以“读到订单号 X,但该号已属于任务 CG-yy”保存到任务和 attempt 的 `error_message`,两者 `error_code` 均为 `PURCHASE_ORDER_NUMBER_ALREADY_USED`;保留下单时间、不可逆时间及实际单价。attempt 以 failed 结束并保留原始 `order_created` 结果类型、请求 ID 和摘要,重复提交按原幂等协议返回;任务释放租约和运行槽,进入既有人工处理结果未知通道,权限不变。
|
||||
|
||||
@@ -1116,3 +1151,21 @@ requestId 沿用 UUID 约定,不增加批次表或全局幂等缓存。既有
|
||||
- 不同商品条件更新旧关联,返回更新后的 product.specContextVersion;同商品不更新关联及 last_update_request_id,但仍返回用于后续匹配的当前版本。请求不是持久化重放协议;客户端超时只回读、不自动重发。
|
||||
- 预览与提交都校验当前允许阶段;采集中拒绝。复用现有人工更换关联的映射语义,后续 auto-match 自动保存及预算沿用 #254。部分匹配/AI故障不回滚已提交关联。
|
||||
- 不提供 replacement 时 #253 行为不变;`/api/client/v1` 携带 latestCollection(包括 replacement)仍403,不扩大客户端密钥授权。
|
||||
|
||||
## 辅助信息缺失的采集关联(#259)
|
||||
|
||||
实现绑定 `e467706`,仅 Server;部署/验收见 #259。替代 #253/#258 中“部分完成一律拒绝”的限制,不改变 completed 原有路径。
|
||||
|
||||
- completed_partial 的 missing 必须是非空合法列表且仅包含 salesText、shopName、reviewCount,才进入辅助信息兼容检查;title、规格/价格缺失、遍历截断、SKU数量上限及未知原因仍拒绝。
|
||||
- `shopeeproduct/partial_collection.go` 核对本次 task 的维度和值、可售SKU的完整标记/规格引用/非负价格、本次颜色价格,以及当前档案可选值由本次有效SKU覆盖,不能借旧档案合并残留的规格或价格通过。真实单维度允许,不虚构另一维度;本次明确报告缺少规格仍拒绝。
|
||||
- 一键关联、替换预览及确认提交共用验证。失败沿用409 LATEST_COLLECTION_UNAVAILABLE及现有提示,不回退更早采集、不换手机。状态 completed_partial 和 missing 原样保留;辅助字段缺失仍可在任务详情查看。
|
||||
- 无接口字段、数据库迁移、Android/Web或权限变更;普通人工关联与采购/AI资格不变。不将“允许关联”视为“已匹配或已采购”。
|
||||
|
||||
## 采购规格现场一次重新匹配(#263)
|
||||
|
||||
2026-09-11 实现绑定 `c6ee6c0`,Server采购与Android Agent;未发布、未安装、未完成真实采购验收。
|
||||
|
||||
- 正式采购阶段仅在服务端下发 `specRematchAllowed=true`、Android 返回稳定的 `PURCHASE_SPEC_TARGET_NOT_VISIBLE` 且尚未进入地址/下单阶段时触发;颜色/尺码选中复核失败、售罄、点击失败、歧义、价格、地址和订单错误不触发。
|
||||
- Agent 在当前PDD规格面板重新读取候选并提交同一采购任务的 `spec_rematch_completed` 结果;Server复用现有确定性优先/必要时AI匹配,结果必须属于当次候选。匹配成功后同task重新领取正式attempt,Agent沿用当前页面继续正式规格、数量、价格、地址和下单校验,不新建任务、不重新打开浏览器链接;页面证据失效时仍按原安全路径处理。
|
||||
- 每个采购任务最多一次;结果attempt和服务端状态原子记录,旧决策指针清除后只允许最新attempt固化新决策。重复请求、无资格、已有不可逆证据或旧attempt覆盖均拒绝;不新增数据库列,依赖现有attempt result_type审计。
|
||||
- 新共享字段 `specRematchAllowed` 和结果类型仅供现有Agent采购接口;旧Agent缺省false,旧 `spec_probe_completed` 重复探测拒绝规则不变。未改变设备租约、并发、支付禁止和任务重试语义。
|
||||
|
||||
@@ -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: 214485db630141aeb03d4b49b22a65e572fb592a
|
||||
synchronized_at: 2026-09-08T02:24:10Z
|
||||
wiki_revision: f1792b7fe35925480c25b890e85fa5e551744812
|
||||
synchronized_at: 2026-09-11T09:11:21Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 12 顺云宝(SYB)ERP 接口契约
|
||||
@@ -495,6 +495,27 @@ POST /am/stock/detail/updateDetailCode?t=0&id={stockID}&detailId={detailID}&code
|
||||
`shopName` 去除首尾空白后与启用店铺精确匹配 → 只为接受的货运单请求明细和入库。
|
||||
店铺过滤只决定明细获取和入库,不能减少原始列表完整性校验范围;已保存不代表整日完整。
|
||||
|
||||
`[必须]` **店铺准入之后再按 `variationSku` 过滤商品(#269)。** 顺序固定为:店铺准入 →
|
||||
结构过滤 → 关键词过滤 → 入库。两类规则都存在 `syb_product_filter`,由 `kind` 区分:
|
||||
|
||||
- `kind=char`:`-` 和 `#` 两条,命中任意一条即跳过(**OR,不是 AND**)。这两个字符是
|
||||
「档口-供应商#货号」编码格式的判据。2026-09-11 核验线上 13010 行明细:含 `#` 8498 行、
|
||||
含 `-` 6392 行、两者都含 6386 行、任一 8504 行(65%)。用 AND 只命中 6386 行,会漏掉
|
||||
`DD#004`、`300斤牛奶絲圓領#A057` 这类只含 `#` 的 2118 行。
|
||||
- `kind=keyword`:关键词清单,匹配 `variationSku`,**不匹配 `productTitle`**。同一次核验
|
||||
中六条初始关键词在 `productTitle` 上命中为 0,且全部已被结构过滤覆盖,净增为 0;保留它
|
||||
是为档口改用不含 `#` 的编码时兜底。
|
||||
|
||||
`[必须]` 空 `variationSku` **不过滤**。核验时 2724 行(21%)为空,分布在正常启用店铺且持续
|
||||
产生,属于尚未填写供应商编码的正常商品,不是档口货。
|
||||
|
||||
`[必须]` 两类命中数分别统计并分别回写到每条规则自己的 `last_hit_count`,不得合并成单一
|
||||
「过滤总数」,也不得按 `kind` 写同一个汇总值。前者会使结构判据失效不可观测(关键词净增为 0
|
||||
时无法区分「没漏网」和「规则没生效」);后者会让停用确认对 `#` 和 `-` 提示同一个数字。
|
||||
|
||||
`[必须]` `kind=char` 不可新增、不可删除,只能停用,且停用需要二次确认并记录操作人与时间。
|
||||
判据本身需要变更(例如档口改用 `/` 或 `@`)属于范围变化,应另建工单评估。
|
||||
|
||||
`[必须]` 同步开始时只读取一次启用店铺,整次运行使用同一个快照。列表允许但明细
|
||||
响应中的 `shopName` 变为空或非允许店铺时再次拦截。没有启用店铺时在会话/OCR/
|
||||
验证码等任何顺运宝请求之前停止,并且不推进覆盖游标。该过滤只影响后续入库,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
param(
|
||||
[string]$BaseUrl = $env:GOAUTO_ADMIN_BASE_URL,
|
||||
[int]$BatchSize = 100,
|
||||
[int]$PageSize = 500,
|
||||
[int]$SleepSeconds = 1,
|
||||
[switch]$Force,
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
if ([string]::IsNullOrWhiteSpace($BaseUrl) -or [string]::IsNullOrWhiteSpace($env:GOAUTO_ADMIN_TOKEN)) {
|
||||
throw '请设置 GOAUTO_ADMIN_BASE_URL 与 GOAUTO_ADMIN_TOKEN 环境变量'
|
||||
}
|
||||
if ($BatchSize -lt 1 -or $BatchSize -gt 500) { throw 'BatchSize 必须在 1 到 500 之间' }
|
||||
$headers = @{ Authorization = "Bearer $($env:GOAUTO_ADMIN_TOKEN)" }
|
||||
$ids = [System.Collections.Generic.List[UInt64]]::new()
|
||||
for ($page = 1; ; $page++) {
|
||||
$uri = "$($BaseUrl.TrimEnd('/'))/api/admin/v1/syb-products?parseStatus=failed&page=$page&pageSize=$PageSize"
|
||||
$response = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get
|
||||
$items = if ($response.data.items) { $response.data.items } elseif ($response.data.list) { $response.data.list } else { @() }
|
||||
foreach ($item in $items) { if ($item.id) { [void]$ids.Add([UInt64]$item.id) } }
|
||||
if ($items.Count -lt $PageSize) { break }
|
||||
}
|
||||
Write-Host "发现失败记录 $($ids.Count) 条"
|
||||
if ($DryRun -or $ids.Count -eq 0) { exit 0 }
|
||||
$ok = 0; $failed = 0
|
||||
for ($i = 0; $i -lt $ids.Count; $i += $BatchSize) {
|
||||
$end = [Math]::Min($i + $BatchSize, $ids.Count)
|
||||
$body = @{ ids = @($ids[$i..($end - 1)]); force = [bool]$Force } | ConvertTo-Json -Compress
|
||||
try {
|
||||
$result = Invoke-RestMethod -Uri "$($BaseUrl.TrimEnd('/'))/api/admin/v1/syb-products/reparse-batch" -Headers ($headers + @{ 'Content-Type' = 'application/json' }) -Method Post -Body $body
|
||||
$rows = @($result.data.results)
|
||||
$ok += @($rows | Where-Object { $_.newStatus -eq 'success' }).Count
|
||||
$failed += @($rows | Where-Object { $_.newStatus -ne 'success' }).Count
|
||||
Write-Host "已处理 $end/$($ids.Count),成功 $ok,仍失败 $failed"
|
||||
} catch { Write-Warning "批次 $($i + 1)-$end 请求失败:$($_.Exception.Message)" }
|
||||
if ($SleepSeconds -gt 0 -and $end -lt $ids.Count) { Start-Sleep -Seconds $SleepSeconds }
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
goautoshopeeproduct "go-admin/app/goauto/shopeeproduct"
|
||||
goautosybimport "go-admin/app/goauto/sybimport"
|
||||
goautosybinnercode "go-admin/app/goauto/sybinnercode"
|
||||
goautosybproductfilter "go-admin/app/goauto/sybproductfilter"
|
||||
goautosybshop "go-admin/app/goauto/sybshop"
|
||||
goautotask "go-admin/app/goauto/task"
|
||||
common "go-admin/common/middleware"
|
||||
@@ -67,4 +68,5 @@ func InitRouter() {
|
||||
goautosybimport.InitRouter(r, authMiddleware)
|
||||
goautosybinnercode.InitRouter(r, authMiddleware)
|
||||
goautosybshop.InitRouter(r, authMiddleware)
|
||||
goautosybproductfilter.InitRouter(r, authMiddleware)
|
||||
}
|
||||
|
||||
@@ -3,18 +3,19 @@ package access
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
ModuleDevices = "devices"
|
||||
ModulePDDProducts = "pdd_products"
|
||||
ModuleShopeeProducts = "shopee_products"
|
||||
ModuleSYBProducts = "syb_products"
|
||||
ModuleSYBShops = "syb_shops"
|
||||
ModuleSYBSyncRuns = "syb_sync_runs"
|
||||
ModuleSYBInnerCodes = "syb_inner_codes"
|
||||
ModuleCollectionRules = "collection_rules"
|
||||
ModulePurchaseRules = "purchase_rules"
|
||||
ModuleCollectionTasks = "collection_tasks"
|
||||
ModulePurchaseTasks = "purchase_tasks"
|
||||
ModuleAIMatching = "ai_matching"
|
||||
ModuleDevices = "devices"
|
||||
ModulePDDProducts = "pdd_products"
|
||||
ModuleShopeeProducts = "shopee_products"
|
||||
ModuleSYBProducts = "syb_products"
|
||||
ModuleSYBShops = "syb_shops"
|
||||
ModuleSYBProductFilters = "syb_product_filters"
|
||||
ModuleSYBSyncRuns = "syb_sync_runs"
|
||||
ModuleSYBInnerCodes = "syb_inner_codes"
|
||||
ModuleCollectionRules = "collection_rules"
|
||||
ModulePurchaseRules = "purchase_rules"
|
||||
ModuleCollectionTasks = "collection_tasks"
|
||||
ModulePurchaseTasks = "purchase_tasks"
|
||||
ModuleAIMatching = "ai_matching"
|
||||
)
|
||||
|
||||
// ModuleDefinition is the single source of truth shared by menu migration,
|
||||
@@ -77,6 +78,7 @@ var goAutoMenuGroupMetadata = []MenuGroupDefinition{
|
||||
Sort: 51,
|
||||
ModuleKeys: []string{
|
||||
ModuleSYBShops,
|
||||
ModuleSYBProductFilters,
|
||||
ModuleCollectionRules,
|
||||
ModulePurchaseRules,
|
||||
ModuleDevices,
|
||||
@@ -91,13 +93,14 @@ var goAutoModuleMetadata = []ModuleDefinition{
|
||||
{Key: ModuleShopeeProducts, Title: "虾皮商品", Path: "/shopee-products", RouteName: "GoAutoShopeeProducts", Component: "/goauto/shopee-products/index", Icon: "goods", Sort: 52, PurchaserDefault: true},
|
||||
{Key: ModuleSYBProducts, Title: "SYB 商品", Path: "/syb-products", RouteName: "GoAutoSybProducts", Component: "/goauto/syb-products/index", Icon: "tickets", Sort: 53, PurchaserDefault: true},
|
||||
{Key: ModuleSYBShops, Title: "SYB 店铺", Path: "/syb-shops", RouteName: "GoAutoSybShops", Component: "/goauto/syb-shops/index", Icon: "shopping", Sort: 54, PurchaserDefault: true},
|
||||
{Key: ModuleSYBSyncRuns, Title: "SYB 同步记录", Path: "/syb-sync-runs", RouteName: "GoAutoSybSyncRuns", Component: "/goauto/syb-sync-runs/index", Icon: "time", Sort: 55, PurchaserDefault: true},
|
||||
{Key: ModuleSYBInnerCodes, Title: "档口入库码", Path: "/syb-inner-codes", RouteName: "GoAutoSybInnerCodes", Component: "/goauto/syb-inner-codes/index", Icon: "list", Sort: 56, PurchaserDefault: true},
|
||||
{Key: ModuleCollectionRules, Title: "采集规则", Path: "/collection-rules", RouteName: "GoAutoCollectionRules", Component: "/goauto/collection-rules/index", Icon: "code", Sort: 57, PurchaserDefault: true},
|
||||
{Key: ModulePurchaseRules, Title: "采购规则", Path: "/purchase-rules", RouteName: "GoAutoPurchaseRules", Component: "/goauto/purchase-rules/index", Icon: "setting", Sort: 58, PurchaserHardHidden: true},
|
||||
{Key: ModuleCollectionTasks, Title: "采集任务", Path: "/collection-tasks", RouteName: "GoAutoCollectionTasks", Component: "/goauto/collection-tasks/index", Icon: "list", Sort: 59, PurchaserDefault: true},
|
||||
{Key: ModulePurchaseTasks, Title: "采购管理", Path: "/purchase-tasks", RouteName: "GoAutoPurchaseTasks", Component: "/goauto/purchase-tasks/index", Icon: "shopping", Sort: 60, PurchaserDefault: true},
|
||||
{Key: ModuleAIMatching, Title: "AI 规格匹配", Path: "/ai-matching-settings", RouteName: "GoAutoAiMatchingSettings", Component: "/goauto/ai-matching-settings/index", Icon: "setting", Sort: 61, PurchaserHardHidden: true},
|
||||
{Key: ModuleSYBProductFilters, Title: "SYB 商品过滤", Path: "/syb-product-filters", RouteName: "GoAutoSybProductFilters", Component: "/goauto/syb-product-filters/index", Icon: "filter", Sort: 55, PurchaserDefault: true},
|
||||
{Key: ModuleSYBSyncRuns, Title: "SYB 同步记录", Path: "/syb-sync-runs", RouteName: "GoAutoSybSyncRuns", Component: "/goauto/syb-sync-runs/index", Icon: "time", Sort: 56, PurchaserDefault: true},
|
||||
{Key: ModuleSYBInnerCodes, Title: "档口入库码", Path: "/syb-inner-codes", RouteName: "GoAutoSybInnerCodes", Component: "/goauto/syb-inner-codes/index", Icon: "list", Sort: 57, PurchaserDefault: true},
|
||||
{Key: ModuleCollectionRules, Title: "采集规则", Path: "/collection-rules", RouteName: "GoAutoCollectionRules", Component: "/goauto/collection-rules/index", Icon: "code", Sort: 58, PurchaserDefault: true},
|
||||
{Key: ModulePurchaseRules, Title: "采购规则", Path: "/purchase-rules", RouteName: "GoAutoPurchaseRules", Component: "/goauto/purchase-rules/index", Icon: "setting", Sort: 59, PurchaserHardHidden: true},
|
||||
{Key: ModuleCollectionTasks, Title: "采集任务", Path: "/collection-tasks", RouteName: "GoAutoCollectionTasks", Component: "/goauto/collection-tasks/index", Icon: "list", Sort: 60, PurchaserDefault: true},
|
||||
{Key: ModulePurchaseTasks, Title: "采购管理", Path: "/purchase-tasks", RouteName: "GoAutoPurchaseTasks", Component: "/goauto/purchase-tasks/index", Icon: "shopping", Sort: 61, PurchaserDefault: true},
|
||||
{Key: ModuleAIMatching, Title: "AI 规格匹配", Path: "/ai-matching-settings", RouteName: "GoAutoAiMatchingSettings", Component: "/goauto/ai-matching-settings/index", Icon: "setting", Sort: 62, PurchaserHardHidden: true},
|
||||
}
|
||||
|
||||
// GoAutoModules returns independent copies so callers cannot mutate the
|
||||
@@ -144,6 +147,8 @@ func moduleKeyForAPI(path string) string {
|
||||
return ModuleSYBProducts
|
||||
case strings.HasPrefix(path, "/api/admin/v1/syb-shops"):
|
||||
return ModuleSYBShops
|
||||
case strings.HasPrefix(path, "/api/admin/v1/syb-product-filters"):
|
||||
return ModuleSYBProductFilters
|
||||
case strings.HasPrefix(path, "/api/admin/v1/syb-inner-codes"):
|
||||
return ModuleSYBInnerCodes
|
||||
case strings.HasPrefix(path, "/api/admin/v1/collection-rules"):
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
func TestGoAutoModulesOwnEveryAdminAPIExactlyOnce(t *testing.T) {
|
||||
modules := GoAutoModules()
|
||||
if len(modules) != 12 {
|
||||
t.Fatalf("got %d modules, want 12", len(modules))
|
||||
if len(modules) != 13 {
|
||||
t.Fatalf("got %d modules, want 13", len(modules))
|
||||
}
|
||||
|
||||
owners := make(map[string]int)
|
||||
@@ -56,8 +56,8 @@ func TestGoAutoMenuSortsFitMySQLSignedTinyInt(t *testing.T) {
|
||||
}
|
||||
previous = module.Sort
|
||||
}
|
||||
if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 61 {
|
||||
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..61", modules[0].Sort, modules[len(modules)-1].Sort)
|
||||
if modules[0].Sort != 50 || modules[len(modules)-1].Sort != 62 {
|
||||
t.Fatalf("GoAuto menu sort range = %d..%d, want 50..62", modules[0].Sort, modules[len(modules)-1].Sort)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestGoAutoMenuGroupsCoverModulesExactlyOnce(t *testing.T) {
|
||||
|
||||
wantOrder := [][]string{
|
||||
{ModuleSYBProducts, ModuleSYBSyncRuns, ModuleSYBInnerCodes, ModuleShopeeProducts, ModulePDDProducts, ModuleCollectionTasks, ModulePurchaseTasks},
|
||||
{ModuleSYBShops, ModuleCollectionRules, ModulePurchaseRules, ModuleDevices, ModuleAIMatching},
|
||||
{ModuleSYBShops, ModuleSYBProductFilters, ModuleCollectionRules, ModulePurchaseRules, ModuleDevices, ModuleAIMatching},
|
||||
}
|
||||
seen := make(map[string]int)
|
||||
for groupIndex, group := range groups {
|
||||
|
||||
@@ -77,6 +77,10 @@ var AdminAPIs = []APIPermission{
|
||||
{"启停 SYB 店铺", "/api/admin/v1/syb-shops/:shopId/enabled", "PATCH", false},
|
||||
{"删除 SYB 店铺", "/api/admin/v1/syb-shops/:shopId", "DELETE", false},
|
||||
{"从 SYB 发现店铺", "/api/admin/v1/syb-shops/discover", "POST", false},
|
||||
{"查看 SYB 商品过滤", "/api/admin/v1/syb-product-filters", "GET", true},
|
||||
{"新增 SYB 商品过滤", "/api/admin/v1/syb-product-filters", "POST", false},
|
||||
{"启停 SYB 商品过滤", "/api/admin/v1/syb-product-filters/:filterId/enabled", "PATCH", false},
|
||||
{"删除 SYB 商品过滤", "/api/admin/v1/syb-product-filters/:filterId", "DELETE", false},
|
||||
|
||||
{"查看采集规则模板", "/api/admin/v1/collection-rules/templates/:templateId", "GET", true},
|
||||
{"查看采集规则", "/api/admin/v1/collection-rules", "GET", true},
|
||||
|
||||
@@ -15,12 +15,14 @@ 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-shops/discover": 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,
|
||||
"POST /api/admin/v1/devices/:deviceId/disable": true,
|
||||
"POST /api/admin/v1/syb-shops/discover": true,
|
||||
"POST /api/admin/v1/syb-product-filters": true,
|
||||
"PATCH /api/admin/v1/syb-product-filters/:filterId/enabled": 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] {
|
||||
|
||||
@@ -15,6 +15,7 @@ var (
|
||||
traditionalToSimplified *gocc.OpenCC
|
||||
traditionalErr error
|
||||
weightPattern = regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)\s*(?:[-~~至到]\s*(\d+(?:\.\d+)?))?\s*(公斤|千克|kg|斤)`)
|
||||
sizeTokenPattern = regexp.MustCompile(`(?i)^(?:xxxs|xxl|xxxl|xxxxl|[2-9]xl|xs|s|m|l|xl|\d+xl)\b`)
|
||||
)
|
||||
|
||||
// Normalize makes a deterministic comparison key. It intentionally removes
|
||||
@@ -80,7 +81,7 @@ func normalizeWeight(raw string) string {
|
||||
// deliberately treated as no match instead of picking the first label.
|
||||
func DeterministicMatch(request MatchRequest) (MatchResult, bool) {
|
||||
color, colorOK := exactCandidate(request.TargetColor, request.Colors)
|
||||
size, sizeOK := exactCandidate(request.TargetSize, request.Sizes)
|
||||
size, sizeOK := exactSizeCandidate(request.TargetSize, request.Sizes)
|
||||
if !colorOK || !sizeOK {
|
||||
return MatchResult{}, false
|
||||
}
|
||||
@@ -88,6 +89,27 @@ func DeterministicMatch(request MatchRequest) (MatchResult, bool) {
|
||||
return result, true
|
||||
}
|
||||
|
||||
func exactSizeCandidate(target string, candidates []string) (string, bool) {
|
||||
key := sizeTokenPattern.FindString(strings.ToLower(Normalize(target)))
|
||||
if key == "" {
|
||||
return exactCandidate(target, candidates)
|
||||
}
|
||||
matches := make([]string, 0, 1)
|
||||
seen := map[string]bool{}
|
||||
for _, candidate := range candidates {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
if candidate == "" || sizeTokenPattern.FindString(strings.ToLower(Normalize(candidate))) != key || seen[candidate] {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = true
|
||||
matches = append(matches, candidate)
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
return matches[0], true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ManualMatch records an already-confirmed mapping without reinterpreting it.
|
||||
// The caller owns the human-confirmation check; this helper only creates the
|
||||
// same bounded task snapshot shape used by automatic decisions.
|
||||
|
||||
@@ -33,7 +33,7 @@ func fixture(t *testing.T) (*gorm.DB, clientkey.Service) {
|
||||
func TestEveryRouteIsExplicitlyScoped(t *testing.T) {
|
||||
db, s := fixture(t)
|
||||
routes := Inventory()
|
||||
if len(s.Modules) != 12 {
|
||||
if len(s.Modules) != 13 {
|
||||
t.Fatal("menu groups lost")
|
||||
}
|
||||
router := gin.New()
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -41,6 +42,7 @@ func MigratedModels() []any {
|
||||
&models.SYBSpecAIParseWorkItem{},
|
||||
&models.SYBSession{},
|
||||
&models.SYBShop{},
|
||||
&models.SYBProductFilter{},
|
||||
&models.SYBSyncRun{},
|
||||
&models.SYBInnerCodeRecord{},
|
||||
&models.SYBInnerCodeItem{},
|
||||
@@ -85,6 +87,15 @@ func Migrate(db *gorm.DB) error {
|
||||
Update("task_type", models.PurchaseTaskTypeSYBOrder).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// #269: seed immutable structure判据 and the initial keyword safety net.
|
||||
seed := []struct{ kind, keyword string }{{"char", "-"}, {"char", "#"}, {"keyword", "CVC短袖220斤"}, {"keyword", "180克純棉圓領短袖"}, {"keyword", "拼色純棉短袖"}, {"keyword", "300斤牛奶絲圓領"}, {"keyword", "印花大學T"}, {"keyword", "印花帽T"}}
|
||||
for _, item := range seed {
|
||||
norm := sybshop.Normalize(item.keyword)
|
||||
row := models.SYBProductFilter{Kind: item.kind, Keyword: item.keyword, NormalizedKeyword: norm, Enabled: true}
|
||||
if err := db.Where("kind = ? AND normalized_keyword = ?", item.kind, norm).FirstOrCreate(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return ensureMySQLDirectSelectConstraint(db)
|
||||
}
|
||||
|
||||
|
||||
@@ -455,7 +455,7 @@ func (product *ShopeeProduct) BeforeCreate(_ *gorm.DB) error {
|
||||
// Parse status for a SYBProduct's productSpec extraction. See sybimport.Parse.
|
||||
const (
|
||||
SYBParseStatusSuccess = "success"
|
||||
SYBParseStatusUncertain = "uncertain"
|
||||
SYBParseStatusUncertain = "uncertain" // legacy input only; persistence normalizes to failed
|
||||
SYBParseStatusFailed = "failed"
|
||||
)
|
||||
|
||||
@@ -600,32 +600,61 @@ type SYBShop struct {
|
||||
|
||||
func (SYBShop) TableName() string { return "syb_shop" }
|
||||
|
||||
// SYBProductFilter controls which SYB detail variationSku values are skipped.
|
||||
// Char rows are seeded business判据 and may only be enabled/disabled; keyword
|
||||
// rows are operator-maintained. DeletedFlag keeps the composite unique key
|
||||
// reusable after a keyword is soft-deleted.
|
||||
type SYBProductFilter struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Kind string `json:"kind" gorm:"size:16;not null;index;uniqueIndex:ux_syb_product_filter_key,priority:1;check:ck_syb_product_filter_kind,kind IN ('char','keyword')"`
|
||||
// Keyword and NormalizedKeyword are sized, not text: MySQL refuses a
|
||||
// BLOB/TEXT column in a key specification without a prefix length, so the
|
||||
// unique index below cannot be built on a text column (Error 1170). SQLite
|
||||
// accepts either, which is why only a real MySQL migration surfaces this.
|
||||
Keyword string `json:"keyword" gorm:"size:200;not null"`
|
||||
NormalizedKeyword string `json:"normalizedKeyword" gorm:"size:200;not null;uniqueIndex:ux_syb_product_filter_key,priority:2"`
|
||||
Enabled bool `json:"enabled" gorm:"not null;default:true"`
|
||||
LastHitAt *time.Time `json:"lastHitAt"`
|
||||
LastHitCount *int `json:"lastHitCount"`
|
||||
Note *string `json:"note" gorm:"type:text"`
|
||||
LastChangedBy string `json:"lastChangedBy" gorm:"size:128;not null;default:''"`
|
||||
LastChangedAt *time.Time `json:"lastChangedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
|
||||
DeletedFlag uint64 `json:"-" gorm:"not null;default:0;uniqueIndex:ux_syb_product_filter_key,priority:3"`
|
||||
}
|
||||
|
||||
func (SYBProductFilter) TableName() string { return "syb_product_filter" }
|
||||
|
||||
// SYBSyncRun records one asynchronous SYB import. ActiveSlot is set only while
|
||||
// a run is active; its unique index is the database-level single-flight guard
|
||||
// across processes and is cleared when the run reaches a terminal state.
|
||||
type SYBSyncRun struct {
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
DateFrom string `json:"dateFrom" gorm:"size:10;not null"`
|
||||
DateTo string `json:"dateTo" gorm:"size:10;not null"`
|
||||
Status string `json:"status" gorm:"size:16;not null;index"`
|
||||
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_syb_sync_run_active_slot"`
|
||||
DaysTotal int `json:"daysTotal" gorm:"not null;default:0"`
|
||||
DaysProcessed int `json:"daysProcessed" gorm:"not null;default:0"`
|
||||
OrderCount int `json:"orderCount" gorm:"not null;default:0"`
|
||||
DetailCount int `json:"detailCount" gorm:"not null;default:0"`
|
||||
AcceptedCount int `json:"acceptedCount" gorm:"not null;default:0"`
|
||||
ShopSkipped int `json:"shopSkipped" gorm:"not null;default:0"`
|
||||
Created int `json:"created" gorm:"column:created_count;not null;default:0"`
|
||||
Updated int `json:"updated" gorm:"column:updated_count;not null;default:0"`
|
||||
ShopFilterHash string `json:"shopFilterHash" gorm:"size:64;not null"`
|
||||
ShopBreakdownJSON string `json:"-" gorm:"type:text;not null"`
|
||||
ErrorMessage string `json:"errorMessage" gorm:"size:1000;not null;default:''"`
|
||||
OperatorID uint64 `json:"operatorId" gorm:"not null;default:0"`
|
||||
OperatorName string `json:"operatorName" gorm:"size:128;not null;default:''"`
|
||||
StartedAt time.Time `json:"startedAt" gorm:"not null"`
|
||||
FinishedAt *time.Time `json:"finishedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
DateFrom string `json:"dateFrom" gorm:"size:10;not null"`
|
||||
DateTo string `json:"dateTo" gorm:"size:10;not null"`
|
||||
Status string `json:"status" gorm:"size:16;not null;index"`
|
||||
ActiveSlot *uint8 `json:"-" gorm:"uniqueIndex:ux_syb_sync_run_active_slot"`
|
||||
DaysTotal int `json:"daysTotal" gorm:"not null;default:0"`
|
||||
DaysProcessed int `json:"daysProcessed" gorm:"not null;default:0"`
|
||||
OrderCount int `json:"orderCount" gorm:"not null;default:0"`
|
||||
DetailCount int `json:"detailCount" gorm:"not null;default:0"`
|
||||
AcceptedCount int `json:"acceptedCount" gorm:"not null;default:0"`
|
||||
ShopSkipped int `json:"shopSkipped" gorm:"not null;default:0"`
|
||||
CharFilterSkipped int `json:"charFilterSkipped" gorm:"column:char_filter_skipped;not null;default:0"`
|
||||
KeywordFilterSkipped int `json:"keywordFilterSkipped" gorm:"column:keyword_filter_skipped;not null;default:0"`
|
||||
Created int `json:"created" gorm:"column:created_count;not null;default:0"`
|
||||
Updated int `json:"updated" gorm:"column:updated_count;not null;default:0"`
|
||||
ShopFilterHash string `json:"shopFilterHash" gorm:"size:64;not null"`
|
||||
ShopBreakdownJSON string `json:"-" gorm:"type:text;not null"`
|
||||
ErrorMessage string `json:"errorMessage" gorm:"size:1000;not null;default:''"`
|
||||
OperatorID uint64 `json:"operatorId" gorm:"not null;default:0"`
|
||||
OperatorName string `json:"operatorName" gorm:"size:128;not null;default:''"`
|
||||
StartedAt time.Time `json:"startedAt" gorm:"not null"`
|
||||
FinishedAt *time.Time `json:"finishedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (SYBSyncRun) TableName() string { return "syb_sync_run" }
|
||||
|
||||
@@ -422,6 +422,14 @@ func (s *Service) previewFromDataset(id uint64, dataset batchPreviewDataset, gua
|
||||
}
|
||||
// #190:映射不完整不再拦截,任务以 unresolved 建立并交由规格探测解析。
|
||||
}
|
||||
// Readiness must agree with the mapping shown in the product detail. A
|
||||
// deterministic suggestion is only a preview; it is not a persisted,
|
||||
// confirmed mapping and must not make the row appear purchase-ready.
|
||||
if source == "unresolved" || !mappingTargetsValid(candidates, syb.TargetColor, syb.TargetSize, item.MappedColor, item.MappedSize) {
|
||||
// Keep Eligible for the existing live-probe path, but expose the
|
||||
// unresolved state so the UI cannot label the row purchase-ready.
|
||||
item.ReasonCode, item.Reason, item.NextAction = CodeMappingRequired, "采购规格尚未匹配并保存", "open_mapping"
|
||||
}
|
||||
reference, minPrice, maxPrice, err := purchasePriceRange(pdd.SpecsJSON, item.MappedColor, guard)
|
||||
if err != nil {
|
||||
item.ReasonCode, item.Reason, item.NextAction = "PDD_PRICE_MISSING", err.Error(), "open_pdd"
|
||||
|
||||
@@ -142,7 +142,7 @@ func TestBatchSpecMatchPersistsExactMatchButPurchaseDoesNotWaitForIt(t *testing.
|
||||
service.Matcher = matcher
|
||||
|
||||
before, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || len(before.Items) != 1 || !before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStagePurchaseReady {
|
||||
if err != nil || len(before.Items) != 1 || !before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStageColorMapping {
|
||||
t.Fatalf("live-probe purchase was incorrectly gated by persisted mapping: %+v err=%v", before, err)
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ func TestExtractedUncertainSpecsCanBeMatchedBeforePurchase(t *testing.T) {
|
||||
service.Matcher = matcher
|
||||
|
||||
before, err := service.BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
if err != nil || len(before.Items) != 1 || !before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStagePurchaseReady {
|
||||
if err != nil || len(before.Items) != 1 || !before.Items[0].Eligible || !before.Items[0].AIMatchEligible || before.Items[0].ProcessStage != ProcessStageColorMapping {
|
||||
t.Fatalf("extracted uncertain specs were not purchase-ready for live probing: %+v err=%v", before, err)
|
||||
}
|
||||
matched, err := service.BatchSpecMatch(context.Background(), BatchSpecMatchRequest{SYBProductIDs: []uint64{f.syb.ID}})
|
||||
@@ -231,7 +231,7 @@ func TestBatchPreviewExposesExplicitAIMatchEligibility(t *testing.T) {
|
||||
t.Fatalf("preview failed: %+v err=%v", preview, err)
|
||||
}
|
||||
item := preview.Items[0]
|
||||
if !item.AIMatchEligible || item.AIMatchDisabledReason != "" || !item.Eligible || item.ProcessStage != ProcessStagePurchaseReady {
|
||||
if !item.AIMatchEligible || item.AIMatchDisabledReason != "" || !item.Eligible || item.ProcessStage != ProcessStageColorMapping {
|
||||
t.Fatalf("explicit AI eligibility mismatch: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@ func TestBatchPreviewBulkLoadsAndNeverCallsAIMatcher(t *testing.T) {
|
||||
t.Fatalf("batch preview used %d queries, want 7 bounded queries including collection eligibility and current purchase rule", queries)
|
||||
}
|
||||
if len(response.Items) != 2 || !response.Items[0].Eligible || !response.Items[1].Eligible || response.EligibleCount != 2 {
|
||||
t.Fatalf("unresolved rows must be ready for mandatory live probing: %+v", response)
|
||||
t.Fatalf("unresolved rows remain eligible for live probing but are not purchase-ready: %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,7 +329,7 @@ func TestBatchPreviewAllowsIncompleteArchivedCandidatesForMandatoryLiveProbe(t *
|
||||
}
|
||||
|
||||
response, err := testService(db).BatchPreview(context.Background(), BatchPreviewRequest{SYBProductIDs: []uint64{fixture.syb.ID}})
|
||||
if err != nil || len(response.Items) != 1 || !response.Items[0].Eligible || response.Items[0].ProcessStage != ProcessStagePurchaseReady {
|
||||
if err != nil || len(response.Items) != 1 || !response.Items[0].Eligible || response.Items[0].ProcessStage != ProcessStageManualAction {
|
||||
t.Fatalf("incomplete archive blocked live probe task creation: %+v err=%v", response, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"go-admin/app/goauto/aimatching"
|
||||
"go-admin/app/goauto/device"
|
||||
@@ -397,6 +398,11 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq
|
||||
case "failed":
|
||||
next = models.PurchaseTaskStatusFailed
|
||||
a.Status = models.PurchaseAttemptStatusFailed
|
||||
// `[必须]` Write the failure onto the attempt too, not only the task.
|
||||
// Without it every attempt produced by this branch carries a NULL
|
||||
// error_code, so attempt-level statistics silently omit them — on
|
||||
// production the spec-panel failures were invisible that way (#271).
|
||||
a.ErrorCode, a.ErrorMessage = &req.ErrorCode, &req.ErrorMessage
|
||||
t.ErrorCode = &req.ErrorCode
|
||||
t.ErrorMessage = &req.ErrorMessage
|
||||
t.ActualUnitPriceCent = req.ActualUnitPriceCent
|
||||
@@ -447,6 +453,26 @@ var allowedOrderUnknownFailures = map[string]string{
|
||||
CodeOrderUnpaidMissing: "订单详情缺少待付款状态证据",
|
||||
}
|
||||
|
||||
// orderFailureMessageLimit matches the error_message column width on both
|
||||
// purchase_task and purchase_task_attempt (size:1000). The canonical sentence is
|
||||
// never sacrificed to fit: only the appended agent evidence is truncated.
|
||||
const orderFailureMessageLimit = 1000
|
||||
|
||||
// normalizeOrderUnknownFailure keeps the canonical sentence as the message
|
||||
// people read, and appends the agent's own diagnostic behind it.
|
||||
//
|
||||
// `[必须]` The canonical text stays authoritative. The agent supplies free text,
|
||||
// so it must not become the primary description of what happened, and the UI
|
||||
// wording has to stay stable per error code.
|
||||
//
|
||||
// `[必须]` The agent's evidence is nevertheless kept. Discarding it left the
|
||||
// PAYMENT_REPEATED failures unanalysable: #210 computes paymentBackAttempts and
|
||||
// consecutivePaymentSamplesAfterBack, the agent reports them, and none of the 61
|
||||
// production failures between 2026-09-07 and 09-11 carried either value, so
|
||||
// "Back did not work", "the page rendered slower than the ~1.1s window" and "the
|
||||
// page was the order page but no marker matched" could not be told apart. The
|
||||
// sibling "failed" branch already stores agent text verbatim, so keeping it here
|
||||
// introduces no new trust assumption (#271).
|
||||
func normalizeOrderUnknownFailure(code, message string) (string, string, error) {
|
||||
code, message = strings.TrimSpace(code), strings.TrimSpace(message)
|
||||
if code == "" && message == "" {
|
||||
@@ -456,7 +482,53 @@ func normalizeOrderUnknownFailure(code, message string) (string, string, error)
|
||||
if !ok || message == "" {
|
||||
return "", "", fail(CodeInvalidRequest, "订单核单失败阶段无效")
|
||||
}
|
||||
return code, canonicalMessage, nil
|
||||
return code, appendAgentEvidence(canonicalMessage, message), nil
|
||||
}
|
||||
|
||||
// appendAgentEvidence joins the canonical sentence with the agent's diagnostic.
|
||||
//
|
||||
// The evidence is flattened to a single line first: agent text reaches the
|
||||
// purchase list, task detail and exports, and an embedded newline or control
|
||||
// character would break those renderings.
|
||||
func appendAgentEvidence(canonical, evidence string) string {
|
||||
evidence = flattenAgentEvidence(evidence)
|
||||
if evidence == "" || evidence == canonical {
|
||||
return canonical
|
||||
}
|
||||
room := orderFailureMessageLimit - len([]rune(canonical)) - 1
|
||||
if room <= 0 {
|
||||
return canonical
|
||||
}
|
||||
if runes := []rune(evidence); len(runes) > room {
|
||||
if room <= 1 {
|
||||
return canonical
|
||||
}
|
||||
evidence = string(runes[:room-1]) + "…"
|
||||
}
|
||||
return canonical + " " + evidence
|
||||
}
|
||||
|
||||
// flattenAgentEvidence removes control characters and collapses whitespace so
|
||||
// the appended text is always one readable line.
|
||||
func flattenAgentEvidence(evidence string) string {
|
||||
var b strings.Builder
|
||||
lastWasSpace := false
|
||||
for _, r := range evidence {
|
||||
switch {
|
||||
case r == 0xFEFF:
|
||||
// Zero-width no-break space: invisible, so drop it rather than
|
||||
// turning it into a space that would look like a stray gap.
|
||||
case unicode.IsControl(r) || unicode.IsSpace(r):
|
||||
if !lastWasSpace {
|
||||
b.WriteRune(' ')
|
||||
lastWasSpace = true
|
||||
}
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
lastWasSpace = false
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
func (s *Service) ApplySpecDecision(ctx context.Context, taskID uint64, req SpecDecisionRequest) (models.PurchaseTask, bool, error) {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package purchase
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// 这条诊断就是 #210 让 Agent 算出来、却一直没能落库的那一段。
|
||||
const paymentRepeatAgentMessage = "支付页安全返回后持续无订单证据,已停止自动核单" +
|
||||
"[paymentBackAttempts=1;consecutivePaymentSamplesAfterBack=3]"
|
||||
|
||||
func TestNormalizeOrderUnknownFailureKeepsCanonicalAndAgentEvidence(t *testing.T) {
|
||||
code, message, err := normalizeOrderUnknownFailure(CodeOrderPaymentRepeat, paymentRepeatAgentMessage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code != CodeOrderPaymentRepeat {
|
||||
t.Fatalf("code changed: %q", code)
|
||||
}
|
||||
canonical := allowedOrderUnknownFailures[CodeOrderPaymentRepeat]
|
||||
if !strings.HasPrefix(message, canonical) {
|
||||
t.Fatalf("canonical sentence must lead the message, got %q", message)
|
||||
}
|
||||
// 没有这两个值就无法区分「Back 没生效」「渲染慢于窗口」「关键词没匹配」。
|
||||
for _, want := range []string{"paymentBackAttempts=1", "consecutivePaymentSamplesAfterBack=3"} {
|
||||
if !strings.Contains(message, want) {
|
||||
t.Fatalf("agent evidence %q missing from %q", want, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOrderUnknownFailureFlattensAndBounds(t *testing.T) {
|
||||
canonical := allowedOrderUnknownFailures[CodeOrderPaymentRepeat]
|
||||
|
||||
t.Run("控制字符和换行被压平", func(t *testing.T) {
|
||||
_, message, err := normalizeOrderUnknownFailure(CodeOrderPaymentRepeat, "证据行一\n\t行二\r\n行三\ufeff")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.ContainsAny(message, "\n\r\t") {
|
||||
t.Fatalf("message must stay on one line: %q", message)
|
||||
}
|
||||
if !strings.Contains(message, "证据行一 行二 行三") {
|
||||
t.Fatalf("whitespace should collapse to single spaces: %q", message)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("超长证据被截断且固定文案完整保留", func(t *testing.T) {
|
||||
_, message, err := normalizeOrderUnknownFailure(CodeOrderPaymentRepeat, strings.Repeat("证", 4000))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(message, canonical) {
|
||||
t.Fatalf("canonical sentence was sacrificed to fit: %q", message)
|
||||
}
|
||||
if n := len([]rune(message)); n > orderFailureMessageLimit {
|
||||
t.Fatalf("message is %d runes, column holds %d", n, orderFailureMessageLimit)
|
||||
}
|
||||
if !strings.HasSuffix(message, "…") {
|
||||
t.Fatalf("truncation should be visible: %q", message[len(message)-30:])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空消息行为不变", func(t *testing.T) {
|
||||
if _, _, err := normalizeOrderUnknownFailure(CodeOrderPaymentRepeat, ""); code(err) != CodeInvalidRequest {
|
||||
t.Fatalf("empty agent message must still be rejected, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("未批准的错误码仍被拒绝", func(t *testing.T) {
|
||||
if _, _, err := normalizeOrderUnknownFailure("UNAPPROVED", "whatever"); code(err) != CodeInvalidRequest {
|
||||
t.Fatalf("unapproved code must be rejected, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("证据与固定文案相同时不重复拼接", func(t *testing.T) {
|
||||
_, message, err := normalizeOrderUnknownFailure(CodeOrderPaymentRepeat, canonical)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if message != canonical {
|
||||
t.Fatalf("identical evidence should not be appended twice: %q", message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 端到端确认证据真的写进了两张表,而不是只在函数里拼对了。
|
||||
func TestSubmitOrderResultUnknownPersistsAgentEvidence(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task, _ := createLive(t, s, f)
|
||||
started := startLivePurchaseAfterProbe(t, s, f, task)
|
||||
if _, err := s.MarkOrderSubmitStarted(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
|
||||
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "order_result_unknown",
|
||||
ErrorCode: CodeOrderPaymentRepeat, ErrorMessage: paymentRepeatAgentMessage,
|
||||
}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var saved models.PurchaseTask
|
||||
if err := db.First(&saved, task.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var attempt models.PurchaseTaskAttempt
|
||||
if err := db.Where("task_id = ?", task.ID).Order("attempt_number DESC, id DESC").First(&attempt).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, got := range map[string]*string{"task": saved.ErrorMessage, "attempt": attempt.ErrorMessage} {
|
||||
if got == nil {
|
||||
t.Fatalf("%s kept no error message", name)
|
||||
}
|
||||
if !strings.Contains(*got, "consecutivePaymentSamplesAfterBack=3") {
|
||||
t.Fatalf("%s dropped the agent evidence: %q", name, *got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "failed" 分支过去只写任务不写 attempt,attempt.error_code 全是 NULL,
|
||||
// 导致按 attempt 统计时这些失败是隐形的(线上规格失败即如此)。
|
||||
func TestSubmitFailedResultRecordsErrorOnAttempt(t *testing.T) {
|
||||
db := testDB(t)
|
||||
f := seed(t, db, liveCaps(), true)
|
||||
s := testService(db)
|
||||
task, _ := createLive(t, s, f)
|
||||
started := startLivePurchaseAfterProbe(t, s, f, task)
|
||||
|
||||
const failCode = "PURCHASE_SPEC_PANEL_EVIDENCE_NOT_MATCHED"
|
||||
const failMessage = "规格入口点击后页面已变化,但规格面板强证据不足 [type=UNKNOWN;scrollables=1]"
|
||||
if _, err := s.SubmitResult(context.Background(), task.ID, ResultRequest{
|
||||
RequestID: uuid.NewString(), TaskAttemptID: started.TaskAttemptID, ResultType: "failed",
|
||||
ErrorCode: failCode, ErrorMessage: failMessage,
|
||||
}, f.token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var attempt models.PurchaseTaskAttempt
|
||||
if err := db.Where("task_id = ?", task.ID).Order("attempt_number DESC, id DESC").First(&attempt).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if attempt.ErrorCode == nil || *attempt.ErrorCode != failCode {
|
||||
t.Fatalf("attempt error code not recorded: %+v", attempt.ErrorCode)
|
||||
}
|
||||
if attempt.ErrorMessage == nil || *attempt.ErrorMessage != failMessage {
|
||||
t.Fatalf("attempt error message not recorded: %+v", attempt.ErrorMessage)
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,7 @@ func (service *Service) linkLatestCollection(ctx context.Context, id uint64, req
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if task.Status != models.TaskStatusCompleted || task.FinishedAt == nil || task.PDDProductID == nil {
|
||||
if task.FinishedAt == nil || task.PDDProductID == nil {
|
||||
return latestUnavailable("最近一次临时采集不完整,请先补采;不会回退关联更早的商品")
|
||||
}
|
||||
var pdd models.PDDProduct
|
||||
@@ -138,6 +138,11 @@ func (service *Service) linkLatestCollection(ctx context.Context, id uint64, req
|
||||
if err != nil || len(color)+len(size) == 0 {
|
||||
return latestUnavailable("最近采集的 PDD 缺少可用规格,请先补采")
|
||||
}
|
||||
if task.Status == models.TaskStatusCompletedPartial {
|
||||
if err := validatePartialLinkResult(tx, task, color, size); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
targetVersion := computeSpecContextVersion(&pdd.ID, "", pdd.SpecsJSON)
|
||||
if replacement != nil && !replacement.Preview && (replacement.TargetPDDProductID != pdd.ID || replacement.TargetVersion != targetVersion) {
|
||||
return contextVersionStale()
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Only auxiliary metadata may be missing. Validate the source task, not the
|
||||
// merged product archive, which can retain data from an older collection.
|
||||
func validatePartialLinkResult(tx *gorm.DB, task models.CollectionTask, colors, sizes map[string]bool) error {
|
||||
reject := func() error {
|
||||
return latestUnavailable("最近一次采集的规格或价格仍不完整,请先补采;不会回退关联更早的商品")
|
||||
}
|
||||
var missing []string
|
||||
if task.MissingJSON == nil || json.Unmarshal([]byte(*task.MissingJSON), &missing) != nil || len(missing) == 0 {
|
||||
return reject()
|
||||
}
|
||||
for _, field := range missing {
|
||||
switch field {
|
||||
case "salesText", "shopName", "reviewCount":
|
||||
default:
|
||||
return reject()
|
||||
}
|
||||
}
|
||||
var dimensions []models.CollectionDimension
|
||||
if err := tx.Where("task_id = ?", task.ID).Find(&dimensions).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
values := map[string]map[string]bool{}
|
||||
for _, dimension := range dimensions {
|
||||
if (dimension.Key != RoleColor && dimension.Key != RoleSize) || values[dimension.Key] != nil {
|
||||
return reject()
|
||||
}
|
||||
var rows []models.CollectionDimensionValue
|
||||
if err := tx.Where("dimension_id = ?", dimension.ID).Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return reject()
|
||||
}
|
||||
values[dimension.Key] = map[string]bool{}
|
||||
for _, row := range rows {
|
||||
if strings.TrimSpace(row.Value) == "" {
|
||||
return reject()
|
||||
}
|
||||
values[dimension.Key][row.Value] = true
|
||||
}
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return reject()
|
||||
}
|
||||
var skus []models.CollectionSKU
|
||||
if err := tx.Where("task_id = ? AND available = ?", task.ID, true).Find(&skus).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(skus) == 0 {
|
||||
return reject()
|
||||
}
|
||||
covered := map[string]map[string]bool{RoleColor: {}, RoleSize: {}}
|
||||
for _, sku := range skus {
|
||||
var specs map[string]string
|
||||
if !sku.Complete || sku.PriceCent < 0 || json.Unmarshal([]byte(sku.SpecKey), &specs) != nil || len(specs) != len(values) {
|
||||
return reject()
|
||||
}
|
||||
for role, value := range specs {
|
||||
if !values[role][value] {
|
||||
return reject()
|
||||
}
|
||||
covered[role][value] = true
|
||||
}
|
||||
}
|
||||
var prices []models.CollectionColorPrice
|
||||
if err := tx.Where("task_id = ?", task.ID).Find(&prices).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
priced := map[string]bool{}
|
||||
for _, price := range prices {
|
||||
if price.PriceCent >= 0 {
|
||||
priced[price.Color] = true
|
||||
}
|
||||
}
|
||||
for role, candidates := range map[string]map[string]bool{RoleColor: colors, RoleSize: sizes} {
|
||||
for value := range candidates {
|
||||
if !covered[role][value] || (role == RoleColor && !priced[value]) {
|
||||
return reject()
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package shopeeproduct
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func seedPartialResult(t *testing.T, db *gorm.DB, task models.CollectionTask, missing string, single bool) {
|
||||
t.Helper()
|
||||
if err := db.Model(&task).Updates(map[string]any{"status": models.TaskStatusCompletedPartial, "missing_json": missing}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dimensions := map[string][]string{"color": {"黑色"}, "size": {"M", "L", "XL", "2XL"}}
|
||||
if single {
|
||||
delete(dimensions, "size")
|
||||
if err := db.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", `[{"role":"color","name":"颜色","values":[{"name":"黑色","selectable":true,"priceCent":1000}]}]`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for role, values := range dimensions {
|
||||
d := models.CollectionDimension{TaskID: task.ID, Key: role, Name: role}
|
||||
if err := db.Create(&d).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, value := range values {
|
||||
if err := db.Create(&models.CollectionDimensionValue{DimensionID: d.ID, Value: value}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := db.Create(&models.CollectionColorPrice{TaskID: task.ID, Color: "黑色", PriceCent: 1000}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sizes := dimensions["size"]
|
||||
if single {
|
||||
sizes = []string{""}
|
||||
}
|
||||
for _, size := range sizes {
|
||||
specs := map[string]string{"color": "黑色"}
|
||||
if size != "" {
|
||||
specs["size"] = size
|
||||
}
|
||||
raw, _ := json.Marshal(specs)
|
||||
if err := db.Create(&models.CollectionSKU{TaskID: task.ID, SpecKey: string(raw), PriceCent: 1000, Available: true, Complete: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialAuxiliaryMetadataAllowsLinkAndKeepsStatus(t *testing.T) {
|
||||
for _, missing := range []string{`["salesText"]`, `["shopName","reviewCount","salesText"]`} {
|
||||
t.Run(missing, func(t *testing.T) {
|
||||
s, id, req, task := latestFixture(t)
|
||||
seedPartialResult(t, s.DB, task, missing, false)
|
||||
result, err := s.LinkPDD(context.Background(), id, req)
|
||||
if err != nil || result.CollectionSource.TaskID != task.ID {
|
||||
t.Fatalf("link: %+v %v", result, err)
|
||||
}
|
||||
s.DB.First(&task, task.ID)
|
||||
if task.Status != models.TaskStatusCompletedPartial || *task.MissingJSON != missing {
|
||||
t.Fatal("changed historical completeness")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialMetadataAllowsSingleDimensionAndReplacement(t *testing.T) {
|
||||
s, id, req, task, old := replacementFixture(t)
|
||||
seedPartialResult(t, s.DB, task, `["salesText"]`, true)
|
||||
preview := previewReplacement(t, s, id, &req)
|
||||
if *preview.Product.PDDProductID != old.ID {
|
||||
t.Fatal("preview wrote")
|
||||
}
|
||||
result, err := s.LinkPDD(context.Background(), id, req)
|
||||
if err != nil || *result.Product.PDDProductID != *task.PDDProductID {
|
||||
t.Fatalf("replacement: %+v %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartialMissingOrResultDefectsStillReject(t *testing.T) {
|
||||
for _, scenario := range []string{"[]", "null", "bad-json", `["salesText","size"]`, `["title"]`, `["price:黑色"]`, `["skuLimit:12>8"]`, `["unknown"]`, "sku-incomplete", "sku-unavailable", "sku-invalid", "no-current-price", "old-archive-value"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
s, id, req, task := latestFixture(t)
|
||||
missing := scenario
|
||||
if scenario[0] != '[' && scenario != "null" && scenario != "bad-json" {
|
||||
missing = `["salesText"]`
|
||||
}
|
||||
seedPartialResult(t, s.DB, task, missing, false)
|
||||
switch scenario {
|
||||
case "sku-incomplete":
|
||||
s.DB.Model(&models.CollectionSKU{}).Where("task_id = ?", task.ID).Update("complete", false)
|
||||
case "sku-unavailable":
|
||||
s.DB.Model(&models.CollectionSKU{}).Where("task_id = ?", task.ID).Update("available", false)
|
||||
case "sku-invalid":
|
||||
var sku models.CollectionSKU
|
||||
if err := s.DB.Where("task_id = ?", task.ID).First(&sku).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.DB.Model(&sku).Update("spec_key", `{}`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "no-current-price":
|
||||
s.DB.Model(&models.CollectionColorPrice{}).Where("task_id = ?", task.ID).Update("color", "不是本次颜色")
|
||||
case "old-archive-value":
|
||||
s.DB.Model(&models.PDDProduct{}).Where("id = ?", *task.PDDProductID).Update("specs_json", `[{"role":"color","values":[{"name":"旧颜色","selectable":true,"priceCent":1000}]}]`)
|
||||
}
|
||||
if _, err := s.LinkPDD(context.Background(), id, req); err == nil {
|
||||
t.Fatal("accepted incomplete current result")
|
||||
}
|
||||
var product models.ShopeeProduct
|
||||
s.DB.First(&product, id)
|
||||
if product.PDDProductID != nil {
|
||||
t.Fatal("rejected request wrote association")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,9 @@ func ApplyDetail(ctx context.Context, db *gorm.DB, order OrderInput, detail Deta
|
||||
}
|
||||
|
||||
parsed := Parse(detail.ProductSpec)
|
||||
if parsed.Status == models.SYBParseStatusUncertain {
|
||||
parsed.Status = models.SYBParseStatusFailed
|
||||
}
|
||||
rawJSON, err := normalizeRaw(detail.Raw)
|
||||
if err != nil {
|
||||
return ApplyResult{}, err
|
||||
|
||||
@@ -25,7 +25,6 @@ var explicitSizePattern = regexp.MustCompile(`(?i)^(?:均(?:码|碼|号|號)|one
|
||||
// Observed in the real SYB sample: "黑色+白色【純棉兩件裝】 簡約親膚" strips
|
||||
// its bracket to "黑色+白色 簡約親膚", which still carries a '+' and internal
|
||||
// whitespace, so it must not be reported as a confident match.
|
||||
var ambiguousPattern = regexp.MustCompile(`[++\s]`)
|
||||
|
||||
// ParseResult is the color/size candidate extracted from one productSpec
|
||||
// string, plus how much the caller should trust it.
|
||||
@@ -73,7 +72,10 @@ func Parse(raw string) ParseResult {
|
||||
if size == "" {
|
||||
return ParseResult{Status: models.SYBParseStatusFailed, Note: "productSpec 剥离备注后为空"}
|
||||
}
|
||||
return ParseResult{Size: size, Status: models.SYBParseStatusUncertain, Note: "无分隔符,仅识别出单一规格值"}
|
||||
if explicitSizePattern.MatchString(size) {
|
||||
return ParseResult{Size: size, Status: models.SYBParseStatusSuccess, Note: "仅识别到尺码"}
|
||||
}
|
||||
return ParseResult{Color: size, Status: models.SYBParseStatusSuccess, Note: "仅识别到颜色"}
|
||||
}
|
||||
|
||||
firstPart := stripBrackets(trimmed[:lastComma])
|
||||
@@ -81,7 +83,17 @@ func Parse(raw string) ParseResult {
|
||||
colorPart, sizePart := firstPart, secondPart
|
||||
|
||||
if colorPart == "" || sizePart == "" {
|
||||
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "按逗号拆分后有一侧为空"}
|
||||
only := colorPart
|
||||
if only == "" {
|
||||
only = sizePart
|
||||
}
|
||||
if explicitSizePattern.MatchString(only) {
|
||||
return ParseResult{Size: only, Status: models.SYBParseStatusSuccess, Note: "仅识别到尺码"}
|
||||
}
|
||||
if only != "" {
|
||||
return ParseResult{Color: only, Status: models.SYBParseStatusSuccess, Note: "仅识别到颜色"}
|
||||
}
|
||||
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusFailed, Note: "按逗号拆分后没有可靠规格"}
|
||||
}
|
||||
firstIsSize, secondIsSize := explicitSizePattern.MatchString(firstPart), explicitSizePattern.MatchString(secondPart)
|
||||
if firstIsSize && secondIsSize {
|
||||
@@ -90,9 +102,6 @@ func Parse(raw string) ParseResult {
|
||||
if firstIsSize {
|
||||
colorPart, sizePart = secondPart, firstPart
|
||||
}
|
||||
if ambiguousPattern.MatchString(colorPart) {
|
||||
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusUncertain, Note: "颜色部分含备注文本或多个分隔符,拆分结果可能不准确"}
|
||||
}
|
||||
return ParseResult{Color: colorPart, Size: sizePart, Status: models.SYBParseStatusSuccess}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,9 @@ func Reparse(ctx context.Context, db *gorm.DB, sybProductID uint64, force bool)
|
||||
return fmt.Errorf("syb product %d: stored raw json is not decodable: %w", record.ID, err)
|
||||
}
|
||||
parsed := Parse(raw.ProductSpec)
|
||||
if parsed.Status == models.SYBParseStatusUncertain {
|
||||
parsed.Status = models.SYBParseStatusFailed
|
||||
}
|
||||
outcome.NewStatus = parsed.Status
|
||||
|
||||
if parsed.Color == record.TargetColor && parsed.Size == record.TargetSize && parsed.Status == record.ParseStatus {
|
||||
|
||||
@@ -42,6 +42,9 @@ func RepairReversedSpecRoles(ctx context.Context, db *gorm.DB) (SpecRoleRepairRe
|
||||
continue
|
||||
}
|
||||
parsed := Parse(raw.ProductSpec)
|
||||
if parsed.Status == models.SYBParseStatusUncertain {
|
||||
parsed.Status = models.SYBParseStatusFailed
|
||||
}
|
||||
if parsed.Status == models.SYBParseStatusFailed || parsed.Color == record.TargetColor ||
|
||||
parsed.Color != record.TargetSize || parsed.Size != record.TargetColor {
|
||||
continue
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/sybclient"
|
||||
"go-admin/app/goauto/sybproductfilter"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -52,8 +53,14 @@ type SyncReport struct {
|
||||
// ShopSkipped how many did not. OrderCount stays the raw total: keeping the
|
||||
// unfiltered number visible is what makes a shop missing from the
|
||||
// allow-list obvious rather than silent.
|
||||
AcceptedCount int `json:"acceptedCount"`
|
||||
ShopSkipped int `json:"shopSkipped"`
|
||||
AcceptedCount int `json:"acceptedCount"`
|
||||
ShopSkipped int `json:"shopSkipped"`
|
||||
CharFilterSkipped int `json:"charFilterSkipped"`
|
||||
KeywordFilterSkipped int `json:"keywordFilterSkipped"`
|
||||
// filterHits accumulates hits per filter rule id so each rule's own
|
||||
// LastHitCount can be written back. It is unexported because it is run
|
||||
// bookkeeping, not part of the report shown to people or stored as JSON.
|
||||
filterHits sybproductfilter.HitCounter
|
||||
// ShopBreakdown separates accepted and skipped orders for every shop. Without
|
||||
// it a renamed or newly opened shop is never noticed (#50).
|
||||
ShopBreakdown map[string]ShopBreakdown `json:"shopBreakdown"`
|
||||
@@ -119,7 +126,8 @@ func SyncWithProgress(ctx context.Context, db *gorm.DB, client *sybclient.Client
|
||||
// run's effective allow-list differ from the snapshot recorded on its row.
|
||||
func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Client, cfg SyncConfig, dateFrom, dateTo string, allowed map[string]string, progress ProgressFunc) (SyncReport, error) {
|
||||
report := SyncReport{From: dateFrom, To: dateTo, StartedAt: time.Now().UTC(),
|
||||
ShopBreakdown: map[string]ShopBreakdown{}, ShopFilterSnapshot: shopSnapshotNames(allowed)}
|
||||
ShopBreakdown: map[string]ShopBreakdown{}, ShopFilterSnapshot: shopSnapshotNames(allowed),
|
||||
filterHits: sybproductfilter.HitCounter{}}
|
||||
|
||||
dates, err := splitDateRange(dateFrom, dateTo)
|
||||
if err != nil {
|
||||
@@ -128,6 +136,10 @@ func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Cl
|
||||
if len(allowed) == 0 {
|
||||
return report, ErrNoEnabledShop
|
||||
}
|
||||
filters, err := sybproductfilter.LoadEnabled(ctx, db)
|
||||
if err != nil {
|
||||
return report, fmt.Errorf("读取商品过滤规则失败: %w", err)
|
||||
}
|
||||
report.ShopFilterHash = enabledShopHash(allowed)
|
||||
emit := func(daysProcessed int) error {
|
||||
if progress == nil {
|
||||
@@ -181,7 +193,7 @@ func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Cl
|
||||
}
|
||||
_, dayErr := loadDailyList(ctx, client, plan.date, pageSize, plan.total, func(rows []sybclient.StockRow, page int) error {
|
||||
report.OrderCount += len(rows)
|
||||
err := importSyncPage(ctx, db, client, rows, allowed, &report)
|
||||
err := importSyncPage(ctx, db, client, rows, allowed, &report, filters)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s 第 %d 页明细/入库阶段失败: %w", plan.date, page, err)
|
||||
}
|
||||
@@ -204,6 +216,9 @@ func SyncWithShopSnapshot(ctx context.Context, db *gorm.DB, client *sybclient.Cl
|
||||
}
|
||||
}
|
||||
report.FinishedAt = time.Now().UTC()
|
||||
if err := sybproductfilter.UpdateHits(ctx, db, filters, report.filterHits, report.FinishedAt); err != nil {
|
||||
return report, err
|
||||
}
|
||||
return report, errors.Join(failures...)
|
||||
}
|
||||
|
||||
@@ -270,6 +285,24 @@ func loadDailyList(ctx context.Context, client *sybclient.Client, date string, p
|
||||
if pageCount != len(page) {
|
||||
return nil, fmt.Errorf("%s 货运单列表第 %d 页响应条数不自洽:total=%d,list=%d", date, pageIndex, pageCount, len(page))
|
||||
}
|
||||
// SYB may change a page while the list is being read. Re-read once
|
||||
// before treating a boundary-page count drift as a fatal snapshot error.
|
||||
if len(page) != expectedPageCount {
|
||||
pause := time.NewTimer(500 * time.Millisecond)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
pause.Stop()
|
||||
return nil, ctx.Err()
|
||||
case <-pause.C:
|
||||
}
|
||||
page, pageCount, err = client.ListPage(ctx, date, date, start, pageIndex, pageSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("重读 %s 货运单列表第 %d 页失败:%w", date, pageIndex, err)
|
||||
}
|
||||
if pageCount != len(page) {
|
||||
return nil, fmt.Errorf("%s 货运单列表第 %d 页重读响应条数不自洽:total=%d,list=%d", date, pageIndex, pageCount, len(page))
|
||||
}
|
||||
}
|
||||
// Validate the whole page first: a same-page duplicate must not be
|
||||
// hidden behind an earlier, recoverable cross-page overlap.
|
||||
pageSeen := make(map[int64]int, len(page))
|
||||
@@ -347,7 +380,11 @@ func validateDetailBatch(requested []int64, details []sybclient.StockDetail) err
|
||||
// applyStockDetail writes one shipment order's items. Each item is its own
|
||||
// ApplyDetail call and its own transaction — a several-thousand-row sync must
|
||||
// not sit in one long-held transaction.
|
||||
func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow, detail sybclient.StockDetail, report *SyncReport) error {
|
||||
func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow, detail sybclient.StockDetail, report *SyncReport, sets ...sybproductfilter.RuleSet) error {
|
||||
var filters sybproductfilter.RuleSet
|
||||
if len(sets) > 0 {
|
||||
filters = sets[0]
|
||||
}
|
||||
order := OrderInput{
|
||||
Code: detail.Code,
|
||||
StockID: uint64(detail.ID),
|
||||
@@ -357,6 +394,23 @@ func applyStockDetail(ctx context.Context, db *gorm.DB, row sybclient.StockRow,
|
||||
order.Code = row.Code
|
||||
}
|
||||
for _, item := range detail.Details {
|
||||
variation := stringField(item.Raw, "variationSku")
|
||||
if rule := filters.Match(variation); rule != nil {
|
||||
// `[必须]` Count the two kinds separately and per rule. A combined
|
||||
// total would hide a structural rule that stopped matching because
|
||||
// 档口 changed its code format, and a per-kind total would make the
|
||||
// disable-confirmation dialog quote the same figure for "#" and "-"
|
||||
// even though they match very different numbers of rows (#269).
|
||||
if rule.Kind == "char" {
|
||||
report.CharFilterSkipped++
|
||||
} else {
|
||||
report.KeywordFilterSkipped++
|
||||
}
|
||||
if report.filterHits != nil {
|
||||
report.filterHits.Add(rule)
|
||||
}
|
||||
continue
|
||||
}
|
||||
raw, err := json.Marshal(item.Raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("货运单 %s 明细 %d 原始数据编码失败: %w", order.Code, item.ID, err)
|
||||
|
||||
@@ -3,6 +3,7 @@ package sybimport
|
||||
import (
|
||||
"context"
|
||||
"go-admin/app/goauto/sybclient"
|
||||
"go-admin/app/goauto/sybproductfilter"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -14,7 +15,11 @@ func (e *syncFatalError) Unwrap() error { return e.error }
|
||||
|
||||
// Read all remote details before opening a page transaction. Publish counters
|
||||
// only after commit, so a rollback never reports records as saved.
|
||||
func importSyncPage(ctx context.Context, db *gorm.DB, client *sybclient.Client, rows []sybclient.StockRow, allowed map[string]string, report *SyncReport) error {
|
||||
func importSyncPage(ctx context.Context, db *gorm.DB, client *sybclient.Client, rows []sybclient.StockRow, allowed map[string]string, report *SyncReport, sets ...sybproductfilter.RuleSet) error {
|
||||
var filters sybproductfilter.RuleSet
|
||||
if len(sets) > 0 {
|
||||
filters = sets[0]
|
||||
}
|
||||
next := *report
|
||||
next.ShopBreakdown = make(map[string]ShopBreakdown, len(report.ShopBreakdown))
|
||||
for k, v := range report.ShopBreakdown {
|
||||
@@ -69,7 +74,7 @@ func importSyncPage(ctx context.Context, db *gorm.DB, client *sybclient.Client,
|
||||
next.ShopSkipped++
|
||||
continue
|
||||
}
|
||||
if err := applyStockDetail(ctx, tx, byID[detail.ID], detail, &next); err != nil {
|
||||
if err := applyStockDetail(ctx, tx, byID[detail.ID], detail, &next, filters); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ func (s *SyncRunService) UpdateProgress(ctx context.Context, id uint64, progress
|
||||
"days_total": progress.DaysTotal, "days_processed": progress.DaysProcessed,
|
||||
"order_count": progress.Report.OrderCount, "detail_count": progress.Report.DetailCount,
|
||||
"accepted_count": progress.Report.AcceptedCount, "shop_skipped": progress.Report.ShopSkipped,
|
||||
"char_filter_skipped": progress.Report.CharFilterSkipped, "keyword_filter_skipped": progress.Report.KeywordFilterSkipped,
|
||||
"created_count": progress.Report.Created, "updated_count": progress.Report.Updated,
|
||||
"shop_filter_hash": progress.Report.ShopFilterHash, "shop_breakdown_json": payload,
|
||||
}).Error
|
||||
@@ -92,6 +93,7 @@ func (s *SyncRunService) Finish(ctx context.Context, id uint64, status string, r
|
||||
"status": status, "active_slot": nil, "finished_at": now, "error_message": message,
|
||||
"order_count": report.OrderCount, "detail_count": report.DetailCount,
|
||||
"accepted_count": report.AcceptedCount, "shop_skipped": report.ShopSkipped,
|
||||
"char_filter_skipped": report.CharFilterSkipped, "keyword_filter_skipped": report.KeywordFilterSkipped,
|
||||
"created_count": report.Created, "updated_count": report.Updated,
|
||||
"shop_breakdown_json": payload,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"gorm.io/gorm"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Handler struct{ DB *gorm.DB }
|
||||
|
||||
func (h Handler) service(c *gin.Context) (*Service, bool) {
|
||||
db := h.DB
|
||||
var e error
|
||||
if db == nil {
|
||||
db, e = pkg.GetOrm(c)
|
||||
}
|
||||
if e != nil {
|
||||
writeError(c, internal(e))
|
||||
return nil, false
|
||||
}
|
||||
return NewService(db), true
|
||||
}
|
||||
func (h Handler) List(c *gin.Context) {
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
p, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
ps, _ := strconv.Atoi(c.DefaultQuery("pageSize", "50"))
|
||||
r, e := s.List(c.Request.Context(), ListRequest{Page: p, PageSize: ps, Kind: c.Query("kind"), Keyword: c.Query("keyword"), EnabledOnly: c.Query("enabledOnly") == "true"})
|
||||
if e != nil {
|
||||
writeError(c, e)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": r})
|
||||
}
|
||||
func (h Handler) Create(c *gin.Context) {
|
||||
var r CreateRequest
|
||||
if !decode(c, &r) {
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
x, e := s.Create(c.Request.Context(), r)
|
||||
if e != nil {
|
||||
writeError(c, e)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": x})
|
||||
}
|
||||
func (h Handler) SetEnabled(c *gin.Context) {
|
||||
id, ok := idParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var r SetEnabledRequest
|
||||
if !decode(c, &r) {
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
claims := jwt.ExtractClaims(c)
|
||||
name, _ := claims["nice"].(string)
|
||||
x, e := s.SetEnabled(c.Request.Context(), id, r, name)
|
||||
if e != nil {
|
||||
writeError(c, e)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": x})
|
||||
}
|
||||
func (h Handler) Delete(c *gin.Context) {
|
||||
id, ok := idParam(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s, ok := h.service(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if e := s.Delete(c.Request.Context(), id); e != nil {
|
||||
writeError(c, e)
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"code": 200, "data": gin.H{"deleted": true}})
|
||||
}
|
||||
func idParam(c *gin.Context) (uint64, bool) {
|
||||
id, e := strconv.ParseUint(c.Param("filterId"), 10, 64)
|
||||
if e != nil || id == 0 {
|
||||
writeError(c, invalid("过滤规则 id 必须是正整数"))
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
func decode(c *gin.Context, t any) bool {
|
||||
d := json.NewDecoder(c.Request.Body)
|
||||
d.DisallowUnknownFields()
|
||||
if e := d.Decode(t); e != nil {
|
||||
writeError(c, invalid("请求体解析失败:"+e.Error()))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func writeError(c *gin.Context, e error) {
|
||||
var se *ServiceError
|
||||
if !errors.As(e, &se) {
|
||||
se = internal(e).(*ServiceError)
|
||||
}
|
||||
status := 500
|
||||
switch se.Code {
|
||||
case CodeInvalidRequest:
|
||||
status = 422
|
||||
case CodeConflict:
|
||||
status = 409
|
||||
case CodeNotFound:
|
||||
status = 404
|
||||
case CodeForbidden:
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
c.JSON(status, gin.H{"code": se.Code, "message": se.Message})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 用线上真实取到的 variationSku 样本,验证过滤判定与每条规则各自计数。
|
||||
func TestRealWorldSamplesPerRuleCounting(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
set, err := LoadEnabled(ctx, db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
samples := []struct {
|
||||
v string
|
||||
wantKind string // "" = 不过滤
|
||||
}{
|
||||
{"新寮-雅伊阁大码女装#301", "char"},
|
||||
{"广达-爆款大码女装#9018", "char"},
|
||||
{"DD#004", "char"},
|
||||
{"300斤牛奶絲圓領#A057", "char"},
|
||||
{"上寮-琪琪尚雅网批#2604##(&15)", "char"},
|
||||
{"PDD0582", ""},
|
||||
{"my238", ""},
|
||||
{"le171", ""},
|
||||
{"1", ""},
|
||||
{"引流款", ""},
|
||||
{"", ""},
|
||||
}
|
||||
hits := HitCounter{}
|
||||
var charN, kwN, keptN int
|
||||
for _, s := range samples {
|
||||
rule := set.Match(s.v)
|
||||
if s.wantKind == "" {
|
||||
if rule != nil {
|
||||
t.Fatalf("%q 不应被过滤,却命中了 %q", s.v, rule.Keyword)
|
||||
}
|
||||
keptN++
|
||||
continue
|
||||
}
|
||||
if rule == nil || rule.Kind != s.wantKind {
|
||||
t.Fatalf("%q 应命中 %s 规则,实际 %v", s.v, s.wantKind, rule)
|
||||
}
|
||||
hits.Add(rule)
|
||||
if rule.Kind == "char" {
|
||||
charN++
|
||||
} else {
|
||||
kwN++
|
||||
}
|
||||
}
|
||||
if charN != 5 || kwN != 0 || keptN != 6 {
|
||||
t.Fatalf("统计不符:char=%d keyword=%d kept=%d", charN, kwN, keptN)
|
||||
}
|
||||
if err := UpdateHits(ctx, db, set, hits, time.Now().UTC()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, err := LoadEnabled(ctx, db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]int{}
|
||||
for _, x := range after.Chars {
|
||||
if x.LastHitCount == nil {
|
||||
t.Fatalf("规则 %q 必须被写入命中数", x.Keyword)
|
||||
}
|
||||
got[x.Keyword] = *x.LastHitCount
|
||||
}
|
||||
// "#" 命中 4 条(301/9018/DD#004/A057 ... 其中带 - 的先被 "-" 规则接走)
|
||||
if got["-"]+got["#"] != 5 {
|
||||
t.Fatalf("两条结构规则命中数之和应为 5,实际 - =%d # =%d", got["-"], got["#"])
|
||||
}
|
||||
if got["-"] == got["#"] {
|
||||
t.Fatalf("两条规则不应记成同一个数(这正是 #269 修的 bug): - =%d # =%d", got["-"], got["#"])
|
||||
}
|
||||
for _, x := range after.Keywords {
|
||||
if x.LastHitCount == nil || *x.LastHitCount != 0 {
|
||||
t.Fatalf("未命中的关键词 %q 应记 0,实际 %v", x.Keyword, x.LastHitCount)
|
||||
}
|
||||
}
|
||||
t.Logf("结构规则命中:- =%d # =%d", got["-"], got["#"])
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
"go-admin/common/middleware"
|
||||
)
|
||||
|
||||
func InitRouter(e *gin.Engine, a *jwt.GinJWTMiddleware) {
|
||||
g := e.Group("/api/admin/v1/syb-product-filters").Use(a.MiddlewareFunc()).Use(middleware.AuthCheckRole())
|
||||
g.GET("", Handler{}.List)
|
||||
g.POST("", middleware.RequireRoleKey("admin"), Handler{}.Create)
|
||||
g.PATCH("/:filterId/enabled", middleware.RequireRoleKey("admin"), Handler{}.SetEnabled)
|
||||
g.DELETE("/:filterId", middleware.RequireRoleKey("admin"), Handler{}.Delete)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ServiceError struct {
|
||||
Code, Message string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *ServiceError) Error() string {
|
||||
if e.Cause != nil {
|
||||
return fmt.Sprintf("%s: %v", e.Message, e.Cause)
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
func (e *ServiceError) Unwrap() error { return e.Cause }
|
||||
|
||||
const (
|
||||
CodeInvalidRequest = "INVALID_REQUEST"
|
||||
CodeNotFound = "SYB_PRODUCT_FILTER_NOT_FOUND"
|
||||
CodeConflict = "SYB_PRODUCT_FILTER_DUPLICATE"
|
||||
CodeForbidden = "FORBIDDEN"
|
||||
)
|
||||
|
||||
func invalid(m string) error { return &ServiceError{Code: CodeInvalidRequest, Message: m} }
|
||||
func internal(e error) error {
|
||||
return &ServiceError{Code: "INTERNAL_ERROR", Message: "服务器内部错误", Cause: e}
|
||||
}
|
||||
|
||||
type Service struct{ DB *gorm.DB }
|
||||
|
||||
func NewService(db *gorm.DB) *Service { return &Service{DB: db} }
|
||||
|
||||
type ListRequest struct {
|
||||
Page, PageSize int
|
||||
Kind, Keyword string
|
||||
EnabledOnly bool
|
||||
}
|
||||
type ListResponse struct {
|
||||
Items []models.SYBProductFilter `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
EnabledCount int64 `json:"enabledCount"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, r ListRequest) (ListResponse, error) {
|
||||
if r.Page < 1 {
|
||||
r.Page = 1
|
||||
}
|
||||
if r.PageSize < 1 || r.PageSize > 200 {
|
||||
r.PageSize = 50
|
||||
}
|
||||
q := s.DB.WithContext(ctx).Model(&models.SYBProductFilter{})
|
||||
if r.Kind != "" {
|
||||
q = q.Where("kind = ?", r.Kind)
|
||||
}
|
||||
if k := sybshop.Normalize(r.Keyword); k != "" {
|
||||
q = q.Where("normalized_keyword LIKE ?", "%"+k+"%")
|
||||
}
|
||||
if r.EnabledOnly {
|
||||
q = q.Where("enabled = ?", true)
|
||||
}
|
||||
var total, enabled int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return ListResponse{}, internal(err)
|
||||
}
|
||||
items := make([]models.SYBProductFilter, 0, r.PageSize)
|
||||
if err := q.Order("kind ASC, id ASC").Offset((r.Page - 1) * r.PageSize).Limit(r.PageSize).Find(&items).Error; err != nil {
|
||||
return ListResponse{}, internal(err)
|
||||
}
|
||||
if err := s.DB.WithContext(ctx).Model(&models.SYBProductFilter{}).Where("enabled = ?", true).Count(&enabled).Error; err != nil {
|
||||
return ListResponse{}, internal(err)
|
||||
}
|
||||
return ListResponse{Items: items, Total: total, EnabledCount: enabled, Page: r.Page, PageSize: r.PageSize}, nil
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
Kind string `json:"kind"`
|
||||
Keyword string `json:"keyword"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
|
||||
func validate(kind, keyword string) (string, string, error) {
|
||||
if kind != "char" && kind != "keyword" {
|
||||
return "", "", invalid("kind 必须是 char 或 keyword")
|
||||
}
|
||||
keyword = strings.TrimSpace(keyword)
|
||||
if keyword == "" {
|
||||
return "", "", invalid("关键词不能为空")
|
||||
}
|
||||
if sybshop.ContainsControl(keyword) {
|
||||
return "", "", invalid("关键词不能包含控制字符")
|
||||
}
|
||||
return keyword, sybshop.Normalize(keyword), nil
|
||||
}
|
||||
func (s *Service) Create(ctx context.Context, r CreateRequest) (models.SYBProductFilter, error) {
|
||||
if r.Kind == "char" {
|
||||
return models.SYBProductFilter{}, &ServiceError{Code: CodeForbidden, Message: "结构过滤不可新增"}
|
||||
}
|
||||
k, n, e := validate(r.Kind, r.Keyword)
|
||||
if e != nil {
|
||||
return models.SYBProductFilter{}, e
|
||||
}
|
||||
var x models.SYBProductFilter
|
||||
if err := s.DB.WithContext(ctx).Where("kind = ? AND normalized_keyword = ?", r.Kind, n).First(&x).Error; err == nil {
|
||||
return models.SYBProductFilter{}, &ServiceError{Code: CodeConflict, Message: fmt.Sprintf("该关键词已存在(归一化后与「%s」相同)", x.Keyword)}
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return models.SYBProductFilter{}, internal(err)
|
||||
}
|
||||
en := true
|
||||
if r.Enabled != nil {
|
||||
en = *r.Enabled
|
||||
}
|
||||
x = models.SYBProductFilter{Kind: r.Kind, Keyword: k, NormalizedKeyword: n, Enabled: en, Note: r.Note}
|
||||
if err := s.DB.WithContext(ctx).Create(&x).Error; err != nil {
|
||||
return models.SYBProductFilter{}, internal(err)
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
type SetEnabledRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *Service) SetEnabled(ctx context.Context, id uint64, r SetEnabledRequest, operator string) (models.SYBProductFilter, error) {
|
||||
var x models.SYBProductFilter
|
||||
if err := s.DB.WithContext(ctx).First(&x, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return x, &ServiceError{Code: CodeNotFound, Message: "过滤规则不存在"}
|
||||
}
|
||||
return x, internal(err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
updates := map[string]any{"enabled": r.Enabled, "updated_at": now}
|
||||
if x.Kind == "char" {
|
||||
updates["last_changed_by"] = strings.TrimSpace(operator)
|
||||
updates["last_changed_at"] = now
|
||||
}
|
||||
if err := s.DB.WithContext(ctx).Model(&x).Updates(updates).Error; err != nil {
|
||||
return x, internal(err)
|
||||
}
|
||||
x.Enabled = r.Enabled
|
||||
x.UpdatedAt = now
|
||||
if x.Kind == "char" {
|
||||
x.LastChangedBy = strings.TrimSpace(operator)
|
||||
x.LastChangedAt = &now
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
func (s *Service) Delete(ctx context.Context, id uint64) error {
|
||||
return s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var x models.SYBProductFilter
|
||||
if err := tx.First(&x, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &ServiceError{Code: CodeNotFound, Message: "过滤规则不存在"}
|
||||
}
|
||||
return internal(err)
|
||||
}
|
||||
if x.Kind == "char" {
|
||||
return &ServiceError{Code: CodeForbidden, Message: "结构过滤不可删除"}
|
||||
}
|
||||
return tx.Model(&x).Updates(map[string]any{"deleted_flag": x.ID, "deleted_at": time.Now().UTC()}).Error
|
||||
})
|
||||
}
|
||||
|
||||
type RuleSet struct{ Chars, Keywords []models.SYBProductFilter }
|
||||
|
||||
func LoadEnabled(ctx context.Context, db *gorm.DB) (RuleSet, error) {
|
||||
var rows []models.SYBProductFilter
|
||||
if err := db.WithContext(ctx).Where("enabled = ?", true).Find(&rows).Error; err != nil {
|
||||
return RuleSet{}, err
|
||||
}
|
||||
r := RuleSet{}
|
||||
for _, x := range rows {
|
||||
if x.Kind == "char" {
|
||||
r.Chars = append(r.Chars, x)
|
||||
} else {
|
||||
r.Keywords = append(r.Keywords, x)
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Match reports which enabled rule first matched v, or nil when none did.
|
||||
//
|
||||
// It returns the matching rule rather than a pair of booleans because the
|
||||
// caller has to attribute the hit to one specific rule: every rule keeps its
|
||||
// own LastHitCount, and the disable-confirmation dialog quotes that per-rule
|
||||
// number ("停用「#」会让约 N 条恢复入库"). Collapsing the result to
|
||||
// char/keyword booleans would force every rule of a kind to share one
|
||||
// aggregate, and the dialog would then warn with the wrong figure — on real
|
||||
// data "#" matches 8498 rows and "-" matches 6392, so the two must never be
|
||||
// reported as the same number.
|
||||
//
|
||||
// Structural rules are checked before keyword rules so a hit is attributed the
|
||||
// way the page presents the two sections, and an empty variationSku never
|
||||
// matches: it is a product with no supplier code yet, not a 档口 item.
|
||||
func (r RuleSet) Match(v string) *models.SYBProductFilter {
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
n := sybshop.Normalize(v)
|
||||
for i, x := range r.Chars {
|
||||
if strings.Contains(n, x.NormalizedKeyword) {
|
||||
return &r.Chars[i]
|
||||
}
|
||||
}
|
||||
for i, x := range r.Keywords {
|
||||
if strings.Contains(n, x.NormalizedKeyword) {
|
||||
return &r.Keywords[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HitCounter accumulates per-rule hits over one sync run, keyed by rule id.
|
||||
type HitCounter map[uint64]int
|
||||
|
||||
// Add records one hit against the rule that matched.
|
||||
func (h HitCounter) Add(rule *models.SYBProductFilter) {
|
||||
if rule == nil {
|
||||
return
|
||||
}
|
||||
h[rule.ID]++
|
||||
}
|
||||
|
||||
// UpdateHits writes each rule's own hit count back to its row.
|
||||
//
|
||||
// Every rule in the set is written, including those with zero hits: leaving a
|
||||
// previous run's number in place would make the confirmation dialog quote a
|
||||
// figure that no longer describes the current data.
|
||||
func UpdateHits(ctx context.Context, db *gorm.DB, r RuleSet, hits HitCounter, at time.Time) error {
|
||||
for _, x := range append(append([]models.SYBProductFilter{}, r.Chars...), r.Keywords...) {
|
||||
if err := db.WithContext(ctx).Model(&models.SYBProductFilter{}).Where("id = ?", x.ID).
|
||||
Updates(map[string]any{"last_hit_count": hits[x.ID], "last_hit_at": at}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package sybproductfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go-admin/app/goauto/migrations"
|
||||
"go-admin/app/goauto/models"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testDB(t *testing.T) *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = migrations.Migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestMatchORAndNormalization(t *testing.T) {
|
||||
r := RuleSet{
|
||||
Chars: []models.SYBProductFilter{{ID: 1, Kind: "char", NormalizedKeyword: "-"}, {ID: 2, Kind: "char", NormalizedKeyword: "#"}},
|
||||
Keywords: []models.SYBProductFilter{{ID: 3, Kind: "keyword", NormalizedKeyword: "cvc短袖220斤"}},
|
||||
}
|
||||
// `[必须]` OR, never AND. Both of these contain "#" but no "-", and on real
|
||||
// production data that difference is 2118 rows (#269).
|
||||
for _, v := range []string{"DD#004", "300斤牛奶絲圓領#A057"} {
|
||||
hit := r.Match(v)
|
||||
if hit == nil || hit.Kind != "char" {
|
||||
t.Fatalf("%q should match a structural rule, got %v", v, hit)
|
||||
}
|
||||
if hit.ID != 2 {
|
||||
t.Fatalf("%q should be attributed to the \"#\" rule, got id %d", v, hit.ID)
|
||||
}
|
||||
}
|
||||
if hit := r.Match("新寮-雅伊阁大码女装"); hit == nil || hit.ID != 1 {
|
||||
t.Fatalf("dash-only value should be attributed to the \"-\" rule, got %v", hit)
|
||||
}
|
||||
if hit := r.Match(""); hit != nil {
|
||||
t.Fatal("empty variationSku must not match: it is a product without a supplier code")
|
||||
}
|
||||
if hit := r.Match("PDD0582"); hit != nil {
|
||||
t.Fatalf("own product code must not match, got %v", hit)
|
||||
}
|
||||
if hit := r.Match(" CVC短袖220斤 "); hit == nil || hit.Kind != "keyword" {
|
||||
t.Fatalf("full-width and surrounding spaces should normalize to a keyword hit, got %v", hit)
|
||||
}
|
||||
if hit := r.Match("cvc短袖220斤"); hit == nil || hit.Kind != "keyword" {
|
||||
t.Fatalf("lowercase should normalize to a keyword hit, got %v", hit)
|
||||
}
|
||||
if hit := r.Match("纯棉"); hit != nil {
|
||||
t.Fatal("normalization must not convert traditional to simplified Chinese")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateHitsIsPerRule pins the rule that每条规则各记各的命中数. Writing one
|
||||
// aggregate per kind would make the disable-confirmation dialog quote the same
|
||||
// figure for "#" and "-" even though they match very different numbers of rows.
|
||||
func TestUpdateHitsIsPerRule(t *testing.T) {
|
||||
db := testDB(t)
|
||||
ctx := context.Background()
|
||||
set, err := LoadEnabled(ctx, db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(set.Chars) != 2 {
|
||||
t.Fatalf("migration should seed 2 structural rules, got %d", len(set.Chars))
|
||||
}
|
||||
byKeyword := map[string]uint64{}
|
||||
for _, x := range set.Chars {
|
||||
byKeyword[x.Keyword] = x.ID
|
||||
}
|
||||
hits := HitCounter{}
|
||||
for i := 0; i < 8498; i++ {
|
||||
hits[byKeyword["#"]]++
|
||||
}
|
||||
for i := 0; i < 6392; i++ {
|
||||
hits[byKeyword["-"]]++
|
||||
}
|
||||
if err := UpdateHits(ctx, db, set, hits, time.Now().UTC()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows []models.SYBProductFilter
|
||||
if err := db.Find(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]int{"#": 8498, "-": 6392}
|
||||
for _, x := range rows {
|
||||
if x.LastHitCount == nil {
|
||||
t.Fatalf("rule %q must be written even when it matched nothing", x.Keyword)
|
||||
}
|
||||
if w, ok := want[x.Keyword]; ok {
|
||||
if *x.LastHitCount != w {
|
||||
t.Fatalf("rule %q should record its own %d hits, got %d", x.Keyword, w, *x.LastHitCount)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if *x.LastHitCount != 0 {
|
||||
t.Fatalf("keyword rule %q matched nothing and must record 0, got %d", x.Keyword, *x.LastHitCount)
|
||||
}
|
||||
if x.LastHitAt == nil {
|
||||
t.Fatalf("rule %q must still be stamped so a stale count is never left behind", x.Keyword)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCharCreateDeleteRejectedAndKeywordDuplicate(t *testing.T) {
|
||||
db := testDB(t)
|
||||
s := NewService(db)
|
||||
ctx := context.Background()
|
||||
if _, e := s.Create(ctx, CreateRequest{Kind: "char", Keyword: "@"}); e == nil {
|
||||
t.Fatal("char create must be rejected")
|
||||
}
|
||||
if e := s.Delete(ctx, 1); e == nil {
|
||||
t.Fatal("seed char delete must be rejected")
|
||||
}
|
||||
if _, e := s.Create(ctx, CreateRequest{Kind: "keyword", Keyword: " CVC "}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if _, e := s.Create(ctx, CreateRequest{Kind: "keyword", Keyword: "cvc"}); e == nil {
|
||||
t.Fatal("normalized duplicate must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/app/goauto/sybshop"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(file), migrateSYBProductFilterTable)
|
||||
}
|
||||
|
||||
// migrateSYBProductFilterTable creates syb_product_filter and seeds its rules
|
||||
// for #269.
|
||||
//
|
||||
// `[必须]` Adding the model to migrations.Migrate only covers databases created
|
||||
// from scratch. Every existing database already has the older version numbers
|
||||
// recorded in sys_migration, so that list never runs again there — a new model
|
||||
// needs its own version file, which is what the post-migration table check in
|
||||
// cmd/migrate enforces.
|
||||
//
|
||||
// The seed is deliberately the same set the fresh-database path writes, created
|
||||
// with FirstOrCreate on the normalized key so running both paths against one
|
||||
// database cannot produce duplicates.
|
||||
func migrateSYBProductFilterTable(db *gorm.DB, version string) error {
|
||||
if err := db.AutoMigrate(&models.SYBProductFilter{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
// 结构判据 is what defines a 档口 product; the keywords are the forward
|
||||
// safety net for the day those codes stop carrying "#" (#269).
|
||||
seed := []struct{ kind, keyword string }{
|
||||
{"char", "-"},
|
||||
{"char", "#"},
|
||||
{"keyword", "CVC短袖220斤"},
|
||||
{"keyword", "180克純棉圓領短袖"},
|
||||
{"keyword", "拼色純棉短袖"},
|
||||
{"keyword", "300斤牛奶絲圓領"},
|
||||
{"keyword", "印花大學T"},
|
||||
{"keyword", "印花帽T"},
|
||||
}
|
||||
for _, item := range seed {
|
||||
norm := sybshop.Normalize(item.keyword)
|
||||
row := models.SYBProductFilter{
|
||||
Kind: item.kind,
|
||||
Keyword: item.keyword,
|
||||
NormalizedKeyword: norm,
|
||||
Enabled: true,
|
||||
}
|
||||
if err := tx.Where("kind = ? AND normalized_keyword = ?", item.kind, norm).
|
||||
FirstOrCreate(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"go-admin/app/goauto/access"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
migrationmodels "go-admin/cmd/migrate/migration/models"
|
||||
common "go-admin/common/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(file), migrateSYBProductFilterMenu)
|
||||
}
|
||||
|
||||
// migrateSYBProductFilterMenu adds the 「SYB 商品过滤」 menu entry for #269.
|
||||
//
|
||||
// `[必须]` Adding a module to access.GoAutoModules() is not enough to make its
|
||||
// page reachable. Menus are written by the versioned migration that first
|
||||
// created them, and that version is already recorded in sys_migration on every
|
||||
// existing database — it never runs again. Startup only reconciles the casbin
|
||||
// API matrix (access.ReconcilePurchaserPermissions), not sys_menu. Without this
|
||||
// migration the page exists, the API answers and the route resolves, yet nobody
|
||||
// can navigate to it on an already-migrated database.
|
||||
func migrateSYBProductFilterMenu(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
module, ok := goAutoModuleByKey(access.ModuleSYBProductFilters)
|
||||
if !ok {
|
||||
return fmt.Errorf("module %q is not registered in access.GoAutoModules()", access.ModuleSYBProductFilters)
|
||||
}
|
||||
|
||||
// The page belongs beside 「SYB 店铺」 under 采采管理, which is where the
|
||||
// admin already manages which SYB data enters the system.
|
||||
var parent migrationmodels.SysMenu
|
||||
if err := tx.Where("menu_name = ?", "GoAutoCollectionManagement").First(&parent).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
child, _, err := upsertGoAutoMenu(tx, migrationmodels.SysMenu{
|
||||
MenuName: module.RouteName,
|
||||
Title: module.Title,
|
||||
Icon: module.Icon,
|
||||
Path: module.Path + "/index",
|
||||
MenuType: "C",
|
||||
Action: "无",
|
||||
ParentId: parent.MenuId,
|
||||
Component: module.Component,
|
||||
Sort: module.Sort,
|
||||
Visible: "0",
|
||||
IsFrame: "1",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Model(&child).Update("paths", fmt.Sprintf("/0/%d/%d", parent.MenuId, child.MenuId)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Bind to both roles the module declares. 采购员 needs it because the
|
||||
// module is PurchaserDefault; 管理员 needs it because only an admin may
|
||||
// change the rules (the write routes are RequireRoleKey("admin")).
|
||||
roleKeys := []string{"admin"}
|
||||
if module.PurchaserDefault {
|
||||
roleKeys = append(roleKeys, access.RolePurchaser)
|
||||
}
|
||||
for _, key := range roleKeys {
|
||||
var role migrationmodels.SysRole
|
||||
if err = tx.Where("role_key = ?", key).First(&role).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Model(&role).Association("SysMenu").Append(&child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func goAutoModuleByKey(key string) (access.ModuleDefinition, bool) {
|
||||
for _, module := range access.GoAutoModules() {
|
||||
if module.Key == key {
|
||||
return module, true
|
||||
}
|
||||
}
|
||||
return access.ModuleDefinition{}, false
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"go-admin/app/goauto/models"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() { migration.Migrate.SetVersion("1789200000000", migrateSYBParseStatusBinary) }
|
||||
|
||||
// migrateSYBParseStatusBinary folds legacy uncertain rows into failed and
|
||||
// narrows the check constraint to the two supported states (#272).
|
||||
func migrateSYBParseStatusBinary(db *gorm.DB, version string) error {
|
||||
if err := db.Model(&models.SYBProduct{}).Where("parse_status = ?", "uncertain").Update("parse_status", models.SYBParseStatusFailed).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
if err := db.Exec("ALTER TABLE syb_product DROP CHECK ck_syb_product_parse_status").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec("ALTER TABLE syb_product ADD CONSTRAINT ck_syb_product_parse_status CHECK (parse_status IN ('success','failed'))").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return db.Create(&common.Migration{Version: version}).Error
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import request from '@/utils/request'
|
||||
const base = '/api/admin/v1/syb-product-filters'
|
||||
export function listSybProductFilters(params) { return request({ url: base, method: 'get', params }) }
|
||||
export function createSybProductFilter(data) { return request({ url: base, method: 'post', data }) }
|
||||
export function setSybProductFilterEnabled(id, data) { return request({ url: `${base}/${id}/enabled`, method: 'patch', data }) }
|
||||
export function deleteSybProductFilter(id) { return request({ url: `${base}/${id}`, method: 'delete' }) }
|
||||
@@ -0,0 +1,315 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="page-card" shadow="never">
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="load">
|
||||
<el-form-item label="搜索">
|
||||
<el-input v-model="query.keyword" clearable placeholder="字符或关键词" @keyup.enter="load" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert
|
||||
v-if="!isAdmin"
|
||||
title="当前账号只有查看权限。新增、启用、停用和删除关键词需要管理员操作。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="notice"
|
||||
/>
|
||||
<el-alert
|
||||
title="关键词匹配 variationSku,不匹配商品标题。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="notice"
|
||||
/>
|
||||
|
||||
<section>
|
||||
<h3>结构过滤(不可增删,仅可停用)</h3>
|
||||
<p class="muted">这两个字符是识别档口商品的判据,命中即不入库。需要变更判据请另行建单评估。</p>
|
||||
<el-table v-loading="loading" :data="chars" row-key="id" border stripe>
|
||||
<el-table-column label="字符" prop="keyword" width="180" />
|
||||
<el-table-column label="状态" width="150">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-if="isAdmin"
|
||||
v-model="row.enabled"
|
||||
:loading="row.updating"
|
||||
active-text="启用"
|
||||
inactive-text="停用"
|
||||
inline-prompt
|
||||
:aria-label="`${row.keyword}结构过滤状态`"
|
||||
@change="toggle(row)"
|
||||
/>
|
||||
<el-tag v-else :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '已启用' : '已停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上次同步命中" width="150">
|
||||
<template #default="{ row }">{{ hitText(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近变更" min-width="240">
|
||||
<template #default="{ row }">{{ changeText(row) }}</template>
|
||||
</el-table-column>
|
||||
<!-- 结构规则不可增删(#269):这里必须是纯文字,不能是看起来可点的按钮。 -->
|
||||
<el-table-column label="操作" width="110" fixed="right">
|
||||
<template #default><span class="disabled-text">不可删除</span></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="keyword-section">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<h3>关键词过滤</h3>
|
||||
<p class="muted">在结构过滤之后执行,命中数为结构过滤未覆盖的部分。</p>
|
||||
</div>
|
||||
<el-tooltip :disabled="isAdmin" content="只有管理员可以配置关键词" placement="bottom">
|
||||
<span>
|
||||
<el-button type="primary" :disabled="!isAdmin" @click="openEditor">新增关键词</el-button>
|
||||
</span>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="keywords"
|
||||
row-key="id"
|
||||
border
|
||||
stripe
|
||||
empty-text="还没有配置关键词过滤。上方结构过滤仍然生效。"
|
||||
>
|
||||
<el-table-column label="关键词" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<div class="primary">{{ row.keyword }}</div>
|
||||
<div class="muted">匹配时忽略首尾空白、全半角和大小写</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-if="isAdmin"
|
||||
v-model="row.enabled"
|
||||
:loading="row.updating"
|
||||
active-text="启用"
|
||||
inactive-text="停用"
|
||||
inline-prompt
|
||||
:aria-label="`${row.keyword}状态`"
|
||||
@change="toggle(row)"
|
||||
/>
|
||||
<el-tag v-else :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '已启用' : '已停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上次同步命中" width="150">
|
||||
<template #default="{ row }">{{ hitText(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="180">
|
||||
<template #default="{ row }">{{ row.note || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="isAdmin" type="danger" link @click="remove(row)">删除</el-button>
|
||||
<span v-else class="muted">只读</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<p class="scope-note">停用或删除只影响后续同步,已经入库的商品明细不会被删除。</p>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="editor.open" title="新增关键词" width="520px" :close-on-click-modal="false" @closed="resetEditor">
|
||||
<el-alert
|
||||
title="关键词匹配 variationSku,不匹配商品标题。系统会去掉首尾空白、统一全半角并忽略大小写;「CVC短袖220斤」「CVC短袖220斤」「cvc短袖220斤」视为同一条,重复添加会被拒绝。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="notice"
|
||||
/>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="editor.keyword" maxlength="200" show-word-limit placeholder="请输入关键词" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注(可空)">
|
||||
<el-input v-model="editor.note" maxlength="200" placeholder="添加理由,可空" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="editor.saving" @click="editor.open = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editor.saving" @click="save">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
listSybProductFilters,
|
||||
createSybProductFilter,
|
||||
setSybProductFilterEnabled,
|
||||
deleteSybProductFilter
|
||||
} from '@/api/goauto/syb-product-filters'
|
||||
|
||||
export default {
|
||||
name: 'GoAutoSybProductFilters',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
chars: [],
|
||||
keywords: [],
|
||||
query: { keyword: '' },
|
||||
editor: { open: false, saving: false, keyword: '', note: '' }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdmin() {
|
||||
return (this.$store.getters.roles || []).includes('admin')
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.load()
|
||||
},
|
||||
methods: {
|
||||
async load() {
|
||||
this.loading = true
|
||||
try {
|
||||
const res = await listSybProductFilters({
|
||||
page: 1,
|
||||
pageSize: 200,
|
||||
keyword: this.query.keyword || undefined
|
||||
})
|
||||
const rows = (res.data.items || []).map(row => ({ ...row, updating: false }))
|
||||
this.chars = rows.filter(row => row.kind === 'char')
|
||||
this.keywords = rows.filter(row => row.kind === 'keyword')
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
reset() {
|
||||
this.query = { keyword: '' }
|
||||
this.load()
|
||||
},
|
||||
formatTime(value) {
|
||||
if (!value) return ''
|
||||
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
|
||||
},
|
||||
// 命中数由同步回写;从未同步过时不要显示 0,那会被读成「一条都没命中」。
|
||||
hitText(row) {
|
||||
return row.lastHitCount == null ? '等待同步记录' : `${row.lastHitCount} 条`
|
||||
},
|
||||
changeText(row) {
|
||||
if (!row.lastChangedAt) return '尚未记录'
|
||||
const who = row.lastChangedBy || '-'
|
||||
return `${this.formatTime(row.lastChangedAt)} ${who} ${row.enabled ? '启用' : '停用'}`
|
||||
},
|
||||
// 停用结构规则会让大量档口明细恢复入库,所以必须二次确认。
|
||||
// 条数取该行实时的 lastHitCount,不能写死:数据会变,写死的警告会骗人(#269)。
|
||||
async confirmDisableChar(row) {
|
||||
const scale = row.lastHitCount == null ? '尚不清楚有多少' : `约 ${row.lastHitCount} 条`
|
||||
await ElMessageBox.confirm(
|
||||
`停用「${row.keyword}」后,SYB 同步将不再过滤含 ${row.keyword} 的档口商品。按最近一次同步数据估算,${scale}明细会恢复入库。`,
|
||||
`确认停用结构过滤「${row.keyword}」?`,
|
||||
{ type: 'warning', confirmButtonText: '确认停用', cancelButtonText: '取消', confirmButtonClass: 'el-button--danger' }
|
||||
)
|
||||
},
|
||||
async toggle(row) {
|
||||
const target = row.enabled
|
||||
if (row.kind === 'char' && !target) {
|
||||
try {
|
||||
await this.confirmDisableChar(row)
|
||||
} catch (e) {
|
||||
row.enabled = true
|
||||
return
|
||||
}
|
||||
}
|
||||
row.updating = true
|
||||
try {
|
||||
await setSybProductFilterEnabled(row.id, { enabled: target })
|
||||
ElMessage.success(target ? '规则已启用' : '规则已停用')
|
||||
} catch (e) {
|
||||
row.enabled = !target
|
||||
throw e
|
||||
} finally {
|
||||
row.updating = false
|
||||
}
|
||||
},
|
||||
openEditor() {
|
||||
this.editor.open = true
|
||||
},
|
||||
resetEditor() {
|
||||
this.editor = { open: false, saving: false, keyword: '', note: '' }
|
||||
},
|
||||
async save() {
|
||||
const keyword = this.editor.keyword.trim()
|
||||
if (!keyword) {
|
||||
ElMessage.warning('请输入关键词')
|
||||
return
|
||||
}
|
||||
this.editor.saving = true
|
||||
try {
|
||||
await createSybProductFilter({ kind: 'keyword', keyword, note: this.editor.note || undefined })
|
||||
ElMessage.success('关键词已添加')
|
||||
this.editor.open = false
|
||||
this.load()
|
||||
} finally {
|
||||
this.editor.saving = false
|
||||
}
|
||||
},
|
||||
async remove(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`删除关键词「${row.keyword}」?`, '删除关键词', { type: 'warning' })
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
await deleteSybProductFilter(row.id)
|
||||
ElMessage.success('关键词已删除')
|
||||
this.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-card {
|
||||
min-height: calc(100vh - 124px);
|
||||
}
|
||||
.search-form {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.notice {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
section {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.primary {
|
||||
color: #303133;
|
||||
}
|
||||
.muted,
|
||||
.scope-note {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.disabled-text {
|
||||
font-size: 13px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.keyword-section {
|
||||
border-top: 1px solid #ebeef5;
|
||||
padding-top: 20px;
|
||||
}
|
||||
.scope-note {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -10,7 +10,7 @@
|
||||
</el-autocomplete>
|
||||
</el-form-item>
|
||||
<el-form-item label="订单号"><el-input v-model="query.orderCodesText" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" resize="none" class="order-codes-input" placeholder="每行一个订单号,最多 100 个" @keydown.ctrl.enter.prevent="search" /></el-form-item>
|
||||
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="存疑" value="uncertain" /><el-option label="失败" value="failed" /></el-select></el-form-item>
|
||||
<el-form-item label="解析状态"><el-select v-model="query.parseStatus" clearable placeholder="全部" style="width: 110px"><el-option label="成功" value="success" /><el-option label="失败" value="failed" /></el-select></el-form-item>
|
||||
<el-form-item label="处理阶段"><el-select v-model="query.processStage" clearable placeholder="全部" style="width: 132px"><el-option v-for="item in processStageOptions" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
|
||||
<el-form-item class="toolbar-actions"><el-button type="primary" :icon="Search" @click="search">查询</el-button><el-button :icon="RefreshLeft" @click="reset">重置</el-button><el-tooltip v-if="canPurchase" :content="aiMatchButtonReason" :disabled="!aiMatchButtonReason" placement="top"><span class="action-button-wrap"><el-button :loading="specMatchLoading" :disabled="purchaseReadinessLoading || specMatchLoading || aiMatchCandidates.length === 0" @click="runBatchSpecMatch">AI 匹配<span class="action-count">{{ aiMatchCandidates.length }}</span></el-button></span></el-tooltip><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || purchaseCandidates.length === 0" @click="openPurchaseBatch">创建采购<span class="action-count action-count-primary">{{ purchaseCandidates.length }}</span></el-button><el-button v-if="canPurchase" type="primary" :loading="purchaseReadinessLoading" :disabled="purchaseReadinessLoading || specMatchLoading || collectionCandidates.length === 0" @click="openCollectionBatch">创建采集<span class="action-count action-count-primary">{{ collectionCandidates.length }}</span></el-button></el-form-item>
|
||||
</el-form>
|
||||
@@ -160,8 +160,8 @@
|
||||
<el-drawer v-model="detail.open" title="SYB 商品明细详情" size="960px">
|
||||
<div v-loading="detail.loading" class="drawer-body">
|
||||
<template v-if="detail.item">
|
||||
<el-alert v-if="detail.item.parseStatus !== 'success'" :title="detail.item.parseStatus === 'failed' ? '解析失败:' + detail.item.parseNote : '解析存疑:' + detail.item.parseNote" :type="detail.item.parseStatus === 'failed' ? 'error' : 'warning'" :closable="false" show-icon class="notice" />
|
||||
<p class="muted purchase-note">存疑不阻断采购:采购时由服务端 AI 拿 SYB 原始颜色尺码匹配 PDD 实际规格,不经过虾皮档案映射表。人工修正可提高匹配准确度,但不是采购的前置条件;失败必须处理,否则采购会明确失败。</p>
|
||||
<el-alert v-if="detail.item.parseStatus !== 'success'" :title="'解析失败:' + detail.item.parseNote" type="error" :closable="false" show-icon class="notice" />
|
||||
<p class="muted purchase-note">解析失败时由服务端 AI 根据 SYB 原始颜色尺码匹配 PDD 实际规格;解析失败不会自动写入虾皮档案映射。</p>
|
||||
|
||||
<div class="split">
|
||||
<div class="split-col">
|
||||
@@ -358,7 +358,7 @@ export default {
|
||||
}
|
||||
},
|
||||
priceText(row) { if (row.unitPriceCent === null || row.unitPriceCent === undefined) return '—'; return `¥${(row.unitPriceCent / 100).toFixed(2)}` },
|
||||
parseMeta(status) { return { success: { label: '成功', type: 'success' }, uncertain: { label: '存疑', type: 'warning' }, failed: { label: '失败', type: 'danger' }}[status] || { label: status || '-', type: 'info' } },
|
||||
parseMeta(status) { return { success: { label: '成功', type: 'success' }, failed: { label: '失败', type: 'danger' }}[status] || { label: status || '-', type: 'info' } },
|
||||
openShopeeDetail(shopeeProductId, targetColor = '', action = '') {
|
||||
if (!shopeeProductId) { ElMessage.warning('该明细尚未关联虾皮商品'); return }
|
||||
this.shopeeDetail = { open: true, productId: Number(shopeeProductId), targetColor: String(targetColor || '').trim(), action: String(action || '') }
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
<el-descriptions-item label="商品明细">{{ detail.item.detailCount }} 条</el-descriptions-item>
|
||||
<el-descriptions-item label="符合店铺">{{ detail.item.acceptedCount }} 张</el-descriptions-item>
|
||||
<el-descriptions-item label="跳过店铺">{{ detail.item.shopSkipped }} 张</el-descriptions-item>
|
||||
<el-descriptions-item label="结构过滤命中">{{ detail.item.charFilterSkipped || 0 }} 条</el-descriptions-item>
|
||||
<el-descriptions-item label="关键词过滤命中">{{ detail.item.keywordFilterSkipped || 0 }} 条</el-descriptions-item>
|
||||
<el-descriptions-item label="新增 / 覆盖">{{ detail.item.created }} / {{ detail.item.updated }}</el-descriptions-item>
|
||||
<el-descriptions-item label="操作人">{{ detail.item.operatorName || `用户 ${detail.item.operatorId || '-'}` }}</el-descriptions-item>
|
||||
<el-descriptions-item label="开始时间">{{ formatTime(detail.item.startedAt) }}</el-descriptions-item>
|
||||
|
||||
Reference in New Issue
Block a user