diff --git a/docs/sw6-to-kra/analytics-source-notes.md b/docs/sw6-to-kra/analytics-source-notes.md index 7f9630c..2b4988d 100644 --- a/docs/sw6-to-kra/analytics-source-notes.md +++ b/docs/sw6-to-kra/analytics-source-notes.md @@ -17,12 +17,13 @@ - 普通商务渠道 summary 累加可见行。超管渠道 summary 使用筛选范围内全局去重;模式 0 按平台前缀区分身份,模式 1 跨平台去重。 - 付费率=充值人数÷活跃人数×100,ARPU=充值金额÷活跃人数,ARPPU=充值金额÷充值人数。分母为零时为零。 - 保留原逐行人数的 Java int 上限,游戏汇总改用 int64 避免相加溢出。相同排序值使用平台、ID 稳定排序。 -- 后端任一日期缺失时周期为上海当天;前端默认近 30 天含今天。后端页长缺失、非正或超过上限时为 500,页面显式传 10。 +- 后端不传日期时周期为上海当天,只传一端会拒绝;前端默认近 30 天含今天。后端页长缺失、非正或超过上限时为 500,页面显式传 10。 - 缺少必要数据表时 `available=false`、`summary=null`,顶部指标为 null,不把缺表当成零。 ## 验证与优化 - 使用聚合查询和批量资料查询代替逐行查询,不改变业务口径。日期非法或倒置返回明确错误。 +- 数字指标先按全部统计行计算汇总、排序和分页,再补当前页的渠道/游戏名称及商务资料;按渠道名称或渠道类型排序时仍先补全体资料。空页不读取资料,官方及未知渠道标签保持原规则。 - 隔离 TCP HTTP 和仓储测试覆盖全量汇总与分页、跨平台同号、官方/孤立渠道、根渠道树去重、类型交集、历史游戏名过滤、当前资料展示、偏好切换及空范围。 - 领域测试覆盖大金额精确排序、分母和人数上限。前端检查筛选回第一页、30 天默认值、汇总不随分页变化、缺数据提示、没有额外导出入口。 diff --git a/docs/sw6-to-kra/server-analysis-source-notes.md b/docs/sw6-to-kra/server-analysis-source-notes.md index fe2cac3..f72345a 100644 --- a/docs/sw6-to-kra/server-analysis-source-notes.md +++ b/docs/sw6-to-kra/server-analysis-source-notes.md @@ -4,7 +4,7 @@ - `/sw/serverAnalysis/games` 复用已迁移游戏选项;新增 `/servers`、`/list`。两者均要求游戏名称;来源支持逗号分隔或重复参数。 - 选项通过当前 `tab_game.game_name` 确定平台+游戏,匹配区服名称;保留区服编号的字符串及前导零。原接口不按商务绑定裁剪资料选项,统计查询独立按可见范围隔离。 -- 选项默认 50,超过 100 时允许 100000,沿用旧批量选项请求。使用 EXISTS 代替为每个游戏生成 OR,保持成对关联并避免重复行。 +- 选项默认 50,超过 100 时允许 100000,沿用旧批量选项请求。使用 `(source_db,game_id) IN (SELECT source_db,id FROM tab_game …)` 保持成对关联并避免重复行;MySQL 5.7 可先按游戏名索引定位,再使用区服的 game_id 索引,避免相关 EXISTS 扫描全部区服。 - 统计读取 `tab_sw_report_server_day` 和身份键表,按平台+游戏+区服编号分组;空白编号不展示。不额外加日游戏报表的 `game_id > 0` 限制。 - 人数在每个区服内 COUNT DISTINCT,金额固定模式 0;筛选历史报表游戏名。展示优先金额汇总的历史游戏名,无金额行时回退当前游戏名,保留旧行为。 - 区服名称/开服日期按三元组批量补充;重复资料固定取最小 ID,提高原不确定顺序的稳定性。相同主排序后保留区服编号顺序,再按平台、游戏 ID 稳定排列。 diff --git a/internal/biz/business/analytics.go b/internal/biz/business/analytics.go index df84c82..9a4a6b5 100644 --- a/internal/biz/business/analytics.go +++ b/internal/biz/business/analytics.go @@ -69,6 +69,7 @@ type AnalyticsPage struct { } type AnalyticsRepo interface { Aggregate(context.Context, AnalyticsKind, AnalyticsScope, AnalyticsFilter) (*AnalyticsData, error) + FillNames(context.Context, AnalyticsKind, AnalyticsScope, AnalyticsFilter, []*AnalyticsRow) error } type AnalyticsUsecase struct { repo AnalyticsRepo @@ -138,7 +139,8 @@ func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter filter.Begin = filter.StartDate.Format("2006-01-02") filter.End = filter.EndDate.Format("2006-01-02") out := &AnalyticsPage{Rows: []*AnalyticsRow{}, Begin: filter.Begin, End: filter.End, Available: true} - data, err := uc.repo.Aggregate(ctx, kind, AnalyticsScope{Viewer: viewer, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup}, filter) + scope := AnalyticsScope{Viewer: viewer, IncludeSub: pref.IncludeSubPromote, IDCardDedup: pref.IDCardDedup} + data, err := uc.repo.Aggregate(ctx, kind, scope, filter) if err != nil { return nil, err } @@ -165,9 +167,6 @@ func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter pay.Add(pay, rounded) } counts = analyticsAdd(counts, row.Counts) - if kind == ChannelAnalytics { - row.Name = (&LedgerNames{PromoteID: &row.ID, PromoteAccount: row.Name}).DisplayPromote() - } } if kind == ChannelAnalytics { counts = analyticsCap(counts) @@ -187,6 +186,28 @@ func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter if err := analyticsValues(out.Summary); err != nil { return nil, err } + // 仅按渠道名称或类型排序时需要提前读取全部资料;数字指标排序 + // 确定当前页后再补名称和商务信息,汇总仍使用全部可见统计行。 + namesBeforeSort := kind == ChannelAnalytics && (filter.SortField == "promoteName" || filter.SortField == "promoteType") + fillNames := func(rows []*AnalyticsRow) error { + if len(rows) == 0 { + return nil + } + if err := uc.repo.FillNames(ctx, kind, scope, filter, rows); err != nil { + return err + } + if kind == ChannelAnalytics { + for _, row := range rows { + row.Name = (&LedgerNames{PromoteID: &row.ID, PromoteAccount: row.Name}).DisplayPromote() + } + } + return nil + } + if namesBeforeSort { + if err := fillNames(data.Rows); err != nil { + return nil, err + } + } if err := sortAnalytics(data.Rows, kind, filter.SortField, filter.Ascending); err != nil { return nil, err } @@ -205,6 +226,11 @@ func (uc *AnalyticsUsecase) List(ctx context.Context, kind AnalyticsKind, filter if from < len(data.Rows) { out.Rows = data.Rows[from:to] } + if !namesBeforeSort { + if err := fillNames(out.Rows); err != nil { + return nil, err + } + } return out, nil } func sortAnalytics(rows []*AnalyticsRow, kind AnalyticsKind, field string, ascending bool) error { diff --git a/internal/biz/business/analytics_test.go b/internal/biz/business/analytics_test.go index 3485d72..90229f1 100644 --- a/internal/biz/business/analytics_test.go +++ b/internal/biz/business/analytics_test.go @@ -7,9 +7,23 @@ import ( ) type analyticsRepoFake struct { - data *AnalyticsData - scope AnalyticsScope - filter AnalyticsFilter + data *AnalyticsData + scope AnalyticsScope + filter AnalyticsFilter + fillCalls [][]AnalyticsKey + fill func([]*AnalyticsRow) error +} + +func (r *analyticsRepoFake) FillNames(_ context.Context, _ AnalyticsKind, _ AnalyticsScope, _ AnalyticsFilter, rows []*AnalyticsRow) error { + keys := make([]AnalyticsKey, len(rows)) + for i, row := range rows { + keys[i] = row.AnalyticsKey + } + r.fillCalls = append(r.fillCalls, keys) + if r.fill != nil { + return r.fill(rows) + } + return nil } func (r *analyticsRepoFake) Aggregate(_ context.Context, _ AnalyticsKind, s AnalyticsScope, f AnalyticsFilter) (*AnalyticsData, error) { @@ -76,3 +90,75 @@ func TestAnalyticsSortAndMissingData(t *testing.T) { t.Fatal("倒置日期应被拒绝") } } + +func TestAnalyticsLoadsMetadataOnlyForSelectedPage(t *testing.T) { + for _, kind := range []AnalyticsKind{ChannelAnalytics, GameAnalytics} { + t.Run(string(kind), func(t *testing.T) { + uc, repo := analyticsFixture() + for _, row := range repo.data.Rows { + row.Name = "" + } + repo.fill = func(rows []*AnalyticsRow) error { + for _, row := range rows { + row.Name = "本页资料" + row.BindingAccount = "本页商务" + } + return nil + } + page, err := uc.List(accountContext(7, 901), kind, AnalyticsFilter{SortField: "totalPay"}, 1, 1) + if err != nil { + t.Fatal(err) + } + if len(repo.fillCalls) != 1 || len(repo.fillCalls[0]) != 1 || repo.fillCalls[0][0].ID != 2 { + t.Fatalf("应只读取排序后当前页的资料: %+v", repo.fillCalls) + } + if page.Total != 2 || page.Summary.Pay != "50.00" || page.Rows[0].Name != "本页资料" || page.Rows[0].BindingAccount != "本页商务" { + t.Fatalf("延迟读取影响汇总或展示资料: %+v", page) + } + repo.fillCalls = nil + page, err = uc.List(accountContext(7, 901), kind, AnalyticsFilter{}, 3, 1) + if err != nil || len(page.Rows) != 0 || len(repo.fillCalls) != 0 || page.Summary.Pay != "50.00" { + t.Fatal("空页不应读取资料,汇总仍须保留") + } + repo.fill = func([]*AnalyticsRow) error { return ErrStorage } + if _, err := uc.List(accountContext(7, 901), kind, AnalyticsFilter{}, 1, 1); err != ErrStorage { + t.Fatalf("资料读取错误必须向上传递: %v", err) + } + }) + } +} + +func TestAnalyticsLoadsMetadataBeforeNameOrTypeSort(t *testing.T) { + for _, field := range []string{"promoteName", "promoteType"} { + t.Run(field, func(t *testing.T) { + uc, repo := analyticsFixture() + for _, row := range repo.data.Rows { + row.Name = "" + } + repo.fill = func(rows []*AnalyticsRow) error { + for _, row := range rows { + name, kind := "Zulu", 9 + if row.ID == 2 { + name, kind = "Alpha", 1 + } + row.Name, row.PromoteType = name, &kind + } + return nil + } + page, err := uc.List(accountContext(7, 901), ChannelAnalytics, AnalyticsFilter{SortField: field, Ascending: true}, 1, 1) + if err != nil || len(page.Rows) != 1 || page.Rows[0].ID != 2 || len(repo.fillCalls) != 1 || len(repo.fillCalls[0]) != 2 { + t.Fatalf("按资料字段排序前必须读取全体资料: page=%+v calls=%+v err=%v", page, repo.fillCalls, err) + } + }) + } +} + +func TestAnalyticsDeferredNamesPreserveOfficialAndUnknownLabels(t *testing.T) { + uc, repo := analyticsFixture() + repo.data.Rows[0].ID, repo.data.Rows[0].Name = 0, "" + repo.data.Rows[1].Name = "" + page, err := uc.List(accountContext(1, 888), ChannelAnalytics, AnalyticsFilter{SortField: "promoteId", Ascending: true}, 1, 10) + if err != nil || page.Rows[0].Name != "官方渠道" || page.Rows[1].Name != "未知渠道(ID:2)" { + t.Fatalf("默认渠道名丢失: %+v %v", page, err) + } +} diff --git a/internal/data/business/analytics.go b/internal/data/business/analytics.go index 7259be6..f7b4e27 100644 --- a/internal/data/business/analytics.go +++ b/internal/data/business/analytics.go @@ -213,9 +213,6 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi return result, nil } } - if err := fillAnalyticsNames(db, kind, scope, f, rowMap); err != nil { - return nil, err - } for _, row := range rowMap { result.Rows = append(result.Rows, row) } @@ -228,6 +225,18 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi }) return result, nil } + +func (r *analyticsRepo) FillNames(ctx context.Context, kind business.AnalyticsKind, scope business.AnalyticsScope, f business.AnalyticsFilter, rows []*business.AnalyticsRow) error { + if len(rows) == 0 { + return nil + } + byKey := make(map[business.AnalyticsKey]*business.AnalyticsRow, len(rows)) + for _, row := range rows { + byKey[row.AnalyticsKey] = row + } + return fillAnalyticsNames(r.data.DB().WithContext(ctx), kind, scope, f, byKey) +} + func analyticsPairs(keys []business.AnalyticsKey) [][]any { result := make([][]any, 0, len(keys)) for _, key := range keys { @@ -269,6 +278,12 @@ func fillAnalyticsNames(db *gorm.DB, kind business.AnalyticsKind, scope business for key := range rows { keys = append(keys, key) } + sort.Slice(keys, func(i, j int) bool { + if keys[i].ID != keys[j].ID { + return keys[i].ID < keys[j].ID + } + return keys[i].SourceDB < keys[j].SourceDB + }) type entityPO struct { ID int64 SourceDB string `gorm:"column:source_db"` diff --git a/internal/data/business/analytics_test.go b/internal/data/business/analytics_test.go index a9a1c84..9a08306 100644 --- a/internal/data/business/analytics_test.go +++ b/internal/data/business/analytics_test.go @@ -34,6 +34,9 @@ func TestAnalyticsChannelTreeAndAdminOfficialRows(t *testing.T) { if err != nil { t.Fatal(err) } + if err := repo.FillNames(context.Background(), business.ChannelAnalytics, scope, filter, data.Rows); err != nil { + t.Fatal(err) + } if len(data.Rows) != 3 || data.GlobalCounts != nil { t.Fatalf("普通商务统计范围错误: %+v", data) } @@ -69,6 +72,12 @@ func TestAnalyticsFiltersKeepHierarchyAndSnapshotNames(t *testing.T) { kind := 1 filter.PromoteType = &kind data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) + if err != nil { + t.Fatal(err) + } + if err := repo.FillNames(context.Background(), business.ChannelAnalytics, scope, filter, data.Rows); err != nil { + t.Fatal(err) + } if err != nil || len(data.Rows) != 1 || data.Rows[0].ID != 100 || portraitTestMoney(data.Rows[0].Pay) != "20.20" || data.Rows[0].PromoteType == nil || *data.Rows[0].PromoteType != 0 { t.Fatalf("类型筛选应限制参与渠道,同时显示归根后的当前资料: %+v %v", data, err) } @@ -78,6 +87,9 @@ func TestAnalyticsFiltersKeepHierarchyAndSnapshotNames(t *testing.T) { if err != nil || len(data.Rows) != 2 { t.Fatalf("按游戏快照名筛选错误: %+v %v", data, err) } + if err := repo.FillNames(context.Background(), business.GameAnalytics, scope, filter, data.Rows); err != nil { + t.Fatal(err) + } for _, row := range data.Rows { if row.SourceDB == "lx" && (row.ID != 1 || row.Name != "游戏甲" || row.Counts.Register != 1) { t.Fatalf("应按报表快照筛选、用当前游戏名展示: %+v", row) diff --git a/internal/data/business/server_analysis.go b/internal/data/business/server_analysis.go index 69468df..2ef2823 100644 --- a/internal/data/business/server_analysis.go +++ b/internal/data/business/server_analysis.go @@ -155,7 +155,11 @@ func (r *serverAnalysisRepo) Servers(ctx context.Context, sources, games []strin return nil, business.ErrServerOptionsUnavailable } } - q := db.Table("tab_game_server s").Where("EXISTS (?)", db.Table("tab_game g").Select("1").Where("g.source_db=s.source_db AND g.id=s.game_id AND g.game_name IN ?", games)) + // MySQL 5.7 不会将相关 EXISTS 展开为半连接,导致扫描全部区服。 + // 多列 IN 让优化器先按游戏名索引定位,再使用区服的 game_id 索引, + // 同时保留平台匹配和重复游戏资料不扩增结果的语义。 + gamesQuery := db.Table("tab_game g").Select("g.source_db,g.id").Where("g.game_name IN ?", games) + q := db.Table("tab_game_server s").Where("(s.source_db,s.game_id) IN (?)", gamesQuery) if len(sources) > 0 { q = q.Where("s.source_db IN ?", sources) } diff --git a/internal/data/business/server_analysis_test.go b/internal/data/business/server_analysis_test.go index 642ac47..39e898b 100644 --- a/internal/data/business/server_analysis_test.go +++ b/internal/data/business/server_analysis_test.go @@ -85,3 +85,30 @@ func TestServerAnalysisStorageScopeSnapshotAndPreference(t *testing.T) { t.Fatal("缺身份表应明确未接入") } } + +func TestServerOptionsPreservePlatformFilterAndDoNotDuplicateMatches(t *testing.T) { + db := openTestDB(t) + for _, sql := range []string{ + "CREATE TABLE tab_game(id INTEGER,source_db TEXT,game_name TEXT)", + "INSERT INTO tab_game VALUES(1,'lx','甲'),(1,'lx','甲'),(1,'hw','乙'),(2,'lx','乙')", + "CREATE TABLE tab_game_server(id INTEGER,source_db TEXT,game_id INTEGER,server_num TEXT,server_name TEXT,start_time INTEGER)", + "INSERT INTO tab_game_server VALUES(1,'lx',1,'001','一区',100),(2,'lx',1,'002','二区',200),(3,'hw',1,'001','其他平台',300),(4,'lx',2,'001','其他游戏',400)", + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + repo := NewServerAnalysisRepo(testData{db}) + rows, err := repo.Servers(context.Background(), nil, []string{"甲"}, "", 100) + if err != nil || len(rows) != 2 || rows[0].Name != "一区" || rows[1].Number != "002" { + t.Fatalf("相同游戏资料不能扩增区服,异平台同号不能匹配: %+v %v", rows, err) + } + rows, err = repo.Servers(context.Background(), []string{"lx"}, []string{"甲", "乙"}, "区", 1) + if err != nil || len(rows) != 1 || rows[0].Name != "一区" { + t.Fatalf("名称筛选、排序或数量限制改变: %+v %v", rows, err) + } + rows, err = repo.Servers(context.Background(), []string{"hw"}, []string{"甲"}, "", 100) + if err != nil || len(rows) != 0 { + t.Fatalf("平台筛选被忽略: %+v %v", rows, err) + } +}