feat(#62): add AI specification matching settings

This commit is contained in:
QiuSW
2026-08-21 17:04:49 +08:00
parent 9adb29bfd1
commit 6181d97f39
28 changed files with 1531 additions and 37 deletions
+4
View File
@@ -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 等非机密项在
+5 -2
View File
@@ -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` |
+6 -5
View File
@@ -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` 语义处理 |
+13 -2
View File
@@ -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
+15 -3
View File
@@ -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`。
+2
View File
@@ -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)
+161
View File
@@ -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)
}
}
+143
View File
@@ -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}
}
+16
View File
@@ -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)
}
+351
View File
@@ -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(&current, 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(&current).Error; createErr != nil {
return createErr
}
} else if saveErr := tx.Save(&current).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)
}
}
+89
View File
@@ -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} }
+1
View File
@@ -15,6 +15,7 @@ func MigratedModels() []any {
return []any{
&models.AgentDevice{},
&models.PDDProduct{},
&models.AIMatchingSetting{},
&models.ShopeeProduct{},
&models.SYBProduct{},
&models.SYBSession{},
+21
View File
@@ -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 = "[]"
+14 -1
View File
@@ -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"
+114 -4
View File
@@ -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 {
+100 -6
View File
@@ -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 {
+50 -6
View File
@@ -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)
+10 -8
View File
@@ -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
})
}
@@ -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
})
}
@@ -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)
}
}
+4
View File
@@ -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
+8
View File
@@ -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' })
}
+13
View File
@@ -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>