feat: 迁移渠道画像累计指标和玩家付费分层
This commit is contained in:
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user