From 6181d97f393c169c766742c38fca81424fa6bdda Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 21 Aug 2026 17:04:49 +0800 Subject: [PATCH] feat(#62): add AI specification matching settings --- config.example.yaml | 4 + docs/02-architecture-and-code-map.md | 7 +- docs/03-business-rules-and-glossary.md | 11 +- docs/04-local-development-and-verification.md | 15 +- docs/08-agent-api-contract.md | 18 +- server/app/admin/router/init_router.go | 2 + server/app/goauto/aimatching/handler.go | 161 ++++++++ server/app/goauto/aimatching/handler_test.go | 51 +++ server/app/goauto/aimatching/normalizer.go | 143 +++++++ server/app/goauto/aimatching/router.go | 16 + server/app/goauto/aimatching/service.go | 351 ++++++++++++++++++ server/app/goauto/aimatching/service_test.go | 101 +++++ server/app/goauto/aimatching/types.go | 89 +++++ server/app/goauto/migrations/migrate.go | 1 + server/app/goauto/models/schema.go | 21 ++ server/app/goauto/purchase/batch.go | 15 +- server/app/goauto/purchase/lifecycle.go | 118 +++++- server/app/goauto/purchase/service.go | 106 +++++- server/app/goauto/purchase/service_test.go | 56 ++- server/app/goauto/purchase/types.go | 18 +- .../1786701300000_ai_matching_setting.go | 27 ++ ...00000_ai_matching_setting_plain_api_key.go | 35 ++ ..._ai_matching_setting_plain_api_key_test.go | 36 ++ server/go.mod | 4 + server/go.sum | 8 + web/src/api/goauto/ai-matching-settings.js | 13 + web/src/router/index.js | 13 + .../goauto/ai-matching-settings/index.vue | 128 +++++++ 28 files changed, 1531 insertions(+), 37 deletions(-) create mode 100644 server/app/goauto/aimatching/handler.go create mode 100644 server/app/goauto/aimatching/handler_test.go create mode 100644 server/app/goauto/aimatching/normalizer.go create mode 100644 server/app/goauto/aimatching/router.go create mode 100644 server/app/goauto/aimatching/service.go create mode 100644 server/app/goauto/aimatching/service_test.go create mode 100644 server/app/goauto/aimatching/types.go create mode 100644 server/cmd/migrate/migration/version-local/1786701300000_ai_matching_setting.go create mode 100644 server/cmd/migrate/migration/version-local/1786701400000_ai_matching_setting_plain_api_key.go create mode 100644 server/cmd/migrate/migration/version-local/1786701400000_ai_matching_setting_plain_api_key_test.go create mode 100644 web/src/api/goauto/ai-matching-settings.js create mode 100644 web/src/views/goauto/ai-matching-settings/index.vue diff --git a/config.example.yaml b/config.example.yaml index 10e65ad..c53ccb1 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -9,6 +9,10 @@ ports: server: 8000 web: 9527 +# AI 规格匹配的 Provider、Base URL、模型和 API Key 均在管理员页面维护。根据 +# #62 的内部部署例外,API Key 明文存放在专用数据库设置表,并只向管理员回显; +# 不要把 API Key 写进此文件、代码、日志、工单、Wiki、任务快照或 Android 配置。 + # 顺云宝(SYB)ERP 凭据,见 docs/12-syb-erp-interface.md。 # # `[必须]` 只放凭据。base_url、page_size、max_matches、ocr_url 等非机密项在 diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index 73441da..8c42912 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -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: 8f036bcfd2db01aeb2b1c0559d066267e9d7908c -synchronized_at: 2026-08-21T07:01:32Z +wiki_revision: 69ae1e11038f1262cee2d86c8be26453b9476624 +synchronized_at: 2026-08-21T08:55:17Z # 架构与代码地图 @@ -65,6 +65,7 @@ Android Portal/Agent | `pdd_account` | 可选的账号调度引用,只保存名称和状态,不保存凭据 | | `purchase_task` | 商品外键和不可变快照、执行模式、状态/租约 guard、价格边界、订单、人工支付复核、物流与回填事实 | | `purchase_task_attempt` | `task_id + attempt_id` 幂等执行记录、阶段、规则哈希、固化规格决策和结构化错误 | +| `ai_matching_setting` | 唯一单例的启用状态、OpenAI-compatible Base URL、模型、超时、内部部署明文 API Key 和更新人;仅管理员设置接口可以读取该字段 | `collection_task` 的状态仅为 `pending`、`running`、`completed`、`completed_partial`、`failed`。设备身份和心跳表属于 Agent 领取任务的必要基础,不承载 PDD 业务数据。 @@ -120,10 +121,12 @@ Android Portal/Agent | SYB 后台导入任务、进度、单任务互斥与启动恢复 | `server/app/goauto/sybimport/sync_run.go`、`sync_run_handler.go`;表 `syb_sync_run`,迁移 `server/cmd/migrate/migration/version-local/1786701000000_syb_sync_run.go` | | 采购任务数据与类型化规则契约 | `server/app/goauto/models/purchase.go`、`server/app/goauto/purchasecontract/`;迁移 `server/cmd/migrate/migration/version-local/1786701100000_purchase_contract.go` | | 采购任务单条/批量预检与创建、租约、attempt 幂等、Admin 只读查询和人工处置状态机 | `server/app/goauto/purchase/`;批量编排复用单任务事务且不新增迁移;既有追加迁移为 `server/cmd/migrate/migration/version-local/1786701200000_purchase_state_machine.go` | +| 采购规格标准化匹配与 AI Provider 设置 | `server/app/goauto/aimatching/`;服务端先做繁简、空白/全半角/大小写和公斤/斤的唯一确定性匹配,再按需调用单一 OpenAI-compatible Provider;`1786701300000_ai_matching_setting.go` 创建设置表,`1786701400000_ai_matching_setting_plain_api_key.go` 将原加密列迁移为内部明文 `api_key`,仅管理员读取 | | 任务领取、结果、重置与删除 | `server/app/goauto/task/` | | 管理端基线 | `web/`(go-admin-ui v3.0.0) | | 管理端闭环页面 | `web/src/views/goauto/` | | Admin 采购任务列表、详情与人工处理 | `web/src/views/goauto/purchase-tasks/`、`web/src/api/goauto/purchase-tasks.js`;创建入口不在本模块 | +| Admin AI 规格匹配设置 | `web/src/views/goauto/ai-matching-settings/`、`web/src/api/goauto/ai-matching-settings.js`;管理员可查看、维护和测试 Provider(包括内部明文 API Key),采购员只可查看启用状态 | | Admin 失败采购任务批量重试 | `POST /api/admin/v1/purchase-tasks/batch-retry`;服务端 `server/app/goauto/purchase/retry.go` 负责资格判定、逐项幂等创建与部分成功结果,Admin 页面只允许选择服务端标记可重试的行;不修改 Android Agent | | SYB 店铺管理页面与接口封装 | `web/src/views/goauto/syb-shops/`、`web/src/api/goauto/syb-shops.js`;确认原型快照 `prototypes/49/v1/index.html` | | SYB 异步导入、当前页采购选择/确认/逐条结果与同步记录页面 | `web/src/views/goauto/syb-products/`、`web/src/views/goauto/syb-sync-runs/`、`web/src/api/goauto/syb-products.js`、`web/src/api/goauto/purchase-tasks.js`;确认原型见 #44 设计证据,导入原型快照为 `prototypes/50/v2/index.html` | diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index e1175a6..884ede4 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: 3186b2ad506dd2696cbdb85a46afaddbece0c325 -synchronized_at: 2026-08-21T08:08:28Z +wiki_revision: 69ae1e11038f1262cee2d86c8be26453b9476624 +synchronized_at: 2026-08-21T08:55:21Z # 业务规则与术语 @@ -124,7 +124,8 @@ synchronized_at: 2026-08-21T08:08:28Z - Agent 本地 SQLite/Outbox 负责断网和重启恢复,服务端以 `task_id + task_attempt_id` 幂等接收并保存最终事实。 - 人工支付复核只记录 `paid` / `unpaid`;系统不执行或识别支付。快递单号与回填状态属于采购任务,后续物流工单实现。 - 采购任务领取时同时占用设备租约和可选 PDD 账号租约;租约过期后才可释放并重新领取。设备还存在采集任务时不能领取采购任务。 -- 规格映射不完整、PDD 档案为待采集或没有规格时,规则必须具有 `purchase.spec-probe.v1`;第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟。无匹配结果明确失败。 +- 采购规格由服务端按顺序决策:先使用已确认的人工映射;否则仅在同一规格角色的可选 PDD 原始标签中做唯一确定性匹配(繁体转简体、空格/全半角/大小写统一,以及公斤/斤换算);仍无唯一结果才调用已启用的服务端 AI。AI 必须返回候选集中的原始标签,候选不完整、歧义、AI 无结果或服务不可用均明确失败,不派发第二趟、更不创建订单。 +- 规格映射不完整、PDD 档案为待采集或没有规格时,规则必须具有 `purchase.spec-probe.v1`;第一趟只探测规格并释放租约,服务端固化同一 attempt 的决策后才派发第二趟。Android 不自行匹配或猜测;其只接收服务端已经固化的精确原始规格标签。 - Agent 提交的相同 attempt 最终结果只能写入一次;相同请求重放返回原事实,不同内容拒绝覆盖。`order_result_unknown` 不参与自动派发,只能人工解除。 - 已创建订单默认禁止再次采购;管理员或采购员可以做一次性重新采购授权,新任务创建成功时在同一事务消耗授权,旧任务和旧订单保留。已标记为已支付的订单不能授权或创建重新采购任务。 - 人工回填候选只允许从已支付订单选择;同一 SYB 明细后来选择的订单覆盖旧候选,但不删除旧订单事实。 @@ -139,7 +140,7 @@ synchronized_at: 2026-08-21T08:08:28Z - 登录失效、验证码、风控、人机验证和找不到唯一控件时失败并给出具体错误。 - Android Agent 端不使用 OCR/VLM 兜底,不保存原始控件树或截图。采集阶段不猜测缺失数据。 - 服务端顺云宝(SYB)登录例外:允许调用配置的线上 OCR 服务识别登录验证码(见 [#48](https://git.ilapage.cn/OPC/goauto/issues/48))。验证码图片会离开本项目发送到该服务,更换服务地址前必须重新评估。此例外只适用于 SYB 登录,不扩大到 Agent 端或任何 PDD 相关流程。 -- 采购阶段的规格匹配由服务端决策(见 [#46](https://git.ilapage.cn/OPC/goauto/issues/46)):Agent 本地不得自行猜测规格或点击相近候选,只执行服务端下发的精确规格;AI 无匹配结果时明确失败。 +- 采购阶段的规格匹配由服务端决策(见 [#46](https://git.ilapage.cn/OPC/goauto/issues/46)、[#62](https://git.ilapage.cn/OPC/goauto/issues/62)):Agent 本地不得自行猜测规格或点击相近候选,只执行服务端下发的精确规格;AI Provider 只有一个 OpenAI-compatible 配置,由管理员维护。根据 #62 已确认的内部部署例外,API Key 明文保存在专用设置表,并只向管理员设置接口返回以便查看和替换;它仍不得写入代码、日志、工单、Wiki、任务快照、采购员接口或 Android 接口。Provider Base URL 不限制内网或公网,支持 HTTP/HTTPS;HTTP 不加密传输中的 API Key,生产环境建议 HTTPS。 - 采购规则的 `openSpecPanel` 默认不写死页面文字。Agent 只在已由页面语义确认的规格入口或底部购买入口中选择;规则若提供 `textAliases`,只能进一步缩小这些安全候选,不能把任意同名页面文字变成可点击入口。 - 正式采购规则可以调用独立审核的改地址、创建待付款订单和只读核单动作;采集规则和演练规则不能调用。真机首次安装或验证仍需独立人工授权。 - 系统不提供自动支付、实时屏幕或管理端远程控制。付款、免密支付及任何等价动作始终禁止。 @@ -168,7 +169,7 @@ synchronized_at: 2026-08-21T08:08:28Z | 虾皮 | Shopee 的中文展示名。管理端界面一律用「虾皮」,数据库表名、字段名、API 路径等程序标识符保持 `shopee_product`(单数)/ `shopee_item_id` 不变 | | SYB | 顺云宝 ERP(ShunYunBao,域名 shunyunbaoerp.com),虾皮订单与货运单的外部来源系统。管理端界面一律用「SYB」,不再使用「货运宝」「顺云宝」等别名;数据库表名与字段保持 `syb_products` | | 货运单 | SYB 中的一张单据(`code`,如 260728TB95MJTQ),下挂一条或多条商品明细。与系统名「SYB」区分,不可混用 | -| AI 规格匹配 | 人工映射缺失、PDD 商品无规格数据或目标规格定位不到时,由服务端 AI 接口把虾皮目标颜色尺码匹配到 PDD 实际规格;候选集限定为实时可选规格,无结果即失败 | +| AI 规格匹配 | 人工映射后仍无法以确定性标准化唯一匹配时,服务端才调用单一 OpenAI-compatible Provider,把虾皮目标颜色尺码匹配到 PDD 实际可选规格;结果必须是候选原始标签,无结果、歧义或 Provider 不可用即失败 | | 快路径 / 慢路径 | 采购执行的两种路径:Agent 能直接定位到下发的目标规格为快路径;定位不到则触发全量遍历采集并重新匹配,为慢路径 | | 两趟执行 | 慢路径的实现方式:第一趟采集规格后结束并释放设备,服务端离线匹配,第二趟重新派发下单,复用 `task_attempt` 机制 | | 采购探测 | 采购时对 PDD 商品页的实时规格采集,同时作为一次商品信息更新写回档案,按 `completed` / `completed_partial` 语义处理 | diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index a149cdd..67af382 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: 9fa21612e642c3d1b3edaa235b6127c876299e4e -synchronized_at: 2026-08-17T03:28:04Z +wiki_revision: 69ae1e11038f1262cee2d86c8be26453b9476624 +synchronized_at: 2026-08-21T08:55:26Z # 本地开发与验证 @@ -35,6 +35,8 @@ go build ./... 默认运行目标是 MySQL。开发者在仓库根目录的 `config.yaml` 中维护本机连接参数和启动端口;该文件已被 Git 忽略,不得提交。可从不含凭据的 `config.example.yaml` 复制。启动脚本读取配置后,仅通过当前进程环境注入数据库连接、服务端端口和前端 API 地址,不把账号密码写回 `server/config/settings.yml`、日志、工单或文档。 +如需启用 AI 规格匹配,管理员在“AI 规格匹配”页面配置单一 OpenAI-compatible Provider 的 Base URL、模型、超时和 API Key;根据 #62 已确认的内部部署例外,API Key 明文保存在专用设置表,并只在管理员设置页面回显,不能出现在代码、日志、工单、Wiki、任务快照、采购员接口或 Android 接口。Base URL 支持公网或内网的 HTTP/HTTPS 地址,不作局域网限制;HTTP 不加密传输中的 API Key,生产环境建议 HTTPS。 + 已经执行过旧版 GoAuto 表结构的数据库,会由 `1786700300000_collection_execution.go` 增量补齐任务幂等、结果、软删除和颜色价格字段;不要通过修改已执行迁移的版本号强制重跑。 Windows 本地 MySQL 8.4 可以从仓库根目录双击或执行: @@ -56,6 +58,15 @@ go test ./app/goauto/... 该命令同时覆盖设备注册的首次签发、requestId 重放、错误 Token、防接管、停用/吊销、HTTPS、限流、心跳任务一致性和超时离线失败测试。 +AI 规格匹配的隔离验证: + +```powershell +Set-Location server +go test ./app/goauto/aimatching ./app/goauto/purchase +``` + +该测试覆盖确定性标准化、歧义拒绝、Provider 回退的候选原文校验、内部 API Key 的管理员读取/采购员隔离以及 HTTP/HTTPS Base URL 校验;不会调用真实 Provider 或创建订单。 + 如需用临时 SQLite 做隔离联调,服务端命令必须显式带上 SQLite 构建标签: ```powershell diff --git a/docs/08-agent-api-contract.md b/docs/08-agent-api-contract.md index 595db8c..8d1030a 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: b3118858779352d67f759fef9e3ab1afcd812453 -synchronized_at: 2026-08-21T08:08:49Z +wiki_revision: 69ae1e11038f1262cee2d86c8be26453b9476624 +synchronized_at: 2026-08-21T08:55:45Z # MVP 共享 API 契约 @@ -174,6 +174,18 @@ Android 的 `pddProductDetailV1` 采集器执行以下固定流程: 无障碍节点只投影为 Agent 进程内的瞬时不可变模型,不序列化、不上传、不写入文件。完整状态机接入后 Agent 才上报 `collector.pdd.product-detail.v1`。 +## 管理端:AI 规格匹配设置 + +```http +GET /api/admin/v1/ai-matching-settings +PUT /api/admin/v1/ai-matching-settings +POST /api/admin/v1/ai-matching-settings/test +``` + +这是采购规格匹配的单例配置,Provider 固定为 `openai_compatible`。管理员可读取、保存和测试连接;保存请求包含 `enabled`、`baseUrl`、`model`、`timeoutSeconds` 与 `apiKey`。根据 #62 已确认的内部部署例外,管理员 `GET` 响应会返回已保存的明文 `apiKey`,供下次查看和替换;采购员 `GET` 只返回 `enabled`,不能读取 Provider、地址、模型或 API Key,也不能保存或测试。 + +Base URL 支持内网或公网的 `http://`、`https://`,不限制为局域网地址。服务端只在管理员点击测试或采购规格没有唯一确定性结果时,才向 Provider 发送目标颜色/尺码和 PDD 可选颜色/尺码;请求中不包含控件树、截图、地址、账号或订单。HTTP 不加密 API Key 的传输,生产环境建议使用 HTTPS。服务端禁止跟随 Provider 重定向,Provider 返回的颜色/尺码必须逐字等于当前候选原始标签,否则按无匹配处理。 + ## 管理端:采集任务 ```http @@ -491,7 +503,7 @@ Admin 列表与详情由 #35 实现;#44 在 SYB 商品列表提供单条/当 | `POST` | `/api/agent/v1/purchase-tasks/{taskId}/order-submit-started` | 创建订单前先落不可逆标记;演练任务永远拒绝 | | `POST` | `/api/agent/v1/purchase-tasks/{taskId}/result` | 请求体携带 `taskAttemptId` 和 `requestId`;幂等提交演练、规格探测、订单或失败结果 | -结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。慢路径第一趟提交规格后释放设备与已知账号租约,任务进入 `spec_probe_pending`;服务端固化同一 attempt 的 AI/人工决策后,第二趟使用新的 attempt 重新派发。无匹配结果则明确失败。`order_result_unknown` 只允许管理员或采购员人工解除,永不自动重派。 +结果提交至少关联 `taskId`、`taskAttemptId`、`deviceId`、规则快照哈希和结构化结果。相同 attempt 的相同结果重复提交返回同一事实;不同内容拒绝覆盖。慢路径第一趟提交规格后释放设备与已知账号租约,任务进入 `spec_probe_pending`;服务端先使用已确认人工映射,否则对实时/档案可选规格做繁简、空白/全半角/大小写及公斤/斤的唯一确定性匹配,仍无唯一结果才调用 AI。第二趟只会收到服务端已固化的精确 PDD 原始标签;无匹配、候选不完整、歧义或 Provider 异常均使任务失败,不再派发。`order_result_unknown` 只允许管理员或采购员人工解除,永不自动重派。 `openSpecPanel.textAliases` 是可选的候选过滤条件,不是原始页面文本选择器。省略该字段时,Agent 使用语义安全的规格入口或底部购买入口;提供时也只能与这些安全候选取交集,匹配不到即返回 `RULE_NOT_MATCHED`。 diff --git a/server/app/admin/router/init_router.go b/server/app/admin/router/init_router.go index 5f92e5a..7ef9ada 100644 --- a/server/app/admin/router/init_router.go +++ b/server/app/admin/router/init_router.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" log "github.com/go-admin-team/go-admin-core/logger" "github.com/go-admin-team/go-admin-core/sdk" + goautoaimatching "go-admin/app/goauto/aimatching" goautodevice "go-admin/app/goauto/device" goautoproduct "go-admin/app/goauto/product" goautopurchase "go-admin/app/goauto/purchase" @@ -48,6 +49,7 @@ func InitRouter() { // 注册 GoAuto Agent 与设备管理路由。 goautodevice.InitRouter(r, authMiddleware) + goautoaimatching.InitRouter(r, authMiddleware) goautotask.InitRouter(r, authMiddleware) goautopurchase.InitRouter(r, authMiddleware) goautoproduct.InitRouter(r, authMiddleware) diff --git a/server/app/goauto/aimatching/handler.go b/server/app/goauto/aimatching/handler.go new file mode 100644 index 0000000..26d57bb --- /dev/null +++ b/server/app/goauto/aimatching/handler.go @@ -0,0 +1,161 @@ +package aimatching + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + + "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" +) + +type Handler struct{ DB *gorm.DB } + +func (h Handler) Get(c *gin.Context) { + role, ok := settingsViewer(c) + if !ok { + return + } + service, ok := h.service(c) + if !ok { + return + } + view, err := service.Settings(c.Request.Context()) + if err != nil { + writeError(c, err) + return + } + if role != "admin" { + // Purchasers may see only whether the fallback is available. Provider, + // endpoint, model and key state remain administrator-only. + c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": gin.H{"enabled": view.Enabled}}) + return + } + c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": view}) +} + +func (h Handler) Save(c *gin.Context) { + operatorID, ok := settingsAdmin(c) + if !ok { + return + } + var request SaveSettingsRequest + if !decodeJSON(c, &request) { + return + } + service, ok := h.service(c) + if !ok { + return + } + view, err := service.SaveSettings(c.Request.Context(), request, operatorID) + if err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": view}) +} + +func (h Handler) TestConnection(c *gin.Context) { + if _, ok := settingsAdmin(c); !ok { + return + } + service, ok := h.service(c) + if !ok { + return + } + if err := service.TestConnection(c.Request.Context()); err != nil { + writeError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "data": gin.H{"connected": true, "message": "AI 服务连接正常"}}) +} + +func (h Handler) service(c *gin.Context) (*Service, bool) { + if h.DB != nil { + return NewService(h.DB), true + } + db, err := pkg.GetOrm(c) + if err != nil { + writeError(c, err) + return nil, false + } + return NewService(db), true +} + +func settingsViewer(c *gin.Context) (string, bool) { + role, _ := jwt.ExtractClaims(c)["rolekey"].(string) + if role == "admin" || role == "purchaser" { + return role, true + } + c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "没有查看 AI 规格匹配设置的权限"}) + c.Abort() + return "", false +} + +func settingsAdmin(c *gin.Context) (uint64, bool) { + role, _ := jwt.ExtractClaims(c)["rolekey"].(string) + if role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"code": "FORBIDDEN", "message": "只有管理员可以修改 AI 规格匹配设置"}) + c.Abort() + return 0, false + } + id := operatorID(c) + if id == 0 { + c.JSON(http.StatusUnprocessableEntity, gin.H{"code": CodeInvalidSetting, "message": "无法识别当前管理员"}) + c.Abort() + return 0, false + } + return id, true +} + +func decodeJSON(c *gin.Context, target any) bool { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20) + decoder := json.NewDecoder(c.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + writeError(c, fail(CodeInvalidSetting, "请求 JSON 无效")) + return false + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + writeError(c, fail(CodeInvalidSetting, "请求只能包含一个 JSON 对象")) + return false + } + return true +} + +func writeError(c *gin.Context, err error) { + status, code, message := http.StatusInternalServerError, "INTERNAL_ERROR", "服务端处理失败" + var matchErr *Error + if errors.As(err, &matchErr) { + code, message = matchErr.Code, matchErr.Message + switch matchErr.Code { + case CodeInvalidSetting, CodeNotConfigured: + status = http.StatusUnprocessableEntity + case CodeNoMatch: + status = http.StatusConflict + case CodeProviderUnavailable: + status = http.StatusBadGateway + } + } + c.JSON(status, gin.H{"code": code, "message": message}) +} + +func operatorID(c *gin.Context) uint64 { + switch value := jwt.ExtractClaims(c)["identity"].(type) { + case float64: + return uint64(value) + case int: + return uint64(value) + case json.Number: + id, _ := strconv.ParseUint(string(value), 10, 64) + return id + case string: + id, _ := strconv.ParseUint(value, 10, 64) + return id + } + return 0 +} diff --git a/server/app/goauto/aimatching/handler_test.go b/server/app/goauto/aimatching/handler_test.go new file mode 100644 index 0000000..cb57e3a --- /dev/null +++ b/server/app/goauto/aimatching/handler_test.go @@ -0,0 +1,51 @@ +package aimatching + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" +) + +func TestSettingsHandlerReturnsKeyOnlyToAdministrator(t *testing.T) { + service := matcherTestService(t) + if _, err := service.SaveSettings(context.Background(), SaveSettingsRequest{ + Enabled: true, BaseURL: "http://provider.example/v1", Model: "test-model", APIKey: "test-secret", TimeoutSeconds: 8, + }, 7); err != nil { + t.Fatal(err) + } + + request := func(role string) map[string]any { + engine := gin.New() + engine.Use(func(c *gin.Context) { + c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": role, "identity": float64(7)}) + c.Next() + }) + engine.GET("/settings", Handler{DB: service.DB}.Get) + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/settings", nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("%s request returned %d: %s", role, recorder.Code, recorder.Body.String()) + } + var response struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + return response.Data + } + + admin := request("admin") + if admin["apiKey"] != "test-secret" || admin["baseUrl"] != "http://provider.example/v1" { + t.Fatalf("administrator must receive the internal setting: %#v", admin) + } + purchaser := request("purchaser") + if purchaser["enabled"] != true || len(purchaser) != 1 { + t.Fatalf("purchaser setting response leaked private fields: %#v", purchaser) + } +} diff --git a/server/app/goauto/aimatching/normalizer.go b/server/app/goauto/aimatching/normalizer.go new file mode 100644 index 0000000..3f31584 --- /dev/null +++ b/server/app/goauto/aimatching/normalizer.go @@ -0,0 +1,143 @@ +package aimatching + +import ( + "regexp" + "strconv" + "strings" + "sync" + "unicode" + + "github.com/liuzl/gocc" +) + +var ( + traditionalOnce sync.Once + traditionalToSimplified *gocc.OpenCC + traditionalErr error + weightPattern = regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)\s*(?:[-~~至到]\s*(\d+(?:\.\d+)?))?\s*(公斤|千克|kg|斤)`) +) + +// Normalize makes a deterministic comparison key. It intentionally removes +// spacing and folds full-width, case and Traditional Chinese differences. For +// weight ranges it uses 斤 as the common unit (1 公斤 = 2 斤); it does not infer +// a size from a nearby value or perform any fuzzy match. +func Normalize(raw string) string { + traditionalOnce.Do(func() { traditionalToSimplified, traditionalErr = gocc.New("t2s") }) + value := strings.TrimSpace(raw) + if traditionalErr == nil { + if converted, err := traditionalToSimplified.Convert(value); err == nil { + value = converted + } + } + value = widthAndSpaceFold(value) + value = strings.ToLower(value) + return weightPattern.ReplaceAllStringFunc(value, normalizeWeight) +} + +func widthAndSpaceFold(raw string) string { + var b strings.Builder + b.Grow(len(raw)) + for _, r := range raw { + switch { + case unicode.IsSpace(r): + continue + case r == '\u3000': + continue + case r >= '\uff01' && r <= '\uff5e': + b.WriteRune(r - 0xfee0) + default: + b.WriteRune(r) + } + } + return b.String() +} + +func normalizeWeight(raw string) string { + parts := weightPattern.FindStringSubmatch(raw) + if len(parts) != 4 { + return raw + } + convert := func(value string) string { + number, err := strconv.ParseFloat(value, 64) + if err != nil { + return value + } + unit := strings.ToLower(parts[3]) + if unit == "公斤" || unit == "千克" || unit == "kg" { + number *= 2 + } + return strconv.FormatFloat(number, 'f', -1, 64) + } + first := convert(parts[1]) + if parts[2] == "" { + return first + "斤" + } + return first + "-" + convert(parts[2]) + "斤" +} + +// DeterministicMatch returns a selection only when every requested dimension +// has exactly one normalized-equal PDD candidate. More than one candidate is +// 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) + if !colorOK || !sizeOK { + return MatchResult{}, false + } + result := matchResult(request, SourceExact, "", "", color, size, "规格名称标准化后唯一一致") + return result, true +} + +// 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. +func ManualMatch(request MatchRequest, color, size string) MatchResult { + return RecordedMatch(request, "manual_mapping", color, size, "已使用人工确认的规格映射") +} + +// RecordedMatch serializes a previously confirmed task mapping. It accepts +// only the established task sources and does not run normalization or AI. +func RecordedMatch(request MatchRequest, source, color, size, reason string) MatchResult { + if source != "manual_mapping" && source != SourceExact && source != SourceAI { + source = "manual_mapping" + } + return matchResult(request, source, "", "", strings.TrimSpace(color), strings.TrimSpace(size), reason) +} + +// NoMatchDecision is used when the probe or provider cannot produce a valid +// exact PDD label. It preserves the observed candidates without exposing a +// provider URL, credential or raw UI data. +func NoMatchDecision(request MatchRequest, source, reason string) DecisionSnapshot { + return matchResult(request, source, "", "", "", "", reason).Decision +} + +func exactCandidate(target string, candidates []string) (string, bool) { + if strings.TrimSpace(target) == "" { + return "", true + } + normalized := Normalize(target) + if normalized == "" { + return "", false + } + matches := make([]string, 0, 1) + seen := map[string]bool{} + for _, candidate := range candidates { + candidate = strings.TrimSpace(candidate) + if candidate == "" || Normalize(candidate) != normalized || seen[candidate] { + continue + } + seen[candidate] = true + matches = append(matches, candidate) + } + returnCandidate := "" + if len(matches) == 1 { + returnCandidate = matches[0] + } + return returnCandidate, len(matches) == 1 +} + +func matchResult(request MatchRequest, source, provider, model, color, size, reason string) MatchResult { + decision := DecisionSnapshot{Source: source, Provider: provider, Model: model, Candidates: CandidateSnapshot{Colors: append([]string(nil), request.Colors...), Sizes: append([]string(nil), request.Sizes...)}, Reason: reason} + decision.Matched.Color, decision.Matched.Size = color, size + return MatchResult{MappedColor: color, MappedSize: size, Source: source, Decision: decision} +} diff --git a/server/app/goauto/aimatching/router.go b/server/app/goauto/aimatching/router.go new file mode 100644 index 0000000..ee94d0c --- /dev/null +++ b/server/app/goauto/aimatching/router.go @@ -0,0 +1,16 @@ +package aimatching + +import ( + "go-admin/common/middleware" + + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" +) + +func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { + handler := Handler{} + admin := engine.Group("/api/admin/v1/ai-matching-settings").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()) + admin.GET("", handler.Get) + admin.PUT("", handler.Save) + admin.POST("/test", handler.TestConnection) +} diff --git a/server/app/goauto/aimatching/service.go b/server/app/goauto/aimatching/service.go new file mode 100644 index 0000000..8eed5f1 --- /dev/null +++ b/server/app/goauto/aimatching/service.go @@ -0,0 +1,351 @@ +package aimatching + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strings" + "time" + + "go-admin/app/goauto/models" + + "gorm.io/gorm" +) + +const ( + settingID = uint64(1) + defaultTimeoutSecs = 15 + maxTimeoutSecs = 60 + minTimeoutSecs = 3 + maxSettingText = 2048 +) + +// Service owns the internal AI Provider configuration. The API key exception +// is deliberately narrow: it is plain text only in the dedicated settings +// table and is returned only by the administrator settings handler. +type Service struct { + DB *gorm.DB + HTTPClient *http.Client +} + +func NewService(db *gorm.DB) *Service { + return &Service{DB: db, HTTPClient: secureHTTPClient()} +} + +func secureHTTPClient() *http.Client { + return &http.Client{ + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }, + } +} + +func (s *Service) Settings(ctx context.Context) (SettingsView, error) { + setting, err := s.setting(ctx) + if errors.Is(err, gorm.ErrRecordNotFound) { + return SettingsView{Enabled: false, Provider: ProviderOpenAICompatible, TimeoutSeconds: defaultTimeoutSecs}, nil + } + if err != nil { + return SettingsView{}, err + } + return settingView(setting), nil +} + +func (s *Service) SaveSettings(ctx context.Context, request SaveSettingsRequest, operatorID uint64) (SettingsView, error) { + if operatorID == 0 { + return SettingsView{}, fail(CodeInvalidSetting, "无法识别当前管理员") + } + request, err := validateSettings(request) + if err != nil { + return SettingsView{}, err + } + var result models.AIMatchingSetting + err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var current models.AIMatchingSetting + err := tx.First(¤t, settingID).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if errors.Is(err, gorm.ErrRecordNotFound) { + current = models.AIMatchingSetting{ID: settingID, Provider: ProviderOpenAICompatible, BaseURL: "", Model: "", APIKey: "", TimeoutSeconds: defaultTimeoutSecs} + } + if key := strings.TrimSpace(request.APIKey); key != "" { + current.APIKey = key + } + if request.Enabled && strings.TrimSpace(current.APIKey) == "" { + return fail(CodeInvalidSetting, "启用 AI 匹配前请填写 API Key") + } + current.Enabled = request.Enabled + current.Provider = ProviderOpenAICompatible + current.BaseURL = request.BaseURL + current.Model = request.Model + current.TimeoutSeconds = request.TimeoutSeconds + current.UpdatedBy = &operatorID + if errors.Is(err, gorm.ErrRecordNotFound) { + if createErr := tx.Create(¤t).Error; createErr != nil { + return createErr + } + } else if saveErr := tx.Save(¤t).Error; saveErr != nil { + return saveErr + } + result = current + return nil + }) + if err != nil { + return SettingsView{}, err + } + return settingView(result), nil +} + +// TestConnection checks the currently saved provider endpoint. It does not log +// or return the credential, response body, or provider URL. +func (s *Service) TestConnection(ctx context.Context) error { + setting, apiKey, err := s.activeSetting(ctx) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(ctx, time.Duration(setting.TimeoutSeconds)*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint(setting.BaseURL, "models"), nil) + if err != nil { + return fail(CodeInvalidSetting, "AI 服务地址无效") + } + req.Header.Set("Authorization", "Bearer "+apiKey) + response, err := s.httpClient().Do(req) + if err != nil { + return &Error{Code: CodeProviderUnavailable, Message: "无法连接 AI 服务,请检查地址、模型和网络", Cause: err} + } + defer response.Body.Close() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return fail(CodeProviderUnavailable, "AI 服务连接失败,请检查地址和 API Key") + } + return nil +} + +// Resolve runs exact deterministic matching first and uses the configured +// OpenAI-compatible provider only when no unique normalized choice exists. +func (s *Service) Resolve(ctx context.Context, request MatchRequest) (MatchResult, error) { + request = sanitizeRequest(request) + if result, ok := DeterministicMatch(request); ok { + return result, nil + } + setting, apiKey, err := s.activeSetting(ctx) + if err != nil { + return MatchResult{}, err + } + payload := openAIChatRequest{Model: setting.Model, Temperature: 0, Messages: []openAIMessage{ + {Role: "system", Content: "你只负责在给定的 PDD 可选规格中选择完全一致的原始标签。不得猜测、不得改写候选值。只返回 JSON:{\"color\":\"候选原文或空\",\"size\":\"候选原文或空\",\"reason\":\"简短原因\",\"confidence\":0到1}。没有唯一可靠结果时对应字段为空。"}, + {Role: "user", Content: matchPrompt(request)}, + }} + body, err := json.Marshal(payload) + if err != nil { + return MatchResult{}, &Error{Code: CodeProviderUnavailable, Message: "AI 匹配请求生成失败", Cause: err} + } + ctx, cancel := context.WithTimeout(ctx, time.Duration(setting.TimeoutSeconds)*time.Second) + defer cancel() + httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint(setting.BaseURL, "chat/completions"), bytes.NewReader(body)) + if err != nil { + return MatchResult{}, fail(CodeInvalidSetting, "AI 服务地址无效") + } + httpRequest.Header.Set("Authorization", "Bearer "+apiKey) + httpRequest.Header.Set("Content-Type", "application/json") + response, err := s.httpClient().Do(httpRequest) + if err != nil { + return MatchResult{}, &Error{Code: CodeProviderUnavailable, Message: "AI 匹配服务暂时不可用", Cause: err} + } + defer response.Body.Close() + limited := io.LimitReader(response.Body, 1<<20) + responseBody, readErr := io.ReadAll(limited) + if readErr != nil || response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return MatchResult{}, fail(CodeProviderUnavailable, "AI 匹配服务暂时不可用") + } + choice, err := parseProviderChoice(responseBody) + if err != nil { + return MatchResult{}, fail(CodeNoMatch, "没有找到可采购的 PDD 颜色或尺码") + } + if !validChoice(request.TargetColor, choice.Color, request.Colors) || !validChoice(request.TargetSize, choice.Size, request.Sizes) { + return MatchResult{}, fail(CodeNoMatch, "没有找到可采购的 PDD 颜色或尺码") + } + result := matchResult(request, SourceAI, ProviderOpenAICompatible, setting.Model, choice.Color, choice.Size, safeReason(choice.Reason)) + if choice.Confidence != nil && *choice.Confidence >= 0 && *choice.Confidence <= 1 { + result.Decision.Confidence = choice.Confidence + } + return result, nil +} + +func (s *Service) activeSetting(ctx context.Context) (models.AIMatchingSetting, string, error) { + setting, err := s.setting(ctx) + if errors.Is(err, gorm.ErrRecordNotFound) || !setting.Enabled { + return models.AIMatchingSetting{}, "", fail(CodeNotConfigured, "AI 规格匹配未启用") + } + if err != nil { + return models.AIMatchingSetting{}, "", err + } + if strings.TrimSpace(setting.APIKey) == "" { + return models.AIMatchingSetting{}, "", fail(CodeNotConfigured, "AI 规格匹配未配置 API Key") + } + return setting, setting.APIKey, nil +} + +func (s *Service) setting(ctx context.Context) (models.AIMatchingSetting, error) { + var setting models.AIMatchingSetting + err := s.DB.WithContext(ctx).First(&setting, settingID).Error + return setting, err +} + +func validateSettings(request SaveSettingsRequest) (SaveSettingsRequest, error) { + request.BaseURL = strings.TrimRight(strings.TrimSpace(request.BaseURL), "/") + request.Model = strings.TrimSpace(request.Model) + request.APIKey = strings.TrimSpace(request.APIKey) + if request.TimeoutSeconds == 0 { + request.TimeoutSeconds = defaultTimeoutSecs + } + if request.TimeoutSeconds < minTimeoutSecs || request.TimeoutSeconds > maxTimeoutSecs { + return SaveSettingsRequest{}, fail(CodeInvalidSetting, "超时时间必须在 3 到 60 秒之间") + } + if len(request.BaseURL) > maxSettingText || len(request.Model) > 255 || len(request.APIKey) > maxSettingText { + return SaveSettingsRequest{}, fail(CodeInvalidSetting, "AI 设置内容过长") + } + if request.Enabled { + if request.BaseURL == "" || request.Model == "" { + return SaveSettingsRequest{}, fail(CodeInvalidSetting, "启用 AI 匹配前请填写服务地址和模型") + } + if _, err := validatedBaseURL(request.BaseURL); err != nil { + return SaveSettingsRequest{}, err + } + } else if request.BaseURL != "" { + if _, err := validatedBaseURL(request.BaseURL); err != nil { + return SaveSettingsRequest{}, err + } + } + return request, nil +} + +func validatedBaseURL(raw string) (*url.URL, error) { + parsed, err := url.Parse(raw) + if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, fail(CodeInvalidSetting, "AI 服务地址必须是有效的 HTTP 或 HTTPS 地址") + } + return parsed, nil +} + +func settingView(setting models.AIMatchingSetting) SettingsView { + timeout := setting.TimeoutSeconds + if timeout == 0 { + timeout = defaultTimeoutSecs + } + return SettingsView{Enabled: setting.Enabled, Provider: ProviderOpenAICompatible, BaseURL: setting.BaseURL, Model: setting.Model, APIKey: setting.APIKey, TimeoutSeconds: timeout} +} + +func (s *Service) httpClient() *http.Client { + if s.HTTPClient == nil { + return secureHTTPClient() + } + return s.HTTPClient +} + +func endpoint(base, suffix string) string { + return strings.TrimRight(base, "/") + "/" + suffix +} + +func sanitizeRequest(request MatchRequest) MatchRequest { + request.TargetColor, request.TargetSize = strings.TrimSpace(request.TargetColor), strings.TrimSpace(request.TargetSize) + request.Colors = usableCandidates(request.Colors) + request.Sizes = usableCandidates(request.Sizes) + return request +} + +func usableCandidates(values []string) []string { + seen := make(map[string]bool, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + return result +} + +func validChoice(target, selected string, candidates []string) bool { + if strings.TrimSpace(target) == "" { + return strings.TrimSpace(selected) == "" + } + selected = strings.TrimSpace(selected) + if selected == "" { + return false + } + for _, candidate := range candidates { + if candidate == selected { + return true + } + } + return false +} + +func safeReason(reason string) string { + reason = strings.TrimSpace(reason) + if reason == "" { + return "AI 在可选规格中返回唯一结果" + } + if len([]rune(reason)) > 200 { + return string([]rune(reason)[:200]) + } + return reason +} + +func matchPrompt(request MatchRequest) string { + payload := struct { + TargetColor string `json:"targetColor,omitempty"` + TargetSize string `json:"targetSize,omitempty"` + Colors []string `json:"pddSelectableColors,omitempty"` + Sizes []string `json:"pddSelectableSizes,omitempty"` + }{request.TargetColor, request.TargetSize, request.Colors, request.Sizes} + raw, _ := json.Marshal(payload) + return string(raw) +} + +type openAIMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type openAIChatRequest struct { + Model string `json:"model"` + Temperature float64 `json:"temperature"` + Messages []openAIMessage `json:"messages"` +} + +type providerChoice struct { + Color string `json:"color"` + Size string `json:"size"` + Reason string `json:"reason"` + Confidence *float64 `json:"confidence"` +} + +func parseProviderChoice(raw []byte) (providerChoice, error) { + var response struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(raw, &response); err != nil || len(response.Choices) == 0 { + return providerChoice{}, errors.New("invalid provider response") + } + content := strings.TrimSpace(response.Choices[0].Message.Content) + content = strings.TrimPrefix(content, "```json") + content = strings.TrimPrefix(content, "```") + content = strings.TrimSuffix(strings.TrimSpace(content), "```") + var choice providerChoice + if err := json.Unmarshal([]byte(content), &choice); err != nil { + return providerChoice{}, err + } + choice.Color, choice.Size = strings.TrimSpace(choice.Color), strings.TrimSpace(choice.Size) + return choice, nil +} diff --git a/server/app/goauto/aimatching/service_test.go b/server/app/goauto/aimatching/service_test.go new file mode 100644 index 0000000..b051bea --- /dev/null +++ b/server/app/goauto/aimatching/service_test.go @@ -0,0 +1,101 @@ +package aimatching + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "go-admin/app/goauto/migrations" + "go-admin/app/goauto/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func matcherTestService(t *testing.T) *Service { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + if err = migrations.Migrate(db); err != nil { + t.Fatal(err) + } + return &Service{DB: db, HTTPClient: &http.Client{Transport: roundTripper(func(request *http.Request) (*http.Response, error) { + body := `{"data":[{"id":"test-model"}]}` + if strings.HasSuffix(request.URL.Path, "/chat/completions") { + body = `{"choices":[{"message":{"content":"{\"color\":\"米白\",\"size\":\"4XL 160-170斤\",\"reason\":\"候选唯一\",\"confidence\":0.91}"}}]}` + } + return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil + })}} +} + +type roundTripper func(*http.Request) (*http.Response, error) + +func (fn roundTripper) RoundTrip(request *http.Request) (*http.Response, error) { return fn(request) } + +func TestNormalizeTraditionalWidthAndWeightRange(t *testing.T) { + if got, want := Normalize(" 4XL 80-85公斤 "), Normalize("4xl 160-170斤"); got != want { + t.Fatalf("normalization mismatch: %q != %q", got, want) + } + if got, want := Normalize("淺藍色"), Normalize("浅蓝色"); got != want { + t.Fatalf("traditional Chinese was not converted: %q != %q", got, want) + } +} + +func TestDeterministicMatchNeverPicksAmbiguousCandidate(t *testing.T) { + if _, ok := DeterministicMatch(MatchRequest{TargetColor: "淺藍", Colors: []string{"浅蓝", "淺藍"}}); ok { + t.Fatal("ambiguous normalized candidates were selected") + } + result, ok := DeterministicMatch(MatchRequest{TargetSize: "4XL 80-85公斤", Sizes: []string{"4XL 160-170斤"}}) + if !ok || result.MappedSize != "4XL 160-170斤" || result.Source != SourceExact { + t.Fatalf("expected exact normalized match, got %+v ok=%v", result, ok) + } +} + +func TestSettingsKeepsInternalKeyAndAIChoiceMustBeExactCandidate(t *testing.T) { + service := matcherTestService(t) + view, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "https://provider.example/v1", Model: "test-model", APIKey: "test-secret", TimeoutSeconds: 8}, 7) + if err != nil || view.APIKey != "test-secret" || view.BaseURL != "https://provider.example/v1" { + t.Fatalf("save settings failed: %+v %v", view, err) + } + var stored models.AIMatchingSetting + if err = service.DB.First(&stored, 1).Error; err != nil { + t.Fatal(err) + } + if stored.APIKey != "test-secret" { + t.Fatalf("internal API key was not stored for administrator readback") + } + if err = service.TestConnection(context.Background()); err != nil { + t.Fatalf("test connection failed: %v", err) + } + result, err := service.Resolve(context.Background(), MatchRequest{TargetColor: "奶白", TargetSize: "4XL 80-85公斤", Colors: []string{"米白"}, Sizes: []string{"4XL 160-170斤"}}) + if err != nil || result.Source != SourceAI || result.MappedColor != "米白" || result.MappedSize != "4XL 160-170斤" { + t.Fatalf("AI result was not constrained to exact candidates: %+v %v", result, err) + } + if result.Decision.Provider != ProviderOpenAICompatible || result.Decision.Model != "test-model" { + t.Fatalf("decision snapshot lost provider identity: %+v", result.Decision) + } +} + +func TestSettingsReadsSavedKeyForAdministrator(t *testing.T) { + service := matcherTestService(t) + if _, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "https://provider.example/v1", Model: "test-model", APIKey: "test-secret"}, 7); err != nil { + t.Fatal(err) + } + view, err := service.Settings(context.Background()) + if err != nil || view.APIKey != "test-secret" { + t.Fatalf("administrator settings must return saved key: %+v %v", view, err) + } +} + +func TestSaveSettingsAllowsPublicHTTPProvider(t *testing.T) { + service := matcherTestService(t) + view, err := service.SaveSettings(context.Background(), SaveSettingsRequest{Enabled: true, BaseURL: "http://192.168.1.10:8000/v1", Model: "local-model", APIKey: "secret"}, 7) + if err != nil || view.BaseURL != "http://192.168.1.10:8000/v1" { + t.Fatalf("HTTP provider must remain configurable for a public or private endpoint: %+v %v", view, err) + } +} diff --git a/server/app/goauto/aimatching/types.go b/server/app/goauto/aimatching/types.go new file mode 100644 index 0000000..d9a9447 --- /dev/null +++ b/server/app/goauto/aimatching/types.go @@ -0,0 +1,89 @@ +// Package aimatching owns deterministic and optional AI fallback matching for +// purchase task specifications. It never sends a task to Android and never +// chooses a value that is not an exact member of the server-provided PDD list. +package aimatching + +import ( + "fmt" +) + +const ( + ProviderOpenAICompatible = "openai_compatible" + + SourceExact = "exact_match" + SourceAI = "ai_match" + + CodeNotConfigured = "AI_MATCHING_NOT_CONFIGURED" + CodeNoMatch = "AI_MATCHING_NO_MATCH" + CodeProviderUnavailable = "AI_MATCHING_PROVIDER_UNAVAILABLE" + CodeInvalidSetting = "AI_MATCHING_INVALID_SETTING" +) + +// MatchRequest includes only the two purchase dimensions and PDD values which +// the Agent has already reported as selectable. It contains no UI tree, +// screenshot, address, account, order or secret. +type MatchRequest struct { + TargetColor string + TargetSize string + Colors []string + Sizes []string +} + +type CandidateSnapshot struct { + Colors []string `json:"colors,omitempty"` + Sizes []string `json:"sizes,omitempty"` +} + +type DecisionSnapshot struct { + Source string `json:"source"` + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + Candidates CandidateSnapshot `json:"candidates"` + Matched struct { + Color string `json:"color,omitempty"` + Size string `json:"size,omitempty"` + } `json:"matched"` + Reason string `json:"reason"` + Confidence *float64 `json:"confidence,omitempty"` +} + +type MatchResult struct { + MappedColor string + MappedSize string + Source string + Decision DecisionSnapshot +} + +type SettingsView struct { + Enabled bool `json:"enabled"` + Provider string `json:"provider,omitempty"` + BaseURL string `json:"baseUrl,omitempty"` + Model string `json:"model,omitempty"` + TimeoutSeconds int `json:"timeoutSeconds,omitempty"` + APIKey string `json:"apiKey,omitempty"` +} + +type SaveSettingsRequest struct { + Enabled bool `json:"enabled"` + BaseURL string `json:"baseUrl"` + Model string `json:"model"` + APIKey string `json:"apiKey"` + TimeoutSeconds int `json:"timeoutSeconds"` +} + +type Error struct { + Code string + Message string + Cause error +} + +func (err *Error) Error() string { + if err.Cause == nil { + return err.Message + } + return fmt.Sprintf("%s: %v", err.Message, err.Cause) +} + +func (err *Error) Unwrap() error { return err.Cause } + +func fail(code, message string) error { return &Error{Code: code, Message: message} } diff --git a/server/app/goauto/migrations/migrate.go b/server/app/goauto/migrations/migrate.go index f18e9df..4041f4c 100644 --- a/server/app/goauto/migrations/migrate.go +++ b/server/app/goauto/migrations/migrate.go @@ -15,6 +15,7 @@ func MigratedModels() []any { return []any{ &models.AgentDevice{}, &models.PDDProduct{}, + &models.AIMatchingSetting{}, &models.ShopeeProduct{}, &models.SYBProduct{}, &models.SYBSession{}, diff --git a/server/app/goauto/models/schema.go b/server/app/goauto/models/schema.go index 32cf2bd..6ccb194 100644 --- a/server/app/goauto/models/schema.go +++ b/server/app/goauto/models/schema.go @@ -63,6 +63,27 @@ type PDDProduct struct { func (PDDProduct) TableName() string { return "pdd_product" } +// AIMatchingSetting is the single server-side configuration for the optional +// OpenAI-compatible fallback used only to choose an already-observed PDD +// specification. By the #62 internal-deployment exception, the API key is +// stored in this dedicated table as plain text and returned only by the +// administrator settings endpoint. It must never be added to task records, +// Agent payloads, purchaser responses, logs, code, tickets, or Wiki pages. +type AIMatchingSetting struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement:false"` + Enabled bool `json:"enabled" gorm:"not null;default:false"` + Provider string `json:"provider" gorm:"size:32;not null;default:openai_compatible"` + BaseURL string `json:"baseUrl" gorm:"type:text;not null"` + Model string `json:"model" gorm:"size:255;not null;default:''"` + APIKey string `json:"apiKey" gorm:"column:api_key;type:text;not null"` + TimeoutSeconds int `json:"timeoutSeconds" gorm:"not null;default:15"` + UpdatedBy *uint64 `json:"updatedBy"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (AIMatchingSetting) TableName() string { return "ai_matching_setting" } + func (product *PDDProduct) BeforeCreate(_ *gorm.DB) error { if product.SpecsJSON == "" { product.SpecsJSON = "[]" diff --git a/server/app/goauto/purchase/batch.go b/server/app/goauto/purchase/batch.go index 856fb2d..c31aabd 100644 --- a/server/app/goauto/purchase/batch.go +++ b/server/app/goauto/purchase/batch.go @@ -8,6 +8,7 @@ import ( "sort" "strings" + "go-admin/app/goauto/aimatching" "go-admin/app/goauto/models" "go-admin/app/goauto/product" "go-admin/app/goauto/purchasecontract" @@ -223,7 +224,19 @@ func (s *Service) previewOne(ctx context.Context, id uint64) BatchPreviewItem { item.ReasonCode, item.Reason, item.NextAction = "PDD_NOT_READY", "拼多多商品尚未采集完成或已停用", "open_pdd" return item } - item.MappedColor, item.MappedSize, _ = confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize) + mappedColor, mappedSize, source := confirmedMappings(shopee.SpecsJSON, syb.TargetColor, syb.TargetSize) + item.MappedColor, item.MappedSize = mappedColor, mappedSize + if source == "unresolved" { + candidates, archiveUsable := archiveCandidates(pdd.SpecsJSON, syb.TargetColor, syb.TargetSize) + if archiveUsable { + matched, matchErr := s.matcher().Resolve(ctx, aimatching.MatchRequest{TargetColor: syb.TargetColor, TargetSize: syb.TargetSize, Colors: candidates.Colors, Sizes: candidates.Sizes}) + if matchErr != nil { + item.ReasonCode, item.Reason, item.NextAction = serviceErrorFields(purchaseMatchError(matchErr)) + return item + } + item.MappedColor, item.MappedSize = matched.MappedColor, matched.MappedSize + } + } reference, minPrice, maxPrice, err := purchasePriceRange(pdd.SpecsJSON, item.MappedColor) if err != nil { item.ReasonCode, item.Reason, item.NextAction = "PDD_PRICE_MISSING", err.Error(), "open_pdd" diff --git a/server/app/goauto/purchase/lifecycle.go b/server/app/goauto/purchase/lifecycle.go index 85710a7..652fa90 100644 --- a/server/app/goauto/purchase/lifecycle.go +++ b/server/app/goauto/purchase/lifecycle.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "go-admin/app/goauto/aimatching" "go-admin/app/goauto/device" "go-admin/app/goauto/models" @@ -247,7 +248,7 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq return TaskPayload{}, fail(CodeResultConflict, "同一次执行已提交不同结果") } } - return s.withRunning(ctx, taskID, token, func(tx *gorm.DB, t *models.PurchaseTask, a *models.PurchaseTaskAttempt, d models.AgentDevice) (TaskPayload, error) { + payload, err := s.withRunning(ctx, taskID, token, func(tx *gorm.DB, t *models.PurchaseTask, a *models.PurchaseTaskAttempt, d models.AgentDevice) (TaskPayload, error) { if req.TaskAttemptID == "" || req.TaskAttemptID != a.AttemptID { return TaskPayload{}, fail(CodeStateConflict, "taskAttemptId 与当前执行不一致") } @@ -321,16 +322,25 @@ func (s *Service) SubmitResult(ctx context.Context, taskID uint64, req ResultReq } return valuePayload(s, t, a, false) }) + if err != nil || req.ResultType != "spec_probe_completed" || payload.Replayed { + return payload, err + } + return s.resolveProbedSpecs(ctx, taskID, req.TaskAttemptID, req.ProbedSpecs) } func (s *Service) ApplySpecDecision(ctx context.Context, taskID uint64, req SpecDecisionRequest) (models.PurchaseTask, bool, error) { + operatorID := req.OperatorID + return s.applySpecDecision(ctx, taskID, req, &operatorID) +} + +func (s *Service) applySpecDecision(ctx context.Context, taskID uint64, req SpecDecisionRequest, operatorID *uint64) (models.PurchaseTask, bool, error) { var t models.PurchaseTask replayed := false err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&t, taskID).Error; e != nil { return purchaseNotFound(e) } - if strings.TrimSpace(req.RequestID) == "" || req.OperatorID == 0 { + if strings.TrimSpace(req.RequestID) == "" || (operatorID != nil && *operatorID == 0) { return fail(CodeInvalidRequest, "requestId 或操作人无效") } var a models.PurchaseTaskAttempt @@ -366,13 +376,21 @@ func (s *Service) ApplySpecDecision(ctx context.Context, taskID uint64, req Spec t.SpecSource = req.Source t.SpecDecisionSnapshot = string(decision) t.SpecDecisionRequestID = &req.RequestID - t.SpecDecisionBy = &req.OperatorID + t.SpecDecisionBy = operatorID if req.NoMatch { - code, message := "PURCHASE_SPEC_NOT_MATCHED", "没有找到可用的商品规格" + code, message := strings.TrimSpace(req.FailureCode), strings.TrimSpace(req.FailureMessage) + if code == "" { + code = "PURCHASE_SPEC_NOT_MATCHED" + } + if message == "" { + message = "没有找到可用的商品规格" + } t.ErrorCode, t.ErrorMessage = &code, &message if e := t.SetStatus(models.PurchaseTaskStatusFailed); e != nil { return internal(e) } + } else if e := t.SetStatus(models.PurchaseTaskStatusPending); e != nil { + return internal(e) } t.StatusVersion++ t.StatusChangedAt = s.Now() @@ -381,6 +399,98 @@ func (s *Service) ApplySpecDecision(ctx context.Context, taskID uint64, req Spec return t, replayed, err } +// resolveProbedSpecs runs after the probe attempt has committed and released +// the device lease. The network call therefore never holds a task-row lock. +// Every outcome is persisted before the Agent can receive a second attempt: +// a valid exact label returns the task to pending; no result fails the task. +func (s *Service) resolveProbedSpecs(ctx context.Context, taskID uint64, attemptID string, raw json.RawMessage) (TaskPayload, error) { + var task models.PurchaseTask + if err := s.DB.WithContext(ctx).First(&task, taskID).Error; err != nil { + return TaskPayload{}, purchaseNotFound(err) + } + candidates, complete := probedCandidates(raw, task.TargetColorSnapshot, task.TargetSizeSnapshot) + request := aimatching.MatchRequest{TargetColor: task.TargetColorSnapshot, TargetSize: task.TargetSizeSnapshot, Colors: candidates.Colors, Sizes: candidates.Sizes} + decision := SpecDecisionRequest{RequestID: uuid.NewString(), TaskAttemptID: attemptID, Source: aimatching.SourceAI} + if !complete { + snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, "规格探测结果没有包含所需的可选颜色或尺码")) + if marshalErr != nil { + return TaskPayload{}, internal(marshalErr) + } + decision.NoMatch, decision.Decision = true, snapshot + decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", "没有找到可采购的 PDD 颜色或尺码" + } else if matched, matchErr := s.matcher().Resolve(ctx, request); matchErr == nil { + snapshot, marshalErr := json.Marshal(matched.Decision) + if marshalErr != nil { + return TaskPayload{}, internal(marshalErr) + } + decision.MappedColor, decision.MappedSize, decision.Source, decision.Decision = matched.MappedColor, matched.MappedSize, matched.Source, snapshot + } else { + snapshot, marshalErr := json.Marshal(aimatching.NoMatchDecision(request, aimatching.SourceAI, purchaseMatchReason(matchErr))) + if marshalErr != nil { + return TaskPayload{}, internal(marshalErr) + } + decision.NoMatch, decision.Decision = true, snapshot + decision.FailureCode, decision.FailureMessage = "PURCHASE_SPEC_NOT_MATCHED", purchaseMatchReason(matchErr) + } + updated, _, err := s.applySpecDecision(ctx, taskID, decision, nil) + if err != nil { + return TaskPayload{}, err + } + return valuePayload(s, &updated, nil, false) +} + +func purchaseMatchReason(err error) string { + var matchErr *aimatching.Error + if errors.As(err, &matchErr) { + switch matchErr.Code { + case aimatching.CodeNotConfigured: + return "规格未匹配,AI 规格匹配未启用" + case aimatching.CodeProviderUnavailable: + return "AI 规格匹配暂时不可用,请稍后重新创建采购任务" + } + } + return "没有找到可采购的 PDD 颜色或尺码" +} + +type probedSpecCandidates struct { + Colors []string + Sizes []string +} + +func probedCandidates(raw json.RawMessage, targetColor, targetSize string) (probedSpecCandidates, bool) { + var payload struct { + Dimensions []struct { + Key string `json:"key"` + Values []string `json:"values"` + } `json:"dimensions"` + } + if json.Unmarshal(raw, &payload) != nil { + return probedSpecCandidates{}, false + } + result := probedSpecCandidates{} + for _, dimension := range payload.Dimensions { + values := make([]string, 0, len(dimension.Values)) + for _, value := range dimension.Values { + if value = strings.TrimSpace(value); value != "" { + values = append(values, value) + } + } + switch strings.ToLower(strings.TrimSpace(dimension.Key)) { + case "color": + result.Colors = append(result.Colors, values...) + case "size": + result.Sizes = append(result.Sizes, values...) + } + } + if strings.TrimSpace(targetColor) != "" && len(result.Colors) == 0 { + return result, false + } + if strings.TrimSpace(targetSize) != "" && len(result.Sizes) == 0 { + return result, false + } + return result, true +} + func (s *Service) withRunning(ctx context.Context, taskID uint64, token string, fn func(*gorm.DB, *models.PurchaseTask, *models.PurchaseTaskAttempt, models.AgentDevice) (TaskPayload, error)) (TaskPayload, error) { var out TaskPayload err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { diff --git a/server/app/goauto/purchase/service.go b/server/app/goauto/purchase/service.go index 662d824..a927ea1 100644 --- a/server/app/goauto/purchase/service.go +++ b/server/app/goauto/purchase/service.go @@ -9,8 +9,10 @@ import ( "strings" "time" + "go-admin/app/goauto/aimatching" "go-admin/app/goauto/device" "go-admin/app/goauto/models" + "go-admin/app/goauto/product" "go-admin/app/goauto/purchasecontract" "go-admin/app/goauto/shopeeproduct" @@ -25,10 +27,17 @@ type Service struct { DB *gorm.DB Now func() time.Time LeaseDuration time.Duration + Matcher SpecMatcher } func NewService(db *gorm.DB) *Service { - return &Service{DB: db, Now: func() time.Time { return time.Now().UTC() }, LeaseDuration: DefaultLeaseDuration} + return &Service{DB: db, Now: func() time.Time { return time.Now().UTC() }, LeaseDuration: DefaultLeaseDuration, Matcher: aimatching.NewService(db)} +} + +// SpecMatcher is server-only. Android receives only the final exact PDD labels +// in its task payload and never knows about AI configuration or candidates. +type SpecMatcher interface { + Resolve(context.Context, aimatching.MatchRequest) (aimatching.MatchResult, error) } func (s *Service) Create(ctx context.Context, req CreateRequest) (models.PurchaseTask, bool, error) { @@ -124,7 +133,12 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (models.Purchas } } quantity, ref, minPrice, maxPrice, currency := req.Quantity, req.ReferenceUnitPriceCent, req.MinUnitPriceCent, req.MaxUnitPriceCent, strings.ToUpper(strings.TrimSpace(req.Currency)) - targetColor, targetSize, mappedColor, mappedSize, specSource := strings.TrimSpace(req.TargetColor), strings.TrimSpace(req.TargetSize), strings.TrimSpace(req.MappedColor), strings.TrimSpace(req.MappedSize), "unresolved" + // Mapping labels are server-owned. In particular, do not trust + // mappedColor/mappedSize from an admin HTTP request: they must come from + // a confirmed mapping, deterministic matching, or the server-only AI + // fallback before the Agent receives them. + targetColor, targetSize, mappedColor, mappedSize, specSource := strings.TrimSpace(req.TargetColor), strings.TrimSpace(req.TargetSize), "", "", "unresolved" + decisionSnapshot := "{}" var sybID, shopeeID *uint64 if req.ExecutionMode == models.PurchaseExecutionModeLive { sybID, shopeeID = &syb.ID, &shopee.ID @@ -145,11 +159,30 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (models.Purchas currency = shopee.Currency } mappedColor, mappedSize, specSource = confirmedMappings(shopee.SpecsJSON, targetColor, targetSize) - } else if (targetColor == "" || mappedColor != "") && (targetSize == "" || mappedSize != "") { - specSource = "manual_mapping" } - if pdd.Status == "pending" || strings.TrimSpace(pdd.SpecsJSON) == "" || strings.TrimSpace(pdd.SpecsJSON) == "[]" { + candidates, archiveUsable := archiveCandidates(pdd.SpecsJSON, targetColor, targetSize) + matchRequest := aimatching.MatchRequest{TargetColor: targetColor, TargetSize: targetSize, Colors: candidates.Colors, Sizes: candidates.Sizes} + if pdd.Status != "active" { mappedColor, mappedSize, specSource = "", "", "unresolved" + } else if specSource == "manual_mapping" || specSource == "exact_match" || specSource == "ai_match" { + decision, marshalErr := json.Marshal(aimatching.RecordedMatch(matchRequest, specSource, mappedColor, mappedSize, "已使用人工确认的规格映射").Decision) + if marshalErr != nil { + return internal(marshalErr) + } + decisionSnapshot = string(decision) + } else if !archiveUsable { + mappedColor, mappedSize, specSource = "", "", "unresolved" + } else { + match, matchErr := s.matcher().Resolve(ctx, matchRequest) + if matchErr != nil { + return purchaseMatchError(matchErr) + } + mappedColor, mappedSize, specSource = match.MappedColor, match.MappedSize, match.Source + decision, marshalErr := json.Marshal(match.Decision) + if marshalErr != nil { + return internal(marshalErr) + } + decisionSnapshot = string(decision) } if specSource == "unresolved" && !containsString(rule.RequiredCapabilities, purchasecontract.CapabilitySpecProbeV1) { return fail(CodeMappingRequired, "规格映射不完整,所选规则不支持规格探测") @@ -160,7 +193,7 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (models.Purchas required, _ := json.Marshal(purchasecontract.RequiredCapabilities(rule)) out = models.PurchaseTask{SYBProductID: sybID, ShopeeProductID: shopeeID, PDDProductID: pdd.ID, DeviceID: req.DeviceID, PDDAccountID: req.PDDAccountID, ExecutionMode: req.ExecutionMode, Status: models.PurchaseTaskStatusPending, ShopeeItemIDSnapshot: shopee.ShopeeItemID, ShopeeTitleSnapshot: shopee.Title, ShopeeShopNameSnapshot: shopee.ShopName, - PDDURLSnapshot: pdd.URL, PDDGoodsIDSnapshot: pdd.GoodsID, PDDTitleSnapshot: pdd.Title, TargetColorSnapshot: targetColor, TargetSizeSnapshot: targetSize, MappedColorSnapshot: mappedColor, MappedSizeSnapshot: mappedSize, SpecSource: specSource, + PDDURLSnapshot: pdd.URL, PDDGoodsIDSnapshot: pdd.GoodsID, PDDTitleSnapshot: pdd.Title, TargetColorSnapshot: targetColor, TargetSizeSnapshot: targetSize, MappedColorSnapshot: mappedColor, MappedSizeSnapshot: mappedSize, SpecSource: specSource, SpecDecisionSnapshot: decisionSnapshot, Quantity: quantity, ReferenceUnitPriceCent: ref, MinUnitPriceCent: minPrice, MaxUnitPriceCent: maxPrice, Currency: currency, RuleType: rule.RuleType, RuleSchemaVersion: rule.SchemaVersion, RequiredCapabilitiesJSON: string(required), RuleSnapshot: string(req.RuleSnapshot), CreateRequestID: req.RequestID, PaymentReviewStatus: models.PurchasePaymentReviewPending, LogisticsStatus: models.PurchaseLogisticsStatusPending, WritebackStatus: models.PurchaseWritebackStatusNotSelected} if err := tx.Create(&out).Error; err != nil { @@ -178,6 +211,67 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (models.Purchas return out, replayed, err } +type specCandidates struct { + Colors []string + Sizes []string +} + +// archiveCandidates only returns selectable PDD values and only considers the +// target roles. Incomplete or malformed archives deliberately fall back to a +// first spec-probe instead of guessing a nearby option. +func archiveCandidates(raw, targetColor, targetSize string) (specCandidates, bool) { + var dimensions []product.SpecDimension + if json.Unmarshal([]byte(raw), &dimensions) != nil { + return specCandidates{}, false + } + result := specCandidates{} + for _, dimension := range dimensions { + var destination *[]string + switch dimension.Role { + case "color": + destination = &result.Colors + case "size": + destination = &result.Sizes + default: + continue + } + for _, value := range dimension.Values { + if value.Selectable && strings.TrimSpace(value.Name) != "" { + *destination = append(*destination, strings.TrimSpace(value.Name)) + } + } + } + if strings.TrimSpace(targetColor) != "" && len(result.Colors) == 0 { + return result, false + } + if strings.TrimSpace(targetSize) != "" && len(result.Sizes) == 0 { + return result, false + } + return result, true +} + +func (s *Service) matcher() SpecMatcher { + if s.Matcher != nil { + return s.Matcher + } + return aimatching.NewService(s.DB) +} + +func purchaseMatchError(err error) error { + var matchErr *aimatching.Error + if errors.As(err, &matchErr) { + switch matchErr.Code { + case aimatching.CodeNoMatch: + return fail(CodeMappingRequired, "没有找到可采购的 PDD 颜色或尺码") + case aimatching.CodeNotConfigured: + return fail(CodeMappingRequired, "规格未匹配,请先配置 AI 匹配或人工确认规格映射") + case aimatching.CodeProviderUnavailable: + return fail(CodeMappingRequired, "AI 规格匹配暂时不可用,请稍后重试") + } + } + return internal(err) +} + func confirmedMappings(raw, color, size string) (string, string, string) { var specs []shopeeproduct.SpecDimension if json.Unmarshal([]byte(raw), &specs) != nil { diff --git a/server/app/goauto/purchase/service_test.go b/server/app/goauto/purchase/service_test.go index da32c2d..94b75a2 100644 --- a/server/app/goauto/purchase/service_test.go +++ b/server/app/goauto/purchase/service_test.go @@ -211,14 +211,12 @@ func TestSlowPathUsesTwoAttemptsAndFreezesDecision(t *testing.T) { if err != nil || first.Phase != models.PurchaseAttemptPhaseSpecProbe { t.Fatalf("probe start: %+v %v", first, err) } - probe := ResultRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, ResultType: "spec_probe_completed", ProbedSpecs: []byte(`[{"color":"黑色","size":"XL"}]`)} - if _, err = s.SubmitResult(context.Background(), task.ID, probe, f.token); 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.PurchaseTaskStatusPending || resolved.MappedColor != "黑色" || resolved.MappedSize != "XL" { + t.Fatalf("probe was not resolved by server: %+v %v", resolved, err) } decision := SpecDecisionRequest{RequestID: uuid.NewString(), TaskAttemptID: first.TaskAttemptID, MappedColor: "黑色", MappedSize: "XL", Source: "ai_match", Decision: []byte(`{"reason":"same label"}`), OperatorID: 1} - if _, _, err = s.ApplySpecDecision(context.Background(), task.ID, decision); err != nil { - t.Fatal(err) - } decision.MappedColor = "白色" if _, _, err = s.ApplySpecDecision(context.Background(), task.ID, decision); code(err) != CodeResultConflict { t.Fatalf("frozen decision changed: %v", err) @@ -232,6 +230,52 @@ func TestSlowPathUsesTwoAttemptsAndFreezesDecision(t *testing.T) { } } +func TestCreateUsesDeterministicTraditionalAndWeightMatching(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), false) + if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"浅蓝色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"4XL 160-170斤","selectable":true}]}]`).Error; err != nil { + t.Fatal(err) + } + if err := db.Model(&models.SYBProduct{}).Where("id = ?", f.syb.ID).Updates(map[string]any{"target_color": "淺藍色", "target_size": "4XL 80-85公斤"}).Error; err != nil { + t.Fatal(err) + } + task, err := createLive(t, testService(db), f) + if err != nil || task.SpecSource != "exact_match" || task.MappedColorSnapshot != "浅蓝色" || task.MappedSizeSnapshot != "4XL 160-170斤" { + t.Fatalf("deterministic match failed: %+v %v", task, err) + } + if strings.Contains(task.SpecDecisionSnapshot, "apiKey") || !strings.Contains(task.SpecDecisionSnapshot, "exact_match") { + t.Fatalf("task decision snapshot is unsafe or incomplete: %s", task.SpecDecisionSnapshot) + } +} + +func TestCreateDoesNotTrustMappedLabelsFromRequest(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), false) + if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"黑色","selectable":true,"priceCent":2000},{"name":"白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"XL","selectable":true}]}]`).Error; err != nil { + t.Fatal(err) + } + task, _, err := testService(db).Create(context.Background(), CreateRequest{ + RequestID: uuid.NewString(), ExecutionMode: models.PurchaseExecutionModeLive, + SYBProductID: &f.syb.ID, DeviceID: &f.device.ID, MappedColor: "白色", MappedSize: "XL", + MinUnitPriceCent: 400, MaxUnitPriceCent: 3000, RuleSnapshot: liveRule(true), + }) + if err != nil || task.SpecSource != "exact_match" || task.MappedColorSnapshot != "黑色" || task.MappedSizeSnapshot != "XL" { + t.Fatalf("request-provided mapping must not override server match: %+v %v", task, err) + } +} + +func TestCreateRejectsUsableArchiveWhenNeitherMatcherFindsSpec(t *testing.T) { + db := testDB(t) + f := seed(t, db, liveCaps(), false) + if err := db.Model(&models.PDDProduct{}).Where("id = ?", f.pdd.ID).Update("specs_json", `[{"name":"颜色","role":"color","values":[{"name":"白色","selectable":true,"priceCent":2000}]},{"name":"尺码","role":"size","values":[{"name":"L","selectable":true}]}]`).Error; err != nil { + t.Fatal(err) + } + _, err := createLive(t, testService(db), f) + if code(err) != CodeMappingRequired || !strings.Contains(err.Error(), "AI") { + t.Fatalf("unmatched active archive should be rejected before dispatch: %v", err) + } +} + func TestOrderUnknownIsNotAutomaticallyRedispatched(t *testing.T) { db := testDB(t) f := seed(t, db, liveCaps(), true) diff --git a/server/app/goauto/purchase/types.go b/server/app/goauto/purchase/types.go index 81e9855..253c52a 100644 --- a/server/app/goauto/purchase/types.go +++ b/server/app/goauto/purchase/types.go @@ -104,14 +104,16 @@ type ResultRequest struct { } type SpecDecisionRequest struct { - RequestID string `json:"requestId"` - TaskAttemptID string `json:"taskAttemptId"` - MappedColor string `json:"mappedColor"` - MappedSize string `json:"mappedSize"` - Source string `json:"source"` - Decision json.RawMessage `json:"decision"` - NoMatch bool `json:"noMatch,omitempty"` - OperatorID uint64 `json:"-"` + RequestID string `json:"requestId"` + TaskAttemptID string `json:"taskAttemptId"` + MappedColor string `json:"mappedColor"` + MappedSize string `json:"mappedSize"` + Source string `json:"source"` + Decision json.RawMessage `json:"decision"` + NoMatch bool `json:"noMatch,omitempty"` + OperatorID uint64 `json:"-"` + FailureCode string `json:"-"` + FailureMessage string `json:"-"` } type ManualRequest struct { diff --git a/server/cmd/migrate/migration/version-local/1786701300000_ai_matching_setting.go b/server/cmd/migrate/migration/version-local/1786701300000_ai_matching_setting.go new file mode 100644 index 0000000..b4a7349 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1786701300000_ai_matching_setting.go @@ -0,0 +1,27 @@ +package version_local + +import ( + "runtime" + + goautomigrations "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + + "gorm.io/gorm" +) + +// This additive migration creates the one-row AI matching configuration. It +// does not modify purchase task rows or create any order. +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateAIMatchingSetting) +} + +func migrateAIMatchingSetting(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := goautomigrations.Migrate(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} diff --git a/server/cmd/migrate/migration/version-local/1786701400000_ai_matching_setting_plain_api_key.go b/server/cmd/migrate/migration/version-local/1786701400000_ai_matching_setting_plain_api_key.go new file mode 100644 index 0000000..44b0119 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1786701400000_ai_matching_setting_plain_api_key.go @@ -0,0 +1,35 @@ +package version_local + +import ( + "runtime" + + goautomigrations "go-admin/app/goauto/migrations" + "go-admin/cmd/migrate/migration" + common "go-admin/common/models" + + "gorm.io/gorm" +) + +// This one-time #62 migration retains the internally managed API key while +// renaming the original encrypted column to its confirmed plain-text name. +// It does not touch products, tasks, attempts, orders, or payment data. +func init() { + _, fileName, _, _ := runtime.Caller(0) + migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateAIMatchingSettingPlainAPIKey) +} + +func migrateAIMatchingSettingPlainAPIKey(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if tx.Migrator().HasTable("ai_matching_setting") && + tx.Migrator().HasColumn("ai_matching_setting", "api_key_encrypted") && + !tx.Migrator().HasColumn("ai_matching_setting", "api_key") { + if err := tx.Migrator().RenameColumn("ai_matching_setting", "api_key_encrypted", "api_key"); err != nil { + return err + } + } + if err := goautomigrations.Migrate(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} diff --git a/server/cmd/migrate/migration/version-local/1786701400000_ai_matching_setting_plain_api_key_test.go b/server/cmd/migrate/migration/version-local/1786701400000_ai_matching_setting_plain_api_key_test.go new file mode 100644 index 0000000..db7dd2f --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1786701400000_ai_matching_setting_plain_api_key_test.go @@ -0,0 +1,36 @@ +package version_local + +import ( + "testing" + + common "go-admin/common/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestMigrateAIMatchingSettingPlainAPIKeyRenamesExistingColumn(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:ai-key-column-rename?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = db.Exec(`CREATE TABLE ai_matching_setting (id INTEGER PRIMARY KEY, enabled BOOLEAN NOT NULL, provider TEXT NOT NULL, base_url TEXT NOT NULL, model TEXT NOT NULL, api_key_encrypted TEXT NOT NULL, timeout_seconds INTEGER NOT NULL, created_at DATETIME, updated_at DATETIME)`).Error; err != nil { + t.Fatal(err) + } + if err = db.Exec(`INSERT INTO ai_matching_setting (id, enabled, provider, base_url, model, api_key_encrypted, timeout_seconds) VALUES (1, 1, 'openai_compatible', 'http://provider.example/v1', 'test-model', 'legacy-key', 15)`).Error; err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&common.Migration{}); err != nil { + t.Fatal(err) + } + if err = migrateAIMatchingSettingPlainAPIKey(db, "1786701400000"); err != nil { + t.Fatal(err) + } + if db.Migrator().HasColumn("ai_matching_setting", "api_key_encrypted") || !db.Migrator().HasColumn("ai_matching_setting", "api_key") { + t.Fatal("legacy key column was not renamed") + } + var value string + if err = db.Raw(`SELECT api_key FROM ai_matching_setting WHERE id = 1`).Scan(&value).Error; err != nil || value != "legacy-key" { + t.Fatalf("API key did not survive rename: %q %v", value, err) + } +} diff --git a/server/go.mod b/server/go.mod index bcb0c87..87ce119 100644 --- a/server/go.mod +++ b/server/go.mod @@ -14,6 +14,7 @@ require ( github.com/go-admin-team/go-admin-core/sdk v1.5.3-rc.3.0.20250408121721-2763de5dcdf4 github.com/google/uuid v1.6.0 github.com/huaweicloud/huaweicloud-sdk-go-obs v3.26.6+incompatible + github.com/liuzl/gocc v0.0.0-20231231122217-0372e1059ca5 github.com/mssola/user_agent v0.6.0 github.com/opentracing/opentracing-go v1.2.0 github.com/pkg/errors v0.9.1 @@ -40,6 +41,7 @@ require ( filippo.io/edwards25519 v1.2.0 // indirect github.com/BurntSushi/toml v1.5.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect + github.com/adamzy/cedar-go v0.0.0-20170805034717-80a9c64b256d // indirect github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 // indirect github.com/andeya/ameda v1.5.3 // indirect github.com/andeya/goutil v1.1.2 // indirect @@ -95,6 +97,8 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/leodido/go-urn v1.5.0 // indirect + github.com/liuzl/cedar-go v0.0.0-20170805034717-80a9c64b256d // indirect + github.com/liuzl/da v0.0.0-20180704015230-14771aad5b1d // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-sqlite3 v1.14.49 // indirect diff --git a/server/go.sum b/server/go.sum index 7620542..cfb7ee3 100644 --- a/server/go.sum +++ b/server/go.sum @@ -26,6 +26,8 @@ github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWX github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= github.com/VividCortex/gohistogram v1.0.0/go.mod h1:Pf5mBqqDxYaXu3hDrrU+w6nw50o/4+TcAqDqk/vUH7g= +github.com/adamzy/cedar-go v0.0.0-20170805034717-80a9c64b256d h1:ir/IFJU5xbja5UaBEQLjcvn7aAU01nqU/NUyOBEU+ew= +github.com/adamzy/cedar-go v0.0.0-20170805034717-80a9c64b256d/go.mod h1:PRWNwWq0yifz6XDPZu48aSld8BWwBfr2JKB2bGWiEd4= github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -349,6 +351,12 @@ github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM= github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4= +github.com/liuzl/cedar-go v0.0.0-20170805034717-80a9c64b256d h1:qSmEGTgjkESUX5kPMSGJ4pcBUtYVDdkNzMrjQyvRvp0= +github.com/liuzl/cedar-go v0.0.0-20170805034717-80a9c64b256d/go.mod h1:x7SghIWwLVcJObXbjK7S2ENsT1cAcdJcPl7dRaSFog0= +github.com/liuzl/da v0.0.0-20180704015230-14771aad5b1d h1:hTRDIpJ1FjS9ULJuEzu69n3qTgc18eI+ztw/pJv47hs= +github.com/liuzl/da v0.0.0-20180704015230-14771aad5b1d/go.mod h1:7xD3p0XnHvJFQ3t/stEJd877CSIMkH/fACVWen5pYnc= +github.com/liuzl/gocc v0.0.0-20231231122217-0372e1059ca5 h1:wnbHIeP1UX8ClYEWKGnw66PfYvReCHu9G5lXSte3Sqc= +github.com/liuzl/gocc v0.0.0-20231231122217-0372e1059ca5/go.mod h1:7KaV9YIR92M1FpbczAcfYQ3UZ5ayT27pNtunDmXvLBo= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ= diff --git a/web/src/api/goauto/ai-matching-settings.js b/web/src/api/goauto/ai-matching-settings.js new file mode 100644 index 0000000..12f53d6 --- /dev/null +++ b/web/src/api/goauto/ai-matching-settings.js @@ -0,0 +1,13 @@ +import request from '@/utils/request' + +export function getAiMatchingSettings() { + return request({ url: '/api/admin/v1/ai-matching-settings', method: 'get' }) +} + +export function saveAiMatchingSettings(data) { + return request({ url: '/api/admin/v1/ai-matching-settings', method: 'put', data }) +} + +export function testAiMatchingConnection() { + return request({ url: '/api/admin/v1/ai-matching-settings/test', method: 'post' }) +} diff --git a/web/src/router/index.js b/web/src/router/index.js index 65d4bfc..8cf19cb 100644 --- a/web/src/router/index.js +++ b/web/src/router/index.js @@ -197,6 +197,19 @@ export const constantRoutes = [ } ] }, + { + path: '/ai-matching-settings', + component: Layout, + redirect: '/ai-matching-settings/index', + children: [ + { + path: 'index', + component: () => import('@/views/goauto/ai-matching-settings/index'), + name: 'GoAutoAiMatchingSettings', + meta: { title: 'AI 规格匹配', icon: 'setting' } + } + ] + }, { path: '/profile', component: Layout, diff --git a/web/src/views/goauto/ai-matching-settings/index.vue b/web/src/views/goauto/ai-matching-settings/index.vue new file mode 100644 index 0000000..b84ccb2 --- /dev/null +++ b/web/src/views/goauto/ai-matching-settings/index.vue @@ -0,0 +1,128 @@ + + + + + + + AI 规格匹配 + 当颜色或尺码的确定性标准化匹配没有唯一结果时,服务端才会请求已配置的 AI 服务。 + + {{ form.enabled ? '已启用' : '未启用' }} + + + + + + + + + 启用状态关闭时不会调用外部 AI 服务,未匹配规格会直接提示处理。 + + + + + + 服务设置支持 OpenAI 兼容的 HTTP 或 HTTPS 接口。内部部署的 API Key 保存后,管理员下次进入本页可以查看和替换。 + + + 支持公网和内网 HTTP/HTTPS 地址;HTTP 不会加密传输中的 API Key,生产环境建议使用 HTTPS。 + + + + + 仅管理员可查看和修改 API Key;采购员和 Android 不会收到该字段。 + + + + + + 保存设置 + 测试连接 + 测试连接只访问已保存的服务地址,不会创建采购任务。 + + + + + + + + + + +
当颜色或尺码的确定性标准化匹配没有唯一结果时,服务端才会请求已配置的 AI 服务。
关闭时不会调用外部 AI 服务,未匹配规格会直接提示处理。
支持 OpenAI 兼容的 HTTP 或 HTTPS 接口。内部部署的 API Key 保存后,管理员下次进入本页可以查看和替换。