diff --git a/internal/config/config.go b/internal/config/config.go index 7e94bfd..add63a1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -209,6 +209,8 @@ type EinoToolsConfig struct { DaOfficialProductDecline ToolConfig `mapstructure:"daOfficialProductDecline"` // 我们的商品统计 RechargeStatisticsOursProduct ToolConfig `mapstructure:"rechargeStatisticsOursProduct"` + // Excel 转图片 + Excel2Pic ToolConfig `mapstructure:"excel2Pic"` } // LoggingConfig 日志配置 diff --git a/internal/domain/repo/repos.go b/internal/domain/repo/repos.go index 40ba3de..a3250ba 100644 --- a/internal/domain/repo/repos.go +++ b/internal/domain/repo/repos.go @@ -1,17 +1,21 @@ package repo import ( + "ai_scheduler/internal/config" "ai_scheduler/internal/data/impl" - "ai_scheduler/utils" + "ai_scheduler/internal/pkg/oss" ) // Repos 聚合所有 Repository type Repos struct { - Session SessionRepo + Session SessionRepo + OssClient *oss.Client } -func NewRepos(sessionImpl *impl.SessionImpl, rdb *utils.Rdb) *Repos { +func NewRepos(sessionImpl *impl.SessionImpl, cfg *config.Config) *Repos { + ossClient, _ := oss.NewClient(cfg.Oss) return &Repos{ - Session: NewSessionAdapter(sessionImpl), + Session: NewSessionAdapter(sessionImpl), + OssClient: ossClient, } } diff --git a/internal/domain/tools/common/excel_generator/client.go b/internal/domain/tools/common/excel_generator/client.go new file mode 100644 index 0000000..eae9451 --- /dev/null +++ b/internal/domain/tools/common/excel_generator/client.go @@ -0,0 +1,76 @@ +package excel_generator + +import ( + "fmt" + + "github.com/go-kratos/kratos/v2/log" + "github.com/xuri/excelize/v2" +) + +// Client Excel 生成器 +type Client struct{} + +func New() *Client { + return &Client{} +} + +// Call 根据模板和数据生成 Excel 字节流 +// templatePath: 模板文件路径 +// data: 二维字符串数组,不再使用反射 +// startRow: 数据填充起始行 (默认 2) +// styleRow: 样式参考行 (默认 2) +func (g *Client) Call(templatePath string, data [][]string, startRow int, styleRow int) ([]byte, error) { + if startRow <= 0 { + startRow = 2 + } + if styleRow <= 0 { + styleRow = 2 + } + + f, err := excelize.OpenFile(templatePath) + if err != nil { + return nil, err + } + defer f.Close() + + sheet := f.GetSheetName(0) + + // 获取样式和行高 + styleID, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", styleRow)) + if err != nil { + log.Errorf("获取样式失败: %v", err) + styleID = 0 + } + rowHeight, err := f.GetRowHeight(sheet, styleRow) + if err != nil { + log.Errorf("获取行高失败: %v", err) + rowHeight = 31 // 默认高度 + } + + row := startRow + for i, item := range data { + currentRow := row + i + + // 设置行高 + f.SetRowHeight(sheet, currentRow, rowHeight) + + // 填充数据 + for col, value := range item { + cell := fmt.Sprintf("%c%d", 'A'+col, currentRow) + f.SetCellValue(sheet, cell, value) + } + + // 设置样式 + if styleID != 0 { + endCol := 'A' + len(item) - 1 + f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("%c%d", endCol, currentRow), styleID) + } + } + + buf, err := f.WriteToBuffer() + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} diff --git a/internal/domain/tools/common/image_converter/client.go b/internal/domain/tools/common/image_converter/client.go new file mode 100644 index 0000000..5eba199 --- /dev/null +++ b/internal/domain/tools/common/image_converter/client.go @@ -0,0 +1,58 @@ +package image_converter + +import ( + "ai_scheduler/internal/config" + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" +) + +// Client 图片转换器 +type Client struct { + cfg config.ToolConfig +} + +func New(cfg config.ToolConfig) *Client { + return &Client{ + cfg: cfg, + } +} + +// Call 将 Excel 文件转换为图片 +func (c *Client) Call(filename string, fileBytes []byte) ([]byte, error) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + part, err := writer.CreateFormFile("file", filename) + if err != nil { + return nil, err + } + if _, err = io.Copy(part, bytes.NewReader(fileBytes)); err != nil { + return nil, err + } + + if err = writer.Close(); err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", c.cfg.BaseURL, body) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("excel2pic service returned status: %s", resp.Status) + } + + return io.ReadAll(resp.Body) +} diff --git a/internal/domain/tools/registry.go b/internal/domain/tools/registry.go index ad9439d..f6b2193 100644 --- a/internal/domain/tools/registry.go +++ b/internal/domain/tools/registry.go @@ -2,6 +2,8 @@ package tools import ( "ai_scheduler/internal/config" + "ai_scheduler/internal/domain/tools/common/excel_generator" + "ai_scheduler/internal/domain/tools/common/image_converter" "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" @@ -10,13 +12,21 @@ import ( "ai_scheduler/internal/domain/tools/hyt/product_upload" "ai_scheduler/internal/domain/tools/hyt/supplier_search" "ai_scheduler/internal/domain/tools/hyt/warehouse_search" + "ai_scheduler/internal/domain/tools/recharge/statistics_ours_product" ) type Manager struct { - Hyt *HytTools + Hyt *HytTools + Recharge *RechargeTools + Common *CommonTools // Zltx *ZltxTools } +type CommonTools struct { + ExcelGenerator *excel_generator.Client + ImageConverter *image_converter.Client +} + type HytTools struct { ProductUpload *product_upload.Client SupplierSearch *supplier_search.Client @@ -28,6 +38,10 @@ type HytTools struct { GoodsBrandSearch *goods_brand_search.Client } +type RechargeTools struct { + StatisticsOursProduct *statistics_ours_product.Client +} + func NewManager(cfg *config.Config) *Manager { return &Manager{ Hyt: &HytTools{ @@ -40,5 +54,12 @@ func NewManager(cfg *config.Config) *Manager { GoodsCategorySearch: goods_category_search.New(cfg.EinoTools.HytGoodsCategorySearch), GoodsBrandSearch: goods_brand_search.New(cfg.EinoTools.HytGoodsBrandSearch), }, + Recharge: &RechargeTools{ + StatisticsOursProduct: statistics_ours_product.New(cfg.EinoTools.RechargeStatisticsOursProduct), + }, + Common: &CommonTools{ + ExcelGenerator: excel_generator.New(), + ImageConverter: image_converter.New(cfg.EinoTools.Excel2Pic), + }, } } diff --git a/internal/domain/workflow/recharge/statistics_ours_product.go b/internal/domain/workflow/recharge/statistics_ours_product.go index 0a7649d..91d5092 100644 --- a/internal/domain/workflow/recharge/statistics_ours_product.go +++ b/internal/domain/workflow/recharge/statistics_ours_product.go @@ -2,32 +2,145 @@ package recharge import ( "ai_scheduler/internal/config" + errorcode "ai_scheduler/internal/data/error" toolManager "ai_scheduler/internal/domain/tools" + "ai_scheduler/internal/domain/tools/recharge/statistics_ours_product" "ai_scheduler/internal/domain/workflow/runtime" "ai_scheduler/internal/entitys" + "ai_scheduler/internal/pkg/oss" "context" + "errors" + "fmt" + "path/filepath" + "time" + + "github.com/cloudwego/eino/compose" ) const WorkflowIDStatisticsOursProduct = "recharge.statisticsOursProduct" func init() { runtime.Register(WorkflowIDStatisticsOursProduct, func(d *runtime.Deps) (runtime.Workflow, error) { - return &statisticsOursProduct{cfg: d.Conf, toolManager: d.ToolManager}, nil + return &statisticsOursProduct{cfg: d.Conf, toolManager: d.ToolManager, ossClient: d.Repos.OssClient}, nil }) } type statisticsOursProduct struct { cfg *config.Config toolManager *toolManager.Manager + ossClient *oss.Client } type StatisticsOursProductWorkflowInput struct { - // 预留字段 + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` +} + +type StatisticsOursProductWorkflowOutput struct { + ImgUrl string `json:"img_url"` } func (w *statisticsOursProduct) ID() string { return WorkflowIDStatisticsOursProduct } func (w *statisticsOursProduct) Invoke(ctx context.Context, rec *entitys.Recognize) (map[string]any, error) { - // 保持流程为空,仅返回 nil - return nil, nil + // 构建工作流 + runnable, err := w.buildWorkflow(ctx) + if err != nil { + return nil, err + } + + // 解析参数 (假设参数在 rec.Match.Parameters 中,或者根据实际情况解析) + // 这里简化处理,假设需要解析参数 + // 实际上这里应该根据 LLM 解析的结果来填充 Input + // 暂时假设 ParameterResult 是 JSON 字符串 + input := &StatisticsOursProductWorkflowInput{ + // 默认值,具体应从 rec 解析 + StartTime: time.Now().Format("2006010200"), + EndTime: time.Now().Format("2006010223"), + } + + // 工作流过程调用 + output, err := runnable.Invoke(ctx, input) + if err != nil { + fmt.Println("Invoke err:", err) + errStr := err.Error() + if u := errors.Unwrap(err); u != nil { + errStr = u.Error() + } + return nil, errorcode.WorkflowErr(errStr) + } + + return map[string]any{"img_url": output.ImgUrl}, nil +} + +func (w *statisticsOursProduct) buildWorkflow(ctx context.Context) (compose.Runnable[*StatisticsOursProductWorkflowInput, *StatisticsOursProductWorkflowOutput], error) { + c := compose.NewChain[*StatisticsOursProductWorkflowInput, *StatisticsOursProductWorkflowOutput]() + + // 1. 调用工具统计我们的商品 + c.AppendLambda(compose.InvokableLambda(w.callStatisticsTool)) + + // 2. 生成 Excel 并转图片上传 + c.AppendLambda(compose.InvokableLambda(w.generateExcelAndUpload)) + + return c.Compile(ctx) +} + +func (w *statisticsOursProduct) callStatisticsTool(ctx context.Context, input *StatisticsOursProductWorkflowInput) ([]statistics_ours_product.StatisticsOursProductItem, error) { + req := statistics_ours_product.StatisticsOursProductRequest{ + Page: 1, + Limit: 100, // 假设取前100条 + Serial: []string{input.StartTime, input.EndTime}, + } + + return w.toolManager.Recharge.StatisticsOursProduct.Call(ctx, req) +} + +func (w *statisticsOursProduct) generateExcelAndUpload(ctx context.Context, data []statistics_ours_product.StatisticsOursProductItem) (*StatisticsOursProductWorkflowOutput, error) { + // 2. 获取模板路径 (假设在项目根目录的 assets/templates 下) + cwd, _ := filepath.Abs(".") + templatePath := filepath.Join(cwd, "assets", "templates", "statistics_ours_product.xlsx") + fileName := fmt.Sprintf("statistics_ours_product_%d", time.Now().Unix()) + + // 3. 转换数据为 [][]string + excelData := w.convertDataToExcelFormat(data) + + // 4. 生成 Excel + excelBytes, err := w.toolManager.Common.ExcelGenerator.Call(templatePath, excelData, 2, 2) + if err != nil { + return nil, fmt.Errorf("生成 Excel 失败: %v", err) + } + + // 5. Excel 转图片 + picBytes, err := w.toolManager.Common.ImageConverter.Call(fileName+".xlsx", excelBytes) + if err != nil { + return nil, fmt.Errorf("Excel 转图片失败: %v", err) + } + + // 6. 上传 OSS + url, err := w.ossClient.UploadBytes(fileName+".png", picBytes) + if err != nil { + return nil, fmt.Errorf("上传 OSS 失败: %v", err) + } + + return &StatisticsOursProductWorkflowOutput{ImgUrl: url}, nil +} + +// convertDataToExcelFormat 将业务数据转换为 Excel 生成器需要的二维字符串数组 +func (w *statisticsOursProduct) convertDataToExcelFormat(data []statistics_ours_product.StatisticsOursProductItem) [][]string { + var result [][]string + for _, item := range data { + row := []string{ + item.OursProductName, + fmt.Sprintf("%d", item.OursProductId), + item.Count, + item.TotalPrice, + item.SuccessCount, + item.SuccessPrice, + item.FailCount, + item.FailPrice, + item.Profit, + } + result = append(result, row) + } + return result } diff --git a/internal/services/dtalk_bot_test.go b/internal/services/dtalk_bot_test.go index 2b8223f..6180016 100644 --- a/internal/services/dtalk_bot_test.go +++ b/internal/services/dtalk_bot_test.go @@ -60,7 +60,7 @@ func run() { // 初始化Redis数据库连接 rdb := utils.NewRdb(configConfig) // 初始化仓库层 - repos := repo.NewRepos(sessionImpl, rdb) + repos := repo.NewRepos(sessionImpl, configConfig) // 初始化包级别的Redis连接 pkgRdb := pkg.NewRdb(configConfig) diff --git a/tmpl/excel_temp/recharge_statistics_ours_product.xlsx b/tmpl/excel_temp/recharge_statistics_ours_product.xlsx new file mode 100755 index 0000000..8d5b5a3 Binary files /dev/null and b/tmpl/excel_temp/recharge_statistics_ours_product.xlsx differ