feat: 调整货易通创建商品工作流

This commit is contained in:
fuzhongyun 2025-12-24 16:51:46 +08:00
parent 208f749483
commit 8a626b3b58
6 changed files with 183 additions and 165 deletions

View File

@ -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"

View File

@ -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:

View File

@ -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"], // 商品电脑端主图,取第一张
}`
)

View File

@ -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
}

View File

@ -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
}

View File

@ -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)