feat: 迁移商务与游戏商务画像及交互看板
This commit is contained in:
+1
-1
@@ -11,4 +11,4 @@ import (
|
||||
)
|
||||
|
||||
// ProviderSet is biz providers.
|
||||
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase)
|
||||
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase)
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
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 {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type portraitRepoFake struct {
|
||||
BusinessPortraitRepo
|
||||
bindings []*Binding
|
||||
channels []*PortraitChannel
|
||||
channelsAvailable bool
|
||||
stats *PortraitStatistics
|
||||
scope PortraitReportScope
|
||||
platforms map[uint64]*PortraitPlatformBusiness
|
||||
}
|
||||
|
||||
func (r *portraitRepoFake) Bindings(_ context.Context, scope UserScope, _ []string, _ *PlatformKey) ([]*Binding, error) {
|
||||
if !scope.All && scope.SelfID == 99 {
|
||||
return []*Binding{}, nil
|
||||
}
|
||||
return r.bindings, nil
|
||||
}
|
||||
func (r *portraitRepoFake) Channels(_ context.Context, pairs []PlatformKey, _ bool) ([]*PortraitChannel, bool, error) {
|
||||
if len(pairs) == 0 {
|
||||
return []*PortraitChannel{}, true, nil
|
||||
}
|
||||
return r.channels, r.channelsAvailable, nil
|
||||
}
|
||||
func (r *portraitRepoFake) Statistics(_ context.Context, scope PortraitReportScope) (*PortraitStatistics, error) {
|
||||
r.scope = scope
|
||||
return r.stats, nil
|
||||
}
|
||||
func (r *portraitRepoFake) PlatformBusiness(_ context.Context, key PlatformKey) (*PortraitPlatformBusiness, error) {
|
||||
return r.platforms[key.BusierID], nil
|
||||
}
|
||||
func portraitFixture() (*BusinessPortraitUsecase, *portraitRepoFake) {
|
||||
accounts := &accountRepoFake{accounts: map[uint]*Account{7: {ID: 7, Username: "owner", RealName: "商务甲", Level: 1, IDPath: "0"}, 9: {ID: 9, Username: "leaf", Level: 3, ParentID: 7, IDPath: "0,7"}, 99: {ID: 99, Username: "other", Level: 1, IDPath: "0"}}}
|
||||
created := time.Date(2026, 11, 1, 0, 0, 0, 0, portraitLocation).Unix()
|
||||
r := &portraitRepoFake{bindings: []*Binding{{UserID: 9, PlatformKey: PlatformKey{SourceDB: "lx", BusierID: 14}, Status: "0"}}, channelsAvailable: true, channels: []*PortraitChannel{{ChannelKey: ChannelKey{SourceDB: "lx", ID: 100}, Level: intPointer(1), Account: "一级渠道", CreatedUnix: &created}}, platforms: map[uint64]*PortraitPlatformBusiness{14: {ID: 14, Account: "platform", RealName: "平台商务", Level: intPointer(3), Status: intPointer(0), Commerce2: portraitUint(12), CreatedUnix: &created}, 12: {ID: 12, Account: "platform-parent", RealName: "平台上级"}}}
|
||||
r.stats = &PortraitStatistics{Available: true, Current: PortraitCounts{Register: 2, Active: 3, Pay: 2}, Previous: PortraitCounts{Register: 1, Active: 4, Pay: 1}, CurrentPay: "12.00", PreviousPay: "6.00", RegisterSeries: map[string]string{"2026-11-01": "2", "0": "3"}, PaySeries: map[string]string{"0": "1.25"}, RegisterRanks: []*PortraitStatRank{{ChannelKey: ChannelKey{SourceDB: "lx", ID: 100}, Value: "2"}}, PayRanks: []*PortraitStatRank{{ChannelKey: ChannelKey{SourceDB: "lx", ID: 100}, Value: "12.00"}}}
|
||||
uc := NewBusinessPortraitUsecase(r, NewAccountUsecase(accounts), NewUserPreferenceUsecase(&channelPreferenceFake{UserPreference{IncludeSubPromote: true, IDCardDedup: true}}))
|
||||
uc.now = func() time.Time { return time.Date(2026, 11, 3, 12, 0, 0, 0, portraitLocation) }
|
||||
return uc, r
|
||||
}
|
||||
func portraitUint(n int64) *int64 { return &n }
|
||||
func TestPortraitCalendarRangesClampPreviousMonth(t *testing.T) {
|
||||
today := time.Date(2024, 3, 31, 10, 0, 0, 0, portraitLocation)
|
||||
cases := []struct{ period, begin, end, prevBegin, prevEnd string }{{"today", "2024-03-31", "2024-03-31", "2024-03-30", "2024-03-30"}, {"yesterday", "2024-03-30", "2024-03-30", "2024-03-29", "2024-03-29"}, {"last7", "2024-03-25", "2024-03-31", "2024-03-18", "2024-03-24"}, {"last30", "2024-03-02", "2024-03-31", "2024-02-01", "2024-03-01"}, {"thismonth", "2024-03-01", "2024-03-31", "2024-02-01", "2024-02-29"}}
|
||||
for _, tc := range cases {
|
||||
r := portraitRanges(tc.period, today)
|
||||
if r.Begin.Format("2006-01-02") != tc.begin || r.End.Format("2006-01-02") != tc.end || r.PreviousBegin.Format("2006-01-02") != tc.prevBegin || r.PreviousEnd.Format("2006-01-02") != tc.prevEnd {
|
||||
t.Fatalf("日期范围错误: %+v %+v", tc, r)
|
||||
}
|
||||
}
|
||||
if normalizePortraitPeriod(" thisMonth ") != "thismonth" || normalizePortraitPeriod("total") != "today" {
|
||||
t.Fatal("商务画像与渠道累计模式混用")
|
||||
}
|
||||
}
|
||||
func TestPortraitMetricsUseActiveDenominatorAndPercentagePoints(t *testing.T) {
|
||||
uc, r := portraitFixture()
|
||||
portrait, err := uc.ForUser(accountContext(7, 901), 7, []string{"lx"}, "today")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := portrait.Metrics
|
||||
if m.Register.Value != "2" || *m.Register.ChangeRate != "100.0" || m.PayRate.Value != "66.7" || *m.PayRate.CompareValue != "25.0" || *m.PayRate.ChangeDiff != "41.7" || m.ARPU.Value != "6.00" || *m.ARPU.ChangeRate != "0.0" {
|
||||
t.Fatalf("画像卡片计算错误: %+v", m)
|
||||
}
|
||||
if *portrait.Basic.PromoteCount != 1 || *portrait.Basic.MonthNewPromote != 1 || len(portrait.RegisterTrend) != 24 || portrait.RegisterTrend[0].Value != "3" || portrait.PayTrend[0].Value != "1.25" {
|
||||
t.Fatal("基本渠道计数或小时趋势错误")
|
||||
}
|
||||
if r.scope.RankLimit != 5 || !r.scope.IDCardDedup || !r.scope.IncludeSub {
|
||||
t.Fatal("画像没有使用查看人的全局偏好")
|
||||
}
|
||||
r.stats.Previous.Active = 0
|
||||
portrait, err = uc.ForUser(accountContext(7, 901), 7, []string{"lx"}, "last7")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if portrait.Metrics.PayRate.CompareValue != nil || portrait.Metrics.PayRate.ChangeDiff != nil || len(portrait.RegisterTrend) != 7 {
|
||||
t.Fatal("无上期活跃时不应伪造付费率环比")
|
||||
}
|
||||
}
|
||||
func TestPortraitEmptyScopeAndUnavailableDataAreDifferent(t *testing.T) {
|
||||
uc, r := portraitFixture()
|
||||
r.channelsAvailable = false
|
||||
p, err := uc.ForUser(accountContext(7, 901), 7, []string{"lx"}, "today")
|
||||
if err != nil || p.Available || p.Basic.UserName != "owner" || p.Basic.PromoteCount != nil || p.Metrics != nil {
|
||||
t.Fatal("缺表时应保留基本资料并标记未知指标")
|
||||
}
|
||||
r.bindings = nil
|
||||
p, err = uc.ForUser(accountContext(7, 901), 7, []string{"lx"}, "today")
|
||||
if err != nil || !p.Available || *p.Basic.PromoteCount != 0 || p.Metrics.Register.Value != "0" || len(p.RegisterTrend) != 24 {
|
||||
t.Fatal("无绑定属于确定空数据")
|
||||
}
|
||||
if _, err := uc.ForUser(accountContext(99, 901), 7, []string{"lx"}, "today"); err != ErrAccountScope {
|
||||
t.Fatal("不能查询其他一级商务画像")
|
||||
}
|
||||
}
|
||||
func TestBindingPortraitUsesPlatformHierarchyAndStatus(t *testing.T) {
|
||||
uc, _ := portraitFixture()
|
||||
p, err := uc.ForBinding(accountContext(9, 903), PlatformKey{SourceDB: "lx", BusierID: 14}, "today")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Basic.UserID != 14 || p.Basic.UserName != "platform" || p.Basic.ParentUserID == nil || *p.Basic.ParentUserID != 12 || p.Basic.ParentUserName != "platform-parent" || *p.Basic.Status != "0" || p.Basic.StatusLabel != "待审核" {
|
||||
t.Fatalf("混淆平台与 SW 的 ID、上级或状态: %+v", p.Basic)
|
||||
}
|
||||
if _, err := uc.ForBinding(accountContext(99, 901), PlatformKey{SourceDB: "lx", BusierID: 14}, "today"); err != ErrAccountScope {
|
||||
t.Fatal("范围外 SDK 画像被放行")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformPortraitNonpositiveParentIsAbsent(t *testing.T) {
|
||||
negative := int64(-1)
|
||||
if parent := (&PortraitPlatformBusiness{Level: intPointer(3), Commerce2: &negative}).ParentID(); parent != nil {
|
||||
t.Fatal("平台的负数父级占位值不能变成无符号 ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortraitDecimalRoundingMatchesBigDecimal(t *testing.T) {
|
||||
card, err := portraitRateCard("1000.00", "1000.01", "较昨日")
|
||||
if err != nil || card.ChangeRate == nil || *card.ChangeRate != "0.0" {
|
||||
t.Fatal("极小负环比应舍入为不带负号的零")
|
||||
}
|
||||
metrics, err := portraitMetrics(&PortraitStatistics{CurrentPay: "8.005", PreviousPay: "0", Current: PortraitCounts{Register: 2}}, "较昨日")
|
||||
if err != nil || metrics.PayAmount.Value != "8.01" || metrics.ARPU.Value != "4.01" {
|
||||
t.Fatal("应先将充值金额保留两位再计算人均充值")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/biz/business"
|
||||
)
|
||||
|
||||
type businessPortraitRepo struct {
|
||||
data Provider
|
||||
platforms PlatformProvider
|
||||
}
|
||||
|
||||
func NewBusinessPortraitRepo(data Provider, platforms PlatformProvider) business.BusinessPortraitRepo {
|
||||
return &businessPortraitRepo{data: data, platforms: platforms}
|
||||
}
|
||||
func (r *businessPortraitRepo) Bindings(ctx context.Context, scope business.UserScope, sources []string, key *business.PlatformKey) ([]*business.Binding, error) {
|
||||
q := channelBindingQuery(r.data.DB().WithContext(ctx), scope, business.ChannelFilter{Sources: sources})
|
||||
if key != nil {
|
||||
q = q.Where("b.source_db = ? AND b.platform_busier_id = ?", key.SourceDB, key.BusierID)
|
||||
}
|
||||
var pos []bindingPO
|
||||
if err := q.Select("b.*").Order("b.user_id,b.source_db,b.platform_busier_id").Scan(&pos).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
rows := make([]*business.Binding, 0, len(pos))
|
||||
for _, po := range pos {
|
||||
rows = append(rows, toBizBinding(po))
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
func portraitBindingPairs(pairs []business.PlatformKey) [][]any {
|
||||
result := make([][]any, 0, len(pairs))
|
||||
for _, pair := range pairs {
|
||||
result = append(result, []any{pair.SourceDB, pair.BusierID})
|
||||
}
|
||||
return result
|
||||
}
|
||||
func (r *businessPortraitRepo) Channels(ctx context.Context, pairs []business.PlatformKey, includeSub bool) ([]*business.PortraitChannel, bool, error) {
|
||||
rows := []*business.PortraitChannel{}
|
||||
if len(pairs) == 0 {
|
||||
return rows, true, nil
|
||||
}
|
||||
db := r.data.DB().WithContext(ctx)
|
||||
available, err := businessTableAvailable(db, "tab_promote")
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !available {
|
||||
return rows, false, nil
|
||||
}
|
||||
seen := map[business.ChannelKey]bool{}
|
||||
for start := 0; start < len(pairs); start += 300 {
|
||||
q := db.Table("tab_promote").Select("id,source_db,account,promote_level,top_promote_id,create_time").Where("(source_db,busier_id) IN ?", portraitBindingPairs(pairs[start:min(start+300, len(pairs))]))
|
||||
if !includeSub {
|
||||
q = q.Where("promote_level = 1")
|
||||
}
|
||||
var pos []channelPO
|
||||
if err := q.Order("source_db,id").Scan(&pos).Error; err != nil {
|
||||
return nil, false, business.ErrStorage
|
||||
}
|
||||
for _, po := range pos {
|
||||
key := business.ChannelKey{SourceDB: po.SourceDB, ID: po.ID}
|
||||
if !seen[key] {
|
||||
rows = append(rows, &business.PortraitChannel{ChannelKey: key, Account: po.Account, Level: po.PromoteLevel, TopPromoteID: po.TopPromoteID, CreatedUnix: po.CreateTime})
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows, true, nil
|
||||
}
|
||||
|
||||
type portraitPlatformPO struct {
|
||||
ID uint64
|
||||
Account, RealName string
|
||||
Level, Status *int
|
||||
Commerce1, Commerce2 *int64
|
||||
CreatedUnix *int64 `gorm:"column:create_time"`
|
||||
}
|
||||
|
||||
func (r *businessPortraitRepo) PlatformBusiness(ctx context.Context, key business.PlatformKey) (*business.PortraitPlatformBusiness, error) {
|
||||
db, err := r.platforms.Database(key.SourceDB)
|
||||
if err != nil || db == nil {
|
||||
return nil, business.ErrPlatformUnavailable
|
||||
}
|
||||
var po portraitPlatformPO
|
||||
if err := db.WithContext(ctx).Table("tab_promote_business").Select("id,account,real_name,level,status,commerce1,commerce2,create_time").Where("id = ?", key.BusierID).Take(&po).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, business.ErrPlatformUnavailable
|
||||
}
|
||||
return &business.PortraitPlatformBusiness{ID: po.ID, Account: po.Account, RealName: po.RealName, Level: po.Level, Status: po.Status, Commerce1: po.Commerce1, Commerce2: po.Commerce2, CreatedUnix: po.CreatedUnix}, nil
|
||||
}
|
||||
|
||||
func portraitPromoteScope(db *gorm.DB, scope business.PortraitReportScope, sourceColumn, promoteColumn string) *gorm.DB {
|
||||
q := db.Table("tab_promote scope_promote").Select("1").Where("scope_promote.source_db = " + sourceColumn + " AND scope_promote.id = " + promoteColumn)
|
||||
if len(scope.Pairs) == 0 {
|
||||
return q.Where("1=0")
|
||||
}
|
||||
q = q.Where("(scope_promote.source_db,scope_promote.busier_id) IN ?", portraitBindingPairs(scope.Pairs))
|
||||
if !scope.IncludeSub {
|
||||
q = q.Where("scope_promote.promote_level = 1")
|
||||
}
|
||||
return q
|
||||
}
|
||||
func portraitReportQuery(db *gorm.DB, scope business.PortraitReportScope, table, begin, end string, mode int) *gorm.DB {
|
||||
q := db.Table(table+" r").Where("r.stat_date >= ? AND r.stat_date <= ? AND r.dedup_mode = ? AND r.game_id > 0", begin, end, mode)
|
||||
if len(scope.Sources) > 0 {
|
||||
q = q.Where("r.source_db IN ?", scope.Sources)
|
||||
}
|
||||
return q.Where("EXISTS (?)", portraitPromoteScope(db, scope, "r.source_db", "r.promote_id"))
|
||||
}
|
||||
func portraitIdentity(db *gorm.DB, dedup bool) string {
|
||||
if dedup {
|
||||
return "r.identity_key"
|
||||
}
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
return "CONCAT(r.source_db,'|',r.identity_key)"
|
||||
}
|
||||
return "(r.source_db || '|' || r.identity_key)"
|
||||
}
|
||||
|
||||
type portraitCountPO struct {
|
||||
Metric int
|
||||
Count int64
|
||||
}
|
||||
|
||||
func toPortraitCounts(rows []portraitCountPO) business.PortraitCounts {
|
||||
result := business.PortraitCounts{}
|
||||
for _, row := range rows {
|
||||
switch row.Metric {
|
||||
case 1:
|
||||
result.Register = row.Count
|
||||
case 2:
|
||||
result.Active = row.Count
|
||||
case 3:
|
||||
result.Pay = row.Count
|
||||
case 4:
|
||||
result.NewPay = row.Count
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *businessPortraitRepo) Statistics(ctx context.Context, scope business.PortraitReportScope) (*business.PortraitStatistics, error) {
|
||||
result := &business.PortraitStatistics{CurrentPay: "0", PreviousPay: "0", RegisterSeries: map[string]string{}, PaySeries: map[string]string{}, RegisterRanks: []*business.PortraitStatRank{}, PayRanks: []*business.PortraitStatRank{}, Available: true}
|
||||
if len(scope.Pairs) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
db := r.data.DB().WithContext(ctx)
|
||||
tables := []string{"tab_promote", "tab_sw_report_day", "tab_sw_report_day_key"}
|
||||
if scope.Range.Hourly {
|
||||
tables = append(tables, "tab_user", "tab_spend")
|
||||
}
|
||||
for _, table := range tables {
|
||||
available, err := businessTableAvailable(db, table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !available {
|
||||
result.Available = false
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
mode := 0
|
||||
if scope.IDCardDedup {
|
||||
mode = 1
|
||||
}
|
||||
begin, end := scope.Range.Begin.Format("2006-01-02"), scope.Range.End.Format("2006-01-02")
|
||||
previousBegin, previousEnd := scope.Range.PreviousBegin.Format("2006-01-02"), scope.Range.PreviousEnd.Format("2006-01-02")
|
||||
for _, period := range []struct {
|
||||
begin, end string
|
||||
counts *business.PortraitCounts
|
||||
pay *string
|
||||
}{{begin, end, &result.Current, &result.CurrentPay}, {previousBegin, previousEnd, &result.Previous, &result.PreviousPay}} {
|
||||
var rows []portraitCountPO
|
||||
q := portraitReportQuery(db, scope, "tab_sw_report_day_key", period.begin, period.end, mode)
|
||||
if err := q.Select("r.metric,COUNT(DISTINCT " + portraitIdentity(db, scope.IDCardDedup) + ") AS count").Group("r.metric").Scan(&rows).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
*period.counts = toPortraitCounts(rows)
|
||||
if err := portraitReportQuery(db, scope, "tab_sw_report_day", period.begin, period.end, 0).Select("COALESCE(SUM(r.total_pay),0)").Scan(period.pay).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
}
|
||||
if scope.Range.Hourly {
|
||||
if err := portraitHourly(db, scope, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := portraitDaily(db, scope, mode, begin, end, result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
register, err := portraitRankQuery(db, scope, mode, begin, end, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.RegisterRanks = register
|
||||
pay, err := portraitRankQuery(db, scope, 0, begin, end, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.PayRanks = pay
|
||||
return result, nil
|
||||
}
|
||||
func portraitDaily(db *gorm.DB, scope business.PortraitReportScope, mode int, begin, end string, result *business.PortraitStatistics) error {
|
||||
dateExpression := "SUBSTR(CAST(r.stat_date AS CHAR),1,10)"
|
||||
var registers []struct {
|
||||
Bucket string
|
||||
Count int64
|
||||
}
|
||||
q := portraitReportQuery(db, scope, "tab_sw_report_day_key", begin, end, mode).Where("r.metric = 1")
|
||||
if err := q.Select(dateExpression + " AS bucket,COUNT(DISTINCT " + portraitIdentity(db, scope.IDCardDedup) + ") AS count").Group(dateExpression).Scan(®isters).Error; err != nil {
|
||||
return business.ErrStorage
|
||||
}
|
||||
for _, row := range registers {
|
||||
result.RegisterSeries[row.Bucket] = strconv.FormatInt(row.Count, 10)
|
||||
}
|
||||
var payments []struct{ Bucket, Amount string }
|
||||
if err := portraitReportQuery(db, scope, "tab_sw_report_day", begin, end, 0).Select(dateExpression + " AS bucket,COALESCE(SUM(r.total_pay),0) AS amount").Group(dateExpression).Scan(&payments).Error; err != nil {
|
||||
return business.ErrStorage
|
||||
}
|
||||
for _, row := range payments {
|
||||
result.PaySeries[row.Bucket] = row.Amount
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func portraitHourly(db *gorm.DB, scope business.PortraitReportScope, result *business.PortraitStatistics) error {
|
||||
start, end := scope.Range.Begin.Unix(), scope.Range.End.AddDate(0, 0, 1).Unix()
|
||||
for _, metric := range []struct {
|
||||
table, column, aggregate, condition string
|
||||
series map[string]string
|
||||
}{{"tab_user", "register_time", "COUNT(*)", "(r.puid = 0 OR r.puid IS NULL)", result.RegisterSeries}, {"tab_spend", "pay_time", "COALESCE(SUM(r.pay_amount),0)", "r.pay_status = 1", result.PaySeries}} {
|
||||
q := db.Table(metric.table+" r").Where("r."+metric.column+" >= ? AND r."+metric.column+" < ?", start, end).Where(metric.condition).Where("EXISTS (?)", portraitPromoteScope(db, scope, "r.source_db", "r.promote_id"))
|
||||
if len(scope.Sources) > 0 {
|
||||
q = q.Where("r.source_db IN ?", scope.Sources)
|
||||
}
|
||||
var rows []struct {
|
||||
Hour int
|
||||
Value string
|
||||
}
|
||||
if db.Dialector.Name() == "mysql" {
|
||||
// 保留原 Mapper 的数据库会话时区口径,最终实库验收同时核对时区配置。
|
||||
hour := "HOUR(FROM_UNIXTIME(r." + metric.column + "))"
|
||||
if err := q.Select(hour + " AS hour," + metric.aggregate + " AS value").Group(hour).Scan(&rows).Error; err != nil {
|
||||
return business.ErrStorage
|
||||
}
|
||||
} else {
|
||||
if err := q.Select("CAST((r."+metric.column+" - ?) / 3600 AS INTEGER) AS hour,"+metric.aggregate+" AS value", start).Group("hour").Scan(&rows).Error; err != nil {
|
||||
return business.ErrStorage
|
||||
}
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Hour >= 0 && row.Hour <= 23 {
|
||||
metric.series[strconv.Itoa(row.Hour)] = row.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func portraitRankQuery(db *gorm.DB, scope business.PortraitReportScope, mode int, begin, end string, pay bool) ([]*business.PortraitStatRank, error) {
|
||||
table, aggregate := "tab_sw_report_day_key", "COUNT(DISTINCT r.identity_key)"
|
||||
if pay {
|
||||
table, aggregate = "tab_sw_report_day", "COALESCE(SUM(r.total_pay),0)"
|
||||
}
|
||||
var selectArgs []any
|
||||
if !pay && scope.CountLimit > 0 {
|
||||
// 人数上限须先于 TOP N 排序,避免截取后才限制导致并列结果不同。
|
||||
aggregate = "CASE WHEN COUNT(DISTINCT r.identity_key)>? THEN ? ELSE COUNT(DISTINCT r.identity_key) END"
|
||||
selectArgs = []any{scope.CountLimit, scope.CountLimit}
|
||||
}
|
||||
q := portraitReportQuery(db, scope, table, begin, end, mode)
|
||||
if !pay {
|
||||
q = q.Where("r.metric = 1")
|
||||
}
|
||||
root := "r.promote_id"
|
||||
if scope.IncludeSub || pay {
|
||||
q = q.Joins("JOIN tab_promote rank_promote ON rank_promote.source_db=r.source_db AND rank_promote.id=r.promote_id")
|
||||
root = "CASE WHEN rank_promote.promote_level=1 THEN rank_promote.id WHEN rank_promote.top_promote_id>0 THEN rank_promote.top_promote_id ELSE rank_promote.id END"
|
||||
}
|
||||
var rows []struct {
|
||||
SourceDB string `gorm:"column:source_db"`
|
||||
PromoteID uint64
|
||||
Value string
|
||||
}
|
||||
if err := q.Select("r.source_db,"+root+" AS promote_id,"+aggregate+" AS value", selectArgs...).Group("r.source_db," + root).Order("value DESC," + root + ",r.source_db").Limit(scope.RankLimit).Scan(&rows).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
result := make([]*business.PortraitStatRank, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
result = append(result, &business.PortraitStatRank{ChannelKey: business.ChannelKey{SourceDB: row.SourceDB, ID: row.PromoteID}, Value: row.Value})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/biz/business"
|
||||
)
|
||||
|
||||
func preparePortraitData(t *testing.T) (*gorm.DB, business.BusinessPortraitRepo, business.PortraitReportScope) {
|
||||
t.Helper()
|
||||
db, _ := prepareChannels(t)
|
||||
zone := time.FixedZone("CST", 8*3600)
|
||||
start := time.Date(2026, 11, 3, 0, 0, 0, 0, zone)
|
||||
for _, sql := range []string{
|
||||
"UPDATE tab_sw_report_day_key SET identity_key='A' WHERE source_db='hw' AND metric=1",
|
||||
"INSERT INTO tab_sw_report_day_key VALUES('hw',100,1,1,'2026-11-01',2,'C'),('lx',100,1,1,'2026-11-01',3,'A'),('hw',100,1,1,'2026-11-01',3,'A')",
|
||||
"INSERT INTO tab_sw_report_day_key VALUES('hw',100,1,0,'2026-11-01',1,'u1')",
|
||||
"ALTER TABLE tab_user ADD COLUMN register_time INTEGER",
|
||||
"ALTER TABLE tab_spend ADD COLUMN pay_time INTEGER",
|
||||
} {
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.Exec("UPDATE tab_user SET register_time = ?", start.Unix()+3600).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec("UPDATE tab_spend SET pay_time = ?", start.Unix()+3600).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scope := business.PortraitReportScope{Pairs: []business.PlatformKey{{SourceDB: "lx", BusierID: 14}, {SourceDB: "hw", BusierID: 14}}, Sources: []string{"lx", "hw"}, IncludeSub: true, IDCardDedup: true, RankLimit: 5, Range: business.PortraitRange{Begin: time.Date(2026, 11, 1, 0, 0, 0, 0, zone), End: start, PreviousBegin: time.Date(2026, 10, 1, 0, 0, 0, 0, zone), PreviousEnd: time.Date(2026, 10, 3, 0, 0, 0, 0, zone)}}
|
||||
return db, NewBusinessPortraitRepo(testData{db}, channelDatabases{}), scope
|
||||
}
|
||||
func portraitTestMoney(s string) string {
|
||||
n, ok := new(big.Rat).SetString(s)
|
||||
if !ok {
|
||||
return "invalid"
|
||||
}
|
||||
return n.FloatString(2)
|
||||
}
|
||||
|
||||
func TestPortraitBindingsAndChannelsFollowBusinessTree(t *testing.T) {
|
||||
_, repo, _ := preparePortraitData(t)
|
||||
ctx := context.Background()
|
||||
bindings, err := repo.Bindings(ctx, channelTestScope(), []string{"lx", "hw"}, nil)
|
||||
if err != nil || len(bindings) != 2 {
|
||||
t.Fatalf("本人及下级绑定范围错误: %+v %v", bindings, err)
|
||||
}
|
||||
for _, b := range bindings {
|
||||
if b.UserID == 10 || b.Status != "0" {
|
||||
t.Fatal("混入平级或未通过绑定")
|
||||
}
|
||||
}
|
||||
pairs := []business.PlatformKey{}
|
||||
for _, b := range bindings {
|
||||
pairs = append(pairs, b.PlatformKey)
|
||||
}
|
||||
channels, available, err := repo.Channels(ctx, pairs, true)
|
||||
if err != nil || !available || len(channels) != 5 {
|
||||
t.Fatalf("渠道范围不正确: %+v %v", channels, err)
|
||||
}
|
||||
channels, _, err = repo.Channels(ctx, pairs, false)
|
||||
if err != nil || len(channels) != 2 {
|
||||
t.Fatal("关闭下级时只能取一级渠道")
|
||||
}
|
||||
outside := business.PlatformKey{SourceDB: "lx", BusierID: 16}
|
||||
bindings, err = repo.Bindings(ctx, channelTestScope(), []string{"lx"}, &outside)
|
||||
if err != nil || len(bindings) != 0 {
|
||||
t.Fatal("绑定过滤绕过商务树")
|
||||
}
|
||||
}
|
||||
func TestPortraitSummaryDedupsAcrossPlatformsAndRanksWithinTrees(t *testing.T) {
|
||||
_, repo, scope := preparePortraitData(t)
|
||||
stats, err := repo.Statistics(context.Background(), scope)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.Current.Register != 3 || stats.Current.Active != 1 || stats.Current.Pay != 1 || portraitTestMoney(stats.CurrentPay) != "2058.60" || portraitTestMoney(stats.PreviousPay) != "1.10" {
|
||||
t.Fatalf("全局去重或金额口径错误: %+v", stats)
|
||||
}
|
||||
if stats.RegisterSeries["2026-11-01"] != "1" || stats.RegisterSeries["2026-11-02"] != "3" {
|
||||
t.Fatalf("日趋势错误: %+v", stats.RegisterSeries)
|
||||
}
|
||||
var lxRoot, hwRoot string
|
||||
for _, rank := range stats.RegisterRanks {
|
||||
if rank.ID == 100 && rank.SourceDB == "lx" {
|
||||
lxRoot = rank.Value
|
||||
}
|
||||
if rank.ID == 100 && rank.SourceDB == "hw" {
|
||||
hwRoot = rank.Value
|
||||
}
|
||||
}
|
||||
if lxRoot != "2" || hwRoot != "1" {
|
||||
t.Fatalf("渠道树注册排行不能直接累加子渠道或混淆同号平台: %+v", stats.RegisterRanks)
|
||||
}
|
||||
scope.IDCardDedup = false
|
||||
stats, err = repo.Statistics(context.Background(), scope)
|
||||
if err != nil || stats.Current.Register != 3 || portraitTestMoney(stats.CurrentPay) != "2058.60" {
|
||||
t.Fatalf("模式0没有区分跨平台同号身份或改变金额: %+v %v", stats, err)
|
||||
}
|
||||
scope.IDCardDedup = true
|
||||
scope.IncludeSub = false
|
||||
stats, err = repo.Statistics(context.Background(), scope)
|
||||
if err != nil || stats.Current.Register != 1 || portraitTestMoney(stats.CurrentPay) != "1009.10" {
|
||||
t.Fatalf("关闭下级没有收窄: %+v %v", stats, err)
|
||||
}
|
||||
}
|
||||
func TestPortraitHourlyUsesRawMainAccountsAndHalfOpenDay(t *testing.T) {
|
||||
db, repo, scope := preparePortraitData(t)
|
||||
scope.Range.Begin = scope.Range.End
|
||||
scope.Range.Hourly = true
|
||||
stats, err := repo.Statistics(context.Background(), scope)
|
||||
if err != nil || stats.RegisterSeries["1"] != "5" || portraitTestMoney(stats.PaySeries["1"]) != "200.90" {
|
||||
t.Fatalf("小时趋势错误: %+v %v", stats, err)
|
||||
}
|
||||
end := scope.Range.End.AddDate(0, 0, 1).Unix()
|
||||
if err := db.Exec("INSERT INTO tab_user(source_db,id,promote_id,puid,idcard,register_time) VALUES('lx',900,100,0,'boundary',?)", end).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stats, err = repo.Statistics(context.Background(), scope)
|
||||
if err != nil || stats.RegisterSeries["0"] != "" || stats.RegisterSeries["1"] != "5" {
|
||||
t.Fatal("次日零点被计入当日")
|
||||
}
|
||||
if err := db.Exec("DROP TABLE tab_sw_report_day_key").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stats, err = repo.Statistics(context.Background(), scope)
|
||||
if err != nil || stats.Available {
|
||||
t.Fatal("缺汇总键表不应返回假零指标")
|
||||
}
|
||||
channels, available, err := repo.Channels(context.Background(), nil, true)
|
||||
if err != nil || !available || len(channels) != 0 {
|
||||
t.Fatal("无绑定应是已知空范围")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortraitCountLimitAppliesBeforeTopN(t *testing.T) {
|
||||
db, repo, scope := preparePortraitData(t)
|
||||
if err := db.Exec("INSERT INTO tab_sw_report_day_key VALUES('lx',103,1,1,'2026-11-02',1,'N2'),('lx',103,1,1,'2026-11-02',1,'N3')").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scope.CountLimit = 1
|
||||
scope.RankLimit = 1
|
||||
scope.Sources = []string{"lx"}
|
||||
stats, err := repo.Statistics(context.Background(), scope)
|
||||
if err != nil || len(stats.RegisterRanks) != 1 || stats.RegisterRanks[0].ID != 100 || stats.RegisterRanks[0].Value != "1" {
|
||||
t.Fatalf("人数归一上限必须先于排行截取: %+v %v", stats, err)
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ var ProviderSet = wire.NewSet(
|
||||
databusiness.NewActivationRepo,
|
||||
databusiness.NewChannelRepo,
|
||||
databusiness.NewChannelWriteRepo,
|
||||
databusiness.NewBusinessPortraitRepo,
|
||||
NewIntegrationRuntime,
|
||||
wire.Bind(new(datasystem.Provider), new(*Data)),
|
||||
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
|
||||
|
||||
@@ -33,8 +33,8 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
|
||||
menus += len(definition.Surface.Menus)
|
||||
apis += len(definition.Surface.APIs)
|
||||
}
|
||||
if menus != 11 || apis != 50 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 11/50", menus, apis)
|
||||
if menus != 11 || apis != 52 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 11/52", menus, apis)
|
||||
}
|
||||
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
|
||||
t.Fatalf("default timed tasks = %d, want 2", len(got))
|
||||
|
||||
@@ -41,6 +41,8 @@ type routeValue struct {
|
||||
// Gin router files still bind handlers; tests keep those registrations aligned
|
||||
// with this catalog while public, audit and API metadata live only here.
|
||||
var routes = map[string]routeValue{
|
||||
"GET /sw/business/:userId/portrait": {group: "商务画像", description: "查询商务画像"},
|
||||
"GET /sw/business/bindPlatform/portrait": {group: "商务画像", description: "查询 SDK 商务画像"},
|
||||
"GET /sw/promote/list": {group: "渠道管理", description: "查询渠道列表及累计指标"},
|
||||
"GET /sw/promote/bindOptions": {group: "渠道管理", description: "查询可用游戏商务绑定"},
|
||||
"GET /sw/promote/game-distribution": {group: "渠道管理", description: "查询推广游戏累计分布"},
|
||||
|
||||
@@ -26,13 +26,14 @@ func NewGinEngine(runtime *config.Store, access *systemservice.AccessControlServ
|
||||
|
||||
func emptyHandlers() *handler.Set {
|
||||
return &handler.Set{
|
||||
Channel: &handler.Channel{},
|
||||
Activation: &handler.Activation{},
|
||||
AlertRecord: &handler.AlertRecord{},
|
||||
AlertRule: &handler.AlertRule{},
|
||||
BusinessPromote: &handler.BusinessPromote{},
|
||||
Business: &handler.Business{},
|
||||
Authority: &handler.Authority{}, Menu: &handler.Menu{}, API: &handler.API{},
|
||||
BusinessPortrait: &handler.BusinessPortrait{},
|
||||
Channel: &handler.Channel{},
|
||||
Activation: &handler.Activation{},
|
||||
AlertRecord: &handler.AlertRecord{},
|
||||
AlertRule: &handler.AlertRule{},
|
||||
BusinessPromote: &handler.BusinessPromote{},
|
||||
Business: &handler.Business{},
|
||||
Authority: &handler.Authority{}, Menu: &handler.Menu{}, API: &handler.API{},
|
||||
Permission: &handler.Permission{}, Organization: &handler.Organization{},
|
||||
Announcement: &handler.Announcement{}, Email: &handler.Email{}, Payment: &handler.Payment{}, Task: &handler.Task{},
|
||||
Media: &handler.Media{}, Audit: &handler.Audit{}, Export: &handler.Export{},
|
||||
@@ -103,7 +104,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
|
||||
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
|
||||
t.Fatalf("registered route log count = %d, want %d", got, want)
|
||||
}
|
||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":233`) {
|
||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":235`) {
|
||||
t.Fatalf("startup route summary is missing: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
"kra/internal/service/business"
|
||||
"kra/internal/service/dto"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type BusinessPortrait struct {
|
||||
service *business.BusinessPortraitService
|
||||
}
|
||||
|
||||
func NewBusinessPortrait(service *business.BusinessPortraitService) *BusinessPortrait {
|
||||
return &BusinessPortrait{service: service}
|
||||
}
|
||||
func (h *BusinessPortrait) ForUser(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("userId"), 10, 64)
|
||||
if err != nil {
|
||||
Fail(c, "商务用户 ID 不正确")
|
||||
return
|
||||
}
|
||||
var req dto.BusinessPortraitQuery
|
||||
if c.ShouldBindQuery(&req) != nil {
|
||||
Fail(c, "商务画像查询参数不正确")
|
||||
return
|
||||
}
|
||||
result, err := h.service.ForUser(c.Request.Context(), uint(id), &req)
|
||||
if err != nil {
|
||||
Fail(c, errors.FromError(err).Message)
|
||||
return
|
||||
}
|
||||
OKWithData(c, result)
|
||||
}
|
||||
func (h *BusinessPortrait) ForBinding(c *gin.Context) {
|
||||
var req dto.BindingPortraitQuery
|
||||
if c.ShouldBindQuery(&req) != nil {
|
||||
Fail(c, "商务画像查询参数不正确")
|
||||
return
|
||||
}
|
||||
result, err := h.service.ForBinding(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
Fail(c, errors.FromError(err).Message)
|
||||
return
|
||||
}
|
||||
OKWithData(c, result)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewAlertRecord,
|
||||
NewActivation,
|
||||
NewChannel,
|
||||
NewBusinessPortrait,
|
||||
NewAuthority, NewMenu, NewAPI, NewPermission, NewOrganization,
|
||||
NewAnnouncement, NewEmail, NewPayment, NewTask, NewMedia, NewAudit,
|
||||
NewExport, NewVersion, NewDictionary, NewParameter, NewAPIToken,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
type Set struct {
|
||||
BusinessPortrait *BusinessPortrait
|
||||
Channel *Channel
|
||||
Activation *Activation
|
||||
AlertRecord *AlertRecord
|
||||
|
||||
@@ -20,6 +20,10 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
|
||||
return
|
||||
}
|
||||
registrations := []func(){
|
||||
func() {
|
||||
private.GET("/sw/business/:userId/portrait", r.handlers.BusinessPortrait.ForUser)
|
||||
private.GET("/sw/business/bindPlatform/portrait", r.handlers.BusinessPortrait.ForBinding)
|
||||
},
|
||||
func() { RegisterChannel(private, r.handlers.Channel) },
|
||||
func() {
|
||||
private.GET("/sw/promoteActivePool/bindList", r.handlers.Activation.List)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"kra/internal/biz/business"
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
type BusinessPortraitUsecase interface {
|
||||
ForUser(context.Context, uint, []string, string) (*business.BusinessPortrait, error)
|
||||
ForBinding(context.Context, business.PlatformKey, string) (*business.BusinessPortrait, error)
|
||||
}
|
||||
type BusinessPortraitService struct{ uc BusinessPortraitUsecase }
|
||||
|
||||
func NewBusinessPortraitService(uc *business.BusinessPortraitUsecase) *BusinessPortraitService {
|
||||
return &BusinessPortraitService{uc: uc}
|
||||
}
|
||||
func portraitMetricResponse(m *business.PortraitMetric) *dto.PortraitMetric {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
return &dto.PortraitMetric{Value: m.Value, CompareValue: m.CompareValue, ChangeRate: m.ChangeRate, ChangeDiff: m.ChangeDiff, CompareLabel: m.CompareLabel}
|
||||
}
|
||||
func portraitResponse(p *business.BusinessPortrait) *dto.BusinessPortraitResponse {
|
||||
b := p.Basic
|
||||
out := &dto.BusinessPortraitResponse{Basic: &dto.PortraitBasic{UserID: b.UserID, UserName: b.UserName, RealName: b.RealName, Level: b.Level, LevelLabel: business.BusinessLevelLabel(b.Level), ParentUserID: b.ParentUserID, ParentUserName: b.ParentUserName, ParentRealName: b.ParentRealName, CreateTime: localTime(b.CreatedAt), Status: b.Status, StatusLabel: b.StatusLabel, PromoteCount: b.PromoteCount, MonthNewPromote: b.MonthNewPromote}, Period: p.Period, RegisterTrend: []*dto.PortraitTrend{}, PayTrend: []*dto.PortraitTrend{}, RegisterTop5: []*dto.PortraitRank{}, PayTop5: []*dto.PortraitRank{}, Available: p.Available, UnavailableReason: p.UnavailableReason}
|
||||
if m := p.Metrics; m != nil {
|
||||
out.Metrics = &dto.PortraitMetrics{Register: portraitMetricResponse(m.Register), PayAmount: portraitMetricResponse(m.PayAmount), PayRate: portraitMetricResponse(m.PayRate), ARPU: portraitMetricResponse(m.ARPU)}
|
||||
}
|
||||
for _, point := range p.RegisterTrend {
|
||||
out.RegisterTrend = append(out.RegisterTrend, &dto.PortraitTrend{Label: point.Label, Value: point.Value})
|
||||
}
|
||||
for _, point := range p.PayTrend {
|
||||
out.PayTrend = append(out.PayTrend, &dto.PortraitTrend{Label: point.Label, Value: point.Value})
|
||||
}
|
||||
ranks := func(rows []*business.PortraitRank) []*dto.PortraitRank {
|
||||
result := make([]*dto.PortraitRank, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
result = append(result, &dto.PortraitRank{Rank: row.Rank, SourceDB: row.SourceDB, SourceDBLabel: business.PlatformName(row.SourceDB), PromoteID: row.ID, PromoteName: row.PromoteName, Value: row.Value})
|
||||
}
|
||||
return result
|
||||
}
|
||||
out.RegisterTop5 = ranks(p.RegisterTop5)
|
||||
out.PayTop5 = ranks(p.PayTop5)
|
||||
return out
|
||||
}
|
||||
func (s *BusinessPortraitService) ForUser(ctx context.Context, id uint, req *dto.BusinessPortraitQuery) (*dto.BusinessPortraitResponse, error) {
|
||||
if id == 0 || req == nil {
|
||||
return nil, business.ErrAccountInput
|
||||
}
|
||||
sources, err := normalizePlatforms(req.Sources)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(sources) == 0 {
|
||||
return nil, business.ErrPortraitPlatforms
|
||||
}
|
||||
portrait, err := s.uc.ForUser(ctx, id, sources, req.Period)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return portraitResponse(portrait), nil
|
||||
}
|
||||
func (s *BusinessPortraitService) ForBinding(ctx context.Context, req *dto.BindingPortraitQuery) (*dto.BusinessPortraitResponse, error) {
|
||||
if req == nil || req.PlatformBusierID == 0 {
|
||||
return nil, business.ErrAccountInput
|
||||
}
|
||||
portrait, err := s.uc.ForBinding(ctx, business.PlatformKey{SourceDB: req.SourceDB, BusierID: req.PlatformBusierID}, req.Period)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return portraitResponse(portrait), nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package dto
|
||||
|
||||
type BusinessPortraitQuery struct {
|
||||
Sources []string `form:"sourceDbList"`
|
||||
Period string `form:"period"`
|
||||
}
|
||||
type BindingPortraitQuery struct {
|
||||
SourceDB string `form:"sourceDb"`
|
||||
PlatformBusierID uint64 `form:"platformBusierId"`
|
||||
Period string `form:"period"`
|
||||
}
|
||||
type PortraitBasic struct {
|
||||
UserID uint64 `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
RealName string `json:"realName"`
|
||||
Level *int `json:"level"`
|
||||
LevelLabel string `json:"levelLabel"`
|
||||
ParentUserID *uint64 `json:"parentUserId"`
|
||||
ParentUserName string `json:"parentUserName"`
|
||||
ParentRealName string `json:"parentRealName"`
|
||||
CreateTime *string `json:"createTime"`
|
||||
Status *string `json:"status"`
|
||||
StatusLabel string `json:"statusLabel"`
|
||||
PromoteCount *int64 `json:"promoteCount"`
|
||||
MonthNewPromote *int64 `json:"monthNewPromote"`
|
||||
}
|
||||
type PortraitMetric struct {
|
||||
Value string `json:"value"`
|
||||
CompareValue *string `json:"compareValue"`
|
||||
ChangeRate *string `json:"changeRate"`
|
||||
ChangeDiff *string `json:"changeDiff"`
|
||||
CompareLabel string `json:"compareLabel"`
|
||||
}
|
||||
type PortraitMetrics struct {
|
||||
Register *PortraitMetric `json:"register"`
|
||||
PayAmount *PortraitMetric `json:"payAmount"`
|
||||
PayRate *PortraitMetric `json:"payRate"`
|
||||
ARPU *PortraitMetric `json:"arpu"`
|
||||
}
|
||||
type PortraitTrend struct {
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
type PortraitRank struct {
|
||||
Rank int `json:"rank"`
|
||||
SourceDB string `json:"sourceDb"`
|
||||
SourceDBLabel string `json:"sourceDbLabel"`
|
||||
PromoteID uint64 `json:"promoteId"`
|
||||
PromoteName string `json:"promoteName"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
type BusinessPortraitResponse struct {
|
||||
Basic *PortraitBasic `json:"basic"`
|
||||
Period string `json:"period"`
|
||||
Metrics *PortraitMetrics `json:"metrics"`
|
||||
RegisterTrend []*PortraitTrend `json:"registerTrend"`
|
||||
PayTrend []*PortraitTrend `json:"payTrend"`
|
||||
RegisterTop5 []*PortraitRank `json:"registerTop5"`
|
||||
PayTop5 []*PortraitRank `json:"payTop5"`
|
||||
Available bool `json:"available"`
|
||||
UnavailableReason string `json:"unavailableReason"`
|
||||
}
|
||||
@@ -21,6 +21,7 @@ var ProviderSet = wire.NewSet(
|
||||
businessservice.NewAlertRecordService,
|
||||
businessservice.NewActivationService,
|
||||
businessservice.NewChannelService,
|
||||
businessservice.NewBusinessPortraitService,
|
||||
systemservice.ProviderSet,
|
||||
paymentservice.ProviderSet,
|
||||
integrationservice.ProviderSet,
|
||||
|
||||
Reference in New Issue
Block a user