package business import ( "context" "github.com/go-kratos/kratos/v3/errors" "math/big" "sort" "strings" "time" ) var ( ErrAnalyticsInput = errors.BadRequest("SW_ANALYTICS_INPUT", "统计查询参数不正确") ErrAnalyticsDate = errors.BadRequest("SW_ANALYTICS_DATE", "统计周期格式错误,应为 yyyy-MM-dd") ErrAnalyticsDateOrder = errors.BadRequest("SW_ANALYTICS_DATE_ORDER", "统计开始日期不能晚于结束日期") ) type AnalyticsKind string const ( ChannelAnalytics AnalyticsKind = "channel" GameAnalytics AnalyticsKind = "game" ) type AnalyticsKey struct { SourceDB string ID int64 } type AnalyticsFilter struct { HasRegister, HasPay *bool Sources, GameNames []string PromoteName string PromoteType *int StartDate, EndDate *time.Time Begin, End, SortField string Ascending bool } type AnalyticsScope struct { Viewer UserScope IncludeSub, IDCardDedup bool } type AnalyticsRow struct { AnalyticsKey Name string PromoteType *int BindingAccount, BindingUserName string Counts PortraitCounts Pay, PayRate, ARPU, ARPPU string } func (f AnalyticsFilter) MatchesMetrics(row *AnalyticsRow) (bool, error) { if f.HasRegister != nil && (row.Counts.Register > 0) != *f.HasRegister { return false, nil } if f.HasPay != nil { amount, err := portraitNumber(row.Pay) if err != nil { return false, err } if (amount.Sign() > 0) != *f.HasPay { return false, nil } } return true, nil } // Inactive channels match a negative activity filter, unless another selected // metric explicitly requires activity. func (f AnalyticsFilter) IncludesInactiveRows() bool { return (f.HasRegister != nil || f.HasPay != nil) && (f.HasRegister == nil || !*f.HasRegister) && (f.HasPay == nil || !*f.HasPay) } func (r *AnalyticsRow) BusinessName() string { if value := strings.TrimSpace(r.BindingAccount); value != "" { return value } return strings.TrimSpace(r.BindingUserName) } type AnalyticsData struct { EffectiveCount int64 Rows []*AnalyticsRow GlobalCounts *PortraitCounts GlobalPay string Available bool } type AnalyticsPage struct { EffectiveCount int64 Rows []*AnalyticsRow Total int64 Summary *AnalyticsRow Begin, End string Available bool } type AnalyticsRepo interface { Aggregate(context.Context, AnalyticsKind, AnalyticsScope, AnalyticsFilter) (*AnalyticsData, error) FillNames(context.Context, AnalyticsKind, AnalyticsScope, AnalyticsFilter, []*AnalyticsRow) error } type AnalyticsUsecase struct { repo AnalyticsRepo accounts *AccountUsecase preferences *UserPreferenceUsecase now func() time.Time effectiveness *EffectivenessUsecase } func NewAnalyticsUsecase(repo AnalyticsRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase, effectiveness *EffectivenessUsecase) *AnalyticsUsecase { return &AnalyticsUsecase{repo: repo, accounts: accounts, preferences: preferences, now: time.Now, effectiveness: effectiveness} } func analyticsCap(c PortraitCounts) PortraitCounts { return PortraitCounts{Register: min(c.Register, 2147483647), Active: min(c.Active, 2147483647), Pay: min(c.Pay, 2147483647), NewPay: min(c.NewPay, 2147483647)} } func analyticsAdd(a, b PortraitCounts) PortraitCounts { return PortraitCounts{Register: a.Register + b.Register, Active: a.Active + b.Active, Pay: a.Pay + b.Pay, NewPay: a.NewPay + b.NewPay} } func analyticsRatio(numerator *big.Rat, denominator int64, percent bool) string { if denominator <= 0 { return "0.00" } n := new(big.Rat).Quo(numerator, big.NewRat(denominator, 1)) if percent { n.Mul(n, big.NewRat(100, 1)) } return portraitDecimal(n, 2) } func analyticsValues(row *AnalyticsRow) error { pay, err := portraitNumber(row.Pay) if err != nil { return err } row.Pay = portraitDecimal(pay, 2) pay, _ = portraitNumber(row.Pay) row.PayRate = analyticsRatio(big.NewRat(row.Counts.Pay, 1), row.Counts.Active, true) row.ARPU = analyticsRatio(pay, row.Counts.Active, false) row.ARPPU = analyticsRatio(pay, row.Counts.Pay, false) return nil } func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter AnalyticsFilter, page, size int) (*AnalyticsPage, error) { return uc.list(ctx, kind, filter, page, size, false) } func (uc *AnalyticsUsecase) ListAll(ctx context.Context, kind AnalyticsKind, filter AnalyticsFilter) (*AnalyticsPage, error) { return uc.list(ctx, kind, filter, 1, 0, true) } func (uc *AnalyticsUsecase) list(ctx context.Context, kind AnalyticsKind, filter AnalyticsFilter, page, size int, all bool) (*AnalyticsPage, error) { actor, account, err := uc.accounts.actor(ctx) if err != nil { return nil, err } pref, err := uc.preferences.Current(ctx) if err != nil { return nil, err } if kind != ChannelAnalytics && kind != GameAnalytics { return nil, ErrAnalyticsInput } viewer := portraitUserScope(account) viewer.All = actor.AuthorityID == 888 if kind == GameAnalytics { filter.PromoteName = "" filter.PromoteType = nil filter.HasRegister, filter.HasPay = nil, nil } if kind == GameAnalytics { filter.StartDate, filter.EndDate = reportDateDefaults(filter.StartDate, filter.EndDate, 1, uc.now()) } else if filter.EndDate == nil { _, filter.EndDate = reportDateDefaults(nil, nil, 1, uc.now()) } if filter.StartDate != nil && filter.EndDate.Before(*filter.StartDate) { return nil, ErrAnalyticsDateOrder } filter.Begin = "" if filter.StartDate != nil { filter.Begin = filter.StartDate.Format("2006-01-02") } filter.End = filter.EndDate.Format("2006-01-02") out := &AnalyticsPage{Rows: []*AnalyticsRow{}, Begin: filter.Begin, End: filter.End, Available: true} scope := AnalyticsScope{Viewer: viewer, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup} if kind == ChannelAnalytics && uc.effectiveness != nil { if err := uc.effectiveness.Ensure(ctx); err != nil { return nil, err } } data, err := uc.repo.Aggregate(ctx, kind, scope, filter) if err != nil { return nil, err } if !data.Available { out.Available = false return out, nil } out.EffectiveCount = data.EffectiveCount counts := PortraitCounts{} pay := new(big.Rat) for _, row := range data.Rows { row.Counts = analyticsCap(row.Counts) raw, err := portraitNumber(row.Pay) if err != nil { return nil, err } if kind == ChannelAnalytics { pay.Add(pay, raw) } if err := analyticsValues(row); err != nil { return nil, err } if kind == GameAnalytics { rounded, _ := portraitNumber(row.Pay) pay.Add(pay, rounded) } counts = analyticsAdd(counts, row.Counts) } if kind == ChannelAnalytics { counts = analyticsCap(counts) if viewer.All && data.GlobalCounts != nil { counts = analyticsCap(*data.GlobalCounts) pay, err = portraitNumber(data.GlobalPay) if err != nil { return nil, err } } } label := "全部渠道" if kind == GameAnalytics { label = "全部游戏" } out.Summary = &AnalyticsRow{Name: label, Counts: counts, Pay: portraitDecimal(pay, 2)} if err := analyticsValues(out.Summary); err != nil { return nil, err } // 仅按渠道名称或类型排序时需要提前读取全部资料;数字指标排序 // 确定当前页后再补名称和商务信息,汇总仍使用全部可见统计行。 namesBeforeSort := kind == ChannelAnalytics && (filter.SortField == "promoteName" || filter.SortField == "promoteType") fillNames := func(rows []*AnalyticsRow) error { if len(rows) == 0 { return nil } if err := uc.repo.FillNames(ctx, kind, scope, filter, rows); err != nil { return err } if kind == ChannelAnalytics { for _, row := range rows { row.Name = (&LedgerNames{PromoteID: &row.ID, PromoteAccount: row.Name}).DisplayPromote() } } return nil } if namesBeforeSort { if err := fillNames(data.Rows); err != nil { return nil, err } } if err := sortAnalytics(data.Rows, kind, filter.SortField, filter.Ascending); err != nil { return nil, err } out.Total = int64(len(data.Rows)) if all { if out.Total > 10000 { return nil, ErrLedgerExportLimit } out.Rows = data.Rows if !namesBeforeSort { if err := fillNames(out.Rows); err != nil { return nil, err } } return out, nil } if page < 1 { page = 1 } if size <= 0 || size > 500 { size = 500 } if page > len(data.Rows)/size+1 { return out, nil } from := (page - 1) * size to := min(from+size, len(data.Rows)) if from < len(data.Rows) { out.Rows = data.Rows[from:to] } if !namesBeforeSort { if err := fillNames(out.Rows); err != nil { return nil, err } } return out, nil } func sortAnalytics(rows []*AnalyticsRow, kind AnalyticsKind, field string, ascending bool) error { if field == "" { sort.SliceStable(rows, func(i, j int) bool { if rows[i].SourceDB != rows[j].SourceDB { return rows[i].SourceDB < rows[j].SourceDB } return rows[i].ID < rows[j].ID }) return nil } allowed := map[string]bool{"newRegisterCount": true, "activeCount": true, "payUserCount": true, "newPayUserCount": true, "totalPay": true, "payRate": true} if kind == GameAnalytics { allowed["gameId"] = true allowed["arpu"] = true allowed["arppu"] = true } else { allowed["promoteId"] = true allowed["promoteName"] = true allowed["promoteType"] = true } if !allowed[field] { field = "totalPay" } values := map[*AnalyticsRow]*big.Rat{} for _, row := range rows { var n *big.Rat var err error switch field { case "newRegisterCount": n = big.NewRat(row.Counts.Register, 1) case "activeCount": n = big.NewRat(row.Counts.Active, 1) case "payUserCount": n = big.NewRat(row.Counts.Pay, 1) case "newPayUserCount": n = big.NewRat(row.Counts.NewPay, 1) case "promoteId", "gameId": n = big.NewRat(row.ID, 1) case "promoteType": value := int64(0) if row.PromoteType != nil { value = int64(*row.PromoteType) } n = big.NewRat(value, 1) case "payRate": n, err = portraitNumber(row.PayRate) case "arpu": n, err = portraitNumber(row.ARPU) case "arppu": n, err = portraitNumber(row.ARPPU) case "promoteName": continue default: n, err = portraitNumber(row.Pay) } if err != nil { return err } values[row] = n } sort.SliceStable(rows, func(i, j int) bool { a, b := rows[i], rows[j] comparison := 0 if field == "promoteName" { comparison = strings.Compare(a.Name, b.Name) } else { comparison = values[a].Cmp(values[b]) } if comparison != 0 { if ascending { return comparison < 0 } return comparison > 0 } if a.SourceDB != b.SourceDB { return a.SourceDB < b.SourceDB } return a.ID < b.ID }) return nil }