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, business.NewBusinessPortraitUsecase)
|
||||
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, business.NewChannelPortraitUsecase)
|
||||
|
||||
@@ -101,6 +101,8 @@ type PortraitStatistics struct {
|
||||
Available bool
|
||||
}
|
||||
type PortraitReportScope struct {
|
||||
Root *Channel
|
||||
NoCompare bool
|
||||
Pairs []PlatformKey
|
||||
Sources []string
|
||||
IncludeSub, IDCardDedup bool
|
||||
|
||||
@@ -40,6 +40,7 @@ type Channel struct {
|
||||
BindingAccount, BindingUserName string
|
||||
GameCount, PlayerCount *int64
|
||||
TotalPayAmount *string
|
||||
CreatedUnix, LastLoginUnix *int64
|
||||
}
|
||||
|
||||
func (c *Channel) BusinessDisplayName() string {
|
||||
@@ -100,6 +101,7 @@ type ChannelGame struct {
|
||||
type ChannelGameScope struct {
|
||||
Root *Channel
|
||||
IncludeSub, IDCardDedup bool
|
||||
BeginDate string
|
||||
EndDate string
|
||||
}
|
||||
type ChannelRepo interface {
|
||||
@@ -327,6 +329,10 @@ func (uc *ChannelUsecase) Games(ctx context.Context, key ChannelKey) ([]*Channel
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalizeChannelGames(games)
|
||||
}
|
||||
|
||||
func normalizeChannelGames(games []*ChannelGame) ([]*ChannelGame, error) {
|
||||
amounts := map[uint64]*big.Rat{}
|
||||
for _, game := range games {
|
||||
game.RegisterCount = min(game.RegisterCount, int64(2147483647))
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
)
|
||||
|
||||
type ChannelPortraitBasic struct {
|
||||
Channel *Channel
|
||||
Parent *Channel
|
||||
GameCount *int64
|
||||
}
|
||||
type ChannelPortraitMetrics struct{ Register, PayAmount, PayRate, Active *PortraitMetric }
|
||||
type ChannelPayTier struct {
|
||||
Tier, Label string
|
||||
Count int64
|
||||
Ratio string
|
||||
}
|
||||
type ChannelPortrait struct {
|
||||
Basic *ChannelPortraitBasic
|
||||
Period string
|
||||
Metrics *ChannelPortraitMetrics
|
||||
RegisterTrend, PayTrend []*PortraitTrend
|
||||
GameDistribution []*ChannelGame
|
||||
PayTiers []*ChannelPayTier
|
||||
Available bool
|
||||
UnavailableReason string
|
||||
}
|
||||
type ChannelIdentityPay struct {
|
||||
UserID *uint64
|
||||
IDCard *string
|
||||
Amount string
|
||||
}
|
||||
type ChannelTierData struct {
|
||||
RegisterKeys []string
|
||||
FallbackCount int64
|
||||
Payments []*ChannelIdentityPay
|
||||
Available bool
|
||||
}
|
||||
type ChannelPortraitRepo interface {
|
||||
GameCount(context.Context, ChannelKey) (*int64, error)
|
||||
EarliestDate(context.Context, ChannelGameScope) (string, bool, error)
|
||||
TierData(context.Context, ChannelGameScope) (*ChannelTierData, error)
|
||||
}
|
||||
type ChannelPortraitUsecase struct {
|
||||
repo ChannelPortraitRepo
|
||||
channels ChannelRepo
|
||||
reports BusinessPortraitRepo
|
||||
accounts *AccountUsecase
|
||||
preferences *UserPreferenceUsecase
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewChannelPortraitUsecase(repo ChannelPortraitRepo, channels ChannelRepo, reports BusinessPortraitRepo, accounts *AccountUsecase, preferences *UserPreferenceUsecase) *ChannelPortraitUsecase {
|
||||
return &ChannelPortraitUsecase{repo: repo, channels: channels, reports: reports, accounts: accounts, preferences: preferences, now: time.Now}
|
||||
}
|
||||
func normalizeChannelPortraitPeriod(period string) string {
|
||||
switch value := strings.ToLower(strings.TrimSpace(period)); value {
|
||||
case "today", "yesterday", "last7", "last30", "total":
|
||||
return value
|
||||
}
|
||||
return "today"
|
||||
}
|
||||
func ChannelLevelLabel(level *int) string {
|
||||
if level == nil {
|
||||
return ""
|
||||
}
|
||||
return map[int]string{1: "一级渠道", 2: "二级渠道", 3: "三级渠道"}[*level]
|
||||
}
|
||||
func ChannelStatusLabel(status *int) string {
|
||||
if status == nil {
|
||||
return ""
|
||||
}
|
||||
return map[int]string{0: "待审核", 1: "正常", -2: "驳回", -3: "商务新增"}[*status]
|
||||
}
|
||||
func ChannelTypeLabel(registerType *int) string {
|
||||
if registerType == nil {
|
||||
return ""
|
||||
}
|
||||
if *registerType == 1 {
|
||||
return "公会"
|
||||
}
|
||||
return "个人"
|
||||
}
|
||||
func ChannelUnixTime(value *int64) *time.Time {
|
||||
if value == nil || *value <= 0 {
|
||||
return nil
|
||||
}
|
||||
t := time.Unix(*value, 0).In(portraitLocation)
|
||||
return &t
|
||||
}
|
||||
func unavailableChannelPortrait(p *ChannelPortrait, reason string) *ChannelPortrait {
|
||||
p.Available = false
|
||||
p.UnavailableReason = reason
|
||||
p.Metrics = nil
|
||||
p.RegisterTrend = []*PortraitTrend{}
|
||||
p.PayTrend = []*PortraitTrend{}
|
||||
p.GameDistribution = []*ChannelGame{}
|
||||
p.PayTiers = []*ChannelPayTier{}
|
||||
return p
|
||||
}
|
||||
func (uc *ChannelPortraitUsecase) Portrait(ctx context.Context, key ChannelKey, period string) (*ChannelPortrait, error) {
|
||||
source, err := NormalizePlatform(key.SourceDB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key.SourceDB = source
|
||||
actor, account, err := uc.accounts.actor(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
viewer := portraitUserScope(account)
|
||||
viewer.All = actor.AuthorityID == 888
|
||||
root, err := uc.channels.Find(ctx, viewer, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
period = normalizeChannelPortraitPeriod(period)
|
||||
profile := &ChannelPortrait{Basic: &ChannelPortraitBasic{Channel: root}, Period: period, Available: true, RegisterTrend: []*PortraitTrend{}, PayTrend: []*PortraitTrend{}, GameDistribution: []*ChannelGame{}, PayTiers: []*ChannelPayTier{}}
|
||||
if (root.Level == nil || *root.Level != 1) && root.ParentID > 0 {
|
||||
parent, err := uc.channels.Find(ctx, UserScope{All: true}, ChannelKey{SourceDB: source, ID: root.ParentID})
|
||||
if err != nil && !errors.IsNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
profile.Basic.Parent = parent
|
||||
}
|
||||
if root.BusierID > 0 {
|
||||
bindings, err := uc.reports.Bindings(ctx, viewer, []string{source}, &PlatformKey{SourceDB: source, BusierID: root.BusierID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(bindings) > 0 {
|
||||
b := bindings[0]
|
||||
root.BusierUserID = &b.UserID
|
||||
root.BindingAccount = b.Account
|
||||
owner, err := uc.accounts.repo.Find(ctx, b.UserID)
|
||||
if err != nil && !errors.IsNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
if owner != nil {
|
||||
root.BindingUserName = owner.Username
|
||||
}
|
||||
}
|
||||
}
|
||||
profile.Basic.GameCount, err = uc.repo.GameCount(ctx, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if profile.Basic.GameCount == nil {
|
||||
return unavailableChannelPortrait(profile, "推广游戏数据尚未接入,渠道画像指标暂不可用"), nil
|
||||
}
|
||||
pref, err := uc.preferences.Current(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := uc.now()
|
||||
today := now.In(portraitLocation).Format("2006-01-02")
|
||||
cumulative := ChannelGameScope{Root: root, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup, EndDate: today}
|
||||
begin, available, err := uc.repo.EarliestDate(ctx, cumulative)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !available {
|
||||
return unavailableChannelPortrait(profile, "日汇总数据尚未接入,渠道画像指标暂不可用"), nil
|
||||
}
|
||||
if begin == "" {
|
||||
begin = today
|
||||
}
|
||||
cumulative.BeginDate = begin
|
||||
ranges := portraitRanges(period, now)
|
||||
if period == "total" {
|
||||
ranges.Begin, err = time.ParseInLocation("2006-01-02", begin, portraitLocation)
|
||||
if err != nil {
|
||||
return nil, ErrStorage
|
||||
}
|
||||
ranges.Hourly = false
|
||||
ranges.CompareLabel = ""
|
||||
}
|
||||
stats, err := uc.reports.Statistics(ctx, PortraitReportScope{Root: root, Sources: []string{source}, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup, Range: ranges, NoCompare: period == "total"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !stats.Available {
|
||||
return unavailableChannelPortrait(profile, "汇总或趋势数据尚未接入,渠道画像指标暂不可用"), nil
|
||||
}
|
||||
profile.Metrics, err = channelPortraitMetrics(stats, ranges.CompareLabel, period != "total")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profile.RegisterTrend, err = portraitTrend(stats.RegisterSeries, ranges, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
profile.PayTrend, err = portraitTrend(stats.PaySeries, ranges, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
games, err := uc.channels.Games(ctx, cumulative)
|
||||
if err != nil {
|
||||
if err == ErrChannelReportsUnavailable {
|
||||
return unavailableChannelPortrait(profile, "推广游戏汇总数据尚未接入"), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
profile.GameDistribution, err = normalizeChannelGames(games)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tiers, err := uc.repo.TierData(ctx, cumulative)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !tiers.Available {
|
||||
return unavailableChannelPortrait(profile, "付费分层所需的注册或支付数据尚未接入"), nil
|
||||
}
|
||||
profile.PayTiers, err = buildChannelPayTiers(source, pref.IDCardDedup, tiers)
|
||||
return profile, err
|
||||
}
|
||||
func channelPortraitMetrics(stats *PortraitStatistics, label string, compare bool) (*ChannelPortraitMetrics, error) {
|
||||
base, err := portraitMetrics(stats, label)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
active, err := portraitRateCard(strconv.FormatInt(min(stats.Current.Active, int64(2147483647)), 10), strconv.FormatInt(min(stats.Previous.Active, int64(2147483647)), 10), label)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &ChannelPortraitMetrics{Register: base.Register, PayAmount: base.PayAmount, PayRate: base.PayRate, Active: active}
|
||||
if !compare {
|
||||
for _, card := range []*PortraitMetric{result.Register, result.PayAmount, result.PayRate, result.Active} {
|
||||
card.CompareValue = nil
|
||||
card.ChangeDiff = nil
|
||||
card.ChangeRate = nil
|
||||
card.CompareLabel = ""
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func buildChannelPayTiers(source string, dedup bool, data *ChannelTierData) ([]*ChannelPayTier, error) {
|
||||
keys := map[string]bool{}
|
||||
for _, key := range data.RegisterKeys {
|
||||
keys[key] = true
|
||||
}
|
||||
denominator := int64(len(keys))
|
||||
if denominator == 0 {
|
||||
denominator = min(data.FallbackCount, int64(2147483647))
|
||||
}
|
||||
counts := []int64{0, 0, 0, 0}
|
||||
if len(keys) == 0 {
|
||||
counts[3] = denominator
|
||||
} else {
|
||||
payByKey := map[string]*big.Rat{}
|
||||
for _, payment := range data.Payments {
|
||||
var key string
|
||||
if dedup {
|
||||
if payment.IDCard == nil {
|
||||
continue
|
||||
}
|
||||
key = normalizeIDCard(*payment.IDCard)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
if payment.UserID == nil {
|
||||
continue
|
||||
}
|
||||
key = source + "|" + strconv.FormatUint(*payment.UserID, 10)
|
||||
}
|
||||
amount, err := portraitNumber(payment.Amount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
amount, _ = portraitNumber(portraitDecimal(amount, 2))
|
||||
if payByKey[key] == nil {
|
||||
payByKey[key] = new(big.Rat)
|
||||
}
|
||||
payByKey[key].Add(payByKey[key], amount)
|
||||
}
|
||||
for key := range keys {
|
||||
pay := payByKey[key]
|
||||
if pay == nil {
|
||||
pay = new(big.Rat)
|
||||
}
|
||||
switch {
|
||||
case pay.Cmp(big.NewRat(10000, 1)) >= 0:
|
||||
counts[0]++
|
||||
case pay.Cmp(big.NewRat(1000, 1)) >= 0:
|
||||
counts[1]++
|
||||
case pay.Sign() > 0:
|
||||
counts[2]++
|
||||
default:
|
||||
counts[3]++
|
||||
}
|
||||
}
|
||||
}
|
||||
result := []*ChannelPayTier{}
|
||||
codes := []string{"whale", "mid", "small", "free"}
|
||||
labels := []string{"大R", "中R", "小R", "免费"}
|
||||
for i, code := range codes {
|
||||
ratio := "0.0"
|
||||
if denominator > 0 {
|
||||
ratio = portraitDecimal(new(big.Rat).Mul(big.NewRat(counts[i], denominator), big.NewRat(100, 1)), 1)
|
||||
}
|
||||
result = append(result, &ChannelPayTier{Tier: code, Label: labels[i], Count: counts[i], Ratio: ratio})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
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("无身份列表时应保留原回退的免费人数")
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,9 @@ func (r *businessPortraitRepo) PlatformBusiness(ctx context.Context, key busines
|
||||
}
|
||||
|
||||
func portraitPromoteScope(db *gorm.DB, scope business.PortraitReportScope, sourceColumn, promoteColumn string) *gorm.DB {
|
||||
if scope.Root != nil {
|
||||
return channelGameTree(db, business.ChannelGameScope{Root: scope.Root, IncludeSub: scope.IncludeSub}).Select("1").Where("p.source_db = " + sourceColumn + " AND p.id = " + promoteColumn)
|
||||
}
|
||||
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")
|
||||
@@ -148,7 +151,7 @@ func toPortraitCounts(rows []portraitCountPO) business.PortraitCounts {
|
||||
|
||||
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 {
|
||||
if len(scope.Pairs) == 0 && scope.Root == nil {
|
||||
return result, nil
|
||||
}
|
||||
db := r.data.DB().WithContext(ctx)
|
||||
@@ -172,11 +175,19 @@ func (r *businessPortraitRepo) Statistics(ctx context.Context, scope business.Po
|
||||
}
|
||||
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 {
|
||||
periods := []struct {
|
||||
begin, end string
|
||||
counts *business.PortraitCounts
|
||||
pay *string
|
||||
}{{begin, end, &result.Current, &result.CurrentPay}, {previousBegin, previousEnd, &result.Previous, &result.PreviousPay}} {
|
||||
}{{begin, end, &result.Current, &result.CurrentPay}}
|
||||
if !scope.NoCompare {
|
||||
periods = append(periods, struct {
|
||||
begin, end string
|
||||
counts *business.PortraitCounts
|
||||
pay *string
|
||||
}{previousBegin, previousEnd, &result.Previous, &result.PreviousPay})
|
||||
}
|
||||
for _, period := range periods {
|
||||
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 {
|
||||
@@ -196,16 +207,18 @@ func (r *businessPortraitRepo) Statistics(ctx context.Context, scope business.Po
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
register, err := portraitRankQuery(db, scope, mode, begin, end, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if scope.RankLimit > 0 {
|
||||
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
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -16,11 +16,12 @@ type channelPO struct {
|
||||
PromoteLevel, RegisterType, Status, LockStatus *int
|
||||
ParentID, TopPromoteID, BusierID uint64
|
||||
CreateTime *int64
|
||||
LastLoginTime *int64 `gorm:"column:last_login_time"`
|
||||
}
|
||||
|
||||
func (channelPO) TableName() string { return "tab_promote" }
|
||||
func toBizChannel(p channelPO) *business.Channel {
|
||||
return &business.Channel{ChannelKey: business.ChannelKey{SourceDB: p.SourceDB, ID: p.ID}, Account: p.Account, Nickname: p.Nickname, ParentName: p.ParentName, RealName: p.RealName, Level: p.PromoteLevel, RegisterType: p.RegisterType, Status: p.Status, LockStatus: p.LockStatus, ParentID: p.ParentID, TopPromoteID: p.TopPromoteID, BusierID: p.BusierID}
|
||||
return &business.Channel{ChannelKey: business.ChannelKey{SourceDB: p.SourceDB, ID: p.ID}, Account: p.Account, Nickname: p.Nickname, ParentName: p.ParentName, RealName: p.RealName, Level: p.PromoteLevel, RegisterType: p.RegisterType, Status: p.Status, LockStatus: p.LockStatus, ParentID: p.ParentID, TopPromoteID: p.TopPromoteID, BusierID: p.BusierID, CreatedUnix: p.CreateTime, LastLoginUnix: p.LastLoginTime}
|
||||
}
|
||||
|
||||
type channelRepo struct{ data Provider }
|
||||
|
||||
@@ -128,13 +128,16 @@ func (r *channelRepo) Games(ctx context.Context, scope business.ChannelGameScope
|
||||
return db.Table(table+" r").Where("r.source_db = ? AND r.game_id > 0 AND r.dedup_mode = ?", scope.Root.SourceDB, dedup).Where("EXISTS (?)", channelGameTree(db, scope).Select("1").Where("p.id=r.promote_id AND p.source_db=r.source_db"))
|
||||
}
|
||||
// 累计起点按当前人数口径的日汇总最早日期决定,与原画像查询一致。
|
||||
var minimum struct{ FirstDate *string }
|
||||
if err := query("tab_sw_report_day", mode).Select("CAST(MIN(r.stat_date) AS CHAR) AS first_date").Scan(&minimum).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
begin := scope.EndDate
|
||||
if minimum.FirstDate != nil && *minimum.FirstDate != "" {
|
||||
begin = *minimum.FirstDate
|
||||
begin := scope.BeginDate
|
||||
if begin == "" {
|
||||
var minimum struct{ FirstDate *string }
|
||||
if err := query("tab_sw_report_day", mode).Select("CAST(MIN(r.stat_date) AS CHAR) AS first_date").Scan(&minimum).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
begin = scope.EndDate
|
||||
if minimum.FirstDate != nil && *minimum.FirstDate != "" {
|
||||
begin = *minimum.FirstDate
|
||||
}
|
||||
}
|
||||
byID := map[uint64]*business.ChannelGame{}
|
||||
for _, id := range ids {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/biz/business"
|
||||
)
|
||||
|
||||
type channelPortraitRepo struct{ data Provider }
|
||||
|
||||
func NewChannelPortraitRepo(data Provider) business.ChannelPortraitRepo {
|
||||
return &channelPortraitRepo{data: data}
|
||||
}
|
||||
|
||||
func (r *channelPortraitRepo) GameCount(ctx context.Context, key business.ChannelKey) (*int64, error) {
|
||||
db := r.data.DB().WithContext(ctx)
|
||||
available, err := businessTableAvailable(db, "tab_promote_apply")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !available {
|
||||
return nil, nil
|
||||
}
|
||||
var count int64
|
||||
if err := db.Table("tab_promote_apply").Where("source_db = ? AND promote_id = ? AND status = 1", key.SourceDB, key.ID).Distinct("game_id").Count(&count).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
return &count, nil
|
||||
}
|
||||
func (r *channelPortraitRepo) EarliestDate(ctx context.Context, scope business.ChannelGameScope) (string, bool, error) {
|
||||
db := r.data.DB().WithContext(ctx)
|
||||
available, err := businessTableAvailable(db, "tab_sw_report_day")
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if !available {
|
||||
return "", false, nil
|
||||
}
|
||||
mode := 0
|
||||
if scope.IDCardDedup {
|
||||
mode = 1
|
||||
}
|
||||
var result struct{ FirstDate *string }
|
||||
q := db.Table("tab_sw_report_day r").Where("r.source_db = ? AND r.dedup_mode = ? AND r.game_id > 0", scope.Root.SourceDB, mode).Where("EXISTS (?)", channelGameTree(db, scope).Select("1").Where("p.source_db=r.source_db AND p.id=r.promote_id"))
|
||||
if err := q.Select("CAST(MIN(r.stat_date) AS CHAR) AS first_date").Scan(&result).Error; err != nil {
|
||||
return "", false, business.ErrStorage
|
||||
}
|
||||
if result.FirstDate == nil {
|
||||
return "", true, nil
|
||||
}
|
||||
return *result.FirstDate, true, nil
|
||||
}
|
||||
func channelTierKeys(db *gorm.DB, scope business.ChannelGameScope) *gorm.DB {
|
||||
mode := 0
|
||||
if scope.IDCardDedup {
|
||||
mode = 1
|
||||
}
|
||||
return db.Table("tab_sw_report_day_key r").Where("r.source_db = ? AND r.dedup_mode = ? AND r.game_id > 0 AND r.metric = 1 AND r.stat_date >= ? AND r.stat_date <= ? AND r.identity_key IS NOT NULL", scope.Root.SourceDB, mode, scope.BeginDate, scope.EndDate).Where("EXISTS (?)", channelGameTree(db, scope).Select("1").Where("p.source_db=r.source_db AND p.id=r.promote_id"))
|
||||
}
|
||||
func (r *channelPortraitRepo) TierData(ctx context.Context, scope business.ChannelGameScope) (*business.ChannelTierData, error) {
|
||||
result := &business.ChannelTierData{Available: true, RegisterKeys: []string{}, Payments: []*business.ChannelIdentityPay{}}
|
||||
db := r.data.DB().WithContext(ctx)
|
||||
available, err := businessTableAvailable(db, "tab_sw_report_day_key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !available {
|
||||
result.Available = false
|
||||
return result, nil
|
||||
}
|
||||
identity := portraitIdentity(db, scope.IDCardDedup)
|
||||
if err := channelTierKeys(db, scope).Select("DISTINCT " + identity).Scan(&result.RegisterKeys).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
if len(result.RegisterKeys) == 0 {
|
||||
if err := channelTierKeys(db, scope).Select("COUNT(DISTINCT " + identity + ")").Scan(&result.FallbackCount).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
tables := []string{"tab_spend"}
|
||||
if scope.IDCardDedup {
|
||||
tables = append(tables, "tab_user")
|
||||
}
|
||||
for _, table := range tables {
|
||||
available, err := businessTableAvailable(db, table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !available {
|
||||
result.Available = false
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
// 分层金额按历史成功支付累计,不受画像周期或注册日期范围限制。
|
||||
q := db.Table("tab_spend s").Where("s.source_db = ? AND s.pay_status = 1", scope.Root.SourceDB).Where("EXISTS (?)", channelGameTree(db, scope).Select("1").Where("p.source_db=s.source_db AND p.id=s.promote_id"))
|
||||
if scope.IDCardDedup {
|
||||
var rows []struct {
|
||||
IDCard *string `gorm:"column:idcard"`
|
||||
Amount string
|
||||
}
|
||||
if err := q.Joins("JOIN tab_user u ON u.id=s.user_id AND u.source_db=s.source_db").Where("u.idcard IS NOT NULL AND TRIM(u.idcard) <> ''").Select("u.idcard AS idcard,COALESCE(SUM(s.pay_amount),0) AS amount").Group("u.idcard").Scan(&rows).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
for _, row := range rows {
|
||||
result.Payments = append(result.Payments, &business.ChannelIdentityPay{IDCard: row.IDCard, Amount: row.Amount})
|
||||
}
|
||||
} else {
|
||||
var rows []struct {
|
||||
UserID *uint64
|
||||
Amount string
|
||||
}
|
||||
if err := q.Select("s.user_id,COALESCE(SUM(s.pay_amount),0) AS amount").Group("s.user_id").Scan(&rows).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
for _, row := range rows {
|
||||
result.Payments = append(result.Payments, &business.ChannelIdentityPay{UserID: row.UserID, Amount: row.Amount})
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/biz/business"
|
||||
)
|
||||
|
||||
func prepareChannelTiers(t *testing.T) (*gorm.DB, business.ChannelPortraitRepo, business.ChannelGameScope) {
|
||||
t.Helper()
|
||||
db, _ := prepareChannels(t)
|
||||
for _, sql := range []string{
|
||||
"ALTER TABLE tab_spend ADD COLUMN user_id INTEGER",
|
||||
"ALTER TABLE tab_spend ADD COLUMN pay_time INTEGER",
|
||||
"DELETE FROM tab_spend",
|
||||
"DELETE FROM tab_sw_report_day_key",
|
||||
"INSERT INTO tab_user VALUES('lx',6,100,0,'subaccount'),('lx',7,100,0,'unpaid')",
|
||||
"INSERT INTO tab_sw_report_day_key VALUES('lx',100,1,1,'2026-11-01',1,'abcx'),('lx',101,1,1,'2026-11-02',1,'abcx'),('lx',100,1,1,'2026-11-01',1,'subaccount'),('lx',100,1,1,'2026-11-01',1,'unpaid'),('lx',100,1,1,'2026-11-04',1,'future'),('lx',103,1,1,'2026-11-01',1,'outside-tree'),('hw',100,1,1,'2026-11-01',1,'outside-platform'),('lx',100,1,0,'2026-11-01',1,'1'),('lx',100,1,0,'2026-11-01',1,'2'),('lx',100,1,0,'2026-11-01',1,'6')",
|
||||
"INSERT INTO tab_spend VALUES('lx',100,1,10.10,1,1000),('lx',101,1,20.20,2,1793577600),('lx',100,1,9969.70,1,2000000000),('lx',101,1,5000,4,1793577600),('lx',102,1,100000,3,1793577600),('lx',103,1,999999,5,1793577600),('hw',100,1,999999,1,1793577600),('lx',100,0,999999,1,1793577600)",
|
||||
} {
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
level := 1
|
||||
scope := business.ChannelGameScope{Root: &business.Channel{ChannelKey: business.ChannelKey{SourceDB: "lx", ID: 100}, Level: &level}, IncludeSub: true, IDCardDedup: true, BeginDate: "2026-11-01", EndDate: "2026-11-03"}
|
||||
return db, NewChannelPortraitRepo(testData{db}), scope
|
||||
}
|
||||
func TestChannelTierStorageUsesAllHistoricalPayAndRawPayer(t *testing.T) {
|
||||
db, repo, scope := prepareChannelTiers(t)
|
||||
data, err := repo.TierData(context.Background(), scope)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !data.Available || len(data.RegisterKeys) != 3 || len(data.Payments) != 3 {
|
||||
t.Fatalf("分层成员或支付范围错误: %+v", data)
|
||||
}
|
||||
amounts := map[string]string{}
|
||||
for _, pay := range data.Payments {
|
||||
if pay.IDCard != nil {
|
||||
amounts[*pay.IDCard] = portraitTestMoney(pay.Amount)
|
||||
}
|
||||
}
|
||||
if amounts[" abcX "] != "9979.80" || amounts["abcx"] != "20.20" || amounts["subaccount"] != "5000.00" {
|
||||
t.Fatalf("历史/未来成功支付被日期过滤、合并到错误用户或混入其他平台: %+v", amounts)
|
||||
}
|
||||
if err := db.Exec("DROP TABLE tab_user").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scope.IDCardDedup = false
|
||||
data, err = repo.TierData(context.Background(), scope)
|
||||
if err != nil || !data.Available {
|
||||
t.Fatalf("模式0不应依赖身份证用户表: %v", err)
|
||||
}
|
||||
keys := map[string]bool{}
|
||||
for _, key := range data.RegisterKeys {
|
||||
keys[key] = true
|
||||
}
|
||||
if len(keys) != 3 || !keys["lx|1"] || !keys["lx|2"] || !keys["lx|6"] {
|
||||
t.Fatalf("模式0身份前缀错误: %+v", keys)
|
||||
}
|
||||
payers := map[uint64]string{}
|
||||
for _, pay := range data.Payments {
|
||||
if pay.UserID != nil {
|
||||
payers[*pay.UserID] = portraitTestMoney(pay.Amount)
|
||||
}
|
||||
}
|
||||
if payers[2] != "20.20" || payers[4] != "5000.00" {
|
||||
t.Fatal("旧分层使用实际付款账号,不能擅自把子账号金额合到父账号")
|
||||
}
|
||||
scope.IDCardDedup = true
|
||||
data, err = repo.TierData(context.Background(), scope)
|
||||
if err != nil || data.Available {
|
||||
t.Fatal("身份证模式缺用户表时不能伪造分层")
|
||||
}
|
||||
}
|
||||
func TestChannelPortraitEarliestDateAndEmptyMembership(t *testing.T) {
|
||||
db, repo, scope := prepareChannelTiers(t)
|
||||
date, available, err := repo.EarliestDate(context.Background(), scope)
|
||||
if err != nil || !available || date != "2026-11-01" {
|
||||
t.Fatalf("累计起点未遵循当前去重模式: %s %v", date, err)
|
||||
}
|
||||
scope.IDCardDedup = false
|
||||
date, _, err = repo.EarliestDate(context.Background(), scope)
|
||||
if err != nil || date != "2026-10-01" {
|
||||
t.Fatal("模式0累计起点错误")
|
||||
}
|
||||
count, err := repo.GameCount(context.Background(), scope.Root.ChannelKey)
|
||||
if err != nil || count == nil || *count != 2 {
|
||||
t.Fatal("游戏数应只统计当前渠道正常申请并去重")
|
||||
}
|
||||
if err := db.Exec("DELETE FROM tab_sw_report_day_key").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec("DROP TABLE tab_spend").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := repo.TierData(context.Background(), scope)
|
||||
if err != nil || !data.Available || len(data.RegisterKeys) != 0 || data.FallbackCount != 0 {
|
||||
t.Fatal("无注册身份是已知零,不应继续查询支付")
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ var ProviderSet = wire.NewSet(
|
||||
databusiness.NewChannelRepo,
|
||||
databusiness.NewChannelWriteRepo,
|
||||
databusiness.NewBusinessPortraitRepo,
|
||||
databusiness.NewChannelPortraitRepo,
|
||||
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 != 52 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 11/52", menus, apis)
|
||||
if menus != 11 || apis != 53 {
|
||||
t.Fatalf("admin surface = %d menus/%d APIs, want 11/53", menus, apis)
|
||||
}
|
||||
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
|
||||
t.Fatalf("default timed tasks = %d, want 2", len(got))
|
||||
|
||||
@@ -41,6 +41,7 @@ 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/promote/portrait": {group: "渠道画像", description: "查询渠道画像和玩家付费分层"},
|
||||
"GET /sw/business/:userId/portrait": {group: "商务画像", description: "查询商务画像"},
|
||||
"GET /sw/business/bindPlatform/portrait": {group: "商务画像", description: "查询 SDK 商务画像"},
|
||||
"GET /sw/promote/list": {group: "渠道管理", description: "查询渠道列表及累计指标"},
|
||||
|
||||
@@ -26,6 +26,7 @@ func NewGinEngine(runtime *config.Store, access *systemservice.AccessControlServ
|
||||
|
||||
func emptyHandlers() *handler.Set {
|
||||
return &handler.Set{
|
||||
ChannelPortrait: &handler.ChannelPortrait{},
|
||||
BusinessPortrait: &handler.BusinessPortrait{},
|
||||
Channel: &handler.Channel{},
|
||||
Activation: &handler.Activation{},
|
||||
@@ -104,7 +105,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":235`) {
|
||||
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":236`) {
|
||||
t.Fatalf("startup route summary is missing: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
"kra/internal/service/business"
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
type ChannelPortrait struct {
|
||||
service *business.ChannelPortraitService
|
||||
}
|
||||
|
||||
func NewChannelPortrait(service *business.ChannelPortraitService) *ChannelPortrait {
|
||||
return &ChannelPortrait{service: service}
|
||||
}
|
||||
func (h *ChannelPortrait) Portrait(c *gin.Context) {
|
||||
var req dto.ChannelPortraitQuery
|
||||
if c.ShouldBindQuery(&req) != nil {
|
||||
Fail(c, "渠道画像查询参数不正确")
|
||||
return
|
||||
}
|
||||
result, err := h.service.Portrait(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
Fail(c, errors.FromError(err).Message)
|
||||
return
|
||||
}
|
||||
OKWithData(c, result)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewActivation,
|
||||
NewChannel,
|
||||
NewBusinessPortrait,
|
||||
NewChannelPortrait,
|
||||
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 {
|
||||
ChannelPortrait *ChannelPortrait
|
||||
BusinessPortrait *BusinessPortrait
|
||||
Channel *Channel
|
||||
Activation *Activation
|
||||
|
||||
@@ -20,6 +20,7 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En
|
||||
return
|
||||
}
|
||||
registrations := []func(){
|
||||
func() { private.GET("/sw/promote/portrait", r.handlers.ChannelPortrait.Portrait) },
|
||||
func() {
|
||||
private.GET("/sw/business/:userId/portrait", r.handlers.BusinessPortrait.ForUser)
|
||||
private.GET("/sw/business/bindPlatform/portrait", r.handlers.BusinessPortrait.ForBinding)
|
||||
|
||||
@@ -19,7 +19,12 @@ 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}
|
||||
var label *string
|
||||
if m.CompareLabel != "" {
|
||||
value := m.CompareLabel
|
||||
label = &value
|
||||
}
|
||||
return &dto.PortraitMetric{Value: m.Value, CompareValue: m.CompareValue, ChangeRate: m.ChangeRate, ChangeDiff: m.ChangeDiff, CompareLabel: label}
|
||||
}
|
||||
func portraitResponse(p *business.BusinessPortrait) *dto.BusinessPortraitResponse {
|
||||
b := p.Basic
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"kra/internal/biz/business"
|
||||
"kra/internal/service/dto"
|
||||
)
|
||||
|
||||
type ChannelPortraitUsecase interface {
|
||||
Portrait(context.Context, business.ChannelKey, string) (*business.ChannelPortrait, error)
|
||||
}
|
||||
type ChannelPortraitService struct{ uc ChannelPortraitUsecase }
|
||||
|
||||
func NewChannelPortraitService(uc *business.ChannelPortraitUsecase) *ChannelPortraitService {
|
||||
return &ChannelPortraitService{uc: uc}
|
||||
}
|
||||
func (s *ChannelPortraitService) Portrait(ctx context.Context, req *dto.ChannelPortraitQuery) (*dto.ChannelPortraitResponse, error) {
|
||||
if req == nil || req.PromoteID == 0 {
|
||||
return nil, business.ErrChannelInput
|
||||
}
|
||||
p, err := s.uc.Portrait(ctx, business.ChannelKey{SourceDB: req.SourceDB, ID: req.PromoteID}, req.Period)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := p.Basic.Channel
|
||||
basic := &dto.ChannelPortraitBasic{PromoteID: c.ID, SourceDB: c.SourceDB, SourceDBLabel: business.PlatformName(c.SourceDB), Account: c.Account, Nickname: c.Nickname, RealName: c.RealName, PromoteLevel: c.Level, PromoteLevelLabel: business.ChannelLevelLabel(c.Level), Status: c.Status, StatusLabel: business.ChannelStatusLabel(c.Status), RegisterType: c.RegisterType, RegisterTypeLabel: business.ChannelTypeLabel(c.RegisterType), CreateTime: localTime(business.ChannelUnixTime(c.CreatedUnix)), LastLoginTime: localTime(business.ChannelUnixTime(c.LastLoginUnix)), BusierUserID: c.BusierUserID, BusierDisplayName: c.BusinessDisplayName(), GameCount: p.Basic.GameCount}
|
||||
if parent := p.Basic.Parent; parent != nil {
|
||||
basic.ParentPromoteID = &parent.ID
|
||||
basic.ParentPromoteName = &parent.Account
|
||||
basic.ParentPromoteAccount = &parent.Account
|
||||
}
|
||||
out := &dto.ChannelPortraitResponse{Basic: basic, Period: p.Period, Available: p.Available, UnavailableReason: p.UnavailableReason, RegisterTrend: []*dto.PortraitTrend{}, PayTrend: []*dto.PortraitTrend{}, GameDistribution: []*dto.ChannelGameResponse{}, PayTiers: []*dto.ChannelPayTier{}}
|
||||
if m := p.Metrics; m != nil {
|
||||
out.Metrics = &dto.ChannelPortraitMetrics{Register: portraitMetricResponse(m.Register), PayAmount: portraitMetricResponse(m.PayAmount), PayRate: portraitMetricResponse(m.PayRate), Active: portraitMetricResponse(m.Active)}
|
||||
}
|
||||
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})
|
||||
}
|
||||
for _, game := range p.GameDistribution {
|
||||
out.GameDistribution = append(out.GameDistribution, &dto.ChannelGameResponse{GameID: game.ID, GameName: game.Name, RegisterCount: game.RegisterCount, PayAmount: game.PayAmount})
|
||||
}
|
||||
for _, tier := range p.PayTiers {
|
||||
out.PayTiers = append(out.PayTiers, &dto.ChannelPayTier{Tier: tier.Tier, TierLabel: tier.Label, Count: tier.Count, Ratio: tier.Ratio})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -29,7 +29,7 @@ type PortraitMetric struct {
|
||||
CompareValue *string `json:"compareValue"`
|
||||
ChangeRate *string `json:"changeRate"`
|
||||
ChangeDiff *string `json:"changeDiff"`
|
||||
CompareLabel string `json:"compareLabel"`
|
||||
CompareLabel *string `json:"compareLabel"`
|
||||
}
|
||||
type PortraitMetrics struct {
|
||||
Register *PortraitMetric `json:"register"`
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package dto
|
||||
|
||||
type ChannelPortraitQuery struct {
|
||||
SourceDB string `form:"sourceDb"`
|
||||
PromoteID uint64 `form:"promoteId"`
|
||||
Period string `form:"period"`
|
||||
}
|
||||
type ChannelPortraitBasic struct {
|
||||
PromoteID uint64 `json:"promoteId"`
|
||||
SourceDB string `json:"sourceDb"`
|
||||
SourceDBLabel string `json:"sourceDbLabel"`
|
||||
Account string `json:"account"`
|
||||
Nickname string `json:"nickname"`
|
||||
RealName string `json:"realName"`
|
||||
PromoteLevel *int `json:"promoteLevel"`
|
||||
PromoteLevelLabel string `json:"promoteLevelLabel"`
|
||||
Status *int `json:"status"`
|
||||
StatusLabel string `json:"statusLabel"`
|
||||
RegisterType *int `json:"registerType"`
|
||||
RegisterTypeLabel string `json:"registerTypeLabel"`
|
||||
CreateTime *string `json:"createTime"`
|
||||
LastLoginTime *string `json:"lastLoginTime"`
|
||||
BusierUserID *uint `json:"busierUserId"`
|
||||
BusierDisplayName string `json:"busierDisplayName"`
|
||||
ParentPromoteID *uint64 `json:"parentPromoteId"`
|
||||
ParentPromoteName *string `json:"parentPromoteName"`
|
||||
ParentPromoteAccount *string `json:"parentPromoteAccount"`
|
||||
GameCount *int64 `json:"gameCount"`
|
||||
}
|
||||
type ChannelPortraitMetrics struct {
|
||||
Register *PortraitMetric `json:"register"`
|
||||
PayAmount *PortraitMetric `json:"payAmount"`
|
||||
PayRate *PortraitMetric `json:"payRate"`
|
||||
Active *PortraitMetric `json:"active"`
|
||||
}
|
||||
type ChannelPayTier struct {
|
||||
Tier string `json:"tier"`
|
||||
TierLabel string `json:"tierLabel"`
|
||||
Count int64 `json:"count"`
|
||||
Ratio string `json:"ratio"`
|
||||
}
|
||||
type ChannelPortraitResponse struct {
|
||||
Basic *ChannelPortraitBasic `json:"basic"`
|
||||
Period string `json:"period"`
|
||||
Metrics *ChannelPortraitMetrics `json:"metrics"`
|
||||
RegisterTrend []*PortraitTrend `json:"registerTrend"`
|
||||
PayTrend []*PortraitTrend `json:"payTrend"`
|
||||
GameDistribution []*ChannelGameResponse `json:"gameDistribution"`
|
||||
PayTiers []*ChannelPayTier `json:"payTiers"`
|
||||
Available bool `json:"available"`
|
||||
UnavailableReason string `json:"unavailableReason"`
|
||||
}
|
||||
@@ -22,6 +22,7 @@ var ProviderSet = wire.NewSet(
|
||||
businessservice.NewActivationService,
|
||||
businessservice.NewChannelService,
|
||||
businessservice.NewBusinessPortraitService,
|
||||
businessservice.NewChannelPortraitService,
|
||||
systemservice.ProviderSet,
|
||||
paymentservice.ProviderSet,
|
||||
integrationservice.ProviderSet,
|
||||
|
||||
Reference in New Issue
Block a user