package business import ( "context" "math/big" "sort" "strconv" "strings" "time" "github.com/go-kratos/kratos/v3/errors" ) var ( ErrPortraitPlatforms = errors.BadRequest("SW_PORTRAIT_PLATFORMS", "请选择所属平台后查看数据") ErrPortraitPlatformMissing = errors.NotFound("SW_PORTRAIT_PLATFORM_BUSINESS_NOT_FOUND", "平台商务不存在") ) var portraitLocation = time.FixedZone("Asia/Shanghai", 8*3600) type PortraitBasic struct { UserID uint64 UserName, RealName string Level *int ParentUserID *uint64 ParentUserName, ParentRealName string CreatedAt *time.Time Status *string StatusLabel string PromoteCount, MonthNewPromote *int64 } type PortraitPlatformBusiness struct { ID uint64 Account, RealName string Level, Status *int Commerce1, Commerce2 *int64 CreatedUnix *int64 } func (p *PortraitPlatformBusiness) ParentID() *uint64 { if p.Level == nil { return nil } var id *int64 switch *p.Level { case 2: id = p.Commerce1 case 3: id = p.Commerce2 } if id == nil || *id <= 0 { return nil } value := uint64(*id) return &value } type PortraitChannel struct { ChannelKey Level *int TopPromoteID uint64 Account string CreatedUnix *int64 } type PortraitMetric struct { Value string CompareValue, ChangeRate, ChangeDiff *string CompareLabel string } type PortraitMetrics struct{ Register, PayAmount, PayRate, ARPU *PortraitMetric } type PortraitTrend struct{ Label, Value string } type PortraitRank struct { Rank int ChannelKey PromoteName, Value string } type BusinessPortrait struct { Basic *PortraitBasic Period string Metrics *PortraitMetrics RegisterTrend, PayTrend []*PortraitTrend RegisterTop5, PayTop5 []*PortraitRank Available bool UnavailableReason string } type PortraitRange struct { Begin, End, PreviousBegin, PreviousEnd time.Time CompareLabel string Hourly bool } type PortraitCounts struct{ Register, Active, Pay, NewPay int64 } type PortraitStatRank struct { ChannelKey Value string } type PortraitStatistics struct { Current, Previous PortraitCounts CurrentPay, PreviousPay string RegisterSeries, PaySeries map[string]string RegisterRanks, PayRanks []*PortraitStatRank Available bool } type PortraitReportScope struct { Root *Channel NoCompare bool Pairs []PlatformKey Sources []string IncludeSub, IDCardDedup bool Range PortraitRange RankLimit int CountLimit int64 } type BusinessPortraitRepo interface { Bindings(context.Context, UserScope, []string, *PlatformKey) ([]*Binding, error) Channels(context.Context, []PlatformKey, bool) ([]*PortraitChannel, bool, error) PlatformBusiness(context.Context, PlatformKey) (*PortraitPlatformBusiness, error) Statistics(context.Context, PortraitReportScope) (*PortraitStatistics, error) } type BusinessPortraitUsecase struct { repo BusinessPortraitRepo accounts *AccountUsecase preferences *UserPreferenceUsecase now func() time.Time } func NewBusinessPortraitUsecase(repo BusinessPortraitRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase) *BusinessPortraitUsecase { return &BusinessPortraitUsecase{repo: repo, accounts: accounts, preferences: preferences, now: time.Now} } func normalizePortraitPeriod(period string) string { switch value := strings.ToLower(strings.TrimSpace(period)); value { case "today", "yesterday", "last7", "last30", "thismonth": return value } return "today" } func portraitRanges(period string, now time.Time) PortraitRange { now = now.In(portraitLocation) today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, portraitLocation) r := PortraitRange{Begin: today, End: today, PreviousBegin: today.AddDate(0, 0, -1), PreviousEnd: today.AddDate(0, 0, -1), CompareLabel: "较昨日", Hourly: true} switch period { case "yesterday": r.Begin = today.AddDate(0, 0, -1) r.End = r.Begin r.PreviousBegin = today.AddDate(0, 0, -2) r.PreviousEnd = r.PreviousBegin r.CompareLabel = "较前日" case "last7", "last30": days := 7 if period == "last30" { days = 30 } r.Begin = today.AddDate(0, 0, 1-days) r.PreviousEnd = r.Begin.AddDate(0, 0, -1) r.PreviousBegin = r.PreviousEnd.AddDate(0, 0, 1-days) r.CompareLabel = "较上一周期" r.Hourly = false case "thismonth": r.Begin = time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, portraitLocation) r.PreviousBegin = r.Begin.AddDate(0, -1, 0) lastDay := r.Begin.AddDate(0, 0, -1).Day() r.PreviousEnd = r.PreviousBegin.AddDate(0, 0, min(today.Day(), lastDay)-1) r.CompareLabel = "较上月同期" r.Hourly = false } return r } func portraitUserScope(account *Account) UserScope { return UserScope{SelfID: account.ID, IncludeSelf: true, OnlySelf: account.Level >= 3, DescendantPath: descendantPath(account)} } func portraitPairs(bindings []*Binding) []PlatformKey { pairs := []PlatformKey{} seen := map[PlatformKey]bool{} for _, b := range bindings { if !seen[b.PlatformKey] { pairs = append(pairs, b.PlatformKey) seen[b.PlatformKey] = true } } return pairs } func businessStatusLabel(status int) string { return map[int]string{0: "正常", 1: "停用", 2: "已驳回", 3: "待审核"}[status] } func BusinessLevelLabel(level *int) string { if level == nil { return "" } return map[int]string{1: "一级商务", 2: "二级商务", 3: "三级商务"}[*level] } func (uc *BusinessPortraitUsecase) ForUser(ctx context.Context, id uint, sources []string, period string) (*BusinessPortrait, error) { if len(sources) == 0 { return nil, ErrPortraitPlatforms } account, err := uc.accounts.Find(ctx, id) if err != nil { return nil, err } status := strconv.Itoa(account.Status) parentID := uint64(account.ParentID) level := account.Level basic := &PortraitBasic{UserID: uint64(account.ID), UserName: account.Username, RealName: account.RealName, Level: &level, ParentUserID: &parentID, CreatedAt: &account.CreatedAt, Status: &status, StatusLabel: businessStatusLabel(account.Status)} if account.ParentID > 0 { parent, err := uc.accounts.repo.Find(ctx, account.ParentID) if err != nil && !errors.IsNotFound(err) { return nil, err } if parent != nil { basic.ParentUserName = parent.Username basic.ParentRealName = parent.RealName } } bindings, err := uc.repo.Bindings(ctx, portraitUserScope(account), sources, nil) if err != nil { return nil, err } return uc.assemble(ctx, basic, bindings, sources, period) } func (uc *BusinessPortraitUsecase) ForBinding(ctx context.Context, key PlatformKey, period string) (*BusinessPortrait, error) { source, err := NormalizePlatform(key.SourceDB) if err != nil { return nil, err } key.SourceDB = source actor, current, err := uc.accounts.actor(ctx) if err != nil { return nil, err } all, err := uc.repo.Bindings(ctx, UserScope{All: true}, []string{source}, &key) if err != nil { return nil, err } if len(all) == 0 { return nil, ErrBindingNotFound } scope := portraitUserScope(current) scope.All = actor.AuthorityID == 888 bindings, err := uc.repo.Bindings(ctx, scope, []string{source}, &key) if err != nil { return nil, err } if len(bindings) == 0 { return nil, ErrAccountScope } platform, err := uc.repo.PlatformBusiness(ctx, key) if err != nil { return nil, err } if platform == nil { return nil, ErrPortraitPlatformMissing } basic := &PortraitBasic{UserID: platform.ID, UserName: platform.Account, RealName: platform.RealName, Level: platform.Level} if parentID := platform.ParentID(); parentID != nil { parent, err := uc.repo.PlatformBusiness(ctx, PlatformKey{SourceDB: source, BusierID: *parentID}) if err != nil { return nil, err } if parent != nil { basic.ParentUserID = parentID basic.ParentUserName = parent.Account basic.ParentRealName = parent.RealName } } if platform.CreatedUnix != nil && *platform.CreatedUnix > 0 { created := time.Unix(*platform.CreatedUnix, 0).In(portraitLocation) basic.CreatedAt = &created } if platform.Status != nil { status := strconv.Itoa(*platform.Status) basic.Status = &status switch *platform.Status { case 1: basic.StatusLabel = "正常" case 0: basic.StatusLabel = "待审核" default: basic.StatusLabel = "停用" } } return uc.assemble(ctx, basic, bindings, []string{source}, period) } func (uc *BusinessPortraitUsecase) assemble(ctx context.Context, basic *PortraitBasic, bindings []*Binding, sources []string, period string) (*BusinessPortrait, error) { pref, err := uc.preferences.Current(ctx) if err != nil { return nil, err } period = normalizePortraitPeriod(period) now := uc.now() ranges := portraitRanges(period, now) out := &BusinessPortrait{Basic: basic, Period: period, RegisterTrend: []*PortraitTrend{}, PayTrend: []*PortraitTrend{}, RegisterTop5: []*PortraitRank{}, PayTop5: []*PortraitRank{}, Available: true} pairs := portraitPairs(bindings) channels, available, err := uc.repo.Channels(ctx, pairs, pref.IncludeSubPromote) if err != nil { return nil, err } if !available { out.Available = false out.UnavailableReason = "渠道数据尚未接入,画像指标暂不可用" return out, nil } count, monthCount := int64(0), int64(0) today := now.In(portraitLocation) monthBegin := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, portraitLocation).Unix() tomorrow := time.Date(today.Year(), today.Month(), today.Day()+1, 0, 0, 0, 0, portraitLocation).Unix() for _, channel := range channels { if channel.Level != nil && *channel.Level == 1 { count++ if channel.CreatedUnix != nil && *channel.CreatedUnix >= monthBegin && *channel.CreatedUnix < tomorrow { monthCount++ } } } basic.PromoteCount = &count basic.MonthNewPromote = &monthCount stats := &PortraitStatistics{Available: true, CurrentPay: "0", PreviousPay: "0", RegisterSeries: map[string]string{}, PaySeries: map[string]string{}} if len(channels) > 0 { stats, err = uc.repo.Statistics(ctx, PortraitReportScope{Pairs: pairs, Sources: sources, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup, Range: ranges, RankLimit: 5, CountLimit: 2147483647}) if err != nil { return nil, err } } if !stats.Available { out.Available = false out.UnavailableReason = "日汇总或趋势数据尚未接入,画像指标暂不可用" return out, nil } out.Metrics, err = portraitMetrics(stats, ranges.CompareLabel) if err != nil { return nil, err } out.RegisterTrend, err = portraitTrend(stats.RegisterSeries, ranges, false) if err != nil { return nil, err } out.PayTrend, err = portraitTrend(stats.PaySeries, ranges, true) if err != nil { return nil, err } out.RegisterTop5, err = portraitRanks(stats.RegisterRanks, channels, true) if err != nil { return nil, err } out.PayTop5, err = portraitRanks(stats.PayRanks, channels, false) return out, err } func portraitNumber(value string) (*big.Rat, error) { if value == "" { value = "0" } n, ok := new(big.Rat).SetString(value) if !ok { return nil, ErrStorage } return n, nil } func stringPointer(value string) *string { return &value } func portraitDecimal(n *big.Rat, digits int) string { value := n.FloatString(digits) // 与 BigDecimal 一致:四舍五入为零后不携带负号。 if strings.HasPrefix(value, "-") && strings.Trim(value[1:], "0.") == "" { return value[1:] } return value } func portraitRateCard(current, previous string, label string) (*PortraitMetric, error) { c, err := portraitNumber(current) if err != nil { return nil, err } p, err := portraitNumber(previous) if err != nil { return nil, err } card := &PortraitMetric{Value: current, CompareValue: stringPointer(previous), CompareLabel: label} if p.Sign() != 0 { diff := new(big.Rat).Sub(c, p) diff.Mul(diff, big.NewRat(100, 1)) diff.Quo(diff, p) card.ChangeRate = stringPointer(portraitDecimal(diff, 1)) } return card, nil } func portraitMetrics(stats *PortraitStatistics, label string) (*PortraitMetrics, error) { pay, err := portraitNumber(stats.CurrentPay) if err != nil { return nil, err } previousPay, err := portraitNumber(stats.PreviousPay) if err != nil { return nil, err } pay, _ = portraitNumber(portraitDecimal(pay, 2)) previousPay, _ = portraitNumber(portraitDecimal(previousPay, 2)) currentRegister, previousRegister := min(stats.Current.Register, int64(2147483647)), min(stats.Previous.Register, int64(2147483647)) register, _ := portraitRateCard(strconv.FormatInt(currentRegister, 10), strconv.FormatInt(previousRegister, 10), label) payCard, _ := portraitRateCard(portraitDecimal(pay, 2), portraitDecimal(previousPay, 2), label) arpu := func(amount *big.Rat, count int64) string { if count == 0 { return "0.00" } return portraitDecimal(new(big.Rat).Quo(amount, big.NewRat(count, 1)), 2) } arpuCard, _ := portraitRateCard(arpu(pay, currentRegister), arpu(previousPay, previousRegister), label) payRate := func(c PortraitCounts) string { active := min(c.Active, int64(2147483647)) if active == 0 { return "0.0" } return portraitDecimal(new(big.Rat).Mul(big.NewRat(min(c.Pay, int64(2147483647)), active), big.NewRat(100, 1)), 1) } rate := &PortraitMetric{Value: payRate(stats.Current), CompareLabel: label} if stats.Previous.Active != 0 { value := payRate(stats.Previous) rate.CompareValue = &value c, _ := portraitNumber(rate.Value) p, _ := portraitNumber(value) rate.ChangeDiff = stringPointer(portraitDecimal(new(big.Rat).Sub(c, p), 1)) } return &PortraitMetrics{Register: register, PayAmount: payCard, PayRate: rate, ARPU: arpuCard}, nil } func portraitTrend(series map[string]string, ranges PortraitRange, money bool) ([]*PortraitTrend, error) { result := []*PortraitTrend{} add := func(key, label string) error { n, err := portraitNumber(series[key]) if err != nil { return err } digits := 0 if money { digits = 2 } else if !ranges.Hourly && n.Cmp(big.NewRat(2147483647, 1)) > 0 { n = big.NewRat(2147483647, 1) } result = append(result, &PortraitTrend{Label: label, Value: portraitDecimal(n, digits)}) return nil } if ranges.Hourly { for hour := 0; hour < 24; hour++ { label := time.Date(2000, 1, 1, hour, 0, 0, 0, time.UTC).Format("15:04") if err := add(strconv.Itoa(hour), label); err != nil { return nil, err } } } else { for day := ranges.Begin; !day.After(ranges.End); day = day.AddDate(0, 0, 1) { if err := add(day.Format("2006-01-02"), day.Format("01-02")); err != nil { return nil, err } } } return result, nil } func portraitRanks(raw []*PortraitStatRank, channels []*PortraitChannel, count bool) ([]*PortraitRank, error) { names := map[ChannelKey]string{} for _, channel := range channels { names[channel.ChannelKey] = channel.Account } values := map[ChannelKey]*big.Rat{} rows := []*PortraitRank{} for _, item := range raw { n, err := portraitNumber(item.Value) if err != nil { return nil, err } if count && n.Cmp(big.NewRat(2147483647, 1)) > 0 { n = big.NewRat(2147483647, 1) } if n.Sign() <= 0 { continue } values[item.ChannelKey] = n rows = append(rows, &PortraitRank{ChannelKey: item.ChannelKey, PromoteName: names[item.ChannelKey], Value: portraitDecimal(n, 2)}) } sort.SliceStable(rows, func(i, j int) bool { cmp := values[rows[i].ChannelKey].Cmp(values[rows[j].ChannelKey]) if cmp != 0 { return cmp > 0 } if rows[i].ID != rows[j].ID { return rows[i].ID < rows[j].ID } return rows[i].SourceDB < rows[j].SourceDB }) if len(rows) > 5 { rows = rows[:5] } for i, row := range rows { row.Rank = i + 1 } return rows, nil }