package business import ( "context" "strconv" "strings" "gorm.io/gorm" "kra/internal/biz/business" ) type ledgerRepo struct{ data Provider } func NewLedgerRepo(data Provider) business.LedgerRepo { return &ledgerRepo{data: data} } // Enumerating the stored platform codes lets MySQL use the leading column of // the time indexes even when the caller requests every platform. Read them // from the table so historical or newly imported codes remain in scope. func resolveLedgerSources(db *gorm.DB, table string, filter *business.LedgerFilter) error { if len(filter.Sources) > 0 { return nil } var sources []struct{ SourceDB *string } if err := db.Table(table).Select("source_db").Distinct().Scan(&sources).Error; err != nil { return business.ErrStorage } for _, source := range sources { if source.SourceDB == nil { // Legacy production columns are NOT NULL. Preserve unfiltered // semantics if another supported storage layout contains NULL. return nil } } for _, source := range sources { filter.Sources = append(filter.Sources, *source.SourceDB) } return nil } func ledgerBasePromotes(db *gorm.DB, scope business.LedgerScope, filter business.LedgerFilter, alias string) *gorm.DB { q := db.Table("tab_promote " + alias) if len(filter.Sources) > 0 { q = q.Where(alias+".source_db IN ?", filter.Sources) } if !scope.IncludeSub { q = q.Where(alias + ".promote_level = 1") } if !scope.Unrestricted { bindings := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{Sources: filter.Sources, BusierAccount: filter.BusierAccount}).Select("1").Where("b.source_db=" + alias + ".source_db AND b.platform_busier_id=" + alias + ".busier_id") q = q.Where("EXISTS (?)", bindings) } return q } func ledgerPromotes(db *gorm.DB, scope business.LedgerScope, filter business.LedgerFilter, alias string) *gorm.DB { q := ledgerBasePromotes(db, scope, filter, alias) if filter.PromoteAccount == "" { return q } hits := ledgerBasePromotes(db, scope, filter, "hit") keyword := strings.ToLower(strings.TrimSpace(filter.PromoteAccount)) hits = hits.Where("(INSTR(LOWER(hit.account),?) > 0 OR INSTR(LOWER(hit.nickname),?) > 0)", keyword, keyword).Where("hit.source_db=" + alias + ".source_db") if scope.IncludeSub { root := "CASE WHEN hit.promote_level=1 THEN hit.id WHEN hit.top_promote_id>0 THEN hit.top_promote_id ELSE hit.id END" hits = hits.Where("(" + alias + ".id = (" + root + ") OR " + alias + ".top_promote_id = (" + root + "))") } else { hits = hits.Where("hit.id=" + alias + ".id") } return q.Where("EXISTS (?)", hits.Select("1")) } func prepareLedger(db *gorm.DB, scope business.LedgerScope, filter business.LedgerFilter) (empty, available bool, err error) { if !scope.Unrestricted { var bindings []struct{ UserID uint } if err := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{Sources: filter.Sources, BusierAccount: filter.BusierAccount}).Select("b.user_id").Limit(1).Scan(&bindings).Error; err != nil { return false, false, business.ErrStorage } if len(bindings) == 0 { return true, true, nil } } available, err = businessTableAvailable(db, "tab_promote") if err != nil || !available { return false, available, err } var rows []struct{ ID uint64 } if err := ledgerPromotes(db, scope, filter, "p").Select("p.id").Limit(1).Scan(&rows).Error; err != nil { return false, false, business.ErrStorage } return len(rows) == 0, true, nil } func ledgerScopeQuery(db *gorm.DB, scope business.LedgerScope, filter business.LedgerFilter, table string) *gorm.DB { q := db.Table(table + " r") if len(filter.Sources) > 0 { q = q.Where("r.source_db IN ?", filter.Sources) } if scope.Unrestricted && scope.IncludeSub && filter.PromoteAccount == "" { return q } return q.Where("EXISTS (?)", ledgerPromotes(db, scope, filter, "p").Select("1").Where("p.source_db=r.source_db AND p.id=r.promote_id")) } type registrationPO struct { ID uint64 SourceDB string `gorm:"column:source_db"` RegisterTime *string FirstGameID *int64 `gorm:"column:fgame_id"` FirstGameName string `gorm:"column:fgame_name"` RegisterIP string `gorm:"column:register_ip"` DeviceName string PromoteID *int64 } func registrationTimeExpression(db *gorm.DB) (string, error) { cast := "CAST(r.register_time AS INTEGER)" if db.Dialector.Name() == "mysql" { cast = "CAST(r.register_time AS UNSIGNED)" var count int64 if err := db.Raw("SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='tab_user' AND column_name='sw_register_time_unix'").Scan(&count).Error; err != nil { return "", business.ErrStorage } if count > 0 { // 直接使用等价生成列,使 COUNT 和分页主键查询走覆盖索引。 // CAST 虽可用于范围定位,但 MySQL 5.7 仍会回表读取原字符串。 cast = "r.sw_register_time_unix" } } return cast, nil } func registrationQuery(db *gorm.DB, scope business.LedgerScope, filter business.LedgerFilter, cast string) *gorm.DB { q := ledgerScopeQuery(db, scope, filter, "tab_user").Where("r.puid = 0").Where(cast+" BETWEEN ? AND ?", filter.BeginUnix, filter.EndUnix) if filter.UserID != nil { q = q.Where("r.id = ?", *filter.UserID) } if len(filter.GameNames) > 0 { q = q.Where("EXISTS (?)", db.Table("tab_game g").Select("1").Where("g.source_db=r.source_db AND g.id=r.fgame_id AND g.game_name IN ?", filter.GameNames)) } return q } func (r *ledgerRepo) Registrations(ctx context.Context, scope business.LedgerScope, filter business.LedgerFilter, options business.LedgerListOptions) (*business.RegistrationPage, error) { db := r.data.DB().WithContext(ctx) page := &business.RegistrationPage{Rows: []*business.Registration{}, Available: true} empty, available, err := prepareLedger(db, scope, filter) if err != nil { return nil, err } page.Available = available if empty || !available { return page, nil } for _, table := range append([]string{"tab_user"}, func() []string { if len(filter.GameNames) > 0 { return []string{"tab_game"} } return nil }()...) { available, err := businessTableAvailable(db, table) if err != nil { return nil, err } if !available { page.Available = false return page, nil } } payAvailable, err := businessTableAvailable(db, "tab_spend") if err != nil { return nil, err } if filter.SortField == "totalPayAmount" && !payAvailable { page.Available = false return page, nil } timeExpression, err := registrationTimeExpression(db) if err != nil { return nil, err } if err := resolveLedgerSources(db, "tab_user", &filter); err != nil { return nil, err } q := registrationQuery(db, scope, filter, timeExpression) if err := q.Count(&page.Total).Error; err != nil { return nil, business.ErrStorage } if options.RejectAbove > 0 && page.Total > options.RejectAbove { return page, nil } direction := " DESC" if filter.Ascending { direction = " ASC" } order := timeExpression if filter.SortField == "totalPayAmount" { order = "(SELECT COALESCE(SUM(s.pay_amount),0) FROM tab_spend s WHERE s.source_db=r.source_db AND s.user_id=r.id AND s.pay_status=1)" } var pos []registrationPO keysQuery := q.Select("r.id,r.source_db").Order(order + direction).Order("r.id DESC,r.source_db ASC").Offset(options.Offset).Limit(options.Limit) pageQuery := db.Table("tab_user r").Joins("JOIN (?) page_ids ON page_ids.id=r.id AND page_ids.source_db=r.source_db", keysQuery) if err := pageQuery.Select("r.id,r.source_db,r.register_time,r.fgame_id,r.fgame_name,r.register_ip,r.device_name,r.promote_id").Order(order + direction).Order("r.id DESC,r.source_db ASC").Scan(&pos).Error; err != nil { return nil, business.ErrStorage } meta := []ledgerMetaTarget{} keys := []business.ChannelKey{} for _, po := range pos { row := &business.Registration{ChannelKey: business.ChannelKey{SourceDB: po.SourceDB, ID: po.ID}, LedgerNames: business.LedgerNames{PromoteID: po.PromoteID}, FirstGameID: po.FirstGameID, FirstGameName: po.FirstGameName, RegisterIP: po.RegisterIP, DeviceName: po.DeviceName} if po.RegisterTime != nil { value, err := strconv.ParseInt(strings.TrimSpace(*po.RegisterTime), 10, 64) if err == nil { row.RegisterTime = &value } } if payAvailable { zero := "0" row.TotalPayAmount = &zero } page.Rows = append(page.Rows, row) meta = append(meta, ledgerMetaTarget{Source: row.SourceDB, Names: &row.LedgerNames}) keys = append(keys, row.ChannelKey) } if err := fillLedgerNames(db, scope, filter, meta); err != nil { return nil, err } if payAvailable && len(keys) > 0 { amounts := map[business.ChannelKey]string{} for start := 0; start < len(keys); start += 300 { var values []struct { SourceDB string `gorm:"column:source_db"` UserID uint64 Amount string } if err := db.Table("tab_spend").Select("source_db,user_id,COALESCE(SUM(pay_amount),0) AS amount").Where("pay_status=1 AND (source_db,user_id) IN ?", channelPairs(keys[start:min(start+300, len(keys))])).Group("source_db,user_id").Scan(&values).Error; err != nil { return nil, business.ErrStorage } for _, value := range values { amounts[business.ChannelKey{SourceDB: value.SourceDB, ID: value.UserID}] = value.Amount } } for _, row := range page.Rows { if amount, ok := amounts[row.ChannelKey]; ok { row.TotalPayAmount = &amount } } } return page, nil } type spendPO struct { ID uint64 SourceDB string `gorm:"column:source_db"` UserID, GameID, PromoteID *int64 PayOrderNumber, GameName, ServerID, ServerName, GamePlayerName, SpendIP, PromoteAccount string PayAmount *string PayTime *int64 PayWay, PayStatus *int } func spendQuery(db *gorm.DB, scope business.LedgerScope, filter business.LedgerFilter, successOnly bool) *gorm.DB { q := ledgerScopeQuery(db, scope, filter, "tab_spend").Where("r.pay_time BETWEEN ? AND ?", filter.BeginUnix, filter.EndUnix) if successOnly { q = q.Where("r.pay_status = 1") } if filter.PayStatus != nil { q = q.Where("r.pay_status = ?", *filter.PayStatus) } if len(filter.GameNames) > 0 { q = q.Where("r.game_name IN ?", filter.GameNames) } if filter.UserID != nil { q = q.Where("r.user_id = ?", *filter.UserID) } if filter.PayWay != nil { q = q.Where("r.pay_way = ?", *filter.PayWay) } if filter.PlayerName != "" { q = q.Where("r.game_player_name LIKE ?", "%"+filter.PlayerName+"%") } if filter.OrderNumber != "" { q = q.Where("r.pay_order_number LIKE ?", "%"+filter.OrderNumber+"%") } return q } func (r *ledgerRepo) Spends(ctx context.Context, scope business.LedgerScope, filter business.LedgerFilter, options business.LedgerListOptions) (*business.SpendPage, error) { db := r.data.DB().WithContext(ctx) zero := "0" page := &business.SpendPage{Rows: []*business.Spend{}, Available: true, TotalPayAmount: &zero} empty, available, err := prepareLedger(db, scope, filter) if err != nil { return nil, err } page.Available = available if !available { page.TotalPayAmount = nil } if empty || !available { return page, nil } available, err = businessTableAvailable(db, "tab_spend") if err != nil { return nil, err } if !available { page.Available = false page.TotalPayAmount = nil return page, nil } if err := resolveLedgerSources(db, "tab_spend", &filter); err != nil { return nil, err } q := spendQuery(db, scope, filter, false) if err := q.Count(&page.Total).Error; err != nil { return nil, business.ErrStorage } if options.RejectAbove > 0 && page.Total > options.RejectAbove { return page, nil } if err := spendQuery(db, scope, filter, true).Select("COALESCE(SUM(r.pay_amount),0)").Scan(page.TotalPayAmount).Error; err != nil { return nil, business.ErrStorage } order := "r.pay_time" if filter.SortField == "payAmount" { order = "r.pay_amount" } else if filter.SortField == "id" { order = "r.id" } if filter.Ascending { order += " ASC" } else { order += " DESC" } var pos []spendPO // 先在窄查询中选出当前页主键,再读取明细列,避免为排序读取整段流水。 keysBase := q tieOrder := "r.id DESC,r.source_db ASC" if filter.SortField == "id" { tieOrder = "r.source_db ASC" if db.Dialector.Name() == "mysql" { // LIMIT can make MySQL prefer scanning the primary key from its // beginning, even when recent payments are near the end of history. // Preserve selective time/scope indexes and sort the matching keys. keysBase = q.Table("tab_spend r IGNORE INDEX FOR ORDER BY (PRIMARY)") } } keysQuery := keysBase.Select("r.id,r.source_db").Order(order).Order(tieOrder).Offset(options.Offset).Limit(options.Limit) pageQuery := db.Table("tab_spend r").Joins("JOIN (?) page_ids ON page_ids.id=r.id AND page_ids.source_db=r.source_db", keysQuery) if err := pageQuery.Select("r.id,r.source_db,r.user_id,r.pay_order_number,r.game_id,r.game_name,r.server_id,r.server_name,r.game_player_name,r.pay_amount,r.pay_time,r.pay_way,r.pay_status,r.spend_ip,r.promote_id,r.promote_account").Order(order).Order(tieOrder).Scan(&pos).Error; err != nil { return nil, business.ErrStorage } meta := []ledgerMetaTarget{} for _, po := range pos { row := &business.Spend{ChannelKey: business.ChannelKey{SourceDB: po.SourceDB, ID: po.ID}, LedgerNames: business.LedgerNames{PromoteID: po.PromoteID, PromoteAccount: po.PromoteAccount}, UserID: po.UserID, GameID: po.GameID, OrderNumber: po.PayOrderNumber, GameName: po.GameName, ServerID: po.ServerID, ServerName: po.ServerName, PlayerName: po.GamePlayerName, PayAmount: po.PayAmount, PayTime: po.PayTime, PayWay: po.PayWay, PayStatus: po.PayStatus, IP: po.SpendIP} page.Rows = append(page.Rows, row) meta = append(meta, ledgerMetaTarget{Source: row.SourceDB, Names: &row.LedgerNames}) } if err := fillLedgerNames(db, scope, filter, meta); err != nil { return nil, err } return page, nil } type ledgerMetaTarget struct { Source string Names *business.LedgerNames } func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.LedgerFilter, targets []ledgerMetaTarget) error { keys := []business.ChannelKey{} seen := map[business.ChannelKey]bool{} for _, target := range targets { if target.Names.PromoteID == nil || *target.Names.PromoteID < 0 { continue } key := business.ChannelKey{SourceDB: target.Source, ID: uint64(*target.Names.PromoteID)} if !seen[key] { keys = append(keys, key) seen[key] = true } } channels := map[business.ChannelKey]channelPO{} pairs := []business.PlatformKey{} pairSeen := map[business.PlatformKey]bool{} for start := 0; start < len(keys); start += 300 { var rows []channelPO if err := ledgerPromotes(db, scope, filter, "p").Select("p.id,p.source_db,p.account,p.busier_id").Where("(p.source_db,p.id) IN ?", channelPairs(keys[start:min(start+300, len(keys))])).Scan(&rows).Error; err != nil { return business.ErrStorage } for _, row := range rows { channels[business.ChannelKey{SourceDB: row.SourceDB, ID: row.ID}] = row key := business.PlatformKey{SourceDB: row.SourceDB, BusierID: row.BusierID} if !pairSeen[key] { pairs = append(pairs, key) pairSeen[key] = true } } } type bindName struct { SourceDB string `gorm:"column:source_db"` PlatformBusierID uint64 PlatformBusierAccount, Username string UserID uint IDPath string `gorm:"column:id_path"` } names := map[business.PlatformKey]bindName{} for start := 0; start < len(pairs); start += 300 { var rows []bindName q := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{}).Select("b.source_db,b.platform_busier_id,b.platform_busier_account,b.user_id,COALESCE(NULLIF(u.nick_name,''),u.username) AS username,u.id_path").Where("(b.source_db,b.platform_busier_id) IN ?", portraitBindingPairs(pairs[start:min(start+300, len(pairs))])).Order("b.user_id") if err := q.Scan(&rows).Error; err != nil { return business.ErrStorage } for _, row := range rows { key := business.PlatformKey{SourceDB: row.SourceDB, BusierID: row.PlatformBusierID} if _, ok := names[key]; !ok { names[key] = row } } } ancestorIDs := map[uint]bool{} paths := map[business.PlatformKey][]uint{} for key, binding := range names { for _, part := range strings.Split(binding.IDPath, ",") { id, err := strconv.ParseUint(strings.TrimSpace(part), 10, strconv.IntSize) if err == nil && id > 0 && uint(id) != binding.UserID { paths[key] = append(paths[key], uint(id)) ancestorIDs[uint(id)] = true } } } ids := make([]uint, 0, len(ancestorIDs)) for id := range ancestorIDs { ids = append(ids, id) } ancestors := map[uint]string{} for start := 0; start < len(ids); start += 300 { var rows []accountPO if err := db.Select("id,COALESCE(NULLIF(nick_name,''),username) AS username").Where("id IN ? AND level > 0", ids[start:min(start+300, len(ids))]).Find(&rows).Error; err != nil { return business.ErrStorage } for _, row := range rows { ancestors[row.ID] = row.Username } } for _, target := range targets { n := target.Names if n.PromoteID == nil || *n.PromoteID < 0 { continue } if c, ok := channels[business.ChannelKey{SourceDB: target.Source, ID: uint64(*n.PromoteID)}]; ok { if strings.TrimSpace(n.PromoteAccount) == "" { n.PromoteAccount = c.Account } if binding, ok := names[business.PlatformKey{SourceDB: c.SourceDB, BusierID: c.BusierID}]; ok { n.BindingAccount = binding.PlatformBusierAccount n.BindingUserName = binding.Username for _, id := range paths[business.PlatformKey{SourceDB: c.SourceDB, BusierID: c.BusierID}] { if name := strings.TrimSpace(ancestors[id]); name != "" { n.BusinessPath = append(n.BusinessPath, name) } } if name := strings.TrimSpace(binding.Username); name != "" { n.BusinessPath = append(n.BusinessPath, name) } } } } return nil } func (r *ledgerRepo) Games(ctx context.Context, viewer business.UserScope, sources []string, keyword string, limit int) ([]*business.GameOption, error) { db := r.data.DB().WithContext(ctx) available, err := approvedGamesAvailable(db) if err != nil { return nil, err } if !available { return nil, business.ErrGameOptionsUnavailable } q := approvedGamesQuery(db, viewer, sources) if keyword != "" { q = q.Where("INSTR(LOWER(g.game_name),?) > 0", strings.ToLower(keyword)) } var rows []struct { ID uint64 SourceDB string `gorm:"column:source_db"` GameName string } if err := q.Select("g.id,g.source_db,g.game_name").Distinct().Order("g.game_name,g.id,g.source_db").Limit(limit).Scan(&rows).Error; err != nil { return nil, business.ErrStorage } result := make([]*business.GameOption, 0, len(rows)) for _, row := range rows { result = append(result, &business.GameOption{ID: row.ID, SourceDB: row.SourceDB, Name: row.GameName}) } return result, nil }