diff --git a/cmd/server/main.go b/cmd/server/main.go index baca30d..c0ff4c8 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -29,6 +29,6 @@ func main() { //钉钉机器人 app.DingBotServer.Run(ctx, *onBot) //定时任务 - app.Cron.Run(ctx) + //app.Cron.Run(ctx) log.Fatal(app.HttpServer.Listen(fmt.Sprintf(":%d", bc.Server.Port))) } diff --git a/config/config_test.yaml b/config/config_test.yaml index 63b4b66..bdbc26d 100644 --- a/config/config_test.yaml +++ b/config/config_test.yaml @@ -4,7 +4,7 @@ server: host: "0.0.0.0" ollama: - base_url: "http://host.docker.internal:11434" + base_url: "http://127.0.0.1:11434" model: "qwen3-coder:480b-cloud" generate_model: "qwen3-coder:480b-cloud" mapping_model: "deepseek-v3.2:cloud" @@ -194,7 +194,3 @@ llm: temperature: 0.7 max_tokens: 4096 stream: true -#ding_talk_bots: -# public: -# client_id: "dingchg59zwwvmuuvldx", -# client_secret: "ZwetAnRiTQobNFVlNrshRagSMAJIFpBAepWkWI7on7Tt_o617KHtTjBLp8fQfplz", diff --git a/internal/biz/ding_talk_bot.go b/internal/biz/ding_talk_bot.go index 0cc4090..631fbd0 100644 --- a/internal/biz/ding_talk_bot.go +++ b/internal/biz/ding_talk_bot.go @@ -9,7 +9,9 @@ import ( "ai_scheduler/internal/data/model" "ai_scheduler/internal/entitys" "ai_scheduler/internal/pkg/l_request" + "ai_scheduler/internal/pkg/utils_oss" "ai_scheduler/internal/tools" + "ai_scheduler/internal/tools/bbxt" "ai_scheduler/tmpl/dataTemp" "io" "net/http" @@ -44,6 +46,7 @@ type DingTalkBotBiz struct { chatHis *impl.BotChatHisImpl conf *config.Config cardSend *dingtalk.SendCardClient + ossClient *utils_oss.Client } // NewDingTalkBotBiz @@ -58,6 +61,7 @@ func NewDingTalkBotBiz( toolManager *tools.Manager, conf *config.Config, cardSend *dingtalk.SendCardClient, + ossClient *utils_oss.Client, ) *DingTalkBotBiz { return &DingTalkBotBiz{ do: do, @@ -71,6 +75,7 @@ func NewDingTalkBotBiz( chatHis: chatHis, conf: conf, cardSend: cardSend, + ossClient: ossClient, } } @@ -473,12 +478,34 @@ func (d *DingTalkBotBiz) HandleStreamRes(ctx context.Context, data *chatbot.BotC 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)" +func (d *DingTalkBotBiz) GetReportLists(ctx context.Context) (reports []*bbxt.ReportRes, err error) { + reportList, err := bbxt.NewBbxtTools() + if err != nil { + return + } + reports, err = reportList.DailyReport(time.Now(), []string{"官方-爱奇艺-星钻季卡", "官方-爱奇艺-星钻半年卡", "官方--腾讯-年卡", "官方--爱奇艺-月卡"}, d.ossClient) + if err != nil { + return + } + + return +} + +func (d *DingTalkBotBiz) SendReport(ctx context.Context, groupInfo model.AiBotGroup, report *bbxt.ReportRes) (err error) { + + reportChan := make(chan string, 10) + defer close(reportChan) + reportChan <- report.Title + reportChan <- fmt.Sprintf("![图片](%s)", report.Url) + err = d.HandleStreamRes(ctx, &chatbot.BotCallbackDataModel{ + RobotCode: groupInfo.RobotCode, + ConversationType: constants.ConversationTypeGroup, + ConversationId: groupInfo.ConversationID, + Text: chatbot.BotCallbackDataTextModel{ + Content: report.ReportName, + }, + }, reportChan) return } diff --git a/internal/config/config.go b/internal/config/config.go index 8c3a6d2..510e59a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -24,8 +24,7 @@ type Config struct { DefaultPrompt SysPrompt `mapstructure:"default_prompt"` PermissionConfig PermissionConfig `mapstructure:"permissionConfig"` LLM LLM `mapstructure:"llm"` - // DingTalkBots map[string]*DingTalkBot `mapstructure:"ding_talk_bots"` - Dingtalk DingtalkConfig `mapstructure:"dingtalk"` + Dingtalk DingtalkConfig `mapstructure:"dingtalk"` } type SysPrompt struct { diff --git a/internal/pkg/func.go b/internal/pkg/func.go index 4d9232b..648996c 100644 --- a/internal/pkg/func.go +++ b/internal/pkg/func.go @@ -10,8 +10,6 @@ import ( "strconv" "strings" "time" - - jsoniter "github.com/json-iterator/go" ) func JsonStringIgonErr(data interface{}) string { @@ -170,131 +168,257 @@ func SafeReplace(template string, replaceTag string, replacements ...string) (st return template, nil } -func StructToMapUsingJsoniter(obj interface{}) (map[string]string, error) { - var json = jsoniter.ConfigCompatibleWithStandardLibrary - - // 转换为JSON - jsonBytes, err := json.Marshal(obj) - if err != nil { - return nil, err - } - - // 解析为map[string]interface{} - var tempMap map[string]interface{} - err = json.Unmarshal(jsonBytes, &tempMap) - if err != nil { - return nil, err - } - - // 转换为map[string]string - result := make(map[string]string) - for k, v := range tempMap { - result[k] = fmt.Sprintf("%v", v) - } - - return result, nil +// 配置选项 +type URLValuesOptions struct { + ArrayFormat string // 数组格式:"brackets" -> name[], "indices" -> name[0], "repeat" -> name=value1&name=value2 + TimeFormat string // 时间格式 } -// 通用结构体转 Query 参数 -func StructToQuery(obj interface{}) (url.Values, error) { - values := url.Values{} - v := reflect.ValueOf(obj) - t := reflect.TypeOf(obj) +var defaultOptions = URLValuesOptions{ + ArrayFormat: "brackets", // 默认使用括号格式 + TimeFormat: time.DateTime, +} - // 如果是指针,获取指向的值 +// StructToURLValues 将结构体转换为 url.Values +func StructToURLValues(input interface{}, options ...URLValuesOptions) (url.Values, error) { + opts := defaultOptions + if len(options) > 0 { + opts = options[0] + } + + values := url.Values{} + + if input == nil { + return values, nil + } + + v := reflect.ValueOf(input) + t := reflect.TypeOf(input) + + // 如果是指针,获取其指向的值 if v.Kind() == reflect.Ptr { + if v.IsNil() { + return values, nil + } v = v.Elem() t = t.Elem() } - // 确保是结构体 + // 确保是结构体类型 if v.Kind() != reflect.Struct { - return values, fmt.Errorf("expected struct, got %v", v.Kind()) + return nil, fmt.Errorf("input must be a struct or pointer to struct") } + // 遍历结构体字段 for i := 0; i < v.NumField(); i++ { - field := v.Field(i) - fieldType := t.Field(i) + field := t.Field(i) + fieldValue := v.Field(i) - // 跳过零值字段(omitempty) - tag := fieldType.Tag.Get("json") - if strings.Contains(tag, "omitempty") && field.IsZero() { + // 跳过非导出字段 + if !field.IsExported() { continue } - // 获取字段名 - fieldName := getFieldName(fieldType) + // 解析 JSON 标签(也可以支持 form 标签) + tag := field.Tag.Get("json") + fieldName, omitempty := parseJSONTag(tag) + if fieldName == "-" { + continue // 忽略该字段 + } if fieldName == "" { + fieldName = field.Name + } + + // 处理指针类型 + if fieldValue.Kind() == reflect.Ptr { + if fieldValue.IsNil() { + if omitempty { + continue + } + // 可以为 nil 指针添加空值 + values.Set(fieldName, "") + continue + } + fieldValue = fieldValue.Elem() + } + + // 处理切片/数组 + if fieldValue.Kind() == reflect.Slice || fieldValue.Kind() == reflect.Array { + if fieldValue.Len() == 0 && omitempty { + continue + } + + // 将切片转换为 URL 参数 + err := addSliceToValues(values, fieldName, fieldValue, opts) + if err != nil { + return nil, err + } continue } - // 处理不同类型的字段 - addFieldToValues(values, fieldName, field) + // 检查是否需要忽略空值 + if omitempty && isEmptyValue(fieldValue) { + continue + } + + // 转换单个值 + str, err := valueToString(fieldValue, opts) + if err != nil { + return nil, err + } + values.Set(fieldName, str) } return values, nil } -func getFieldName(field reflect.StructField) string { - tag := field.Tag.Get("json") - if tag != "" { - parts := strings.Split(tag, ",") - if parts[0] != "-" && parts[0] != "" { - return parts[0] - } - if parts[0] == "-" { - return "" // 跳过该字段 - } - } - return field.Name -} - -func addFieldToValues(values url.Values, name string, field reflect.Value) { - if !field.IsValid() || field.IsZero() { - return +// 解析 JSON 标签 +func parseJSONTag(tag string) (fieldName string, omitempty bool) { + if tag == "" { + return "", false } - switch field.Kind() { - case reflect.String: - values.Add(name, field.String()) + parts := strings.Split(tag, ",") + fieldName = parts[0] - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - values.Add(name, strconv.FormatInt(field.Int(), 10)) - - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - values.Add(name, strconv.FormatUint(field.Uint(), 10)) - - case reflect.Float32, reflect.Float64: - values.Add(name, strconv.FormatFloat(field.Float(), 'f', -1, 64)) - - case reflect.Bool: - values.Add(name, strconv.FormatBool(field.Bool())) - - case reflect.Slice: - // 处理切片,特别是 []string - if field.Type().Elem().Kind() == reflect.String { - for i := 0; i < field.Len(); i++ { - item := field.Index(i).String() - // 特殊处理 ct 字段 - if name == "ct" { - formatted := strings.Replace(item, " ", "+", 1) - if i == 1 && field.Len() >= 2 { - formatted = formatted + ".999" - } - values.Add("ct[]", formatted) - } else { - values.Add(fmt.Sprintf("%s[]", name), item) - } + if len(parts) > 1 { + for _, part := range parts[1:] { + if part == "omitempty" { + omitempty = true } } + } - case reflect.Struct: - // 处理 time.Time - if t, ok := field.Interface().(time.Time); ok { - values.Add(name, t.Format("2006-01-02+15:04:05")) + return fieldName, omitempty +} + +// 添加切片到 values +func addSliceToValues(values url.Values, fieldName string, slice reflect.Value, opts URLValuesOptions) error { + length := slice.Len() + if length == 0 { + return nil + } + + switch opts.ArrayFormat { + case "brackets": + // 格式:field[]=value1&field[]=value2 + for i := 0; i < length; i++ { + item := slice.Index(i) + str, err := valueToString(item, opts) + if err != nil { + return err + } + values.Add(fieldName, str) + } + + case "indices": + // 格式:field[0]=value1&field[1]=value2 + for i := 0; i < length; i++ { + item := slice.Index(i) + str, err := valueToString(item, opts) + if err != nil { + return err + } + values.Set(fmt.Sprintf("%s[%d]", fieldName, i), str) + } + + case "repeat": + // 格式:field=value1&field=value2 + for i := 0; i < length; i++ { + item := slice.Index(i) + str, err := valueToString(item, opts) + if err != nil { + return err + } + values.Add(fieldName, str) } default: - values.Add(name, fmt.Sprintf("%v", field.Interface())) + // 默认使用 brackets 格式 + for i := 0; i < length; i++ { + item := slice.Index(i) + str, err := valueToString(item, opts) + if err != nil { + return err + } + values.Add(fieldName+"[]", str) + } + } + + return nil +} + +// 将值转换为字符串 +func valueToString(v reflect.Value, opts URLValuesOptions) (string, error) { + if !v.IsValid() { + return "", nil + } + + // 处理不同类型 + switch v.Kind() { + case reflect.String: + return v.String(), nil + + case reflect.Bool: + return strconv.FormatBool(v.Bool()), nil + + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return strconv.FormatInt(v.Int(), 10), nil + + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return strconv.FormatUint(v.Uint(), 10), nil + + case reflect.Float32, reflect.Float64: + return strconv.FormatFloat(v.Float(), 'f', -1, 64), nil + + case reflect.Struct: + // 特殊处理 time.Time + if t, ok := v.Interface().(time.Time); ok { + return t.Format(opts.TimeFormat), nil + } + // 其他结构体递归处理 + // 这里可以扩展为递归处理嵌套结构体 + + default: + // 默认使用 fmt 的字符串表示 + return fmt.Sprintf("%v", v.Interface()), nil + } + + return fmt.Sprintf("%v", v.Interface()), nil +} + +// 检查值是否为空 +func isEmptyValue(v reflect.Value) bool { + switch v.Kind() { + case reflect.String: + return v.String() == "" + case reflect.Bool: + return false + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return v.Uint() == 0 + case reflect.Float32, reflect.Float64: + return v.Float() == 0 + case reflect.Slice, reflect.Array, reflect.Map: + return v.Len() == 0 + case reflect.Ptr, reflect.Interface: + return v.IsNil() + case reflect.Struct: + if t, ok := v.Interface().(time.Time); ok { + return t.IsZero() + } + return false + default: + return false } } + +// 方便函数:直接生成查询字符串 +func StructToQueryString(input interface{}, options ...URLValuesOptions) (string, error) { + values, err := StructToURLValues(input, options...) + if err != nil { + return "", err + } + return values.Encode(), nil +} diff --git a/internal/pkg/provider_set.go b/internal/pkg/provider_set.go index 1e8bdb4..dcb5d8f 100644 --- a/internal/pkg/provider_set.go +++ b/internal/pkg/provider_set.go @@ -2,9 +2,9 @@ package pkg import ( "ai_scheduler/internal/pkg/dingtalk" - "ai_scheduler/internal/pkg/oss" "ai_scheduler/internal/pkg/utils_langchain" "ai_scheduler/internal/pkg/utils_ollama" + "ai_scheduler/internal/pkg/utils_oss" "ai_scheduler/internal/pkg/utils_vllm" "github.com/google/wire" @@ -21,5 +21,5 @@ var ProviderSetClient = wire.NewSet( dingtalk.NewContactClient, dingtalk.NewNotableClient, - oss.NewClient, + utils_oss.NewClient, ) diff --git a/internal/pkg/oss/client.go b/internal/pkg/utils_oss/client.go similarity index 84% rename from internal/pkg/oss/client.go rename to internal/pkg/utils_oss/client.go index 225e8d9..6db558e 100644 --- a/internal/pkg/oss/client.go +++ b/internal/pkg/utils_oss/client.go @@ -1,4 +1,4 @@ -package oss +package utils_oss import ( "ai_scheduler/internal/config" @@ -16,19 +16,19 @@ type Client struct { } // NewClient 初始化 OSS 客户端 -func NewClient(cfg config.Oss) (*Client, error) { - client, err := oss.New(cfg.Endpoint, cfg.AccessKey, cfg.SecretKey) +func NewClient(cfg *config.Config) (*Client, error) { + client, err := oss.New(cfg.Oss.Endpoint, cfg.Oss.AccessKey, cfg.Oss.SecretKey) if err != nil { return nil, fmt.Errorf("oss new client failed: %v", err) } - bucket, err := client.Bucket(cfg.Bucket) + bucket, err := client.Bucket(cfg.Oss.Bucket) if err != nil { return nil, fmt.Errorf("oss get bucket failed: %v", err) } return &Client{ - config: cfg, + config: cfg.Oss, client: client, bucket: bucket, }, nil diff --git a/internal/services/cron.go b/internal/services/cron.go index 5f624d1..3f1eea8 100644 --- a/internal/services/cron.go +++ b/internal/services/cron.go @@ -3,10 +3,9 @@ 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" + "github.com/gofiber/fiber/v2/log" ) type CronService struct { @@ -22,22 +21,23 @@ func NewCronService(config *config.Config, dingTalkBotBiz *biz.DingTalkBotBiz) * } func (d *CronService) CronReportSend(ctx context.Context) error { - reportChan, err := d.dingTalkBotBiz.GetReportLists(ctx) + reports, err := d.dingTalkBotBiz.GetReportLists(ctx) if err != nil { return err } - groupId := 23 + groupId := 28 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) + //contentChan <- "截止今日23点利润亏损合计:127917.0866元,亏损500元以上的分销商和产品金额如下图:" + //contentChan <- "![图片](https://lsxdmgoss.oss-cn-chengdu.aliyuncs.com/MarketingSaaS/image/V2/other/shanghu.png)" + for _, report := range reports { + err = d.dingTalkBotBiz.SendReport(ctx, groupInfo, report) + if err != nil { + log.Error(err) + continue + } + } return nil } diff --git a/internal/services/dtalk_bot.go b/internal/services/dtalk_bot.go index 4635b63..b71e40b 100644 --- a/internal/services/dtalk_bot.go +++ b/internal/services/dtalk_bot.go @@ -3,7 +3,6 @@ package services import ( "ai_scheduler/internal/biz" "ai_scheduler/internal/config" - "ai_scheduler/internal/data/constants" "ai_scheduler/internal/entitys" "context" "log" @@ -136,24 +135,3 @@ 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_test.go b/internal/services/dtalk_bot_test.go index 2b8223f..10f027d 100644 --- a/internal/services/dtalk_bot_test.go +++ b/internal/services/dtalk_bot_test.go @@ -15,6 +15,7 @@ import ( "ai_scheduler/internal/pkg" "ai_scheduler/internal/pkg/dingtalk" "ai_scheduler/internal/pkg/utils_ollama" + "ai_scheduler/internal/pkg/utils_oss" "ai_scheduler/internal/pkg/utils_vllm" "ai_scheduler/internal/tools" @@ -27,7 +28,7 @@ import ( func Test_Report(t *testing.T) { run() - a := dingBotService.CronReportSend(context.Background()) + a := cronService.CronReportSend(context.Background()) t.Log(a) } @@ -35,6 +36,7 @@ var ( configConfig *config.Config err error dingBotService *DingBotService + cronService *CronService ) // run 函数是程序的入口函数,负责初始化和配置各个组件 @@ -99,7 +101,9 @@ func run() { // 初始化处理器 handle := do.NewHandle(ollamaService, manager, configConfig, sessionImpl, registry, oldClient, contactClient, notableClient) // 初始化钉钉机器人业务逻辑 - dingTalkBotBiz := biz.NewDingTalkBotBiz(doDo, handle, botConfigImpl, botGroupImpl, user, toolRegis, botChatHisImpl, manager, configConfig, sendCardClient) + utils_ossClient, _ := utils_oss.NewClient(configConfig) + dingTalkBotBiz := biz.NewDingTalkBotBiz(doDo, handle, botConfigImpl, botGroupImpl, user, toolRegis, botChatHisImpl, manager, configConfig, sendCardClient, utils_ossClient) // 初始化钉钉机器人服务 + cronService = NewCronService(configConfig, dingTalkBotBiz) dingBotService = NewDingBotService(configConfig, dingTalkBotBiz) } diff --git a/internal/tools/bbxt/api.go b/internal/tools/bbxt/api.go index b07ce1e..8b6c823 100644 --- a/internal/tools/bbxt/api.go +++ b/internal/tools/bbxt/api.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/url" + "strings" ) @@ -40,10 +41,10 @@ func StatisOursProductLossSumApi(param *StatisOursProductLossSumReq) (*StatisOur } type GetProfitRankingSumRequest struct { - Ct []string `protobuf:"bytes,1,rep,name=ct,proto3" json:"ct,omitempty"` - Page int32 `protobuf:"varint,3,opt,name=page,proto3" json:"page,omitempty"` - Limit int32 `protobuf:"varint,4,opt,name=limit,proto3" json:"limit,omitempty"` - ResellerIds []int32 `protobuf:"varint,5,rep,packed,name=reseller_ids,json=resellerIds,proto3" json:"reseller_ids,omitempty"` + Ct []string `json:"ct,omitempty"` + Page int32 `json:"page,omitempty"` + Limit int32 `json:"limit,omitempty"` + ResellerIds []int32 `json:"reseller_ids,omitempty"` } type GetProfitRankingSumResponse struct { @@ -87,18 +88,18 @@ type GetStatisOfficialProductSumRequest struct { } type GetStatisOfficialProductSumResponse struct { - OfficialProductSum []*GetStatisOfficialProductSum `protobuf:"bytes,1,rep,name=official_product_sum,json=officialProductSum,proto3" json:"official_product_sum,omitempty"` - DataCount int32 `protobuf:"varint,2,opt,name=data_count,json=dataCount,proto3" json:"data_count,omitempty"` + OfficialProductSum []*GetStatisOfficialProductSum `protobuf:"bytes,1,rep,name=OfficialProductSum,json=officialProductSum,proto3" json:"officialProductSum,omitempty"` + DataCount int32 `protobuf:"varint,2,opt,name=DataCount,json=dataCount,proto3" json:"dataCount,omitempty"` } type GetStatisOfficialProductSum struct { - OfficialProductId int32 `protobuf:"varint,1,opt,name=official_product_id,json=officialProductId,proto3" json:"official_product_id,omitempty"` - OfficialProductName string `protobuf:"bytes,2,opt,name=official_product_name,json=officialProductName,proto3" json:"official_product_name,omitempty"` - CurrentNum int32 `protobuf:"varint,3,opt,name=current_num,json=currentNum,proto3" json:"current_num,omitempty"` - HistoryOneNum int32 `protobuf:"varint,4,opt,name=history_one_num,json=historyOneNum,proto3" json:"history_one_num,omitempty"` - HistoryTwoNum int32 `protobuf:"varint,5,opt,name=history_two_num,json=historyTwoNum,proto3" json:"history_two_num,omitempty"` - HistoryOneDiff int32 `protobuf:"varint,6,opt,name=history_one_diff,json=historyOneDiff,proto3" json:"history_one_diff,omitempty"` - HistoryTwoDiff int32 `protobuf:"varint,7,opt,name=history_two_diff,json=historyTwoDiff,proto3" json:"history_two_diff,omitempty"` + OfficialProductId int32 `protobuf:"varint,1,opt,name=official_product_id,json=officialProductId,proto3" json:"officialProductId,omitempty"` + OfficialProductName string `protobuf:"bytes,2,opt,name=official_product_name,json=officialProductName,proto3" json:"officialProductName,omitempty"` + CurrentNum int32 `protobuf:"varint,3,opt,name=current_num,json=currentNum,proto3" json:"currentNum,omitempty"` + HistoryOneNum int32 `protobuf:"varint,4,opt,name=history_one_num,json=historyOneNum,proto3" json:"historyOneNum,omitempty"` + HistoryTwoNum int32 `protobuf:"varint,5,opt,name=history_two_num,json=historyTwoNum,proto3" json:"historyTwoNum,omitempty"` + HistoryOneDiff int32 `protobuf:"varint,6,opt,name=history_one_diff,json=historyOneDiff,proto3" json:"historyOneDiff,omitempty"` + HistoryTwoDiff int32 `protobuf:"varint,7,opt,name=history_two_diff,json=historyTwoDiff,proto3" json:"historyTwoDiff,omitempty"` } // GetStatisOfficialProductSumApi 销量同比分析 @@ -169,16 +170,19 @@ func GetStatisFilterOfficialProductApi(param *GetStatisFilterOfficialProductRequ func request(url string, reqData interface{}, resData interface{}) error { - reqParam, err := pkg.StructToQuery(reqData) + reqParam, err := pkg.StructToURLValues(reqData) if err != nil { return err } req := &l_request.Request{ - Url: Base + url + "?" + customEncode(reqParam), + Url: FormatPHPURL(Base+url, reqParam), Method: http.MethodGet, } res, err := req.Send() + if err != nil { + return err + } if res.StatusCode != http.StatusOK { return fmt.Errorf("request failed, status code: %d,resion: %s", res.StatusCode, res.Reason) } @@ -195,13 +199,50 @@ func request(url string, reqData interface{}, resData interface{}) error { return nil } -func customEncode(params url.Values) string { - encoded := params.Encode() +// FormatPHPURL 将 url.Values 格式化为 PHP 风格的 URL +// 输入:基础URL和url.Values参数 +// 输出:PHP风格的URL字符串 +func FormatPHPURL(baseURL string, values url.Values) string { + if values == nil || len(values) == 0 { + return baseURL + } - // 解码我们想要保留的字符 - encoded = strings.ReplaceAll(encoded, "%5B", "[") // 恢复 [ - encoded = strings.ReplaceAll(encoded, "%5D", "]") // 恢复 ] - encoded = strings.ReplaceAll(encoded, "%2B", "+") // 恢复 + + var queryParts []string - return encoded + // 遍历所有参数 + for key, paramValues := range values { + // 检查这个key是否有多个值(数组参数) + if len(paramValues) > 1 { + // 多值参数,使用PHP数组格式:key[]=value + for _, value := range paramValues { + if value != "" { + // 编码值 + encodedValue := url.QueryEscape(value) + // 使用PHP数组格式 + queryParts = append(queryParts, fmt.Sprintf("%s[]=%s", key, encodedValue)) + } + } + } else if len(paramValues) == 1 && paramValues[0] != "" { + // 单值参数 + encodedValue := url.QueryEscape(paramValues[0]) + queryParts = append(queryParts, fmt.Sprintf("%s=%s", key, encodedValue)) + } + } + + if len(queryParts) == 0 { + return baseURL + } + + // 构建查询字符串 + query := strings.Join(queryParts, "&") + + // 转换为PHP风格:解码中括号和冒号 + query = strings.ReplaceAll(query, "%5B", "[") + query = strings.ReplaceAll(query, "%5D", "]") + query = strings.ReplaceAll(query, "%3A", ":") + + // 注意:保留空格为+号(这是PHP的常见格式) + // query = strings.ReplaceAll(query, "+", "%20") // 如果需要将+转为%20,可以取消注释 + + return baseURL + "?" + query } diff --git a/internal/tools/bbxt/bbxt.go b/internal/tools/bbxt/bbxt.go index a5c2aa3..3a1619d 100644 --- a/internal/tools/bbxt/bbxt.go +++ b/internal/tools/bbxt/bbxt.go @@ -1,27 +1,28 @@ package bbxt import ( - "ai_scheduler/internal/pkg/oss" + "ai_scheduler/internal/pkg/utils_oss" "ai_scheduler/pkg" "fmt" - "reflect" - "regexp" "math/rand" - "sort" - "strings" - "time" - "github.com/go-kratos/kratos/v2/log" - "github.com/xuri/excelize/v2" + "sort" + + "time" +) + +const ( + RedStyle = "${color: FF0000;horizontal:center;vertical:center;borderColor:#000000}" + GreenStyle = "${color: 00B050;horizontal:center;vertical:center;borderColor:#000000}" ) type BbxtTools struct { cacheDir string excelTempDir string - ossClient *oss.Client + ossClient *utils_oss.Client } -func NewBbxtTools(ossClient *oss.Client) (*BbxtTools, error) { +func NewBbxtTools() (*BbxtTools, error) { cache, err := pkg.GetCacheDir() if err != nil { return nil, err @@ -34,24 +35,42 @@ func NewBbxtTools(ossClient *oss.Client) (*BbxtTools, error) { return &BbxtTools{ cacheDir: cache, excelTempDir: fmt.Sprintf("%s/excel_temp", tempDir), - ossClient: ossClient, }, nil } -func (b *BbxtTools) DailyReport(now time.Time) (err error) { - - err = b.StatisOursProductLossSum([]string{ - time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Format("2006-01-02 15:04:05"), - time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).Format("2006-01-02 15:04:05"), - }) +func (b *BbxtTools) DailyReport(now time.Time, productName []string, ossClient *utils_oss.Client) (reports []*ReportRes, err error) { + reports = make([]*ReportRes, 0, 4) + productLossReport, err := b.StatisOursProductLossSum(now) if err != nil { return } + profitRankingSum, err := b.GetProfitRankingSum(now) + if err != nil { + return + } + statisOfficialProductSum, err := b.GetStatisOfficialProductSum(now, productName) + if err != nil { + return + } + reports = append(reports, productLossReport...) + reports = append(reports, statisOfficialProductSum, profitRankingSum) + uploader := NewUploader(ossClient) + if ossClient != nil { + for _, report := range reports { + _ = uploader.Run(report) + } + } + return } -// StatisOursProductLossSumTotal 负利润分析 -func (b *BbxtTools) StatisOursProductLossSum(ct []string) (err error) { +// StatisOursProductLossSum 负利润分析 +func (b *BbxtTools) StatisOursProductLossSum(now time.Time) (report []*ReportRes, err error) { + ct := []string{ + time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Format("2006-01-02 15:04:05"), + adjustedTime(time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location())), + } + data, err := StatisOursProductLossSumApi(&StatisOursProductLossSumReq{ Ct: ct, }) @@ -106,7 +125,10 @@ func (b *BbxtTools) StatisOursProductLossSum(ct []string) (err error) { sort.Slice(resellers, func(i, j int) bool { return resellers[i].Total < resellers[j].Total }) - + var ( + totalSum float64 + totalSum500 float64 + ) // 构建分组 for _, v := range resellers { if v.Total <= -100 { @@ -117,27 +139,48 @@ func (b *BbxtTools) StatisOursProductLossSum(ct []string) (err error) { } if v.Total <= -500 { gt = append(gt, v) + totalSum500 += v.Total } + totalSum += v.Total } + report = make([]*ReportRes, 2) + //总量生成excel if len(total) > 0 { filePath := b.cacheDir + "/kshj_total" + fmt.Sprintf("%d%d", time.Now().Unix(), rand.Intn(1000)) + ".xlsx" - err = b.SimpleFillExcel(b.excelTempDir+"/"+"kshj_total.xlsx", filePath, total) + err = b.SimpleFillExcelWithTitle(b.excelTempDir+"/"+"kshj_total.xlsx", filePath, total, "") + report[0] = &ReportRes{ + ReportName: "负利润分析(合计表)", + Title: "截至今日23点利润累计亏损" + fmt.Sprintf("%.2f", totalSum), + Path: filePath, + Data: total, + } + } + if err != nil { + return } - if len(gt) > 0 { filePath := b.cacheDir + "/kshj_gt" + fmt.Sprintf("%d%d", time.Now().Unix(), rand.Intn(1000)) + ".xlsx" - // err = b.resellerDetailFillExcel(b.excelTempDir+"/"+"kshj_gt.xlsx", filePath, gt) err = b.resellerDetailFillExcelV2(b.excelTempDir+"/"+"kshj_gt.xlsx", filePath, gt) + report[1] = &ReportRes{ + ReportName: "负利润分析(亏损500以上)", + Title: "截至今日23点亏顺500以上利润累计亏损" + fmt.Sprintf("%.2f", totalSum500), + Path: filePath, + Data: total, + } } - return err + if err != nil { + return + } + return report, nil } // GetProfitRankingSum 利润同比分销商排行榜 -func (b *BbxtTools) GetProfitRankingSum(now time.Time) (err error) { +func (b *BbxtTools) GetProfitRankingSum(now time.Time) (report *ReportRes, err error) { + ct := []string{ time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Format("2006-01-02 15:04:05"), - now.Format(time.DateTime), + adjustedTime(now), } data, err := GetProfitRankingSumApi(&GetProfitRankingSumRequest{ @@ -166,9 +209,9 @@ func (b *BbxtTools) GetProfitRankingSum(now time.Time) (err error) { for _, v := range top { var diff string if v.HistoryOneDiff > 0 { - diff = fmt.Sprintf("${color: FF0000;horizontal:center;vertical:center}↑%.4f", v.HistoryOneDiff) + diff = fmt.Sprintf("%s↑%.4f", RedStyle, v.HistoryOneDiff) } else { - diff = fmt.Sprintf("${color: 00B050;horizontal:center;vertical:center}↓%.4f", v.HistoryOneDiff) + diff = fmt.Sprintf("%s↓%.4f", GreenStyle, v.HistoryOneDiff) } total = append(total, []string{ fmt.Sprintf("%s", v.ResellerName), @@ -178,18 +221,25 @@ func (b *BbxtTools) GetProfitRankingSum(now time.Time) (err error) { }) } //总量生成excel - if len(total) > 0 { - filePath := b.cacheDir + "/lrtb_rank" + fmt.Sprintf("%d", time.Now().Unix()) + ".xlsx" - err = b.SimpleFillExcelWithTitle(b.excelTempDir+"/"+"lrtb_rank.xlsx", filePath, total, title) + if len(total) == 0 { + return } - return err + filePath := b.cacheDir + "/lrtb_rank" + fmt.Sprintf("%d", time.Now().Unix()) + ".xlsx" + err = b.SimpleFillExcelWithTitle(b.excelTempDir+"/"+"lrtb_rank.xlsx", filePath, total, title) + return &ReportRes{ + ReportName: "利润同比分销商排行榜", + Title: title, + Path: filePath, + Data: total, + }, err } // GetStatisOfficialProductSum 利润同比分销商排行榜 -func (b *BbxtTools) GetStatisOfficialProductSum(now time.Time, productName []string) (err error) { +func (b *BbxtTools) GetStatisOfficialProductSum(now time.Time, productName []string) (report *ReportRes, err error) { + ct := []string{ time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Format("2006-01-02 15:04:05"), - now.Format(time.DateTime), + adjustedTime(now), } var ids []int32 if len(productName) > 0 { @@ -215,14 +265,14 @@ func (b *BbxtTools) GetStatisOfficialProductSum(now time.Time, productName []str lastWeekDiff string ) if v.HistoryOneDiff > 0 { - yeterDatyDiff = fmt.Sprintf("${color: FF0000;horizontal:center;vertical:center}↑%d", v.HistoryOneDiff) + yeterDatyDiff = fmt.Sprintf("%s↑%d", RedStyle, v.HistoryOneDiff) } else { - yeterDatyDiff = fmt.Sprintf("${color: 00B050;horizontal:center;vertical:center}↓%d", v.HistoryOneDiff) + yeterDatyDiff = fmt.Sprintf("%s↓%d", GreenStyle, v.HistoryOneDiff) } if v.HistoryTwoDiff > 0 { - lastWeekDiff = fmt.Sprintf("${color: FF0000;horizontal:center;vertical:center}↑%d", v.HistoryTwoDiff) + lastWeekDiff = fmt.Sprintf("%s↑%d", RedStyle, v.HistoryTwoDiff) } else { - lastWeekDiff = fmt.Sprintf("${color: 00B050;horizontal:center;vertical:center}↓%d", v.HistoryTwoDiff) + lastWeekDiff = fmt.Sprintf("%s↓%d", GreenStyle, v.HistoryTwoDiff) } total = append(total, []string{ fmt.Sprintf("%s", v.OfficialProductName), @@ -237,11 +287,17 @@ func (b *BbxtTools) GetStatisOfficialProductSum(now time.Time, productName []str timeCh := now.Format("1月2日15点") title := "截至" + timeCh + "销售同比分析" //总量生成excel - if len(total) > 0 { - filePath := b.cacheDir + "/xstb_ana" + fmt.Sprintf("%d", time.Now().Unix()) + ".xlsx" - err = b.SimpleFillExcelWithTitle(b.excelTempDir+"/"+"xstb_ana.xlsx", filePath, total, title) + if len(total) == 0 { + return } - return err + filePath := b.cacheDir + "/xstb_ana" + fmt.Sprintf("%d", time.Now().Unix()) + ".xlsx" + err = b.SimpleFillExcelWithTitle(b.excelTempDir+"/"+"xstb_ana.xlsx", filePath, total, title) + return &ReportRes{ + ReportName: "利润同比分销商排行榜", + Title: title, + Path: filePath, + Data: total, + }, err } func (b *BbxtTools) getProductIdFromProductName(productNames []string) ([]int32, error) { @@ -262,149 +318,11 @@ func (b *BbxtTools) getProductIdFromProductName(productNames []string) ([]int32, return ids, nil } -func (b *BbxtTools) SimpleFillExcelWithTitle(templatePath, outputPath string, dataSlice interface{}, title string) error { - // 1. 打开模板 - f, err := excelize.OpenFile(templatePath) - if err != nil { - return err - } - defer f.Close() - - sheet := f.GetSheetName(0) - - // 1.1 获取第三行模板样式 - templateRow := 3 - styleID, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", templateRow)) - if err != nil { - log.Errorf("获取模板样式失败: %v", err) - styleID = 0 - } - - // 1.2 获取模板行高 - rowHeight, err := f.GetRowHeight(sheet, templateRow) - if err != nil { - log.Errorf("获取模板行高失败: %v", err) - rowHeight = 31 // 默认高度 - } - - // 2. 写入标题到第一行 - f.SetCellValue(sheet, "A1", title) - - // 3. 反射获取切片数据 - v := reflect.ValueOf(dataSlice) - if v.Kind() != reflect.Slice { - return fmt.Errorf("dataSlice must be a slice") - } - - if v.Len() == 0 { - return nil - } - - // 4. 从第三行开始填充数据(第二行留空或作为标题行) - startRow := 3 - pattern := `\$\{(.*?)\}` - re := regexp.MustCompile(pattern) - for i := 0; i < v.Len(); i++ { - currentRow := startRow + i - - // 获取当前行数据 - item := v.Index(i) - - // 处理不同类型的切片 - var rowData []interface{} - - if item.Kind() == reflect.Slice || item.Kind() == reflect.Array { - // 处理 []string 或 [][]string 中的一行 - for j := 0; j < item.Len(); j++ { - if item.Index(j).CanInterface() { - rowData = append(rowData, item.Index(j).Interface()) - } - } - } else if item.Kind() == reflect.Interface { - // 处理 interface{} 类型 - if actualValue, ok := item.Interface().([]string); ok { - for _, val := range actualValue { - rowData = append(rowData, val) - } - } else { - rowData = []interface{}{item.Interface()} - } - } else { - rowData = []interface{}{item.Interface()} - } - - // 4.1 设置行高 - f.SetRowHeight(sheet, currentRow, rowHeight) - - // 5. 填充数据到Excel - for col, value := range rowData { - cell := fmt.Sprintf("%c%d", 'A'+col, currentRow) - // 5.1 应用模板样式到整行(根据实际列数) - if styleID != 0 && len(rowData) > 0 { - startCol := "A" - endCol := fmt.Sprintf("%c", 'A'+len(rowData)-1) - endCell := fmt.Sprintf("%s%d", endCol, currentRow) - - f.SetCellStyle(sheet, fmt.Sprintf("%s%d", startCol, currentRow), - endCell, styleID) - } - switch value.(type) { - case string: - var style = value.(string) - if re.MatchString(style) { - matches := re.FindStringSubmatch(style) - styleMap := make(map[string]string) - //matches = strings.Replace(matches, "$", "", 1) - if len(matches) != 2 { - continue - } - for _, kv := range strings.Split(matches[1], ";") { - kvParts := strings.Split(kv, ":") - if len(kvParts) == 2 { - styleMap[strings.TrimSpace(kvParts[0])] = strings.TrimSpace(kvParts[1]) - } - } - fontStyleID, _err := SetStyle(styleMap, f) - if _err == nil { - f.SetCellStyle(sheet, cell, cell, fontStyleID) - } - - value = re.ReplaceAllString(style, "") - - } - f.SetCellValue(sheet, cell, value) - default: - - } - } - - } - - // 6. 保存 - return f.SaveAs(outputPath) -} - -func SetStyle(styleMap map[string]string, f *excelize.File) (int, error) { - - var style = &excelize.Style{} - if colorHex, exists := styleMap["color"]; exists { - style.Font = &excelize.Font{ - Color: colorHex, - } - } - if horizontal, exists := styleMap["horizontal"]; exists { - if style.Alignment == nil { - style.Alignment = &excelize.Alignment{} - } - style.Alignment.Horizontal = horizontal - } - - if vertical, exists := styleMap["vertical"]; exists { - if style.Alignment == nil { - style.Alignment = &excelize.Alignment{} - } - style.Alignment.Vertical = vertical - } - - return f.NewStyle(style) +func adjustedTime(t time.Time) string { + adjusted := time.Date( + t.Year(), t.Month(), t.Day(), + t.Hour(), t.Minute(), 59, 999_000_000, + t.Location(), + ) + return adjusted.Format("2006-01-02 15:04:05.999") } diff --git a/internal/tools/bbxt/bbxt_test.go b/internal/tools/bbxt/bbxt_test.go index e3cfb73..4a5f700 100644 --- a/internal/tools/bbxt/bbxt_test.go +++ b/internal/tools/bbxt/bbxt_test.go @@ -2,30 +2,44 @@ package bbxt import ( "ai_scheduler/internal/config" - "ai_scheduler/internal/pkg/oss" + "ai_scheduler/internal/pkg/utils_oss" "testing" "time" ) func Test_StatisOursProductLossSumApiTotal(t *testing.T) { - ossClient, err := oss.NewClient(config.Oss{ - AccessKey: "LTAI5tGGZzjf3tvqWk8SQj2G", - SecretKey: "S0NKOAUaYWoK4EGSxrMFmYDzllhvpq", - Bucket: "attachment-public", - Domain: "https://attachment-public.oss-cn-hangzhou.aliyuncs.com", - Endpoint: "https://oss-cn-hangzhou.aliyuncs.com", - }) + var config = &config.Config{ + Oss: config.Oss{ + AccessKey: "LTAI5tGGZzjf3tvqWk8SQj2G", + SecretKey: "S0NKOAUaYWoK4EGSxrMFmYDzllhvpq", + Bucket: "attachment-public", + Domain: "https://attachment-public.oss-cn-hangzhou.aliyuncs.com", + Endpoint: "https://oss-cn-hangzhou.aliyuncs.com", + }, + } + + ossClient, err := utils_oss.NewClient(config) if err != nil { panic(err) } - - o, err := NewBbxtTools(ossClient) + o, err := NewBbxtTools() if err != nil { panic(err) } - err = o.DailyReport(time.Date(2025, 12, 30, 0, 0, 0, 0, time.Local)) + reports, err := o.DailyReport(time.Now(), []string{"官方-爱奇艺-星钻季卡", "官方-爱奇艺-星钻半年卡", "官方--腾讯-年卡", "官方--爱奇艺-月卡"}, ossClient) - t.Log(err) + t.Log(reports, err) + +} + +func Test_StatisOursProductLossSum(t *testing.T) { + o, err := NewBbxtTools() + if err != nil { + panic(err) + } + report, err := o.StatisOursProductLossSum(time.Now()) + + t.Log(report, err) } @@ -34,9 +48,9 @@ func Test_GetProfitRankingSum(t *testing.T) { if err != nil { panic(err) } - err = o.GetProfitRankingSum(time.Now()) + report, err := o.GetProfitRankingSum(time.Now()) - t.Log(err) + t.Log(report, err) } @@ -45,8 +59,8 @@ func Test_GetStatisOfficialProductSum(t *testing.T) { if err != nil { panic(err) } - err = o.GetStatisOfficialProductSum(time.Now(), []string{"官方-爱奇艺-星钻季卡", "官方-爱奇艺-星钻半年卡", "官方--腾讯-年卡", "官方--爱奇艺-月卡"}) + report, err := o.GetStatisOfficialProductSum(time.Now(), []string{"官方-爱奇艺-星钻季卡", "官方-爱奇艺-星钻半年卡", "官方--腾讯-年卡", "官方--爱奇艺-月卡"}) - t.Log(err) + t.Log(report, err) } diff --git a/internal/tools/bbxt/entitys.go b/internal/tools/bbxt/entitys.go index a3dc743..d886dd6 100644 --- a/internal/tools/bbxt/entitys.go +++ b/internal/tools/bbxt/entitys.go @@ -12,3 +12,12 @@ type ProductLoss struct { ProductName string Loss float64 } + +type ReportRes struct { + ReportName string + Title string + Path string + Url string + Data [][]string + Desc string +} diff --git a/internal/tools/bbxt/excel.go b/internal/tools/bbxt/excel.go index f5b52bf..b6b1b6c 100644 --- a/internal/tools/bbxt/excel.go +++ b/internal/tools/bbxt/excel.go @@ -1,14 +1,9 @@ package bbxt import ( - "bytes" "fmt" - "io" - "mime/multipart" - "net/http" - "os" - "path/filepath" "reflect" + "regexp" "sort" "strings" @@ -17,9 +12,9 @@ import ( "github.com/xuri/excelize/v2" ) -// 最简单的通用函数 -func (b *BbxtTools) SimpleFillExcel(templatePath, outputPath string, dataSlice interface{}) error { - // 1. 打开模板 +func (b *BbxtTools) SimpleFillExcelWithTitle(templatePath, outputPath string, dataSlice interface{}, title string) error { + // 打开模板 + f, err := excelize.OpenFile(templatePath) if err != nil { return err @@ -27,175 +22,155 @@ func (b *BbxtTools) SimpleFillExcel(templatePath, outputPath string, dataSlice i 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 + startLen := 2 + if len(title) > 0 { + // 写入标题 + f.SetCellValue(sheet, "A1", title) + startLen = 3 } - // 1.2 获取分销商总计行高 - rowHeightReseller, err := f.GetRowHeight(sheet, resellerTplRow) + // 获取模板样式 + templateRow := startLen + styleID, err := f.GetCellStyle(sheet, fmt.Sprintf("A%d", templateRow)) if err != nil { - log.Errorf("获取分销商总计行高失败: %v", err) - rowHeightReseller = 31 // 默认高度 + log.Errorf("获取模板样式失败: %v", err) + styleID = 0 } - // 2. 反射获取切片数据 + // 获取模板行高 + rowHeight, err := f.GetRowHeight(sheet, templateRow) + if err != nil { + log.Errorf("获取模板行高失败: %v", err) + rowHeight = 31 // 默认高度 + } + + // 反射获取切片数据 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 + if v.Len() == 0 { + return nil + } - // 4. 将item转换为一行数据 + // 从第三行开始填充数据(第二行留空或作为标题行) + startRow := startLen + pattern := `\$\{(.*?)\}` + re := regexp.MustCompile(pattern) + for i := 0; i < v.Len(); i++ { + currentRow := startRow + i + + // 获取当前行数据 + item := v.Index(i) + + // 处理不同类型的切片 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()) + if item.Kind() == reflect.Slice || item.Kind() == reflect.Array { + // 处理 []string 或 [][]string 中的一行 + for j := 0; j < item.Len(); j++ { + if item.Index(j).CanInterface() { + rowData = append(rowData, item.Index(j).Interface()) } } + } else if item.Kind() == reflect.Interface { + // 处理 interface{} 类型 + if actualValue, ok := item.Interface().([]string); ok { + for _, val := range actualValue { + rowData = append(rowData, val) + } + } else { + rowData = []interface{}{item.Interface()} + } } else { - rowData = []interface{}{item} + rowData = []interface{}{item.Interface()} } - // 4.1 设置行高 - f.SetRowHeight(sheet, currentRow, rowHeightReseller) - // 5. 填充到Excel + // 4.1 设置行高 + f.SetRowHeight(sheet, currentRow, rowHeight) + + // 应用模板样式到整行(根据实际列数) + if styleID != 0 && len(rowData) > 0 { + startCol := "A" + endCol := fmt.Sprintf("%c", 'A'+len(rowData)-1) + endCell := fmt.Sprintf("%s%d", endCol, currentRow) + + f.SetCellStyle(sheet, fmt.Sprintf("%s%d", startCol, currentRow), + endCell, styleID) + } + // 填充数据到Excel for col, value := range rowData { cell := fmt.Sprintf("%c%d", 'A'+col, currentRow) - f.SetCellValue(sheet, cell, value) + + switch value.(type) { + case string: + var style = value.(string) + if re.MatchString(style) { + matches := re.FindStringSubmatch(style) + styleMap := make(map[string]string) + //matches = strings.Replace(matches, "$", "", 1) + if len(matches) != 2 { + continue + } + for _, kv := range strings.Split(matches[1], ";") { + kvParts := strings.Split(kv, ":") + if len(kvParts) == 2 { + styleMap[strings.TrimSpace(kvParts[0])] = strings.TrimSpace(kvParts[1]) + } + } + fontStyleID, _err := SetStyle(styleMap, f) + if _err == nil { + f.SetCellStyle(sheet, cell, cell, fontStyleID) + } + + value = re.ReplaceAllString(style, "") + + } + f.SetCellValue(sheet, cell, value) + default: + + } } - // 5.1 使用第二行模板样式 - if styleIDReseller != 0 { - f.SetCellStyle(sheet, fmt.Sprintf("A%d", currentRow), fmt.Sprintf("B%d", currentRow), styleIDReseller) - } } - - 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) // 本地生成图片,仅测试 - // outputPath 提取文件名(不包含扩展名) - filename := filepath.Base(outputPath) - filename = strings.TrimSuffix(filename, filepath.Ext(filename)) - imgUrl := b.uploadToOSS(filename, picBytes) - log.Infof("imgUrl: %s", imgUrl) - - // 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() +func SetStyle(styleMap map[string]string, f *excelize.File) (int, error) { - 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++ + var style = &excelize.Style{} + // 设置字体颜色 + if colorHex, exists := styleMap["color"]; exists { + style.Font = &excelize.Font{ + Color: colorHex, } } + // 设置水平对齐 + if horizontal, exists := styleMap["horizontal"]; exists { + if style.Alignment == nil { + style.Alignment = &excelize.Alignment{} + } + style.Alignment.Horizontal = horizontal + } + // 设置垂直对齐 + if vertical, exists := styleMap["vertical"]; exists { + if style.Alignment == nil { + style.Alignment = &excelize.Alignment{} + } + style.Alignment.Vertical = vertical + } - // 6. 保存 - return f.SaveAs(outputPath) + // 设置边框(新增) + if borderColor, exists := styleMap["borderColor"]; exists { + style.Border = []excelize.Border{ + {Type: "left", Color: borderColor, Style: 1}, // 左边框 + {Type: "right", Color: borderColor, Style: 1}, // 右边框 + {Type: "top", Color: borderColor, Style: 1}, // 上边框 + {Type: "bottom", Color: borderColor, Style: 1}, // 下边框 + } + } + return f.NewStyle(style) } // 分销商负利润详情填充excel-V2 @@ -327,118 +302,6 @@ 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) // 本地生成图片,仅测试 - // outputPath 提取文件名(不包含扩展名) - filename := filepath.Base(outputPath) - filename = strings.TrimSuffix(filename, filepath.Ext(filename)) - imgUrl := b.uploadToOSS(filename, picBytes) - log.Infof("imgUrl: %s", imgUrl) - // 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) { - // 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() - } - - // 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) -} - -// uploadToOSS 上传至 oss 返回图片url -func (b *BbxtTools) uploadToOSS(fileName string, fileBytes []byte) string { - objectKey := fmt.Sprintf("ai-scheduler/data-analytics/images/%s.png", fileName) - url, err := b.ossClient.UploadBytes(objectKey, fileBytes) - if err != nil { - log.Errorf("oss upload failed: %v", err) - return "" - } - return url -} diff --git a/internal/tools/bbxt/upload.go b/internal/tools/bbxt/upload.go new file mode 100644 index 0000000..a5d0886 --- /dev/null +++ b/internal/tools/bbxt/upload.go @@ -0,0 +1,153 @@ +package bbxt + +import ( + "ai_scheduler/internal/pkg/utils_oss" + "bytes" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/gofiber/fiber/v2/log" + "github.com/xuri/excelize/v2" +) + +type Uploader struct { + ossClient *utils_oss.Client +} + +const RequestUrl = "http://192.168.6.109:8010/api/v1/convert" + +func NewUploader(oss *utils_oss.Client) *Uploader { + return &Uploader{ + ossClient: oss, + } +} + +func (u *Uploader) Run(report *ReportRes) (err error) { + if len(report.Path) == 0 { + return + } + f, err := excelize.OpenFile(report.Path) + if err != nil { + return err + } + defer f.Close() + + excelBytes, err := f.WriteToBuffer() + if err != nil { + return fmt.Errorf("write to bytes failed: %v", err) + } + + picBytes, err := u.excel2picPy(report.Path, excelBytes.Bytes()) + if err != nil { + return fmt.Errorf("excel2picPy failed: %v", err) + } + // b.savePic("temp.png", picBytes) // 本地生成图片,仅测试 + // outputPath 提取文件名(不包含扩展名) + filename := filepath.Base(report.Path) + filename = strings.TrimSuffix(filename, filepath.Ext(filename)) + report.Url = u.uploadToOSS(filename, picBytes) + log.Infof("imgUrl: %s", report.Url) + + return +} + +// 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 (u *Uploader) 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() + } + + // 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 请求 + + req, err := http.NewRequest("POST", RequestUrl, 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 (u *Uploader) 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) +} + +// uploadToOSS 上传至 oss 返回图片url +func (u *Uploader) uploadToOSS(fileName string, fileBytes []byte) string { + objectKey := fmt.Sprintf("ai-scheduler/data-analytics/images/%s.png", fileName) + url, err := u.ossClient.UploadBytes(objectKey, fileBytes) + if err != nil { + log.Errorf("oss upload failed: %v", err) + return "" + } + return url +}