From f6b5d71a05dcbd82872a396478d5f4cee51949ce Mon Sep 17 00:00:00 2001 From: renzhiyuan <465386466@qq.com> Date: Mon, 29 Dec 2025 21:11:34 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=E9=87=8D=E6=9E=84=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E7=BB=93=E6=9E=84=EF=BC=8C=E4=BC=98=E5=8C=96=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/server/main.go | 7 +- go.mod | 4 +- go.sum | 6 + internal/biz/ding_talk_bot.go | 18 ++ .../biz/handle/dingtalk/send_card.go.bak1 | 280 ------------------ internal/config/config.go | 32 +- internal/pkg/channel_pool.go | 75 ----- internal/pkg/func.go | 60 ---- internal/pkg/provider_set.go | 2 +- internal/server/cron.go | 93 ++++++ internal/server/provider_set.go | 1 + internal/server/server.go | 10 +- internal/services/cron.go | 43 +++ internal/services/dtalk_bot.go | 22 ++ internal/services/dtalk_bot.go.bak | 130 -------- internal/services/dtalk_bot_test.go | 105 +++++++ internal/services/provider_set.go | 1 + internal/tools/bbxt/bbxt.go | 4 +- internal/tools/bbxt/bbxt_test.go | 11 +- pkg/func.go | 65 ++++ 20 files changed, 404 insertions(+), 565 deletions(-) delete mode 100644 internal/biz/handle/dingtalk/send_card.go.bak1 delete mode 100644 internal/pkg/channel_pool.go create mode 100644 internal/server/cron.go create mode 100644 internal/services/cron.go delete mode 100644 internal/services/dtalk_bot.go.bak create mode 100644 internal/services/dtalk_bot_test.go create mode 100644 pkg/func.go diff --git a/cmd/server/main.go b/cmd/server/main.go index d765735..baca30d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -13,6 +13,7 @@ func main() { configPath := flag.String("config", "./config/config_test.yaml", "Path to configuration file") onBot := flag.String("bot", "", "bot start") flag.Parse() + ctx := context.Background() bc, err := config.LoadConfig(*configPath) if err != nil { log.Fatalf("加载配置失败: %v", err) @@ -25,7 +26,9 @@ func main() { defer func() { cleanup() }() - app.DingBotServer.Run(context.Background(), *onBot) - + //钉钉机器人 + app.DingBotServer.Run(ctx, *onBot) + //定时任务 + app.Cron.Run(ctx) log.Fatal(app.HttpServer.Listen(fmt.Sprintf(":%d", bc.Server.Port))) } diff --git a/go.mod b/go.mod index 694ee17..fa8498d 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/faabiosr/cachego v0.26.0 github.com/fastwego/dingding v1.0.0-beta.4 github.com/gabriel-vasile/mimetype v1.4.11 - github.com/go-kratos/kratos/v2 v2.9.1 + github.com/go-kratos/kratos/v2 v2.9.2 github.com/go-playground/locales v0.14.1 github.com/go-playground/universal-translator v0.18.1 github.com/go-playground/validator/v10 v10.20.0 @@ -39,6 +39,7 @@ require ( ) require ( + dario.cat/mergo v1.0.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect github.com/alibabacloud-go/debug v1.0.1 // indirect @@ -90,6 +91,7 @@ require ( github.com/richardlehane/mscfb v1.0.4 // indirect github.com/richardlehane/msoleps v1.0.4 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/sagikazarmark/locafero v0.3.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect diff --git a/go.sum b/go.sum index b753e6a..b0d2c51 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,8 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= @@ -193,6 +195,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-kratos/kratos/v2 v2.9.1 h1:EGif6/S/aK/RCR5clIbyhioTNyoSrii3FC118jG40Z0= github.com/go-kratos/kratos/v2 v2.9.1/go.mod h1:a1MQLjMhIh7R0kcJS9SzJYR43BRI7EPzzN0J1Ksu2bA= +github.com/go-kratos/kratos/v2 v2.9.2 h1:px8GJQBeLpquDKQWQ9zohEWiLA8n4D/pv7aH3asvUvo= +github.com/go-kratos/kratos/v2 v2.9.2/go.mod h1:Jc7jaeYd4RAPjetun2C+oFAOO7HNMHTT/Z4LxpuEDJM= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -375,6 +379,8 @@ github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= diff --git a/internal/biz/ding_talk_bot.go b/internal/biz/ding_talk_bot.go index b8976df..0cc4090 100644 --- a/internal/biz/ding_talk_bot.go +++ b/internal/biz/ding_talk_bot.go @@ -470,6 +470,24 @@ func (d *DingTalkBotBiz) HandleStreamRes(ctx context.Context, data *chatbot.BotC SenderStaffId: data.SenderStaffId, Title: data.Text.Content, }) + return +} + +func (d *DingTalkBotBiz) GetReportLists(ctx context.Context) (contentChan chan string, err error) { + contentChan = make(chan string, 10) + defer close(contentChan) + contentChan <- "截止今日23点利润亏损合计:127917.0866元,亏损500元以上的分销商和产品金额如下图:" + contentChan <- "![图片](https://lsxdmgoss.oss-cn-chengdu.aliyuncs.com/MarketingSaaS/image/V2/other/shanghu.png)" + + return +} + +func (d *DingTalkBotBiz) GetGroupInfo(ctx context.Context, groupId int) (group model.AiBotGroup, err error) { + + cond := builder.NewCond() + cond = cond.And(builder.Eq{"group_id": groupId}) + cond = cond.And(builder.Eq{"status": constants.Enable}) + err = d.botGroupImpl.GetOneBySearchToStrut(&cond, &group) return } diff --git a/internal/biz/handle/dingtalk/send_card.go.bak1 b/internal/biz/handle/dingtalk/send_card.go.bak1 deleted file mode 100644 index 9fb1e8d..0000000 --- a/internal/biz/handle/dingtalk/send_card.go.bak1 +++ /dev/null @@ -1,280 +0,0 @@ -package dingtalk - -import ( - "ai_scheduler/internal/data/constants" - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "sync" - "time" - - openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" - dingtalkcard_1_0 "github.com/alibabacloud-go/dingtalk/card_1_0" - dingtalkim_1_0 "github.com/alibabacloud-go/dingtalk/im_1_0" - util "github.com/alibabacloud-go/tea-utils/v2/service" - "github.com/alibabacloud-go/tea/tea" - "github.com/gofiber/fiber/v2/log" - "github.com/google/uuid" -) - -const DefaultInterval = 100 * time.Millisecond -const HeardBeatX = 50 - -type SendCardClient struct { - Auth *Auth - CardClient *sync.Map - mu sync.RWMutex // 保护 CardClient 的并发访问 - logger log.AllLogger // 日志记录 - botOption *Bot -} - -func NewSendCardClient(auth *Auth, logger log.AllLogger) *SendCardClient { - return &SendCardClient{ - Auth: auth, - CardClient: &sync.Map{}, - logger: logger, - botOption: &Bot{}, - } -} - -// initClient 初始化或复用 DingTalk 客户端 -func (s *SendCardClient) initClient(robotCode string) (*dingtalkcard_1_0.Client, error) { - if client, ok := s.CardClient.Load(robotCode); ok { - return client.(*dingtalkcard_1_0.Client), nil - } - s.botOption.BotCode = robotCode - config := &openapi.Config{ - Protocol: tea.String("https"), - RegionId: tea.String("central"), - } - client, err := dingtalkcard_1_0.NewClient(config) - if err != nil { - s.logger.Error("failed to init DingTalk client") - return nil, fmt.Errorf("init client failed: %w", err) - } - - s.CardClient.Store(robotCode, client) - return client, nil -} - -func (s *SendCardClient) NewCard(ctx context.Context, cardSend *CardSend) error { - // 参数校验 - if (len(cardSend.ContentSlice) == 0 || cardSend.ContentSlice == nil) && cardSend.ContentChannel == nil { - return errors.New("卡片内容不能为空") - } - if cardSend.UpdateInterval == 0 { - cardSend.UpdateInterval = DefaultInterval // 默认更新间隔 - } - if cardSend.Title == "" { - cardSend.Title = "钉钉卡片" - } - //替换标题 - cardSend.Template = constants.CardTemp(strings.Replace(string(cardSend.Template), "${title}", cardSend.Title, 1)) - // 初始化客户端 - client, err := s.initClient(cardSend.RobotCode) - if err != nil { - return fmt.Errorf("初始化client失败: %w", err) - } - - // 生成卡片实例ID - cardInstanceId, err := uuid.NewUUID() - if err != nil { - return fmt.Errorf("创建uuid失败: %w", err) - } - - // 构建初始请求 - request, err := s.buildBaseRequest(cardSend, cardInstanceId.String()) - if err != nil { - return fmt.Errorf("请求失败: %w", err) - } - - // 发送初始卡片 - if _, err := s.SendInteractiveCard(ctx, request, cardSend.RobotCode, client); err != nil { - return fmt.Errorf("发送初始卡片失败: %w", err) - } - - // 处理切片内容(同步) - if len(cardSend.ContentSlice) > 0 { - if err := s.processContentSlice(ctx, cardSend, cardInstanceId.String(), client); err != nil { - return fmt.Errorf("内容同步失败: %w", err) - } - } - - // 处理通道内容(异步) - if cardSend.ContentChannel != nil { - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - s.processContentChannel(ctx, cardSend, cardInstanceId.String(), client) - }() - wg.Wait() - } - - return nil -} - -// buildBaseRequest 构建基础请求 -func (s *SendCardClient) buildBaseRequest(cardSend *CardSend, cardInstanceId string) (*dingtalkcard_1_0.StreamingUpdateRequest, error) { - cardData := fmt.Sprintf(string(cardSend.Template), "") // 初始空内容 - request := &dingtalkcard_1_0.StreamingUpdateRequest{ - OutTrackId: tea.String("your-out-track-id"), - Guid: tea.String("0F714542-0AFC-2B0E-CF14-E2D39F5BFFE8"), - Key: tea.String("your-ai-param"), - Content: tea.String("test"), - IsFull: tea.Bool(false), - IsFinalize: tea.Bool(false), - IsError: tea.Bool(false), - } - - switch cardSend.ConversationType { - case constants.ConversationTypeGroup: - request.SetOpenConversationId(cardSend.ConversationId) - case constants.ConversationTypeSingle: - receiver, err := json.Marshal(map[string]string{"userId": cardSend.SenderStaffId}) - if err != nil { - return nil, fmt.Errorf("数据整理失败: %w", err) - } - request.SetSingleChatReceiver(string(receiver)) - default: - return nil, errors.New("未知的聊天场景") - } - - return request, nil -} - -// processContentChannel 处理通道内容(异步更新) -func (s *SendCardClient) processContentChannel(ctx context.Context, cardSend *CardSend, cardInstanceId string, client *dingtalkim_1_0.Client) { - defer func() { - if r := recover(); r != nil { - s.logger.Error("panic in processContentChannel") - } - }() - - ticker := time.NewTicker(cardSend.UpdateInterval) - defer ticker.Stop() - heartbeatTicker := time.NewTicker(time.Duration(HeardBeatX) * DefaultInterval) - defer heartbeatTicker.Stop() - - var ( - contentBuilder strings.Builder - lastUpdate time.Time - ) - for { - - select { - case content, ok := <-cardSend.ContentChannel: - if !ok { - // 通道关闭,发送最终内容 - if contentBuilder.Len() > 0 { - if err := s.updateCardContent(ctx, cardSend, cardInstanceId, contentBuilder.String(), client); err != nil { - s.logger.Errorf("更新卡片失败1:%s", err.Error()) - } - } - return - } - contentBuilder.WriteString(content) - if contentBuilder.Len() > 0 { - if err := s.updateCardContent(ctx, cardSend, cardInstanceId, contentBuilder.String(), client); err != nil { - s.logger.Errorf("更新卡片失败2:%s", err.Error()) - } - } - lastUpdate = time.Now() - - case <-heartbeatTicker.C: - if time.Now().Unix()-lastUpdate.Unix() >= HeardBeatX { - return - } - - case <-ctx.Done(): - s.logger.Info("context canceled, stop channel processing") - return - } - } - -} - -// processContentSlice 处理切片内容(同步更新) -func (s *SendCardClient) processContentSlice(ctx context.Context, cardSend *CardSend, cardInstanceId string, client *dingtalkim_1_0.Client) error { - var contentBuilder strings.Builder - for _, content := range cardSend.ContentSlice { - contentBuilder.WriteString(content) - err := s.updateCardRequest(ctx, &UpdateCardRequest{ - Template: string(cardSend.Template), - Content: contentBuilder.String(), - Client: client, - RobotCode: cardSend.RobotCode, - CardInstanceId: cardInstanceId, - }) - if err != nil { - return fmt.Errorf("更新卡片失败: %w", err) - } - time.Sleep(cardSend.UpdateInterval) // 控制更新频率 - } - return nil -} - -// updateCardContent 封装卡片更新逻辑 -func (s *SendCardClient) updateCardContent(ctx context.Context, cardSend *CardSend, cardInstanceId, content string, client *dingtalkim_1_0.Client) error { - err := s.updateCardRequest(ctx, &UpdateCardRequest{ - Template: string(cardSend.Template), - Content: content, - Client: client, - RobotCode: cardSend.RobotCode, - CardInstanceId: cardInstanceId, - }) - - return err -} - -func (s *SendCardClient) updateCardRequest(ctx context.Context, updateCardRequest *UpdateCardRequest) error { - - updateRequest := &dingtalkim_1_0.UpdateRobotInteractiveCardRequest{ - CardBizId: tea.String(updateCardRequest.CardInstanceId), - CardData: tea.String(fmt.Sprintf(updateCardRequest.Template, updateCardRequest.Content)), - } - _, err := s.UpdateInteractiveCard(ctx, updateRequest, updateCardRequest.RobotCode, updateCardRequest.Client) - return err -} - -// UpdateInteractiveCard 更新交互卡片(封装错误处理) -func (s *SendCardClient) UpdateInteractiveCard(ctx context.Context, request *dingtalkim_1_0.UpdateRobotInteractiveCardRequest, robotCode string, client *dingtalkim_1_0.Client) (*dingtalkim_1_0.UpdateRobotInteractiveCardResponse, error) { - authInfo, err := s.Auth.GetTokenFromBotOption(ctx, WithBot(s.botOption)) - if err != nil { - return nil, fmt.Errorf("get token failed: %w", err) - } - - headers := &dingtalkim_1_0.UpdateRobotInteractiveCardHeaders{ - XAcsDingtalkAccessToken: tea.String(authInfo.AccessToken), - } - - response, err := client.UpdateRobotInteractiveCardWithOptions(request, headers, &util.RuntimeOptions{}) - if err != nil { - return nil, fmt.Errorf("API call failed: %w,request:%v", err, request.String()) - } - return response, nil -} - -// SendInteractiveCard 发送交互卡片(封装错误处理) -func (s *SendCardClient) SendInteractiveCard(ctx context.Context, request *dingtalkim_1_0.SendRobotInteractiveCardRequest, robotCode string, client *dingtalkim_1_0.Client) (res *dingtalkim_1_0.SendRobotInteractiveCardResponse, err error) { - err = s.Auth.GetBotConfigFromModel(s.botOption) - if err != nil { - return nil, fmt.Errorf("初始化bot失败: %w", err) - } - authInfo, err := s.Auth.GetTokenFromBotOption(ctx, WithBot(s.botOption)) - if err != nil { - return nil, fmt.Errorf("get token failed: %w", err) - } - - headers := &dingtalkim_1_0.SendRobotInteractiveCardHeaders{ - XAcsDingtalkAccessToken: tea.String(authInfo.AccessToken), - } - - response, err := client.SendRobotInteractiveCardWithOptions(request, headers, &util.RuntimeOptions{}) - if err != nil { - return nil, fmt.Errorf("API call failed: %w", err) - } - return response, nil -} diff --git a/internal/config/config.go b/internal/config/config.go index 441d09d..35f2115 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,10 +1,10 @@ package config import ( + "ai_scheduler/pkg" "fmt" - "time" - "github.com/spf13/viper" + "time" ) // Config 应用配置 @@ -222,10 +222,32 @@ func LoadConfig(configPath string) (*Config, error) { } // 解析配置 - var config Config - if err := viper.Unmarshal(&config); err != nil { + var bc Config + if err := viper.Unmarshal(&bc); err != nil { return nil, fmt.Errorf("failed to unmarshal config: %w", err) } - return &config, nil + return &bc, nil +} + +func LoadConfigWithTest() (*Config, error) { + var bc Config + modularDir, err := pkg.GetModuleDir() + if err != nil { + return nil, err + } + viper.SetConfigFile(modularDir + "/config/config_test.yaml") + viper.SetConfigType("yaml") + // 读取配置文件 + if err := viper.ReadInConfig(); err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + // 解析配置 + + if err := viper.Unmarshal(&bc); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + return &bc, nil + } diff --git a/internal/pkg/channel_pool.go b/internal/pkg/channel_pool.go deleted file mode 100644 index eda85fa..0000000 --- a/internal/pkg/channel_pool.go +++ /dev/null @@ -1,75 +0,0 @@ -package pkg - -import ( - "ai_scheduler/internal/config" - "ai_scheduler/internal/entitys" - "sync" -) - -type SafeChannelPool struct { - pool chan chan entitys.ResponseData // 存储空闲 channel 的队列 - bufSize int // channel 缓冲大小 - mu sync.Mutex - closed bool -} - -func NewSafeChannelPool(c *config.Config) (*SafeChannelPool, func()) { - pool := &SafeChannelPool{ - pool: make(chan chan entitys.ResponseData, c.Sys.ChannelPoolLen), - bufSize: c.Sys.ChannelPoolSize, - } - - cleanup := pool.Close - return pool, cleanup -} - -// 从池中获取 channel(若无空闲则创建新 channel) -func (p *SafeChannelPool) Get() chan entitys.ResponseData { - p.mu.Lock() - defer p.mu.Unlock() - - if p.closed { - return make(chan entitys.ResponseData, p.bufSize) - } - - select { - case ch := <-p.pool: // 从池中取 - return ch - default: // 池为空,创建新 channel - return make(chan entitys.ResponseData, p.bufSize) - } -} - -// 将 channel 放回池中(必须确保 channel 已清空!) -func (p *SafeChannelPool) Put(ch chan entitys.ResponseData) { - p.mu.Lock() - defer p.mu.Unlock() - - if p.closed { - return - } - - // 清空 channel(防止复用时读取旧数据) - go func() { - for range ch { - // 丢弃所有数据(或根据业务需求处理) - } - }() - - select { - case p.pool <- ch: // 尝试放回池中 - default: // 池已满,直接关闭 channel(避免泄漏) - close(ch) - } - return -} - -// 关闭池(释放所有资源) -func (p *SafeChannelPool) Close() { - p.mu.Lock() - defer p.mu.Unlock() - - p.closed = true - close(p.pool) // 关闭池队列 - // 需额外逻辑关闭所有内部 channel(此处简化) -} diff --git a/internal/pkg/func.go b/internal/pkg/func.go index d07b202..4d9232b 100644 --- a/internal/pkg/func.go +++ b/internal/pkg/func.go @@ -6,8 +6,6 @@ import ( "errors" "fmt" "net/url" - "os" - "path/filepath" "reflect" "strconv" "strings" @@ -197,64 +195,6 @@ func StructToMapUsingJsoniter(obj interface{}) (map[string]string, error) { return result, nil } -func GetModuleDir() (string, error) { - dir, err := os.Getwd() - if err != nil { - return "", err - } - - for { - modPath := filepath.Join(dir, "go.mod") - if _, err := os.Stat(modPath); err == nil { - return dir, nil // 找到 go.mod - } - - // 向上查找父目录 - parent := filepath.Dir(dir) - if parent == dir { - break // 到达根目录,未找到 - } - dir = parent - } - - return "", fmt.Errorf("go.mod not found in current directory or parents") -} - -// GetCacheDir 用于获取缓存目录路径 -// 如果缓存目录不存在,则会自动创建 -// 返回值: -// - string: 缓存目录的路径 -// - error: 如果获取模块目录失败或创建缓存目录失败,则返回错误信息 -func GetCacheDir() (string, error) { - // 获取模块目录 - modDir, err := GetModuleDir() - if err != nil { - return "", err - } - // 拼接缓存目录路径 - path := fmt.Sprintf("%s/cache", modDir) - // 创建目录(包括所有必要的父目录),权限设置为0755 - err = os.MkdirAll(path, 0755) - if err != nil { - return "", fmt.Errorf("创建目录失败: %w", err) - } - // 返回成功创建的缓存目录路径 - return path, nil -} - -func GetTmplDir() (string, error) { - modDir, err := GetModuleDir() - if err != nil { - return "", err - } - path := fmt.Sprintf("%s/tmpl", modDir) - err = os.MkdirAll(path, 0755) - if err != nil { - return "", fmt.Errorf("创建目录失败: %w", err) - } - return path, nil -} - // 通用结构体转 Query 参数 func StructToQuery(obj interface{}) (url.Values, error) { values := url.Values{} diff --git a/internal/pkg/provider_set.go b/internal/pkg/provider_set.go index f8fadac..603dedc 100644 --- a/internal/pkg/provider_set.go +++ b/internal/pkg/provider_set.go @@ -15,7 +15,7 @@ var ProviderSetClient = wire.NewSet( utils_langchain.NewUtilLangChain, utils_ollama.NewClient, utils_vllm.NewClient, - NewSafeChannelPool, + dingtalk.NewOldClient, dingtalk.NewContactClient, dingtalk.NewNotableClient, diff --git a/internal/server/cron.go b/internal/server/cron.go new file mode 100644 index 0000000..76c5739 --- /dev/null +++ b/internal/server/cron.go @@ -0,0 +1,93 @@ +package server + +import ( + "ai_scheduler/internal/services" + "context" + + "github.com/gofiber/fiber/v2/log" + "github.com/robfig/cron/v3" +) + +type CronServer struct { + Cron *cron.Cron + jobs []*cronJob + log log.AllLogger + cronService *services.CronService + ctx context.Context +} + +type cronJob struct { + EntryId int32 + Func func(context.Context) error + Name string + Schedule string +} + +func NewCronServer( + log log.AllLogger, + cronService *services.CronService, +) *CronServer { + return &CronServer{ + Cron: cron.New(), + log: log, + cronService: cronService, + ctx: context.Background(), + } +} + +func (c *CronServer) InitJobs(ctx context.Context) { + // 创建一个可用于所有定时任务的上下文(可以取消的上下文) + c.ctx = ctx + c.jobs = []*cronJob{ + { + Func: c.cronService.CronReportSend, + Name: "直连天下报表推送", + Schedule: "@every 60s", + }, + } +} + +func (c *CronServer) Run(ctx context.Context) { + // 先初始化任务 + if c.jobs == nil { + c.InitJobs(ctx) + } + + for i, job := range c.jobs { + // 复制变量到闭包内,避免闭包变量捕获问题 + job := job + jobID := i + 1 + _, err := c.Cron.AddFunc(job.Schedule, func() { + c.log.Infof("任务[%d]:%s开始执行", jobID, job.Name) + + defer func() { + if r := recover(); r != nil { + c.log.Errorf("任务[%d]:%s执行时发生panic: %v", jobID, job.Name, r) + } + c.log.Infof("任务[%d]:%s执行结束", jobID, job.Name) + }() + + // 为每次执行创建新的上下文 + ctx := context.Background() + err := job.Func(ctx) + if err != nil { + c.log.Errorf("任务[%d]:%s执行失败: %s", jobID, job.Name, err.Error()) + } + }) + if err != nil { + c.log.Errorf("添加任务失败:%s", err.Error()) + } + } + + // 启动cron调度器 + c.Cron.Start() + c.log.Info("Cron调度器已启动") +} + +// Stop 停止cron调度器 +func (c *CronServer) Stop() { + if c.Cron != nil { + c.Cron.Stop() + c.log.Info("Cron调度器已停止") + } +} diff --git a/internal/server/provider_set.go b/internal/server/provider_set.go index d5cef3d..08bc37b 100644 --- a/internal/server/provider_set.go +++ b/internal/server/provider_set.go @@ -9,4 +9,5 @@ var ProviderSetServer = wire.NewSet( NewHTTPServer, ProvideAllDingBotServices, NewDingTalkBotServer, + NewCronServer, ) diff --git a/internal/server/server.go b/internal/server/server.go index 02c8f84..b455eb4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -10,18 +10,14 @@ type Servers struct { cfg *config.Config HttpServer *fiber.App DingBotServer *DingTalkBotServer + Cron *CronServer } -func NewServers(cfg *config.Config, fiber *fiber.App, DingBotServer *DingTalkBotServer) *Servers { +func NewServers(cfg *config.Config, fiber *fiber.App, DingBotServer *DingTalkBotServer, cron *CronServer) *Servers { return &Servers{ HttpServer: fiber, cfg: cfg, DingBotServer: DingBotServer, + Cron: cron, } } - -//func DingBotServerInit(clientId string, clientSecret string, cfg *config.Config, handler *do.Handle, do *do.Do) (cli *client.StreamClient) { -// cli = client.NewStreamClient(client.WithAppCredential(client.NewAppCredentialConfig(clientId, clientSecret))) -// cli.RegisterChatBotCallbackRouter(services.NewDingBotService(cfg, handler, do).OnChatBotMessageReceived) -// return -//} diff --git a/internal/services/cron.go b/internal/services/cron.go new file mode 100644 index 0000000..5f624d1 --- /dev/null +++ b/internal/services/cron.go @@ -0,0 +1,43 @@ +package services + +import ( + "ai_scheduler/internal/biz" + "ai_scheduler/internal/config" + "ai_scheduler/internal/data/constants" + "context" + + "gitea.cdlsxd.cn/self-tools/l-dingtalk-stream-sdk-go/chatbot" +) + +type CronService struct { + config *config.Config + dingTalkBotBiz *biz.DingTalkBotBiz +} + +func NewCronService(config *config.Config, dingTalkBotBiz *biz.DingTalkBotBiz) *CronService { + return &CronService{ + config: config, + dingTalkBotBiz: dingTalkBotBiz, + } +} + +func (d *CronService) CronReportSend(ctx context.Context) error { + reportChan, err := d.dingTalkBotBiz.GetReportLists(ctx) + if err != nil { + return err + } + groupId := 23 + groupInfo, err := d.dingTalkBotBiz.GetGroupInfo(ctx, groupId) + if err != nil { + return err + } + err = d.dingTalkBotBiz.HandleStreamRes(ctx, &chatbot.BotCallbackDataModel{ + RobotCode: groupInfo.RobotCode, + ConversationType: constants.ConversationTypeGroup, + ConversationId: groupInfo.ConversationID, + Text: chatbot.BotCallbackDataTextModel{ + Content: "报表", + }, + }, reportChan) + return nil +} diff --git a/internal/services/dtalk_bot.go b/internal/services/dtalk_bot.go index b71e40b..4635b63 100644 --- a/internal/services/dtalk_bot.go +++ b/internal/services/dtalk_bot.go @@ -3,6 +3,7 @@ package services import ( "ai_scheduler/internal/biz" "ai_scheduler/internal/config" + "ai_scheduler/internal/data/constants" "ai_scheduler/internal/entitys" "context" "log" @@ -135,3 +136,24 @@ func (d *DingBotService) runBackgroundTasks(ctx context.Context, data *chatbot.B return nil } + +func (d *DingBotService) CronReportSend(ctx context.Context) error { + reportChan, err := d.dingTalkBotBiz.GetReportLists(ctx) + if err != nil { + return err + } + groupId := 23 + groupInfo, err := d.dingTalkBotBiz.GetGroupInfo(ctx, groupId) + if err != nil { + return err + } + err = d.dingTalkBotBiz.HandleStreamRes(ctx, &chatbot.BotCallbackDataModel{ + RobotCode: groupInfo.RobotCode, + ConversationType: constants.ConversationTypeGroup, + ConversationId: groupInfo.ConversationID, + Text: chatbot.BotCallbackDataTextModel{ + Content: "报表", + }, + }, reportChan) + return nil +} diff --git a/internal/services/dtalk_bot.go.bak b/internal/services/dtalk_bot.go.bak deleted file mode 100644 index 75c2c7f..0000000 --- a/internal/services/dtalk_bot.go.bak +++ /dev/null @@ -1,130 +0,0 @@ -package services - -import ( - "ai_scheduler/internal/biz" - "log" - "sync" - "time" - - "ai_scheduler/internal/config" - "ai_scheduler/internal/entitys" - "context" - - "gitea.cdlsxd.cn/self-tools/l-dingtalk-stream-sdk-go/chatbot" -) - -type DingBotService struct { - config *config.Config - dingTalkBotBiz *biz.DingTalkBotBiz -} - -func NewDingBotService(config *config.Config, DingTalkBotBiz *biz.DingTalkBotBiz) *DingBotService { - return &DingBotService{config: config, dingTalkBotBiz: DingTalkBotBiz} -} - -func (d *DingBotService) GetServiceCfg() ([]entitys.DingTalkBot, error) { - return d.dingTalkBotBiz.GetDingTalkBotCfgList() -} - -func (d *DingBotService) OnChatBotMessageReceived(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) { - var ( - lastErr error - chat []string - streamWG sync.WaitGroup - resChan = make(chan string, 100) // 缓冲通道防止阻塞 - ) - - // 初始化请求 - requireData, err := d.dingTalkBotBiz.InitRequire(ctx, data) - if err != nil { - return nil, err - } - - // 创建子上下文用于控制goroutine生命周期 - subCtx, cancel := context.WithCancel(ctx) - defer cancel() - - // 启动流式处理goroutine - streamWG.Add(1) - go func() { - defer streamWG.Done() - err = d.dingTalkBotBiz.HandleStreamRes(subCtx, data, resChan) - if err != nil { - return - } - }() - - // 启动业务处理goroutine - done := make(chan error, 1) - go func() { - done <- d.dingTalkBotBiz.Do(subCtx, requireData) - }() - - // 主处理循环 - for { - select { - case <-ctx.Done(): - lastErr = ctx.Err() - goto cleanup - - case resp, ok := <-requireData.Ch: - if !ok { - goto cleanup - } - - // 处理不同类型响应 - switch resp.Type { - case entitys.ResponseLog: - // 忽略日志类型 - continue - - //case entitys.ResponseText, entitys.ResponseJson: - // chat = append(chat, resp.Content) - // if err := d.dingTalkBotBiz.ReplyText(ctx, data.SessionWebhook, resp.Content); err != nil { - // log.Printf("处理非流响应失败: %v", err) - // lastErr = err - // } - - default: - chat = append(chat, resp.Content) - select { - case resChan <- resp.Content: - case <-ctx.Done(): - lastErr = ctx.Err() - goto cleanup - } - } - } - } - -cleanup: - streamWG.Wait() - // 关闭流式通道 - close(resChan) - - // 保存历史记录 - if saveErr := d.dingTalkBotBiz.SaveHis(ctx, requireData, chat); saveErr != nil { - log.Printf("保存历史记录失败: %v", saveErr) - if lastErr == nil { - lastErr = saveErr - } - } - - // 等待业务处理完成(带超时) - select { - case err := <-done: - if err != nil { - log.Printf("业务处理失败: %v", err) - if lastErr == nil { - lastErr = err - } - } - case <-time.After(3 * time.Second): // 增加超时时间 - log.Println("警告:等待业务处理超时,可能发生goroutine泄漏") - } - - if lastErr != nil { - return nil, lastErr - } - return []byte("success"), nil -} diff --git a/internal/services/dtalk_bot_test.go b/internal/services/dtalk_bot_test.go new file mode 100644 index 0000000..2b8223f --- /dev/null +++ b/internal/services/dtalk_bot_test.go @@ -0,0 +1,105 @@ +package services + +import ( + "ai_scheduler/internal/biz" + "ai_scheduler/internal/biz/do" + dingtalk2 "ai_scheduler/internal/biz/handle/dingtalk" + "ai_scheduler/internal/biz/llm_service" + "ai_scheduler/internal/biz/tools_regis" + "ai_scheduler/internal/config" + "ai_scheduler/internal/data/impl" + "ai_scheduler/internal/domain/component" + "ai_scheduler/internal/domain/component/callback" + "ai_scheduler/internal/domain/repo" + "ai_scheduler/internal/domain/workflow" + "ai_scheduler/internal/pkg" + "ai_scheduler/internal/pkg/dingtalk" + "ai_scheduler/internal/pkg/utils_ollama" + "ai_scheduler/internal/pkg/utils_vllm" + + "ai_scheduler/internal/tools" + "ai_scheduler/utils" + "context" + "testing" + + "github.com/gofiber/fiber/v2/log" +) + +func Test_Report(t *testing.T) { + run() + a := dingBotService.CronReportSend(context.Background()) + t.Log(a) +} + +var ( + configConfig *config.Config + err error + dingBotService *DingBotService +) + +// run 函数是程序的入口函数,负责初始化和配置各个组件 +func run() { + // 加载测试配置 + configConfig, err = config.LoadConfigWithTest() + // 初始化数据库连接 + db, _ := utils.NewGormDb(configConfig) + // 初始化各种实现层组件 + sysImpl := impl.NewSysImpl(db) + taskImpl := impl.NewTaskImpl(db) + chatHisImpl := impl.NewChatHisImpl(db) + sessionImpl := impl.NewSessionImpl(db) + botConfigImpl := impl.NewBotConfigImpl(db) + botGroupImpl := impl.NewBotGroupImpl(db) + botUserImpl := impl.NewBotUserImpl(db) + // 初始化Do业务对象 + doDo := do.NewDo(sysImpl, taskImpl, chatHisImpl, configConfig) + // 初始化Ollama客户端 + client, _, _ := utils_ollama.NewClient(configConfig) + // 初始化vLLM客户端 + utils_vllmClient, _, _ := utils_vllm.NewClient(configConfig) + // 初始化Redis数据库连接 + rdb := utils.NewRdb(configConfig) + // 初始化仓库层 + repos := repo.NewRepos(sessionImpl, rdb) + // 初始化包级别的Redis连接 + pkgRdb := pkg.NewRdb(configConfig) + + // 初始化机器人工具实现层 + botToolsImpl := impl.NewBotToolsImpl(db) + // 初始化机器人部门实现层 + botDeptImpl := impl.NewBotDeptImpl(db) + // 初始化Redis管理器 + redisManager := callback.NewRedisManager(pkgRdb) + // 初始化组件 + components := component.NewComponents(redisManager) + // 初始化工作流注册表 + registry := workflow.NewRegistry(configConfig, client, repos, components) + // 初始化钉钉旧版客户端 + oldClient := dingtalk.NewOldClient(configConfig) + // 初始化Ollama服务 + ollamaService := llm_service.NewOllamaGenerate(client, utils_vllmClient, configConfig, chatHisImpl) + // 初始化工具管理器 + manager := tools.NewManager(configConfig, client) + // 初始化钉钉联系人客户端 + contactClient, _ := dingtalk.NewContactClient(configConfig) + // 初始化钉钉记事本客户端 + notableClient, _ := dingtalk.NewNotableClient(configConfig) + // 初始化工具注册 + toolRegis := tools_regis.NewToolsRegis(botToolsImpl) + // 初始化机器人聊天历史实现层 + botChatHisImpl := impl.NewBotChatHisImpl(db) + // 初始化钉钉认证 + auth := dingtalk2.NewAuth(configConfig, rdb, botConfigImpl) + // 初始化部门服务 + dept := dingtalk2.NewDept(botDeptImpl, auth) + // 初始化用户服务 + user := dingtalk2.NewUser(botUserImpl, auth, dept) + // 初始化发送卡片客户端 + sendCardClient := dingtalk2.NewSendCardClient(auth, log.DefaultLogger()) + // 初始化处理器 + handle := do.NewHandle(ollamaService, manager, configConfig, sessionImpl, registry, oldClient, contactClient, notableClient) + // 初始化钉钉机器人业务逻辑 + dingTalkBotBiz := biz.NewDingTalkBotBiz(doDo, handle, botConfigImpl, botGroupImpl, user, toolRegis, botChatHisImpl, manager, configConfig, sendCardClient) + // 初始化钉钉机器人服务 + dingBotService = NewDingBotService(configConfig, dingTalkBotBiz) +} diff --git a/internal/services/provider_set.go b/internal/services/provider_set.go index 375a886..55eed7a 100644 --- a/internal/services/provider_set.go +++ b/internal/services/provider_set.go @@ -14,4 +14,5 @@ var ProviderSetServices = wire.NewSet( NewDingBotService, NewHistoryService, NewCapabilityService, + NewCronService, ) diff --git a/internal/tools/bbxt/bbxt.go b/internal/tools/bbxt/bbxt.go index 8fe2885..7675d64 100644 --- a/internal/tools/bbxt/bbxt.go +++ b/internal/tools/bbxt/bbxt.go @@ -1,7 +1,7 @@ package bbxt import ( - "ai_scheduler/internal/pkg" + "ai_scheduler/pkg" "fmt" "reflect" "time" @@ -92,7 +92,7 @@ func (b *BbxtTools) StatisOursProductLossSumTotal(ct []string) (err error) { for _, v := range resellerMap { if v.Total <= -100 { total = append(total, []string{ - fmt.Sprintf("%d", v.ResellerName), + fmt.Sprintf("%s", v.ResellerName), fmt.Sprintf("%.2f", v.Total), }) } diff --git a/internal/tools/bbxt/bbxt_test.go b/internal/tools/bbxt/bbxt_test.go index 80e6b29..2770488 100644 --- a/internal/tools/bbxt/bbxt_test.go +++ b/internal/tools/bbxt/bbxt_test.go @@ -1,13 +1,20 @@ package bbxt -import "testing" +import ( + "testing" + "time" +) func Test_StatisOursProductLossSumApiTotal(t *testing.T) { o, err := NewBbxtTools() if err != nil { panic(err) } - err = o.StatisOursProductLossSumTotal() + today := time.Now() + err = o.StatisOursProductLossSumTotal([]string{ + time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location()).Format("2006-01-02 15:04:05"), + time.Date(today.Year(), today.Month(), today.Day(), 23, 59, 59, 0, today.Location()).Format("2006-01-02 15:04:05"), + }) t.Log(err) diff --git a/pkg/func.go b/pkg/func.go new file mode 100644 index 0000000..006f16f --- /dev/null +++ b/pkg/func.go @@ -0,0 +1,65 @@ +package pkg + +import ( + "fmt" + "os" + "path/filepath" +) + +func GetModuleDir() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + + for { + modPath := filepath.Join(dir, "go.mod") + if _, err := os.Stat(modPath); err == nil { + return dir, nil // 找到 go.mod + } + + // 向上查找父目录 + parent := filepath.Dir(dir) + if parent == dir { + break // 到达根目录,未找到 + } + dir = parent + } + + return "", fmt.Errorf("go.mod not found in current directory or parents") +} + +// GetCacheDir 用于获取缓存目录路径 +// 如果缓存目录不存在,则会自动创建 +// 返回值: +// - string: 缓存目录的路径 +// - error: 如果获取模块目录失败或创建缓存目录失败,则返回错误信息 +func GetCacheDir() (string, error) { + // 获取模块目录 + modDir, err := GetModuleDir() + if err != nil { + return "", err + } + // 拼接缓存目录路径 + path := fmt.Sprintf("%s/cache", modDir) + // 创建目录(包括所有必要的父目录),权限设置为0755 + err = os.MkdirAll(path, 0755) + if err != nil { + return "", fmt.Errorf("创建目录失败: %w", err) + } + // 返回成功创建的缓存目录路径 + return path, nil +} + +func GetTmplDir() (string, error) { + modDir, err := GetModuleDir() + if err != nil { + return "", err + } + path := fmt.Sprintf("%s/tmpl", modDir) + err = os.MkdirAll(path, 0755) + if err != nil { + return "", fmt.Errorf("创建目录失败: %w", err) + } + return path, nil +} From 3a77e0e32b66e825ede5f54728e6db2e5fa0f946 Mon Sep 17 00:00:00 2001 From: fuzhongyun <15339891972@163.com> Date: Tue, 30 Dec 2025 10:22:19 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E4=BD=BF=E7=94=A8=E9=A2=9D=E5=A4=96?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tools/bbxt/bbxt.go | 180 ++-------------------------- internal/tools/bbxt/bbxt_test.go | 7 +- internal/tools/bbxt/excel.go | 194 +++++++++++++++++++++++++++++++ tmpl/excel_temp/kshj_gt.xlsx | Bin 9473 -> 9491 bytes 4 files changed, 210 insertions(+), 171 deletions(-) create mode 100644 internal/tools/bbxt/excel.go diff --git a/internal/tools/bbxt/bbxt.go b/internal/tools/bbxt/bbxt.go index d7d00ef..6d593db 100644 --- a/internal/tools/bbxt/bbxt.go +++ b/internal/tools/bbxt/bbxt.go @@ -3,12 +3,8 @@ package bbxt import ( "ai_scheduler/internal/pkg" "fmt" - "reflect" "sort" "time" - - "github.com/gofiber/fiber/v2/log" - "github.com/xuri/excelize/v2" ) type BbxtTools struct { @@ -91,7 +87,18 @@ func (b *BbxtTools) StatisOursProductLossSumTotal(ct []string) (err error) { reseller.ProductLoss[info.OursProductId] = productLoss } } + + // 按经销商总亏损排序 + resellers := make([]*ResellerLoss, 0, len(resellerMap)) for _, v := range resellerMap { + resellers = append(resellers, v) + } + sort.Slice(resellers, func(i, j int) bool { + return resellers[i].Total < resellers[j].Total + }) + + // 构建分组 + for _, v := range resellers { if v.Total <= -100 { total = append(total, []string{ fmt.Sprintf("%s", v.ResellerName), @@ -114,168 +121,3 @@ func (b *BbxtTools) StatisOursProductLossSumTotal(ct []string) (err error) { } return err } - -// 最简单的通用函数 -func (b *BbxtTools) SimpleFillExcel(templatePath, outputPath string, dataSlice interface{}) error { - // 1. 打开模板 - f, err := excelize.OpenFile(templatePath) - if err != nil { - return err - } - defer f.Close() - - sheet := f.GetSheetName(0) - - // 1.1 获取第二行模板样式 - resellerTplRow := 2 - styleIDReseller, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", resellerTplRow)) - if err != nil { - log.Errorf("获取分销商总计样式失败: %v", err) - styleIDReseller = 0 - } - // 1.2 获取分销商总计行高 - rowHeightReseller, err := f.GetRowHeight(sheet, resellerTplRow) - if err != nil { - log.Errorf("获取分销商总计行高失败: %v", err) - rowHeightReseller = 31 // 默认高度 - } - - // 2. 反射获取切片数据 - v := reflect.ValueOf(dataSlice) - if v.Kind() != reflect.Slice { - return fmt.Errorf("dataSlice must be a slice") - } - - // 3. 从第2行开始填充 - row := 2 - for i := 0; i < v.Len(); i++ { - item := v.Index(i).Interface() - currentRow := row + i - - // 4. 将item转换为一行数据 - var rowData []interface{} - - // 如果是切片 - if reflect.TypeOf(item).Kind() == reflect.Slice { - itemV := reflect.ValueOf(item) - for j := 0; j < itemV.Len(); j++ { - rowData = append(rowData, itemV.Index(j).Interface()) - } - } else if reflect.TypeOf(item).Kind() == reflect.Struct { - itemV := reflect.ValueOf(item) - for j := 0; j < itemV.NumField(); j++ { - if itemV.Field(j).CanInterface() { - rowData = append(rowData, itemV.Field(j).Interface()) - } - } - } else { - rowData = []interface{}{item} - } - // 4.1 设置行高 - f.SetRowHeight(sheet, currentRow, rowHeightReseller) - - // 5. 填充到Excel - for col, value := range rowData { - cell := fmt.Sprintf("%c%d", 'A'+col, currentRow) - f.SetCellValue(sheet, cell, value) - } - - // 5.1 使用第二行模板样式 - if styleIDReseller != 0 { - f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("B%d", currentRow), styleIDReseller) - } - } - - // 6. 保存 - return f.SaveAs(outputPath) -} - -// 分销商负利润详情填充excel -// 1.使用模板文件作为输出文件 -// 2.分销商总计使用第二行样式(宽高、背景、颜色等) -// 3.商品详情使用第三行样式(宽高、背景、颜色等) -// 4.保存为新文件 -func (b *BbxtTools) resellerDetailFillExcel(templatePath, outputPath string, dataSlice []*ResellerLoss) error { - // 1. 读取模板 - f, err := excelize.OpenFile(templatePath) - if err != nil { - return err - } - defer f.Close() - - sheet := f.GetSheetName(0) - - // 获取模板样式1:第二行-分销商总计 - resellerTplRow := 2 - styleIDReseller, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", resellerTplRow)) - if err != nil { - log.Errorf("获取分销商总计样式失败: %v", err) - styleIDReseller = 0 - } - rowHeightReseller, err := f.GetRowHeight(sheet, resellerTplRow) - if err != nil { - log.Errorf("获取分销商总计行高失败: %v", err) - rowHeightReseller = 31 // 默认高度 - } - // 获取模板样式2:第三行-产品亏损明细 - productTplRow := 3 - styleIDProduct, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", productTplRow)) - if err != nil { - log.Errorf("获取商品详情样式失败: %v", err) - styleIDProduct = 0 - } - rowHeightProduct, err := f.GetRowHeight(sheet, productTplRow) - if err != nil { - log.Errorf("获取商品详情行高失败: %v", err) - rowHeightProduct = 25 // 默认高度 - } - - currentRow := 2 - - for _, reseller := range dataSlice { - // 3. 填充经销商数据 (ResellerName, Total) - // 设置行高 - f.SetRowHeight(sheet, currentRow, rowHeightReseller) - - // 设置单元格值 - f.SetCellValue(sheet, fmt.Sprintf("A%d", currentRow), reseller.ResellerName) - f.SetCellValue(sheet, fmt.Sprintf("B%d", currentRow), reseller.Total) - - // 应用样式 - if styleIDReseller != 0 { - f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("B%d", currentRow), styleIDReseller) - } - - currentRow++ - - // 4. 填充产品亏损明细 - // 先对 ProductLoss 进行排序 - var products []ProductLoss - for _, p := range reseller.ProductLoss { - products = append(products, p) - } - // 按 Loss 升序排序 (亏损越多越靠前,负数越小) - sort.Slice(products, func(i, j int) bool { - return products[i].Loss < products[j].Loss - }) - - for _, p := range products { - // 设置行高 - f.SetRowHeight(sheet, currentRow, rowHeightProduct) - - // 设置单元格值 - f.SetCellValue(sheet, fmt.Sprintf("A%d", currentRow), p.ProductName) - f.SetCellValue(sheet, fmt.Sprintf("B%d", currentRow), p.Loss) - - // 应用样式 - if styleIDProduct != 0 { - f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("B%d", currentRow), styleIDProduct) - } - - currentRow++ - } - } - - // 6. 保存 - return f.SaveAs(outputPath) -} diff --git a/internal/tools/bbxt/bbxt_test.go b/internal/tools/bbxt/bbxt_test.go index 628c675..b6b2275 100644 --- a/internal/tools/bbxt/bbxt_test.go +++ b/internal/tools/bbxt/bbxt_test.go @@ -1,13 +1,16 @@ package bbxt -import "testing" +import ( + "testing" + "time" +) func Test_StatisOursProductLossSumApiTotal(t *testing.T) { o, err := NewBbxtTools() if err != nil { panic(err) } - err = o.StatisOursProductLossSumTotal([]string{"2025-12-28+00:00:00", "2025-12-28+23:59:59.999"}) + err = o.DailyReport(time.Date(2025, 12, 30, 0, 0, 0, 0, time.Local)) t.Log(err) diff --git a/internal/tools/bbxt/excel.go b/internal/tools/bbxt/excel.go new file mode 100644 index 0000000..3711aa0 --- /dev/null +++ b/internal/tools/bbxt/excel.go @@ -0,0 +1,194 @@ +package bbxt + +import ( + "fmt" + "reflect" + "sort" + + "github.com/go-kratos/kratos/v2/log" + "github.com/xuri/excelize/v2" +) + +// 最简单的通用函数 +func (b *BbxtTools) SimpleFillExcel(templatePath, outputPath string, dataSlice interface{}) error { + // 1. 打开模板 + f, err := excelize.OpenFile(templatePath) + if err != nil { + return err + } + defer f.Close() + + sheet := f.GetSheetName(0) + + // 1.1 获取第二行模板样式 + resellerTplRow := 2 + styleIDReseller, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", resellerTplRow)) + if err != nil { + log.Errorf("获取分销商总计样式失败: %v", err) + styleIDReseller = 0 + } + // 1.2 获取分销商总计行高 + rowHeightReseller, err := f.GetRowHeight(sheet, resellerTplRow) + if err != nil { + log.Errorf("获取分销商总计行高失败: %v", err) + rowHeightReseller = 31 // 默认高度 + } + + // 2. 反射获取切片数据 + v := reflect.ValueOf(dataSlice) + if v.Kind() != reflect.Slice { + return fmt.Errorf("dataSlice must be a slice") + } + + // 3. 从第2行开始填充 + row := 2 + for i := 0; i < v.Len(); i++ { + item := v.Index(i).Interface() + currentRow := row + i + + // 4. 将item转换为一行数据 + var rowData []interface{} + + // 如果是切片 + if reflect.TypeOf(item).Kind() == reflect.Slice { + itemV := reflect.ValueOf(item) + for j := 0; j < itemV.Len(); j++ { + rowData = append(rowData, itemV.Index(j).Interface()) + } + } else if reflect.TypeOf(item).Kind() == reflect.Struct { + itemV := reflect.ValueOf(item) + for j := 0; j < itemV.NumField(); j++ { + if itemV.Field(j).CanInterface() { + rowData = append(rowData, itemV.Field(j).Interface()) + } + } + } else { + rowData = []interface{}{item} + } + // 4.1 设置行高 + f.SetRowHeight(sheet, currentRow, rowHeightReseller) + + // 5. 填充到Excel + for col, value := range rowData { + cell := fmt.Sprintf("%c%d", 'A'+col, currentRow) + f.SetCellValue(sheet, cell, value) + } + + // 5.1 使用第二行模板样式 + if styleIDReseller != 0 { + f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("B%d", currentRow), styleIDReseller) + } + } + + // 6. 保存 + return f.SaveAs(outputPath) +} + +// 分销商负利润详情填充excel +// 1.使用模板文件作为输出文件 +// 2.分销商总计使用第二行样式(宽高、背景、颜色等) +// 3.商品详情使用第三行样式(宽高、背景、颜色等) +// 4.保存为新文件 +func (b *BbxtTools) resellerDetailFillExcel(templatePath, outputPath string, dataSlice []*ResellerLoss) error { + // 1. 读取模板 + f, err := excelize.OpenFile(templatePath) + if err != nil { + return err + } + defer f.Close() + + sheet := f.GetSheetName(0) + + // 获取模板样式1:第二行-分销商总计 + resellerTplRow := 2 + styleIDReseller, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", resellerTplRow)) + if err != nil { + log.Errorf("获取分销商总计样式失败: %v", err) + styleIDReseller = 0 + } + rowHeightReseller, err := f.GetRowHeight(sheet, resellerTplRow) + if err != nil { + log.Errorf("获取分销商总计行高失败: %v", err) + rowHeightReseller = 31 // 默认高度 + } + // 获取模板样式2:第三行-产品亏损明细 + productTplRow := 3 + styleIDProduct, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", productTplRow)) + if err != nil { + log.Errorf("获取商品详情样式失败: %v", err) + styleIDProduct = 0 + } + rowHeightProduct, err := f.GetRowHeight(sheet, productTplRow) + if err != nil { + log.Errorf("获取商品详情行高失败: %v", err) + rowHeightProduct = 25 // 默认高度 + } + + currentRow := 2 + + for _, reseller := range dataSlice { + // 3. 填充经销商数据 (ResellerName, Total) + // 设置行高 + f.SetRowHeight(sheet, currentRow, rowHeightReseller) + + // 设置单元格值 + f.SetCellValue(sheet, fmt.Sprintf("A%d", currentRow), reseller.ResellerName) + f.SetCellValue(sheet, fmt.Sprintf("B%d", currentRow), reseller.Total) + + // 应用样式 + if styleIDReseller != 0 { + f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("B%d", currentRow), styleIDReseller) + } + + currentRow++ + + // 4. 填充产品亏损明细 + // 先对 ProductLoss 进行排序 + var products []ProductLoss + for _, p := range reseller.ProductLoss { + products = append(products, p) + } + // 按 Loss 升序排序 (亏损越多越靠前,负数越小) + sort.Slice(products, func(i, j int) bool { + return products[i].Loss < products[j].Loss + }) + + for _, p := range products { + // 设置行高 + f.SetRowHeight(sheet, currentRow, rowHeightProduct) + + // 设置单元格值 + f.SetCellValue(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("·%s", p.ProductName)) + f.SetCellValue(sheet, fmt.Sprintf("B%d", currentRow), p.Loss) + + // 应用样式 + if styleIDProduct != 0 { + f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("B%d", currentRow), styleIDProduct) + } + + currentRow++ + } + } + + // buffer, err := f.WriteToBuffer() + // if err != nil { + // return err + // } + + // return buffer.Bytes(), nil + + // 6. 保存 + return f.SaveAs(outputPath) +} + +// excel2picPy 将excel转换为图片python +// python 接口如下: +// curl --location --request POST 'http://192.168.6.109:8010/api/v1/convert' \ +// --header 'Content-Type: multipart/form-data; boundary=--------------------------952147881043913664015069' \ +// --form 'file=@"C:\\Users\\Administrator\\Downloads\\销售同比分析2025-12-29 0-12点.xlsx"' \ +// --form 'sheet_name="销售同比分析"' +func (b *BbxtTools) excel2picPy(templatePath string, excelBytes []byte) ([]byte, error) { + + return nil, nil + // return picBytes, nil +} diff --git a/tmpl/excel_temp/kshj_gt.xlsx b/tmpl/excel_temp/kshj_gt.xlsx index c826635797dcc5b89a8eb2b89a85592c445b16c9..6933f633648e61451b2be2ea5d6371174a19dcf2 100755 GIT binary patch delta 4202 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 MK={HA&p*t60ON@J%>V!Z delta 4159 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 Date: Tue, 30 Dec 2025 10:52:30 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=20=E5=A2=9E=E5=8A=A0=E8=B4=9F?= =?UTF-8?q?=E5=88=A9=E6=B6=A6=E5=AF=BC=E5=87=BAV2=E7=89=88=E6=9C=AC?= =?UTF-8?q?=EF=BC=8C=E5=90=88=E5=B9=B6=E5=8D=95=E5=85=83=E6=A0=BC=E7=89=88?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tools/bbxt/bbxt.go | 3 +- internal/tools/bbxt/excel.go | 131 ++++++++++++++++++++++++++++++++ tmpl/excel_temp/kshj_gt.xlsx | Bin 9491 -> 9905 bytes tmpl/excel_temp/kshj_gt.xlsx.v1 | Bin 0 -> 9491 bytes 4 files changed, 133 insertions(+), 1 deletion(-) create mode 100755 tmpl/excel_temp/kshj_gt.xlsx.v1 diff --git a/internal/tools/bbxt/bbxt.go b/internal/tools/bbxt/bbxt.go index f7d1e68..77a5f29 100644 --- a/internal/tools/bbxt/bbxt.go +++ b/internal/tools/bbxt/bbxt.go @@ -117,7 +117,8 @@ func (b *BbxtTools) StatisOursProductLossSumTotal(ct []string) (err error) { if len(gt) > 0 { filePath := b.cacheDir + "/kshj_gt" + fmt.Sprintf("%d", time.Now().Unix()) + ".xlsx" - err = b.resellerDetailFillExcel(b.excelTempDir+"/"+"kshj_gt.xlsx", filePath, gt) + // err = b.resellerDetailFillExcel(b.excelTempDir+"/"+"kshj_gt.xlsx", filePath, gt) + err = b.resellerDetailFillExcelV2(b.excelTempDir+"/"+"kshj_gt.xlsx", filePath, gt) } return err } diff --git a/internal/tools/bbxt/excel.go b/internal/tools/bbxt/excel.go index 3711aa0..807680b 100644 --- a/internal/tools/bbxt/excel.go +++ b/internal/tools/bbxt/excel.go @@ -181,6 +181,137 @@ func (b *BbxtTools) resellerDetailFillExcel(templatePath, outputPath string, dat return f.SaveAs(outputPath) } +// 分销商负利润详情填充excel-V2 +// 1.使用模板文件作为输出文件,从第二行开始填充 +// 2.整体为3列:1.分销商名称(以ResellerName为分组,分销商名称列使用的样式为) 2.商品名称(p.ProductName) 3.亏损金额(p.Loss) +// 3.分销商名称列使用的样式为 A2;商品名称、亏损金额使用的样式为 B2、C2;样式包括宽高、背景、颜色等 +// 4.以ResellerName分组,合并单元格 +// 5.在文件末尾使用“合计”,合计行样式为模板第四行 +// 6.保存为新文件 +func (b *BbxtTools) resellerDetailFillExcelV2(templatePath, outputPath string, dataSlice []*ResellerLoss) error { + // 1. 读取模板 + f, err := excelize.OpenFile(templatePath) + if err != nil { + return err + } + defer f.Close() + + sheet := f.GetSheetName(0) + + // ---------------- 样式获取 ---------------- + // 模板第2行:数据行样式 + tplRowData := 2 + styleA2, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", tplRowData)) + if err != nil { + styleA2 = 0 + } + // B2和C2通常样式一致,这里取B2作为明细列样式 + styleB2, err := f.GetCellStyle(sheet, fmt.Sprintf("B%d", tplRowData)) + if err != nil { + styleB2 = 0 + } + styleC2, err := f.GetCellStyle(sheet, fmt.Sprintf("C%d", tplRowData)) + if err != nil { + styleC2 = 0 + } + + rowHeightData, err := f.GetRowHeight(sheet, tplRowData) + if err != nil { + rowHeightData = 20 + } + + // 模板第4行:合计行样式 + tplRowTotal := 4 + styleTotalA, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", tplRowTotal)) + if err != nil { + styleTotalA = 0 + } + styleTotalB, err := f.GetCellStyle(sheet, fmt.Sprintf("B%d", tplRowTotal)) + if err != nil { + styleTotalB = 0 + } + styleTotalC, err := f.GetCellStyle(sheet, fmt.Sprintf("C%d", tplRowTotal)) + if err != nil { + styleTotalC = 0 + } + rowHeightTotal, err := f.GetRowHeight(sheet, tplRowTotal) + if err != nil { + rowHeightTotal = 30 + } + // ---------------------------------------- + + currentRow := 2 + totalLoss := 0.0 + + for _, reseller := range dataSlice { + // 排序 ProductLoss + var products []ProductLoss + for _, p := range reseller.ProductLoss { + products = append(products, p) + } + sort.Slice(products, func(i, j int) bool { + return products[i].Loss < products[j].Loss + }) + + startRow := currentRow + + // 填充该经销商的所有产品 + for _, p := range products { + // 设置行高 + f.SetRowHeight(sheet, currentRow, rowHeightData) + + // 设置值 + f.SetCellValue(sheet, fmt.Sprintf("A%d", currentRow), reseller.ResellerName) + f.SetCellValue(sheet, fmt.Sprintf("B%d", currentRow), p.ProductName) + f.SetCellValue(sheet, fmt.Sprintf("C%d", currentRow), p.Loss) + + // 设置样式 + if styleA2 != 0 { + f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("A%d", currentRow), styleA2) + } + if styleB2 != 0 { + f.SetCellStyle(sheet, fmt.Sprintf("B%d", currentRow), fmt.Sprintf("B%d", currentRow), styleB2) + } + if styleC2 != 0 { + f.SetCellStyle(sheet, fmt.Sprintf("C%d", currentRow), fmt.Sprintf("C%d", currentRow), styleC2) + } + + totalLoss += p.Loss + currentRow++ + } + + endRow := currentRow - 1 + // 合并单元格 (如果多于1行) + if endRow > startRow { + f.MergeCell(sheet, fmt.Sprintf("A%d", startRow), fmt.Sprintf("A%d", endRow)) + } + } + + // ---------------- 填充合计行 ---------------- + // 设置行高 + f.SetRowHeight(sheet, currentRow, rowHeightTotal) + + f.SetCellValue(sheet, fmt.Sprintf("A%d", currentRow), "合计") + // B列留空,C列填充总亏损 + f.SetCellValue(sheet, fmt.Sprintf("C%d", currentRow), totalLoss) + + // 设置合计行样式 + if styleTotalA != 0 { + f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("A%d", currentRow), styleTotalA) + } + if styleTotalB != 0 { + f.SetCellStyle(sheet, fmt.Sprintf("B%d", currentRow), fmt.Sprintf("B%d", currentRow), styleTotalB) + } + if styleTotalC != 0 { + f.SetCellStyle(sheet, fmt.Sprintf("C%d", currentRow), fmt.Sprintf("C%d", currentRow), styleTotalC) + } + // 取消合并合计行的A、B列 + // f.MergeCell(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("B%d", currentRow)) + + // 6. 保存 + return f.SaveAs(outputPath) +} + // excel2picPy 将excel转换为图片python // python 接口如下: // curl --location --request POST 'http://192.168.6.109:8010/api/v1/convert' \ diff --git a/tmpl/excel_temp/kshj_gt.xlsx b/tmpl/excel_temp/kshj_gt.xlsx index 6933f633648e61451b2be2ea5d6371174a19dcf2..e270a67aaa52be82e3c03c824e02b53b180b36d8 100755 GIT binary patch delta 7919 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

;^J9-Az?fkgaVmvkfJ?!dvnoj-%K!EOSwq+S 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 F{U5~S37G%@ diff --git a/tmpl/excel_temp/kshj_gt.xlsx.v1 b/tmpl/excel_temp/kshj_gt.xlsx.v1 new file mode 100755 index 0000000000000000000000000000000000000000..6933f633648e61451b2be2ea5d6371174a19dcf2 GIT binary patch literal 9491 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 fKWDA=KX`uy0mw?g{@ASOkf<7X2;Dj11K Date: Tue, 30 Dec 2025 12:00:22 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0excel2picPy?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=9B=BE=E7=89=87=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/tools/bbxt/excel.go | 94 +++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 2 deletions(-) diff --git a/internal/tools/bbxt/excel.go b/internal/tools/bbxt/excel.go index 807680b..4ba932d 100644 --- a/internal/tools/bbxt/excel.go +++ b/internal/tools/bbxt/excel.go @@ -1,7 +1,13 @@ package bbxt import ( + "bytes" "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" "reflect" "sort" @@ -308,6 +314,17 @@ func (b *BbxtTools) resellerDetailFillExcelV2(templatePath, outputPath string, d // 取消合并合计行的A、B列 // f.MergeCell(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("B%d", currentRow)) + excelBytes, err := f.WriteToBuffer() + if err != nil { + return fmt.Errorf("write to bytes failed: %v", err) + } + + picBytes, err := b.excel2picPy(templatePath, excelBytes.Bytes()) + if err != nil { + return fmt.Errorf("excel2picPy failed: %v", err) + } + b.SavePic("temp.png", picBytes) + // 6. 保存 return f.SaveAs(outputPath) } @@ -319,7 +336,80 @@ func (b *BbxtTools) resellerDetailFillExcelV2(templatePath, outputPath string, d // --form 'file=@"C:\\Users\\Administrator\\Downloads\\销售同比分析2025-12-29 0-12点.xlsx"' \ // --form 'sheet_name="销售同比分析"' func (b *BbxtTools) excel2picPy(templatePath string, excelBytes []byte) ([]byte, error) { + // 1. 获取 Sheet Name + // 尝试从 excelBytes 解析,如果失败则使用默认值 "Sheet1" + sheetName := "Sheet1" + f, err := excelize.OpenReader(bytes.NewReader(excelBytes)) + if err == nil { + sheetName = f.GetSheetName(0) + if sheetName == "" { + sheetName = "Sheet1" + } + f.Close() + } - return nil, nil - // return picBytes, nil + // 2. 构造 Multipart 请求 + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // 添加文件字段 + // 使用 templatePath 的文件名作为上传文件名,如果没有则用 default.xlsx + filename := "default.xlsx" + if templatePath != "" { + filename = filepath.Base(templatePath) + } + + part, err := writer.CreateFormFile("file", filename) + if err != nil { + return nil, fmt.Errorf("create form file failed: %v", err) + } + if _, err = part.Write(excelBytes); err != nil { + return nil, fmt.Errorf("write file part failed: %v", err) + } + + // 添加 sheet_name 字段 + if err = writer.WriteField("sheet_name", sheetName); err != nil { + return nil, fmt.Errorf("write field sheet_name failed: %v", err) + } + + if err = writer.Close(); err != nil { + return nil, fmt.Errorf("close writer failed: %v", err) + } + + // 3. 发送 HTTP POST 请求 + url := "http://192.168.6.109:8010/api/v1/convert" + req, err := http.NewRequest("POST", url, body) + if err != nil { + return nil, fmt.Errorf("create request failed: %v", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("send request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("api request failed with status: %d, body: %s", resp.StatusCode, string(respBody)) + } + + // 4. 读取响应 Body (图片内容) + picBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response body failed: %v", err) + } + + return picBytes, nil +} + +// SavePic 保存图片到本地 +func (b *BbxtTools) SavePic(outputPath string, picBytes []byte) error { + dir := filepath.Dir(outputPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create directory failed: %v", err) + } + return os.WriteFile(outputPath, picBytes, 0644) }