feat: 迁移商务与游戏商务画像及交互看板

This commit is contained in:
Yvan
2026-11-03 23:11:46 +08:00
parent 7f8105f6fb
commit 0de822f121
30 changed files with 1959 additions and 26 deletions
+1 -1
View File
@@ -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)
+491
View File
@@ -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("应先将充值金额保留两位再计算人均充值")
}
}