package business import ( "context" "testing" "time" ) type channelPortraitRepoFake struct { count *int64 first string tiers *ChannelTierData tierScope ChannelGameScope } func (r *channelPortraitRepoFake) GameCount(context.Context, ChannelKey) (*int64, error) { return r.count, nil } func (r *channelPortraitRepoFake) EarliestDate(context.Context, ChannelGameScope) (string, bool, error) { return r.first, true, nil } func (r *channelPortraitRepoFake) TierData(_ context.Context, s ChannelGameScope) (*ChannelTierData, error) { r.tierScope = s return r.tiers, nil } type portraitChannelReaderFake struct { ChannelRepo rows map[ChannelKey]*Channel gameScope ChannelGameScope } func (r *portraitChannelReaderFake) Find(_ context.Context, scope UserScope, key ChannelKey) (*Channel, error) { if !scope.All && scope.SelfID == 99 { return nil, ErrChannelScope } row := r.rows[key] if row == nil { return nil, ErrChannelNotFound } copy := *row return ©, nil } func (r *portraitChannelReaderFake) Games(_ context.Context, s ChannelGameScope) ([]*ChannelGame, error) { r.gameScope = s return []*ChannelGame{{ID: 1, Name: "游戏", RegisterCount: 1, PayAmount: "12.00"}}, nil } func channelPortraitFixture() (*ChannelPortraitUsecase, *channelPortraitRepoFake, *portraitChannelReaderFake, *portraitRepoFake) { base, reports := portraitFixture() key := ChannelKey{SourceDB: "lx", ID: 101} parent := ChannelKey{SourceDB: "lx", ID: 100} count := int64(1) channels := &portraitChannelReaderFake{rows: map[ChannelKey]*Channel{key: {ChannelKey: key, Account: "child", Nickname: "子渠道", Level: intPointer(2), Status: intPointer(-3), ParentID: 100, BusierID: 14}, parent: {ChannelKey: parent, Account: "parent", Level: intPointer(1)}}} repo := &channelPortraitRepoFake{count: &count, first: "2026-11-01", tiers: &ChannelTierData{Available: true, RegisterKeys: []string{"A"}}} uc := NewChannelPortraitUsecase(repo, channels, reports, base.accounts, base.preferences) uc.now = func() time.Time { return time.Date(2026, 11, 3, 12, 0, 0, 0, portraitLocation) } return uc, repo, channels, reports } func TestChannelPortraitTotalHasNoComparisonAndKeepsCumulativeParts(t *testing.T) { uc, repo, channels, reports := channelPortraitFixture() p, err := uc.Portrait(accountContext(7, 901), ChannelKey{SourceDB: "lx", ID: 101}, "total") if err != nil { t.Fatal(err) } if p.Period != "total" || !reports.scope.NoCompare || reports.scope.Root.ID != 101 || len(reports.scope.Pairs) != 0 { t.Fatal("累计模式或渠道范围错误") } if reports.scope.Range.Begin.Format("2006-01-02") != "2026-11-01" || len(p.RegisterTrend) != 3 || p.Metrics.Active.Value != "3" || p.Metrics.Active.CompareValue != nil || p.Metrics.PayAmount.ChangeRate != nil { t.Fatal("累计卡片不应带环比,趋势应从最早汇总开始") } if p.Basic.Parent == nil || p.Basic.Parent.Account != "parent" || p.Basic.Channel.BusinessDisplayName() != "leaf" { t.Fatal("上级渠道或归属商务资料未正确补齐") } p, err = uc.Portrait(accountContext(7, 901), ChannelKey{SourceDB: "lx", ID: 101}, "last7") if err != nil { t.Fatal(err) } if len(p.RegisterTrend) != 7 || channels.gameScope.BeginDate != "2026-11-01" || repo.tierScope.BeginDate != "2026-11-01" || reports.scope.Range.Begin.Format("2006-01-02") != "2026-10-28" { t.Fatal("日期切换不应把游戏分布或分层改成当期口径") } } func TestChannelPortraitMissingDataAndVisibility(t *testing.T) { uc, repo, _, _ := channelPortraitFixture() repo.count = nil p, err := uc.Portrait(accountContext(7, 901), ChannelKey{SourceDB: "lx", ID: 101}, "today") if err != nil || p.Available || p.Metrics != nil || p.Basic.Channel.Account != "child" { t.Fatal("缺业务数据时应保留已读取资料,不造零指标") } if _, err := uc.Portrait(accountContext(99, 901), ChannelKey{SourceDB: "lx", ID: 101}, "today"); err != ErrChannelScope { t.Fatal("越权渠道画像被放行") } if normalizeChannelPortraitPeriod("thismonth") != "today" || normalizeChannelPortraitPeriod("TOTAL") != "total" { t.Fatal("渠道周期范围与商务画像混用") } } func TestChannelPayTiersUseRegisteredIdentitiesAndExactThresholds(t *testing.T) { data := &ChannelTierData{RegisterKeys: []string{"A-x", "A-x", "B", "C", "D", "E", "F"}, Payments: []*ChannelIdentityPay{{IDCard: stringPointer(" A-X "), Amount: "9999.50"}, {IDCard: stringPointer("A-x"), Amount: "0.50"}, {IDCard: stringPointer("B"), Amount: "1000.00"}, {IDCard: stringPointer("C"), Amount: "0.01"}, {IDCard: stringPointer("D"), Amount: "0"}, {IDCard: stringPointer("E"), Amount: "-1"}, {IDCard: stringPointer("outside"), Amount: "999999"}}} tiers, err := buildChannelPayTiers("lx", true, data) if err != nil { t.Fatal(err) } want := []int64{1, 1, 1, 3} for i, tier := range tiers { if tier.Count != want[i] { t.Fatalf("分层边界、归一或成员范围错误: %+v", tiers) } } if tiers[0].Ratio != "16.7" || tiers[3].Ratio != "50.0" { t.Fatal("分层比例应独立保留一位,不强行凑整") } id1, id2, id4 := uint64(1), uint64(2), uint64(4) tiers, err = buildChannelPayTiers("lx", false, &ChannelTierData{RegisterKeys: []string{"lx|1", "lx|2", "lx|3"}, Payments: []*ChannelIdentityPay{{UserID: &id1, Amount: "1000"}, {UserID: &id2, Amount: "999.99"}, {UserID: &id4, Amount: "100000"}}}) if err != nil || tiers[0].Count != 0 || tiers[1].Count != 1 || tiers[2].Count != 1 || tiers[3].Count != 1 { t.Fatal("模式0必须使用带平台前缀的原始用户 ID,未注册身份不参与分层") } tiers, err = buildChannelPayTiers("lx", false, &ChannelTierData{FallbackCount: 5}) if err != nil || tiers[3].Count != 5 || tiers[3].Ratio != "100.0" { t.Fatal("无身份列表时应保留原回退的免费人数") } }