package business import ( "context" "errors" "strconv" "golang.org/x/sync/errgroup" "gorm.io/gorm" "kra/internal/biz/business" ) type businessPortraitRepo struct { data Provider platforms PlatformProvider } func NewBusinessPortraitRepo(data Provider, platforms PlatformProvider) business.BusinessPortraitRepo { return &businessPortraitRepo{data: data, platforms: platforms} } func (r *businessPortraitRepo) Bindings(ctx context.Context, scope business.UserScope, sources []string, key *business.PlatformKey) ([]*business.Binding, error) { q := channelBindingQuery(r.data.DB().WithContext(ctx), scope, business.ChannelFilter{Sources: sources}) if key != nil { q = q.Where("b.source_db = ? AND b.platform_busier_id = ?", key.SourceDB, key.BusierID) } var pos []bindingPO if err := q.Select("b.*").Order("b.user_id,b.source_db,b.platform_busier_id").Scan(&pos).Error; err != nil { return nil, business.ErrStorage } rows := make([]*business.Binding, 0, len(pos)) for _, po := range pos { rows = append(rows, toBizBinding(po)) } return rows, nil } func portraitBindingPairs(pairs []business.PlatformKey) [][]any { result := make([][]any, 0, len(pairs)) for _, pair := range pairs { result = append(result, []any{pair.SourceDB, pair.BusierID}) } return result } func (r *businessPortraitRepo) Channels(ctx context.Context, pairs []business.PlatformKey, includeSub bool) ([]*business.PortraitChannel, bool, error) { rows := []*business.PortraitChannel{} if len(pairs) == 0 { return rows, true, nil } db := r.data.DB().WithContext(ctx) available, err := businessTableAvailable(db, "tab_promote") if err != nil { return nil, false, err } if !available { return rows, false, nil } seen := map[business.ChannelKey]bool{} for start := 0; start < len(pairs); start += 300 { q := db.Table("tab_promote").Select("id,source_db,account,promote_level,top_promote_id,create_time").Where("(source_db,busier_id) IN ?", portraitBindingPairs(pairs[start:min(start+300, len(pairs))])) if !includeSub { q = q.Where("promote_level = 1") } var pos []channelPO if err := q.Order("source_db,id").Scan(&pos).Error; err != nil { return nil, false, business.ErrStorage } for _, po := range pos { key := business.ChannelKey{SourceDB: po.SourceDB, ID: po.ID} if !seen[key] { rows = append(rows, &business.PortraitChannel{ChannelKey: key, Account: po.Account, Level: po.PromoteLevel, TopPromoteID: po.TopPromoteID, CreatedUnix: po.CreateTime}) seen[key] = true } } } return rows, true, nil } type portraitPlatformPO struct { ID uint64 Account, RealName string Level, Status *int Commerce1, Commerce2 *int64 CreatedUnix *int64 `gorm:"column:create_time"` } func (r *businessPortraitRepo) PlatformBusiness(ctx context.Context, key business.PlatformKey) (*business.PortraitPlatformBusiness, error) { db, err := r.platforms.Database(key.SourceDB) if err != nil || db == nil { return nil, business.ErrPlatformUnavailable } var po portraitPlatformPO if err := db.WithContext(ctx).Table("tab_promote_business").Select("id,account,real_name,level,status,commerce1,commerce2,create_time").Where("id = ?", key.BusierID).Take(&po).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, nil } return nil, business.ErrPlatformUnavailable } return &business.PortraitPlatformBusiness{ID: po.ID, Account: po.Account, RealName: po.RealName, Level: po.Level, Status: po.Status, Commerce1: po.Commerce1, Commerce2: po.Commerce2, CreatedUnix: po.CreatedUnix}, nil } // Build an uncorrelated channel set once, so MySQL can use a semijoin instead // of reevaluating a descendant-tree predicate for each historical event. func portraitPromoteScope(db *gorm.DB, scope business.PortraitReportScope) *gorm.DB { if scope.Root != nil { return channelGameTree(db, business.ChannelGameScope{Root: scope.Root, IncludeSub: scope.IncludeSub}).Select("p.source_db,p.id") } q := db.Table("tab_promote scope_promote").Select("scope_promote.source_db,scope_promote.id") if len(scope.Pairs) == 0 { return q.Where("1=0") } q = q.Where("(scope_promote.source_db,scope_promote.busier_id) IN ?", portraitBindingPairs(scope.Pairs)) if !scope.IncludeSub { q = q.Where("scope_promote.promote_level = 1") } return q } func portraitReportQuery(db *gorm.DB, scope business.PortraitReportScope, table, begin, end string, mode int) *gorm.DB { q := db.Table(table+" r").Where("r.stat_date >= ? AND r.stat_date <= ? AND r.dedup_mode = ? AND r.game_id > 0", begin, end, mode) if len(scope.Sources) > 0 { q = q.Where("r.source_db IN ?", scope.Sources) } return q.Where("(r.source_db,r.promote_id) IN (?)", portraitPromoteScope(db, scope)) } func portraitIdentity(db *gorm.DB, dedup bool) string { if dedup { return "r.identity_key" } if db.Dialector.Name() == "mysql" { return "CONCAT(r.source_db,'|',r.identity_key)" } return "(r.source_db || '|' || r.identity_key)" } type portraitCountPO struct { Metric int Count int64 } func toPortraitCounts(rows []portraitCountPO) business.PortraitCounts { result := business.PortraitCounts{} for _, row := range rows { switch row.Metric { case 1: result.Register = row.Count case 2: result.Active = row.Count case 3: result.Pay = row.Count case 4: result.NewPay = row.Count } } return result } 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 && scope.Root == nil { return result, nil } db := r.data.DB().WithContext(ctx) tables := []string{"tab_promote", "tab_sw_report_day", "tab_sw_report_day_key"} if scope.Range.Hourly { tables = append(tables, "tab_user", "tab_spend") } available, err := businessTablesAvailable(db, tables...) if err != nil { return nil, err } if !available { result.Available = false return result, nil } mode := 0 if scope.IDCardDedup { mode = 1 } 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") // 合并本期和对比期的扫描;本月同期之间的日期空隙不能混入统计。 group, queryCtx := errgroup.WithContext(ctx) group.SetLimit(2) queryDB := db.WithContext(queryCtx) summaryQuery := func(table string, dedup int) *gorm.DB { if scope.NoCompare { return portraitReportQuery(queryDB, scope, table, begin, end, dedup) } return portraitReportQuery(queryDB, scope, table, min(begin, previousBegin), max(end, previousEnd), dedup). Where("(r.stat_date >= ? AND r.stat_date <= ?) OR (r.stat_date >= ? AND r.stat_date <= ?)", begin, end, previousBegin, previousEnd) } group.Go(func() error { var rows []struct { Metric int Current, Previous int64 } identity := portraitIdentity(queryDB, scope.IDCardDedup) selectSQL := "r.metric,COUNT(DISTINCT CASE WHEN r.stat_date >= ? AND r.stat_date <= ? THEN " + identity + " END) AS current" args := []any{begin, end} if !scope.NoCompare { selectSQL += ",COUNT(DISTINCT CASE WHEN r.stat_date >= ? AND r.stat_date <= ? THEN " + identity + " END) AS previous" args = append(args, previousBegin, previousEnd) } if err := summaryQuery("tab_sw_report_day_key", mode).Select(selectSQL, args...).Group("r.metric").Scan(&rows).Error; err != nil { return business.ErrStorage } current, previous := make([]portraitCountPO, 0, len(rows)), make([]portraitCountPO, 0, len(rows)) for _, row := range rows { current = append(current, portraitCountPO{Metric: row.Metric, Count: row.Current}) previous = append(previous, portraitCountPO{Metric: row.Metric, Count: row.Previous}) } result.Current, result.Previous = toPortraitCounts(current), toPortraitCounts(previous) return nil }) group.Go(func() error { var amounts struct{ Current, Previous string } selectSQL := "COALESCE(SUM(CASE WHEN r.stat_date >= ? AND r.stat_date <= ? THEN r.total_pay ELSE 0 END),0) AS current" args := []any{begin, end} if !scope.NoCompare { selectSQL += ",COALESCE(SUM(CASE WHEN r.stat_date >= ? AND r.stat_date <= ? THEN r.total_pay ELSE 0 END),0) AS previous" args = append(args, previousBegin, previousEnd) } if err := summaryQuery("tab_sw_report_day", 0).Select(selectSQL, args...).Scan(&amounts).Error; err != nil { return business.ErrStorage } result.CurrentPay = amounts.Current if !scope.NoCompare { result.PreviousPay = amounts.Previous } return nil }) group.Go(func() error { if scope.Range.Hourly { return portraitHourly(queryDB, scope, result) } return portraitDaily(queryDB, scope, mode, begin, end, result) }) if scope.RankLimit > 0 { group.Go(func() error { register, err := portraitRankQuery(queryDB, scope, mode, begin, end, false) result.RegisterRanks = register return err }) group.Go(func() error { pay, err := portraitRankQuery(queryDB, scope, 0, begin, end, true) result.PayRanks = pay return err }) } if err := group.Wait(); err != nil { return nil, err } return result, nil } func portraitDaily(db *gorm.DB, scope business.PortraitReportScope, mode int, begin, end string, result *business.PortraitStatistics) error { dateExpression := "SUBSTR(CAST(r.stat_date AS CHAR),1,10)" var registers []struct { Bucket string Count int64 } q := portraitReportQuery(db, scope, "tab_sw_report_day_key", begin, end, mode).Where("r.metric = 1") if err := q.Select(dateExpression + " AS bucket,COUNT(DISTINCT " + portraitIdentity(db, scope.IDCardDedup) + ") AS count").Group(dateExpression).Scan(®isters).Error; err != nil { return business.ErrStorage } for _, row := range registers { result.RegisterSeries[row.Bucket] = strconv.FormatInt(row.Count, 10) } var payments []struct{ Bucket, Amount string } if err := portraitReportQuery(db, scope, "tab_sw_report_day", begin, end, 0).Select(dateExpression + " AS bucket,COALESCE(SUM(r.total_pay),0) AS amount").Group(dateExpression).Scan(&payments).Error; err != nil { return business.ErrStorage } for _, row := range payments { result.PaySeries[row.Bucket] = row.Amount } return nil } func portraitHourly(db *gorm.DB, scope business.PortraitReportScope, result *business.PortraitStatistics) error { start, end := scope.Range.Begin.Unix(), scope.Range.End.AddDate(0, 0, 1).Unix() // register_time 是历史字符串列;数值比较不能利用其字符串索引。 // 复用已有数值生成列和时间范围索引,未迁移的库保留 CAST 回退。 registerTime, err := registrationTimeExpression(db) if err != nil { return err } for _, metric := range []struct { table, column, aggregate, condition string series map[string]string }{{"tab_user", registerTime, "COUNT(*)", "(r.puid = 0 OR r.puid IS NULL)", result.RegisterSeries}, {"tab_spend", "r.pay_time", "COALESCE(SUM(r.pay_amount),0)", "r.pay_status = 1", result.PaySeries}} { q := db.Table(metric.table+" r").Where(metric.column+" >= ? AND "+metric.column+" < ?", start, end).Where(metric.condition).Where("(r.source_db,r.promote_id) IN (?)", portraitPromoteScope(db, scope)) if len(scope.Sources) > 0 { q = q.Where("r.source_db IN ?", scope.Sources) } var rows []struct { Hour int Value string } if db.Dialector.Name() == "mysql" { // 保留原 Mapper 的数据库会话时区口径,最终实库验收同时核对时区配置。 hour := "HOUR(FROM_UNIXTIME(" + metric.column + "))" if err := q.Select(hour + " AS hour," + metric.aggregate + " AS value").Group(hour).Scan(&rows).Error; err != nil { return business.ErrStorage } } else { if err := q.Select("CAST(("+metric.column+" - ?) / 3600 AS INTEGER) AS hour,"+metric.aggregate+" AS value", start).Group("hour").Scan(&rows).Error; err != nil { return business.ErrStorage } } for _, row := range rows { if row.Hour >= 0 && row.Hour <= 23 { metric.series[strconv.Itoa(row.Hour)] = row.Value } } } return nil } func portraitRankQuery(db *gorm.DB, scope business.PortraitReportScope, mode int, begin, end string, pay bool) ([]*business.PortraitStatRank, error) { table, aggregate := "tab_sw_report_day_key", "COUNT(DISTINCT r.identity_key)" if pay { table, aggregate = "tab_sw_report_day", "COALESCE(SUM(r.total_pay),0)" } var selectArgs []any if !pay && scope.CountLimit > 0 { // 人数上限须先于 TOP N 排序,避免截取后才限制导致并列结果不同。 aggregate = "CASE WHEN COUNT(DISTINCT r.identity_key)>? THEN ? ELSE COUNT(DISTINCT r.identity_key) END" selectArgs = []any{scope.CountLimit, scope.CountLimit} } q := portraitReportQuery(db, scope, table, begin, end, mode) if !pay { q = q.Where("r.metric = 1") } root := "r.promote_id" if scope.IncludeSub || pay { q = q.Joins("JOIN tab_promote rank_promote ON rank_promote.source_db=r.source_db AND rank_promote.id=r.promote_id") root = "CASE WHEN rank_promote.promote_level=1 THEN rank_promote.id WHEN rank_promote.top_promote_id>0 THEN rank_promote.top_promote_id ELSE rank_promote.id END" } var rows []struct { SourceDB string `gorm:"column:source_db"` PromoteID uint64 Value string } if err := q.Select("r.source_db,"+root+" AS promote_id,"+aggregate+" AS value", selectArgs...).Group("r.source_db," + root).Order("value DESC," + root + ",r.source_db").Limit(scope.RankLimit).Scan(&rows).Error; err != nil { return nil, business.ErrStorage } result := make([]*business.PortraitStatRank, 0, len(rows)) for _, row := range rows { result = append(result, &business.PortraitStatRank{ChannelKey: business.ChannelKey{SourceDB: row.SourceDB, ID: row.PromoteID}, Value: row.Value}) } return result, nil }