Files
kra-new/internal/biz/business/business_portrait.go
T

494 lines
16 KiB
Go

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
}