diff --git a/internal/data/business/analytics.go b/internal/data/business/analytics.go index 8faefa6..9100317 100644 --- a/internal/data/business/analytics.go +++ b/internal/data/business/analytics.go @@ -228,6 +228,13 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi } } if metricFilter { + // Only channels present in the report can contribute to its summary. + // Keep zero-activity catalog rows out of the aggregate IN list; large + // catalogs would otherwise exhaust MySQL's prepared-statement parameters. + factKeys := make([]business.AnalyticsKey, 0, len(rowMap)) + for key := range rowMap { + factKeys = append(factKeys, key) + } if f.IncludesInactiveRows() { if err := includeInactiveAnalyticsRows(db, scope, f, rowMap); err != nil { return nil, err @@ -243,9 +250,11 @@ func (r *analyticsRepo) Aggregate(ctx context.Context, kind business.AnalyticsKi } } if withGlobal { - keys := make([]business.AnalyticsKey, 0, len(rowMap)) - for key := range rowMap { - keys = append(keys, key) + keys := make([]business.AnalyticsKey, 0, len(factKeys)) + for _, key := range factKeys { + if rowMap[key] != nil { + keys = append(keys, key) + } } // Deduplicate identities across all matched channels, before pagination. // Summing per-channel counts would double-count shared players. diff --git a/internal/data/business/analytics_test.go b/internal/data/business/analytics_test.go index ff929bc..1c21541 100644 --- a/internal/data/business/analytics_test.go +++ b/internal/data/business/analytics_test.go @@ -2,6 +2,8 @@ package business import ( "context" + "strings" + "sync/atomic" "testing" "time" @@ -130,6 +132,32 @@ func TestAnalyticsNegativeFiltersIncludeInactiveAuthorizedChannels(t *testing.T) } } +func TestAnalyticsInactiveCatalogDoesNotExpandSummaryParameters(t *testing.T) { + db, repo, scope, filter := prepareAnalyticsData(t) + rows := make([]map[string]any, 2000) + for i := range rows { + rows[i] = map[string]any{"id": 1000 + i, "source_db": "lx", "account": "inactive", "promote_level": 1, "parent_id": 0, "top_promote_id": 0, "busier_id": 14} + } + if err := db.Table("tab_promote").CreateInBatches(rows, 200).Error; err != nil { + t.Fatal(err) + } + var excessive atomic.Bool + if err := db.Callback().Row().After("gorm:row").Register("check_summary_parameters", func(tx *gorm.DB) { + if strings.Contains(tx.Statement.SQL.String(), "COUNT(DISTINCT") && len(tx.Statement.Vars) > 100 { + excessive.Store(true) + } + }); err != nil { + t.Fatal(err) + } + scope.Viewer, scope.IncludeSub = business.UserScope{All: true}, false + no := false + filter.HasRegister, filter.HasPay = &no, &no + data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) + if err != nil || len(data.Rows) < len(rows) || excessive.Load() || data.GlobalCounts.Register != 0 || portraitTestMoney(data.GlobalPay) != "0.00" { + t.Fatalf("inactive rows bloated the summary query or changed totals: rows=%d excessive=%v err=%v", len(data.Rows), excessive.Load(), err) + } +} + func TestAnalyticsRowsOnlyComeFromSelectedPeriod(t *testing.T) { db, repo, scope, filter := prepareAnalyticsData(t) for _, sql := range []string{