271 lines
12 KiB
Go
271 lines
12 KiB
Go
package business
|
|
|
|
import (
|
|
"context"
|
|
"math/big"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
"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)
|
|
}
|
|
}
|
|
|
|
func TestPortraitComparisonKeepsPeriodDedupAndExcludesGaps(t *testing.T) {
|
|
db, repo, scope := preparePortraitData(t)
|
|
for _, sql := range []string{
|
|
"INSERT INTO tab_sw_report_day_key VALUES('lx',100,1,1,'2026-10-01',1,'A'),('lx',101,1,1,'2026-10-02',1,'A'),('hw',100,1,1,'2026-10-03',1,'P'),('lx',100,1,1,'2026-10-15',1,'gap'),('lx',100,1,1,'2026-10-03',2,'active'),('lx',100,1,1,'2026-10-03',3,'payer'),('lx',100,1,1,'2026-10-03',4,'new-payer')",
|
|
"INSERT INTO tab_sw_report_day VALUES('lx',100,1,0,'2026-10-03',2.20),('lx',100,1,0,'2026-10-15',99999.99)",
|
|
} {
|
|
if err := db.Exec(sql).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
stats, err := repo.Statistics(context.Background(), scope)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantPrevious := business.PortraitCounts{Register: 2, Active: 1, Pay: 1, NewPay: 1}
|
|
if stats.Current.Register != 3 || stats.Previous != wantPrevious || portraitTestMoney(stats.PreviousPay) != "3.30" || portraitTestMoney(stats.CurrentPay) != "2058.60" {
|
|
t.Fatalf("合并扫描改变了周期内去重或混入同期之间的日期: %+v", stats)
|
|
}
|
|
scope.NoCompare = true
|
|
withoutCompare, err := repo.Statistics(context.Background(), scope)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if withoutCompare.Current != stats.Current || withoutCompare.CurrentPay != stats.CurrentPay || withoutCompare.Previous != (business.PortraitCounts{}) || portraitTestMoney(withoutCompare.PreviousPay) != "0.00" {
|
|
t.Fatalf("关闭对比仍然返回了上期统计: %+v", withoutCompare)
|
|
}
|
|
scope.NoCompare = false
|
|
scope.Range.PreviousBegin, scope.Range.PreviousEnd = scope.Range.Begin, scope.Range.End
|
|
overlap, err := repo.Statistics(context.Background(), scope)
|
|
if err != nil || overlap.Current != overlap.Previous || overlap.CurrentPay != overlap.PreviousPay {
|
|
t.Fatalf("重叠周期必须独立统计: %+v %v", overlap, err)
|
|
}
|
|
}
|
|
|
|
type portraitSQLCapture struct {
|
|
logger.Interface
|
|
queries []string
|
|
}
|
|
|
|
func (l *portraitSQLCapture) Trace(_ context.Context, _ time.Time, fc func() (string, int64), _ error) {
|
|
sql, _ := fc()
|
|
if strings.Contains(sql, "HOUR(FROM_UNIXTIME(") {
|
|
l.queries = append(l.queries, sql)
|
|
}
|
|
}
|
|
|
|
func TestMySQLPortraitHourlyUsesRegistrationRangeAndPreservesLegacyValues(t *testing.T) {
|
|
db := openRehearsalMySQL(t)
|
|
pool, _ := db.DB()
|
|
pool.SetMaxOpenConns(1)
|
|
for _, sql := range []string{
|
|
"SET SESSION time_zone='+08:00'",
|
|
"CREATE TABLE tab_promote(id INT,source_db VARCHAR(10),busier_id INT,promote_level INT,PRIMARY KEY(id,source_db),KEY(source_db,busier_id))",
|
|
"INSERT INTO tab_promote VALUES(100,'lx',14,1),(100,'hw',15,1)",
|
|
"CREATE TABLE tab_user(id INT,source_db VARCHAR(10),promote_id INT,puid INT,register_time VARCHAR(11),PRIMARY KEY(id,source_db),KEY(promote_id,puid),KEY(register_time))",
|
|
"INSERT INTO tab_user VALUES(1,'lx',100,0,'1793638800'),(2,'lx',100,NULL,'01793638800'),(3,'lx',100,0,'1793638800 '),(4,'lx',100,1,'1793638800'),(5,'hw',100,0,'1793638800'),(6,'lx',100,0,'1793721600'),(7,'lx',100,0,'1793635199'),(8,'lx',100,0,'1793635200')",
|
|
"CREATE TABLE tab_spend(source_db VARCHAR(10),promote_id INT,pay_status INT,pay_time INT,pay_amount DECIMAL(20,2))",
|
|
"INSERT INTO tab_spend VALUES('lx',100,1,1793638800,1.10),('lx',100,0,1793638800,999.00),('hw',100,1,1793638800,999.00),('lx',100,1,1793721600,999.00)",
|
|
} {
|
|
if err := db.Exec(sql).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
oldUsers := make([]map[string]any, 2000)
|
|
for i := range oldUsers {
|
|
oldUsers[i] = map[string]any{"id": i + 100, "source_db": "lx", "promote_id": 100 + i%200, "puid": 0, "register_time": "1793000000"}
|
|
}
|
|
if err := db.Table("tab_user").CreateInBatches(oldUsers, 200).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
channels := make([]map[string]any, 199)
|
|
for i := range channels {
|
|
channels[i] = map[string]any{"id": i + 101, "source_db": "lx", "busier_id": 14, "promote_level": 1}
|
|
}
|
|
if err := db.Table("tab_promote").CreateInBatches(channels, 200).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
day := time.Date(2026, 11, 3, 0, 0, 0, 0, time.FixedZone("CST", 8*3600))
|
|
scope := business.PortraitReportScope{Pairs: []business.PlatformKey{{SourceDB: "lx", BusierID: 14}}, Sources: []string{"lx", "hw"}, IncludeSub: true, Range: business.PortraitRange{Begin: day, End: day, Hourly: true}}
|
|
read := func(queryDB *gorm.DB) *business.PortraitStatistics {
|
|
t.Helper()
|
|
out := &business.PortraitStatistics{RegisterSeries: map[string]string{}, PaySeries: map[string]string{}}
|
|
if err := portraitHourly(queryDB, scope, out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
before := read(db)
|
|
if !reflect.DeepEqual(before.RegisterSeries, map[string]string{"0": "1", "1": "3"}) || portraitTestMoney(before.PaySeries["1"]) != "1.10" || len(before.PaySeries) != 1 {
|
|
t.Fatalf("旧时间字符串、主账号、跨平台或零点边界口径错误: %+v", before)
|
|
}
|
|
if err := migrateRegistrationIndex(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Exec("ANALYZE TABLE tab_user,tab_promote").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
capture := &portraitSQLCapture{Interface: logger.Default}
|
|
after := read(db.Session(&gorm.Session{Logger: capture}))
|
|
if !reflect.DeepEqual(before, after) || len(capture.queries) != 2 {
|
|
t.Fatalf("已有索引和未迁移时的结果不同: before=%+v after=%+v", before, after)
|
|
}
|
|
var plan []struct{ Table, Key, Type string }
|
|
if err := db.Raw("EXPLAIN " + capture.queries[0]).Scan(&plan).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, step := range plan {
|
|
if step.Table == "r" && step.Key == "idx_sw_user_registration" && step.Type == "range" {
|
|
return
|
|
}
|
|
}
|
|
t.Fatalf("小时注册趋势没有按数值时间索引做范围查找: %+v", plan)
|
|
}
|