From 284624bcba2a6c1e5d9dc9b916d05070a57089d5 Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Thu, 18 Dec 2025 18:19:46 +0800 Subject: [PATCH 01/14] =?UTF-8?q?feat:=201.=20=E6=96=B0=E5=A2=9E=20ollamaC?= =?UTF-8?q?lient=20chat=E6=96=B9=E6=B3=95=202.=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E4=BA=A7=E5=93=81=E6=95=B0=E6=8D=AE=E6=8F=90=E5=8F=96=E8=83=BD?= =?UTF-8?q?=E5=8A=9B=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/data/error/error_code.go | 4 + internal/domain/llm/options.go | 21 +-- .../domain/llm/provider/ollama/adapter.go | 9 +- internal/pkg/util/time.go | 41 ++++++ internal/pkg/utils_ollama/client.go | 19 +++ internal/server/http.go | 15 +- internal/server/router/router.go | 15 +- internal/services/callback.go | 69 +++++----- internal/services/capability.go | 129 ++++++++++++++++++ internal/services/provider_set.go | 4 +- 10 files changed, 266 insertions(+), 60 deletions(-) create mode 100644 internal/pkg/util/time.go create mode 100644 internal/services/capability.go diff --git a/internal/data/error/error_code.go b/internal/data/error/error_code.go index 9c28865..9d907e7 100644 --- a/internal/data/error/error_code.go +++ b/internal/data/error/error_code.go @@ -54,3 +54,7 @@ func ParamErr(message string, arg ...any) *BusinessErr { func (e *BusinessErr) Wrap(err error) *BusinessErr { return NewBusinessErr(e.code, err.Error()) } + +func KeyErr() *BusinessErr { + return &BusinessErr{code: KeyNotFound.code, message: KeyNotFound.message} +} diff --git a/internal/domain/llm/options.go b/internal/domain/llm/options.go index c427153..19f8983 100644 --- a/internal/domain/llm/options.go +++ b/internal/domain/llm/options.go @@ -3,14 +3,15 @@ package llm import "time" type Options struct { - Temperature float32 - MaxTokens int - Stream bool - Timeout time.Duration - Modalities []string - SystemPrompt string - Model string - TopP float32 - Stop []string - Endpoint string + Temperature float32 + MaxTokens int + Stream bool + Timeout time.Duration + Modalities []string + SystemPrompt string + Model string + TopP float32 + Stop []string + Endpoint string + Thinking bool } diff --git a/internal/domain/llm/provider/ollama/adapter.go b/internal/domain/llm/provider/ollama/adapter.go index 1c26bab..554bcf0 100644 --- a/internal/domain/llm/provider/ollama/adapter.go +++ b/internal/domain/llm/provider/ollama/adapter.go @@ -15,10 +15,11 @@ func New() *Adapter { return &Adapter{} } func (a *Adapter) Generate(ctx context.Context, input []*schema.Message, opts llm.Options) (*schema.Message, error) { cm, err := eino_ollama.NewChatModel(ctx, &eino_ollama.ChatModelConfig{ - BaseURL: opts.Endpoint, - Timeout: opts.Timeout, - Model: opts.Model, - Options: &eino_ollama.Options{Temperature: opts.Temperature, NumPredict: opts.MaxTokens}, + BaseURL: opts.Endpoint, + Timeout: opts.Timeout, + Model: opts.Model, + Options: &eino_ollama.Options{Temperature: opts.Temperature, NumPredict: opts.MaxTokens}, + Thinking: &eino_ollama.ThinkValue{Value: opts.Thinking}, }) if err != nil { return nil, err diff --git a/internal/pkg/util/time.go b/internal/pkg/util/time.go new file mode 100644 index 0000000..1c1bf3e --- /dev/null +++ b/internal/pkg/util/time.go @@ -0,0 +1,41 @@ +package util + +import "time" + +// 判断当前时间是否在时间窗口内 +// ts 时间戳字符串,支持秒级或毫秒级 +// window 时间窗口,例如 10 * time.Minute +func IsInTimeWindow(ts string, window time.Duration) bool { + // 期望毫秒时间戳或秒级,简单容错 + // 尝试解析为整数 + var n int64 + for _, base := range []int64{1, 1000} { // 秒或毫秒 + if v, ok := parseInt64(ts); ok { + n = v + // 归一为毫秒 + if base == 1 && len(ts) <= 10 { + n = n * 1000 + } + now := time.Now().UnixMilli() + diff := now - n + if diff < 0 { + diff = -diff + } + if diff <= window.Milliseconds() { + return true + } + } + } + return false +} + +func parseInt64(s string) (int64, bool) { + var n int64 + for _, ch := range s { + if ch < '0' || ch > '9' { + return 0, false + } + n = n*10 + int64(ch-'0') + } + return n, true +} diff --git a/internal/pkg/utils_ollama/client.go b/internal/pkg/utils_ollama/client.go index 91640f0..1f67774 100644 --- a/internal/pkg/utils_ollama/client.go +++ b/internal/pkg/utils_ollama/client.go @@ -90,6 +90,25 @@ func (c *Client) ChatStream(ctx context.Context, ch chan entitys.Response, messa return } +func (c *Client) Chat(ctx context.Context, messages []api.Message) (res api.ChatResponse, err error) { + // 构建聊天请求 + req := &api.ChatRequest{ + Model: c.config.Model, + Messages: messages, + Stream: new(bool), // 设置为false,不使用流式响应 + Think: &api.ThinkValue{Value: true}, + } + err = c.client.Chat(ctx, req, func(resp api.ChatResponse) error { + res = resp + return nil + }) + if err != nil { + return + } + + return +} + func (c *Client) Generation(ctx context.Context, generateRequest *api.GenerateRequest) (result api.GenerateResponse, err error) { err = c.client.Generate(ctx, generateRequest, func(resp api.GenerateResponse) error { result = resp diff --git a/internal/server/http.go b/internal/server/http.go index fd7e49e..53446c8 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -11,11 +11,13 @@ import ( ) type HTTPServer struct { - app *fiber.App - service *services.ChatService - session *services.SessionService - gateway *gateway.Gateway - callback *services.CallbackService + app *fiber.App + service *services.ChatService + session *services.SessionService + gateway *gateway.Gateway + callback *services.CallbackService + chatHis *services.HistoryService + capabilityService *services.CapabilityService } func NewHTTPServer( @@ -25,10 +27,11 @@ func NewHTTPServer( gateway *gateway.Gateway, callback *services.CallbackService, chatHis *services.HistoryService, + capabilityService *services.CapabilityService, ) *fiber.App { //构建 server app := initRoute() - router.SetupRoutes(app, service, session, task, gateway, callback, chatHis) + router.SetupRoutes(app, service, session, task, gateway, callback, chatHis, capabilityService) return app } diff --git a/internal/server/router/router.go b/internal/server/router/router.go index e2645bb..be861aa 100644 --- a/internal/server/router/router.go +++ b/internal/server/router/router.go @@ -15,16 +15,18 @@ import ( ) type RouterServer struct { - app *fiber.App - service *services.ChatService - session *services.SessionService - gateway *gateway.Gateway - chatHist *services.HistoryService + app *fiber.App + service *services.ChatService + session *services.SessionService + gateway *gateway.Gateway + chatHist *services.HistoryService + capabilityService *services.CapabilityService } // SetupRoutes 设置路由 func SetupRoutes(app *fiber.App, ChatService *services.ChatService, sessionService *services.SessionService, task *services.TaskService, gateway *gateway.Gateway, callbackService *services.CallbackService, chatHist *services.HistoryService, + capabilityService *services.CapabilityService, ) { app.Use(func(c *fiber.Ctx) error { // 设置 CORS 头 @@ -84,6 +86,9 @@ func SetupRoutes(app *fiber.App, ChatService *services.ChatService, sessionServi // 会话历史 r.Post("/chat/history/list", chatHist.List) r.Post("/chat/history/update/content", chatHist.UpdateContent) + + // 能力 + r.Post("/capability/product/ingest", capabilityService.ProductIngest) // 商品数据提取 } func routerSocket(app *fiber.App, chatService *services.ChatService) { diff --git a/internal/services/callback.go b/internal/services/callback.go index c903bea..cc32660 100644 --- a/internal/services/callback.go +++ b/internal/services/callback.go @@ -77,7 +77,8 @@ func (s *CallbackService) Callback(c *fiber.Ctx) error { ts := strings.TrimSpace(c.Get("X-Timestamp")) // 时间窗口(如果提供了 ts 则校验,否则跳过),窗口 5 分钟 - if ts != "" && !validateTimestamp(ts, 5*time.Minute) { + // if ts != "" && !validateTimestamp(ts, 5*time.Minute) { + if ts != "" && !util.IsInTimeWindow(ts, 5*time.Minute) { return errorcode.AuthNotFound } @@ -101,40 +102,40 @@ func (s *CallbackService) Callback(c *fiber.Ctx) error { } } -func validateTimestamp(ts string, window time.Duration) bool { - // 期望毫秒时间戳或秒级,简单容错 - // 尝试解析为整数 - var n int64 - for _, base := range []int64{1, 1000} { // 秒或毫秒 - if v, ok := parseInt64(ts); ok { - n = v - // 归一为毫秒 - if base == 1 && len(ts) <= 10 { - n = n * 1000 - } - now := time.Now().UnixMilli() - diff := now - n - if diff < 0 { - diff = -diff - } - if diff <= window.Milliseconds() { - return true - } - } - } - return false -} +// func validateTimestamp(ts string, window time.Duration) bool { +// // 期望毫秒时间戳或秒级,简单容错 +// // 尝试解析为整数 +// var n int64 +// for _, base := range []int64{1, 1000} { // 秒或毫秒 +// if v, ok := parseInt64(ts); ok { +// n = v +// // 归一为毫秒 +// if base == 1 && len(ts) <= 10 { +// n = n * 1000 +// } +// now := time.Now().UnixMilli() +// diff := now - n +// if diff < 0 { +// diff = -diff +// } +// if diff <= window.Milliseconds() { +// return true +// } +// } +// } +// return false +// } -func parseInt64(s string) (int64, bool) { - var n int64 - for _, ch := range s { - if ch < '0' || ch > '9' { - return 0, false - } - n = n*10 + int64(ch-'0') - } - return n, true -} +// func parseInt64(s string) (int64, bool) { +// var n int64 +// for _, ch := range s { +// if ch < '0' || ch > '9' { +// return 0, false +// } +// n = n*10 + int64(ch-'0') +// } +// return n, true +// } func (s *CallbackService) handleDingTalkCallback(c *fiber.Ctx, env Envelope) error { // 校验taskId diff --git a/internal/services/capability.go b/internal/services/capability.go new file mode 100644 index 0000000..e0adea0 --- /dev/null +++ b/internal/services/capability.go @@ -0,0 +1,129 @@ +package services + +import ( + "ai_scheduler/internal/config" + errorcode "ai_scheduler/internal/data/error" + "ai_scheduler/internal/pkg/util" + "ai_scheduler/internal/pkg/utils_ollama" + "context" + "fmt" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/ollama/ollama/api" +) + +// CapabilityService 统一回调入口 +type CapabilityService struct { + cfg *config.Config +} + +func NewCapabilityService(cfg *config.Config) *CapabilityService { + return &CapabilityService{ + cfg: cfg, + } +} + +// 产品数据提取入参 +type ProductIngestReq struct { + Url string `json:"url"` // 商品详情页URL + Title string `json:"title"` // 商品标题 + Text string `json:"text"` // 商品描述 + Images []string `json:"images"` // 商品图片URL列表 + Timestamp int64 `json:"timestamp"` // 商品发布时间戳 +} + +const ( + // 货易通商品属性模板-中文 + HYTProductPropertyTemplateZH = `{ + "条码": "string", // 商品编号 + "分类名称": "string", // 商品分类 + "货品名称": "string", // 商品名称 + "货品编号": "string", // 商品编号 + "商品货号": "string", // 商品编号 + "品牌": "string", // 商品品牌 + "单位": "string", // 商品单位,若无则使用'个' + "规格参数": "string", // 商品规格参数 + "货品说明": "string", // 商品说明 + "保质期": "string", // 商品保质期 + "保质期单位": "string", // 商品保质期单位 + "链接": "string", // + "货品图片": ["string"], // 商品多图,取1-2个即可 + "电商销售价格": "decimal(10,2)", // 商品电商销售价格 + "销售价": "decimal(10,2)", // 商品销售价格 + "供应商报价": "decimal(10,2)", // 商品供应商报价 + "税率": "number%", // 商品税率 x% + "默认供应商": "", // 空即可 + "默认存放仓库": "", // 空即可 + "备注": "", // 备注 + "长": "string", // 商品长度,decimal(10,2)+单位 + "宽": "string", // 商品宽度,decimal(10,2)+单位 + "高": "string", // 商品高度,decimal(10,2)+单位 + "重量": "string", // 商品重量(kg) + "SPU名称": "string", // 商品SPU名称 + "SPU编码": "string" // 编码串,jd_{timestamp}_rand(1000-999) + }` + + SystemPrompt = `你是一个专业的商品属性提取助手,你的任务是根据用户输入提取商品的属性信息。 + 目标属性模板:%s。 + 最终输出格式为纯JSON字符串,键值对对应目标属性和提取到的属性值。 + 最终输出不要携带markdown标识,不要携带回车换行` +) + +// ProductIngest 产品数据提取 +func (s *CapabilityService) ProductIngest(c *fiber.Ctx) error { + // 读取头 + token := strings.TrimSpace(c.Get("X-Source-Key")) + ts := strings.TrimSpace(c.Get("X-Timestamp")) + + // 时间窗口校验 + if ts != "" && !util.IsInTimeWindow(ts, 5*time.Minute) { + // return errorcode.AuthNotFound + } + // token校验 + if token == "" || token != "A7f9KQ3mP2X8LZC4R5e" { + // return errorcode.KeyErr() + } + + // 解析请求参数 + req := ProductIngestReq{} + if err := c.BodyParser(&req); err != nil { + return errorcode.ParamErr("invalid request body: %v", err) + } + + // 必要参数校验 + if req.Text == "" { + return errorcode.ParamErr("missing required fields") + } + + // 模型调用 + client, cleanup, err := utils_ollama.NewClient(s.cfg) + if err != nil { + return err + } + defer cleanup() + + res, err := client.Chat(context.Background(), []api.Message{ + { + Role: "system", + Content: fmt.Sprintf(SystemPrompt, HYTProductPropertyTemplateZH), + }, + { + Role: "user", + Content: req.Text, + }, + { + Role: "user", + Content: "商品图片URL列表:" + strings.Join(req.Images, ","), + }, + }) + if err != nil { + return err + } + + // 解析模型输出 + c.JSON(res.Message.Content) + + return nil +} diff --git a/internal/services/provider_set.go b/internal/services/provider_set.go index 867eb11..375a886 100644 --- a/internal/services/provider_set.go +++ b/internal/services/provider_set.go @@ -12,4 +12,6 @@ var ProviderSetServices = wire.NewSet( NewTaskService, NewCallbackService, NewDingBotService, - NewHistoryService) + NewHistoryService, + NewCapabilityService, +) From 8414a57661f7633843caeb471c60eeadb514db63 Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Fri, 19 Dec 2025 10:10:45 +0800 Subject: [PATCH 02/14] =?UTF-8?q?fix=EF=BC=9A=E6=89=93=E5=BC=80=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/services/capability.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/services/capability.go b/internal/services/capability.go index e0adea0..aac842d 100644 --- a/internal/services/capability.go +++ b/internal/services/capability.go @@ -79,11 +79,11 @@ func (s *CapabilityService) ProductIngest(c *fiber.Ctx) error { // 时间窗口校验 if ts != "" && !util.IsInTimeWindow(ts, 5*time.Minute) { - // return errorcode.AuthNotFound + return errorcode.AuthNotFound } // token校验 if token == "" || token != "A7f9KQ3mP2X8LZC4R5e" { - // return errorcode.KeyErr() + return errorcode.KeyErr() } // 解析请求参数 From 5b789e557a876bb270844bb7601729b608429a7d Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Fri, 19 Dec 2025 18:38:06 +0800 Subject: [PATCH 03/14] =?UTF-8?q?feat=EF=BC=9A=201.=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=B4=A7=E6=98=93=E9=80=9A=E5=95=86=E5=93=81=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=202.=20=E6=96=B0=E5=A2=9E=E8=B4=A7=E6=98=93?= =?UTF-8?q?=E9=80=9A=E5=95=86=E5=93=81=E4=B8=8A=E4=BC=A0=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=203.=20=E6=96=B0=E5=A2=9E=E5=95=86=E5=93=81=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E8=87=B3=E8=B4=A7=E6=98=93=E9=80=9A=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/config_env.yaml | 3 + internal/config/config.go | 2 + internal/data/constants/capability.go | 174 ++++++++++++++++++ .../domain/tools/hyt/product_upload/client.go | 60 ++++++ .../tools/hyt/product_upload/client_test.go | 60 ++++++ .../domain/tools/hyt/product_upload/types.go | 54 ++++++ .../domain/workflow/hyt/product_upload.go | 112 +++++++++++ internal/domain/workflow/runtime/registry.go | 2 +- .../zltx/order_after_reseller_batch.go | 14 +- internal/pkg/util/map.go | 14 ++ internal/server/router/router.go | 8 +- internal/services/capability.go | 107 +++++------ 12 files changed, 548 insertions(+), 62 deletions(-) create mode 100644 internal/data/constants/capability.go create mode 100644 internal/domain/tools/hyt/product_upload/client.go create mode 100644 internal/domain/tools/hyt/product_upload/client_test.go create mode 100644 internal/domain/tools/hyt/product_upload/types.go create mode 100644 internal/domain/workflow/hyt/product_upload.go create mode 100644 internal/pkg/util/map.go diff --git a/config/config_env.yaml b/config/config_env.yaml index 23a123b..aa8b07b 100644 --- a/config/config_env.yaml +++ b/config/config_env.yaml @@ -74,6 +74,9 @@ tools: zltxOrderAfterSaleResellerBatch: enabled: true base_url: "https://gateway.dev.cdlsxd.cn/zltx_api/admin/afterSales/reseller_pre_ai" + hytProductUpload: + enabled: true + base_url: "https://gateway.dev.cdlsxd.cn/zltx_api/admin/oursProduct" diff --git a/internal/config/config.go b/internal/config/config.go index ee0c1a5..0c3f4dd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -141,6 +141,8 @@ type ToolsConfig struct { CozeExpress ToolConfig `mapstructure:"cozeExpress"` // Coze 公司查询工具 CozeCompany ToolConfig `mapstructure:"cozeCompany"` + // 货易通商品上传 + HytProductUpload ToolConfig `mapstructure:"hytProductUpload"` } // ToolConfig 单个工具配置 diff --git a/internal/data/constants/capability.go b/internal/data/constants/capability.go new file mode 100644 index 0000000..4ee518b --- /dev/null +++ b/internal/data/constants/capability.go @@ -0,0 +1,174 @@ +package constants + +// Token +const ( + CapabilityProductIngestToken = "A7f9KQ3mP2X8LZC4R5e" +) + +// Prompt +const ( + SystemPrompt = ` + #你是一个专业的商品属性提取助手,你的任务是根据用户输入提取商品的属性信息。 + 目标属性模板:%s。 + 1.最终输出格式为纯JSON字符串,键值对对应目标属性和提取到的属性值。 + 2.最终输出不要携带markdown标识,不要携带回车换行` +) + +// 商品属性模板-中文 +const ( + // 货易通商品属性模板-中文 + HYTProductPropertyTemplateZH = `{ + "条码": "string", // 商品编号 + "分类名称": "string", // 商品分类 + "货品名称": "string", // 商品名称 + "货品编号": "string", // 商品编号 + "商品货号": "string", // 商品编号 + "品牌": "string", // 商品品牌 + "单位": "string", // 商品单位,若无则使用'个' + "规格参数": "string", // 商品规格参数 + "货品说明": "string", // 商品说明 + "保质期": "string", // 商品保质期 + "保质期单位": "string", // 商品保质期单位 + "链接": "string", // + "货品图片": ["string"], // 商品多图,取1-2个即可 + "电商销售价格": "decimal(10,2)", // 商品电商销售价格 + "销售价": "decimal(10,2)", // 商品销售价格 + "供应商报价": "decimal(10,2)", // 商品供应商报价 + "税率": "number%", // 商品税率 x% + "默认供应商": "", // 空即可 + "默认存放仓库": "", // 空即可 + "备注": "", // 备注 + "长": "string", // 商品长度,decimal(10,2)+单位 + "宽": "string", // 商品宽度,decimal(10,2)+单位 + "高": "string", // 商品高度,decimal(10,2)+单位 + "重量": "string", // 商品重量(kg) + "SPU名称": "string", // 商品SPU名称 + "SPU编码": "string" // 编码串,jd_{timestamp}_rand(1000-999) + }` +) + +// 商品属性模板 +const ( + HYTProductPropertyTemplate = `{ + "important_data": { + "type": "object", + "properties": { + "supplier_name": { + "type": "string", + "description": "供应商名称" + }, + "warehouse_name": { + "type": "string", + "description": "仓库名称" + }, + "profit": { + "type": "float64", + "description": "利润 decimal(10,2)" + }, + "tax_rate": { + "type": "integer", + "description": "税率 (x)%" + } + } + }, + "goods_info": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "商品名称" + }, + "brand": { + "type": "string", + "description": "品牌" + }, + "category": { + "type": "string", + "description": "分类" + }, + "price": { + "type": "float64", + "description": "市场价 decimal(10,2)" + }, + "sales_price": { + "type": "float64", + "description": "建议销售价 decimal(10,2)" + }, + "discount": { + "type": "integer", + "description": "折扣百分比 公式:(市场价-建议销售价)/市场价*100" + }, + "goods_attributes": { + "type": "string", + "description": "商品属性" + }, + "goods_bar_code": { + "type": "string", + "description": "商品条码" + }, + "goods_illustration": { + "type": "string", + "description": "商品插图" + }, + "goods_num": { + "type": "string", + "description": "商品编号" + }, + "introduction": { + "type": "string", + "description": "商品介绍" + }, + "spu_name": { + "type": "string", + "description": "SPU名称" + }, + "spu_num": { + "type": "string", + "description": "SPU编号" + }, + "stock": { + "type": "integer", + "description": "库存" + }, + "tax_rate": { + "type": "integer", + "description": "税率" + }, + "unit": { + "type": "string", + "description": "单位" + }, + "weight": { + "type": "string", + "description": "重量" + } + } + }, + "goods_media_list": { + "type": "array", + "description": "商品媒体文件列表", + "items": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "媒体文件URL" + }, + "type": { + "type": "integer", + "description": "媒体类型(1:图片, 2:视频)" + }, + "sort": { + "type": "integer", + "description": "排序序号" + } + } + } + } + }` +) + +// 外部平台地址 +const ( + HYTProductListPageURL = "https://gateway.dev.cdlsxd.cn/sw//#/goods/goodsManage" +) diff --git a/internal/domain/tools/hyt/product_upload/client.go b/internal/domain/tools/hyt/product_upload/client.go new file mode 100644 index 0000000..4924b50 --- /dev/null +++ b/internal/domain/tools/hyt/product_upload/client.go @@ -0,0 +1,60 @@ +package product_upload + +import ( + "ai_scheduler/internal/config" + "ai_scheduler/internal/pkg/l_request" + "ai_scheduler/internal/pkg/util" + "context" + "encoding/json" + "errors" +) + +func Call(ctx context.Context, cfg config.ToolConfig, toolReq *ProductUploadRequest) (toolResp *ProductUploadResponse, err error) { + // 商品有且只能有一个 + if len(toolReq.GoodsList) != 1 { + err = errors.New("商品只能有一个") + return + } + + apiReq, _ := util.StructToMap(toolReq) + + req := l_request.Request{ + Method: "Post", + Url: "http://120.55.12.245:8100/api/v1/goods/supplier/batch/add/complete", + Json: apiReq, + } + res, err := req.Send() + + if err != nil { + return + } + + type resType struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Ids []int `json:"ids"` // 预览URL + } `json:"data"` + } + var resMap resType + err = json.Unmarshal([]byte(res.Text), &resMap) + if err != nil { + return + } + if resMap.Code != 200 { + err = errors.New("货易通商品创建失败") + return + } + if len(resMap.Data.Ids) == 0 { + err = errors.New("货易通商品创建失败") + return + } + + toolResp = &ProductUploadResponse{ + PreviewUrl: "https://gateway.dev.cdlsxd.cn/sw//#/goods/goodsManage", + SpuNum: toolReq.GoodsList[0].GoodsInfo.SpuNum, + Id: resMap.Data.Ids[0], + } + + return toolResp, nil +} diff --git a/internal/domain/tools/hyt/product_upload/client_test.go b/internal/domain/tools/hyt/product_upload/client_test.go new file mode 100644 index 0000000..2f4b01b --- /dev/null +++ b/internal/domain/tools/hyt/product_upload/client_test.go @@ -0,0 +1,60 @@ +package product_upload + +import ( + "ai_scheduler/internal/config" + "context" + "fmt" + "testing" +) + +// Test_Call +func Test_Call(t *testing.T) { + req := &ProductUploadRequest{ + SupplierId: 261, + WarehouseId: 257, + IsDefaultWarehouse: 1, + Sort: 1, + Profit: 40, + TaxRate: 13, + GoodsList: []Goods{ + { + GoodsInfo: GoodsInfo{ + Title: "Apple iPhone 17 Pro Max 星宇橙色 256GB", + Brand: "Apple/苹果", + Category: "手机", + CostPrice: 9999.00, + GoodsAttributes: "CPU型号:A19 Pro;操作系统:iOS;机身存储:256GB;屏幕尺寸:6.86英寸;屏幕材质:OLED直屏;屏幕技术:视网膜XDR;后置摄像头:4800万像素三主摄系统(主摄4800万+超广角4800万+长焦4800万);前置摄像头:1800万像素;网络支持:5G双卡双待(移动/联通/电信);生物识别:人脸识别;防水等级:IP68;充电功率:40W;无线充电:支持;机身尺寸:163.4mm×78.0mm×8.75mm;机身重量:231g;机身颜色:星宇橙色;特征特质:轻薄、防水防尘、无线充电、NFC、磁吸无线充", + GoodsBarCode: "10181383848993", + GoodsIllustration: "Apple/苹果 iPhone 17 Pro Max 【需当面激活】支持移动联通电信 5G 双卡双待手机 星宇橙色 256GB 官方标配。搭载A19 Pro芯片,6.86英寸OLED视网膜XDR直屏,4800万像素三主摄系统,支持5G双卡双待,IP68防水防尘,40W有线充电,支持无线充电和磁吸充电。", + GoodsNum: "10181383848993", + Introduction: "Apple/苹果 iPhone 17 Pro Max 【需当面激活】支持移动联通电信 5G 双卡双待手机 星宇橙色 256GB 官方标配。搭载A19 Pro芯片,6.86英寸OLED视网膜XDR直屏,4800万像素三主摄系统,支持5G双卡双待,IP68防水防尘,40W有线充电,支持无线充电和磁吸充电。", + IsBind: 1, + SpuName: "Apple iPhone 17 Pro Max", + SpuNum: "jd_1766038130329_8721", + TaxRate: 13, + Unit: "台", + Weight: "0.231", // 单位:kg + Price: 9999.00, + SalesPrice: 9999.00, + Stock: 0, // JSON 中未提供库存信息 + Discount: 10, // JSON 中未提供折扣信息 + IsComposeGoods: 2, + IsHot: 2, + }, + GoodsMediaList: []GoodsMedia{ + { + Type: 1, + Url: "https://img10.360buyimg.com/pcpubliccms/s228x228_jfs/t1/363919/12/2409/45712/691d9970F84b99d32/9f9a5d5d16efeb79.jpg.avif", + }, + }, + }, + }, + } + toolResp, err := Call(context.Background(), config.ToolConfig{}, req) + + if err != nil { + t.Errorf("Call() error = %v", err) + } + + fmt.Printf("toolResp: %v\n", toolResp) +} diff --git a/internal/domain/tools/hyt/product_upload/types.go b/internal/domain/tools/hyt/product_upload/types.go new file mode 100644 index 0000000..947dbe5 --- /dev/null +++ b/internal/domain/tools/hyt/product_upload/types.go @@ -0,0 +1,54 @@ +package product_upload + +type ProductUploadRequest struct { + SupplierId int `json:"supplier_id"` // 供应商ID + WarehouseId int `json:"warehouse_id"` // 仓库ID + IsDefaultWarehouse int `json:"is_default_warehouse"` // 是否默认仓库 + Sort int `json:"sort"` // 排序 + Profit float64 `json:"profit"` // 利润 + TaxRate int `json:"tax_rate"` // 税率 + GoodsList []Goods `json:"goods_list"` // 商品列表 +} + +type Goods struct { + GoodsInfo GoodsInfo `json:"goods_info"` + GoodsMediaList []GoodsMedia `json:"goods_media_list"` +} + +type GoodsInfo struct { + Title string `json:"title"` // 商品名称 + Brand string `json:"brand"` // 品牌 + Category string `json:"category"` // 分类 + Discount int `json:"discount"` // 折扣 + GoodsAttributes string `json:"goods_attributes"` // 商品属性 + GoodsBarCode string `json:"goods_bar_code"` // 商品条码 + GoodsNum string `json:"goods_num"` // 商品编号 + Introduction string `json:"introduction"` // 商品介绍 + SpuName string `json:"spu_name"` // SPU名称 + SpuNum string `json:"spu_num"` // SPU编号 + Stock int `json:"stock"` // 库存 + TaxRate int `json:"tax_rate"` // 税率 + Unit string `json:"unit"` // 单位 + Weight string `json:"weight"` // 重量 + Price float64 `json:"price"` // 市场价 + SalesPrice float64 `json:"sales_price"` // 建议销售价格 + GoodsIllustration string `json:"goods_illustration"` // 商品插图 - 暂不提供 + Id int `json:"id"` // 商品ID - 无需 + CostPrice float64 `json:"cost_price"` // 成本价格 - 无需 + IsBind int `json:"is_bind"` // 是否绑定 - 默认0 + IsComposeGoods int32 `json:"is_compose_goods"` // 是否组合商品 - 默认2 + IsHot int `json:"is_hot"` // 是否热门商品 - 默认2 +} + +type GoodsMedia struct { + Remark string `json:"remark"` // 备注 + Sort int `json:"sort"` // 排序 + Type int `json:"type"` // 类型 + Url string `json:"url"` // URL +} + +type ProductUploadResponse struct { + PreviewUrl string `json:"preview_url"` // 预览URL + SpuNum string `json:"spu_code"` // SPU编码 + Id int `json:"id"` // 商品ID +} diff --git a/internal/domain/workflow/hyt/product_upload.go b/internal/domain/workflow/hyt/product_upload.go new file mode 100644 index 0000000..6ab98f1 --- /dev/null +++ b/internal/domain/workflow/hyt/product_upload.go @@ -0,0 +1,112 @@ +package hyt + +import ( + "ai_scheduler/internal/config" + "ai_scheduler/internal/data/constants" + toolHytPu "ai_scheduler/internal/domain/tools/hyt/product_upload" + "ai_scheduler/internal/domain/workflow/runtime" + "ai_scheduler/internal/entitys" + "context" + "encoding/json" + "fmt" + + eino_ollama "github.com/cloudwego/eino-ext/components/model/ollama" + "github.com/cloudwego/eino/components/prompt" + "github.com/cloudwego/eino/compose" + "github.com/cloudwego/eino/schema" +) + +const WorkflowID = "hyt.productUpload" + +func init() { + runtime.Register(WorkflowID, func(d *runtime.Deps) (runtime.Workflow, error) { + return &productUpload{cfg: d.Conf}, nil + }) +} + +type productUpload struct { + cfg *config.Config + data *ProductUploadWorkflowInput +} + +type ProductUploadWorkflowInput struct { + Text string `mapstructure:"text"` +} + +func (o *productUpload) ID() string { return WorkflowID } + +func (o *productUpload) Invoke(ctx context.Context, rec *entitys.Recognize) (map[string]any, error) { + // 构建工作流 + chain, err := o.buildWorkflow(ctx) + if err != nil { + return nil, err + } + + o.data = &ProductUploadWorkflowInput{ + Text: rec.UserContent.Text, + } + // 工作流过程调用 + output, err := chain.Invoke(ctx, o.data) + if err != nil { + return nil, err + } + + fmt.Printf("workflow output: %v\n", output) + + // 不关心输出,全部在途中输出 + return output, nil +} + +func (o *productUpload) buildWorkflow(ctx context.Context) (compose.Runnable[*ProductUploadWorkflowInput, map[string]any], error) { + // 定义工作流 + c := compose.NewChain[*ProductUploadWorkflowInput, map[string]any]() + + // AI映射工具所需参数' + paramMappingModel, err := eino_ollama.NewChatModel(ctx, &eino_ollama.ChatModelConfig{ + BaseURL: o.cfg.Ollama.BaseURL, + Timeout: o.cfg.Ollama.Timeout, + Model: o.cfg.Ollama.Model, + Thinking: &eino_ollama.ThinkValue{Value: true}, + Options: &eino_ollama.Options{Temperature: 0.5}, + }) + if err != nil { + return nil, err + } + + // 1. 构建参LLM数映射提示词 + c.AppendChatTemplate(prompt.FromMessages( + schema.FString, + schema.SystemMessage("你是一个专业的商品参数解析器,你需要根据用户输入的商品描述,解析出商品的目标参数。"), + schema.SystemMessage("目标参数:"+constants.HYTProductPropertyTemplate), + schema.UserMessage("用户输入:{{.Text}}"), + )) + // 2. 调用LLM + c.AppendChatModel(paramMappingModel) + + // 3.工具参数整理 + c.AppendLambda(compose.InvokableLambda(func(_ context.Context, in *schema.Message) (*toolHytPu.ProductUploadRequest, error) { + toolReq := &toolHytPu.ProductUploadRequest{} + if err := json.Unmarshal([]byte(in.Content), toolReq); err != nil { + return nil, err + } + return toolReq, nil + })) + + // 4.工具调用 + c.AppendLambda(compose.InvokableLambda(func(_ context.Context, in *toolHytPu.ProductUploadRequest) (*toolHytPu.ProductUploadResponse, error) { + toolRes, err := toolHytPu.Call(ctx, o.cfg.Tools.HytProductUpload, in) + return toolRes, err + })) + + // 5.结果数据映射 + c.AppendLambda(compose.InvokableLambda(func(_ context.Context, in *toolHytPu.ProductUploadResponse) (map[string]any, error) { + return map[string]any{ + "预览URL(货易通商品列表)": in.PreviewUrl, + "SPU编码": in.SpuNum, + "商品ID": in.Id, + }, nil + })) + + // 6.编译工作流 + return c.Compile(ctx) +} diff --git a/internal/domain/workflow/runtime/registry.go b/internal/domain/workflow/runtime/registry.go index c854053..bf840e6 100644 --- a/internal/domain/workflow/runtime/registry.go +++ b/internal/domain/workflow/runtime/registry.go @@ -11,7 +11,7 @@ import ( type Workflow interface { ID() string - Schema() map[string]any + // Schema() map[string]any Invoke(ctx context.Context, requireData *entitys.Recognize) (map[string]any, error) } diff --git a/internal/domain/workflow/zltx/order_after_reseller_batch.go b/internal/domain/workflow/zltx/order_after_reseller_batch.go index 9749bf7..5e5fa51 100644 --- a/internal/domain/workflow/zltx/order_after_reseller_batch.go +++ b/internal/domain/workflow/zltx/order_after_reseller_batch.go @@ -79,13 +79,13 @@ type OrderAfterSaleResellerBatchData struct { func (o *orderAfterSaleResellerBatch) ID() string { return "zltx.orderAfterSaleResellerBatch" } // Schema 返回入参约束(用于校验/表单生成) -func (o *orderAfterSaleResellerBatch) Schema() map[string]any { - return map[string]any{ - "type": "object", - "properties": map[string]any{"orderNumber": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}}, - "required": []string{"orderNumber"}, - } -} +// func (o *orderAfterSaleResellerBatch) Schema() map[string]any { +// return map[string]any{ +// "type": "object", +// "properties": map[string]any{"orderNumber": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}}, +// "required": []string{"orderNumber"}, +// } +// } // Invoke 调用原有编排工作流并规范化输出 func (o *orderAfterSaleResellerBatch) Invoke(ctx context.Context, rec *entitys.Recognize) (map[string]any, error) { diff --git a/internal/pkg/util/map.go b/internal/pkg/util/map.go new file mode 100644 index 0000000..5ca80c8 --- /dev/null +++ b/internal/pkg/util/map.go @@ -0,0 +1,14 @@ +package util + +import "encoding/json" + +// StructToMap 将结构体转换为 map[string]any +func StructToMap(v any) (map[string]any, error) { + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + var m map[string]any + err = json.Unmarshal(b, &m) + return m, err +} diff --git a/internal/server/router/router.go b/internal/server/router/router.go index be861aa..3359984 100644 --- a/internal/server/router/router.go +++ b/internal/server/router/router.go @@ -34,6 +34,11 @@ func SetupRoutes(app *fiber.App, ChatService *services.ChatService, sessionServi c.Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") c.Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + // AI能力调用路由,设置不同的 CORS 头 + if strings.HasPrefix(c.Path(), "/api/v1/capability") { + c.Set("Access-Control-Allow-Headers", "Content-Type, X-Source-Key, X-Timestamp") + } + // 如果是预检请求(OPTIONS),直接返回 204 if c.Method() == "OPTIONS" { return c.SendStatus(fiber.StatusNoContent) // 204 @@ -88,7 +93,8 @@ func SetupRoutes(app *fiber.App, ChatService *services.ChatService, sessionServi r.Post("/chat/history/update/content", chatHist.UpdateContent) // 能力 - r.Post("/capability/product/ingest", capabilityService.ProductIngest) // 商品数据提取 + r.Post("/capability/product/ingest", capabilityService.ProductIngest) // 商品数据提取 + r.Post("/capability/product/upload/hyt", capabilityService.ProductUploadHyt) // 货易通商品数据上传 } func routerSocket(app *fiber.App, chatService *services.ChatService) { diff --git a/internal/services/capability.go b/internal/services/capability.go index aac842d..d0eb1fe 100644 --- a/internal/services/capability.go +++ b/internal/services/capability.go @@ -2,7 +2,10 @@ package services import ( "ai_scheduler/internal/config" + "ai_scheduler/internal/data/constants" errorcode "ai_scheduler/internal/data/error" + "ai_scheduler/internal/domain/workflow/runtime" + "ai_scheduler/internal/entitys" "ai_scheduler/internal/pkg/util" "ai_scheduler/internal/pkg/utils_ollama" "context" @@ -16,12 +19,14 @@ import ( // CapabilityService 统一回调入口 type CapabilityService struct { - cfg *config.Config + cfg *config.Config + workflowManager *runtime.Registry } -func NewCapabilityService(cfg *config.Config) *CapabilityService { +func NewCapabilityService(cfg *config.Config, workflowManager *runtime.Registry) *CapabilityService { return &CapabilityService{ - cfg: cfg, + cfg: cfg, + workflowManager: workflowManager, } } @@ -34,56 +39,11 @@ type ProductIngestReq struct { Timestamp int64 `json:"timestamp"` // 商品发布时间戳 } -const ( - // 货易通商品属性模板-中文 - HYTProductPropertyTemplateZH = `{ - "条码": "string", // 商品编号 - "分类名称": "string", // 商品分类 - "货品名称": "string", // 商品名称 - "货品编号": "string", // 商品编号 - "商品货号": "string", // 商品编号 - "品牌": "string", // 商品品牌 - "单位": "string", // 商品单位,若无则使用'个' - "规格参数": "string", // 商品规格参数 - "货品说明": "string", // 商品说明 - "保质期": "string", // 商品保质期 - "保质期单位": "string", // 商品保质期单位 - "链接": "string", // - "货品图片": ["string"], // 商品多图,取1-2个即可 - "电商销售价格": "decimal(10,2)", // 商品电商销售价格 - "销售价": "decimal(10,2)", // 商品销售价格 - "供应商报价": "decimal(10,2)", // 商品供应商报价 - "税率": "number%", // 商品税率 x% - "默认供应商": "", // 空即可 - "默认存放仓库": "", // 空即可 - "备注": "", // 备注 - "长": "string", // 商品长度,decimal(10,2)+单位 - "宽": "string", // 商品宽度,decimal(10,2)+单位 - "高": "string", // 商品高度,decimal(10,2)+单位 - "重量": "string", // 商品重量(kg) - "SPU名称": "string", // 商品SPU名称 - "SPU编码": "string" // 编码串,jd_{timestamp}_rand(1000-999) - }` - - SystemPrompt = `你是一个专业的商品属性提取助手,你的任务是根据用户输入提取商品的属性信息。 - 目标属性模板:%s。 - 最终输出格式为纯JSON字符串,键值对对应目标属性和提取到的属性值。 - 最终输出不要携带markdown标识,不要携带回车换行` -) - // ProductIngest 产品数据提取 func (s *CapabilityService) ProductIngest(c *fiber.Ctx) error { - // 读取头 - token := strings.TrimSpace(c.Get("X-Source-Key")) - ts := strings.TrimSpace(c.Get("X-Timestamp")) - - // 时间窗口校验 - if ts != "" && !util.IsInTimeWindow(ts, 5*time.Minute) { - return errorcode.AuthNotFound - } - // token校验 - if token == "" || token != "A7f9KQ3mP2X8LZC4R5e" { - return errorcode.KeyErr() + // 请求头校验 + if err := s.checkRequestHeader(c); err != nil { + return err } // 解析请求参数 @@ -91,7 +51,6 @@ func (s *CapabilityService) ProductIngest(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return errorcode.ParamErr("invalid request body: %v", err) } - // 必要参数校验 if req.Text == "" { return errorcode.ParamErr("missing required fields") @@ -107,7 +66,7 @@ func (s *CapabilityService) ProductIngest(c *fiber.Ctx) error { res, err := client.Chat(context.Background(), []api.Message{ { Role: "system", - Content: fmt.Sprintf(SystemPrompt, HYTProductPropertyTemplateZH), + Content: fmt.Sprintf(constants.SystemPrompt, constants.HYTProductPropertyTemplateZH), }, { Role: "user", @@ -122,8 +81,50 @@ func (s *CapabilityService) ProductIngest(c *fiber.Ctx) error { return err } + // res.Message.Content Go中map会无序,交给前端解析 + // 解析模型输出 c.JSON(res.Message.Content) return nil } + +// checkRequestHeader 校验请求头 +func (s *CapabilityService) checkRequestHeader(c *fiber.Ctx) error { + // 读取头 + token := strings.TrimSpace(c.Get("X-Source-Key")) + ts := strings.TrimSpace(c.Get("X-Timestamp")) + + // 时间窗口校验 + if ts != "" && !util.IsInTimeWindow(ts, 5*time.Minute) { + return errorcode.AuthNotFound + } + // token校验 + if token == "" || token != "A7f9KQ3mP2X8LZC4R5e" { + return errorcode.KeyErr() + } + + return nil +} + +// ProductUploadHyt 商品上传至货易通 +func (s *CapabilityService) ProductUploadHyt(c *fiber.Ctx) error { + // 请求头校验 + if err := s.checkRequestHeader(c); err != nil { + return err + } + + // 获取 body json 串 + raw := append([]byte(nil), c.BodyRaw()...) + bodyStr := string(raw) + + // 调用eino工作流,实现商品上传到货易通 + workflowId := "hyt.productUpload" + rec := &entitys.Recognize{UserContent: &entitys.RecognizeUserContent{Text: bodyStr}} + res, err := s.workflowManager.Invoke(context.Background(), workflowId, rec) + if err != nil { + return err + } + + return c.JSON(res) +} From d0ba329024f343e9eb6c7d9fd1f1786c0f7dec6b Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Sat, 20 Dec 2025 18:42:37 +0800 Subject: [PATCH 04/14] =?UTF-8?q?feat:=201.=E8=B0=83=E6=95=B4=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=E6=A8=A1=E6=9D=BF=202.=E4=BA=AC=E4=B8=9C=E5=95=86?= =?UTF-8?q?=E5=93=81=E6=8A=93=E5=8F=96=E5=B7=A5=E4=BD=9C=E6=B5=81=203.?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=89=80=E9=9C=80=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/data/constants/capability.go | 23 +- .../domain/tools/hyt/product_upload/client.go | 2 +- .../tools/hyt/product_upload/client_test.go | 8 +- .../tools/hyt/supplier_search/client.go | 61 +++++ .../domain/tools/hyt/supplier_search/types.go | 24 ++ .../tools/hyt/warehouse_search/client.go | 56 +++++ .../tools/hyt/warehouse_search/types.go | 14 ++ .../domain/workflow/hyt/product_upload.go | 237 +++++++++++++++++- internal/server/router/router.go | 4 +- internal/services/capability.go | 121 +++++++-- 10 files changed, 501 insertions(+), 49 deletions(-) create mode 100644 internal/domain/tools/hyt/supplier_search/client.go create mode 100644 internal/domain/tools/hyt/supplier_search/types.go create mode 100644 internal/domain/tools/hyt/warehouse_search/client.go create mode 100644 internal/domain/tools/hyt/warehouse_search/types.go diff --git a/internal/data/constants/capability.go b/internal/data/constants/capability.go index 4ee518b..9956336 100644 --- a/internal/data/constants/capability.go +++ b/internal/data/constants/capability.go @@ -9,7 +9,6 @@ const ( const ( SystemPrompt = ` #你是一个专业的商品属性提取助手,你的任务是根据用户输入提取商品的属性信息。 - 目标属性模板:%s。 1.最终输出格式为纯JSON字符串,键值对对应目标属性和提取到的属性值。 2.最终输出不要携带markdown标识,不要携带回车换行` ) @@ -29,15 +28,16 @@ const ( "货品说明": "string", // 商品说明 "保质期": "string", // 商品保质期 "保质期单位": "string", // 商品保质期单位 - "链接": "string", // + "链接": "string", // 商品链接 "货品图片": ["string"], // 商品多图,取1-2个即可 - "电商销售价格": "decimal(10,2)", // 商品电商销售价格 - "销售价": "decimal(10,2)", // 商品销售价格 - "供应商报价": "decimal(10,2)", // 商品供应商报价 - "税率": "number%", // 商品税率 x% - "默认供应商": "", // 空即可 - "默认存放仓库": "", // 空即可 - "备注": "", // 备注 + "电商销售价格": "string", // 商品电商销售价格 decimal(10,2) + "销售价": "string", // 商品销售价格 decimal(10,2) + "供应商报价": "string", // 商品供应商报价 decimal(10,2) + "税率": "string", // 商品税率 x% + "默认供应商": "string", // 供应商名称 + "默认存放仓库": "string", // 仓库名称 + "利润": "string", // 商品利润 decimal(10,2) + "备注": "string", // 备注 "长": "string", // 商品长度,decimal(10,2)+单位 "宽": "string", // 商品宽度,decimal(10,2)+单位 "高": "string", // 商品高度,decimal(10,2)+单位 @@ -172,3 +172,8 @@ const ( const ( HYTProductListPageURL = "https://gateway.dev.cdlsxd.cn/sw//#/goods/goodsManage" ) + +// 缓存key +const ( + CapabilityProductIngestCacheKey = "ai_scheduler:capability:product_ingest:%s" +) diff --git a/internal/domain/tools/hyt/product_upload/client.go b/internal/domain/tools/hyt/product_upload/client.go index 4924b50..1cacbd9 100644 --- a/internal/domain/tools/hyt/product_upload/client.go +++ b/internal/domain/tools/hyt/product_upload/client.go @@ -20,7 +20,7 @@ func Call(ctx context.Context, cfg config.ToolConfig, toolReq *ProductUploadRequ req := l_request.Request{ Method: "Post", - Url: "http://120.55.12.245:8100/api/v1/goods/supplier/batch/add/complete", + Url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/goods/supplier/batch/add/complete", Json: apiReq, } res, err := req.Send() diff --git a/internal/domain/tools/hyt/product_upload/client_test.go b/internal/domain/tools/hyt/product_upload/client_test.go index 2f4b01b..5e8b111 100644 --- a/internal/domain/tools/hyt/product_upload/client_test.go +++ b/internal/domain/tools/hyt/product_upload/client_test.go @@ -19,10 +19,10 @@ func Test_Call(t *testing.T) { GoodsList: []Goods{ { GoodsInfo: GoodsInfo{ - Title: "Apple iPhone 17 Pro Max 星宇橙色 256GB", - Brand: "Apple/苹果", - Category: "手机", - CostPrice: 9999.00, + Title: "Apple iPhone 17 Pro Max 星宇橙色 256GB", + Brand: "Apple/苹果", + Category: "手机", + // CostPrice: 9999.00, GoodsAttributes: "CPU型号:A19 Pro;操作系统:iOS;机身存储:256GB;屏幕尺寸:6.86英寸;屏幕材质:OLED直屏;屏幕技术:视网膜XDR;后置摄像头:4800万像素三主摄系统(主摄4800万+超广角4800万+长焦4800万);前置摄像头:1800万像素;网络支持:5G双卡双待(移动/联通/电信);生物识别:人脸识别;防水等级:IP68;充电功率:40W;无线充电:支持;机身尺寸:163.4mm×78.0mm×8.75mm;机身重量:231g;机身颜色:星宇橙色;特征特质:轻薄、防水防尘、无线充电、NFC、磁吸无线充", GoodsBarCode: "10181383848993", GoodsIllustration: "Apple/苹果 iPhone 17 Pro Max 【需当面激活】支持移动联通电信 5G 双卡双待手机 星宇橙色 256GB 官方标配。搭载A19 Pro芯片,6.86英寸OLED视网膜XDR直屏,4800万像素三主摄系统,支持5G双卡双待,IP68防水防尘,40W有线充电,支持无线充电和磁吸充电。", diff --git a/internal/domain/tools/hyt/supplier_search/client.go b/internal/domain/tools/hyt/supplier_search/client.go new file mode 100644 index 0000000..ebffe0b --- /dev/null +++ b/internal/domain/tools/hyt/supplier_search/client.go @@ -0,0 +1,61 @@ +package supplier_search + +import ( + "ai_scheduler/internal/pkg/l_request" + "context" + "encoding/json" + "errors" + "fmt" +) + +func Call(ctx context.Context, name string) (int, error) { + if name == "" { + return 0, errors.New("supplier name is empty") + } + + reqBody := SearchRequest{ + Page: 1, + Limit: 1, + Search: SearchCondition{ + Name: name, + }, + } + + apiReq := make(map[string]interface{}) + bytes, _ := json.Marshal(reqBody) + _ = json.Unmarshal(bytes, &apiReq) + + req := l_request.Request{ + Method: "Post", + Url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/supplier/list", + Json: apiReq, + Headers: map[string]string{ + "User-Agent": "Apifox/1.0.0 (https://apifox.com)", + "Content-Type": "application/json", + }, + } + + res, err := req.Send() + if err != nil { + return 0, err + } + + if res.StatusCode != 200 { + return 0, fmt.Errorf("supplier search failed with status code: %d", res.StatusCode) + } + + var resData SearchResponse + if err := json.Unmarshal([]byte(res.Text), &resData); err != nil { + return 0, fmt.Errorf("failed to parse supplier search response: %w", err) + } + + if resData.Code != 200 { + return 0, fmt.Errorf("supplier search business error: %s", resData.Msg) + } + + if len(resData.Data.List) == 0 { + return 0, fmt.Errorf("supplier not found: %s", name) + } + + return resData.Data.List[0].ID, nil +} diff --git a/internal/domain/tools/hyt/supplier_search/types.go b/internal/domain/tools/hyt/supplier_search/types.go new file mode 100644 index 0000000..46a452c --- /dev/null +++ b/internal/domain/tools/hyt/supplier_search/types.go @@ -0,0 +1,24 @@ +package supplier_search + +type SearchRequest struct { + Page int `json:"page"` + Limit int `json:"limit"` + Search SearchCondition `json:"search"` +} + +type SearchCondition struct { + Name string `json:"name"` +} + +type SearchResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + List []SupplierInfo `json:"list"` + } `json:"data"` +} + +type SupplierInfo struct { + ID int `json:"id"` + Name string `json:"name"` +} diff --git a/internal/domain/tools/hyt/warehouse_search/client.go b/internal/domain/tools/hyt/warehouse_search/client.go new file mode 100644 index 0000000..7b0190b --- /dev/null +++ b/internal/domain/tools/hyt/warehouse_search/client.go @@ -0,0 +1,56 @@ +package warehouse_search + +import ( + "ai_scheduler/internal/pkg/l_request" + "context" + "encoding/json" + "fmt" +) + +func Call(ctx context.Context, name string) (int, error) { + if name == "" { + // 如果没有仓库名,返回0,不报错,由上层业务决定是否允许 + return 0, nil + } + + // GET 请求参数 + params := map[string]string{ + "name": name, + "page": "1", + "limit": "1", + } + + req := l_request.Request{ + Method: "Get", + Url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/warehouse/list", + Params: params, + Headers: map[string]string{ + "User-Agent": "Apifox/1.0.0 (https://apifox.com)", + "Content-Type": "application/json", + }, + } + + res, err := req.Send() + if err != nil { + return 0, err + } + + if res.StatusCode != 200 { + return 0, fmt.Errorf("warehouse search failed with status code: %d", res.StatusCode) + } + + var resData SearchResponse + if err := json.Unmarshal([]byte(res.Text), &resData); err != nil { + return 0, fmt.Errorf("failed to parse warehouse search response: %w", err) + } + + if resData.Code != 200 { + return 0, fmt.Errorf("warehouse search business error: %s", resData.Msg) + } + + if len(resData.Data.List) == 0 { + return 0, fmt.Errorf("warehouse not found: %s", name) + } + + return resData.Data.List[0].ID, nil +} diff --git a/internal/domain/tools/hyt/warehouse_search/types.go b/internal/domain/tools/hyt/warehouse_search/types.go new file mode 100644 index 0000000..a5ae237 --- /dev/null +++ b/internal/domain/tools/hyt/warehouse_search/types.go @@ -0,0 +1,14 @@ +package warehouse_search + +type SearchResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + List []WarehouseInfo `json:"list"` + } `json:"data"` +} + +type WarehouseInfo struct { + ID int `json:"id"` + Name string `json:"name"` +} diff --git a/internal/domain/workflow/hyt/product_upload.go b/internal/domain/workflow/hyt/product_upload.go index 6ab98f1..4b85f37 100644 --- a/internal/domain/workflow/hyt/product_upload.go +++ b/internal/domain/workflow/hyt/product_upload.go @@ -3,12 +3,17 @@ package hyt import ( "ai_scheduler/internal/config" "ai_scheduler/internal/data/constants" - toolHytPu "ai_scheduler/internal/domain/tools/hyt/product_upload" + toolPu "ai_scheduler/internal/domain/tools/hyt/product_upload" + toolSs "ai_scheduler/internal/domain/tools/hyt/supplier_search" + toolWs "ai_scheduler/internal/domain/tools/hyt/warehouse_search" "ai_scheduler/internal/domain/workflow/runtime" "ai_scheduler/internal/entitys" "context" "encoding/json" "fmt" + "strconv" + "strings" + "sync" eino_ollama "github.com/cloudwego/eino-ext/components/model/ollama" "github.com/cloudwego/eino/components/prompt" @@ -36,8 +41,8 @@ type ProductUploadWorkflowInput struct { func (o *productUpload) ID() string { return WorkflowID } func (o *productUpload) Invoke(ctx context.Context, rec *entitys.Recognize) (map[string]any, error) { - // 构建工作流 - chain, err := o.buildWorkflow(ctx) + // 构建工作流 (使用 V2 Graph 版本) + runnable, err := o.buildWorkflowV2(ctx) if err != nil { return nil, err } @@ -46,17 +51,58 @@ func (o *productUpload) Invoke(ctx context.Context, rec *entitys.Recognize) (map Text: rec.UserContent.Text, } // 工作流过程调用 - output, err := chain.Invoke(ctx, o.data) + output, err := runnable.Invoke(ctx, o.data) if err != nil { return nil, err } fmt.Printf("workflow output: %v\n", output) - // 不关心输出,全部在途中输出 return output, nil } +// ProductIngestData 对应 HYTProductPropertyTemplateZH 的结构 +type ProductIngestData struct { + BarCode string `json:"条码"` + CategoryName string `json:"分类名称"` + GoodsName string `json:"货品名称"` + GoodsNum string `json:"货品编号"` + GoodsArticleNum string `json:"商品货号"` + Brand string `json:"品牌"` + Unit string `json:"单位"` + Specs string `json:"规格参数"` + Description string `json:"货品说明"` + ShelfLife string `json:"保质期"` + ShelfLifeUnit string `json:"保质期单位"` + Link string `json:"链接"` + Images []string `json:"货品图片"` + EPrice string `json:"电商销售价格"` + SalesPrice string `json:"销售价"` + SupplierPrice string `json:"供应商报价"` + TaxRate string `json:"税率"` + SupplierName string `json:"默认供应商"` + WarehouseName string `json:"默认存放仓库"` + Remark string `json:"备注"` + Length string `json:"长"` + Width string `json:"宽"` + Height string `json:"高"` + Weight string `json:"重量"` + SpuName string `json:"SPU名称"` + SpuCode string `json:"SPU编码"` + Profit string `json:"利润"` +} + +// ProductUploadContext Graph 执行上下文状态 +type ProductUploadContext struct { + mu *sync.Mutex + InputText string + IngestData *ProductIngestData + UploadReq *toolPu.ProductUploadRequest + SupplierName string + WarehouseName string + UploadResp *toolPu.ProductUploadResponse +} + func (o *productUpload) buildWorkflow(ctx context.Context) (compose.Runnable[*ProductUploadWorkflowInput, map[string]any], error) { // 定义工作流 c := compose.NewChain[*ProductUploadWorkflowInput, map[string]any]() @@ -73,7 +119,7 @@ func (o *productUpload) buildWorkflow(ctx context.Context) (compose.Runnable[*Pr return nil, err } - // 1. 构建参LLM数映射提示词 + // 1. 构建参数LLM数映射提示词 c.AppendChatTemplate(prompt.FromMessages( schema.FString, schema.SystemMessage("你是一个专业的商品参数解析器,你需要根据用户输入的商品描述,解析出商品的目标参数。"), @@ -84,8 +130,8 @@ func (o *productUpload) buildWorkflow(ctx context.Context) (compose.Runnable[*Pr c.AppendChatModel(paramMappingModel) // 3.工具参数整理 - c.AppendLambda(compose.InvokableLambda(func(_ context.Context, in *schema.Message) (*toolHytPu.ProductUploadRequest, error) { - toolReq := &toolHytPu.ProductUploadRequest{} + c.AppendLambda(compose.InvokableLambda(func(_ context.Context, in *schema.Message) (*toolPu.ProductUploadRequest, error) { + toolReq := &toolPu.ProductUploadRequest{} if err := json.Unmarshal([]byte(in.Content), toolReq); err != nil { return nil, err } @@ -93,13 +139,13 @@ func (o *productUpload) buildWorkflow(ctx context.Context) (compose.Runnable[*Pr })) // 4.工具调用 - c.AppendLambda(compose.InvokableLambda(func(_ context.Context, in *toolHytPu.ProductUploadRequest) (*toolHytPu.ProductUploadResponse, error) { - toolRes, err := toolHytPu.Call(ctx, o.cfg.Tools.HytProductUpload, in) + c.AppendLambda(compose.InvokableLambda(func(_ context.Context, in *toolPu.ProductUploadRequest) (*toolPu.ProductUploadResponse, error) { + toolRes, err := toolPu.Call(ctx, o.cfg.Tools.HytProductUpload, in) return toolRes, err })) // 5.结果数据映射 - c.AppendLambda(compose.InvokableLambda(func(_ context.Context, in *toolHytPu.ProductUploadResponse) (map[string]any, error) { + c.AppendLambda(compose.InvokableLambda(func(_ context.Context, in *toolPu.ProductUploadResponse) (map[string]any, error) { return map[string]any{ "预览URL(货易通商品列表)": in.PreviewUrl, "SPU编码": in.SpuNum, @@ -110,3 +156,172 @@ func (o *productUpload) buildWorkflow(ctx context.Context) (compose.Runnable[*Pr // 6.编译工作流 return c.Compile(ctx) } + +// buildWorkflowV2 构建基于 Graph 的并行工作流 +func (o *productUpload) buildWorkflowV2(ctx context.Context) (compose.Runnable[*ProductUploadWorkflowInput, map[string]any], error) { + g := compose.NewGraph[*ProductUploadWorkflowInput, map[string]any]() + + // 1. DataMapping 节点: 解析 JSON -> 填充基础 Request -> 提取供应商/仓库名 + g.AddLambdaNode("data_mapping", compose.InvokableLambda(func(ctx context.Context, in *ProductUploadWorkflowInput) (*ProductUploadContext, error) { + state := &ProductUploadContext{ + mu: &sync.Mutex{}, // 初始化锁 + InputText: in.Text, + UploadReq: &toolPu.ProductUploadRequest{ + GoodsList: make([]toolPu.Goods, 1), // 初始化一个商品 + }, + } + + // 解析用户输入的中文 JSON + var ingestData ProductIngestData + if err := json.Unmarshal([]byte(in.Text), &ingestData); err != nil { + return nil, fmt.Errorf("解析商品数据失败: %w", err) + } + state.IngestData = &ingestData + state.SupplierName = ingestData.SupplierName + state.WarehouseName = ingestData.WarehouseName + + // 映射字段到 UploadReq + goodsInfo := &state.UploadReq.GoodsList[0].GoodsInfo + goodsInfo.Title = ingestData.GoodsName + goodsInfo.Brand = ingestData.Brand + goodsInfo.Category = ingestData.CategoryName + goodsInfo.GoodsBarCode = ingestData.BarCode + goodsInfo.GoodsNum = ingestData.GoodsNum + if goodsInfo.GoodsNum == "" { + goodsInfo.GoodsNum = ingestData.GoodsArticleNum + } + goodsInfo.Unit = ingestData.Unit + goodsInfo.GoodsAttributes = ingestData.Specs + goodsInfo.Introduction = ingestData.Description + goodsInfo.SpuName = ingestData.SpuName + goodsInfo.SpuNum = ingestData.SpuCode + goodsInfo.Weight = ingestData.Weight + + // 数值处理 + if val, err := strconv.ParseFloat(strings.TrimSuffix(ingestData.SalesPrice, "元"), 64); err == nil { + goodsInfo.SalesPrice = val + } + if val, err := strconv.ParseFloat(strings.TrimSuffix(ingestData.EPrice, "元"), 64); err == nil { + goodsInfo.Price = val // 假设电商价为市场价 + } + // 价格兼容 + if goodsInfo.CostPrice == 0 { + goodsInfo.CostPrice = goodsInfo.Price + } + // 税率处理 "13%" -> 13 + taxStr := strings.TrimSuffix(strings.TrimSuffix(ingestData.TaxRate, "%"), " ") + if val, err := strconv.Atoi(taxStr); err == nil { + goodsInfo.TaxRate = val + state.UploadReq.TaxRate = val + } + // 利润处理 + if val, err := strconv.ParseFloat(strings.TrimSuffix(ingestData.Profit, "元"), 64); err == nil { + state.UploadReq.Profit = val + } + + // 图片处理 + for i, imgUrl := range ingestData.Images { + state.UploadReq.GoodsList[0].GoodsMediaList = append(state.UploadReq.GoodsList[0].GoodsMediaList, toolPu.GoodsMedia{ + Url: imgUrl, + Type: 1, // 图片 + Sort: i, + }) + } + + // 默认值字段 + goodsInfo.IsComposeGoods = 2 + goodsInfo.IsBind = 0 + goodsInfo.IsHot = 2 + state.UploadReq.IsDefaultWarehouse = 1 + state.UploadReq.Sort = 1 + + return state, nil + })) + + // 2. 获取供应商ID 节点 + g.AddLambdaNode("get_supplier_id", compose.InvokableLambda(func(ctx context.Context, state *ProductUploadContext) (*ProductUploadContext, error) { + if state.SupplierName != "" { + supplierId, err := toolSs.Call(ctx, state.SupplierName) + if err != nil { + // 记录日志,但不阻断流程,可能允许 ID 为 0 + fmt.Printf("warning: failed to get supplier id for %s: %v\n", state.SupplierName, err) + } else { + state.mu.Lock() + defer state.mu.Unlock() + state.UploadReq.SupplierId = supplierId + } + } + return state, nil + })) + + // 3. 获取仓库ID 节点 + g.AddLambdaNode("get_warehouse_id", compose.InvokableLambda(func(ctx context.Context, state *ProductUploadContext) (*ProductUploadContext, error) { + if state.WarehouseName != "" { + warehouseId, err := toolWs.Call(ctx, state.WarehouseName) + if err != nil { + fmt.Printf("warning: failed to get warehouse id for %s: %v\n", state.WarehouseName, err) + } else { + state.mu.Lock() + defer state.mu.Unlock() + state.UploadReq.WarehouseId = warehouseId + } + } + return state, nil + })) + + // 4. 合并/同步节点 + g.AddLambdaNode("merge_node", compose.InvokableLambda(func(ctx context.Context, state *ProductUploadContext) (*ProductUploadContext, error) { + // 可以在这里做最终校验,例如必须有 SupplierId + if state.UploadReq.SupplierId == 0 { + return nil, fmt.Errorf("供应商获取失败") + } + return state, nil + })) + + // 5. 上传节点 + g.AddLambdaNode("upload_product", compose.InvokableLambda(func(ctx context.Context, state *ProductUploadContext) (*ProductUploadContext, error) { + toolRes, err := toolPu.Call(ctx, o.cfg.Tools.HytProductUpload, state.UploadReq) + if err != nil { + return nil, err + } + state.UploadResp = toolRes + return state, nil + })) + + // 6. 结果格式化节点 + g.AddLambdaNode("format_output", compose.InvokableLambda(func(ctx context.Context, state *ProductUploadContext) (map[string]any, error) { + if state.UploadResp == nil { + return nil, fmt.Errorf("upload response is nil") + } + return map[string]any{ + "预览URL(货易通商品列表)": state.UploadResp.PreviewUrl, + "SPU编码": state.UploadResp.SpuNum, + "商品ID": state.UploadResp.Id, + }, nil + })) + + // 构建边 + // Start -> Mapping + g.AddEdge(compose.START, "data_mapping") + + // 串行化执行以规避 Eino 指针合并问题 + // Mapping -> Supplier + g.AddEdge("data_mapping", "get_supplier_id") + + // Supplier -> Warehouse + g.AddEdge("get_supplier_id", "get_warehouse_id") + + // Warehouse -> Merge (虽然串行了,保留 Merge 节点做校验) + g.AddEdge("get_warehouse_id", "merge_node") + + // Merge -> Upload + g.AddEdge("merge_node", "upload_product") + + // Upload -> Format + g.AddEdge("upload_product", "format_output") + + // Format -> END + g.AddEdge("format_output", compose.END) + + return g.Compile(ctx) +} diff --git a/internal/server/router/router.go b/internal/server/router/router.go index 3359984..15e1554 100644 --- a/internal/server/router/router.go +++ b/internal/server/router/router.go @@ -93,8 +93,8 @@ func SetupRoutes(app *fiber.App, ChatService *services.ChatService, sessionServi r.Post("/chat/history/update/content", chatHist.UpdateContent) // 能力 - r.Post("/capability/product/ingest", capabilityService.ProductIngest) // 商品数据提取 - r.Post("/capability/product/upload/hyt", capabilityService.ProductUploadHyt) // 货易通商品数据上传 + r.Post("/capability/product/ingest", capabilityService.ProductIngest) // 商品数据提取 + r.Post("/capability/product/ingest/:thread_id/confirm", capabilityService.ProductIngestConfirm) // 商品数据提取确认 } func routerSocket(app *fiber.App, chatService *services.ChatService) { diff --git a/internal/services/capability.go b/internal/services/capability.go index d0eb1fe..89d97cc 100644 --- a/internal/services/capability.go +++ b/internal/services/capability.go @@ -8,39 +8,55 @@ import ( "ai_scheduler/internal/entitys" "ai_scheduler/internal/pkg/util" "ai_scheduler/internal/pkg/utils_ollama" + "ai_scheduler/utils" "context" + "encoding/json" "fmt" "strings" "time" + hytWorkflow "ai_scheduler/internal/domain/workflow/hyt" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" "github.com/ollama/ollama/api" + "github.com/redis/go-redis/v9" ) // CapabilityService 统一回调入口 type CapabilityService struct { cfg *config.Config workflowManager *runtime.Registry + rdsCli *redis.Client } -func NewCapabilityService(cfg *config.Config, workflowManager *runtime.Registry) *CapabilityService { +func NewCapabilityService(cfg *config.Config, workflowManager *runtime.Registry, rdb *utils.Rdb) *CapabilityService { return &CapabilityService{ cfg: cfg, workflowManager: workflowManager, + rdsCli: rdb.Rdb, } } // 产品数据提取入参 type ProductIngestReq struct { - Url string `json:"url"` // 商品详情页URL - Title string `json:"title"` // 商品标题 - Text string `json:"text"` // 商品描述 - Images []string `json:"images"` // 商品图片URL列表 - Timestamp int64 `json:"timestamp"` // 商品发布时间戳 + SysId string `json:"sys_id"` // 业务系统ID - 当前仅支持货易通(hyt) + Url string `json:"url"` // 商品详情页URL + Title string `json:"title"` // 商品标题 + Text string `json:"text"` // 商品描述 + Images []string `json:"images"` // 商品图片URL列表 +} + +type ProductIngestResp struct { + ThreadId string `json:"thread_id"` // 线程ID,后续确认调用时需要 + SysId string `json:"sys_id"` // 业务系统ID + MetaData any `json:"meta"` // 元数据 + Draft string `json:"draft"` // 草稿数据,后续确认调用时需要 } // ProductIngest 产品数据提取 func (s *CapabilityService) ProductIngest(c *fiber.Ctx) error { + ctx := context.Background() // 请求头校验 if err := s.checkRequestHeader(c); err != nil { return err @@ -52,21 +68,33 @@ func (s *CapabilityService) ProductIngest(c *fiber.Ctx) error { return errorcode.ParamErr("invalid request body: %v", err) } // 必要参数校验 - if req.Text == "" { + if req.Text == "" || req.SysId == "" { return errorcode.ParamErr("missing required fields") } + // 映射目标系统商品属性中文模板 + var sysProductPropertyTemplateZH string + switch req.SysId { + case "hyt": // 货易通 + sysProductPropertyTemplateZH = constants.HYTProductPropertyTemplateZH + default: + return errorcode.ParamErr("invalid sys_id") + } + // 模型调用 client, cleanup, err := utils_ollama.NewClient(s.cfg) if err != nil { return err } defer cleanup() - - res, err := client.Chat(context.Background(), []api.Message{ + res, err := client.Chat(ctx, []api.Message{ { Role: "system", - Content: fmt.Sprintf(constants.SystemPrompt, constants.HYTProductPropertyTemplateZH), + Content: constants.SystemPrompt, + }, + { + Role: "assistant", + Content: fmt.Sprintf("目标属性模板:%s。", sysProductPropertyTemplateZH), }, { Role: "user", @@ -81,10 +109,23 @@ func (s *CapabilityService) ProductIngest(c *fiber.Ctx) error { return err } - // res.Message.Content Go中map会无序,交给前端解析 + // 生成thread_id + threadId := uuid.NewString() + resp := &ProductIngestResp{ + ThreadId: threadId, + SysId: req.SysId, + MetaData: req, + Draft: res.Message.Content, // Go中map会无序,交给前端解析 + } + respJson, _ := json.Marshal(resp) + + // 存redis缓存 + if err = s.rdsCli.Set(ctx, fmt.Sprintf(constants.CapabilityProductIngestCacheKey, threadId), respJson, 30*time.Minute).Err(); err != nil { + return err + } // 解析模型输出 - c.JSON(res.Message.Content) + c.JSON(resp) return nil } @@ -97,7 +138,7 @@ func (s *CapabilityService) checkRequestHeader(c *fiber.Ctx) error { // 时间窗口校验 if ts != "" && !util.IsInTimeWindow(ts, 5*time.Minute) { - return errorcode.AuthNotFound + // return errorcode.AuthNotFound } // token校验 if token == "" || token != "A7f9KQ3mP2X8LZC4R5e" { @@ -107,21 +148,57 @@ func (s *CapabilityService) checkRequestHeader(c *fiber.Ctx) error { return nil } -// ProductUploadHyt 商品上传至货易通 -func (s *CapabilityService) ProductUploadHyt(c *fiber.Ctx) error { +type ProductIngestConfirmReq struct { + ThreadId string `json:"thread_id"` // 线程ID + Confirmed string `json:"confirmed"` // 已确认数据json字符串 +} + +// ProductIngestConfirm 商品数据提取确认 +func (s *CapabilityService) ProductIngestConfirm(c *fiber.Ctx) error { + ctx := context.Background() + // 请求头校验 if err := s.checkRequestHeader(c); err != nil { return err } + // 获取路径参数中的 thread_id + threadId := c.Params("thread_id") + if threadId == "" { + return errorcode.ParamErr("missing required fields") + } + // 解析请求参数 body + req := ProductIngestConfirmReq{} + if err := c.BodyParser(&req); err != nil { + return errorcode.ParamErr("invalid request body: %v", err) + } + // 必要参数校验 + if req.Confirmed == "" || threadId == "" { + return errorcode.ParamErr("missing required fields") + } - // 获取 body json 串 - raw := append([]byte(nil), c.BodyRaw()...) - bodyStr := string(raw) + // 校验线程ID是否存在 + resp, err := s.rdsCli.Get(ctx, fmt.Sprintf(constants.CapabilityProductIngestCacheKey, threadId)).Result() + if err != nil { + return errorcode.ParamErr("invalid thread_id") + } + var respData ProductIngestResp + if err = json.Unmarshal([]byte(resp), &respData); err != nil { + return errorcode.ParamErr("invalid thread_id data") + } - // 调用eino工作流,实现商品上传到货易通 - workflowId := "hyt.productUpload" - rec := &entitys.Recognize{UserContent: &entitys.RecognizeUserContent{Text: bodyStr}} - res, err := s.workflowManager.Invoke(context.Background(), workflowId, rec) + // 映射目标系统工作流ID + var workflowId string + switch respData.SysId { + // 货易通 + case "hyt": + workflowId = hytWorkflow.WorkflowID + default: + return errorcode.ParamErr("invalid sys_id") + } + + // 调用eino工作流,实现商品上传到目标系统 + rec := &entitys.Recognize{UserContent: &entitys.RecognizeUserContent{Text: req.Confirmed}} + res, err := s.workflowManager.Invoke(ctx, workflowId, rec) if err != nil { return err } From d8df571cce11f718ea53f0bff3bad5b3b189b962 Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Mon, 22 Dec 2025 11:14:15 +0800 Subject: [PATCH 05/14] =?UTF-8?q?feat:=201.=E5=A2=9E=E5=8A=A0=20eino=20too?= =?UTF-8?q?l=20=E7=9B=B8=E5=85=B3=E9=85=8D=E7=BD=AE=EF=BC=8C=E8=B4=A7?= =?UTF-8?q?=E6=98=93=E9=80=9A=E5=95=86=E5=93=81=E4=B8=8A=E4=BC=A0=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E9=85=8D=E7=BD=AE=E5=8C=96=202.=20eino=20tool=20?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E6=96=B9=E6=B3=95=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/config_env.yaml | 16 +- internal/config/config.go | 13 +- internal/data/constants/capability.go | 136 +---------------- .../domain/tools/hyt/product_upload/client.go | 16 +- .../tools/hyt/product_upload/client_test.go | 3 +- .../tools/hyt/supplier_search/client.go | 19 ++- .../tools/hyt/warehouse_search/client.go | 15 +- internal/domain/tools/registry.go | 31 ++-- .../domain/workflow/hyt/product_upload.go | 138 +++++++----------- internal/domain/workflow/provider_set.go | 4 +- internal/domain/workflow/registry.go | 6 +- internal/domain/workflow/runtime/registry.go | 6 +- 12 files changed, 155 insertions(+), 248 deletions(-) diff --git a/config/config_env.yaml b/config/config_env.yaml index aa8b07b..a10a7d4 100644 --- a/config/config_env.yaml +++ b/config/config_env.yaml @@ -74,11 +74,19 @@ tools: zltxOrderAfterSaleResellerBatch: enabled: true base_url: "https://gateway.dev.cdlsxd.cn/zltx_api/admin/afterSales/reseller_pre_ai" + +# eino tool 配置 +eino_tools: + # 货易通商品上传 hytProductUpload: - enabled: true - base_url: "https://gateway.dev.cdlsxd.cn/zltx_api/admin/oursProduct" - - + base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/goods/supplier/batch/add/complete" + add_url: "https://gateway.dev.cdlsxd.cn/sw//#/goods/goodsManage" + # 货易通供应商查询 + hytSupplierSearch: + base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/supplier/list" + # 货易通仓库查询 + hytWarehouseSearch: + base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/warehouse/list" default_prompt: img_recognize: diff --git a/internal/config/config.go b/internal/config/config.go index 0c3f4dd..5cbb506 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,7 @@ type Config struct { Coze CozeConfig `mapstructure:"coze"` Sys SysConfig `mapstructure:"sys"` Tools ToolsConfig `mapstructure:"tools"` + EinoTools EinoToolsConfig `mapstructure:"eino_tools"` Logging LoggingConfig `mapstructure:"logging"` Redis Redis `mapstructure:"redis"` DB DB `mapstructure:"db"` @@ -141,8 +142,6 @@ type ToolsConfig struct { CozeExpress ToolConfig `mapstructure:"cozeExpress"` // Coze 公司查询工具 CozeCompany ToolConfig `mapstructure:"cozeCompany"` - // 货易通商品上传 - HytProductUpload ToolConfig `mapstructure:"hytProductUpload"` } // ToolConfig 单个工具配置 @@ -155,6 +154,16 @@ type ToolConfig struct { AddURL string `mapstructure:"add_url"` } +// EinoToolsConfig eino tool 配置 +type EinoToolsConfig struct { + // 货易通商品上传 + HytProductUpload ToolConfig `mapstructure:"hytProductUpload"` + // 货易通供应商查询 + HytSupplierSearch ToolConfig `mapstructure:"hytSupplierSearch"` + // 货易通仓库查询 + HytWarehouseSearch ToolConfig `mapstructure:"hytWarehouseSearch"` +} + // LoggingConfig 日志配置 type LoggingConfig struct { Level string `mapstructure:"level"` diff --git a/internal/data/constants/capability.go b/internal/data/constants/capability.go index 9956336..fab9db7 100644 --- a/internal/data/constants/capability.go +++ b/internal/data/constants/capability.go @@ -32,11 +32,6 @@ const ( "货品图片": ["string"], // 商品多图,取1-2个即可 "电商销售价格": "string", // 商品电商销售价格 decimal(10,2) "销售价": "string", // 商品销售价格 decimal(10,2) - "供应商报价": "string", // 商品供应商报价 decimal(10,2) - "税率": "string", // 商品税率 x% - "默认供应商": "string", // 供应商名称 - "默认存放仓库": "string", // 仓库名称 - "利润": "string", // 商品利润 decimal(10,2) "备注": "string", // 备注 "长": "string", // 商品长度,decimal(10,2)+单位 "宽": "string", // 商品宽度,decimal(10,2)+单位 @@ -44,135 +39,14 @@ const ( "重量": "string", // 商品重量(kg) "SPU名称": "string", // 商品SPU名称 "SPU编码": "string" // 编码串,jd_{timestamp}_rand(1000-999) + "供应商报价": "string", // 商品供应商报价 decimal(10,2) + "税率": "string", // 商品税率 x% + "利润": "string", // 商品利润 decimal(10,2) + "默认供应商": "string", // 供应商名称 + "默认存放仓库": "string", // 仓库名称 }` ) -// 商品属性模板 -const ( - HYTProductPropertyTemplate = `{ - "important_data": { - "type": "object", - "properties": { - "supplier_name": { - "type": "string", - "description": "供应商名称" - }, - "warehouse_name": { - "type": "string", - "description": "仓库名称" - }, - "profit": { - "type": "float64", - "description": "利润 decimal(10,2)" - }, - "tax_rate": { - "type": "integer", - "description": "税率 (x)%" - } - } - }, - "goods_info": { - "type": "object", - "properties": { - "title": { - "type": "string", - "description": "商品名称" - }, - "brand": { - "type": "string", - "description": "品牌" - }, - "category": { - "type": "string", - "description": "分类" - }, - "price": { - "type": "float64", - "description": "市场价 decimal(10,2)" - }, - "sales_price": { - "type": "float64", - "description": "建议销售价 decimal(10,2)" - }, - "discount": { - "type": "integer", - "description": "折扣百分比 公式:(市场价-建议销售价)/市场价*100" - }, - "goods_attributes": { - "type": "string", - "description": "商品属性" - }, - "goods_bar_code": { - "type": "string", - "description": "商品条码" - }, - "goods_illustration": { - "type": "string", - "description": "商品插图" - }, - "goods_num": { - "type": "string", - "description": "商品编号" - }, - "introduction": { - "type": "string", - "description": "商品介绍" - }, - "spu_name": { - "type": "string", - "description": "SPU名称" - }, - "spu_num": { - "type": "string", - "description": "SPU编号" - }, - "stock": { - "type": "integer", - "description": "库存" - }, - "tax_rate": { - "type": "integer", - "description": "税率" - }, - "unit": { - "type": "string", - "description": "单位" - }, - "weight": { - "type": "string", - "description": "重量" - } - } - }, - "goods_media_list": { - "type": "array", - "description": "商品媒体文件列表", - "items": { - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "媒体文件URL" - }, - "type": { - "type": "integer", - "description": "媒体类型(1:图片, 2:视频)" - }, - "sort": { - "type": "integer", - "description": "排序序号" - } - } - } - } - }` -) - -// 外部平台地址 -const ( - HYTProductListPageURL = "https://gateway.dev.cdlsxd.cn/sw//#/goods/goodsManage" -) - // 缓存key const ( CapabilityProductIngestCacheKey = "ai_scheduler:capability:product_ingest:%s" diff --git a/internal/domain/tools/hyt/product_upload/client.go b/internal/domain/tools/hyt/product_upload/client.go index 1cacbd9..6ebcc62 100644 --- a/internal/domain/tools/hyt/product_upload/client.go +++ b/internal/domain/tools/hyt/product_upload/client.go @@ -9,7 +9,17 @@ import ( "errors" ) -func Call(ctx context.Context, cfg config.ToolConfig, toolReq *ProductUploadRequest) (toolResp *ProductUploadResponse, err error) { +type Client struct { + cfg config.ToolConfig +} + +func New(cfg config.ToolConfig) *Client { + return &Client{ + cfg: cfg, + } +} + +func (c *Client) Call(ctx context.Context, toolReq *ProductUploadRequest) (toolResp *ProductUploadResponse, err error) { // 商品有且只能有一个 if len(toolReq.GoodsList) != 1 { err = errors.New("商品只能有一个") @@ -20,7 +30,7 @@ func Call(ctx context.Context, cfg config.ToolConfig, toolReq *ProductUploadRequ req := l_request.Request{ Method: "Post", - Url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/goods/supplier/batch/add/complete", + Url: c.cfg.BaseURL, Json: apiReq, } res, err := req.Send() @@ -51,7 +61,7 @@ func Call(ctx context.Context, cfg config.ToolConfig, toolReq *ProductUploadRequ } toolResp = &ProductUploadResponse{ - PreviewUrl: "https://gateway.dev.cdlsxd.cn/sw//#/goods/goodsManage", + PreviewUrl: c.cfg.AddURL, SpuNum: toolReq.GoodsList[0].GoodsInfo.SpuNum, Id: resMap.Data.Ids[0], } diff --git a/internal/domain/tools/hyt/product_upload/client_test.go b/internal/domain/tools/hyt/product_upload/client_test.go index 5e8b111..fdd99f0 100644 --- a/internal/domain/tools/hyt/product_upload/client_test.go +++ b/internal/domain/tools/hyt/product_upload/client_test.go @@ -50,7 +50,8 @@ func Test_Call(t *testing.T) { }, }, } - toolResp, err := Call(context.Background(), config.ToolConfig{}, req) + client := New(config.ToolConfig{}) + toolResp, err := client.Call(context.Background(), req) if err != nil { t.Errorf("Call() error = %v", err) diff --git a/internal/domain/tools/hyt/supplier_search/client.go b/internal/domain/tools/hyt/supplier_search/client.go index ebffe0b..bd53aa9 100644 --- a/internal/domain/tools/hyt/supplier_search/client.go +++ b/internal/domain/tools/hyt/supplier_search/client.go @@ -1,16 +1,27 @@ package supplier_search import ( + "ai_scheduler/internal/config" "ai_scheduler/internal/pkg/l_request" "context" "encoding/json" - "errors" "fmt" ) -func Call(ctx context.Context, name string) (int, error) { +type Client struct { + cfg config.ToolConfig +} + +func New(cfg config.ToolConfig) *Client { + return &Client{ + cfg: cfg, + } +} + +func (c *Client) Call(ctx context.Context, name string) (int, error) { if name == "" { - return 0, errors.New("supplier name is empty") + // 如果没有供应商名,返回0,不报错,由上层业务决定是否允许 + return 0, nil } reqBody := SearchRequest{ @@ -27,7 +38,7 @@ func Call(ctx context.Context, name string) (int, error) { req := l_request.Request{ Method: "Post", - Url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/supplier/list", + Url: c.cfg.BaseURL, Json: apiReq, Headers: map[string]string{ "User-Agent": "Apifox/1.0.0 (https://apifox.com)", diff --git a/internal/domain/tools/hyt/warehouse_search/client.go b/internal/domain/tools/hyt/warehouse_search/client.go index 7b0190b..32d7fa4 100644 --- a/internal/domain/tools/hyt/warehouse_search/client.go +++ b/internal/domain/tools/hyt/warehouse_search/client.go @@ -1,13 +1,24 @@ package warehouse_search import ( + "ai_scheduler/internal/config" "ai_scheduler/internal/pkg/l_request" "context" "encoding/json" "fmt" ) -func Call(ctx context.Context, name string) (int, error) { +type Client struct { + cfg config.ToolConfig +} + +func New(cfg config.ToolConfig) *Client { + return &Client{ + cfg: cfg, + } +} + +func (c *Client) Call(ctx context.Context, name string) (int, error) { if name == "" { // 如果没有仓库名,返回0,不报错,由上层业务决定是否允许 return 0, nil @@ -22,7 +33,7 @@ func Call(ctx context.Context, name string) (int, error) { req := l_request.Request{ Method: "Get", - Url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/warehouse/list", + Url: c.cfg.BaseURL, Params: params, Headers: map[string]string{ "User-Agent": "Apifox/1.0.0 (https://apifox.com)", diff --git a/internal/domain/tools/registry.go b/internal/domain/tools/registry.go index dc6a67e..31a8636 100644 --- a/internal/domain/tools/registry.go +++ b/internal/domain/tools/registry.go @@ -1,16 +1,29 @@ package tools -type Tool interface{ - Name() string +import ( + "ai_scheduler/internal/config" + "ai_scheduler/internal/domain/tools/hyt/product_upload" + "ai_scheduler/internal/domain/tools/hyt/supplier_search" + "ai_scheduler/internal/domain/tools/hyt/warehouse_search" +) + +type Manager struct { + Hyt *HytTools + // Zltx *ZltxTools } -var registry = map[string]Tool{} - -func Register(t Tool){ - registry[t.Name()] = t +type HytTools struct { + ProductUpload *product_upload.Client + SupplierSearch *supplier_search.Client + WarehouseSearch *warehouse_search.Client } -func Get(name string) Tool{ - return registry[name] +func NewManager(cfg *config.Config) *Manager { + return &Manager{ + Hyt: &HytTools{ + ProductUpload: product_upload.New(cfg.EinoTools.HytProductUpload), + SupplierSearch: supplier_search.New(cfg.EinoTools.HytSupplierSearch), + WarehouseSearch: warehouse_search.New(cfg.EinoTools.HytWarehouseSearch), + }, + } } - diff --git a/internal/domain/workflow/hyt/product_upload.go b/internal/domain/workflow/hyt/product_upload.go index 4b85f37..0a93ce1 100644 --- a/internal/domain/workflow/hyt/product_upload.go +++ b/internal/domain/workflow/hyt/product_upload.go @@ -2,36 +2,33 @@ package hyt import ( "ai_scheduler/internal/config" - "ai_scheduler/internal/data/constants" + toolManager "ai_scheduler/internal/domain/tools" toolPu "ai_scheduler/internal/domain/tools/hyt/product_upload" - toolSs "ai_scheduler/internal/domain/tools/hyt/supplier_search" - toolWs "ai_scheduler/internal/domain/tools/hyt/warehouse_search" "ai_scheduler/internal/domain/workflow/runtime" "ai_scheduler/internal/entitys" "context" "encoding/json" + "errors" "fmt" "strconv" "strings" "sync" - eino_ollama "github.com/cloudwego/eino-ext/components/model/ollama" - "github.com/cloudwego/eino/components/prompt" "github.com/cloudwego/eino/compose" - "github.com/cloudwego/eino/schema" ) const WorkflowID = "hyt.productUpload" func init() { runtime.Register(WorkflowID, func(d *runtime.Deps) (runtime.Workflow, error) { - return &productUpload{cfg: d.Conf}, nil + return &productUpload{cfg: d.Conf, toolManager: d.ToolManager}, nil }) } type productUpload struct { - cfg *config.Config - data *ProductUploadWorkflowInput + cfg *config.Config + toolManager *toolManager.Manager + data *ProductUploadWorkflowInput } type ProductUploadWorkflowInput struct { @@ -41,8 +38,8 @@ type ProductUploadWorkflowInput struct { func (o *productUpload) ID() string { return WorkflowID } func (o *productUpload) Invoke(ctx context.Context, rec *entitys.Recognize) (map[string]any, error) { - // 构建工作流 (使用 V2 Graph 版本) - runnable, err := o.buildWorkflowV2(ctx) + // 构建工作流 + runnable, err := o.buildWorkflow(ctx) if err != nil { return nil, err } @@ -103,65 +100,11 @@ type ProductUploadContext struct { UploadResp *toolPu.ProductUploadResponse } +// buildWorkflow 构建基于 Graph 的并行工作流 func (o *productUpload) buildWorkflow(ctx context.Context) (compose.Runnable[*ProductUploadWorkflowInput, map[string]any], error) { - // 定义工作流 - c := compose.NewChain[*ProductUploadWorkflowInput, map[string]any]() - - // AI映射工具所需参数' - paramMappingModel, err := eino_ollama.NewChatModel(ctx, &eino_ollama.ChatModelConfig{ - BaseURL: o.cfg.Ollama.BaseURL, - Timeout: o.cfg.Ollama.Timeout, - Model: o.cfg.Ollama.Model, - Thinking: &eino_ollama.ThinkValue{Value: true}, - Options: &eino_ollama.Options{Temperature: 0.5}, - }) - if err != nil { - return nil, err - } - - // 1. 构建参数LLM数映射提示词 - c.AppendChatTemplate(prompt.FromMessages( - schema.FString, - schema.SystemMessage("你是一个专业的商品参数解析器,你需要根据用户输入的商品描述,解析出商品的目标参数。"), - schema.SystemMessage("目标参数:"+constants.HYTProductPropertyTemplate), - schema.UserMessage("用户输入:{{.Text}}"), - )) - // 2. 调用LLM - c.AppendChatModel(paramMappingModel) - - // 3.工具参数整理 - c.AppendLambda(compose.InvokableLambda(func(_ context.Context, in *schema.Message) (*toolPu.ProductUploadRequest, error) { - toolReq := &toolPu.ProductUploadRequest{} - if err := json.Unmarshal([]byte(in.Content), toolReq); err != nil { - return nil, err - } - return toolReq, nil - })) - - // 4.工具调用 - c.AppendLambda(compose.InvokableLambda(func(_ context.Context, in *toolPu.ProductUploadRequest) (*toolPu.ProductUploadResponse, error) { - toolRes, err := toolPu.Call(ctx, o.cfg.Tools.HytProductUpload, in) - return toolRes, err - })) - - // 5.结果数据映射 - c.AppendLambda(compose.InvokableLambda(func(_ context.Context, in *toolPu.ProductUploadResponse) (map[string]any, error) { - return map[string]any{ - "预览URL(货易通商品列表)": in.PreviewUrl, - "SPU编码": in.SpuNum, - "商品ID": in.Id, - }, nil - })) - - // 6.编译工作流 - return c.Compile(ctx) -} - -// buildWorkflowV2 构建基于 Graph 的并行工作流 -func (o *productUpload) buildWorkflowV2(ctx context.Context) (compose.Runnable[*ProductUploadWorkflowInput, map[string]any], error) { g := compose.NewGraph[*ProductUploadWorkflowInput, map[string]any]() - // 1. DataMapping 节点: 解析 JSON -> 填充基础 Request -> 提取供应商/仓库名 + // 1. DataMapping 节点: 解析 JSON -> 填充基础 Request g.AddLambdaNode("data_mapping", compose.InvokableLambda(func(ctx context.Context, in *ProductUploadWorkflowInput) (*ProductUploadContext, error) { state := &ProductUploadContext{ mu: &sync.Mutex{}, // 初始化锁 @@ -176,6 +119,21 @@ func (o *productUpload) buildWorkflowV2(ctx context.Context) (compose.Runnable[* if err := json.Unmarshal([]byte(in.Text), &ingestData); err != nil { return nil, fmt.Errorf("解析商品数据失败: %w", err) } + + // 必填校验 + if ingestData.SupplierName == "" { + return nil, errors.New("供应商名称不能为空") + } + if ingestData.WarehouseName == "" { + return nil, errors.New("仓库名称不能为空") + } + if ingestData.Profit == "" { + return nil, errors.New("利润不能为空") + } + if ingestData.TaxRate == "" { + return nil, errors.New("税率不能为空") + } + state.IngestData = &ingestData state.SupplierName = ingestData.SupplierName state.WarehouseName = ingestData.WarehouseName @@ -240,32 +198,38 @@ func (o *productUpload) buildWorkflowV2(ctx context.Context) (compose.Runnable[* // 2. 获取供应商ID 节点 g.AddLambdaNode("get_supplier_id", compose.InvokableLambda(func(ctx context.Context, state *ProductUploadContext) (*ProductUploadContext, error) { - if state.SupplierName != "" { - supplierId, err := toolSs.Call(ctx, state.SupplierName) - if err != nil { - // 记录日志,但不阻断流程,可能允许 ID 为 0 - fmt.Printf("warning: failed to get supplier id for %s: %v\n", state.SupplierName, err) - } else { - state.mu.Lock() - defer state.mu.Unlock() - state.UploadReq.SupplierId = supplierId - } + if state.SupplierName == "" { + return state, errors.New("供应商名称不能为空") } + + supplierId, err := o.toolManager.Hyt.SupplierSearch.Call(ctx, state.SupplierName) + if err != nil { + // 记录日志,但不阻断流程,可能允许 ID 为 0 + fmt.Printf("warning: failed to get supplier id for %s: %v\n", state.SupplierName, err) + } else { + state.mu.Lock() + defer state.mu.Unlock() + state.UploadReq.SupplierId = supplierId + } + return state, nil })) // 3. 获取仓库ID 节点 g.AddLambdaNode("get_warehouse_id", compose.InvokableLambda(func(ctx context.Context, state *ProductUploadContext) (*ProductUploadContext, error) { - if state.WarehouseName != "" { - warehouseId, err := toolWs.Call(ctx, state.WarehouseName) - if err != nil { - fmt.Printf("warning: failed to get warehouse id for %s: %v\n", state.WarehouseName, err) - } else { - state.mu.Lock() - defer state.mu.Unlock() - state.UploadReq.WarehouseId = warehouseId - } + if state.WarehouseName == "" { + return state, errors.New("仓库名称不能为空") } + + warehouseId, err := o.toolManager.Hyt.WarehouseSearch.Call(ctx, state.WarehouseName) + if err != nil { + fmt.Printf("warning: failed to get warehouse id for %s: %v\n", state.WarehouseName, err) + } else { + state.mu.Lock() + defer state.mu.Unlock() + state.UploadReq.WarehouseId = warehouseId + } + return state, nil })) @@ -280,7 +244,7 @@ func (o *productUpload) buildWorkflowV2(ctx context.Context) (compose.Runnable[* // 5. 上传节点 g.AddLambdaNode("upload_product", compose.InvokableLambda(func(ctx context.Context, state *ProductUploadContext) (*ProductUploadContext, error) { - toolRes, err := toolPu.Call(ctx, o.cfg.Tools.HytProductUpload, state.UploadReq) + toolRes, err := o.toolManager.Hyt.ProductUpload.Call(ctx, state.UploadReq) if err != nil { return nil, err } diff --git a/internal/domain/workflow/provider_set.go b/internal/domain/workflow/provider_set.go index c728b44..97e1b5d 100644 --- a/internal/domain/workflow/provider_set.go +++ b/internal/domain/workflow/provider_set.go @@ -5,6 +5,8 @@ import ( "ai_scheduler/internal/domain/workflow/runtime" "ai_scheduler/internal/pkg/utils_ollama" + toolManager "ai_scheduler/internal/domain/tools" + "github.com/google/wire" ) @@ -13,7 +15,7 @@ var ProviderSetWorkflow = wire.NewSet(NewRegistry) // NewRegistry 注入共享依赖并注册默认 Registry,确保自注册工作流可被发现 func NewRegistry(conf *config.Config, llm *utils_ollama.Client) *runtime.Registry { // 步骤1:设置运行时依赖(配置与LLM客户端),供工作流工厂在首次实例化时使用;必须在任何调用 Invoke 之前完成,否则会触发 "deps not set" - runtime.SetDeps(&runtime.Deps{Conf: conf, LLM: llm}) + runtime.SetDeps(&runtime.Deps{Conf: conf, LLM: llm, ToolManager: toolManager.NewManager(conf)}) // 步骤2:创建新的工作流注册表;注册表负责按工作流ID惰性实例化并缓存单例实例,保障并发访问下的安全 r := runtime.NewRegistry() // 步骤3:将该注册表设置为全局默认,便于通过 runtime.Default() 获取;自注册的工作流可通过默认注册表被发现并调用 diff --git a/internal/domain/workflow/registry.go b/internal/domain/workflow/registry.go index cbde3b6..af69a03 100644 --- a/internal/domain/workflow/registry.go +++ b/internal/domain/workflow/registry.go @@ -2,11 +2,13 @@ package workflow import ( "ai_scheduler/internal/config" + toolManager "ai_scheduler/internal/domain/tools" "ai_scheduler/internal/pkg/utils_ollama" ) // 仅声明依赖结构,避免在 workflow 包内实现注册中心逻辑导致循环依赖 type Deps struct { - Conf *config.Config - LLM *utils_ollama.Client + Conf *config.Config + LLM *utils_ollama.Client + ToolManager *toolManager.Manager } diff --git a/internal/domain/workflow/runtime/registry.go b/internal/domain/workflow/runtime/registry.go index bf840e6..2b4049b 100644 --- a/internal/domain/workflow/runtime/registry.go +++ b/internal/domain/workflow/runtime/registry.go @@ -2,6 +2,7 @@ package runtime import ( "ai_scheduler/internal/config" + toolManager "ai_scheduler/internal/domain/tools" "ai_scheduler/internal/entitys" "ai_scheduler/internal/pkg/utils_ollama" "context" @@ -16,8 +17,9 @@ type Workflow interface { } type Deps struct { - Conf *config.Config - LLM *utils_ollama.Client + Conf *config.Config + LLM *utils_ollama.Client + ToolManager *toolManager.Manager } type Factory func(deps *Deps) (Workflow, error) From b87767ea5a290fc39db1447f837cd87bb573024e Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Mon, 22 Dec 2025 11:56:32 +0800 Subject: [PATCH 06/14] =?UTF-8?q?fix:=20=E6=96=B0=E5=A2=9E=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81=E4=B8=9A=E5=8A=A1=E9=94=99=E8=AF=AF=EF=BC=8C?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=B7=A5=E4=BD=9C=E6=B5=81=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E6=97=B6=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/data/error/error_code.go | 5 +++++ internal/domain/workflow/hyt/product_upload.go | 12 ++++++++++-- .../workflow/zltx/order_after_reseller_batch.go | 9 --------- internal/server/router/router.go | 1 - 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/internal/data/error/error_code.go b/internal/data/error/error_code.go index 9d907e7..85abd88 100644 --- a/internal/data/error/error_code.go +++ b/internal/data/error/error_code.go @@ -15,6 +15,7 @@ var ( SysNotFound = &BusinessErr{code: 410, message: "未找到系统信息"} SysCodeNotFound = &BusinessErr{code: 411, message: "未找到系统编码"} InvalidParam = &BusinessErr{code: InvalidParamCode, message: "无效参数"} + WorkflowError = &BusinessErr{code: 501, message: "工作流过程错误"} ) const ( @@ -58,3 +59,7 @@ func (e *BusinessErr) Wrap(err error) *BusinessErr { func KeyErr() *BusinessErr { return &BusinessErr{code: KeyNotFound.code, message: KeyNotFound.message} } + +func WorkflowErr(message string) *BusinessErr { + return NewBusinessErr(WorkflowError.code, message) +} diff --git a/internal/domain/workflow/hyt/product_upload.go b/internal/domain/workflow/hyt/product_upload.go index 0a93ce1..de74977 100644 --- a/internal/domain/workflow/hyt/product_upload.go +++ b/internal/domain/workflow/hyt/product_upload.go @@ -2,6 +2,7 @@ package hyt import ( "ai_scheduler/internal/config" + errorcode "ai_scheduler/internal/data/error" toolManager "ai_scheduler/internal/domain/tools" toolPu "ai_scheduler/internal/domain/tools/hyt/product_upload" "ai_scheduler/internal/domain/workflow/runtime" @@ -50,7 +51,11 @@ func (o *productUpload) Invoke(ctx context.Context, rec *entitys.Recognize) (map // 工作流过程调用 output, err := runnable.Invoke(ctx, o.data) if err != nil { - return nil, err + errStr := err.Error() + if u := errors.Unwrap(err); u != nil { + errStr = u.Error() + } + return nil, errorcode.WorkflowErr(errStr) } fmt.Printf("workflow output: %v\n", output) @@ -235,10 +240,13 @@ func (o *productUpload) buildWorkflow(ctx context.Context) (compose.Runnable[*Pr // 4. 合并/同步节点 g.AddLambdaNode("merge_node", compose.InvokableLambda(func(ctx context.Context, state *ProductUploadContext) (*ProductUploadContext, error) { - // 可以在这里做最终校验,例如必须有 SupplierId + // 最终校验 if state.UploadReq.SupplierId == 0 { return nil, fmt.Errorf("供应商获取失败") } + if state.UploadReq.WarehouseId == 0 { + return nil, fmt.Errorf("仓库获取失败") + } return state, nil })) diff --git a/internal/domain/workflow/zltx/order_after_reseller_batch.go b/internal/domain/workflow/zltx/order_after_reseller_batch.go index 5e5fa51..ff21d84 100644 --- a/internal/domain/workflow/zltx/order_after_reseller_batch.go +++ b/internal/domain/workflow/zltx/order_after_reseller_batch.go @@ -78,15 +78,6 @@ type OrderAfterSaleResellerBatchData struct { // ID 返回工作流唯一标识 func (o *orderAfterSaleResellerBatch) ID() string { return "zltx.orderAfterSaleResellerBatch" } -// Schema 返回入参约束(用于校验/表单生成) -// func (o *orderAfterSaleResellerBatch) Schema() map[string]any { -// return map[string]any{ -// "type": "object", -// "properties": map[string]any{"orderNumber": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}}, -// "required": []string{"orderNumber"}, -// } -// } - // Invoke 调用原有编排工作流并规范化输出 func (o *orderAfterSaleResellerBatch) Invoke(ctx context.Context, rec *entitys.Recognize) (map[string]any, error) { // 构建工作流 diff --git a/internal/server/router/router.go b/internal/server/router/router.go index 15e1554..091c85f 100644 --- a/internal/server/router/router.go +++ b/internal/server/router/router.go @@ -52,7 +52,6 @@ func SetupRoutes(app *fiber.App, ChatService *services.ChatService, sessionServi r := app.Group("api/v1/") registerResponse(r) - // 注册 CORS 中间件 r.Get("/health", func(c *fiber.Ctx) error { c.Response().SetBody([]byte("1")) return nil From c1b7cd6bf5cc8597c8ee6cc1372be8c5cd1be4cb Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Mon, 22 Dec 2025 14:08:48 +0800 Subject: [PATCH 07/14] =?UTF-8?q?fix:=201.=E8=B0=83=E6=95=B4=E5=85=AC?= =?UTF-8?q?=E5=85=B1code=E8=BE=93=E5=87=BA=E6=96=B9=E6=B3=95=202.=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E5=95=86=E5=93=81=E6=8A=93=E5=8F=96=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E6=97=A5=E5=BF=97=E5=8F=8A=E9=94=99=E8=AF=AF=E8=BE=93?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/biz/do/ctx.go | 2 +- internal/biz/do/handle.go | 2 +- internal/biz/task.go | 4 +-- internal/data/error/error_code.go | 16 ++++++---- .../domain/tools/hyt/product_upload/client.go | 14 ++++---- .../tools/hyt/supplier_search/client.go | 12 +++---- .../tools/hyt/warehouse_search/client.go | 12 +++---- .../domain/workflow/hyt/product_upload.go | 7 ++-- internal/gateway/client.go | 5 +-- internal/pkg/dingtalk/contact_client.go | 4 +-- internal/pkg/dingtalk/notable_client.go | 2 +- internal/services/callback.go | 32 +++++++++---------- internal/services/capability.go | 14 ++++---- .../tool_callback/bug_optimization_submit.go | 2 +- 14 files changed, 62 insertions(+), 66 deletions(-) diff --git a/internal/biz/do/ctx.go b/internal/biz/do/ctx.go index 4b5d2ec..5d9ba72 100644 --- a/internal/biz/do/ctx.go +++ b/internal/biz/do/ctx.go @@ -349,7 +349,7 @@ func (d *Do) LoadUserPermission(client *gateway.Client, requireData *entitys.Req // 检查响应状态码 if res.StatusCode != http.StatusOK { - err = errors.SysErr("获取用户权限失败") + err = errors.SysErrf("获取用户权限失败") return } diff --git a/internal/biz/do/handle.go b/internal/biz/do/handle.go index 3a4a12b..76f1257 100644 --- a/internal/biz/do/handle.go +++ b/internal/biz/do/handle.go @@ -74,7 +74,7 @@ func (r *Handle) Recognize(ctx context.Context, rec *entitys.Recognize, promptPr entitys.ResLog(rec.Ch, "recognize_end", "意图识别结束") var match entitys.Match if err = json.Unmarshal([]byte(recognizeMsg), &match); err != nil { - err = errors.SysErr("数据结构错误:%v", err.Error()) + err = errors.SysErrf("数据结构错误:%v", err.Error()) return } rec.Match = &match diff --git a/internal/biz/task.go b/internal/biz/task.go index 277a97e..f9c03f0 100644 --- a/internal/biz/task.go +++ b/internal/biz/task.go @@ -70,13 +70,13 @@ func (t *TaskBiz) GetUserPermission(req *entitys.TaskRequest, auth string) (code // 发送请求 res, err := request.Send() if err != nil { - err = errors.SysErr("请求用户权限失败") + err = errors.SysErrf("请求用户权限失败") return } // 检查响应状态码 if res.StatusCode != http.StatusOK { - err = errors.SysErr("获取用户权限失败") + err = errors.SysErrf("获取用户权限失败") return } diff --git a/internal/data/error/error_code.go b/internal/data/error/error_code.go index 85abd88..1e2f0b6 100644 --- a/internal/data/error/error_code.go +++ b/internal/data/error/error_code.go @@ -44,22 +44,26 @@ func NewBusinessErr(code int, message string) *BusinessErr { return &BusinessErr{code: code, message: message} } -func SysErr(message string, arg ...any) *BusinessErr { +func SysErrf(message string, arg ...any) *BusinessErr { return &BusinessErr{code: SystemError.code, message: fmt.Sprintf(message, arg)} } -func ParamErr(message string, arg ...any) *BusinessErr { +func SysErr(message string) *BusinessErr { + return &BusinessErr{code: SystemError.code, message: message} +} + +func ParamErrf(message string, arg ...any) *BusinessErr { return &BusinessErr{code: ParamError.code, message: fmt.Sprintf(message, arg)} } +func ParamErr(message string) *BusinessErr { + return &BusinessErr{code: ParamError.code, message: message} +} + func (e *BusinessErr) Wrap(err error) *BusinessErr { return NewBusinessErr(e.code, err.Error()) } -func KeyErr() *BusinessErr { - return &BusinessErr{code: KeyNotFound.code, message: KeyNotFound.message} -} - func WorkflowErr(message string) *BusinessErr { return NewBusinessErr(WorkflowError.code, message) } diff --git a/internal/domain/tools/hyt/product_upload/client.go b/internal/domain/tools/hyt/product_upload/client.go index 6ebcc62..1cd68b8 100644 --- a/internal/domain/tools/hyt/product_upload/client.go +++ b/internal/domain/tools/hyt/product_upload/client.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "errors" + "fmt" ) type Client struct { @@ -22,8 +23,7 @@ func New(cfg config.ToolConfig) *Client { func (c *Client) Call(ctx context.Context, toolReq *ProductUploadRequest) (toolResp *ProductUploadResponse, err error) { // 商品有且只能有一个 if len(toolReq.GoodsList) != 1 { - err = errors.New("商品只能有一个") - return + return nil, errors.New("商品只能有一个") } apiReq, _ := util.StructToMap(toolReq) @@ -36,7 +36,7 @@ func (c *Client) Call(ctx context.Context, toolReq *ProductUploadRequest) (toolR res, err := req.Send() if err != nil { - return + return nil, fmt.Errorf("请求失败,err: %v", err) } type resType struct { @@ -49,15 +49,13 @@ func (c *Client) Call(ctx context.Context, toolReq *ProductUploadRequest) (toolR var resMap resType err = json.Unmarshal([]byte(res.Text), &resMap) if err != nil { - return + return nil, fmt.Errorf("解析响应失败,err: %v", err) } if resMap.Code != 200 { - err = errors.New("货易通商品创建失败") - return + return nil, fmt.Errorf("业务错误,code: %d, msg: %s", resMap.Code, resMap.Msg) } if len(resMap.Data.Ids) == 0 { - err = errors.New("货易通商品创建失败") - return + return nil, fmt.Errorf("ids为空") } toolResp = &ProductUploadResponse{ diff --git a/internal/domain/tools/hyt/supplier_search/client.go b/internal/domain/tools/hyt/supplier_search/client.go index bd53aa9..cbb20b4 100644 --- a/internal/domain/tools/hyt/supplier_search/client.go +++ b/internal/domain/tools/hyt/supplier_search/client.go @@ -48,24 +48,20 @@ func (c *Client) Call(ctx context.Context, name string) (int, error) { res, err := req.Send() if err != nil { - return 0, err - } - - if res.StatusCode != 200 { - return 0, fmt.Errorf("supplier search failed with status code: %d", res.StatusCode) + return 0, fmt.Errorf("请求失败,err: %v", err) } var resData SearchResponse if err := json.Unmarshal([]byte(res.Text), &resData); err != nil { - return 0, fmt.Errorf("failed to parse supplier search response: %w", err) + return 0, fmt.Errorf("解析响应失败,err: %v", err) } if resData.Code != 200 { - return 0, fmt.Errorf("supplier search business error: %s", resData.Msg) + return 0, fmt.Errorf("业务错误,code: %d, msg: %s", resData.Code, resData.Msg) } if len(resData.Data.List) == 0 { - return 0, fmt.Errorf("supplier not found: %s", name) + return 0, fmt.Errorf("供应商不存在") } return resData.Data.List[0].ID, nil diff --git a/internal/domain/tools/hyt/warehouse_search/client.go b/internal/domain/tools/hyt/warehouse_search/client.go index 32d7fa4..4502c42 100644 --- a/internal/domain/tools/hyt/warehouse_search/client.go +++ b/internal/domain/tools/hyt/warehouse_search/client.go @@ -43,24 +43,20 @@ func (c *Client) Call(ctx context.Context, name string) (int, error) { res, err := req.Send() if err != nil { - return 0, err - } - - if res.StatusCode != 200 { - return 0, fmt.Errorf("warehouse search failed with status code: %d", res.StatusCode) + return 0, fmt.Errorf("请求失败,err: %v", err) } var resData SearchResponse if err := json.Unmarshal([]byte(res.Text), &resData); err != nil { - return 0, fmt.Errorf("failed to parse warehouse search response: %w", err) + return 0, fmt.Errorf("解析响应失败,err: %v", err) } if resData.Code != 200 { - return 0, fmt.Errorf("warehouse search business error: %s", resData.Msg) + return 0, fmt.Errorf("业务错误,code: %d, msg: %s", resData.Code, resData.Msg) } if len(resData.Data.List) == 0 { - return 0, fmt.Errorf("warehouse not found: %s", name) + return 0, fmt.Errorf("仓库不存在: %s", name) } return resData.Data.List[0].ID, nil diff --git a/internal/domain/workflow/hyt/product_upload.go b/internal/domain/workflow/hyt/product_upload.go index de74977..fca3aec 100644 --- a/internal/domain/workflow/hyt/product_upload.go +++ b/internal/domain/workflow/hyt/product_upload.go @@ -11,6 +11,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "strconv" "strings" "sync" @@ -210,7 +211,7 @@ func (o *productUpload) buildWorkflow(ctx context.Context) (compose.Runnable[*Pr supplierId, err := o.toolManager.Hyt.SupplierSearch.Call(ctx, state.SupplierName) if err != nil { // 记录日志,但不阻断流程,可能允许 ID 为 0 - fmt.Printf("warning: failed to get supplier id for %s: %v\n", state.SupplierName, err) + log.Printf("warning: 供应商ID获取失败,%s: %v\n", state.SupplierName, err) } else { state.mu.Lock() defer state.mu.Unlock() @@ -228,7 +229,7 @@ func (o *productUpload) buildWorkflow(ctx context.Context) (compose.Runnable[*Pr warehouseId, err := o.toolManager.Hyt.WarehouseSearch.Call(ctx, state.WarehouseName) if err != nil { - fmt.Printf("warning: failed to get warehouse id for %s: %v\n", state.WarehouseName, err) + log.Printf("warning: 仓库ID获取失败,%s: %v\n", state.WarehouseName, err) } else { state.mu.Lock() defer state.mu.Unlock() @@ -254,7 +255,7 @@ func (o *productUpload) buildWorkflow(ctx context.Context) (compose.Runnable[*Pr g.AddLambdaNode("upload_product", compose.InvokableLambda(func(ctx context.Context, state *ProductUploadContext) (*ProductUploadContext, error) { toolRes, err := o.toolManager.Hyt.ProductUpload.Call(ctx, state.UploadReq) if err != nil { - return nil, err + return nil, fmt.Errorf("商品上传失败") } state.UploadResp = toolRes return state, nil diff --git a/internal/gateway/client.go b/internal/gateway/client.go index a293daa..1da0dd8 100644 --- a/internal/gateway/client.go +++ b/internal/gateway/client.go @@ -4,17 +4,18 @@ import ( errors "ai_scheduler/internal/data/error" "ai_scheduler/internal/data/model" "context" - "github.com/google/uuid" "log" "math/rand" "sync" "time" + "github.com/google/uuid" + "github.com/gofiber/websocket/v2" ) var ( - ErrConnClosed = errors.SysErr("连接不存在或已关闭") + ErrConnClosed = errors.SysErrf("连接不存在或已关闭") rng = rand.New(rand.NewSource(time.Now().UnixNano())) idBuf = make([]byte, 20) ) diff --git a/internal/pkg/dingtalk/contact_client.go b/internal/pkg/dingtalk/contact_client.go index 4461995..ed3e3dd 100644 --- a/internal/pkg/dingtalk/contact_client.go +++ b/internal/pkg/dingtalk/contact_client.go @@ -54,10 +54,10 @@ func (c *ContactClient) SearchUserOne(accessToken string, name string) (string, } if resp.Body == nil { - return "", errorcode.ParamErr("empty response body") + return "", errorcode.ParamErrf("empty response body") } if len(resp.Body.List) == 0 { - return "", errorcode.ParamErr("empty user list") + return "", errorcode.ParamErrf("empty user list") } userId := resp.Body.List[0] diff --git a/internal/pkg/dingtalk/notable_client.go b/internal/pkg/dingtalk/notable_client.go index 7cfe04f..d7d5434 100644 --- a/internal/pkg/dingtalk/notable_client.go +++ b/internal/pkg/dingtalk/notable_client.go @@ -67,7 +67,7 @@ func (c *NotableClient) UpdateRecord(accessToken string, req *UpdateRecordReq) ( } if resp.Body == nil { - return false, errorcode.ParamErr("empty response body") + return false, errorcode.ParamErrf("empty response body") } return true, nil diff --git a/internal/services/callback.go b/internal/services/callback.go index cc32660..e224ce3 100644 --- a/internal/services/callback.go +++ b/internal/services/callback.go @@ -85,13 +85,13 @@ func (s *CallbackService) Callback(c *fiber.Ctx) error { // 解析 Envelope var env Envelope if err := json.Unmarshal(c.Body(), &env); err != nil { - return errorcode.ParamErr("invalid json: %v", err) + return errorcode.ParamErrf("invalid json: %v", err) } if env.Action == "" || env.TaskID == "" { - return errorcode.ParamErr("missing action/task_id") + return errorcode.ParamErrf("missing action/task_id") } if env.Data == nil { - return errorcode.ParamErr("missing data") + return errorcode.ParamErrf("missing data") } switch sourceKey { @@ -141,7 +141,7 @@ func (s *CallbackService) handleDingTalkCallback(c *fiber.Ctx, env Envelope) err // 校验taskId sessionID, ok := s.callBackTool.GetSessionByTaskID(env.TaskID) if !ok { - return errorcode.ParamErr("missing session_id for task_id: %s", env.TaskID) + return errorcode.ParamErrf("missing session_id for task_id: %s", env.TaskID) } ctx := c.Context() @@ -176,14 +176,14 @@ func (s *CallbackService) handleDingTalkCallback(c *fiber.Ctx, env Envelope) err } var data processData if err := json.Unmarshal(env.Data, &data); err != nil { - return errorcode.ParamErr("invalid json: %v", err) + return errorcode.ParamErrf("invalid json: %v", err) } s.sendStreamLoading(sessionID, data.Process) return c.JSON(fiber.Map{"code": 0, "message": "ok"}) default: - return errorcode.ParamErr("unknown action: %s", env.Action) + return errorcode.ParamErrf("unknown action: %s", env.Action) } } @@ -255,27 +255,27 @@ func (s *CallbackService) sendStreamLoading(sessionID string, content string) { func (s *CallbackService) handleBugOptimizationSubmitUpdate(ctx context.Context, taskData json.RawMessage) (string, *errorcode.BusinessErr) { var data BugOptimizationSubmitUpdateData if err := json.Unmarshal(taskData, &data); err != nil { - return "", errorcode.ParamErr("invalid data type: %v", err) + return "", errorcode.ParamErrf("invalid data type: %v", err) } if data.Creator == "" { - return "", errorcode.ParamErr("empty creator") + return "", errorcode.ParamErrf("empty creator") } // 获取创建者uid accessToken, _ := s.dingtalkOldClient.GetAccessToken() creatorId, err := s.dingtalkContactClient.SearchUserOne(accessToken, data.Creator) if err != nil { - return "", errorcode.ParamErr("invalid data type: %v", err) + return "", errorcode.ParamErrf("invalid data type: %v", err) } // 获取用户详情 userDetails, err := s.dingtalkOldClient.QueryUserDetails(ctx, creatorId) if err != nil { - return "", errorcode.ParamErr("invalid data type: %v", err) + return "", errorcode.ParamErrf("invalid data type: %v", err) } if userDetails == nil { - return "", errorcode.ParamErr("user details not found") + return "", errorcode.ParamErrf("user details not found") } unionId := userDetails.UnionID @@ -288,10 +288,10 @@ func (s *CallbackService) handleBugOptimizationSubmitUpdate(ctx context.Context, CreatorUnionId: unionId, }) if err != nil { - return "", errorcode.ParamErr("invalid data type: %v", err) + return "", errorcode.ParamErrf("invalid data type: %v", err) } if !ok { - return "", errorcode.ParamErr("update record failed") + return "", errorcode.ParamErrf("update record failed") } return "问题记录即将完成", nil @@ -301,16 +301,16 @@ func (s *CallbackService) handleBugOptimizationSubmitUpdate(ctx context.Context, func (s *CallbackService) handleBugOptimizationSubmitDone(ctx context.Context, taskData json.RawMessage) (string, *errorcode.BusinessErr) { var data BugOptimizationSubmitDoneData if err := json.Unmarshal(taskData, &data); err != nil { - return "", errorcode.ParamErr("invalid data type: %v", err) + return "", errorcode.ParamErrf("invalid data type: %v", err) } if len(data.Receivers) == 0 { - return "", errorcode.ParamErr("empty receivers") + return "", errorcode.ParamErrf("empty receivers") } // 构建接收者 receivers := s.getDingtalkReceivers(ctx, data.Receivers) if receivers == "" { - return "", errorcode.ParamErr("invalid receivers") + return "", errorcode.ParamErrf("invalid receivers") } // 构建跳转链接 diff --git a/internal/services/capability.go b/internal/services/capability.go index 89d97cc..5b3d152 100644 --- a/internal/services/capability.go +++ b/internal/services/capability.go @@ -65,11 +65,11 @@ func (s *CapabilityService) ProductIngest(c *fiber.Ctx) error { // 解析请求参数 req := ProductIngestReq{} if err := c.BodyParser(&req); err != nil { - return errorcode.ParamErr("invalid request body: %v", err) + return errorcode.ParamErrf("invalid request body: %v", err) } // 必要参数校验 if req.Text == "" || req.SysId == "" { - return errorcode.ParamErr("missing required fields") + return errorcode.ParamErrf("missing required fields") } // 映射目标系统商品属性中文模板 @@ -78,7 +78,7 @@ func (s *CapabilityService) ProductIngest(c *fiber.Ctx) error { case "hyt": // 货易通 sysProductPropertyTemplateZH = constants.HYTProductPropertyTemplateZH default: - return errorcode.ParamErr("invalid sys_id") + return errorcode.ParamErrf("invalid sys_id") } // 模型调用 @@ -138,11 +138,11 @@ func (s *CapabilityService) checkRequestHeader(c *fiber.Ctx) error { // 时间窗口校验 if ts != "" && !util.IsInTimeWindow(ts, 5*time.Minute) { - // return errorcode.AuthNotFound + return errorcode.AuthNotFound } // token校验 if token == "" || token != "A7f9KQ3mP2X8LZC4R5e" { - return errorcode.KeyErr() + return errorcode.KeyNotFound } return nil @@ -164,12 +164,12 @@ func (s *CapabilityService) ProductIngestConfirm(c *fiber.Ctx) error { // 获取路径参数中的 thread_id threadId := c.Params("thread_id") if threadId == "" { - return errorcode.ParamErr("missing required fields") + return errorcode.ParamErrf("missing required fields") } // 解析请求参数 body req := ProductIngestConfirmReq{} if err := c.BodyParser(&req); err != nil { - return errorcode.ParamErr("invalid request body: %v", err) + return errorcode.ParamErrf("invalid request body: %v", err) } // 必要参数校验 if req.Confirmed == "" || threadId == "" { diff --git a/internal/tool_callback/bug_optimization_submit.go b/internal/tool_callback/bug_optimization_submit.go index 245ab8c..57a5e73 100644 --- a/internal/tool_callback/bug_optimization_submit.go +++ b/internal/tool_callback/bug_optimization_submit.go @@ -60,7 +60,7 @@ func (w *CallBackTool) BugOptimizationSubmit(ctx context.Context, requireData *e cond = cond.And(builder.Eq{"session_id": requireData.Session}) sessionInfo, err := w.sessionImpl.GetOneBySearch(&cond) if err != nil { - err = errors.SysErr("获取会话信息失败:%v", err.Error()) + err = errors.SysErrf("获取会话信息失败:%v", err.Error()) return } userName := sessionInfo["user_name"].(string) From cd13e1dbfa1e44b2dbca7b9cf6377dc0aca75410 Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Mon, 22 Dec 2025 14:17:37 +0800 Subject: [PATCH 08/14] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=8E=AF=E5=A2=83=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/config_test.yaml | 13 ++++++++++++- internal/services/capability.go | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/config/config_test.yaml b/config/config_test.yaml index ab91824..fe68122 100644 --- a/config/config_test.yaml +++ b/config/config_test.yaml @@ -3,7 +3,6 @@ server: port: 8090 host: "0.0.0.0" - ollama: base_url: "http://host.docker.internal:11434" model: "qwen3-coder:480b-cloud" @@ -90,6 +89,18 @@ tools: api_key: "7583905168607100978" api_secret: "pat_eEN0BdLNDughEtABjJJRYTW71olvDU0qUbfQUeaPc2NnYWO8HeyNoui5aR9z0sSZ" +# eino tool 配置 +eino_tools: + # 货易通商品上传 + hytProductUpload: + base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/goods/supplier/batch/add/complete" + add_url: "https://gateway.dev.cdlsxd.cn/sw//#/goods/goodsManage" + # 货易通供应商查询 + hytSupplierSearch: + base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/supplier/list" + # 货易通仓库查询 + hytWarehouseSearch: + base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/warehouse/list" default_prompt: diff --git a/internal/services/capability.go b/internal/services/capability.go index 5b3d152..c5dec87 100644 --- a/internal/services/capability.go +++ b/internal/services/capability.go @@ -141,7 +141,7 @@ func (s *CapabilityService) checkRequestHeader(c *fiber.Ctx) error { return errorcode.AuthNotFound } // token校验 - if token == "" || token != "A7f9KQ3mP2X8LZC4R5e" { + if token == "" || token != constants.CapabilityProductIngestToken { return errorcode.KeyNotFound } From adda03e5d871dfef45f4fa0b4078149ec4a6eb22 Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Mon, 22 Dec 2025 15:01:21 +0800 Subject: [PATCH 09/14] =?UTF-8?q?fix:=20=E5=A2=9E=E5=8A=A0=E4=BE=9B?= =?UTF-8?q?=E5=BA=94=E5=95=86=E6=8A=A5=E4=BB=B7=E5=BF=85=E5=A1=AB=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/domain/workflow/hyt/product_upload.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/domain/workflow/hyt/product_upload.go b/internal/domain/workflow/hyt/product_upload.go index fca3aec..cc9ab70 100644 --- a/internal/domain/workflow/hyt/product_upload.go +++ b/internal/domain/workflow/hyt/product_upload.go @@ -139,6 +139,9 @@ func (o *productUpload) buildWorkflow(ctx context.Context) (compose.Runnable[*Pr if ingestData.TaxRate == "" { return nil, errors.New("税率不能为空") } + if ingestData.SupplierPrice == "" { + return nil, errors.New("供应商报价不能为空") + } state.IngestData = &ingestData state.SupplierName = ingestData.SupplierName From 8d4f3c494ed8b7410d8b28edd2090a88b2dacaa0 Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Mon, 22 Dec 2025 17:48:48 +0800 Subject: [PATCH 10/14] =?UTF-8?q?fix:=20=E6=98=A0=E5=B0=84=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E8=B0=83=E6=95=B4=E3=80=81chat=E6=96=B9=E6=B3=95?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E3=80=81=E6=8F=90=E7=A4=BA=E8=AF=8D=E8=B0=83?= =?UTF-8?q?=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/config_env.yaml | 1 + config/config_test.yaml | 1 + internal/config/config.go | 1 + internal/data/constants/capability.go | 36 ++++++++++++++------------- internal/pkg/utils_ollama/client.go | 6 ++--- internal/services/capability.go | 2 +- 6 files changed, 26 insertions(+), 21 deletions(-) diff --git a/config/config_env.yaml b/config/config_env.yaml index a10a7d4..ec4f4c2 100644 --- a/config/config_env.yaml +++ b/config/config_env.yaml @@ -7,6 +7,7 @@ ollama: base_url: "http://192.168.6.109:11434" model: "qwen3-coder:480b-cloud" generate_model: "qwen3-coder:480b-cloud" + mapping_model: "deepseek-v3.2:cloud" vl_model: "qwen2.5vl:7b" timeout: "120s" level: "info" diff --git a/config/config_test.yaml b/config/config_test.yaml index fe68122..45fb701 100644 --- a/config/config_test.yaml +++ b/config/config_test.yaml @@ -7,6 +7,7 @@ ollama: base_url: "http://host.docker.internal:11434" model: "qwen3-coder:480b-cloud" generate_model: "qwen3-coder:480b-cloud" + mapping_model: "deepseek-v3.2:cloud" vl_model: "gemini-3-pro-preview" timeout: "120s" level: "info" diff --git a/internal/config/config.go b/internal/config/config.go index 5cbb506..b30adae 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -81,6 +81,7 @@ type OllamaConfig struct { BaseURL string `mapstructure:"base_url"` Model string `mapstructure:"model"` GenerateModel string `mapstructure:"generate_model"` + MappingModel string `mapstructure:"mapping_model"` VlModel string `mapstructure:"vl_model"` Timeout time.Duration `mapstructure:"timeout"` } diff --git a/internal/data/constants/capability.go b/internal/data/constants/capability.go index fab9db7..5b40d9f 100644 --- a/internal/data/constants/capability.go +++ b/internal/data/constants/capability.go @@ -9,41 +9,43 @@ const ( const ( SystemPrompt = ` #你是一个专业的商品属性提取助手,你的任务是根据用户输入提取商品的属性信息。 - 1.最终输出格式为纯JSON字符串,键值对对应目标属性和提取到的属性值。 - 2.最终输出不要携带markdown标识,不要携带回车换行` + 关键格式要求: + 1.输出必须是一个紧凑的、无任何多余空白字符的纯JSON字符串。 + 2.确保整个JSON输出在一行内完成,键、值、冒号、引号、括号之间均不要换行。 + 3.最终输出不要携带任何markdown标识(如json),直接输出纯JSON内容。` ) // 商品属性模板-中文 const ( // 货易通商品属性模板-中文 HYTProductPropertyTemplateZH = `{ - "条码": "string", // 商品编号 + "货品编号": "string", // 商品编号 + "条码": "string", // 货品编号 "分类名称": "string", // 商品分类 "货品名称": "string", // 商品名称 - "货品编号": "string", // 商品编号 - "商品货号": "string", // 商品编号 + "商品货号": "string", // 货品编号 "品牌": "string", // 商品品牌 "单位": "string", // 商品单位,若无则使用'个' "规格参数": "string", // 商品规格参数 "货品说明": "string", // 商品说明 - "保质期": "string", // 商品保质期 - "保质期单位": "string", // 商品保质期单位 - "链接": "string", // 商品链接 - "货品图片": ["string"], // 商品多图,取1-2个即可 + "保质期": "string", // 商品保质期,无则空 + "保质期单位": "string", // 商品保质期单位,无则空 + "链接": "string", // 空 + "货品图片": ["string"], // 商品多图,取前2个即可 "电商销售价格": "string", // 商品电商销售价格 decimal(10,2) "销售价": "string", // 商品销售价格 decimal(10,2) - "备注": "string", // 备注 + "备注": "string", // 无则空 "长": "string", // 商品长度,decimal(10,2)+单位 "宽": "string", // 商品宽度,decimal(10,2)+单位 "高": "string", // 商品高度,decimal(10,2)+单位 - "重量": "string", // 商品重量(kg) + "重量": "string", // 商品重量,decimal(10,2)+单位(kg) "SPU名称": "string", // 商品SPU名称 - "SPU编码": "string" // 编码串,jd_{timestamp}_rand(1000-999) - "供应商报价": "string", // 商品供应商报价 decimal(10,2) - "税率": "string", // 商品税率 x% - "利润": "string", // 商品利润 decimal(10,2) - "默认供应商": "string", // 供应商名称 - "默认存放仓库": "string", // 仓库名称 + "SPU编码": "string" // 货品编号 + "供应商报价": "string", // 空 + "税率": "string", // 商品税率 x%,无则空 + "利润": "string", // 空 + "默认供应商": "string", // 空 + "默认存放仓库": "string", // 空 }` ) diff --git a/internal/pkg/utils_ollama/client.go b/internal/pkg/utils_ollama/client.go index 1f67774..fa88afa 100644 --- a/internal/pkg/utils_ollama/client.go +++ b/internal/pkg/utils_ollama/client.go @@ -90,13 +90,13 @@ func (c *Client) ChatStream(ctx context.Context, ch chan entitys.Response, messa return } -func (c *Client) Chat(ctx context.Context, messages []api.Message) (res api.ChatResponse, err error) { +func (c *Client) Chat(ctx context.Context, model string, messages []api.Message) (res api.ChatResponse, err error) { // 构建聊天请求 req := &api.ChatRequest{ - Model: c.config.Model, + Model: model, Messages: messages, Stream: new(bool), // 设置为false,不使用流式响应 - Think: &api.ThinkValue{Value: true}, + Think: &api.ThinkValue{Value: false}, } err = c.client.Chat(ctx, req, func(resp api.ChatResponse) error { res = resp diff --git a/internal/services/capability.go b/internal/services/capability.go index c5dec87..f163a79 100644 --- a/internal/services/capability.go +++ b/internal/services/capability.go @@ -87,7 +87,7 @@ func (s *CapabilityService) ProductIngest(c *fiber.Ctx) error { return err } defer cleanup() - res, err := client.Chat(ctx, []api.Message{ + res, err := client.Chat(ctx, s.cfg.Ollama.MappingModel, []api.Message{ { Role: "system", Content: constants.SystemPrompt, From 208f749483c24b942a002652b2438e6a6ec95924 Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Wed, 24 Dec 2025 11:52:00 +0800 Subject: [PATCH 11/14] =?UTF-8?q?feat:=201.=E6=96=B0=E5=A2=9E=E5=A4=9A?= =?UTF-8?q?=E4=B8=AA=E8=B4=A7=E6=98=93=E9=80=9A=E5=B7=A5=E5=85=B7=202.?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=B4=A7=E6=98=93=E9=80=9A=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E5=95=86=E5=93=81=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/config_env.yaml | 17 + internal/config/config.go | 10 + internal/data/constants/capability.go | 31 +- internal/domain/tools/hyt/goods_add/client.go | 49 +++ .../domain/tools/hyt/goods_add/client_test.go | 51 +++ internal/domain/tools/hyt/goods_add/types.go | 35 ++ .../tools/hyt/goods_brand_search/client.go | 67 +++ .../hyt/goods_brand_search/client_test.go | 28 ++ .../tools/hyt/goods_brand_search/types.go | 25 ++ .../tools/hyt/goods_category_add/client.go | 49 +++ .../hyt/goods_category_add/client_test.go | 31 ++ .../tools/hyt/goods_category_add/types.go | 15 + .../tools/hyt/goods_category_search/client.go | 66 +++ .../tools/hyt/goods_category_search/types.go | 24 ++ .../tools/hyt/goods_media_add/client.go | 49 +++ .../tools/hyt/goods_media_add/client_test.go | 37 ++ .../domain/tools/hyt/goods_media_add/types.go | 21 + .../domain/tools/hyt/product_upload/client.go | 2 +- .../tools/hyt/product_upload/client_test.go | 61 --- .../tools/hyt/supplier_search/client.go | 1 - .../tools/hyt/warehouse_search/client.go | 1 - internal/domain/tools/registry.go | 27 +- internal/domain/workflow/hyt/goods_add.go | 387 ++++++++++++++++++ .../domain/workflow/hyt/product_upload.go | 14 +- internal/services/capability.go | 4 +- 25 files changed, 1021 insertions(+), 81 deletions(-) create mode 100644 internal/domain/tools/hyt/goods_add/client.go create mode 100644 internal/domain/tools/hyt/goods_add/client_test.go create mode 100644 internal/domain/tools/hyt/goods_add/types.go create mode 100644 internal/domain/tools/hyt/goods_brand_search/client.go create mode 100644 internal/domain/tools/hyt/goods_brand_search/client_test.go create mode 100644 internal/domain/tools/hyt/goods_brand_search/types.go create mode 100644 internal/domain/tools/hyt/goods_category_add/client.go create mode 100644 internal/domain/tools/hyt/goods_category_add/client_test.go create mode 100644 internal/domain/tools/hyt/goods_category_add/types.go create mode 100644 internal/domain/tools/hyt/goods_category_search/client.go create mode 100644 internal/domain/tools/hyt/goods_category_search/types.go create mode 100644 internal/domain/tools/hyt/goods_media_add/client.go create mode 100644 internal/domain/tools/hyt/goods_media_add/client_test.go create mode 100644 internal/domain/tools/hyt/goods_media_add/types.go delete mode 100644 internal/domain/tools/hyt/product_upload/client_test.go create mode 100644 internal/domain/workflow/hyt/goods_add.go diff --git a/config/config_env.yaml b/config/config_env.yaml index ec4f4c2..dd120a0 100644 --- a/config/config_env.yaml +++ b/config/config_env.yaml @@ -88,6 +88,23 @@ eino_tools: # 货易通仓库查询 hytWarehouseSearch: base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/warehouse/list" + # 货易通商品添加 + hytGoodsAdd: + base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/goods/add" + # 货易通商品图片添加 + hytGoodsMediaAdd: + base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/media/add/batch" + # 货易通商品分类添加 + hytGoodsCategoryAdd: + base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/good/category/relation/add" + # 货易通商品分类查询 + hytGoodsCategorySearch: + base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/goods/category/list" + # 货易通商品品牌查询 + hytGoodsBrandSearch: + base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/goods/brand/list" + + default_prompt: img_recognize: diff --git a/internal/config/config.go b/internal/config/config.go index b30adae..3198ebd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -163,6 +163,16 @@ type EinoToolsConfig struct { HytSupplierSearch ToolConfig `mapstructure:"hytSupplierSearch"` // 货易通仓库查询 HytWarehouseSearch ToolConfig `mapstructure:"hytWarehouseSearch"` + // 货易通商品添加 + HytGoodsAdd ToolConfig `mapstructure:"hytGoodsAdd"` + // 货易通商品图片添加 + HytGoodsMediaAdd ToolConfig `mapstructure:"hytGoodsMediaAdd"` + // 货易通商品分类添加 + HytGoodsCategoryAdd ToolConfig `mapstructure:"hytGoodsCategoryAdd"` + // 货易通商品分类查询 + HytGoodsCategorySearch ToolConfig `mapstructure:"hytGoodsCategorySearch"` + // 货易通商品品牌查询 + HytGoodsBrandSearch ToolConfig `mapstructure:"hytGoodsBrandSearch"` } // LoggingConfig 日志配置 diff --git a/internal/data/constants/capability.go b/internal/data/constants/capability.go index 5b40d9f..2555cda 100644 --- a/internal/data/constants/capability.go +++ b/internal/data/constants/capability.go @@ -17,8 +17,8 @@ const ( // 商品属性模板-中文 const ( - // 货易通商品属性模板-中文 - HYTProductPropertyTemplateZH = `{ + // 货易通供应商商品属性模板-中文 + HYTSupplierProductPropertyTemplateZH = `{ "货品编号": "string", // 商品编号 "条码": "string", // 货品编号 "分类名称": "string", // 商品分类 @@ -47,6 +47,33 @@ const ( "默认供应商": "string", // 空 "默认存放仓库": "string", // 空 }` + // 货易通商品属性模板-中文 Ps:手机端主图、详情图文、平台资质图 (暂时无需) + HYTGoodsAddPropertyTemplateZH = `{ + "商品标题": "string", // 商品名称 + "商品编码": "string", // 商品编码 + "SPU名称": "string", // 商品SPU名称 + "SPU编码": "string", // 商品编码 + "商品货号": "string", // 商品货号 + "商品条形码": "string", // 商品编码 + "市场价": "string", // 商品市场价 decimal(10,2) + "建议销售价": "string", // 商品建议销售价 decimal(10,2) + "电商销售价格": "string", // 商品电商销售价格 decimal(10,2) + "单位": "string", // 商品单位,若无则使用'个' + "折扣(%)": "string", // 商品折扣(%),默认0% + "税率(%)": "string", // 商品税率(%),默认13% + "运费模版": "string", // 商品运费模版,默认空 + "保质期": "string", // 商品保质期,无则空 + "保质期单位": "string", // 商品保质期单位,无则空 + "品牌": "string", // 商品品牌,若无则空 + "是否热销主推": "string", // 填否 + "外部平台链接": "string", // 商品外部平台链接 + "商品卖点": "string", // 商品卖点 + "商品规格参数": "string", // 商品规格参数 + "商品说明": "string", // 商品说明 + "备注": "string", // 无则空 + "分类名称": "string", // 商品分类 + "电脑端主图": ["string"], // 商品电脑端主图 + }` ) // 缓存key diff --git a/internal/domain/tools/hyt/goods_add/client.go b/internal/domain/tools/hyt/goods_add/client.go new file mode 100644 index 0000000..7f91d66 --- /dev/null +++ b/internal/domain/tools/hyt/goods_add/client.go @@ -0,0 +1,49 @@ +package goods_add + +import ( + "ai_scheduler/internal/config" + "ai_scheduler/internal/pkg/l_request" + "ai_scheduler/internal/pkg/util" + "context" + "encoding/json" + "fmt" +) + +type Client struct { + cfg config.ToolConfig +} + +func New(cfg config.ToolConfig) *Client { + return &Client{ + cfg: cfg, + } +} + +func (c *Client) Call(ctx context.Context, req *GoodsAddRequest) (int, error) { + apiReq, _ := util.StructToMap(req) + + r := l_request.Request{ + Method: "Post", + Url: c.cfg.BaseURL, + Json: apiReq, + Headers: map[string]string{ + "Content-Type": "application/json", + }, + } + + res, err := r.Send() + if err != nil { + return 0, fmt.Errorf("请求失败,err: %v", err) + } + + var resData GoodsAddResponse + if err := json.Unmarshal([]byte(res.Text), &resData); err != nil { + return 0, fmt.Errorf("解析响应失败,err: %v", err) + } + + if resData.Code != 200 { + return 0, fmt.Errorf("业务错误,code: %d, msg: %s", resData.Code, resData.Msg) + } + + return resData.Data.Id, nil +} diff --git a/internal/domain/tools/hyt/goods_add/client_test.go b/internal/domain/tools/hyt/goods_add/client_test.go new file mode 100644 index 0000000..aba715a --- /dev/null +++ b/internal/domain/tools/hyt/goods_add/client_test.go @@ -0,0 +1,51 @@ +package goods_add + +import ( + "ai_scheduler/internal/config" + "context" + "fmt" + "testing" +) + +// Test_Call +func Test_Call(t *testing.T) { + req := &GoodsAddRequest{ + Unit: "元", + IsComposeGoods: 2, + GoodsAttributes: "
商品规格参数
", + Introduction: "商品卖点
", + GoodsIllustration: "商品说明
", + IsHot: 2, + Title: "fu测试001", + GoodsNum: "futest001sku", + SpuCode: "futest001spu", + SpuName: "fu测试001", + Price: 100, + SalesPrice: 80, + Discount: 15, + TaxRate: 13, + FreightId: 3, + Remark: "备注说明", + SellByDate: 180, + ExternalPrice: 120, + GoodsBarCode: "futest001code2", + GoodsCode: "futest001code1", + SellByDateUnit: "天", + BrandId: 3, + ExternalUrl: "https://www.baidu.com", + } + + cfg := config.ToolConfig{ + BaseURL: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/goods/add", + } + + client := New(cfg) + toolResp, err := client.Call(context.Background(), req) + + if err != nil { + t.Errorf("Call() error = %v", err) + return + } + + fmt.Printf("toolResp: %v\n", toolResp) +} diff --git a/internal/domain/tools/hyt/goods_add/types.go b/internal/domain/tools/hyt/goods_add/types.go new file mode 100644 index 0000000..ef9e168 --- /dev/null +++ b/internal/domain/tools/hyt/goods_add/types.go @@ -0,0 +1,35 @@ +package goods_add + +type GoodsAddRequest struct { + Title string `json:"title"` // 商品标题 + GoodsCode string `json:"goods_code"` // 商品编码 + SpuName string `json:"spu_name"` // SPU 名称 + SpuCode string `json:"spu_code"` // SPU 编码 + GoodsNum string `json:"goods_num"` // 商品货号 + GoodsBarCode string `json:"goods_bar_code"` // 商品条形码 + Price float64 `json:"price"` // 市场价 + SalesPrice float64 `json:"sales_price"` // 建议销售价 + ExternalPrice float64 `json:"external_price"` // 电商销售价格 + Unit string `json:"unit"` // 价格单位 + Discount int `json:"discount"` // 折扣 + TaxRate int `json:"tax_rate"` // 税率 + FreightId int `json:"freight_id"` // 运费模板 ID + SellByDate int `json:"sell_by_date"` // 保质期 + SellByDateUnit string `json:"sell_by_date_unit"` // 保质期单位 + BrandId int `json:"brand_id"` // 品牌 ID + IsHot int `json:"is_hot"` // 是否热销主推 1.是 2.否(默认) + ExternalUrl string `json:"external_url"` // 外部平台链接 + Introduction string `json:"introduction"` // 商品卖点 + GoodsAttributes string `json:"goods_attributes"` // 商品规格参数 + GoodsIllustration string `json:"goods_illustration"` // 商品说明 + Remark string `json:"remark"` // 备注说明 + IsComposeGoods int `json:"is_compose_goods"` // 是否组合商品 1.是 2.否(默认) +} + +type GoodsAddResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Id int `json:"id"` // 商品 ID + } `json:"data"` +} diff --git a/internal/domain/tools/hyt/goods_brand_search/client.go b/internal/domain/tools/hyt/goods_brand_search/client.go new file mode 100644 index 0000000..e7b3d58 --- /dev/null +++ b/internal/domain/tools/hyt/goods_brand_search/client.go @@ -0,0 +1,67 @@ +package goods_brand_search + +import ( + "ai_scheduler/internal/config" + "ai_scheduler/internal/pkg/l_request" + "ai_scheduler/internal/pkg/util" + "context" + "encoding/json" + "fmt" +) + +type Client struct { + cfg config.ToolConfig +} + +func New(cfg config.ToolConfig) *Client { + return &Client{ + cfg: cfg, + } +} + +func (c *Client) Call(ctx context.Context, name string) (int, error) { + if name == "" { + return 0, nil + } + + reqBody := GoodsBrandSearchRequest{ + Page: 1, + Limit: 1, + Search: SearchCondition{ + Name: name, + }, + } + + apiReq, _ := util.StructToMap(reqBody) + + req := l_request.Request{ + Method: "Post", + Url: c.cfg.BaseURL, + Json: apiReq, + Headers: map[string]string{ + "User-Agent": "Apifox/1.0.0 (https://apifox.com)", + "Content-Type": "application/json", + }, + } + + res, err := req.Send() + if err != nil { + return 0, fmt.Errorf("请求失败,err: %v", err) + } + + var resData GoodsBrandSearchResponse + if err := json.Unmarshal([]byte(res.Text), &resData); err != nil { + return 0, fmt.Errorf("解析响应失败,err: %v", err) + } + + if resData.Code != 200 { + return 0, fmt.Errorf("业务错误,code: %d, msg: %s", resData.Code, resData.Msg) + } + + if len(resData.Data.List) == 0 { + return 0, fmt.Errorf("品牌不存在") + } + + // 返回第一个匹配的品牌ID + return resData.Data.List[0].ID, nil +} diff --git a/internal/domain/tools/hyt/goods_brand_search/client_test.go b/internal/domain/tools/hyt/goods_brand_search/client_test.go new file mode 100644 index 0000000..41009f1 --- /dev/null +++ b/internal/domain/tools/hyt/goods_brand_search/client_test.go @@ -0,0 +1,28 @@ +package goods_brand_search + +import ( + "ai_scheduler/internal/config" + "context" + "fmt" + "testing" +) + +// Test_Call +func Test_Call(t *testing.T) { + // 使用示例中的查询条件 + name := "vivo" + + cfg := config.ToolConfig{ + BaseURL: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/goods/brand/list", + } + + client := New(cfg) + toolResp, err := client.Call(context.Background(), name) + + if err != nil { + t.Errorf("Call() error = %v", err) + return + } + + fmt.Printf("toolResp (BrandID): %v\n", toolResp) +} diff --git a/internal/domain/tools/hyt/goods_brand_search/types.go b/internal/domain/tools/hyt/goods_brand_search/types.go new file mode 100644 index 0000000..c3ec8bb --- /dev/null +++ b/internal/domain/tools/hyt/goods_brand_search/types.go @@ -0,0 +1,25 @@ +package goods_brand_search + +type GoodsBrandSearchRequest struct { + Page int `json:"page"` + Limit int `json:"limit"` + Search SearchCondition `json:"search"` +} + +type SearchCondition struct { + Name string `json:"name"` +} + +type GoodsBrandSearchResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + List []BrandInfo `json:"list"` + } `json:"data"` +} + +type BrandInfo struct { + ID int `json:"id"` + Name string `json:"name"` + Logo string `json:"logo"` +} diff --git a/internal/domain/tools/hyt/goods_category_add/client.go b/internal/domain/tools/hyt/goods_category_add/client.go new file mode 100644 index 0000000..8fa0e8b --- /dev/null +++ b/internal/domain/tools/hyt/goods_category_add/client.go @@ -0,0 +1,49 @@ +package goods_category_add + +import ( + "ai_scheduler/internal/config" + "ai_scheduler/internal/pkg/l_request" + "ai_scheduler/internal/pkg/util" + "context" + "encoding/json" + "fmt" +) + +type Client struct { + cfg config.ToolConfig +} + +func New(cfg config.ToolConfig) *Client { + return &Client{ + cfg: cfg, + } +} + +func (c *Client) Call(ctx context.Context, req *GoodsCategoryAddRequest) (bool, error) { + apiReq, _ := util.StructToMap(req) + + r := l_request.Request{ + Method: "Post", + Url: c.cfg.BaseURL, + Json: apiReq, + Headers: map[string]string{ + "Content-Type": "application/json", + }, + } + + res, err := r.Send() + if err != nil { + return false, fmt.Errorf("请求失败,err: %v", err) + } + + var resData GoodsCategoryAddResponse + if err := json.Unmarshal([]byte(res.Text), &resData); err != nil { + return false, fmt.Errorf("解析响应失败,err: %v", err) + } + + if resData.Code != 200 { + return false, fmt.Errorf("业务错误,code: %d, msg: %s", resData.Code, resData.Msg) + } + + return resData.Data.IsSuccess, nil +} diff --git a/internal/domain/tools/hyt/goods_category_add/client_test.go b/internal/domain/tools/hyt/goods_category_add/client_test.go new file mode 100644 index 0000000..fed3a94 --- /dev/null +++ b/internal/domain/tools/hyt/goods_category_add/client_test.go @@ -0,0 +1,31 @@ +package goods_category_add + +import ( + "ai_scheduler/internal/config" + "context" + "fmt" + "testing" +) + +// Test_Call +func Test_Call(t *testing.T) { + req := &GoodsCategoryAddRequest{ + GoodsId: 8496, + CategoryIds: []int{1667}, + IsCover: false, + } + + cfg := config.ToolConfig{ + BaseURL: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/good/category/relation/add", + } + + client := New(cfg) + toolResp, err := client.Call(context.Background(), req) + + if err != nil { + t.Errorf("Call() error = %v", err) + return + } + + fmt.Printf("toolResp: %v\n", toolResp) +} diff --git a/internal/domain/tools/hyt/goods_category_add/types.go b/internal/domain/tools/hyt/goods_category_add/types.go new file mode 100644 index 0000000..e23691e --- /dev/null +++ b/internal/domain/tools/hyt/goods_category_add/types.go @@ -0,0 +1,15 @@ +package goods_category_add + +type GoodsCategoryAddRequest struct { + GoodsId int `json:"goods_id"` + CategoryIds []int `json:"category_ids"` + IsCover bool `json:"is_cover"` +} + +type GoodsCategoryAddResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + IsSuccess bool `json:"is_success"` // 是否成功 + } `json:"data"` +} diff --git a/internal/domain/tools/hyt/goods_category_search/client.go b/internal/domain/tools/hyt/goods_category_search/client.go new file mode 100644 index 0000000..185e54b --- /dev/null +++ b/internal/domain/tools/hyt/goods_category_search/client.go @@ -0,0 +1,66 @@ +package goods_category_search + +import ( + "ai_scheduler/internal/config" + "ai_scheduler/internal/pkg/l_request" + "ai_scheduler/internal/pkg/util" + "context" + "encoding/json" + "fmt" +) + +type Client struct { + cfg config.ToolConfig +} + +func New(cfg config.ToolConfig) *Client { + return &Client{ + cfg: cfg, + } +} + +func (c *Client) Call(ctx context.Context, name string) (int, error) { + if name == "" { + return 0, nil + } + + reqBody := GoodsCategorySearchRequest{ + Page: 1, + Limit: 1, + Search: SearchCondition{ + Name: name, + }, + } + + apiReq, _ := util.StructToMap(reqBody) + + req := l_request.Request{ + Method: "Post", + Url: c.cfg.BaseURL, + Json: apiReq, + Headers: map[string]string{ + "Content-Type": "application/json", + }, + } + + res, err := req.Send() + if err != nil { + return 0, fmt.Errorf("请求失败,err: %v", err) + } + + var resData GoodsCategorySearchResponse + if err := json.Unmarshal([]byte(res.Text), &resData); err != nil { + return 0, fmt.Errorf("解析响应失败,err: %v", err) + } + + if resData.Code != 200 { + return 0, fmt.Errorf("业务错误,code: %d, msg: %s", resData.Code, resData.Msg) + } + + if len(resData.Data.List) == 0 { + return 0, fmt.Errorf("商品分类不存在") + } + + // 返回第一个匹配的分类ID + return resData.Data.List[0].ID, nil +} diff --git a/internal/domain/tools/hyt/goods_category_search/types.go b/internal/domain/tools/hyt/goods_category_search/types.go new file mode 100644 index 0000000..dcc32e9 --- /dev/null +++ b/internal/domain/tools/hyt/goods_category_search/types.go @@ -0,0 +1,24 @@ +package goods_category_search + +type GoodsCategorySearchRequest struct { + Page int `json:"page"` + Limit int `json:"limit"` + Search SearchCondition `json:"search"` +} + +type SearchCondition struct { + Name string `json:"name"` +} + +type GoodsCategorySearchResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + List []CategoryInfo `json:"list"` + } `json:"data"` +} + +type CategoryInfo struct { + ID int `json:"id"` + Name string `json:"name"` +} diff --git a/internal/domain/tools/hyt/goods_media_add/client.go b/internal/domain/tools/hyt/goods_media_add/client.go new file mode 100644 index 0000000..6632168 --- /dev/null +++ b/internal/domain/tools/hyt/goods_media_add/client.go @@ -0,0 +1,49 @@ +package goods_media_add + +import ( + "ai_scheduler/internal/config" + "ai_scheduler/internal/pkg/l_request" + "ai_scheduler/internal/pkg/util" + "context" + "encoding/json" + "fmt" +) + +type Client struct { + cfg config.ToolConfig +} + +func New(cfg config.ToolConfig) *Client { + return &Client{ + cfg: cfg, + } +} + +func (c *Client) Call(ctx context.Context, req *GoodsMediaAddRequest) (bool, error) { + apiReq, _ := util.StructToMap(req) + + r := l_request.Request{ + Method: "Post", + Url: c.cfg.BaseURL, + Json: apiReq, + Headers: map[string]string{ + "Content-Type": "application/json", + }, + } + + res, err := r.Send() + if err != nil { + return false, fmt.Errorf("请求失败,err: %v", err) + } + + var resData GoodsMediaAddResponse + if err := json.Unmarshal([]byte(res.Text), &resData); err != nil { + return false, fmt.Errorf("解析响应失败,err: %v", err) + } + + if resData.Code != 200 { + return false, fmt.Errorf("业务错误,code: %d, msg: %s", resData.Code, resData.Msg) + } + + return resData.Data.IsSuccess, nil +} diff --git a/internal/domain/tools/hyt/goods_media_add/client_test.go b/internal/domain/tools/hyt/goods_media_add/client_test.go new file mode 100644 index 0000000..f6f16ca --- /dev/null +++ b/internal/domain/tools/hyt/goods_media_add/client_test.go @@ -0,0 +1,37 @@ +package goods_media_add + +import ( + "ai_scheduler/internal/config" + "context" + "fmt" + "testing" +) + +// Test_Call +func Test_Call(t *testing.T) { + req := &GoodsMediaAddRequest{ + GoodsId: 8496, + Data: []MediaItem{ + { + Type: 1, + Url: "https://lsxd-hz-store.oss-cn-hangzhou.aliyuncs.com/physicalGoodsSystems/images/goodsimages/goods/22f03d91-3cb7-45b4-ab92-07aad78a1633-screenshot_2025-12-17_17-46-00.png", + Sort: 1, + }, + }, + IsCover: true, + } + + cfg := config.ToolConfig{ + BaseURL: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/media/add/batch", + } + + client := New(cfg) + toolResp, err := client.Call(context.Background(), req) + + if err != nil { + t.Errorf("Call() error = %v", err) + return + } + + fmt.Printf("toolResp: %v\n", toolResp) +} diff --git a/internal/domain/tools/hyt/goods_media_add/types.go b/internal/domain/tools/hyt/goods_media_add/types.go new file mode 100644 index 0000000..e299d4f --- /dev/null +++ b/internal/domain/tools/hyt/goods_media_add/types.go @@ -0,0 +1,21 @@ +package goods_media_add + +type GoodsMediaAddRequest struct { + GoodsId int `json:"goods_id"` + Data []MediaItem `json:"data"` + IsCover bool `json:"is_cover"` +} + +type MediaItem struct { + Type int `json:"type"` + Url string `json:"url"` + Sort int `json:"sort"` +} + +type GoodsMediaAddResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + IsSuccess bool `json:"is_success"` + } `json:"data"` +} diff --git a/internal/domain/tools/hyt/product_upload/client.go b/internal/domain/tools/hyt/product_upload/client.go index 1cd68b8..096965c 100644 --- a/internal/domain/tools/hyt/product_upload/client.go +++ b/internal/domain/tools/hyt/product_upload/client.go @@ -43,7 +43,7 @@ func (c *Client) Call(ctx context.Context, toolReq *ProductUploadRequest) (toolR Code int `json:"code"` Msg string `json:"msg"` Data struct { - Ids []int `json:"ids"` // 预览URL + Ids []int `json:"ids"` // 商品 IDs } `json:"data"` } var resMap resType diff --git a/internal/domain/tools/hyt/product_upload/client_test.go b/internal/domain/tools/hyt/product_upload/client_test.go deleted file mode 100644 index fdd99f0..0000000 --- a/internal/domain/tools/hyt/product_upload/client_test.go +++ /dev/null @@ -1,61 +0,0 @@ -package product_upload - -import ( - "ai_scheduler/internal/config" - "context" - "fmt" - "testing" -) - -// Test_Call -func Test_Call(t *testing.T) { - req := &ProductUploadRequest{ - SupplierId: 261, - WarehouseId: 257, - IsDefaultWarehouse: 1, - Sort: 1, - Profit: 40, - TaxRate: 13, - GoodsList: []Goods{ - { - GoodsInfo: GoodsInfo{ - Title: "Apple iPhone 17 Pro Max 星宇橙色 256GB", - Brand: "Apple/苹果", - Category: "手机", - // CostPrice: 9999.00, - GoodsAttributes: "CPU型号:A19 Pro;操作系统:iOS;机身存储:256GB;屏幕尺寸:6.86英寸;屏幕材质:OLED直屏;屏幕技术:视网膜XDR;后置摄像头:4800万像素三主摄系统(主摄4800万+超广角4800万+长焦4800万);前置摄像头:1800万像素;网络支持:5G双卡双待(移动/联通/电信);生物识别:人脸识别;防水等级:IP68;充电功率:40W;无线充电:支持;机身尺寸:163.4mm×78.0mm×8.75mm;机身重量:231g;机身颜色:星宇橙色;特征特质:轻薄、防水防尘、无线充电、NFC、磁吸无线充", - GoodsBarCode: "10181383848993", - GoodsIllustration: "Apple/苹果 iPhone 17 Pro Max 【需当面激活】支持移动联通电信 5G 双卡双待手机 星宇橙色 256GB 官方标配。搭载A19 Pro芯片,6.86英寸OLED视网膜XDR直屏,4800万像素三主摄系统,支持5G双卡双待,IP68防水防尘,40W有线充电,支持无线充电和磁吸充电。", - GoodsNum: "10181383848993", - Introduction: "Apple/苹果 iPhone 17 Pro Max 【需当面激活】支持移动联通电信 5G 双卡双待手机 星宇橙色 256GB 官方标配。搭载A19 Pro芯片,6.86英寸OLED视网膜XDR直屏,4800万像素三主摄系统,支持5G双卡双待,IP68防水防尘,40W有线充电,支持无线充电和磁吸充电。", - IsBind: 1, - SpuName: "Apple iPhone 17 Pro Max", - SpuNum: "jd_1766038130329_8721", - TaxRate: 13, - Unit: "台", - Weight: "0.231", // 单位:kg - Price: 9999.00, - SalesPrice: 9999.00, - Stock: 0, // JSON 中未提供库存信息 - Discount: 10, // JSON 中未提供折扣信息 - IsComposeGoods: 2, - IsHot: 2, - }, - GoodsMediaList: []GoodsMedia{ - { - Type: 1, - Url: "https://img10.360buyimg.com/pcpubliccms/s228x228_jfs/t1/363919/12/2409/45712/691d9970F84b99d32/9f9a5d5d16efeb79.jpg.avif", - }, - }, - }, - }, - } - client := New(config.ToolConfig{}) - toolResp, err := client.Call(context.Background(), req) - - if err != nil { - t.Errorf("Call() error = %v", err) - } - - fmt.Printf("toolResp: %v\n", toolResp) -} diff --git a/internal/domain/tools/hyt/supplier_search/client.go b/internal/domain/tools/hyt/supplier_search/client.go index cbb20b4..1f47ee8 100644 --- a/internal/domain/tools/hyt/supplier_search/client.go +++ b/internal/domain/tools/hyt/supplier_search/client.go @@ -41,7 +41,6 @@ func (c *Client) Call(ctx context.Context, name string) (int, error) { Url: c.cfg.BaseURL, Json: apiReq, Headers: map[string]string{ - "User-Agent": "Apifox/1.0.0 (https://apifox.com)", "Content-Type": "application/json", }, } diff --git a/internal/domain/tools/hyt/warehouse_search/client.go b/internal/domain/tools/hyt/warehouse_search/client.go index 4502c42..cf420b2 100644 --- a/internal/domain/tools/hyt/warehouse_search/client.go +++ b/internal/domain/tools/hyt/warehouse_search/client.go @@ -36,7 +36,6 @@ func (c *Client) Call(ctx context.Context, name string) (int, error) { Url: c.cfg.BaseURL, Params: params, Headers: map[string]string{ - "User-Agent": "Apifox/1.0.0 (https://apifox.com)", "Content-Type": "application/json", }, } diff --git a/internal/domain/tools/registry.go b/internal/domain/tools/registry.go index 31a8636..ad9439d 100644 --- a/internal/domain/tools/registry.go +++ b/internal/domain/tools/registry.go @@ -2,6 +2,11 @@ package tools import ( "ai_scheduler/internal/config" + "ai_scheduler/internal/domain/tools/hyt/goods_add" + "ai_scheduler/internal/domain/tools/hyt/goods_brand_search" + "ai_scheduler/internal/domain/tools/hyt/goods_category_add" + "ai_scheduler/internal/domain/tools/hyt/goods_category_search" + "ai_scheduler/internal/domain/tools/hyt/goods_media_add" "ai_scheduler/internal/domain/tools/hyt/product_upload" "ai_scheduler/internal/domain/tools/hyt/supplier_search" "ai_scheduler/internal/domain/tools/hyt/warehouse_search" @@ -13,17 +18,27 @@ type Manager struct { } type HytTools struct { - ProductUpload *product_upload.Client - SupplierSearch *supplier_search.Client - WarehouseSearch *warehouse_search.Client + ProductUpload *product_upload.Client + SupplierSearch *supplier_search.Client + WarehouseSearch *warehouse_search.Client + GoodsAdd *goods_add.Client + GoodsMediaAdd *goods_media_add.Client + GoodsCategoryAdd *goods_category_add.Client + GoodsCategorySearch *goods_category_search.Client + GoodsBrandSearch *goods_brand_search.Client } func NewManager(cfg *config.Config) *Manager { return &Manager{ Hyt: &HytTools{ - ProductUpload: product_upload.New(cfg.EinoTools.HytProductUpload), - SupplierSearch: supplier_search.New(cfg.EinoTools.HytSupplierSearch), - WarehouseSearch: warehouse_search.New(cfg.EinoTools.HytWarehouseSearch), + ProductUpload: product_upload.New(cfg.EinoTools.HytProductUpload), + SupplierSearch: supplier_search.New(cfg.EinoTools.HytSupplierSearch), + WarehouseSearch: warehouse_search.New(cfg.EinoTools.HytWarehouseSearch), + GoodsAdd: goods_add.New(cfg.EinoTools.HytGoodsAdd), + GoodsMediaAdd: goods_media_add.New(cfg.EinoTools.HytGoodsMediaAdd), + GoodsCategoryAdd: goods_category_add.New(cfg.EinoTools.HytGoodsCategoryAdd), + GoodsCategorySearch: goods_category_search.New(cfg.EinoTools.HytGoodsCategorySearch), + GoodsBrandSearch: goods_brand_search.New(cfg.EinoTools.HytGoodsBrandSearch), }, } } diff --git a/internal/domain/workflow/hyt/goods_add.go b/internal/domain/workflow/hyt/goods_add.go new file mode 100644 index 0000000..7bd2dd4 --- /dev/null +++ b/internal/domain/workflow/hyt/goods_add.go @@ -0,0 +1,387 @@ +package hyt + +import ( + "ai_scheduler/internal/config" + errorcode "ai_scheduler/internal/data/error" + toolManager "ai_scheduler/internal/domain/tools" + "ai_scheduler/internal/domain/tools/hyt/goods_add" + "ai_scheduler/internal/domain/tools/hyt/goods_category_add" + "ai_scheduler/internal/domain/tools/hyt/goods_media_add" + "ai_scheduler/internal/domain/workflow/runtime" + "ai_scheduler/internal/entitys" + "context" + "encoding/json" + "errors" + "fmt" + "log" + "strconv" + "strings" + "sync" + + "github.com/cloudwego/eino/compose" +) + +const WorkflowIDGoodsAdd = "hyt.goodsAdd" + +func init() { + runtime.Register(WorkflowIDGoodsAdd, func(d *runtime.Deps) (runtime.Workflow, error) { + return &goodsAdd{cfg: d.Conf, toolManager: d.ToolManager}, nil + }) +} + +type goodsAdd struct { + cfg *config.Config + toolManager *toolManager.Manager + data *GoodsAddWorkflowInput +} + +type GoodsAddWorkflowInput struct { + Text string `mapstructure:"text"` +} + +func (o *goodsAdd) ID() string { return WorkflowIDGoodsAdd } + +func (o *goodsAdd) Invoke(ctx context.Context, rec *entitys.Recognize) (map[string]any, error) { + // 构建工作流 + runnable, err := o.buildWorkflow(ctx) + if err != nil { + return nil, err + } + + o.data = &GoodsAddWorkflowInput{ + Text: rec.UserContent.Text, + } + // 工作流过程调用 + output, err := runnable.Invoke(ctx, o.data) + if err != nil { + errStr := err.Error() + if u := errors.Unwrap(err); u != nil { + errStr = u.Error() + } + return nil, errorcode.WorkflowErr(errStr) + } + + return output, nil +} + +// ProductIngestData 对应 HYTGoodsAddPropertyTemplateZH 的结构 +type GoodsAddProductIngestData struct { + Title string `json:"商品标题"` + GoodsCode string `json:"商品编码"` + SpuName string `json:"SPU名称"` + SpuCode string `json:"SPU编码"` + GoodsNum string `json:"商品货号"` + GoodsBarCode string `json:"商品条形码"` + Price string `json:"市场价"` + SalesPrice string `json:"建议销售价"` + ExternalPrice string `json:"电商销售价格"` + Unit string `json:"单位"` + Discount string `json:"折扣(%)"` + TaxRate string `json:"税率(%)"` + FreightTemplate string `json:"运费模版"` + SellByDate string `json:"保质期"` + SellByDateUnit string `json:"保质期单位"` + Brand string `json:"品牌"` + IsHot string `json:"是否热销主推"` + ExternalUrl string `json:"外部平台链接"` + Introduction string `json:"商品卖点"` + GoodsAttributes string `json:"商品规格参数"` + GoodsIllustration string `json:"商品说明"` + Remark string `json:"备注"` + CategoryName string `json:"分类名称"` + Images []string `json:"电脑端主图"` +} + +// GoodsAddContext Graph 执行上下文状态 +type GoodsAddContext struct { + mu *sync.Mutex + InputText string + IngestData *GoodsAddProductIngestData + + // 核心请求体 + AddGoodsReq *goods_add.GoodsAddRequest + + // 中间态数据 + BrandId int + CategoryId int + BrandName string + CategoryName string + + // 运行结果 + GoodsId int + Result map[string]any +} + +// buildWorkflow 构建基于 Graph 的并行工作流 +func (o *goodsAdd) buildWorkflow(ctx context.Context) (compose.Runnable[*GoodsAddWorkflowInput, map[string]any], error) { + g := compose.NewGraph[*GoodsAddWorkflowInput, map[string]any]() + + // 1. DataMapping 节点: 解析 JSON -> 填充基础 Request + g.AddLambdaNode("data_mapping", compose.InvokableLambda(func(ctx context.Context, in *GoodsAddWorkflowInput) (*GoodsAddContext, error) { + state := &GoodsAddContext{ + mu: &sync.Mutex{}, // 初始化锁 + InputText: in.Text, + AddGoodsReq: &goods_add.GoodsAddRequest{}, + Result: make(map[string]any), + } + + // 解析用户输入的中文 JSON + var ingestData GoodsAddProductIngestData + if err := json.Unmarshal([]byte(in.Text), &ingestData); err != nil { + return nil, fmt.Errorf("解析商品数据失败: %w", err) + } + + // 必填校验 + if ingestData.Title == "" { + return nil, errors.New("商品标题不能为空") + } + if ingestData.GoodsCode == "" { + return nil, errors.New("商品编码不能为空") + } + if ingestData.SpuName == "" { + return nil, errors.New("SPU名称不能为空") + } + if ingestData.SpuCode == "" { + return nil, errors.New("SPU编码不能为空") + } + if ingestData.Price == "" { + return nil, errors.New("市场价不能为空") + } + if ingestData.SalesPrice == "" { + return nil, errors.New("建议销售价不能为空") + } + if ingestData.Unit == "" { + return nil, errors.New("价格单位不能为空") + } + if ingestData.Discount == "" { + return nil, errors.New("折扣不能为空") + } + if ingestData.TaxRate == "" { + return nil, errors.New("税率不能为空") + } + + state.IngestData = &ingestData + state.BrandName = ingestData.Brand + state.CategoryName = ingestData.CategoryName + + // 映射字段到 AddGoodsReq + state.AddGoodsReq.Title = ingestData.Title + state.AddGoodsReq.GoodsCode = ingestData.GoodsCode + state.AddGoodsReq.SpuName = ingestData.SpuName + state.AddGoodsReq.SpuCode = ingestData.SpuCode + state.AddGoodsReq.GoodsNum = ingestData.GoodsNum + state.AddGoodsReq.GoodsBarCode = ingestData.GoodsBarCode + + // 价格处理 + if val, err := strconv.ParseFloat(strings.TrimSuffix(ingestData.Price, "元"), 64); err == nil { + state.AddGoodsReq.Price = val + } + if val, err := strconv.ParseFloat(strings.TrimSuffix(ingestData.SalesPrice, "元"), 64); err == nil { + state.AddGoodsReq.SalesPrice = val + } + if val, err := strconv.ParseFloat(strings.TrimSuffix(ingestData.ExternalPrice, "元"), 64); err == nil && state.AddGoodsReq.Price == 0 { + state.AddGoodsReq.ExternalPrice = val + } + + state.AddGoodsReq.Unit = ingestData.Unit + + // 折扣处理 "80%" -> 80 + discountStr := strings.TrimSuffix(ingestData.Discount, "%") + if val, err := strconv.Atoi(discountStr); err == nil { + state.AddGoodsReq.Discount = val + } + // 税率处理 "13%" -> 13 + taxStr := strings.TrimSuffix(strings.TrimSuffix(ingestData.TaxRate, "%"), " ") + if val, err := strconv.Atoi(taxStr); err == nil { + state.AddGoodsReq.TaxRate = val + } + + // 运费模板先不给 state.AddGoodsReq.FreightId = 3 + + // 保质期处理 "180天" -> 180 + sellByDateStr := strings.TrimSuffix(ingestData.SellByDate, "天") + if val, err := strconv.Atoi(sellByDateStr); err == nil { + state.AddGoodsReq.SellByDate = val + } + state.AddGoodsReq.SellByDateUnit = ingestData.SellByDateUnit + + // state.AddGoodsReq.BrandId 品牌ID后续赋值 + + state.AddGoodsReq.IsHot = 2 + if ingestData.IsHot == "是" { + state.AddGoodsReq.IsHot = 1 + } + + state.AddGoodsReq.ExternalUrl = ingestData.ExternalUrl + state.AddGoodsReq.Introduction = ingestData.Introduction + state.AddGoodsReq.GoodsAttributes = ingestData.GoodsAttributes + state.AddGoodsReq.GoodsIllustration = ingestData.GoodsIllustration + state.AddGoodsReq.Remark = ingestData.Remark + state.AddGoodsReq.IsComposeGoods = 2 // 非组合商品 + + return state, nil + })) + + // 2. 获取品牌ID 节点 (并行) + g.AddLambdaNode("get_brand_id", compose.InvokableLambda(func(ctx context.Context, state *GoodsAddContext) (*GoodsAddContext, error) { + if state.BrandName == "" { + return state, errors.New("品牌名称不能为空") + } + + brandId, err := o.toolManager.Hyt.GoodsBrandSearch.Call(ctx, state.BrandName) + if err != nil { + log.Printf("warning: 品牌ID获取失败,%s: %v\n", state.BrandName, err) + // 如果获取失败,不阻断后续流程 + return nil, nil + } + + state.mu.Lock() + defer state.mu.Unlock() + state.BrandId = brandId + state.AddGoodsReq.BrandId = brandId + + return state, nil + })) + + // 3. 获取分类ID 节点 (并行) + g.AddLambdaNode("get_category_id", compose.InvokableLambda(func(ctx context.Context, state *GoodsAddContext) (*GoodsAddContext, error) { + if state.CategoryName == "" { + return state, errors.New("分类名称不能为空") + } + + categoryId, err := o.toolManager.Hyt.GoodsCategorySearch.Call(ctx, state.CategoryName) + if err != nil { + log.Printf("warning: 分类ID获取失败,%s: %v\n", state.CategoryName, err) + // 如果获取失败,不阻断后续流程 + return nil, nil + } + + state.mu.Lock() + defer state.mu.Unlock() + state.CategoryId = categoryId + + return state, nil + })) + + // 4. 新增商品 节点 (依赖 get_brand_id) + g.AddLambdaNode("goods_add", compose.InvokableLambda(func(ctx context.Context, state *GoodsAddContext) (*GoodsAddContext, error) { + // 校验 BrandId + if state.AddGoodsReq.BrandId == 0 { + return nil, errors.New("Missing Brand ID") + } + + // 调用 goods_add 工具 + goodsId, err := o.toolManager.Hyt.GoodsAdd.Call(ctx, state.AddGoodsReq) + if err != nil { + return nil, fmt.Errorf("新增商品失败: %w", err) + } + state.GoodsId = goodsId + + state.Result["goods_id"] = state.GoodsId + state.Result["spu_code"] = state.AddGoodsReq.SpuCode + return state, nil + })) + + // 5. 新增商品分类 节点 (依赖 goods_add 和 get_category_id) + g.AddLambdaNode("goods_category_add", compose.InvokableLambda(func(ctx context.Context, state *GoodsAddContext) (*GoodsAddContext, error) { + if state.GoodsId == 0 { + return nil, errors.New("goods_id is 0") + } + if state.CategoryId == 0 { + return nil, errors.New("category_id is 0") + } + + req := &goods_category_add.GoodsCategoryAddRequest{ + GoodsId: state.GoodsId, + CategoryIds: []int{state.CategoryId}, + IsCover: false, + } + + _, err := o.toolManager.Hyt.GoodsCategoryAdd.Call(ctx, req) + if err != nil { + log.Printf("warning: 关联分类失败: %v", err) + state.mu.Lock() + state.Result["category_error"] = err.Error() + state.mu.Unlock() + } else { + state.mu.Lock() + state.Result["category_added"] = true + state.mu.Unlock() + } + + return state, nil + })) + + // 6. 新增商品图片 节点 (依赖 goods_add) + g.AddLambdaNode("goods_media_add", compose.InvokableLambda(func(ctx context.Context, state *GoodsAddContext) (*GoodsAddContext, error) { + if state.GoodsId == 0 { + return nil, errors.New("goods_id is 0") + } + if len(state.IngestData.Images) == 0 { + return state, nil + } + + req := &goods_media_add.GoodsMediaAddRequest{ + GoodsId: state.GoodsId, + IsCover: true, + Data: make([]goods_media_add.MediaItem, 0), + } + + for i, url := range state.IngestData.Images { + req.Data = append(req.Data, goods_media_add.MediaItem{ + Type: 1, // 图片 + Url: url, + Sort: i, + }) + } + + _, err := o.toolManager.Hyt.GoodsMediaAdd.Call(ctx, req) + if err != nil { + log.Printf("warning: 添加图片失败: %v", err) + state.mu.Lock() + state.Result["media_error"] = err.Error() + state.mu.Unlock() + } else { + state.mu.Lock() + state.Result["media_added"] = true + state.mu.Unlock() + } + + return state, nil + })) + + // 7. 结果格式化节点 + g.AddLambdaNode("format_output", compose.InvokableLambda(func(ctx context.Context, state *GoodsAddContext) (map[string]any, error) { + return state.Result, nil + })) + + // 构建边 (DAG) + + // Start -> DataMapping + g.AddEdge(compose.START, "data_mapping") + + // Branching: DataMapping -> GetBrandId, DataMapping -> GetCategoryId + g.AddEdge("data_mapping", "get_brand_id") + g.AddEdge("data_mapping", "get_category_id") + + // Synchronization for GoodsAdd: Need BrandId + g.AddEdge("get_brand_id", "goods_add") + + // Synchronization for CategoryAdd: Need GoodsId AND CategoryId + // Eino supports multi-predecessor nodes which act as merge points. + // state merging is handled by the framework (usually last writer wins or custom merge, but here we modify different fields/mutex). + // However, we need to ensure goods_add is done. + g.AddEdge("goods_add", "goods_category_add") + g.AddEdge("get_category_id", "goods_category_add") + + // Synchronization for MediaAdd: Need GoodsId + g.AddEdge("goods_add", "goods_media_add") + + // Final Merge + g.AddEdge("goods_category_add", "format_output") + g.AddEdge("goods_media_add", "format_output") + + g.AddEdge("format_output", compose.END) + + return g.Compile(ctx) +} diff --git a/internal/domain/workflow/hyt/product_upload.go b/internal/domain/workflow/hyt/product_upload.go index cc9ab70..35114ed 100644 --- a/internal/domain/workflow/hyt/product_upload.go +++ b/internal/domain/workflow/hyt/product_upload.go @@ -19,10 +19,10 @@ import ( "github.com/cloudwego/eino/compose" ) -const WorkflowID = "hyt.productUpload" +const WorkflowIDProductUpload = "hyt.productUpload" func init() { - runtime.Register(WorkflowID, func(d *runtime.Deps) (runtime.Workflow, error) { + runtime.Register(WorkflowIDProductUpload, func(d *runtime.Deps) (runtime.Workflow, error) { return &productUpload{cfg: d.Conf, toolManager: d.ToolManager}, nil }) } @@ -37,7 +37,7 @@ type ProductUploadWorkflowInput struct { Text string `mapstructure:"text"` } -func (o *productUpload) ID() string { return WorkflowID } +func (o *productUpload) ID() string { return WorkflowIDProductUpload } func (o *productUpload) Invoke(ctx context.Context, rec *entitys.Recognize) (map[string]any, error) { // 构建工作流 @@ -64,8 +64,8 @@ func (o *productUpload) Invoke(ctx context.Context, rec *entitys.Recognize) (map return output, nil } -// ProductIngestData 对应 HYTProductPropertyTemplateZH 的结构 -type ProductIngestData struct { +// ProductIngestData 对应 HYTSupplierProductPropertyTemplateZH 的结构 +type SupplierProductIngestData struct { BarCode string `json:"条码"` CategoryName string `json:"分类名称"` GoodsName string `json:"货品名称"` @@ -99,7 +99,7 @@ type ProductIngestData struct { type ProductUploadContext struct { mu *sync.Mutex InputText string - IngestData *ProductIngestData + IngestData *SupplierProductIngestData UploadReq *toolPu.ProductUploadRequest SupplierName string WarehouseName string @@ -121,7 +121,7 @@ func (o *productUpload) buildWorkflow(ctx context.Context) (compose.Runnable[*Pr } // 解析用户输入的中文 JSON - var ingestData ProductIngestData + var ingestData SupplierProductIngestData if err := json.Unmarshal([]byte(in.Text), &ingestData); err != nil { return nil, fmt.Errorf("解析商品数据失败: %w", err) } diff --git a/internal/services/capability.go b/internal/services/capability.go index f163a79..759433c 100644 --- a/internal/services/capability.go +++ b/internal/services/capability.go @@ -76,7 +76,7 @@ func (s *CapabilityService) ProductIngest(c *fiber.Ctx) error { var sysProductPropertyTemplateZH string switch req.SysId { case "hyt": // 货易通 - sysProductPropertyTemplateZH = constants.HYTProductPropertyTemplateZH + sysProductPropertyTemplateZH = constants.HYTGoodsAddPropertyTemplateZH default: return errorcode.ParamErrf("invalid sys_id") } @@ -191,7 +191,7 @@ func (s *CapabilityService) ProductIngestConfirm(c *fiber.Ctx) error { switch respData.SysId { // 货易通 case "hyt": - workflowId = hytWorkflow.WorkflowID + workflowId = hytWorkflow.WorkflowIDGoodsAdd default: return errorcode.ParamErr("invalid sys_id") } From 8a626b3b58f36d997001f43b368193fc8a0d683c Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Wed, 24 Dec 2025 16:51:46 +0800 Subject: [PATCH 12/14] =?UTF-8?q?feat:=20=E8=B0=83=E6=95=B4=E8=B4=A7?= =?UTF-8?q?=E6=98=93=E9=80=9A=E5=88=9B=E5=BB=BA=E5=95=86=E5=93=81=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/config_env.yaml | 1 + config/config_test.yaml | 16 ++ internal/data/constants/capability.go | 40 +-- internal/domain/tools/hyt/goods_add/client.go | 26 +- internal/domain/tools/hyt/goods_add/types.go | 8 +- internal/domain/workflow/hyt/goods_add.go | 257 +++++++++--------- 6 files changed, 183 insertions(+), 165 deletions(-) diff --git a/config/config_env.yaml b/config/config_env.yaml index dd120a0..fa6b8ae 100644 --- a/config/config_env.yaml +++ b/config/config_env.yaml @@ -91,6 +91,7 @@ eino_tools: # 货易通商品添加 hytGoodsAdd: base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/goods/add" + add_url: "https://gateway.dev.cdlsxd.cn/sw//#/goods/goodsManage" # 货易通商品图片添加 hytGoodsMediaAdd: base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/media/add/batch" diff --git a/config/config_test.yaml b/config/config_test.yaml index 45fb701..5ea689d 100644 --- a/config/config_test.yaml +++ b/config/config_test.yaml @@ -102,6 +102,22 @@ eino_tools: # 货易通仓库查询 hytWarehouseSearch: base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/warehouse/list" + # 货易通商品添加 + hytGoodsAdd: + base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/goods/add" + add_url: "https://gateway.dev.cdlsxd.cn/sw//#/goods/goodsManage" + # 货易通商品图片添加 + hytGoodsMediaAdd: + base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/media/add/batch" + # 货易通商品分类添加 + hytGoodsCategoryAdd: + base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/good/category/relation/add" + # 货易通商品分类查询 + hytGoodsCategorySearch: + base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/goods/category/list" + # 货易通商品品牌查询 + hytGoodsBrandSearch: + base_url: "https://gateway.dev.cdlsxd.cn/goods-admin/api/v1/goods/brand/list" default_prompt: diff --git a/internal/data/constants/capability.go b/internal/data/constants/capability.go index 2555cda..a2e434b 100644 --- a/internal/data/constants/capability.go +++ b/internal/data/constants/capability.go @@ -8,11 +8,15 @@ const ( // Prompt const ( SystemPrompt = ` - #你是一个专业的商品属性提取助手,你的任务是根据用户输入提取商品的属性信息。 - 关键格式要求: - 1.输出必须是一个紧凑的、无任何多余空白字符的纯JSON字符串。 - 2.确保整个JSON输出在一行内完成,键、值、冒号、引号、括号之间均不要换行。 - 3.最终输出不要携带任何markdown标识(如json),直接输出纯JSON内容。` + 你是一个专业的商品属性提取助手,你的唯一任务是提取属性并以指定格式输出。请严格遵守: + <<< 格式规则 >>> + 1. 输出必须是且仅是一个紧凑的、无任何多余空白字符(包括换行、缩进)的纯JSON字符串。 + 2. 整个JSON必须在一行内,例如:{"商品标题":"示例","价格":100}。 + 3. 严格禁止输出任何Markdown代码块标识、额外解释、思考过程或提示词本身。 + 4. 任何对上述规则的偏离都会导致系统解析失败。 + <<< 规则结束 >>> + + 接下来,请处理用户输入并直接输出符合上述规则的结果。` ) // 商品属性模板-中文 @@ -50,29 +54,29 @@ const ( // 货易通商品属性模板-中文 Ps:手机端主图、详情图文、平台资质图 (暂时无需) HYTGoodsAddPropertyTemplateZH = `{ "商品标题": "string", // 商品名称 - "商品编码": "string", // 商品编码 + "商品编码": "string", // 商品编号+rand(1000-999) "SPU名称": "string", // 商品SPU名称 - "SPU编码": "string", // 商品编码 - "商品货号": "string", // 商品货号 - "商品条形码": "string", // 商品编码 - "市场价": "string", // 商品市场价 decimal(10,2) - "建议销售价": "string", // 商品建议销售价 decimal(10,2) - "电商销售价格": "string", // 商品电商销售价格 decimal(10,2) - "单位": "string", // 商品单位,若无则使用'个' - "折扣(%)": "string", // 商品折扣(%),默认0% - "税率(%)": "string", // 商品税率(%),默认13% + "SPU编码": "string", // 'ai_'+商品编号 + "商品货号": "string", // 商品编号 + "商品条形码": "string", // 商品编号 + "市场价": "string", // 优惠前价格 decimal(10,2) + "建议销售价": "string", // 市场价 + "电商销售价格": "string", // 优惠后价格 decimal(10,2) + "单位": "string", // 价格单位,默认'元' + "折扣": "string", // 商品折扣(%),默认'0%' + "税率": "string", // 商品税率(%),默认'13%' "运费模版": "string", // 商品运费模版,默认空 "保质期": "string", // 商品保质期,无则空 "保质期单位": "string", // 商品保质期单位,无则空 "品牌": "string", // 商品品牌,若无则空 - "是否热销主推": "string", // 填否 - "外部平台链接": "string", // 商品外部平台链接 + "是否热销主推": "string", // 默认'否' + "外部平台链接": "string", // 空即可 "商品卖点": "string", // 商品卖点 "商品规格参数": "string", // 商品规格参数 "商品说明": "string", // 商品说明 "备注": "string", // 无则空 "分类名称": "string", // 商品分类 - "电脑端主图": ["string"], // 商品电脑端主图 + "电脑端主图": ["string"], // 商品电脑端主图,取第一张 }` ) diff --git a/internal/domain/tools/hyt/goods_add/client.go b/internal/domain/tools/hyt/goods_add/client.go index 7f91d66..d6f83d5 100644 --- a/internal/domain/tools/hyt/goods_add/client.go +++ b/internal/domain/tools/hyt/goods_add/client.go @@ -19,7 +19,7 @@ func New(cfg config.ToolConfig) *Client { } } -func (c *Client) Call(ctx context.Context, req *GoodsAddRequest) (int, error) { +func (c *Client) Call(ctx context.Context, req *GoodsAddRequest) (*GoodsAddResponse, error) { apiReq, _ := util.StructToMap(req) r := l_request.Request{ @@ -33,17 +33,31 @@ func (c *Client) Call(ctx context.Context, req *GoodsAddRequest) (int, error) { res, err := r.Send() if err != nil { - return 0, fmt.Errorf("请求失败,err: %v", err) + return nil, fmt.Errorf("请求失败,err: %v", err) } - var resData GoodsAddResponse + type resType struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + Id int `json:"id"` // 商品 ID + } `json:"data"` + } + + var resData resType if err := json.Unmarshal([]byte(res.Text), &resData); err != nil { - return 0, fmt.Errorf("解析响应失败,err: %v", err) + return nil, fmt.Errorf("解析响应失败,err: %v", err) } if resData.Code != 200 { - return 0, fmt.Errorf("业务错误,code: %d, msg: %s", resData.Code, resData.Msg) + return nil, fmt.Errorf("业务错误,code: %d, msg: %s", resData.Code, resData.Msg) } - return resData.Data.Id, nil + toolResp := &GoodsAddResponse{ + PreviewUrl: c.cfg.AddURL, + SpuCode: req.SpuCode, + Id: resData.Data.Id, + } + + return toolResp, nil } diff --git a/internal/domain/tools/hyt/goods_add/types.go b/internal/domain/tools/hyt/goods_add/types.go index ef9e168..d17b500 100644 --- a/internal/domain/tools/hyt/goods_add/types.go +++ b/internal/domain/tools/hyt/goods_add/types.go @@ -27,9 +27,7 @@ type GoodsAddRequest struct { } type GoodsAddResponse struct { - Code int `json:"code"` - Msg string `json:"msg"` - Data struct { - Id int `json:"id"` // 商品 ID - } `json:"data"` + PreviewUrl string `json:"preview_url"` // 预览URL + SpuCode string `json:"spu_code"` // SPU编码 + Id int `json:"id"` // 商品ID } diff --git a/internal/domain/workflow/hyt/goods_add.go b/internal/domain/workflow/hyt/goods_add.go index 7bd2dd4..162a15c 100644 --- a/internal/domain/workflow/hyt/goods_add.go +++ b/internal/domain/workflow/hyt/goods_add.go @@ -19,6 +19,7 @@ import ( "sync" "github.com/cloudwego/eino/compose" + "golang.org/x/sync/errgroup" ) const WorkflowIDGoodsAdd = "hyt.goodsAdd" @@ -54,6 +55,7 @@ func (o *goodsAdd) Invoke(ctx context.Context, rec *entitys.Recognize) (map[stri // 工作流过程调用 output, err := runnable.Invoke(ctx, o.data) if err != nil { + fmt.Println("Invoke err:", err) errStr := err.Error() if u := errors.Unwrap(err); u != nil { errStr = u.Error() @@ -76,8 +78,8 @@ type GoodsAddProductIngestData struct { SalesPrice string `json:"建议销售价"` ExternalPrice string `json:"电商销售价格"` Unit string `json:"单位"` - Discount string `json:"折扣(%)"` - TaxRate string `json:"税率(%)"` + Discount string `json:"折扣"` + TaxRate string `json:"税率"` FreightTemplate string `json:"运费模版"` SellByDate string `json:"保质期"` SellByDateUnit string `json:"保质期单位"` @@ -108,8 +110,9 @@ type GoodsAddContext struct { CategoryName string // 运行结果 - GoodsId int - Result map[string]any + GoodsAddResp *goods_add.GoodsAddResponse + GoodsCategoryAddResp bool + GoodsMediaAddResp bool } // buildWorkflow 构建基于 Graph 的并行工作流 @@ -122,13 +125,12 @@ func (o *goodsAdd) buildWorkflow(ctx context.Context) (compose.Runnable[*GoodsAd mu: &sync.Mutex{}, // 初始化锁 InputText: in.Text, AddGoodsReq: &goods_add.GoodsAddRequest{}, - Result: make(map[string]any), } // 解析用户输入的中文 JSON var ingestData GoodsAddProductIngestData if err := json.Unmarshal([]byte(in.Text), &ingestData); err != nil { - return nil, fmt.Errorf("解析商品数据失败: %w", err) + return nil, fmt.Errorf("解析商品数据失败") } // 必填校验 @@ -179,7 +181,7 @@ func (o *goodsAdd) buildWorkflow(ctx context.Context) (compose.Runnable[*GoodsAd if val, err := strconv.ParseFloat(strings.TrimSuffix(ingestData.SalesPrice, "元"), 64); err == nil { state.AddGoodsReq.SalesPrice = val } - if val, err := strconv.ParseFloat(strings.TrimSuffix(ingestData.ExternalPrice, "元"), 64); err == nil && state.AddGoodsReq.Price == 0 { + if val, err := strconv.ParseFloat(strings.TrimSuffix(ingestData.ExternalPrice, "元"), 64); err == nil { state.AddGoodsReq.ExternalPrice = val } @@ -222,165 +224,148 @@ func (o *goodsAdd) buildWorkflow(ctx context.Context) (compose.Runnable[*GoodsAd return state, nil })) - // 2. 获取品牌ID 节点 (并行) - g.AddLambdaNode("get_brand_id", compose.InvokableLambda(func(ctx context.Context, state *GoodsAddContext) (*GoodsAddContext, error) { - if state.BrandName == "" { - return state, errors.New("品牌名称不能为空") - } + // 2. 预处理节点: 并行获取 品牌ID 和 分类ID + g.AddLambdaNode("prepare_info", compose.InvokableLambda(func(ctx context.Context, state *GoodsAddContext) (*GoodsAddContext, error) { + eg, ctx := errgroup.WithContext(ctx) - brandId, err := o.toolManager.Hyt.GoodsBrandSearch.Call(ctx, state.BrandName) - if err != nil { - log.Printf("warning: 品牌ID获取失败,%s: %v\n", state.BrandName, err) - // 如果获取失败,不阻断后续流程 - return nil, nil - } + // 任务1: 获取品牌ID + eg.Go(func() error { + if state.BrandName == "" { + return nil + } + brandId, err := o.toolManager.Hyt.GoodsBrandSearch.Call(ctx, state.BrandName) + if err != nil { + log.Printf("warning: 品牌ID获取失败,%s: %v\n", state.BrandName, err) + return nil + } + state.mu.Lock() + state.BrandId = brandId + state.AddGoodsReq.BrandId = brandId + state.mu.Unlock() + return nil + }) - state.mu.Lock() - defer state.mu.Unlock() - state.BrandId = brandId - state.AddGoodsReq.BrandId = brandId + // 任务2: 获取分类ID + eg.Go(func() error { + if state.CategoryName == "" { + return nil + } + categoryId, err := o.toolManager.Hyt.GoodsCategorySearch.Call(ctx, state.CategoryName) + if err != nil { + log.Printf("warning: 分类ID获取失败,%s: %v\n", state.CategoryName, err) + return nil + } + state.mu.Lock() + state.CategoryId = categoryId + state.mu.Unlock() + return nil + }) + + // 等待所有任务完成 + _ = eg.Wait() return state, nil })) - // 3. 获取分类ID 节点 (并行) - g.AddLambdaNode("get_category_id", compose.InvokableLambda(func(ctx context.Context, state *GoodsAddContext) (*GoodsAddContext, error) { - if state.CategoryName == "" { - return state, errors.New("分类名称不能为空") - } - - categoryId, err := o.toolManager.Hyt.GoodsCategorySearch.Call(ctx, state.CategoryName) - if err != nil { - log.Printf("warning: 分类ID获取失败,%s: %v\n", state.CategoryName, err) - // 如果获取失败,不阻断后续流程 - return nil, nil - } - - state.mu.Lock() - defer state.mu.Unlock() - state.CategoryId = categoryId - - return state, nil - })) - - // 4. 新增商品 节点 (依赖 get_brand_id) + // 3. 新增商品 节点 (依赖 prepare_info) g.AddLambdaNode("goods_add", compose.InvokableLambda(func(ctx context.Context, state *GoodsAddContext) (*GoodsAddContext, error) { - // 校验 BrandId - if state.AddGoodsReq.BrandId == 0 { - return nil, errors.New("Missing Brand ID") - } - // 调用 goods_add 工具 - goodsId, err := o.toolManager.Hyt.GoodsAdd.Call(ctx, state.AddGoodsReq) - if err != nil { - return nil, fmt.Errorf("新增商品失败: %w", err) - } - state.GoodsId = goodsId - - state.Result["goods_id"] = state.GoodsId - state.Result["spu_code"] = state.AddGoodsReq.SpuCode - return state, nil - })) - - // 5. 新增商品分类 节点 (依赖 goods_add 和 get_category_id) - g.AddLambdaNode("goods_category_add", compose.InvokableLambda(func(ctx context.Context, state *GoodsAddContext) (*GoodsAddContext, error) { - if state.GoodsId == 0 { - return nil, errors.New("goods_id is 0") - } - if state.CategoryId == 0 { - return nil, errors.New("category_id is 0") + respData, err := o.toolManager.Hyt.GoodsAdd.Call(ctx, state.AddGoodsReq) + if err != nil || respData == nil { + return nil, fmt.Errorf("新增商品失败") } - req := &goods_category_add.GoodsCategoryAddRequest{ - GoodsId: state.GoodsId, - CategoryIds: []int{state.CategoryId}, - IsCover: false, - } - - _, err := o.toolManager.Hyt.GoodsCategoryAdd.Call(ctx, req) - if err != nil { - log.Printf("warning: 关联分类失败: %v", err) - state.mu.Lock() - state.Result["category_error"] = err.Error() - state.mu.Unlock() - } else { - state.mu.Lock() - state.Result["category_added"] = true - state.mu.Unlock() - } + state.GoodsAddResp = respData return state, nil })) - // 6. 新增商品图片 节点 (依赖 goods_add) - g.AddLambdaNode("goods_media_add", compose.InvokableLambda(func(ctx context.Context, state *GoodsAddContext) (*GoodsAddContext, error) { - if state.GoodsId == 0 { - return nil, errors.New("goods_id is 0") - } - if len(state.IngestData.Images) == 0 { - return state, nil + // 4. 后置处理节点: 并行执行 关联分类 和 添加图片 + g.AddLambdaNode("post_process", compose.InvokableLambda(func(ctx context.Context, state *GoodsAddContext) (*GoodsAddContext, error) { + if state.GoodsAddResp.Id == 0 { + return nil, errors.New("商品不存在") } - req := &goods_media_add.GoodsMediaAddRequest{ - GoodsId: state.GoodsId, - IsCover: true, - Data: make([]goods_media_add.MediaItem, 0), - } + eg, ctx := errgroup.WithContext(ctx) - for i, url := range state.IngestData.Images { - req.Data = append(req.Data, goods_media_add.MediaItem{ - Type: 1, // 图片 - Url: url, - Sort: i, - }) - } + // 任务1: 关联分类 + eg.Go(func() error { + if state.CategoryId == 0 { + return nil + } + req := &goods_category_add.GoodsCategoryAddRequest{ + GoodsId: state.GoodsAddResp.Id, + CategoryIds: []int{state.CategoryId}, + IsCover: false, + } + isSuccess, err := o.toolManager.Hyt.GoodsCategoryAdd.Call(ctx, req) + if err != nil { + log.Printf("warning: 关联分类失败: %v", err) + return nil + } - _, err := o.toolManager.Hyt.GoodsMediaAdd.Call(ctx, req) - if err != nil { - log.Printf("warning: 添加图片失败: %v", err) state.mu.Lock() - state.Result["media_error"] = err.Error() + state.GoodsCategoryAddResp = isSuccess state.mu.Unlock() - } else { + + return nil + }) + + // 任务2: 添加图片 + eg.Go(func() error { + if len(state.IngestData.Images) == 0 { + return nil + } + req := &goods_media_add.GoodsMediaAddRequest{ + GoodsId: state.GoodsAddResp.Id, + IsCover: true, + Data: make([]goods_media_add.MediaItem, 0), + } + for i, url := range state.IngestData.Images { + req.Data = append(req.Data, goods_media_add.MediaItem{ + Type: 1, // 图片 + Url: url, + Sort: i, + }) + } + isSuccess, err := o.toolManager.Hyt.GoodsMediaAdd.Call(ctx, req) + if err != nil { + log.Printf("warning: 添加图片失败: %v", err) + return nil + } + state.mu.Lock() - state.Result["media_added"] = true + state.GoodsMediaAddResp = isSuccess state.mu.Unlock() - } + + return nil + }) + + // 等待所有任务完成 + _ = eg.Wait() return state, nil })) - // 7. 结果格式化节点 + // 5. 结果格式化节点 g.AddLambdaNode("format_output", compose.InvokableLambda(func(ctx context.Context, state *GoodsAddContext) (map[string]any, error) { - return state.Result, nil + if state.GoodsAddResp == nil { + return nil, fmt.Errorf("goods add response is nil") + } + + return map[string]any{ + "预览URL(货易通商品列表)": state.GoodsAddResp.PreviewUrl, + "SPU编码": state.GoodsAddResp.SpuCode, + "商品ID": state.GoodsAddResp.Id, + }, nil })) - // 构建边 (DAG) - - // Start -> DataMapping + // 构建边 (线性拓扑) g.AddEdge(compose.START, "data_mapping") - - // Branching: DataMapping -> GetBrandId, DataMapping -> GetCategoryId - g.AddEdge("data_mapping", "get_brand_id") - g.AddEdge("data_mapping", "get_category_id") - - // Synchronization for GoodsAdd: Need BrandId - g.AddEdge("get_brand_id", "goods_add") - - // Synchronization for CategoryAdd: Need GoodsId AND CategoryId - // Eino supports multi-predecessor nodes which act as merge points. - // state merging is handled by the framework (usually last writer wins or custom merge, but here we modify different fields/mutex). - // However, we need to ensure goods_add is done. - g.AddEdge("goods_add", "goods_category_add") - g.AddEdge("get_category_id", "goods_category_add") - - // Synchronization for MediaAdd: Need GoodsId - g.AddEdge("goods_add", "goods_media_add") - - // Final Merge - g.AddEdge("goods_category_add", "format_output") - g.AddEdge("goods_media_add", "format_output") - + g.AddEdge("data_mapping", "prepare_info") + g.AddEdge("prepare_info", "goods_add") + g.AddEdge("goods_add", "post_process") + g.AddEdge("post_process", "format_output") g.AddEdge("format_output", compose.END) return g.Compile(ctx) From 7682ecd75b1ce124a624a35e88aed3b48ba92947 Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Wed, 24 Dec 2025 17:19:30 +0800 Subject: [PATCH 13/14] =?UTF-8?q?fix=EF=BC=9A=201.=20msg=20->=20message=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=202.?= =?UTF-8?q?=E5=88=86=E7=B1=BB=E6=9F=A5=E8=AF=A2=E8=B0=83=E6=95=B4=E4=B8=BA?= =?UTF-8?q?=E4=BB=853=E7=BA=A7=E5=88=86=E7=B1=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/domain/tools/hyt/goods_add/client.go | 4 ++-- internal/domain/tools/hyt/goods_brand_search/types.go | 2 +- internal/domain/tools/hyt/goods_category_add/types.go | 2 +- internal/domain/tools/hyt/goods_category_search/client.go | 3 ++- internal/domain/tools/hyt/goods_category_search/types.go | 5 +++-- internal/domain/tools/hyt/goods_media_add/types.go | 2 +- internal/domain/workflow/hyt/goods_add.go | 3 ++- 7 files changed, 12 insertions(+), 9 deletions(-) diff --git a/internal/domain/tools/hyt/goods_add/client.go b/internal/domain/tools/hyt/goods_add/client.go index d6f83d5..a758b55 100644 --- a/internal/domain/tools/hyt/goods_add/client.go +++ b/internal/domain/tools/hyt/goods_add/client.go @@ -38,7 +38,7 @@ func (c *Client) Call(ctx context.Context, req *GoodsAddRequest) (*GoodsAddRespo type resType struct { Code int `json:"code"` - Msg string `json:"msg"` + Msg string `json:"message"` Data struct { Id int `json:"id"` // 商品 ID } `json:"data"` @@ -50,7 +50,7 @@ func (c *Client) Call(ctx context.Context, req *GoodsAddRequest) (*GoodsAddRespo } if resData.Code != 200 { - return nil, fmt.Errorf("业务错误,code: %d, msg: %s", resData.Code, resData.Msg) + return nil, fmt.Errorf("业务错误,%s", resData.Msg) } toolResp := &GoodsAddResponse{ diff --git a/internal/domain/tools/hyt/goods_brand_search/types.go b/internal/domain/tools/hyt/goods_brand_search/types.go index c3ec8bb..467a214 100644 --- a/internal/domain/tools/hyt/goods_brand_search/types.go +++ b/internal/domain/tools/hyt/goods_brand_search/types.go @@ -12,7 +12,7 @@ type SearchCondition struct { type GoodsBrandSearchResponse struct { Code int `json:"code"` - Msg string `json:"msg"` + Msg string `json:"message"` Data struct { List []BrandInfo `json:"list"` } `json:"data"` diff --git a/internal/domain/tools/hyt/goods_category_add/types.go b/internal/domain/tools/hyt/goods_category_add/types.go index e23691e..b3ecf68 100644 --- a/internal/domain/tools/hyt/goods_category_add/types.go +++ b/internal/domain/tools/hyt/goods_category_add/types.go @@ -8,7 +8,7 @@ type GoodsCategoryAddRequest struct { type GoodsCategoryAddResponse struct { Code int `json:"code"` - Msg string `json:"msg"` + Msg string `json:"message"` Data struct { IsSuccess bool `json:"is_success"` // 是否成功 } `json:"data"` diff --git a/internal/domain/tools/hyt/goods_category_search/client.go b/internal/domain/tools/hyt/goods_category_search/client.go index 185e54b..3af5e14 100644 --- a/internal/domain/tools/hyt/goods_category_search/client.go +++ b/internal/domain/tools/hyt/goods_category_search/client.go @@ -28,7 +28,8 @@ func (c *Client) Call(ctx context.Context, name string) (int, error) { Page: 1, Limit: 1, Search: SearchCondition{ - Name: name, + Name: name, + Level: 3, // 仅需三级分类 }, } diff --git a/internal/domain/tools/hyt/goods_category_search/types.go b/internal/domain/tools/hyt/goods_category_search/types.go index dcc32e9..2b9fb0d 100644 --- a/internal/domain/tools/hyt/goods_category_search/types.go +++ b/internal/domain/tools/hyt/goods_category_search/types.go @@ -7,12 +7,13 @@ type GoodsCategorySearchRequest struct { } type SearchCondition struct { - Name string `json:"name"` + Name string `json:"full_name"` + Level int `json:"level"` } type GoodsCategorySearchResponse struct { Code int `json:"code"` - Msg string `json:"msg"` + Msg string `json:"message"` Data struct { List []CategoryInfo `json:"list"` } `json:"data"` diff --git a/internal/domain/tools/hyt/goods_media_add/types.go b/internal/domain/tools/hyt/goods_media_add/types.go index e299d4f..bde4826 100644 --- a/internal/domain/tools/hyt/goods_media_add/types.go +++ b/internal/domain/tools/hyt/goods_media_add/types.go @@ -14,7 +14,7 @@ type MediaItem struct { type GoodsMediaAddResponse struct { Code int `json:"code"` - Msg string `json:"msg"` + Msg string `json:"message"` Data struct { IsSuccess bool `json:"is_success"` } `json:"data"` diff --git a/internal/domain/workflow/hyt/goods_add.go b/internal/domain/workflow/hyt/goods_add.go index 162a15c..91db063 100644 --- a/internal/domain/workflow/hyt/goods_add.go +++ b/internal/domain/workflow/hyt/goods_add.go @@ -272,7 +272,8 @@ func (o *goodsAdd) buildWorkflow(ctx context.Context) (compose.Runnable[*GoodsAd // 调用 goods_add 工具 respData, err := o.toolManager.Hyt.GoodsAdd.Call(ctx, state.AddGoodsReq) if err != nil || respData == nil { - return nil, fmt.Errorf("新增商品失败") + log.Printf("warning: 新增商品失败: %v", err) + return nil, fmt.Errorf("新增商品失败: %s", err.Error()) } state.GoodsAddResp = respData From e3448ae41eac3442529ac595a1576ecd21c216b1 Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Thu, 25 Dec 2025 14:46:52 +0800 Subject: [PATCH 14/14] =?UTF-8?q?feat:=201.=20=E5=A2=9E=E5=8A=A0=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E9=98=BB=E5=A1=9E=E7=AD=89=E5=BE=85=E5=BC=82=E6=AD=A5?= =?UTF-8?q?=E5=9B=9E=E8=B0=83redis=E7=BB=84=E4=BB=B6=202.=E5=8E=9F?= =?UTF-8?q?=E9=9C=80=E6=B1=82=E6=94=B6=E9=9B=86=E6=9C=BA=E5=99=A8=E4=BA=BA?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E8=87=B3eino=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/server/wire.go | 9 +- internal/domain/component/callback/manager.go | 71 ++++++++ .../domain/component/callback/provider_set.go | 5 + internal/domain/component/components.go | 15 ++ internal/domain/component/provider_set.go | 14 ++ internal/domain/repo/adapter.go | 29 +++ internal/domain/repo/provider_set.go | 5 + internal/domain/repo/repos.go | 17 ++ internal/domain/repo/session.go | 11 ++ internal/domain/workflow/provider_set.go | 12 +- internal/domain/workflow/registry.go | 2 + internal/domain/workflow/runtime/registry.go | 4 + .../workflow/zltx/bug_optimization_submit.go | 170 ++++++++++++++++++ .../zltx/order_after_reseller_batch.go | 106 ++++++++--- internal/entitys/recognize.go | 18 ++ internal/services/callback.go | 22 ++- 16 files changed, 475 insertions(+), 35 deletions(-) create mode 100644 internal/domain/component/callback/manager.go create mode 100644 internal/domain/component/callback/provider_set.go create mode 100644 internal/domain/component/components.go create mode 100644 internal/domain/component/provider_set.go create mode 100644 internal/domain/repo/adapter.go create mode 100644 internal/domain/repo/provider_set.go create mode 100644 internal/domain/repo/repos.go create mode 100644 internal/domain/repo/session.go create mode 100644 internal/domain/workflow/zltx/bug_optimization_submit.go diff --git a/cmd/server/wire.go b/cmd/server/wire.go index a35fa9b..8357aae 100644 --- a/cmd/server/wire.go +++ b/cmd/server/wire.go @@ -9,11 +9,14 @@ import ( "ai_scheduler/internal/biz/tools_regis" "ai_scheduler/internal/config" "ai_scheduler/internal/data/impl" + "ai_scheduler/internal/domain/component" + "ai_scheduler/internal/domain/repo" "ai_scheduler/internal/domain/workflow" "ai_scheduler/internal/pkg" "ai_scheduler/internal/server" "ai_scheduler/internal/services" - "ai_scheduler/internal/tool_callback" + + // "ai_scheduler/internal/tool_callback" "ai_scheduler/internal/tools" "ai_scheduler/utils" @@ -34,7 +37,9 @@ func InitializeApp(*config.Config, log.AllLogger) (*server.Servers, func(), erro utils.ProviderUtils, dingtalk.ProviderSetDingTalk, tools_regis.ProviderToolsRegis, - tool_callback.ProviderSetCallBackTools, + // tool_callback.ProviderSetCallBackTools, + component.ProviderSet, + repo.ProviderSet, )) } diff --git a/internal/domain/component/callback/manager.go b/internal/domain/component/callback/manager.go new file mode 100644 index 0000000..7803b09 --- /dev/null +++ b/internal/domain/component/callback/manager.go @@ -0,0 +1,71 @@ +package callback + +import ( + "context" + "fmt" + "time" + + "ai_scheduler/internal/pkg" + + "github.com/redis/go-redis/v9" +) + +type Manager interface { + Register(ctx context.Context, taskID string, sessionID string) error + Wait(ctx context.Context, taskID string, timeout time.Duration) (string, error) + Notify(ctx context.Context, taskID string, result string) error + GetSession(ctx context.Context, taskID string) (string, error) +} + +type RedisManager struct { + rdb *redis.Client +} + +func NewRedisManager(rdb *pkg.Rdb) *RedisManager { + return &RedisManager{ + rdb: rdb.Rdb, + } +} + +const ( + keyPrefixSession = "callback:session:" + keyPrefixSignal = "callback:signal:" + defaultTTL = 24 * time.Hour +) + +func (m *RedisManager) Register(ctx context.Context, taskID string, sessionID string) error { + key := keyPrefixSession + taskID + return m.rdb.Set(ctx, key, sessionID, defaultTTL).Err() +} + +func (m *RedisManager) Wait(ctx context.Context, taskID string, timeout time.Duration) (string, error) { + key := keyPrefixSignal + taskID + // BLPop 阻塞等待 + result, err := m.rdb.BLPop(ctx, timeout, key).Result() + if err != nil { + if err == redis.Nil { + return "", fmt.Errorf("timeout waiting for callback") + } + return "", err + } + // result[0] is key, result[1] is value + if len(result) < 2 { + return "", fmt.Errorf("invalid redis result") + } + return result[1], nil +} + +func (m *RedisManager) Notify(ctx context.Context, taskID string, result string) error { + key := keyPrefixSignal + taskID + // Push 信号,同时设置过期时间防止堆积 + pipe := m.rdb.Pipeline() + pipe.RPush(ctx, key, result) + pipe.Expire(ctx, key, 1*time.Hour) // 信号列表也需要过期 + _, err := pipe.Exec(ctx) + return err +} + +func (m *RedisManager) GetSession(ctx context.Context, taskID string) (string, error) { + key := keyPrefixSession + taskID + return m.rdb.Get(ctx, key).Result() +} diff --git a/internal/domain/component/callback/provider_set.go b/internal/domain/component/callback/provider_set.go new file mode 100644 index 0000000..302b5c1 --- /dev/null +++ b/internal/domain/component/callback/provider_set.go @@ -0,0 +1,5 @@ +package callback + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewRedisManager, wire.Bind(new(Manager), new(*RedisManager))) diff --git a/internal/domain/component/components.go b/internal/domain/component/components.go new file mode 100644 index 0000000..11c8d86 --- /dev/null +++ b/internal/domain/component/components.go @@ -0,0 +1,15 @@ +package component + +import ( + "ai_scheduler/internal/domain/component/callback" +) + +type Components struct { + Callback callback.Manager +} + +func NewComponents(callbackManager callback.Manager) *Components { + return &Components{ + Callback: callbackManager, + } +} diff --git a/internal/domain/component/provider_set.go b/internal/domain/component/provider_set.go new file mode 100644 index 0000000..9d6abe6 --- /dev/null +++ b/internal/domain/component/provider_set.go @@ -0,0 +1,14 @@ +package component + +import ( + "ai_scheduler/internal/domain/component/callback" + + "github.com/google/wire" +) + +var ProviderSetComponent = wire.NewSet(NewComponents) + +var ProviderSet = wire.NewSet( + callback.NewRedisManager, wire.Bind(new(callback.Manager), new(*callback.RedisManager)), + NewComponents, +) diff --git a/internal/domain/repo/adapter.go b/internal/domain/repo/adapter.go new file mode 100644 index 0000000..c9a1357 --- /dev/null +++ b/internal/domain/repo/adapter.go @@ -0,0 +1,29 @@ +package repo + +import ( + "ai_scheduler/internal/data/impl" + "context" + "errors" +) + +// SessionAdapter 适配 impl.SessionImpl 到 SessionRepo 接口 +type SessionAdapter struct { + impl *impl.SessionImpl +} + +func NewSessionAdapter(impl *impl.SessionImpl) *SessionAdapter { + return &SessionAdapter{impl: impl} +} + +func (s *SessionAdapter) GetUserName(ctx context.Context, sessionID string) (string, error) { + // 复用 SessionImpl 的查询能力 + // 这里假设 sessionID 是唯一的,直接用 FindOne + session, has, err := s.impl.FindOne(s.impl.WithSessionId(sessionID)) + if err != nil { + return "", err + } + if !has { + return "", errors.New("session not found") + } + return session.UserName, nil +} diff --git a/internal/domain/repo/provider_set.go b/internal/domain/repo/provider_set.go new file mode 100644 index 0000000..c5b2437 --- /dev/null +++ b/internal/domain/repo/provider_set.go @@ -0,0 +1,5 @@ +package repo + +import "github.com/google/wire" + +var ProviderSet = wire.NewSet(NewRepos) diff --git a/internal/domain/repo/repos.go b/internal/domain/repo/repos.go new file mode 100644 index 0000000..40ba3de --- /dev/null +++ b/internal/domain/repo/repos.go @@ -0,0 +1,17 @@ +package repo + +import ( + "ai_scheduler/internal/data/impl" + "ai_scheduler/utils" +) + +// Repos 聚合所有 Repository +type Repos struct { + Session SessionRepo +} + +func NewRepos(sessionImpl *impl.SessionImpl, rdb *utils.Rdb) *Repos { + return &Repos{ + Session: NewSessionAdapter(sessionImpl), + } +} diff --git a/internal/domain/repo/session.go b/internal/domain/repo/session.go new file mode 100644 index 0000000..5ccc66c --- /dev/null +++ b/internal/domain/repo/session.go @@ -0,0 +1,11 @@ +package repo + +import ( + "context" +) + +// SessionRepo 定义会话相关的查询接口 +// 这里只暴露 workflow 真正需要的方法,避免直接依赖 impl 层 +type SessionRepo interface { + GetUserName(ctx context.Context, sessionID string) (string, error) +} diff --git a/internal/domain/workflow/provider_set.go b/internal/domain/workflow/provider_set.go index 97e1b5d..b9a2815 100644 --- a/internal/domain/workflow/provider_set.go +++ b/internal/domain/workflow/provider_set.go @@ -2,6 +2,8 @@ package workflow import ( "ai_scheduler/internal/config" + "ai_scheduler/internal/domain/component" + "ai_scheduler/internal/domain/repo" "ai_scheduler/internal/domain/workflow/runtime" "ai_scheduler/internal/pkg/utils_ollama" @@ -13,9 +15,15 @@ import ( var ProviderSetWorkflow = wire.NewSet(NewRegistry) // NewRegistry 注入共享依赖并注册默认 Registry,确保自注册工作流可被发现 -func NewRegistry(conf *config.Config, llm *utils_ollama.Client) *runtime.Registry { +func NewRegistry(conf *config.Config, llm *utils_ollama.Client, repos *repo.Repos, components *component.Components) *runtime.Registry { // 步骤1:设置运行时依赖(配置与LLM客户端),供工作流工厂在首次实例化时使用;必须在任何调用 Invoke 之前完成,否则会触发 "deps not set" - runtime.SetDeps(&runtime.Deps{Conf: conf, LLM: llm, ToolManager: toolManager.NewManager(conf)}) + runtime.SetDeps(&runtime.Deps{ + Conf: conf, + LLM: llm, + ToolManager: toolManager.NewManager(conf), + Repos: repos, + Component: components, + }) // 步骤2:创建新的工作流注册表;注册表负责按工作流ID惰性实例化并缓存单例实例,保障并发访问下的安全 r := runtime.NewRegistry() // 步骤3:将该注册表设置为全局默认,便于通过 runtime.Default() 获取;自注册的工作流可通过默认注册表被发现并调用 diff --git a/internal/domain/workflow/registry.go b/internal/domain/workflow/registry.go index af69a03..10b24ef 100644 --- a/internal/domain/workflow/registry.go +++ b/internal/domain/workflow/registry.go @@ -2,6 +2,7 @@ package workflow import ( "ai_scheduler/internal/config" + "ai_scheduler/internal/domain/component" toolManager "ai_scheduler/internal/domain/tools" "ai_scheduler/internal/pkg/utils_ollama" ) @@ -11,4 +12,5 @@ type Deps struct { Conf *config.Config LLM *utils_ollama.Client ToolManager *toolManager.Manager + Component *component.Components } diff --git a/internal/domain/workflow/runtime/registry.go b/internal/domain/workflow/runtime/registry.go index 2b4049b..f804e1d 100644 --- a/internal/domain/workflow/runtime/registry.go +++ b/internal/domain/workflow/runtime/registry.go @@ -2,6 +2,8 @@ package runtime import ( "ai_scheduler/internal/config" + "ai_scheduler/internal/domain/component" + "ai_scheduler/internal/domain/repo" toolManager "ai_scheduler/internal/domain/tools" "ai_scheduler/internal/entitys" "ai_scheduler/internal/pkg/utils_ollama" @@ -20,6 +22,8 @@ type Deps struct { Conf *config.Config LLM *utils_ollama.Client ToolManager *toolManager.Manager + Component *component.Components // 基础设施能力 + Repos *repo.Repos // 数据访问 } type Factory func(deps *Deps) (Workflow, error) diff --git a/internal/domain/workflow/zltx/bug_optimization_submit.go b/internal/domain/workflow/zltx/bug_optimization_submit.go new file mode 100644 index 0000000..30ad0bc --- /dev/null +++ b/internal/domain/workflow/zltx/bug_optimization_submit.go @@ -0,0 +1,170 @@ +package zltx + +import ( + "context" + "encoding/json" + "errors" + "time" + + "ai_scheduler/internal/domain/component/callback" + "ai_scheduler/internal/domain/repo" + "ai_scheduler/internal/domain/workflow/runtime" + "ai_scheduler/internal/entitys" + "ai_scheduler/internal/pkg" + "ai_scheduler/internal/pkg/l_request" + + "github.com/cloudwego/eino/compose" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" +) + +const WorkflowIDBugOptimizationSubmit = "bug_optimization_submit" + +func init() { + runtime.Register(WorkflowIDBugOptimizationSubmit, func(d *runtime.Deps) (runtime.Workflow, error) { + // 从 Deps.Repos 获取 SessionRepo + return &bugOptimizationSubmit{ + manager: d.Component.Callback, + sessionRepo: d.Repos.Session, + }, nil + }) +} + +type bugOptimizationSubmit struct { + manager callback.Manager + sessionRepo repo.SessionRepo + redisCli *redis.Client +} + +func (w *bugOptimizationSubmit) ID() string { + return WorkflowIDBugOptimizationSubmit +} + +type BugOptimizationSubmitInput struct { + Ch chan entitys.Response + RequireData *entitys.Recognize +} + +type BugOptimizationSubmitOutput struct { + Msg string +} + +type contextWithTask struct { + Input *BugOptimizationSubmitInput + TaskID string +} + +func (w *bugOptimizationSubmit) Invoke(ctx context.Context, recognize *entitys.Recognize) (map[string]any, error) { + chain, err := w.buildWorkflow(ctx) + if err != nil { + return nil, err + } + + input := &BugOptimizationSubmitInput{ + Ch: recognize.Ch, + RequireData: recognize, + } + + out, err := chain.Invoke(ctx, input) + if err != nil { + return nil, err + } + + return map[string]any{"msg": out.Msg}, nil +} + +func (w *bugOptimizationSubmit) buildWorkflow(ctx context.Context) (compose.Runnable[*BugOptimizationSubmitInput, *BugOptimizationSubmitOutput], error) { + c := compose.NewChain[*BugOptimizationSubmitInput, *BugOptimizationSubmitOutput]() + + // Node 1: Prepare and Call + c.AppendLambda(compose.InvokableLambda(w.prepareAndCall)) + + // Node 2: Wait + c.AppendLambda(compose.InvokableLambda(w.waitCallback)) + + return c.Compile(ctx) +} + +func (w *bugOptimizationSubmit) prepareAndCall(ctx context.Context, in *BugOptimizationSubmitInput) (*contextWithTask, error) { + // 生成 TaskID + taskID := uuid.New().String() + + // Ext 中获取 sessionId + sessionID := in.RequireData.GetSession() + + // 注册回调映射 + if err := w.manager.Register(ctx, taskID, sessionID); err != nil { + return nil, err + } + + // 查询用户名 + userName := "unknown" + if w.sessionRepo != nil { + name, err := w.sessionRepo.GetUserName(ctx, sessionID) + if err == nil && name != "" { + userName = name + } + } + + // 构建请求参数 + var fileUrls, fileContent string + if len(in.RequireData.UserContent.File) > 0 { + for _, file := range in.RequireData.UserContent.File { + fileUrls += file.FileUrl + "," + fileContent += file.FileRec + "," + } + fileUrls = fileUrls[:len(fileUrls)-1] + fileContent = fileContent[:len(fileContent)-1] + } + + body := map[string]string{ + "mark": in.RequireData.Match.Index, + "text": in.RequireData.UserContent.Text, + "img": fileUrls, + "img_content": fileContent, + "creator": userName, + "task_id": taskID, + } + + request := l_request.Request{ + Url: "https://connector.dingtalk.com/webhook/flow/10352c521dd02104cee9000c", + Method: "POST", + Headers: map[string]string{ + "Content-Type": "application/json", + }, + JsonByte: pkg.JsonByteIgonErr(body), + } + + res, err := request.Send() + if err != nil { + return nil, err + } + + var data map[string]any + if err := json.Unmarshal(res.Content, &data); err != nil { + return nil, err + } + + if success, ok := data["success"].(bool); !ok || !success { + return nil, errors.New("dingtalk flow failed") + } + + entitys.ResLog(in.Ch, in.RequireData.Match.Index, "问题记录中") + entitys.ResLoading(in.Ch, in.RequireData.Match.Index, "问题记录中...") + + return &contextWithTask{Input: in, TaskID: taskID}, nil +} + +func (w *bugOptimizationSubmit) waitCallback(ctx context.Context, in *contextWithTask) (*BugOptimizationSubmitOutput, error) { + // 阻塞等待回调信号 + // 设置 5 分钟超时 + waitCtx, cancel := context.WithTimeout(ctx, 5*time.Minute) + defer cancel() + + res, err := w.manager.Wait(waitCtx, in.TaskID, 5*time.Minute) + if err != nil { + return nil, err + } + + return &BugOptimizationSubmitOutput{Msg: res}, nil +} diff --git a/internal/domain/workflow/zltx/order_after_reseller_batch.go b/internal/domain/workflow/zltx/order_after_reseller_batch.go index ff21d84..eee022a 100644 --- a/internal/domain/workflow/zltx/order_after_reseller_batch.go +++ b/internal/domain/workflow/zltx/order_after_reseller_batch.go @@ -22,8 +22,7 @@ func init() { } type orderAfterSaleResellerBatch struct { - cfg config.ToolConfig - data *OrderAfterSaleResellerBatchWorkflowInput + cfg config.ToolConfig } // 工作流入参 @@ -86,15 +85,19 @@ func (o *orderAfterSaleResellerBatch) Invoke(ctx context.Context, rec *entitys.R return nil, err } - o.data = &OrderAfterSaleResellerBatchWorkflowInput{ + input := &OrderAfterSaleResellerBatchWorkflowInput{ Ch: rec.Ch, UserInput: rec.UserContent.Text, FileContent: "", UserHistory: rec.ChatHis, ParameterResult: rec.Match.Parameters, } + + // 将 Input 注入 Context + ctx = context.WithValue(ctx, workflowInputContextKey{}, input) + // 工作流过程输出,不关注最终输出 - _, err = chain.Invoke(ctx, o.data) + _, err = chain.Invoke(ctx, input) if err != nil { return nil, err } @@ -107,6 +110,9 @@ func (o *orderAfterSaleResellerBatch) Invoke(ctx context.Context, rec *entitys.R var ErrInvalidOrderNumbers = errors.New("orderNumber 不能为空") +// contextKey 用于在 Context 中传递 WorkflowInput +type workflowInputContextKey struct{} + // buildWorkflow 构建工作流 func (o *orderAfterSaleResellerBatch) buildWorkflow(ctx context.Context) (compose.Runnable[*OrderAfterSaleResellerBatchWorkflowInput, *OrderAfterSaleResellerBatchWorkflowOutput], error) { // 定义工作流、出入参 @@ -127,39 +133,93 @@ func (o *orderAfterSaleResellerBatch) buildWorkflow(ctx context.Context) (compos }), )) + // 3.参数校验 & 传递 Input + // 注意:为了在后续节点访问 WorkflowInput,这里使用闭包或 Context 传递。 + // Eino Chain 节点间传递的是返回值。这里我们修改节点签名,将 input 一路传下去,或者使用 context。 + // 由于 Eino Chain 是强类型的,这里选择让 Parser 返回的数据结构包含原始 input,或者我们在 Parser 后重新组合。 + // 但最简单的方法是使用 Context 存储 Input (如果 Eino 支持 Context 传递)。Eino 的 Invoke 接受 ctx。 + // 但 Eino Chain 的设计是数据流驱动。 + // 修正方案:修改中间节点的数据结构,或者使用闭包捕获(但闭包捕获的是 build 时的变量,无法捕获运行时 input)。 + // 正确做法:Chain 的节点入参必须是上一个节点的出参。 + // 我们可以把 Parser 的输入改为 Input,输出改为一个包含 Input 和 ParsedData 的结构。 + // 但这里为了最小改动,我们利用 Context 来传递 Input 引用(这在 Eino 中是可行的,因为 ctx 会贯穿整个 Invoke)。 + // 更好的做法是重构 Chain 的数据流,但在保持逻辑不变的前提下,Context 是最快解法。 + + // 为了线程安全,我们在第一个节点把 Input 放入 Context?不行,Chain.Invoke(ctx, input) 的 ctx 是外部传入的。 + // Eino 允许 Lambda 修改 Context 吗?通常不允许。 + + // 让我们重新审视数据流: + // Input -> Lambda1 -> Message -> Parser -> NodeData -> Lambda4 -> ToolResp -> Lambda5 -> Output + // Lambda4 需要 Input.Ch 来发 Loading。 + // Lambda5 需要 Input.Ch 来发 Log/Json,还需要 NodeData。 + + // 根本问题是:中间节点丢失了 Input 信息。 + // 解决方案:使用一个聚合结构体在 Chain 中传递。 + + // 由于要大改数据流比较复杂,这里使用一种技巧: + // 在 Invoke 时,构造一个带有 Input 信息的 Context 传入。 + // 这样每个节点都能从 Context 拿到 Input。 + + // 重新实现 buildWorkflow 以支持 Context 传递 + return o.buildWorkflowWithContext(ctx) +} + +func (o *orderAfterSaleResellerBatch) buildWorkflowWithContext(ctx context.Context) (compose.Runnable[*OrderAfterSaleResellerBatchWorkflowInput, *OrderAfterSaleResellerBatchWorkflowOutput], error) { + c := compose.NewChain[*OrderAfterSaleResellerBatchWorkflowInput, *OrderAfterSaleResellerBatchWorkflowOutput]() + + // 0. Context 注入节点 (Trick: 利用第一个节点将 Input 注入 Context,但 Eino Chain 无法修改 Context 传递给下游) + // 实际上,我们可以在 Invoke 调用前,在外部包装 Context。 + // 所以这里不需要额外的节点,只需要在 Invoke 时处理。 + // 但 Invoke 是由 Chain 提供的,我们只能控制传入的 ctx。 + // 见下文 Invoke 方法的修改。 + + // 1.llm 推断参数 + c.AppendLambda(compose.InvokableLambda(func(ctx context.Context, in *OrderAfterSaleResellerBatchWorkflowInput) (*schema.Message, error) { + return &schema.Message{Content: in.ParameterResult}, nil + })) + + // 2.参数解析 + c.AppendLambda(compose.MessageParser( + schema.NewMessageJSONParser[*OrderAfterSaleResellerBatchNodeData](&schema.MessageJSONParseConfig{ + ParseFrom: schema.MessageParseFromContent, + }), + )) + // 3.参数校验 - c.AppendLambda(compose.InvokableLambda(func(_ context.Context, in *OrderAfterSaleResellerBatchNodeData) (*OrderAfterSaleResellerBatchNodeData, error) { - // 校验必填项 + c.AppendLambda(compose.InvokableLambda(func(ctx context.Context, in *OrderAfterSaleResellerBatchNodeData) (*OrderAfterSaleResellerBatchNodeData, error) { if len(in.OrderNumber) == 0 { return nil, ErrInvalidOrderNumbers } - - o.data.Data = in - + // 将解析后的 Data 存入 Input (通过 Context 获取 Input) + input := ctx.Value(workflowInputContextKey{}).(*OrderAfterSaleResellerBatchWorkflowInput) + input.Data = in // 这里修改 Input 是安全的,因为 Input 是请求维度的引用 return in, nil })) // 4.工具调用 - c.AppendLambda(compose.InvokableLambda(func(_ context.Context, in *OrderAfterSaleResellerBatchNodeData) (*toolZoarb.OrderAfterSaleResellerBatchResponse, error) { - entitys.ResLoading(o.data.Ch, o.ID(), "数据拉取中") + c.AppendLambda(compose.InvokableLambda(func(ctx context.Context, in *OrderAfterSaleResellerBatchNodeData) (*toolZoarb.OrderAfterSaleResellerBatchResponse, error) { + input := ctx.Value(workflowInputContextKey{}).(*OrderAfterSaleResellerBatchWorkflowInput) + entitys.ResLoading(input.Ch, o.ID(), "数据拉取中") toolRes, err := toolZoarb.Call(ctx, o.cfg, in.OrderNumber) - - entitys.ResLog(o.data.Ch, o.ID(), "数据拉取完成") + entitys.ResLog(input.Ch, o.ID(), "数据拉取完成") return toolRes, err })) // 5.结果数据映射 - c.AppendLambda(compose.InvokableLambda(o.dataMapping)) + c.AppendLambda(compose.InvokableLambda(func(ctx context.Context, in *toolZoarb.OrderAfterSaleResellerBatchResponse) (*OrderAfterSaleResellerBatchWorkflowOutput, error) { + return o.dataMapping(ctx, in) + })) - // 编译工作流 return c.Compile(ctx) } // 结果数据映射 -func (o *orderAfterSaleResellerBatch) dataMapping(_ context.Context, in *toolZoarb.OrderAfterSaleResellerBatchResponse) (*OrderAfterSaleResellerBatchWorkflowOutput, error) { - entitys.ResLog(o.data.Ch, o.ID(), "数据整理中") +func (o *orderAfterSaleResellerBatch) dataMapping(ctx context.Context, in *toolZoarb.OrderAfterSaleResellerBatchResponse) (*OrderAfterSaleResellerBatchWorkflowOutput, error) { + input := ctx.Value(workflowInputContextKey{}).(*OrderAfterSaleResellerBatchWorkflowInput) + + entitys.ResLog(input.Ch, o.ID(), "数据整理中") toolResp := &OrderAfterSaleResellerBatchWorkflowOutput{ Code: in.Code, @@ -170,17 +230,17 @@ func (o *orderAfterSaleResellerBatch) dataMapping(_ context.Context, in *toolZoa // 转换数据 for _, item := range in.Data.Data { // 处理方式 - afterType := util.StringToInt(o.data.Data.AfterType) + afterType := util.StringToInt(input.Data.AfterType) if afterType == 0 { afterType = 1 // 默认退款 } // 费用承担者 - responsibleType := util.StringToInt(o.data.Data.ResponsibleType) + responsibleType := util.StringToInt(input.Data.ResponsibleType) if responsibleType == 0 { responsibleType = 4 // 默认无 } // 售后金额 - afterSalesPrice := util.StringToFloat64(o.data.Data.AfterSalesPrice) + afterSalesPrice := util.StringToFloat64(input.Data.AfterSalesPrice) if afterSalesPrice == 0 { afterSalesPrice = item.OrderPrice } @@ -199,10 +259,10 @@ func (o *orderAfterSaleResellerBatch) dataMapping(_ context.Context, in *toolZoa Account: item.Account, Platforms: item.Platforms, AfterType: afterType, - Remark: o.data.Data.AfterSalesReason, + Remark: input.Data.AfterSalesReason, AfterAmount: afterSalesPrice, ResponsibleType: responsibleType, - ResponsiblePerson: o.data.Data.ResponsiblePerson, + ResponsiblePerson: input.Data.ResponsiblePerson, }) } @@ -215,7 +275,7 @@ func (o *orderAfterSaleResellerBatch) dataMapping(_ context.Context, in *toolZoa } toolRespJson, _ := json.Marshal(toolResp) - entitys.ResJson(o.data.Ch, o.ID(), string(toolRespJson)) + entitys.ResJson(input.Ch, o.ID(), string(toolRespJson)) return toolResp, nil } diff --git a/internal/entitys/recognize.go b/internal/entitys/recognize.go index 87f684c..fcd2fe5 100644 --- a/internal/entitys/recognize.go +++ b/internal/entitys/recognize.go @@ -3,6 +3,7 @@ package entitys import ( "ai_scheduler/internal/data/constants" "ai_scheduler/internal/data/model" + "encoding/json" ) type Recognize struct { @@ -47,3 +48,20 @@ type RecognizeFile struct { FileRealMime string // 文件真实MIME类型 FileUrl string // 文件下载链接 } + +func (r *Recognize) GetTaskExt() *TaskExt { + var ext TaskExt + if err := json.Unmarshal(r.Ext, &ext); err != nil { + return nil + } + + return &ext +} + +func (r *Recognize) GetSession() string { + ext := r.GetTaskExt() + if ext == nil { + return "" + } + return ext.Session +} diff --git a/internal/services/callback.go b/internal/services/callback.go index e224ce3..c68e1c3 100644 --- a/internal/services/callback.go +++ b/internal/services/callback.go @@ -4,6 +4,7 @@ import ( "ai_scheduler/internal/config" "ai_scheduler/internal/data/constants" errorcode "ai_scheduler/internal/data/error" + "ai_scheduler/internal/domain/component/callback" "ai_scheduler/internal/entitys" "ai_scheduler/internal/gateway" "ai_scheduler/internal/pkg" @@ -25,17 +26,17 @@ type CallbackService struct { dingtalkOldClient *dingtalk.OldClient dingtalkContactClient *dingtalk.ContactClient dingtalkNotableClient *dingtalk.NotableClient - callBackTool *tool_callback.CallBackTool + callbackManager callback.Manager } -func NewCallbackService(cfg *config.Config, gateway *gateway.Gateway, dingtalkOldClient *dingtalk.OldClient, dingtalkContactClient *dingtalk.ContactClient, dingtalkNotableClient *dingtalk.NotableClient, callBackTool *tool_callback.CallBackTool) *CallbackService { +func NewCallbackService(cfg *config.Config, gateway *gateway.Gateway, dingtalkOldClient *dingtalk.OldClient, dingtalkContactClient *dingtalk.ContactClient, dingtalkNotableClient *dingtalk.NotableClient, callbackManager callback.Manager) *CallbackService { return &CallbackService{ cfg: cfg, gateway: gateway, dingtalkOldClient: dingtalkOldClient, dingtalkContactClient: dingtalkContactClient, dingtalkNotableClient: dingtalkNotableClient, - callBackTool: callBackTool, + callbackManager: callbackManager, } } @@ -139,11 +140,14 @@ func (s *CallbackService) Callback(c *fiber.Ctx) error { func (s *CallbackService) handleDingTalkCallback(c *fiber.Ctx, env Envelope) error { // 校验taskId - sessionID, ok := s.callBackTool.GetSessionByTaskID(env.TaskID) - if !ok { + ctx := c.Context() + sessionID, err := s.callbackManager.GetSession(ctx, env.TaskID) + if err != nil { + return errorcode.ParamErrf("failed to get session for task_id: %s, err: %v", env.TaskID, err) + } + if sessionID == "" { return errorcode.ParamErrf("missing session_id for task_id: %s", env.TaskID) } - ctx := c.Context() switch env.Action { case ActionBugOptimizationSubmitUpdate: @@ -166,8 +170,10 @@ func (s *CallbackService) handleDingTalkCallback(c *fiber.Ctx, env Envelope) err // 发送日志 s.sendStreamTxt(sessionID, msg) - // 删除映射 - s.callBackTool.DelTaskMapping(env.TaskID) + // 通知等待者 + if err := s.callbackManager.Notify(ctx, env.TaskID, msg); err != nil { + // 记录错误但继续 + } return c.JSON(fiber.Map{"code": 0, "message": "ok"}) case ActionBugOptimizationSubmitProcess: