Files
kra-new/internal/data/business/ledger.go
T

491 lines
19 KiB
Go

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
}