Files
rootandClaude Opus 5 836e42b364 修复排名图空数据时生成坏图
innerServers 未配置时 GetRankPhoto/GetRankDetail 的跨服循环零次执行,
statList 为空仍继续渲染。go-charts 对空数据集会把坐标轴范围退化成
math.MinInt64 与 NaN,产出一张 Y 轴刻度为 -9,223,372,036,854,776,000、
X 轴为 NaN、图例全 0 的坏图,并照常推送到群里。

增加 ErrNoRankData 哨兵错误,两个函数在渲染前拦截空数据集。11 个调用点
原本就是 if err != nil { return err },无需改动即可全部覆盖。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 09:13:03 +08:00

584 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}