diff --git a/docs/00-project-profile.md b/docs/00-project-profile.md index 71dc5e4..0b753c9 100644 --- a/docs/00-project-profile.md +++ b/docs/00-project-profile.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Project-Profile wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Project-Profile.- -wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0 -synchronized_at: 2026-09-04T11:29:41Z +wiki_revision: 7468b9fbdd4d0bbbb9a73580c22ec868b3085753 +synchronized_at: 2026-09-05T07:16:39Z # 项目档案 @@ -21,16 +21,20 @@ synchronized_at: 2026-09-04T11:29:41Z | 后续设计范围 | 从 SYB 创建采购任务、创建待付款订单、物流采集与自动回填 | | 预计规模 | 20 台 Android;每天约 100 个采集任务、200 个采购任务 | +## 项目治理模式 + +GoAuto 默认采用轻量治理:文案、注释、格式、局部样式或布局、预期行为明确的小 Bug,以及不改变接口、数据结构、权限和安全边界的单模块低风险调整可以直接实施,无需为了留痕补建工单。完整独立需求、新页面、跨模块功能,以及涉及 API、数据结构、权限、安全、迁移或范围不明确的变化必须建立单元工单。采购、创建订单、真实个人或生产数据、权限、安全、并发、迁移、删除、发布和不可逆操作始终升级为高风险,必须具备对象和范围明确的人工授权;已有有效授权时不机械重复确认,范围或环境发生实质变化时重新确认。永久禁止付款以及设备、隐私、AI 和真机安全红线不可裁剪。 + ## 建设基线 | 基线 | 来源与版本 | 许可证 / 使用方式 | GoAuto 适配 | |---|---|---|---| -| DevHarness | `D:\OPC\dev_harness`,目标提交 `4bbacf4d7fb265984396bb5589c544105043fa0b` | 开发流程与文档模板 | 2026-08-27 升级(#114):在 #76 已采用的单人工单事实源基础上,增量加入部署模板、Gitea MCP 与最小工单读取、线上原型默认审核与按需 HTML 导出、PowerShell UTF-8/ExecutionPolicy 边界;继续保留 GoAuto 专用安全与设计门禁 | +| DevHarness | `D:\OPC\dev_harness`,目标提交 `ecab899` | 开发流程与文档模板 | 2026-09-05 升级(#221):在既有单人工单事实源基础上,选择性加入轻量治理、明确授权边界、中文默认沟通、Windows PowerShell 安全规则,以及按 revision 增量同步与显式深度检查;继续保留 GoAuto 专用高风险与安全门禁 | | 服务端 | `go-admin` v2.3.0 | 上游开源管理端基线;升级时复核许可证和安全公告 | 保留认证、菜单、配置和管理端基础能力,新增 GoAuto 业务模块 | | 管理端 | `go-admin-ui` v3.0.0,`web/package.json` 标注 MIT | Vue 管理界面基线 | 保留应用外壳与通用组件,新增 GoAuto 页面 | | Android | 原生 Kotlin Agent | 自研业务客户端 | 通过管理员明确配置的 HTTP 或 HTTPS Origin 直连服务端,不保留 Windows 桌面 Client/ADB 作为生产拓扑 | -升级必须比较当前记录的目标提交与新的明确提交,不能笼统复制“最新版”。本次上一基线为 `bfdf648962d11a8024f62768380d8571e1f45f68`,目标为 `4bbacf4d7fb265984396bb5589c544105043fa0b`,区间共 13 个提交;更早基线 `b1f500128d6eb100985792d4a715db8b6b5ae203` 的适配见 #47。模板内容一律按 GoAuto 事实改写:不复制 DevHarness 的项目事实、任务记录、占位部署参数或历史归档;`harness.py` 继续校验 GoAuto 实际核心页面与产品 README,GoAuto 更严格的付款、订单、设备、数据和真机门禁继续优先。 +升级必须比较当前记录的目标提交与新的明确提交,不能笼统复制“最新版”。本次上一基线为 `4bbacf4d7fb265984396bb5589c544105043fa0b`,目标为 `ecab899`,选择性适配其后的 7 个提交;更早基线适配见 #47 与 #114。模板内容一律按 GoAuto 事实改写:不复制 DevHarness 的项目事实、任务记录、占位部署参数或历史归档;`harness.py` 继续校验 GoAuto 实际核心页面与产品 README,GoAuto 更严格的付款、订单、设备、数据和真机门禁继续优先。 ## 交付单元 diff --git a/docs/01-workflow.md b/docs/01-workflow.md index b56be19..393e698 100644 --- a/docs/01-workflow.md +++ b/docs/01-workflow.md @@ -2,12 +2,18 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Development-Workflow wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Development-Workflow.- -wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0 -synchronized_at: 2026-09-04T11:29:46Z +wiki_revision: 62ddbe4469740c02ce4a6ca2fd1966a89a79322f +synchronized_at: 2026-09-05T07:16:44Z # 开发工作流 +## 语言与术语 + +- 用户可以使用中文、英文或合理的中英混合语言交流;默认使用中文分析、回复、编写工单和维护内部项目文档。 +- 代码标识符、命令、参数、路径、文件名、API 名称、协议名、日志和错误原文保持原样;必要时补充简短中文解释。 +- 用户明确要求某次回复或交付物使用其他语言时,按该次要求执行,不改写接口契约或影响搜索和执行的原文。 + ## 事实来源 | 信息 | 唯一事实来源 | @@ -18,7 +24,7 @@ synchronized_at: 2026-09-04T11:29:46Z | 源码、迁移、测试、版本绑定分析、本地原型和核心 Wiki 镜像 | Git | | 可编辑交互设计 | QuantUX;App ID、版本、链接和确认状态记录在工单 | -核心页面通过 `wiki-docs.json` 显式映射,固定执行 Wiki → `docs/` 单向同步。既有 Wiki 任务归档和 `docs/task/` 只作历史兼容;标准任务不创建或导出,只有用户明确要求专项快照时才使用 `archive` / `export`。 +核心页面通过 `wiki-docs.json` 显式映射,固定执行 Wiki → `docs/` 单向同步。日常 `sync` 与 `sync --check` 先比较页面 revision,revision 未变化时不重复下载正文;疑似镜像损坏或需要完整核对时显式使用 `sync --deep-check`。既有 Wiki 任务归档和 `docs/task/` 只作历史兼容;标准任务不创建或导出,只有用户明确要求专项快照时才使用 `archive` / `export`。 ## 权威源与事实边界 @@ -53,6 +59,19 @@ synchronized_at: 2026-09-04T11:29:46Z - 每个核心页面写入后必须在线回读并取得 revision。页面缺失、回读失败或没有 revision 时停止初始化。 - 产品编码前运行 `python dev_scripts/harness.py sync --verify`;全部成功才表示初始化完成。 +## 项目治理模式与不可裁剪底线 + +GoAuto 默认采用轻量治理。文案、注释、格式、局部样式或布局、预期行为明确的小 Bug,以及不改变接口、数据结构、权限和安全边界的单模块低风险调整可以直接实施,无需为了留痕补建工单。完整独立需求、新页面、跨模块功能,以及 API、数据结构、权限、安全、迁移或范围不明确的变化必须建单。 + +采购、创建订单、真实个人或生产数据、权限、安全、并发、数据库迁移、删除数据、发布和其他不可逆操作始终升级为高风险。无论任务采用何种最小门禁,凭据保护、永久禁止付款、个人与生产数据最小化、设备互斥、Agent 禁止 OCR/VLM、控件树与整屏截图禁存、工作区保护、真实测试和人工验收均不可裁剪。 + +### 明确授权后的执行 + +- 当前聊天中用户给出的明确指令,或 Gitea 工单中能够归属于有权人工的明确授权,可以作为执行依据,不要求把同一授权重复复制到工单后再次确认。 +- 授权必须能识别操作、对象和范围;Agent 自动生成的工单、草稿、摘要或对用户意图的转述不能单独构成人工授权。 +- 获得有效授权后,只核对准确目标、授权范围和当前状态等最小必要前提,不得仅因操作不可逆而重复询问或拒绝。 +- 授权不自动覆盖相邻对象或后续任务;环境、对象、范围或影响发生实质变化时重新确认。平台自身强制的审批、安全策略或权限限制继续有效。 + ## 工单与设计证据双门禁 正式实施前先判断是否需要工单,再判断需要什么设计证据。工单不能替代原型确认,原型也不能替代技术方案、安全检查和单元工单。 diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 6370d55..52beba9 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -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: 0761aa56d2eb0e5b544b1947336371e0716ae907 -synchronized_at: 2026-09-05T02:27:41Z +wiki_revision: 21ad1681d12a40e7042567f03716d899d49b737e +synchronized_at: 2026-09-05T07:16:58Z # 业务规则与术语 @@ -144,7 +144,7 @@ synchronized_at: 2026-09-05T02:27:41Z - SYB 商品页的 PDD 采集资格独立于采购处理阶段:只要已关联的 PDD 商品未停用、没有 `pending` / `running` 采集任务且存在可用采集规则,即可创建采集任务;因此已完成采集并进入“可创建采购”的商品也可以重新采集。相同 PDD 商品在批量创建前按商品去重,服务端创建时仍按最新状态复核。 - 批量创建读取管理员选定的当前采购规则并重新执行契约校验,每条任务保存不可变快照;当前规则缺失或无效时明确阻断,不回退到代码常量。设备默认人工指定,也可以留空由符合能力的空闲设备领取。 - 批量创建的价格保护来自 PDD 商品档案而不是 SYB/Shopee 的 TWD 售价:当前采购规则可用 `priceGuard.minRatio`(0.1~1.0)和 `maxRatio`(1.0~3.0)配置比例,缺省仍为 0.2 / 1.5;最低价向下取整、最高价向上取整到人民币分。已确认颜色映射时按该颜色计算,待规格探测时以全部可用颜色的最低/最高价计算,参考价取最高价;没有可用颜色价格时不能创建。 -- 地址后缀为 `_cg{purchase_task.id}`。选好规格和数量后,Agent 仍停留在当前规格/下单面板;若收货地址被面板裁切,只允许在唯一、可见且占据主要宽度的纵向滚动容器内有限向下拉动来显示地址,禁止按页面最大滚动区域盲目滑动。Agent 只在唯一地址入口、唯一修改按钮和唯一详细地址输入框均成立时修改;脱敏手机号文字本身不可点击时,仍以该唯一文字节点的中心坐标执行一次精确手势点击,不沿用可能覆盖整个下单面板的可点击祖先。“修改”“保存”和“提交订单”等文字节点即使依赖可点击父节点,也必须保留原始文字节点作为每次重新定位的锚点,父节点只用于验证存在可点击路径。近乎完全重叠的无障碍重复节点按一个目标处理,仍有多个独立目标或页面切换超时则明确失败。按首个 `-` 或 `_` 截取地址主体后追加当前后缀,保存后必须回读完整新地址。修改或回读失败时禁止创建订单,地址全文和控件树不落库。 +- 地址后缀为 `_cg{purchase_task.id}`。精确规格、数量、价格和摘要核验通过后,若仍处于已识别的规格面板,Agent 只允许点击唯一、可见、启用、可点击且文案精确命中“确定/确认”别名的规格确认控件;不得点击提交订单、确认购买、付款、支付、地址修改或地址保存控件,也不使用中心手势兜底。点击后必须重新读取并确认已出现订单确认页或地址入口强证据,否则明确失败。进入订单确认页后,若收货地址被面板裁切,只允许在唯一、可见且占据主要宽度的纵向滚动容器内有限向下拉动来显示地址,禁止按页面最大滚动区域盲目滑动。Agent 只在唯一地址入口、唯一修改按钮和唯一详细地址输入框均成立时修改;脱敏手机号文字本身不可点击时,仍以该唯一文字节点的中心坐标执行一次精确手势点击,不沿用可能覆盖整个下单面板的可点击祖先。“修改”“保存”和“提交订单”等文字节点即使依赖可点击父节点,也必须保留原始文字节点作为每次重新定位的锚点,父节点只用于验证存在可点击路径。近乎完全重叠的无障碍重复节点按一个目标处理,仍有多个独立目标或页面切换超时则明确失败。按首个 `-` 或 `_` 截取地址主体后追加当前后缀,保存后必须回读完整新地址。修改或回读失败时禁止创建订单,地址全文和控件树不落库。 - 地址编辑页可以同时存在收货人、手机号和详细地址等多个输入框;Agent 只选择与“详细地址”标签纵向重叠且位于其右侧的唯一输入框,不能用页面输入框总数或顺序猜测。 - 点击创建订单前,Agent 必须先在本地事务保存 `order_submit_started`、不可逆时间、稳定请求 ID 和不含地址全文的最终确认快照,再用同一请求 ID通知服务端;两侧成功后才允许精确点击唯一创建订单按钮一次。 - 进入不可逆边界后,进程重启、断网、点击结果不明或无法取得唯一订单号/下单时间时只允许只读核单并进入 `order_result_unknown`,禁止再次点击;任务与订单正式关联仍以完整 PDD 订单号为准。 @@ -429,3 +429,5 @@ synchronized_at: 2026-09-05T02:27:41Z - 管理员可在设备管理对未停用设备发起“重置设备身份”。此动作不删除设备记录、不改变设备 ID、能力或已绑定的待领取任务;它立即使旧 Token 无效,并只生成一次、有效期 10 分钟的恢复码。 - 恢复码仅显示给发起操作的管理员一次,服务端只保存摘要;不得进入列表、日志、任务记录、Android 持久化或普通接口。手机操作员必须在同一安装实例的 Agent 设置中手动输入。 - 服务端只接受同一 `installId`、未过期且尚未使用的恢复码完成重新注册,成功后签发新 Token 并使恢复码失效。过期、重复使用、installId 不符或停用均明确失败;不能通过清空数据、直接改库或“吊销 Token”恢复原任务归属。 + +- 自 #223 起,任务创建时把与冻结 SYB 目标对应的已确认商品规格映射保存为不可变的探测指导快照,但仍不得跳过首趟真机探测。探测完成后,每个角色先验证快照映射能否按既有规范化规则唯一对应当次候选,能对应时固化当次候选原文;不能对应时只对该未解决角色执行确定性匹配,仍无结果才调用 AI。已解决角色不重复交给 AI,任一最终值仍必须逐字属于当次候选;历史映射失效、规范化后歧义或角色不符时不得复用。候选完整但无法决策时提示“已采集到当前规格,但未能确定颜色或尺码映射”,不再误报候选不存在。 diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index 3ee2ac2..1a39f3e 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Local-Development-and-Verification wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Local-Development-and-Verification.- -wiki_revision: b1b1b343917e66288f4282bc6b3b90ea4ff3cca0 -synchronized_at: 2026-09-04T11:30:02Z +wiki_revision: 835494c4a63a1494601658561fde1ab76657be3d +synchronized_at: 2026-09-05T07:17:05Z # 本地开发与验证 @@ -24,6 +24,32 @@ T01 已建立可执行的三端骨架。建议从仓库根目录运行统一脚 - 不得仅为设置编码重复启动一层 PowerShell;嵌套进程会增加启动时间、转义复杂度和错误定位成本。 - 代码发现优先使用项目配置的代码图工具;检索字符串、配置和非代码文件,或图工具不足时使用 `rg`。 +### PowerShell 语法与外部命令 + +- Windows 命令不得默认套用 Bash 语法;复杂正则优先先赋给变量或使用 `rg -e`,避免在多层引号中继续嵌套。 +- 多行 Python 或 JSON 正文使用单引号 PowerShell here-string,避免 `$()`、反引号和变量被 PowerShell 提前展开: + +```powershell +$script = @' +print("保持原文") +'@ +$script | python - +``` + +- `foreach`、`if` 等语句块应保留在同一个 PowerShell 解析上下文中;需要收集表达式结果时使用数组表达式: + +```powershell +$items = @(foreach ($path in $paths) { + if (Test-Path -LiteralPath $path) { Get-Item -LiteralPath $path } +}) +``` + +- `rg` 使用真实目录配合 `-g/--glob`,不要把 Bash 风格通配路径作为目录参数: + +```powershell +rg -n -g '*.md' 'sync --check' docs +``` + ### 文件编码与控制台输出 文件解码和控制台输出是两个边界。读取 UTF-8 文本时,在命令支持的情况下显式指定字面路径与编码: diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index fcbb8e2..19efb1b 100644 --- a/docs/08-agent-api-contract.md +++ b/docs/08-agent-api-contract.md @@ -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: 08a65eb49d3d1091f0be30e6f6d1a9372f6e5b37 -synchronized_at: 2026-09-05T02:28:13Z +wiki_revision: e5a443e16e288143c2c890c674038c531abe3f16 +synchronized_at: 2026-09-05T07:17:37Z # MVP 共享 API 契约 @@ -865,3 +865,5 @@ X-GoAuto-Device-Recovery-Code: 仅管理员可以对未停用的既有设备发起身份重置。服务端立即使旧 Device Token 无效,并生成 10 分钟内仅能使用一次的恢复码;恢复码只在该管理员操作的响应中返回一次,服务端仅保存不可逆摘要,管理端设备列表、日志、任务接口和 Android 本地持久化均不得保存或返回原文。管理员将恢复码经受控人工渠道输入同一安装实例的 Agent 设置页。 Agent 携带既有 Token(可已失效)及恢复码重新调用注册接口。服务端必须同时校验同一 `installId`、未停用状态、恢复码摘要、未过期和未使用;成功后使用原 `deviceId` 写入新 Token 摘要并返回一次新 Token,清除恢复码摘要和有效期。旧 Token 与恢复码都立即失效,已分配的 pending 采集或采购任务保持原 `deviceId`,不创建替代设备记录。缺少或错误恢复码仍为 `DEVICE_INSTALL_ID_CONFLICT`;过期码为 `DEVICE_RECOVERY_EXPIRED`;停用设备为 `DEVICE_DISABLED`。 + +自 #223 起,新建 SYB 任务在保持 `mappedColor` / `mappedSize` 为空和首趟 `spec_probe` 不变的同时,把创建时与目标规格对应的 confirmed 商品映射冻结为仅供服务端决策的指导快照。服务端收到当次候选后按角色验证该快照:只有规范化后唯一对应当次候选时才复用,并固化当次候选原文;否则该角色继续执行确定性匹配,仍未解决才把该角色及其封闭候选交给 AI。已解决角色不得重复发送给 AI,最终颜色和尺码仍须逐字属于各自当次候选。任务决策快照通过 `roleSources` 记录每个角色的 `manual_mapping` / `exact_match` / `ai_match` 来源;任务级 `specSource` 使用现有枚举汇总,不新增 Agent 决策权限。 diff --git a/server/app/goauto/aimatching/types.go b/server/app/goauto/aimatching/types.go index 0c10e31..327533f 100644 --- a/server/app/goauto/aimatching/types.go +++ b/server/app/goauto/aimatching/types.go @@ -35,11 +35,12 @@ type CandidateSnapshot struct { } type DecisionSnapshot struct { - Source string `json:"source"` - Provider string `json:"provider,omitempty"` - Model string `json:"model,omitempty"` - Candidates CandidateSnapshot `json:"candidates"` - Matched struct { + Source string `json:"source"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + Candidates CandidateSnapshot `json:"candidates"` + RoleSources map[string]string `json:"roleSources,omitempty"` + Matched struct { Color string `json:"color,omitempty"` Size string `json:"size,omitempty"` } `json:"matched"` diff --git a/server/app/goauto/purchase/lifecycle.go b/server/app/goauto/purchase/lifecycle.go index 0fe6d07..543eb42 100644 --- a/server/app/goauto/purchase/lifecycle.go +++ b/server/app/goauto/purchase/lifecycle.go @@ -550,8 +550,8 @@ func (s *Service) resolveProbedSpecs(ctx context.Context, taskID uint64, attempt decision.NoMatch, decision.Decision = true, snapshot decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", "没有找到可采购的 PDD 颜色或尺码" } else { - matched, matchErr := s.matcher().Resolve(ctx, request) - valid := matchErr == nil && (matched.Source == aimatching.SourceExact || matched.Source == aimatching.SourceAI) && + matched, matchErr := s.resolveProbedMatch(ctx, task, request) + valid := matchErr == nil && (matched.Source == "manual_mapping" || matched.Source == aimatching.SourceExact || matched.Source == aimatching.SourceAI) && matchCandidateValid(request.TargetColor, matched.MappedColor, request.Colors) && matchCandidateValid(request.TargetSize, matched.MappedSize, request.Sizes) if valid { @@ -590,7 +590,7 @@ func purchaseMatchReason(err error) string { return "AI 规格匹配暂时不可用,请稍后重新创建采购任务" } } - return "没有找到可采购的 PDD 颜色或尺码" + return "已采集到当前规格,但未能确定颜色或尺码映射" } type probedSpecCandidates struct { diff --git a/server/app/goauto/purchase/probe_resolution.go b/server/app/goauto/purchase/probe_resolution.go new file mode 100644 index 0000000..c810798 --- /dev/null +++ b/server/app/goauto/purchase/probe_resolution.go @@ -0,0 +1,207 @@ +package purchase + +import ( + "context" + "encoding/json" + "strings" + + "go-admin/app/goauto/aimatching" + "go-admin/app/goauto/models" + "go-admin/app/goauto/shopeeproduct" +) + +// resolveProbedMatch keeps the live probe as the source of executable labels. +// Confirmed product mappings may guide the decision only when they still map +// uniquely to a label observed by this attempt. +func (s *Service) resolveProbedMatch(ctx context.Context, task models.PurchaseTask, request aimatching.MatchRequest) (aimatching.MatchResult, error) { + resolvedColor, resolvedSize := "", "" + roleSources := map[string]string{} + + mappings := probeGuidanceFromSnapshot(task.SpecDecisionSnapshot) + if candidate, ok := currentMappedCandidate(mappings.color.value, request.Colors); ok { + resolvedColor, roleSources[shopeeproduct.RoleColor] = candidate, mappings.color.source + } + if candidate, ok := currentMappedCandidate(mappings.size.value, request.Sizes); ok { + resolvedSize, roleSources[shopeeproduct.RoleSize] = candidate, mappings.size.source + } + + if task.TargetColorSnapshot != "" && resolvedColor == "" { + if exact, ok := aimatching.DeterministicMatch(aimatching.MatchRequest{TargetColor: task.TargetColorSnapshot, Colors: request.Colors}); ok { + resolvedColor, roleSources[shopeeproduct.RoleColor] = exact.MappedColor, aimatching.SourceExact + } + } + if task.TargetSizeSnapshot != "" && resolvedSize == "" { + if exact, ok := aimatching.DeterministicMatch(aimatching.MatchRequest{TargetSize: task.TargetSizeSnapshot, Sizes: request.Sizes}); ok { + resolvedSize, roleSources[shopeeproduct.RoleSize] = exact.MappedSize, aimatching.SourceExact + } + } + + missingColor := task.TargetColorSnapshot != "" && resolvedColor == "" + missingSize := task.TargetSizeSnapshot != "" && resolvedSize == "" + var providerDecision aimatching.DecisionSnapshot + if missingColor || missingSize { + remaining := aimatching.MatchRequest{} + if missingColor { + remaining.TargetColor, remaining.Colors = task.TargetColorSnapshot, request.Colors + } + if missingSize { + remaining.TargetSize, remaining.Sizes = task.TargetSizeSnapshot, request.Sizes + } + matched, err := s.matcher().Resolve(ctx, remaining) + if err != nil { + return aimatching.MatchResult{}, err + } + if matched.Source != aimatching.SourceExact && matched.Source != aimatching.SourceAI { + return aimatching.MatchResult{}, &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "规格匹配来源无效"} + } + providerDecision = matched.Decision + if missingColor { + if !matchCandidateValid(remaining.TargetColor, matched.MappedColor, remaining.Colors) { + return aimatching.MatchResult{}, &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "AI 返回的颜色不属于当次候选"} + } + resolvedColor, roleSources[shopeeproduct.RoleColor] = matched.MappedColor, matched.Source + } + if missingSize { + if !matchCandidateValid(remaining.TargetSize, matched.MappedSize, remaining.Sizes) { + return aimatching.MatchResult{}, &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "AI 返回的尺码不属于当次候选"} + } + resolvedSize, roleSources[shopeeproduct.RoleSize] = matched.MappedSize, matched.Source + } + } + + source := probeSourceSummary(roleSources) + reason := "当次候选完成确定性匹配" + if containsRoleSource(roleSources, "manual_mapping") || containsRoleSource(roleSources, aimatching.SourceAI) { + reason = "已确认映射经当次候选验证,未解决规格按确定性或 AI 匹配" + } + result := aimatching.RecordedMatch(request, source, resolvedColor, resolvedSize, reason) + result.Decision.RoleSources = roleSources + result.Decision.Provider = providerDecision.Provider + result.Decision.Model = providerDecision.Model + result.Decision.Confidence = providerDecision.Confidence + return result, nil +} + +type confirmedProbeMapping struct { + value string + source string + invalid bool +} + +type confirmedProbeMappingSet struct { + color confirmedProbeMapping + size confirmedProbeMapping +} + +type probeGuidanceSnapshot struct { + ConfirmedMappings map[string]probeGuidanceMapping `json:"confirmedMappings,omitempty"` +} + +type probeGuidanceMapping struct { + Value string `json:"value"` + Source string `json:"source"` +} + +func newProbeGuidanceSnapshot(raw, targetColor, targetSize string) (string, error) { + mappings := confirmedProbeMappings(raw, targetColor, targetSize) + snapshot := probeGuidanceSnapshot{ConfirmedMappings: map[string]probeGuidanceMapping{}} + if mappings.color.value != "" { + snapshot.ConfirmedMappings[shopeeproduct.RoleColor] = probeGuidanceMapping{Value: mappings.color.value, Source: mappings.color.source} + } + if mappings.size.value != "" { + snapshot.ConfirmedMappings[shopeeproduct.RoleSize] = probeGuidanceMapping{Value: mappings.size.value, Source: mappings.size.source} + } + encoded, err := json.Marshal(snapshot) + return string(encoded), err +} + +func probeGuidanceFromSnapshot(raw string) confirmedProbeMappingSet { + var snapshot probeGuidanceSnapshot + if json.Unmarshal([]byte(raw), &snapshot) != nil { + return confirmedProbeMappingSet{} + } + result := confirmedProbeMappingSet{} + if mapping, ok := snapshot.ConfirmedMappings[shopeeproduct.RoleColor]; ok { + result.color = confirmedProbeMapping{value: strings.TrimSpace(mapping.Value), source: mapping.Source} + } + if mapping, ok := snapshot.ConfirmedMappings[shopeeproduct.RoleSize]; ok { + result.size = confirmedProbeMapping{value: strings.TrimSpace(mapping.Value), source: mapping.Source} + } + return result +} + +func confirmedProbeMappings(raw, targetColor, targetSize string) confirmedProbeMappingSet { + var specs []shopeeproduct.SpecDimension + if json.Unmarshal([]byte(raw), &specs) != nil { + return confirmedProbeMappingSet{} + } + result := confirmedProbeMappingSet{} + for _, dimension := range specs { + for _, value := range dimension.Values { + if value.Mapping == nil || value.Mapping.Status != shopeeproduct.MappingStatusConfirmed { + continue + } + mapping := confirmedProbeMapping{value: strings.TrimSpace(value.Mapping.PDDValue), source: mapSource(value.Mapping.Source)} + switch { + case dimension.Role == shopeeproduct.RoleColor && value.Name == targetColor: + result.color = mergeConfirmedProbeMapping(result.color, mapping) + case dimension.Role == shopeeproduct.RoleSize && value.Name == targetSize: + result.size = mergeConfirmedProbeMapping(result.size, mapping) + } + } + } + return result +} + +func mergeConfirmedProbeMapping(current, incoming confirmedProbeMapping) confirmedProbeMapping { + if current.invalid { + return current + } + if current.value == "" { + return incoming + } + if current.value != incoming.value { + return confirmedProbeMapping{invalid: true} + } + return current +} + +func currentMappedCandidate(mapped string, candidates []string) (string, bool) { + mapped = strings.TrimSpace(mapped) + if mapped == "" { + return "", false + } + matches := make([]string, 0, 1) + seen := map[string]bool{} + for _, candidate := range candidates { + candidate = strings.TrimSpace(candidate) + if candidate == "" || seen[candidate] || aimatching.Normalize(candidate) != aimatching.Normalize(mapped) { + continue + } + seen[candidate] = true + matches = append(matches, candidate) + } + if len(matches) != 1 { + return "", false + } + return matches[0], true +} + +func probeSourceSummary(roleSources map[string]string) string { + if containsRoleSource(roleSources, "manual_mapping") { + return "manual_mapping" + } + if containsRoleSource(roleSources, aimatching.SourceAI) { + return aimatching.SourceAI + } + return aimatching.SourceExact +} + +func containsRoleSource(roleSources map[string]string, target string) bool { + for _, source := range roleSources { + if source == target { + return true + } + } + return false +} diff --git a/server/app/goauto/purchase/service.go b/server/app/goauto/purchase/service.go index 57c6945..7b4ffa9 100644 --- a/server/app/goauto/purchase/service.go +++ b/server/app/goauto/purchase/service.go @@ -281,9 +281,14 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas currency = shopee.Currency } // Every new SYB purchase must use the candidates observed on the - // current PDD page. Persisted mappings remain product-level history, - // but they cannot skip this task's read-only probe phase. + // current PDD page. Freeze confirmed mappings only as guidance for + // validation against that future probe; they never skip the probe. mappedColor, mappedSize, specSource = "", "", "unresolved" + guidance, marshalErr := newProbeGuidanceSnapshot(shopee.SpecsJSON, targetColor, targetSize) + if marshalErr != nil { + return internal(marshalErr) + } + decisionSnapshot = guidance } candidates, archiveUsable := archiveCandidates(pdd.SpecsJSON, targetColor, targetSize) matchRequest := aimatching.MatchRequest{TargetColor: targetColor, TargetSize: targetSize, Colors: candidates.Colors, Sizes: candidates.Sizes} @@ -293,7 +298,7 @@ func (s *Service) create(ctx context.Context, req CreateRequest) (models.Purchas } else if req.ExecutionMode == models.PurchaseExecutionModeLive { // The first attempt is always spec_probe for an SYB purchase. The // exact task-level decision is frozen only from that probe result. - mappedColor, mappedSize, specSource, decisionSnapshot = "", "", "unresolved", "{}" + mappedColor, mappedSize, specSource = "", "", "unresolved" } else if pdd.Status != "active" { mappedColor, mappedSize, specSource = "", "", "unresolved" } else if specSource == "manual_mapping" || specSource == "exact_match" || specSource == "ai_match" { diff --git a/server/app/goauto/purchase/service_test.go b/server/app/goauto/purchase/service_test.go index 26bc5e7..335e6d0 100644 --- a/server/app/goauto/purchase/service_test.go +++ b/server/app/goauto/purchase/service_test.go @@ -31,13 +31,15 @@ type fixture struct { } type liveProbeMatcher struct { - result aimatching.MatchResult - err error - calls int + result aimatching.MatchResult + err error + calls int + request aimatching.MatchRequest } -func (matcher *liveProbeMatcher) Resolve(context.Context, aimatching.MatchRequest) (aimatching.MatchResult, error) { +func (matcher *liveProbeMatcher) Resolve(_ context.Context, request aimatching.MatchRequest) (aimatching.MatchResult, error) { matcher.calls++ + matcher.request = request return matcher.result, matcher.err } @@ -422,10 +424,109 @@ func TestLiveProbeCallsAIOnlyAfterCandidatesAreReturned(t *testing.T) { } } +func TestLiveProbeReusesConfirmedColorAndResolvesCurrentSizeWithoutAI(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{ + "target_color": "薑黃色", + "target_size": "5XL", + }).Error; err != nil { + t.Fatal(err) + } + specs := []shopeeproduct.SpecDimension{ + {Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "薑黃色", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "黄色", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}}}}, + {Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "5XL", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "XXXXXL", Source: shopeeproduct.MappingSourceAIMatch, Status: shopeeproduct.MappingStatusConfirmed, Reason: "历史档案原文"}}}}, + } + raw, _ := json.Marshal(specs) + if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", string(raw)).Error; err != nil { + t.Fatal(err) + } + matcher := &liveProbeMatcher{err: errors.New("AI should not be called")} + s := testService(db) + s.Matcher = matcher + task, err := createLive(t, s, f) + if err != nil || task.MappedColorSnapshot != "" || task.MappedSizeSnapshot != "" { + t.Fatalf("live task skipped mandatory probe: %+v err=%v", task, err) + } + if err = db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", `[]`).Error; err != nil { + t.Fatal(err) + } + if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil { + t.Fatal(err) + } + first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) + if err != nil { + t.Fatal(err) + } + probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["白色","黄色","黑色"]},{"key":"size","values":["4XL","5XL"]}]}`)} + resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token) + if err != nil || matcher.calls != 0 || resolved.Status != models.PurchaseTaskStatusPending { + t.Fatalf("confirmed mapping resolution failed: %+v calls=%d err=%v", resolved, matcher.calls, err) + } + if resolved.MappedColor != "黄色" || resolved.MappedSize != "5XL" { + t.Fatalf("task did not freeze current probe labels: %+v", resolved) + } + var saved models.PurchaseTask + if err = db.First(&saved, task.ID).Error; err != nil { + t.Fatal(err) + } + var decision struct { + RoleSources map[string]string `json:"roleSources"` + } + if err = json.Unmarshal([]byte(saved.SpecDecisionSnapshot), &decision); err != nil { + t.Fatal(err) + } + if saved.SpecSource != "manual_mapping" || decision.RoleSources[shopeeproduct.RoleColor] != "manual_mapping" || decision.RoleSources[shopeeproduct.RoleSize] != aimatching.SourceExact { + t.Fatalf("mixed role sources were not audited: task=%+v decision=%+v", saved, decision) + } +} + +func TestLiveProbeCallsAIOnlyForRoleWithoutCurrentConfirmedMapping(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), true) + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "薑黃色").Error; err != nil { + t.Fatal(err) + } + specs := []shopeeproduct.SpecDimension{ + {Name: "颜色", Role: shopeeproduct.RoleColor, Values: []shopeeproduct.SpecValue{{Name: "薑黃色", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "旧黄色", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}}}}, + {Name: "尺码", Role: shopeeproduct.RoleSize, Values: []shopeeproduct.SpecValue{{Name: "XL", Source: shopeeproduct.ValueSourceImport, Mapping: &shopeeproduct.Mapping{PDDValue: "XL", Source: shopeeproduct.MappingSourceManual, Status: shopeeproduct.MappingStatusConfirmed}}}}, + } + raw, _ := json.Marshal(specs) + if err := db.Model(&models.ShopeeProduct{}).Where("id = ?", f.shopee.ID).Update("specs_json", string(raw)).Error; err != nil { + t.Fatal(err) + } + aiRequest := aimatching.MatchRequest{TargetColor: "薑黃色", Colors: []string{"黄色"}} + matcher := &liveProbeMatcher{result: aimatching.RecordedMatch(aiRequest, aimatching.SourceAI, "黄色", "", "当前颜色候选匹配")} + s := testService(db) + s.Matcher = matcher + task, err := createLive(t, s, f) + if err != nil { + t.Fatal(err) + } + if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil { + t.Fatal(err) + } + first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) + if err != nil { + t.Fatal(err) + } + probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黄色"]},{"key":"size","values":["XL"]}]}`)} + resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token) + if err != nil || matcher.calls != 1 || resolved.MappedColor != "黄色" || resolved.MappedSize != "XL" { + t.Fatalf("partial AI resolution failed: %+v request=%+v err=%v", resolved, matcher.request, err) + } + if matcher.request.TargetColor != "薑黃色" || matcher.request.TargetSize != "" || len(matcher.request.Colors) != 1 || len(matcher.request.Sizes) != 0 { + t.Fatalf("resolved size was unnecessarily sent to AI: %+v", matcher.request) + } +} + func TestLiveProbeRejectsMatcherValueOutsideCurrentCandidates(t *testing.T) { db := testDB(t) f := seed(t, db, liveCaps(), false) - request := aimatching.MatchRequest{TargetColor: "黑色", TargetSize: "XL", Colors: []string{"黑色"}, Sizes: []string{"XL"}} + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "象牙黑").Error; err != nil { + t.Fatal(err) + } + request := aimatching.MatchRequest{TargetColor: "象牙黑", Colors: []string{"黑色"}} matcher := &liveProbeMatcher{result: aimatching.RecordedMatch(request, aimatching.SourceAI, "候选外颜色", "XL", "无效返回")} s := testService(db) s.Matcher = matcher @@ -449,6 +550,42 @@ func TestLiveProbeRejectsMatcherValueOutsideCurrentCandidates(t *testing.T) { if err = db.First(&saved, task.ID).Error; err != nil || saved.ErrorCode == nil || *saved.ErrorCode != "PURCHASE_SPEC_NOT_MATCHED" { t.Fatalf("outside-candidate failure was not persisted: %+v err=%v", saved, err) } + if saved.ErrorMessage == nil || *saved.ErrorMessage != "已采集到当前规格,但未能确定颜色或尺码映射" { + t.Fatalf("outside-candidate failure reason was not preserved: %+v", saved) + } +} + +func TestLiveProbeUsesAccurateMessageWhenCompleteCandidatesCannotBeMatched(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), false) + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Update("target_color", "薑黃色").Error; err != nil { + t.Fatal(err) + } + s := testService(db) + s.Matcher = &liveProbeMatcher{err: &aimatching.Error{Code: aimatching.CodeNoMatch, Message: "no match"}} + task, err := createLive(t, s, f) + if err != nil { + t.Fatal(err) + } + if _, err = s.Claim(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token); err != nil { + t.Fatal(err) + } + first, err := s.Start(context.Background(), task.ID, ActionRequest{RequestID: uuid.NewString()}, f.token) + if err != nil { + t.Fatal(err) + } + probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`{"dimensions":[{"key":"color","values":["黄色"]},{"key":"size","values":["XL"]}]}`)} + resolved, err := s.SubmitResult(context.Background(), task.ID, probe, f.token) + if err != nil || resolved.Status != models.PurchaseTaskStatusFailed { + t.Fatalf("no-match probe did not converge: %+v err=%v", resolved, err) + } + var saved models.PurchaseTask + if err = db.First(&saved, task.ID).Error; err != nil { + t.Fatal(err) + } + if saved.ErrorMessage == nil || *saved.ErrorMessage != "已采集到当前规格,但未能确定颜色或尺码映射" { + t.Fatalf("complete candidates used misleading failure reason: %+v", saved) + } } func TestSecondSpecProbeFailsClosedWithoutClearingDecision(t *testing.T) {