diff --git a/api/proto/version.go b/api/proto/version.go index 2adf404..85ae3b4 100644 --- a/api/proto/version.go +++ b/api/proto/version.go @@ -87,14 +87,15 @@ type TfInfo struct { // 返回下载链接 type PageButtonClickResp struct { - VersionNum string `from:"versionNum" json:"versionNum"` //版本号 - Terminal string `from:"terminal" json:"terminal"` //终端ios android light - Link []string `from:"link" json:"link"` //下载链接 - Tf []TfInfo `from:"tf" json:"tf"` //TFinfo - BackUrl string `bson:"backUrl" json:"BackUrl"` //企业签URL (不用) - PackageType versionMod.PkgType `from:"packageType" json:"packageType" binding:"required"` //包类型 1:企业包 2:商店包 3:TF包 - Status bool `from:"status" json:"status"` //是否启用 - LightData []byte `from:"lightData" json:"lightData"` //如果是轻量版,返回数据 + VersionNum string `from:"versionNum" json:"versionNum"` //版本号 + Terminal string `from:"terminal" json:"terminal"` //终端ios android light + Link []string `from:"link" json:"link"` //下载链接 + Tf []TfInfo `from:"tf" json:"tf"` //TFinfo + BackUrl string `bson:"backUrl" json:"BackUrl"` //企业签URL (不用) + PackageType versionMod.PkgType `from:"packageType" json:"packageType" binding:"required"` //包类型 1:企业包 2:商店包 3:TF包 + Status bool `from:"status" json:"status"` //是否启用 + LightData []byte `from:"lightData" json:"lightData"` //如果是轻量版,返回数据 + ChannelApkAndDownUrl string `json:"channelApkAndDownUrl"` //双端下载配置里该渠道的Android下载地址,有则客户端优先用它 } // 落地页获取显示的按钮信息(appid标示具体应用) @@ -107,15 +108,17 @@ type PageButtonListReq struct { // 落地页获取显示的按钮信息 type PageButtonListResp struct { - IosDown bool `json:"iosDown"` // 显示完整版,是企业包 - IosStore bool `json:"iosStore"` // 显示商店包 - IosTF bool `json:"iosTF"` // TF包 - IosH5 bool `json:"iosH5"` // H5跳转链接 - IosLink string `json:"iosLink"` // 商店包下载链接 - AndroidButton int `json:"androidButton"` // android下载按钮 - HasChannelApk bool `json:"hasChannelApk"` // 有安卓渠道包 - PictureList []PageButtonPicture `json:"pictureList"` // 资源数据 - Contact ContactInfo `json:"contact"` // 联系方式 + IosDown bool `json:"iosDown"` // 显示完整版,是企业包 + IosStore bool `json:"iosStore"` // 显示商店包 + IosTF bool `json:"iosTF"` // TF包 + IosH5 bool `json:"iosH5"` // H5跳转链接 + IosLink string `json:"iosLink"` // 商店包下载链接 + AndroidButton int `json:"androidButton"` // android下载按钮 + HasChannelApk bool `json:"hasChannelApk"` // 有安卓渠道包 + // 双端下载配置里该渠道的 Android 下载地址,有则落地页优先用它 + ChannelApkAndDownUrl string `json:"channelApkAndDownUrl"` + PictureList []PageButtonPicture `json:"pictureList"` // 资源数据 + Contact ContactInfo `json:"contact"` // 联系方式 } type PageButtonPicture struct { diff --git a/models/botdailyhourstat/helper.go b/models/botdailyhourstat/helper.go index 7c11b70..bf7c790 100644 --- a/models/botdailyhourstat/helper.go +++ b/models/botdailyhourstat/helper.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "stat/common/statutils" + "time" ) func LineCount(iTemp string, iValue int64, permilValue int64) string { @@ -442,7 +443,7 @@ func (s *BotHourStat) GenerateTodayTable(title string, statName string, filepath } } -func (s *BotHourStat) DisplayDayHourTextWithTitle(appName string, compare *BotHourStat) (text string, title string, descript string) { +func (s *BotHourStat) DisplayDayHourTextWithTitle(appName string, compare, lastWeek *BotHourStat) (text string, title string, descript string) { calc := func(curr, hist *statutils.Stat) (*statutils.Stat, *statutils.Stat) { diff := curr.Sub(hist) rate := diff.Permil(hist) @@ -587,6 +588,7 @@ func (s *BotHourStat) DisplayDayHourTextWithTitle(appName string, compare *BotHo text += fmt.Sprintf("┗游戏下分: %d\n", s.TodayGame.DecAmount) } }() + text += s.DisplayCompareText("9️⃣", compare, lastWeek) text += fmt.Sprintf("🆚%s同比数据:当日的昨天%d点\n", appName, compare.SumDate.Hour()) } else { func() { @@ -693,7 +695,103 @@ func (s *BotHourStat) DisplayDayHourTextWithTitle(appName string, compare *BotHo text += fmt.Sprintf(" ┗游戏下分: %d\n", s.TodayGame.DecAmount) } }() + text += s.DisplayCompareText("6️⃣", compare, lastWeek) text += fmt.Sprintf("🆚【%s】同比数据未找到\n", appName) } return text, title, descript } + +// compareMetric 核心对比指标。数据机器人汇报末尾的"核心对比"块用,只挑最常看的几项; +// 全量指标在上面各段落里已经逐行带了同比涨跌,这里不重复。 +type compareMetric struct { + Name string + IsMoney bool // true 表示底层值的单位是"分",展示前要除以 100 + Pick func(*BotHourStat) int64 +} + +var compareMetrics = []compareMetric{ + {Name: "当日充值", IsMoney: true, Pick: func(s *BotHourStat) int64 { return s.TodayStat.Recharge.Value() }}, + {Name: "当日新增", Pick: func(s *BotHourStat) int64 { return s.TodayStat.Register.Count() }}, + {Name: "当日日活", Pick: func(s *BotHourStat) int64 { return s.TodayStat.Visit.Count() }}, + {Name: "小时充值", IsMoney: true, Pick: func(s *BotHourStat) int64 { return s.HourStat.Recharge.Value() }}, + {Name: "小时新增", Pick: func(s *BotHourStat) int64 { return s.HourStat.Register.Count() }}, +} + +// weekdayCN 星期的中文名,用来拼"周三"这种基准时间标签 +var weekdayCN = [...]string{"日", "一", "二", "三", "四", "五", "六"} + +// formatRate 把万分比涨跌渲染成带箭头的百分数,箭头用法和 LineCount 保持一致。 +// 这里按万分比算而不是沿用 LineCount 的千分比: 千分比只能给出一位小数, +// 23.45% 会被截成 23.40%,而这一块就是专门拿来看涨跌的。 +func formatRate(bp int64) string { + v := float64(bp) / 100 + switch { + case v < 0: + return fmt.Sprintf("⬇%.2f%%", v) + case v > 0: + return fmt.Sprintf("⬆+%.2f%%", v) + default: + return fmt.Sprintf("%.2f%%", v) + } +} + +// divisor 该指标展示时的除数 +func (m compareMetric) divisor() int64 { + if m.IsMoney { + return 100 + } + return 1 +} + +// compareCell 渲染一个指标相对某一期基准的涨跌幅。 +// base 为 nil 表示那一期的记录没查到;基准为 0 时算不出涨跌幅, +// 写 -- 而不是一个看起来正常的 0.00%。 +func compareCell(label string, m compareMetric, curr int64, base *BotHourStat) string { + if base == nil { + return fmt.Sprintf("%s:无数据", label) + } + baseValue := m.Pick(base) + if baseValue == 0 { + return fmt.Sprintf("%s:--", label) + } + bp := (curr - baseValue) * 10000 / baseValue + return fmt.Sprintf("%s:%s", label, formatRate(bp)) +} + +// hourDesc 把小时对齐的时点写成"11-14 周六 15点" +func hourDesc(t time.Time) string { + return fmt.Sprintf("%s 周%s %d点", t.Format("01-02"), weekdayCN[t.Weekday()], t.Hour()) +} + +// compareDesc 基准时间的可读描述。不重复写小时(两期基准都跟当日同一个小时), +// 但保留星期: 环比是整七天前,星期几必然和当日一致,写出来才能一眼看出没错位。 +// 记录没查到时直接在标签上标出来,免得看到一排"无数据"还要猜是哪天的。 +func compareDesc(want time.Time, got *BotHourStat) string { + desc := fmt.Sprintf("%s 周%s", want.Format("01-02"), weekdayCN[want.Weekday()]) + if got == nil { + return desc + "(无数据)" + } + return desc +} + +// DisplayCompareText 汇报末尾的"核心对比"块。 +// 业务口径: 同比 = 对比昨天同一小时, 环比 = 对比上周当日同一小时。 +// 这个叫法和财务上的习惯正好相反,是按需求方的要求定的,不要"纠正"过来。 +// 两期基准都拿不到时返回空串,不往汇报里塞一块全是"无数据"的内容。 +func (s *BotHourStat) DisplayCompareText(index string, yesterday, lastWeek *BotHourStat) string { + if s == nil || (yesterday == nil && lastWeek == nil) { + return "" + } + text := fmt.Sprintf("%s核心对比:\n", index) + text += fmt.Sprintf("┣当日:%s\n", hourDesc(s.SumDate)) + text += fmt.Sprintf("┣基准:环比 %s / 同比 %s\n", + compareDesc(s.SumDate.AddDate(0, 0, -7), lastWeek), + compareDesc(s.SumDate.AddDate(0, 0, -1), yesterday)) + for _, m := range compareMetrics { + curr := m.Pick(s) + text += fmt.Sprintf("┣%s:%d %s %s\n", m.Name, curr/m.divisor(), + compareCell("环比", m, curr, lastWeek), + compareCell("同比", m, curr, yesterday)) + } + return text +} diff --git a/service/botser/data.go b/service/botser/data.go index 39c79f5..c7671c7 100644 --- a/service/botser/data.go +++ b/service/botser/data.go @@ -94,7 +94,7 @@ func DailyBotReportTask(ctx context.Context, bot *tgbot.TgBot, appId int32, repo //text := report.DisplayDayHourText(appName, report) //_ = bot.SendBroadcastMessage(text, "", tgbot.FileTypeNone) - text, title, desc := report.DisplayDayHourTextWithTitle(appName, historyReport) + text, title, desc := report.DisplayDayHourTextWithTitle(appName, historyReport, lastWeekHourStat(ctx, appId, reportTime)) et := tgbot.NewEntityList(title, desc, []*tgbot.Entity{ { Content: text, @@ -144,7 +144,7 @@ func DailyBotReportInfo(ctx context.Context, appId int32, reportTime time.Time) appNameMap, _ := appInfoMod.GetAllAppIdNameMap(ctx) appName := appNameMap[appId] if statg.GlobalEndpoints.GetBotStatEndpoints().ExistsByAppId(appId) { - text, title, desc = report.DisplayDayHourTextWithTitle(appName, historyReport) + text, title, desc = report.DisplayDayHourTextWithTitle(appName, historyReport, lastWeekHourStat(ctx, appId, reportTime)) } log.Error("DailyBotReportTask end", log.Any("appid", appId), log.Any("time", reportTime)) @@ -347,3 +347,23 @@ func BotDailyHourStat(ctx context.Context, appId int32, reportTime time.Time, sa } return report, nil } + +// lastWeekHourStat 取上周当日同一小时的统计记录,给汇报里的"环比"做基准。 +// 只查不补算: 现算一份七天前的数据要把当天的原始流水全量聚合一遍,代价高, +// 而且历史原始数据可能已经被清理,算出来反而是错的。查不到就返回 nil, +// 展示层会把那一行标成"无数据"。 +func lastWeekHourStat(ctx context.Context, appId int32, reportTime time.Time) *botdailyhourstat.BotHourStat { + filter := bson.M{ + botreport.FN.BotReport.AppId: appId, + botreport.FN.BotReport.SumDate: reportTime.AddDate(0, 0, -7), + } + report, err := botdailyhourstat.FindOneBotHourStatEx(ctx, filter) + if err != nil { + if err != mongo.ErrNoDocuments { + log.Warn("lastWeekHourStat failed", log.E(err), + log.Any("appId", appId), log.Any("sumDate", reportTime)) + } + return nil + } + return report +} diff --git a/service/botser/handler.go b/service/botser/handler.go index 41850a2..ef3a876 100644 --- a/service/botser/handler.go +++ b/service/botser/handler.go @@ -491,7 +491,7 @@ func DailyStat(ctx context.Context, cmd *tgbot.Command, moveBackDays int) error return tgbot.NewUserError(cmd, "服务器错误:"+err.Error()) } } - text, title, desc := report.DisplayDayHourTextWithTitle(appInfo.Name, historyReport) + text, title, desc := report.DisplayDayHourTextWithTitle(appInfo.Name, historyReport, lastWeekHourStat(ctx, appId, reportTime)) et := tgbot.NewEntityList(title, desc, []*tgbot.Entity{ { Content: text, diff --git a/service/versionSer/landingpage.go b/service/versionSer/landingpage.go index c3cb763..b41181d 100644 --- a/service/versionSer/landingpage.go +++ b/service/versionSer/landingpage.go @@ -14,6 +14,7 @@ import ( downloadinfo "stat/models/downloadinfo" "stat/models/versionMod" "stat/service/cacheSer" + "stat/service/cpcDownloadCfgSer" "stat/service/crossSer" "stat/statg" "strings" @@ -189,6 +190,18 @@ func PageButtonClick(ctx context.Context, userAgent string, peerIP string, req * return } + // 双端下载配置里给该渠道配了 Android 下载地址,就用它覆盖 link 并直接返回。 + // link 也改: 只读 data.link 的老客户端(useAppDownload.ts)才能用上渠道包; + // 客户端取值是 links[index] || links[0],单元素数组任意下标都能回落。 + // 必须在批量打包逻辑之前返回: 下面那段会按默认域名重算并覆盖 link。 + if req.Terminal == "android" && channelCode.DC != "" { + if packUrl := channelApkDownUrl(ctx, req.AppId, channelCode.DC); packUrl != "" { + resp.ChannelApkAndDownUrl = packUrl + resp.Link = []string{packUrl} + return resp, nil + } + } + // 新增批量打包逻辑 if req.Terminal == "android" && len(data.Md5) > 0 { randTime := int64(60*60) + int64(rand.Intn(60*60)) @@ -273,6 +286,22 @@ func PageButtonClick(ctx context.Context, userAgent string, peerIP string, req * return } +// channelApkDownUrl 取双端下载配置里该渠道的 Android 下载地址,只用于落地页按钮。 +// 配置里有一部分填的是落地页地址(形如 https://xxx?dc=blt0001)而不是 apk 包, +// 落地页拿到它会再跳一次落地页,所以带 dc 参数的一律当没配,返回空串让调用方走默认下载链接。 +// 不能直接改 cpcDownloadCfgSer.GetDownloadUrl: /download/{appId}/{channel} 这条双端路由 +// 依赖它跳落地页,过滤掉会把那个功能弄坏。 +func channelApkDownUrl(ctx context.Context, appId int32, dc string) string { + url := cpcDownloadCfgSer.GetDownloadUrl(ctx, appId, dc) + if url == "" { + return "" + } + if strings.Contains(url, "?dc=") || strings.Contains(url, "&dc=") { + return "" + } + return url +} + // PageButtonList 落地页获取下载按钮 func PageButtonList(ctx context.Context, isIos bool, peerIP string, req *proto.PageButtonListReq) (resp *proto.PageButtonListResp, err error) { resp = &proto.PageButtonListResp{ @@ -377,5 +406,10 @@ func PageButtonList(ctx context.Context, isIos bool, peerIP string, req *proto.P resp.HasChannelApk = true } + // 双端下载配置里给该渠道配了 Android 下载地址就一并返回,落地页优先用它 + if !isIos && channelCode.DC != "" { + resp.ChannelApkAndDownUrl = channelApkDownUrl(ctx, req.AppId, channelCode.DC) + } + return }