package crossSer import ( "context" "errors" "fmt" "sort" "stat/api/proto" "stat/common" "stat/common/statutils" "stat/common/tgbot" "stat/common/utils" "stat/models/botdailyhourstat" "stat/statg" "time" "github.com/wcharczuk/go-chart/v2" "github.com/wcharczuk/go-chart/v2/drawing" "github.com/vicanso/go-charts/v2" ) // ErrNoRankData 排名数据为空。空数据集不能交给 go-charts 渲染: 坐标轴范围会退化成 // math.MinInt64 与 NaN,产出一张刻度为 -9,223,372,036,854,776,000 的坏图。 // 常见原因是 innerServers 未配置,跨服循环零次执行。 var ErrNoRankData = errors.New("排名数据为空,无法生成图表(请检查 innerServers 配置)") var Colors = []drawing.Color{ drawing.ColorFromHex("0780cf"), drawing.ColorFromHex("765005"), drawing.ColorFromHex("fa6d1d"), drawing.ColorFromHex("0e2c82"), drawing.ColorFromHex("b6b51f"), drawing.ColorFromHex("da1f18"), drawing.ColorFromHex("701866"), drawing.ColorFromHex("f47a75"), drawing.ColorFromHex("009db2"), drawing.ColorFromHex("024b51"), drawing.ColorFromHex("0780cf"), drawing.ColorFromHex("765005"), } type CrossStatMethod = func(ctx context.Context, cmd *tgbot.Command) error // 统计方法汇总 var CrossStatMethodMap = map[string]CrossStatMethod{ statutils.StatProjectRecharge: CrossStatRecharge, statutils.StatProjectRechargeNew: CrossStatRechargeNew, statutils.StatProjectRechargeOld: CrossStatRechargeOld, statutils.StatProjectRechargeGold: CrossStatRechargeGold, statutils.StatProjectRechargeVip: CrossStatRechargeVip, statutils.StatProjectRegister: CrossStatRegister, statutils.StatProjectVisit: CrossStatVisit, statutils.StatProjectOrder: CrossStatOrder, } type CrossAppDownloadRes struct { Code int32 `json:"code"` Msg string `json:"msg"` Tip string `json:"tip"` Data *proto.PageButtonClickResp `json:"data"` Time time.Time `json:"time"` } type CrossStatRes struct { Code int32 `json:"code"` Msg string `json:"msg"` Tip string `json:"tip"` Data *proto.BotDailyHourListResp `json:"data"` Time time.Time `json:"time"` } type CrossMonthRes struct { Code int32 `json:"code"` Msg string `json:"msg"` Tip string `json:"tip"` Data *proto.BotMonthStatResp `json:"data"` Time time.Time `json:"time"` } type StatItem struct { Value int64 Stat *botdailyhourstat.BotHourStat } type FuncGetStatValue func(item *botdailyhourstat.BotHourStat) int64 type StatItemList []*StatItem func (a StatItemList) Len() int { return len(a) } func (a StatItemList) Less(i, j int) bool { return a[i].Value > a[j].Value } func (a StatItemList) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func CrossMonthStat(ctx context.Context, cmd *tgbot.Command) error { appId, _ := cmd.GetFlagInt32Value("-app") monthStr := cmd.GetFlagStringValue("-month") month, err := time.ParseInLocation("2006-01-02", monthStr, time.Local) if err != nil { return tgbot.NewUserError(cmd, "解析日期出错, 格式:\n"+cmd.Help+"\n"+cmd.Desc) } getValue := func(item *botdailyhourstat.BotHourStat) int64 { return item.TodayStat.Recharge.Value() } statList := make(StatItemList, 0) for _, addr := range statg.Conf.InnerServers { req := &proto.BotMonthStatReq{Month: month} if appId > 0 { req.AppId = &appId } res := CrossMonthRes{} err = InnerRequest(addr+"/api/stat/inner/botMonthStat", req, &res, true) if err != nil { return err } if res.Data == nil { continue } for _, stat := range res.Data.List { if statg.GlobalEndpoints.ExistsByAppId(stat.AppId) { value := getValue(stat) statList = append(statList, &StatItem{ Value: value, Stat: stat, }) } } } // sort sort.Sort(statList) // 显示 y, m, _ := month.Date() for _, item := range statList { appName := statg.GlobalEndpoints.ExistsByAppId(item.Stat.AppId) title := fmt.Sprintf("%d年%d月【%s】数据汇总", y, m, appName) text := item.Stat.DisplaySummaryText(title) if err = cmd.SendBack(text); err != nil { return err } } return nil } func CrossStatRecharge(ctx context.Context, cmd *tgbot.Command) error { moveBackDays, _ := cmd.GetFlagInt64Value("-back") if moveBackDays > 0 { moveBackDays = moveBackDays * -1 } // get stats now := time.Now() reportTime := utils.RecentMinute(now, 60) if moveBackDays != 0 { reportTime = utils.BeginWithTime(now.AddDate(0, 0, int(moveBackDays)+1)) } title := GetDateDesc(0) + "充值排名:" + reportTime.Format("2006-01-02T15:04:05") photo, _, err := GetRankPhoto(reportTime, title, func(item *botdailyhourstat.BotHourStat) int64 { return item.TodayStat.Recharge.Value() / 100 }) if err != nil { return err } cmd.PhotoPath = photo return cmd.SendBack(fmt.Sprintf("充值排名:%s\n", reportTime.Format("2006-01-02T15:04:05"))) } func CrossStatRechargeNew(ctx context.Context, cmd *tgbot.Command) error { moveBackDays, _ := cmd.GetFlagInt64Value("-back") if moveBackDays > 0 { moveBackDays = moveBackDays * -1 } // get stats now := time.Now() reportTime := utils.RecentMinute(now, 60) if moveBackDays != 0 { reportTime = utils.BeginWithTime(now.AddDate(0, 0, int(moveBackDays)+1)) } title := GetDateDesc(0) + "新用户充值排名:" + reportTime.Format("2006-01-02T15:04:05") photo, _, err := GetRankPhoto(reportTime, title, func(item *botdailyhourstat.BotHourStat) int64 { return item.TodayStat.RechargeNew.Value() / 100 }) if err != nil { return err } cmd.PhotoPath = photo return cmd.SendBack(fmt.Sprintf("新用户充值排名:%s\n", reportTime.Format("2006-01-02T15:04:05"))) } func CrossStatRechargeOld(ctx context.Context, cmd *tgbot.Command) error { moveBackDays, _ := cmd.GetFlagInt64Value("-back") if moveBackDays > 0 { moveBackDays = moveBackDays * -1 } // get stats now := time.Now() reportTime := utils.RecentMinute(now, 60) if moveBackDays != 0 { reportTime = utils.BeginWithTime(now.AddDate(0, 0, int(moveBackDays)+1)) } title := GetDateDesc(0) + "老用户充值排名:" + reportTime.Format("2006-01-02T15:04:05") photo, _, err := GetRankPhoto(reportTime, title, func(item *botdailyhourstat.BotHourStat) int64 { return item.TodayStat.RechargeOld.Value() / 100 }) if err != nil { return err } cmd.PhotoPath = photo return cmd.SendBack(fmt.Sprintf("老用户充值排名:%s\n", reportTime.Format("2006-01-02T15:04:05"))) } func CrossStatRechargeGold(ctx context.Context, cmd *tgbot.Command) error { moveBackDays, _ := cmd.GetFlagInt64Value("-back") if moveBackDays > 0 { moveBackDays = moveBackDays * -1 } // get stats now := time.Now() reportTime := utils.RecentMinute(now, 60) if moveBackDays != 0 { reportTime = utils.BeginWithTime(now.AddDate(0, 0, int(moveBackDays)+1)) } title := GetDateDesc(0) + "金币充值排名:" + reportTime.Format("2006-01-02T15:04:05") photo, _, err := GetRankPhoto(reportTime, title, func(item *botdailyhourstat.BotHourStat) int64 { return item.TodayStat.RechargeGold.Value() / 100 }) if err != nil { return err } cmd.PhotoPath = photo return cmd.SendBack(fmt.Sprintf("金币充值排名:%s\n", reportTime.Format("2006-01-02T15:04:05"))) } func CrossStatRechargeVip(ctx context.Context, cmd *tgbot.Command) error { moveBackDays, _ := cmd.GetFlagInt64Value("-back") if moveBackDays > 0 { moveBackDays = moveBackDays * -1 } // get stats now := time.Now() reportTime := utils.RecentMinute(now, 60) if moveBackDays != 0 { reportTime = utils.BeginWithTime(now.AddDate(0, 0, int(moveBackDays)+1)) } title := GetDateDesc(0) + "会员充值排名:" + reportTime.Format("2006-01-02T15:04:05") photo, _, err := GetRankPhoto(reportTime, title, func(item *botdailyhourstat.BotHourStat) int64 { return item.TodayStat.RechargeVip.Value() / 100 }) if err != nil { return err } cmd.PhotoPath = photo return cmd.SendBack(fmt.Sprintf("会员充值排名:%s\n", reportTime.Format("2006-01-02T15:04:05"))) } func CrossStatRegister(ctx context.Context, cmd *tgbot.Command) error { moveBackDays, _ := cmd.GetFlagInt64Value("-back") if moveBackDays > 0 { moveBackDays = moveBackDays * -1 } // get stats now := time.Now() reportTime := utils.RecentMinute(now, 60) if moveBackDays != 0 { reportTime = utils.BeginWithTime(now.AddDate(0, 0, int(moveBackDays)+1)) } title := GetDateDesc(0) + "注册排名:" + reportTime.Format("2006-01-02T15:04:05") photo, _, err := GetRankPhoto(reportTime, title, func(item *botdailyhourstat.BotHourStat) int64 { return item.TodayStat.Register.Value() }) if err != nil { return err } cmd.PhotoPath = photo return cmd.SendBack(fmt.Sprintf("注册排名:%s\n", reportTime.Format("2006-01-02T15:04:05"))) } func CrossStatVisit(ctx context.Context, cmd *tgbot.Command) error { moveBackDays, _ := cmd.GetFlagInt64Value("-back") if moveBackDays > 0 { moveBackDays = moveBackDays * -1 } // get stats now := time.Now() reportTime := utils.RecentMinute(now, 60) if moveBackDays != 0 { reportTime = utils.BeginWithTime(now.AddDate(0, 0, int(moveBackDays)+1)) } title := GetDateDesc(0) + "日活排名:" + reportTime.Format("2006-01-02T15:04:05") photo, _, err := GetRankPhoto(reportTime, title, func(item *botdailyhourstat.BotHourStat) int64 { return item.TodayStat.Visit.Value() }) if err != nil { return err } cmd.PhotoPath = photo return cmd.SendBack(fmt.Sprintf("日活排名:%s\n", reportTime.Format("2006-01-02T15:04:05"))) } func CrossStatOrder(ctx context.Context, cmd *tgbot.Command) error { moveBackDays, _ := cmd.GetFlagInt64Value("-back") if moveBackDays > 0 { moveBackDays = moveBackDays * -1 } // get stats now := time.Now() reportTime := utils.RecentMinute(now, 60) if moveBackDays != 0 { reportTime = utils.BeginWithTime(now.AddDate(0, 0, int(moveBackDays)+1)) } title := GetDateDesc(0) + "订单排名:" + reportTime.Format("2006-01-02T15:04:05") photo, _, err := GetRankPhoto(reportTime, title, func(item *botdailyhourstat.BotHourStat) int64 { return item.TodayStat.Order.Count() }) if err != nil { return err } cmd.PhotoPath = photo return cmd.SendBack(fmt.Sprintf("订单排名:%s\n", reportTime.Format("2006-01-02T15:04:05"))) } func GetRankPhoto(reportTime time.Time, title string, getValue FuncGetStatValue) (string, []byte, error) { charts.SetDefaultFont(statutils.Font) statList := make(StatItemList, 0) registerStatList := make(StatItemList, 0) var total, registerTotal, visitTotal int64 for _, addr := range statg.Conf.InnerServers { req := &proto.BotDailyHourListReq{ Page: common.Page{ PageNum: 1, PageSize: 100, }, SumDateStart: reportTime, SumDateStop: reportTime, } res := CrossStatRes{} err := InnerRequest(addr+"/api/stat/inner/botStatList", req, &res, true) if err != nil { return "", nil, err } if res.Data == nil { continue } for _, stat := range res.Data.List { if statg.GlobalEndpoints.GetBotRankEndpoints().ExistsByAppId(stat.AppId) { value := getValue(stat) total += value statList = append(statList, &StatItem{ Value: value, Stat: stat, }) registerValue := stat.TodayStat.Register.Total.Value() registerTotal += registerValue visitTotal += stat.TodayStat.Visit.Total.Value() registerStatList = append(registerStatList, &StatItem{ Value: registerValue, Stat: stat, }) } } } // 空数据集不能进入渲染: go-charts 会把坐标轴范围退化成 MinInt64/NaN, // 产出一张刻度为 -9,223,372,036,854,776,000 的坏图并照常发到群里。 if len(statList) == 0 { return "", nil, ErrNoRankData } // sort sort.Sort(statList) // draw chart xNames := make([]string, 0) for _, stat := range statList { xNames = append(xNames, statg.GlobalEndpoints.GetAppShortNameById(stat.Stat.AppId)) } sDatas := make([]charts.SeriesData, 0) rDatas := make([]charts.SeriesData, 0) for index, stat := range statList { sDatas = append(sDatas, charts.SeriesData{ Value: float64(stat.Value), Style: charts.Style{ FillColor: Colors[index%len(Colors)], TextWrap: chart.TextWrapRune, }, }) for _, registerStat := range registerStatList { if stat.Stat.AppId == registerStat.Stat.AppId { rDatas = append(rDatas, charts.SeriesData{ Value: float64(registerStat.Value), Style: charts.Style{ FillColor: Colors[index%len(Colors)], TextWrap: chart.TextWrapRune, }, }) } } } chartOpt := charts.ChartOption{ Width: statg.Conf.ChartsConfig.Width, Height: statg.Conf.ChartsConfig.Height, Type: charts.ChartOutputPNG, Title: charts.TitleOption{ Text: title, }, XAxis: charts.NewXAxisOption(xNames), Legend: charts.LegendOption{ Data: []string{ fmt.Sprintf("应用:%d", len(xNames)), fmt.Sprintf("充值:%d", total), fmt.Sprintf("日活:%d", visitTotal), fmt.Sprintf("注册:%d", registerTotal), }, Icon: charts.IconRect, Left: charts.PositionRight, }, SeriesList: []charts.Series{ { Type: charts.ChartTypeBar, Data: sDatas, Label: charts.SeriesLabel{ Show: true, Position: charts.PositionBottom, }, MarkLine: charts.NewMarkLine( charts.SeriesMarkDataTypeAverage, ), }, { Type: charts.ChartTypeBar, Data: rDatas, Label: charts.SeriesLabel{ Show: true, Position: charts.PositionBottom, }, }, }, } show := true chartOpt.XAxis.Show = &show d, err := charts.Render(chartOpt) if err != nil { return "", nil, err } buf, err := d.Bytes() if err != nil { return "", buf, err } photo := fmt.Sprintf("/tmp/%s.png", utils.UUID()) if err = statutils.WriteBytesToFile(buf, photo); err != nil { return "", buf, err } return photo, buf, nil } func GetRankDetail(reportTime time.Time, title string, getValue FuncGetStatValue) (string, error) { charts.SetDefaultFont(statutils.Font) statList := make(StatItemList, 0) registerStatList := make(StatItemList, 0) var total, registerTotal int64 for _, addr := range statg.Conf.InnerServers { req := &proto.BotDailyHourListReq{ Page: common.Page{ PageNum: 1, PageSize: 100, }, SumDateStart: reportTime, SumDateStop: reportTime, } res := CrossStatRes{} err := InnerRequest(addr+"/api/stat/inner/botStatList", req, &res, true) if err != nil { return "", err } if res.Data == nil { continue } for _, stat := range res.Data.List { if statg.GlobalEndpoints.GetBotRankEndpoints().ExistsByAppId(stat.AppId) { value := getValue(stat) total += value statList = append(statList, &StatItem{ Value: value, Stat: stat, }) registerValue := stat.TodayStat.Register.Total.Value() registerTotal += registerValue registerStatList = append(registerStatList, &StatItem{ Value: registerValue, Stat: stat, }) } } } // 同 GetRankPhoto: 空数据集会渲染出坐标轴为 MinInt64/NaN 的坏图 if len(statList) == 0 { return "", ErrNoRankData } // sort sort.Sort(statList) // draw chart xNames := make([]string, 0) for _, stat := range statList { xNames = append(xNames, statg.GlobalEndpoints.GetAppShortNameById(stat.Stat.AppId)) } sDatas := make([]charts.SeriesData, 0) rDatas := make([]charts.SeriesData, 0) for index, stat := range statList { sDatas = append(sDatas, charts.SeriesData{ Value: float64(stat.Value), Style: charts.Style{ FillColor: Colors[index%len(Colors)], TextWrap: chart.TextWrapRune, }, }) for _, registerStat := range registerStatList { if stat.Stat.AppId == registerStat.Stat.AppId { rDatas = append(rDatas, charts.SeriesData{ Value: float64(registerStat.Value), Style: charts.Style{ FillColor: Colors[index%len(Colors)], TextWrap: chart.TextWrapRune, }, }) } } } chartOpt := charts.ChartOption{ Width: statg.Conf.ChartsConfig.Width, Height: statg.Conf.ChartsConfig.Height, Type: charts.ChartOutputPNG, Title: charts.TitleOption{ Text: title, }, XAxis: charts.NewXAxisOption(xNames), Legend: charts.LegendOption{ Data: []string{ fmt.Sprintf("应用:%d", len(xNames)), fmt.Sprintf("充值:%d", total), fmt.Sprintf("注册:%d", registerTotal), }, Icon: charts.IconRect, Left: charts.PositionRight, }, SeriesList: []charts.Series{ { Type: charts.ChartTypeBar, Data: sDatas, Label: charts.SeriesLabel{ Show: true, Position: charts.PositionBottom, }, MarkLine: charts.NewMarkLine( charts.SeriesMarkDataTypeAverage, ), }, { Type: charts.ChartTypeBar, Data: rDatas, Label: charts.SeriesLabel{ Show: true, Position: charts.PositionBottom, }, }, }, } show := true chartOpt.XAxis.Show = &show d, err := charts.Render(chartOpt) if err != nil { return "", err } buf, err := d.Bytes() if err != nil { return "", err } photo := fmt.Sprintf("/tmp/%s.png", utils.UUID()) if err = statutils.WriteBytesToFile(buf, photo); err != nil { return "", err } return photo, nil }