feat(#62): add AI specification matching settings
This commit is contained in:
@@ -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 等非机密项在
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -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` 语义处理 |
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 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`。
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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} }
|
||||
@@ -15,6 +15,7 @@ func MigratedModels() []any {
|
||||
return []any{
|
||||
&models.AgentDevice{},
|
||||
&models.PDDProduct{},
|
||||
&models.AIMatchingSetting{},
|
||||
&models.ShopeeProduct{},
|
||||
&models.SYBProduct{},
|
||||
&models.SYBSession{},
|
||||
|
||||
@@ -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 = "[]"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
+35
@@ -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
|
||||
})
|
||||
}
|
||||
+36
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card v-loading="loading" class="settings-card" shadow="never">
|
||||
<div class="page-heading">
|
||||
<div>
|
||||
<h1>AI 规格匹配</h1>
|
||||
<p>当颜色或尺码的确定性标准化匹配没有唯一结果时,服务端才会请求已配置的 AI 服务。</p>
|
||||
</div>
|
||||
<el-tag :type="form.enabled ? 'success' : 'info'" effect="light">{{ form.enabled ? '已启用' : '未启用' }}</el-tag>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="!isAdmin"
|
||||
title="当前账号只能查看 AI 规格匹配是否启用。服务地址、模型和 API Key 仅管理员可见和修改。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="notice"
|
||||
/>
|
||||
<template v-else>
|
||||
<el-alert
|
||||
title="AI 只会在服务端已有的可选 PDD 颜色和尺码中返回原始标签。没有唯一结果时采购任务会明确失败,不会让 Android 猜测或点击相近规格。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="notice"
|
||||
/>
|
||||
<el-form ref="settingsForm" :model="form" :rules="rules" label-position="top" class="settings-form" @submit.prevent>
|
||||
<section class="form-section" aria-labelledby="ai-status-title">
|
||||
<div class="section-heading">
|
||||
<div><h2 id="ai-status-title">启用状态</h2><p>关闭时不会调用外部 AI 服务,未匹配规格会直接提示处理。</p></div>
|
||||
<el-switch v-model="form.enabled" active-text="启用" inactive-text="关闭" aria-label="AI 规格匹配启用状态" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section" aria-labelledby="ai-provider-title">
|
||||
<div class="section-heading"><div><h2 id="ai-provider-title">服务设置</h2><p>支持 OpenAI 兼容的 HTTP 或 HTTPS 接口。内部部署的 API Key 保存后,管理员下次进入本页可以查看和替换。</p></div></div>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="Provider"><el-input :model-value="providerLabel" readonly aria-readonly="true" /></el-form-item>
|
||||
<el-form-item label="Base URL" prop="baseUrl"><el-input v-model.trim="form.baseUrl" :disabled="!form.enabled || saving" placeholder="http://provider.example.com:8000/v1" autocomplete="url" /><div class="field-help">支持公网和内网 HTTP/HTTPS 地址;HTTP 不会加密传输中的 API Key,生产环境建议使用 HTTPS。</div></el-form-item>
|
||||
<el-form-item label="模型" prop="model"><el-input v-model.trim="form.model" :disabled="!form.enabled || saving" placeholder="例如 gpt-4.1-mini" autocomplete="off" /></el-form-item>
|
||||
<el-form-item label="超时时间(秒)" prop="timeoutSeconds"><el-input-number v-model="form.timeoutSeconds" :min="3" :max="60" :disabled="!form.enabled || saving" controls-position="right" /></el-form-item>
|
||||
<el-form-item label="API Key" prop="apiKey" class="api-key-field">
|
||||
<el-input v-model="form.apiKey" type="password" show-password :disabled="!form.enabled || saving" placeholder="请输入 API Key" autocomplete="new-password" />
|
||||
<div class="field-help" aria-live="polite">仅管理员可查看和修改 API Key;采购员和 Android 不会收到该字段。</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
<el-button type="primary" :loading="saving" @click="save">保存设置</el-button>
|
||||
<el-button :loading="testing" :disabled="saving || !form.enabled" @click="testConnection">测试连接</el-button>
|
||||
<span class="helper">测试连接只访问已保存的服务地址,不会创建采购任务。</span>
|
||||
</div>
|
||||
</el-form>
|
||||
</template>
|
||||
</el-card>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getAiMatchingSettings, saveAiMatchingSettings, testAiMatchingConnection } from '@/api/goauto/ai-matching-settings'
|
||||
|
||||
export default {
|
||||
name: 'GoAutoAiMatchingSettings',
|
||||
data() {
|
||||
const validHttps = (rule, value, callback) => {
|
||||
if (!this.form.enabled || !value) return callback()
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) return callback(new Error('请输入有效的 HTTP 或 HTTPS 服务地址'))
|
||||
} catch (_) { return callback(new Error('请输入有效的 HTTP 或 HTTPS 服务地址')) }
|
||||
return callback()
|
||||
}
|
||||
return {
|
||||
loading: false,
|
||||
saving: false,
|
||||
testing: false,
|
||||
form: this.emptyForm(),
|
||||
rules: {
|
||||
baseUrl: [{ validator: validHttps, trigger: 'blur' }],
|
||||
model: [{ validator: (_, value, callback) => { if (!this.form.enabled || value) callback(); else callback(new Error('请输入模型名称')) }, trigger: 'blur' }],
|
||||
apiKey: [{ validator: (_, value, callback) => { if (!this.form.enabled || value) callback(); else callback(new Error('请输入 API Key')) }, trigger: 'blur' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdmin() { return (this.$store.getters.roles || []).includes('admin') },
|
||||
providerLabel() { return 'OpenAI-compatible' }
|
||||
},
|
||||
created() { this.load() },
|
||||
methods: {
|
||||
emptyForm() { return { enabled: false, provider: 'openai_compatible', baseUrl: '', model: '', timeoutSeconds: 15, apiKey: '' } },
|
||||
async load() {
|
||||
this.loading = true
|
||||
try {
|
||||
const response = await getAiMatchingSettings()
|
||||
this.form = { ...this.emptyForm(), ...response.data }
|
||||
} finally { this.loading = false }
|
||||
},
|
||||
async save() {
|
||||
const valid = await this.$refs.settingsForm.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
this.saving = true
|
||||
try {
|
||||
const response = await saveAiMatchingSettings({ enabled: this.form.enabled, baseUrl: this.form.baseUrl, model: this.form.model, timeoutSeconds: this.form.timeoutSeconds, apiKey: this.form.apiKey })
|
||||
this.form = { ...this.emptyForm(), ...response.data }
|
||||
ElMessage.success('AI 规格匹配设置已保存')
|
||||
} finally { this.saving = false }
|
||||
},
|
||||
async testConnection() {
|
||||
if (!this.form.enabled) return
|
||||
this.testing = true
|
||||
try {
|
||||
const response = await testAiMatchingConnection()
|
||||
ElMessage.success(response.data.message || 'AI 服务连接正常')
|
||||
} finally { this.testing = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.settings-card{min-height:calc(100vh - 124px);max-width:980px}.page-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:24px}.page-heading h1{margin:0 0 6px;color:#1f2937;font-size:24px;line-height:1.35}.page-heading p{max-width:680px;margin:0;color:#606266;line-height:1.6}.notice{margin-bottom:20px}.settings-form{max-width:820px}.form-section{margin-bottom:18px;padding:20px;border:1px solid #e5e7eb;border-radius:8px;background:#fff}.section-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:18px}.section-heading h2{margin:0 0 4px;color:#1f2937;font-size:16px;line-height:1.4}.section-heading p,.field-help,.helper{margin:0;color:#64748b;font-size:13px;line-height:1.5}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 20px}.form-grid :deep(.el-input-number){width:100%}.api-key-field{grid-column:1 / -1}.field-help{margin-top:6px}.actions{display:flex;align-items:center;flex-wrap:wrap;gap:12px;margin-top:24px}.actions .helper{margin-left:4px}@media(max-width:768px){.page-heading,.section-heading{flex-direction:column}.form-grid{grid-template-columns:1fr}.form-section{padding:16px}.actions{align-items:stretch}.actions :deep(.el-button){margin-left:0}}
|
||||
</style>
|
||||
Reference in New Issue
Block a user