494 lines
16 KiB
Go
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
|
|
}
|