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] =?UTF-8?q?feat:=20=E8=B0=83=E6=95=B4=E8=B4=A7=E6=98=93?= =?UTF-8?q?=E9=80=9A=E5=88=9B=E5=BB=BA=E5=95=86=E5=93=81=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=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)